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

+148 -7
View File
@@ -23,6 +23,9 @@ const Hud := preload("res://src/game/ui/hud.gd")
const ActionRadial := preload("res://src/game/ui/action_radial.gd")
const RadioPanel := preload("res://src/game/ui/radio_panel.gd")
const ShiftReview := preload("res://src/game/ui/shift_review.gd")
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")
var world_builder: WorldBuilder = null
var door := DoorState.new()
@@ -62,6 +65,11 @@ var hud: Hud = null
var radial: ActionRadial = null
var radio_panel: RadioPanel = null
var shift_review_panel: ShiftReview = null
var start_menu: StartMenu = null
var pause_overlay: PauseOverlay = null
var settings_panel: SettingsPanel = null
var _shift_running := false
var player_settings: Dictionary = {}
var _focused_avatar: Node3D = null
var audio: AudioFeedback = null
var verifier := AccessVerifier.new()
@@ -99,6 +107,7 @@ var _nearby_index := -1
func _ready() -> void:
_door_rng.randomize()
player_settings = Save.load_settings() if Save else {}
scene_root = Node3D.new()
scene_root.name = "SceneRoot"
add_child(scene_root)
@@ -115,6 +124,7 @@ func _ready() -> void:
guard.rotation.y = PI
guard.picked_target.connect(_on_guard_target)
guard.cleared_target.connect(_on_guard_clear)
_apply_settings(player_settings)
selected_scenario = _build_scenario(active_scenario_id)
scenario_duration_s = selected_scenario.shift_duration_s
@@ -125,9 +135,10 @@ func _ready() -> void:
coordinator.radio.responder_arrived.connect(_on_responder_arrived)
_build_ui()
coordinator.start()
_last_phase = -1
_apply_phase_lighting()
# Boot into the venue menu; the shift starts when the player picks a venue.
_show_start_menu()
var selected_scenario: VenueScenario = null
@@ -179,20 +190,106 @@ func switch_scenario(id: String) -> void:
restart_shift()
func restart_shift() -> void:
shift_review_panel.hide()
if shift_review_panel:
shift_review_panel.hide()
_reset_shift_state()
_start_shift()
## Clear all per-shift runtime state (NPCs, incidents, timers).
func _reset_shift_state() -> void:
for n in npcs:
n.queue_free()
if is_instance_valid(n):
n.queue_free()
npcs.clear()
npc_counter = 0
inspector_current_events = []
current_incident_active = false
current_incident_npc_id = ""
targeted_npc = null
_focused_avatar = null
_nearby_index = -1
inspection_open = false
wand_open = false
_wand_cooldown = 0.0
scenario_duration_s = selected_scenario.shift_duration_s
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.start()
_last_phase = -1
_apply_phase_lighting()
## Begin (or resume) the shift clock and recapture the mouse.
func _start_shift() -> void:
_shift_running = true
coordinator.start()
if hud:
hud.set_shift_state(coordinator, selected_scenario)
_refresh_freeze()
func _show_start_menu() -> void:
_shift_running = false
if pause_overlay:
pause_overlay.close()
if shift_review_panel:
shift_review_panel.hide()
if guard:
guard.input_locked = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
if start_menu:
start_menu.open()
func _on_venue_selected(scenario_id: String) -> void:
if start_menu and start_menu.visible:
start_menu.close()
active_scenario_id = scenario_id
_venue_index = maxi(0, VENUE_ORDER.find(scenario_id))
selected_scenario = _build_scenario(scenario_id)
if hud:
hud.set_wand_icon(false)
_reset_shift_state()
_start_shift()
## Apply loaded settings to the guard and HUD.
func _apply_settings(settings: Dictionary) -> void:
player_settings = settings
if guard:
guard.look_sensitivity = float(settings.get("look_sensitivity", 0.0022))
if hud and hud.has_method("set_font_scale"):
hud.set_font_scale(float(settings.get("hud_font_scale", 1.0)))
func _on_settings_applied(settings: Dictionary) -> void:
_apply_settings(settings)
if start_menu and start_menu.visible:
start_menu.open()
elif _shift_running:
_open_pause()
func _open_settings() -> void:
if start_menu:
start_menu.close()
if pause_overlay:
pause_overlay.close()
if settings_panel:
settings_panel.open(player_settings)
func _open_pause() -> void:
if pause_overlay == null or not _shift_running:
return
if _close_top_modal():
pass
pause_overlay.open()
if guard:
guard.input_locked = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func _close_pause() -> void:
if pause_overlay:
pause_overlay.close()
_refresh_freeze()
func _quit_to_menu() -> void:
if pause_overlay:
pause_overlay.close()
_show_start_menu()
var inspector_current_events: Array = []
func _build_ui() -> void:
@@ -216,7 +313,32 @@ func _build_ui() -> void:
shift_review_panel = ShiftReview.new()
add_child(shift_review_panel)
shift_review_panel.hide()
shift_review_panel.restart_requested.connect(restart_shift)
shift_review_panel.restart_requested.connect(_on_review_restart)
shift_review_panel.visible = false
pause_overlay = PauseOverlay.new()
add_child(pause_overlay)
pause_overlay.hide()
pause_overlay.resume_requested.connect(_close_pause)
pause_overlay.restart_requested.connect(restart_shift)
pause_overlay.settings_requested.connect(_open_settings)
pause_overlay.quit_to_menu_requested.connect(_quit_to_menu)
settings_panel = SettingsPanel.new()
add_child(settings_panel)
settings_panel.hide()
settings_panel.applied.connect(_on_settings_applied)
start_menu = StartMenu.new()
add_child(start_menu)
start_menu.hide()
start_menu.venue_selected.connect(_on_venue_selected)
start_menu.settings_requested.connect(_open_settings)
start_menu.quit_requested.connect(func(): get_tree().quit())
## "Run another shift" on the review screen returns to the venue menu.
func _on_review_restart() -> void:
_show_start_menu()
func _add_crosshair() -> void:
var layer := CanvasLayer.new()
@@ -309,6 +431,9 @@ func _spawn_npc(profile: NpcProfile, intent: String, npc_id: String, event: Dict
items.append(str(item))
spawn_profile.carried_items = items
avatar.setup(spawn_profile, intent, seed_value, spawn_pos, target_pos, door_pos, npc_id, override)
# Authored events stay for the whole shift; routine traffic cycles out.
avatar.ephemeral = event.get("kind", "routine") == "routine"
avatar.lifetime_s = 45.0 + float((npc_counter * 17) % 60)
scene_root.add_child(avatar)
npcs.append(avatar)
@@ -362,6 +487,7 @@ func _responder_profile() -> NpcProfile:
return p
func _on_shift_ended(review: Dictionary) -> void:
_shift_running = false
# Persist the finished shift locally (Save autoload, user:// JSON).
if Save:
Save.save_shift_result(review)
@@ -510,13 +636,22 @@ func _handle_action_key(keycode: Key) -> void:
_:
pass
## `cancel` (Esc, RMB) closes the top modal. With nothing open, Esc toggles mouse
## capture; RMB does nothing so it never grabs the mouse by accident.
## `cancel` (Esc, RMB) closes the top modal. With nothing open, Esc pauses the
## shift (or resumes it when paused); RMB does nothing so it never grabs the
## mouse by accident.
func _handle_cancel(from_mouse: bool) -> void:
if _close_top_modal():
return
if from_mouse:
return
if settings_panel and settings_panel.visible:
return
if pause_overlay and pause_overlay.visible:
_close_pause()
return
if _shift_running and not (start_menu and start_menu.visible):
_open_pause()
return
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
else:
@@ -610,6 +745,12 @@ func _refresh_freeze() -> void:
freeze = true
if shift_review_panel and shift_review_panel.visible:
freeze = true
if pause_overlay and pause_overlay.visible:
freeze = true
if settings_panel and settings_panel.visible:
freeze = true
if not _shift_running:
freeze = true
if guard:
guard.input_locked = freeze
if freeze:
+21 -5
View File
@@ -12,6 +12,11 @@ var target_position: Vector3 = Vector3.ZERO
var door_position: Vector3 = Vector3(0, 0.5, 1.2)
var awaiting_interaction := false
var arrived := false
## Seconds idle at an arrived waypoint. Ordinary traffic lives for a while, then
## despawns so the entrance does not fill with standers (TODO: NPC crowd cap).
var idle_s := 0.0
var lifetime_s := 90.0
var ephemeral := true
var agent: NavigationAgent3D = null
## Set once the nav agent proves it has a path; direct movement is the fallback.
var _nav_usable := false
@@ -123,8 +128,8 @@ func _build_focus_ring() -> void:
_ring = MeshInstance3D.new()
_ring.name = "FocusRing"
var torus := TorusMesh.new()
torus.inner_radius = 0.16
torus.outer_radius = 0.20
torus.inner_radius = 0.26
torus.outer_radius = 0.34
torus.rings = 12
torus.ring_segments = 16
_ring.mesh = torus
@@ -139,10 +144,10 @@ func _build_focus_ring() -> void:
_name_tag = Label3D.new()
_name_tag.name = "NameTag"
_name_tag.text = npc.profile.display_name if npc and npc.profile else ""
_name_tag.font_size = 28
_name_tag.font_size = 48
_name_tag.pixel_size = 0.004
_name_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
_name_tag.position = Vector3(0, 0.42, 0)
_name_tag.position = Vector3(0, 0.58, 0)
_name_tag.modulate = Color(0.95, 0.95, 0.9, 0.9)
_name_tag.visible = false
_head.add_child(_name_tag)
@@ -257,20 +262,31 @@ func _animate(delta: float) -> void:
_head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1)
func _advance_fsm(delta: float) -> void:
# Routine traffic despawns after a while once it has finished its walk, so
# the crowd cap is not permanently occupied by idle standers.
if ephemeral and arrived and npc.fsm.state != "LEAVING":
idle_s += delta
if idle_s >= lifetime_s:
leave_requested_by_lifetime = true
return
# Returning-trespasser: an NPC directed away may circle back to the door.
if npc.tick_return(delta):
arrived = false
awaiting_interaction = false
target_position = door_position
return
if npc.leave_requested:
if npc.leave_requested or leave_requested_by_lifetime:
var away := global_position + (global_position - spawn_position).normalized() * 40.0
away.y = 0.5
target_position = away
if (global_position - away).length() < 2.0:
queue_free()
return
if arrived and not awaiting_interaction:
awaiting_interaction = true
var leave_requested_by_lifetime := false
func _move_toward_target(delta: float) -> void:
if agent == null:
_move_direct(delta)
+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"]))
+55 -9
View File
@@ -5,6 +5,7 @@ const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png")
const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png")
const T_SKYLINE := preload("res://assets/textures/skyline.png")
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
## No art assets required; all geometry is BoxMesh with flat materials.
@@ -25,6 +26,7 @@ func _init(root_node: Node3D) -> void:
func build() -> void:
_build_ground()
_build_building()
_build_skyline_backdrop()
_build_marquee()
_build_steps()
_build_sidewalk()
@@ -90,10 +92,9 @@ func _build_building() -> void:
root.add_child(building)
func _build_steps() -> void:
# Steps are visual only (no collider) so NPCs and the guard can walk up the
# entrance on the flat navigation mesh; the ground and building provide the
# actual collision boundaries.
var steps := Node3D.new()
# Steps have collision so NPCs and the guard can walk up the entrance.
# The ground and building provide the actual collision boundaries.
var steps := StaticBody3D.new()
steps.name = "Steps"
var step_w := 8.0
var tread := 0.6
@@ -101,9 +102,22 @@ func _build_steps() -> void:
for i in 4:
var y: float = i * height
var z: float = 4.0 * tread - i * tread
_add_box(steps, Vector3(step_w - i * 0.35, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
# Landing at the top
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, 4 * height + 0.1, -1.5), Color(0.5, 0.47, 0.45))
var w: float = step_w - i * 0.35
_add_box(steps, Vector3(w, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
# Add collision shape to each tread
var tread_col := BoxShape3D.new()
tread_col.size = Vector3(w, height, tread)
var owner_id := steps.create_shape_owner(steps)
steps.shape_owner_add_shape(owner_id, tread_col)
steps.shape_owner_set_transform(owner_id, Transform3D(Basis(), Vector3(0, y + height / 2.0, z)))
# Landing at the top with collision
var landing_y: float = 4 * height + 0.1
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, landing_y, -1.5), Color(0.5, 0.47, 0.45))
var landing_col := BoxShape3D.new()
landing_col.size = Vector3(9, 0.2, 3)
var landing_owner := steps.create_shape_owner(steps)
steps.shape_owner_add_shape(landing_owner, landing_col)
steps.shape_owner_set_transform(landing_owner, Transform3D(Basis(), Vector3(0, landing_y, -1.5)))
root.add_child(steps)
func _build_sidewalk() -> void:
@@ -150,6 +164,33 @@ func _build_navmesh() -> void:
region.navigation_mesh = mesh
root.add_child(region)
func _build_skyline_backdrop() -> void:
## Skyline silhouette backdrop behind the building. The texture has a
## transparent sky, so only the building silhouettes show; the material must
## be set up for alpha or the plane renders as an opaque slab (ADR/TODO).
var backdrop := Node3D.new()
backdrop.name = "SkylineBackdrop"
var plane := MeshInstance3D.new()
var mesh := PlaneMesh.new()
mesh.size = Vector2(240, 60) # wide, low horizon
plane.mesh = mesh
var mat := StandardMaterial3D.new()
mat.albedo_texture = T_SKYLINE
mat.albedo_texture_repeat = true
mat.uv1_scale = Vector3(2.0, 1.0, 1.0)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
mat.alpha_scissor_threshold = 0.3
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
plane.material_override = mat
plane.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
plane.position = Vector3(0, 18, -60) # behind and above the building
backdrop.add_child(plane)
root.add_child(backdrop)
_skyline_mat = mat
var _skyline_mat: StandardMaterial3D = null
func _build_marquee() -> void:
## Striped awning above the door, then the neon sign strip + label.
var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), false, T_AWNING)
@@ -167,9 +208,9 @@ func _build_marquee() -> void:
marquee_label.text = "STEP LINE"
marquee_label.position = Vector3(0, 7.3, -3.55)
marquee_label.modulate = Color(1, 0.6, 0.9)
marquee_label.font_size = 72
marquee_label.font_size = 160
marquee_label.pixel_size = 0.004
marquee_label.outline_size = 12
marquee_label.outline_size = 24
marquee_label.outline_modulate = Color(0.9, 0.15, 0.5)
marquee_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
marquee_label.no_depth_test = false
@@ -264,6 +305,11 @@ func _plain_material(color: Color) -> StandardMaterial3D:
func _build_guard_spawn() -> void:
var marker := Marker3D.new()
marker.name = "GuardSpawn"
# Guard spawns on the landing at the top of the steps, facing the street.
# The landing is at y=4*height+0.1 where height=0.16, so y=0.76
# The guard's head offset is 1.6m, so spawn at y=0.76-1.6=-0.84 to place feet on ground
# But we want the guard at eye-level with the sidewalk, so adjust to y=0.1 for feet
# Then the head will be at y=1.7 (1.6 offset + 0.1 base)
marker.position = Vector3(0, 0.1, -2.0)
root.add_child(marker)