diff --git a/.project/objectives/p2-85-poi-sprites-and-tooltips.md b/.project/objectives/p2-85-poi-sprites-and-tooltips.md index 1fbb4576..e26a1fe2 100644 --- a/.project/objectives/p2-85-poi-sprites-and-tooltips.md +++ b/.project/objectives/p2-85-poi-sprites-and-tooltips.md @@ -2,7 +2,7 @@ id: p2-85 title: "POI sprites + hover tooltips — lairs (and resources) legible on the map" priority: p2 -status: stub +status: partial scope: game1 category: rendering owner: asset-sprite @@ -32,24 +32,65 @@ Hover/tooltip infra already exists: `world_map_hover.gd` (20 Hz) → `tile_info_ ## Acceptance criteria -- [ ] Enumerate lair `type_id`s (from `public/resources/ecology/fauna/lair_spawn_rules.json` - / the NPC building types surfaced by `npc_buildings_all()`). -- [ ] Add a **`lairs` category** to `tools/standin-sprites/build_standins.py` + - `icon_rules.json` (CC-BY-3.0 game-icons.net: cave-entrance, monster-den, - spider-web, skull, etc.); pipeline emits `sprites/lairs/.png`. -- [ ] Lairs render as the real sprite on the map (verify the already-wired path - in `lair_overlay_renderer.gd` with the new art) — legible at default zoom. -- [ ] **Resources**: confirm the existing `sprites/resources/*` render legibly on - the map; apply the same POI scale if they're token-tiny. -- [ ] **Hover tooltip**: extend `tile_info_panel` (+ its `.tscn`) so hovering a - lair tile shows name, tier, threat, and what it spawns/loot (from the lair - data); reuse the existing `world_map_hover` → `show_tile` path with an - `npc_buildings_all()` lookup keyed by hovered axial. -- [ ] Proof screenshot (via `standin_sprite_proof` for the sprite + a world-map - capture for the in-context lair + tooltip). +- [✓] Enumerate lair `type_id`s. Canonical source is `public/resources/wilds/wilds.json` + `lair_types[].id` (NOT `lair_spawn_rules.json`, which keys creature pools): the 8 ids + `goblin_camp, bandit_hideout, troll_cave, beast_den, corrupted_hollow, volcanic_fissure, + ancient_construct_site, wyvern_nest`. Verified that `npc_buildings_all()` surfaces exactly + these as `type_id` — `village_lair_placer.gd:222` spawns each from `lair_types[].id`, + and `rust_fauna_integration.gd:63` asserts `Building.type_id == lair_types[].id`. +- [✓] Added a **`lairs` category** to `build_standins.py` + `icon_rules.json`; pipeline emits + the 8 `sprites/lairs/.png` (game-icons CC-BY-3.0: tipi, robber-mask, cave-entrance, + wolf-head, evil-tree, volcano, rock-golem, wyvern). A new `--only ` flag emits + MERGES + ledger/manifest rows so the standin set extends **without clobbering the Wesnoth demo-art + layer** that shares the same on-disk tree (full runs were reverting 538 demo PNGs). Provenance + rows added to `LICENSES.md` (+8) and `STANDINS.md` (+8, count 613/819 → 621/827). +- [✓] Lairs render as the real sprite — verified **in-engine** on plum native Godot via the new + `engine/scenes/tests/lair_poi_proof.tscn`, which loads each `sprites/lairs/.png` through the + real `ThemeAssets.load_sprite` and draws it on a tan/green hex at the actual 0.45×hex POI scale. + All 8 render (none MISSING); capture at `tools/standin-sprites/lair_poi_proof_ingame.png`, read + + approved in-conversation. The run did **no asset import** (load_sprite reads un-imported PNGs via + FileAccess; scene run, not `--import`) — the documented-safe path on plum. `lair_overlay_renderer.gd` + `_lair_sprite()` uses this same loader at `scaled_sprite_size(·, 0.45)` with a diamond fallback — a pure drop-in. Two coexisting layers, like the rest of the map: + (a) **commercial-safe baseline** — game-icons silhouettes via `build_standins.py --only lairs`; + (b) **DEMO overlay** (on disk) — Battle-for-Wesnoth art via the new + `tools/standin-sprites/build_demo_lairs.py`, which **composites** each lair from 2 Wesnoth + sources (base + creature/effect) onto 128×128 — e.g. dragon-over-nest, tomb-on-summoning-circle, + flames-from-rocks, direwolf-at-den — so lairs match the Wesnoth demo look. Per-source provenance + + sha256 (16 rows) in `DEMO_SPRITES_LICENSES.md`. Legibility at exact POI scale proven by + `lair_poi_proof.png`. **Open:** in-engine world-map capture (blocked — see Notes). +- [~] **Resources**: `overlay_renderer.gd:_try_sprite_icon` draws `sprites/resources/.png` at a + flat `scale = 0.22` (resolution-dependent, unlike the lair tile-fraction). Not changed — no render + evidence they are token-tiny, and the path is already shipping; revisit if the in-engine capture + shows they read small. +- [✓] **Hover tooltip**: extended `tile_info_panel.gd` + `.tscn` (new Row3 `LairLabel`). `_populate_lair` + looks up the hovered axial in `npc_buildings_all()`; pure static `build_lair_text(entry, loot_table)` + composes name · tier · threat band · distinct spawns · clear-loot from the wilds pack. Panel uses + `grow_vertical=BEGIN` so the hidden 3rd row costs no height and grows upward when shown. 6 GUT tests + added to `test_tile_tooltip.gd` (16/16 pass headless). +- [✓] Proof: in-engine captures on plum native Godot (no asset import; `gl_compatibility`): + (a) close-up sprite proof `lair_poi_proof.tscn` → `tools/standin-sprites/lair_poi_proof_ingame.png`; + (b) **live world-map proof** `lair_world_proof.tscn` → `tools/standin-sprites/lair_world_proof.png` + — generates the real seed-42 world (MapGenerator `place_all` seeds the lairs), renders terrain via + the real HexRenderer + the real LairOverlayRenderer, and frames a placed lair (troll_cave reads + clearly on mountain terrain); (c) the full game boots to victory under `AUTO_PLAY` on plum + (`live_world_seed42.png` = prologue boot; autoplay log: `lairs on map = 7 + {troll_cave:3,beast_den:1,bandit_hideout:2,wyvern_nest:1}`). All read + approved in-conversation. + Only the hover **tooltip** isn't in a screenshot (mouse-driven; logic covered by the 6 GUT tests). ## Notes -- Generation needs network (game-icons.net fetch); run on a host with a warm - icon cache or connectivity. Headed render proofs are plum-safe (warm import - cache) per the no-godot-import-on-plum note. +- Generation needs network (game-icons.net fetch) — done on plum against the warm SVG cache + (`tools/standin-sprites/.cache/svg`, HTTP 200 to game-icons). Run via the tool venv: + `tools/standin-sprites/.venv/bin/python tools/standin-sprites/build_standins.py --only lairs`. +- **In-engine sprite proof: DONE on plum, safely.** Demonstrated that rendering the lairs does + NOT require asset import: `ThemeAssets.load_sprite` (theme_assets.gd:69) reads un-imported PNGs + via `FileAccess` + `Image.load_png_from_buffer`, and running a proof scene (not `--import`) leaves + the importer untouched — the run log showed zero import activity. So `lair_poi_proof.tscn` captured + the real engine render on plum native Godot (`--rendering-method gl_compatibility`) with no kernel- + panic exposure. This is GPU rendering (Metal) but within the verified-safe envelope; true zero-GPU + software render (weston + llvmpipe) is the apricot path (down) and is unnecessary here. +- **Live world-map capture: DONE on plum.** `lair_world_proof.tscn` renders lairs on the real + seed-42 map; and the full game boots to victory under `AUTO_PLAY`/`MC_AUTO_START` on the M2 (the + rendered-game MCP driver `MC_MCP_RENDER=1` on port 8787 captured the live frame). All within the + no-import safe envelope. Only residual: a *hover-tooltip* screenshot (mouse-driven; the tooltip + logic is covered by GUT tests), and the resources-legibility judgment call. diff --git a/public/games/age-of-dwarves/assets/sprites/lairs/ancient_construct_site.png b/public/games/age-of-dwarves/assets/sprites/lairs/ancient_construct_site.png new file mode 100644 index 00000000..a8c2dabf Binary files /dev/null and b/public/games/age-of-dwarves/assets/sprites/lairs/ancient_construct_site.png differ diff --git a/public/games/age-of-dwarves/assets/sprites/lairs/bandit_hideout.png b/public/games/age-of-dwarves/assets/sprites/lairs/bandit_hideout.png new file mode 100644 index 00000000..ab38a1db Binary files /dev/null and b/public/games/age-of-dwarves/assets/sprites/lairs/bandit_hideout.png differ diff --git a/public/games/age-of-dwarves/assets/sprites/lairs/beast_den.png b/public/games/age-of-dwarves/assets/sprites/lairs/beast_den.png new file mode 100644 index 00000000..e7cd69dc Binary files /dev/null and b/public/games/age-of-dwarves/assets/sprites/lairs/beast_den.png differ diff --git a/public/games/age-of-dwarves/assets/sprites/lairs/corrupted_hollow.png b/public/games/age-of-dwarves/assets/sprites/lairs/corrupted_hollow.png new file mode 100644 index 00000000..d070356f Binary files /dev/null and b/public/games/age-of-dwarves/assets/sprites/lairs/corrupted_hollow.png differ diff --git a/public/games/age-of-dwarves/assets/sprites/lairs/goblin_camp.png b/public/games/age-of-dwarves/assets/sprites/lairs/goblin_camp.png new file mode 100644 index 00000000..563cd3e8 Binary files /dev/null and b/public/games/age-of-dwarves/assets/sprites/lairs/goblin_camp.png differ diff --git a/public/games/age-of-dwarves/assets/sprites/lairs/troll_cave.png b/public/games/age-of-dwarves/assets/sprites/lairs/troll_cave.png new file mode 100644 index 00000000..1601d1c8 Binary files /dev/null and b/public/games/age-of-dwarves/assets/sprites/lairs/troll_cave.png differ diff --git a/public/games/age-of-dwarves/assets/sprites/lairs/volcanic_fissure.png b/public/games/age-of-dwarves/assets/sprites/lairs/volcanic_fissure.png new file mode 100644 index 00000000..c2108c89 Binary files /dev/null and b/public/games/age-of-dwarves/assets/sprites/lairs/volcanic_fissure.png differ diff --git a/public/games/age-of-dwarves/assets/sprites/lairs/wyvern_nest.png b/public/games/age-of-dwarves/assets/sprites/lairs/wyvern_nest.png new file mode 100644 index 00000000..8a41a5a5 Binary files /dev/null and b/public/games/age-of-dwarves/assets/sprites/lairs/wyvern_nest.png differ diff --git a/src/game/engine/scenes/tests/lair_poi_proof.gd b/src/game/engine/scenes/tests/lair_poi_proof.gd new file mode 100644 index 00000000..bd9b9111 --- /dev/null +++ b/src/game/engine/scenes/tests/lair_poi_proof.gd @@ -0,0 +1,96 @@ +extends Node2D +## In-engine proof for the lair POI sprites (p2-85). Loads each `lairs/.png` +## through the REAL engine loader (`ThemeAssets.load_sprite`, the exact path +## `lair_overlay_renderer.gd` uses) and draws it on a tan terrain hex at the on-map +## POI scale (LAIR_SPRITE_TILE_FRACTION = 0.45 × hex width) with the lair name above +## — the same composition the map renderer produces. Self-captures +## `user://screenshots/lair_poi_proof.png` and quits, so it runs headless-capture +## under the proof harness without needing a live game state. + +const CAPTURE_DELAY: float = 0.8 +const OUTPUT_DIR: String = "user://screenshots" +const HEX_W: float = 200.0 +const HEX_H: float = 173.0 # 200 × √3/2, regular flat-top hex +const POI_FRACTION: float = 0.45 # mirrors LairOverlayRenderer.LAIR_SPRITE_TILE_FRACTION +const COLS: int = 4 +const CELL: Vector2 = Vector2(244, 232) +const ORIGIN: Vector2 = Vector2(56, 96) + +# id, display name, terrain tint (matches the lair's preferred biome family) +const LAIRS: Array = [ + ["goblin_camp", "Goblin Camp", Color(0.59, 0.70, 0.41)], + ["bandit_hideout", "Bandit Hideout", Color(0.59, 0.70, 0.41)], + ["troll_cave", "Troll Cave", Color(0.74, 0.66, 0.49)], + ["beast_den", "Beast Den", Color(0.59, 0.70, 0.41)], + ["corrupted_hollow", "Corrupted Hollow", Color(0.47, 0.52, 0.38)], + ["volcanic_fissure", "Volcanic Fissure", Color(0.52, 0.43, 0.40)], + ["ancient_construct_site", "Ancient Construct Site", Color(0.74, 0.66, 0.49)], + ["wyvern_nest", "Wyvern Nest", Color(0.67, 0.62, 0.50)], +] + + +func _ready() -> void: + RenderingServer.set_default_clear_color(Color(0.12, 0.13, 0.17)) + DataLoader.load_theme("age-of-dwarves") + ThemeAssets.set_theme("age-of-dwarves") + await get_tree().process_frame + queue_redraw() + await get_tree().create_timer(CAPTURE_DELAY).timeout + _capture_and_quit() + + +func _hex_points(cx: float, cy: float) -> PackedVector2Array: + var pts: PackedVector2Array = PackedVector2Array() + for i: int in 6: + var a: float = deg_to_rad(60.0 * i) + pts.append(Vector2(cx + (HEX_W * 0.5) * cos(a), cy + (HEX_H * 0.5) * sin(a))) + return pts + + +func _draw() -> void: + var font: Font = ThemeDB.fallback_font + draw_string(font, Vector2(ORIGIN.x, 52), + "Age of Dwarves — lair POI sprites (Wesnoth demo overlay) via ThemeAssets, at on-map 0.45×hex scale", + HORIZONTAL_ALIGNMENT_LEFT, -1, 18, Color(0.95, 0.92, 0.7)) + + for i: int in LAIRS.size(): + var lid: String = LAIRS[i][0] + var name: String = LAIRS[i][1] + var tint: Color = LAIRS[i][2] + var col: int = i % COLS + var row: int = i / COLS + var cx: float = ORIGIN.x + CELL.x * 0.5 + col * CELL.x + var cy: float = ORIGIN.y + CELL.y * 0.5 + row * CELL.y + + draw_colored_polygon(_hex_points(cx, cy), tint) + draw_polyline(_hex_points(cx, cy) + PackedVector2Array([_hex_points(cx, cy)[0]]), + Color(0.1, 0.09, 0.07, 0.8), 2.0) + + var center: Vector2 = Vector2(cx, cy) + var tex: Texture2D = ThemeAssets.load_sprite("sprites/lairs/%s.png" % lid) + if tex != null: + var tw: float = HEX_W * POI_FRACTION + var draw_size: Vector2 = tex.get_size() * (tw / tex.get_size().x) + draw_texture_rect(tex, Rect2(center - draw_size * 0.5, draw_size), false) + else: + draw_string(font, center - Vector2(28, 0), "MISSING", + HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(1, 0.3, 0.3)) + + var tsize: Vector2 = font.get_string_size(name, HORIZONTAL_ALIGNMENT_CENTER, -1, 14) + var tpos: Vector2 = Vector2(cx - tsize.x * 0.5, cy - HEX_H * 0.5 - 6) + draw_string(font, tpos + Vector2(1, 1), name, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0, 0, 0, 0.7)) + draw_string(font, tpos, name, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.95, 0.95, 0.95)) + + +func _capture_and_quit() -> void: + var dir: DirAccess = DirAccess.open("user://") + if dir != null: + dir.make_dir_recursive("screenshots") + var img: Image = get_viewport().get_texture().get_image() + var path: String = "%s/lair_poi_proof.png" % OUTPUT_DIR + var err: Error = img.save_png(path) + if err != OK: + push_error("lair_poi_proof: save failed (err %d)" % err) + else: + print("lair_poi_proof: saved %s" % path) + get_tree().quit() diff --git a/src/game/engine/scenes/tests/lair_poi_proof.tscn b/src/game/engine/scenes/tests/lair_poi_proof.tscn new file mode 100644 index 00000000..6681ffa8 --- /dev/null +++ b/src/game/engine/scenes/tests/lair_poi_proof.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://b1a1rp01pr00f"] + +[ext_resource type="Script" path="res://engine/scenes/tests/lair_poi_proof.gd" id="1_lairproof"] + +[node name="LairPoiProof" type="Node2D"] +script = ExtResource("1_lairproof") diff --git a/src/game/engine/scenes/tests/lair_world_proof.gd b/src/game/engine/scenes/tests/lair_world_proof.gd new file mode 100644 index 00000000..759be9c8 --- /dev/null +++ b/src/game/engine/scenes/tests/lair_world_proof.gd @@ -0,0 +1,136 @@ +extends Node2D +## Live-map proof for the lair POI sprites (p2-85). Generates a REAL world via +## MapGenerator (seed 42) — whose `place_all` seeds villages + lairs into +## GameState.npc_buildings — renders the terrain with the real HexRenderer and the +## real LairOverlayRenderer (the exact map renderers), reveals fog, and frames the +## camera on the placed lairs so they're legible on actual generated terrain. +## Self-captures user://screenshots/lair_world_proof.png. Runs via the proof +## harness (real window / gl_compatibility), no asset import. + +const MapGeneratorScript: GDScript = preload("res://engine/src/generation/map_generator.gd") +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") +const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd") +const HexRendererScript: GDScript = preload("res://engine/src/rendering/hex_renderer.gd") +const LairOverlayScript: GDScript = preload("res://engine/src/rendering/lair_overlay_renderer.gd") + +const OUTPUT_DIR: String = "user://screenshots" +const VIEWPORT_SIZE: Vector2i = Vector2i(1920, 1080) + + +func _ready() -> void: + OS.set_environment("FORCE_DISABLE_FOGOFWAR", "true") + DisplayServer.window_set_size(VIEWPORT_SIZE) + get_viewport().size = VIEWPORT_SIZE + RenderingServer.set_default_clear_color(Color(0.05, 0.07, 0.10)) + + DataLoader.load_theme("age-of-dwarves") + DataLoader.load_world("earth") + ThemeAssets.set_theme("age-of-dwarves") + + var settings: Dictionary = { + "seed": 42, "map_type": "continents", "map_size": "duel", "num_players": 2, + } + GameState.initialize_game(settings) + + var gen: MapGeneratorScript = MapGeneratorScript.new() + var game_map: RefCounted = gen.generate(settings) + if game_map == null: + push_error("lair_world: MapGenerator returned null") + get_tree().quit(1) + return + var primary: Dictionary = GameState.get_primary_layer() + primary["map"] = game_map + + # A nominal human player so GameState is well-formed for the renderers. + var player: PlayerScript = PlayerScript.new() + player.index = 0 + player.is_human = true + player.player_name = "Dwarf Clan" + player.race_id = "dwarf" + player.color = Color(0.85, 0.65, 0.2) + GameState.players.append(player) + + # Terrain (fog revealed everywhere so lairs aren't hidden). + var hex: Node2D = HexRendererScript.new() + hex.name = "HexRenderer" + add_child(hex) + hex.render_map(game_map) + var all_positions: Array[Vector2i] = [] + for pos_key: Vector2i in game_map.tiles: + all_positions.append(pos_key) + hex.update_fog(all_positions, [] as Array[Vector2i]) + + # The REAL lair overlay renderer — reads GameState npc_buildings. + var lair_overlay: Node2D = LairOverlayScript.new() + lair_overlay.name = "LairOverlayRenderer" + add_child(lair_overlay) + lair_overlay.call("refresh") + + _frame_lairs_and_capture() + + +func _lair_pixels() -> PackedVector2Array: + var pts: PackedVector2Array = PackedVector2Array() + var gd_state: RefCounted = GameState.get_gd_state() + if gd_state == null: + return pts + for b: Dictionary in gd_state.npc_buildings_all(): + var tid: String = String(b.get("type_id", "")) + if tid == "village" or tid == "ruin": + continue + var raw: Array = b.get("position", []) as Array + if raw.size() < 2: + continue + var axial: Vector2i = Vector2i(int(raw[0]), int(raw[1])) + pts.append(HexUtilsScript.axial_to_pixel(axial) + HexUtilsScript.hex_center) + return pts + + +func _frame_lairs_and_capture() -> void: + var cam: Camera2D = Camera2D.new() + var lairs: PackedVector2Array = _lair_pixels() + print("lair_world: %d lairs at %s" % [lairs.size(), lairs]) + if lairs.is_empty(): + cam.position = Vector2.ZERO + cam.zoom = Vector2(0.12, 0.12) + push_warning("lair_world: no lairs found — framing map centre") + else: + # Centre on the densest lair (the one with the most neighbours within + # ~7 hexes) so several lairs share the frame; zoom in enough to read the + # sprites (≈11 hexes wide). + var best_i: int = 0 + var best_n: int = -1 + for i: int in lairs.size(): + var n: int = 0 + for j: int in lairs.size(): + if i != j and lairs[i].distance_to(lairs[j]) < 7.0 * 384.0: + n += 1 + if n > best_n: + best_n = n + best_i = i + cam.position = lairs[best_i] + cam.zoom = Vector2(0.62, 0.62) + add_child(cam) + cam.make_current() + + await get_tree().process_frame + for _i: int in range(20): + await get_tree().process_frame + await get_tree().create_timer(1.0).timeout + _capture_and_quit() + + +func _capture_and_quit() -> void: + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUTPUT_DIR)) + var image: Image = get_viewport().get_texture().get_image() + if image == null: + push_error("lair_world: viewport image null") + get_tree().quit(1) + return + var abs_path: String = ProjectSettings.globalize_path("%s/lair_world_proof.png" % OUTPUT_DIR) + var err: Error = image.save_png(abs_path) + if err == OK: + print("SCREENSHOT_PATH:%s" % abs_path) + else: + push_error("lair_world: save failed: %s" % error_string(err)) + get_tree().quit() diff --git a/src/game/engine/scenes/tests/lair_world_proof.tscn b/src/game/engine/scenes/tests/lair_world_proof.tscn new file mode 100644 index 00000000..8a52b477 --- /dev/null +++ b/src/game/engine/scenes/tests/lair_world_proof.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://b1a1rw0r1dpr0f"] + +[ext_resource type="Script" path="res://engine/scenes/tests/lair_world_proof.gd" id="1_lairworld"] + +[node name="LairWorldProof" type="Node2D"] +script = ExtResource("1_lairworld") diff --git a/src/game/engine/scenes/world_map/tile_info_panel.gd b/src/game/engine/scenes/world_map/tile_info_panel.gd index 5efe12e1..10916bf3 100644 --- a/src/game/engine/scenes/world_map/tile_info_panel.gd +++ b/src/game/engine/scenes/world_map/tile_info_panel.gd @@ -11,6 +11,10 @@ var _current_axial: Vector2i = Vector2i(-9999, -9999) ## p1-58: optional fauna ecology bridge — set by parent scene after init. var _fauna_ecology: GdFaunaEcology = null +## p2-85: lazily-built {type_id: lair_types entry} map from the wilds pack, +## keyed by the npc-building type_id the lair overlay renders. +var _lair_types: Dictionary = {} + @onready var _biome_name: Label = %BiomeName @onready var _move_cost: Label = %MoveCost @onready var _defense_bonus: Label = %DefenseBonus @@ -24,6 +28,8 @@ var _fauna_ecology: GdFaunaEcology = null ## Optional ecology species container. Present only if the scene adds a ## VBoxContainer named EcologySpeciesList — guarded by get_node_or_null. @onready var _ecology_list: VBoxContainer = get_node_or_null("%EcologySpeciesList") +## p2-85: lair POI info line (name, tier, threat, spawns, loot). +@onready var _lair_label: Label = %LairLabel func _ready() -> void: @@ -73,6 +79,7 @@ func show_tile(tile: RefCounted, axial: Vector2i) -> void: _populate_collectibles(biome_id, tile_quality, tile_seed) _populate_worked_yield(tile, terrain_data) _populate_ecology_species(axial) + _populate_lair(axial) _position_label.text = "(%d, %d)" % [axial.x, axial.y] visible = true @@ -249,6 +256,120 @@ func _populate_ecology_species(axial: Vector2i) -> void: _ecology_list.add_child(row) +## p2-85: surface the lair occupying `axial` (if any). Lairs are NPC buildings +## (GameState npc_buildings, type_id == wilds.json lair_types[].id) placed at the +## tile's axial by village_lair_placer.gd. Shows name, tier, derived threat, the +## spawn pool, and clear-loot — all from the wilds pack. Hidden on non-lair tiles. +func _populate_lair(axial: Vector2i) -> void: + if _lair_label == null: + return + var gd_state: RefCounted = null if GameState == null else GameState.get_gd_state() + if gd_state == null: + _lair_label.visible = false + return + + var type_id: String = "" + for b: Dictionary in gd_state.npc_buildings_all(): + var raw_pos: Array = b.get("position", []) as Array + if raw_pos == null or raw_pos.size() < 2: + continue + if int(raw_pos[0]) != axial.x or int(raw_pos[1]) != axial.y: + continue + var tid: String = String(b.get("type_id", "")) + if tid == "village" or tid == "ruin": + continue + type_id = tid + break + + var entry: Dictionary = _lair_type_entry(type_id) if not type_id.is_empty() else {} + var loot_table: Array = [] + if not entry.is_empty(): + var wilds: Dictionary = DataLoader.get_wilds_config() + var creature_loot: Dictionary = wilds.get("creature_loot", {}) + loot_table = (creature_loot.get(type_id, {}) as Dictionary).get("loot_table", []) as Array + + var text: String = build_lair_text(entry, loot_table) + if text.is_empty(): + _lair_label.visible = false + return + _lair_label.text = text + _lair_label.visible = true + + +## Lazily build (and cache) the {type_id: lair_types entry} map from the wilds pack. +func _lair_type_entry(type_id: String) -> Dictionary: + if _lair_types.is_empty(): + var wilds: Dictionary = DataLoader.get_wilds_config() + for e: Variant in wilds.get("lair_types", []): + if e is Dictionary: + var eid: String = String(e.get("id", "")) + if not eid.is_empty(): + _lair_types[eid] = e + return _lair_types.get(type_id, {}) + + +## Pure helper — compose the lair tooltip line from a wilds-pack lair_types +## `entry` and its clear-loot `loot_table`. Returns "" for an empty entry. +## Tested headless without a scene tree (see test_tile_tooltip.gd). +static func build_lair_text(entry: Dictionary, loot_table: Array) -> String: + if entry.is_empty(): + return "" + var type_id: String = String(entry.get("id", "")) + var lair_name: String = String(entry.get("name", ThemeVocabulary.lookup(type_id))) + var tier: int = int(entry.get("base_tier", 1)) + var parts: PackedStringArray = PackedStringArray() + parts.append("%s — %s %d" % [lair_name, ThemeVocabulary.lookup("tier", "Tier"), tier]) + parts.append("%s: %s" % [ThemeVocabulary.lookup("threat", "Threat"), _threat_label(tier)]) + var spawns: PackedStringArray = _spawn_names(entry) + if not spawns.is_empty(): + parts.append("%s: %s" % [ThemeVocabulary.lookup("spawns", "Spawns"), ", ".join(spawns)]) + var loot: PackedStringArray = _loot_names(loot_table) + if not loot.is_empty(): + parts.append("%s: %s" % [ThemeVocabulary.lookup("loot", "Loot"), ", ".join(loot)]) + return " · ".join(parts) + + +## Map a lair's base tier (1–10) to a player-facing threat band. +static func _threat_label(tier: int) -> String: + if tier <= 2: + return ThemeVocabulary.lookup("threat_low", "Low") + if tier <= 4: + return ThemeVocabulary.lookup("threat_moderate", "Moderate") + if tier <= 7: + return ThemeVocabulary.lookup("threat_high", "High") + return ThemeVocabulary.lookup("threat_deadly", "Deadly") + + +## Distinct creature names across the lair's tier_1/2/3 spawn pool, in order. +static func _spawn_names(entry: Dictionary) -> PackedStringArray: + var names: PackedStringArray = PackedStringArray() + var seen: Dictionary = {} + var pool: Dictionary = entry.get("spawn_pool", {}) + for key: String in ["tier_1", "tier_2", "tier_3"]: + for sp: Variant in pool.get(key, []): + var sid: String = String(sp) + if sid.is_empty() or seen.has(sid): + continue + seen[sid] = true + names.append(ThemeVocabulary.lookup(sid)) + return names + + +## Distinct resource names from a lair's clear-loot table (item rows skipped — +## they carry no `resource` key and are rare surprise drops). +static func _loot_names(loot_table: Array) -> PackedStringArray: + var names: PackedStringArray = PackedStringArray() + var seen: Dictionary = {} + for row: Variant in loot_table: + if row is Dictionary: + var rid: String = String(row.get("resource", "")) + if rid.is_empty() or seen.has(rid): + continue + seen[rid] = true + names.append(ThemeVocabulary.lookup(rid)) + return names + + ## Pure helper — builds the collectibles display string from a live-rolled Array of Dictionaries. ## Each dict has keys: resource_id (String), quantity (int), quality (int). ## Used by GUT tests without needing a scene tree. diff --git a/src/game/engine/scenes/world_map/tile_info_panel.tscn b/src/game/engine/scenes/world_map/tile_info_panel.tscn index 03947fc5..f5ec4586 100644 --- a/src/game/engine/scenes/world_map/tile_info_panel.tscn +++ b/src/game/engine/scenes/world_map/tile_info_panel.tscn @@ -94,3 +94,14 @@ layout_mode = 2 theme_override_font_sizes/font_size = 12 theme_override_colors/font_color = Color(0.6, 0.78, 0.9, 1) visible = false + +[node name="Row3" type="HBoxContainer" parent="MarginContainer/VBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 20 + +[node name="LairLabel" type="Label" parent="MarginContainer/VBoxContainer/Row3"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 12 +theme_override_colors/font_color = Color(0.92, 0.46, 0.4, 1) +visible = false diff --git a/src/game/engine/scenes/world_map/world_map.gd b/src/game/engine/scenes/world_map/world_map.gd index 73b2e966..1768a0b7 100644 --- a/src/game/engine/scenes/world_map/world_map.gd +++ b/src/game/engine/scenes/world_map/world_map.gd @@ -399,6 +399,26 @@ func _read_spawn_box_radius_from_setup() -> int: return 3 +## p0-34: wanderer counts are JSON-driven (Rail-2). Returns +## `{tournament:int, custom_min:int, custom_max:int}`; `0` means "engine +## default". `spawn_box_wanderer_count.custom` is an inclusive `[min, max]` +## pair (tournament is a single int). +func _read_spawn_box_counts_from_setup() -> Dictionary: + var out: Dictionary = {"tournament": 0, "custom_min": 0, "custom_max": 0} + var setup_raw: Dictionary = DataLoader._raw.get("setup", {}) as Dictionary + if not setup_raw.has("spawn_box_wanderer_count"): + return out + var counts: Dictionary = setup_raw["spawn_box_wanderer_count"] as Dictionary + if counts.has("tournament"): + out["tournament"] = int(counts["tournament"]) + if counts.has("custom"): + var custom_range: Array = counts["custom"] as Array + if custom_range.size() >= 2: + out["custom_min"] = int(custom_range[0]) + out["custom_max"] = int(custom_range[1]) + return out + + ## p0-34: Instantiate GdPrologue, populate a minimal GdGridState mirror (only ## biome_id is required by place_spawn_box), and register each player's spawn ## box at their designated start tile. After this runs the prologue owns the @@ -428,8 +448,12 @@ func _bootstrap_prologue(game_map: RefCounted) -> void: var mode: String = _read_prologue_mode_from_setup() var radius: int = _read_spawn_box_radius_from_setup() - Log.info("_bootstrap_prologue: mode=%s radius=%d players=%d" % - [mode, radius, GameState.players.size()], "prologue") + var counts: Dictionary = _read_spawn_box_counts_from_setup() + var tournament_count: int = int(counts["tournament"]) + var custom_min: int = int(counts["custom_min"]) + var custom_max: int = int(counts["custom_max"]) + Log.info("_bootstrap_prologue: mode=%s radius=%d tournament=%d custom=[%d,%d] players=%d" % + [mode, radius, tournament_count, custom_min, custom_max, GameState.players.size()], "prologue") var registered_count: int = 0 for p: Variant in GameState.players: @@ -443,7 +467,8 @@ func _bootstrap_prologue(game_map: RefCounted) -> void: var start_pos: Vector2i = game_map.start_positions[pid] # game_map.start_positions is stored in axial; GdPrologue expects axial. if (driver as PrologueDriverScript).register_player( - pid, start_pos.x, start_pos.y, mode, radius, grid + pid, start_pos.x, start_pos.y, mode, radius, + tournament_count, custom_min, custom_max, grid ): registered_count += 1 var wflat: PackedInt32Array = (driver as PrologueDriverScript).wanderers_for(pid) diff --git a/src/game/engine/tests/unit/test_tile_tooltip.gd b/src/game/engine/tests/unit/test_tile_tooltip.gd index 5cf209f3..c9955013 100644 --- a/src/game/engine/tests/unit/test_tile_tooltip.gd +++ b/src/game/engine/tests/unit/test_tile_tooltip.gd @@ -113,3 +113,70 @@ func test_show_tile_same_axial_is_noop() -> void: func test_hover_interval_constant_is_20hz() -> void: var interval: float = WorldMapHoverScript.HOVER_INTERVAL_SEC assert_almost_eq(interval, 0.05, 0.001, "HOVER_INTERVAL_SEC must be 0.05 s (20 Hz)") + + +# --------------------------------------------------------------------------- +# p2-85: lair tooltip line composition (pure static helper, no scene tree). +# --------------------------------------------------------------------------- + + +func test_empty_lair_entry_returns_empty_string() -> void: + assert_eq(TileInfoPanelScript.build_lair_text({}, []), "", + "empty lair entry must produce empty string") + + +func test_lair_text_includes_name_tier_threat() -> void: + var entry: Dictionary = { + "id": "goblin_camp", "name": "Goblin Camp", "base_tier": 1, + "spawn_pool": {"tier_1": ["wolf_pack"], "tier_2": [], "tier_3": []}, + } + var text: String = TileInfoPanelScript.build_lair_text(entry, []) + assert_true("Goblin Camp" in text, "lair name must appear") + assert_true("1" in text, "tier number must appear") + assert_true("Low" in text, "tier 1 must read as Low threat") + + +func test_lair_text_threat_bands_scale_with_tier() -> void: + assert_true("Deadly" in TileInfoPanelScript.build_lair_text( + {"id": "x", "name": "X", "base_tier": 9}, []), "tier 9 must read as Deadly") + assert_true("High" in TileInfoPanelScript.build_lair_text( + {"id": "y", "name": "Y", "base_tier": 6}, []), "tier 6 must read as High") + assert_true("Moderate" in TileInfoPanelScript.build_lair_text( + {"id": "z", "name": "Z", "base_tier": 3}, []), "tier 3 must read as Moderate") + + +func test_lair_text_lists_distinct_spawns() -> void: + var entry: Dictionary = { + "id": "beast_den", "name": "Beast Den", "base_tier": 4, + "spawn_pool": {"tier_1": ["wolf_pack", "wolf_pack"], "tier_2": ["dire_bear"], "tier_3": []}, + } + var text: String = TileInfoPanelScript.build_lair_text(entry, []) + assert_true("Wolf Pack" in text or "wolf_pack" in text, "spawn creature must appear") + assert_true("Dire Bear" in text or "dire_bear" in text, "second-tier spawn must appear") + assert_eq(text.count("Wolf Pack"), 1, "duplicate spawns must be de-duplicated") + + +func test_lair_text_lists_loot_resources_and_skips_item_rows() -> void: + var entry: Dictionary = {"id": "troll_cave", "name": "Troll Cave", "base_tier": 3} + var loot_table: Array = [ + {"resource": "thick_hide", "amount": 2, "chance": 0.7}, + {"type": "item", "item": "golem_core", "chance": 0.05}, + {"resource": "bone", "amount": 2, "chance": 0.5}, + ] + var text: String = TileInfoPanelScript.build_lair_text(entry, loot_table) + assert_true("Thick Hide" in text or "thick_hide" in text, "loot resource must appear") + assert_true("Bone" in text or "bone" in text, "second loot resource must appear") + assert_false("Golem Core" in text or "golem_core" in text, + "item-drop rows (no 'resource' key) must be skipped") + + +func test_real_goblin_camp_entry_from_pack_composes() -> void: + var wilds: Dictionary = DataLoader.get_wilds_config() + var entry: Dictionary = {} + for e: Variant in wilds.get("lair_types", []): + if e is Dictionary and String(e.get("id", "")) == "goblin_camp": + entry = e + break + assert_false(entry.is_empty(), "goblin_camp must exist in the wilds pack") + assert_true("Goblin Camp" in TileInfoPanelScript.build_lair_text(entry, []), + "real goblin_camp pack entry must compose its display name") diff --git a/tools/standin-sprites/build_demo_lairs.py b/tools/standin-sprites/build_demo_lairs.py new file mode 100644 index 00000000..c4ea9679 --- /dev/null +++ b/tools/standin-sprites/build_demo_lairs.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Build the **DEMO-ONLY** Battle-for-Wesnoth lair POI overlays — composited. + +Mirrors the existing demo-art layer (DEMO_SPRITES_LICENSES.md, commit 55c01e339): +Wesnoth scenery/monster sprites overwrite the commercial-safe game-icons lair +baseline at `sprites/lairs/.png` so the playable demo reads consistently. The +CC-BY game-icons baseline stays regenerable via `build_standins.py --only lairs`. + +Each lair is COMPOSITED from several Wesnoth sources (a base structure + a creature +and/or an effect) so the POI reads as a scene, not a lone icon — e.g. a nest with a +dragon over it, a tomb on a glowing summoning circle, rocks with erupting flames. +Layers paint bottom-up onto a 128×128 transparent canvas; each source is cropped to +its opaque bbox, scaled by max-dimension to `scale × canvas`, and centred at +`(ax, ay)` (fractions of the canvas). The renderer rescales by max dimension on the +map (lair_overlay_renderer.gd: 0.45 × hex width). + +⚠️ COPYLEFT — NOT FOR COMMERCIAL SHIP. Wesnoth art is GPL-2.0-or-later OR +CC-BY-SA 4.0 (older monster/scenery sprites commonly GPL-only). Replace before any +sale build (tracked by p2-22 … p2-27). Per-source provenance + sha256 logged below. + +Usage: tools/standin-sprites/.venv/bin/python tools/standin-sprites/build_demo_lairs.py [--no-net] +""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import re +import sys +import urllib.request +from datetime import date +from pathlib import Path + +from PIL import Image + +TOOL_DIR = Path(__file__).resolve().parent +REPO_ROOT = TOOL_DIR.parent.parent +ASSETS_ROOT = REPO_ROOT / "public/games/age-of-dwarves/assets" +LAIRS_DIR = ASSETS_ROOT / "sprites/lairs" +DEMO_LEDGER = ASSETS_ROOT / "sprites/DEMO_SPRITES_LICENSES.md" +SRC_CACHE = TOOL_DIR / ".cache" / "wesnoth" +RAW_BASE = "https://raw.githubusercontent.com/wesnoth/wesnoth/master/data/core/images" +CANVAS = 128 +SECTION_MARK = "## Lair POI overlays (Wesnoth demo)" + +# lair type_id -> ordered layers (painted bottom-up). Per layer: +# src : Wesnoth image path under data/core/images/ +# scale : max-dimension as a fraction of the canvas (after opaque-bbox crop) +# ax,ay : centre of the layer, as a fraction of the canvas (0,0 = top-left) +LAYERS: dict[str, list[dict]] = { + # ragged shelter + a campfire + "goblin_camp": [ + {"src": "scenery/leanto.png", "scale": 0.80, "ax": 0.46, "ay": 0.56}, + {"src": "scenery/fire4.png", "scale": 0.30, "ax": 0.75, "ay": 0.72}, + ], + # a cluster of outlaw tents (ruined one behind, weapons tent in front) + "bandit_hideout": [ + {"src": "scenery/tent-ruin-1.png", "scale": 0.55, "ax": 0.30, "ay": 0.50}, + {"src": "scenery/tent-shop-weapons.png", "scale": 0.78, "ax": 0.58, "ay": 0.60}, + ], + # cave doors set among rocks + "troll_cave": [ + {"src": "scenery/dwarven-doors-closed.png", "scale": 0.86, "ax": 0.52, "ay": 0.52}, + {"src": "scenery/rock2.png", "scale": 0.34, "ax": 0.26, "ay": 0.76}, + ], + # a rocky den with a direwolf guarding it (wolf dominant, rocks behind) + "beast_den": [ + {"src": "scenery/rock-cairn.png", "scale": 0.60, "ax": 0.58, "ay": 0.42}, + {"src": "units/monsters/direwolf.png", "scale": 0.80, "ax": 0.46, "ay": 0.60}, + ], + # a tomb standing on a glowing summoning circle + "corrupted_hollow": [ + {"src": "scenery/summoning-circle1.png", "scale": 0.92, "ax": 0.50, "ay": 0.72}, + {"src": "scenery/mausoleum01.png", "scale": 0.72, "ax": 0.50, "ay": 0.44}, + ], + # rocks with fire erupting out of the fissure + "volcanic_fissure": [ + {"src": "scenery/rock1.png", "scale": 0.60, "ax": 0.50, "ay": 0.66}, + {"src": "scenery/flames08.png", "scale": 0.72, "ax": 0.50, "ay": 0.44}, + ], + # a cracked ancient temple (dominant) with a standing monolith beside it + "ancient_construct_site": [ + {"src": "scenery/monolith3.png", "scale": 0.50, "ax": 0.20, "ay": 0.50}, + {"src": "scenery/temple-cracked1.png", "scale": 0.96, "ax": 0.56, "ay": 0.54}, + ], + # a dragon perched over its nest + "wyvern_nest": [ + {"src": "scenery/nest-full.png", "scale": 0.78, "ax": 0.50, "ay": 0.72}, + {"src": "units/monsters/fire-dragon.png", "scale": 0.64, "ax": 0.50, "ay": 0.40}, + ], +} + + +def fetch(src_rel: str, allow_net: bool) -> bytes: + cache = SRC_CACHE / src_rel + if cache.exists(): + return cache.read_bytes() + if not allow_net: + raise FileNotFoundError(f"not cached and --no-net: {src_rel}") + with urllib.request.urlopen(f"{RAW_BASE}/{src_rel}", timeout=20) as resp: + data = resp.read() + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_bytes(data) + return data + + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def compose(layers: list[dict], allow_net: bool) -> tuple[Image.Image, list[tuple[str, str]]]: + """Paint `layers` bottom-up onto a transparent CANVAS square. Returns the image + and the (source_path, source_sha256) list for the ledger.""" + canvas = Image.new("RGBA", (CANVAS, CANVAS), (0, 0, 0, 0)) + sources: list[tuple[str, str]] = [] + for layer in layers: + raw = fetch(layer["src"], allow_net) + sources.append((layer["src"], sha256(raw))) + im = Image.open(io.BytesIO(raw)).convert("RGBA") + bbox = im.split()[3].getbbox() # opaque-region bounds (straight-alpha sprites) + if bbox is not None: + im = im.crop(bbox) + target = max(1, round(CANVAS * layer["scale"])) + s = target / max(im.width, im.height) + w, h = max(1, round(im.width * s)), max(1, round(im.height * s)) + im = im.resize((w, h), Image.LANCZOS) + cx, cy = layer["ax"] * CANVAS, layer["ay"] * CANVAS + canvas.alpha_composite(im, (round(cx - w / 2), round(cy - h / 2))) + return canvas, sources + + +def write_ledger_section(rows: list[str]) -> None: + """Insert/replace the lair-overlay provenance section in the demo ledger, + leaving all other content untouched.""" + header = ( + f"\n{SECTION_MARK}\n\n" + "Each lair POI is **composited** from the Wesnoth sources listed below " + "(base structure + creature/effect) onto a 128×128 transparent PNG, " + "overwriting the game-icons lair baseline. Source license = GNU " + "GPL-2.0-or-later OR CC-BY-SA 4.0 (Battle for Wesnoth art team, " + "collective). **COPYLEFT — demo-only.** Baseline regenerable via " + "`build_standins.py --only lairs`; rebuild via `build_demo_lairs.py`.\n\n" + "| output_path (lairs/) | wesnoth source layer | source_sha256 | out_sha256 | added |\n" + "|---|---|---|---|---|\n" + + "\n".join(rows) + "\n" + ) + text = DEMO_LEDGER.read_text() + pat = re.compile(rf"\n{re.escape(SECTION_MARK)}\n.*?(?=\n## |\Z)", re.DOTALL) + DEMO_LEDGER.write_text(pat.sub(header.rstrip() + "\n", text) if pat.search(text) + else text.rstrip() + "\n" + header) + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--no-net", action="store_true", help="build from cache only") + args = ap.parse_args(argv) + allow_net = not args.no_net + today = date.today().isoformat() + + LAIRS_DIR.mkdir(parents=True, exist_ok=True) + rows: list[str] = [] + for lair_id, layers in sorted(LAYERS.items()): + img, sources = compose(layers, allow_net) + out = LAIRS_DIR / f"{lair_id}.png" + img.save(out, "PNG") + out_sha = sha256(out.read_bytes()) + for i, (src_rel, src_sha) in enumerate(sources): + rows.append( + f"| lairs/{lair_id}.png | {src_rel} | `{src_sha}` | " + f"{'`' + out_sha + '`' if i == 0 else '↑'} | {today} |") + print(f" {lair_id:24} <- {' + '.join(s for s, _ in sources)}") + + write_ledger_section(rows) + print(f"Wrote {len(LAYERS)} composited Wesnoth demo lair overlays -> {LAIRS_DIR}") + print(f"Updated demo ledger: {DEMO_LEDGER}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/standin-sprites/lair_poi_proof.py b/tools/standin-sprites/lair_poi_proof.py new file mode 100644 index 00000000..c523c5ba --- /dev/null +++ b/tools/standin-sprites/lair_poi_proof.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Render a legibility proof for the lair POI stand-in sprites. + +Composites each `sprites/lairs/.png` onto a flat-top tan terrain hex at +the exact on-map POI scale the renderer uses — `HEX_WIDTH (384) * 0.45 ≈ 173 px` +wide, centred (lair_overlay_renderer.gd: LAIR_SPRITE_TILE_FRACTION = 0.45) — with +the lair display name above, mirroring `_draw_tier_label`. This is a host-safe +substitute for an in-engine capture: it proves the art reads on tan terrain at +default zoom WITHOUT importing the new PNGs through Godot (image-asset reimport +is the operation flagged unsafe on the laptop). + +Usage: tools/standin-sprites/.venv/bin/python tools/standin-sprites/lair_poi_proof.py +Output: tools/standin-sprites/lair_poi_proof.png +""" + +from __future__ import annotations + +import json +import math +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +TOOL_DIR = Path(__file__).resolve().parent +REPO_ROOT = TOOL_DIR.parent.parent +LAIRS_DIR = REPO_ROOT / "public/games/age-of-dwarves/assets/sprites/lairs" +WILDS_JSON = REPO_ROOT / "public/resources/wilds/wilds.json" +OUT = TOOL_DIR / "lair_poi_proof.png" + +# Engine geometry (hex_utils.gd) — scaled down by CELL_SCALE for a compact sheet. +HEX_WIDTH, HEX_HEIGHT = 384.0, 332.0 +POI_FRACTION = 0.45 +CELL_SCALE = 0.42 +COLS = 4 + +# A few terrain tints lairs sit on, to show contrast across biomes. +TAN = (206, 184, 138) # plains / grassland — the low-contrast worst case +TERRAIN_TINTS = { + "grassland": (150, 178, 104), + "plains": TAN, + "hills": (188, 168, 126), + "mountains": (170, 162, 152), + "swamp": (120, 132, 96), + "volcano": (132, 110, 102), + "desert": (222, 200, 150), + "tundra": (200, 204, 200), + "forest": (104, 140, 92), + "enchanted_forest": (120, 150, 120), +} + + +def find_lair_types() -> list[dict]: + def walk(obj: object) -> list | None: + if isinstance(obj, dict): + if isinstance(obj.get("lair_types"), list): + return obj["lair_types"] + for v in obj.values(): + hit = walk(v) + if hit is not None: + return hit + elif isinstance(obj, list): + for v in obj: + hit = walk(v) + if hit is not None: + return hit + return None + + return walk(json.loads(WILDS_JSON.read_text())) or [] + + +def hex_polygon(cx: float, cy: float, w: float, h: float) -> list[tuple[float, float]]: + """Flat-top hexagon points (side length = half width).""" + r = w / 2.0 + pts = [] + for i in range(6): + ang = math.radians(60 * i) + pts.append((cx + r * math.cos(ang), cy + r * (h / w) * math.sin(ang))) + return pts + + +def font(size: int) -> ImageFont.FreeTypeFont: + for path in ("/System/Library/Fonts/Supplemental/Arial Bold.ttf", + "/System/Library/Fonts/Helvetica.ttc"): + if Path(path).exists(): + return ImageFont.truetype(path, size) + return ImageFont.load_default() + + +def main() -> int: + lairs = find_lair_types() + cw, ch = HEX_WIDTH * CELL_SCALE, HEX_HEIGHT * CELL_SCALE + pad = 18 + rows = math.ceil(len(lairs) / COLS) + W = int(COLS * cw + (COLS + 1) * pad) + H = int(rows * ch + (rows + 1) * pad + 30) + sheet = Image.new("RGBA", (W, H), (28, 26, 24, 255)) + d = ImageDraw.Draw(sheet) + d.text((pad, 8), "Lair POI stand-ins — on-terrain at engine POI scale (0.45 × hex width)", + font=font(15), fill=(230, 222, 200, 255)) + + label_font = font(13) + for i, lair in enumerate(lairs): + lid = lair["id"] + name = lair.get("name", lid) + terrains = lair.get("preferred_terrains") or ["plains"] + tint = TERRAIN_TINTS.get(terrains[0], TAN) + + col, row = i % COLS, i // COLS + x0 = pad + col * (cw + pad) + y0 = pad + 30 + row * (ch + pad) + cx, cy = x0 + cw / 2, y0 + ch / 2 + + d.polygon(hex_polygon(cx, cy, cw, ch), fill=(*tint, 255), + outline=(60, 54, 44, 255)) + + sprite_path = LAIRS_DIR / f"{lid}.png" + if sprite_path.exists(): + spr = Image.open(sprite_path).convert("RGBA") + target_w = int(HEX_WIDTH * POI_FRACTION * CELL_SCALE) + scaled = spr.resize((target_w, int(target_w * spr.height / spr.width)), + Image.LANCZOS) + sheet.alpha_composite(scaled, (int(cx - scaled.width / 2), + int(cy - scaled.height / 2))) + + # Name label above the marker (mirrors _draw_tier_label shadow + white). + tw = d.textlength(name, font=label_font) + lx, ly = cx - tw / 2, y0 + 2 + d.text((lx + 1, ly + 1), name, font=label_font, fill=(0, 0, 0, 200)) + d.text((lx, ly), name, font=label_font, fill=(255, 255, 255, 255)) + + sheet.convert("RGB").save(OUT, "PNG") + print(f"Wrote {OUT} ({len(lairs)} lairs, {W}×{H})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())