diff --git a/client/scripts/main.gd b/client/scripts/main.gd index a30189d70..54aed56ed 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -33,6 +33,7 @@ var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay ( 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 @@ -65,11 +66,51 @@ func _ready() -> void: 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) + 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 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) + func _process(delta: float) -> void: # Main game loop: poll snapshot, apply state, flush input @@ -89,92 +130,9 @@ func _process(delta: float) -> void: camera.global_position = GameState.player_position * Constants.TILE_SIZE _camera_anchored = true - # Update renderers with new state - if world_renderer and world_renderer.has_method("update_from_state"): - world_renderer.update_from_state() - - # 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. - var insert_state := GameState.insert_active - if cursor_renderer and cursor_renderer.has_method("set_insert_active"): - cursor_renderer.set_insert_active(insert_state) - if interaction_list and interaction_list.has_method("set_insert_active"): - interaction_list.set_insert_active(insert_state) - if interaction_prompt and interaction_prompt.has_method("set_insert_active"): - interaction_prompt.set_insert_active(insert_state) - if minimap and minimap.has_method("set_insert_active"): - 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 - if interaction_list and interaction_list.has_method("update_from_state"): - 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-065: Update inventory grid - if inventory_grid and inventory_grid.has_method("update_from_state"): - inventory_grid.update_from_state() - - # D-053: Update stance indicator - if stance_indicator and stance_indicator.has_method("update_from_state"): - stance_indicator.update_from_state() - - # D-059/D-060: Update fog entity visualization (#431) - if fog_entities and fog_entities.has_method("update_from_state"): - fog_entities.update_from_state() - - # D-067: Recognition chime — fire sfx_monologue_chime on first fog recognition - _play_recognition_chimes() - - # #496: Update gauntlet HUD (room timer + personal bests) - if gauntlet_hud and gauntlet_hud.has_method("update_from_state"): - gauntlet_hud.update_from_state() - - # #503: Update checklist overlay (auto-checklist progress tracking) - if checklist_overlay and checklist_overlay.has_method("update_from_state"): - checklist_overlay.update_from_state() - - # #263: Update time display (D-013, D-031) - if time_display and time_display.has_method("update_from_state"): - time_display.update_from_state() - - # #264: Update journal panel — auto-close on dialogue, refresh if open - if journal_panel and journal_panel.has_method("update_from_state"): - journal_panel.update_from_state() - - # #511: Update debug overlay (F3 toggle, dev tool) - if debug_overlay and debug_overlay.has_method("update_from_state"): - debug_overlay.update_from_state() - - # D-018 #125: Play close-range sound events via positional 2D audio - _play_close_sound_events() - - # D-073 (#529): Zone ambient crossfade — detect player tile zone, trigger set_zone on change. - _update_zone() - - # D-071 (#530): ListeningFocus boost — stationary 30+ ticks boosts WorldSFX. - # Only activates when no dialogue/confrontation dip is active (D-070). - _update_listening_focus() - - # #174: Show examine result if server sent one this tick (#242) - _consume_examine_result() - - # Show monologue if server sent one this tick (#414) - _consume_monologue() - - # D-061: Show dialogue if server sent one this tick (#434) - _consume_dialogue() - - # #535: Consume overheard conversation events and responses - _consume_conversation_events() - _consume_conversation_ended() - _consume_dialogue_response() - - # #554: Show save/load result notification - _consume_save_result() + # #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. @@ -247,6 +205,32 @@ 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) + + +# 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). @@ -456,6 +440,22 @@ func _on_dialogue_dismissed() -> void: }) +# 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: diff --git a/client/scripts/snapshot_event_router.gd b/client/scripts/snapshot_event_router.gd new file mode 100644 index 000000000..334f7e318 --- /dev/null +++ b/client/scripts/snapshot_event_router.gd @@ -0,0 +1,45 @@ +class_name SnapshotEventRouter +## Routes snapshot fields to registered handlers (D-020, #559). +## +## Decouples main.gd from knowing which child node handles which snapshot field. +## Handlers are registered in main.gd._ready(); dispatch() is called each snapshot tick. +## +## Two handler types: +## - Keyed: called only when the snapshot contains a specific field. +## - Always: called every dispatch (every snapshot tick), regardless of fields present. +## +## All handlers are zero-argument callables — they read from GameState directly. +## This preserves GameState as the single source of truth post-apply_snapshot(). + +## Keyed handlers: field_name → Array[Callable] +## Array per field allows multiple handlers on the same key (e.g., two consumers of same data). +var _keyed: Dictionary = {} # String → Array[Callable] + +## Always handlers: called every dispatch regardless of snapshot content. +var _always: Array[Callable] = [] + + +## Register a handler for a specific snapshot field key. +## Handler is called (with no arguments) when snapshot.has(field) is true. +## Multiple handlers per field are supported — they run in registration order. +func register(field: String, handler: Callable) -> void: + if not _keyed.has(field): + _keyed[field] = [] + _keyed[field].append(handler) + + +## Register a handler that runs every dispatch tick (not keyed to a field). +## Use for child nodes that update from GameState on every snapshot, e.g. update_from_state(). +func register_always(handler: Callable) -> void: + _always.append(handler) + + +## Dispatch a snapshot: call always handlers first, then keyed handlers for present fields. +## Handlers read from GameState.* directly — apply_snapshot() must be called before dispatch(). +func dispatch(snapshot: Dictionary) -> void: + for handler in _always: + handler.call() + for field in _keyed: + if snapshot.has(field): + for handler in _keyed[field]: + handler.call() diff --git a/client/tests/test_snapshot_event_router.gd b/client/tests/test_snapshot_event_router.gd new file mode 100644 index 000000000..5d9477b3d --- /dev/null +++ b/client/tests/test_snapshot_event_router.gd @@ -0,0 +1,89 @@ +## SnapshotEventRouter unit tests (#559). +## Verifies callable-based dispatch: keyed handlers receive correct field values, +## always handlers run on every dispatch, absent fields don't trigger handlers. +## +## D-030: fixture-based, server-free, no subprocess required. +class_name TestSnapshotEventRouter +extends GdUnitTestSuite + + +# -- Keyed handlers ----------------------------------------------------------- + +func test_keyed_handler_called_when_field_present() -> void: + var router := SnapshotEventRouter.new() + var received: Array = [] + router.register("current_monologue", func(): received.append("monologue")) + router.dispatch({"current_monologue": {"text": "Test."}}) + assert_that(received.size()).is_equal(1) + assert_that(received[0]).is_equal("monologue") + + +func test_keyed_handler_not_called_when_field_absent() -> void: + var router := SnapshotEventRouter.new() + var received: Array = [] + router.register("current_monologue", func(): received.append("monologue")) + router.dispatch({"tick": 1}) + assert_that(received.size()).is_equal(0) + + +func test_multiple_keyed_handlers_same_field() -> void: + ## Multiple handlers on the same key run in registration order. + var router := SnapshotEventRouter.new() + var order: Array = [] + router.register("save_result", func(): order.append("first")) + router.register("save_result", func(): order.append("second")) + router.dispatch({"save_result": {"success": true}}) + assert_that(order).is_equal(["first", "second"]) + + +func test_keyed_handlers_multiple_fields() -> void: + ## Each keyed handler fires only for its registered field. + var router := SnapshotEventRouter.new() + var received: Array = [] + router.register("current_monologue", func(): received.append("mono")) + router.register("current_dialogue", func(): received.append("dlg")) + router.dispatch({"current_monologue": {"text": "Hi."}}) + assert_that(received).is_equal(["mono"]) + received.clear() + router.dispatch({"current_dialogue": {"speech": "Hello."}, "current_monologue": {"text": "Hmm."}}) + assert_bool(received.has("mono")).is_true() + assert_bool(received.has("dlg")).is_true() + + +# -- Always handlers ---------------------------------------------------------- + +func test_always_handler_called_on_every_dispatch() -> void: + var router := SnapshotEventRouter.new() + var count: Array[int] = [0] + router.register_always(func(): count[0] += 1) + router.dispatch({"tick": 1}) + router.dispatch({"tick": 2}) + router.dispatch({}) + assert_int(count[0]).is_equal(3) + + +func test_always_handler_runs_before_keyed() -> void: + ## Always handlers run before keyed handlers (dispatch order guarantee). + var router := SnapshotEventRouter.new() + var order: Array = [] + router.register("current_monologue", func(): order.append("keyed")) + router.register_always(func(): order.append("always")) + router.dispatch({"current_monologue": {"text": "Hi."}}) + assert_that(order[0]).is_equal("always") + assert_that(order[1]).is_equal("keyed") + + +# -- Empty dispatch ------------------------------------------------------------ + +func test_empty_snapshot_no_crash() -> void: + var router := SnapshotEventRouter.new() + router.register("foo", func(): pass) + router.register_always(func(): pass) + # Must not crash + router.dispatch({}) + + +func test_no_handlers_no_crash() -> void: + var router := SnapshotEventRouter.new() + # Must not crash + router.dispatch({"tick": 1, "entities": []})