extends Node2D @onready var world_renderer = $World @onready var fog_entities = $World/FogEntities # D-059/D-060: cognitive delay fog visualization @onready var camera = $Camera2D @onready var hud = $UILayer/HUD @onready var monologue_display = $UILayer/MonologueDisplay @onready var interaction_prompt = $InsertOverlay/InteractionPrompt # v0.1 single-line fallback @onready var interaction_list = $InsertOverlay/InteractionList # D-057: z-layer 6 @onready var world_radial = $InsertOverlay/WorldRadial # D-058: z-layer 6 @onready var dialogue_box = $InsertOverlay/DialogueBox # D-061: z-layer 6 @onready var inventory_grid = $UILayer/InventoryGrid # D-065: z-layer 7 @onready var stance_indicator = $UILayer/StanceIndicator # D-053: z-layer 7 @onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7 @onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests @onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress @onready var time_display = $InsertOverlay/TimeDisplay # #263: diegetic time display (D-013, D-031) @onready var minimap = $InsertOverlay/Minimap # #151: diegetic minimap overlay (D-013, D-049) @onready var examine_display = $InsertOverlay/ExamineDisplay # #174: examine result overlay @onready var journal_panel = $InsertOverlay/JournalPanel # #264: knowledge journal (D-041) @onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay @onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button @onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU) @onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load @onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console @onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7) var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution var _camera_anchored: bool = false var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice var _last_dialogue_tick: int = -1 var _last_confrontation_tick: int = -1 # Deduplicate confrontation_monologue signals within same tick var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed var _known_triangle_ids: Dictionary = {} # #590: triangle_ids that have already fired the activation chime var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber) var _teleport_in_progress: bool = false # #501/#117: forces camera snap (not lerp) on next _process frame var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates func _ready() -> void: print("The Settled Reach — client initialized") # #117: Manual lerp approach — disable Godot's built-in Camera2D smoothing. # We lerp camera.global_position directly in _process() using CAMERA_SMOOTHING_SPEED, # matching entity_renderer.gd's exponential smoothing pattern. Built-in smoothing # would conflict because we'd be setting global_position to the target every frame. camera.position_smoothing_enabled = false # Connect to simulation (test mode sets CONNECTED immediately) SimBridge.connect_to_sim() # #257: If returning from main menu "Load Game" selection, defer dispatch until connected. # In test mode, connect_to_sim() sets CONNECTED synchronously — dispatch fires immediately. # In live mode, state is CONNECTING — signal handler dispatches once connected. if not GameState.pending_load_path.is_empty(): if loading_screen: loading_screen.show_loading() if SimBridge.state == SimBridge.ConnectionState.CONNECTED: _dispatch_pending_load() else: SimBridge.connection_state_changed.connect(_on_sim_connected_for_load) # Camera anchor: snap to player position before the first frame renders. # In test mode poll_snapshot() returns synchronously — position is set # immediately. In live mode the snapshot isn't available yet — _process # handles it via the lerp block in _process(). var first_snapshot: Variant = SimBridge.poll_snapshot() if first_snapshot != null: GameState.apply_snapshot(first_snapshot) camera.global_position = GameState.player_position * Constants.TILE_SIZE _camera_anchored = true # D-061: Connect dialogue box signals if dialogue_box: dialogue_box.option_selected.connect(_on_dialogue_option_selected) dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed) dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue) dialogue_box.pause_requested.connect(_on_dialogue_pause_requested) dialogue_box.unpause_requested.connect(_on_dialogue_unpause_requested) # D-020 (#558): Decoupled signals — coordinator routes state changes. dialogue_box.dialogue_state_changed.connect(_on_dialogue_state_changed) dialogue_box.audio_dip_requested.connect(_on_audio_dip_requested) dialogue_box.audio_dip_cleared.connect(_on_audio_dip_cleared) # #496: Print gauntlet session summary on disconnect if gauntlet_hud: SimBridge.connection_state_changed.connect(_on_connection_state_changed) # #559: Register snapshot dispatch handlers — replaces inline dispatch in _process(). _router = SnapshotEventRouter.new() # Always-run: child nodes that update from GameState on every snapshot tick. if world_renderer: _router.register_always(world_renderer.update_from_state) _router.register_always(_propagate_insert_state) _router.register_always(_update_interaction_list) if inventory_grid: _router.register_always(inventory_grid.update_from_state) if stance_indicator: _router.register_always(stance_indicator.update_from_state) if fog_entities: _router.register_always(fog_entities.update_from_state) _router.register_always(_play_recognition_chimes) _router.register_always(_handle_triangle_crisis_events) if gauntlet_hud: _router.register_always(gauntlet_hud.update_from_state) if checklist_overlay: _router.register_always(checklist_overlay.update_from_state) if time_display: _router.register_always(time_display.update_from_state) if news_ticker: _router.register_always(news_ticker.update_from_state) if journal_panel: _router.register_always(journal_panel.update_from_state) if debug_overlay: _router.register_always(debug_overlay.update_from_state) _router.register_always(_play_close_sound_events) _router.register_always(_update_zone) _router.register_always(_update_listening_focus) _router.register_always(_consume_examine_result) # Keyed: consume methods guarded by specific snapshot fields. _router.register("current_monologue", _consume_monologue) _router.register("current_dialogue", _consume_dialogue) _router.register("conversation_events", _consume_conversation_events) _router.register("conversation_ended", _consume_conversation_ended) _router.register("dialogue_response", _consume_dialogue_response) _router.register("save_result", _consume_save_result) _router.register("debug_response", _consume_debug_response) # #581: Wire settings_dialog debug console toggle → debug_console.set_enabled if settings_dialog and debug_console: settings_dialog.debug_console_toggled.connect(debug_console.set_enabled) # #581 D-088: Wire debug console pause/unpause — sim must not advance during debug input if debug_console: debug_console.pause_requested.connect(_on_dialogue_pause_requested) debug_console.unpause_requested.connect(_on_dialogue_unpause_requested) func _process(delta: float) -> void: # Main game loop: poll snapshot, apply state, flush input var snapshot: Variant = SimBridge.poll_snapshot() if snapshot != null: var old_pos := GameState.player_position GameState.apply_snapshot(snapshot) # #501: Detect teleport (large position jump > 5 tiles) and trigger fade if _camera_anchored and _detect_teleport(old_pos, GameState.player_position): _teleport_transition() # Late anchor: live mode — first snapshot arrives during _process. # Smoothing is already OFF (disabled in _ready), so setting # global_position takes effect immediately with no lerp. if not _camera_anchored: camera.global_position = GameState.player_position * Constants.TILE_SIZE _camera_anchored = true # #559: Dispatch snapshot to registered handlers (router pattern). # Always-run handlers update child nodes; keyed handlers fire for present fields. _router.dispatch(snapshot) # Track camera to player (D-015: locked, fixed-north). # #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED. # Teleport (flag set by _teleport_transition): snap immediately, resume lerp next frame. # Init: camera already snapped in _ready() or late-anchor path above. if _camera_anchored: var target := GameState.player_position * Constants.TILE_SIZE if _teleport_in_progress: camera.global_position = target _teleport_in_progress = false else: var weight := 1.0 - exp(-Constants.CAMERA_SMOOTHING_SPEED * delta) camera.global_position = camera.global_position.lerp(target, weight) # Send queued input to simulation # #507: Server-bound inputs are accumulated into _pending_record_inputs across frames. # At 60fps/10tps, inputs on non-snapshot frames must not be lost from the ring buffer. var inputs = InputMapper.flush_queue() for input in inputs: # #495: F12 WRONG button — client-only, trigger bug report capture if input.action == InputMapper.Action.BUG_REPORT: if bug_report_dialog and not bug_report_dialog.is_active(): bug_report_dialog.start_capture() continue # #264: J — client-only, toggle knowledge journal panel if input.action == InputMapper.Action.OPEN_JOURNAL: _toggle_journal() continue # #257: LOAD_GAME — send first, then show loading screen (avoids stuck overlay if send fails) if input.action == InputMapper.Action.LOAD_GAME: var err := SimBridge.send_input(input) _pending_record_inputs.append(input) if err == OK and loading_screen: loading_screen.show_loading() elif err != OK: push_error("main.gd: LOAD_GAME send_input failed: %s" % error_string(err)) continue # #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog if input.action == InputMapper.Action.OPEN_MENU: if settings_dialog: if settings_dialog.is_open(): settings_dialog.close() else: settings_dialog.open() continue if input.action == InputMapper.Action.INTERACT: # D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1) var target_id: int = -1 var verb: String = "" if interaction_list and interaction_list.has_method("get_interaction_target"): target_id = interaction_list.get_interaction_target() verb = interaction_list.get_selected_verb() if target_id < 0 and interaction_prompt: target_id = interaction_prompt.get_interaction_target() verb = interaction_prompt.get_selected_verb() # Always send struct form for Interact (#415) — server expects named fields if target_id >= 0: input["action_data"] = { "target_entity_id": target_id, "verb": verb, } else: input["action_data"] = { "target_entity_id": null, "verb": null, } SimBridge.send_input(input) _pending_record_inputs.append(input) # #507: Record tick data to ring buffer — once per server tick (snapshot arrival). # Flushes all inputs accumulated since the last snapshot (across multiple display frames), # then clears the accumulator for the next tick. if snapshot != null and bug_report_dialog and bug_report_dialog.has_method("record_tick"): bug_report_dialog.record_tick( GameState.current_tick, JSON.stringify(GameState.current_snapshot), _pending_record_inputs ) _pending_record_inputs.clear() # OQ-07 (#522): Propagate insert state to all z-layer-6 display nodes. # Cursor shape still fires (D-056 option a) — only verb labels suppressed. func _propagate_insert_state() -> void: var insert_state := GameState.insert_active if cursor_renderer: cursor_renderer.set_insert_active(insert_state) if interaction_list: interaction_list.set_insert_active(insert_state) if interaction_prompt: interaction_prompt.set_insert_active(insert_state) if minimap: minimap.set_insert_active(insert_state) # D-057: Update interaction list from game state. # Suppress during dialogue — player is in conversation, verb list is noise. func _update_interaction_list() -> void: if not interaction_list: return if dialogue_box and dialogue_box.is_dialogue_active(): if interaction_list.is_showing(): interaction_list.hide_list() else: interaction_list.update_from_state() # D-018 #125: Play close-range sound events — fired once per snapshot tick. # Each event is passed to AudioManager.play_sound_event() for 2D positional playback # on the WorldSFX bus. Events with no registered asset are silently skipped (D-038). # Consume-once: events are cleared after processing so they don't replay if # _process runs again before the next server tick (D-009 multiplayer-safe pattern). func _play_close_sound_events() -> void: for evt in GameState.close_sound_events: if not evt is Dictionary or not evt.has("x") or not evt.has("y"): continue AudioManager.play_sound_event( evt.get("event_type", ""), Vector2(float(evt.x), float(evt.y)) ) GameState.close_sound_events = [] # D-067: Recognition chime — fires sfx_monologue_chime when a fog entity # enters the cognitive delay recognition queue for the first time. # "The chime marks the character's attention shifting" (D-067). # IDs persist for the session — one chime per entity, no re-trigger on # fog oscillation or server re-send. Cleared on room change (teleport). func _play_recognition_chimes() -> void: for rec in GameState.pending_recognitions: if not rec is Dictionary or not rec.has("entity_id"): continue var eid: int = rec.entity_id if not _known_recognition_ids.has(eid): _known_recognition_ids[eid] = true AudioManager.play(AudioManager.CHIME_RECOGNITION) # #590 D-072/D-089: Triangle activation consumer — fires sfx_monologue_chime_urgent once # per triangle_id. The tell_state on the activated NPC and subsequent proximity monologue # lines are the visible consequence (D-039 wow moment #2 "The Character's Eye"). # No overlay is shown — the chime is the only client-side reaction (D-039 intent). func _handle_triangle_crisis_events() -> void: var events: Array = GameState.current_snapshot.get("triangle_crisis_events", []) for ev in events: if not ev is Dictionary or not ev.has("triangle_id"): continue var tid: int = ev.triangle_id if not _known_triangle_ids.has(tid): _known_triangle_ids[tid] = true AudioManager.play(AudioManager.CHIME_ACTIVATION, AudioManager.BUS_UI_SOUNDS) # D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id # (extracted in apply_snapshot(), server-authoritative per D-020). # Calls AudioManager.set_zone() when zone changes (AudioManager handles crossfade). func _update_zone() -> void: var zone := GameState.current_zone_id if zone != _current_zone: _current_zone = zone AudioManager.set_zone(zone) # D-071 (#530): ListeningFocus boost — World SFX +2.5dB when stationary 30+ ticks. # Uses AudioManager.get_active_dip() as single source of truth (no separate flag). # Only activates when no other dip (dialogue/confrontation) is running. # Only deactivates its own dip — never touches dialogue/confrontation. # D-070: no UI indicator — the boost is "felt, not computed." func _update_listening_focus() -> void: var current_dip := AudioManager.get_active_dip() var threshold_met := GameState.stationary_ticks >= LISTENING_FOCUS_TICKS if threshold_met and current_dip == "": AudioManager.apply_dip("listening_focus") elif not threshold_met and current_dip == "listening_focus": AudioManager.clear_dip() # Consume-once per tick: show monologue text, then clear. # Tick guard prevents re-triggering when the same tick is polled multiple # times (client FPS > sim tick rate). func _consume_monologue() -> void: if GameState.current_monologue == null or not monologue_display: return if GameState.current_tick == _last_monologue_tick: return _last_monologue_tick = GameState.current_tick var mono: Dictionary = GameState.current_monologue monologue_display.show_monologue( mono.get("text", ""), mono.get("duration_seconds", 5.0), mono.get("priority", 2), mono.get("is_urgent", false) ) # #502: Amber flash on room reset var mono_id: String = mono.get("id", "") if mono_id.begins_with("room_reset"): _screen_flash(Constants.ENTITY_COLOR_POI, 0.15) GameState.current_monologue = null # Consume-once per tick with ID tracking: show dialogue, then clear. # Tick guard + is_dialogue_active check prevent re-triggering. func _consume_dialogue() -> void: if GameState.current_dialogue == null or not dialogue_box: return if GameState.current_tick == _last_dialogue_tick: return if dialogue_box.is_dialogue_active(): GameState.current_dialogue = null return _last_dialogue_tick = GameState.current_tick # #264: Close journal when dialogue opens (cannot be open simultaneously) if journal_panel and journal_panel.has_method("close"): journal_panel.close() var dlg: Dictionary = GameState.current_dialogue _last_dialogue_npc_id = dlg.get("npc_entity_id", -1) _last_dialogue_npc_name = dlg.get("npc_name", "") dialogue_box.show_dialogue( dlg.get("npc_name", ""), dlg.get("speech", ""), dlg.get("options", []), _last_dialogue_npc_id ) GameState.current_dialogue = null # #535: Consume overheard NPC-NPC conversation events (D-078). # Each event carries pre-occluded text — render verbatim in the dialogue log. func _consume_conversation_events() -> void: if not dialogue_box: return for event in GameState.conversation_events: dialogue_box.append_conversation_event(event) GameState.conversation_events = [] # #535: Handle conversation_ended events — notify dialogue box to stop tracking pairs. func _consume_conversation_ended() -> void: if not dialogue_box: return for event in GameState.conversation_ended: dialogue_box.on_conversation_ended(event) GameState.conversation_ended = [] # #535: Consume dialogue_response — NPC follow-up line after player picks an option. # Updates dialogue_box entity display registry with speaker identity from the wire. func _consume_dialogue_response() -> void: if GameState.dialogue_response == null or not dialogue_box: return var dr: Dictionary = GameState.dialogue_response # v0.1: falls back to _last_dialogue_npc_id if wire omits speaker_entity_id. # Edge case: fast re-engagement with a different NPC could misattribute — low probability. var speaker_entity_id: int = dr.get("speaker_entity_id", _last_dialogue_npc_id) var speaker_color_index: int = dr.get("speaker_color_index", -1) var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name) dialogue_box.update_entity_display(speaker_entity_id, speaker_name, speaker_color_index) dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""), speaker_entity_id) GameState.dialogue_response = null # #554/#257: Show save/load result notification; hide loading screen on load complete. func _consume_save_result() -> void: if GameState.save_result == null: return var result: Dictionary = GameState.save_result GameState.save_result = null # consume once # #257: Dismiss loading screen regardless of success/failure if loading_screen: loading_screen.hide_loading() var msg: String if result.get("success", false): if result.get("kind", "") == "save": msg = UIStrings.get_text("notifications.save_complete") else: msg = UIStrings.get_text("notifications.load_complete") else: if result.get("kind", "") == "save": msg = UIStrings.get_text("notifications.save_failed") else: msg = UIStrings.get_text("notifications.load_failed") if monologue_display: monologue_display.show_notification(msg) # #581: Forward debug_response from server to the debug console. func _consume_debug_response() -> void: if GameState.debug_response == null or not debug_console: return debug_console.append_response(GameState.debug_response) GameState.debug_response = null # D-061: Handle dialogue option selection → send to server func _on_dialogue_option_selected(response_id: String, text: String) -> void: SimBridge.send_input({ "action": InputMapper.Action.INTERACT, "timestamp_msec": Time.get_ticks_msec(), "action_data": { "target_entity_id": null, "verb": "DialogueResponse", "response_id": response_id, }, }) # D-063: Handle confrontation beat monologue → show on monologue display (layer 7) # Confrontation lines are high-priority (3) and urgent — full opacity, elevated colour. # Tick guard deduplicates if dialogue box emits the signal multiple times in one tick. func _on_confrontation_monologue(text: String, duration: float) -> void: if not monologue_display: return if GameState.current_tick == _last_confrontation_tick: return _last_confrontation_tick = GameState.current_tick monologue_display.show_monologue(text, duration, 3, true) # D-061: Auto-pause on dialogue open — routed through input recording (#507, Tyre #3) func _on_dialogue_pause_requested() -> void: var input := {"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()} SimBridge.send_input(input) _pending_record_inputs.append(input) # D-061: Auto-unpause on dialogue close — routed through input recording (#507, Tyre #3) func _on_dialogue_unpause_requested() -> void: var input := {"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()} SimBridge.send_input(input) _pending_record_inputs.append(input) # D-064: Handle walk-away → send WalkAway{npc_id} to server func _on_dialogue_dismissed() -> void: SimBridge.send_input({ "action": InputMapper.Action.INTERACT, "timestamp_msec": Time.get_ticks_msec(), "action_data": { "target_entity_id": _last_dialogue_npc_id if _last_dialogue_npc_id >= 0 else null, "verb": "WalkAway", }, }) # D-020 (#558): Coordinator handles dialogue state changes from dialogue_box. # Synchronous signal — GameState.dialogue_active updates same frame (D-064). func _on_dialogue_state_changed(active: bool) -> void: GameState.dialogue_active = active # D-020 (#558): Coordinator routes audio dip requests from dialogue_box. func _on_audio_dip_requested(profile: String) -> void: AudioManager.apply_dip(profile) # D-020 (#558): Coordinator routes audio dip clear from dialogue_box. func _on_audio_dip_cleared() -> void: AudioManager.clear_dip() # #496: Finalize gauntlet stats on disconnect func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void: if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud: gauntlet_hud.finalize() # #257: Deferred LOAD_GAME dispatch — fires once when SimBridge reaches CONNECTED. # pending_load_path is set by main_menu.gd before scene change. func _on_sim_connected_for_load(_old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void: if new_state != SimBridge.ConnectionState.CONNECTED: return if SimBridge.connection_state_changed.is_connected(_on_sim_connected_for_load): SimBridge.connection_state_changed.disconnect(_on_sim_connected_for_load) _dispatch_pending_load() func _dispatch_pending_load() -> void: var load_path := GameState.pending_load_path if load_path.is_empty(): return GameState.pending_load_path = "" var err := SimBridge.send_input({ "action": InputMapper.Action.LOAD_GAME, "timestamp_msec": Time.get_ticks_msec(), "action_data": {"path": load_path}, }) if err != OK: push_error("main.gd: failed to send LOAD_GAME after connection — %s" % error_string(err)) if loading_screen: loading_screen.hide_loading(false) # #501: Detect large position jump indicating a teleport (not normal movement). const TELEPORT_DISTANCE_THRESHOLD: float = 5.0 func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool: return old_pos.distance_to(new_pos) > TELEPORT_DISTANCE_THRESHOLD # #501: Gauntlet dev teleport transition — snap camera + 0.3s fade-from-black. # Clears dialogue/monologue/interaction state (server clears its side too). # Scoped to Gauntlet testing only — production fast-travel uses diegetic gates. func _teleport_transition() -> void: # Set teleport flag — the camera tracking block in _process() will snap # to the player's new position this frame (no lerp). Flag clears after snap. _camera_anchored = true _teleport_in_progress = true # Clear client-side buffers GameState.current_monologue = null GameState.current_dialogue = null GameState.dialogue_active = false _known_recognition_ids.clear() # D-067: reset chimes for new room _known_triangle_ids.clear() # #590: reset activation chimes for new room if dialogue_box and dialogue_box.is_dialogue_active(): dialogue_box.hide_dialogue() # Fade from black: instant black overlay, fades to transparent over 0.3s if _flash_rect and is_instance_valid(_flash_rect): _flash_rect.queue_free() _flash_rect = ColorRect.new() _flash_rect.color = Color(0, 0, 0, 1.0) _flash_rect.anchors_preset = Control.PRESET_FULL_RECT _flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE $UILayer.add_child(_flash_rect) var tween := create_tween() tween.tween_property(_flash_rect, "color:a", 0.0, 0.3) tween.tween_callback(_flash_rect.queue_free) # #174: Consume examine result — show overlay when server sends character-filtered observation. # Clears after display (single-consume). Dismiss examine when dialogue opens. func _consume_examine_result() -> void: if GameState.current_examine_result == null or not examine_display: return var result: Dictionary = GameState.current_examine_result # Dismiss existing examine result if dialogue is active (focus priority) if dialogue_box and dialogue_box.is_dialogue_active(): if examine_display.has_method("dismiss"): examine_display.dismiss() else: if examine_display.has_method("show_result"): examine_display.show_result(result) GameState.current_examine_result = null # #264: Toggle journal panel. Called from input handler when J key pressed. func _toggle_journal() -> void: if not journal_panel: return # Journal and dialogue cannot be open simultaneously (sprint briefing) if dialogue_box and dialogue_box.is_dialogue_active(): return if journal_panel.has_method("toggle"): journal_panel.toggle() # #502: Full-screen color flash — fades from color to transparent over duration. # Used for room reset amber flash. Creates ephemeral ColorRect on UILayer. func _screen_flash(color: Color, duration: float) -> void: if _flash_rect and is_instance_valid(_flash_rect): _flash_rect.queue_free() _flash_rect = ColorRect.new() _flash_rect.color = Color(color.r, color.g, color.b, 0.4) _flash_rect.anchors_preset = Control.PRESET_FULL_RECT _flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE $UILayer.add_child(_flash_rect) var tween := create_tween() tween.tween_property(_flash_rect, "color:a", 0.0, duration) tween.tween_callback(_flash_rect.queue_free)