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 # D-020 (#558): Decoupled signals — dialogue_box emits, main.gd (coordinator) handles. # Replaces direct GameState.dialogue_active mutation and AudioManager calls. signal dialogue_state_changed(active: bool) signal audio_dip_requested(profile: String) signal audio_dip_cleared 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", ] # -- Log state -- # Entry format (legacy): {speaker: String, target: String, text, is_passive, pinned, timestamp_msec} # Entry format (entity-anchored): {speaker_id: int, target_id: int, 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) # Entity ID → {name: String, color_index: int} # Populated from server events; drives retroactive re-render when NPC names resolve. var _entity_display: Dictionary = {} # -- 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 = "" # -- Entity color registry (#573) -- # Maps entity_id → Color for dialogue participants. # Assigned from _npc_colors palette on first encounter; player uses _player_color. # v0.1: colors are per-conversation — cleared in _end_player_conversation() to avoid # palette exhaustion (8 entries) across long sessions with 9+ NPCs. var _npc_entity_colors: Dictionary = {} # entity_id -> Color var _npc_entity_id: int = -1 # Entity ID of the current player conversation NPC var _next_npc_color: int = 0 # Round-robin palette index for client-side assignment # -- UI state -- 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 = 45.0 var _entry_fade: float = 5.0 @onready var panel: PanelContainer = $PanelContainer @onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog @onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer func _ready() -> void: # Panel is always visible as a permanent insert UI element (D-061). # Content fades in/out but the panel frame stays on screen. visible = true panel.modulate.a = 1.0 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 _in_player_conversation: return # Number keys 1-3 select dialogue options if event is InputEventKey and event.pressed: var key_index := -1 if event.keycode == KEY_1: key_index = 0 elif event.keycode == KEY_2: key_index = 1 elif event.keycode == KEY_3: key_index = 2 if key_index >= 0 and key_index < _option_controls.size(): get_viewport().set_input_as_handled() _on_option_pressed(key_index) 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.85) 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. ## speaker_entity_id/target_entity_id: optional entity IDs for stable color lookup (#573). ## TODO Phase 2: 6 positional params is unwieldy — consider dictionary-options overload. func append_line( speaker: String, target: String, text: String, is_passive: bool = false, speaker_entity_id: int = -1, target_entity_id: int = -1 ) -> void: var pinned := not is_passive and _in_player_conversation var entry: Dictionary = { "speaker": speaker, "target": target, "text": text, "is_passive": is_passive, "pinned": pinned, "timestamp_msec": Time.get_ticks_msec(), } if speaker_entity_id >= 0: entry["speaker_entity_id"] = speaker_entity_id if target_entity_id >= 0: entry["target_entity_id"] = target_entity_id _log_entries.append(entry) _log_dirty = true _ensure_visible() ## Append an overheard conversation event (D-078). ## Stores entity IDs for retroactive name resolution when NPC display names change. func append_conversation_event(event: Dictionary) -> void: var text: String = event.get("occluded_line", "") if text.is_empty(): return var speaker_id: int = event.get("speaker_id", -1) var target_id: int = event.get("target_id", -1) var speaker_name: String = event.get("speaker_name", "?") var target_name: String = event.get("target_name", "?") var speaker_color_index: int = event.get("speaker_color_index", -1) var target_color_index: int = event.get("target_color_index", -1) # Update entity display registry — set dirty if a known name changed (retroactive update) if speaker_id >= 0: var prev: Dictionary = _entity_display.get(speaker_id, {}) _entity_display[speaker_id] = {"name": speaker_name, "color_index": speaker_color_index} if prev.has("name") and prev.get("name", "") != speaker_name: _log_dirty = true if target_id >= 0: var prev: Dictionary = _entity_display.get(target_id, {}) _entity_display[target_id] = {"name": target_name, "color_index": target_color_index} if prev.has("name") and prev.get("name", "") != target_name: _log_dirty = true var entry: Dictionary = { "text": text, "is_passive": true, "pinned": false, "timestamp_msec": Time.get_ticks_msec(), } if speaker_id >= 0: entry["speaker_id"] = speaker_id entry["target_id"] = target_id else: # Fallback: no entity IDs on wire, store raw names for legacy rendering entry["speaker"] = speaker_name entry["target"] = target_name _log_entries.append(entry) _log_dirty = true _ensure_visible() ## Update entity display name and color index — called when Talk responses arrive. ## Triggers retroactive log re-render if the entity's displayed name has changed. func update_entity_display(entity_id: int, name: String, color_index: int) -> void: if entity_id < 0: return var prev: Dictionary = _entity_display.get(entity_id, {}) _entity_display[entity_id] = {"name": name, "color_index": color_index} if prev.has("name") and prev.get("name", "") != name: _log_dirty = 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, -1, _npc_entity_id) ## Append an NPC follow-up line (from dialogue_response). ## Note: expects show_dialogue() to have been called first to set _npc_entity_id. ## Defensive: if entity_id is valid but not yet registered, _assign_npc_color handles it. func append_dialogue_response(npc_name: String, text: String, entity_id: int = -1) -> void: if entity_id >= 0: _assign_npc_color(entity_id) append_line(npc_name, PLAYER_NAME, text, false, entity_id, -1) # -- Active player conversation -- ## Show dialogue with NPC speech and response options. ## npc_name: who is speaking. speech: the NPC's line. options: player choices. ## npc_entity_id: entity ID of the NPC for stable color assignment (#573). func show_dialogue( npc_name: String, speech: String, options: Array = [], npc_entity_id: int = -1 ) -> void: _npc_name = npc_name _npc_entity_id = npc_entity_id _cancel_beat() _in_player_conversation = true if npc_entity_id >= 0: _assign_npc_color(npc_entity_id) # Append NPC's line to the log if not speech.is_empty(): append_line(npc_name, PLAYER_NAME, speech, false, npc_entity_id, -1) # Clear old options and show new ones _clear_options() _show_options(options) # Show panel _ensure_visible() mouse_filter = Control.MOUSE_FILTER_STOP dialogue_state_changed.emit(true) # D-064: coordinator blocks movement # D-069: Dialogue dip — coordinator routes to AudioManager audio_dip_requested.emit("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 cleared immediately so WASD resumes. ## Log entries remain visible and expire via timeout (cosmetic only). 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 # #573: Clear per-conversation color registry to avoid palette exhaustion _npc_entity_colors.clear() _npc_entity_id = -1 _next_npc_color = 0 # D-069: Clear dialogue/confrontation dip — coordinator routes to AudioManager audio_dip_cleared.emit() # D-061: unpause — signal to main.gd for input recording (#507) unpause_requested.emit() # D-064: unblock movement immediately — coordinator handles GameState update. dialogue_state_changed.emit(false) # If no entries remain, hide the panel with fade. if _log_entries.is_empty(): hide_dialogue() ## End active dialogue state. Panel stays visible (permanent insert UI element). func hide_dialogue() -> void: if _in_player_conversation: _end_player_conversation() return # _end_player_conversation may call hide_dialogue if log is empty dialogue_state_changed.emit(false) # D-020: coordinator handles GameState update func is_dialogue_active() -> bool: return _in_player_conversation 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) # D-063: Confrontation options render italic — first-person voice, weighted differently. # Use RichTextLabel with BBCode [i] tags for confrontation; plain Label for standard. var ctrl: Control if is_confrontation: var rtl := RichTextLabel.new() rtl.bbcode_enabled = true rtl.fit_content = true rtl.scroll_active = false rtl.add_theme_font_size_override("normal_font_size", 14) rtl.add_theme_color_override( "default_color", Color( Constants.INSERT_COLOR_TEXT.r * 1.08, Constants.INSERT_COLOR_TEXT.g * 0.96, Constants.INSERT_COLOR_TEXT.b * 0.90, 1.0 ) ) # Slight warm tint for confrontation weight rtl.mouse_filter = Control.MOUSE_FILTER_STOP rtl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND rtl.text = "[i]%d. %s[/i]" % [i + 1, raw_text] ctrl = rtl else: var label := Label.new() label.add_theme_font_size_override("font_size", 14) label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT) label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.mouse_filter = Control.MOUSE_FILTER_STOP label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND label.text = "%d. %s" % [i + 1, raw_text] ctrl = label var idx := i ctrl.gui_input.connect( func(event: InputEvent): if ( event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT ): _on_option_pressed(idx) ) ctrl.mouse_entered.connect(_make_hover_on(ctrl)) ctrl.mouse_exited.connect(_make_hover_off(ctrl)) options_container.add_child(ctrl) _option_controls.append(ctrl) _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 ) audio_dip_requested.emit("confrontation") # D-069: coordinator routes to AudioManager _beat_tween = create_tween() _beat_tween.tween_interval(CONFRONTATION_BEAT_DURATION) _beat_tween.tween_callback( func(): audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager 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 audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager # -- 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). ## Entity-anchored entries resolve display name and color from _entity_display. func _format_entry(entry: Dictionary, alpha: float) -> String: var speaker: String var target: String var speaker_color: Color var target_color: Color var involves_player: bool if entry.has("speaker_id"): # Entity-anchored entry: resolve from _entity_display registry var sp_data: Dictionary = _entity_display.get(entry["speaker_id"], {}) var tg_data: Dictionary = _entity_display.get(entry.get("target_id", -1), {}) speaker = _escape_bbcode(sp_data.get("name", "?")) target = _escape_bbcode(tg_data.get("name", "?")) var sp_ci: int = sp_data.get("color_index", -1) var tg_ci: int = tg_data.get("color_index", -1) if sp_ci >= 0 and not _npc_colors.is_empty(): speaker_color = _enforce_contrast(_npc_colors[sp_ci % _npc_colors.size()]) else: speaker_color = _color_for_name(sp_data.get("name", "?")) if tg_ci >= 0 and not _npc_colors.is_empty(): target_color = _enforce_contrast(_npc_colors[tg_ci % _npc_colors.size()]) else: target_color = _color_for_name(tg_data.get("name", "?")) involves_player = false # Overheard entries never involve the player directly else: # Legacy string-keyed entry (player dialogue, backward compat) speaker = _escape_bbcode(entry.get("speaker", "?")) target = _escape_bbcode(entry.get("target", "?")) # #573: use entity-ID-bound color if available; fall back to name-hash var sp_eid: int = entry.get("speaker_entity_id", -1) var tg_eid: int = entry.get("target_entity_id", -1) if sp_eid >= 0 and _npc_entity_colors.has(sp_eid): speaker_color = _npc_entity_colors[sp_eid] else: speaker_color = _color_for_name(entry.get("speaker", "?")) if tg_eid >= 0 and _npc_entity_colors.has(tg_eid): target_color = _npc_entity_colors[tg_eid] else: target_color = _color_for_name(entry.get("target", "?")) involves_player = ( (entry.get("speaker", "") == PLAYER_NAME) or (entry.get("target", "") == PLAYER_NAME) ) var text: String = _escape_bbcode(entry.text) var is_passive: bool = entry.is_passive # Desaturate passive name colours (Araminta review), re-enforce contrast floor after if is_passive: speaker_color = _enforce_contrast(_desaturate(speaker_color, PASSIVE_DESATURATION)) target_color = _enforce_contrast(_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 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] ) # 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]").replace("]", "[rb]") ## Assign a palette color to an NPC entity ID on first encounter (#573). ## Returns the same color on subsequent calls for the same entity ID. ## TODO D-033 Phase 2: derive from relationship color — current independent palette ## will need alignment when relationship-based entity colors arrive. func _assign_npc_color(entity_id: int) -> Color: if entity_id < 0: return _speech_color if _npc_entity_colors.has(entity_id): return _npc_entity_colors[entity_id] if _npc_colors.is_empty(): return _speech_color var color := _enforce_contrast(_npc_colors[_next_npc_color % _npc_colors.size()]) _next_npc_color += 1 _npc_entity_colors[entity_id] = color return color ## 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 (oldest first, skipping pinned) var i := 0 while i < _log_entries.size(): var entry: Dictionary = _log_entries[i] if entry.pinned: i += 1 continue # Pinned entries never expire — keep scanning 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(i) removed = true # Don't increment i — element at i is now the next entry # 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 -- ## No-op — panel is always visible as a permanent insert UI element. func _ensure_visible() -> void: pass 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(ctrl: Control) -> Callable: return func(): # RichTextLabel uses "default_color"; Label uses "font_color" if ctrl is RichTextLabel: ctrl.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER) else: ctrl.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER) static func _make_hover_off(ctrl: Control) -> Callable: return func(): if ctrl is RichTextLabel: ctrl.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT) else: ctrl.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)