Move screen designs to the runtime root and add the UI art loader

- screen_designs.py now reads/writes assets/images/ui/ (ADR-0014); docs/
  screens/designs/ stays the reference-only snapshot, documented as historical
- add src/game/ui/art.gd: cached loader for assets/images/ui|scenes with null
  fallbacks for missing files, plus headless tests
- start menu uses the start_menu scene art as a full-screen backdrop, covers the
  baked-in mockup panel with an opaque panel, and styles the venue rows to match
- update README/architecture/TODO path notes and mark the storage/code-path/loader
  items done; DONE records the historical path move
- compare still 11 good / 0 bad; headless 226/226

README/PLAN realism-pass edits from another workstream remain unstaged." && git log --oneline -1
This commit is contained in:
zwitschi committed 2026-11-01 16:00:12 +01:00
1 parent 15deb58b73
commit 4745adeb13
6 files changed
+177 -17

No files matched your search

+7
View File
@@ -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/`.
+8 -8
View File
@@ -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
+53
View File
@@ -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 `<name>.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()
+65 -5
View File
@@ -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
+38
View File
@@ -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
+6 -4
View File
@@ -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)")