Add session shell and close world presentation follow-ups
Full build / Import + headless tests (push) Successful in 24s
Full build / Full desktop build + optional release (push) Skipped

Session shell
- Startup venue menu (src/game/ui/start_menu.gd): keyboard + mouse, replaces
  the live-shift boot and V as the player-facing venue path (V stays a debug bind).
- Pause overlay on Esc (src/game/ui/pause_overlay.gd): resume, restart shift,
  settings, quit to menu.
- Settings panel (src/game/ui/settings_panel.gd): mouse look sensitivity and
  HUD font scale, persisted via Save into user://settings.json and loaded on boot.
- Shift review "Run another shift" now returns to the venue menu.
- New smoke: src/test/smoke_session_shell.gd (menu -> shift -> pause -> settings
  -> menu), passes headless and windowed.

World and presentation
- Skyline backdrop restored with a transparent asset and an alpha-scissor
  unshaded material; fixes the opaque black slab from the earlier attempt and
  the unimported texture path.
- Steps and landing are a StaticBody3D with per-tread colliders, so the guard
  and NPCs walk up instead of clipping through.
- Marquee Label3D scaled up (72 -> 160) so STEP LINE reads from the sidewalk.
- Focus torus and name tag enlarged for 2-3 m readability.
- Routine traffic despawns after an idle lifetime (NpcAvatar.ephemeral), so the
  16-NPC cap no longer fills with standers; authored events stay for the shift.

Docs and tests
- PLAN.md test/CI section corrected to the custom headless runner and Gitea Actions.
- README controls table, smoke checklist, test count, and status lists refreshed.
- TODO/DONE/MEMORY updated; completed sections moved to DONE.md.
- Headless suite 77 -> 88 passing (settings round-trip, step colliders, skyline
  alpha, session-shell UI behavior, traffic lifetime).
This commit is contained in:
zwitschi committed 2026-10-31 20:19:22 +01:00
1 parent f11b441c07
commit be429cab29
20 files changed
+989 -90

No files matched your search

+12
View File
@@ -15,12 +15,23 @@ var state := 0
var state_frames := 0
var stage_npc: Node3D = null
var failures: Array[String] = []
var _shift_started := false
func _initialize() -> void:
root.size = Vector2i(1280, 720)
var packed: PackedScene = load("res://scenes/main.tscn")
root.add_child(packed.instantiate())
## The game now boots into the venue menu, so the smoke starts a shift first.
func _start_shift_if_needed() -> void:
if _shift_started or gm == null:
return
var menu = gm.get("start_menu")
if menu != null and menu.visible:
gm.call("_on_venue_selected", "hollywood_building")
_shift_started = true
print("smoke: started hollywood_building shift from the menu")
func _process(_delta: float) -> bool:
frames += 1
if gm == null:
@@ -39,6 +50,7 @@ func _advance() -> void:
state_frames += 1
match state:
0:
_start_shift_if_needed()
# Wait for the guard to settle on the ground, then grab one NPC.
if state_frames >= 40 and gm.npcs.size() > 0:
print("smoke: spawn guard_y=%.2f z=%.2f" % [
+63
View File
@@ -0,0 +1,63 @@
extends SceneTree
## Session-shell smoke: boot the main scene, confirm the venue menu is showing,
## start a Joke Shop shift, open the pause overlay and settings, then return to
## the menu. Exits non-zero on failure.
##
## Usage:
## godot --headless --path . --script res://src/test/smoke_session_shell.gd
## godot --path . --script res://src/test/smoke_session_shell.gd (windowed)
var _frames := 0
var _manager = null
var _main = null
func _initialize() -> void:
var packed: PackedScene = load("res://scenes/main.tscn")
if packed == null:
printerr("cannot load main scene")
quit(1)
return
_main = packed.instantiate()
root.add_child(_main)
print("boot: main scene added")
func _process(_delta: float) -> bool:
_frames += 1
if _frames == 10:
_manager = _main.get_node_or_null("GameManager")
if _manager.start_menu == null or not _manager.start_menu.visible:
printerr("start menu not visible at boot")
quit(1)
return true
print("boot: start menu visible")
_manager._on_venue_selected("joke_shop")
if _frames == 200:
if _manager.npcs.size() == 0:
printerr("no NPCs spawned after shift start")
quit(1)
return true
print("boot: shift running, npcs=%d" % _manager.npcs.size())
_manager._open_pause()
if _frames == 210:
if _manager.pause_overlay == null or not _manager.pause_overlay.visible:
printerr("pause overlay did not open")
quit(1)
return true
print("boot: pause overlay visible")
_manager._open_settings()
if _frames == 220:
if _manager.settings_panel == null or not _manager.settings_panel.visible:
printerr("settings panel did not open")
quit(1)
return true
print("boot: settings visible")
_manager.settings_panel._on_cancel()
_manager._quit_to_menu()
if _frames == 230:
if not _manager.start_menu.visible:
printerr("quit-to-menu did not return to the start menu")
quit(1)
return true
print("SMOKE PASS: menu -> shift -> pause -> settings -> menu")
quit(0)
return true
return false
+21
View File
@@ -30,3 +30,24 @@ func test_load_returns_array_when_empty_or_missing() -> bool:
var ok := TestUtils.check(results is Array, "load_shift_results always returns Array")
save.free()
return ok
func test_settings_round_trip() -> bool:
var save := _save_instance()
var err: int = save.save_settings({"look_sensitivity": 0.0035, "hud_font_scale": 1.25})
var a := TestUtils.check_eq(err, OK, "save_settings succeeds")
var loaded: Dictionary = save.load_settings()
var b := TestUtils.check(is_equal_approx(float(loaded.get("look_sensitivity", 0.0)), 0.0035), "round-trips sensitivity")
var c := TestUtils.check(is_equal_approx(float(loaded.get("hud_font_scale", 0.0)), 1.25), "round-trips font scale")
# Restore defaults so later runs are not affected.
save.save_settings(save.DEFAULT_SETTINGS)
save.free()
return a and b and c
func test_settings_defaults_present() -> bool:
var save := _save_instance()
var defaults: Dictionary = save.load_settings()
var ok := TestUtils.check(
defaults.has("look_sensitivity") and defaults.has("hud_font_scale"),
"settings have documented defaults")
save.free()
return ok
+100
View File
@@ -0,0 +1,100 @@
extends RefCounted
## Session shell (start menu, pause overlay, settings) and routine-traffic
## despawn. These are headless-shape tests: they instantiate the UI scripts and
## check state transitions rather than rendering.
const StartMenu := preload("res://src/game/ui/start_menu.gd")
const PauseOverlay := preload("res://src/game/ui/pause_overlay.gd")
const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
func test_start_menu_lists_all_venues() -> bool:
var menu: Node = StartMenu.new()
# Buttons are created in _ready; build them without a tree by calling _build.
menu._build()
var ids: Array[String] = []
for venue in StartMenu.VENUES:
ids.append(str(venue["id"]))
var a := TestUtils.check_eq(ids.size(), 3, "three venues offered")
var b := TestUtils.check(ids.has("hollywood_building"), "Hollywood Blvd present")
var c := TestUtils.check(ids.has("joke_shop"), "Joke Shop present")
var d := TestUtils.check(ids.has("tv_studio"), "TV Studio present")
menu.free()
return a and b and c and d
func test_start_menu_emits_selected_venue() -> bool:
var menu: Node = StartMenu.new()
menu._build()
var got := {"id": ""}
menu.venue_selected.connect(func(id): got["id"] = id)
menu._on_venue_pressed(1)
var a := TestUtils.check_eq(got["id"], "joke_shop", "clicking venue 2 selects the Joke Shop")
var b := TestUtils.check(not menu.visible, "menu closes after a venue is picked")
menu.free()
return a and b
func test_start_menu_clamps_focus() -> bool:
var menu: Node = StartMenu.new()
menu._build()
menu._focus_index(-5)
var low: int = menu._index
menu._focus_index(99)
var high: int = menu._index
menu.free()
return TestUtils.check_eq(low, 0, "focus clamps at the top") \
and TestUtils.check_eq(high, StartMenu.VENUES.size() - 1, "focus clamps at the bottom")
func test_pause_overlay_buttons_emit() -> bool:
var overlay: Node = PauseOverlay.new()
overlay._build()
var seen: Array[String] = []
overlay.resume_requested.connect(func(): seen.append("resume"))
overlay.restart_requested.connect(func(): seen.append("restart"))
overlay.settings_requested.connect(func(): seen.append("settings"))
overlay.quit_to_menu_requested.connect(func(): seen.append("menu"))
for node_name in ["ResumeButton", "RestartButton", "SettingsButton", "MenuButton"]:
var button: Button = overlay.find_child(node_name, true, false) as Button
if button:
button.pressed.emit()
overlay.free()
var expected := ["resume", "restart", "settings", "menu"]
return TestUtils.check_eq(seen, expected, "pause overlay exposes resume/restart/settings/menu")
func test_settings_panel_persists_values() -> bool:
var panel: Node = SettingsPanel.new()
panel._build()
panel.open({"look_sensitivity": 0.0022, "hud_font_scale": 1.0})
panel._on_sensitivity_changed(0.004)
panel._on_font_scale_changed(1.3)
var applied := {"settings": {}}
panel.applied.connect(func(s): applied["settings"] = s)
panel._on_apply()
var s: Dictionary = applied["settings"]
var a := TestUtils.check(is_equal_approx(float(s.get("look_sensitivity", 0.0)), 0.004), "sensitivity applied")
var b := TestUtils.check(is_equal_approx(float(s.get("hud_font_scale", 0.0)), 1.3), "font scale applied")
var c := TestUtils.check(not panel.visible, "panel closes on apply")
# Restore defaults written by the apply call.
if Save:
Save.save_settings(Save.DEFAULT_SETTINGS)
panel.free()
return a and b and c
func test_settings_panel_cancel_discards() -> bool:
var panel: Node = SettingsPanel.new()
panel._build()
panel.open({"look_sensitivity": 0.0022, "hud_font_scale": 1.0})
panel._on_sensitivity_changed(0.005)
var applied := {"count": 0}
panel.applied.connect(func(_s): applied["count"] += 1)
panel._on_cancel()
var a := TestUtils.check(not panel.visible, "cancel closes the panel")
var b := TestUtils.check_eq(applied["count"], 0, "cancel does not apply")
panel.free()
return a and b
func test_routine_traffic_is_ephemeral() -> bool:
var avatar: Node = NpcAvatar.new()
var a := TestUtils.check(avatar.ephemeral, "avatars default to ephemeral")
var b := TestUtils.check(avatar.lifetime_s > 0.0, "ephemeral traffic has a lifetime")
avatar.free()
return a and b
+30
View File
@@ -78,6 +78,36 @@ func test_asset_textures_present_and_pow2() -> bool:
return TestUtils.fail("asset not power-of-two: %s (%dx%d)" % [path, w, h])
return true
func test_steps_have_colliders() -> bool:
## Steps were visual-only and the guard walked through them (TODO).
var root := Node3D.new()
WorldBuilder.new(root).build()
var steps := root.find_child("Steps", true, false)
var a := TestUtils.check(steps != null, "Steps node exists")
var b := TestUtils.check(steps is StaticBody3D, "Steps are a physics body")
var shape_count := 0
if steps is StaticBody3D:
var body := steps as StaticBody3D
shape_count = body.get_shape_owners().size()
var c := TestUtils.check(shape_count >= 5, "four treads + landing have colliders (got %d)" % shape_count)
root.free()
return a and b and c
func test_skyline_backdrop_uses_alpha() -> bool:
## The opaque skyline texture made the plane render as a black slab (TODO).
var root := Node3D.new()
var builder := WorldBuilder.new(root)
builder.build()
var backdrop := root.find_child("SkylineBackdrop", true, false)
var a := TestUtils.check(backdrop != null, "SkylineBackdrop exists")
var mat: StandardMaterial3D = builder._skyline_mat
var b := TestUtils.check(mat != null, "skyline material captured")
var c := TestUtils.check(
mat != null and mat.transparency != BaseMaterial3D.TRANSPARENCY_DISABLED,
"skyline material uses alpha, not an opaque slab")
root.free()
return a and b and c
func test_door_indicator_built() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root)