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
@@ -279,6 +279,18 @@ func set_wand_icon(visible: bool) -> void:
if wand_icon:
wand_icon.visible = visible
## Scale the whole HUD text block (accessibility settings).
func set_font_scale(scale: float) -> void:
var clamped := clampf(scale, 0.75, 1.75)
if top_label:
top_label.scale = Vector2(clamped, clamped)
if log_label:
log_label.scale = Vector2(clamped, clamped)
if target_label:
target_label.scale = Vector2(clamped, clamped)
if hint_label:
hint_label.scale = Vector2(clamped, clamped)
func _make_icon(texture: Texture2D) -> TextureRect:
var tr := TextureRect.new()
tr.texture = texture
+84
View File
@@ -0,0 +1,84 @@
class_name PauseOverlay
extends CanvasLayer
## Shift pause overlay: resume, restart the shift, open settings, or quit to the
## venue menu. Opened with Esc from the world (or a dedicated pause action) and
## while the overlay is open the guard is frozen.
signal resume_requested()
signal restart_requested()
signal settings_requested()
signal quit_to_menu_requested()
var _root: Control = null
var _resume_button: Button = null
func _ready() -> void:
layer = 18
_build()
hide()
func _build() -> void:
_root = Control.new()
_root.name = "PauseRoot"
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_root)
var bg := ColorRect.new()
bg.color = Color(0.02, 0.03, 0.05, 0.82)
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)
_root.add_child(center)
var column := VBoxContainer.new()
column.custom_minimum_size = Vector2(320, 0)
column.add_theme_constant_override("separation", 10)
center.add_child(column)
var title := Label.new()
title.text = "Paused"
title.add_theme_font_size_override("font_size", 36)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
column.add_child(title)
_resume_button = _add_button(column, "Resume", "ResumeButton", func(): resume_requested.emit())
_add_button(column, "Restart shift", "RestartButton", func(): restart_requested.emit())
_add_button(column, "Settings", "SettingsButton", func(): settings_requested.emit())
_add_button(column, "Quit to menu", "MenuButton", func(): quit_to_menu_requested.emit())
var hint := Label.new()
hint.text = "Esc resumes"
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65))
column.add_child(hint)
func _add_button(parent: Control, text: String, node_name: String, cb: Callable) -> Button:
var button := Button.new()
button.name = node_name
button.text = text
button.custom_minimum_size = Vector2(320, 44)
button.pressed.connect(cb)
parent.add_child(button)
return button
func open() -> void:
visible = true
_root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
if _resume_button:
_resume_button.grab_focus()
func close() -> void:
visible = false
_root.visible = false
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
resume_requested.emit()
get_viewport().set_input_as_handled()
+154
View File
@@ -0,0 +1,154 @@
class_name SettingsPanel
extends CanvasLayer
## Settings: mouse look sensitivity and HUD font scale, persisted through the
## `Save` autoload into `user://settings.json`.
##
## Emits `applied(settings)` so the game manager can push values into the guard
## and HUD as soon as the player confirms.
signal applied(settings: Dictionary)
signal closed()
var _root: Control = null
var _sensitivity: HSlider = null
var _font_scale: HSlider = null
var _sensitivity_value: Label = null
var _font_scale_value: Label = null
var settings: Dictionary = {}
func _ready() -> void:
layer = 22
_build()
hide()
func _init() -> void:
layer = 22
_build()
hide()
func _build() -> void:
_root = Control.new()
_root.name = "SettingsRoot"
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_root)
var bg := ColorRect.new()
bg.color = Color(0.02, 0.03, 0.05, 0.9)
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)
_root.add_child(center)
var column := VBoxContainer.new()
column.custom_minimum_size = Vector2(420, 0)
column.add_theme_constant_override("separation", 12)
center.add_child(column)
var title := Label.new()
title.text = "Settings"
title.add_theme_font_size_override("font_size", 32)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
column.add_child(title)
var sens_row := VBoxContainer.new()
column.add_child(sens_row)
_sensitivity_value = _add_label_row(sens_row, "Mouse look sensitivity")
_sensitivity = _add_slider(sens_row, 0.0005, 0.0060, 0.0001)
_sensitivity.value_changed.connect(_on_sensitivity_changed)
var font_row := VBoxContainer.new()
column.add_child(font_row)
_font_scale_value = _add_label_row(font_row, "HUD font scale")
_font_scale = _add_slider(font_row, 0.75, 1.75, 0.05)
_font_scale.value_changed.connect(_on_font_scale_changed)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 12)
column.add_child(row)
var save_button := Button.new()
save_button.name = "ApplyButton"
save_button.text = "Apply"
save_button.pressed.connect(_on_apply)
row.add_child(save_button)
var cancel_button := Button.new()
cancel_button.name = "CancelButton"
cancel_button.text = "Cancel"
cancel_button.pressed.connect(_on_cancel)
row.add_child(cancel_button)
func _add_label_row(parent: Control, text: String) -> Label:
var row := HBoxContainer.new()
parent.add_child(row)
var label := Label.new()
label.text = text
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(label)
var value := Label.new()
value.add_theme_color_override("font_color", Color(0.75, 0.8, 0.9))
row.add_child(value)
return value
func _add_slider(parent: Control, min_v: float, max_v: float, step: float) -> HSlider:
var slider := HSlider.new()
slider.min_value = min_v
slider.max_value = max_v
slider.step = step
slider.custom_minimum_size = Vector2(420, 24)
parent.add_child(slider)
return slider
func open(current: Dictionary) -> void:
settings = current.duplicate(true)
var sens: float = float(settings.get("look_sensitivity", 0.0022))
var scale: float = float(settings.get("hud_font_scale", 1.0))
if _sensitivity:
_sensitivity.set_value_no_signal(clampf(sens, _sensitivity.min_value, _sensitivity.max_value))
if _font_scale:
_font_scale.set_value_no_signal(clampf(scale, _font_scale.min_value, _font_scale.max_value))
_sync_labels()
visible = true
_root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func close() -> void:
visible = false
_root.visible = false
func _sync_labels() -> void:
if _sensitivity_value and _sensitivity:
_sensitivity_value.text = "%.4f" % _sensitivity.value
if _font_scale_value and _font_scale:
_font_scale_value.text = "%.2fx" % _font_scale.value
func _on_sensitivity_changed(value: float) -> void:
settings["look_sensitivity"] = value
_sync_labels()
func _on_font_scale_changed(value: float) -> void:
settings["hud_font_scale"] = value
_sync_labels()
func _on_apply() -> void:
if Save:
Save.save_settings(settings)
applied.emit(settings)
close()
closed.emit()
func _on_cancel() -> void:
close()
closed.emit()
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
_on_cancel()
get_viewport().set_input_as_handled()
+140
View File
@@ -0,0 +1,140 @@
class_name StartMenu
extends CanvasLayer
## Startup menu: pick a venue, start the shift, open settings, or quit.
## Keyboard and mouse both work. Replaces `V` as the player-facing venue path
## (the `V` bind stays as a debug shortcut).
signal venue_selected(scenario_id: String)
signal settings_requested()
signal quit_requested()
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."},
{"id": "tv_studio", "label": "Late Night TV Studio", "blurb": "Credentials, filming boundary, celebrity arrivals."},
]
var _index := 0
var _buttons: Array[Button] = []
var _root: Control = null
func _ready() -> void:
layer = 20
_build()
hide()
func _build() -> void:
_root = Control.new()
_root.name = "StartMenuRoot"
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_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 center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.add_child(center)
var column := VBoxContainer.new()
column.name = "Column"
column.custom_minimum_size = Vector2(460, 0)
column.add_theme_constant_override("separation", 14)
center.add_child(column)
var title := Label.new()
title.text = "STEP LINE"
title.add_theme_font_size_override("font_size", 44)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
column.add_child(title)
var subtitle := Label.new()
subtitle.text = "Pick a venue to start your shift."
subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
subtitle.add_theme_color_override("font_color", Color(0.72, 0.75, 0.82))
column.add_child(subtitle)
for i in VENUES.size():
var venue: Dictionary = VENUES[i]
var button := Button.new()
button.name = "Venue_%s" % venue["id"]
button.text = "%s\n%s" % [venue["label"], venue["blurb"]]
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.custom_minimum_size = Vector2(460, 62)
button.pressed.connect(_on_venue_pressed.bind(i))
column.add_child(button)
_buttons.append(button)
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 10)
column.add_child(spacer)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 12)
column.add_child(row)
var settings_button := Button.new()
settings_button.name = "SettingsButton"
settings_button.text = "Settings"
settings_button.pressed.connect(func(): settings_requested.emit())
row.add_child(settings_button)
var quit_button := Button.new()
quit_button.name = "QuitButton"
quit_button.text = "Quit"
quit_button.pressed.connect(func(): quit_requested.emit())
row.add_child(quit_button)
var hint := Label.new()
hint.text = "Up/Down to choose, Enter to start, Esc to quit"
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65))
column.add_child(hint)
_focus_index(0)
func _focus_index(i: int) -> void:
if _buttons.is_empty():
return
_index = clampi(i, 0, _buttons.size() - 1)
_buttons[_index].grab_focus()
func open() -> void:
visible = true
_root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
_focus_index(_index)
func close() -> void:
visible = false
_root.visible = false
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo:
match event.keycode:
KEY_UP, KEY_W:
_focus_index(_index - 1)
get_viewport().set_input_as_handled()
KEY_DOWN, KEY_S:
_focus_index(_index + 1)
get_viewport().set_input_as_handled()
KEY_ENTER, KEY_KP_ENTER, KEY_SPACE:
_on_venue_pressed(_index)
get_viewport().set_input_as_handled()
KEY_ESCAPE:
quit_requested.emit()
get_viewport().set_input_as_handled()
func _on_venue_pressed(i: int) -> void:
if i < 0 or i >= VENUES.size():
return
var venue: Dictionary = VENUES[i]
close()
venue_selected.emit(str(venue["id"]))