feat(ui): bind dialogue speaker colors to entity identity (#573)

Maintains Dict[entity_id → Color] in dialogue_box for player conversations.
On first encounter, assigns a round-robin palette color; reuses on subsequent lines.
Eliminates position-based name-hash coloring for player dialogue.

Changes:
- Add _npc_entity_colors dict, _npc_entity_id, _next_npc_color fields
- Add _assign_npc_color(entity_id) — registers palette color on first encounter
- show_dialogue: accept npc_entity_id param, register entity color
- append_line: optional speaker_entity_id/target_entity_id stored in log entries
- append_player_line: pass _npc_entity_id as target_entity_id
- append_dialogue_response: accept entity_id, register, pass to append_line
- _format_entry else branch: look up _npc_entity_colors before name-hash fallback
- main.gd: pass _last_dialogue_npc_id to show_dialogue, speaker_entity_id to append_dialogue_response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 02:05:15 +01:00
co-authored by Claude Sonnet 4.6
parent 87b5cbb6c2
commit 974bb2b28e
2 changed files with 56 additions and 12 deletions
+3 -2
View File
@@ -351,7 +351,8 @@ func _consume_dialogue() -> void:
dialogue_box.show_dialogue(
dlg.get("npc_name", ""),
dlg.get("speech", ""),
dlg.get("options", [])
dlg.get("options", []),
_last_dialogue_npc_id
)
GameState.current_dialogue = null
@@ -385,7 +386,7 @@ func _consume_dialogue_response() -> void:
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", ""))
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""), speaker_entity_id)
GameState.dialogue_response = null
+53 -10
View File
@@ -45,6 +45,13 @@ var _option_texts: Array[String] = []
var _option_is_confrontation: Array[bool] = []
var _npc_name: String = ""
# -- Entity color registry (#573) --
# Maps entity_id → Color for dialogue participants.
# Assigned from _npc_colors palette on first encounter; player uses _player_color.
var _npc_entity_colors: Dictionary = {} # entity_id -> Color
var _npc_entity_id: int = -1 # Entity ID of the current player conversation NPC
var _next_npc_color: int = 0 # Round-robin palette index for client-side assignment
# -- UI state --
var _active_tween: Tween = null
var _beat_tween: Tween = null # D-063: confrontation beat delay
@@ -186,16 +193,22 @@ func _update_layout() -> void:
## 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:
## speaker_entity_id/target_entity_id: optional entity IDs for stable color lookup (#573).
func append_line(speaker: String, target: String, text: String, is_passive: bool = false, speaker_entity_id: int = -1, target_entity_id: int = -1) -> void:
var pinned := not is_passive and _in_player_conversation
_log_entries.append({
var entry: Dictionary = {
"speaker": speaker,
"target": target,
"text": text,
"is_passive": is_passive,
"pinned": pinned,
"timestamp_msec": Time.get_ticks_msec(),
})
}
if speaker_entity_id >= 0:
entry["speaker_entity_id"] = speaker_entity_id
if target_entity_id >= 0:
entry["target_entity_id"] = target_entity_id
_log_entries.append(entry)
_log_dirty = true
_ensure_visible()
@@ -263,26 +276,32 @@ func on_conversation_ended(_event: Dictionary) -> void:
## 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_line(PLAYER_NAME, target_npc, text, false, -1, _npc_entity_id)
## 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)
func append_dialogue_response(npc_name: String, text: String, entity_id: int = -1) -> void:
if entity_id >= 0:
_assign_npc_color(entity_id)
append_line(npc_name, PLAYER_NAME, text, false, entity_id, -1)
# -- Active player conversation --
## Show dialogue with NPC speech and response options.
## npc_name: who is speaking. speech: the NPC's line. options: player choices.
func show_dialogue(npc_name: String, speech: String, options: Array = []) -> void:
## npc_entity_id: entity ID of the NPC for stable color assignment (#573).
func show_dialogue(npc_name: String, speech: String, options: Array = [], npc_entity_id: int = -1) -> void:
_npc_name = npc_name
_npc_entity_id = npc_entity_id
_cancel_beat()
_in_player_conversation = true
if npc_entity_id >= 0:
_assign_npc_color(npc_entity_id)
# Append NPC's line to the log
if not speech.is_empty():
append_line(npc_name, PLAYER_NAME, speech, false)
append_line(npc_name, PLAYER_NAME, speech, false, npc_entity_id, -1)
# Clear old options and show new ones
_clear_options()
@@ -509,8 +528,17 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
# 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", "?"))
# #573: use entity-ID-bound color if available; fall back to name-hash
var sp_eid: int = entry.get("speaker_entity_id", -1)
var tg_eid: int = entry.get("target_entity_id", -1)
if sp_eid >= 0 and _npc_entity_colors.has(sp_eid):
speaker_color = _npc_entity_colors[sp_eid]
else:
speaker_color = _color_for_name(entry.get("speaker", "?"))
if tg_eid >= 0 and _npc_entity_colors.has(tg_eid):
target_color = _npc_entity_colors[tg_eid]
else:
target_color = _color_for_name(entry.get("target", "?"))
involves_player = (entry.get("speaker", "") == PLAYER_NAME) or (entry.get("target", "") == PLAYER_NAME)
var text: String = _escape_bbcode(entry.text)
@@ -546,6 +574,21 @@ static func _escape_bbcode(text: String) -> String:
return text.replace("[", "[lb]").replace("]", "[rb]")
## Assign a palette color to an NPC entity ID on first encounter (#573).
## Returns the same color on subsequent calls for the same entity ID.
func _assign_npc_color(entity_id: int) -> Color:
if entity_id < 0:
return _speech_color
if _npc_entity_colors.has(entity_id):
return _npc_entity_colors[entity_id]
if _npc_colors.is_empty():
return _speech_color
var color := _enforce_contrast(_npc_colors[_next_npc_color % _npc_colors.size()])
_next_npc_color += 1
_npc_entity_colors[entity_id] = color
return color
## Get a stable color for a character name, with contrast floor enforcement.
func _color_for_name(char_name: String) -> Color:
if char_name == PLAYER_NAME: