Files
settled-reach/client/scripts/snapshot_handler.gd
T
jpmschweitzerandClaude Opus 4.6 3f5b4258ba feat(client): sprint 38 — free camera viewer, archetype strip, test fixes
- Add free camera mode (F4 toggle): WASD pan, scroll zoom, decoupled
  from player position (#898)
- Strip archetype-driven code: remove character_archetype, lattice_profile,
  and lattice color palettes from client (#882)
- Fix confrontation_monologue signal not firing in headless test mode (#867)
- Revive fog state behavioral tests: EXP_EXPLORED persistence, grow-only
  bounds, texture-resize copy, BoundaryWall handling (#879)
- Triage pre-existing test failures: fix examine_display dismiss timing,
  fog test position fragility, rendering snapshot assertions,
  time_display format (#871)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 10:19:11 +02:00

286 lines
10 KiB
GDScript

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
# 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
# v21: economy_snapshot (#824, D-181)
if snapshot.has("economy_snapshot") and snapshot.economy_snapshot is Dictionary:
GameState.economy_snapshot = snapshot.economy_snapshot
else:
GameState.economy_snapshot = null
# v23: bookmark_catalog (#614) — one-shot response to RequestBookmarkCatalog.
if snapshot.has("bookmark_catalog") and snapshot.bookmark_catalog is Dictionary:
var bmc: Dictionary = snapshot.bookmark_catalog
if bmc.get("bookmarks") is Array:
GameState.bookmark_catalog = bmc["bookmarks"]
# #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