From bfc699c15d15e699456eca0c2a9b8c8e646c66fe Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 17 Feb 2026 16:34:55 +0100 Subject: [PATCH] =?UTF-8?q?feat(ui):=20implement=20dialogue=20pipeline=20?= =?UTF-8?q?=E2=80=94=20selection,=20walk-away,=20confrontation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/scripts/autoloads/game_state.gd | 8 +- client/scripts/autoloads/input_mapper.gd | 3 + client/scripts/autoloads/sim_bridge.gd | 10 +- client/scripts/main.gd | 27 ++-- client/scripts/protocol/protocol.gd | 25 ++++ client/ui/dialogue_box.gd | 151 +++++++++++++++++++---- 6 files changed, 185 insertions(+), 39 deletions(-) diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 2856e5570..cc5856d91 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -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}] diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index df077aa4f..0b99cb386 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -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 diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 7e8be7af4..6f839a7bd 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -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}, ], } diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 20e6114da..147d2c9ca 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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", }, }) diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index 505e61238..28a5e010d 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -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, } diff --git a/client/ui/dialogue_box.gd b/client/ui/dialogue_box.gd index 7c5e55e9e..fff191cb5 100644 --- a/client/ui/dialogue_box.gd +++ b/client/ui/dialogue_box.gd @@ -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)