NEO-85: Make combat dummies visible with labels and lock highlight.

Replace subtle mast markers with capsule bodies, Dummy α/β Label3D names,
and brighten the Tab-locked target; HUD hints point to orange/purple dummies.
pull/120/head
VinPropane 2026-05-25 13:23:13 -04:00
parent c8f6884ea0
commit 79b8a68364
8 changed files with 116 additions and 29 deletions

View File

@ -103,7 +103,7 @@ Full checklist: [`docs/manual-qa/NEO-25.md`](../manual-qa/NEO-25.md).
- **HUD:** `UICanvas/TargetLockLabel` shows the last **server-acknowledged** state: `lockedTargetId` (or **`—`** when no lock), `validity` (`none` / `ok` / `out_of_range` / `invalid_target`), `sequence`, one **display-only** line per known target in the form `<id>: <dist> m / <radius> (in|out)` (computed client-side from the live capsule position against `prototype_target_constants.gd`), and (on denials) `Denied: <reasonCode>` on the last line. **UI never shows an optimistic lock the server denied** — it always paints from the authoritative `targetState` echoed by each `200` response. Distance lines are a lag diagnostic: if the HUD shows `Validity: ok` but `alpha: 7.1 m / 6.0 (out)`, the server's position snapshot is lagging the visible capsule, and the next `authoritative_ack` refresh will reconcile. `UICanvas/PlayerPositionLabel` also shows the last **server-acknowledged** world position + horizontal delta from the visible capsule — when that `Δ` is large, targeting denials are being evaluated against a stale server snapshot. - **HUD:** `UICanvas/TargetLockLabel` shows the last **server-acknowledged** state: `lockedTargetId` (or **`—`** when no lock), `validity` (`none` / `ok` / `out_of_range` / `invalid_target`), `sequence`, one **display-only** line per known target in the form `<id>: <dist> m / <radius> (in|out)` (computed client-side from the live capsule position against `prototype_target_constants.gd`), and (on denials) `Denied: <reasonCode>` on the last line. **UI never shows an optimistic lock the server denied** — it always paints from the authoritative `targetState` echoed by each `200` response. Distance lines are a lag diagnostic: if the HUD shows `Validity: ok` but `alpha: 7.1 m / 6.0 (out)`, the server's position snapshot is lagging the visible capsule, and the next `authoritative_ack` refresh will reconcile. `UICanvas/PlayerPositionLabel` also shows the last **server-acknowledged** world position + horizontal delta from the visible capsule — when that `Δ` is large, targeting denials are being evaluated against a stale server snapshot.
- **`positionHint` on select (NEO-24 follow-up #5):** when `TargetSelectionClient.set_freshness_kick(authority, player)` is wired (as `main.gd` does), every `POST /target/select` body also carries `positionHint: {x,y,z}` = the live capsule position. The server uses the hint for the radius check and for the `validity` field in the echoed `targetState`, eliminating the cross-request race between `move-stream` and `target/select` POSTs — a Tab pressed several seconds after stopping (with an old stored snap on the server) now still succeeds when you are visibly in range. The hint is advisory (server never writes it to the position store); `move-stream` remains the sole write path. Tests that omit `set_freshness_kick(...)` fall back to the no-hint code path. - **`positionHint` on select (NEO-24 follow-up #5):** when `TargetSelectionClient.set_freshness_kick(authority, player)` is wired (as `main.gd` does), every `POST /target/select` body also carries `positionHint: {x,y,z}` = the live capsule position. The server uses the hint for the radius check and for the `validity` field in the echoed `targetState`, eliminating the cross-request race between `move-stream` and `target/select` POSTs — a Tab pressed several seconds after stopping (with an old stored snap on the server) now still succeeds when you are visibly in range. The hint is advisory (server never writes it to the position store); `move-stream` remains the sole write path. Tests that omit `set_freshness_kick(...)` fall back to the no-hint code path.
- **Freshness kick (ancillary):** alongside the hint, `set_freshness_kick(...)` also has the target client nudge the authority with `submit_stream_targets([player.global_position])` before each select POST. That write is no longer load-bearing for denial correctness (the hint handles that), but it keeps the stored snap reasonably fresh so the movement-triggered validity GET that runs on the next `authoritative_ack` sees current data. - **Freshness kick (ancillary):** alongside the hint, `set_freshness_kick(...)` also has the target client nudge the authority with `submit_stream_targets([player.global_position])` before each select POST. That write is no longer load-bearing for denial correctness (the hint handles that), but it keeps the stored snap reasonably fresh so the movement-triggered validity GET that runs on the next `authoritative_ack` sees current data.
- **Visible anchors:** `scripts/prototype_target_markers.gd` (under `World/PrototypeTargetMarkers`) spawns a small colored mast + a flat translucent ring at every `ANCHORS` entry so the lock radius is visible in-world. All prototype targets share a single radius (`PrototypeTargetConstants.SHARED_LOCK_RADIUS` = `PrototypeTargetRegistry.SharedLockRadius` = **6 m**); anchors are placed at `(-3, 0.5, -3)` and `(3, 0, 3)` so the two rings overlap at origin (~3.5 m wide), letting you flip between targets with Tab without locomotion. The script and `prototype_target_constants.gd` mirror the server's `PrototypeTargetRegistry.cs` — change them together in the same commit so the markers never drift from server authority. Per-target radii will return with real combat design (E5.M1). - **Visible anchors:** `scripts/prototype_target_markers.gd` (under `World/PrototypeTargetMarkers`) spawns a **capsule dummy body**, floating **Label3D** name (**Dummy α** / **Dummy β**), and a flat translucent **lock ring** at every `ANCHORS` entry. **Orange** = `prototype_target_alpha` at **`(-3, 0.5, -3)`**; **purple** = `prototype_target_beta` at **`(3, 0, 3)`** — both near default spawn **`(-5, 0.9, -5)`** with overlapping 6 m rings at the origin. **Tab** locks a dummy; the locked capsule + ring **brighten**. All prototype targets share `PrototypeTargetConstants.SHARED_LOCK_RADIUS` = **6 m** (mirrors `PrototypeTargetRegistry.cs`). Keep client constants and server registry in the same commit when anchors move.
- **Soft lock refresh:** the client does **not** poll on a timer. It refreshes via `GET …/target` on boot, on every `POST …/target/select` response (body already carries `targetState`), and — while a lock is currently held — on **`authoritative_ack`** from `PositionAuthorityClient`, throttled to at most one GET per **250 ms**. `authoritative_ack` fires on every server-confirmed position (boot `GET` 200 **and** successful `move-stream` 200), so a held lock revalidates during normal WASD locomotion without re-introducing snap rubber-banding. Boot is naturally skipped because no lock exists yet. Purely server-driven state flips (future PvP / combat categories) would require revisiting this policy; today NEO-23 has none. - **Soft lock refresh:** the client does **not** poll on a timer. It refreshes via `GET …/target` on boot, on every `POST …/target/select` response (body already carries `targetState`), and — while a lock is currently held — on **`authoritative_ack`** from `PositionAuthorityClient`, throttled to at most one GET per **250 ms**. `authoritative_ack` fires on every server-confirmed position (boot `GET` 200 **and** successful `move-stream` 200), so a held lock revalidates during normal WASD locomotion without re-introducing snap rubber-banding. Boot is naturally skipped because no lock exists yet. Purely server-driven state flips (future PvP / combat categories) would require revisiting this policy; today NEO-23 has none.
- **Inspector:** match **`base_url`** / **`dev_player_id`** on the `TargetSelectionClient` node to the other HTTP clients / `Game:DevPlayerId`. - **Inspector:** match **`base_url`** / **`dev_player_id`** on the `TargetSelectionClient` node to the other HTTP clients / `Game:DevPlayerId`.
@ -141,7 +141,8 @@ Full checklist: [`docs/manual-qa/NEO-31.md`](../docs/manual-qa/NEO-31.md) · cas
- **Scripts:** `scripts/ability_cast_client.gd` (parses **`combatResolution`** on accept), `scripts/combat_targets_client.gd`; wired from `main.gd` in `_setup_hotbar_loadout_sync()` / `_setup_combat_targets_sync()`. - **Scripts:** `scripts/ability_cast_client.gd` (parses **`combatResolution`** on accept), `scripts/combat_targets_client.gd`; wired from `main.gd` in `_setup_hotbar_loadout_sync()` / `_setup_combat_targets_sync()`.
- **HUD:** - **HUD:**
- **`UICanvas/CastFeedbackLabel`** — on accept: **`Cast: {damage} dmg → {remaining} HP`** or **`… — defeated!`** when **`targetDefeated`**; denies unchanged from NEO-28. - **`UICanvas/CastFeedbackLabel`** — on accept: **`Cast: {damage} dmg → {remaining} HP`** or **`… — defeated!`** when **`targetDefeated`**; denies unchanged from NEO-28.
- **`UICanvas/CombatTargetHpLabel`** — locked target **`currentHp/maxHp`** (+ **` (defeated)`**); **`Target HP: — (no lock)`** when unlocked. - **`UICanvas/CombatTargetHpLabel`** — locked target **`currentHp/maxHp`** (+ **` (defeated)`**); **`Target HP: — (Tab → orange/purple dummy near spawn)`** when unlocked.
- **World dummies:** **`World/PrototypeTargetMarkers`** — orange **Dummy α** west of spawn, purple **Dummy β** south-east; **Tab** locks and highlights the active dummy (see Targeting section above).
- **Refresh (event-driven):** combat-target GET after successful cast accept and when **`lockedTargetId`** changes (Tab lock / Esc clear / server correction). No periodic HP poll. - **Refresh (event-driven):** combat-target GET after successful cast accept and when **`lockedTargetId`** changes (Tab lock / Esc clear / server correction). No periodic HP poll.
- **Dev hotbar bootstrap (NEO-85):** on first hotbar sync, if slot **0** is empty the client POSTs a bind for **`prototype_pulse`** once (same preset as Bruno `Post hotbar bind slot0 pulse`) so digit **1** works without a manual bind step when the server is up. - **Dev hotbar bootstrap (NEO-85):** on first hotbar sync, if slot **0** is empty the client POSTs a bind for **`prototype_pulse`** once (same preset as Bruno `Post hotbar bind slot0 pulse`) so digit **1** works without a manual bind step when the server is up.
- **Dev fixture reset:** when the server exposes **`POST /game/__dev/combat-targets-fixture`** (Development/Testing), reset dummy HP between manual QA runs — see [server README](../server/README.md#combat-entity-health-store-neo-80). - **Dev fixture reset:** when the server exposes **`POST /game/__dev/combat-targets-fixture`** (Development/Testing), reset dummy HP between manual QA runs — see [server README](../server/README.md#combat-entity-health-store-neo-80).

View File

@ -1182,7 +1182,7 @@ theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 6 theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 15 theme_override_font_sizes/font_size = 15
autowrap_mode = 3 autowrap_mode = 3
text = "Target HP: —" text = "Target HP: — (Tab → dummy)"
[node name="GatherFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000008] [node name="GatherFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000008]
offset_left = 8.0 offset_left = 8.0

View File

@ -120,6 +120,7 @@ var _skill_progression_label: Label = _economy_hud_section.get_node("Body/SkillP
@onready var _recipe_defs_client: Node = $RecipeDefinitionsClient @onready var _recipe_defs_client: Node = $RecipeDefinitionsClient
@onready var _craft_client: Node = $CraftClient @onready var _craft_client: Node = $CraftClient
@onready var _target_client: Node = $TargetSelectionClient @onready var _target_client: Node = $TargetSelectionClient
@onready var _target_markers: Node3D = $World/PrototypeTargetMarkers
@onready var _interaction_client: Node = $InteractionRequestClient @onready var _interaction_client: Node = $InteractionRequestClient
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor @onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
@ -276,6 +277,8 @@ func _on_target_state_changed(state: Dictionary) -> void:
_render_target_lock_label(_player.global_position) _render_target_lock_label(_player.global_position)
_render_combat_target_hp_label() _render_combat_target_hp_label()
var new_lock := _locked_target_id_from_state(_last_target_state) var new_lock := _locked_target_id_from_state(_last_target_state)
if is_instance_valid(_target_markers) and _target_markers.has_method("set_locked_target"):
_target_markers.call("set_locked_target", new_lock)
if new_lock != prev_lock: if new_lock != prev_lock:
_request_combat_targets_refresh() _request_combat_targets_refresh()
@ -900,7 +903,9 @@ func _render_combat_target_hp_label() -> void:
return return
var lock_id := _locked_target_id_from_state(_last_target_state) var lock_id := _locked_target_id_from_state(_last_target_state)
if lock_id.is_empty(): if lock_id.is_empty():
_combat_target_hp_label.text = "Target HP: — (no lock)" _combat_target_hp_label.text = (
"Target HP: — (Tab → orange/purple dummy near spawn)"
)
return return
var row: Dictionary = {} var row: Dictionary = {}
if is_instance_valid(_combat_targets_client) and _combat_targets_client.has_method("target_row"): if is_instance_valid(_combat_targets_client) and _combat_targets_client.has_method("target_row"):

View File

@ -115,6 +115,17 @@ static func next_in_range_id_after(current: Variant, world: Vector3) -> String:
return "" return ""
## Player-facing short label for world [Label3D] markers (NEO-85 combat UX).
static func display_short_name(target_id: String) -> String:
match target_id:
"prototype_target_alpha":
return "Dummy α"
"prototype_target_beta":
return "Dummy β"
_:
return target_id.strip_edges()
## Returns the next id in [constant ORDERED_IDS] after [param current]. Returns the **first** id ## Returns the next id in [constant ORDERED_IDS] after [param current]. Returns the **first** id
## when [param current] is [code]null[/code], empty, or not found (start of cycle). Wraps from ## when [param current] is [code]null[/code], empty, or not found (start of cycle). Wraps from
## last → first. ## last → first.

View File

@ -1,23 +1,25 @@
extends Node3D extends Node3D
## NEO-24 UX: the targeting registry anchors (`prototype_target_alpha`, ## NEO-24 UX: combat dummy anchors (`prototype_target_alpha`, `prototype_target_beta`) need
## `prototype_target_beta`) are invisible in the world, so "Tab says ok but I feel ## obvious world markers — a capsule body, lock ring, and [Label3D] name per
## far away" is impossible to reason about without debug markers. This node spawns ## `PrototypeTargetConstants.ANCHORS`. NEO-85: highlight the server-locked dummy via
## one small mast + a flat radius ring per id in ## [method set_locked_target].
## `PrototypeTargetConstants.ANCHORS`, so the player can see exactly where each
## anchor sits and how big its lock radius is.
## ##
## Display-only; no game logic hangs off these nodes. If the server registry moves, ## Display-only; no game logic hangs off these nodes. If the server registry moves,
## update `prototype_target_constants.gd` — markers respawn on next scene load. ## update `prototype_target_constants.gd` — markers respawn on next scene load.
const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd") const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd")
## Visual tuning — keep markers unmistakable but not dominant. ## Visual tuning — readable from the default isometric camera at spawn.
const _MAST_HEIGHT: float = 2.5 const _DUMMY_CAPSULE_HEIGHT: float = 1.75
const _MAST_RADIUS: float = 0.18 const _DUMMY_CAPSULE_RADIUS: float = 0.38
const _RING_RADIUS_SEGMENTS: int = 72 const _RING_RADIUS_SEGMENTS: int = 72
const _RING_Y: float = 0.05 const _RING_Y: float = 0.05
const _RING_THICKNESS: float = 0.06 const _RING_THICKNESS: float = 0.08
const _LABEL_Y: float = 2.15
var _markers_by_id: Dictionary = {}
var _locked_target_id: String = ""
func _ready() -> void: func _ready() -> void:
@ -27,6 +29,11 @@ func _ready() -> void:
_spawn_marker(id_variant as String) _spawn_marker(id_variant as String)
func set_locked_target(target_id: String) -> void:
_locked_target_id = target_id.strip_edges()
_apply_highlight_states()
func _spawn_marker(target_id: String) -> void: func _spawn_marker(target_id: String) -> void:
var anchor: Vector3 = PrototypeTargetConstants.anchor_position(target_id) var anchor: Vector3 = PrototypeTargetConstants.anchor_position(target_id)
var radius: float = PrototypeTargetConstants.anchor_radius(target_id) var radius: float = PrototypeTargetConstants.anchor_radius(target_id)
@ -39,23 +46,64 @@ func _spawn_marker(target_id: String) -> void:
group.position = anchor group.position = anchor
add_child(group) add_child(group)
var mast := MeshInstance3D.new() var body := MeshInstance3D.new()
mast.name = "Mast" body.name = "DummyBody"
var mast_mesh := CylinderMesh.new() var capsule := CapsuleMesh.new()
mast_mesh.top_radius = _MAST_RADIUS capsule.radius = _DUMMY_CAPSULE_RADIUS
mast_mesh.bottom_radius = _MAST_RADIUS capsule.height = _DUMMY_CAPSULE_HEIGHT
mast_mesh.height = _MAST_HEIGHT body.mesh = capsule
mast.mesh = mast_mesh body.position = Vector3(0.0, _DUMMY_CAPSULE_HEIGHT * 0.5, 0.0)
# CylinderMesh is Y-centered at its origin; raise so the base sits on the anchor Y. var body_mat := _make_unshaded_material(tint, 0.88)
mast.position = Vector3(0.0, _MAST_HEIGHT * 0.5, 0.0) body.material_override = body_mat
mast.material_override = _make_unshaded_material(tint, 1.0) group.add_child(body)
group.add_child(mast)
var ring: MeshInstance3D = _make_radius_ring(radius, tint) var ring: MeshInstance3D = _make_radius_ring(radius, tint)
ring.name = "RadiusRing" ring.name = "RadiusRing"
ring.position = Vector3(0.0, _RING_Y - anchor.y, 0.0) ring.position = Vector3(0.0, _RING_Y - anchor.y, 0.0)
group.add_child(ring) group.add_child(ring)
var label := Label3D.new()
label.name = "NameLabel"
label.text = PrototypeTargetConstants.display_short_name(target_id)
label.font_size = 24
label.outline_size = 6
label.modulate = tint.lightened(0.15)
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.position = Vector3(0.0, _LABEL_Y, 0.0)
group.add_child(label)
_markers_by_id[target_id] = {
"body_mat": body_mat,
"ring_mat": ring.material_override,
"tint": tint,
}
func _apply_highlight_states() -> void:
for id_variant: Variant in _markers_by_id.keys():
if not id_variant is String:
continue
var target_id: String = id_variant as String
var entry: Dictionary = _markers_by_id[target_id]
var tint: Color = entry["tint"]
var body_mat: StandardMaterial3D = entry["body_mat"]
var ring_mat: StandardMaterial3D = entry["ring_mat"]
var highlighted: bool = target_id == _locked_target_id and not _locked_target_id.is_empty()
if highlighted:
body_mat.albedo_color = tint.lightened(0.25)
body_mat.emission_enabled = true
body_mat.emission = tint
body_mat.emission_energy_multiplier = 1.4
var ring_c := tint
ring_c.a = 0.78
ring_mat.albedo_color = ring_c
else:
body_mat.albedo_color = Color(tint.r, tint.g, tint.b, 0.88)
body_mat.emission_enabled = false
var ring_c2 := tint
ring_c2.a = 0.45
ring_mat.albedo_color = ring_c2
func _make_unshaded_material(tint: Color, alpha: float) -> StandardMaterial3D: func _make_unshaded_material(tint: Color, alpha: float) -> StandardMaterial3D:
var m := StandardMaterial3D.new() var m := StandardMaterial3D.new()
@ -68,9 +116,6 @@ func _make_unshaded_material(tint: Color, alpha: float) -> StandardMaterial3D:
return m return m
## Builds a thin flat ring (annulus) by triangulating the band between `radius - t/2`
## and `radius + t/2`. `ImmediateMesh` with `SurfaceTool` would also work, but an
## `ArrayMesh` keeps this a static resource with no per-frame cost.
func _make_radius_ring(radius: float, tint: Color) -> MeshInstance3D: func _make_radius_ring(radius: float, tint: Color) -> MeshInstance3D:
var mi := MeshInstance3D.new() var mi := MeshInstance3D.new()
var arr_mesh := ArrayMesh.new() var arr_mesh := ArrayMesh.new()

View File

@ -0,0 +1,23 @@
extends GdUnitTestSuite
## NEO-85: prototype target display helpers for combat dummy markers.
const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd")
func test_display_short_name_maps_alpha_and_beta() -> void:
# Arrange
# Act
var alpha: String = PrototypeTargetConstants.display_short_name("prototype_target_alpha")
var beta: String = PrototypeTargetConstants.display_short_name("prototype_target_beta")
# Assert
assert_that(alpha).is_equal("Dummy α")
assert_that(beta).is_equal("Dummy β")
func test_display_short_name_falls_back_to_id() -> void:
# Arrange
# Act
var unknown: String = PrototypeTargetConstants.display_short_name("unknown_target")
# Assert
assert_that(unknown).is_equal("unknown_target")

View File

@ -0,0 +1 @@
uid://bneo85prototgt01

View File

@ -16,7 +16,8 @@
## 2) Client — combat HUD ## 2) Client — combat HUD
- [ ] Open Godot main scene with server running; after hotbar sync, slot **0** auto-binds to **`prototype_pulse`** when empty (NEO-85 dev bootstrap — same as Bruno `Post hotbar bind slot0 pulse`). - [ ] Open Godot main scene with server running; after hotbar sync, slot **0** auto-binds to **`prototype_pulse`** when empty (NEO-85 dev bootstrap — same as Bruno `Post hotbar bind slot0 pulse`).
- [ ] **Tab** to lock **`prototype_target_alpha`**; **`CombatTargetHpLabel`** shows **`Target HP: prototype_target_alpha 100/100`** (or **`…`** briefly until GET completes). - [ ] Locate combat dummies near spawn: **orange capsule "Dummy α"** at **`(-3, -3)`** (west of player) and **purple "Dummy β"** at **`(3, 3)`**; each has a flat lock ring on the ground. Walk toward the orange dummy if needed.
- [ ] **Tab** to lock **`prototype_target_alpha`** — dummy **brightens**; **`CombatTargetHpLabel`** shows **`Target HP: prototype_target_alpha 100/100`** (or **`…`** briefly until GET completes).
- [ ] Press **1** (hotbar slot 1 → index 0): **`CastFeedbackLabel`** shows **`Cast: 25 dmg → 75 HP`** (not Output-only). - [ ] Press **1** (hotbar slot 1 → index 0): **`CastFeedbackLabel`** shows **`Cast: 25 dmg → 75 HP`** (not Output-only).
- [ ] **`CombatTargetHpLabel`** updates to **`75/100`** after the cast (event-driven GET refresh). - [ ] **`CombatTargetHpLabel`** updates to **`75/100`** after the cast (event-driven GET refresh).
- [ ] Cast **three more** pulse hits (respect cooldown): feedback steps **`50`**, **`25`**, then **`Cast: 25 dmg → 0 HP — defeated!`** on the lethal hit; HP label shows **`0/100 (defeated)`**. - [ ] Cast **three more** pulse hits (respect cooldown): feedback steps **`50`**, **`25`**, then **`Cast: 25 dmg → 0 HP — defeated!`** on the lethal hit; HP label shows **`0/100 (defeated)`**.