- Escalation cue now includes a shape meter and numeric level
("Escalation: Blocking [###--] L3/5") in addition to the colour and name, so it
reads in grayscale. Hud.escalation_meter(level) is pure and tested.
- HUD font scale from settings actually works: set_font_scale() resizes fonts via
theme overrides instead of Control.scale, which pushed the right-anchored clock
label off-screen. It now covers the top, log, target, hint, time, mode,
escalation, cooldown, and debug labels.
- Settings load before the HUD exists, so _ready() re-applies player settings after
_build_ui(); previously a saved font scale was ignored until the next apply.
Tests/docs
- Headless suite 134 -> 139 (test_accessibility.gd).
- Verified visually at 1.4x scale: no overflow, meter readable.
- README, TODO section moved to DONE, MEMORY. Version 0.2.9.
1445 lines
52 KiB
GDScript
1445 lines
52 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 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")
|
|
|
|
var world_builder: WorldBuilder = 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": 1.2, "sun_angle": -50.0, "sun_color": Color(1.0, 0.98, 0.92),
|
|
"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,
|
|
},
|
|
Rules.Phase.MIDDAY: {
|
|
"sun_energy": 1.5, "sun_angle": -62.0, "sun_color": Color(1.0, 1.0, 0.98),
|
|
"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,
|
|
},
|
|
Rules.Phase.EVENING: {
|
|
"sun_energy": 0.7, "sun_angle": -16.0, "sun_color": Color(1.0, 0.72, 0.5),
|
|
"sky_top": Color(0.62, 0.32, 0.35), "sky_horizon": Color(1.0, 0.68, 0.42),
|
|
"sky_ground": Color(0.3, 0.24, 0.3), "ambient": Color(0.55, 0.45, 0.5), "night": true,
|
|
},
|
|
Rules.Phase.LATE: {
|
|
"sun_energy": 0.05, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
|
|
"sky_top": Color(0.02, 0.03, 0.08), "sky_horizon": Color(0.1, 0.12, 0.2),
|
|
"sky_ground": Color(0.05, 0.05, 0.08), "ambient": Color(0.08, 0.09, 0.16), "night": true,
|
|
},
|
|
}
|
|
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 _timeline_from_review := false
|
|
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
|
|
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: cycle through nearby NPCs with Tab (accessibility).
|
|
const NEARBY_RADIUS := 12.0
|
|
## 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
|
|
var _nearby_index := -1
|
|
|
|
func _ready() -> void:
|
|
_door_rng.randomize()
|
|
player_settings = Save.load_settings() if Save else {}
|
|
scene_root = Node3D.new()
|
|
scene_root.name = "SceneRoot"
|
|
add_child(scene_root)
|
|
|
|
world_builder = WorldBuilder.new(scene_root)
|
|
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.npc_spawn_requested.connect(_spawn_npc)
|
|
coordinator.shift_ended.connect(_on_shift_ended)
|
|
_connect_radio()
|
|
_connect_crowd()
|
|
|
|
_build_ui()
|
|
# 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 = 22.0
|
|
flashlight.spot_angle = 30.0
|
|
flashlight.light_energy = 4.0
|
|
flashlight.spot_attenuation = 0.6
|
|
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")
|
|
return g
|
|
|
|
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)
|
|
if coordinator:
|
|
restart_shift()
|
|
|
|
func restart_shift() -> void:
|
|
if shift_review_panel:
|
|
shift_review_panel.hide()
|
|
_reset_shift_state()
|
|
_start_shift()
|
|
|
|
## Clear all per-shift runtime state (NPCs, incidents, timers).
|
|
func _reset_shift_state() -> void:
|
|
for n in npcs:
|
|
if is_instance_valid(n):
|
|
n.queue_free()
|
|
npcs.clear()
|
|
npc_counter = 0
|
|
inspector_current_events = []
|
|
current_incident_active = false
|
|
current_incident_npc_id = ""
|
|
targeted_npc = null
|
|
_focused_avatar = null
|
|
_nearby_index = -1
|
|
inspection_open = false
|
|
wand_open = false
|
|
_wand_cooldown = 0.0
|
|
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)
|
|
_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)
|
|
_refresh_freeze()
|
|
|
|
func _show_start_menu() -> void:
|
|
_shift_running = false
|
|
if pause_overlay:
|
|
pause_overlay.close()
|
|
if shift_review_panel:
|
|
shift_review_panel.hide()
|
|
if guard:
|
|
guard.input_locked = true
|
|
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
|
if start_menu:
|
|
start_menu.open()
|
|
|
|
func _on_venue_selected(scenario_id: String) -> void:
|
|
if start_menu and start_menu.visible:
|
|
start_menu.close()
|
|
active_scenario_id = scenario_id
|
|
_venue_index = maxi(0, VENUE_ORDER.find(scenario_id))
|
|
selected_scenario = _build_scenario(scenario_id)
|
|
if hud:
|
|
hud.set_wand_icon(false)
|
|
_reset_shift_state()
|
|
_start_shift()
|
|
|
|
## Apply loaded settings to the guard and HUD.
|
|
func _apply_settings(settings: Dictionary) -> void:
|
|
player_settings = settings
|
|
if guard:
|
|
guard.look_sensitivity = float(settings.get("look_sensitivity", 0.0022))
|
|
if hud and hud.has_method("set_font_scale"):
|
|
hud.set_font_scale(float(settings.get("hud_font_scale", 1.0)))
|
|
|
|
func _on_settings_applied(settings: Dictionary) -> void:
|
|
_apply_settings(settings)
|
|
if start_menu and start_menu.visible:
|
|
start_menu.open()
|
|
elif _shift_running:
|
|
_open_pause()
|
|
|
|
func _open_settings() -> void:
|
|
if start_menu:
|
|
start_menu.close()
|
|
if pause_overlay:
|
|
pause_overlay.close()
|
|
if settings_panel:
|
|
settings_panel.open(player_settings)
|
|
|
|
func _open_pause() -> void:
|
|
if pause_overlay == null or not _shift_running:
|
|
return
|
|
if _close_top_modal():
|
|
pass
|
|
pause_overlay.open()
|
|
if guard:
|
|
guard.input_locked = true
|
|
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
|
|
|
func _close_pause() -> void:
|
|
if pause_overlay:
|
|
pause_overlay.close()
|
|
_refresh_freeze()
|
|
|
|
func _quit_to_menu() -> void:
|
|
if pause_overlay:
|
|
pause_overlay.close()
|
|
_show_start_menu()
|
|
|
|
var inspector_current_events: Array = []
|
|
|
|
func _build_ui() -> void:
|
|
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.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)
|
|
|
|
event_timeline = EventTimeline.new()
|
|
add_child(event_timeline)
|
|
event_timeline.hide()
|
|
event_timeline.closed.connect(_on_timeline_closed)
|
|
|
|
start_menu = StartMenu.new()
|
|
add_child(start_menu)
|
|
start_menu.hide()
|
|
start_menu.venue_selected.connect(_on_venue_selected)
|
|
start_menu.settings_requested.connect(_open_settings)
|
|
start_menu.quit_requested.connect(func(): get_tree().quit())
|
|
|
|
## 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()
|
|
|
|
## "Run another shift" on the review screen returns to the venue menu.
|
|
func _on_review_restart() -> void:
|
|
_show_start_menu()
|
|
|
|
func _add_crosshair() -> void:
|
|
var layer := CanvasLayer.new()
|
|
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
|
|
env.ambient_light_energy = 0.5
|
|
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
|
|
_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.rotation_degrees = Vector3(float(cfg.get("sun_angle", -50.0)), -30, 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 world_builder:
|
|
world_builder.set_night_mode(cfg.get("night", false))
|
|
|
|
func _spawn_npc(profile: NpcProfile, intent: String, npc_id: String, event: Dictionary = {}) -> void:
|
|
_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)
|
|
scene_root.add_child(avatar)
|
|
npcs.append(avatar)
|
|
|
|
## 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)
|
|
shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else [])
|
|
_refresh_freeze()
|
|
|
|
func _on_guard_target(target: Node3D) -> void:
|
|
targeted_npc = target
|
|
_set_focused_avatar(target)
|
|
_begin_incident(target)
|
|
|
|
func _on_guard_clear() -> void:
|
|
targeted_npc = null
|
|
_set_focused_avatar(null)
|
|
hud.set_target_label("")
|
|
hud.set_action_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)
|
|
|
|
# ------------------------------------------------------------------ input ---
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
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)
|
|
|
|
func _handle_mouse_button(event: InputEventMouseButton) -> void:
|
|
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 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
|
|
match keycode:
|
|
KEY_Q:
|
|
if targeted_npc != null and not inspection_open:
|
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction")
|
|
KEY_TAB:
|
|
_cycle_nearby_target()
|
|
KEY_F:
|
|
hud.toggle_debug()
|
|
KEY_G:
|
|
_toggle_flashlight()
|
|
KEY_L:
|
|
_toggle_door_lock()
|
|
KEY_B:
|
|
_toggle_body_camera()
|
|
KEY_V:
|
|
_cycle_venue()
|
|
_:
|
|
_handle_action_key(keycode)
|
|
|
|
func _handle_action_key(keycode: Key) -> void:
|
|
match keycode:
|
|
KEY_1:
|
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
|
|
KEY_2:
|
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
|
|
KEY_3:
|
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake")
|
|
KEY_4:
|
|
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
|
|
KEY_5:
|
|
_apply_interaction(Rules.Level.BLOCKING, "blocking")
|
|
KEY_6:
|
|
_open_radio(false)
|
|
KEY_R:
|
|
_open_radio(true)
|
|
KEY_A:
|
|
if inspection_open:
|
|
decide_access(true)
|
|
elif wand_open:
|
|
decide_wand(true)
|
|
KEY_D:
|
|
if inspection_open:
|
|
decide_access(false)
|
|
elif wand_open:
|
|
decide_wand(false)
|
|
_:
|
|
pass
|
|
|
|
## `cancel` (Esc, RMB) closes the top modal. With nothing open, Esc pauses the
|
|
## shift (or resumes it when paused); RMB does nothing so it never grabs the
|
|
## mouse by accident.
|
|
func _handle_cancel(from_mouse: bool) -> void:
|
|
if _close_top_modal():
|
|
return
|
|
if from_mouse:
|
|
return
|
|
if settings_panel and settings_panel.visible:
|
|
return
|
|
if pause_overlay and pause_overlay.visible:
|
|
_close_pause()
|
|
return
|
|
if _shift_running and not (start_menu and start_menu.visible):
|
|
_open_pause()
|
|
return
|
|
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
|
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
|
else:
|
|
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)
|
|
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 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). Lets players
|
|
## interact without precise aiming (accessibility).
|
|
func _cycle_nearby_target() -> void:
|
|
if guard == null:
|
|
return
|
|
var nearby: Array = []
|
|
for avatar in npcs:
|
|
if not is_instance_valid(avatar):
|
|
continue
|
|
if (avatar.global_position - guard.global_position).length() <= NEARBY_RADIUS:
|
|
nearby.append(avatar)
|
|
if nearby.is_empty():
|
|
hud.log(Strings.lookup("hud.no_one_nearby"))
|
|
return
|
|
_nearby_index = (_nearby_index + 1) % nearby.size()
|
|
var next_target: Node3D = nearby[_nearby_index]
|
|
targeted_npc = next_target
|
|
_set_focused_avatar(next_target)
|
|
_begin_incident(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]))
|
|
|
|
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)
|
|
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")
|
|
# 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)
|
|
_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
|
|
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
|
|
var hits: int = coordinator.radio.credibility_hits(report, current_npc_truth)
|
|
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)
|
|
_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
|
|
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)
|
|
if coordinator.current_phase != _last_phase:
|
|
_last_phase = coordinator.current_phase
|
|
_apply_phase_lighting()
|
|
hud.set_shift_state(coordinator, selected_scenario)
|
|
_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]))
|