diff --git a/DONE.md b/DONE.md index d7124b0..6bc584d 100644 --- a/DONE.md +++ b/DONE.md @@ -887,3 +887,10 @@ artifacts (Linux, Windows, macOS, NSIS installer, AppImage, Web) for tag `v0.6.0 - [x] Bumped the version and synchronised README/architecture/blender-assets/plan docs. - [x] Headless **222/222**, Python **69/69**, all smokes, `compare` **11 good / 0 bad**. - [x] Release verified: `https://git.allucanget.biz/zwitschi/security-game/releases/tag/v0.6.0`. + +## Runtime Image Path Migration + +Completed 2026-11-01. Historical note: the screen-design references originally lived only in +`docs/screens/designs/`; they were promoted to the runtime root `assets/images/ui/` under +ADR-0014. `docs/screens/designs/` remains a reference-only snapshot; `tools/screen_designs.py` +now reads and writes `assets/images/ui/`. diff --git a/TODO.md b/TODO.md index e0d1850..722c954 100644 --- a/TODO.md +++ b/TODO.md @@ -32,20 +32,20 @@ and scene visuals with those mockups — verified with ### Documentation path updates -- [ ] Update `README.md` paths (screen-designs section, confirmed-assets links, the `assets/images/backgrounds` resize example). -- [ ] Update `docs/architecture.md`, `PLAN.md`, and ADR-0010/0011/0012/0013 references to `docs/screens/designs/`. -- [ ] Update `tools/screen_designs.py` docstrings and mark the old paths in `DONE.md` as historical. +- [x] Update `README.md` paths (screen-designs section, confirmed-assets links, the `assets/images/scenes` resize example). +- [x] Update `docs/architecture.md`, `PLAN.md`, and ADR references: docs/screens/designs/ is now explicitly the reference-only snapshot; runtime art lives in assets/images/ (ADR-0014). +- [x] Update `tools/screen_designs.py` docstrings and mark the old paths in `DONE.md` as historical. ### Code path updates -- [ ] Point `tools/screen_designs.py generate` at the new design output path and keep `compare`/`judge` reading the right locations. -- [ ] Update GDScript `preload()` paths (`materials.gd`, `hud.gd`, `world_builder.gd`) if the game textures move. -- [ ] Update `capture_screenshots.gd` / `capture_prop_gallery.gd` output directories if captures relocate. +- [x] Point `tools/screen_designs.py generate` at the new design output path and keep `compare`/`judge` reading the right locations. +- [x] Update GDScript `preload()` paths if game textures move (no runtime material/model textures moved; UI art added under assets/images/). +- [x] Update capture output directories if captures relocate (none did; QA captures stay in docs/screenshots/). ### Load proposed interfaces + scene art from disk -- [ ] Add a central UI/scene art loader (e.g. `src/game/ui/art.gd`) that loads the mockup-derived images from the new runtime root, with a procedural fallback when a file is missing. -- [ ] Wire the start menu and venue selection screen to use the `start_menu` scene art. +- [x] Add a central UI/scene art loader (e.g. `src/game/ui/art.gd`) that loads the mockup-derived images from the new runtime root, with a procedural fallback when a file is missing. +- [x] Wire the start menu and venue selection screen to use the `start_menu` scene art. - [ ] Wire the pause overlay, settings, shift review, radial menu, and HUD chrome to their reference images. ### Align in-game visuals diff --git a/src/game/ui/art.gd b/src/game/ui/art.gd new file mode 100644 index 0000000..dcce819 --- /dev/null +++ b/src/game/ui/art.gd @@ -0,0 +1,53 @@ +class_name UiArt +extends RefCounted +## Central loader for runtime interface/scene art (ADR-0014). +## +## Art lives under `assets/images/ui/` (composed screens) and +## `assets/images/scenes/` (full-frame scene art). Missing files are a normal +## state: callers get null and fall back to their procedural UI instead of +## failing to load a screen. + +const ROOT := "res://assets/images/" +const DIRECTORIES := ["ui", "scenes"] + +var _cache := {} + +## Load a named PNG, trying `ui/` first and then `scenes/`. Returns null when +## the image is absent or not imported yet. +func texture(art_name: String) -> Texture2D: + if art_name.strip_edges() == "": + return null + if _cache.has(art_name): + return _cache[art_name] + for directory in DIRECTORIES: + var path := ROOT + str(directory) + "/" + art_name + ".png" + if not ResourceLoader.exists(path): + continue + var loaded := ResourceLoader.load(path) + if loaded is Texture2D: + _cache[art_name] = loaded + return loaded + _cache[art_name] = null + return null + +func has(art_name: String) -> bool: + return texture(art_name) != null + +## Load an explicit relative path under `assets/images/`, for art whose screen +## name does not follow the simple `.png` convention. +func texture_at(relative_path: String) -> Texture2D: + if relative_path.strip_edges() == "": + return null + var path := ROOT + relative_path.trim_prefix("/") + if _cache.has(path): + return _cache[path] + if not ResourceLoader.exists(path): + _cache[path] = null + return null + var loaded := ResourceLoader.load(path) + var result := loaded as Texture2D + _cache[path] = result + return result + +func clear_cache() -> void: + _cache.clear() diff --git a/src/game/ui/start_menu.gd b/src/game/ui/start_menu.gd index 7788219..07604d2 100644 --- a/src/game/ui/start_menu.gd +++ b/src/game/ui/start_menu.gd @@ -8,6 +8,8 @@ signal venue_selected(scenario_id: String) signal settings_requested() signal quit_requested() +const UiArt := preload("res://src/game/ui/art.gd") + const VENUES := [ {"id": "hollywood_building", "label": "Hollywood Boulevard", "blurb": "Building entrance, tourists and late-night trouble."}, {"id": "joke_shop", "label": "The Joke Shop", "blurb": "Guest list, IDs and wand screening."}, @@ -30,11 +32,39 @@ func _build() -> void: _root.mouse_filter = Control.MOUSE_FILTER_STOP add_child(_root) - var bg := ColorRect.new() - bg.color = Color(0.03, 0.04, 0.07, 0.96) - bg.set_anchors_preset(Control.PRESET_FULL_RECT) - bg.mouse_filter = Control.MOUSE_FILTER_IGNORE - _root.add_child(bg) + var art := UiArt.new() + var backdrop_texture := art.texture("start_menu") + if backdrop_texture != null: + var backdrop := TextureRect.new() + backdrop.name = "MenuArt" + backdrop.texture = backdrop_texture + backdrop.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + backdrop.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + backdrop.set_anchors_preset(Control.PRESET_FULL_RECT) + backdrop.mouse_filter = Control.MOUSE_FILTER_IGNORE + _root.add_child(backdrop) + # The mockup includes its own title/buttons; cover that panel with an + # opaque rounded panel and keep the real controls on top. + var panel := ColorRect.new() + panel.name = "MenuPanel" + panel.color = Color(0.075, 0.08, 0.095, 1.0) + panel.set_anchor(SIDE_LEFT, 0.148) + panel.set_anchor(SIDE_TOP, 0.061) + panel.set_anchor(SIDE_RIGHT, 0.781) + panel.set_anchor(SIDE_BOTTOM, 0.921) + panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + _root.add_child(panel) + var dim := ColorRect.new() + dim.color = Color(0.02, 0.03, 0.05, 0.18) + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + dim.mouse_filter = Control.MOUSE_FILTER_IGNORE + _root.add_child(dim) + else: + var bg := ColorRect.new() + bg.color = Color(0.03, 0.04, 0.07, 0.96) + bg.set_anchors_preset(Control.PRESET_FULL_RECT) + bg.mouse_filter = Control.MOUSE_FILTER_IGNORE + _root.add_child(bg) var center := CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) @@ -66,6 +96,7 @@ func _build() -> void: button.alignment = HORIZONTAL_ALIGNMENT_LEFT button.custom_minimum_size = Vector2(460, 62) button.pressed.connect(_on_venue_pressed.bind(i)) + _style_venue_button(button) column.add_child(button) _buttons.append(button) @@ -98,6 +129,35 @@ func _build() -> void: _focus_index(0) +## Match the dark, rounded rows in the `start_menu` mockup. +func _style_venue_button(button: Button) -> void: + button.add_theme_font_size_override("font_size", 18) + button.add_theme_color_override("font_color", Color(0.94, 0.94, 0.92)) + button.add_theme_color_override("font_hover_color", Color.WHITE) + var normal := StyleBoxFlat.new() + normal.bg_color = Color(0.115, 0.125, 0.15, 0.96) + normal.corner_radius_top_left = 12 + normal.corner_radius_top_right = 12 + normal.corner_radius_bottom_left = 12 + normal.corner_radius_bottom_right = 12 + normal.content_margin_left = 22 + normal.content_margin_right = 22 + normal.content_margin_top = 10 + normal.content_margin_bottom = 10 + var hover := normal.duplicate() as StyleBoxFlat + hover.bg_color = Color(0.16, 0.175, 0.21, 0.98) + var focus := normal.duplicate() as StyleBoxFlat + focus.bg_color = Color(0.14, 0.155, 0.19, 0.98) + focus.border_color = Color(0.85, 0.86, 0.88) + focus.border_width_left = 2 + focus.border_width_top = 2 + focus.border_width_right = 2 + focus.border_width_bottom = 2 + button.add_theme_stylebox_override("normal", normal) + button.add_theme_stylebox_override("hover", hover) + button.add_theme_stylebox_override("focus", focus) + button.add_theme_stylebox_override("pressed", hover) + func _focus_index(i: int) -> void: if _buttons.is_empty(): return diff --git a/src/test/tests/test_ui_art.gd b/src/test/tests/test_ui_art.gd new file mode 100644 index 0000000..41275fd --- /dev/null +++ b/src/test/tests/test_ui_art.gd @@ -0,0 +1,38 @@ +extends RefCounted +## Headless tests for the runtime image loader (ADR-0014). + +const UiArt := preload("res://src/game/ui/art.gd") + +func test_loads_runtime_ui_art() -> bool: + var art := UiArt.new() + var start := art.texture("start_menu") + var venue := art.texture("venue_overview") + var a := TestUtils.check(start != null, "start_menu art loads from assets/images/ui") + var b := TestUtils.check(venue != null, "venue_overview art loads from assets/images/ui") + var c := TestUtils.check(art.has("start_menu"), "has() sees the runtime art") + return a and b and c + +func test_missing_art_returns_null() -> bool: + var art := UiArt.new() + var missing := art.texture("does_not_exist") + var empty := art.texture("") + var a := TestUtils.check(missing == null, "missing art returns null for a fallback") + var b := TestUtils.check(empty == null, "empty name returns null") + return a and b + +func test_explicit_relative_path() -> bool: + var art := UiArt.new() + var texture := art.texture_at("ui/start_menu.png") + var missing := art.texture_at("ui/nope.png") + var a := TestUtils.check(texture != null, "texture_at loads an explicit path") + var b := TestUtils.check(missing == null, "texture_at returns null when absent") + return a and b + +func test_cache_returns_the_same_resource() -> bool: + var art := UiArt.new() + var first := art.texture("start_menu") + var second := art.texture("start_menu") + var a := TestUtils.check(first != null and first == second, "textures are cached") + art.clear_cache() + var b := TestUtils.check(art.texture("start_menu") != null, "cache can be cleared") + return a and b diff --git a/tools/screen_designs.py b/tools/screen_designs.py index 18eae9c..ba64058 100644 --- a/tools/screen_designs.py +++ b/tools/screen_designs.py @@ -9,7 +9,7 @@ screenshots against. Two commands: - generate write one reference image per screen to docs/screens/designs/. + generate write one runtime reference image per screen to assets/images/ui/. Two modes: --ai realistic 3D game scenes and in-game screen designs via the OpenRouter image API (default; requires @@ -23,7 +23,7 @@ Two commands: Requires OPENROUTER_API_KEY (falls back to the repo .env). --dry-run build and print the requests without calling the API. - compare compare docs/screenshots/*.png against the reference designs and + compare compare docs/screenshots/*.png against the runtime reference art and flag gross drift. Semantic composition is judged with coarse, Pillow-only signals (aspect ratio, frame size, blank-frame contrast, grayscale MAE, colour-histogram distance, edge density). @@ -53,7 +53,9 @@ from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps, ImageStat import generate_textures ROOT = Path(__file__).resolve().parent.parent -DESIGNS_DIR = ROOT / "docs" / "screens" / "designs" +## Runtime reference art (ADR-0014); docs/screens/designs/ is the reference-only +## snapshot kept for docs and historical comparison. +DESIGNS_DIR = ROOT / "assets" / "images" / "ui" SCREENSHOTS_DIR = ROOT / "docs" / "screenshots" # Reference "design" dimensions — match the capture tool and the README gallery. @@ -1022,7 +1024,7 @@ def build_parser(): commands = parser.add_subparsers(dest="command", required=True) gen = commands.add_parser( - "generate", help="write reference design images to docs/screens/designs/") + "generate", help="write reference design images to assets/images/ui/") mode = gen.add_mutually_exclusive_group() mode.add_argument("--draft", action="store_true", help="draw deterministic wireframes (no API/network)")