From 9263d5b4c5be67b28b51d7ffd814467cd13a3b63 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 26 Apr 2026 14:51:05 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20city=20screen=20design=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .project/designs/city-screen-sketch.html | 590 +++++++++++++++++ .project/designs/hud-sketch.html | 610 ++++++++++++++++++ src/game/engine/src/entities/city.gd | 65 +- src/game/engine/src/entities/player.gd | 18 +- .../src/modules/management/turn_processor.gd | 3 +- .../management/turn_processor_helpers.gd | 3 +- .../engine/src/rendering/city_renderer.gd | 49 +- 7 files changed, 1326 insertions(+), 12 deletions(-) create mode 100644 .project/designs/city-screen-sketch.html create mode 100644 .project/designs/hud-sketch.html diff --git a/.project/designs/city-screen-sketch.html b/.project/designs/city-screen-sketch.html new file mode 100644 index 00000000..3831a82f --- /dev/null +++ b/.project/designs/city-screen-sketch.html @@ -0,0 +1,590 @@ + + + + + +Age of Dwarves — City Screen Sketch + + + + + + +
+ +
+ + +
+
+
+
Ironhold
+
Stoneguard Clan · Capital
+
+
★ Capital
+
Republic
+
Iron Age
+
+
+
+
+ +
+
+
🌾
+
+
+14
+
Food
+
+
+
+
🔨
+
+
+22
+
Production
+
+
+
+
🪙
+
+
+36
+
Gold
+
+
+
+
+
+
+18
+
Science
+
+
+
+
🎭
+
+
+8
+
Culture
+
+
+
+
+
+
+5
+
Happiness
+
+
+
+
+ + +
+
Production
+
Citizens
+
Buildings
+
Diplomacy
+
Overview
+
+ + +
+ + +
+ +
Currently Building
+ +
+
🏛
+
+
Marketplace
+
3 turns remaining · 66 / 150 ⚒
+
+
+
150 ⚒
+2🪙
+
+ +
Build Queue
+ +
+
Barracks 6t↑↓ ✕
+
📚 Library 7t↑↓ ✕
+
🛁 Bathhouse 5t↑↓ ✕
+
+
+ Add to queue
+ +
Buildings (8)
+ +
+
🏰
Palace
+1 all yields
+
🔥
Forge
+3 ⚒ +1 🪙
+
🍺
Ale Hall
+2 ☺ +1 🎭
+
🌊
Aqueduct
+3 🌾 pop cap +2
+
Barracks
+15% unit XP
+
🛡
Walls
+5 def · +50% city HP
+
🏗
Granary
+2 🌾 +10% food
+
🗿
Monument
+2 🎭
+
+ +
Tile Improvements (6 worked)
+ +
+
Mine ⛰ Mountains (2,3) ⚒+3 🪙+1
+
🌾 Farm 🌿 Plains (1,4) 🌾+3
+
Mine ⛰ Hills (3,2) ⚒+2
+
🪵 Lumber 🌲 Forest (0,3) 🌾+1 ⚒+2
+
🌾 Farm 🌿 Plains (2,5) 🌾+3
+
Quarry ⛰ Stone (4,2) ⚒+4
+
+ +
+ + +
+ +
Population
+ +
+
+
8
+
Citizens working 6 tiles
+
+
+
Next citizen
+
4 turns
+
+
+ +
+
+ Food stored + 34 / 50 +
+
+
+14 🌾 per turn · consumes 16 🌾 · net +8 🌾
+
+ +
Citizens
+ +
+
👷 Miner ⛰ Mountains (2,3)⚒+3 🪙+1
+
👷 Farmer 🌿 Plains (1,4)🌾+3
+
👷 Miner ⛰ Hills (3,2)⚒+2
+
👷 Logger 🌲 Forest (0,3)🌾+1 ⚒+2
+
👷 Farmer 🌿 Plains (2,5)🌾+3
+
👷 Quarrier ⛰ Stone (4,2)⚒+4
+
🧑 Specialist (idle) City center⚗+2
+
🧑 Specialist (idle) City center⚗+2
+
+ +
City Stats
+ +
+
+
City HP
+
200
+
/ 200 max · Walled
+
+
+
Defense
+
18
+
+5 walls · +3 garrison
+
+
+
Border turns
+
3
+
Culture expanding
+
+
+
Upkeep
+
−14
+
🪙 per turn
+
+
+ +
Happiness
+ +
+
Happiness Breakdown
+
Ale Hall+2
+
Republic gov.+3
+
Era bonus+1
+
Luxury resources+2
+
Population penalty−2
+
War weariness−1
+
Total+5 ☺
+
+ +
+
+
+ + + diff --git a/.project/designs/hud-sketch.html b/.project/designs/hud-sketch.html new file mode 100644 index 00000000..12000d56 --- /dev/null +++ b/.project/designs/hud-sketch.html @@ -0,0 +1,610 @@ + + + + + +Age of Dwarves — World Map HUD Sketch + + + + + + + +
+ + +
+
+
+
+
+ + +
+
🏛
+
Ironhold
+
★ Capital · Pop 8
+
+ +
+
🏛
+
Ashspire
+
Emberfall · Pop 5
+
+ +
+
🏛
+
Deepvault
+
Stoneguard · Pop 6
+
+ + +
+
🏹
+
+ + +
+
+
+
Turn 42
+
Iron Age
+
+
+ +
+
🪙+84
+
+22
+
+7
+
🎭+12
+
+ +
+
+ 🌡 +
+
Climate
+
+
+ +0.4° +
+
+ +
+
+
Stoneguard Clan
+
Republic · 3 rivals
+
+
+
📜
+
+
+
+ + +
+
+
+
Research Complete
+
Iron Smelting unlocked · Choose next research
+
+
+ + +
+
+
Iron Warrior
+
Melee Infantry · 3 moves left
+
+
+
+
Attack
+
12
+
+
+
Defense
+
8
+
+
+
Movement
+
2/2
+
+
+
XP
+
24
+
+
+
+
HP68 / 80
+
+
+
+
★ Strength I
+
★ Flanking
+
+
+
⚔ Attack
+
🚶 Move
+
💤 Sleep
+
+
+
+ + +
+
Chronicle
+
+ T42 + Iron Smelting research complete +
+
+ T41 + Ashspire founded by Emberfall Clan +
+
+ T40 + Warrior defeated Emberfall Scout (+12 XP) +
+
+ T38 + Deepvault: Forge construction complete +
+
+ T37 + Stoneguard enters Iron Age +
+
+ + +
+
End Turn →
+
+ + +
+
+ +
+
+
+ +
+
+ +
+
+
+ + +
+
Clans
+
Stoneguard (You)
+
Emberfall
+
Deephollow
+
Ironveil
+
Ashpeak
+
+ + +
top-bar · full width · 44px · z-100
+
unit-panel · 260×auto · bottom-left · 12px margin
+
end-turn · 200px wide · above minimap
+
minimap · 200×140px · bottom-right · 12px margin
+
chronicle · 280px · semi-transparent · bottom-left+272
+
toast notification · centered · below top-bar
+ + + diff --git a/src/game/engine/src/entities/city.gd b/src/game/engine/src/entities/city.gd index b53b65f4..972e65ac 100644 --- a/src/game/engine/src/entities/city.gd +++ b/src/game/engine/src/entities/city.gd @@ -39,6 +39,11 @@ var position: Vector2i = Vector2i.ZERO ## Mirror of the Rust-side buildings list. var buildings: Array[String] = [] +## Tile-placed building positions: {building_id -> Vector2i axial}. +## Only populated for buildings queued via add_to_queue_with_tile(). +## Non-tile-placed buildings are absent from this dict. +var placed_buildings: Dictionary = {} + ## Owning player index (set by spawner alongside `player`). var owner: int = -1: set(v): @@ -48,7 +53,7 @@ var owner: int = -1: var owner_index: int = -1 ## GDScript-side building/unit production queue (not yet in Rust). -## Each entry: {type: "building"|"unit", id: String, cost: int} +## Each entry: {type: "building"|"unit", id: String, cost: int[, tile_pos: Vector2i]} var production_queue: Array = [] ## Whether this city has used its bombard action this turn. @@ -394,6 +399,12 @@ func add_building(building: String) -> void: _register_building_yields(building) +func add_building_at(building: String, tile_pos: Vector2i) -> void: + add_building(building) + if tile_pos != Vector2i(-1, -1): + placed_buildings[building] = tile_pos + + func has_building(building: String) -> bool: return building in buildings @@ -474,6 +485,58 @@ func from_json(json: String) -> bool: return ok +func to_save_dict() -> Dictionary: + var rust_json_str: String = to_json() + var placed: Dictionary = {} + for bid: String in placed_buildings: + var v: Vector2i = placed_buildings[bid] as Vector2i + placed[bid] = [v.x, v.y] + var queue_data: Array = [] + for entry: Dictionary in production_queue: + var e: Dictionary = { + "type": str(entry.get("type", "")), + "id": str(entry.get("id", "")), + "cost": int(entry.get("cost", 0)), + } + var tp: Vector2i = entry.get("tile_pos", Vector2i(-1, -1)) as Vector2i + if tp != Vector2i(-1, -1): + e["tile_pos"] = [tp.x, tp.y] + queue_data.append(e) + return { + "rust_json": rust_json_str, + "owner": owner, + "original_capital_owner": original_capital_owner, + "production_progress": production_progress, + "placed_buildings": placed, + "production_queue": queue_data, + } + + +func from_save_dict(data: Dictionary) -> void: + var rust_json_str: String = str(data.get("rust_json", "{}")) + from_json(rust_json_str) + owner = int(data.get("owner", -1)) + original_capital_owner = int(data.get("original_capital_owner", -1)) + production_progress = int(data.get("production_progress", 0)) + placed_buildings.clear() + var placed_raw: Dictionary = data.get("placed_buildings", {}) as Dictionary + for bid: String in placed_raw: + var coords: Array = placed_raw[bid] as Array + if coords.size() == 2: + placed_buildings[bid] = Vector2i(int(coords[0]), int(coords[1])) + production_queue.clear() + for entry_raw: Dictionary in data.get("production_queue", []) as Array: + var e: Dictionary = { + "type": str(entry_raw.get("type", "")), + "id": str(entry_raw.get("id", "")), + "cost": int(entry_raw.get("cost", 0)), + } + var tp_raw: Array = entry_raw.get("tile_pos", []) as Array + if tp_raw.size() == 2: + e["tile_pos"] = Vector2i(int(tp_raw[0]), int(tp_raw[1])) + production_queue.append(e) + + # ── internals ─────────────────────────────────────────────────────── func _sync_from_rust() -> void: diff --git a/src/game/engine/src/entities/player.gd b/src/game/engine/src/entities/player.gd index 033b4041..beca1efb 100644 --- a/src/game/engine/src/entities/player.gd +++ b/src/game/engine/src/entities/player.gd @@ -17,6 +17,8 @@ extends RefCounted ## the iter 7k dict adapter. `serialize()`/`deserialize()` are the ## save-manager snapshot hooks. +const CityScript: GDScript = preload("res://engine/src/entities/city.gd") + # ── Identity ────────────────────────────────────────────────────────── ## 0-indexed player slot. -1 for wild/nature player. var index: int = -1 @@ -188,10 +190,13 @@ func to_bridge_dict() -> Dictionary: # ── Save / load ─────────────────────────────────────────────────────── -## Full snapshot for SaveManager. Units and cities are serialized -## separately by `GameState.serialize()` via their own owners; this -## snapshot only holds per-player scalar and container state. +## Full snapshot for SaveManager. Includes city state so placed_buildings +## and production queues survive save/load. func serialize() -> Dictionary: + var city_data: Array = [] + for c: RefCounted in cities: + if c.has_method("to_save_dict"): + city_data.append(c.call("to_save_dict")) return { "index": index, "player_name": player_name, @@ -200,6 +205,7 @@ func serialize() -> Dictionary: "is_human": is_human, "color": [color.r, color.g, color.b, color.a], "gold": gold, + "cities": city_data, "gold_per_turn": gold_per_turn, "traded_luxuries": traded_luxuries.duplicate(), "golden_age_active": golden_age_active, @@ -279,3 +285,9 @@ func deserialize(data: Dictionary) -> void: clan_id = str(data.get("clan_id", clan_id)) ascension_active = bool(data.get("ascension_active", ascension_active)) strategic_ledger = (data.get("strategic_ledger", {}) as Dictionary).duplicate() + cities = [] + for city_raw: Dictionary in data.get("cities", []) as Array: + var city: CityScript = CityScript.new() + city.player = self + city.from_save_dict(city_raw) + cities.append(city) diff --git a/src/game/engine/src/modules/management/turn_processor.gd b/src/game/engine/src/modules/management/turn_processor.gd index f03ccbcb..9b88b38e 100644 --- a/src/game/engine/src/modules/management/turn_processor.gd +++ b/src/game/engine/src/modules/management/turn_processor.gd @@ -117,7 +117,8 @@ func _process_production(player: RefCounted) -> void: # Player unit.gain_xp(xp_bonus) EventBus.city_unit_completed.emit(city_ref, unit) elif item_type == "building": - c.add_building(item_id) + var tile_pos: Vector2i = current.get("tile_pos", Vector2i(-1, -1)) as Vector2i + c.add_building_at(item_id, tile_pos) _apply_building_bonuses(c, item_id) EventBus.city_building_completed.emit(city_ref, item_id) elif item_type == "item": diff --git a/src/game/engine/src/modules/management/turn_processor_helpers.gd b/src/game/engine/src/modules/management/turn_processor_helpers.gd index e95359f3..9980aed1 100644 --- a/src/game/engine/src/modules/management/turn_processor_helpers.gd +++ b/src/game/engine/src/modules/management/turn_processor_helpers.gd @@ -53,7 +53,8 @@ static func process_production( if unit != null: EventBus.city_unit_completed.emit(city, unit) elif item_type == "building": - c.add_building(item_id) + var tile_pos: Vector2i = current.get("tile_pos", Vector2i(-1, -1)) as Vector2i + c.add_building_at(item_id, tile_pos) EventBus.city_building_completed.emit(city, item_id) diff --git a/src/game/engine/src/rendering/city_renderer.gd b/src/game/engine/src/rendering/city_renderer.gd index 53e90391..400e1d6a 100644 --- a/src/game/engine/src/rendering/city_renderer.gd +++ b/src/game/engine/src/rendering/city_renderer.gd @@ -31,6 +31,11 @@ const SPRITE_LOOKUP_CITY_FORMAT: String = "sprites/cities/city_q%d.png" const CITY_QUALITY_BUCKET: int = 5 const CITY_QUALITY_MAX: int = 5 +## Placed building icon size at the hex tile (clamped to fit on tile). +const PLACED_BUILDING_ICON_SIZE: float = 14.0 +## Offset from hex center for placed building icon (top-left quadrant to avoid city sprite). +const PLACED_BUILDING_ICON_OFFSET: Vector2 = Vector2(-14.0, -14.0) + ## Internal state: city_id -> Dictionary with cached render data ## Each entry: { "city": RefCounted, "color": Color } var _cities: Dictionary = {} @@ -128,6 +133,13 @@ func _draw() -> void: _draw_city_label(pixel, city) _draw_hp_bar(pixel, city) + # Pass 3: Draw tile-placed building icons at their chosen hexes + for city_id: String in _cities: + var city: CityScript = (_cities[city_id] as Dictionary).get("city") as CityScript + if city == null or city.placed_buildings.is_empty(): + continue + _draw_placed_buildings(city) + func _draw_city_sprite(pixel: Vector2, city: CityScript, color: Color) -> void: ## Baseline: always draw the colored circle + initial letter (never removed). @@ -203,6 +215,28 @@ func _draw_hp_bar(pixel: Vector2, city: CityScript) -> void: ) +func _draw_placed_buildings(city: CityScript) -> void: + for bid: String in city.placed_buildings: + var axial: Vector2i = city.placed_buildings[bid] as Vector2i + var center: Vector2 = HexUtilsScript.axial_to_pixel(axial) + HexUtilsScript.hex_center + var icon_pos: Vector2 = center + PLACED_BUILDING_ICON_OFFSET + var sprite_path: String = "sprites/buildings/%s.png" % bid + var tex: Texture2D = _load_cached_sprite(sprite_path) + if tex != null: + var tex_size: Vector2 = tex.get_size() + var scale: float = PLACED_BUILDING_ICON_SIZE / maxf(tex_size.x, tex_size.y) + var draw_size: Vector2 = tex_size * scale + draw_texture_rect(tex, Rect2(icon_pos - draw_size * 0.5, draw_size), false) + else: + # Geometric fallback: small colored square with first letter + var h: float = PLACED_BUILDING_ICON_SIZE * 0.5 + draw_rect(Rect2(icon_pos - Vector2(h, h), Vector2(h * 2.0, h * 2.0)), Color(0.6, 0.4, 0.1, 0.85)) + var font: Font = ThemeDB.fallback_font + var letter: String = bid[0].to_upper() if bid.length() > 0 else "B" + var ts: Vector2 = font.get_string_size(letter, HORIZONTAL_ALIGNMENT_CENTER, -1, 10) + draw_string(font, icon_pos - ts * 0.5 + Vector2(0.0, ts.y * 0.35), letter, HORIZONTAL_ALIGNMENT_LEFT, -1, 10, Color.WHITE) + + func _draw_all_borders() -> void: ## Draw cultural border overlays for all cities. for city_id: String in _cities: @@ -250,6 +284,14 @@ func _draw_city_borders(city: CityScript, color: Color) -> void: ## -- Sprite loading -- +func _load_cached_sprite(sprite_path: String) -> Texture2D: + if _sprite_cache.has(sprite_path): + return _sprite_cache[sprite_path] + var texture: Texture2D = ThemeAssets.load_sprite(sprite_path) + _sprite_cache[sprite_path] = texture + return texture + + func _get_city_sprite(city: CityScript) -> Texture2D: ## Try to load a city sprite via ThemeAssets using the population-tier key. ## Returns null if unavailable — caller renders the procedural baseline @@ -257,12 +299,7 @@ func _get_city_sprite(city: CityScript) -> Texture2D: var quality: int = clampi( city.population / CITY_QUALITY_BUCKET + 1, 1, CITY_QUALITY_MAX ) - var sprite_path: String = SPRITE_LOOKUP_CITY_FORMAT % quality - if _sprite_cache.has(sprite_path): - return _sprite_cache[sprite_path] - var texture: Texture2D = ThemeAssets.load_sprite(sprite_path) - _sprite_cache[sprite_path] = texture - return texture + return _load_cached_sprite(SPRITE_LOOKUP_CITY_FORMAT % quality) ## -- Player color lookup --