@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
package/docs/character-world.md
CHANGED
|
@@ -12,6 +12,25 @@ lock side-on movement to one axis — see §8a).
|
|
|
12
12
|
> For a bare scene with no character (a spinning object, a data viz, a menu), use the **scene**
|
|
13
13
|
> recipe instead: `get_started({ kind: "scene" })`.
|
|
14
14
|
|
|
15
|
+
> ## SCAFFOLD FIRST — DO NOT HAND-WRITE THESE FILES
|
|
16
|
+
>
|
|
17
|
+
> **Call `scaffold_world` and run the `helix init` command it returns.** It writes all nine files —
|
|
18
|
+
> `package.json`, `vite.config.ts`, `tsconfig.json`, `index.html`, `.gitignore`, `public/helix.json`,
|
|
19
|
+
> `src/helix.runtime.ts`, `src/loading.ts`, and a **runnable `src/main.ts`** — already correct and already
|
|
20
|
+
> building. Existing files are skipped by default, so it is safe to run inside an existing project.
|
|
21
|
+
>
|
|
22
|
+
> **Everything below is EXPLANATION, not source to copy.** The snippets show what each generated file
|
|
23
|
+
> means and how to change it. An agent that types them out instead of scaffolding reproduces the exact
|
|
24
|
+
> failure this recipe exists to prevent: the last time one did, it got four API calls wrong against the
|
|
25
|
+
> installed `.d.ts` (`loadCharacterAssets` takes the asset base **positionally**; `RapierBody.create` is a
|
|
26
|
+
> static factory with a private constructor; `Character.create` takes `{model, camera, domElement, body,
|
|
27
|
+
> input}`; the world needs a floor collider) and shipped a world that built, validated, and rendered
|
|
28
|
+
> nothing. **The scaffold is the source of truth. Read the installed `.d.ts` before any call you are
|
|
29
|
+
> unsure of — never this prose.**
|
|
30
|
+
>
|
|
31
|
+
> Then: `npm install` → `install_world_packages` → `npm run build` → `validate_world` →
|
|
32
|
+
> **`helix verify-subpath dist`** → `publish_world` (§9).
|
|
33
|
+
|
|
15
34
|
## 0. Discover what's published FIRST — don't assume
|
|
16
35
|
|
|
17
36
|
The catalog grows. Before you design anything, **fetch the latest** so you reuse what already exists
|
|
@@ -29,27 +48,37 @@ shooting → `gun-control`) before writing any custom behavior.
|
|
|
29
48
|
|
|
30
49
|
## 1. Project layout
|
|
31
50
|
|
|
51
|
+
`helix init` writes every file marked ✎ below. `helix install` fills in the rest.
|
|
52
|
+
|
|
32
53
|
```
|
|
33
54
|
my-world/
|
|
34
|
-
├── package.json
|
|
35
|
-
├── vite.config.ts
|
|
36
|
-
├── tsconfig.json
|
|
37
|
-
├── index.html
|
|
55
|
+
├── package.json ✎
|
|
56
|
+
├── vite.config.ts ✎
|
|
57
|
+
├── tsconfig.json ✎
|
|
58
|
+
├── index.html ✎
|
|
59
|
+
├── .gitignore ✎ node_modules/, dist/, public/helix_modules/
|
|
38
60
|
├── helix.lock.json ← written by `helix install`; commit it
|
|
39
61
|
├── public/
|
|
40
|
-
│ ├── helix.json
|
|
62
|
+
│ ├── helix.json ✎ the v0.2 manifest (pins); lands at dist/ root on build
|
|
41
63
|
│ └── helix_modules/ ← written by `helix install` (code only); gitignored
|
|
42
64
|
└── src/
|
|
43
|
-
├── main.ts
|
|
44
|
-
├── loading.ts
|
|
45
|
-
└── helix.runtime.ts
|
|
65
|
+
├── main.ts ✎ a RUNNABLE starter — edit it, don't replace it
|
|
66
|
+
├── loading.ts ✎ the loading screen (count-based progress bar); restyle/extend freely (§8b)
|
|
67
|
+
└── helix.runtime.ts ✎ stub, then overwritten by `helix install`; committed (do not hand-edit)
|
|
46
68
|
```
|
|
47
69
|
|
|
48
70
|
## 2. package.json
|
|
49
71
|
|
|
50
72
|
`three` is a **devDependency**: it is external at runtime (the platform hosts the single shared
|
|
51
73
|
instance), but Vite needs it at build time to bundle the GLTFLoader/KTX2Loader addons + for types.
|
|
52
|
-
`@dimforge/rapier3d-compat` (the physics engine)
|
|
74
|
+
`@dimforge/rapier3d-compat` (the physics engine) works the same way — a devDependency for types and
|
|
75
|
+
`helix dev`, import-mapped to the platform-hosted instance at runtime. It never bundles.
|
|
76
|
+
|
|
77
|
+
**Keep the three devDependency on the platform's line — `0.185.1` today.** The core comes from the import
|
|
78
|
+
map, but the `three/examples/jsm/...` addons bundle out of THIS copy and `helix dev` runs it, so a stale
|
|
79
|
+
devDep ships mismatched halves and makes local dev disagree with the published build. The system pin is what
|
|
80
|
+
decides the hosted version (`humanoid-character` `^0.3` → three 0.185.1); new publishes targeting an older
|
|
81
|
+
three are refused — `read_doc({ name: "upgrades" })` has the migration for an existing world.
|
|
53
82
|
|
|
54
83
|
```json
|
|
55
84
|
{
|
|
@@ -57,8 +86,8 @@ instance), but Vite needs it at build time to bundle the GLTFLoader/KTX2Loader a
|
|
|
57
86
|
"private": true,
|
|
58
87
|
"type": "module",
|
|
59
88
|
"scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", "typecheck": "tsc --noEmit" },
|
|
60
|
-
"dependencies": { "@
|
|
61
|
-
"devDependencies": { "@types/three": "^0.
|
|
89
|
+
"dependencies": { "@hypersoniclabs/helix-sdk": "{{SDK_DEP_SPEC}}" },
|
|
90
|
+
"devDependencies": { "@dimforge/rapier3d-compat": "^0.14.0", "@types/three": "^0.185.1", "three": "^0.185.1", "typescript": "~5.7.3", "vite": "^6.0.0" }
|
|
62
91
|
}
|
|
63
92
|
```
|
|
64
93
|
|
|
@@ -67,25 +96,48 @@ The system itself is NOT an npm dependency — `helix install` materializes it u
|
|
|
67
96
|
|
|
68
97
|
## 3. vite.config.ts
|
|
69
98
|
|
|
70
|
-
|
|
71
|
-
|
|
99
|
+
**`base: './'` is REQUIRED and is the single most important line in this file.** A published world is
|
|
100
|
+
served from a nested path (`…/instant-worlds/<id>/<build>/`), never the origin root, so root-absolute
|
|
101
|
+
asset URLs 404 in production while working perfectly in local preview. A relative base is what makes one
|
|
102
|
+
build work in both places.
|
|
103
|
+
|
|
104
|
+
Externalize the **bare `three`** so the world, the embedded system, and every ability share the
|
|
105
|
+
one hosted instance (two copies of three break `instanceof` and silently corrupt rendering), and
|
|
106
|
+
`@dimforge/rapier3d-compat` so the ~2 MB physics engine loads once from the platform, not per world.
|
|
72
107
|
`three/examples/jsm/...` is NOT externalized — Vite bundles the addons from your `node_modules` three.
|
|
73
108
|
|
|
109
|
+
The scaffold writes the two aliases a starter world uses. Add the others **only when you import them** —
|
|
110
|
+
the `engine-core` SUBPATH entries must always precede any root `@helix/engine-core` entry, because rollup
|
|
111
|
+
substitutes by `'/'`-boundary prefix in object order and a root alias listed first mangles the subpath
|
|
112
|
+
import into an ENOENT.
|
|
113
|
+
|
|
74
114
|
```ts
|
|
75
115
|
import { defineConfig } from 'vite';
|
|
76
116
|
import { fileURLToPath } from 'node:url';
|
|
77
117
|
|
|
78
118
|
export default defineConfig({
|
|
79
|
-
base: './',
|
|
119
|
+
base: './', // ← REQUIRED; `helix verify-subpath dist` proves it
|
|
80
120
|
resolve: {
|
|
81
121
|
alias: {
|
|
122
|
+
// Written by the scaffold:
|
|
123
|
+
'@helix/engine-core/inspect': fileURLToPath(new URL('./public/helix_modules/engine-core/inspect/index.js', import.meta.url)),
|
|
82
124
|
'@helix/humanoid-character': fileURLToPath(new URL('./public/helix_modules/humanoid-character/index.js', import.meta.url)),
|
|
125
|
+
// Add when you use them (screenshots — read_doc "screenshots"; subpaths BEFORE the root entry):
|
|
126
|
+
// '@helix/engine-core/screenshot': fileURLToPath(new URL('./public/helix_modules/engine-core/screenshot/index.js', import.meta.url)),
|
|
127
|
+
// '@helix/engine-core/world-camera': fileURLToPath(new URL('./public/helix_modules/engine-core/world-camera/index.js', import.meta.url)),
|
|
128
|
+
// '@helix/engine-core': fileURLToPath(new URL('./public/helix_modules/engine-core/index.js', import.meta.url)),
|
|
83
129
|
},
|
|
84
130
|
},
|
|
85
|
-
build: {
|
|
131
|
+
build: {
|
|
132
|
+
target: 'es2022',
|
|
133
|
+
rollupOptions: { external: (id) => id === 'three' || id === '@dimforge/rapier3d-compat' || id.startsWith('@helix/') },
|
|
134
|
+
},
|
|
86
135
|
});
|
|
87
136
|
```
|
|
88
137
|
|
|
138
|
+
Keep that `rollupOptions` line **byte-identical and on one line** — `helix install` matches it literally, and any
|
|
139
|
+
reformatting (an equivalent multi-line form included) makes it classify the world `legacy-bundled` and publish fail.
|
|
140
|
+
|
|
89
141
|
## 4. tsconfig.json
|
|
90
142
|
|
|
91
143
|
Point the type path at the embedded system's declarations (installed alongside the code).
|
|
@@ -95,7 +147,12 @@ Point the type path at the embedded system's declarations (installed alongside t
|
|
|
95
147
|
"compilerOptions": {
|
|
96
148
|
"target": "ES2022", "module": "ESNext", "moduleResolution": "bundler",
|
|
97
149
|
"lib": ["ES2022", "DOM"], "strict": true, "noEmit": true, "skipLibCheck": true,
|
|
98
|
-
"paths": {
|
|
150
|
+
"paths": {
|
|
151
|
+
"@helix/humanoid-character": ["./public/helix_modules/humanoid-character/index.d.ts"],
|
|
152
|
+
"@helix/engine-core": ["./public/helix_modules/engine-core/index.d.ts"],
|
|
153
|
+
"@helix/engine-core/world-camera": ["./public/helix_modules/engine-core/world-camera/index.d.ts"],
|
|
154
|
+
"@helix/engine-core/screenshot": ["./public/helix_modules/engine-core/screenshot/index.d.ts"]
|
|
155
|
+
}
|
|
99
156
|
},
|
|
100
157
|
"include": ["src"]
|
|
101
158
|
}
|
|
@@ -104,9 +161,9 @@ Point the type path at the embedded system's declarations (installed alongside t
|
|
|
104
161
|
## 5. index.html
|
|
105
162
|
|
|
106
163
|
Ship the `helix:three` import-map markers exactly as below — `helix install` rewrites the import map
|
|
107
|
-
to the hosted three
|
|
108
|
-
loading screen: it paints on HTML parse (before any JS), and `src/loading.ts` drives its
|
|
109
|
-
(§8b). Keep the markers and the overlay; restyle the overlay freely.
|
|
164
|
+
to the hosted three and Rapier URLs. Leave the placeholder; do not fill it by hand. The `#loading`
|
|
165
|
+
overlay is the loading screen: it paints on HTML parse (before any JS), and `src/loading.ts` drives its
|
|
166
|
+
progress bar (§8b). Keep the markers and the overlay; restyle the overlay freely.
|
|
110
167
|
|
|
111
168
|
```html
|
|
112
169
|
<!doctype html>
|
|
@@ -138,6 +195,9 @@ loading screen: it paints on HTML parse (before any JS), and `src/loading.ts` dr
|
|
|
138
195
|
#loading-text { margin-top: 12px; color: #9aa7b4; }
|
|
139
196
|
@keyframes helix-marquee { 0% { transform: translateX(-110%); } 100% { transform: translateX(370%); } }
|
|
140
197
|
@keyframes helix-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
198
|
+
/* HUD — the player shell overlays its chrome bar across the TOP-CENTER (~top 56px: Exit / Save / helixOS).
|
|
199
|
+
Keep your HUD OUT of that strip: anchored top-left here; use a corner or the bottom, never top-center. */
|
|
200
|
+
#hud { position: fixed; top: 12px; left: 12px; z-index: 10; max-width: min(340px, 40vw); font: 13px/1.5 system-ui, sans-serif; color: #e6edf5; }
|
|
141
201
|
</style>
|
|
142
202
|
</head>
|
|
143
203
|
<body>
|
|
@@ -158,9 +218,9 @@ loading screen: it paints on HTML parse (before any JS), and `src/loading.ts` dr
|
|
|
158
218
|
## 6. public/helix.json — pin what you discovered in step 0
|
|
159
219
|
|
|
160
220
|
`helixVersion: "0.2"` opts into systems/abilities. Pin the system + every ability you want as
|
|
161
|
-
`slug -> semver range` (
|
|
162
|
-
ability
|
|
163
|
-
system
|
|
221
|
+
`slug -> semver range` (the system is `^0.3` unless `get_package_manifest` shows a newer line — and pin each
|
|
222
|
+
ability at the version `get_package_manifest` reports for that system line: an ability's bundle declares the
|
|
223
|
+
system range it was built against and refuses to load at runtime outside it). `helix install`
|
|
164
224
|
resolves each range to an exact version and bakes it into `helix.lock.json`.
|
|
165
225
|
|
|
166
226
|
```json
|
|
@@ -173,11 +233,16 @@ resolves each range to an exact version and bakes it into `helix.lock.json`.
|
|
|
173
233
|
"permissions": ["auth.profile"],
|
|
174
234
|
"supportsMobile": true,
|
|
175
235
|
"contentRating": "everyone",
|
|
176
|
-
"systems": { "humanoid-character": "^0.2" },
|
|
177
|
-
"abilities": { "fly": "^0.
|
|
236
|
+
"systems": { "humanoid-character": "^0.3", "engine-core": "^0.1.2" },
|
|
237
|
+
"abilities": { "fly": "^0.3", "swim": "^0.3" }
|
|
178
238
|
}
|
|
179
239
|
```
|
|
180
240
|
|
|
241
|
+
**Keep the `engine-core` pin the scaffold wrote.** It is what makes `inspect_world`, `world_metrics`
|
|
242
|
+
and `capture_world_screenshot` work on this world. A world published without it cannot be measured or
|
|
243
|
+
screenshotted later without a pin edit + reinstall + rebuild + republish — `check_for_updates` reports
|
|
244
|
+
its absence as `missing-recommended` precisely because retrofitting it is the expensive path.
|
|
245
|
+
|
|
181
246
|
## 7. src/helix.runtime.ts — stub (helix install overwrites it)
|
|
182
247
|
|
|
183
248
|
```ts
|
|
@@ -188,57 +253,131 @@ export const TRANSCODER_PATH = '/runtime/basis/';
|
|
|
188
253
|
export const SYSTEM_ASSET_BASE = '';
|
|
189
254
|
```
|
|
190
255
|
|
|
256
|
+
These are **placeholders**. `helix install` replaces all four with absolute platform-CDN URLs
|
|
257
|
+
(`https://…/runtime/three/<v>/three.module.js`, `https://…/runtime/basis/three-<v>/`, the system's asset
|
|
258
|
+
base). They are the one place root-absolute-looking values are fine before install, because none of them
|
|
259
|
+
survives it — never hand-edit this file, and never hardcode a `/runtime/...` path of your own.
|
|
260
|
+
|
|
191
261
|
## 8. src/main.ts — the flow
|
|
192
262
|
|
|
263
|
+
**`helix init` already wrote a working `src/main.ts`.** Read that file, then edit it. What follows is
|
|
264
|
+
the same flow annotated, so you understand what you are changing — it is not a file to type out.
|
|
265
|
+
|
|
193
266
|
`loadCharacterAssets` streams the body/face LOD + locomotion clips from the CDN (never bundled);
|
|
194
267
|
`Character.create` drives the chassis; `LocomotionAbility` is the built-in movement; everything you
|
|
195
268
|
pinned loads via `loadInstalledAbilities`. With the `avatar` option, a logged-in player's equipped
|
|
196
269
|
**universal avatar** becomes their body automatically — skeleton-gated and fault-tolerant (guests, no
|
|
197
270
|
avatar, an unconverted rig, or any load failure all fall back to the default body; the world never
|
|
198
|
-
breaks).
|
|
271
|
+
breaks). **To opt a single-player world out, delete the `avatar:` line** — that is the whole opt-out, because in the bare-`Character` recipe the world resolves the avatar itself. Do *not* reach for `character.universalAvatar.enabled: false` for this: that is the native feature set's TOP SWITCH and it also turns off emotes, the emote wheel, x-ray, pointing, sitting and the avatar camera (§8g).
|
|
272
|
+
|
|
273
|
+
**Changing the player's avatar: there is ONE function — `mp.changeAvatar(...)`** *(humanoid-character ≥ 0.3.90 / 0.2.69)*.
|
|
274
|
+
```ts
|
|
275
|
+
const outcome = await mp.changeAvatar(inventoryItemId); // an avatar instance the player OWNS
|
|
276
|
+
await mp.changeAvatar(null); // take it off → the platform default body
|
|
277
|
+
// outcome: 'changed' | 'unchanged' | 'refused' | 'unreachable' | 'unsupported'
|
|
278
|
+
```
|
|
279
|
+
It is the same call the quick-slot ring and a Home's equip station make. It puts the avatar placeholder up at
|
|
280
|
+
once, persists the change through the platform (which checks ownership and keeps the player to one body),
|
|
281
|
+
swaps the body when the new avatar loads, and hints the room so **every other player sees it** — and it
|
|
282
|
+
survives a reload. An equip station in your world is just a prompt whose action calls this. **Do not** build
|
|
283
|
+
your own: no `Helix.avatar.updateLoadout` to change the body, no loading a GLB onto the local character, no
|
|
284
|
+
hand-rolled placeholder or room message. There is deliberately no local-only variant — other players see only
|
|
285
|
+
what the platform says the player wears. Details: `helix-web-engine-new/docs/architecture/avatar-change.md`.
|
|
286
|
+
|
|
287
|
+
**Mid-room avatar swaps are transparent — do not write swap code.** When the player equips a different
|
|
288
|
+
avatar mid-session, the platform rebuilds the body behind a stable façade: `mp.local` keeps its identity
|
|
289
|
+
(it is a `LocalCharacter` seat, not the raw `Character`), attachments re-bind to the same handles, ability
|
|
290
|
+
installs replay, and runtime config — ammo included — carries over. Everything wired through
|
|
291
|
+
`mp.attach(...)` / `mp.local` (events, services, combat systems) keeps working untouched. Do NOT rebuild
|
|
292
|
+
on `Helix.avatar.onAvatarChanged` — it fires pre-swap and debounced, the wrong hook. The one legitimate
|
|
293
|
+
use of a swap hook is body-INTERNAL wiring: something you `model.add(...)`ed onto the old body, a held IK
|
|
294
|
+
reach handle, a listener registered on a per-body object. For those, subscribe ONCE to
|
|
295
|
+
`mp.onLocalSwap(cb)`: `{ phase: 'started', url }` fires while the old body is still live (checkpoint
|
|
296
|
+
window), `{ phase: 'ended', url, recovered, props }` after the rebuild — `props` lists each surviving
|
|
297
|
+
attachment as `{ handle, previousObject }` (same handle, re-bound; `previousObject` is your key for
|
|
298
|
+
re-pointing any world-side caches). It rides the seat, so one subscription hears every swap.
|
|
199
299
|
|
|
200
300
|
```ts
|
|
201
301
|
import * as THREE from 'three';
|
|
202
302
|
import { Helix } from '@hypersoniclabs/helix-sdk';
|
|
203
|
-
import { Character, loadCharacterAssets, loadInstalledAbilities, LocomotionAbility, RapierBody } from '@helix/humanoid-character';
|
|
303
|
+
import { Character, InputService, loadCharacterAssets, loadInstalledAbilities, LocomotionAbility, RapierBody } from '@helix/humanoid-character';
|
|
304
|
+
import { world } from '@helix/engine-core/inspect';
|
|
204
305
|
import { SYSTEM_ASSET_BASE, TRANSCODER_PATH } from './helix.runtime';
|
|
205
306
|
import { createLoadingScreen } from './loading'; // loading screen — hooks DefaultLoadingManager (§8b)
|
|
206
307
|
|
|
308
|
+
const SPAWN = { x: 0, y: 0.2, z: 0 };
|
|
309
|
+
|
|
207
310
|
const scene = new THREE.Scene();
|
|
311
|
+
scene.background = new THREE.Color(0x14161a); // an unset background renders as a void
|
|
208
312
|
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.05, 200);
|
|
209
313
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
210
314
|
renderer.setSize(innerWidth, innerHeight);
|
|
315
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
211
316
|
document.body.appendChild(renderer.domElement);
|
|
317
|
+
|
|
318
|
+
// LIGHTING: the two hand-placed lights below are the CLASSIC starter — what the scaffold still writes,
|
|
319
|
+
// and what a world that keeps its own visual style (a deliberate opt-out) ships. The PLATFORM DEFAULT is
|
|
320
|
+
// the visual runtime: hand it the canvas, declare how the world is lit in public/helix.visuals.json, and
|
|
321
|
+
// delete both lights AND the renderer block above — createVisualRuntime owns the renderer, sky, sun,
|
|
322
|
+
// tone mapping and grade, and bounded static worlds then ship BAKED lighting at publish. The conversion
|
|
323
|
+
// is five mechanical steps: read_doc({ name: "lighting-world" }). If you keep the classic path:
|
|
324
|
+
// ambient fill ALONE leaves a dark character on a dark background — add a key light, or the world
|
|
325
|
+
// renders as a near-empty frame and `verify-subpath` fails it on blank-render (correctly).
|
|
326
|
+
// Either lane, making the world LOOK good — asset checks, materials, textures, composition —
|
|
327
|
+
// is read_doc({ name: "world-look" }).
|
|
212
328
|
scene.add(new THREE.HemisphereLight(0xbfd4ff, 0x40382a, 0.9));
|
|
329
|
+
const sun = new THREE.DirectionalLight(0xffffff, 2.2);
|
|
330
|
+
sun.position.set(6, 12, 4);
|
|
331
|
+
scene.add(sun);
|
|
213
332
|
|
|
214
333
|
// Mount the loading screen before any loader runs — it hooks THREE.DefaultLoadingManager so every asset
|
|
215
334
|
// load below (character, abilities, any extra GLB you add) counts on the bar automatically. See §8b.
|
|
216
335
|
const loading = createLoadingScreen();
|
|
217
336
|
|
|
218
337
|
// Init FIRST — the body is bind-once, so the player's avatar must resolve before Character.create.
|
|
219
|
-
|
|
220
|
-
|
|
338
|
+
// Fail-soft: off-platform (a plain `npm run preview`) there is no session and the world must still run.
|
|
339
|
+
await Helix.init().catch(() => undefined);
|
|
340
|
+
const equipped = await Helix.avatar.getEquipped().catch(() => null); // null for guests / no avatar / any failure
|
|
221
341
|
|
|
342
|
+
// NOTE the POSITIONAL signature: the asset base is the FIRST argument, not a field on an options
|
|
343
|
+
// object. Passing an object here fails deep inside the loader with "t.replace is not a function".
|
|
222
344
|
const { model, clips } = await loadCharacterAssets(SYSTEM_ASSET_BASE, {
|
|
223
345
|
renderer, transcoderPath: TRANSCODER_PATH,
|
|
224
346
|
avatar: equipped?.glbUrl ? { url: equipped.glbUrl, skeleton: equipped.skeleton } : undefined,
|
|
225
347
|
});
|
|
226
348
|
scene.add(model);
|
|
227
349
|
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
|
|
350
|
+
// RapierBody.create is a static async factory — the constructor is PRIVATE, `new RapierBody()` will not
|
|
351
|
+
// compile. The world needs a floor collider or the character falls forever through an empty scene.
|
|
352
|
+
const body = await RapierBody.create({ position: SPAWN });
|
|
353
|
+
body.addStaticCuboid({ x: 24, y: 0.5, z: 24 }, { x: 0, y: -0.5, z: 0 }); // floor COLLIDER, top at y=0
|
|
231
354
|
|
|
232
|
-
|
|
233
|
-
|
|
355
|
+
// …and a floor you can SEE. The collider above is invisible: physics geometry and visual geometry are
|
|
356
|
+
// separate, and a world with only the collider renders as a character floating in a void.
|
|
357
|
+
const floor = new THREE.Mesh(
|
|
358
|
+
new THREE.BoxGeometry(48, 1, 48),
|
|
359
|
+
new THREE.MeshStandardMaterial({ color: 0x2a3138, roughness: 0.95 }),
|
|
360
|
+
);
|
|
361
|
+
floor.position.y = -0.5;
|
|
362
|
+
scene.add(floor);
|
|
363
|
+
|
|
364
|
+
// ONE world-owned InputService, injected into the character — register every game key on this router,
|
|
365
|
+
// never a raw addEventListener (§8c).
|
|
366
|
+
const input = new InputService();
|
|
234
367
|
|
|
235
368
|
const character = await Character.create({
|
|
236
|
-
model, camera, domElement: renderer.domElement, body,
|
|
369
|
+
model, camera, domElement: renderer.domElement, body, input,
|
|
237
370
|
// FIRST-PERSON: set the initial camera mode. Omit for the third-person default.
|
|
238
|
-
config: { character: { camera: { mode: 'first-person' } } },
|
|
371
|
+
config: { character: { spawn: SPAWN, camera: { mode: 'first-person' } } },
|
|
239
372
|
});
|
|
240
373
|
character.abilities.register(new LocomotionAbility(clips));
|
|
241
|
-
|
|
374
|
+
|
|
375
|
+
// Everything `helix install` resolved (fly, swim, … from helix.json). RESOLVE THE MODULES BASE AGAINST
|
|
376
|
+
// document.baseURI. A published world is served from a nested path (…/instant-worlds/<id>/<build>/), so a
|
|
377
|
+
// root-absolute '/helix_modules' 404s in production and THROWS — the world renders nothing but an error
|
|
378
|
+
// bar, while local preview from the origin root looks perfect. `helix verify-subpath dist` proves it.
|
|
379
|
+
const modulesBase = new URL('helix_modules/', document.baseURI).href;
|
|
380
|
+
await loadInstalledAbilities(modulesBase, character);
|
|
242
381
|
|
|
243
382
|
// First-person mouse-look needs pointer lock — request it from a click (browser gesture rule).
|
|
244
383
|
renderer.domElement.addEventListener('click', () => character.services.input.requestLook());
|
|
@@ -248,10 +387,22 @@ let firstFrame = true;
|
|
|
248
387
|
renderer.setAnimationLoop(() => {
|
|
249
388
|
character.update(Math.min(clock.getDelta(), 0.1));
|
|
250
389
|
renderer.render(scene, camera);
|
|
251
|
-
if (firstFrame) { firstFrame = false; loading.
|
|
390
|
+
if (firstFrame) { firstFrame = false; loading.done(); } // world on screen → fade the overlay
|
|
252
391
|
});
|
|
253
392
|
```
|
|
254
393
|
|
|
394
|
+
**One uncaught throw inside this callback stops rendering FOREVER** — three.js schedules the next frame
|
|
395
|
+
only after the callback returns, so a single bad per-frame read ships as "the world froze" (audio keeps
|
|
396
|
+
playing, the last frame stays on screen). Guard reads of state that may not exist yet
|
|
397
|
+
(`blackboard.has(key)` before `get`, optional chaining on services) — the loop is the one place a throw
|
|
398
|
+
has no second chance.
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
// Measurement hook for inspect_world / world_metrics verification (§10). A no-op during normal play —
|
|
402
|
+
// it only activates under ?helixInspect=1. Call it at the END of boot, once the scene is built.
|
|
403
|
+
world.ready({ scene, body, spawn: SPAWN });
|
|
404
|
+
```
|
|
405
|
+
|
|
255
406
|
## 8a. Configure the character — read the knobs from the manifest, NEVER guess
|
|
256
407
|
|
|
257
408
|
**This is the step agents get wrong.** Speed, camera, spawn, slope, jump-feel — almost everything a
|
|
@@ -275,8 +426,10 @@ const character = await Character.create({
|
|
|
275
426
|
model, camera, domElement: renderer.domElement, body,
|
|
276
427
|
config: {
|
|
277
428
|
character: {
|
|
278
|
-
|
|
279
|
-
camera
|
|
429
|
+
// 0° = +Z; 90° = +X. With initialYaw omitted/null, 0.2.21+ derives a stable
|
|
430
|
+
// behind-character camera (spawn facing - 180°), including after respawn.
|
|
431
|
+
spawn: { x: 4, z: -2, facingDeg: 90 },
|
|
432
|
+
camera: { mode: 'third-person', lookSensitivity: 1.4, fov: 70 },
|
|
280
433
|
},
|
|
281
434
|
locomotion: { runSpeed: 7, coyoteTimeMs: 120 },
|
|
282
435
|
// ability namespaces too, once pinned & installed: fly: { maxSpeed: 14 }
|
|
@@ -284,6 +437,13 @@ const character = await Character.create({
|
|
|
284
437
|
});
|
|
285
438
|
```
|
|
286
439
|
|
|
440
|
+
> **Spawn facing is durable in `humanoid-character` 0.2.21+.** `character.spawn.facingDeg` is body
|
|
441
|
+
> yaw (`0 = +Z`, `90 = +X`). Leave `character.camera.initialYaw` omitted or `null` and the engine
|
|
442
|
+
> derives the matching behind-character boom yaw as `facingDeg - 180`; locomotion no longer erases
|
|
443
|
+
> that authored facing, and `character.respawn()` restores both body and camera. Set a numeric
|
|
444
|
+
> `initialYaw` only when you intentionally want a camera seed independent of spawn facing. Camera
|
|
445
|
+
> yaw is boom azimuth: `0` places the camera on `+Z` looking toward `-Z`.
|
|
446
|
+
|
|
287
447
|
**2. Runtime — `character.config.set('ns.key', value)`.** Read live every tick; the change applies
|
|
288
448
|
immediately. This is how you do timed buffs, difficulty changes, "look at the boss", etc.
|
|
289
449
|
|
|
@@ -330,6 +490,27 @@ character.camera?.addTrauma(0.6); // camera shake for a hit
|
|
|
330
490
|
speed }` — `impactSpeed` is vertical, use it for fall damage), `fell`, `respawned`, `jumped`,
|
|
331
491
|
`stateEntered`. The full list + payloads are in `capabilities.events`.
|
|
332
492
|
|
|
493
|
+
### Where the character is FACING — read the model, never the camera
|
|
494
|
+
|
|
495
|
+
The camera is NOT the character. In third person it orbits the character freely (and in top-down it is
|
|
496
|
+
fixed), so any "is the player facing X" check built from the camera's position or direction is wrong in
|
|
497
|
+
every framing except first-person. The character's authoritative forward is its model yaw (0 = +Z):
|
|
498
|
+
|
|
499
|
+
```ts
|
|
500
|
+
const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(character.model.quaternion);
|
|
501
|
+
const to = obj.position.clone().sub(character.model.position);
|
|
502
|
+
to.y = 0; fwd.y = 0; // horizontal test — height must not affect a facing gate
|
|
503
|
+
const facingIt = to.lengthSq() > 1e-8 &&
|
|
504
|
+
to.normalize().dot(fwd.normalize()) >= Math.cos(THREE.MathUtils.degToRad(50)); // ±50° prompt window
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
This is the same math the runtime uses internally. **For IK reaches, do not hand-roll it at all** —
|
|
508
|
+
`ik.reach(hand, target, { engage: {} })` already gates on the reaching arm's workspace arc (midline to
|
|
509
|
+
~135° on that arm's side, ≥ 0.2.17) plus distance, and auto-releases when the player turns or walks
|
|
510
|
+
away; a one-off `press` gates itself the same way with `press: { when: {} }` (≥ 0.2.16 — a failed gate
|
|
511
|
+
fires nothing and the handle reports `fired: false`). See the character-animation doc's Runtime IK
|
|
512
|
+
section. Reserve the snippet above for your own prompts and UI ("press E to interact").
|
|
513
|
+
|
|
333
514
|
### Lock the controls to the genre — disable what doesn't fit (don't ship every control everywhere)
|
|
334
515
|
|
|
335
516
|
Every `allow*` gate **defaults to `true`** (all controls enabled). So you must **opt OUT** of the ones
|
|
@@ -342,10 +523,10 @@ the manifest `config`):
|
|
|
342
523
|
|
|
343
524
|
| gate | removes |
|
|
344
525
|
|---|---|
|
|
345
|
-
| `character.camera.allowModeToggle` | first
|
|
526
|
+
| `character.camera.allowModeToggle` | the first↔third-person legs of the camera cycle (the **T** action cycles FP → TP → shoulder-R → shoulder-L) |
|
|
346
527
|
| `character.camera.allowRotate` | orbit / mouse-look (fixed camera angle) |
|
|
347
528
|
| `character.camera.allowZoom` | scroll-wheel zoom |
|
|
348
|
-
| `character.camera.allowShoulderSwap` | over-shoulder
|
|
529
|
+
| `character.camera.allowShoulderSwap` | the over-shoulder legs of the camera cycle (**V** is the melee action, not the camera) |
|
|
349
530
|
| `locomotion.allowJump` / `allowCrouch` / `allowSprint` | the jump / crouch / sprint actions |
|
|
350
531
|
|
|
351
532
|
Genre presets (set these at create time, then add the rest of your tuning):
|
|
@@ -366,6 +547,8 @@ config: { locomotion: { allowJump: false } }
|
|
|
366
547
|
|
|
367
548
|
You can also flip these live (e.g. disable controls during a cutscene): `character.config.set('character.camera.allowModeToggle', false)`.
|
|
368
549
|
|
|
550
|
+
The two camera legs are also **native features** with their own refusal ids — `character.native.disabled: ['camera-first-person']` makes the world third-person only and sets `mode` + `allowModeToggle` for you, so every surface that reads the camera policy agrees. Refusing BOTH throws at create. See §8g for the full native set and the rest of the veto.
|
|
551
|
+
|
|
369
552
|
### First-person vs third-person (a slice of the camera config)
|
|
370
553
|
|
|
371
554
|
- Initial mode: `config.character.camera.mode` = `'first-person'` or `'third-person'` (default).
|
|
@@ -376,86 +559,25 @@ You can also flip these live (e.g. disable controls during a cutscene): `charact
|
|
|
376
559
|
## 8b. Loading screen — `src/loading.ts` (count-based progress, extensible)
|
|
377
560
|
|
|
378
561
|
The `#loading` overlay in `index.html` paints on HTML parse (before any JS downloads → no flash).
|
|
379
|
-
`src/loading.ts`
|
|
380
|
-
GLTFLoader/KTX2Loader use (one shared
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
compile, character setup), then fades on the first rendered frame. Ship this file verbatim; restyle freely.
|
|
562
|
+
`src/loading.ts` — **written by `helix init`; open that file, do not retype it** — hooks
|
|
563
|
+
**`THREE.DefaultLoadingManager`**, the SAME instance the engine's GLTFLoader/KTX2Loader use (one shared
|
|
564
|
+
three via the import map). So the character, every ability, and ANY extra three load you add count on the
|
|
565
|
+
bar automatically, with no extra code. Restyle it freely.
|
|
384
566
|
|
|
385
|
-
|
|
386
|
-
|
|
567
|
+
The scaffolded module exports exactly this surface — **match it; there is no `dismiss`, `working`,
|
|
568
|
+
`label` or `fail`**:
|
|
387
569
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
// + CSS live in index.html so it paints before this module downloads (no flash). Restyle/extend freely.
|
|
393
|
-
|
|
394
|
-
const BAND_MAX = 0.9; // the measured climb fills [0, 0.9]; the last tenth is the post-load tail + first frame
|
|
395
|
-
const QUEUE_MIN = 5; // go indeterminate→measured only once a real wave is queued (total-loaded ≥ this), so the
|
|
396
|
-
// brief body+face 2/2 blip can't fill the bar then snap backward when the 38 clips enqueue
|
|
397
|
-
|
|
398
|
-
export interface LoadingScreen {
|
|
399
|
-
/** Fold a NON-three async asset into the bar (counts like one item). Call before the work; handle.done() when it resolves. */
|
|
400
|
-
task(): { done(): void };
|
|
401
|
-
/** Set the status line under the bar. */
|
|
402
|
-
label(text: string): void;
|
|
403
|
-
/** Enter the indeterminate "Starting…" tail — covers the un-metered window (Rapier WASM compile, character setup). */
|
|
404
|
-
working(text?: string): void;
|
|
570
|
+
```ts
|
|
571
|
+
export type LoadingScreen = {
|
|
572
|
+
/** Register an extra async unit of work so it counts on the bar. Returns its completion callback. */
|
|
573
|
+
task: (label?: string) => () => void;
|
|
405
574
|
/** One-shot fade + remove. Call AFTER the first renderer.render(), never on manager.onLoad (it fires early). */
|
|
406
|
-
|
|
575
|
+
done: () => void;
|
|
407
576
|
/** Switch to the error state (red bar + message). */
|
|
408
|
-
|
|
409
|
-
}
|
|
577
|
+
error: (message: string) => void;
|
|
578
|
+
};
|
|
410
579
|
|
|
411
|
-
export function createLoadingScreen(): LoadingScreen
|
|
412
|
-
const root = document.getElementById('loading');
|
|
413
|
-
const bar = document.getElementById('bar');
|
|
414
|
-
const textEl = document.getElementById('loading-text');
|
|
415
|
-
const noop = (): void => {};
|
|
416
|
-
if (!root || !bar || !textEl) return { task: () => ({ done: noop }), label: noop, working: noop, dismiss: noop, fail: noop };
|
|
417
|
-
|
|
418
|
-
const mgr = THREE.DefaultLoadingManager;
|
|
419
|
-
let determinate = false; // latched once a real queue appears; never reverts (no marquee↔fill flicker)
|
|
420
|
-
let tail = false; // working() latched — ignore further manager ticks; the pulse owns the bar
|
|
421
|
-
let shown = 0; // last rendered fraction — monotonic, never decreases
|
|
422
|
-
let extra = 0; // open task() handles, folded into the denominator
|
|
423
|
-
|
|
424
|
-
const render = (loaded: number, total: number): void => {
|
|
425
|
-
if (tail) return;
|
|
426
|
-
const t = total + extra;
|
|
427
|
-
if (!determinate && t - loaded >= QUEUE_MIN) { determinate = true; root.classList.add('determinate'); }
|
|
428
|
-
if (!determinate) return;
|
|
429
|
-
const frac = Math.min(loaded / Math.max(t, 1), 1) * BAND_MAX;
|
|
430
|
-
shown = Math.max(shown, frac);
|
|
431
|
-
bar.style.width = `${(shown * 100).toFixed(1)}%`;
|
|
432
|
-
};
|
|
433
|
-
|
|
434
|
-
mgr.onStart = (_url, loaded, total) => render(loaded, total);
|
|
435
|
-
mgr.onProgress = (url, loaded, total) => {
|
|
436
|
-
render(loaded, total);
|
|
437
|
-
if (tail) return;
|
|
438
|
-
if (/anims\/.*\.glb(\?|$)/.test(url)) textEl.textContent = `Loading animations… ${loaded}/${total}`;
|
|
439
|
-
else if (/\.ktx2(\?|$)/.test(url) || /basis_transcoder/.test(url)) textEl.textContent = 'Decoding textures…';
|
|
440
|
-
};
|
|
441
|
-
// No mgr.onLoad: it fires on the transient body/face 2/2 before the clips queue. Dismissal is the world's
|
|
442
|
-
// job — a one-shot after the first rendered frame (see main.ts).
|
|
443
|
-
|
|
444
|
-
return {
|
|
445
|
-
task() { extra += 1; let closed = false; return { done() { if (!closed) { closed = true; extra -= 1; } } }; },
|
|
446
|
-
label(text) { textEl.textContent = text; },
|
|
447
|
-
working(text = 'Starting…') { tail = true; root.classList.add('determinate', 'working'); textEl.textContent = text; },
|
|
448
|
-
dismiss() {
|
|
449
|
-
if (root.classList.contains('done')) return;
|
|
450
|
-
root.classList.remove('working');
|
|
451
|
-
root.classList.add('determinate');
|
|
452
|
-
bar.style.width = '100%';
|
|
453
|
-
root.classList.add('done');
|
|
454
|
-
setTimeout(() => root.remove(), 450);
|
|
455
|
-
},
|
|
456
|
-
fail(message) { root.classList.add('error'); textEl.textContent = `ERROR: ${message}`; },
|
|
457
|
-
};
|
|
458
|
-
}
|
|
580
|
+
export function createLoadingScreen(): LoadingScreen;
|
|
459
581
|
```
|
|
460
582
|
|
|
461
583
|
**Add your own assets to the bar.** An extra three load is counted automatically — just load it on the
|
|
@@ -463,25 +585,508 @@ default manager (`new GLTFLoader().loadAsync(...)`). For a NON-three asset (audi
|
|
|
463
585
|
in `loading.task()`:
|
|
464
586
|
|
|
465
587
|
```ts
|
|
466
|
-
loading.
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
audio.addEventListener('canplaythrough', () => music.done(), { once: true });
|
|
588
|
+
const musicDone = loading.task('Loading music…'); // counts like one item; the bar waits for it
|
|
589
|
+
const audio = new Audio(new URL('theme.mp3', document.baseURI).href); // relative to baseURI — never '/theme.mp3'
|
|
590
|
+
audio.addEventListener('canplaythrough', () => musicDone(), { once: true });
|
|
470
591
|
audio.load();
|
|
471
592
|
```
|
|
472
593
|
|
|
473
|
-
**Errors.**
|
|
474
|
-
|
|
594
|
+
**Errors.** The scaffold already wraps boot in a `main().catch(...)` that calls `loading.error(...)` and
|
|
595
|
+
prints the message into the HUD. Keep that guard — a throw with no guard is a frozen bar and a blank
|
|
596
|
+
screen, which is indistinguishable from a hang.
|
|
597
|
+
|
|
598
|
+
**Refinements worth adding if the bar feels rough** (the scaffold keeps the file small on purpose):
|
|
599
|
+
clamp the measured climb to ~90% so the un-metered tail (Rapier WASM compile, character setup) still has
|
|
600
|
+
somewhere to go; only switch marquee→measured once at least ~5 items are queued, so the brief body+face
|
|
601
|
+
`2/2` blip cannot fill the bar and then snap backwards when the clips enqueue; and make the rendered
|
|
602
|
+
fraction monotonic so it never decreases.
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
## 8c. Input — the standard action set, the world-owned router, menu contexts
|
|
606
|
+
|
|
607
|
+
The character system ships ONE canonical input vocabulary — the **standard action set**. Movement,
|
|
608
|
+
look, jump, camera come pre-bound (keyboard/mouse + gamepad + touch-ready metadata); your world adds
|
|
609
|
+
its own actions on the **same router** instead of raw `addEventListener`.
|
|
610
|
+
|
|
611
|
+
> **Rule:** the authoritative action list is in `get_package_manifest("humanoid-character")` →
|
|
612
|
+
> **`capabilities.actions`** (per action: id, kind, context, default bindings, touch hint), the
|
|
613
|
+
> semantic gamepad map in **`capabilities.controller`** (`faceDown`/`triggerR`/`dpadUp`… →
|
|
614
|
+
> standard-mapping indices), and the platform-reserved keys in **`capabilities.reservedKeys`**.
|
|
615
|
+
> Read them before wiring input. Do not invent keys; never bind a reserved key.
|
|
616
|
+
|
|
617
|
+
**Two different "actions" — don't conflate them.** An **input action** (this section) is a named,
|
|
618
|
+
rebindable device binding on the input router (`jump`, `interact`). A multiplayer **declared action**
|
|
619
|
+
(`room.sendAction('plant')` — multiplayer-logic §14) is a client→server intent the room's rules
|
|
620
|
+
interpret. They compose — read the input action, send the declared action:
|
|
621
|
+
|
|
622
|
+
```ts
|
|
623
|
+
if (input.wasPressed('interact')) mp.room?.sendAction('plant');
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
### The world-owned router *(humanoid-character ≥ 0.2.7)*
|
|
627
|
+
|
|
628
|
+
Create ONE `InputService` in the world, own the DOM attach, register the world-domain standard
|
|
629
|
+
actions you need, and inject it — world UI and character gameplay then share one router and one
|
|
630
|
+
context stack:
|
|
631
|
+
|
|
632
|
+
```ts
|
|
633
|
+
import { Character, InputService, registerStandardActions } from '@helix/humanoid-character';
|
|
634
|
+
|
|
635
|
+
const input = new InputService();
|
|
636
|
+
input.attach(window as never); // keyboard + mouse buttons
|
|
637
|
+
input.attachPointer(renderer.domElement as never, undefined, { dragLook: true }); // locked look, drag/touch look, wheel
|
|
638
|
+
registerStandardActions(input, { only: ['pause', 'inventory', 'confirm', 'cancel', 'uiNavigate'] });
|
|
639
|
+
|
|
640
|
+
const character = await Character.create({ model, camera, domElement: renderer.domElement, input, body, config });
|
|
641
|
+
// Multiplayer: CharacterMultiplayer.create({ ..., input }) — the same option, forwarded to the inner character.
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
Omit `input` and the character self-creates a private router — fine for a world with no menus and no
|
|
645
|
+
custom keys. Rebind ability-registered actions via config: `config: { bindings: { jump: { keys: ['KeyJ'] } } }`
|
|
646
|
+
(locomotion's move/sprint/walk/crouch/jump + any installed ability's actions; chassis-registered ones —
|
|
647
|
+
look, cameraMode, zoom, modifier, the equip cycle — take `input.rebind(id, …)` after create instead).
|
|
648
|
+
|
|
649
|
+
### World input — bind the standard ids first; mint custom actions last
|
|
650
|
+
|
|
651
|
+
The standard set already ships world-ready ids — `interact` (E / `faceLeft` / touch button),
|
|
652
|
+
`primary`, `secondary` (aim), `ability1..9` + the `equipPrev`/`equipNext` pad cycle, `melee`,
|
|
653
|
+
`emote`, plus the menu set. **Prefer them: you never pick a key at all** — the id carries its
|
|
654
|
+
keyboard + gamepad + touch bindings, and player rebinding later comes for free:
|
|
655
|
+
|
|
656
|
+
```ts
|
|
657
|
+
registerStandardActions(input, { only: ['interact', 'pause', 'confirm', 'cancel'] });
|
|
658
|
+
|
|
659
|
+
renderer.setAnimationLoop(() => {
|
|
660
|
+
const dt = Math.min(clock.getDelta(), 0.1);
|
|
661
|
+
character.update(dt); // pumps the router — query actions AFTER this
|
|
662
|
+
if (input.wasPressed('interact')) mp.room?.sendAction('plant'); // E / face-left / touch — one line
|
|
663
|
+
});
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
Mint a **namespaced custom action** only for something genuinely outside the standard set. The router
|
|
667
|
+
throws on a duplicate action *id* but does NOT police two actions sharing a physical source in the
|
|
668
|
+
same context — collisions are yours to avoid, and the collision rule is about what is **REGISTERED in
|
|
669
|
+
this world**, not what appears in the registry:
|
|
670
|
+
|
|
671
|
+
- **Never a reserved key** (`capabilities.reservedKeys`: Escape, KeyN, backtick).
|
|
672
|
+
- **Never a source a REGISTERED same-context action uses.** Always live in a character world: the
|
|
673
|
+
locomotion/camera set — Space/WASD/Shift/KeyC/Alt/Ctrl/KeyT + faceDown/faceRight/stickL/dpadLeft,
|
|
674
|
+
both sticks, and dpadRight (the modifier). Plus whatever this world opts into (`interact`'s
|
|
675
|
+
KeyE/faceLeft, the menu set) and whatever its abilities bind (gun-control's mouse buttons +
|
|
676
|
+
triggers + KeyR/faceUp, the equip cycle's Digit1-9/brackets/bumpers when `equip.slots` is on).
|
|
677
|
+
- **Registry ids this world never registers are FREE REAL ESTATE — reuse their bindings.** A card
|
|
678
|
+
game has no gunplay: `reload`'s KeyR/faceUp, `primary`/`secondary`'s triggers, `melee`'s
|
|
679
|
+
KeyV/stickR, `emote`'s KeyG/dpadDown are all unclaimed there, and a plain button always beats a
|
|
680
|
+
chord or an exotic key. Genre decides: reuse when the owning action clearly can never appear in
|
|
681
|
+
this world; leave it alone when it plausibly might (a shooter keeps `reload`'s slots free even
|
|
682
|
+
before guns ship). Note the reuse in a comment at the registration site.
|
|
683
|
+
- **No equip slots ⇒ the bumpers and Digit1-9 are freed** — the bumpers are the natural pad home for
|
|
684
|
+
a world's own cycling/selection verbs (radio channels, tabs, targets), digits for direct select.
|
|
685
|
+
- **To reuse an ALWAYS-ON action's source, disable the owner first** via its genre gate
|
|
686
|
+
(`locomotion.allowCrouch: false` frees KeyC/faceRight for a world with no crouching — the same
|
|
687
|
+
match-controls-to-genre pass you already do). Never bind over a live action.
|
|
688
|
+
|
|
689
|
+
**Gamepad placement — in order of preference:**
|
|
690
|
+
|
|
691
|
+
```ts
|
|
692
|
+
input.registerAction('world.mark', { kind: 'button', keys: ['KeyM'], pad: 'faceLeft' }, 'world'); // 1: genre-freed (no interact here)
|
|
693
|
+
input.registerAction('world.honk', { kind: 'button', keys: ['KeyH'], pad: 'dpadUp' }, 'world'); // 2: the always-free fallback
|
|
694
|
+
input.registerAction('world.taunt', { kind: 'button', keys: ['KeyG'], withModifier: { pad: 'faceUp' } }, 'world'); // 3: a modifier chord (KeyN is reserved — voice PTT)
|
|
695
|
+
input.registerAction('world.debugStats', { kind: 'toggle', keys: ['F9'] }, 'world'); // 4: keyboard-only (debug)
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
1. **A genre-freed plain button** (the rules above) — the DEFAULT choice: put the verb where hands
|
|
699
|
+
expect it (triggers for firing-shaped verbs, bumpers for cycling/selection, face buttons for
|
|
700
|
+
frequent verbs). **Paired/linked verbs share ONE symmetric group** — bumperL/bumperR,
|
|
701
|
+
dpadUp/dpadDown, faceLeft/faceRight, or triggerL/triggerR — never split a pair across groups
|
|
702
|
+
(red-team on the d-pad with blue-team on a face button is disorienting).
|
|
703
|
+
2. **`pad: 'dpadUp'`** — always free in every world, but a FALLBACK, not a first pick: a lone odd
|
|
704
|
+
verb with no better home, or half of a dpadUp/dpadDown pair when `emote`'s slot is genre-freed.
|
|
705
|
+
3. **A `withModifier` chord** — `dpadRight` is the platform's held `modifier`. While it is down,
|
|
706
|
+
`withModifier` bindings resolve and their sources go quiet for their plain owners, opening a
|
|
707
|
+
second layer. Resolution is additive per device — plain bindings stay live while the chord is
|
|
708
|
+
held (KeyH above still fires). **RESERVED chords — never claim:** `modifier`+right stick (the
|
|
709
|
+
platform camera zoom), `modifier`+bumperR (the standard `voicePTT` — push-to-talk on pad,
|
|
710
|
+
auto-registered when the world attaches voice), and `modifier`+bumperL (reserved for future
|
|
711
|
+
platform voice controls). Ergonomics: holding the modifier occupies the d-pad thumb, so chord
|
|
712
|
+
only occasional actions that tolerate a movement pause (a zoom does; a dodge would not).
|
|
713
|
+
4. **Keyboard-only (omit `pad`)** — for testing/debug conveniences ONLY (stat overlays, free-cam,
|
|
714
|
+
cheats). A pad or touch player can never reach it, so anything gameplay-relevant belongs on a
|
|
715
|
+
plain button, a chord, or an indirect route (contextual `interact`, the equip cycle, a menu
|
|
716
|
+
entry) — the standard set itself works this way: `ability1..9` are keyboard digits, pad players
|
|
717
|
+
cycle with `equipPrev`/`equipNext`.
|
|
718
|
+
|
|
719
|
+
Don't stack chords or mint exotic keys while obvious freed buttons sit idle — reusing the standard
|
|
720
|
+
layout's real estate keeps pad controls where players' hands expect them.
|
|
721
|
+
|
|
722
|
+
**Installable abilities bind the standard ids too**: an `ability.json` action may declare a bare
|
|
723
|
+
standard character id — `{ "id": "primary" }`, no `kind`, no `defaultBinding` (the registry owns
|
|
724
|
+
both) — instead of minting a private `<ability>.<name>`. gun-control reads `primary`/`secondary`/
|
|
725
|
+
`reload` this way. Custom ability actions stay namespaced.
|
|
726
|
+
|
|
727
|
+
**Slot cycling comes built in** — enable the chassis tracker instead of writing one:
|
|
728
|
+
`config: { character: { equip: { slots: 4 } } }` registers `ability1..4` + `equipPrev`/`equipNext`,
|
|
729
|
+
tracks the active slot (wraparound + direct select), publishes blackboard `equipSlot`, and emits
|
|
730
|
+
`equipSlotChanged { slot, previous }` — your world does the actual equipping off that state.
|
|
731
|
+
|
|
732
|
+
A raw `addEventListener('keydown', …)` bypasses the context stack (your key still fires inside
|
|
733
|
+
menus), can collide with platform keys, gets no gamepad/touch support — and can never be rebound.
|
|
734
|
+
Rebinding is per-ACTION, not per-key: `bindings.<id>` config overrides the defaults of
|
|
735
|
+
ability-registered actions (see above), and a settings
|
|
736
|
+
menu rebinds live via `input.rebind(id, { keys: [...] })` — enumerate what's bound with
|
|
737
|
+
`input.actions()` (or `describeActions(input)` for registry labels/touch hints). Keyboard names are W3C
|
|
738
|
+
`KeyboardEvent.code` (`KeyE`, `Space`, `ShiftLeft` — physical/positional, layout-independent);
|
|
739
|
+
gamepad bindings use the semantic names from `capabilities.controller` (`pad: 'faceUp'`,
|
|
740
|
+
`stick: 'leftStick'`) — never raw indices. Kinds: `button` | `toggle` | `vec2` | `delta`; queries:
|
|
741
|
+
`isDown` / `wasPressed` / `wasReleased` / `isToggled` / `vec2` / `delta`.
|
|
742
|
+
|
|
743
|
+
### Player-facing control text — `format()` tokens, never key names *(humanoid-character ≥ 0.2.7)*
|
|
744
|
+
|
|
745
|
+
Never write a physical control name ("WASD", "press E", "RT") into HUD/help text — it is wrong the
|
|
746
|
+
moment the player picks up a pad or rebinds. The router knows the truth; render through it and
|
|
747
|
+
re-render each frame — that IS the live device-swap mechanism (the text flips the frame the player
|
|
748
|
+
touches the other device):
|
|
749
|
+
|
|
750
|
+
```ts
|
|
751
|
+
hudEl.textContent = input.format('Drive with {move} · {interact} to grab');
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
Bare `{actionId}` tokens expand via `input.hint(id)` — the CURRENT resolved binding of that action
|
|
755
|
+
on the device whose activity was seen last (`input.activeDevice()`), so rebinds and config
|
|
756
|
+
overrides display correctly for free. Keyboard renders short key text ("E", "WASD", "Left click"),
|
|
757
|
+
gamepad renders Xbox-style names ("X", "RT", "Left stick"; chords as "D-pad Right + RB"). An action
|
|
758
|
+
unbound on the shown device renders its OTHER device's binding, and only falls back to its `label`
|
|
759
|
+
when no device has one. Pass a device to pin one (`input.hint('interact', 'gamepad')`); unknown ids
|
|
760
|
+
throw; a doubled opening brace escapes a literal one (token ids are word chars, dots, hyphens).
|
|
761
|
+
Give CUSTOM actions a `label` at registration so fallback text reads like a verb, not an id:
|
|
475
762
|
|
|
476
763
|
```ts
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
764
|
+
// faceRight reuses crouch's slot — this world sets locomotion.allowCrouch:false (gate-disable first!)
|
|
765
|
+
input.registerAction('world.discard', { kind: 'button', keys: ['KeyH'], pad: 'faceRight', label: 'Discard' }, 'world');
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
Worlds without their own router reach the character's: `mp.local.services.input.format(...)`.
|
|
769
|
+
README/prose may name default keys, stated as defaults ("E by default").
|
|
770
|
+
|
|
771
|
+
### Menus that actually pause the game — the context stack
|
|
772
|
+
|
|
773
|
+
Every action carries a `context` (default `'gameplay'`); the router resolves only the top of its
|
|
774
|
+
context stack. Push `'menu'` and every gameplay action reads inert (movement decelerates to a stop,
|
|
775
|
+
look and zoom freeze) while `confirm`/`cancel`/`uiNavigate` go live — the character never learns a
|
|
776
|
+
menu is open, its actions simply stop resolving:
|
|
777
|
+
|
|
778
|
+
```ts
|
|
779
|
+
if (input.wasPressed('pause') || input.wasPressed('inventory')) { input.pushContext('menu'); showMenu(); }
|
|
780
|
+
if (input.wasPressed('confirm') || input.wasPressed('cancel')) { input.popContext('menu'); hideMenu(); }
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
No edge leaks across the transition (the press that opened the menu never re-fires as `confirm`) and
|
|
784
|
+
held keys resume cleanly on pop.
|
|
785
|
+
|
|
786
|
+
**Context pushes free the mouse — that is their job.** A push that suspends `look` releases pointer lock
|
|
787
|
+
the way Esc would (a menu needs the cursor), and the engine can only re-acquire on a real user click. So
|
|
788
|
+
reach for `pushContext` ONLY for UI that wants the cursor. For gameplay states that suppress input while
|
|
789
|
+
the cursor stays captured — knockout, stun, ragdoll, a cutscene — do NOT push a context: locomotion is
|
|
790
|
+
already gated while `dead`/`ragdolled`/emote-locked, and a camera freeze is
|
|
791
|
+
`config.set('character.camera.allowRotate', false)` (restore on exit), which never touches the lock.
|
|
792
|
+
|
|
793
|
+
### Reserved keys — never bind these
|
|
794
|
+
|
|
795
|
+
From `capabilities.reservedKeys`: **Escape** (the SDK's phone/tablet overlay; pointer-lock exit also
|
|
796
|
+
rides it), **KeyN** (the voice push-to-talk default — repoint voice via the SDK's `pttKey` option
|
|
797
|
+
rather than binding over it), **backtick** (the debug-overlay convention). **KeyB is no longer
|
|
798
|
+
reserved** — it is now the `point` standard action's binding, so it is *bound*, not free. The standard
|
|
799
|
+
set already avoids the reserved keys — `pause` is **P**, `cancel` is **Backspace**. Pad players get
|
|
800
|
+
PTT without KeyN: the `voicePTT` standard action (hold modifier + right bumper, `global` context so it
|
|
801
|
+
works under menus) is auto-registered by `mp.attachVoice` and forwarded to the SDK.
|
|
802
|
+
|
|
803
|
+
### Driving actions without a keyboard (AI / NPCs / tests)
|
|
804
|
+
|
|
805
|
+
`input.setVirtualButton('jump', true)` / `setVirtualVec2('move', { x, y })` /
|
|
806
|
+
`setVirtualDelta('zoom', d)` write the same actions a player does — abilities and game logic cannot
|
|
807
|
+
tell the difference. Pass `null` to clear.
|
|
808
|
+
|
|
809
|
+
### Default mobile controls
|
|
810
|
+
|
|
811
|
+
A current `helix init` character world already mounts `createMobileControls(input, { surface })`
|
|
812
|
+
and calls `mobileControls.update()` before the character tick. The generated, framework-neutral HUD
|
|
813
|
+
uses the same registered action metadata and virtual-input channel described above: left stick,
|
|
814
|
+
open-space drag look, true two-pointer pinch zoom/camera-mode transition, jump, and contextual
|
|
815
|
+
`button`/`hold`/`tap` actions. It activates only for touch capability, respects safe areas plus the
|
|
816
|
+
host-provided top offset, follows the input context stack, and clears captures/virtual state on
|
|
817
|
+
cancel, blur, teardown, or remount.
|
|
818
|
+
|
|
819
|
+
Customize it through constrained data, not copied DOM:
|
|
820
|
+
|
|
821
|
+
```ts
|
|
822
|
+
input.registerAction('world.scan', {
|
|
823
|
+
kind: 'button', context: 'gameplay', label: 'Scan', touch: 'hold', keys: ['KeyF']
|
|
824
|
+
}, 'world');
|
|
825
|
+
|
|
826
|
+
const mobileControls = createMobileControls(input, {
|
|
827
|
+
surface: renderer.domElement,
|
|
828
|
+
actions: [
|
|
829
|
+
{ id: 'world.scan', label: 'Scan', order: 1 },
|
|
830
|
+
{ id: 'cameraMode', placement: 'utility' },
|
|
831
|
+
{ id: 'crouch', visible: false },
|
|
832
|
+
],
|
|
833
|
+
theme: { accent: '#7dd3fc' },
|
|
834
|
+
});
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
Labels are inserted as plain text; layout and theme are safe tokens. Core move/look/zoom remain on
|
|
838
|
+
unless intentionally disabled with `core`. To replace the default entirely, provide an equivalent
|
|
839
|
+
custom InputService controller and declare `provider: "custom"` in `public/helix.controls.json` while
|
|
840
|
+
preserving the required move/look/zoom/jump capabilities. In both cases, `supportsMobile: true`
|
|
841
|
+
requires touch-only browser proof at portrait and landscape phone sizes.
|
|
842
|
+
|
|
843
|
+
## 8d. Custom gestures — see the character-animation doc
|
|
844
|
+
|
|
845
|
+
Worlds can author their own character animations as JSON keyframe poses (no GLB export, no platform
|
|
846
|
+
publish) — a wave, a salute, a world-specific ritual — and they replicate in multiplayer by default.
|
|
847
|
+
|
|
848
|
+
Most worlds do not need this: locomotion, jumps and the standard emote set already ship with the
|
|
849
|
+
humanoid-character system.
|
|
850
|
+
|
|
851
|
+
**Scope first:** the pose DSL is for SHORT, SIMPLE, upper-body-led motion — player actions (wave, point,
|
|
852
|
+
sip), held stances (hurt, carrying), one-shot accents (nod, flinch), and additive overlays that ride
|
|
853
|
+
locomotion (a limp, a hunch). It is NOT for dances, fight choreography, acrobatics, or anything where the
|
|
854
|
+
feet must step, plant or leave the ground: a gesture is bone rotations only, with no root motion, no foot
|
|
855
|
+
planting and no weight shift, because the locomotion graph owns the lower body. Those need a
|
|
856
|
+
motion-captured clip.
|
|
857
|
+
|
|
858
|
+
If you DO need a custom gesture, read the dedicated doc before writing any
|
|
859
|
+
keys — joint-space posing has non-obvious failure modes (rotations chain, so the axis meanings only hold
|
|
860
|
+
near rest) and the doc carries the axis table, the composition rules and the measurement tooling:
|
|
861
|
+
|
|
862
|
+
```
|
|
863
|
+
read_doc("character-animation")
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
## 8e. Sitting & lying spots — furniture players can actually use *(humanoid-character ≥ 0.2.36)*
|
|
867
|
+
|
|
868
|
+
Any world with chairs, benches, couches or beds should register **sit/lie spots** — a social hub without
|
|
869
|
+
them is furniture nobody can use. One call per seat; the prompt ("E — Sit"), the interact press, the seated
|
|
870
|
+
pose, replication to every client and remote occupancy (the prompt walks past a taken chair) are ALL
|
|
871
|
+
platform defaults — the world authors nothing else:
|
|
872
|
+
|
|
873
|
+
```ts
|
|
874
|
+
const spots = mp.local.sitting.spots; // (multiplayer facade; solo: character.sitting.spots)
|
|
875
|
+
spots.register({ id: 'couch-left', seat: { x: -0.4, y: 0.45, z: 2.35 }, facingYawDeg: 180, pose: 'sit' });
|
|
876
|
+
spots.register({ id: 'bed', seat: { x: -3.3, y: 0.35, z: 1.4 }, facingYawDeg: -90, pose: 'lie', label: 'Lie down' });
|
|
877
|
+
// returns an unregister closure; optional: label, exitPoint, clearanceM
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
**The coordinate contract — get these right and it just works:**
|
|
881
|
+
- `seat` sits **ON the surface** the player sits/lies on: the cushion top, the mattress top — NOT the floor,
|
|
882
|
+
NOT a point in the air. The engine lifts the hips the anatomical clearance itself (sit ~0.07 m,
|
|
883
|
+
lie ~0.10 m; `clearanceM` overrides for odd furniture). Place it where a butt (sit) or hips (lie) go —
|
|
884
|
+
centred on the seating surface, clear of armrests and backrests.
|
|
885
|
+
- `facingYawDeg` = which way the OCCUPANT faces (0 = +Z, positive toward +X). For a chair: outward, away
|
|
886
|
+
from the backrest. **For a bed: the body lies down BACKWARD, so the head lands OPPOSITE the facing —
|
|
887
|
+
face AWAY from the pillow end.**
|
|
888
|
+
- **Any seat height works.** Typical surfaces: chair/couch 0.42–0.5 m, bar stool 0.6–0.8 m (the feet
|
|
889
|
+
simply dangle), bed 0.3–0.5 m. Sub-chair heights (floor cushions ~0.1 m) will plant feet oddly — the
|
|
890
|
+
shipped sit pose is chair-height; prefer real furniture heights.
|
|
891
|
+
- Two seats on one couch: author one spot per cushion (~0.6–0.8 m apart); occupancy offers the free one.
|
|
892
|
+
- **`seat` goes near the furniture's FRONT face, never at a deep cushion's center.** The seated pose puts
|
|
893
|
+
the pelvis AT the seat point and drops the knees/shins **~0.25 m in front of it** — any furniture
|
|
894
|
+
occupying that space clips through the legs. Rule: place the seat point **0.15–0.2 m behind the front
|
|
895
|
+
face** of the cushion, measured along the facing direction. A chair (~0.5 m deep) can use its center; a
|
|
896
|
+
couch or bench cushion 0.7 m+ deep CANNOT — bring the point forward (e.g. an 0.8 m-deep cushion with its
|
|
897
|
+
front face at z=2.0, occupant facing −Z: seat z ≈ 2.2, not the 2.4 center). Depth behind the seat point
|
|
898
|
+
is free — the body leans back over it.
|
|
899
|
+
- `exitPoint` is optional — standing up defaults to just in front of the seat at the height the player
|
|
900
|
+
walked in at.
|
|
901
|
+
|
|
902
|
+
The seated POSE is the player's own carried sit/lie emote, falling back to the DEFAULT BUNDLED WITH THE
|
|
903
|
+
CHARACTER SYSTEM — every player can take a chair or a bed, guests and empty emote sets included; nothing
|
|
904
|
+
needs equipping and no platform config is involved. (The exception: a world that turns EMOTES off turns the
|
|
905
|
+
whole posture lane off with them — the facade's `emotes: false`, `character.emote.enabled: false`, or
|
|
906
|
+
`character.native.disabled: ['emotes']`. Refusing `'sitting'` outright removes the spots, the prompt and the
|
|
907
|
+
seat claim on `interact` (§8g). Do not disable either in a world that authors sitting spots.) Do NOT put furniture colliders where the
|
|
908
|
+
seated body goes — the posture pins the body; a mattress collider only fights it. Tunables:
|
|
909
|
+
`character.sitting.promptRangeM` (1 — HORIZONTAL body→seat-point distance; the seat point is the seat
|
|
910
|
+
CENTER ~0.25 m inside the furniture, so this offers about a stride from the furniture's edge. Height is a
|
|
911
|
+
separate ±1.5 m band, so a mezzanine chair never offers through a floor and a tall stool costs no reach)
|
|
912
|
+
and `.promptSwitchMarginM` (0.35).
|
|
913
|
+
**A world with its OWN interaction UI style should own the sit prompt too**: pass `sitPrompt: false` to the
|
|
914
|
+
facade (kills the built-in pill; the mechanic still takes the seat on `interact`) and render from
|
|
915
|
+
`mp.sitPrompt` / `mp.onSitPrompt((p) => ...)` — `{ kind: 'enter' | 'exit', spotId, label, distance }` or
|
|
916
|
+
null, fired on change, never per frame. That is how sitting joins a world's existing prompt system instead
|
|
917
|
+
of stacking a second pill beside it. Otherwise the prompt pill restyles or
|
|
918
|
+
switches off via the facade's `sitPrompt` option. Paired (two-person) emotes need NO world authoring —
|
|
919
|
+
they are player-carried items; the platform renders the offer and join prompts.
|
|
920
|
+
|
|
921
|
+
## 8f. Size geometry from `world_metrics` BEFORE you place it
|
|
922
|
+
|
|
923
|
+
**Call `world_metrics({ projectDir })` the moment you start laying out a world — the first step, ledge,
|
|
924
|
+
gap, ramp, doorway or ceiling — not after publishing.** It reads the traversal envelope out of the
|
|
925
|
+
world's OWN installed character system: player capsule (height / radius / crouch), **step height** (what
|
|
926
|
+
auto-steps versus what is a wall), max walkable slope, movement speeds, and the derived jump envelope
|
|
927
|
+
(air time, max flat-ground gap at run and at sprint) plus corridor and ceiling clearances.
|
|
928
|
+
|
|
929
|
+
This is the cheapest bug in the catalogue to avoid and one of the most expensive to find: a world shipped
|
|
930
|
+
with a **0.42 m step against a 0.35 m limit** looked completely fine in every screenshot, passed
|
|
931
|
+
validation, and published — the objective was simply unreachable, and nothing flagged it because a
|
|
932
|
+
screenshot cannot show that a player physically cannot get somewhere. Numbers can.
|
|
933
|
+
|
|
934
|
+
The values are the system DEFAULTS the world starts from; if you override them in `config.locomotion` /
|
|
935
|
+
`config.character.body`, your own values win — read the manifest (§8a) and size against those instead.
|
|
936
|
+
|
|
937
|
+
Then, once geometry is placed, verify it with `inspect_world` (§10) — `world_metrics` tells you what will
|
|
938
|
+
fit, `inspect_world` tells you what you actually built.
|
|
939
|
+
|
|
940
|
+
## 8f. Health, death & ragdoll — the survival chassis *(humanoid-character ≥ 0.2.25)*
|
|
941
|
+
|
|
942
|
+
Any world where the character can DIE (combat, hazards, survival) turns on the health mirror:
|
|
943
|
+
|
|
944
|
+
```ts
|
|
945
|
+
character: { health: { enabled: true, maxHealth: 100 } }
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
That registers blackboard `health` / `deathDir` (plus `dead`, which always exists), the events
|
|
949
|
+
`healthChanged{value,previous}` / `died` / `revived` / `respawned`, and `character.setHealth(v)`. **Two
|
|
950
|
+
authority modes:** single-player worlds drive `setHealth` themselves (a hazard zone, a fall); multiplayer
|
|
951
|
+
worlds NEVER write health client-side — declare `playerVars.health` and let server rules mutate it (the
|
|
952
|
+
facade mirrors the room's number onto the chassis, local and replicas alike — see the `shooter-range`
|
|
953
|
+
template). On death the chassis gates input, plays the directional stagger (`deathDir`), and hands the body
|
|
954
|
+
to the **ragdoll**:
|
|
955
|
+
|
|
956
|
+
```ts
|
|
957
|
+
character: { ragdoll: { enabled: true, blendSeconds: 0.35, launchSpeed: 2.5, deathCamera: 'third-person' } }
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
Physics takes the body at the death instant (the stagger cross-fades over `blendSeconds`; `0` = hard
|
|
961
|
+
switch); `deathCamera` pulls a first-person victim out to third person to watch the fall (`'none'` opts
|
|
962
|
+
out). All defaults are tuned — most worlds change nothing.
|
|
963
|
+
|
|
964
|
+
**Ragdoll is also a world MECHANIC**, independent of death: `character.ragdoll.enter({ launch, speedMps })`
|
|
965
|
+
drops the body limp (knockouts, launch pads, physics comedy), `exit()` stands it back up, `.active` reads
|
|
966
|
+
the state, and locomotion input is gated while limp. It replicates automatically — remotes see the body go
|
|
967
|
+
limp and late joiners converge — and any world can read another player's state via
|
|
968
|
+
`room.state.players[id].ragdolled`. `enter()` refuses while dead (death owns that body).
|
|
969
|
+
|
|
970
|
+
For guns and the full combat loop on top of this, read `read_doc({ name: "shooter-worlds" })`.
|
|
971
|
+
|
|
972
|
+
## 8g. Native avatar features — what every world gets, and the one way to refuse it *(humanoid-character ≥ 0.2.54)*
|
|
973
|
+
|
|
974
|
+
**These are ON in every character world, with no world code, no option and no republish.** They are
|
|
975
|
+
constructed by `Character` itself, so they do not depend on which constructor you called: a world built
|
|
976
|
+
on the bare `Character` + `LocomotionAbility` gets exactly what a `CharacterMultiplayer.create` world
|
|
977
|
+
gets.
|
|
978
|
+
|
|
979
|
+
| id | what it is | its affordance |
|
|
980
|
+
|---|---|---|
|
|
981
|
+
| `emotes` | the player's carried emotes: tap-to-replay, the wheel's slots, and the sit/lie posture lane | `emote` — **KeyG** / dpad-down, touch **Emote** |
|
|
982
|
+
| `emote-wheel` | the 8-petal radial overlay (hold `emote`, steer, release), including the **Customize** door that opens the platform's emote-slots editor | the overlay only (emotes and tap-replay survive refusing it) |
|
|
983
|
+
| `xray` | hold-to-scan whatever is under the reticle — player, placed item or vehicle — and its detail card | `xray` — **KeyZ** (pad: modifier + left bumper), touch hold |
|
|
984
|
+
| `pointing` | hold to point the right index finger along the aim | `point` — **KeyB**, touch hold |
|
|
985
|
+
| `sitting` | sit/lie at authored spots (§8e), the proximity prompt, and the seat claim on `interact` | the *claim* on `interact` — the key itself stays, because worlds share that id |
|
|
986
|
+
| `avatar-camera` | the Helix OS phone presentation and the embodied avatar camera composed on it | `shutter` — **KeyF**, touch button |
|
|
987
|
+
| `camera-first-person` | the first-person camera leg | the FP leg of the `cameraMode` cycle (**KeyT**) |
|
|
988
|
+
| `camera-third-person` | the third-person camera leg | the TP leg of the `cameraMode` cycle |
|
|
989
|
+
| `universal-avatar` | rendering each player's equipped universal avatar as their body | — (the **top switch**, see below) |
|
|
990
|
+
|
|
991
|
+
The player's carried emotes arrive on their own: the runtime reads the loadout off the shell's platform
|
|
992
|
+
channel, so a world does **not** have to wire `Helix.emotes` for the wheel to work. When the wheel is
|
|
993
|
+
empty it says *why* — signed out, nothing equipped, outside the HELIX app, or the platform could not be
|
|
994
|
+
reached — rather than showing eight blank petals. The **Customize** door on the wheel appears only once
|
|
995
|
+
the shell has proved it can service the request (so: inside the HELIX app, signed in, on a shell that
|
|
996
|
+
mounts the editor) — it is genuinely absent otherwise rather than painted hopefully, which is the same
|
|
997
|
+
rule as the rest of this set: no affordance without its feature.
|
|
998
|
+
|
|
999
|
+
### The creator veto — `character.native.disabled`
|
|
1000
|
+
|
|
1001
|
+
A world that does not want one of these **names it in a refusal list**, in the config you hand
|
|
1002
|
+
`Character.create` / `CharacterMultiplayer.create`:
|
|
1003
|
+
|
|
1004
|
+
```ts
|
|
1005
|
+
config: {
|
|
1006
|
+
character: {
|
|
1007
|
+
native: { disabled: ['xray', 'pointing'] }, // everything not listed stays ON
|
|
1008
|
+
},
|
|
482
1009
|
}
|
|
483
1010
|
```
|
|
484
1011
|
|
|
1012
|
+
This is **runtime config, not manifest** — it does not go in `public/helix.json`.
|
|
1013
|
+
|
|
1014
|
+
Six things to understand before you use it:
|
|
1015
|
+
|
|
1016
|
+
1. **It is a blocklist, and never an allow-list.** The platform runtime resolves fresh at launch, so a
|
|
1017
|
+
world published today runs a *newer* character system tomorrow (see §9 and the delivery contract). A
|
|
1018
|
+
list of things you *want* would freeze every future feature out of every already-published world. A
|
|
1019
|
+
list of **refusals** cannot: a feature nobody has heard of yet is in nobody's list, so it arrives ON.
|
|
1020
|
+
That is deliberate. Refuse the features you do not want by name; do **not** exact-pin the system to
|
|
1021
|
+
stop receiving them — engine systems are ranges, and publish refuses an exact pin.
|
|
1022
|
+
2. **`universal-avatar` is the top switch, and it is all-or-nothing.** Refusing it also refuses `emotes`,
|
|
1023
|
+
`emote-wheel`, `xray`, `pointing`, `sitting` and `avatar-camera`, and every key, touch button and
|
|
1024
|
+
prompt that advertised them. Those features are authored against the platform humanoid — its skeleton,
|
|
1025
|
+
its sockets, its posture clips — so they are not portable onto a world's own character rig. **The two
|
|
1026
|
+
camera legs survive it**: a world still needs a camera whatever body it draws. One asymmetry to know:
|
|
1027
|
+
on `CharacterMultiplayer` this id also stops the facade resolving equipped avatars at all, while in the
|
|
1028
|
+
bare-`Character` recipe your own code is what passes `avatar:` to `loadCharacterAssets` (§8) — so there,
|
|
1029
|
+
drop that line too, or you will keep drawing the equipped body while having refused everything that
|
|
1030
|
+
hangs off it.
|
|
1031
|
+
3. **A refusal takes the affordance with it.** No key binding, no rebind row, no controller button, no
|
|
1032
|
+
touch pad, no prompt. A world that refuses `xray` must not still show a scan key that does nothing —
|
|
1033
|
+
the runtime enforces that for you, and the refusal stands even against a later
|
|
1034
|
+
`registerStandardActions`.
|
|
1035
|
+
4. **Refusing both camera legs throws at create.** `['camera-first-person', 'camera-third-person']` is
|
|
1036
|
+
not a world. Refuse one to make the world single-mode — that also sets `character.camera.mode` and
|
|
1037
|
+
clears `allowModeToggle`, so the camera cycle, the zoom-through-mode switch and the death camera all
|
|
1038
|
+
agree — or neither to keep the cycle.
|
|
1039
|
+
5. **An id this runtime does not recognise is kept and warned about once, never fatal.** It is either
|
|
1040
|
+
your typo (the warning names the legal ids) or a feature from a runtime newer than the one that
|
|
1041
|
+
happens to be resolved. A `disabled` that is not a list at all is ignored with its own warning, and
|
|
1042
|
+
nothing is refused — check the console if a refusal did not take.
|
|
1043
|
+
6. **The older per-feature switches still work**, and route into this same resolver, so you never have to
|
|
1044
|
+
migrate and never get a precedence puzzle — a feature is off if *either* surface says so:
|
|
1045
|
+
`character.emote.enabled: false` (= `emotes`), `character.emote.wheel: false` (= `emote-wheel`),
|
|
1046
|
+
`character.xray.enabled: false` (= `xray`), `character.universalAvatar.enabled: false`
|
|
1047
|
+
(= `universal-avatar`, **and therefore its whole dependent set** — see 2). On the multiplayer facade,
|
|
1048
|
+
`CharacterMultiplayer.create`'s `emotes: false`, `emoteWheel: false` and `universalAvatars: false`
|
|
1049
|
+
options compile into exactly those config keys.
|
|
1050
|
+
|
|
1051
|
+
Not part of the veto: `localControl: false` on `CharacterMultiplayer.create`. That is not a feature
|
|
1052
|
+
switch — it declares a passive spectator body the world drives its own camera and input for, and such a
|
|
1053
|
+
body is not a native host at all (neither are replicas), so it mounts no native overlays by construction.
|
|
1054
|
+
|
|
1055
|
+
**Version — read this before you rely on any of it.** All of §8g needs `@helix/humanoid-character`
|
|
1056
|
+
**0.2.54** or newer. A runtime older than that ignores `character.native.disabled` silently and keeps
|
|
1057
|
+
whatever it already did: your refusals do not take, and the features listed above are not all there to
|
|
1058
|
+
refuse. So do not reason from this section about what a given world actually has — **confirm against the
|
|
1059
|
+
resolved system's own config schema** (`get_package_manifest` / `list_systems`): if `character.native`
|
|
1060
|
+
appears in its `config`, that runtime provides this surface. That schema is also the authoritative id
|
|
1061
|
+
list; the table above is prose about it.
|
|
1062
|
+
|
|
1063
|
+
## 8h. NPCs — see the NPC recipe *(humanoid-character ≥ 0.3.13)*
|
|
1064
|
+
|
|
1065
|
+
A world can put **characters that are not players** in it — a shopkeeper behind a counter, a quest giver, a
|
|
1066
|
+
guard on a post, a zombie that hunts you — on the same chassis this recipe just configured. **An NPC is a
|
|
1067
|
+
`Character` whose driver is your behaviour instead of the keyboard**: same config namespaces, same locomotion
|
|
1068
|
+
and animation graph, same abilities, same gestures, built headless (no camera, no DOM) with an `AIDriver`
|
|
1069
|
+
writing virtual intent onto its own private router, so every ability resolves that intent exactly as it
|
|
1070
|
+
resolves yours. `NpcScene` owns the model clone, the router, the body, the nameplate and the teardown; you
|
|
1071
|
+
author a `behaviour` callback, and `InteractionSpots` gives any of them a "walk up, press interact" prompt.
|
|
1072
|
+
|
|
1073
|
+
**Scope first:** the platform ships the *chassis and the steering* — a seek with whisker obstacle avoidance and
|
|
1074
|
+
a stuck commit, optional A* routing over a `NavGrid` rasterized off your own level colliders, two body tiers
|
|
1075
|
+
(`stand` runs no physics at all, `physics` gets its own Rapier world mirrored from the player's), a 16-NPC
|
|
1076
|
+
budget, and Vault characters as bodies. It does **not** ship attacking, patrolling, aggro, factions or
|
|
1077
|
+
dialogue: those are game logic you write in the behaviour. And it does not ship replication — `NpcScene` NPCs
|
|
1078
|
+
are **client-local**, which is right for a standing NPC and wrong for a moving one (in a multiplayer world a
|
|
1079
|
+
moving NPC belongs to the room, rendered by `humanoidEntities`).
|
|
1080
|
+
|
|
1081
|
+
If the world needs an NPC, read the dedicated recipe before writing any of it — the failure modes are ordering
|
|
1082
|
+
rules with silent symptoms (read `arrived` before re-seeking or the attack never fires; re-seek only when the
|
|
1083
|
+
target moved or the stuck detector is disabled), and the doc carries the worked shop NPC, the hunting one, the
|
|
1084
|
+
nav-grid limits with real numbers, the Vault-character flow and the multiplayer handoff:
|
|
1085
|
+
|
|
1086
|
+
```
|
|
1087
|
+
read_doc("npc-world")
|
|
1088
|
+
```
|
|
1089
|
+
|
|
485
1090
|
## 9. Install, build, publish
|
|
486
1091
|
|
|
487
1092
|
```bash
|
|
@@ -490,22 +1095,72 @@ npm install
|
|
|
490
1095
|
|
|
491
1096
|
Then resolve the pins: call the **`install_world_packages`** tool with this project directory (or run
|
|
492
1097
|
`npx -y {{CLI_PKG}}@latest install` in it) — it downloads the pinned code, writes installed.json + the
|
|
493
|
-
lock, and wires the
|
|
1098
|
+
lock, and wires the runtime import map + `src/helix.runtime.ts`. Re-run it after editing pins; pass
|
|
494
1099
|
`update: true` (or `--update`) to re-resolve ranges to newer versions. Then:
|
|
495
1100
|
|
|
496
1101
|
```bash
|
|
497
|
-
npm run build #
|
|
1102
|
+
npm run build # creator code/addons bundle; three, rapier and @helix systems stay external
|
|
498
1103
|
```
|
|
499
1104
|
|
|
500
1105
|
Then `validate_world` on `dist/` (fix every problem — no `.glb`/`.ktx2` may ship; assets stream from
|
|
501
|
-
the CDN),
|
|
1106
|
+
the CDN), then the sub-path gate:
|
|
1107
|
+
|
|
1108
|
+
```bash
|
|
1109
|
+
npx -y {{CLI_PKG}}@latest verify-subpath dist
|
|
1110
|
+
```
|
|
1111
|
+
|
|
1112
|
+
**Do not skip this, and do not publish without it.** `npm run preview`, `validate_world`,
|
|
1113
|
+
`inspect_world` and `capture_world_screenshot` all serve `dist/` from an origin **root**. A published
|
|
1114
|
+
world is only ever served from a **nested path**. So every one of those gates passes a bundle whose
|
|
1115
|
+
root-absolute URL 404s and throws in production — the world renders a red error bar and nothing else.
|
|
1116
|
+
`verify-subpath` serves the same bytes from a nested path and fails on a throw, a 404, or a blank
|
|
1117
|
+
render. It is the only local check that sees the difference.
|
|
1118
|
+
|
|
1119
|
+
Finally `whoami` to confirm login, and `publish_world` on `dist/` for the play link.
|
|
1120
|
+
|
|
1121
|
+
## 10. Verify placement by measurement — see the world-inspect doc
|
|
1122
|
+
|
|
1123
|
+
Do not judge object placement from screenshots — distances and depth read off an image are unreliable.
|
|
1124
|
+
`inspect_world` on `dist/` returns the scene as data (positions, extents, colliders) and runs placement
|
|
1125
|
+
checks: floating/sunk/intersecting geometry, collider-vs-visual mismatches, invisible walls.
|
|
1126
|
+
`focusNear: [x, y, z]` checks just the object you placed at that coordinate, and `world_metrics` gives the
|
|
1127
|
+
capsule/step/slope/jump numbers for sizing gaps, steps and doorways BEFORE you place geometry. Findings are
|
|
1128
|
+
measurements to check against your intent, never orders to change the world. Full workflow:
|
|
1129
|
+
|
|
1130
|
+
```
|
|
1131
|
+
read_doc("world-inspect")
|
|
1132
|
+
```
|
|
502
1133
|
|
|
503
1134
|
## The rules that matter
|
|
504
1135
|
|
|
1136
|
+
- **NEVER write a root-absolute URL for anything inside your own bundle.** A published world is served
|
|
1137
|
+
from `…/instant-worlds/<id>/<build>/`, so `'/helix_modules'`, `'/models/x.glb'`, `fetch('/data.json')`
|
|
1138
|
+
and friends resolve against the SITE root, 404, and throw. Always resolve against the document:
|
|
1139
|
+
`new URL('helix_modules/', document.baseURI).href`. Keep `base: './'` in the vite config, and prove it
|
|
1140
|
+
with `helix verify-subpath dist` — local preview serves from the root and cannot show you this bug.
|
|
1141
|
+
(Absolute `https://…` CDN URLs and the platform values `helix install` writes into
|
|
1142
|
+
`src/helix.runtime.ts` are a different thing and are correct.)
|
|
505
1143
|
- **One three.** `three` is external everywhere (import map → hosted instance). Never bundle it.
|
|
1144
|
+
- **One rapier.** Same for `@dimforge/rapier3d-compat`: external in every build, import map → the hosted
|
|
1145
|
+
instance, never bundled. The npm devDependency exists only for types + `helix dev`.
|
|
1146
|
+
- **Platform systems stay external.** Keep the generated `id.startsWith('@helix/')` external rule.
|
|
1147
|
+
Compatible `^`/`~` system ranges receive safe native character, emote, camera and pointing updates on
|
|
1148
|
+
a fresh launch without republishing — **including native features that did not exist when you published**
|
|
1149
|
+
(§8g). Engine systems are ranges: validation and publish **refuse an exact version** (it freezes the World on
|
|
1150
|
+
one build). To get an engine fix, promote it rather than pinning to it. Validation and publish also refuse a
|
|
1151
|
+
custom config that silently bundles those systems.
|
|
506
1152
|
- **Assets always stream from the CDN.** Animation clips + textures are never downloaded or shipped
|
|
507
1153
|
with the world. The build contains no `.glb`/`.ktx2`. `loadCharacterAssets` fetches them at runtime.
|
|
508
1154
|
- **Test with `build` + `preview`, not `dev`.** The dev server rewrites bare imports and hides whether
|
|
509
1155
|
the import map is correct; preview serves the real built output the platform runs.
|
|
510
1156
|
- **Pins are locked.** A rebuild uses the versions in `helix.lock.json`; `helix install --update`
|
|
511
1157
|
advances them. Commit the lock so rebuilds are reproducible.
|
|
1158
|
+
- **Keep the top-center clear for platform chrome.** The player shell overlays a bar across the TOP-CENTER of
|
|
1159
|
+
every world (~top 56px: Exit / Save Progress / helixOS / fullscreen — user-hideable, shown by default). Anchor
|
|
1160
|
+
your HUD / on-screen UI to a CORNER (top-left/right) or the BOTTOM; a full-width or centered element goes ≥64px
|
|
1161
|
+
down (the templates' banners sit ~20% down). Never place UI at top-center — it renders behind the chrome.
|
|
1162
|
+
- **World keys are router actions, not `addEventListener`.** Register them on the shared `InputService`
|
|
1163
|
+
(§8c), read them with `wasPressed`/`isDown` after `character.update(dt)`, use `pushContext('menu')` for
|
|
1164
|
+
pause/UI, and never bind the reserved keys (Escape, KeyN, backtick — `capabilities.reservedKeys`).
|
|
1165
|
+
- **Control names come from the router.** Player-facing text uses `input.format('… {move} …')` tokens
|
|
1166
|
+
re-rendered per frame — never hardcoded key names (§8c); custom actions carry a `label`.
|