diff --git a/client/README.md b/client/README.md index ba64eb3..0be663a 100644 --- a/client/README.md +++ b/client/README.md @@ -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 `: m / (in|out)` (computed client-side from the live capsule position against `prototype_target_constants.gd`), and (on denials) `Denied: ` 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. - **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. - **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()`. - **HUD:** - **`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. - **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). diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index b923ff9..e87f11d 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -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_font_sizes/font_size = 15 autowrap_mode = 3 -text = "Target HP: —" +text = "Target HP: — (Tab → dummy)" [node name="GatherFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000008] offset_left = 8.0 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 10bb98e..d099ad2 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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 _craft_client: Node = $CraftClient @onready var _target_client: Node = $TargetSelectionClient +@onready var _target_markers: Node3D = $World/PrototypeTargetMarkers @onready var _interaction_client: Node = $InteractionRequestClient @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_combat_target_hp_label() 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: _request_combat_targets_refresh() @@ -900,7 +903,9 @@ func _render_combat_target_hp_label() -> void: return var lock_id := _locked_target_id_from_state(_last_target_state) 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 var row: Dictionary = {} if is_instance_valid(_combat_targets_client) and _combat_targets_client.has_method("target_row"): diff --git a/client/scripts/prototype_target_constants.gd b/client/scripts/prototype_target_constants.gd index a986e68..db28123 100644 --- a/client/scripts/prototype_target_constants.gd +++ b/client/scripts/prototype_target_constants.gd @@ -115,6 +115,17 @@ static func next_in_range_id_after(current: Variant, world: Vector3) -> String: 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 ## when [param current] is [code]null[/code], empty, or not found (start of cycle). Wraps from ## last → first. diff --git a/client/scripts/prototype_target_markers.gd b/client/scripts/prototype_target_markers.gd index 2ed84cb..3d7d8d9 100644 --- a/client/scripts/prototype_target_markers.gd +++ b/client/scripts/prototype_target_markers.gd @@ -1,23 +1,25 @@ extends Node3D -## NEO-24 UX: the targeting registry anchors (`prototype_target_alpha`, -## `prototype_target_beta`) are invisible in the world, so "Tab says ok but I feel -## far away" is impossible to reason about without debug markers. This node spawns -## one small mast + a flat radius ring per id in -## `PrototypeTargetConstants.ANCHORS`, so the player can see exactly where each -## anchor sits and how big its lock radius is. +## NEO-24 UX: combat dummy anchors (`prototype_target_alpha`, `prototype_target_beta`) need +## obvious world markers — a capsule body, lock ring, and [Label3D] name per +## `PrototypeTargetConstants.ANCHORS`. NEO-85: highlight the server-locked dummy via +## [method set_locked_target]. ## ## 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. const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd") -## Visual tuning — keep markers unmistakable but not dominant. -const _MAST_HEIGHT: float = 2.5 -const _MAST_RADIUS: float = 0.18 +## Visual tuning — readable from the default isometric camera at spawn. +const _DUMMY_CAPSULE_HEIGHT: float = 1.75 +const _DUMMY_CAPSULE_RADIUS: float = 0.38 const _RING_RADIUS_SEGMENTS: int = 72 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: @@ -27,6 +29,11 @@ func _ready() -> void: _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: var anchor: Vector3 = PrototypeTargetConstants.anchor_position(target_id) var radius: float = PrototypeTargetConstants.anchor_radius(target_id) @@ -39,23 +46,64 @@ func _spawn_marker(target_id: String) -> void: group.position = anchor add_child(group) - var mast := MeshInstance3D.new() - mast.name = "Mast" - var mast_mesh := CylinderMesh.new() - mast_mesh.top_radius = _MAST_RADIUS - mast_mesh.bottom_radius = _MAST_RADIUS - mast_mesh.height = _MAST_HEIGHT - mast.mesh = mast_mesh - # CylinderMesh is Y-centered at its origin; raise so the base sits on the anchor Y. - mast.position = Vector3(0.0, _MAST_HEIGHT * 0.5, 0.0) - mast.material_override = _make_unshaded_material(tint, 1.0) - group.add_child(mast) + var body := MeshInstance3D.new() + body.name = "DummyBody" + var capsule := CapsuleMesh.new() + capsule.radius = _DUMMY_CAPSULE_RADIUS + capsule.height = _DUMMY_CAPSULE_HEIGHT + body.mesh = capsule + body.position = Vector3(0.0, _DUMMY_CAPSULE_HEIGHT * 0.5, 0.0) + var body_mat := _make_unshaded_material(tint, 0.88) + body.material_override = body_mat + group.add_child(body) var ring: MeshInstance3D = _make_radius_ring(radius, tint) ring.name = "RadiusRing" ring.position = Vector3(0.0, _RING_Y - anchor.y, 0.0) 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: var m := StandardMaterial3D.new() @@ -68,9 +116,6 @@ func _make_unshaded_material(tint: Color, alpha: float) -> StandardMaterial3D: 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: var mi := MeshInstance3D.new() var arr_mesh := ArrayMesh.new() diff --git a/client/test/prototype_target_constants_test.gd b/client/test/prototype_target_constants_test.gd new file mode 100644 index 0000000..e354213 --- /dev/null +++ b/client/test/prototype_target_constants_test.gd @@ -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") diff --git a/client/test/prototype_target_constants_test.gd.uid b/client/test/prototype_target_constants_test.gd.uid new file mode 100644 index 0000000..bf783b7 --- /dev/null +++ b/client/test/prototype_target_constants_test.gd.uid @@ -0,0 +1 @@ +uid://bneo85prototgt01 diff --git a/docs/manual-qa/NEO-85.md b/docs/manual-qa/NEO-85.md index 9d3ae44..678c9ba 100644 --- a/docs/manual-qa/NEO-85.md +++ b/docs/manual-qa/NEO-85.md @@ -16,7 +16,8 @@ ## 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`). -- [ ] **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). - [ ] **`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)`**.