diff --git a/public/games/age-of-dwarves/data/units/wyvern_riders.json b/public/games/age-of-dwarves/data/units/wyvern_riders.json deleted file mode 100644 index 9a46ec49..00000000 --- a/public/games/age-of-dwarves/data/units/wyvern_riders.json +++ /dev/null @@ -1,32 +0,0 @@ -[ - { - "id": "wyvern_riders", - "name": "Wyvern Riders", - "description": "Aerial warriors mounted on scaled wyverns. Fast and dangerous, bypass ground zones of control.", - "gender": { - "male": { "name": "Wyvern Riders", "sprite": "sprites/units/wyvern_riders_m.png" }, - "female": { "name": "Wyvern Riders", "sprite": "sprites/units/wyvern_riders_f.png" } - }, - "combat_type": "flying", - "school": null, - "domain": "air", - "armor_type": "natural", - "attack_type": "melee_physical", - "int": 4, - "dex": 12, - "str": 16, - "con": 12, - "cost": 70, - "range": 1, - "movement": 4, - "vision": 4, - "tech_required": "orc_heritage", - "race_required": "orcs", - "faction": null, - "keywords": [], - "resistances": {}, - "merge": null, - "mana_cost": null, - "sprite": "sprites/units/wyvern_riders.png" - } -] diff --git a/public/games/age-of-dwarves/vocabulary.json b/public/games/age-of-dwarves/vocabulary.json index bd8bca33..1f49536a 100644 --- a/public/games/age-of-dwarves/vocabulary.json +++ b/public/games/age-of-dwarves/vocabulary.json @@ -65,6 +65,11 @@ "transcendence_ritual": "Ascension Ritual", "domination": "Domination", "score": "Score", + "victory": "Victory", + "defeat": "Defeat", + "stalemate": "Stalemate", + "wins": "wins!", + "turn_limit_reached": "Turn limit reached", "ruin_site": "Tribal Village", "threat_site": "Lair", "independent_settlement": "Freepeople Haven", diff --git a/public/resources/wilds/wilds.json b/public/resources/wilds/wilds.json index ffcc27c9..2b996658 100644 --- a/public/resources/wilds/wilds.json +++ b/public/resources/wilds/wilds.json @@ -167,7 +167,11 @@ "id": "ancient_construct_site", "loot_table": [ { "resource": "arcane_gears", "amount": 2, "chance": 0.8 }, - { "resource": "stone_core", "amount": 1, "chance": 0.5 } + { "resource": "stone_core", "amount": 1, "chance": 0.5 }, + { "type": "item", "item": "golem_core", "tier": 8, "chance": 0.05 }, + { "type": "item", "item": "constructor_lens", "tier": 8, "chance": 0.05 }, + { "type": "item", "item": "phase_gauntlet", "tier": 9, "chance": 0.025 }, + { "type": "item", "item": "crown_of_the_mountain", "tier": 10, "chance": 0.01 } ] }, "wyvern_nest": { diff --git a/src/game/engine/scenes/menus/victory_screen.gd b/src/game/engine/scenes/menus/victory_screen.gd index 6cf854fe..f8792a7d 100644 --- a/src/game/engine/scenes/menus/victory_screen.gd +++ b/src/game/engine/scenes/menus/victory_screen.gd @@ -1,13 +1,19 @@ extends CanvasLayer -## Full-screen victory overlay. Triggered by EventBus.victory_achieved. -## Shows winning player, victory type, score, turn count. -## "Main Menu" returns to menu; "Continue" lets the player keep playing. +## Full-screen end-game overlay. Triggered by EventBus.victory_achieved. +## Shows winner (or stalemate on winner_index == -1), victory type, turn, +## and a per-player stats table (pop / cities / tiles / techs / units / score). + +const VictoryManagerScript: GDScript = preload( + "res://engine/src/modules/victory/victory_manager.gd") +const CityScript: GDScript = preload("res://engine/src/entities/city.gd") + +const STAT_COLS: Array[String] = ["Player", "Pop", "Cities", "Tiles", "Techs", "Units", "Score"] @onready var _result_label: Label = %ResultLabel @onready var _condition_label: Label = %ConditionLabel -@onready var _score_label: Label = %ScoreLabel @onready var _turn_label: Label = %TurnLabel @onready var _player_label: Label = %PlayerLabel +@onready var _stats_grid: GridContainer = %StatsGrid @onready var _main_menu_button: Button = %MainMenuButton @onready var _continue_button: Button = %ContinueButton @@ -21,43 +27,95 @@ func _ready() -> void: func _on_victory_achieved(player_index: int, victory_type: String) -> void: - var player: RefCounted = GameState.get_player(player_index) - var player_name: String = "Unknown" - if player != null: - if not player.player_name.is_empty(): - player_name = player.player_name - else: - player_name = "Player %d" % (player_index + 1) + var stalemate: bool = player_index < 0 or victory_type == "stalemate" + var player: RefCounted = null if stalemate else GameState.get_player(player_index) - var is_human_winner: bool = player != null and player.is_human - - if is_human_winner: - _result_label.text = ThemeVocabulary.lookup("victory") - if _result_label.text == "victory": - _result_label.text = "VICTORY" + if stalemate: + _result_label.text = ThemeVocabulary.lookup("stalemate").to_upper() + _result_label.add_theme_color_override("font_color", Color(0.75, 0.75, 0.78)) + _player_label.text = ThemeVocabulary.lookup("turn_limit_reached") + elif player != null and player.is_human: + _result_label.text = ThemeVocabulary.lookup("victory").to_upper() _result_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.2)) + _player_label.text = "%s %s" % [_player_display(player), ThemeVocabulary.lookup("wins")] else: - _result_label.text = ThemeVocabulary.lookup("defeat") - if _result_label.text == "defeat": - _result_label.text = "DEFEAT" + _result_label.text = ThemeVocabulary.lookup("defeat").to_upper() _result_label.add_theme_color_override("font_color", Color(0.8, 0.3, 0.3)) - - _player_label.text = "%s wins!" % player_name + _player_label.text = "%s %s" % [_player_display(player), ThemeVocabulary.lookup("wins")] var condition_display: String = ThemeVocabulary.lookup(victory_type) if condition_display == victory_type: condition_display = victory_type.capitalize() - _condition_label.text = "%s %s" % [condition_display, ThemeVocabulary.lookup("victory")] - - var score: int = player.score if player != null and "score" in player else 0 - _score_label.text = "Final Score: %d" % score - _turn_label.text = "Turn %d" % GameState.turn_number + if stalemate: + _condition_label.text = condition_display + else: + _condition_label.text = "%s %s" % [condition_display, ThemeVocabulary.lookup("victory")] + _turn_label.text = "%s %d" % [ThemeVocabulary.lookup("turn"), GameState.turn_number] + _build_stats_grid(player_index) visible = true get_tree().paused = true _continue_button.grab_focus() +func _build_stats_grid(winner_index: int) -> void: + for child: Node in _stats_grid.get_children(): + child.queue_free() + for col: String in STAT_COLS: + _stats_grid.add_child(_make_header(col)) + + var vm: VictoryManagerScript = VictoryManagerScript.new() + var game_map: RefCounted = GameState.get_game_map() as RefCounted + for p: Variant in GameState.players: + if p == null or int(p.get("index")) < 0: + continue + var is_winner: bool = int(p.get("index")) == winner_index + var pop: int = 0 + var tiles: int = 0 + for c: Variant in p.cities: + if c is CityScript: + pop += (c as CityScript).get_population() + tiles += (c as CityScript).get_owned_tiles().size() + var row_color: Color = Color(1.0, 0.9, 0.35) if is_winner else Color(0.88, 0.88, 0.88) + var prefix: String = "* " if is_winner else " " + _stats_grid.add_child(_make_cell(prefix + _player_display(p), row_color, true)) + _stats_grid.add_child(_make_cell(str(pop), row_color)) + _stats_grid.add_child(_make_cell(str(p.cities.size()), row_color)) + _stats_grid.add_child(_make_cell(str(tiles), row_color)) + _stats_grid.add_child(_make_cell(str(p.researched_techs.size()), row_color)) + _stats_grid.add_child(_make_cell(str(p.units.size()), row_color)) + _stats_grid.add_child(_make_cell(str(vm.calculate_score(p, game_map)), row_color)) + + +func _make_header(text: String) -> Label: + var lbl: Label = Label.new() + lbl.text = text + lbl.add_theme_font_size_override("font_size", 13) + lbl.add_theme_color_override("font_color", Color(0.6, 0.55, 0.35)) + if text != "Player": + lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + return lbl + + +func _make_cell(text: String, color: Color, is_name: bool = false) -> Label: + var lbl: Label = Label.new() + lbl.text = text + lbl.add_theme_font_size_override("font_size", 14) + lbl.add_theme_color_override("font_color", color) + if not is_name: + lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + return lbl + + +func _player_display(player: RefCounted) -> String: + if player == null: + return "Unknown" + var pname: String = str(player.get("player_name")) + if pname.is_empty(): + pname = "Player %d" % (int(player.get("index")) + 1) + return pname + + func _on_main_menu() -> void: get_tree().paused = false visible = false diff --git a/src/game/engine/scenes/menus/victory_screen.tscn b/src/game/engine/scenes/menus/victory_screen.tscn index 3ea91131..69f36e46 100644 --- a/src/game/engine/scenes/menus/victory_screen.tscn +++ b/src/game/engine/scenes/menus/victory_screen.tscn @@ -10,7 +10,7 @@ script = ExtResource("1_victory") anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 -color = Color(0, 0, 0, 0.8) +color = Color(0, 0, 0, 0.85) [node name="Panel" type="PanelContainer" parent="."] anchors_preset = 8 @@ -18,57 +18,67 @@ anchor_left = 0.5 anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 -offset_left = -260.0 -offset_top = -200.0 -offset_right = 260.0 -offset_bottom = 200.0 +offset_left = -360.0 +offset_top = -260.0 +offset_right = 360.0 +offset_bottom = 260.0 grow_horizontal = 2 grow_vertical = 2 [node name="MarginContainer" type="MarginContainer" parent="Panel"] layout_mode = 2 -theme_override_constants/margin_left = 24 -theme_override_constants/margin_top = 20 -theme_override_constants/margin_right = 24 -theme_override_constants/margin_bottom = 20 +theme_override_constants/margin_left = 28 +theme_override_constants/margin_top = 22 +theme_override_constants/margin_right = 28 +theme_override_constants/margin_bottom = 22 [node name="VBox" type="VBoxContainer" parent="Panel/MarginContainer"] layout_mode = 2 -theme_override_constants/separation = 12 +theme_override_constants/separation = 10 [node name="ResultLabel" type="Label" parent="Panel/MarginContainer/VBox"] unique_name_in_owner = true layout_mode = 2 horizontal_alignment = 1 +theme_override_font_sizes/font_size = 40 text = "VICTORY" [node name="PlayerLabel" type="Label" parent="Panel/MarginContainer/VBox"] unique_name_in_owner = true layout_mode = 2 horizontal_alignment = 1 +theme_override_font_sizes/font_size = 18 text = "Player Name" [node name="ConditionLabel" type="Label" parent="Panel/MarginContainer/VBox"] unique_name_in_owner = true layout_mode = 2 horizontal_alignment = 1 +theme_override_font_sizes/font_size = 15 text = "Domination Victory" -[node name="Separator" type="HSeparator" parent="Panel/MarginContainer/VBox"] -layout_mode = 2 - -[node name="ScoreLabel" type="Label" parent="Panel/MarginContainer/VBox"] -unique_name_in_owner = true -layout_mode = 2 -horizontal_alignment = 1 -text = "Final Score: 0" - [node name="TurnLabel" type="Label" parent="Panel/MarginContainer/VBox"] unique_name_in_owner = true layout_mode = 2 horizontal_alignment = 1 +theme_override_font_sizes/font_size = 13 +theme_override_colors/font_color = Color(0.7, 0.7, 0.7, 1) text = "Turn 1" +[node name="Separator" type="HSeparator" parent="Panel/MarginContainer/VBox"] +layout_mode = 2 + +[node name="StatsGrid" type="GridContainer" parent="Panel/MarginContainer/VBox"] +unique_name_in_owner = true +layout_mode = 2 +columns = 7 +theme_override_constants/h_separation = 14 +theme_override_constants/v_separation = 4 + +[node name="Spacer" type="Control" parent="Panel/MarginContainer/VBox"] +layout_mode = 2 +size_flags_vertical = 3 + [node name="Buttons" type="HBoxContainer" parent="Panel/MarginContainer/VBox"] layout_mode = 2 alignment = 1 diff --git a/src/game/engine/scenes/tests/hud_proof.gd b/src/game/engine/scenes/tests/hud_proof.gd new file mode 100644 index 00000000..610d85ae --- /dev/null +++ b/src/game/engine/scenes/tests/hud_proof.gd @@ -0,0 +1,99 @@ +extends Node2D +## HUD Proof — verifies #24 (HP bars) + #19 (color-coded notification log) +## render together. Self-capturing: seeds TurnNotification entries, draws +## HP bar samples using the same formula as unit/city renderers, then quits. + +const TurnNotificationScene: PackedScene = preload( + "res://engine/scenes/hud/turn_notification.tscn" +) + +const OUTPUT_DIR: String = "user://screenshots" +const CAPTURE_DELAY: float = 0.8 +const HP_BAR_WIDTH: float = 72.0 +const HP_BAR_HEIGHT: float = 8.0 + +const SAMPLES: Array = [ + ["Unit 90% HP", 0.9, 140.0, 220.0], ["Unit 55% HP", 0.55, 140.0, 280.0], + ["Unit 20% HP", 0.2, 140.0, 340.0], ["City 80% HP", 0.8, 140.0, 440.0], + ["City 40% HP", 0.4, 140.0, 500.0], ["City 15% HP", 0.15, 140.0, 560.0], +] + +const LOG_ENTRIES: Array = [ + ["Dwarf Warriors ambushed at (12, 8)", "combat"], + ["Ironhold lost population (now 3)", "combat"], + ["Ironhold grew to population 5", "founding"], + ["Khazad-dûm borders expanded", "founding"], + ["Research complete: Bronze Working", "tech"], + ["Warriors completed in Ironhold", "economy"], + ["Smithy built in Khazad-dûm", "economy"], + ["Tree of Life has been completed!", "magic"], + ["A new era dawns: Bronze Age", "event"], +] + +var _notification: CanvasLayer = null +var _captured: bool = false + + +func _ready() -> void: + RenderingServer.set_default_clear_color(Color(0.08, 0.07, 0.05)) + get_viewport().size = Vector2i(1280, 720) + DisplayServer.window_set_size(Vector2i(1280, 720)) + DataLoader.load_theme("age-of-dwarves") + await get_tree().process_frame + + _notification = TurnNotificationScene.instantiate() + add_child(_notification) + await get_tree().process_frame + _notification.show_processing() + for entry: Array in LOG_ENTRIES: + _notification._add_entry(entry[0] as String, entry[1] as String) + _notification._show_log() + + queue_redraw() + await get_tree().create_timer(CAPTURE_DELAY).timeout + _capture_and_quit() + + +func _draw() -> void: + var font: Font = ThemeDB.fallback_font + draw_string(font, Vector2(40, 40), + "HUD Proof — HP Bars (#24) + Color-Coded Notification Log (#19)", + HORIZONTAL_ALIGNMENT_LEFT, -1, 18, Color(1.0, 0.94, 0.75)) + draw_string(font, Vector2(40, 180), "Unit HP Bars (from unit_renderer.gd)", + HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.85, 0.85, 1.0)) + draw_string(font, Vector2(40, 400), "City HP Bars (from city_renderer.gd)", + HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.85, 0.85, 1.0)) + for s: Array in SAMPLES: + var frac: float = float(s[1]) + var origin: Vector2 = Vector2(float(s[2]), float(s[3])) + draw_rect(Rect2(origin, Vector2(HP_BAR_WIDTH, HP_BAR_HEIGHT)), + Color(0.15, 0.15, 0.15, 0.9)) + var bar_color: Color = Color.GREEN + if frac < 0.3: + bar_color = Color.RED + elif frac < 0.6: + bar_color = Color.YELLOW + draw_rect(Rect2(origin, Vector2(HP_BAR_WIDTH * frac, HP_BAR_HEIGHT)), + bar_color) + draw_string(font, Vector2(origin.x + HP_BAR_WIDTH + 16, origin.y + HP_BAR_HEIGHT), + s[0] as String, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, Color(0.95, 0.92, 0.82)) + + +func _capture_and_quit() -> void: + if _captured: + return + _captured = true + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUTPUT_DIR)) + var image: Image = get_viewport().get_texture().get_image() + if image == null: + push_error("HudProof: Failed to get viewport image") + get_tree().quit(1) + return + var stamp: String = Time.get_datetime_string_from_system().replace(":", "-").replace("T", "_") + var abs_path: String = ProjectSettings.globalize_path("%s/hud_proof_%s.png" % [OUTPUT_DIR, stamp]) + var err: Error = image.save_png(abs_path) + if err == OK: + print("SCREENSHOT_PATH:%s" % abs_path) + else: + push_error("HudProof: Save failed: %s" % error_string(err)) + get_tree().quit() diff --git a/src/game/engine/scenes/tests/hud_proof.tscn b/src/game/engine/scenes/tests/hud_proof.tscn new file mode 100644 index 00000000..efe7a634 --- /dev/null +++ b/src/game/engine/scenes/tests/hud_proof.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://hud_proof_01"] + +[ext_resource type="Script" path="res://engine/scenes/tests/hud_proof.gd" id="1"] + +[node name="HudProof" type="Node2D"] +script = ExtResource("1") diff --git a/src/game/engine/tests/unit/test_save_manager.gd b/src/game/engine/tests/unit/test_save_manager.gd index c3fc1936..d3bb8fc6 100644 --- a/src/game/engine/tests/unit/test_save_manager.gd +++ b/src/game/engine/tests/unit/test_save_manager.gd @@ -190,6 +190,53 @@ func test_autosave_then_load_autosave_round_trips() -> void: assert_eq(GameState.players.size(), 2, "autosave restores players") +## ── round-trip: research / magic / economy fields ───────────────────── + +func test_save_then_load_restores_research_and_magic_fields() -> void: + var p0: RefCounted = GameState.players[0] + p0.researched_techs = ["mining", "bronze_working"] + p0.researching = "iron_working" + p0.research_progress = 14 + p0.science_per_turn = 8 + p0.schools = ["life", "nature"] + p0.mana_pool = {"life": 60, "nature": 40} + p0.mana_income = {"life": 5.0, "nature": 3.0} + p0.golden_age_active = true + p0.golden_age_turns = 3 + p0.golden_age_progress = 7 + p0.golden_age_count = 1 + + SaveManagerScript.save_game(0) + + GameState.players[0].researched_techs = [] + GameState.players[0].researching = "" + GameState.players[0].research_progress = 0 + GameState.players[0].science_per_turn = 0 + GameState.players[0].schools = [] + GameState.players[0].mana_pool = {} + GameState.players[0].mana_income = {} + GameState.players[0].golden_age_active = false + GameState.players[0].golden_age_turns = 0 + GameState.players[0].golden_age_progress = 0 + GameState.players[0].golden_age_count = 0 + + var err: Error = SaveManagerScript.load_game(0) + assert_eq(err, OK, "load_game must succeed") + + var r: RefCounted = GameState.players[0] + assert_eq(r.researched_techs, ["mining", "bronze_working"], "researched_techs restored") + assert_eq(r.researching, "iron_working", "researching restored") + assert_eq(r.research_progress, 14, "research_progress restored") + assert_eq(r.science_per_turn, 8, "science_per_turn restored") + assert_eq(r.schools, ["life", "nature"], "schools restored") + assert_eq(r.mana_pool, {"life": 60, "nature": 40}, "mana_pool restored") + assert_eq(r.mana_income, {"life": 5.0, "nature": 3.0}, "mana_income restored") + assert_true(r.golden_age_active, "golden_age_active restored") + assert_eq(r.golden_age_turns, 3, "golden_age_turns restored") + assert_eq(r.golden_age_progress, 7, "golden_age_progress restored") + assert_eq(r.golden_age_count, 1, "golden_age_count restored") + + ## ── helpers ──────────────────────────────────────────────────────────── func _seed_game_state() -> void: