Files
security-game/src/game/game_manager.gd
T
Gig Line Dev 26e6dd6413
Full build / Import + headless tests (push) Successful in 37s
Full build / Full desktop build + optional release (push) Skipped
Close every top modal through one Esc and right-click path
- Add Help, Settings, Pause and review to the top-modal close chain so right-click and Esc behave consistently.
- Restore the venue menu after menu Settings and prevent Help from opening Pause behind itself.
- Reopen pause after in-shift Settings apply and recapture the mouse/unfreeze state on close.
- Extend the session smoke for menu Settings, right-click Help and guard unfreeze.
2026-11-02 16:10:50 +01:00

2084 lines
77 KiB
GDScript

class_name GameManager
extends Node
## Wireframe: connects a scenario to a ShiftCoordinator, spawns NPC avatars,
## and routes player input into the interaction and access flows.
const Rules := preload("res://src/core/rules.gd")
const Strings := preload("res://src/core/strings.gd")
const AccessArtifact := preload("res://src/core/access_artifact.gd")
const AccessLoadout := preload("res://src/core/access_loadout.gd")
const AccessVerifier := preload("res://src/core/access_verifier.gd")
const DoorState := preload("res://src/core/door_state.gd")
const CrowdSystem := preload("res://src/core/crowd_system.gd")
const WandScreener := preload("res://src/core/wand_screener.gd")
const RadioSystem := preload("res://src/core/radio_system.gd")
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
const VenueScenario := preload("res://src/core/venue_scenario.gd")
const WorldBuilder := preload("res://src/game/world_builder.gd")
const Materials := preload("res://src/game/materials.gd")
const InputBindings := preload("res://src/core/input_bindings.gd")
const GuardController := preload("res://src/game/guard_controller.gd")
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
const AudioFeedback := preload("res://src/game/audio_feedback.gd")
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")
const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
const Tutorial := preload("res://src/game/tutorial.gd")
const TutorialOverlay := preload("res://src/game/ui/tutorial_overlay.gd")
const Campaign := preload("res://src/core/campaign.gd")
const HelpOverlay := preload("res://src/game/ui/help_overlay.gd")
var world_builder: WorldBuilder = null
## Dedicated container for the procedural world, so switching venues can rebuild
## the geometry without touching the guard, UI, or other scene-root children.
var world_root: Node3D = null
var door := DoorState.new()
var flashlight_on := false
var body_camera_on := false
var coordinator: ShiftCoordinator = null
var guard: GuardController = null
var _sun: DirectionalLight3D = null
var _fill: DirectionalLight3D = null
var _env_node: WorldEnvironment = null
var _sky: ProceduralSkyMaterial = null
var _last_phase: int = -1
## Menu-only alias for the night scenery variant; it resolves to the Hollywood
## scenario with a LATE phase offset.
const HOLLYWOOD_NIGHT_ID := "hollywood_building_night"
var _start_phase_override: int = -1
## Per-phase lighting looks for the sun, sky, and ambient (PLAN.md: shift
## structure — phase-based lighting). Night phases switch street lamps on.
const PHASE_LIGHTING := {
Rules.Phase.EARLY: {
"sun_energy": 0.62, "sun_angle": -34.0, "sun_yaw": -30.0, "sun_color": Color(1.0, 0.96, 0.88),
"sky_top": Color(0.32, 0.55, 0.95), "sky_horizon": Color(0.82, 0.88, 0.95),
"sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false,
"exposure": 0.6, "glow": 0.45, "fog_color": Color(0.87, 0.83, 0.76),
"fog_density": 0.0034, "fog_energy": 0.5, "contrast": 1.05, "saturation": 1.04,
"ambient_energy": 0.1, "fill_energy": 0.22, "volumetric_density": 0.004, "volumetric_energy": 0.0,
},
Rules.Phase.MIDDAY: {
"sun_energy": 1.0, "sun_angle": -62.0, "sun_yaw": -30.0, "sun_color": Color(1.0, 0.99, 0.96),
"sky_top": Color(0.3, 0.52, 0.95), "sky_horizon": Color(0.85, 0.9, 0.96),
"sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false,
"exposure": 0.6, "glow": 0.4, "fog_color": Color(0.88, 0.87, 0.83),
"fog_density": 0.0028, "fog_energy": 0.5, "contrast": 1.06, "saturation": 1.04,
"ambient_energy": 0.28, "fill_energy": 0.18, "volumetric_density": 0.003, "volumetric_energy": 0.0,
},
# Evening is dusk, not a bright sunset: the sun is almost down and the street
# is carried by the marquee, the lamps and the lit windows (realism pass).
Rules.Phase.EVENING: {
"sun_energy": 0.12, "sun_angle": -9.0, "sun_color": Color(1.0, 0.66, 0.42),
"sky_top": Color(0.18, 0.15, 0.24), "sky_horizon": Color(0.42, 0.28, 0.3),
"sky_ground": Color(0.1, 0.09, 0.12), "ambient": Color(0.2, 0.19, 0.26), "night": true,
"exposure": 0.72, "glow": 0.8, "fog_color": Color(0.34, 0.27, 0.3),
"fog_density": 0.0026, "fog_energy": 0.45, "contrast": 1.1, "saturation": 1.05,
"ambient_energy": 0.12, "fill_energy": 0.10, "volumetric_density": 0.014, "volumetric_energy": 0.10,
},
Rules.Phase.LATE: {
"sun_energy": 0.02, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
"sky_top": Color(0.015, 0.02, 0.05), "sky_horizon": Color(0.06, 0.07, 0.13),
"sky_ground": Color(0.03, 0.03, 0.05), "ambient": Color(0.06, 0.07, 0.12), "night": true,
"exposure": 0.95, "glow": 1.15, "fog_color": Color(0.08, 0.1, 0.2),
"fog_density": 0.0028, "fog_energy": 0.45, "contrast": 1.12, "saturation": 1.0,
"ambient_energy": 0.30, "fill_energy": 0.06, "volumetric_density": 0.020, "volumetric_energy": 0.14,
},
}
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
## Settings opened from the venue menu must return there on Cancel/Apply;
## settings opened from pause must not leave a menu visible or the guard frozen.
var _settings_from_start_menu := false
var event_timeline: EventTimeline = null
var tutorial_overlay: TutorialOverlay = null
var help_overlay: HelpOverlay = null
var tutorial: Tutorial = null
var tutorial_active := false
var campaign: Campaign = null
var _resuming := false
var _resume_pending: Dictionary = {}
var _tutorial_start_pos := Vector3.ZERO
var _tutorial_start_yaw := 0.0
var _tutorial_start_pitch := 0.0
var _timeline_from_review := false
var _queue_tick := 0.0
var _input_device := "keyboard"
var _shift_running := false
var player_settings: Dictionary = {}
var _focused_avatar: Node3D = null
var audio: AudioFeedback = null
var verifier := AccessVerifier.new()
var wand_screener := WandScreener.new()
var active_scenario_id := "hollywood_building"
## Shift length mirrors the scenario default (900s / 10-15 min per PLAN.md) unless
## overridden for a test or debug build.
var scenario_duration_s: float = 900.0
var _wand_cooldown := 0.0
const WAND_COOLDOWN_S := 0.8
## Difficulty only changes pacing/forgiveness; the de-escalation rules stay fixed.
const DIFFICULTY := {
"easy": {"spawn": 0.7, "compliance": 0.12, "report": 0.7},
"normal": {"spawn": 1.0, "compliance": 0.0, "report": 1.0},
"hard": {"spawn": 1.35, "compliance": -0.08, "report": 1.5},
}
var scene_root: Node3D = null
var npcs: Array = []
var npc_counter := 0
var targeted_npc: Object = null
var inspection_open := false
var wand_open := false
var current_incident_npc_id := ""
var current_incident_active := false
var current_npc_truth := {"weapons_seen": false, "danger_level": 0}
## Venue cycling (V): next venue in the scenario list.
const VENUE_ORDER := ["hollywood_building", "joke_shop", "tv_studio"]
var _venue_index := 0
## RNG for venue door-staff lines so repeated interactions vary.
var _door_rng := RandomNumberGenerator.new()
## Aim-free targeting lives on the guard (`GuardController.nearby_candidates`);
## this file only reacts to the signals it emits and logs the selection.
## Upper bound on live avatars. Routine traffic is dropped at the cap; scripted
## events evict the oldest unfocused NPC so authored moments still spawn.
const MAX_LIVE_NPCS := 16
func _ready() -> void:
_ensure_action_bindings()
_door_rng.randomize()
player_settings = Save.load_settings() if Save else {}
scene_root = Node3D.new()
scene_root.name = "SceneRoot"
add_child(scene_root)
world_root = Node3D.new()
world_root.name = "World"
scene_root.add_child(world_root)
world_builder = WorldBuilder.new(world_root, active_scenario_id)
world_builder.build()
_ensure_lighting()
guard = _build_guard()
scene_root.add_child(guard)
# Stand on the landing in front of the door, facing the street where guests
# arrive. The building volume now starts behind the door (world_builder).
guard.global_position = Vector3(0, 0.1, -2.0)
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
coordinator = ShiftCoordinator.new()
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.scheduler.spawn_rate_multiplier = float(_difficulty()["spawn"])
coordinator.npc_spawn_requested.connect(_spawn_npc)
coordinator.shift_ended.connect(_on_shift_ended)
_connect_radio()
_connect_crowd()
_build_ui()
campaign = Campaign.new()
campaign.setup(Save.load_campaign() if Save else {})
# The HUD does not exist yet when settings are loaded, so push them again now
# that it does (otherwise the saved font scale is ignored until the next apply).
_apply_settings(player_settings)
_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
func _build_guard() -> GuardController:
var g := GuardController.new()
g.name = "Guard"
var head := Node3D.new()
head.name = "Head"
# Eye height. The guard root sits on the floor, so this keeps the camera at
# ~1.7 m while gravity/floor snapping handles the steps (ADR-0007).
head.position = Vector3(0, 1.6, 0)
g.add_child(head)
var camera := Camera3D.new()
camera.name = "Camera3D"
camera.current = true
head.add_child(camera)
var flashlight := SpotLight3D.new()
flashlight.name = "Flashlight"
flashlight.visible = false
flashlight.position = Vector3(0, -0.15, -0.4)
# Aim straight ahead: tilting it down made the cone hit the ground right in
# front of the guard instead of the person or door being checked.
flashlight.rotation_degrees = Vector3.ZERO
flashlight.spot_range = 30.0 if Materials.modern_light_falloff() else 20.0
flashlight.spot_angle = 14.0
flashlight.light_energy = 4.2
flashlight.spot_attenuation = 1.0
flashlight.spot_angle_attenuation = 0.9
# The volumetric shaft is a Forward+ (desktop HQ) effect. Compatibility and
# headless builds leave the light non-volumetric, so their look is unchanged.
flashlight.light_volumetric_fog_energy = 3.0 if RenderingServer.get_rendering_device() != null else 0.0
head.add_child(flashlight)
var collider := CollisionShape3D.new()
var shape := CapsuleShape3D.new()
shape.radius = 0.4
shape.height = 1.7
collider.shape = shape
collider.position = Vector3(0, 0.85, 0)
g.add_child(collider)
g.add_to_group("guard")
g.footstep.connect(_on_guard_footstep)
g.landed.connect(_on_guard_landed)
g.precision_changed.connect(_on_precision_changed)
return g
func _on_guard_footstep(surface: String) -> void:
if audio:
audio.play_footstep(surface)
func _on_guard_landed() -> void:
if audio:
audio.play_land()
func _on_precision_changed(active: bool) -> void:
if hud:
hud.set_precision(active)
if audio and active:
audio.play_precision_tick()
func _build_scenario(id: String) -> VenueScenario:
match id:
"joke_shop":
return ScenarioFactory.joke_shop()
"tv_studio":
return ScenarioFactory.tv_studio()
_:
return ScenarioFactory.hollywood_building()
func switch_scenario(id: String) -> void:
_start_phase_override = -1
active_scenario_id = id
selected_scenario = _build_scenario(id)
_rebuild_world_for_venue(id)
if coordinator:
restart_shift()
## Rebuild the procedural venue in place. The guard/UI stay under `SceneRoot`;
## only the `World` container is replaced, so navigation, props, lights and the
## venue-specific dressing match the selected scenario.
func _rebuild_world_for_venue(id: String) -> void:
if world_root == null:
return
for child in world_root.get_children():
child.free()
world_builder = WorldBuilder.new(world_root, id)
world_builder.build()
if guard != null:
guard.global_position = world_builder.spec.get("guard_spawn", Vector3(0, 0.1, -2.0))
guard.rotation.y = PI
if world_builder != null:
world_builder.set_door_locked(door.locked)
func restart_shift() -> void:
if shift_review_panel:
shift_review_panel.hide()
if Save:
Save.clear_shift_state()
_reset_shift_state()
_start_shift()
## Clear all per-shift runtime state (NPCs, incidents, timers).
func _reset_shift_state() -> void:
for n in npcs:
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
inspection_open = false
wand_open = false
_wand_cooldown = 0.0
body_camera_on = false
if hud:
hud.set_body_camera(false)
scenario_duration_s = selected_scenario.shift_duration_s
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
if _start_phase_override >= 0:
coordinator.phase_offset_s = _phase_start_s(_start_phase_override)
coordinator.current_phase = coordinator._phase_for_time(0.0)
coordinator.scheduler.spawn_rate_multiplier = float(_difficulty()["spawn"])
_connect_radio()
_connect_crowd()
_last_phase = -1
_apply_phase_lighting()
## Begin (or resume) the shift clock and recapture the mouse.
func _start_shift() -> void:
_shift_running = true
if audio:
audio.start_crowd_bed()
audio.set_crowd_level(coordinator.crowd.mood)
coordinator.start()
if hud:
hud.set_shift_state(coordinator, selected_scenario)
_maybe_start_tutorial()
# Leaving the venue menu clears input_locked and returns the mouse to
# captured mode; without this the guard stays frozen until another modal
# opens and closes.
_refresh_freeze()
func _phase_start_s(phase: int) -> float:
if selected_scenario == null:
return 0.0
return float((selected_scenario.shift_phases.get(phase, {}) as Dictionary).get("start_s", 0.0))
func _show_start_menu() -> void:
_shift_running = false
_settings_from_start_menu = false
if pause_overlay:
pause_overlay.close()
if settings_panel:
settings_panel.close()
if help_overlay:
help_overlay.close_panel()
if event_timeline:
event_timeline.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(campaign.summary() if campaign else {})
func _on_venue_selected(scenario_id: String) -> void:
if start_menu and start_menu.visible:
start_menu.close()
if not _resuming and Save:
Save.clear_shift_state()
_start_phase_override = -1
if scenario_id == HOLLYWOOD_NIGHT_ID:
_start_phase_override = Rules.Phase.LATE
scenario_id = "hollywood_building"
active_scenario_id = scenario_id
_venue_index = maxi(0, VENUE_ORDER.find(scenario_id))
selected_scenario = _build_scenario(scenario_id)
_rebuild_world_for_venue(scenario_id)
if hud:
hud.set_wand_icon(false)
_reset_shift_state()
_start_shift()
if not _resume_pending.is_empty():
var pending := _resume_pending
_resume_pending = {}
_restore_shift_state(pending)
_resuming = false
## Apply loaded settings to the guard and HUD.
func _difficulty() -> Dictionary:
return DIFFICULTY.get(str(player_settings.get("ai_difficulty", "normal")),
DIFFICULTY["normal"])
func _apply_settings(settings: Dictionary) -> void:
player_settings = settings
if guard:
guard.look_sensitivity = float(settings.get("look_sensitivity", 0.0022))
guard.invert_y = bool(settings.get("invert_y", false))
guard.head_bob_enabled = bool(settings.get("head_bob", true)) and not bool(settings.get("reduce_motion", false))
if audio:
audio.set_volumes(float(settings.get("master_volume", 1.0)),
float(settings.get("sfx_volume", 1.0)), float(settings.get("ambience_volume", 1.0)))
InputBindings.apply(settings.get("input_bindings", {}))
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 campaign:
campaign.set_difficulty(str(settings.get("ai_difficulty", "normal")))
if Save:
Save.save_campaign(campaign.summary())
if coordinator:
coordinator.scheduler.spawn_rate_multiplier = float(_difficulty()["spawn"])
# A settings panel opened from the venue menu is restored by
# `_on_settings_closed`; an in-shift panel returns to pause. Close it before
# opening pause so the new top-modal pass cannot cancel it twice.
if _shift_running:
if settings_panel:
settings_panel.close()
_open_pause()
func _on_settings_closed() -> void:
if _settings_from_start_menu:
_settings_from_start_menu = false
_show_start_menu()
else:
_refresh_freeze()
## Clear the tutorial completion/skip flags and restart the Hollywood shift so
## the next player can replay the first-shift lesson from Settings.
func _on_reset_campaign() -> void:
if Save:
Save.reset_campaign()
if campaign:
campaign.setup({})
_settings_from_start_menu = false
if settings_panel:
settings_panel.close()
if pause_overlay:
pause_overlay.close()
_show_start_menu()
func _on_replay_tutorial() -> void:
player_settings["tutorial_completed"] = false
player_settings["tutorial_skipped"] = false
if Save:
Save.save_settings(player_settings)
_settings_from_start_menu = false
if settings_panel:
settings_panel.close()
if pause_overlay:
pause_overlay.close()
_on_venue_selected("hollywood_building")
func _open_help() -> void:
if help_overlay:
help_overlay.open(selected_scenario.policy_summary if selected_scenario else "")
func _open_settings() -> void:
_settings_from_start_menu = start_menu != null and start_menu.visible
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
_save_shift_checkpoint()
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:
hud = Hud.new()
add_child(hud)
_add_crosshair()
audio = AudioFeedback.new()
add_child(audio)
radial = ActionRadial.new()
add_child(radial)
radial.confirmed.connect(_on_radial_confirmed)
radial.cancelled.connect(_cancel_radial)
radio_panel = RadioPanel.new()
add_child(radio_panel)
radio_panel.hide()
radio_panel.submitted.connect(_on_radio_submitted)
radio_panel.cancelled.connect(func():
radio_panel.hide()
if audio:
audio.play_radio_close()
hud.set_action_hint("")
_refresh_freeze())
shift_review_panel = ShiftReview.new()
add_child(shift_review_panel)
shift_review_panel.hide()
shift_review_panel.restart_requested.connect(_on_review_restart)
shift_review_panel.continue_requested.connect(_on_review_continue)
shift_review_panel.timeline_requested.connect(_open_timeline)
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)
settings_panel.replay_tutorial_requested.connect(_on_replay_tutorial)
settings_panel.reset_campaign_requested.connect(_on_reset_campaign)
settings_panel.closed.connect(_on_settings_closed)
event_timeline = EventTimeline.new()
add_child(event_timeline)
event_timeline.hide()
event_timeline.closed.connect(_on_timeline_closed)
tutorial_overlay = TutorialOverlay.new()
add_child(tutorial_overlay)
help_overlay = HelpOverlay.new()
add_child(help_overlay)
help_overlay.closed.connect(_refresh_freeze)
start_menu = StartMenu.new()
add_child(start_menu)
start_menu.hide()
start_menu.venue_selected.connect(_on_venue_selected)
start_menu.continue_requested.connect(_on_campaign_continue)
start_menu.help_requested.connect(_open_help)
start_menu.settings_requested.connect(_open_settings)
start_menu.quit_requested.connect(func(): get_tree().quit())
## Body-camera timeline from the review screen (ADR-0005 events, no 3D replay).
func _open_timeline(review: Dictionary) -> void:
if event_timeline == null:
return
# Hide the review dim so the timeline is not stacked under two overlays.
_timeline_from_review = shift_review_panel != null and shift_review_panel.visible
if shift_review_panel:
shift_review_panel.visible = false
event_timeline.show_timeline(review)
_refresh_freeze()
func _on_timeline_closed() -> void:
if _timeline_from_review and shift_review_panel:
shift_review_panel.visible = true
_timeline_from_review = false
_refresh_freeze()
## Pause-safe checkpoint: enough state to resume the current shift without
## replaying completed interactions. The scheduler RNG is restored so future
## routine traffic does not repeat the same sequence.
func _save_shift_checkpoint() -> void:
if Save == null or not _shift_running or coordinator == null:
return
var npc_rows: Array = []
for avatar in npcs:
if not is_instance_valid(avatar):
continue
var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null
var profile: Object = controller.profile if controller else null
npc_rows.append({
"npc_id": str(controller.npc_id) if controller else "",
"npc_type": int(profile.npc_type) if profile else Rules.NpcType.TOURIST,
"profile_id": str(profile.id) if profile else "",
"intent": str(controller.intent) if controller else "roam",
"position": _vec3_array(avatar.global_position),
"target": _vec3_array(avatar.target_position),
"carried_items": controller.profile.carried_items.duplicate() if controller and controller.profile else [],
"leave_requested": bool(controller.leave_requested) if controller else false,
"player_recognized": bool(controller.player_recognized) if controller else false,
"access_override": (controller.access_override as Dictionary).duplicate(true) if controller else {},
"ephemeral": bool(avatar.ephemeral),
"lifetime_s": float(avatar.lifetime_s),
})
var _save_err := Save.save_shift_state({
"version": 1,
"scenario_id": active_scenario_id,
"guard_position": _vec3_array(guard.global_position) if guard else [0.0, 0.0, 0.0],
"guard_yaw": guard.rotation.y if guard else 0.0,
"head_pitch": guard.head.rotation.x if guard else 0.0,
"door_locked": door.locked,
"flashlight_on": flashlight_on,
"body_camera_on": body_camera_on,
"npc_counter": npc_counter,
"coordinator": coordinator.snapshot(),
"npcs": npc_rows,
})
func _vec3_array(value: Vector3) -> Array:
return [value.x, value.y, value.z]
func _vec3_from(value: Variant, fallback: Vector3 = Vector3.ZERO) -> Vector3:
if value is Vector3:
return value
if value is Array and (value as Array).size() >= 3:
return Vector3(float(value[0]), float(value[1]), float(value[2]))
return fallback
func _resume_shift(state: Dictionary) -> void:
if state.is_empty() or not state.has("scenario_id"):
return
_resume_pending = state
_resuming = true
_on_venue_selected(str(state["scenario_id"]))
func _restore_shift_state(state: Dictionary) -> void:
if coordinator == null or not state.has("coordinator"):
return
coordinator.restore(state.get("coordinator", {}))
coordinator.running = true
npc_counter = int(state.get("npc_counter", 0))
npcs.clear()
for row in state.get("npcs", []):
if not (row is Dictionary):
continue
var data: Dictionary = row
var profile: NpcProfile = selected_scenario.profile_for(int(data.get("npc_type", Rules.NpcType.TOURIST)))
if profile == null:
profile = NpcProfile.new()
profile.id = str(data.get("profile_id", "guest"))
profile.display_name = str(data.get("profile_id", "Guest")).replace("_", " ").capitalize()
profile.npc_type = int(data.get("npc_type", Rules.NpcType.TOURIST))
_spawn_npc(profile, str(data.get("intent", "roam")), str(data.get("npc_id", "resumed")),
{"kind": "scripted"})
if npcs.is_empty():
continue
var avatar: Node3D = npcs[npcs.size() - 1]
avatar.global_position = _vec3_from(data.get("position"), avatar.global_position)
avatar.target_position = _vec3_from(data.get("target"), avatar.target_position)
var controller: Object = avatar.get_npc_controller()
if controller:
controller.leave_requested = bool(data.get("leave_requested", false))
controller.player_recognized = bool(data.get("player_recognized", false))
controller.access_override = (data.get("access_override", {}) as Dictionary).duplicate(true)
controller.profile.carried_items = []
for item in data.get("carried_items", []):
controller.profile.carried_items.append(str(item))
avatar.ephemeral = bool(data.get("ephemeral", false))
avatar.lifetime_s = float(data.get("lifetime_s", 60.0))
# Restore presentation state after the world/NPC reset.
if guard:
guard.global_position = _vec3_from(state.get("guard_position"), guard.global_position)
guard.rotation.y = float(state.get("guard_yaw", guard.rotation.y))
guard.head.rotation.x = float(state.get("head_pitch", 0.0))
door.set_locked(bool(state.get("door_locked", false)))
if world_builder:
world_builder.set_door_locked(door.locked)
flashlight_on = bool(state.get("flashlight_on", false))
body_camera_on = bool(state.get("body_camera_on", false))
if hud:
hud.set_door(door.locked)
hud.set_flashlight(flashlight_on)
hud.set_body_camera(body_camera_on)
if guard:
var lamp := guard.get_node_or_null("Head/Flashlight") as SpotLight3D
if lamp:
lamp.visible = flashlight_on
_last_phase = -1
_apply_phase_lighting()
_refresh_freeze()
## "Run another shift" on the review screen returns to the venue menu.
func _on_review_restart() -> void:
_show_start_menu()
## Campaign "continue" starts the next venue; completing the last venue returns
## to the free-play menu.
func _on_review_continue() -> void:
if shift_review_panel:
shift_review_panel.visible = false
var next_id := campaign.next_venue() if campaign else ""
if next_id == "":
_show_start_menu()
return
_on_venue_selected(next_id)
func _on_campaign_continue() -> void:
var saved := Save.load_shift_state() if Save else {}
if not saved.is_empty() and saved.has("scenario_id"):
_resume_shift(saved)
return
if campaign == null:
return
var next_id := campaign.next_venue()
if next_id == "":
_show_start_menu()
else:
_on_venue_selected(next_id)
func _add_crosshair() -> void:
var layer := CanvasLayer.new()
layer.layer = 9
layer.name = "Crosshair"
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
layer.add_child(center)
var box := Control.new()
box.custom_minimum_size = Vector2(16, 16)
box.mouse_filter = Control.MOUSE_FILTER_IGNORE
center.add_child(box)
var h := ColorRect.new()
h.color = Color(1, 1, 1, 0.75)
h.size = Vector2(12, 2)
h.position = Vector2(2, 7)
h.mouse_filter = Control.MOUSE_FILTER_IGNORE
box.add_child(h)
var v := ColorRect.new()
v.color = Color(1, 1, 1, 0.75)
v.size = Vector2(2, 12)
v.position = Vector2(7, 2)
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
box.add_child(v)
add_child(layer)
func _ensure_lighting() -> void:
_sun = DirectionalLight3D.new()
_sun.name = "Sun"
_sun.rotation_degrees = Vector3(-50, -30, 0)
_sun.light_energy = 1.2
_sun.shadow_enabled = true
_sun.light_angular_distance = 1.2
_sun.directional_shadow_mode = DirectionalLight3D.SHADOW_PARALLEL_4_SPLITS
_sun.directional_shadow_max_distance = 60.0
scene_root.add_child(_sun)
# A soft sky-bounce fill from the north keeps the shaded Ovation frontage
# readable without adding a second set of hard shadows.
_fill = DirectionalLight3D.new()
_fill.name = "SkyFill"
_fill.rotation_degrees = Vector3(-24, 180, 0)
_fill.light_energy = 0.2
_fill.light_color = Color(0.62, 0.72, 0.9)
_fill.shadow_enabled = false
scene_root.add_child(_fill)
_env_node = WorldEnvironment.new()
var env := Environment.new()
env.background_mode = Environment.BG_SKY
_sky = ProceduralSkyMaterial.new()
var sky := Sky.new()
sky.sky_material = _sky
env.sky = sky
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
# Tonemap, glow, fog and colour grading live in SurfaceMaterials so the
# world builder and the environment tune-up stay in one place.
Materials.configure_environment(env, PHASE_LIGHTING[Rules.Phase.EARLY])
_env_node.environment = env
scene_root.add_child(_env_node)
## Apply the lighting look for the current shift phase (sun, sky, ambient,
## street lamps). Runs on phase change and once at shift start.
func _apply_phase_lighting() -> void:
var phase: int = coordinator.current_phase if coordinator else Rules.Phase.EARLY
var cfg: Dictionary = PHASE_LIGHTING.get(phase, PHASE_LIGHTING[Rules.Phase.EARLY])
if _sun:
_sun.light_energy = float(cfg.get("sun_energy", 1.0))
_sun.light_color = cfg.get("sun_color", Color.WHITE)
# `sun_yaw` sets which side of the street the light comes from; the
# entrance view reads as shade when the sun is off the facade.
_sun.rotation_degrees = Vector3(float(cfg.get("sun_angle", -50.0)),
float(cfg.get("sun_yaw", -30.0)), 0)
if _fill:
_fill.light_energy = float(cfg.get("fill_energy", 0.16))
if _sky:
_sky.sky_top_color = cfg.get("sky_top", Color.BLUE)
_sky.sky_horizon_color = cfg.get("sky_horizon", Color.WHITE)
_sky.ground_bottom_color = cfg.get("sky_ground", Color(0.5, 0.5, 0.5))
_sky.ground_horizon_color = cfg.get("sky_horizon", Color.WHITE)
if _env_node and _env_node.environment:
Materials.configure_environment(_env_node.environment, cfg)
if world_builder:
# One call carries the skyline panorama, the wet/dry street and the
# night lighting, so the backdrop always matches the phase.
world_builder.set_phase(phase)
## Start the first-shift tutorial only for a real player run: scripted tests,
## captures and smokes pass --script and are explicitly exempt. A tutorial smoke
## can opt back in with a user `--tutorial` argument.
func _maybe_start_tutorial() -> void:
if tutorial_active or _resuming or active_scenario_id != "hollywood_building":
return
if bool(player_settings.get("tutorial_completed", false)) \
or bool(player_settings.get("tutorial_skipped", false)):
return
var scripted := false
var explicit := false
for arg in OS.get_cmdline_args():
scripted = scripted or str(arg) == "--script"
for arg in OS.get_cmdline_user_args():
explicit = explicit or str(arg) == "--tutorial"
if scripted and not explicit:
return
tutorial_active = true
tutorial = Tutorial.new()
tutorial.beat_changed.connect(_on_tutorial_beat)
tutorial.completed.connect(_on_tutorial_completed)
_tutorial_start_pos = guard.global_position if guard else Vector3.ZERO
_tutorial_start_yaw = guard.rotation.y if guard else 0.0
_tutorial_start_pitch = guard.head.rotation.x if guard else 0.0
_spawn_tutorial_visitor()
tutorial.start()
if tutorial_overlay:
tutorial_overlay.show_beat(_tutorial_prompt(tutorial.current()), tutorial.progress())
func _spawn_tutorial_visitor() -> void:
var p := NpcProfile.new()
p.id = "tutorial_visitor"
p.display_name = "Visitor"
p.npc_type = Rules.NpcType.AUTHORIZED_GUEST
p.temperament = 0.0
p.base_compliance = 1.0
p.reaction_delay = 0.15
p.dialogue_lines = ["I have a wristband for the event."]
_spawn_npc(p, "enter", "tutorial_visitor", {
"kind": "scripted",
"tutorial": true,
"override_access": {"has_wristband": true},
})
# Keep the one tutorial subject near the steps so focus/cycling is immediate.
if not npcs.is_empty():
var visitor: Node3D = npcs[npcs.size() - 1]
visitor.global_position = Vector3(2.0, 0.5, 6.0)
visitor.target_position = selected_scenario._location_or_default(
"entrance", Vector3(0.0, 0.5, 1.2))
func _tutorial_prompt(beat: Dictionary) -> String:
var key := str(beat.get("prompt_key", ""))
return Strings.lookup(key) if key != "" else str(beat.get("prompt", ""))
func _on_tutorial_beat(_index: int, beat: Dictionary) -> void:
if tutorial_overlay:
tutorial_overlay.show_beat(_tutorial_prompt(beat), tutorial.progress())
func _tutorial_advance(event: String) -> void:
if not tutorial_active or tutorial == null:
return
tutorial.advance(event)
func _on_tutorial_completed(skipped: bool) -> void:
if tutorial == null:
return
tutorial_active = false
player_settings["tutorial_completed"] = not skipped
player_settings["tutorial_skipped"] = skipped
if Save:
Save.save_settings(player_settings)
if tutorial_overlay:
tutorial_overlay.close()
if hud:
hud.log(Strings.lookup("tutorial.skipped" if skipped else "tutorial.completed"))
if not skipped:
# Let the visitor leave cleanly after the final lesson.
for avatar in npcs:
if is_instance_valid(avatar):
var controller: Object = avatar.get_npc_controller()
if controller and controller.npc_id == "tutorial_visitor":
controller.leave_requested = true
break
func _skip_tutorial() -> void:
if tutorial_active and tutorial:
tutorial.skip()
func _tutorial_tick() -> void:
if not tutorial_active or tutorial == null or guard == null:
return
if tutorial.current().get("id", "") != "arrive":
return
var moved := guard.global_position.distance_to(_tutorial_start_pos) > 0.75
var yaw := absf(wrapf(guard.rotation.y - _tutorial_start_yaw, -PI, PI))
var pitch := absf(guard.head.rotation.x - _tutorial_start_pitch)
if moved and (yaw > 0.05 or pitch > 0.05):
_tutorial_advance("moved")
func _spawn_npc(profile: NpcProfile, intent: String, npc_id: String, event: Dictionary = {}) -> void:
if tutorial_active and not bool(event.get("tutorial", false)):
return
_purge_npcs()
if npcs.size() >= MAX_LIVE_NPCS and event.get("kind", "routine") == "routine":
return
if npcs.size() >= MAX_LIVE_NPCS:
_evict_oldest_npc()
npc_counter += 1
var avatar := NpcAvatar.new()
avatar.name = "Npc_%d" % npc_counter
var spawn_side: float = 1.0 if npc_counter % 2 == 0 else -1.0
var spawn_x := spawn_side * (4.0 + float((npc_counter * 13) % 18))
var spawn_pos := Vector3(spawn_x, 0.5, 6.0 + float((npc_counter * 7) % 16))
# Per-intent waypoint (photo spots, queue, stage door, sidewalk exit) instead
# of every NPC walking to the entrance (TODO: Core Simulation — NPC Perception).
var target_pos: Vector3 = selected_scenario.waypoint_for_intent(intent)
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
var spawn_profile: NpcProfile = profile.duplicate(true) if profile else profile
var override: Dictionary = event.get("override_access", {})
var seed_value := npc_counter * 991 + 17
if spawn_profile and selected_scenario.access_policy.wand_screening_required:
var items: Array[String] = []
for item in AccessLoadout.roll_carried_items(seed_value, override):
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)
avatar.get_npc_controller().refuses_screening = bool(event.get("refuses_screening", false))
scene_root.add_child(avatar)
npcs.append(avatar)
# Authored events can point the player at the right verb (PLAN.md verbs).
if event.has("hint") and hud:
hud.log(Strings.lookup(str(event["hint"])))
# Celebrity arrivals draw extra bystanders, not just one avatar.
for extra in event.get("extra_spawns", []):
var extra_profile := selected_scenario.profile_for(extra.get("npc_type", Rules.NpcType.TOURIST))
if extra_profile == null or npcs.size() >= MAX_LIVE_NPCS:
continue
_spawn_npc(extra_profile, str(extra.get("intent", "photo")),
"%s_w%d" % [npc_id, npcs.size()], {"kind": "routine"})
## Drop references to freed avatars so the cap and Tab cycling stay accurate.
func _purge_npcs() -> void:
for i in range(npcs.size() - 1, -1, -1):
if not is_instance_valid(npcs[i]):
npcs.remove_at(i)
## Make room for a scripted event by removing the oldest NPC the player is not
## currently focused on.
func _evict_oldest_npc() -> void:
for i in npcs.size():
var npc: Node = npcs[i]
if npc == targeted_npc or npc == _focused_avatar:
continue
npcs.remove_at(i)
npc.queue_free()
return
## Spawn a dispatched responder avatar when a radio call's arrival delay elapses
## (TODO: Escalation and Radio — Responder Dispatch). The responder walks to the
## entrance and resolves the incident with command/restraint/evacuation/arrest.
func _on_responder_arrived(call: Dictionary) -> void:
var outcome: String = RadioSystem.responder_outcome(call)
var responder_profile := _responder_profile()
npc_counter += 1
var avatar := NpcAvatar.new()
avatar.name = "Responder_%d" % npc_counter
# Responder approaches from the street, toward the entrance.
var spawn_pos := Vector3(0.0, 0.5, 14.0)
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
avatar.setup(responder_profile, "respond", npc_counter * 991 + 17, spawn_pos, door_pos, door_pos)
scene_root.add_child(avatar)
npcs.append(avatar)
hud.log(Strings.fmt("responder.arrived", [outcome]))
coordinator.responder_outcomes.append(outcome)
coordinator.recorder.add_event(call.get("report", {}).get("npc_id", ""), {
"type": "responder_action",
"outcome": outcome,
"call_type": call.get("type", "assist"),
})
coordinator.crowd.apply_event("emergency_responder" if call.get("type", "") == "emergency" else "calm_professionalism")
if outcome == "evacuation":
_evacuate_entrance()
## Responder outcome "evacuation": the entrance is cleared and the door stays
## locked while the block is secured (PLAN.md: Level 5 Emergency Response).
func _evacuate_entrance() -> void:
if door and not door.locked:
door.set_locked(true)
if world_builder:
world_builder.set_door_locked(true)
if hud:
hud.set_door(true)
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
for npc in npcs:
if not is_instance_valid(npc):
continue
if npc.has_method("start_flee"):
npc.start_flee(door_pos)
var controller: Object = npc.get_npc_controller() if npc.has_method("get_npc_controller") else null
if controller != null:
coordinator.recorder.add_event(controller.npc_id, {
"type": "evacuation",
"outcome": "evacuated",
})
if controller.leave_requested == false:
controller.leave_requested = true
coordinator.reputation.apply("security_failure", -3.0, "entrance evacuated by responder")
if hud:
hud.log(Strings.lookup("responder.evacuated"))
## Responder dispatch. `coordinator.setup()` builds a new RadioSystem for each
## shift, so the arrival signal must be re-connected after every setup or radio
## calls are silently resolved by nobody.
func _connect_radio() -> void:
if coordinator == null or coordinator.radio == null:
return
if not coordinator.radio.responder_arrived.is_connected(_on_responder_arrived):
coordinator.radio.responder_arrived.connect(_on_responder_arrived)
## Crowd presentation (PLAN: Public Crowd Reaction). The crowd system is a
## mood/attention float; this turns its events into avatar behavior.
func _connect_crowd() -> void:
# CrowdSystem routes its updates through the EventBus (`event_bus.crowd_changed`);
# its own `changed` signal is not emitted by apply_event().
if coordinator == null or coordinator.event_bus == null:
return
if not coordinator.event_bus.crowd_changed.is_connected(_on_crowd_changed):
coordinator.event_bus.crowd_changed.connect(_on_crowd_changed)
func _on_crowd_changed(payload: Dictionary) -> void:
var event_type := str(payload.get("event", ""))
var panic := bool(payload.get("panic", false))
if audio:
audio.set_crowd_level(float(payload.get("mood", 0.0)))
# Panic wins: people clear away from the entrance.
if panic or event_type == "crowd_panic":
_flee_crowd()
return
if CrowdSystem.reaction_for_event(event_type) == "watch":
_rubberneck_crowd()
func _incident_point() -> Vector3:
if targeted_npc is Node3D and is_instance_valid(targeted_npc):
return (targeted_npc as Node3D).global_position
if guard:
return guard.global_position
return Vector3(0, 0, 1.2)
## Idle bystanders stop and face the incident instead of walking on.
func _rubberneck_crowd() -> void:
var point := _incident_point()
for npc in npcs:
if not is_instance_valid(npc) or not npc.has_method("start_rubberneck"):
continue
if npc == targeted_npc:
continue
npc.start_rubberneck(point)
## Panic: everyone nearby moves away from the door.
func _flee_crowd() -> void:
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
for npc in npcs:
if not is_instance_valid(npc) or not npc.has_method("start_flee"):
continue
npc.start_flee(door_pos)
func _responder_profile() -> NpcProfile:
var p := NpcProfile.new()
p.id = "responder"
p.display_name = "Responder"
p.npc_type = Rules.NpcType.RESPONDER
p.temperament = 0.1
p.base_compliance = 1.0
return p
func _on_shift_ended(review: Dictionary) -> void:
_shift_running = false
if audio:
audio.stop_crowd_bed()
# Persist the finished shift locally (Save autoload, user:// JSON).
if Save:
Save.save_shift_result(review)
Save.clear_shift_state()
if campaign:
campaign.record_shift(active_scenario_id, review)
if Save:
Save.save_campaign(campaign.summary())
shift_review_panel.show_review(review, selected_scenario,
Save.load_shift_results() if Save else [],
campaign.summary() if campaign else {})
_tutorial_advance("review_opened")
_refresh_freeze()
## Keep the people waiting at the door in a deterministic queue: nearest first,
## lateral slots behind the door, so they do not stack on one point.
func _process(delta: float) -> void:
if coordinator == null or npcs.is_empty():
return
_queue_tick -= delta
if _queue_tick > 0.0:
return
_queue_tick = 0.3
_refresh_queue()
func _refresh_queue() -> void:
var waiting: Array = []
for node in npcs:
if not is_instance_valid(node) or node.npc == null:
continue
if node.awaiting_interaction and not node.npc.leave_requested:
waiting.append(node)
else:
node.set_queue_offset(Vector3.ZERO)
waiting.sort_custom(func(a, b) -> bool:
var da: float = a.global_position.distance_to(a.door_position)
var db: float = b.global_position.distance_to(b.door_position)
return da < db)
for index in waiting.size():
waiting[index].set_queue_offset(NpcAvatar.queue_slot_for(index))
func _on_guard_target(target: Node3D) -> void:
targeted_npc = target
_set_focused_avatar(target)
_begin_incident(target)
_refresh_nearby_hint()
func _on_guard_clear() -> void:
targeted_npc = null
_set_focused_avatar(null)
hud.set_target_label("")
hud.set_action_hint("")
_refresh_nearby_hint()
func _set_focused_avatar(avatar: Node3D) -> void:
if _focused_avatar and is_instance_valid(_focused_avatar) and _focused_avatar.has_method("set_focus_visual"):
_focused_avatar.set_focus_visual(false, false)
_focused_avatar = avatar
var menu_open := radial != null and radial.is_open
if avatar and avatar.has_method("set_focus_visual"):
avatar.set_focus_visual(true, menu_open)
func _begin_incident(avatar: Node3D) -> void:
var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null
if controller == null:
return
current_incident_npc_id = controller.npc_id
current_incident_active = true
current_npc_truth = _read_npc_truth(controller)
# Open a structured incident so player actions, access evidence, and
# escalation levels are actually recorded (ADR-0005). Without this the
# recorder drops every event and the review reports zero incidents.
if not coordinator.recorder.has_open_incident(controller.npc_id):
coordinator.recorder.start_incident(controller.npc_id, "player_focus")
var profile: Object = controller.profile
var label := "%s — %s" % [
profile.display_name if profile else "?",
controller.intent,
]
hud.set_target_label(label)
var hint := ""
if profile and profile.npc_type == Rules.NpcType.VIOLENT_THREAT:
hint = "High risk: threaten/force-entry likely. Emergency radio (R) is justified."
elif profile and profile.npc_type == Rules.NpcType.MENTAL_CRISIS:
hint = "Requires patience. Avoid aggressive confrontation."
elif profile and profile.npc_type == Rules.NpcType.AUTHORIZED_GUEST:
hint = "Likely authorized. Press E to verify access."
hud.set_action_hint(hint)
## Bind the remaining gameplay keys as named actions, so remaps can target them
## (and gamepads can grow onto them). Existing actions are left untouched.
static func _ensure_action_bindings() -> void:
var defaults := {
"polite_direction": KEY_Q,
"toggle_debug": KEY_F,
"toggle_flashlight": KEY_G,
"door_lock": KEY_L,
"body_camera": KEY_B,
"cycle_venue": KEY_V,
}
for action in defaults.keys():
if not InputMap.has_action(action):
InputMap.add_action(action)
var has_key := false
for event in InputMap.action_get_events(action):
if event is InputEventKey:
has_key = true
if has_key:
continue
var key := InputEventKey.new()
key.physical_keycode = defaults[action]
InputMap.action_add_event(action, key)
if InputMap.has_action("cycle_target_next"):
var has_tab := false
for event in InputMap.action_get_events("cycle_target_next"):
if event is InputEventKey and event.physical_keycode == KEY_TAB:
has_tab = true
if not has_tab:
var tab := InputEventKey.new()
tab.physical_keycode = KEY_TAB
InputMap.action_add_event("cycle_target_next", tab)
# ------------------------------------------------------------------ input ---
func _unhandled_input(event: InputEvent) -> void:
_track_input_device(event)
if event is InputEventKey and event.echo:
return
if event.is_action_pressed("cancel"):
_handle_cancel(event is InputEventMouseButton)
return
if event is InputEventMouseButton and event.pressed:
_handle_mouse_button(event)
return
if event is InputEventKey and event.pressed:
_handle_key(event)
## Switch the control legend when the player picks up a gamepad or goes back to
## mouse/keyboard. Cheap: only the legend text changes.
func _track_input_device(event: InputEvent) -> void:
var device := ""
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
device = "gamepad"
elif event is InputEventKey or event is InputEventMouse:
device = "keyboard"
if device == "" or device == _input_device:
return
_input_device = device
if hud:
hud.set_input_device(device)
func _handle_mouse_button(event: InputEventMouseButton) -> void:
if event.is_action_pressed("cycle_target_next"):
_cycle_nearby_target(1)
return
if event.is_action_pressed("cycle_target_prev"):
_cycle_nearby_target(-1)
return
if event.button_index != MOUSE_BUTTON_LEFT:
return
if radial and radial.is_open:
return
if not _modal_blocks_radial() and targeted_npc != null:
_open_radial()
func _modal_blocks_radial() -> bool:
if inspection_open or wand_open:
return true
if radio_panel and radio_panel.visible:
return true
if shift_review_panel and shift_review_panel.visible:
return true
return false
func _handle_key(event: InputEvent) -> void:
var keycode: Key = event.keycode
if tutorial_active and keycode == KEY_F1:
_skip_tutorial()
return
if radial and radial.is_open:
if event.is_action_pressed("confirm"):
_confirm_radial()
return
match keycode:
KEY_A, KEY_LEFT:
radial.step(-1)
KEY_D, KEY_RIGHT:
radial.step(1)
return
if event.is_action_pressed("action_menu"):
if targeted_npc != null and not _modal_blocks_radial():
_open_radial()
return
if event.is_action_pressed("inspect"):
if targeted_npc != null and not inspection_open and not wand_open:
_toggle_inspection()
return
if event.is_action_pressed("wand"):
if targeted_npc != null and not inspection_open and not wand_open:
_toggle_wand_screening()
return
if event.is_action_pressed("polite_direction"):
if targeted_npc != null and not inspection_open:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction")
return
if event.is_action_pressed("cycle_target_next"):
_cycle_nearby_target(1)
return
if event.is_action_pressed("cycle_target_prev"):
_cycle_nearby_target(-1)
return
if event.is_action_pressed("toggle_debug"):
hud.toggle_debug()
return
if event.is_action_pressed("toggle_flashlight"):
_toggle_flashlight()
return
if event.is_action_pressed("door_lock"):
_toggle_door_lock()
return
if event.is_action_pressed("body_camera"):
_toggle_body_camera()
return
if event.is_action_pressed("cycle_venue"):
_cycle_venue()
return
_handle_action_key(event)
func _handle_action_key(event: InputEvent) -> void:
if event.is_action_pressed("gesture_raise_hand"):
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
elif event.is_action_pressed("gesture_point"):
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
elif event.is_action_pressed("gesture_head_shake"):
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake")
elif event.is_action_pressed("escalate_warning"):
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
elif event.is_action_pressed("escalate_block"):
_apply_interaction(Rules.Level.BLOCKING, "blocking")
elif event.is_action_pressed("radio_assist"):
_open_radio(false)
elif event.is_action_pressed("radio_emergency"):
_open_radio(true)
elif event.is_action_pressed("move_left"):
if inspection_open:
decide_access(true)
elif wand_open:
decide_wand(true)
elif event.is_action_pressed("move_right"):
if inspection_open:
decide_access(false)
elif wand_open:
decide_wand(false)
## `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:
# Right click with nothing open drops the current target, so the player can
# break focus without walking or looking away.
if targeted_npc != null:
_clear_focus()
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:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func _close_top_modal() -> bool:
if radial and radial.is_open:
_cancel_radial()
return true
if inspection_open:
hud.hide_inspection()
inspection_open = false
_refresh_freeze()
return true
if wand_open:
hud.hide_wand()
wand_open = false
_refresh_freeze()
return true
if radio_panel and radio_panel.visible:
radio_panel.hide()
if audio:
audio.play_radio_close()
hud.set_action_hint("")
_refresh_freeze()
return true
if event_timeline and event_timeline.visible:
event_timeline.close()
return true
if help_overlay and help_overlay.visible:
help_overlay.close_panel()
return true
if settings_panel and settings_panel.visible:
settings_panel._on_cancel()
return true
if pause_overlay and pause_overlay.visible:
_close_pause()
return true
if shift_review_panel and shift_review_panel.visible:
_on_review_restart()
return true
return false
func _open_radial() -> void:
if targeted_npc == null:
hud.log(Strings.lookup("hud.aim_first"))
return
var controller: Object = _targeted_controller()
var p_name := "Person"
if controller and controller.profile:
p_name = str(controller.profile.display_name)
var wand_ok := selected_scenario != null and selected_scenario.equipment.has("wand")
radial.open_menu(p_name, wand_ok)
_tutorial_advance("wheel_opened")
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
targeted_npc.set_focus_visual(true, true)
_refresh_freeze()
func _confirm_radial() -> void:
if radial == null or not radial.is_open:
return
var slice := radial.confirm_selected()
radial.close_menu()
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
targeted_npc.set_focus_visual(true, false)
_refresh_freeze()
if slice != "":
_on_radial_confirmed(slice)
func _cancel_radial() -> void:
if radial == null or not radial.is_open:
return
radial.close_menu()
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
targeted_npc.set_focus_visual(true, false)
_refresh_freeze()
func _on_radial_confirmed(slice_id: String) -> void:
if radial and radial.is_open:
radial.close_menu()
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
targeted_npc.set_focus_visual(true, false)
_refresh_freeze()
match slice_id:
"raise_hand":
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
"eye_contact":
_eye_contact()
"point":
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
"head_shake":
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake")
"polite":
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction")
"inspect":
_toggle_inspection()
"wand":
_toggle_wand_screening()
"warning":
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
"block":
_apply_interaction(Rules.Level.BLOCKING, "blocking")
"reason":
_request_reason()
"recognize":
_recognize_target()
"radio":
_open_radio(false)
func _refresh_freeze() -> void:
var freeze := inspection_open or wand_open
if radial and radial.is_open:
freeze = true
if radio_panel and radio_panel.visible:
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 event_timeline and event_timeline.visible:
freeze = true
if not _shift_running:
freeze = true
if guard:
guard.input_locked = freeze
if freeze:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
else:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
## Toggle the guard's flashlight. In evening/late phases it widens NPC
## perception radius (PLAN.md: Level 3 — flashlight at night).
func _toggle_flashlight() -> void:
flashlight_on = not flashlight_on
var lamp: SpotLight3D = guard.get_node_or_null("Head/Flashlight") as SpotLight3D
if lamp:
lamp.visible = flashlight_on
hud.set_flashlight(flashlight_on)
hud.log(Strings.lookup("flashlight.on") if flashlight_on else Strings.lookup("flashlight.off"))
## Toggle the body camera (PLAN Level 2). The overlay is only visible while the
## camera is on, and turning it on or off is written to the incident log.
func _toggle_body_camera() -> void:
_set_body_camera(not body_camera_on, "manual")
func _set_body_camera(on: bool, reason: String) -> void:
if body_camera_on == on:
return
body_camera_on = on
if on:
_tutorial_advance("body_camera")
if hud:
hud.set_body_camera(on)
var controller: Object = _targeted_controller()
if controller != null:
_ensure_incident(controller)
if coordinator and current_incident_active:
coordinator.recorder.add_event(current_incident_npc_id, {
"type": "body_camera",
"on": on,
"reason": reason,
})
if hud:
hud.log(Strings.lookup("bodycam.on") if on else Strings.lookup("bodycam.off"))
## Lock or unlock the restricted door (PLAN.md: Level 3). Locked doors thwart
## FORCE_ENTRY attempts during the reaction flow.
func _toggle_door_lock() -> void:
door.toggle()
if world_builder:
world_builder.set_door_locked(door.locked)
hud.set_door(door.locked)
hud.log(Strings.lookup("door.locked") if door.locked else Strings.lookup("door.unlocked"))
## Cycle to the next venue (Hollywood Blvd → The Joke Shop → TV Studio).
## The coordinator is rebuilt with the new scenario and the shift restarts.
func _cycle_venue() -> void:
_venue_index = (_venue_index + 1) % VENUE_ORDER.size()
switch_scenario(VENUE_ORDER[_venue_index])
var venue_name: String = selected_scenario.display_name if selected_scenario else "?"
hud.log(Strings.fmt("hud.venue_switched", [venue_name]))
hud.set_wand_icon(false)
hud.set_shift_state(coordinator, selected_scenario)
## Aim-free targeting: cycle through NPCs near the guard (Tab, mouse wheel).
## Lets players interact without precise aiming (accessibility) and pins the
## target so the focus cone does not drop someone the player is not looking at.
func _cycle_nearby_target(direction: int = 1) -> void:
if guard == null:
return
var next_target: Node3D = guard.cycle_target(direction)
if next_target != null:
_tutorial_advance("cycled")
if next_target == null:
hud.log(Strings.lookup("hud.no_one_nearby"))
_refresh_nearby_hint()
return
guard.pin_target(next_target)
var controller: Object = _targeted_controller()
var who: String = next_target.name
if controller and controller.profile and controller.profile.display_name != "":
who = str(controller.profile.display_name)
hud.log(Strings.fmt("hud.targeting", [who]))
_refresh_nearby_hint()
## Drop the focus and hand selection back to the aim cone (right click).
func _clear_focus() -> void:
if guard:
guard.clear_target()
targeted_npc = null
_set_focused_avatar(null)
if hud:
hud.set_target_label("")
hud.set_action_hint("")
_refresh_nearby_hint()
## How many people the aim-free ring can cycle through right now.
func _refresh_nearby_hint() -> void:
if hud == null or guard == null:
return
hud.set_nearby_hint(guard.nearby_candidates().size())
func _apply_interaction(level: int, action_name: String) -> void:
if targeted_npc == null:
hud.log(Strings.lookup("hud.aim_first"))
return
var controller: Object = _targeted_controller()
if controller == null or coordinator == null:
return
_ensure_incident(controller)
var outcome: Dictionary = controller.apply_player_action(
level, float(_difficulty()["compliance"]))
var npc_id: String = controller.npc_id
coordinator.recorder.add_event(npc_id, {
"type": "player_action",
"action": action_name,
"level": level,
})
coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id])
if level >= Rules.Level.FORMAL_WARNING:
coordinator.reputation.apply("prop", -1.5, "escalation L%d %s" % [level, action_name])
# A formal warning is the documented point where the body camera / security
# light goes on: it creates a record of the interaction (PLAN Level 2).
if level == Rules.Level.FORMAL_WARNING and not body_camera_on:
_set_body_camera(true, "formal warning")
if level == Rules.Level.SIMPLE_DETERRENCE and action_name == "polite direction":
_tutorial_advance("polite_direction")
if level == Rules.Level.FORMAL_WARNING:
_tutorial_advance("formal_warning")
# Blocking also means physically standing between the person and the door
# (PLAN Level 3), not just sending the escalation level.
if level == Rules.Level.BLOCKING:
_station_between(controller, controller.profile.display_name if controller.profile else "them")
hud.set_escalation(level)
if audio:
audio.play_escalation(level)
var context := ""
if outcome.get("reaction", -1) in [Rules.Reaction.COMPLY, Rules.Reaction.LEAVE]:
context = "deescalation"
elif outcome.get("reaction", -1) in [Rules.Reaction.ARGUE, Rules.Reaction.RECORD]:
context = "refusal"
if context != "":
var line: String = controller.context_line(context)
if line != "":
hud.log("\"%s\"" % line)
_react_to_outcome(controller, outcome)
## Level 1 eye contact: a distinct, quieter cue than raise-hand (PLAN.md). It
## uses the same Level 1 reaction table but is logged as its own action.
func _eye_contact() -> void:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "eye contact")
## "I recognize this person" is the player's own decision, recorded as access
## evidence rather than pre-attached to every staff/guest type (PLAN.md).
func _recognize_target() -> void:
var controller: Object = _targeted_controller()
if controller == null:
hud.log(Strings.lookup("hud.aim_first"))
return
var name: String = controller.profile.display_name if controller.profile else "them"
if controller.player_recognized:
hud.log(Strings.fmt("recognition.already", [name]))
return
_ensure_incident(controller)
controller.player_recognized = true
var recognition_line: String = controller.context_line("recognition")
if recognition_line != "":
hud.log("\"%s\"" % recognition_line)
coordinator.recorder.add_access_evidence(controller.npc_id, {
"kind": "recognition",
"source": "guard_decision",
"recognized": true,
})
coordinator.recorder.add_event(controller.npc_id, {
"type": "recognition_decision",
"recognized": true,
"source": "player",
})
hud.log(Strings.fmt("recognition.declared", [name]))
## Level 3 ask: request the person's reason for entry. The reply comes from the
## NPC's authored `dialogue_lines`; silence is a valid, telling answer.
func _request_reason() -> void:
var controller: Object = _targeted_controller()
if controller == null:
hud.log(Strings.lookup("hud.aim_first"))
return
var name: String = controller.profile.display_name if controller.profile else "They"
_ensure_incident(controller)
var reply: String = controller.request_reason()
coordinator.recorder.add_event(controller.npc_id, {
"type": "reason_requested",
"reply": reply,
})
hud.set_escalation(Rules.Level.BLOCKING)
hud.log(Strings.fmt("reason.ask", [name]))
if reply == "":
hud.log(Strings.fmt("reason.silent", [name]))
else:
hud.log(Strings.fmt("reason.reply", [name, reply]))
## Physically move the guard between the person and the door (PLAN Level 3).
func _station_between(controller: Object, name: String) -> void:
if guard == null or targeted_npc == null:
return
var npc_pos: Vector3 = (targeted_npc as Node3D).global_position
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
var to_door := door_pos - npc_pos
to_door.y = 0.0
if to_door.length() < 0.05:
return
var station: Vector3 = npc_pos + to_door.normalized() * 0.9
station.y = guard.global_position.y
guard.global_position = station
var look_at_point := Vector3(npc_pos.x, guard.global_position.y, npc_pos.z)
if (look_at_point - guard.global_position).length() > 0.05:
guard.look_at(look_at_point, Vector3.UP)
hud.log(Strings.fmt("station.blocking", [name]))
func _react_to_outcome(controller: Object, outcome: Dictionary) -> void:
var reaction: int = outcome.get("reaction", -1)
var name: String = (controller.profile.display_name if controller.profile else "Person")
match reaction:
Rules.Reaction.COMPLY:
hud.log(Strings.fmt("react.comply", [name]))
coordinator.crowd.apply_event("de_escalation")
coordinator.reputation.apply("peaceful", 2.0, "resolved peacefully")
_close_incident("peaceful")
Rules.Reaction.LEAVE:
hud.log(Strings.fmt("react.leave", [name]))
coordinator.crowd.apply_event("calm_professionalism")
_close_incident("left")
Rules.Reaction.ARGUE:
hud.log(Strings.fmt("react.argue", [name]))
coordinator.crowd.apply_event("aggressive_behavior")
Rules.Reaction.RECORD:
hud.log(Strings.fmt("react.record", [name]))
coordinator.reputation.apply("complaint", -3.0, "filmed interaction")
coordinator.crowd.apply_event("aggressive_behavior")
Rules.Reaction.IGNORE:
hud.log(Strings.fmt("react.ignore", [name]))
Rules.Reaction.THREATEN:
hud.log(Strings.fmt("react.threaten", [name]))
coordinator.crowd.apply_event("aggressive_behavior")
Rules.Reaction.FORCE_ENTRY:
var force_result: Dictionary = door.attempt_force()
if force_result.get("succeeded", false):
hud.log(Strings.fmt("react.force_entry", [name]))
coordinator.crowd.apply_event("emergency_responder")
coordinator.reputation.apply("security_failure", -5.0, "forced entry attempt")
else:
hud.log(Strings.fmt("door.thwarted", [name]))
coordinator.crowd.apply_event("aggressive_behavior")
coordinator.reputation.apply("prop", -2.0, "forcing attempt (locked door)")
Rules.Reaction.RETURN_LATER:
hud.log(Strings.fmt("react.return_later", [name]))
Rules.Reaction.CALL_SOMEONE:
hud.log(Strings.fmt("react.call_someone", [name]))
coordinator.crowd.apply_event("aggressive_behavior")
_:
hud.log(Strings.lookup("react.none"))
func _open_radio(emergency: bool) -> void:
if targeted_npc == null:
hud.log(Strings.lookup("radio.address_first"))
return
var controller: Object = _targeted_controller()
if controller == null:
return
radio_panel.open(controller, emergency, current_npc_truth)
hud.set_action_hint("Fill in the radio report on screen.")
if audio:
audio.play_radio_open()
_refresh_freeze()
func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
if audio:
audio.play_radio_close()
hud.set_action_hint("")
_refresh_freeze()
var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report)
if not result.get("ok", false):
hud.log(Strings.fmt("radio.blocked", [result.get("reason", "unknown")]))
return
_tutorial_advance("radio_submitted")
var hits: int = coordinator.radio.credibility_hits(report, current_npc_truth)
hits = int(ceil(float(hits) * float(_difficulty()["report"])))
if hits > 0:
coordinator.reputation.apply("false_report", -4.0 * hits, "inaccurate radio report")
hud.log(Strings.fmt("radio.inaccurate", [4 * hits]))
else:
hud.log(Strings.lookup("radio.received"))
if emergency:
coordinator.crowd.apply_event("emergency_responder")
hud.log(Strings.lookup("radio.emergency_dispatched"))
# A call that the situation did not justify is reviewed afterwards
# (PLAN.md: Level 5 consequences).
if _is_unjustified_emergency():
var who: String = "the person"
var controller: Object = _targeted_controller()
if controller and controller.profile:
who = str(controller.profile.display_name)
coordinator.investigations.append(Strings.fmt("investigation.unjustified", [who]))
coordinator.recorder.add_event(current_incident_npc_id, {
"type": "investigation_opened",
"reason": "unjustified emergency call",
})
coordinator.reputation.apply("false_report", -6.0, "unjustified emergency call")
hud.log(Strings.lookup("investigation.started"))
else:
coordinator.crowd.apply_event("calm_professionalism")
if audio:
audio.play_radio_response()
_maybe_radio_easter_egg()
## True when an emergency call is not supported by the situation: no weapon
## seen and low danger (PLAN.md: unjustified call consequences).
func _is_unjustified_emergency() -> bool:
var weapons := bool(current_npc_truth.get("weapons_seen", false))
var danger := int(current_npc_truth.get("danger_level", 0))
return not weapons and danger <= 1
## Rare hidden radio transmission: a stray voice asks "¿Dónde está la mota?"
## over the radio. Fires on a small random chance after a successful call.
func _maybe_radio_easter_egg() -> void:
if _door_rng.randf() < 0.025:
hud.log(Strings.lookup("radio.easter_egg"))
# ----------------------------------------------------------------- access --
func _toggle_inspection() -> void:
if inspection_open:
hud.hide_inspection()
inspection_open = false
_refresh_freeze()
return
var controller: Object = _targeted_controller()
if controller == null:
return
inspection_open = true
var artifacts := _artifacts_for(controller)
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy, controller.screened)
hud.show_inspection(controller, artifacts, result)
_tutorial_advance("inspected")
_refresh_freeze()
# Venue door-staff flavor: an applicable check line (PLAN.md: door-staff dialogue).
var line := _door_context_line()
if line != "":
hud.log(line)
# ------------------------------------------------------------- wand screening --
## Handheld wand inspection (The Joke Shop). Only available when the venue
## carries a wand. The wand reports an alert; the player interprets it.
func _toggle_wand_screening() -> void:
if _wand_cooldown > 0.0:
hud.log(Strings.lookup("wand.recharge"))
return
if wand_open:
hud.hide_wand()
hud.set_wand_icon(false)
wand_open = false
_refresh_freeze()
return
if not selected_scenario.equipment.has("wand"):
hud.log(Strings.lookup("hud.no_wand"))
hud.set_wand_icon(false)
return
hud.set_wand_icon(true)
var controller: Object = _targeted_controller()
if controller == null:
return
if controller.refuses_screening:
# Authored event: this guest refuses the screen (Joke Shop content).
hud.set_wand_icon(false)
hud.log(Strings.fmt("wand.refused", [controller.profile.display_name if controller.profile else "The guest"]))
coordinator.recorder.add_event(controller.npc_id, {
"type": "wand_refused",
"outcome": "refused",
})
coordinator.reputation.apply("prop", -1.0, "guest refused screening")
return
var profile: Object = controller.profile
var items: Array = profile.carried_items if profile else []
var result: Dictionary = wand_screener.screen(items)
wand_open = true
hud.show_wand(controller, result)
_refresh_freeze()
var wand_line := selected_scenario.door_staff_line("wand_check", _door_rng)
if wand_line != "":
hud.log(wand_line)
## Resolve a wand screening: the player decides whether to admit the guest.
func decide_wand(allowed: bool) -> void:
var controller: Object = _targeted_controller()
if controller == null:
hud.hide_wand()
wand_open = false
_refresh_freeze()
return
var profile: Object = controller.profile
var items: Array = profile.carried_items if profile else []
var result: Dictionary = wand_screener.screen(items)
var decision: Dictionary = wand_screener.decide(result, allowed)
coordinator.recorder.add_access_evidence(controller.npc_id, {
"type": "wand_screening",
"decided": allowed,
"prohibited": decision.get("prohibited", false),
"false_alarm": decision.get("false_alarm", false),
"item": decision.get("item", ""),
"correct": decision.get("correct", false),
})
var name: String = (profile.display_name if profile else "Person")
if decision.get("prohibited", false) and allowed:
hud.log(Strings.fmt("wand.admitted_prohibited", [name]))
coordinator.reputation.apply("security_failure", -5.0, "admitted prohibited item")
elif decision.get("prohibited", false) and not allowed:
var ban_line := selected_scenario.door_staff_line("prohibited_item", _door_rng)
if ban_line != "":
hud.log(ban_line)
coordinator.reputation.apply("access_judgment", 1.0, "correct prohibited-item refusal")
elif decision.get("false_alarm", false) and not allowed:
hud.log(Strings.fmt("wand.denied_false_alarm", [name]))
coordinator.reputation.apply("complaint", -2.0, "false-alarm refusal")
elif allowed:
hud.log(Strings.fmt("wand.cleared", [name]))
coordinator.reputation.apply("access_judgment", 1.0, "correct wand admission")
else:
hud.log(Strings.fmt("wand.refused", [name]))
coordinator.reputation.apply("access_judgment", 1.0, "correct wand refusal")
# Mark the NPC as screened so the screening-before-entry flow is satisfied.
controller.screened = true
hud.hide_wand()
wand_open = false
_refresh_freeze()
# Short scan cooldown: the wand must “recharge” between guests (PLAN.md: feel).
_wand_cooldown = WAND_COOLDOWN_S
func decide_access(allowed: bool) -> void:
var controller: Object = _targeted_controller()
if controller == null:
hud.hide_inspection()
inspection_open = false
_refresh_freeze()
return
# Screening-before-entry: at wand venues, a guest must be screened first.
if allowed and selected_scenario.access_policy.wand_screening_required and not controller.screened:
hud.log(Strings.lookup("hud.screen_first"))
hud.hide_inspection()
inspection_open = false
_refresh_freeze()
return
var artifacts := _artifacts_for(controller)
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy, controller.screened)
var granted: bool = result.get("granted", false)
coordinator.recorder.add_access_evidence(controller.npc_id, {
"decided": allowed,
"verified_granted": granted,
"issues": result.get("issues", []),
"checked": result.get("checked_artifacts", []),
})
if allowed == granted:
if allowed:
hud.log(Strings.fmt("access.granted", ["; ".join(result.get("reasons", [])) if result.get("reasons", []) else Strings.lookup("access.policy_satisfied")]))
coordinator.reputation.apply("access_judgment", 2.0, "correct admission")
if audio:
audio.play_confirm()
else:
hud.log(Strings.fmt("access.denied", ["; ".join(result.get("issues", []))]))
var refusal_line := selected_scenario.door_staff_line("refusal", _door_rng)
if refusal_line != "":
hud.log(refusal_line)
coordinator.reputation.apply("access_judgment", 1.0, "correct refusal")
if audio:
audio.play_deny()
else:
if allowed:
hud.log(Strings.lookup("access.wrong_grant"))
else:
hud.log(Strings.lookup("access.wrong_deny"))
coordinator.reputation.apply("access_judgment", -6.0, "wrong access decision")
if audio:
audio.play_deny()
hud.hide_inspection()
inspection_open = false
_refresh_freeze()
## Venue-specific opening line for the access inspection (e.g. The Joke Shop's
## ID / ticket check lines). Returns "" when the venue has no matching lines.
func _door_context_line() -> String:
if selected_scenario == null:
return ""
match str(selected_scenario.id):
"joke_shop":
if selected_scenario.access_policy.require_id:
return selected_scenario.door_staff_line("id_check", _door_rng)
return selected_scenario.door_staff_line("ticket_check", _door_rng)
"tv_studio":
return selected_scenario.door_staff_line("ticket_check", _door_rng)
_:
return selected_scenario.door_staff_line("wristband_check", _door_rng)
func _artifacts_for(controller: Object) -> Array:
var profile: NpcProfile = controller.profile as NpcProfile
var override: Dictionary = controller.access_override
var policy = selected_scenario.access_policy if selected_scenario else null
var artifacts: Array = AccessLoadout.build(profile, policy, override)
# The guard's own recognition is evidence the player created, so it is
# appended here rather than baked into every staff/guest artifact set.
if controller.player_recognized and not _has_recognition_artifact(artifacts):
artifacts.push_front(AccessArtifact.new(
Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true, "source": "player"}))
return artifacts
func _has_recognition_artifact(artifacts: Array) -> bool:
for artifact in artifacts:
if artifact is AccessArtifact and artifact.kind == Rules.AccessKind.RECOGNITION:
return true
return false
func _read_npc_truth(controller: Object) -> Dictionary:
var profile: Object = controller.profile
var npc_type: int = profile.npc_type if profile else -1
return {
"weapons_seen": npc_type == Rules.NpcType.VIOLENT_THREAT,
"danger_level": 3 if npc_type == Rules.NpcType.VIOLENT_THREAT else 1,
}
func _targeted_controller() -> Object:
if targeted_npc == null:
return null
if targeted_npc.has_method("get_npc_controller"):
return targeted_npc.get_npc_controller()
return null
## Guarantee an open incident for this NPC before recording evidence, so verbs
## work even if the player acted without a prior focus change.
func _ensure_incident(controller: Object) -> void:
if controller == null or coordinator == null:
return
if coordinator.recorder.has_open_incident(controller.npc_id):
return
coordinator.recorder.start_incident(controller.npc_id, "player_action")
current_incident_npc_id = controller.npc_id
current_incident_active = true
func _close_incident(outcome: String) -> void:
if current_incident_active and not current_incident_npc_id.is_empty():
coordinator.recorder.close_incident(current_incident_npc_id, outcome)
current_incident_active = false
current_incident_npc_id = ""
func _physics_process(delta: float) -> void:
if _wand_cooldown > 0.0:
_wand_cooldown = maxf(0.0, _wand_cooldown - delta)
if coordinator == null or not coordinator.running:
return
coordinator.tick(delta)
_tutorial_tick()
if coordinator.current_phase != _last_phase:
_last_phase = coordinator.current_phase
_apply_phase_lighting()
if hud and not tutorial_active:
hud.log("Objective: %s — keep the entrance clear and verify access." % str(
Rules.PHASE_NAMES.get(coordinator.current_phase, "Early")))
hud.set_shift_state(coordinator, selected_scenario)
var preview := coordinator.scheduler.preview(coordinator.schedule_time_s(),
coordinator.current_phase)
hud.refresh_debug(coordinator, npcs, preview)
_perception_pass()
## Perception pass: NPCs notice the guard's proximity (PLAN.md: Level 0
## Observation). Feeds each NPC's distance to the guard into its controller
## and logs a one-time reaction when an NPC first notices the guard.
func _perception_pass() -> void:
if guard == null:
return
var guard_pos: Vector3 = guard.global_position
var lit_bonus := 0.0
if flashlight_on and coordinator.current_phase >= Rules.Phase.EVENING:
lit_bonus = 4.0
for avatar in npcs:
if not is_instance_valid(avatar):
continue
var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null
if controller == null:
continue
var distance: float = (avatar.global_position - guard_pos).length()
var already_noticed: bool = controller.guard_noticed
var perception: Dictionary = controller.perceive_guard(distance, lit_bonus)
if perception.get("noticed", false) and not already_noticed:
# First-time notice: log a subtle reaction.
var name: String = (controller.profile.display_name if controller.profile else "Person")
if perception.get("band", "") == "near":
hud.log(Strings.fmt("perception.near", [name]))
else:
hud.log(Strings.fmt("perception.aware", [name]))