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:
@@ -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}]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
+125
-26
@@ -5,10 +5,11 @@ extends Control
|
||||
# NPC speech top, player response options below, left-aligned.
|
||||
# Max 3 visible options. No close button — walk-away (WASD) or option select only.
|
||||
# Auto-pause in single-player when dialogue is open (D-061).
|
||||
# Sprint 7: UI skeleton with mock data. Server wiring deferred to #305.
|
||||
# D-063: Confrontation options render italic, trigger monologue beat before send.
|
||||
|
||||
signal option_selected(index: int, text: String)
|
||||
signal option_selected(response_id: String, text: String)
|
||||
signal dialogue_dismissed # Walk-away or conversation end
|
||||
signal confrontation_monologue(text: String, duration: float) # D-063: beat monologue
|
||||
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var npc_speech: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/NpcSpeech
|
||||
@@ -16,7 +17,11 @@ signal dialogue_dismissed # Walk-away or conversation end
|
||||
|
||||
var _is_showing: bool = false
|
||||
var _active_tween: Tween = null
|
||||
var _option_buttons: Array[Button] = []
|
||||
var _beat_tween: Tween = null # D-063: confrontation beat delay
|
||||
var _option_controls: Array[Control] = []
|
||||
var _option_response_ids: Array[String] = [] # response_id per option, same index
|
||||
var _option_texts: Array[String] = [] # raw display text per option
|
||||
var _option_is_confrontation: Array[bool] = [] # confrontation flag per option
|
||||
var _npc_name: String = ""
|
||||
var _dialogue_id: int = 0 # Tracks current dialogue to prevent consume-once race
|
||||
|
||||
@@ -25,6 +30,9 @@ const FADE_OUT: float = 0.3 # D-064: 300ms fade on walk-away
|
||||
const MAX_OPTIONS: int = 3 # D-061: max 3 response options visible
|
||||
const MAX_HEIGHT_RATIO: float = 0.2 # D-061: max 20% viewport height
|
||||
const MAX_WIDTH_PX: float = 832.0 # D-061: max-width cap (~65% of 1280)
|
||||
const CONFRONTATION_BEAT_DURATION: float = 1.5 # D-063: pause before sending
|
||||
const CONFRONTATION_DIM_ALPHA: float = 0.7 # D-063: dialogue box dims during beat
|
||||
const CONFRONTATION_MONOLOGUE: String = "This changes things. No taking it back."
|
||||
|
||||
# D-064: movement actions that trigger walk-away
|
||||
const _WALK_AWAY_ACTIONS: Array[StringName] = [
|
||||
@@ -50,6 +58,7 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed:
|
||||
for action in _WALK_AWAY_ACTIONS:
|
||||
if event.is_action_pressed(action):
|
||||
_cancel_beat()
|
||||
hide_dialogue()
|
||||
dialogue_dismissed.emit()
|
||||
return
|
||||
@@ -68,10 +77,13 @@ func _update_layout() -> void:
|
||||
# Show dialogue with NPC speech and response options.
|
||||
# npc_name: who is speaking (displayed as prefix)
|
||||
# speech: the NPC's dialogue text
|
||||
# options: Array of Strings — player response choices (max 3 shown)
|
||||
# options: Array of {text, response_id, priority, confrontation} — sorted by priority, max 3 shown.
|
||||
# D-062: locked options are invisible (server filters before sending).
|
||||
# D-063: confrontation options render italic.
|
||||
func show_dialogue(npc_name: String, speech: String, options: Array = []) -> void:
|
||||
_npc_name = npc_name
|
||||
_dialogue_id += 1
|
||||
_cancel_beat()
|
||||
|
||||
# NPC speech — name prefix in bold
|
||||
if npc_name.is_empty():
|
||||
@@ -79,30 +91,54 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
|
||||
else:
|
||||
npc_speech.text = "[b]%s:[/b] %s" % [npc_name, speech]
|
||||
|
||||
# Clear old option buttons
|
||||
# Clear old options
|
||||
_clear_options()
|
||||
|
||||
# Build response option buttons (max 3)
|
||||
var count := mini(options.size(), MAX_OPTIONS)
|
||||
# Sort by priority ascending, cap at MAX_OPTIONS (D-061: max 3 visible)
|
||||
var sorted_opts: Array = options.duplicate()
|
||||
sorted_opts.sort_custom(func(a, b): return a.get("priority", 0) < b.get("priority", 0))
|
||||
var count := mini(sorted_opts.size(), MAX_OPTIONS)
|
||||
|
||||
for i in range(count):
|
||||
var btn := Button.new()
|
||||
btn.text = options[i]
|
||||
btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
btn.flat = true
|
||||
btn.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
# Insert-styled colors from shared palette (D-048/D-056)
|
||||
btn.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
|
||||
btn.add_theme_color_override("font_hover_color", Constants.INSERT_COLOR_HOVER)
|
||||
btn.add_theme_color_override("font_pressed_color", Constants.INSERT_COLOR_ACTIVE)
|
||||
var opt: Dictionary = sorted_opts[i]
|
||||
var raw_text: String = opt.get("text", "")
|
||||
var is_confrontation: bool = opt.get("confrontation", false)
|
||||
|
||||
# RichTextLabel for BBCode support (D-063: confrontation italic)
|
||||
var label := RichTextLabel.new()
|
||||
label.bbcode_enabled = true
|
||||
label.fit_content = true
|
||||
label.scroll_active = false
|
||||
label.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT)
|
||||
|
||||
if is_confrontation:
|
||||
label.text = "[i]%s[/i]" % raw_text
|
||||
else:
|
||||
label.text = raw_text
|
||||
|
||||
# Click handling
|
||||
var idx := i
|
||||
btn.pressed.connect(func(): _on_option_pressed(idx))
|
||||
options_container.add_child(btn)
|
||||
_option_buttons.append(btn)
|
||||
label.gui_input.connect(func(event: InputEvent):
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
_on_option_pressed(idx)
|
||||
)
|
||||
# Hover color
|
||||
label.mouse_entered.connect(_make_hover_on(label))
|
||||
label.mouse_exited.connect(_make_hover_off(label))
|
||||
|
||||
options_container.add_child(label)
|
||||
_option_controls.append(label)
|
||||
_option_response_ids.append(opt.get("response_id", ""))
|
||||
_option_texts.append(raw_text)
|
||||
_option_is_confrontation.append(is_confrontation)
|
||||
|
||||
# Show with fade
|
||||
visible = true
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_is_showing = true
|
||||
GameState.dialogue_active = true # D-064: block movement while dialogue visible/fading
|
||||
|
||||
# D-061: auto-pause in single-player when dialogue opens
|
||||
SimBridge.send_input({"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()})
|
||||
@@ -131,6 +167,7 @@ func hide_dialogue() -> void:
|
||||
_active_tween.tween_callback(func():
|
||||
visible = false
|
||||
_clear_options()
|
||||
GameState.dialogue_active = false # D-064: unblock movement after fade completes
|
||||
)
|
||||
|
||||
|
||||
@@ -143,13 +180,75 @@ func get_dialogue_id() -> int:
|
||||
|
||||
|
||||
func _on_option_pressed(index: int) -> void:
|
||||
if index < _option_buttons.size():
|
||||
option_selected.emit(index, _option_buttons[index].text)
|
||||
hide_dialogue()
|
||||
if index >= _option_controls.size():
|
||||
return
|
||||
var rid: String = _option_response_ids[index] if index < _option_response_ids.size() else ""
|
||||
var text: String = _option_texts[index] if index < _option_texts.size() else ""
|
||||
var is_confront: bool = _option_is_confrontation[index] if index < _option_is_confrontation.size() else false
|
||||
|
||||
if is_confront:
|
||||
_start_confrontation_beat(rid, text)
|
||||
else:
|
||||
option_selected.emit(rid, text)
|
||||
hide_dialogue()
|
||||
|
||||
|
||||
# D-063: Confrontation beat — delay before sending response.
|
||||
# 1. Dim dialogue to 70%, show monologue, dip audio
|
||||
# 2. Wait CONFRONTATION_BEAT_DURATION
|
||||
# 3. Emit option_selected, restore audio, hide dialogue
|
||||
func _start_confrontation_beat(response_id: String, text: String) -> void:
|
||||
# Disable option clicks during beat
|
||||
for ctrl in _option_controls:
|
||||
if is_instance_valid(ctrl):
|
||||
ctrl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
# Dim dialogue box
|
||||
if _active_tween and _active_tween.is_valid():
|
||||
_active_tween.kill()
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
|
||||
|
||||
# D-063: monologue beat — hardcoded v0.1 line
|
||||
confrontation_monologue.emit(CONFRONTATION_MONOLOGUE, CONFRONTATION_BEAT_DURATION)
|
||||
|
||||
# D-063: audio dip via AudioManager
|
||||
AudioManager.apply_dip("confrontation")
|
||||
|
||||
# Delay, then complete
|
||||
_beat_tween = create_tween()
|
||||
_beat_tween.tween_interval(CONFRONTATION_BEAT_DURATION)
|
||||
_beat_tween.tween_callback(func():
|
||||
AudioManager.clear_dip()
|
||||
option_selected.emit(response_id, text)
|
||||
hide_dialogue()
|
||||
)
|
||||
|
||||
|
||||
# Cancel an in-flight confrontation beat (e.g. player walks away mid-beat).
|
||||
func _cancel_beat() -> void:
|
||||
if _beat_tween and _beat_tween.is_valid():
|
||||
_beat_tween.kill()
|
||||
_beat_tween = null
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
func _clear_options() -> void:
|
||||
for btn in _option_buttons:
|
||||
if is_instance_valid(btn):
|
||||
btn.queue_free()
|
||||
_option_buttons.clear()
|
||||
for ctrl in _option_controls:
|
||||
if is_instance_valid(ctrl):
|
||||
ctrl.queue_free()
|
||||
_option_controls.clear()
|
||||
_option_response_ids.clear()
|
||||
_option_texts.clear()
|
||||
_option_is_confrontation.clear()
|
||||
|
||||
|
||||
# Hover callbacks — closures that capture the label reference.
|
||||
static func _make_hover_on(label: RichTextLabel) -> Callable:
|
||||
return func():
|
||||
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER)
|
||||
|
||||
|
||||
static func _make_hover_off(label: RichTextLabel) -> Callable:
|
||||
return func():
|
||||
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT)
|
||||
|
||||
Reference in New Issue
Block a user