extends Control # Dialogue box — D-061: bottom screen, max 20% height, no portraits. # #535: Unified conversation log — player dialogue and overheard NPC-NPC conversations # flow chronologically. Oldest at top, scrolls up. Options at bottom. # # InsertOverlay (CanvasLayer 10, z-layer 6) — diegetic, insert-styled. # D-063: Confrontation options render italic, trigger monologue beat before send. # D-064: Walk-away via WASD dismisses active conversation (log entries persist). # dialogue_active held until fade completes (D-064 auto-pause spec). # D-078: Overheard NPC-NPC lines prefixed with ┃ glyph + desaturated colours. 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 signal pause_requested # D-061: auto-pause — main.gd routes through input recording (#507) signal unpause_requested # D-061: auto-unpause @onready var panel: PanelContainer = $PanelContainer @onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog @onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer # -- Log state -- # Entry format: {speaker, target, text, is_passive, pinned, timestamp_msec} # pinned entries do not expire (active conversation lines, Araminta review). var _log_entries: Array[Dictionary] = [] var _in_player_conversation: bool = false var _log_dirty: bool = false # Dirty flag — prevents per-frame O(n) BBCode rebuild (Hoshe #1) # -- Option state -- var _option_controls: Array[Control] = [] var _option_response_ids: Array[String] = [] var _option_texts: Array[String] = [] var _option_is_confrontation: Array[bool] = [] var _npc_name: String = "" # -- UI state -- var _is_showing: bool = false var _active_tween: Tween = null var _beat_tween: Tween = null # D-063: confrontation beat delay # -- Theme (loaded from data/dialogue-theme.yaml) -- var _player_color: Color = Color("#e0e8ff") var _npc_colors: Array[Color] = [] var _arrow_color: Color = Color("#8890a0") var _speech_color: Color = Color("#c8d0e0") var _passive_opacity: float = 0.9 var _entry_lifetime: float = 15.0 var _entry_fade: float = 3.0 const THEME_PATH: String = "res://data/dialogue-theme.yaml" 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" const PLAYER_NAME: String = "You" const PASSIVE_GLYPH: String = "\u2503 " # ┃ prefix for overheard lines (Araminta review) const PASSIVE_DESATURATION: float = 0.4 # Desaturate passive name colours by this factor const MIN_CONTRAST_LUMINANCE: float = 0.25 # Floor for name colour brightness against dark BG # 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 _load_theme() _update_layout() get_viewport().size_changed.connect(_update_layout) func _process(_delta: float) -> void: _expire_entries() if _log_dirty: _log_dirty = false _rebuild_log() func _unhandled_input(event: InputEvent) -> void: if not _is_showing: return if not _in_player_conversation: return # D-064: WASD during active player dialogue → walk-away 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() _end_player_conversation() dialogue_dismissed.emit() return # -- Theme loading -- func _load_theme() -> void: # Default NPC palette in case file not found _npc_colors = [ Color("#4a9ebb"), Color("#6bc9a6"), Color("#e8c547"), Color("#d49e5d"), Color("#b586d4"), Color("#d45d5d"), Color("#5daa7d"), Color("#7daccc"), ] if not FileAccess.file_exists(THEME_PATH): push_warning("DialogueBox: theme file not found: %s — using defaults" % THEME_PATH) return var file := FileAccess.open(THEME_PATH, FileAccess.READ) if file == null: return var text := file.get_as_text() file.close() var strings := UIStrings._parse_yaml(text) if strings.has("player_color"): _player_color = Color(strings["player_color"]) if strings.has("arrow_color"): _arrow_color = Color(strings["arrow_color"]) if strings.has("speech_color"): _speech_color = Color(strings["speech_color"]) if strings.has("passive_opacity"): _passive_opacity = float(strings["passive_opacity"]) if strings.has("entry_lifetime_seconds"): _entry_lifetime = float(strings["entry_lifetime_seconds"]) if strings.has("entry_fade_seconds"): _entry_fade = float(strings["entry_fade_seconds"]) # Load NPC colors from indexed keys (npc_colors.0 through npc_colors.7) var loaded_colors: Array[Color] = [] for i in range(16): # support up to 16 palette entries var key := "npc_colors.%d" % i if strings.has(key): loaded_colors.append(Color(strings[key])) if loaded_colors.size() > 0: _npc_colors = loaded_colors # -- Responsive layout -- 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 # -- Log entry management -- ## Append a dialogue line to the log. ## speaker/target: display names. text: the spoken line. ## is_passive: true for overheard NPC-NPC (renders with ┃ prefix + desaturated). ## Active conversation entries are pinned (no timeout) while _in_player_conversation. func append_line(speaker: String, target: String, text: String, is_passive: bool = false) -> void: var pinned := not is_passive and _in_player_conversation _log_entries.append({ "speaker": speaker, "target": target, "text": text, "is_passive": is_passive, "pinned": pinned, "timestamp_msec": Time.get_ticks_msec(), }) _log_dirty = true _ensure_visible() ## Append an overheard conversation event (D-078). func append_conversation_event(event: Dictionary) -> void: var speaker: String = event.get("speaker_name", "?") var target: String = event.get("target_name", "?") var text: String = event.get("occluded_line", "") if text.is_empty(): return append_line(speaker, target, text, true) ## Handle conversation_ended — no-op currently (entries expire via timeout). func on_conversation_ended(_event: Dictionary) -> void: pass ## Append the player's chosen response to the log. func append_player_line(target_npc: String, text: String) -> void: append_line(PLAYER_NAME, target_npc, text, false) ## Append an NPC follow-up line (from dialogue_response). func append_dialogue_response(npc_name: String, text: String) -> void: append_line(npc_name, PLAYER_NAME, text, false) # -- Active player conversation -- ## Show dialogue with NPC speech and response options. ## npc_name: who is speaking. speech: the NPC's line. options: player choices. func show_dialogue(npc_name: String, speech: String, options: Array = []) -> void: _npc_name = npc_name _cancel_beat() _in_player_conversation = true # Append NPC's line to the log if not speech.is_empty(): append_line(npc_name, PLAYER_NAME, speech, false) # Clear old options and show new ones _clear_options() _show_options(options) # Show panel _ensure_visible() mouse_filter = Control.MOUSE_FILTER_STOP GameState.dialogue_active = true # D-064: block movement while in conversation # D-069: Dialogue dip AudioManager.apply_dip("dialogue") # D-061: auto-pause — signal to main.gd for input recording (#507) pause_requested.emit() ## End active player conversation — clears options but preserves log. ## D-064: dialogue_active held until fade completes. func _end_player_conversation() -> void: _in_player_conversation = false _clear_options() mouse_filter = Control.MOUSE_FILTER_IGNORE # Unpin all entries and reset their timestamps so timeout starts now (Araminta review) var now := Time.get_ticks_msec() for entry in _log_entries: if entry.pinned: entry.pinned = false entry.timestamp_msec = now _log_dirty = true # D-069: Clear dialogue/confrontation dip AudioManager.clear_dip() # D-061: unpause — signal to main.gd for input recording (#507) unpause_requested.emit() # D-064: dialogue_active stays true — cleared after fade completes in hide callback. # If log entries still exist, panel stays visible and entries expire via timeout. # If no entries, hide immediately with fade. if _log_entries.is_empty(): hide_dialogue() ## Hide the entire panel with fade. func hide_dialogue() -> void: if not _is_showing: return if _in_player_conversation: _end_player_conversation() return # _end_player_conversation may call hide_dialogue if log is empty _is_showing = false 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 GameState.dialogue_active = false # D-064: unblock movement after fade completes ) func is_dialogue_active() -> bool: return _in_player_conversation or GameState.dialogue_active func has_active_entries() -> bool: return _log_entries.size() > 0 or _in_player_conversation # -- Options -- func _show_options(options: Array) -> void: 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) 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 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) ) 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) 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 # Append player's chosen response to the log append_player_line(_npc_name, text) if is_confront: _start_confrontation_beat(rid, text) else: option_selected.emit(rid, text) _end_player_conversation() # -- Confrontation beat (D-063) -- func _start_confrontation_beat(response_id: String, text: String) -> void: for ctrl in _option_controls: if is_instance_valid(ctrl): ctrl.mouse_filter = Control.MOUSE_FILTER_IGNORE 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) confrontation_monologue.emit(UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION) AudioManager.apply_dip("confrontation") _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) _end_player_conversation() ) func _cancel_beat() -> void: if _beat_tween and _beat_tween.is_valid(): _beat_tween.kill() _beat_tween = null AudioManager.clear_dip() # -- Log rendering -- ## Rebuild the dialogue log BBCode from all non-expired entries. func _rebuild_log() -> void: if not dialogue_log: return var now := Time.get_ticks_msec() var bbcode := "" for entry in _log_entries: var alpha: float = 1.0 if not entry.pinned: var age_sec: float = (now - entry.timestamp_msec) / 1000.0 if age_sec > _entry_lifetime: alpha = clampf(1.0 - (age_sec - _entry_lifetime) / _entry_fade, 0.0, 1.0) if entry.is_passive: alpha *= _passive_opacity if alpha <= 0.0: continue var line := _format_entry(entry, alpha) if not bbcode.is_empty(): bbcode += "\n" bbcode += line dialogue_log.text = bbcode ## Format a single log entry as BBCode. ## Hoshe #2: escape BBCode brackets in server-sourced strings. ## Araminta: passive lines get ┃ prefix + desaturated colours. ## Non-blocking: 1-on-1 player dialogue simplifies to "Speaker:" (no → You). func _format_entry(entry: Dictionary, alpha: float) -> String: var speaker: String = _escape_bbcode(entry.speaker) var target: String = _escape_bbcode(entry.target) var text: String = _escape_bbcode(entry.text) var is_passive: bool = entry.is_passive var speaker_color := _color_for_name(entry.speaker) var target_color := _color_for_name(entry.target) # Desaturate passive name colours (Araminta review) if is_passive: speaker_color = _desaturate(speaker_color, PASSIVE_DESATURATION) target_color = _desaturate(target_color, PASSIVE_DESATURATION) var sc := _color_with_alpha(speaker_color, alpha) var ac := _color_with_alpha(_arrow_color, alpha) var tc := _color_with_alpha(target_color, alpha) var txc := _color_with_alpha(_speech_color, alpha) var prefix := PASSIVE_GLYPH if is_passive else "" # Non-blocking: simplify 1-on-1 player dialogue — no arrow for Speaker → You or You → Speaker var involves_player := entry.speaker == PLAYER_NAME or entry.target == PLAYER_NAME if involves_player and not is_passive: # Just "Speaker: text" or "You: text" return "%s[color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [ prefix, sc, speaker, txc, text ] else: # Full "Speaker → Target: text" for overheard return "%s[color=%s][b]%s[/b][/color][color=%s] \u2192 [/color][color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [ prefix, sc, speaker, ac, tc, target, txc, text ] ## Escape BBCode bracket characters in server-sourced text (Hoshe #2). static func _escape_bbcode(text: String) -> String: return text.replace("[", "[lb]") ## Get a stable color for a character name, with contrast floor enforcement. func _color_for_name(char_name: String) -> Color: if char_name == PLAYER_NAME: return _player_color if _npc_colors.is_empty(): return _speech_color var idx := absi(char_name.hash()) % _npc_colors.size() var color := _npc_colors[idx] return _enforce_contrast(color) ## Enforce minimum luminance so name colours remain readable against dark BG (Araminta #3). static func _enforce_contrast(color: Color) -> Color: var lum := color.r * 0.299 + color.g * 0.587 + color.b * 0.114 if lum < MIN_CONTRAST_LUMINANCE: var boost := MIN_CONTRAST_LUMINANCE / maxf(lum, 0.001) return Color( minf(color.r * boost, 1.0), minf(color.g * boost, 1.0), minf(color.b * boost, 1.0), color.a ) return color ## Desaturate a colour by a factor (0.0 = no change, 1.0 = full greyscale). static func _desaturate(color: Color, amount: float) -> Color: var grey := color.r * 0.299 + color.g * 0.587 + color.b * 0.114 return Color( lerpf(color.r, grey, amount), lerpf(color.g, grey, amount), lerpf(color.b, grey, amount), color.a ) ## Convert a Color to a hex string with alpha baked in. static func _color_with_alpha(base: Color, alpha: float) -> String: return Color(base.r, base.g, base.b, base.a * alpha).to_html(true) # -- Entry expiry -- ## Remove fully expired entries. Mark dirty if any fading entries exist. ## Pinned entries (active conversation) skip expiry entirely (Araminta #2). func _expire_entries() -> void: if _log_entries.is_empty(): return var now := Time.get_ticks_msec() var total_lifetime_msec: int = int((_entry_lifetime + _entry_fade) * 1000.0) var removed := false var has_fading := false # Remove expired non-pinned entries from the front (oldest first) while _log_entries.size() > 0: var entry: Dictionary = _log_entries[0] if entry.pinned: break # Pinned entries never expire var age: int = now - entry.timestamp_msec if age < total_lifetime_msec: # Check if this entry is in the fading phase if age > int(_entry_lifetime * 1000.0): has_fading = true break _log_entries.remove_at(0) removed = true # Check remaining entries for fading state if not has_fading: for entry in _log_entries: if not entry.pinned: var age: int = now - entry.timestamp_msec if age > int(_entry_lifetime * 1000.0): has_fading = true break if removed or has_fading: _log_dirty = true if removed and _log_entries.is_empty() and not _in_player_conversation: hide_dialogue() # -- Visibility -- ## Ensure panel is visible (fade in if needed). func _ensure_visible() -> void: if _is_showing: return visible = true _is_showing = true 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) 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 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)