feat(@projects/@magic-civilization): ✨ lair POI sprites + tile tooltips (p2-85)
world_map lair POI overlay, tile_info_panel tooltip wiring, lair standin sprites + build_demo_lairs.py, tooltip unit test, lair proof scenes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
@ -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/<type_id>.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/<type_id>.png` (game-icons CC-BY-3.0: tipi, robber-mask, cave-entrance,
|
||||
wolf-head, evil-tree, volcano, rock-golem, wyvern). A new `--only <cats>` 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/<id>.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/<id>.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.
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 21 KiB |
BIN
public/games/age-of-dwarves/assets/sprites/lairs/beast_den.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
BIN
public/games/age-of-dwarves/assets/sprites/lairs/goblin_camp.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/games/age-of-dwarves/assets/sprites/lairs/troll_cave.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 17 KiB |
BIN
public/games/age-of-dwarves/assets/sprites/lairs/wyvern_nest.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
96
src/game/engine/scenes/tests/lair_poi_proof.gd
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
extends Node2D
|
||||
## In-engine proof for the lair POI sprites (p2-85). Loads each `lairs/<id>.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()
|
||||
6
src/game/engine/scenes/tests/lair_poi_proof.tscn
Normal file
|
|
@ -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")
|
||||
136
src/game/engine/scenes/tests/lair_world_proof.gd
Normal file
|
|
@ -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()
|
||||
6
src/game/engine/scenes/tests/lair_world_proof.tscn
Normal file
|
|
@ -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")
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
181
tools/standin-sprites/build_demo_lairs.py
Normal file
|
|
@ -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/<id>.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())
|
||||
137
tools/standin-sprites/lair_poi_proof.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Render a legibility proof for the lair POI stand-in sprites.
|
||||
|
||||
Composites each `sprites/lairs/<type_id>.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())
|
||||