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

+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