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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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)
+
+
+
+
+
+
+
+
🛡
Walls
+5 def · +50% city HP
+
+
+
+
+
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
+
+
+
+⚔
+🏹
+⚔
+
+
+
+
+
+
+
🪙+84
+
⚗+22
+
☺+7
+
🎭+12
+
+
+
+
+
+
+
Stoneguard Clan
+
Republic · 3 rivals
+
+
⚔
+
📜
+
⚙
+
+
+
+
+
+
⚗
+
+
Research Complete
+
Iron Smelting unlocked · Choose next research
+
+
+
+
+
+
+
+
+
+
+
⚔ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+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 --