Remove premature Voice/VoiceConversation from SOUND_EVENT_ASSETS (D-072 defers murmur to end-to-end sprint). Extract current_zone_id in GameState.apply_snapshot() as first-class field, eliminating O(N) tile scan in main.gd (D-020 server-authoritative). Add dir.list_dir_end() after registry scan. Add rapid zone-crossing + _load_prefs() roundtrip tests. Enhance comments on hub/workplace same-asset pattern, station base hum, and confrontation dip replacement semantics. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
260 lines
9.3 KiB
GDScript
260 lines
9.3 KiB
GDScript
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 = Constants.DIALOGUE_MAX_WIDTH # D-076: 640px (OQ-29)
|
|
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-069: Dialogue dip — reduce ambient noise to foreground conversation.
|
|
# Confrontation options REPLACE (not nest) this dip via apply_dip("confrontation")
|
|
# in _start_confrontation_beat(). hide_dialogue()'s clear_dip() restores to base
|
|
# volumes regardless of which profile was last active — intentional replacement semantics.
|
|
AudioManager.apply_dip("dialogue")
|
|
|
|
# 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-069: Clear dialogue/confrontation dip — restore bus volumes to player slider settings.
|
|
# Safe to call even if confrontation beat already cleared the dip (no-op when empty).
|
|
AudioManager.clear_dip()
|
|
|
|
# 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)
|