refactor(client): extract SnapshotEventRouter from main.gd (#559)
New SnapshotEventRouter class (46 lines) provides callable-based snapshot dispatch via register(), register_always(), and dispatch(). main.gd _process() now calls _router.dispatch(snapshot) instead of 15+ inline if-has blocks. Handlers registered in _ready(). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+86
-86
@@ -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 _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 _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 _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
|
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.confrontation_monologue.connect(_on_confrontation_monologue)
|
||||||
dialogue_box.pause_requested.connect(_on_dialogue_pause_requested)
|
dialogue_box.pause_requested.connect(_on_dialogue_pause_requested)
|
||||||
dialogue_box.unpause_requested.connect(_on_dialogue_unpause_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
|
# #496: Print gauntlet session summary on disconnect
|
||||||
if gauntlet_hud:
|
if gauntlet_hud:
|
||||||
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
|
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:
|
func _process(delta: float) -> void:
|
||||||
# Main game loop: poll snapshot, apply state, flush input
|
# 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.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||||
_camera_anchored = true
|
_camera_anchored = true
|
||||||
|
|
||||||
# Update renderers with new state
|
# #559: Dispatch snapshot to registered handlers (router pattern).
|
||||||
if world_renderer and world_renderer.has_method("update_from_state"):
|
# Always-run handlers update child nodes; keyed handlers fire for present fields.
|
||||||
world_renderer.update_from_state()
|
_router.dispatch(snapshot)
|
||||||
|
|
||||||
# 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()
|
|
||||||
|
|
||||||
# Track camera to player (D-015: locked, fixed-north).
|
# Track camera to player (D-015: locked, fixed-north).
|
||||||
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
|
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
|
||||||
@@ -247,6 +205,32 @@ func _process(delta: float) -> void:
|
|||||||
_pending_record_inputs.clear()
|
_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.
|
# 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
|
# 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).
|
# 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
|
# #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:
|
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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": []})
|
||||||
Reference in New Issue
Block a user