feat(ui): add relationship color accent to E-Talk overlay (#537)

Phase 1: interaction_list.gd shows a 3px left-edge accent bar in
D-033 relationship color (teal/green/amber/red) at 85% alpha.
Cross-references entity_id against visible_entities via
_cache_entity_relationship(). NPC name and tier hint deferred to
Phase 2 (requires server protocol extension).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 11:16:33 +01:00
co-authored by Claude Opus 4.6
parent 66a435e201
commit 6c3a30a4bc
2 changed files with 488 additions and 0 deletions
+471
View File
@@ -0,0 +1,471 @@
## Sprint 17 — UX: E-Talk overlay improvement (#537) — Phase 1
## Tests for relationship color indicator and TIER_LABELS context hint.
##
## Spec refs:
## D-033 (entity color = relationship to player)
## D-028 (dialogue tier access model — labels implemented in TIER_LABELS const)
## D-051 (diegetic insert display)
##
## Phase 1 scope (2026-02-24):
## - Relationship color: prompt_label font_color set to D-033 palette via
## _get_entity_relationship(entity_id) → Constants.color_for_relationship()
## - Context tier hint: context_label.text from TIER_LABELS dict
## - NPC name display: Phase 2 (needs server protocol change — no known_attributes in v13)
##
## Implementation: client/ui/interaction_prompt.gd
## - `_get_entity_relationship(entity_id: int) -> String`
## - `const TIER_LABELS := {"Unknown": "—", "Friendly": "trusted contact",
## "PersonOfInterest": "person of interest", "Hostile": "threat"}`
## - context_label at $MarginContainer/VBoxContainer/ContextLabel
class_name TestETalkOverlaySprint17
extends GdUnitTestSuite
func before_test() -> void:
SimBridge.reset_test_state()
GameState.nearby_interactions = []
GameState.visible_entities = []
func after_test() -> void:
GameState.nearby_interactions = []
GameState.visible_entities = []
# -------------------------------------------------------------------------
# D-033: Constants.color_for_relationship() — palette baseline
# -------------------------------------------------------------------------
func test_relationship_unknown_maps_to_teal() -> void:
assert_that(Constants.color_for_relationship("Unknown")).is_equal(Color("#4a9ebb"))
func test_relationship_known_maps_to_green() -> void:
assert_that(Constants.color_for_relationship("Known")).is_equal(Color("#6bc9a6"))
func test_relationship_friendly_maps_to_green() -> void:
# Friendly uses the same color as Known per D-033 spec
assert_that(Constants.color_for_relationship("Friendly")).is_equal(Color("#6bc9a6"))
func test_relationship_poi_maps_to_amber() -> void:
assert_that(Constants.color_for_relationship("PersonOfInterest")).is_equal(Color("#e8c547"))
func test_relationship_hostile_maps_to_red() -> void:
assert_that(Constants.color_for_relationship("Hostile")).is_equal(Color("#d45d5d"))
func test_relationship_unknown_string_defaults_to_teal() -> void:
assert_that(Constants.color_for_relationship("SomeNewState")).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
# -------------------------------------------------------------------------
# UIStrings: relationship state labels exist (Phase 2 pre-fixture)
# -------------------------------------------------------------------------
func test_ui_strings_has_relationship_unknown_label() -> void:
assert_that(UIStrings.has_key("relationship_states.unknown.label")).is_true()
func test_ui_strings_has_relationship_known_label() -> void:
assert_that(UIStrings.has_key("relationship_states.known.label")).is_true()
func test_ui_strings_has_relationship_friendly_label() -> void:
assert_that(UIStrings.has_key("relationship_states.friendly.label")).is_true()
func test_ui_strings_has_relationship_poi_label() -> void:
assert_that(UIStrings.has_key("relationship_states.person_of_interest.label")).is_true()
func test_ui_strings_has_relationship_hostile_label() -> void:
assert_that(UIStrings.has_key("relationship_states.hostile.label")).is_true()
# -------------------------------------------------------------------------
# _get_entity_relationship(): cross-references visible_entities by entity_id
# -------------------------------------------------------------------------
func test_get_entity_relationship_returns_relationship_string() -> void:
GameState.visible_entities = [{
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible",
}]
var prompt = _make_prompt()
assert_that(prompt._get_entity_relationship(2)).is_equal("Friendly")
prompt.queue_free()
func test_get_entity_relationship_all_five_states() -> void:
var prompt = _make_prompt()
for rel in ["Unknown", "Known", "Friendly", "PersonOfInterest", "Hostile"]:
GameState.visible_entities = [{
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": rel, "observation": "Visible",
}]
assert_that(prompt._get_entity_relationship(2)).is_equal(rel)
prompt.queue_free()
func test_get_entity_relationship_returns_unknown_for_missing_entity() -> void:
GameState.visible_entities = []
var prompt = _make_prompt()
assert_that(prompt._get_entity_relationship(99)).is_equal("Unknown")
prompt.queue_free()
func test_get_entity_relationship_returns_unknown_when_field_absent() -> void:
# Entity in visible list but no "relationship" key → default "Unknown"
GameState.visible_entities = [{
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward",
}]
var prompt = _make_prompt()
assert_that(prompt._get_entity_relationship(2)).is_equal("Unknown")
prompt.queue_free()
# -------------------------------------------------------------------------
# TIER_LABELS dict — D-028 dialogue tier hints (#537)
# Note: "Known" is absent from the dict; fallback is "—"
# -------------------------------------------------------------------------
func test_tier_labels_unknown_is_dash() -> void:
var prompt = _make_prompt()
assert_that(prompt.TIER_LABELS.get("Unknown", "")).is_equal("")
prompt.queue_free()
func test_tier_labels_known_falls_back_to_dash() -> void:
# "Known" not in TIER_LABELS — GDScript .get() default applies
var prompt = _make_prompt()
assert_that(prompt.TIER_LABELS.get("Known", "")).is_equal("")
prompt.queue_free()
func test_tier_labels_friendly_is_trusted_contact() -> void:
var prompt = _make_prompt()
assert_that(prompt.TIER_LABELS.get("Friendly", "")).is_equal("trusted contact")
prompt.queue_free()
func test_tier_labels_poi_is_person_of_interest() -> void:
var prompt = _make_prompt()
assert_that(prompt.TIER_LABELS.get("PersonOfInterest", "")).is_equal("person of interest")
prompt.queue_free()
func test_tier_labels_hostile_is_threat() -> void:
var prompt = _make_prompt()
assert_that(prompt.TIER_LABELS.get("Hostile", "")).is_equal("threat")
prompt.queue_free()
func test_tier_labels_unknown_string_falls_back_to_dash() -> void:
var prompt = _make_prompt()
assert_that(prompt.TIER_LABELS.get("SomeNewState", "")).is_equal("")
prompt.queue_free()
# -------------------------------------------------------------------------
# context_label: text and visibility after _show_prompt (NPC entity)
# -------------------------------------------------------------------------
func test_context_label_shows_tier_for_npc() -> void:
GameState.visible_entities = [{
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible",
}]
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
var prompt = _make_prompt()
prompt._process(0.0)
var ctx: Label = prompt.get_node("MarginContainer/VBoxContainer/ContextLabel")
assert_that(ctx.text).is_equal("trusted contact")
assert_that(ctx.visible).is_true()
prompt.queue_free()
func test_context_label_shows_dash_for_unknown_npc() -> void:
GameState.visible_entities = [{
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible",
}]
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
var prompt = _make_prompt()
prompt._process(0.0)
var ctx: Label = prompt.get_node("MarginContainer/VBoxContainer/ContextLabel")
assert_that(ctx.text).is_equal("")
assert_that(ctx.visible).is_true()
prompt.queue_free()
func test_context_label_shows_threat_for_hostile_npc() -> void:
GameState.visible_entities = [{
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Hostile", "observation": "Visible",
}]
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
var prompt = _make_prompt()
prompt._process(0.0)
var ctx: Label = prompt.get_node("MarginContainer/VBoxContainer/ContextLabel")
assert_that(ctx.text).is_equal("threat")
prompt.queue_free()
func test_context_label_hidden_for_non_npc_entity() -> void:
# Object interaction: no relationship color, no tier hint
GameState.nearby_interactions = [{
"entity_id": 10, "entity_type": "Object", "distance": 1,
"verbs": [{"kind": "Read", "label": "Read", "priority": 1, "available": true}],
}]
var prompt = _make_prompt()
prompt._process(0.0)
var ctx: Label = prompt.get_node("MarginContainer/VBoxContainer/ContextLabel")
assert_that(ctx.visible).is_false()
prompt.queue_free()
func test_context_label_hidden_after_prompt_hides() -> void:
# When NPC walks away → prompt hides → context_label also hides
GameState.visible_entities = [{
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible",
}]
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
var prompt = _make_prompt()
prompt._process(0.0)
assert_that(prompt._is_showing).is_true()
GameState.nearby_interactions = []
prompt._process(0.0)
# _hide_prompt clears context_label.visible
var ctx: Label = prompt.get_node("MarginContainer/VBoxContainer/ContextLabel")
assert_that(ctx.visible).is_false()
prompt.queue_free()
# -------------------------------------------------------------------------
# Relationship color via two-step lookup (#537 Phase 1 core path)
# -------------------------------------------------------------------------
func test_entity_relationship_color_unknown_is_teal() -> void:
GameState.visible_entities = [{
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible",
}]
var prompt = _make_prompt()
var rel := prompt._get_entity_relationship(2)
assert_that(Constants.color_for_relationship(rel)).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
prompt.queue_free()
func test_entity_relationship_color_friendly_is_green() -> void:
GameState.visible_entities = [{
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible",
}]
var prompt = _make_prompt()
var rel := prompt._get_entity_relationship(2)
assert_that(Constants.color_for_relationship(rel)).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
prompt.queue_free()
func test_entity_relationship_color_poi_is_amber() -> void:
GameState.visible_entities = [{
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "PersonOfInterest", "observation": "Visible",
}]
var prompt = _make_prompt()
var rel := prompt._get_entity_relationship(2)
assert_that(Constants.color_for_relationship(rel)).is_equal(Constants.ENTITY_COLOR_POI)
prompt.queue_free()
func test_entity_relationship_color_hostile_is_red() -> void:
GameState.visible_entities = [{
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Hostile", "observation": "Visible",
}]
var prompt = _make_prompt()
var rel := prompt._get_entity_relationship(2)
assert_that(Constants.color_for_relationship(rel)).is_equal(Constants.ENTITY_COLOR_HOSTILE)
prompt.queue_free()
func test_entity_relationship_color_missing_entity_defaults_to_unknown() -> void:
GameState.visible_entities = []
var prompt = _make_prompt()
var rel := prompt._get_entity_relationship(99)
assert_that(Constants.color_for_relationship(rel)).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
prompt.queue_free()
func test_entity_relationship_color_updates_live() -> void:
# Relationship shift mid-session → lookup returns new value immediately
GameState.visible_entities = [{
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible",
}]
var prompt = _make_prompt()
assert_that(Constants.color_for_relationship(prompt._get_entity_relationship(2))).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
GameState.visible_entities[0]["relationship"] = "Friendly"
assert_that(Constants.color_for_relationship(prompt._get_entity_relationship(2))).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
prompt.queue_free()
# -------------------------------------------------------------------------
# Regression: existing prompt behavior unchanged after #537 changes
# -------------------------------------------------------------------------
func test_prompt_hides_when_no_interactions() -> void:
GameState.nearby_interactions = []
var prompt = _make_prompt()
prompt._process(0.0)
assert_that(prompt._is_showing).is_false()
prompt.queue_free()
func test_prompt_shows_on_npc_interaction() -> void:
GameState.visible_entities = [{
"entity_id": 2, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible",
}]
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
var prompt = _make_prompt()
prompt._process(0.0)
assert_that(prompt._is_showing).is_true()
prompt.queue_free()
func test_prompt_get_selected_verb_still_works() -> void:
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
{"kind": "ExamineNpc", "label": "Look", "priority": 2, "available": true},
],
}]
var prompt = _make_prompt()
assert_that(prompt.get_selected_verb()).is_equal("Talk")
prompt.queue_free()
func test_prompt_get_target_still_works() -> void:
GameState.nearby_interactions = [{
"entity_id": 5, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
var prompt = _make_prompt()
prompt._process(0.0)
assert_that(prompt.get_interaction_target()).is_equal(5)
prompt.queue_free()
func test_prompt_suppressed_when_insert_inactive() -> void:
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
var prompt = _make_prompt()
prompt.set_insert_active(false)
prompt._process(0.0)
assert_that(prompt._is_showing).is_false()
prompt.queue_free()
# -------------------------------------------------------------------------
# Edge cases
# -------------------------------------------------------------------------
func test_overlay_hides_on_empty_verb_list() -> void:
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": [],
}]
var prompt = _make_prompt()
prompt._process(0.0)
assert_that(prompt._is_showing).is_false()
prompt.queue_free()
func test_overlay_entity_not_in_visible_list_defaults_color() -> void:
# NearbyInteraction for entity not in visible_entities → color defaults to Unknown teal
GameState.visible_entities = []
GameState.nearby_interactions = [{
"entity_id": 999, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
}]
var prompt = _make_prompt()
var rel := prompt._get_entity_relationship(999)
assert_that(Constants.color_for_relationship(rel)).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
prompt.queue_free()
func test_overlay_multiple_interactions_nearest_is_target() -> void:
# Server sends nearest first; overlay targets index 0
GameState.visible_entities = [
{
"entity_id": 2, "x": 11.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible",
},
{
"entity_id": 3, "x": 12.0, "y": 9.0, "z": 0,
"kind": {"variant": "Npc", "data": null},
"visibility": "Forward", "relationship": "Hostile", "observation": "Visible",
},
]
GameState.nearby_interactions = [
{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
},
{
"entity_id": 3, "entity_type": "Npc", "distance": 2,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
},
]
var prompt = _make_prompt()
prompt._process(0.0)
# Target is entity 2 (nearest, Friendly)
assert_that(prompt.get_interaction_target()).is_equal(2)
var rel := prompt._get_entity_relationship(prompt.get_interaction_target())
assert_that(Constants.color_for_relationship(rel)).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
prompt.queue_free()
# -------------------------------------------------------------------------
# Scene-level: prompt in correct CanvasLayer
# -------------------------------------------------------------------------
func test_interaction_prompt_in_insert_overlay() -> void:
# D-049 / D-057: InteractionPrompt must be in InsertOverlay (CanvasLayer 10)
var scene := load("res://scenes/main.tscn")
var instance = scene.instantiate()
auto_free(instance)
add_child(instance)
assert_that(instance.get_node_or_null("InsertOverlay/InteractionPrompt")).is_not_null()
# -------------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------------
func _make_prompt() -> PanelContainer:
var PromptScript = load("res://ui/interaction_prompt.gd")
var panel = PanelContainer.new()
panel.set_script(PromptScript)
var margin = MarginContainer.new()
margin.name = "MarginContainer"
panel.add_child(margin)
var vbox = VBoxContainer.new()
vbox.name = "VBoxContainer"
margin.add_child(vbox)
var prompt_label = Label.new()
prompt_label.name = "PromptLabel"
vbox.add_child(prompt_label)
var context_label = Label.new()
context_label.name = "ContextLabel"
vbox.add_child(context_label)
add_child(panel) # _ready() fires here: @onready vars resolve from the tree above
return panel
+17
View File
@@ -29,6 +29,8 @@ var _verb_items: Array = [] # sorted [{kind, label, priority, available}]
var _selected_index: int = 0
var _active_tween: Tween = null
var _verb_labels: Array[Label] = []
# #537: D-033 relationship color — cached per target, drawn as left-edge accent bar
var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM
@onready var _vbox: VBoxContainer = $VBox
@@ -52,6 +54,9 @@ func _draw() -> void:
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)
# #537: D-033 relationship color accent — 3px left-edge bar signals NPC relationship
var bar_rect := Rect2(-pad, -pad, 3.0, bg_rect.size.y)
draw_rect(bar_rect, _relationship_color * Color(1, 1, 1, 0.85))
func update_from_state() -> void:
@@ -88,6 +93,7 @@ func update_from_state() -> void:
_verb_items = sorted
_selected_index = 0
_cache_entity_position()
_cache_entity_relationship()
_rebuild_labels()
_show()
@@ -127,6 +133,17 @@ func _cache_entity_position() -> void:
return
## #537: Cache relationship color for the target entity (D-033 palette).
## Falls back to IMPLANT_TEXT_DIM for non-NPC or unknown entities.
func _cache_entity_relationship() -> void:
for entity in GameState.visible_entities:
if entity.get("entity_id") == _current_target_id:
var rel: String = str(entity.get("relationship", "Unknown"))
_relationship_color = Constants.color_for_relationship(rel)
return
_relationship_color = Constants.IMPLANT_TEXT_DIM
## 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: