From 260eefcdd1b2f142484340c116da61d1cbc8a1e4 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 5 Apr 2026 09:59:34 +0200 Subject: [PATCH] refactor(client): decompose main.gd and game_state.gd god objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract SnapshotHandler (static snapshot parsing), SnapshotConsumers (non-dialogue consumers + audio handlers), and DialogueCoordinator (dialogue consumers + signal handlers) as class_name scripts. main.gd: 28KB → 13KB. game_state.gd: 20KB → 8.5KB. Autoload parse-order safety maintained via load() inline pattern. Ticket: #775 Co-Authored-By: Claude Opus 4.6 (1M context) --- client/scripts/autoloads/game_state.gd | 286 +-------------- client/scripts/dialogue_coordinator.gd | 166 +++++++++ client/scripts/main.gd | 444 ++++------------------- client/scripts/snapshot_consumers.gd | 194 ++++++++++ client/scripts/snapshot_handler.gd | 254 +++++++++++++ client/tests/test_game_state.gd | 10 +- client/tests/test_game_state_sprint20.gd | 2 +- client/tests/test_main_scene.gd | 3 +- 8 files changed, 694 insertions(+), 665 deletions(-) create mode 100644 client/scripts/dialogue_coordinator.gd create mode 100644 client/scripts/snapshot_consumers.gd create mode 100644 client/scripts/snapshot_handler.gd diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 7507b4b07..14e137ce2 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -19,7 +19,8 @@ var player_position: Vector2 = Vector2.ZERO var visible_entities: Array = [] var visible_tiles: Array = [] var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups (normal LOS tiles) -var boundary_positions: Dictionary = {} # Vector2i -> true, BoundaryWall margin tiles (#585) — visible in fog but not explored +var boundary_positions: Dictionary = {} # Vector2i -> true, BoundaryWall margin tiles (#585) +# — visible in fog but not explored # v2 fields (D-015, D-031) var game_time: Dictionary = {} # {day, time_of_day, day_phase, tick_rate} or empty @@ -50,7 +51,8 @@ var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch var player_inventory: Array = [] # [{item_id, name, slot}] # v7 fields (#435, D-061/D-062) -var current_dialogue: Variant = null # {npc_name, npc_entity_id, speech, options: [{text, response_id, priority}]} or null +var current_dialogue: Variant = null # {npc_name, npc_entity_id, speech, options: [{text, response_id, priority}]} +# or null # D-064: true while dialogue box is visible or fading out (300ms). # InputMapper suppresses movement when this is true. @@ -152,8 +154,7 @@ var close_sound_events: Array = [] # Fallback: client-side accumulation (deprecated, remove when server populates field). # ListeningFocus boost activates at 30+ ticks (main.gd manages the dip). var stationary_ticks: int = 0 -# DEPRECATED: Only used by client-side accumulation fallback. Remove with fallback. -var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position +# DEPRECATED: _prev_player_position moved to SnapshotHandler (client-side accumulation fallback). # D-073 (#529): Server-authoritative zone_id from the player's current tile. # D-020: Read directly from snapshot "zone_id" field. @@ -162,276 +163,7 @@ var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previou var current_zone_id: String = "" func apply_snapshot(snapshot: Dictionary) -> void: - current_snapshot = snapshot - - if snapshot.has("tick"): - current_tick = snapshot.tick - - if snapshot.has("entities"): - visible_entities = snapshot.entities - # Derive player position from the entity with kind.variant == "Player" - var found_player := false - for entity in visible_entities: - if entity.has("kind") and entity.kind is Dictionary and entity.kind.get("variant") == "Player": - player_position = Vector2(entity.x, entity.y) - if entity.has("entity_id"): - player_entity_id = entity.entity_id - found_player = true - break - if not found_player and visible_entities.size() > 0: - push_warning("GameState: no Player entity found in %d entities" % [ - visible_entities.size()]) - - # D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost. - # Prefer server-sent value; fall back to client-side accumulation until server populates. - if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int: - # D-020: direct field assignment from server-authoritative snapshot. - stationary_ticks = snapshot.stationary_ticks - else: - # DEPRECATED fallback — client-side accumulation. Remove when server sends - # "stationary_ticks" in ObserverSnapshot (D-020 violation: derives behavior- - # driving state on the client). Server tracks this in ListeningFocus component. - if player_position == _prev_player_position: - stationary_ticks += 1 - else: - stationary_ticks = 0 - _prev_player_position = player_position - - # Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles" - if snapshot.has("tiles"): - visible_tiles = snapshot.tiles - elif snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0: - # Live server: visible_tiles now includes type from tile_kind field - var has_type := false - if snapshot.visible_tiles.size() > 0 and snapshot.visible_tiles[0] is Dictionary: - has_type = snapshot.visible_tiles[0].has("type") - if has_type: - visible_tiles = snapshot.visible_tiles - - if snapshot.has("visible_positions"): - visible_positions.clear() - for pos in snapshot.visible_positions: - visible_positions[Vector2i(pos.x, pos.y)] = true - - # v2: game_time (D-031) - if snapshot.has("game_time") and snapshot.game_time is Dictionary: - game_time = snapshot.game_time - - # v2: player_facing (D-015) - if snapshot.has("player_facing") and snapshot.player_facing is String: - player_facing = snapshot.player_facing - - # v4: nearby_interactions (#404/#405) - if snapshot.has("nearby_interactions") and snapshot.nearby_interactions is Array: - nearby_interactions = snapshot.nearby_interactions - else: - nearby_interactions = [] - - # v5: current_monologue (#414) - if snapshot.has("current_monologue") and snapshot.current_monologue is Dictionary: - current_monologue = snapshot.current_monologue - else: - current_monologue = null - - # #122: lattice_profile — character insert capability level for monologue colour - if snapshot.has("lattice_profile") and snapshot.lattice_profile is String: - lattice_profile = snapshot.lattice_profile - - # v6: player_stance (#449, D-053) - if snapshot.has("player_stance") and snapshot.player_stance is String: - player_stance = snapshot.player_stance - - # v6: player_inventory (#449, D-065) - if snapshot.has("player_inventory") and snapshot.player_inventory is Array: - player_inventory = snapshot.player_inventory - else: - player_inventory = [] - - # v7: current_dialogue (#434, D-061) - if snapshot.has("current_dialogue") and snapshot.current_dialogue is Dictionary: - current_dialogue = snapshot.current_dialogue - else: - current_dialogue = null - - # v7: pending_recognitions (#431, D-059/D-060) - if snapshot.has("pending_recognitions") and snapshot.pending_recognitions is Array: - pending_recognitions = snapshot.pending_recognitions - else: - pending_recognitions = [] - - # v9: conversation_events (#535, D-078) — overheard NPC-to-NPC lines - if snapshot.has("conversation_events") and snapshot.conversation_events is Array: - conversation_events = snapshot.conversation_events - else: - conversation_events = [] - - # v9: conversation_ended (#535, D-078) — pairs whose conversation ended - if snapshot.has("conversation_ended") and snapshot.conversation_ended is Array: - conversation_ended = snapshot.conversation_ended - else: - conversation_ended = [] - - # v8: dialogue_response (#305, D-028) — NPC follow-up after player choice - if snapshot.has("dialogue_response") and snapshot.dialogue_response is Dictionary: - dialogue_response = snapshot.dialogue_response - else: - dialogue_response = null - - # v8: gauntlet mode (#496) — room_id and gauntlet_mode - if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true: - gauntlet_mode = true - else: - gauntlet_mode = false - if snapshot.has("room_id") and snapshot.room_id is String: - room_id = snapshot.room_id - else: - room_id = null - - # OQ-07 (#522): insert_active — defaults true (v0.1 always has insert). - # Server may send false for characters without an insert in future sprints. - if snapshot.has("insert_active") and snapshot.insert_active is bool: - insert_active = snapshot.insert_active - else: - insert_active = true - - # #507: rng_seed — server sends current RNG seed for replay determinism. - # Field: "rng_seed" (u64 as integer). Null if server does not include it. - if snapshot.has("rng_seed"): - rng_seed = snapshot.rng_seed - else: - rng_seed = null - - # D-018: Sound events from server — partition by range_category. - # #126: Medium → fog-edge directional indicators. - # #125: Close → positional 2D audio via AudioManager. - if snapshot.has("sound_events") and snapshot.sound_events is Array: - medium_sound_events = [] - close_sound_events = [] - for se in snapshot.sound_events: - if not se is Dictionary: - continue - var rc: String = se.get("range_category", "") - if rc == "Medium": - medium_sound_events.append(se) - elif rc == "Close": - close_sound_events.append(se) - else: - medium_sound_events = [] - close_sound_events = [] - - # v10: discovered_pois (#151, D-013) — server sends POIs discovered by the player. - # Accepts "discovered_pois" or "poi_list" key — both map to the same client field. - # Only update if the field is present — absence means "no change since last tick". - if snapshot.has("discovered_pois") and snapshot.discovered_pois is Array: - discovered_pois = snapshot.discovered_pois - elif snapshot.has("poi_list") and snapshot.poi_list is Array: - discovered_pois = snapshot.poi_list - - # v14: examine_result (#174, #242) — character-filtered observation from Examine verb. - if snapshot.has("examine_result") and snapshot.examine_result is Dictionary: - current_examine_result = snapshot.examine_result - else: - current_examine_result = null - - # v15: save_result (#554, D-085) — one-shot save/load confirmation from server. - if snapshot.has("save_result") and snapshot.save_result is Dictionary: - save_result = snapshot.save_result - else: - save_result = null - - # v18: debug_response (#580) — debug console command result. - if snapshot.has("debug_response") and snapshot.debug_response is Dictionary: - debug_response = snapshot.debug_response - else: - debug_response = null - - # v20: settings_response (#627, D-138) — one-shot settings ack/dump from server. - # "full" kind → iterate settings array and hydrate matching fields. - if snapshot.has("settings_response") and snapshot.settings_response is Dictionary: - settings_response = snapshot.settings_response - var sr: Dictionary = snapshot.settings_response - if sr.get("kind") == "full": - var sr_settings: Variant = sr.get("settings") - if sr_settings is Array: - for entry in sr_settings: - if not entry is Dictionary: - continue - if entry.get("key") == "ai_dialogue.enabled": - var val: Variant = entry.get("value") - if val != null: - ai_enhanced_dialogue_enabled = _extract_bool_setting("ai_dialogue.enabled", val) - else: - settings_response = null - - # #718: character_visual_descriptor — restored from server snapshot on save/load. - # Server persists the descriptor and includes it in ObserverSnapshot after load. - # Only update when field is present (null means no change). - if snapshot.has("character_visual_descriptor") and snapshot.character_visual_descriptor is Dictionary: - # load() returns a cached script — safe to call per-tick once the resource is in cache. - # Cannot use CharacterVisualDescriptor directly: autoloads compile before global class_names - # are registered, causing a parse-time "not declared" error. - var CVD := load("res://scripts/rendering/character_visual_descriptor.gd") - if CVD != null: - var restored = CVD.from_dict(snapshot.character_visual_descriptor) - if restored != null: - character_visual_descriptor = restored - - # v14: player_knowledge (#264, D-041) — partial KG dump for journal panel. - # Only update when field is present (null means no change, server sends when KG changes). - if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary: - player_knowledge = snapshot.player_knowledge - - # D-020/D-073 (#529): Server-authoritative zone_id for zone ambient crossfade. - # Prefer server-sent top-level value; fall back to client-side tile lookup until - # server populates top-level "zone_id" in ObserverSnapshot. - if snapshot.has("zone_id") and snapshot.zone_id is String: - # D-020: direct field assignment from server-authoritative snapshot. - current_zone_id = snapshot.zone_id - else: - # DEPRECATED fallback — client-side tile lookup. Remove when server sends - # top-level "zone_id" in ObserverSnapshot (D-020 violation: derives zone - # identity on the client via tile iteration). Server sends zone_id per - # VisibleTile but not as a top-level snapshot field. - var _tile_by_coord: Dictionary = {} - for vtile in visible_tiles: - if vtile is Dictionary and vtile.has("x") and vtile.has("y"): - _tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile - var player_pos_key := Vector2i(int(player_position.x), int(player_position.y)) - var player_tile = _tile_by_coord.get(player_pos_key, null) - current_zone_id = player_tile.get("zone_id", "") if player_tile else "" - - # v2: visible_tiles with visibility sectors - # Derives visible_positions when not explicitly provided (real server mode). - # #585: BoundaryWall tiles go to boundary_positions — rendered in fog but not marked explored. - if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0: - visibility_sectors.clear() - var has_explicit_positions := snapshot.has("visible_positions") - if not has_explicit_positions: - visible_positions.clear() - boundary_positions.clear() - for vtile in snapshot.visible_tiles: - if not vtile is Dictionary or not vtile.has("x") or not vtile.has("y"): - continue - var pos := Vector2i(vtile.x, vtile.y) - var vis_sector: String = vtile.get("visibility", "") - if vtile.has("visibility"): - visibility_sectors[pos] = vis_sector - # #585: BoundaryWall tiles are margin tiles visible through fog but not persistently - # explored — they don't update the player's exploration memory when they leave LOS. - if vis_sector == "BoundaryWall": - boundary_positions[pos] = true - elif not has_explicit_positions: - visible_positions[pos] = true - - -# -- Helpers ------------------------------------------------------------------ - -## Extract a bool from a tagged-union {"Bool": true} or plain bool value. -## Handles both serde encoding styles; emits push_warning on unrecognised format. -static func _extract_bool_setting(key: String, val: Variant) -> bool: - if val is bool: - return val - if val is Dictionary and val.has("Bool"): - return bool(val["Bool"]) - push_warning("GameState: unexpected type for setting '%s': %s" % [key, str(val)]) - return false + # Autoload parse-order: class_name types are not registered when autoloads compile. + # load() returns the cached resource after the first call — essentially free per-tick. + var SH := load("res://scripts/snapshot_handler.gd") + SH.apply(snapshot) diff --git a/client/scripts/dialogue_coordinator.gd b/client/scripts/dialogue_coordinator.gd new file mode 100644 index 000000000..f092c8b61 --- /dev/null +++ b/client/scripts/dialogue_coordinator.gd @@ -0,0 +1,166 @@ +class_name DialogueCoordinator +## Dialogue snapshot consumers and signal handlers extracted from main.gd (#775). +## +## Owns NPC identity tracking state (_last_dialogue_npc_id/name) shared between +## consuming dialogue snapshots and handling dialogue_box signals. +## Registered with SnapshotEventRouter; reads from GameState directly. + +var dialogue_box: Node = null +var monologue_display: Node = null +var journal_panel: Node = null + +# Shared mutable reference — GDScript arrays are reference types. +# main.gd and this coordinator both append to the same array. +var _pending_record_inputs: Array = [] + +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 _last_dialogue_tick: int = -1 +var _last_confrontation_tick: int = -1 + + +func init(refs: Dictionary, pending_record_inputs: Array) -> DialogueCoordinator: + dialogue_box = refs.get("dialogue_box") + monologue_display = refs.get("monologue_display") + journal_panel = refs.get("journal_panel") + _pending_record_inputs = pending_record_inputs + return self + + +## Connect dialogue_box signals. Call from main.gd._ready() after init(). +func connect_signals() -> void: + if not dialogue_box: + return + 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) + + +# -- Snapshot consumers (registered with SnapshotEventRouter) ----------------- + +# Consume-once per tick with ID tracking: show dialogue, then clear. +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 + 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). +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. +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. +func consume_dialogue_response() -> void: + if GameState.dialogue_response == null or not dialogue_box: + return + var dr: Dictionary = GameState.dialogue_response + 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 + + +# -- Signal handlers ---------------------------------------------------------- + +# 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: Confrontation beat monologue -> show on monologue display (layer 7) +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 +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 +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. +func on_dialogue_state_changed(active: bool) -> void: + GameState.dialogue_active = active + + +# D-020 (#558): Route audio dip requests. +func on_audio_dip_requested(profile: String) -> void: + AudioManager.apply_dip(profile) + + +# D-020 (#558): Route audio dip clear. +func on_audio_dip_cleared() -> void: + AudioManager.clear_dip() diff --git a/client/scripts/main.gd b/client/scripts/main.gd index bcaf36ced..05c3d8fd9 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -1,5 +1,15 @@ extends Node2D +const TELEPORT_DISTANCE_THRESHOLD: float = 5.0 + +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 @@ -26,37 +36,17 @@ extends Node2D @onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7) @onready var star_map = $UILayer/HUD/StarMap # #674: star map insert module (hop-ring view) -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. + # #257: Deferred load dispatch if not GameState.pending_load_path.is_empty(): if loading_screen: loading_screen.show_loading() @@ -66,46 +56,52 @@ func _ready() -> void: 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) + # #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, + "star_map": star_map, + }, _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 — replaces inline dispatch in _process(). + # #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(_propagate_insert_state) - _router.register_always(_update_interaction_list) + _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(_play_recognition_chimes) - _router.register_always(_handle_triangle_crisis_events) + _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: @@ -118,27 +114,27 @@ func _ready() -> void: _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) + _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", _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) + _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) - # #581: Wire settings_dialog debug console toggle → debug_console.set_enabled + # #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 — sim must not advance during debug input + # #581 D-088: Wire debug console pause/unpause if debug_console: - debug_console.pause_requested.connect(_on_dialogue_pause_requested) - debug_console.unpause_requested.connect(_on_dialogue_unpause_requested) + debug_console.pause_requested.connect(_dialogue.on_dialogue_pause_requested) + debug_console.unpause_requested.connect(_dialogue.on_dialogue_unpause_requested) func _unhandled_key_input(event: InputEvent) -> void: @@ -160,20 +156,15 @@ func _process(delta: float) -> void: _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. + # #117: Manual exponential smoothing. if _camera_anchored: var target := GameState.player_position * Constants.TILE_SIZE if _teleport_in_progress: @@ -184,11 +175,9 @@ func _process(delta: float) -> void: 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 + # #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() @@ -197,7 +186,7 @@ func _process(delta: float) -> void: 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) + # #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) @@ -224,7 +213,6 @@ func _process(delta: float) -> void: 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, @@ -238,9 +226,7 @@ func _process(delta: float) -> void: 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. + # #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, @@ -250,296 +236,13 @@ func _process(delta: float) -> void: _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) - if star_map: - star_map.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: +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 @@ -564,19 +267,13 @@ func _dispatch_pending_load() -> void: loading_screen.hide_loading(false) -# #501: Detect large position jump indicating a teleport (not normal movement). -const TELEPORT_DISTANCE_THRESHOLD: float = 5.0 - +# #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. -# 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 @@ -584,12 +281,11 @@ func _teleport_transition() -> void: 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 + _consumers.clear_recognition_state() 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 + # Fade from black if _flash_rect and is_instance_valid(_flash_rect): _flash_rect.queue_free() _flash_rect = ColorRect.new() @@ -602,35 +298,17 @@ func _teleport_transition() -> void: 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. +# #264: Toggle journal panel. 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. +# #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() diff --git a/client/scripts/snapshot_consumers.gd b/client/scripts/snapshot_consumers.gd new file mode 100644 index 000000000..59bad3e52 --- /dev/null +++ b/client/scripts/snapshot_consumers.gd @@ -0,0 +1,194 @@ +class_name SnapshotConsumers +## Non-dialogue snapshot consumers and event handlers extracted from main.gd (#775). +## +## Registered with SnapshotEventRouter; reads from GameState directly. +## UI node references passed via init(). All methods are zero-argument +## callables compatible with SnapshotEventRouter. + +const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost + +var monologue_display: Node = null +var dialogue_box: Node = null +var examine_display: Node = null +var loading_screen: Node = null +var debug_console: Node = null +var cursor_renderer: Node = null +var interaction_list: Node = null +var interaction_prompt: Node = null +var minimap: Node = null +var star_map: Node = null + +var _screen_flash_fn: Callable # Callable(color: Color, duration: float) + +var _last_monologue_tick: int = -1 +var _known_recognition_ids: Dictionary = {} +var _known_triangle_ids: Dictionary = {} +var _current_zone: String = "" + + +func init(refs: Dictionary, screen_flash: Callable) -> SnapshotConsumers: + monologue_display = refs.get("monologue_display") + dialogue_box = refs.get("dialogue_box") + examine_display = refs.get("examine_display") + loading_screen = refs.get("loading_screen") + debug_console = refs.get("debug_console") + cursor_renderer = refs.get("cursor_renderer") + interaction_list = refs.get("interaction_list") + interaction_prompt = refs.get("interaction_prompt") + minimap = refs.get("minimap") + star_map = refs.get("star_map") + _screen_flash_fn = screen_flash + return self + + +# OQ-07 (#522): Propagate insert state to all z-layer-6 display nodes. +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) + if star_map: + star_map.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. +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. +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. +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. +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. +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. +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_fn.call(Constants.ENTITY_COLOR_POI, 0.15) + GameState.current_monologue = 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 + 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 + + +# #174: Consume examine result — show overlay when server sends character-filtered observation. +func consume_examine_result() -> void: + if GameState.current_examine_result == null or not examine_display: + return + var result: Dictionary = GameState.current_examine_result + 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 + + +## Clear session-scoped recognition state (call on teleport / room change). +func clear_recognition_state() -> void: + _known_recognition_ids.clear() + _known_triangle_ids.clear() diff --git a/client/scripts/snapshot_handler.gd b/client/scripts/snapshot_handler.gd new file mode 100644 index 000000000..97bb44514 --- /dev/null +++ b/client/scripts/snapshot_handler.gd @@ -0,0 +1,254 @@ +class_name SnapshotHandler +## Applies ObserverSnapshot data to GameState fields (D-020). +## +## Extracted from game_state.gd to separate snapshot parsing from state storage. +## All methods are static — no instance state required. +## Called via GameState.apply_snapshot() which delegates here. + +# DEPRECATED: Client-side stationary_ticks fallback. Remove when server sends +# "stationary_ticks" in ObserverSnapshot (D-020 violation). +static var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) + + +static func apply(snapshot: Dictionary) -> void: + GameState.current_snapshot = snapshot + + if snapshot.has("tick"): + GameState.current_tick = snapshot.tick + + if snapshot.has("entities"): + GameState.visible_entities = snapshot.entities + var found_player := false + for entity in GameState.visible_entities: + if entity.has("kind") and entity.kind is Dictionary and entity.kind.get("variant") == "Player": + GameState.player_position = Vector2(entity.x, entity.y) + if entity.has("entity_id"): + GameState.player_entity_id = entity.entity_id + found_player = true + break + if not found_player and GameState.visible_entities.size() > 0: + push_warning("GameState: no Player entity found in %d entities" % [ + GameState.visible_entities.size()]) + + # D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost. + if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int: + GameState.stationary_ticks = snapshot.stationary_ticks + else: + # DEPRECATED fallback — client-side accumulation. Remove when server sends field. + if GameState.player_position == _prev_player_position: + GameState.stationary_ticks += 1 + else: + GameState.stationary_ticks = 0 + _prev_player_position = GameState.player_position + + # Tiles for rendering: test mode sends "tiles", live server sends "visible_tiles" + if snapshot.has("tiles"): + GameState.visible_tiles = snapshot.tiles + elif snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0: + var has_type := false + if snapshot.visible_tiles.size() > 0 and snapshot.visible_tiles[0] is Dictionary: + has_type = snapshot.visible_tiles[0].has("type") + if has_type: + GameState.visible_tiles = snapshot.visible_tiles + + if snapshot.has("visible_positions"): + GameState.visible_positions.clear() + for pos in snapshot.visible_positions: + GameState.visible_positions[Vector2i(pos.x, pos.y)] = true + + # v2: game_time (D-031) + if snapshot.has("game_time") and snapshot.game_time is Dictionary: + GameState.game_time = snapshot.game_time + + # v2: player_facing (D-015) + if snapshot.has("player_facing") and snapshot.player_facing is String: + GameState.player_facing = snapshot.player_facing + + # v4: nearby_interactions (#404/#405) + if snapshot.has("nearby_interactions") and snapshot.nearby_interactions is Array: + GameState.nearby_interactions = snapshot.nearby_interactions + else: + GameState.nearby_interactions = [] + + # v5: current_monologue (#414) + if snapshot.has("current_monologue") and snapshot.current_monologue is Dictionary: + GameState.current_monologue = snapshot.current_monologue + else: + GameState.current_monologue = null + + # #122: lattice_profile + if snapshot.has("lattice_profile") and snapshot.lattice_profile is String: + GameState.lattice_profile = snapshot.lattice_profile + + # v6: player_stance (#449, D-053) + if snapshot.has("player_stance") and snapshot.player_stance is String: + GameState.player_stance = snapshot.player_stance + + # v6: player_inventory (#449, D-065) + if snapshot.has("player_inventory") and snapshot.player_inventory is Array: + GameState.player_inventory = snapshot.player_inventory + else: + GameState.player_inventory = [] + + # v7: current_dialogue (#434, D-061) + if snapshot.has("current_dialogue") and snapshot.current_dialogue is Dictionary: + GameState.current_dialogue = snapshot.current_dialogue + else: + GameState.current_dialogue = null + + # v7: pending_recognitions (#431, D-059/D-060) + if snapshot.has("pending_recognitions") and snapshot.pending_recognitions is Array: + GameState.pending_recognitions = snapshot.pending_recognitions + else: + GameState.pending_recognitions = [] + + # v9: conversation_events (#535, D-078) + if snapshot.has("conversation_events") and snapshot.conversation_events is Array: + GameState.conversation_events = snapshot.conversation_events + else: + GameState.conversation_events = [] + + # v9: conversation_ended (#535, D-078) + if snapshot.has("conversation_ended") and snapshot.conversation_ended is Array: + GameState.conversation_ended = snapshot.conversation_ended + else: + GameState.conversation_ended = [] + + # v8: dialogue_response (#305, D-028) + if snapshot.has("dialogue_response") and snapshot.dialogue_response is Dictionary: + GameState.dialogue_response = snapshot.dialogue_response + else: + GameState.dialogue_response = null + + # v8: gauntlet mode (#496) + if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true: + GameState.gauntlet_mode = true + else: + GameState.gauntlet_mode = false + if snapshot.has("room_id") and snapshot.room_id is String: + GameState.room_id = snapshot.room_id + else: + GameState.room_id = null + + # OQ-07 (#522): insert_active + if snapshot.has("insert_active") and snapshot.insert_active is bool: + GameState.insert_active = snapshot.insert_active + else: + GameState.insert_active = true + + # #507: rng_seed + if snapshot.has("rng_seed"): + GameState.rng_seed = snapshot.rng_seed + else: + GameState.rng_seed = null + + # D-018: Sound events — partition by range_category. + if snapshot.has("sound_events") and snapshot.sound_events is Array: + GameState.medium_sound_events = [] + GameState.close_sound_events = [] + for se in snapshot.sound_events: + if not se is Dictionary: + continue + var rc: String = se.get("range_category", "") + if rc == "Medium": + GameState.medium_sound_events.append(se) + elif rc == "Close": + GameState.close_sound_events.append(se) + else: + GameState.medium_sound_events = [] + GameState.close_sound_events = [] + + # v10: discovered_pois (#151, D-013) + if snapshot.has("discovered_pois") and snapshot.discovered_pois is Array: + GameState.discovered_pois = snapshot.discovered_pois + elif snapshot.has("poi_list") and snapshot.poi_list is Array: + GameState.discovered_pois = snapshot.poi_list + + # v14: examine_result (#174, #242) + if snapshot.has("examine_result") and snapshot.examine_result is Dictionary: + GameState.current_examine_result = snapshot.examine_result + else: + GameState.current_examine_result = null + + # v15: save_result (#554, D-085) + if snapshot.has("save_result") and snapshot.save_result is Dictionary: + GameState.save_result = snapshot.save_result + else: + GameState.save_result = null + + # v18: debug_response (#580) + if snapshot.has("debug_response") and snapshot.debug_response is Dictionary: + GameState.debug_response = snapshot.debug_response + else: + GameState.debug_response = null + + # v20: settings_response (#627, D-138) + if snapshot.has("settings_response") and snapshot.settings_response is Dictionary: + GameState.settings_response = snapshot.settings_response + var sr: Dictionary = snapshot.settings_response + if sr.get("kind") == "full": + var sr_settings: Variant = sr.get("settings") + if sr_settings is Array: + for entry in sr_settings: + if not entry is Dictionary: + continue + if entry.get("key") == "ai_dialogue.enabled": + var val: Variant = entry.get("value") + if val != null: + GameState.ai_enhanced_dialogue_enabled = _extract_bool_setting("ai_dialogue.enabled", val) + else: + GameState.settings_response = null + + # #718: character_visual_descriptor — restored from server snapshot on save/load. + if snapshot.has("character_visual_descriptor") and snapshot.character_visual_descriptor is Dictionary: + var CVD := load("res://scripts/rendering/character_visual_descriptor.gd") + if CVD != null: + var restored = CVD.from_dict(snapshot.character_visual_descriptor) + if restored != null: + GameState.character_visual_descriptor = restored + + # v14: player_knowledge (#264, D-041) + if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary: + GameState.player_knowledge = snapshot.player_knowledge + + # D-020/D-073 (#529): Server-authoritative zone_id for zone ambient crossfade. + if snapshot.has("zone_id") and snapshot.zone_id is String: + GameState.current_zone_id = snapshot.zone_id + else: + # DEPRECATED fallback — client-side tile lookup. Remove when server sends top-level "zone_id". + var tile_by_coord: Dictionary = {} + for vtile in GameState.visible_tiles: + if vtile is Dictionary and vtile.has("x") and vtile.has("y"): + tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile + var player_pos_key := Vector2i(int(GameState.player_position.x), int(GameState.player_position.y)) + var player_tile = tile_by_coord.get(player_pos_key, null) + GameState.current_zone_id = player_tile.get("zone_id", "") if player_tile else "" + + # v2: visible_tiles with visibility sectors + if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0: + GameState.visibility_sectors.clear() + var has_explicit_positions := snapshot.has("visible_positions") + if not has_explicit_positions: + GameState.visible_positions.clear() + GameState.boundary_positions.clear() + for vtile in snapshot.visible_tiles: + if not vtile is Dictionary or not vtile.has("x") or not vtile.has("y"): + continue + var pos := Vector2i(vtile.x, vtile.y) + var vis_sector: String = vtile.get("visibility", "") + if vtile.has("visibility"): + GameState.visibility_sectors[pos] = vis_sector + if vis_sector == "BoundaryWall": + GameState.boundary_positions[pos] = true + elif not has_explicit_positions: + GameState.visible_positions[pos] = true + + +## Extract a bool from a tagged-union {"Bool": true} or plain bool value. +static func _extract_bool_setting(key: String, val: Variant) -> bool: + if val is bool: + return val + if val is Dictionary and val.has("Bool"): + return bool(val["Bool"]) + push_warning("GameState: unexpected type for setting '%s': %s" % [key, str(val)]) + return false diff --git a/client/tests/test_game_state.gd b/client/tests/test_game_state.gd index aebd84461..e46c323b5 100644 --- a/client/tests/test_game_state.gd +++ b/client/tests/test_game_state.gd @@ -19,7 +19,7 @@ func before_each() -> void: GameState.player_stance = "Walk" GameState.player_inventory = [] GameState.stationary_ticks = 0 - GameState._prev_player_position = Vector2(-1e9, -1e9) + SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9) GameState.current_zone_id = "" GameState.insert_active = true @@ -67,7 +67,8 @@ func test_apply_snapshot_player_facing_missing_keeps_default() -> void: func test_apply_snapshot_sets_nearby_interactions() -> void: var interactions := [ - {"entity_id": 5, "entity_type": "Npc", "distance": 1.2, "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}]}, + {"entity_id": 5, "entity_type": "Npc", "distance": 1.2, + "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}]}, ] GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": interactions}) assert_that(GameState.nearby_interactions.size()).is_equal(1) @@ -83,7 +84,10 @@ func test_apply_snapshot_nearby_interactions_absent_clears_list() -> void: # -- v5: current_monologue (#414) ----------------------------------------- func test_apply_snapshot_sets_monologue() -> void: - var monologue := {"id": "m1", "text": "Something is off here.", "duration_seconds": 4.0, "priority": 1, "is_urgent": false} + var monologue := { + "id": "m1", "text": "Something is off here.", + "duration_seconds": 4.0, "priority": 1, "is_urgent": false, + } GameState.apply_snapshot({"tick": 1, "entities": [], "current_monologue": monologue}) assert_that(GameState.current_monologue).is_not_null() assert_that(GameState.current_monologue.get("text")).is_equal("Something is off here.") diff --git a/client/tests/test_game_state_sprint20.gd b/client/tests/test_game_state_sprint20.gd index 7c302a03d..8aa22c060 100644 --- a/client/tests/test_game_state_sprint20.gd +++ b/client/tests/test_game_state_sprint20.gd @@ -14,7 +14,7 @@ extends GdUnitTestSuite func before_each() -> void: GameState.stationary_ticks = 0 - GameState._prev_player_position = Vector2(-1e9, -1e9) + SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9) GameState.current_zone_id = "" GameState.player_position = Vector2.ZERO GameState.visible_tiles = [] diff --git a/client/tests/test_main_scene.gd b/client/tests/test_main_scene.gd index ed266e4b6..24ffccba9 100644 --- a/client/tests/test_main_scene.gd +++ b/client/tests/test_main_scene.gd @@ -1,5 +1,6 @@ ## Smoke tests for main scene initialization -## Run with: godot4 --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/ +## Run with: godot4 --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd +## --ignoreHeadlessMode -a res://tests/ class_name TestMainScene extends GdUnitTestSuite