Merge remote-tracking branch 'origin/main' into client
# Conflicts: # client/data/dialogue-theme.yaml # client/scripts/main.gd # client/scripts/protocol/protocol.gd # client/scripts/ui/debug_overlay.gd # client/ui/dialogue_box.gd
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://dfy0ye3srawos"
|
||||
path="res://.godot/imported/amb_bar_layer.ogg-8e32a9c679a33f27226744a176d7d405.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/amb_bar_layer.ogg"
|
||||
dest_files=["res://.godot/imported/amb_bar_layer.ogg-8e32a9c679a33f27226744a176d7d405.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://cne005dbmwt6d"
|
||||
path="res://.godot/imported/amb_corridor_layer.ogg-f14ba54010b129b65b0d248444331998.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/amb_corridor_layer.ogg"
|
||||
dest_files=["res://.godot/imported/amb_corridor_layer.ogg-f14ba54010b129b65b0d248444331998.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://b6ycjdvxpphfa"
|
||||
path="res://.godot/imported/amb_station_base.ogg-8056e0ae231edecc9ec0e49bb90136b7.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/amb_station_base.ogg"
|
||||
dest_files=["res://.godot/imported/amb_station_base.ogg-8056e0ae231edecc9ec0e49bb90136b7.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://dw2pokvg5d7v2"
|
||||
path="res://.godot/imported/amb_workplace_layer.ogg-580ba32198a3f6c782381c66f9520e63.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/amb_workplace_layer.ogg"
|
||||
dest_files=["res://.godot/imported/amb_workplace_layer.ogg-580ba32198a3f6c782381c66f9520e63.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://da81bx5y6iw87"
|
||||
path="res://.godot/imported/sfx_footstep_metal_run.ogg-0d448f29204d35d4133f5314a179d054.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/sfx_footstep_metal_run.ogg"
|
||||
dest_files=["res://.godot/imported/sfx_footstep_metal_run.ogg-0d448f29204d35d4133f5314a179d054.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://d14dx3q0qd483"
|
||||
path="res://.godot/imported/sfx_footstep_metal_walk.ogg-af30f14f9f80059ea65ad6e78234cc05.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/sfx_footstep_metal_walk.ogg"
|
||||
dest_files=["res://.godot/imported/sfx_footstep_metal_walk.ogg-af30f14f9f80059ea65ad6e78234cc05.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://caeuztw9ah5h5l"
|
||||
uid="uid://cow7symyvpmal"
|
||||
path="res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
[remap]
|
||||
|
||||
importer="font_data_dynamic"
|
||||
type="FontFile"
|
||||
uid="uid://qbg8rfnetlqn"
|
||||
path="res://.godot/imported/Michroma-Regular.ttf-928de7d8513fc5249047ef3681175fc2.fontdata"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/fonts/Michroma-Regular.ttf"
|
||||
dest_files=["res://.godot/imported/Michroma-Regular.ttf-928de7d8513fc5249047ef3681175fc2.fontdata"]
|
||||
|
||||
[params]
|
||||
|
||||
Rendering=null
|
||||
antialiasing=1
|
||||
generate_mipmaps=false
|
||||
disable_embedded_bitmaps=true
|
||||
multichannel_signed_distance_field=false
|
||||
msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=1
|
||||
subpixel_positioning=4
|
||||
keep_rounding_remainders=true
|
||||
oversampling=0.0
|
||||
Fallbacks=null
|
||||
fallbacks=[]
|
||||
Compress=null
|
||||
compress=true
|
||||
preload=[]
|
||||
language_support={}
|
||||
script_support={}
|
||||
opentype_features={}
|
||||
@@ -43,5 +43,5 @@ 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
|
||||
entry_lifetime_seconds: 45.0
|
||||
entry_fade_seconds: 5.0
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="128" height="128" fill="#1a1a2e"/></svg>
|
||||
|
After Width: | Height: | Size: 119 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://caxeq5xayr0iy"
|
||||
path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://icon.svg"
|
||||
dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -44,7 +44,7 @@ const FILTER_CUTOFF_DEFAULT := 20500.0
|
||||
# --- D-073: Zone crossfade constants ---
|
||||
const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range
|
||||
|
||||
# Maps server zone_id strings to ambient asset keys (filenames in res://audio/).
|
||||
# Maps server zone_id strings to ambient asset keys (filenames in res://assets/audio/).
|
||||
# Hub and Workplace intentionally share the same ambient layer (amb_hub_layer) —
|
||||
# they are the same location type, so hub→workplace transition is a same-asset no-op
|
||||
# (old_asset != new_asset guard skips the fade-out). Sprint brief consolidates
|
||||
@@ -112,15 +112,15 @@ func _setup_buses() -> void:
|
||||
# --- Asset registry (D-068 directory-scan pattern) ---
|
||||
|
||||
func _scan_registry() -> void:
|
||||
_scan_dir("res://audio/")
|
||||
_scan_dir("res://assets/audio/")
|
||||
print("AudioManager: %d assets registered" % _registry.size())
|
||||
|
||||
|
||||
func _scan_dir(path: String) -> void:
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
if path == "res://audio/":
|
||||
print("AudioManager: res://audio/ not found — all play methods no-op")
|
||||
if path == "res://assets/audio/":
|
||||
print("AudioManager: res://assets/audio/ not found — all play methods no-op")
|
||||
return
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
@@ -197,7 +197,7 @@ func stop_all_loops() -> void:
|
||||
|
||||
# --- Audio asset registry: event type → asset key (D-018, #125) ---
|
||||
# Maps server-sent sound event_type strings to audio asset keys.
|
||||
# Keys match filename stems in res://audio/ (scanned by _scan_registry).
|
||||
# Keys match filename stems in res://assets/audio/ (scanned by _scan_registry).
|
||||
# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
|
||||
# Missing assets no-op gracefully (D-038 fallback pattern).
|
||||
const SOUND_EVENT_ASSETS: Dictionary = {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://rqfw0ycyb4c6
|
||||
@@ -94,7 +94,7 @@ const FACING_INDICATOR_OFFSET: float = 14.0
|
||||
# 640px = 20 × TILE_SIZE (32px) — grid-aligned, ~33% of 1920px viewport.
|
||||
# Tyre architecture review 2026-02-19: readability over max-width; fits
|
||||
# two columns of text comfortably, leaves world game visible alongside.
|
||||
const DIALOGUE_MAX_WIDTH: int = 640
|
||||
const DIALOGUE_MAX_WIDTH: int = 1200
|
||||
|
||||
# Default camera zoom — used as fallback when get_camera_2d() returns null
|
||||
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
|
||||
|
||||
@@ -250,22 +250,16 @@ func _play_close_sound_events() -> void:
|
||||
# D-067: Recognition chime — fires sfx_monologue_chime when a fog entity
|
||||
# enters the cognitive delay recognition queue for the first time.
|
||||
# "The chime marks the character's attention shifting" (D-067).
|
||||
# Entities that complete recognition (leave pending_recognitions) are removed
|
||||
# from _known_recognition_ids so they can chime again if re-encountered.
|
||||
# IDs persist for the session — one chime per entity, no re-trigger on
|
||||
# fog oscillation or server re-send. Cleared on room change (teleport).
|
||||
func _play_recognition_chimes() -> void:
|
||||
var active_ids: Dictionary = {}
|
||||
for rec in GameState.pending_recognitions:
|
||||
if not rec is Dictionary or not rec.has("entity_id"):
|
||||
continue
|
||||
var eid: int = rec.entity_id
|
||||
active_ids[eid] = true
|
||||
if not _known_recognition_ids.has(eid):
|
||||
_known_recognition_ids[eid] = true
|
||||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||||
# Expire IDs no longer in the recognition queue
|
||||
for eid in _known_recognition_ids.keys():
|
||||
if not active_ids.has(eid):
|
||||
_known_recognition_ids.erase(eid)
|
||||
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id
|
||||
@@ -358,11 +352,16 @@ func _consume_conversation_ended() -> void:
|
||||
|
||||
|
||||
# #535: Consume dialogue_response — NPC follow-up line after player picks an option.
|
||||
# Updates dialogue_box entity display registry with speaker identity from the wire.
|
||||
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", ""))
|
||||
var speaker_entity_id: int = dr.get("speaker_entity_id", _last_dialogue_npc_id)
|
||||
var speaker_color_index: int = dr.get("speaker_color_index", -1)
|
||||
var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name)
|
||||
dialogue_box.update_entity_display(speaker_entity_id, speaker_name, speaker_color_index)
|
||||
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""))
|
||||
GameState.dialogue_response = null
|
||||
|
||||
|
||||
@@ -446,6 +445,7 @@ func _teleport_transition() -> void:
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
_known_recognition_ids.clear() # D-067: reset chimes for new room
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
dialogue_box.hide_dialogue()
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class_name Protocol
|
||||
|
||||
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
||||
## Reject snapshots where version != this value.
|
||||
const PROTOCOL_VERSION: int = 9
|
||||
const PROTOCOL_VERSION: int = 12
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://cb10ir0idsr0g
|
||||
@@ -88,7 +88,7 @@ func _draw() -> void:
|
||||
var header_w := font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x
|
||||
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
|
||||
var box_w: float = max(header_w, content_w) + PADDING.x * 2
|
||||
var line_count := max(left_lines.size(), right_lines.size())
|
||||
var line_count: int = maxi(left_lines.size(), right_lines.size())
|
||||
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
|
||||
|
||||
# Background
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://c55ow14m345vv
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
uid://duvqpvheku2j2
|
||||
@@ -0,0 +1 @@
|
||||
uid://d30by4d62g7ts
|
||||
@@ -0,0 +1 @@
|
||||
uid://b1tata3wjyb46
|
||||
@@ -0,0 +1 @@
|
||||
uid://dhgcekc2ywr26
|
||||
@@ -0,0 +1 @@
|
||||
uid://qv808kduvlbk
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmalit26eiwlh
|
||||
@@ -0,0 +1 @@
|
||||
uid://6q82kiijx7ml
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8ng0ubdfumht
|
||||
@@ -0,0 +1 @@
|
||||
uid://bn4a46egt2uol
|
||||
@@ -0,0 +1 @@
|
||||
uid://tp8wpp6t8tvx
|
||||
@@ -243,6 +243,7 @@ func _close() -> void:
|
||||
_active = false
|
||||
visible = false
|
||||
if _line_edit:
|
||||
_line_edit.release_focus()
|
||||
_line_edit.queue_free()
|
||||
_line_edit = null
|
||||
|
||||
@@ -344,7 +345,7 @@ func _save_report(description: String) -> void:
|
||||
else:
|
||||
push_error("BugReport: failed to write %s" % seed_path)
|
||||
|
||||
push_warning("BugReport: saved %d/6 files to %s (ring: %d ticks)" % [
|
||||
print("BugReport: saved %d/6 files to %s (ring: %d ticks)" % [
|
||||
files_saved, base_path, _input_count])
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://c5xgtnkp0butl
|
||||
+119
-30
@@ -21,12 +21,18 @@ signal unpause_requested # D-061: auto-unpause
|
||||
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
|
||||
|
||||
# -- Log state --
|
||||
# Entry format: {speaker, target, text, is_passive, pinned, timestamp_msec}
|
||||
# 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] = []
|
||||
@@ -45,8 +51,8 @@ var _npc_colors: Array[Color] = []
|
||||
var _arrow_color: Color = Color("#8890a0")
|
||||
var _speech_color: Color = Color("#c8d0e0")
|
||||
var _passive_opacity: float = 0.9
|
||||
var _entry_lifetime: float = 15.0
|
||||
var _entry_fade: float = 3.0
|
||||
var _entry_lifetime: float = 45.0
|
||||
var _entry_fade: float = 5.0
|
||||
|
||||
const THEME_PATH: String = "res://data/dialogue-theme.yaml"
|
||||
|
||||
@@ -93,6 +99,18 @@ 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:
|
||||
@@ -153,7 +171,7 @@ func _load_theme() -> void:
|
||||
func _update_layout() -> void:
|
||||
var vp := get_viewport_rect().size
|
||||
var max_h := vp.y * MAX_HEIGHT_RATIO
|
||||
var w := minf(MAX_WIDTH_PX, vp.x * 0.65)
|
||||
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
|
||||
@@ -180,13 +198,59 @@ func append_line(speaker: String, target: String, text: String, is_passive: bool
|
||||
|
||||
|
||||
## 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 speaker: String = event.get("speaker_name", "?")
|
||||
var target: String = event.get("target_name", "?")
|
||||
var text: String = event.get("occluded_line", "")
|
||||
if text.is_empty():
|
||||
return
|
||||
append_line(speaker, target, text, true)
|
||||
|
||||
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).
|
||||
@@ -234,7 +298,8 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
|
||||
|
||||
|
||||
## End active player conversation — clears options but preserves log.
|
||||
## D-064: dialogue_active held until fade completes.
|
||||
## 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()
|
||||
@@ -254,9 +319,10 @@ func _end_player_conversation() -> void:
|
||||
# D-061: unpause — signal to main.gd for input recording (#507)
|
||||
unpause_requested.emit()
|
||||
|
||||
# D-064: dialogue_active stays true — cleared after fade completes in hide callback.
|
||||
# If log entries still exist, panel stays visible and entries expire via timeout.
|
||||
# If no entries, hide immediately with fade.
|
||||
# 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()
|
||||
|
||||
@@ -302,18 +368,15 @@ func _show_options(options: Array) -> void:
|
||||
var raw_text: String = opt.get("text", "")
|
||||
var is_confrontation: bool = opt.get("confrontation", false)
|
||||
|
||||
var label := RichTextLabel.new()
|
||||
label.bbcode_enabled = true
|
||||
label.fit_content = true
|
||||
label.scroll_active = false
|
||||
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.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT)
|
||||
|
||||
if is_confrontation:
|
||||
label.text = "[i]%s[/i]" % raw_text
|
||||
else:
|
||||
label.text = raw_text
|
||||
var numbered_text := "%d. %s" % [i + 1, raw_text]
|
||||
label.text = numbered_text
|
||||
|
||||
var idx := i
|
||||
label.gui_input.connect(func(event: InputEvent):
|
||||
@@ -407,15 +470,42 @@ func _rebuild_log() -> void:
|
||||
## 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 = _escape_bbcode(entry.speaker)
|
||||
var target: String = _escape_bbcode(entry.target)
|
||||
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
|
||||
|
||||
var speaker_color := _color_for_name(entry.speaker)
|
||||
var target_color := _color_for_name(entry.target)
|
||||
|
||||
# Desaturate passive name colours (Araminta review)
|
||||
if is_passive:
|
||||
speaker_color = _desaturate(speaker_color, PASSIVE_DESATURATION)
|
||||
@@ -429,7 +519,6 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
|
||||
var prefix := PASSIVE_GLYPH if is_passive else ""
|
||||
|
||||
# Non-blocking: simplify 1-on-1 player dialogue — no arrow for Speaker → You or You → Speaker
|
||||
var involves_player := entry.speaker == PLAYER_NAME or entry.target == PLAYER_NAME
|
||||
if involves_player and not is_passive:
|
||||
# Just "Speaker: text" or "You: text"
|
||||
return "%s[color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
|
||||
@@ -555,11 +644,11 @@ func _clear_options() -> void:
|
||||
|
||||
|
||||
# Hover callbacks
|
||||
static func _make_hover_on(label: RichTextLabel) -> Callable:
|
||||
static func _make_hover_on(label: Control) -> Callable:
|
||||
return func():
|
||||
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER)
|
||||
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER)
|
||||
|
||||
|
||||
static func _make_hover_off(label: RichTextLabel) -> Callable:
|
||||
static func _make_hover_off(label: Control) -> Callable:
|
||||
return func():
|
||||
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT)
|
||||
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
|
||||
|
||||
@@ -19,10 +19,12 @@ const LABEL_GAP := 2
|
||||
const INSERT_FG := Constants.IMPLANT_TEXT_COLOR
|
||||
const INSERT_DIM := Constants.IMPLANT_TEXT_DIM
|
||||
const INSERT_BG := Color(0.05, 0.05, 0.08, 0.7)
|
||||
const ENTITY_OFFSET := Vector2(0, -12) # nudge above entity sprite center
|
||||
|
||||
var _showing: bool = false
|
||||
var _insert_active: bool = true
|
||||
var _current_target_id: int = -1
|
||||
var _entity_world_pos: Vector2 = Vector2.ZERO # cached world tile position of target
|
||||
var _verb_items: Array = [] # sorted [{kind, label, priority, available}]
|
||||
var _selected_index: int = 0
|
||||
var _active_tween: Tween = null
|
||||
@@ -37,6 +39,21 @@ func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _showing:
|
||||
_update_screen_position()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not _showing or _verb_labels.is_empty():
|
||||
return
|
||||
var pad := 6.0
|
||||
var bg_rect := Rect2(-pad, -pad, size.x + pad * 2, size.y + pad * 2)
|
||||
draw_rect(bg_rect, INSERT_BG)
|
||||
draw_rect(bg_rect, Constants.IMPLANT_TEXT_DIM * Color(1, 1, 1, 0.3), false, 1.0)
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
# D-055: Sprint stance suppresses interaction list
|
||||
if GameState.player_stance == "Sprint":
|
||||
@@ -70,6 +87,7 @@ func update_from_state() -> void:
|
||||
_current_target_id = entity_id
|
||||
_verb_items = sorted
|
||||
_selected_index = 0
|
||||
_cache_entity_position()
|
||||
_rebuild_labels()
|
||||
_show()
|
||||
|
||||
@@ -84,15 +102,46 @@ func _rebuild_labels() -> void:
|
||||
for i in range(_verb_items.size()):
|
||||
var verb: Dictionary = _verb_items[i]
|
||||
var lbl := Label.new()
|
||||
lbl.text = verb.get("label", "")
|
||||
lbl.text = " %s " % verb.get("label", "")
|
||||
lbl.add_theme_font_size_override("font_size", 14)
|
||||
lbl.add_theme_color_override("font_color", INSERT_FG if i == _selected_index else INSERT_DIM)
|
||||
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
lbl.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
lbl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
var idx := i
|
||||
lbl.gui_input.connect(func(event: InputEvent):
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
_select_and_interact(idx)
|
||||
)
|
||||
lbl.mouse_entered.connect(func(): _hover_index(idx))
|
||||
lbl.mouse_exited.connect(func(): _unhover_index(idx))
|
||||
_vbox.add_child(lbl)
|
||||
_verb_labels.append(lbl)
|
||||
|
||||
|
||||
## Cache the target entity's world tile position from GameState.visible_entities.
|
||||
func _cache_entity_position() -> void:
|
||||
for entity in GameState.visible_entities:
|
||||
if entity.get("entity_id") == _current_target_id:
|
||||
_entity_world_pos = Vector2(entity.x, entity.y)
|
||||
return
|
||||
|
||||
|
||||
## Convert entity world position to screen coords and reposition this Control.
|
||||
## Runs every frame while showing so the list tracks the entity as the camera moves.
|
||||
func _update_screen_position() -> void:
|
||||
var camera := get_viewport().get_camera_2d()
|
||||
if camera == null:
|
||||
return
|
||||
var viewport_size := get_viewport_rect().size
|
||||
var cam_center := camera.get_screen_center_position()
|
||||
var zoom: Vector2 = camera.zoom if camera.zoom.length_squared() > 0.01 else Constants.CAMERA_DEFAULT_ZOOM
|
||||
var world_px := _entity_world_pos * Constants.TILE_SIZE
|
||||
var screen_pos := (world_px - cam_center) * zoom + viewport_size / 2.0
|
||||
# Anchor above the entity, centered horizontally
|
||||
position = screen_pos + ENTITY_OFFSET * zoom - Vector2(size.x / 2.0, size.y)
|
||||
|
||||
|
||||
func _show() -> void:
|
||||
if _showing:
|
||||
return
|
||||
@@ -161,6 +210,29 @@ func get_verb_labels() -> Array:
|
||||
return labels
|
||||
|
||||
|
||||
func _hover_index(idx: int) -> void:
|
||||
_selected_index = idx
|
||||
_update_label_colors()
|
||||
|
||||
|
||||
func _unhover_index(_idx: int) -> void:
|
||||
pass # keep last hover highlighted
|
||||
|
||||
|
||||
func _select_and_interact(idx: int) -> void:
|
||||
if idx < 0 or idx >= _verb_items.size():
|
||||
return
|
||||
_selected_index = idx
|
||||
verb_selected.emit(_verb_items[idx].get("kind", ""), _current_target_id)
|
||||
|
||||
|
||||
func _update_label_colors() -> void:
|
||||
for i in range(_verb_labels.size()):
|
||||
if is_instance_valid(_verb_labels[i]):
|
||||
_verb_labels[i].add_theme_color_override(
|
||||
"font_color", INSERT_FG if i == _selected_index else INSERT_DIM)
|
||||
|
||||
|
||||
func set_insert_active(active: bool) -> void:
|
||||
_insert_active = active
|
||||
if not active and _showing:
|
||||
|
||||
@@ -94,7 +94,7 @@ func _show_line(text: String, duration: float, priority: int, is_urgent: bool, l
|
||||
node = line_node,
|
||||
expire_timer = maxf(duration, MIN_DURATION), # clamp: survives own fade-in
|
||||
priority = priority,
|
||||
tween = null as Tween,
|
||||
tween = null,
|
||||
}
|
||||
_visible.append(slot)
|
||||
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://cpjq8yfsnpr5m
|
||||
@@ -5,7 +5,7 @@
|
||||
; D-058: World radial menu — right-click, 2 spokes (Observe + Insert)
|
||||
[node name="WorldRadial" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchors_preset = 0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
|
||||
Reference in New Issue
Block a user