Rewrites the monologue display system per Tyre architecture review (Sprint 14): Rendering: - Up to 3 simultaneous visible lines (VBoxContainer, dynamic node creation) - Lines created programmatically as MarginContainer > RichTextLabel per slot - Percentage-based anchors: 5% left, 75–98% vertical (25% area from bottom), 50% max width - Z-layer 7 in UILayer (CanvasLayer 20, D-049) Queue: - 5-entry priority queue; highest priority drains first - On overflow: incoming line replaces lowest-priority queued entry if it outranks it - Lower/equal priority incoming lines silently dropped when queue full API: show_monologue(text, duration, priority=2, is_urgent=false) - Replaces old (text, duration, character_type) signature - main.gd passes priority and is_urgent from MonologueEvent fields - Confrontation monologue: priority=3, is_urgent=true (D-063) Colour: - Reads GameState.lattice_profile at render time (D-032) - lattice_augmented (detective): standard #d0d4e0 / urgent #e0e8f8 - lattice_baseline (smuggler): standard #d8d0c4 / urgent #f0e4d4 - Fallback for unknown profiles; no crash Stagger: 0.15s between consecutive fade-ins (spec §5.4) Opacity: standard 0.85, urgent 1.0; bloom deferred GameState: adds lattice_profile field, parsed from snapshot Tests: 27 gdUnit4 test cases — queue order, priority drop, overflow, stagger, no-overwrite (P0 #477), BBCode output, palette selection, slot lifecycle Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
240 lines
9.7 KiB
GDScript
240 lines
9.7 KiB
GDScript
extends Node
|
|
|
|
# Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities, tiles}).
|
|
# Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}.
|
|
# Tiles use format: [{x, y, z, type}].
|
|
var current_snapshot: Dictionary = {}
|
|
var current_tick: int = 0
|
|
var player_position: Vector2 = Vector2.ZERO
|
|
var visible_entities: Array = []
|
|
var visible_tiles: Array = []
|
|
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups
|
|
|
|
# v2 fields (D-015, D-031)
|
|
var game_time: Dictionary = {} # {day, time_of_day, day_phase, tick_rate} or empty
|
|
var player_facing: String = "North" # 8-directional facing direction
|
|
var visibility_sectors: Dictionary = {} # Vector2i -> "Forward"/"Peripheral"
|
|
|
|
# Player entity ID — the first entity is assumed to be the player (will be
|
|
# refined when the server assigns explicit player entity IDs).
|
|
var player_entity_id: int = 1
|
|
|
|
# v4 fields (#404/#405)
|
|
var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]}]
|
|
|
|
# v5 fields (#414)
|
|
var current_monologue: Variant = null # {id, text, duration_seconds, priority, is_urgent} or null
|
|
|
|
# #122 (D-032): Character lattice profile — selects monologue text colour palette.
|
|
# "lattice_augmented" = detective, "lattice_baseline" = smuggler.
|
|
# Server sends this field as part of the player's capability snapshot.
|
|
var lattice_profile: String = "lattice_baseline"
|
|
|
|
# v6 fields (#449, D-053, D-065)
|
|
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
|
|
|
|
# D-064: true while dialogue box is visible or fading out (300ms).
|
|
# InputMapper suppresses movement when this is true.
|
|
var dialogue_active: bool = false
|
|
|
|
# v8 fields (#496): Gauntlet mode — room timer + personal bests
|
|
var room_id: Variant = null # String room_id from snapshot, null in non-gauntlet mode
|
|
var gauntlet_mode: bool = false # true when snapshot includes gauntlet_mode flag
|
|
|
|
# OQ-07 (#522): Insert active state — false suppresses verb labels (z-layer 6).
|
|
# Cursor shape changes still fire when false (D-056 option a).
|
|
# v0.1 assumption: always true — both playable characters (detective and smuggler)
|
|
# have neural inserts. Future characters without inserts would receive false from
|
|
# the server's "insert_active" snapshot field, disabling all z-layer-6 UI.
|
|
var insert_active: bool = true
|
|
|
|
# #507: RNG seed for replay determinism — populated from snapshot "rng_seed" field.
|
|
# Null in v0.1 (server does not yet send this field; protocol change required).
|
|
var rng_seed: Variant = null
|
|
|
|
# v7 fields (#431, D-059/D-060)
|
|
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
|
|
|
# #126, D-018: Medium-range sound events for fog-edge directional indicators.
|
|
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
|
|
var medium_sound_events: Array = []
|
|
|
|
# #125, D-018: Close-range sound events for positional 2D audio.
|
|
# Format: [{x, y, event_type, range_category}] — consumed once per tick in main.gd.
|
|
var close_sound_events: Array = []
|
|
|
|
# D-071 (#530): Consecutive ticks without player position change.
|
|
# Incremented per snapshot in apply_snapshot(). Reset to 0 on movement.
|
|
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
|
|
var stationary_ticks: int = 0
|
|
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
|
|
|
|
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
|
|
# Extracted in apply_snapshot() — avoids O(N) tile scan in main.gd per Tyre review.
|
|
# Empty string when zone_id field absent (server hasn't shipped OQ-09 yet).
|
|
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-071 (#530): Track consecutive stationary ticks for ListeningFocus boost.
|
|
# Compares current player_position against previous snapshot's position.
|
|
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 = []
|
|
|
|
# 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 = []
|
|
|
|
# D-073 (#529): Extract zone_id from the player's current tile (server-authoritative).
|
|
# O(1) via visible_positions dict would be ideal, but tiles are arrays without
|
|
# positional indexing — use the same tile iteration below instead.
|
|
current_zone_id = ""
|
|
var _px := int(player_position.x)
|
|
var _py := int(player_position.y)
|
|
for _ztile in visible_tiles:
|
|
if _ztile is Dictionary and _ztile.get("x") == _px and _ztile.get("y") == _py:
|
|
current_zone_id = _ztile.get("zone_id", "")
|
|
break
|
|
|
|
# v2: visible_tiles with visibility sectors
|
|
# Derives visible_positions when not explicitly provided (real server mode)
|
|
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()
|
|
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)
|
|
if vtile.has("visibility"):
|
|
visibility_sectors[pos] = vtile.visibility
|
|
if not has_explicit_positions:
|
|
visible_positions[pos] = true
|