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 _env_node: WorldEnvironment = null var _sky: ProceduralSkyMaterial = null var _last_phase: 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, "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, "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, "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.22, "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 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 = 0.0 if Materials.modern_light_falloff() else 0.9 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: 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) 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 _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(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() 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"]) if start_menu and start_menu.visible: start_menu.open(campaign.summary() if campaign else {}) elif _shift_running: _open_pause() ## 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({}) 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) 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: 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) 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) _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 _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 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 "" if selected_scenario.access_policy.require_id: return selected_scenario.door_staff_line("id_check", _door_rng) if selected_scenario.access_policy.requires(Rules.AccessKind.TICKET): return selected_scenario.door_staff_line("ticket_check", _door_rng) return "" 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.elapsed_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]))