feat(ui): unified dialogue log with overheard NPC conversations (#535)
Refactors dialogue box into a scrolling conversation log. All dialogue (player-NPC and overheard NPC-NPC) flows chronologically, oldest at top. Player response options at the bottom during active conversations. - Entries expire after configurable timeout (equal for all message types) - Walk-away clears options but preserves log entries (fair information) - Per-character name colors from dialogue-theme.yaml (hash-indexed palette) - Overheard lines render at 90% opacity (D-078) - Protocol decode for conversation_events + conversation_ended - GameState fields for conversation_events, conversation_ended, dialogue_response - Mock Mira/Soren NPC-NPC conversation in test snapshot - Also wires #511 debug overlay into main.gd and main.tscn Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
# Dialogue Log Theme — The Settled Reach
|
||||||
|
#
|
||||||
|
# Ticket: #535 | Sprint: 14
|
||||||
|
# Colors and timing for the unified dialogue log panel.
|
||||||
|
# Loaded by dialogue_box.gd at runtime.
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PLAYER COLOR
|
||||||
|
# Fixed color for the player's name in dialogue log entries.
|
||||||
|
# ============================================================
|
||||||
|
player_color: "#e0e8ff"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# NPC COLOR PALETTE
|
||||||
|
# 8 distinct colors for NPC names. Indexed by hash(npc_name) % 8.
|
||||||
|
# Must be readable on a dark semi-transparent panel background.
|
||||||
|
# ============================================================
|
||||||
|
npc_colors:
|
||||||
|
0: "#4a9ebb" # teal
|
||||||
|
1: "#6bc9a6" # green
|
||||||
|
2: "#e8c547" # amber
|
||||||
|
3: "#d49e5d" # warm orange
|
||||||
|
4: "#b586d4" # lavender
|
||||||
|
5: "#d45d5d" # muted red
|
||||||
|
6: "#5daa7d" # forest
|
||||||
|
7: "#7daccc" # sky blue
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# TEXT COLORS
|
||||||
|
# Arrow separator and speech text color.
|
||||||
|
# ============================================================
|
||||||
|
arrow_color: "#8890a0"
|
||||||
|
speech_color: "#c8d0e0"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PASSIVE (OVERHEARD) OPACITY
|
||||||
|
# Base opacity multiplier for non-player-centric lines.
|
||||||
|
# 1.0 = full opacity, 0.0 = invisible.
|
||||||
|
# ============================================================
|
||||||
|
passive_opacity: "0.9"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ENTRY TIMING
|
||||||
|
# All entry types share the same lifetime and fade duration.
|
||||||
|
# ============================================================
|
||||||
|
entry_lifetime_seconds: "15.0"
|
||||||
|
entry_fade_seconds: "3.0"
|
||||||
+12
-1
@@ -1,4 +1,4 @@
|
|||||||
[gd_scene load_steps=22 format=3 uid="uid://bswrmh7w8dbgm"]
|
[gd_scene load_steps=23 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
[ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"]
|
[ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"]
|
||||||
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
|
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
|
||||||
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
|
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
|
||||||
|
[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
|
||||||
|
|
||||||
[node name="Game" type="Node2D"]
|
[node name="Game" type="Node2D"]
|
||||||
script = ExtResource("1_main")
|
script = ExtResource("1_main")
|
||||||
@@ -152,6 +153,16 @@ layer = 20
|
|||||||
; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
|
; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
|
||||||
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
|
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
|
||||||
|
|
||||||
|
; #511: F3 debug overlay — real-time game state, toggled by F3
|
||||||
|
[node name="DebugOverlay" type="Control" parent="UILayer"]
|
||||||
|
anchors_preset = 0
|
||||||
|
offset_left = 16
|
||||||
|
offset_top = 120
|
||||||
|
offset_right = 400
|
||||||
|
offset_bottom = 400
|
||||||
|
mouse_filter = 2
|
||||||
|
script = ExtResource("22_debug")
|
||||||
|
|
||||||
; D-056: Cursor state machine — insert-styled geometric cursor, topmost in UILayer
|
; D-056: Cursor state machine — insert-styled geometric cursor, topmost in UILayer
|
||||||
[node name="CursorRenderer" type="Node2D" parent="UILayer"]
|
[node name="CursorRenderer" type="Node2D" parent="UILayer"]
|
||||||
script = ExtResource("10_cursor")
|
script = ExtResource("10_cursor")
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ var rng_seed: Variant = null
|
|||||||
# v7 fields (#431, D-059/D-060)
|
# v7 fields (#431, D-059/D-060)
|
||||||
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
||||||
|
|
||||||
|
# v9 fields (#535, D-078): Overheard NPC-to-NPC conversations
|
||||||
|
var conversation_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}]
|
||||||
|
var conversation_ended: Array = [] # [{speaker_id, target_id}]
|
||||||
|
var dialogue_response: Variant = null # {line_id, text, speaker_entity_id} — NPC follow-up after player choice
|
||||||
|
|
||||||
# #126, D-018: Medium-range sound events for fog-edge directional indicators.
|
# #126, D-018: Medium-range sound events for fog-edge directional indicators.
|
||||||
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
|
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
|
||||||
var medium_sound_events: Array = []
|
var medium_sound_events: Array = []
|
||||||
@@ -169,6 +174,24 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
|||||||
else:
|
else:
|
||||||
pending_recognitions = []
|
pending_recognitions = []
|
||||||
|
|
||||||
|
# v9: conversation_events (#535, D-078) — overheard NPC-to-NPC lines
|
||||||
|
if snapshot.has("conversation_events") and snapshot.conversation_events is Array:
|
||||||
|
conversation_events = snapshot.conversation_events
|
||||||
|
else:
|
||||||
|
conversation_events = []
|
||||||
|
|
||||||
|
# v9: conversation_ended (#535, D-078) — pairs whose conversation ended
|
||||||
|
if snapshot.has("conversation_ended") and snapshot.conversation_ended is Array:
|
||||||
|
conversation_ended = snapshot.conversation_ended
|
||||||
|
else:
|
||||||
|
conversation_ended = []
|
||||||
|
|
||||||
|
# v9: dialogue_response (#535, D-028) — NPC follow-up after player choice
|
||||||
|
if snapshot.has("dialogue_response") and snapshot.dialogue_response is Dictionary:
|
||||||
|
dialogue_response = snapshot.dialogue_response
|
||||||
|
else:
|
||||||
|
dialogue_response = null
|
||||||
|
|
||||||
# v8: gauntlet mode (#496) — room_id and gauntlet_mode
|
# v8: gauntlet mode (#496) — room_id and gauntlet_mode
|
||||||
if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true:
|
if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true:
|
||||||
gauntlet_mode = true
|
gauntlet_mode = true
|
||||||
|
|||||||
@@ -396,6 +396,37 @@ func _test_snapshot() -> Dictionary:
|
|||||||
"total_delay_ticks": total_delay,
|
"total_delay_ticks": total_delay,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# #535: Mock overheard NPC-NPC conversation (D-078)
|
||||||
|
# Two NPCs (Mira and Soren) trade lines every 5 ticks starting at tick 3.
|
||||||
|
# Conversation ends after 6 exchanges (~30 ticks).
|
||||||
|
var conv_events: Array = []
|
||||||
|
var conv_ended: Array = []
|
||||||
|
var conv_start := 3
|
||||||
|
var conv_lines := [
|
||||||
|
{"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."},
|
||||||
|
{"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."},
|
||||||
|
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."},
|
||||||
|
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."},
|
||||||
|
{"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."},
|
||||||
|
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
|
||||||
|
]
|
||||||
|
var conv_tick_interval := 5
|
||||||
|
var conv_total_ticks := conv_lines.size() * conv_tick_interval
|
||||||
|
if _test_tick >= conv_start and _test_tick < conv_start + conv_total_ticks:
|
||||||
|
var conv_index := (_test_tick - conv_start) / conv_tick_interval
|
||||||
|
var within_tick := (_test_tick - conv_start) % conv_tick_interval
|
||||||
|
if within_tick == 0 and conv_index < conv_lines.size():
|
||||||
|
var cl: Dictionary = conv_lines[conv_index]
|
||||||
|
conv_events.append({
|
||||||
|
"speaker_id": 10,
|
||||||
|
"target_id": 11,
|
||||||
|
"speaker_name": cl.speaker,
|
||||||
|
"target_name": cl.target,
|
||||||
|
"occluded_line": cl.line,
|
||||||
|
})
|
||||||
|
elif _test_tick == conv_start + conv_total_ticks:
|
||||||
|
conv_ended.append({"speaker_id": 10, "target_id": 11})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tick": _test_tick,
|
"tick": _test_tick,
|
||||||
"version": Protocol.PROTOCOL_VERSION,
|
"version": Protocol.PROTOCOL_VERSION,
|
||||||
@@ -417,6 +448,8 @@ func _test_snapshot() -> Dictionary:
|
|||||||
"current_dialogue": dialogue,
|
"current_dialogue": dialogue,
|
||||||
"pending_recognitions": pending_recs,
|
"pending_recognitions": pending_recs,
|
||||||
"gauntlet_mode": _test_gauntlet_mode,
|
"gauntlet_mode": _test_gauntlet_mode,
|
||||||
|
"conversation_events": conv_events,
|
||||||
|
"conversation_ended": conv_ended,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Generate a small test room: 8x6 room with walls, a door, and floor
|
# Generate a small test room: 8x6 room with walls, a door, and floor
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ extends Node2D
|
|||||||
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
|
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
|
||||||
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
||||||
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
||||||
|
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
|
||||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||||
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
||||||
|
|
||||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||||
|
var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution
|
||||||
var _camera_anchored: bool = false
|
var _camera_anchored: bool = false
|
||||||
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
|
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
|
||||||
var _last_dialogue_tick: int = -1
|
var _last_dialogue_tick: int = -1
|
||||||
@@ -130,6 +132,10 @@ func _process(_delta: float) -> void:
|
|||||||
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
|
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
|
||||||
checklist_overlay.update_from_state()
|
checklist_overlay.update_from_state()
|
||||||
|
|
||||||
|
# #511: Update debug overlay (F3 toggle, dev tool)
|
||||||
|
if debug_overlay and debug_overlay.has_method("update_from_state"):
|
||||||
|
debug_overlay.update_from_state()
|
||||||
|
|
||||||
# D-018 #125: Play close-range sound events via positional 2D audio
|
# D-018 #125: Play close-range sound events via positional 2D audio
|
||||||
_play_close_sound_events()
|
_play_close_sound_events()
|
||||||
|
|
||||||
@@ -146,6 +152,11 @@ func _process(_delta: float) -> void:
|
|||||||
# D-061: Show dialogue if server sent one this tick (#434)
|
# D-061: Show dialogue if server sent one this tick (#434)
|
||||||
_consume_dialogue()
|
_consume_dialogue()
|
||||||
|
|
||||||
|
# #535: Consume overheard conversation events and responses
|
||||||
|
_consume_conversation_events()
|
||||||
|
_consume_conversation_ended()
|
||||||
|
_consume_dialogue_response()
|
||||||
|
|
||||||
# Track camera to player position every frame (D-015: locked, no panning)
|
# Track camera to player position every frame (D-015: locked, no panning)
|
||||||
if _camera_anchored:
|
if _camera_anchored:
|
||||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||||
@@ -316,6 +327,7 @@ func _consume_dialogue() -> void:
|
|||||||
_last_dialogue_tick = GameState.current_tick
|
_last_dialogue_tick = GameState.current_tick
|
||||||
var dlg: Dictionary = GameState.current_dialogue
|
var dlg: Dictionary = GameState.current_dialogue
|
||||||
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
|
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
|
||||||
|
_last_dialogue_npc_name = dlg.get("npc_name", "")
|
||||||
dialogue_box.show_dialogue(
|
dialogue_box.show_dialogue(
|
||||||
dlg.get("npc_name", ""),
|
dlg.get("npc_name", ""),
|
||||||
dlg.get("speech", ""),
|
dlg.get("speech", ""),
|
||||||
@@ -324,6 +336,34 @@ func _consume_dialogue() -> void:
|
|||||||
GameState.current_dialogue = null
|
GameState.current_dialogue = null
|
||||||
|
|
||||||
|
|
||||||
|
# #535: Consume overheard NPC-NPC conversation events (D-078).
|
||||||
|
# Each event carries pre-occluded text — render verbatim in the dialogue log.
|
||||||
|
func _consume_conversation_events() -> void:
|
||||||
|
if not dialogue_box:
|
||||||
|
return
|
||||||
|
for event in GameState.conversation_events:
|
||||||
|
dialogue_box.append_conversation_event(event)
|
||||||
|
GameState.conversation_events = []
|
||||||
|
|
||||||
|
|
||||||
|
# #535: Handle conversation_ended events — notify dialogue box to stop tracking pairs.
|
||||||
|
func _consume_conversation_ended() -> void:
|
||||||
|
if not dialogue_box:
|
||||||
|
return
|
||||||
|
for event in GameState.conversation_ended:
|
||||||
|
dialogue_box.on_conversation_ended(event)
|
||||||
|
GameState.conversation_ended = []
|
||||||
|
|
||||||
|
|
||||||
|
# #535: Consume dialogue_response — NPC follow-up line after player picks an option.
|
||||||
|
func _consume_dialogue_response() -> void:
|
||||||
|
if GameState.dialogue_response == null or not dialogue_box:
|
||||||
|
return
|
||||||
|
var dr: Dictionary = GameState.dialogue_response
|
||||||
|
dialogue_box.append_dialogue_response(_last_dialogue_npc_name, dr.get("text", ""))
|
||||||
|
GameState.dialogue_response = null
|
||||||
|
|
||||||
|
|
||||||
# D-061: Handle dialogue option selection → send to server
|
# D-061: Handle dialogue option selection → send to server
|
||||||
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
||||||
SimBridge.send_input({
|
SimBridge.send_input({
|
||||||
|
|||||||
@@ -180,6 +180,32 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
|||||||
"speaker_entity_id": int(raw_dr.get("speaker_entity_id", -1)),
|
"speaker_entity_id": int(raw_dr.get("speaker_entity_id", -1)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# v9: conversation_events (#535, D-078) — overheard NPC-to-NPC dialogue lines.
|
||||||
|
# Each event carries pre-occluded text plus speaker/target attribution.
|
||||||
|
var conversation_events: Array = []
|
||||||
|
var raw_conv_events: Variant = raw.get("conversation_events")
|
||||||
|
if raw_conv_events is Array:
|
||||||
|
for raw_ce in raw_conv_events:
|
||||||
|
if raw_ce is Dictionary and raw_ce.has("occluded_line"):
|
||||||
|
conversation_events.append({
|
||||||
|
"speaker_id": int(raw_ce.get("speaker_id", 0)),
|
||||||
|
"target_id": int(raw_ce.get("target_id", 0)),
|
||||||
|
"speaker_name": str(raw_ce.get("speaker_name", "")),
|
||||||
|
"target_name": str(raw_ce.get("target_name", "")),
|
||||||
|
"occluded_line": str(raw_ce["occluded_line"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
# v9: conversation_ended (#535, D-078) — pairs whose conversation ended this tick.
|
||||||
|
var conversation_ended: Array = []
|
||||||
|
var raw_conv_ended: Variant = raw.get("conversation_ended")
|
||||||
|
if raw_conv_ended is Array:
|
||||||
|
for raw_end in raw_conv_ended:
|
||||||
|
if raw_end is Dictionary:
|
||||||
|
conversation_ended.append({
|
||||||
|
"speaker_id": int(raw_end.get("speaker_id", 0)),
|
||||||
|
"target_id": int(raw_end.get("target_id", 0)),
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tick": tick,
|
"tick": tick,
|
||||||
"entities": entities,
|
"entities": entities,
|
||||||
@@ -195,6 +221,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
|||||||
"current_dialogue": current_dialogue,
|
"current_dialogue": current_dialogue,
|
||||||
"dialogue_response": dialogue_response,
|
"dialogue_response": dialogue_response,
|
||||||
"pending_recognitions": pending_recognitions,
|
"pending_recognitions": pending_recognitions,
|
||||||
|
"conversation_events": conversation_events,
|
||||||
|
"conversation_ended": conversation_ended,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+308
-92
@@ -1,28 +1,49 @@
|
|||||||
extends Control
|
extends Control
|
||||||
|
|
||||||
# Dialogue box — D-061: bottom screen, max 20% height, no portraits.
|
# 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.
|
# 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.
|
# D-063: Confrontation options render italic, trigger monologue beat before send.
|
||||||
|
# D-064: Walk-away via WASD dismisses active conversation (log entries persist).
|
||||||
|
# D-078: Overheard NPC-NPC lines shown at reduced opacity, no response options.
|
||||||
|
|
||||||
signal option_selected(response_id: String, text: String)
|
signal option_selected(response_id: String, text: String)
|
||||||
signal dialogue_dismissed # Walk-away or conversation end
|
signal dialogue_dismissed # Walk-away or conversation end
|
||||||
signal confrontation_monologue(text: String, duration: float) # D-063: beat monologue
|
signal confrontation_monologue(text: String, duration: float) # D-063: beat monologue
|
||||||
|
|
||||||
@onready var panel: PanelContainer = $PanelContainer
|
@onready var panel: PanelContainer = $PanelContainer
|
||||||
@onready var npc_speech: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/NpcSpeech
|
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
|
||||||
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
|
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
|
||||||
|
|
||||||
|
# -- Log state --
|
||||||
|
var _log_entries: Array[Dictionary] = [] # {speaker, target, text, is_passive, timestamp_msec}
|
||||||
|
var _active_overheard: Dictionary = {} # "speaker_id:target_id" -> true
|
||||||
|
var _in_player_conversation: bool = false
|
||||||
|
|
||||||
|
# -- 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 _is_showing: bool = false
|
||||||
var _active_tween: Tween = null
|
var _active_tween: Tween = null
|
||||||
var _beat_tween: Tween = null # D-063: confrontation beat delay
|
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
|
# -- Theme (loaded from data/dialogue-theme.yaml) --
|
||||||
var _option_texts: Array[String] = [] # raw display text per option
|
var _player_color: Color = Color("#e0e8ff")
|
||||||
var _option_is_confrontation: Array[bool] = [] # confrontation flag per option
|
var _npc_colors: Array[Color] = []
|
||||||
var _npc_name: String = ""
|
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_IN: float = 0.2
|
||||||
const FADE_OUT: float = 0.3 # D-064: 300ms fade on walk-away
|
const FADE_OUT: float = 0.3 # D-064: 300ms fade on walk-away
|
||||||
@@ -32,6 +53,7 @@ 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_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_DIM_ALPHA: float = 0.7 # D-063: dialogue box dims during beat
|
||||||
const CONFRONTATION_MONOLOGUE_KEY: String = "dialogue.confrontation_beat"
|
const CONFRONTATION_MONOLOGUE_KEY: String = "dialogue.confrontation_beat"
|
||||||
|
const PLAYER_NAME: String = "You"
|
||||||
|
|
||||||
# D-064: movement actions that trigger walk-away
|
# D-064: movement actions that trigger walk-away
|
||||||
const _WALK_AWAY_ACTIONS: Array[StringName] = [
|
const _WALK_AWAY_ACTIONS: Array[StringName] = [
|
||||||
@@ -45,26 +67,78 @@ func _ready() -> void:
|
|||||||
visible = false
|
visible = false
|
||||||
_is_showing = false
|
_is_showing = false
|
||||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_load_theme()
|
||||||
_update_layout()
|
_update_layout()
|
||||||
get_viewport().size_changed.connect(_update_layout)
|
get_viewport().size_changed.connect(_update_layout)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
_expire_entries()
|
||||||
|
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
if not _is_showing:
|
if not _is_showing:
|
||||||
return
|
return
|
||||||
|
if not _in_player_conversation:
|
||||||
|
return
|
||||||
|
|
||||||
# D-064: WASD during dialogue → walk-away, 300ms fade
|
# D-064: WASD during active player dialogue → walk-away
|
||||||
if event is InputEventKey and event.pressed:
|
if event is InputEventKey and event.pressed:
|
||||||
for action in _WALK_AWAY_ACTIONS:
|
for action in _WALK_AWAY_ACTIONS:
|
||||||
if event.is_action_pressed(action):
|
if event.is_action_pressed(action):
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
_cancel_beat()
|
_cancel_beat()
|
||||||
hide_dialogue()
|
_end_player_conversation()
|
||||||
dialogue_dismissed.emit()
|
dialogue_dismissed.emit()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
# Responsive layout — clamps width to MAX_WIDTH_PX and height to 20% viewport.
|
# -- 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:
|
func _update_layout() -> void:
|
||||||
var vp := get_viewport_rect().size
|
var vp := get_viewport_rect().size
|
||||||
var max_h := vp.y * MAX_HEIGHT_RATIO
|
var max_h := vp.y * MAX_HEIGHT_RATIO
|
||||||
@@ -74,26 +148,128 @@ func _update_layout() -> void:
|
|||||||
panel.offset_top = -max_h
|
panel.offset_top = -max_h
|
||||||
|
|
||||||
|
|
||||||
# Show dialogue with NPC speech and response options.
|
# -- Log entry management --
|
||||||
# npc_name: who is speaking (displayed as prefix)
|
|
||||||
# speech: the NPC's dialogue text
|
## Append a dialogue line to the log.
|
||||||
# options: Array of {text, response_id, priority, confrontation} — sorted by priority, max 3 shown.
|
## speaker/target: display names. text: the spoken line.
|
||||||
# D-062: locked options are invisible (server filters before sending).
|
## is_passive: true for overheard NPC-NPC (renders at reduced opacity).
|
||||||
# D-063: confrontation options render italic.
|
func append_line(speaker: String, target: String, text: String, is_passive: bool = false) -> void:
|
||||||
|
_log_entries.append({
|
||||||
|
"speaker": speaker,
|
||||||
|
"target": target,
|
||||||
|
"text": text,
|
||||||
|
"is_passive": is_passive,
|
||||||
|
"timestamp_msec": Time.get_ticks_msec(),
|
||||||
|
})
|
||||||
|
_rebuild_log()
|
||||||
|
_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
|
||||||
|
var pair_key := "%s:%s" % [event.get("speaker_id", 0), event.get("target_id", 0)]
|
||||||
|
_active_overheard[pair_key] = true
|
||||||
|
append_line(speaker, target, text, true)
|
||||||
|
|
||||||
|
|
||||||
|
## Handle conversation_ended — remove pair from active tracking.
|
||||||
|
func on_conversation_ended(event: Dictionary) -> void:
|
||||||
|
var pair_key := "%s:%s" % [event.get("speaker_id", 0), event.get("target_id", 0)]
|
||||||
|
_active_overheard.erase(pair_key)
|
||||||
|
# Also try the reverse pair
|
||||||
|
var reverse_key := "%s:%s" % [event.get("target_id", 0), event.get("speaker_id", 0)]
|
||||||
|
_active_overheard.erase(reverse_key)
|
||||||
|
|
||||||
|
|
||||||
|
## 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:
|
func show_dialogue(npc_name: String, speech: String, options: Array = []) -> void:
|
||||||
_npc_name = npc_name
|
_npc_name = npc_name
|
||||||
_cancel_beat()
|
_cancel_beat()
|
||||||
|
_in_player_conversation = true
|
||||||
|
|
||||||
# NPC speech — name prefix in bold
|
# Append NPC's line to the log
|
||||||
if npc_name.is_empty():
|
if not speech.is_empty():
|
||||||
npc_speech.text = speech
|
append_line(npc_name, PLAYER_NAME, speech, false)
|
||||||
else:
|
|
||||||
npc_speech.text = "[b]%s:[/b] %s" % [npc_name, speech]
|
|
||||||
|
|
||||||
# Clear old options
|
# Clear old options and show new ones
|
||||||
_clear_options()
|
_clear_options()
|
||||||
|
_show_options(options)
|
||||||
|
|
||||||
# Sort by priority ascending, cap at MAX_OPTIONS (D-061: max 3 visible)
|
# 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 in single-player
|
||||||
|
SimBridge.send_input({"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()})
|
||||||
|
|
||||||
|
|
||||||
|
## End active player conversation — clears options but preserves log.
|
||||||
|
func _end_player_conversation() -> void:
|
||||||
|
_in_player_conversation = false
|
||||||
|
_clear_options()
|
||||||
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
|
||||||
|
# D-069: Clear dialogue/confrontation dip
|
||||||
|
AudioManager.clear_dip()
|
||||||
|
|
||||||
|
# D-061: unpause
|
||||||
|
SimBridge.send_input({"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()})
|
||||||
|
|
||||||
|
GameState.dialogue_active = false
|
||||||
|
|
||||||
|
|
||||||
|
## Hide the entire panel with fade.
|
||||||
|
func hide_dialogue() -> void:
|
||||||
|
if not _is_showing:
|
||||||
|
return
|
||||||
|
|
||||||
|
if _in_player_conversation:
|
||||||
|
_end_player_conversation()
|
||||||
|
|
||||||
|
_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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
var sorted_opts: Array = options.duplicate()
|
||||||
sorted_opts.sort_custom(func(a, b): return a.get("priority", 0) < b.get("priority", 0))
|
sorted_opts.sort_custom(func(a, b): return a.get("priority", 0) < b.get("priority", 0))
|
||||||
var count := mini(sorted_opts.size(), MAX_OPTIONS)
|
var count := mini(sorted_opts.size(), MAX_OPTIONS)
|
||||||
@@ -103,7 +279,6 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
|
|||||||
var raw_text: String = opt.get("text", "")
|
var raw_text: String = opt.get("text", "")
|
||||||
var is_confrontation: bool = opt.get("confrontation", false)
|
var is_confrontation: bool = opt.get("confrontation", false)
|
||||||
|
|
||||||
# RichTextLabel for BBCode support (D-063: confrontation italic)
|
|
||||||
var label := RichTextLabel.new()
|
var label := RichTextLabel.new()
|
||||||
label.bbcode_enabled = true
|
label.bbcode_enabled = true
|
||||||
label.fit_content = true
|
label.fit_content = true
|
||||||
@@ -117,13 +292,11 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
|
|||||||
else:
|
else:
|
||||||
label.text = raw_text
|
label.text = raw_text
|
||||||
|
|
||||||
# Click handling
|
|
||||||
var idx := i
|
var idx := i
|
||||||
label.gui_input.connect(func(event: InputEvent):
|
label.gui_input.connect(func(event: InputEvent):
|
||||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||||
_on_option_pressed(idx)
|
_on_option_pressed(idx)
|
||||||
)
|
)
|
||||||
# Hover color
|
|
||||||
label.mouse_entered.connect(_make_hover_on(label))
|
label.mouse_entered.connect(_make_hover_on(label))
|
||||||
label.mouse_exited.connect(_make_hover_off(label))
|
label.mouse_exited.connect(_make_hover_off(label))
|
||||||
|
|
||||||
@@ -133,56 +306,6 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
|
|||||||
_option_texts.append(raw_text)
|
_option_texts.append(raw_text)
|
||||||
_option_is_confrontation.append(is_confrontation)
|
_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:
|
func _on_option_pressed(index: int) -> void:
|
||||||
if index >= _option_controls.size():
|
if index >= _option_controls.size():
|
||||||
@@ -191,46 +314,40 @@ func _on_option_pressed(index: int) -> void:
|
|||||||
var text: String = _option_texts[index] if index < _option_texts.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
|
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:
|
if is_confront:
|
||||||
_start_confrontation_beat(rid, text)
|
_start_confrontation_beat(rid, text)
|
||||||
else:
|
else:
|
||||||
option_selected.emit(rid, text)
|
option_selected.emit(rid, text)
|
||||||
hide_dialogue()
|
_end_player_conversation()
|
||||||
|
|
||||||
|
|
||||||
# D-063: Confrontation beat — delay before sending response.
|
# -- Confrontation beat (D-063) --
|
||||||
# 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:
|
func _start_confrontation_beat(response_id: String, text: String) -> void:
|
||||||
# Disable option clicks during beat
|
|
||||||
for ctrl in _option_controls:
|
for ctrl in _option_controls:
|
||||||
if is_instance_valid(ctrl):
|
if is_instance_valid(ctrl):
|
||||||
ctrl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
ctrl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
|
||||||
# Dim dialogue box
|
|
||||||
if _active_tween and _active_tween.is_valid():
|
if _active_tween and _active_tween.is_valid():
|
||||||
_active_tween.kill()
|
_active_tween.kill()
|
||||||
_active_tween = create_tween()
|
_active_tween = create_tween()
|
||||||
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
|
_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)
|
confrontation_monologue.emit(UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION)
|
||||||
|
|
||||||
# D-063: audio dip via AudioManager
|
|
||||||
AudioManager.apply_dip("confrontation")
|
AudioManager.apply_dip("confrontation")
|
||||||
|
|
||||||
# Delay, then complete
|
|
||||||
_beat_tween = create_tween()
|
_beat_tween = create_tween()
|
||||||
_beat_tween.tween_interval(CONFRONTATION_BEAT_DURATION)
|
_beat_tween.tween_interval(CONFRONTATION_BEAT_DURATION)
|
||||||
_beat_tween.tween_callback(func():
|
_beat_tween.tween_callback(func():
|
||||||
AudioManager.clear_dip()
|
AudioManager.clear_dip()
|
||||||
option_selected.emit(response_id, text)
|
option_selected.emit(response_id, text)
|
||||||
hide_dialogue()
|
_end_player_conversation()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Cancel an in-flight confrontation beat (e.g. player walks away mid-beat).
|
|
||||||
func _cancel_beat() -> void:
|
func _cancel_beat() -> void:
|
||||||
if _beat_tween and _beat_tween.is_valid():
|
if _beat_tween and _beat_tween.is_valid():
|
||||||
_beat_tween.kill()
|
_beat_tween.kill()
|
||||||
@@ -238,6 +355,105 @@ func _cancel_beat() -> void:
|
|||||||
AudioManager.clear_dip()
|
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 age_sec: float = (now - entry.timestamp_msec) / 1000.0
|
||||||
|
var alpha: float = 1.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.
|
||||||
|
func _format_entry(entry: Dictionary, alpha: float) -> String:
|
||||||
|
var speaker_color := _color_for_name(entry.speaker)
|
||||||
|
var target_color := _color_for_name(entry.target)
|
||||||
|
|
||||||
|
# Apply alpha to all colors in this line
|
||||||
|
var sc := _color_with_alpha(speaker_color, alpha)
|
||||||
|
var tc := _color_with_alpha(target_color, alpha)
|
||||||
|
var ac := _color_with_alpha(_arrow_color, alpha)
|
||||||
|
var txc := _color_with_alpha(_speech_color, alpha)
|
||||||
|
|
||||||
|
return "[color=%s][b]%s[/b][/color][color=%s] \u2192 [/color][color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
|
||||||
|
sc, entry.speaker, ac, tc, entry.target, txc, entry.text
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
## Get a stable color for a character name.
|
||||||
|
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 := char_name.hash() % _npc_colors.size()
|
||||||
|
# hash() can return negative in GDScript — use abs
|
||||||
|
if idx < 0:
|
||||||
|
idx = -idx
|
||||||
|
return _npc_colors[idx]
|
||||||
|
|
||||||
|
|
||||||
|
## 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. Hide panel if nothing remains.
|
||||||
|
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 had_entries := _log_entries.size() > 0
|
||||||
|
|
||||||
|
# Remove expired entries from the front (oldest first)
|
||||||
|
while _log_entries.size() > 0:
|
||||||
|
var age: int = now - _log_entries[0].timestamp_msec
|
||||||
|
if age < total_lifetime_msec:
|
||||||
|
break
|
||||||
|
_log_entries.remove_at(0)
|
||||||
|
|
||||||
|
# Rebuild if entries were removed
|
||||||
|
if had_entries and _log_entries.size() == 0:
|
||||||
|
_rebuild_log()
|
||||||
|
if not _in_player_conversation:
|
||||||
|
hide_dialogue()
|
||||||
|
elif had_entries:
|
||||||
|
# Rebuild to update fading entries
|
||||||
|
_rebuild_log()
|
||||||
|
|
||||||
|
|
||||||
|
# -- 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:
|
func _clear_options() -> void:
|
||||||
for ctrl in _option_controls:
|
for ctrl in _option_controls:
|
||||||
if is_instance_valid(ctrl):
|
if is_instance_valid(ctrl):
|
||||||
@@ -248,7 +464,7 @@ func _clear_options() -> void:
|
|||||||
_option_is_confrontation.clear()
|
_option_is_confrontation.clear()
|
||||||
|
|
||||||
|
|
||||||
# Hover callbacks — closures that capture the label reference.
|
# Hover callbacks
|
||||||
static func _make_hover_on(label: RichTextLabel) -> Callable:
|
static func _make_hover_on(label: RichTextLabel) -> Callable:
|
||||||
return func():
|
return func():
|
||||||
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER)
|
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER)
|
||||||
|
|||||||
@@ -37,12 +37,14 @@ theme_override_constants/margin_bottom = 14
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_constants/separation = 10
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
[node name="NpcSpeech" type="RichTextLabel" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
[node name="DialogueLog" type="RichTextLabel" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
bbcode_enabled = true
|
bbcode_enabled = true
|
||||||
text = ""
|
text = ""
|
||||||
fit_content = true
|
fit_content = false
|
||||||
scroll_active = false
|
scroll_active = true
|
||||||
|
scroll_following = true
|
||||||
|
|
||||||
[node name="OptionsContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
[node name="OptionsContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
|||||||
Reference in New Issue
Block a user