feat(ui): implement dialogue pipeline — selection, walk-away, confrontation

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>
This commit is contained in:
2026-02-17 16:34:55 +01:00
co-authored by Claude Opus 4.6
parent 9e6b859cdf
commit bfc699c15d
6 changed files with 185 additions and 39 deletions
+6 -2
View File
@@ -29,8 +29,12 @@ var current_monologue: Variant = null # {id, text, duration_seconds} or null
var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch
var player_inventory: Array = [] # [{item_id, name, slot}]
# v7 fields (#434, D-061)
var current_dialogue: Variant = null # {npc_name, speech, options: [String]} or null
# 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
# v7 fields (#431, D-059/D-060)
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
+3
View File
@@ -31,7 +31,10 @@ var _last_move_msec: int = 0
# Hold-to-move: poll held direction keys each frame, throttled by stance.
# Server-side cooldown (D-053) is authoritative; this prevents client flooding.
# D-064: movement suppressed during dialogue (walk-away handled by dialogue_box).
func _process(_delta: float) -> void:
if GameState.dialogue_active:
return
var dir := Vector2i.ZERO
if Input.is_action_pressed("move_north"):
dir.y -= 1
+6 -4
View File
@@ -340,18 +340,20 @@ func _test_snapshot() -> Dictionary:
"duration_seconds": 5.0,
}
# v7: mock dialogue (#434, D-061) — triggered by Interact near NPC
# v7: mock dialogue (#435, D-061/D-062) — triggered by Interact near NPC
# Sustained: dialogue persists across ticks while _test_in_dialogue is true.
# Movement (walk-away) clears it. Client consume-once guards against re-show.
# Options: structured {text, response_id, priority} per #435.
var dialogue: Variant = null
if _test_in_dialogue:
dialogue = {
"npc_name": "Kael",
"npc_entity_id": 2,
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
"options": [
"Just arrived. Still getting my bearings.",
"Passing through. Know where I can find work?",
"I'm looking for someone.",
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false},
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false},
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true},
],
}
+20 -7
View File
@@ -17,6 +17,7 @@ extends Node2D
# 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
@@ -50,6 +51,7 @@ func _ready() -> void:
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:
@@ -70,8 +72,13 @@ func _process(_delta: float) -> void:
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"):
interaction_list.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"):
@@ -156,6 +163,7 @@ func _consume_dialogue() -> void:
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", ""),
@@ -166,26 +174,31 @@ func _consume_dialogue() -> void:
# D-061: Handle dialogue option selection → send to server
func _on_dialogue_option_selected(index: int, text: String) -> void:
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",
"dialogue_option_index": index,
"dialogue_option_text": text,
"response_id": response_id,
},
})
# D-064: Handle walk-away → send DialogueEnd to server
# 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": null,
"verb": "DialogueEnd",
"target_entity_id": _last_dialogue_npc_id if _last_dialogue_npc_id >= 0 else null,
"verb": "WalkAway",
},
})
+25
View File
@@ -145,6 +145,30 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
})
count += 1
# v7: current_dialogue (#435, D-061/D-062) — NPC speech + player response options
# Options carry response_id for server round-trip and priority for display ordering.
# Locked options are invisible (D-062): server filters before sending.
var current_dialogue: Variant = null
var raw_dialogue: Variant = raw.get("current_dialogue")
if raw_dialogue is Dictionary and raw_dialogue.has("speech"):
var dialogue_options: Array = []
var raw_options: Variant = raw_dialogue.get("options")
if raw_options is Array:
for raw_opt in raw_options:
if raw_opt is Dictionary and raw_opt.has("text"):
dialogue_options.append({
"text": str(raw_opt["text"]),
"response_id": str(raw_opt.get("response_id", "")),
"priority": int(raw_opt.get("priority", 0)),
"confrontation": bool(raw_opt.get("confrontation", false)),
})
current_dialogue = {
"npc_name": str(raw_dialogue.get("npc_name", "")),
"npc_entity_id": int(raw_dialogue.get("npc_entity_id", -1)),
"speech": str(raw_dialogue["speech"]),
"options": dialogue_options,
}
return {
"tick": tick,
"entities": entities,
@@ -157,6 +181,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"visible_tiles": visible_tiles,
"nearby_interactions": nearby_interactions,
"current_monologue": current_monologue,
"current_dialogue": current_dialogue,
"pending_recognitions": pending_recognitions,
}