refactor(client): decompose main.gd and game_state.gd god objects

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 09:59:34 +02:00
co-authored by Claude Opus 4.6
parent 8eb9e0a383
commit 260eefcdd1
8 changed files with 694 additions and 665 deletions
+9 -277
View File
@@ -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)
+166
View File
@@ -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()
+61 -383
View File
@@ -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()
+194
View File
@@ -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()
+254
View File
@@ -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
+7 -3
View File
@@ -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.")
+1 -1
View File
@@ -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 = []
+2 -1
View File
@@ -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