extends Control # Dialogue box — D-061: bottom screen, max 20% height, no portraits. # InsertOverlay (CanvasLayer 10, z-layer 6) — diegetic, insert-styled. # 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). # D-063: Confrontation options render italic, trigger monologue beat before send. 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 @onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer var _is_showing: bool = false var _active_tween: Tween = null 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 = "" const FADE_IN: float = 0.2 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_KEY: String = "dialogue.confrontation_beat" # D-064: movement actions that trigger walk-away const _WALK_AWAY_ACTIONS: Array[StringName] = [ &"move_north", &"move_south", &"move_east", &"move_west", &"move_northeast", &"move_southeast", &"move_southwest", &"move_northwest", ] func _ready() -> void: panel.modulate.a = 0.0 visible = false _is_showing = false mouse_filter = Control.MOUSE_FILTER_IGNORE _update_layout() get_viewport().size_changed.connect(_update_layout) func _unhandled_input(event: InputEvent) -> void: if not _is_showing: return # D-064: WASD during dialogue → walk-away, 300ms fade if event is InputEventKey and event.pressed: for action in _WALK_AWAY_ACTIONS: if event.is_action_pressed(action): get_viewport().set_input_as_handled() _cancel_beat() hide_dialogue() dialogue_dismissed.emit() return # Responsive layout — clamps width to MAX_WIDTH_PX and height to 20% viewport. func _update_layout() -> void: var vp := get_viewport_rect().size var max_h := vp.y * MAX_HEIGHT_RATIO var w := minf(MAX_WIDTH_PX, vp.x * 0.65) panel.offset_left = -w / 2.0 panel.offset_right = w / 2.0 panel.offset_top = -max_h # 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 {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 _cancel_beat() # NPC speech — name prefix in bold if npc_name.is_empty(): npc_speech.text = speech else: npc_speech.text = "[b]%s:[/b] %s" % [npc_name, speech] # Clear old options _clear_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 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 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()}) if _active_tween and _active_tween.is_valid(): _active_tween.kill() _active_tween = create_tween() _active_tween.tween_property(panel, "modulate:a", 1.0, FADE_IN) # Hide dialogue with fade (D-064: 300ms) func hide_dialogue() -> void: if not _is_showing: return _is_showing = false mouse_filter = Control.MOUSE_FILTER_IGNORE # D-061: unpause when dialogue closes SimBridge.send_input({"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()}) if _active_tween and _active_tween.is_valid(): _active_tween.kill() _active_tween = create_tween() _active_tween.tween_property(panel, "modulate:a", 0.0, FADE_OUT) _active_tween.tween_callback(func(): visible = false _clear_options() GameState.dialogue_active = false # D-064: unblock movement after fade completes ) func is_dialogue_active() -> bool: return _is_showing or GameState.dialogue_active func _on_option_pressed(index: int) -> void: 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 — text from ui-strings.yaml (D-042) confrontation_monologue.emit(UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), 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 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)