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:
@@ -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")]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://wmbu7ivynu5d
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bcny2nvx6b8q3
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwyma3isr20u0
|
||||
@@ -0,0 +1 @@
|
||||
uid://dn7p6lsej3702
|
||||
@@ -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", "")
|
||||
@@ -0,0 +1 @@
|
||||
uid://b43jk3eu2uig
|
||||
@@ -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 = ""
|
||||
Reference in New Issue
Block a user