Confrontation options use RichTextLabel with italic tags for first-person voice (D-063). Examine result overlay auto-dismisses after 5s with confidence-based color tinting. Dismisses when dialogue opens. D-062 invisible locked options confirmed correct. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
660 lines
23 KiB
GDScript
660 lines
23 KiB
GDScript
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 (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 = ""
|
|
|
|
# -- 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
|
|
|
|
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 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.
|
|
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).
|
|
## 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)
|
|
|
|
|
|
## 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 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
|
|
|
|
# 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: unblock movement immediately — log entries stay visible but don't block input.
|
|
GameState.dialogue_active = 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
|
|
|
|
GameState.dialogue_active = false
|
|
|
|
|
|
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)
|
|
|
|
# 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)
|
|
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).
|
|
## 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", "?"))
|
|
speaker_color = _color_for_name(entry.get("speaker", "?"))
|
|
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)
|
|
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
|
|
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 --
|
|
|
|
## 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)
|