Refactors dialogue box into a scrolling conversation log. All dialogue (player-NPC and overheard NPC-NPC) flows chronologically, oldest at top. Player response options at the bottom during active conversations. - Entries expire after configurable timeout (equal for all message types) - Walk-away clears options but preserves log entries (fair information) - Per-character name colors from dialogue-theme.yaml (hash-indexed palette) - Overheard lines render at 90% opacity (D-078) - Protocol decode for conversation_events + conversation_ended - GameState fields for conversation_events, conversation_ended, dialogue_response - Mock Mira/Soren NPC-NPC conversation in test snapshot - Also wires #511 debug overlay into main.gd and main.tscn Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
462 lines
19 KiB
GDScript
462 lines
19 KiB
GDScript
extends Node2D
|
|
|
|
@onready var world_renderer = $World
|
|
@onready var fog_entities = $World/FogEntities # D-059/D-060: cognitive delay fog visualization
|
|
@onready var camera = $Camera2D
|
|
@onready var hud = $UILayer/HUD
|
|
@onready var monologue_display = $UILayer/MonologueDisplay
|
|
@onready var interaction_prompt = $InsertOverlay/InteractionPrompt # v0.1 single-line fallback
|
|
@onready var interaction_list = $InsertOverlay/InteractionList # D-057: z-layer 6
|
|
@onready var world_radial = $InsertOverlay/WorldRadial # D-058: z-layer 6
|
|
@onready var dialogue_box = $InsertOverlay/DialogueBox # D-061: z-layer 6
|
|
@onready var inventory_grid = $UILayer/InventoryGrid # D-065: z-layer 7
|
|
@onready var stance_indicator = $UILayer/StanceIndicator # D-053: z-layer 7
|
|
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
|
|
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
|
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
|
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
|
|
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
|
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
|
|
|
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 _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
|
|
var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport
|
|
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
|
|
|
|
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
|
|
|
|
func _ready() -> void:
|
|
print("The Settled Reach — client initialized")
|
|
|
|
# Disable camera smoothing during init. Camera2D's position_smoothing
|
|
# lerps an internal smoothed_camera_pos toward global_position each frame.
|
|
# That smoothed position initializes at (0,0) — the Camera2D's default in
|
|
# the .tscn. Even after we set global_position to the player coords,
|
|
# smoothing causes the viewport to still show (0,0) on the first rendered
|
|
# frame because the lerp hasn't converged. With smoothing OFF, the viewport
|
|
# uses global_position directly. Re-enabled in _process() after anchor.
|
|
camera.position_smoothing_enabled = false
|
|
|
|
# Connect to simulation (test mode sets CONNECTED immediately)
|
|
SimBridge.connect_to_sim()
|
|
|
|
# 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. No reset_smoothing() needed: smoothing is OFF.
|
|
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)
|
|
|
|
# #496: Print gauntlet session summary on disconnect
|
|
if gauntlet_hud:
|
|
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
# Main game loop: poll snapshot, apply state, flush input
|
|
var snapshot: Variant = SimBridge.poll_snapshot()
|
|
if snapshot != null:
|
|
var old_pos := GameState.player_position
|
|
GameState.apply_snapshot(snapshot)
|
|
|
|
# #501: Detect teleport (large position jump > 5 tiles) and trigger fade
|
|
if _camera_anchored and _detect_teleport(old_pos, GameState.player_position):
|
|
_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
|
|
|
|
# Update renderers with new state
|
|
if world_renderer and world_renderer.has_method("update_from_state"):
|
|
world_renderer.update_from_state()
|
|
|
|
# OQ-07 (#522): propagate insert state to all z-layer-6 display nodes.
|
|
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
|
|
var insert_state := GameState.insert_active
|
|
if cursor_renderer and cursor_renderer.has_method("set_insert_active"):
|
|
cursor_renderer.set_insert_active(insert_state)
|
|
if interaction_list and interaction_list.has_method("set_insert_active"):
|
|
interaction_list.set_insert_active(insert_state)
|
|
if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
|
|
interaction_prompt.set_insert_active(insert_state)
|
|
|
|
# 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()
|
|
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()
|
|
|
|
# #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()
|
|
|
|
# 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()
|
|
|
|
# Track camera to player position every frame (D-015: locked, no panning)
|
|
if _camera_anchored:
|
|
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
|
|
|
# Re-enable smoothing after the first anchored frame. The frame that just
|
|
# rendered used smoothing=OFF (correct viewport from frame one). Now we
|
|
# turn smoothing back on and sync its internal state so subsequent frames
|
|
# get smooth camera tracking during gameplay.
|
|
# #501: Skip re-enable during teleport — _teleport_transition() disables
|
|
# smoothing for a clean camera snap. Defer by one frame to avoid the
|
|
# re-enable block in the same _process() call undoing the snap.
|
|
if _camera_anchored and not camera.position_smoothing_enabled:
|
|
if _teleport_in_progress:
|
|
_teleport_in_progress = false
|
|
else:
|
|
camera.position_smoothing_enabled = true
|
|
camera.reset_smoothing()
|
|
|
|
# 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
|
|
if input.action == InputMapper.Action.BUG_REPORT:
|
|
if bug_report_dialog and not bug_report_dialog.is_active():
|
|
bug_report_dialog.start_capture()
|
|
continue
|
|
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
|
|
if input.action == InputMapper.Action.OPEN_MENU:
|
|
if settings_dialog:
|
|
if settings_dialog.is_open():
|
|
settings_dialog.close()
|
|
else:
|
|
settings_dialog.open()
|
|
continue
|
|
if input.action == InputMapper.Action.INTERACT:
|
|
# D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1)
|
|
var target_id: int = -1
|
|
var verb: String = ""
|
|
if interaction_list and interaction_list.has_method("get_interaction_target"):
|
|
target_id = interaction_list.get_interaction_target()
|
|
verb = interaction_list.get_selected_verb()
|
|
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,
|
|
"verb": verb,
|
|
}
|
|
else:
|
|
input["action_data"] = {
|
|
"target_entity_id": null,
|
|
"verb": null,
|
|
}
|
|
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.
|
|
if snapshot != null and bug_report_dialog and bug_report_dialog.has_method("record_tick"):
|
|
bug_report_dialog.record_tick(
|
|
GameState.current_tick,
|
|
JSON.stringify(GameState.current_snapshot),
|
|
_pending_record_inputs
|
|
)
|
|
_pending_record_inputs.clear()
|
|
|
|
|
|
# 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).
|
|
# Entities that complete recognition (leave pending_recognitions) are removed
|
|
# from _known_recognition_ids so they can chime again if re-encountered.
|
|
func _play_recognition_chimes() -> void:
|
|
var active_ids: Dictionary = {}
|
|
for rec in GameState.pending_recognitions:
|
|
if not rec is Dictionary or not rec.has("entity_id"):
|
|
continue
|
|
var eid: int = rec.entity_id
|
|
active_ids[eid] = true
|
|
if not _known_recognition_ids.has(eid):
|
|
_known_recognition_ids[eid] = true
|
|
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
|
# Expire IDs no longer in the recognition queue
|
|
for eid in _known_recognition_ids.keys():
|
|
if not active_ids.has(eid):
|
|
_known_recognition_ids.erase(eid)
|
|
|
|
|
|
# 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
|
|
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", [])
|
|
)
|
|
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.
|
|
func _consume_dialogue_response() -> void:
|
|
if GameState.dialogue_response == null or not dialogue_box:
|
|
return
|
|
var dr: Dictionary = GameState.dialogue_response
|
|
dialogue_box.append_dialogue_response(_last_dialogue_npc_name, dr.get("text", ""))
|
|
GameState.dialogue_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-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",
|
|
},
|
|
})
|
|
|
|
|
|
# #496: Finalize gauntlet stats on disconnect
|
|
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
|
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
|
|
gauntlet_hud.finalize()
|
|
|
|
|
|
# #501: Detect large position jump indicating a teleport (not normal movement).
|
|
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
|
|
|
|
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:
|
|
# Snap camera: disable smoothing, force re-anchor.
|
|
# _teleport_in_progress defers smoothing re-enable by one frame so the
|
|
# re-enable block at the bottom of _process() doesn't undo the snap.
|
|
camera.position_smoothing_enabled = false
|
|
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
|
_camera_anchored = true
|
|
_teleport_in_progress = true
|
|
|
|
# Clear client-side buffers
|
|
GameState.current_monologue = null
|
|
GameState.current_dialogue = null
|
|
GameState.dialogue_active = false
|
|
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
|
|
if _flash_rect and is_instance_valid(_flash_rect):
|
|
_flash_rect.queue_free()
|
|
_flash_rect = ColorRect.new()
|
|
_flash_rect.color = Color(0, 0, 0, 1.0)
|
|
_flash_rect.anchors_preset = Control.PRESET_FULL_RECT
|
|
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
$UILayer.add_child(_flash_rect)
|
|
var tween := create_tween()
|
|
tween.tween_property(_flash_rect, "color:a", 0.0, 0.3)
|
|
tween.tween_callback(_flash_rect.queue_free)
|
|
|
|
|
|
# #502: Full-screen color flash — fades from color to transparent over duration.
|
|
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
|
|
func _screen_flash(color: Color, duration: float) -> void:
|
|
if _flash_rect and is_instance_valid(_flash_rect):
|
|
_flash_rect.queue_free()
|
|
_flash_rect = ColorRect.new()
|
|
_flash_rect.color = Color(color.r, color.g, color.b, 0.4)
|
|
_flash_rect.anchors_preset = Control.PRESET_FULL_RECT
|
|
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
$UILayer.add_child(_flash_rect)
|
|
var tween := create_tween()
|
|
tween.tween_property(_flash_rect, "color:a", 0.0, duration)
|
|
tween.tween_callback(_flash_rect.queue_free)
|