feat(client): add interaction prompt system (#405)

Server-driven interaction prompt that displays "E - Talk" when near
an interactable NPC. Decodes v4 nearby_interactions from snapshot,
stores in GameState, renders via InteractionPrompt UI with fade
animation. Extensible interface (get_interaction_target/get_selected_verb)
for future radial verb menu (v0.2).

- Protocol: decode nearby_interactions array with nested VerbOption structs,
  entity relationship/observation fields, tick_rate in GameTime
- GameState: store/clear nearby_interactions per snapshot
- SimBridge: test mode generates v4 format with structured verbs
- InteractionPrompt: PanelContainer with fade in/out, polls GameState
- Tests: 19 new test cases covering protocol, state, sim bridge, UI, encoding
- Fixture assertions updated for v4 protocol version

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 23:43:37 +01:00
co-authored by Claude Opus 4.6
parent c970d2eba7
commit 6bebbd9933
17 changed files with 447 additions and 8 deletions
+4 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=9 format=3 uid="uid://bswrmh7w8dbgm"]
[gd_scene load_steps=10 format=3 uid="uid://bswrmh7w8dbgm"]
[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"]
@@ -8,6 +8,7 @@
[ext_resource type="PackedScene" path="res://ui/hud.tscn" id="6_hud"]
[ext_resource type="PackedScene" path="res://ui/minimap.tscn" id="7_minimap"]
[ext_resource type="PackedScene" path="res://ui/monologue_display.tscn" id="8_monologue"]
[ext_resource type="PackedScene" path="res://ui/interaction_prompt.tscn" id="9_prompt"]
[node name="Game" type="Node2D"]
script = ExtResource("1_main")
@@ -36,3 +37,5 @@ zoom = Vector2(2, 2)
[node name="Minimap" parent="UILayer" instance=ExtResource("7_minimap")]
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")]
[node name="InteractionPrompt" parent="UILayer" instance=ExtResource("9_prompt")]
+9
View File
@@ -19,6 +19,9 @@ var visibility_sectors: Dictionary = {} # Vector2i -> "Forward"/"Peripheral"
# refined when the server assigns explicit player entity IDs).
var player_entity_id: int = 1
# v4 fields (#404/#405)
var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]}]
func apply_snapshot(snapshot: Dictionary) -> void:
current_snapshot = snapshot
@@ -54,6 +57,12 @@ func apply_snapshot(snapshot: Dictionary) -> void:
if snapshot.has("player_facing") and snapshot.player_facing is String:
player_facing = snapshot.player_facing
# v4: nearby_interactions (#404/#405)
if snapshot.has("nearby_interactions") and snapshot.nearby_interactions is Array:
nearby_interactions = snapshot.nearby_interactions
else:
nearby_interactions = []
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
+16 -2
View File
@@ -294,20 +294,34 @@ func _test_snapshot() -> Dictionary:
"visibility": sector,
})
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
var nearby: Array = []
if npc_dist <= 2 and _test_has_los(Vector2i(px, py), npc_pos):
nearby.append({
"entity_id": 2,
"entity_type": "Npc",
"distance": npc_dist,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 0, "available": true},
{"kind": "ExamineNpc", "label": "Examine", "priority": 1, "available": true},
],
})
return {
"tick": _test_tick,
"version": 2,
"version": 4,
"game_time": {
"day": 0,
"time_of_day": _test_tick * 10,
"day_phase": "Morning",
"paused": false,
"tick_rate": "Full",
},
"player_facing": _test_facing,
"entities": entities,
"tiles": _test_tiles(),
"visible_tiles": _test_visible_tiles(),
"visible_positions": _test_visible_positions(),
"nearby_interactions": nearby,
}
# Generate a small test room: 8x6 room with walls, a door, and floor
+1
View File
@@ -0,0 +1 @@
uid://wmbu7ivynu5d
+9
View File
@@ -4,6 +4,7 @@ extends Node2D
@onready var camera = $Camera2D
@onready var hud = $UILayer/HUD
@onready var monologue_display = $UILayer/MonologueDisplay
@onready var interaction_prompt = $UILayer/InteractionPrompt
func _ready() -> void:
print("The Settled Reach — client initialized")
@@ -28,4 +29,12 @@ func _process(_delta: float) -> void:
# Send queued input to simulation
var inputs = InputMapper.flush_queue()
for input in inputs:
# TODO: Once server accepts Interact(InteractData), attach target + verb:
# if input.action == InputMapper.Action.INTERACT:
# var target_id: int = interaction_prompt.get_interaction_target()
# if target_id >= 0:
# input["action_data"] = {
# "target_entity_id": target_id,
# "verb": interaction_prompt.get_selected_verb(),
# }
SimBridge.send_input(input)
+71
View File
@@ -70,6 +70,15 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
tile_entry["visibility"] = vis
visible_tiles.append(tile_entry)
# v4: nearby_interactions (#404/#405)
var nearby_interactions: Array = []
var raw_interactions: Variant = raw.get("nearby_interactions")
if raw_interactions is Array:
for raw_ni in raw_interactions:
var ni = _decode_nearby_interaction(raw_ni)
if ni != null:
nearby_interactions.append(ni)
return {
"tick": tick,
"entities": entities,
@@ -78,6 +87,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"game_time": game_time,
"player_facing": player_facing,
"visible_tiles": visible_tiles,
"nearby_interactions": nearby_interactions,
}
@@ -96,6 +106,19 @@ static func _decode_entity(raw: Dictionary) -> Variant:
if raw_vis is String:
visibility = raw_vis
# v4: relationship (D-033) and observation state
var relationship: String = "Unknown"
var raw_rel: Variant = raw.get("relationship")
if raw_rel is String:
relationship = raw_rel
var observation: Variant = "Visible"
var raw_obs: Variant = raw.get("observation")
if raw_obs is String:
observation = raw_obs
elif raw_obs is Dictionary:
observation = _decode_enum_variant(raw_obs)
return {
"entity_id": entity_id,
"x": float(raw["x"]),
@@ -103,6 +126,54 @@ static func _decode_entity(raw: Dictionary) -> Variant:
"z": int(raw["z"]),
"kind": _decode_enum_variant(raw["kind"]),
"visibility": visibility,
"relationship": relationship,
"observation": observation,
}
## Decode a NearbyInteraction from a raw msgpack map.
## Returns {entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]} or null.
static func _decode_nearby_interaction(raw) -> Variant:
if not raw is Dictionary:
return null
if not raw.has("entity_id") or not raw.has("verbs"):
push_warning("Protocol: nearby_interaction missing required fields: %s" % str(raw.keys()))
return null
var verbs: Array[Dictionary] = []
var raw_verbs: Variant = raw.get("verbs")
if raw_verbs is Array:
for rv in raw_verbs:
var verb = _decode_verb_option(rv)
if verb != null:
verbs.append(verb)
if verbs.is_empty():
return null
var entity_type: String = ""
var raw_et: Variant = raw.get("entity_type")
if raw_et is String:
entity_type = raw_et
return {
"entity_id": int(raw["entity_id"]),
"entity_type": entity_type,
"distance": int(raw.get("distance", 0)),
"verbs": verbs,
}
## Decode a VerbOption from a raw msgpack map.
## Returns {kind, label, priority, available} or null.
static func _decode_verb_option(raw) -> Variant:
if not raw is Dictionary or not raw.has("label"):
return null
var kind: String = ""
var raw_kind: Variant = raw.get("kind")
if raw_kind is String:
kind = raw_kind
return {
"kind": kind,
"label": str(raw["label"]),
"priority": int(raw.get("priority", 0)),
"available": bool(raw.get("available", true)),
}
@@ -0,0 +1 @@
uid://d2evnkstnqtpb
+1
View File
@@ -0,0 +1 @@
uid://bcny2nvx6b8q3
+219
View File
@@ -0,0 +1,219 @@
class_name TestInteractionPrompt
extends GdUnitTestSuite
# Tests for ticket #405: Interaction prompt system.
# Covers protocol v4 decode, GameState storage, SimBridge test mode,
# InteractionPrompt UI, and input encoding.
# -- Protocol: v4 nearby_interactions decoding --
func test_protocol_decode_v4_with_nearby_interactions() -> void:
var raw := {
"tick": 10,
"version": 4,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Player",
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
],
"nearby_interactions": [{
"entity_id": 2,
"entity_type": "Npc",
"distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 0, "available": true},
{"kind": "ExamineNpc", "label": "Examine", "priority": 1, "available": true},
],
}],
}
var encoded = Messagepack.encode(raw)
assert_that(encoded.status == null).is_true()
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot).is_not_null()
assert_that(snapshot.nearby_interactions.size()).is_equal(1)
var ni = snapshot.nearby_interactions[0]
assert_that(ni.entity_id).is_equal(2)
assert_that(ni.entity_type).is_equal("Npc")
assert_that(ni.distance).is_equal(1)
assert_that(ni.verbs.size()).is_equal(2)
assert_that(ni.verbs[0].kind).is_equal("Talk")
assert_that(ni.verbs[0].label).is_equal("Talk")
assert_that(ni.verbs[0].priority).is_equal(0)
assert_that(ni.verbs[0].available).is_true()
assert_that(ni.verbs[1].kind).is_equal("ExamineNpc")
func test_protocol_decode_v2_no_nearby_interactions() -> void:
var raw := {
"tick": 5,
"version": 2,
"entities": [],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot).is_not_null()
assert_that(snapshot.nearby_interactions.size()).is_equal(0)
func test_protocol_decode_empty_nearby_interactions() -> void:
var raw := {
"tick": 1,
"entities": [],
"nearby_interactions": [],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot.nearby_interactions.size()).is_equal(0)
func test_protocol_decode_interaction_missing_verbs() -> void:
var raw := {
"tick": 1,
"entities": [],
"nearby_interactions": [{"entity_id": 2}],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot.nearby_interactions.size()).is_equal(0)
func test_protocol_decode_interaction_empty_verbs() -> void:
var raw := {
"tick": 1,
"entities": [],
"nearby_interactions": [{"entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": []}],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot.nearby_interactions.size()).is_equal(0)
func test_protocol_decode_v4_entity_relationship() -> void:
var raw := {
"tick": 1,
"version": 4,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Npc",
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible"},
],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot.entities[0].relationship).is_equal("Friendly")
# -- GameState: nearby_interactions storage --
func test_game_state_stores_nearby_interactions() -> void:
var ni := [{"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 0, "available": true}]}]
GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": ni})
assert_that(GameState.nearby_interactions.size()).is_equal(1)
assert_that(GameState.nearby_interactions[0].entity_id).is_equal(2)
GameState.nearby_interactions = []
func test_game_state_clears_nearby_interactions_when_absent() -> void:
var ni := [{"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 0, "available": true}]}]
GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": ni})
assert_that(GameState.nearby_interactions.size()).is_equal(1)
GameState.apply_snapshot({"tick": 2, "entities": []})
assert_that(GameState.nearby_interactions.size()).is_equal(0)
# -- SimBridge test mode: nearby_interactions generation --
func test_sim_bridge_test_snapshot_interaction_when_near_npc() -> void:
SimBridge.reset_test_state()
SimBridge._test_player_pos = Vector2i(11, 9)
var snap = SimBridge._test_snapshot()
assert_that(snap.has("nearby_interactions")).is_true()
assert_that(snap.nearby_interactions.size()).is_equal(1)
assert_that(snap.nearby_interactions[0].entity_id).is_equal(2)
assert_that(snap.nearby_interactions[0].verbs.size()).is_greater(0)
func test_sim_bridge_test_snapshot_no_interaction_when_far() -> void:
SimBridge.reset_test_state()
SimBridge._test_player_pos = Vector2i(10, 10)
var snap = SimBridge._test_snapshot()
assert_that(snap.nearby_interactions.size()).is_equal(0)
func test_sim_bridge_test_snapshot_interaction_at_range_2() -> void:
SimBridge.reset_test_state()
SimBridge._test_player_pos = Vector2i(10, 9)
var snap = SimBridge._test_snapshot()
assert_that(snap.nearby_interactions.size()).is_equal(1)
func test_sim_bridge_test_snapshot_v4_version() -> void:
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.version).is_equal(4)
# -- InteractionPrompt UI --
func test_prompt_get_selected_verb_returns_first_kind() -> void:
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 0, "available": true},
{"kind": "ExamineNpc", "label": "Examine", "priority": 1, "available": true},
],
}]
var prompt = _make_prompt()
assert_that(prompt.get_selected_verb()).is_equal("Talk")
prompt.queue_free()
GameState.nearby_interactions = []
func test_prompt_get_selected_verb_empty_when_no_interactions() -> void:
GameState.nearby_interactions = []
var prompt = _make_prompt()
assert_that(prompt.get_selected_verb()).is_equal("")
prompt.queue_free()
func test_prompt_get_target_negative_when_no_interactions() -> void:
GameState.nearby_interactions = []
var prompt = _make_prompt()
assert_that(prompt.get_interaction_target()).is_equal(-1)
prompt.queue_free()
func test_prompt_hidden_initially() -> void:
GameState.nearby_interactions = []
var prompt = _make_prompt()
assert_that(prompt._is_showing).is_false()
prompt.queue_free()
# -- Input encoding: Interact --
func test_interact_encodes_as_unit_variant() -> void:
var inputs: Array = [{"tick": 100, "action_name": "Interact"}]
var bytes := Protocol.encode_player_inputs(inputs)
var raw = Messagepack.decode(bytes)
assert_that(raw.status == null).is_true()
assert_that(raw.value[0]["action"]).is_equal("Interact")
func test_interact_with_data_encodes_as_data_variant() -> void:
# Future: once server accepts Interact(InteractData)
var inputs: Array = [{
"tick": 100,
"action_name": "Interact",
"action_data": {"target_entity_id": 2, "verb": "Talk"},
}]
var bytes := Protocol.encode_player_inputs(inputs)
var raw = Messagepack.decode(bytes)
assert_that(raw.status == null).is_true()
assert_that(raw.value[0]["action"] is Dictionary).is_true()
assert_that(raw.value[0]["action"].has("Interact")).is_true()
# -- 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 label = Label.new()
label.name = "PromptLabel"
margin.add_child(label)
add_child(panel)
return panel
@@ -0,0 +1 @@
uid://bu0ib6ucyleoc
+3 -4
View File
@@ -279,13 +279,12 @@ func test_decode_snapshot_v2_full() -> void:
assert_that(snapshot).is_not_null()
assert_that(snapshot.tick).is_equal(500)
assert_that(snapshot.version).is_equal(2)
assert_that(snapshot.version).is_equal(4)
# game_time
assert_that(snapshot.game_time).is_not_null()
assert_that(snapshot.game_time.day).is_equal(1)
assert_that(snapshot.game_time.time_of_day).is_equal(720)
assert_that(snapshot.game_time.paused).is_false()
# player_facing (unit enum → bare string)
assert_that(snapshot.player_facing).is_equal("Southeast")
@@ -302,12 +301,12 @@ func test_decode_snapshot_v2_full() -> void:
func test_existing_fixtures_have_v2_fields() -> void:
# All fixtures are generated by v2 fixture_snapshot() — verify decoder extracts v2 fields
# All fixtures are generated by fixture_snapshot() — verify decoder extracts v2+ fields
for fixture_name in ["snapshot_one_npc", "snapshot_empty", "snapshot_player", "snapshot_multi_entity"]:
var bytes = _load_fixture(fixture_name)
var snapshot = Protocol.decode_snapshot(bytes)
assert_that(snapshot).is_not_null()
assert_that(snapshot.version).is_equal(2)
assert_that(snapshot.version).is_equal(4)
assert_that(snapshot.player_facing).is_equal("North")
assert_that(snapshot.game_time).is_not_null()
+1 -1
View File
@@ -175,7 +175,7 @@ func test_sim_bridge_test_snapshot_has_v2_fields() -> void:
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.has("version")).is_true()
assert_that(snap.version).is_equal(2)
assert_that(snap.version).is_equal(4)
assert_that(snap.has("game_time")).is_true()
assert_that(snap.has("player_facing")).is_true()
assert_that(snap.has("visible_tiles")).is_true()
+1
View File
@@ -0,0 +1 @@
uid://cwyma3isr20u0
+1
View File
@@ -0,0 +1 @@
uid://dn7p6lsej3702
+80
View File
@@ -0,0 +1,80 @@
extends PanelContainer
# Interaction prompt — displays available interaction verb for nearby entity.
# Server-driven: shows when GameState.nearby_interactions is non-empty,
# hides when empty. No game logic — pure display layer.
#
# v0.1: Single-line "E - Talk" (first verb on nearest entity)
# v0.2: Will be replaced/extended with radial verb menu.
# Public interface: get_interaction_target(), get_selected_verb()
@onready var prompt_label: Label = $MarginContainer/PromptLabel
var _is_showing: bool = false
var _active_tween: Tween = null
var _current_target_id: int = -1
const FADE_IN: float = 0.15
const FADE_OUT: float = 0.15
func _ready() -> void:
modulate.a = 0.0
visible = false
_is_showing = false
func _process(_delta: float) -> void:
var interactions: Array = GameState.nearby_interactions
if interactions.size() > 0:
_show_prompt(interactions[0])
elif _is_showing:
_hide_prompt()
func _show_prompt(interaction: Dictionary) -> void:
var target_id: int = interaction.get("entity_id", -1)
var verbs: Array = interaction.get("verbs", [])
if verbs.is_empty():
if _is_showing:
_hide_prompt()
return
# v0.1: pick first verb (sorted by priority from server)
var verb_label: String = verbs[0].get("label", "")
var display_text: String = "E - %s" % verb_label
if _current_target_id != target_id or prompt_label.text != display_text:
prompt_label.text = display_text
_current_target_id = target_id
if not _is_showing:
visible = true
_is_showing = true
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(self, "modulate:a", 1.0, FADE_IN)
func _hide_prompt() -> void:
if not _is_showing:
return
_is_showing = false
_current_target_id = -1
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(self, "modulate:a", 0.0, FADE_OUT)
_active_tween.tween_callback(func(): visible = false)
## Returns the current interaction target entity ID, or -1 if no interaction.
func get_interaction_target() -> int:
return _current_target_id
## Returns the selected verb kind (v0.1: first verb on nearest, v0.2: radial selection).
func get_selected_verb() -> String:
var interactions: Array = GameState.nearby_interactions
if interactions.is_empty():
return ""
var verbs: Array = interactions[0].get("verbs", [])
if verbs.is_empty():
return ""
return verbs[0].get("kind", "")
+1
View File
@@ -0,0 +1 @@
uid://b43jk3eu2uig
+28
View File
@@ -0,0 +1,28 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/interaction_prompt.gd" id="1_prompt"]
[node name="InteractionPrompt" type="PanelContainer"]
anchors_preset = 7
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 200.0
offset_top = -60.0
offset_right = -200.0
grow_horizontal = 2
grow_vertical = 0
mouse_filter = 2
script = ExtResource("1_prompt")
[node name="MarginContainer" type="MarginContainer" parent="."]
layout_mode = 2
theme_override_constants/margin_left = 16
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 16
theme_override_constants/margin_bottom = 8
[node name="PromptLabel" type="Label" parent="MarginContainer"]
layout_mode = 2
horizontal_alignment = 1
text = ""