@hypersoniclabs/helix-mcp 0.2.5 → 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -11
- package/dist/continuumCanary.d.ts +17 -0
- package/dist/continuumCanary.js +17 -0
- package/dist/continuumCanary.js.map +1 -0
- package/dist/server.d.ts +14 -1
- package/dist/server.js +4058 -162
- package/dist/server.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/vehicleTools.d.ts +86 -0
- package/dist/vehicleTools.js +229 -0
- package/dist/vehicleTools.js.map +1 -0
- package/docs/avatar-face.md +115 -0
- package/docs/bridge.md +98 -0
- package/docs/bring-your-world.md +117 -0
- package/docs/catalog.md +69 -1
- package/docs/character-animation.md +442 -0
- package/docs/character-attachments.md +166 -0
- package/docs/character-world.md +785 -130
- package/docs/continuum.md +153 -0
- package/docs/items.md +73 -0
- package/docs/lighting-world.md +667 -0
- package/docs/locomotion-clip-spec.md +294 -0
- package/docs/manifest.md +31 -6
- package/docs/multiplayer-logic.md +460 -17
- package/docs/multiplayer-templates/chrono-orchard.md +36 -22
- package/docs/multiplayer-templates/collect-a-thon.md +28 -38
- package/docs/multiplayer-templates/collections.md +24 -24
- package/docs/multiplayer-templates/hangout.md +124 -111
- package/docs/multiplayer-templates/npc-wave.md +310 -0
- package/docs/multiplayer-templates/obby.md +13 -16
- package/docs/multiplayer-templates/persistent-progress.md +218 -0
- package/docs/multiplayer-templates/physics-bumper.md +27 -9
- package/docs/multiplayer-templates/physics-football.md +22 -8
- package/docs/multiplayer-templates/relic-bearers.md +12 -15
- package/docs/multiplayer-templates/server-motion.md +16 -19
- package/docs/multiplayer-templates/shooter-range.md +275 -0
- package/docs/multiplayer-templates/team-control.md +28 -15
- package/docs/multiplayer-templates/turn-arena.md +31 -22
- package/docs/multiplayer-templates/voice-radio.md +166 -0
- package/docs/multiplayer-templates/wave-survival.md +7 -8
- package/docs/multiplayer-templates/world-shop.md +240 -0
- package/docs/multiplayer-world.md +222 -129
- package/docs/npc-world.md +623 -0
- package/docs/publishing.md +108 -28
- package/docs/purchases.md +223 -0
- package/docs/scene-performance.md +64 -0
- package/docs/screenshots.md +140 -0
- package/docs/sdk.md +324 -5
- package/docs/shooter-worlds.md +537 -0
- package/docs/terrain.md +173 -0
- package/docs/upgrades.md +324 -0
- package/docs/vehicles.md +727 -0
- package/docs/world-inspect.md +156 -0
- package/docs/world-look.md +241 -0
- package/docs/world-recipe.md +65 -6
- package/package.json +15 -4
- package/skills/README.md +91 -0
- package/skills/helix-assets/SKILL.md +491 -0
- package/skills/helix-assets/references/asset-sources.md +143 -0
- package/skills/helix-assets/references/vault-api.md +105 -0
- package/skills/helix-avatar-qa/SKILL.md +85 -0
- package/skills/helix-avatars/SKILL.md +206 -0
- package/skills/helix-avatars/references/contract.md +166 -0
- package/skills/helix-avatars/references/dynamics.md +367 -0
- package/skills/helix-avatars/references/face.md +50 -0
- package/skills/helix-avatars/references/publish.md +76 -0
- package/skills/helix-avatars/references/qa.md +251 -0
- package/skills/helix-avatars/references/rigging.md +88 -0
- package/skills/helix-avatars/references/source-generated.md +190 -0
- package/skills/helix-avatars/references/source-model.md +90 -0
- package/skills/helix-avatars/references/source-rigid.md +90 -0
- package/skills/helix-avatars/references/source-vrm.md +61 -0
- package/skills/helix-gauntlet/SKILL.md +128 -0
- package/skills/helix-multiplayer/SKILL.md +150 -0
- package/skills/helix-multiplayer/references/dsl-capability-map.md +107 -0
- package/skills/helix-vehicles/SKILL.md +218 -0
- package/skills/helix-vehicles/references/addons.md +212 -0
- package/skills/helix-vehicles/references/appearance.md +339 -0
- package/skills/helix-vehicles/references/audio-import.md +138 -0
- package/skills/helix-vehicles/references/audio.md +580 -0
- package/skills/helix-vehicles/references/cabin.md +225 -0
- package/skills/helix-vehicles/references/host-manifest.md +174 -0
- package/skills/helix-vehicles/references/physics-from-specs.md +463 -0
- package/skills/helix-vehicles/references/publish.md +214 -0
- package/skills/helix-vehicles/references/qa.md +177 -0
- package/skills/helix-vehicles/references/reference-package.json +3481 -0
- package/skills/helix-vehicles/references/reference-package.md +69 -0
- package/skills/helix-vehicles/references/source-beamng.md +167 -0
- package/skills/helix-vehicles/references/source-concept.md +38 -0
- package/skills/helix-vehicles/references/source-model.md +100 -0
- package/skills/helix-vehicles/references/source-scratch.md +60 -0
- package/skills/helix-world-build/SKILL.md +376 -0
- package/skills/helix-world-build/references/config-gates.md +104 -0
- package/skills/helix-world-director/SKILL.md +210 -0
- package/skills/helix-world-qa/SKILL.md +371 -0
- package/skills/helix-world-qa/references/perf-budgets.md +240 -0
- package/skills/helix-world-qa/references/perf-handle.md +125 -0
- package/skills/helix-world-qa/references/visual-scorecard.md +85 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
# HELIX Instant — NPC Characters (the recipe)
|
|
2
|
+
|
|
3
|
+
Follow this when the world needs someone in it who is **not a player**: a shopkeeper behind a counter, a quest
|
|
4
|
+
giver, a guard on a post, a zombie that hunts you around a wall, a companion that follows you. NPCs are a
|
|
5
|
+
**layer on the character system** — the same `humanoid-character` package the player is built on, with one seam
|
|
6
|
+
swapped.
|
|
7
|
+
|
|
8
|
+
> ## SCAFFOLD THE WORLD FIRST — THIS IS A LAYER, NOT A PROJECT
|
|
9
|
+
>
|
|
10
|
+
> NPCs go **into** a character world. Call `get_started({ kind: "character" })`, run the `helix init` command
|
|
11
|
+
> `scaffold_world` returns, and edit the `src/main.ts` it wrote. Do not hand-write project files from this doc:
|
|
12
|
+
> every snippet below is an addition to that generated boot flow, and every import comes from the **system
|
|
13
|
+
> alias** `@helix/humanoid-character` — never an engine path, never a relative import into a package.
|
|
14
|
+
>
|
|
15
|
+
> **Read the API before you call it.** `get_package_manifest("humanoid-character")` → `capabilities.api` carries
|
|
16
|
+
> the shipped signatures for `NpcScene`, `InteractionSpots`, `NavGrid` and `loadVaultCharacter`, and the
|
|
17
|
+
> installed `.d.ts` carries the full types. Never invent an NPC API — there is no `npc.attack()`, no
|
|
18
|
+
> `npc.patrol()`, no dialogue system. What exists is below.
|
|
19
|
+
|
|
20
|
+
## 0. What an NPC is here *(humanoid-character ≥ 0.3.13)*
|
|
21
|
+
|
|
22
|
+
**An NPC is a `Character` whose driver is your behaviour instead of the keyboard.** It is the same chassis the
|
|
23
|
+
player runs on — the same config namespaces (`character.*`, `locomotion.*`), the same locomotion + animation
|
|
24
|
+
graph, the same abilities, the same gestures, the same nameplate — built headless (no camera, no DOM). The one
|
|
25
|
+
difference is the seam that feeds it intent: a player's `Character` reads real keys through
|
|
26
|
+
`LocalInputDriver`, and an NPC's reads an **`AIDriver`** that writes *virtual* input onto the NPC's own private
|
|
27
|
+
router, so every ability resolves that intent exactly as it resolves a player's and nothing downstream can tell
|
|
28
|
+
the difference. You never build that plumbing: **`NpcScene`** owns the model clone, the private router, the
|
|
29
|
+
body, the plate and the teardown, and you author two things — a `behaviour` callback and, when the player
|
|
30
|
+
should be able to talk to it, an interaction spot.
|
|
31
|
+
|
|
32
|
+
## 1. The five-minute shop NPC *(humanoid-character ≥ 0.3.13)*
|
|
33
|
+
|
|
34
|
+
The smallest useful NPC: it stands at its counter, turns to watch whoever walks up, and says a line when you
|
|
35
|
+
press interact.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import {
|
|
39
|
+
InteractionSpots, NpcScene, type AIBehaviour,
|
|
40
|
+
} from '@helix/humanoid-character';
|
|
41
|
+
|
|
42
|
+
// `assets` is what loadCharacterAssets returned (or `mp.assets` in a multiplayer world) — the shared base
|
|
43
|
+
// model + locomotion clips. NpcScene SkeletonUtils-clones that model per NPC, so one load feeds every NPC.
|
|
44
|
+
const npcs = new NpcScene({ scene, assets });
|
|
45
|
+
|
|
46
|
+
const COUNTER = { x: 7, y: 1, z: 4 };
|
|
47
|
+
const WATCH_RANGE_M = 6;
|
|
48
|
+
|
|
49
|
+
// The behaviour runs FIRST inside the NPC's own update, once a frame, and steers through the driver's
|
|
50
|
+
// public surface: seek / stop / face / jump / setCrouched, plus the `arrived` and `path` reads.
|
|
51
|
+
const shopBehaviour: AIBehaviour = (npc) => {
|
|
52
|
+
const self = npc.position; // the body's live feet position — copy it, never hold it
|
|
53
|
+
const near = Math.hypot(body.position.x - self.x, body.position.z - self.z) <= WATCH_RANGE_M;
|
|
54
|
+
npc.face(near ? body.position : COUNTER); // idle facing: a world point, or a yaw in DEGREES
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const shop = await npcs.add({
|
|
58
|
+
id: 'shopkeeper',
|
|
59
|
+
at: { x: 7, y: 0, z: 5.2 },
|
|
60
|
+
facingDeg: 180,
|
|
61
|
+
body: 'stand', // the default — no physics world at all
|
|
62
|
+
behaviour: shopBehaviour,
|
|
63
|
+
name: 'Shopkeeper', // omit for no nameplate; nameColor restyles it
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Proximity interaction: walk into range, an overhead pill offers `{interact} Talk`, the press calls back.
|
|
67
|
+
const spots = new InteractionSpots({ body, input, scene });
|
|
68
|
+
spots.register({ // returns its unregister closure — keep it if the NPC can leave
|
|
69
|
+
id: 'shopkeeper-talk',
|
|
70
|
+
label: 'Talk', // the verb, sentence case
|
|
71
|
+
// A LIVE anchor, not a fixed point: the pill has to hang above wherever the NPC currently stands.
|
|
72
|
+
at: () => { const p = shop.driver.position; return { x: p.x, y: p.y + 2.1, z: p.z }; },
|
|
73
|
+
onInteract: () => showLine(nextShopLine()), // your own HUD + dialogue code, not an SDK API
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
renderer.setAnimationLoop(() => {
|
|
77
|
+
const dt = Math.min(clock.getDelta(), 0.1);
|
|
78
|
+
character.update(dt); // the PLAYER first — it moves the body every NPC behaviour reads, and its driver
|
|
79
|
+
// is what advances this frame's press edges on the shared router
|
|
80
|
+
npcs.update(dt); // then every NPC (each one's behaviour + driver run inside its own character.update)
|
|
81
|
+
spots.update(dt); // then the spots — the anchors they track just moved, and the interact edge is live
|
|
82
|
+
renderer.render(scene, camera);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// THE ONE LINE THAT MAKES NPCs MEASURABLE — the scaffold already calls world.ready at the end of boot; add
|
|
86
|
+
// `npcs` to it and `inspect_npc` can measure every NPC in this world (§5). `npcState` is your OWN behaviour
|
|
87
|
+
// machine, published per NPC and carried verbatim into every sample; optional, and everything else works without it.
|
|
88
|
+
world.ready({ scene, body, spawn, npcs, npcState: (id) => brains.get(id) ?? null });
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**The loop order is the recipe, not a preference.** `spots.update` reads `wasPressed('interact')` off the
|
|
92
|
+
player's router, and that router advances its edges inside `character.update(dt)` — run the spots first and the
|
|
93
|
+
press either misses by a frame or reads stale. NPCs go between the two so the pill tracks where the NPC is
|
|
94
|
+
*this* frame rather than trailing it.
|
|
95
|
+
|
|
96
|
+
**Pass `npcs` to `world.ready` the moment you build an `NpcScene`.** Without it the inspect helper installs no
|
|
97
|
+
NPC surface at all, and the only way to find out what a behaviour is doing is to watch it with your eyes — which
|
|
98
|
+
is exactly what §5 is about not doing. `npcState` is where your own state goes: whatever object you return for an
|
|
99
|
+
id (`{ phase: "chase", cooldown: 0.4 }`) rides along on every sample, so a state machine that never leaves its
|
|
100
|
+
wind-up shows up as a flat timeline instead of a mystery. Return `null` for an NPC you have nothing to say about.
|
|
101
|
+
|
|
102
|
+
**The two body tiers:**
|
|
103
|
+
|
|
104
|
+
| `body` | what it is | needs | walks? |
|
|
105
|
+
|---|---|---|---|
|
|
106
|
+
| `'stand'` *(default)* | `StandBody` — no physics world at all, so a standing NPC costs no simulation | nothing | **never.** `seek()` writes intent a stationary body ignores; `teleport()` is the only thing that moves it |
|
|
107
|
+
| `'physics'` | its own Rapier world, with the level geometry mirrored in from the scene's `staticsFrom` | `staticsFrom` on the `NpcScene` | yes — and it routes over a `nav` grid when it has one (§3) |
|
|
108
|
+
|
|
109
|
+
Everything else about the spot is a platform default: distance is **horizontal** (put the anchor at head
|
|
110
|
+
height — about 2.1 m above the feet — for where the pill *draws*; the radius ignores `y`, so height costs you no
|
|
111
|
+
reach), focus is taken at **2.2 m** and released only past **3.0 m** (per-spot hysteresis, so standing on the
|
|
112
|
+
boundary cannot flicker), nearest wins with where the player is *looking* settling a near-tie inside 0.5 m, and
|
|
113
|
+
the pill renders the **real keycap** for `interact` on the player's current device — `E`, an Xbox glyph, a pad
|
|
114
|
+
prompt — because it composes the label through the router's own `format()`. `register()` throws on a duplicate
|
|
115
|
+
id (a silent replace would re-point a verb the player is looking at) and returns its unregister closure.
|
|
116
|
+
`interact` is *ensured* on the router: if the world never registered it, `InteractionSpots` does, because an
|
|
117
|
+
offer nobody can take is worse than no offer.
|
|
118
|
+
|
|
119
|
+
> **If the world also authors sit/lie spots, yield to them.** Pass `suppressed: () => mp.sitPrompt !== null`
|
|
120
|
+
> (the multiplayer facade's live sit prompt) so an NPC pill and a chair pill never stack. While suppressed no
|
|
121
|
+
> prompt shows and presses are ignored.
|
|
122
|
+
|
|
123
|
+
## 2. A zombie that hunts *(humanoid-character ≥ 0.3.13)*
|
|
124
|
+
|
|
125
|
+
A hunting NPC needs the `'physics'` tier, and that tier needs level geometry. Build the scene with
|
|
126
|
+
`staticsFrom` pointed at the **player's own body** — the walls the NPC steers around are then literally the
|
|
127
|
+
walls you walk on, mirrored into its private world:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
const npcs = new NpcScene({ scene, assets, staticsFrom: body, nav }); // `nav`: §3
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Skip `staticsFrom` and `add()` refuses before it builds anything:
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
NpcScene.add('zombie'): body 'physics' needs level geometry — build the NpcScene with staticsFrom
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
const ATTACK_RANGE_M = 1.6;
|
|
141
|
+
const ATTACK_COOLDOWN_S = 1.2;
|
|
142
|
+
const RESEEK_M = 0.25;
|
|
143
|
+
/** The point the standing seek was last issued for — see rule 2. */
|
|
144
|
+
const sought = { x: Infinity, z: Infinity };
|
|
145
|
+
let cooldown = 0;
|
|
146
|
+
|
|
147
|
+
const zombieBehaviour: AIBehaviour = (npc, dt) => {
|
|
148
|
+
cooldown = Math.max(cooldown - dt, 0);
|
|
149
|
+
const self = npc.position;
|
|
150
|
+
const planar = Math.hypot(body.position.x - self.x, body.position.z - self.z);
|
|
151
|
+
|
|
152
|
+
// RULE 1 — read `arrived` BEFORE re-seeking.
|
|
153
|
+
if (npc.arrived && planar < ATTACK_RANGE_M && cooldown === 0) {
|
|
154
|
+
startAttack(); // your wind-up state machine — see the attack doctrine below
|
|
155
|
+
cooldown = ATTACK_COOLDOWN_S;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// RULE 2 — re-seek only once the target actually MOVED.
|
|
159
|
+
if (Math.hypot(body.position.x - sought.x, body.position.z - sought.z) > RESEEK_M) {
|
|
160
|
+
sought.x = body.position.x;
|
|
161
|
+
sought.z = body.position.z;
|
|
162
|
+
npc.seek(body.position, { arriveM: 1.2 }); // arriveM = the stop radius (default 0.6); sprint: true runs
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const zombie = await npcs.add({
|
|
167
|
+
id: 'zombie', at: { x: -15, y: 0.2, z: -15 }, body: 'physics',
|
|
168
|
+
behaviour: zombieBehaviour, name: 'Zombie', nameColor: '#ff5c5c',
|
|
169
|
+
config: { locomotion: { runSpeed: 3.2 } }, // per-NPC config, merged OVER the spawn this scene derives from `at`
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
> **Rule 1 — read `arrived` before you re-seek, in the same behaviour.** `seek()` clears the arrived flag, so a
|
|
174
|
+
> behaviour that seeks first and checks after reads `false` **every single frame** and the attack never fires
|
|
175
|
+
> once. The symptom is an NPC that chases perfectly and is completely harmless.
|
|
176
|
+
|
|
177
|
+
> **Rule 2 — re-seek only when the target actually moved.** `seek()` also resets the driver's stuck-progress
|
|
178
|
+
> window, so re-issuing it every frame silently disables the sideways commit that rounds a wall: the NPC grinds
|
|
179
|
+
> into the corner forever. (The A* plan itself is *not* the cost — a routed seek keeps its path unless the
|
|
180
|
+
> target drifted more than 1 m, exactly so a behaviour can chase a shuffling player without replanning per
|
|
181
|
+
> frame. The stuck detector is what you break.) A 0.25 m threshold is plenty.
|
|
182
|
+
|
|
183
|
+
**What the driver does for you, and what it does not.** Built in: whisker obstacle avoidance (three rays from
|
|
184
|
+
0.9 m eye height, 2.5 m lookahead, ±35°, turning harder the closer the hit) and a **stuck commit** — when a
|
|
185
|
+
seek makes less than 0.35 m of progress in 1.5 s the NPC commits sideways for 1.2 s along the freer whisker,
|
|
186
|
+
which is what gets it around an L-shaped wall. Those numbers are the shipped defaults and `npcs.add()` does not
|
|
187
|
+
expose them; a world that must retune them constructs an `AIDriver` itself and hands it over with
|
|
188
|
+
`character.setDriver(...)`. Not built in: attacking, patrolling, fleeing, dialogue, aggro, factions. All of
|
|
189
|
+
that is your `behaviour` over the surface above.
|
|
190
|
+
|
|
191
|
+
> ## THE ATTACK DOCTRINE — the animation never causes damage
|
|
192
|
+
>
|
|
193
|
+
> A clip is a *picture* of an attack. The behaviour owns the attack: a small state machine of **wind-up →
|
|
194
|
+
> damage window → recovery**, each phase timed off `dt` against numbers you chose to match the clip, with the
|
|
195
|
+
> damage applied on exactly one tick inside the window. Drive the visual with
|
|
196
|
+
> `zombie.character.playAnimation('zombie-swipe')` (a world-authored pose-DSL gesture — construct the scene
|
|
197
|
+
> with `gestures` and read `read_doc({ name: "character-animation" })`), but never hang a hit off the
|
|
198
|
+
> animation's own timing: a clip that fades, blends or gets interrupted takes the hit with it, and two NPCs
|
|
199
|
+
> mid-swipe are two different frames of the same clip.
|
|
200
|
+
>
|
|
201
|
+
> **Solo damage is `character.setHealth(...)` on the PLAYER** — requires `character: { health: { enabled: true,
|
|
202
|
+
> maxHealth: 100 } }` (§8f of the character recipe), which brings `died`/`revived`/`healthChanged` and the
|
|
203
|
+
> death ragdoll with it. **Multiplayer damage is never client-side** — it is a server rule; see §6.
|
|
204
|
+
|
|
205
|
+
## 3. Pathfinding — `NavGrid` *(humanoid-character ≥ 0.3.13)*
|
|
206
|
+
|
|
207
|
+
Without a grid a seek steers straight at its target and leans on the whiskers plus the stuck commit. With one,
|
|
208
|
+
the seek is **routed**: A* over a rasterized walkable grid, string-pulled to a short waypoint list, with the
|
|
209
|
+
whiskers still live over the route for whatever the grid cannot know about.
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
// Rasterized off the SAME collider roster staticsFrom mirrors into each NPC's private world — one source of
|
|
213
|
+
// truth for what is walkable, so the planner can never promise a route the capsule cannot take.
|
|
214
|
+
const nav = NavGrid.fromColliders(body.describeColliders());
|
|
215
|
+
const npcs = new NpcScene({ scene, assets, staticsFrom: body, nav });
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The scene's `nav` is inherited by **`'physics'` NPCs only** (the only tier that walks). A per-NPC `nav` on
|
|
219
|
+
`add()` wins over it, *including* an explicit `nav: null`, which means "no routing even though the scene has a
|
|
220
|
+
grid".
|
|
221
|
+
|
|
222
|
+
**v1 is single-plane, and honest about it.** Defaults: `cellM` 0.5, `agentRadiusM` 0.4, `walkableY` 0,
|
|
223
|
+
`floorBandM` 0.5, `stepM` 0.35, `heightM` 1.8. Colliders whose top sits within `floorBandM` of `walkableY`
|
|
224
|
+
are **floor** (and floor classification wins, so a kerb inside the step band stays walkable instead of punching
|
|
225
|
+
a hole in the map); anything overlapping the body band blocks. Only **cuboids** rasterize, and only
|
|
226
|
+
axis-aligned or **yaw**-rotated ones — a tilted cuboid has no planar footprint. Everything else is skipped with
|
|
227
|
+
one warning naming what it dropped:
|
|
228
|
+
|
|
229
|
+
```
|
|
230
|
+
NavGrid: skipped 2× trimesh, 1× ball — v1 rasterizes yaw-aligned cuboids only, so routes over that geometry degrade to direct steering
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**Degradation is silent by design.** A route the grid cannot make (endpoints in different components, a target
|
|
234
|
+
outside the floor bounds, geometry that never rasterized) leaves the plan null and the seek steers direct — no
|
|
235
|
+
throw, no log. So when an NPC beelines into a wall, **check the console for that warn first**; the world is
|
|
236
|
+
usually made of trimesh, or of a second storey the grid cannot represent.
|
|
237
|
+
|
|
238
|
+
> **THE INFLATION CONSEQUENCE — corridors need more than `2 × agentRadiusM + cellM`.** Every blocker is
|
|
239
|
+
> inflated by `agentRadiusM` plus half a cell's support (0.4 + 0.25 = **0.65 m** per side at defaults) before it
|
|
240
|
+
> is stamped, so no cell centre survives in a gap narrower than **1.3 m** — that doorway rasterizes shut, and
|
|
241
|
+
> the route through it degrades to direct steering. Author doorways and alleys at 1.5 m or wider, or lower
|
|
242
|
+
> `cellM`/`agentRadiusM` deliberately and accept the cost (a finer grid is quadratically more cells;
|
|
243
|
+
> `fromColliders` throws past a 1024 × 1024 budget). Size the geometry with `world_metrics` before you place
|
|
244
|
+
> it — the player's own capsule and corridor numbers are the floor for the NPC's.
|
|
245
|
+
|
|
246
|
+
Both endpoints of `findPath(from, to)` are **clamped onto the grid** first, so a target standing on a counter
|
|
247
|
+
resolves to the floor beside it and `null` genuinely means "these two ends are disconnected". `clampToWalkable(p)`
|
|
248
|
+
answers the same question for a spawn point before you place an NPC on it, and `npc.path` reads the live plan
|
|
249
|
+
(cell centres, goal included) — read it, never hold it.
|
|
250
|
+
|
|
251
|
+
## 4. Vault characters as NPC bodies *(humanoid-character ≥ 0.3.13)*
|
|
252
|
+
|
|
253
|
+
An NPC does not have to wear the platform body. Any Vault **character** whose rig is a helix-humanoid can be
|
|
254
|
+
the model, and the platform's shared locomotion clips drive it — the flow is search → verify the rig → install
|
|
255
|
+
→ load → pass as `model`.
|
|
256
|
+
|
|
257
|
+
1. **Search with `match: 'narrow'`** — it makes the query a hard FILTER, so an empty result is the honest
|
|
258
|
+
answer to "does the Vault have a zombie?" instead of the nearest confidently-irrelevant rows:
|
|
259
|
+
`search_assets({ query: "zombie", kinds: ["character"], match: "narrow", engine: "web" })`.
|
|
260
|
+
2. **Read the row before you install.** `skeleton` says which rig the character carries,
|
|
261
|
+
`skeletonVerification` says whether the publish gate actually MEASURED that, and
|
|
262
|
+
`character.boneCount` / `character.skinned` say whether there is a rig at all. The browser runtime plays
|
|
263
|
+
humanoid rigs — a verified humanoid skeleton is what makes a character web-playable, and this row is the
|
|
264
|
+
cheapest place to find that out.
|
|
265
|
+
3. **Install it** — `install_asset({ directory, assetId })` downloads the exact immutable version, verifies
|
|
266
|
+
the checksum and writes provenance. The file lands at `public/assets/vault/<assetId>/<slug>.glb`.
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
import { loadVaultCharacter } from '@helix/humanoid-character';
|
|
270
|
+
|
|
271
|
+
// Resolve against the document — a root-absolute '/assets/...' 404s in production (character recipe §9).
|
|
272
|
+
const url = new URL('assets/vault/<assetId>/<slug>.glb', document.baseURI).href;
|
|
273
|
+
const { model } = await loadVaultCharacter(url, { renderer, transcoderPath: TRANSCODER_PATH });
|
|
274
|
+
await npcs.add({ id: 'zombie-1', at: SPAWN, body: 'physics', behaviour: zombieBehaviour, model });
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**The skeleton gate is the whole point.** Platform clips bind by bone NAME, so a foreign rig assembles
|
|
278
|
+
perfectly and then stands there in its bind pose forever. Instead of that silent T-pose, `loadVaultCharacter`
|
|
279
|
+
**throws** and names the fix — recognize this message and route to `import_character` (the CLI's
|
|
280
|
+
`helix character import`), which conforms the mesh onto the canonical skeleton at its own proportions and
|
|
281
|
+
generates the LOD chain; then install the converted result:
|
|
282
|
+
|
|
283
|
+
```
|
|
284
|
+
loadVaultCharacter: <url> is not a helix-humanoid rig — missing canonical bones: pelvis, hand_l. Convert it first (helix character import), then install it into the world.
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
The bones it checks are the core of `helix-humanoid@1`: `root`, `pelvis`, `spine_01`, `head`, `hand_l`,
|
|
288
|
+
`hand_r`, `foot_l`, `foot_r` — few enough that a legitimately decimated LOD export still passes.
|
|
289
|
+
|
|
290
|
+
A multi-scene GLB assembles as an LOD chain (scene *i* = level *i*, one skeleton, one mixer). A single-scene one
|
|
291
|
+
loads as-is with `lod: null` and one warning — worth fixing for a crowd, harmless for one shopkeeper:
|
|
292
|
+
|
|
293
|
+
```
|
|
294
|
+
loadVaultCharacter: <url> is a single-scene GLB — no LOD chain, so its full-detail mesh renders at every distance. Re-export with one scene per LOD level.
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Two more real details: **one model per NPC** — `NpcScene` clones the shared `assets.model` for every NPC that
|
|
298
|
+
passes no `model`, but a model you *pass* is used as-is, so give each NPC its own load (handing the same object
|
|
299
|
+
to two `add()` calls puts one body in two places). And when the page already built a character loader, pass
|
|
300
|
+
`assetIO` so the whole page shares **one** KTX2 transcoder:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
const io = createCharacterAssetIO({ renderer, transcoderPath: TRANSCODER_PATH });
|
|
304
|
+
const assets = await loadCharacterAssets(SYSTEM_ASSET_BASE, { io });
|
|
305
|
+
const guard = await loadVaultCharacter(url, { assetIO: io });
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## 4a. Armed NPCs — a guard that shoots back *(humanoid-character ≥ 0.3.13 · `gun-control` ≥ 0.3 · asset pack `humanoid-character-assets` ≥ 0.1.6)*
|
|
309
|
+
|
|
310
|
+
An NPC can carry a **real** gun: the same `gun-control` ability, the same weapon profile, the same FX and hit
|
|
311
|
+
systems the player's gun runs on — the gun contract itself is `read_doc({ name: "shooter-worlds" })`, and this
|
|
312
|
+
section is only what changes when the shooter is an NPC. The engine poses the body, plays the effects and
|
|
313
|
+
resolves the ray. **When it pulls the trigger and what a hit costs are yours**, in the same behaviour loop §2
|
|
314
|
+
put the zombie's attack in.
|
|
315
|
+
|
|
316
|
+
**Arming happens inside `decorate`, in REPLICA mode, with the `net:*` keys registered up front:**
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
import {
|
|
320
|
+
AttachmentService, GunFxSystem, GunHitSystem, applyWeaponProfile, createCharacterAssetIO, hitBearingDeg,
|
|
321
|
+
loadInstalledAbilities, presetForCategory, resolveWeaponProfile, type NpcHandle,
|
|
322
|
+
} from '@helix/humanoid-character';
|
|
323
|
+
|
|
324
|
+
const GUN_URL = new URL('props/weapons/Pistol/Gaston.glb', document.baseURI).href; // never root-absolute
|
|
325
|
+
const OWNER = 'guard-brain'; // blackboard owner tag for the keys this world synthesizes
|
|
326
|
+
const profile = resolveWeaponProfile(GUN_URL); // null = nothing resolved for that BASENAME (shooter-worlds §3)
|
|
327
|
+
let fx: GunFxSystem; // built here, advanced in the frame loop below
|
|
328
|
+
|
|
329
|
+
const arm = async (npc: NpcHandle): Promise<void> => {
|
|
330
|
+
await loadInstalledAbilities(modulesBaseUrl, npc.character); // the ONLY window — see the three rules below
|
|
331
|
+
npc.character.services.config.set('gun-control.remoteDriven', true);
|
|
332
|
+
const bb = npc.character.blackboard;
|
|
333
|
+
for (const key of ['net:gunDrawn', 'net:aiming', 'net:reloading']) bb.register(key, 'boolean', OWNER);
|
|
334
|
+
bb.register('net:shotSeq', 'number', OWNER);
|
|
335
|
+
bb.set('net:shotSeq', 0); // THE TRAP: the first observation only ANCHORS — start it here, count at fire time
|
|
336
|
+
const attachments = new AttachmentService(npc.character.services.sockets!, {
|
|
337
|
+
io: createCharacterAssetIO({ renderer }), // the system's own IO — a bare GLTFLoader drops KTX2/meshopt props
|
|
338
|
+
gestures: npc.character.gestures,
|
|
339
|
+
});
|
|
340
|
+
const held = await attachments.attach(GUN_URL, 'hand_r.grip', { preset: presetForCategory(profile!.category) });
|
|
341
|
+
applyWeaponProfile(npc.character, profile!); // cadence, clip, spread, grip family, placement — never hand-authored
|
|
342
|
+
fx = new GunFxSystem({ scene, camera, soundsBaseUrl, audio, flashPool, shells }); // the world's SHARED pools
|
|
343
|
+
fx.attachTo(npc.character);
|
|
344
|
+
fx.setWeapon(GUN_URL, held.object); // BOTH halves — an FX system without setWeapon is silently inert
|
|
345
|
+
bb.set('net:gunDrawn', true); // on duty: the gun stays out, so engaging only changes the aim
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const guard = await npcs.add({
|
|
349
|
+
id: 'guard', at: POST_A, body: 'physics', name: 'Guard',
|
|
350
|
+
// Catching here keeps an unarmable guard patrolling; let it throw and add() REJECTS with no NPC at all.
|
|
351
|
+
decorate: (npc) => arm(npc).catch((error: unknown) => console.warn('guard could not be armed:', error)),
|
|
352
|
+
});
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
- **`remoteDriven`, never local mode.** The local lane's ADS reaches for the camera rig and a headless NPC has
|
|
356
|
+
none, so it throws on the first aim. The replica lane drives the identical stance/recoil/reload one-shots off
|
|
357
|
+
four blackboard keys instead — which is exactly the seam a behaviour can write.
|
|
358
|
+
- **In `decorate`, nothing later.** `decorate` is awaited before the NPC joins the tick, and the first tick
|
|
359
|
+
starts its `AbilityManager`; a started manager refuses every install, so "right after `add()`" is already too
|
|
360
|
+
late.
|
|
361
|
+
- **Register the `net:*` keys yourself.** On a player replica a `NetworkDriver` registers and writes them; an
|
|
362
|
+
NPC has no network lane, so your world **is** the driver. **`net:shotSeq` swallows its first observation on
|
|
363
|
+
purpose** (a late joiner must not replay a stranger's magazine) — register it at 0 in `decorate` and
|
|
364
|
+
increment it at fire time, or the first shot is anchored away instead of fired.
|
|
365
|
+
|
|
366
|
+
**One set of FX pools per WORLD, never per NPC.** `GunAudio`, `MuzzleFlashPool` and `ShellEjector` are built
|
|
367
|
+
once and injected into every `GunFxSystem` (the player's and each armed NPC's); their update is dt-driven, so a
|
|
368
|
+
second system that also advances them decays every flash and shell twice as fast, and `GunAudio` opens an
|
|
369
|
+
AudioContext browsers cap around six. Give the extra systems no-op `update`/`dispose` wrappers around the
|
|
370
|
+
shared three — the same pattern the shooter templates use for replicas.
|
|
371
|
+
|
|
372
|
+
**The behaviour loop owns the trigger.** `aimAt(point)` is the only driver surface a gun needs: a **sticky** aim
|
|
373
|
+
triad the NPC holds while it keeps walking wherever `seek` sent it, so a guard patrols and tracks you at once
|
|
374
|
+
(precedence `aimAt` > `face` > move heading; `null` clears it). Everything else is a counter in your tick:
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
// The gun's own numbers, read as DEFAULTS — a deliberate guard fires slower than its rpm, and may say so.
|
|
378
|
+
const FIRE_COOLDOWN_S = Math.max(0.02, 60 / profile!.fire.rpm);
|
|
379
|
+
const MAGAZINE = profile!.ammo.clip;
|
|
380
|
+
const RELOAD_S = guard.character.services.config.get<number>('gun-control.reloadDuration');
|
|
381
|
+
let clip = MAGAZINE, fireT = 0, reloadT = 0, shots = 0;
|
|
382
|
+
|
|
383
|
+
const eye = new THREE.Vector3(), chest = new THREE.Vector3(), dir = new THREE.Vector3(); // scratch, reused
|
|
384
|
+
|
|
385
|
+
/** Clear line from the eye to the point it wants to shoot — the SAME statics the shot ray resolves against. */
|
|
386
|
+
const losClear = (from: THREE.Vector3, to: THREE.Vector3): boolean => {
|
|
387
|
+
const d = from.distanceTo(to);
|
|
388
|
+
return d < 1e-3 || body.castRay(from, dir.copy(to).sub(from).divideScalar(d), d) === null;
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
/** Runs AFTER npcs.update(dt): the aim, the proxy camera and the ray all read THIS frame's transforms. */
|
|
392
|
+
const guardTick = (dt: number): void => {
|
|
393
|
+
const bb = guard.character.blackboard;
|
|
394
|
+
const self = guard.driver.position;
|
|
395
|
+
eye.set(self.x, self.y + 1.65, self.z); // the NPC's eye
|
|
396
|
+
chest.set(body.position.x, body.position.y + 1.4, body.position.z); // the point on you it aims at
|
|
397
|
+
const engaged = eye.distanceTo(chest) <= ENGAGE_RANGE_M && losClear(eye, chest); // your range, your LoS policy
|
|
398
|
+
if (engaged) { guard.driver.stop(); guard.driver.aimAt(chest); } // re-issued, so it tracks you
|
|
399
|
+
else { guard.driver.aimAt(null); if (guard.driver.arrived) guard.driver.seek(nextPost(), { arriveM: 0.8 }); }
|
|
400
|
+
bb.set('net:aiming', engaged);
|
|
401
|
+
|
|
402
|
+
reloadT = Math.max(0, reloadT - dt);
|
|
403
|
+
if (reloadT === 0 && bb.get<boolean>('net:reloading')) { bb.set('net:reloading', false); clip = MAGAZINE; }
|
|
404
|
+
if (!engaged || reloadT > 0) return;
|
|
405
|
+
fireT = Math.max(0, fireT - dt);
|
|
406
|
+
if (fireT > 0) return;
|
|
407
|
+
if (clip === 0) { bb.set('net:reloading', true); reloadT = RELOAD_S; return; } // the reload one-shot plays itself
|
|
408
|
+
clip -= 1;
|
|
409
|
+
fireT = FIRE_COOLDOWN_S;
|
|
410
|
+
bb.set('net:shotSeq', (shots += 1)); // MONOTONIC: the ability reacts to the DELTA and re-anchors on anything else
|
|
411
|
+
};
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
**Who controls how this guard fights? You do — all of it.** The engine owns exactly three things: the **pose**
|
|
415
|
+
(stance, aim, recoil, the reload one-shot), the **FX** (flash, sound, shells, tracer) and the **hit math**
|
|
416
|
+
(spread cone, capsule + head narrow phase, the wall ray). Every knob a player would call behaviour is a number
|
|
417
|
+
in your loop:
|
|
418
|
+
|
|
419
|
+
| Knob | Where it lives |
|
|
420
|
+
|---|---|
|
|
421
|
+
| **fire cadence, bursts, hesitation** | when your tick increments `net:shotSeq`. `profile.fire.rpm` is a default to read, not a rule |
|
|
422
|
+
| **damage per hit** | whatever `onPlayerHit` subtracts below. The profile's number ×2 on a head is a CONVENTION, not policy |
|
|
423
|
+
| **magazine & reload timing** | your counter and your timer, gated by `net:reloading`; `ammo.clip` / `gun-control.reloadDuration` are defaults |
|
|
424
|
+
| **engage range** | your distance test — there is no aggro system |
|
|
425
|
+
| **line of sight** | your `castRay` policy, including "no LoS check at all" |
|
|
426
|
+
| **which gun** | any prop URL; the profile resolves by BASENAME (`aliasWeaponProfile` first for a hashed CDN URL) |
|
|
427
|
+
| **accuracy** | `config.set('gun-control.spreadScale', …)` on that NPC — the authored dial into its cone |
|
|
428
|
+
|
|
429
|
+
**Damage back at the player — one `GunHitSystem` per armed NPC.** It traces from a *camera*, and a headless NPC
|
|
430
|
+
has none, so give it a proxy that stands at the eye and looks down the aim:
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
const DAMAGE = profile!.damage.base; // a default to READ — what a hit costs is the world's call
|
|
434
|
+
const HEAD_MULTIPLIER = profile!.damage.headMultiplier; // ×2 on a head is a convention, not platform policy
|
|
435
|
+
const hp = (): number => character.blackboard.get<number>('health');
|
|
436
|
+
const headAt = new THREE.Vector3(); // scratch for the head read — consumed before the next
|
|
437
|
+
// Never added to the scene — nothing renders through it; it exists to carry an origin and a direction.
|
|
438
|
+
const guardEye = new THREE.PerspectiveCamera();
|
|
439
|
+
|
|
440
|
+
const gunHit = new GunHitSystem({
|
|
441
|
+
camera: guardEye,
|
|
442
|
+
// The PLAYER's RapierBody: the only one carrying the level, so walls block the shot — and it excludes its own
|
|
443
|
+
// capsule, so the player is hit as a CANDIDATE, never as a wall. A 'stand' NPC's body has no statics at all.
|
|
444
|
+
body,
|
|
445
|
+
walkSpeedMps: guard.character.services.config.get<number>('locomotion.walkSpeed'), // live config, never literals
|
|
446
|
+
maxSpeedMps: guard.character.services.config.get<number>('locomotion.runSpeed'),
|
|
447
|
+
muzzle: (out) => fx.muzzleWorld(out), // arms the near-hit muzzle veto (shooter-worlds §4)
|
|
448
|
+
players: function* () { // the SHOOTER is the NPC, so the player is a legal candidate
|
|
449
|
+
if (character.blackboard.get<boolean>('dead')) return;
|
|
450
|
+
yield {
|
|
451
|
+
id: 'player',
|
|
452
|
+
position: character.model.position, // FEET
|
|
453
|
+
crouched: body.isCrouched,
|
|
454
|
+
ragdolled: character.ragdoll?.active ?? false,
|
|
455
|
+
// getWorldPosition through the ancestors — the character tick leaves matrixWorld stale (§4b aims off this).
|
|
456
|
+
head: () => character.services.sockets?.anchorOf('head')?.getWorldPosition(headAt) ?? null,
|
|
457
|
+
};
|
|
458
|
+
},
|
|
459
|
+
onPlayerHit: ({ claim, part, point }) => {
|
|
460
|
+
if (!claim) return; // exactly ONE claim per trigger pull
|
|
461
|
+
character.hitReaction.setHint(hitBearingDeg(body.facingYaw, character.model.position, point)); // §4b
|
|
462
|
+
character.setHealth(hp() - DAMAGE * (part === 'head' ? HEAD_MULTIPLIER : 1)); // YOUR number, not the engine's
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
gunHit.attachTo(guard.character); // latches `gunFired`, which the replica lane emits per shotSeq delta
|
|
466
|
+
gunHit.setWeapon(GUN_URL);
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
**The frame order is a contract, exactly as it is for a player's gun** — everything gun resolves after the
|
|
470
|
+
character ticks that moved the bodies:
|
|
471
|
+
|
|
472
|
+
```ts
|
|
473
|
+
character.update(dt); // the player
|
|
474
|
+
npcs.update(dt); // every NPC (behaviours run inside)
|
|
475
|
+
spots.update(dt);
|
|
476
|
+
guardTick(dt); // the attack machine, on transforms both ticks just settled
|
|
477
|
+
guardEye.position.copy(eye); guardEye.lookAt(chest); // the proxy rides the aim, so the traced shot IS the aim
|
|
478
|
+
gunHit.update(dt); // the ray fires against THIS frame's player transform
|
|
479
|
+
fx.update(dt); // latched shots resolve at THIS frame's muzzle
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
> **`character.health.enabled` is OFF by default — on the player and on every NPC.** Without
|
|
483
|
+
> `character: { health: { enabled: true, maxHealth: 100 } }` on whoever is being shot, `setHealth` publishes
|
|
484
|
+
> nothing, no `healthChanged` fires and no flinch plays: the guard aims, the muzzle flashes, the brass ejects,
|
|
485
|
+
> and the hit lands on nobody. Turn it on for anything that can be hurt — a shootable NPC needs it too.
|
|
486
|
+
|
|
487
|
+
**Publish the attack machine's phase through `npcState`** (§1) — `{ phase: engaged ? 'engaging' : 'patrol',
|
|
488
|
+
clip, reloading }` — and every `inspect_npc` sample carries it (§5). That timeline is how you see a guard that
|
|
489
|
+
engages and never fires (a swallowed `shotSeq`) or one that never leaves reload, neither of which a screenshot
|
|
490
|
+
can show.
|
|
491
|
+
|
|
492
|
+
## 4b. Hit reactions — the flinch layer *(humanoid-character ≥ 0.3.13 · asset pack `humanoid-character-assets` ≥ 0.1.6 — the ten hit takes)*
|
|
493
|
+
|
|
494
|
+
**Every character already carries it and it is on by default** — the player, a replica, an NPC. Any health
|
|
495
|
+
DECREASE the body survives plays a directional one-shot **additively** over whatever it is already doing
|
|
496
|
+
(walking, aiming, mid-emote), so nothing about the gait or the grip is replaced. There is nothing to register
|
|
497
|
+
and nothing to wire: `setHealth` is the trigger.
|
|
498
|
+
|
|
499
|
+
- **Aim it with a bearing.** `character.hitReaction.setHint(bearingDeg)` **before** the `setHealth` that fires
|
|
500
|
+
it (that flinch consumes the hint): 0 = the hit came from ahead, +90 = the victim's right, ±180 = behind.
|
|
501
|
+
Compute it from the hit point with the exported `hitBearingDeg(facingYaw, victimPosition, from)` — the impact
|
|
502
|
+
point stands in for the shooter, because it is always on the side the ray came in from. Without a hint the
|
|
503
|
+
flinch falls through to the front family. **In multiplayer you author no bearing at all**: the platform
|
|
504
|
+
derives it from the attacker's seat when your damage rule writes the `lastHitBy` playerVar — declare that var
|
|
505
|
+
and set it in the rule, as the shooter templates already do.
|
|
506
|
+
- **Scripted flinches** ignore health entirely: `character.hitReaction.play({ direction: 'back', intensity:
|
|
507
|
+
'heavy' })` (or `{ bearingDeg }`) for a shove, a melee that costs no hp, a cutscene beat.
|
|
508
|
+
- **Tune with `character.hitReactions.*`** — `enabled`, `weight` (0-1, how much reaches the body), `blendInS` /
|
|
509
|
+
`blendOutS`, `cooldownS` (the pellet-storm guard: several damage events in one frame must not re-trigger at
|
|
510
|
+
frame 0 forever) and the `lightFraction` / `mediumFraction` damage thresholds the auto-trigger buckets on.
|
|
511
|
+
Read them from `get_package_manifest("humanoid-character")`, never guess.
|
|
512
|
+
- **The coverage is honest: HEAVY is authored on the front only.** Heavy from a side or behind degrades to that
|
|
513
|
+
direction's medium rather than turning into a front flinch — the direction reads louder than the force. The
|
|
514
|
+
killing blow never flinches (the death stagger and the ragdoll own that body) and neither does a limp one.
|
|
515
|
+
|
|
516
|
+
> **The FIRST health change per character is deliberately swallowed.** It is a multiplayer seeding guard — a
|
|
517
|
+
> late joiner arriving at 40 hp must not flinch on frame one — and it applies to a solo world too, so the
|
|
518
|
+
> session's first hit on a given body does not react. A world that cares burns the seed at spawn with a
|
|
519
|
+
> scratch: `setHealth(maxHealth - 1)` (swallowed) then `setHealth(maxHealth)` (an increase never flinches), and
|
|
520
|
+
> every real hit after it reacts.
|
|
521
|
+
|
|
522
|
+
## 5. Budgets & verification *(humanoid-character ≥ 0.3.13)*
|
|
523
|
+
|
|
524
|
+
**The budget is 16 live NPCs (`maxNpcs`), and going past it throws:**
|
|
525
|
+
|
|
526
|
+
```
|
|
527
|
+
NpcScene.add('guard-9'): NPC budget full (maxNpcs = 16) — remove one or raise maxNpcs
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
That is a refusal, not a silent degrade, because an NPC that never appears is a world-logic bug and you want to
|
|
531
|
+
find it at `add()` rather than in a screenshot. The number is real cost, not ceremony: **each NPC is a full
|
|
532
|
+
skinned character** — its own model clone, anim graph, mixer, router and driver, plus a private Rapier world for
|
|
533
|
+
every `'physics'` one. Raising `maxNpcs` is a decision about your frame budget; a crowd of standing NPCs is far
|
|
534
|
+
cheaper than a pack of hunting ones. Every refusal (duplicate id, full budget, physics without geometry) is
|
|
535
|
+
decided **before the first await**, so a rejected `add()` has built and added nothing:
|
|
536
|
+
|
|
537
|
+
```
|
|
538
|
+
NpcScene.add: an NPC with id 'zombie' already exists
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
Housekeeping is symmetric: `npcs.get(id)` / `has` / `list()` / `ids()` / `count` read the pool, `remove(id)`
|
|
542
|
+
tears one down completely (false if the id is unknown), `dispose()` frees all of them — model, body, private
|
|
543
|
+
router, plate — and `unregister()` from `spots.register(...)` retires the pill that followed one.
|
|
544
|
+
|
|
545
|
+
**Verifying NPCs** *(humanoid-character ≥ 0.3.13; `inspect_npc` and `capture_npc` additionally need the world
|
|
546
|
+
**rebuilt** since §1's `world.ready({ …, npcs, npcState })` line — a bundle built before it exposes no NPC
|
|
547
|
+
surface, and both tools fail naming the exact line and whether a reinstall is needed first)*:
|
|
548
|
+
|
|
549
|
+
**Measure first, look second — the order is a rule, not a preference.** Every way an NPC goes wrong here is
|
|
550
|
+
*silent*, and none of them is visible in a still frame: the chaser that never attacks (rule 1), the one grinding
|
|
551
|
+
into a corner (rule 2), an NPC on `body: 'stand'` that physically cannot walk, a NavGrid that degraded to direct
|
|
552
|
+
steering without a word (§3). A screenshot shows a character standing in a room in all four cases.
|
|
553
|
+
|
|
554
|
+
1. **`inspect_npc({ directory: "<world>/dist", seconds: 10, target: [x, y, z] })`** — boots the built world
|
|
555
|
+
headlessly, watches it run, and reports per NPC: distance actually **walked** vs distance actually **gained**
|
|
556
|
+
(the progress ratio — 1.00 is a straight line), start/closest/final distance to `target` and how much ground
|
|
557
|
+
it **closed**, **stuck windows** on the driver's own rule (under 0.35 m in 1.5 s — the same stall its sideways
|
|
558
|
+
commit fires on), the **`arrived` edges** with timestamps, how much of the run held a planned nav path at all,
|
|
559
|
+
and the **locomotion + `npcState` timelines**. Pass `npc: "zombie"` to scope it to one.
|
|
560
|
+
2. **Read the three that decide whether the behaviour works.** *Did it close on the target?* — a chase that moves
|
|
561
|
+
and never closes is rule 1. *Any stuck windows?* — rule 2, or a doorway the grid rasterized shut. *Was it ever
|
|
562
|
+
on a path?* — `nav path: 0%` while it moves means the grid produced no route and nobody said so.
|
|
563
|
+
3. **`capture_npc({ directory: "<world>/dist", npc: "zombie" })`** — only once the numbers pass. A captioned
|
|
564
|
+
filmstrip of the running world: each frame carries the sample taken at that same instant, so "T-posing at
|
|
565
|
+
4 s" is answerable. It answers *presence, body, pose, surroundings* and nothing about distance — and it has
|
|
566
|
+
no camera control, so the frames are the world's own view, not a shot framed on the NPC.
|
|
567
|
+
4. **`world_metrics({ projectDir })`** — **before** you place the geometry an NPC has to walk through: the step
|
|
568
|
+
height, slope and corridor clearances it reports for the player are the same envelope the NPC's capsule has,
|
|
569
|
+
and they are what §3's corridor rule is measured against.
|
|
570
|
+
|
|
571
|
+
A failed capture or inspection is still diagnostic: page errors come back in the timeout message, which is where
|
|
572
|
+
a `NpcScene.add` throw will be.
|
|
573
|
+
|
|
574
|
+
## 6. Multiplayer — where NpcScene stops *(humanoid-character ≥ 0.3.13)*
|
|
575
|
+
|
|
576
|
+
**`NpcScene` NPCs are client-local.** Nothing about them crosses the wire: each client that loads the world
|
|
577
|
+
builds its own copy and runs its own behaviour. For a **stand** NPC that is exactly right — it never moves, so
|
|
578
|
+
every client agrees about it for free, and a shopkeeper with a Talk spot needs no server involvement at all.
|
|
579
|
+
For anything that *moves*, it is exactly wrong: four clients each run their own zombie chasing their own
|
|
580
|
+
player, and no two players see the same fight. The multiplayer answer is to move the brain to the room and keep
|
|
581
|
+
the humanoid as pure **rendering** — the enemy becomes a declared entity, and
|
|
582
|
+
`humanoidEntities({ scene, assets: mp.assets })` is the `mp.entities` build fn that renders each one as a full
|
|
583
|
+
headless character, deriving its locomotion animation from the replicated motion. Damage follows the same
|
|
584
|
+
split: the entity carries a declared **zone** and a `zoneEnter` rule subtracts health server-side — the client
|
|
585
|
+
never writes health, and the attack animation still causes nothing.
|
|
586
|
+
|
|
587
|
+
```
|
|
588
|
+
read_template({ name: "npc-wave" })
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
## The rules that matter (NPCs)
|
|
592
|
+
|
|
593
|
+
- **Hand the pool to `world.ready`, then MEASURE before you look.** `world.ready({ scene, body, spawn, npcs,
|
|
594
|
+
npcState })` is what makes `inspect_npc` possible at all; every NPC failure mode on this system is silent, so
|
|
595
|
+
numbers first and `capture_npc` last (§5).
|
|
596
|
+
- **An NPC is a Character whose driver is your behaviour.** Same chassis, same config, same abilities — do not
|
|
597
|
+
hand-roll a "monster" out of meshes and lerps, and do not invent an NPC API. Read `capabilities.api` from
|
|
598
|
+
`get_package_manifest("humanoid-character")`.
|
|
599
|
+
- **Read `arrived` before you re-seek, and re-seek only when the target moved.** The first mistake makes a
|
|
600
|
+
harmless chaser; the second makes one that grinds into every wall. Both are silent.
|
|
601
|
+
- **The animation never causes damage.** The behaviour owns wind-up → damage window → recovery; solo damage is
|
|
602
|
+
`setHealth`, multiplayer damage is a server rule.
|
|
603
|
+
- **An armed NPC is `gun-control` in REPLICA mode, armed inside `decorate`.** `remoteDriven` (local mode's ADS
|
|
604
|
+
wants a camera an NPC has not got) plus the `net:*` keys you register yourself — `net:shotSeq` at 0, because
|
|
605
|
+
its first observation only anchors. Cadence, damage, magazine, range, LoS and accuracy are all your loop's;
|
|
606
|
+
the engine owns the pose, the FX and the hit math (§4a).
|
|
607
|
+
- **Flinches are automatic on any survived health drop, on every character.** `setHint(bearingDeg)` before
|
|
608
|
+
`setHealth` aims one; the first health change per body is swallowed by design, and `character.health.enabled`
|
|
609
|
+
is off until you turn it on (§4b).
|
|
610
|
+
- **`body: 'stand'` never walks** (a seek on it is inert; `teleport` is the only move) and **`body: 'physics'`
|
|
611
|
+
needs `staticsFrom`** — the scene refuses without it.
|
|
612
|
+
- **One source of truth for the level.** Build the nav grid from the same `describeColliders()` roster
|
|
613
|
+
`staticsFrom` mirrors, or the planner will route through a wall the capsule cannot pass.
|
|
614
|
+
- **NavGrid v1 is single-plane, yaw-aligned cuboids only, and degrades to direct steering in silence.** Check
|
|
615
|
+
the skipped-geometry warn before theorising, and keep corridors wider than `2 × agentRadiusM + cellM` (1.3 m
|
|
616
|
+
at defaults).
|
|
617
|
+
- **Verify a Vault rig before you install it** (`skeleton` / `skeletonVerification` on the search row) and
|
|
618
|
+
recognize the convert-first throw: `import_character` first, install second.
|
|
619
|
+
- **16 NPCs is the default budget and add() throws past it.** Each one is a full skinned character; a raised
|
|
620
|
+
cap is a frame-budget decision.
|
|
621
|
+
- **Loop order: player → NPCs → spots.** The press edge and the pill anchors both depend on it.
|
|
622
|
+
- **NpcScene NPCs never replicate.** Anything that moves and must be shared belongs to the room —
|
|
623
|
+
`read_template({ name: "npc-wave" })`.
|