Protocol: decode current_dialogue with structured options {text,
response_id, priority, confrontation} and npc_entity_id (#435).
Dialogue box: priority sort, max 3 visible, RichTextLabel for BBCode
italic confrontation options (D-063), 1.5s monologue beat with audio
dip before confrontation send. Walk-away: WASD triggers WalkAway
input, 300ms fade, dialogue_active flag gates movement (D-064).
Implements #435, #437, #436.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
205 lines
8.1 KiB
GDScript
205 lines
8.1 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
|
|
|
|
# Tracks the dialogue_id from dialogue_box to prevent consume-once race during fade-in.
|
|
# If a new snapshot arrives with null dialogue while fade-in is still running,
|
|
# we don't re-trigger show_dialogue because _last_dialogue_id still matches.
|
|
var _last_dialogue_id: int = 0
|
|
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
|
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
|
|
|
|
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)
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
# Main game loop: poll snapshot, apply state, flush input
|
|
var snapshot: Variant = SimBridge.poll_snapshot()
|
|
if snapshot != null:
|
|
GameState.apply_snapshot(snapshot)
|
|
|
|
# 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()
|
|
|
|
# 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()
|
|
|
|
# Show monologue if server sent one this tick (#414)
|
|
_consume_monologue()
|
|
|
|
# D-061: Show dialogue if server sent one this tick (#434)
|
|
_consume_dialogue()
|
|
|
|
# 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.
|
|
if _camera_anchored and not camera.position_smoothing_enabled:
|
|
camera.position_smoothing_enabled = true
|
|
camera.reset_smoothing()
|
|
|
|
# Send queued input to simulation
|
|
var inputs = InputMapper.flush_queue()
|
|
for input in inputs:
|
|
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)
|
|
|
|
|
|
# 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))
|
|
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)
|
|
dialogue_box.show_dialogue(
|
|
dlg.get("npc_name", ""),
|
|
dlg.get("speech", ""),
|
|
dlg.get("options", [])
|
|
)
|
|
_last_dialogue_id = dialogue_box.get_dialogue_id()
|
|
GameState.current_dialogue = 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)
|
|
func _on_confrontation_monologue(text: String, duration: float) -> void:
|
|
if monologue_display:
|
|
monologue_display.show_monologue(text, duration)
|
|
|
|
|
|
# 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",
|
|
},
|
|
})
|