extends Node2D const TELEPORT_DISTANCE_THRESHOLD: float = 5.0 # #898: Free camera pan speed in pixels/second (unzoomed) and zoom step per scroll tick. const FREE_CAMERA_PAN_SPEED: float = 400.0 const FREE_CAMERA_ZOOM_STEP: float = 0.1 const FREE_CAMERA_ZOOM_MIN: float = 0.5 const FREE_CAMERA_ZOOM_MAX: float = 8.0 var economics_app = null # EconomicsApp — populated in _ready() via ImplantRegistry var atlas_app = null # AtlasApp — populated in _ready() via ImplantRegistry var _camera_anchored: bool = false var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay var _teleport_in_progress: bool = false # #501/#117: forces camera snap on next frame var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch var _consumers: SnapshotConsumers # #775: non-dialogue snapshot consumers var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers @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 = $InsertOverlay/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 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 = $MetaLayer/BugReportDialog # #495: F12 WRONG button @onready var settings_dialog = $MetaLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU) @onready var loading_screen = $MetaLayer/LoadingScreen # #257: blocking overlay during load @onready var debug_console = $MetaLayer/DebugConsole # #581: tilde debug console @onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7) func _ready() -> void: print("The Settled Reach — client initialized") # #844 D-191: Populate app refs from registry (hud._ready already called instantiate_all). atlas_app = ImplantRegistry.get_app_instance("implant/map") economics_app = ImplantRegistry.get_app_instance("implant/economics") # #117: Manual lerp approach — disable Godot's built-in Camera2D smoothing. camera.position_smoothing_enabled = false # Connect to simulation (test mode sets CONNECTED immediately). # Guard: Option A flow leaves SimBridge CONNECTED when main.tscn loads — don't drop it. # Option A state handoff: main_menu polls and applies the snapshot first, # seeding GameState (including bookmark_catalog) via the autoload before the # scene swap. main.tscn then re-applies the next snapshot on top. Both paths # write through GameState — which is autoloaded, so catalog state survives. if SimBridge.state == SimBridge.ConnectionState.DISCONNECTED: SimBridge.connect_to_sim() # #257: Deferred load dispatch 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. 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-170: Register HUD nodes into layer groups for node in [ hud, minimap, stance_indicator, interaction_prompt, interaction_list, inventory_grid, news_ticker, examine_display, world_radial, ]: if node and node is CanvasItem: HudGroups.register(node, "gameplay") # #775: Initialize extracted components _consumers = ( SnapshotConsumers . new() . init( { "monologue_display": monologue_display, "dialogue_box": dialogue_box, "examine_display": examine_display, "loading_screen": loading_screen, "debug_console": debug_console, "cursor_renderer": cursor_renderer, "interaction_list": interaction_list, "interaction_prompt": interaction_prompt, "minimap": minimap, "economics_app": economics_app, "atlas_app": atlas_app, }, _screen_flash ) ) _dialogue = ( DialogueCoordinator . new() . init( { "dialogue_box": dialogue_box, "monologue_display": monologue_display, "journal_panel": journal_panel, }, _pending_record_inputs ) ) _dialogue.connect_signals() # #496: Print gauntlet session summary on disconnect if gauntlet_hud: SimBridge.connection_state_changed.connect(_on_connection_state_changed) # #559: Register snapshot dispatch handlers _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(_consumers.propagate_insert_state) _router.register_always(_consumers.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(_consumers.play_recognition_chimes) _router.register_always(_consumers.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 hud: _router.register_always(hud.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(_consumers.play_close_sound_events) _router.register_always(_consumers.update_zone) _router.register_always(_consumers.update_listening_focus) _router.register_always(_consumers.consume_examine_result) # Keyed: consume methods guarded by specific snapshot fields. _router.register("current_monologue", _consumers.consume_monologue) _router.register("current_dialogue", _dialogue.consume_dialogue) _router.register("conversation_events", _dialogue.consume_conversation_events) _router.register("conversation_ended", _dialogue.consume_conversation_ended) _router.register("dialogue_response", _dialogue.consume_dialogue_response) _router.register("save_result", _consumers.consume_save_result) _router.register("debug_response", _consumers.consume_debug_response) _router.register("economy_snapshot", _consumers.consume_economy_snapshot) # #581: Wire settings_dialog debug console toggle if settings_dialog and debug_console: settings_dialog.debug_console_toggled.connect(debug_console.set_enabled) # #581 D-088: Wire debug console pause/unpause if debug_console: debug_console.pause_requested.connect(_dialogue.on_dialogue_pause_requested) debug_console.unpause_requested.connect(_dialogue.on_dialogue_unpause_requested) # #835 D-191: Atlas → Economics Monitor cross-link. The atlas city data panel # emits economics_link_requested(system_id); we pre-filter the monitor and # open it as an insert panel on top. if atlas_app and economics_app: atlas_app.economics_link_requested.connect(_on_atlas_economics_link) func _unhandled_input(event: InputEvent) -> void: # #898: Scroll wheel zoom in free camera mode. if GameState.free_camera_mode and event is InputEventMouseButton: var mb := event as InputEventMouseButton if mb.pressed: var zoom: Vector2 = camera.zoom if mb.button_index == MOUSE_BUTTON_WHEEL_UP: zoom += Vector2(FREE_CAMERA_ZOOM_STEP, FREE_CAMERA_ZOOM_STEP) elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN: zoom -= Vector2(FREE_CAMERA_ZOOM_STEP, FREE_CAMERA_ZOOM_STEP) camera.zoom = zoom.clamp( Vector2(FREE_CAMERA_ZOOM_MIN, FREE_CAMERA_ZOOM_MIN), Vector2(FREE_CAMERA_ZOOM_MAX, FREE_CAMERA_ZOOM_MAX) ) get_viewport().set_input_as_handled() func _unhandled_key_input(event: InputEvent) -> void: if not (event is InputEventKey) or not event.is_pressed() or event.is_echo(): return var key_event := event as InputEventKey # #898: F4 toggles free camera mode. Reset zoom to 1:1 on exit. if Input.is_action_just_pressed("free_camera"): GameState.free_camera_mode = not GameState.free_camera_mode if not GameState.free_camera_mode: camera.zoom = Vector2.ONE return # Registry-driven toggle: each manifest declares its own default_key. for manifest: ImplantAppManifest in ImplantRegistry.get_manifests(): if manifest.app_path.is_empty(): push_warning("main.gd: manifest with empty app_path — skipping") continue if manifest.default_key == key_event.physical_keycode: HudGroups.toggle_app( manifest.app_path, ImplantRegistry.get_resolved_mode(manifest.app_path) ) return # [ / ] — in-app navigation for the economics monitor. Not manifest-declared because # these control intra-app navigation (prev/next system), not app launch. A planned # handle_global_key lifecycle hook will absorb this (see arch doc Follow-up). if key_event.physical_keycode == KEY_BRACKETLEFT: if economics_app and HudGroups.is_app_active("implant/economics"): economics_app.navigate(-1) elif key_event.physical_keycode == KEY_BRACKETRIGHT: if economics_app and HudGroups.is_app_active("implant/economics"): economics_app.navigate(1) func _on_atlas_economics_link(system_id: String) -> void: # #835 D-191: Pre-filter the economics monitor to the city's system and open # it as an insert panel. AtlasApp closes automatically when Economics opens # (HudGroups single-active-app rule → app_changed signal). if economics_app == null: return economics_app.select_system(system_id) HudGroups.open_app("implant/economics", HudGroups.Mode.INSERT) 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. if not _camera_anchored: camera.global_position = GameState.player_position * Constants.TILE_SIZE _camera_anchored = true # #559: Dispatch snapshot to registered handlers (router pattern). _router.dispatch(snapshot) # #898: Free camera WASD pan — runs in place of player tracking. if GameState.free_camera_mode: var pan := Vector2.ZERO if Input.is_action_pressed("move_north"): pan.y -= 1.0 if Input.is_action_pressed("move_south"): pan.y += 1.0 if Input.is_action_pressed("move_east"): pan.x += 1.0 if Input.is_action_pressed("move_west"): pan.x -= 1.0 if pan != Vector2.ZERO: var speed: float = FREE_CAMERA_PAN_SPEED / camera.zoom.x camera.global_position += pan.normalized() * speed * delta # Track camera to player (D-015: locked, fixed-north). # #117: Manual exponential smoothing. elif _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 var inputs = InputMapper.flush_queue() for input in inputs: # #495: F12 WRONG button — client-only 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 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 — delegate to ordered priority chain if input.action == InputMapper.Action.OPEN_MENU: _handle_menu_key() 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() 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 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() # #528: ESC/OPEN_MENU priority chain — first handler to consume wins. # Order matters: MetaStack modal > open implant app > settings dialog. # Adding a fourth handler: append a new step here; don't re-inline in _process. func _handle_menu_key() -> void: if MetaStack.handle_escape(): return if HudGroups.is_implant_active(): HudGroups.close_app() return if settings_dialog == null: return if settings_dialog.is_open(): settings_dialog.close() else: MetaStack.push(settings_dialog) settings_dialog.open() # #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. 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. 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. func _teleport_transition() -> void: _camera_anchored = true _teleport_in_progress = true # Clear client-side buffers GameState.current_monologue = null GameState.current_dialogue = null GameState.dialogue_active = false _consumers.clear_recognition_state() if dialogue_box and dialogue_box.is_dialogue_active(): dialogue_box.hide_dialogue() # Fade from black 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) # #264: Toggle journal panel. func _toggle_journal() -> void: if not journal_panel: return if dialogue_box and dialogue_box.is_dialogue_active(): return if journal_panel.has_method("toggle"): journal_panel.toggle() # #502: Full-screen color flash. 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)