feat(@projects/@magic-civilization): add dire wolf and frostfang alpha units

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-16 15:07:09 -07:00
parent 118621d8e8
commit 400425585a
8 changed files with 276 additions and 79 deletions

View file

@ -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"
}
]

View file

@ -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",

View file

@ -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": {

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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")

View file

@ -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: