@helix3/helix-mcp 0.2.2-helix3.14
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/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +353 -0
- package/dist/server.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/docs/catalog.md +28 -0
- package/docs/character-world.md +506 -0
- package/docs/manifest.md +51 -0
- package/docs/multiplayer-logic.md +394 -0
- package/docs/multiplayer-templates/chrono-orchard.md +123 -0
- package/docs/multiplayer-templates/collect-a-thon.md +126 -0
- package/docs/multiplayer-templates/collections.md +116 -0
- package/docs/multiplayer-templates/hangout.md +184 -0
- package/docs/multiplayer-templates/obby.md +84 -0
- package/docs/multiplayer-templates/physics-bumper.md +153 -0
- package/docs/multiplayer-templates/physics-football.md +133 -0
- package/docs/multiplayer-templates/relic-bearers.md +140 -0
- package/docs/multiplayer-templates/server-motion.md +128 -0
- package/docs/multiplayer-templates/team-control.md +98 -0
- package/docs/multiplayer-templates/turn-arena.md +159 -0
- package/docs/multiplayer-templates/wave-survival.md +141 -0
- package/docs/multiplayer-world.md +231 -0
- package/docs/publishing.md +42 -0
- package/docs/sdk.md +117 -0
- package/docs/world-recipe.md +137 -0
- package/package.json +33 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
# HELIX Instant — Character World Recipe (v0.2)
|
|
2
|
+
|
|
3
|
+
Follow this when the world has a **player character**, in **any camera framing**: first-person,
|
|
4
|
+
third-person, **3D top-down** (twin-stick / ARPG / MOBA), **2.5D / side-on** (platformer), an avatar
|
|
5
|
+
that walks / runs / jumps, or behaviors like flying, swimming, or shooting. You build on the published
|
|
6
|
+
**`humanoid-character` system** instead of hand-rolling a character — it gives you a physics-driven
|
|
7
|
+
chassis, locomotion + animation, first/third-person cameras, and an ability host. Top-down and 2.5D
|
|
8
|
+
are **not a different foundation** — they are camera + movement *config* on this same system
|
|
9
|
+
(`camera.initialPitch` / `allowRotate:false` for a fixed top-down angle, `locomotion.movementAxis` to
|
|
10
|
+
lock side-on movement to one axis — see §8a).
|
|
11
|
+
|
|
12
|
+
> For a bare scene with no character (a spinning object, a data viz, a menu), use the **scene**
|
|
13
|
+
> recipe instead: `get_started({ kind: "scene" })`.
|
|
14
|
+
|
|
15
|
+
## 0. Discover what's published FIRST — don't assume
|
|
16
|
+
|
|
17
|
+
The catalog grows. Before you design anything, **fetch the latest** so you reuse what already exists
|
|
18
|
+
instead of rebuilding it:
|
|
19
|
+
|
|
20
|
+
- `list_systems` — runtime frameworks a world can build on (today: `humanoid-character`).
|
|
21
|
+
- `list_abilities` — installable behaviors that plug into a system (e.g. `fly`, `swim`, `gun-control`).
|
|
22
|
+
Filter by `system` to see only compatible ones: `list_abilities({ system: "humanoid-character" })`.
|
|
23
|
+
- `get_package_manifest({ slug })` — the contract for one package before you pin it. For an ability
|
|
24
|
+
this includes its **`configSchema`** (the tunable surface, e.g. `fly.maxSpeed`), activation, and
|
|
25
|
+
actions. The manifest *is* the documentation — never guess a package's options or version.
|
|
26
|
+
|
|
27
|
+
Map the user's request to abilities that already exist (a flying game → pin `fly`; underwater → `swim`;
|
|
28
|
+
shooting → `gun-control`) before writing any custom behavior.
|
|
29
|
+
|
|
30
|
+
## 1. Project layout
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
my-world/
|
|
34
|
+
├── package.json
|
|
35
|
+
├── vite.config.ts
|
|
36
|
+
├── tsconfig.json
|
|
37
|
+
├── index.html
|
|
38
|
+
├── helix.lock.json ← written by `helix install`; commit it
|
|
39
|
+
├── public/
|
|
40
|
+
│ ├── helix.json ← the v0.2 manifest (pins); lands at dist/ root on build
|
|
41
|
+
│ └── helix_modules/ ← written by `helix install` (code only); gitignored
|
|
42
|
+
└── src/
|
|
43
|
+
├── main.ts
|
|
44
|
+
├── loading.ts ← the loading screen (count-based progress bar); restyle/extend freely (§8b)
|
|
45
|
+
└── helix.runtime.ts ← written by `helix install`; committed (do not hand-edit)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 2. package.json
|
|
49
|
+
|
|
50
|
+
`three` is a **devDependency**: it is external at runtime (the platform hosts the single shared
|
|
51
|
+
instance), but Vite needs it at build time to bundle the GLTFLoader/KTX2Loader addons + for types.
|
|
52
|
+
`@dimforge/rapier3d-compat` (the physics engine) bundles into the world.
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"name": "my-world",
|
|
57
|
+
"private": true,
|
|
58
|
+
"type": "module",
|
|
59
|
+
"scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", "typecheck": "tsc --noEmit" },
|
|
60
|
+
"dependencies": { "@dimforge/rapier3d-compat": "^0.14.0", "@hypersoniclabs/helix-sdk": "^0.1" },
|
|
61
|
+
"devDependencies": { "@types/three": "^0.172.0", "three": "^0.172.0", "typescript": "~5.7.3", "vite": "^6.0.0" }
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The system itself is NOT an npm dependency — `helix install` materializes it under
|
|
66
|
+
`public/helix_modules/` and the alias below resolves it.
|
|
67
|
+
|
|
68
|
+
## 3. vite.config.ts
|
|
69
|
+
|
|
70
|
+
Externalize **only the bare `three`** so the world, the embedded system, and every ability share the
|
|
71
|
+
one hosted instance (two copies of three break `instanceof` and silently corrupt rendering).
|
|
72
|
+
`three/examples/jsm/...` is NOT externalized — Vite bundles the addons from your `node_modules` three.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { defineConfig } from 'vite';
|
|
76
|
+
import { fileURLToPath } from 'node:url';
|
|
77
|
+
|
|
78
|
+
export default defineConfig({
|
|
79
|
+
base: './',
|
|
80
|
+
resolve: {
|
|
81
|
+
alias: {
|
|
82
|
+
'@helix/humanoid-character': fileURLToPath(new URL('./public/helix_modules/humanoid-character/index.js', import.meta.url)),
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
build: { target: 'es2022', rollupOptions: { external: ['three'] } },
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## 4. tsconfig.json
|
|
90
|
+
|
|
91
|
+
Point the type path at the embedded system's declarations (installed alongside the code).
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"compilerOptions": {
|
|
96
|
+
"target": "ES2022", "module": "ESNext", "moduleResolution": "bundler",
|
|
97
|
+
"lib": ["ES2022", "DOM"], "strict": true, "noEmit": true, "skipLibCheck": true,
|
|
98
|
+
"paths": { "@helix/humanoid-character": ["./public/helix_modules/humanoid-character/index.d.ts"] }
|
|
99
|
+
},
|
|
100
|
+
"include": ["src"]
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## 5. index.html
|
|
105
|
+
|
|
106
|
+
Ship the `helix:three` import-map markers exactly as below — `helix install` rewrites the import map
|
|
107
|
+
to the hosted three URL. Leave the placeholder; do not fill it by hand. The `#loading` overlay is the
|
|
108
|
+
loading screen: it paints on HTML parse (before any JS), and `src/loading.ts` drives its progress bar
|
|
109
|
+
(§8b). Keep the markers and the overlay; restyle the overlay freely.
|
|
110
|
+
|
|
111
|
+
```html
|
|
112
|
+
<!doctype html>
|
|
113
|
+
<html lang="en">
|
|
114
|
+
<head>
|
|
115
|
+
<meta charset="utf-8" />
|
|
116
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
117
|
+
<title>My World</title>
|
|
118
|
+
<!-- helix:three:start -->
|
|
119
|
+
<script type="importmap">
|
|
120
|
+
{ "imports": { "three": "" } }
|
|
121
|
+
</script>
|
|
122
|
+
<!-- helix:three:end -->
|
|
123
|
+
<style>
|
|
124
|
+
html, body { margin: 0; height: 100%; overflow: hidden; background: #14161a; }
|
|
125
|
+
/* Loading overlay — painted on HTML parse (before any JS downloads → no flash). The bar starts as a
|
|
126
|
+
CSS marquee (no JS); src/loading.ts switches it to a measured fill, then fades out. Restyle freely. */
|
|
127
|
+
#loading { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center;
|
|
128
|
+
background: #14161a; color: #cdd6e0; font: 13px/1.5 ui-monospace, monospace; opacity: 1; transition: opacity 0.4s ease; }
|
|
129
|
+
#loading.done { opacity: 0; pointer-events: none; }
|
|
130
|
+
#loading .card { width: min(360px, 76vw); text-align: center; }
|
|
131
|
+
#loading .eyebrow { font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #6cc7ff; opacity: 0.75; }
|
|
132
|
+
#loading .title { margin: 6px 0 18px; font-size: 16px; font-weight: 600; color: #e6edf5; }
|
|
133
|
+
#loading .bar { height: 6px; border-radius: 3px; background: #232830; overflow: hidden; }
|
|
134
|
+
#loading #bar { width: 30%; height: 100%; border-radius: 3px; background: #6cc7ff; animation: helix-marquee 1.1s ease-in-out infinite; }
|
|
135
|
+
#loading.determinate #bar { animation: none; transition: width 0.25s ease; }
|
|
136
|
+
#loading.working #bar { animation: helix-pulse 1s ease-in-out infinite; }
|
|
137
|
+
#loading.error #bar { animation: none; width: 100% !important; background: #e5675e; }
|
|
138
|
+
#loading-text { margin-top: 12px; color: #9aa7b4; }
|
|
139
|
+
@keyframes helix-marquee { 0% { transform: translateX(-110%); } 100% { transform: translateX(370%); } }
|
|
140
|
+
@keyframes helix-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
141
|
+
</style>
|
|
142
|
+
</head>
|
|
143
|
+
<body>
|
|
144
|
+
<div id="loading">
|
|
145
|
+
<div class="card">
|
|
146
|
+
<div class="eyebrow">HELIX Instant</div>
|
|
147
|
+
<div class="title">My World</div>
|
|
148
|
+
<div class="bar"><div id="bar"></div></div>
|
|
149
|
+
<div id="loading-text">Starting…</div>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
<div id="hud"></div>
|
|
153
|
+
<script type="module" src="/src/main.ts"></script>
|
|
154
|
+
</body>
|
|
155
|
+
</html>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## 6. public/helix.json — pin what you discovered in step 0
|
|
159
|
+
|
|
160
|
+
`helixVersion: "0.2"` opts into systems/abilities. Pin the system + every ability you want as
|
|
161
|
+
`slug -> semver range` (use `^0.2` unless `get_package_manifest` shows a newer line — and keep the system and
|
|
162
|
+
ability pins on the SAME minor: an ability's bundle pins the system minor it was built for, so mixing e.g.
|
|
163
|
+
system `^0.2` with an ability `^0.1` makes the ability refuse to load at runtime). `helix install`
|
|
164
|
+
resolves each range to an exact version and bakes it into `helix.lock.json`.
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"helixVersion": "0.2",
|
|
169
|
+
"title": "My World",
|
|
170
|
+
"slug": "my-world",
|
|
171
|
+
"entry": "index.html",
|
|
172
|
+
"maxPlayers": 1,
|
|
173
|
+
"permissions": ["auth.profile"],
|
|
174
|
+
"supportsMobile": true,
|
|
175
|
+
"contentRating": "everyone",
|
|
176
|
+
"systems": { "humanoid-character": "^0.2" },
|
|
177
|
+
"abilities": { "fly": "^0.2", "swim": "^0.2" }
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## 7. src/helix.runtime.ts — stub (helix install overwrites it)
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
// GENERATED by `helix install` — placeholders until you run it; do not hand-edit.
|
|
185
|
+
export const THREE_VERSION = '';
|
|
186
|
+
export const THREE_MODULE_URL = '';
|
|
187
|
+
export const TRANSCODER_PATH = '/runtime/basis/';
|
|
188
|
+
export const SYSTEM_ASSET_BASE = '';
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## 8. src/main.ts — the flow
|
|
192
|
+
|
|
193
|
+
`loadCharacterAssets` streams the body/face LOD + locomotion clips from the CDN (never bundled);
|
|
194
|
+
`Character.create` drives the chassis; `LocomotionAbility` is the built-in movement; everything you
|
|
195
|
+
pinned loads via `loadInstalledAbilities`. With the `avatar` option, a logged-in player's equipped
|
|
196
|
+
**universal avatar** becomes their body automatically — skeleton-gated and fault-tolerant (guests, no
|
|
197
|
+
avatar, an unconverted rig, or any load failure all fall back to the default body; the world never
|
|
198
|
+
breaks). Delete the `avatar:` line to opt the world out (see `character.universalAvatar.enabled`).
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
import * as THREE from 'three';
|
|
202
|
+
import { Helix } from '@hypersoniclabs/helix-sdk';
|
|
203
|
+
import { Character, loadCharacterAssets, loadInstalledAbilities, LocomotionAbility, RapierBody } from '@helix/humanoid-character';
|
|
204
|
+
import { SYSTEM_ASSET_BASE, TRANSCODER_PATH } from './helix.runtime';
|
|
205
|
+
import { createLoadingScreen } from './loading'; // loading screen — hooks DefaultLoadingManager (§8b)
|
|
206
|
+
|
|
207
|
+
const scene = new THREE.Scene();
|
|
208
|
+
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.05, 200);
|
|
209
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
210
|
+
renderer.setSize(innerWidth, innerHeight);
|
|
211
|
+
document.body.appendChild(renderer.domElement);
|
|
212
|
+
scene.add(new THREE.HemisphereLight(0xbfd4ff, 0x40382a, 0.9));
|
|
213
|
+
|
|
214
|
+
// Mount the loading screen before any loader runs — it hooks THREE.DefaultLoadingManager so every asset
|
|
215
|
+
// load below (character, abilities, any extra GLB you add) counts on the bar automatically. See §8b.
|
|
216
|
+
const loading = createLoadingScreen();
|
|
217
|
+
|
|
218
|
+
// Init FIRST — the body is bind-once, so the player's avatar must resolve before Character.create.
|
|
219
|
+
await Helix.init();
|
|
220
|
+
const equipped = await Helix.avatar.getEquipped(); // null for guests / no avatar / any failure
|
|
221
|
+
|
|
222
|
+
const { model, clips } = await loadCharacterAssets(SYSTEM_ASSET_BASE, {
|
|
223
|
+
renderer, transcoderPath: TRANSCODER_PATH,
|
|
224
|
+
avatar: equipped?.glbUrl ? { url: equipped.glbUrl, skeleton: equipped.skeleton } : undefined,
|
|
225
|
+
});
|
|
226
|
+
scene.add(model);
|
|
227
|
+
|
|
228
|
+
// Assets are in (bar ~90%); the rest is un-metered (Rapier WASM compile, character setup) → show the
|
|
229
|
+
// indeterminate "Starting…" tail so the bar never looks frozen, then dismiss on the first frame.
|
|
230
|
+
loading.working('Starting…');
|
|
231
|
+
|
|
232
|
+
const body = await RapierBody.create({ position: { x: 0, y: 0.2, z: 0 } });
|
|
233
|
+
body.addStaticCuboid({ x: 24, y: 0.5, z: 24 }, { x: 0, y: -0.5, z: 0 }); // floor
|
|
234
|
+
|
|
235
|
+
const character = await Character.create({
|
|
236
|
+
model, camera, domElement: renderer.domElement, body,
|
|
237
|
+
// FIRST-PERSON: set the initial camera mode. Omit for the third-person default.
|
|
238
|
+
config: { character: { camera: { mode: 'first-person' } } },
|
|
239
|
+
});
|
|
240
|
+
character.abilities.register(new LocomotionAbility(clips));
|
|
241
|
+
await loadInstalledAbilities('/helix_modules', character); // fly, swim, … from helix.json
|
|
242
|
+
|
|
243
|
+
// First-person mouse-look needs pointer lock — request it from a click (browser gesture rule).
|
|
244
|
+
renderer.domElement.addEventListener('click', () => character.services.input.requestLook());
|
|
245
|
+
|
|
246
|
+
const clock = new THREE.Clock();
|
|
247
|
+
let firstFrame = true;
|
|
248
|
+
renderer.setAnimationLoop(() => {
|
|
249
|
+
character.update(Math.min(clock.getDelta(), 0.1));
|
|
250
|
+
renderer.render(scene, camera);
|
|
251
|
+
if (firstFrame) { firstFrame = false; loading.dismiss(); } // world on screen → fade the overlay
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## 8a. Configure the character — read the knobs from the manifest, NEVER guess
|
|
256
|
+
|
|
257
|
+
**This is the step agents get wrong.** Speed, camera, spawn, slope, jump-feel — almost everything a
|
|
258
|
+
game designer reaches for is already a **config value**, not a method on the TypeScript class. If you
|
|
259
|
+
inspect the `Character` / `LocomotionAbility` class API and conclude "there's no setting for X", you
|
|
260
|
+
looked in the wrong place: the tunable surface is the **published config**, and the authoritative list
|
|
261
|
+
is in the manifest.
|
|
262
|
+
|
|
263
|
+
> **Rule:** `get_package_manifest("humanoid-character")` → its **`config`** is the complete tunable
|
|
264
|
+
> surface (every key with default, range, and description: `character.spawn.*`, `character.camera.*`,
|
|
265
|
+
> `character.body.*`, `character.lod.*`, `locomotion.*`). Its **`capabilities`** lists input actions,
|
|
266
|
+
> blackboard keys, the runtime **`api`** entry points, and the **`events`**. Each ability adds its own
|
|
267
|
+
> `configSchema` (read its `get_package_manifest`). **Read it before you configure. Do not invent keys.**
|
|
268
|
+
|
|
269
|
+
There are exactly two ways to set any config value, and one runtime API surface:
|
|
270
|
+
|
|
271
|
+
**1. Initial — `Character.create({ config })`.** Nested under the namespace; merges over defaults.
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
const character = await Character.create({
|
|
275
|
+
model, camera, domElement: renderer.domElement, body,
|
|
276
|
+
config: {
|
|
277
|
+
character: {
|
|
278
|
+
spawn: { x: 4, z: -2, facingDeg: 90 }, // where the player starts + which way they look (0 = +Z)
|
|
279
|
+
camera: { mode: 'third-person', initialYaw: 90, lookSensitivity: 1.4, fov: 70 },
|
|
280
|
+
},
|
|
281
|
+
locomotion: { runSpeed: 7, coyoteTimeMs: 120 },
|
|
282
|
+
// ability namespaces too, once pinned & installed: fly: { maxSpeed: 14 }
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
**2. Runtime — `character.config.set('ns.key', value)`.** Read live every tick; the change applies
|
|
288
|
+
immediately. This is how you do timed buffs, difficulty changes, "look at the boss", etc.
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
// Timed speed boost (a pickup): boost, then restore after 4 s — no engine change, pure config.
|
|
292
|
+
const base = 5.5; // or read it: character.config.get('locomotion.runSpeed')
|
|
293
|
+
character.config.set('locomotion.runSpeed', 11);
|
|
294
|
+
setTimeout(() => character.config.set('locomotion.runSpeed', base), 4000);
|
|
295
|
+
|
|
296
|
+
// Re-aim the camera (respawn / cutscene / face a target). yaw = boom azimuth, pitch = view (up +).
|
|
297
|
+
// initialYaw/initialPitch are construct-time SEEDS — to re-aim a LIVE camera, call these, not config.set.
|
|
298
|
+
character.camera?.setYaw(180);
|
|
299
|
+
character.camera?.setPitch(-10);
|
|
300
|
+
|
|
301
|
+
// Live difficulty / movement-lock changes (these keys re-apply on set):
|
|
302
|
+
character.config.set('character.camera.allowRotate', false);
|
|
303
|
+
character.config.set('character.camera.tp.distance', 15);
|
|
304
|
+
character.config.set('locomotion.movementAxis', 'x'); // restrict planar movement to one axis (2.5D)
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
**Top-down / 2.5D recipe.** Set the fixed framing at create time (`initialPitch` is a seed); `allowRotate`,
|
|
308
|
+
`tp.distance` and `movementAxis` can still be flipped live afterward.
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
// 3D top-down (twin-stick): camera looks straight down, no orbit, body faces the movement direction.
|
|
312
|
+
config: { character: { camera: { mode: 'third-person', allowRotate: false, initialPitch: -80, tp: { distance: 15 } } },
|
|
313
|
+
locomotion: { facingMode: 'movement' } }
|
|
314
|
+
|
|
315
|
+
// 2.5D side-on platformer: lock planar movement to the X axis (camera framing as you like).
|
|
316
|
+
config: { locomotion: { movementAxis: 'x' } }
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
**Runtime APIs (from `capabilities.api`)** — for things config can't express:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
character.services.body.teleport({ x, y, z }); // move now (runtime equivalent of spawn)
|
|
323
|
+
character.services.body.applyImpulse({ x: 0, y: 8, z: 0 }); // jump pad / knockback / explosion
|
|
324
|
+
character.respawn(/* optional checkpoint */); // teleport + zero velocity + stand up + reface
|
|
325
|
+
character.setEnabled(false); // hard pause (halts gravity/abilities/anim) — cutscenes
|
|
326
|
+
character.camera?.addTrauma(0.6); // camera shake for a hit/landing (0..1)
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
**Events (subscribe via `character.events.on(name, cb)`):** `landed` (`{ impactSpeed, fallDistance,
|
|
330
|
+
speed }` — `impactSpeed` is vertical, use it for fall damage), `fell`, `respawned`, `jumped`,
|
|
331
|
+
`stateEntered`. The full list + payloads are in `capabilities.events`.
|
|
332
|
+
|
|
333
|
+
### Lock the controls to the genre — disable what doesn't fit (don't ship every control everywhere)
|
|
334
|
+
|
|
335
|
+
Every `allow*` gate **defaults to `true`** (all controls enabled). So you must **opt OUT** of the ones
|
|
336
|
+
that don't belong, or the player keeps a control the game shouldn't have. The classic bug: a
|
|
337
|
+
first-person game where pressing **T** still flips to third-person, because `allowModeToggle` was left
|
|
338
|
+
on. **Match the gates to the genre when you configure the character — don't leave it to chance.**
|
|
339
|
+
|
|
340
|
+
The gates (all `boolean`, default `true` — set `false` to remove the control; confirm names/defaults in
|
|
341
|
+
the manifest `config`):
|
|
342
|
+
|
|
343
|
+
| gate | removes |
|
|
344
|
+
|---|---|
|
|
345
|
+
| `character.camera.allowModeToggle` | first/third-person switch (the **T** action) |
|
|
346
|
+
| `character.camera.allowRotate` | orbit / mouse-look (fixed camera angle) |
|
|
347
|
+
| `character.camera.allowZoom` | scroll-wheel zoom |
|
|
348
|
+
| `character.camera.allowShoulderSwap` | over-shoulder side swap (the **V** action) |
|
|
349
|
+
| `locomotion.allowJump` / `allowCrouch` / `allowSprint` | the jump / crouch / sprint actions |
|
|
350
|
+
|
|
351
|
+
Genre presets (set these at create time, then add the rest of your tuning):
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
// First-person ONLY — keep the player in FP; T must NOT switch to third-person.
|
|
355
|
+
config: { character: { camera: { mode: 'first-person', allowModeToggle: false } } }
|
|
356
|
+
|
|
357
|
+
// Third-person ONLY — lock to TP; no FP toggle, no shoulder-swap clutter.
|
|
358
|
+
config: { character: { camera: { mode: 'third-person', allowModeToggle: false, allowShoulderSwap: false } } }
|
|
359
|
+
|
|
360
|
+
// Top-down — fixed framing: no mode toggle, no orbit, no zoom (combine with the top-down recipe above).
|
|
361
|
+
config: { character: { camera: { mode: 'third-person', allowModeToggle: false, allowRotate: false, allowZoom: false } } }
|
|
362
|
+
|
|
363
|
+
// No-jump (walking sim / puzzle / point-to-move).
|
|
364
|
+
config: { locomotion: { allowJump: false } }
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
You can also flip these live (e.g. disable controls during a cutscene): `character.config.set('character.camera.allowModeToggle', false)`.
|
|
368
|
+
|
|
369
|
+
### First-person vs third-person (a slice of the camera config)
|
|
370
|
+
|
|
371
|
+
- Initial mode: `config.character.camera.mode` = `'first-person'` or `'third-person'` (default).
|
|
372
|
+
- Toggle at runtime: `character.services.camera?.setMode('first-person')` (or `.toggleMode()`).
|
|
373
|
+
- Tune via config like everything else: `character.camera.fp.fov`, `character.camera.tp.distance`,
|
|
374
|
+
`character.camera.lookSensitivity`, `character.camera.minPitchDeg` / `maxPitchDeg`, … — see the manifest.
|
|
375
|
+
|
|
376
|
+
## 8b. Loading screen — `src/loading.ts` (count-based progress, extensible)
|
|
377
|
+
|
|
378
|
+
The `#loading` overlay in `index.html` paints on HTML parse (before any JS downloads → no flash).
|
|
379
|
+
`src/loading.ts` then hooks **`THREE.DefaultLoadingManager`** — the SAME instance the engine's
|
|
380
|
+
GLTFLoader/KTX2Loader use (one shared three via the import map) — so the character, every ability, and
|
|
381
|
+
ANY extra three load you add count on the bar automatically, with no extra code. The bar climbs as
|
|
382
|
+
assets stream, shows an indeterminate **"Starting…"** tail for the un-metered window (Rapier WASM
|
|
383
|
+
compile, character setup), then fades on the first rendered frame. Ship this file verbatim; restyle freely.
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
import * as THREE from 'three';
|
|
387
|
+
|
|
388
|
+
// Loading screen for this world. It hooks THREE.DefaultLoadingManager — the SAME instance the engine's
|
|
389
|
+
// GLTFLoader/KTX2Loader use (one shared three via the import map) — so the character, every ability, and
|
|
390
|
+
// ANY extra three load you add (a world GLB, a TextureLoader, …) count on the bar automatically, with no
|
|
391
|
+
// extra code. For a NON-three asset (audio, a raw fetch), wrap it in loading.task(). The overlay markup
|
|
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;
|
|
405
|
+
/** One-shot fade + remove. Call AFTER the first renderer.render(), never on manager.onLoad (it fires early). */
|
|
406
|
+
dismiss(): void;
|
|
407
|
+
/** Switch to the error state (red bar + message). */
|
|
408
|
+
fail(message: string): void;
|
|
409
|
+
}
|
|
410
|
+
|
|
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
|
+
}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
**Add your own assets to the bar.** An extra three load is counted automatically — just load it on the
|
|
462
|
+
default manager (`new GLTFLoader().loadAsync(...)`). For a NON-three asset (audio, a raw fetch), wrap it
|
|
463
|
+
in `loading.task()`:
|
|
464
|
+
|
|
465
|
+
```ts
|
|
466
|
+
loading.label('Loading music…');
|
|
467
|
+
const music = loading.task(); // counts like one item; the bar waits for it
|
|
468
|
+
const audio = new Audio(new URL('theme.mp3', document.baseURI).href);
|
|
469
|
+
audio.addEventListener('canplaythrough', () => music.done(), { once: true });
|
|
470
|
+
audio.load();
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
**Errors.** To surface a load failure on the overlay instead of a frozen bar, guard the awaited setup and
|
|
474
|
+
call `loading.fail(...)`:
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
try {
|
|
478
|
+
// … loadCharacterAssets … Character.create … loadInstalledAbilities …
|
|
479
|
+
} catch (err) {
|
|
480
|
+
loading.fail(err instanceof Error ? err.message : String(err));
|
|
481
|
+
throw err;
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
## 9. Install, build, publish
|
|
486
|
+
|
|
487
|
+
```bash
|
|
488
|
+
npm install
|
|
489
|
+
helix install # resolve the pins → download code, write installed.json + lock, wire the
|
|
490
|
+
# three import map + src/helix.runtime.ts (re-run after editing pins;
|
|
491
|
+
# `helix install --update` re-resolves ranges to newer versions)
|
|
492
|
+
npm run build # vite build (three external; system + addons + rapier bundle)
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
Then `validate_world` on `dist/` (fix every problem — no `.glb`/`.ktx2` may ship; assets stream from
|
|
496
|
+
the CDN), `whoami` to confirm login, and `publish_world` on `dist/` for the play link.
|
|
497
|
+
|
|
498
|
+
## The rules that matter
|
|
499
|
+
|
|
500
|
+
- **One three.** `three` is external everywhere (import map → hosted instance). Never bundle it.
|
|
501
|
+
- **Assets always stream from the CDN.** Animation clips + textures are never downloaded or shipped
|
|
502
|
+
with the world. The build contains no `.glb`/`.ktx2`. `loadCharacterAssets` fetches them at runtime.
|
|
503
|
+
- **Test with `build` + `preview`, not `dev`.** The dev server rewrites bare imports and hides whether
|
|
504
|
+
the import map is correct; preview serves the real built output the platform runs.
|
|
505
|
+
- **Pins are locked.** A rebuild uses the versions in `helix.lock.json`; `helix install --update`
|
|
506
|
+
advances them. Commit the lock so rebuilds are reproducible.
|
package/docs/manifest.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# helix.json — Manifest Reference (v0.1 / v0.2 / v0.3)
|
|
2
|
+
|
|
3
|
+
Declares a world's identity, entry point, and the platform capabilities it is allowed to use. Lives at the bundle root as helix.json.
|
|
4
|
+
|
|
5
|
+
`helixVersion` selects the schema: **"0.1"** (base), **"0.2"** (adds `systems`/`abilities` catalog pins), **"0.3"** (adds the declarative `multiplayer` block + more permissions). Pick the LOWEST version that has what you need — most single-player scene worlds are 0.1, character worlds are 0.2, multiplayer worlds are 0.3.
|
|
6
|
+
|
|
7
|
+
Required fields (all versions): `helixVersion`, `title`, `slug`, `entry`. Unknown fields are REJECTED.
|
|
8
|
+
|
|
9
|
+
## Base fields (every version)
|
|
10
|
+
|
|
11
|
+
| Field | Type / allowed values | Default | Description |
|
|
12
|
+
| --- | --- | --- | --- |
|
|
13
|
+
| `helixVersion` | `"0.1"` / `"0.2"` / `"0.3"` | — (required) | Manifest schema version. Determines which fields and permissions are available. |
|
|
14
|
+
| `title` | `string` (1–80 chars) | — (required) | Display name shown on the world page and in listings. |
|
|
15
|
+
| `slug` | `string` matching `^[a-z0-9](?:[a-z0-9-]{1,48})[a-z0-9]$` | — (required) | URL identifier (3-50 chars: lowercase letters, digits, hyphens; no leading/trailing hyphen). Globally unique; the world's play URL is /g/{slug}. |
|
|
16
|
+
| `entry` | `string` matching `^(?!/)(?!.*\.\.)[A-Za-z0-9._/-]+\.html$` | — (required) | Bundle-relative path to the HTML entry point (e.g. index.html). Must not be absolute or contain '..'. |
|
|
17
|
+
| `maxPlayers` | `integer` (1–100; platform room cap 24, or 12 at `multiplayer.uploadHz: 20`) | `1` | Player slots per room. 1 = single-player. Any value > 1 REQUIRES the `multiplayer` permission (v0.3). |
|
|
18
|
+
| `permissions` | array (see per-version list below) | `[]` | Platform capabilities the world requests, OAuth-scope style. Worlds get nothing by default. |
|
|
19
|
+
| `supportsMobile` | `boolean` | `false` | Whether the world is playable on mobile browsers. Surfaces as a compatibility tag. |
|
|
20
|
+
| `requiresAuth` | `boolean` | `false` | When true, players must log in before playing — the shell's login overlay offers no guest option, and the world page shows an 'Account required' badge. Leave false (default) for instant guest play. NOTE: the `multiplayer` permission forces this to true (login is mandatory for multiplayer). |
|
|
21
|
+
| `contentRating` | `"unrated"` / `"everyone"` / `"teen"` / `"mature"` | `"unrated"` | Self-declared content rating. Curated review may override. 'unrated' worlds may be restricted from public listing. |
|
|
22
|
+
|
|
23
|
+
## Permissions per version
|
|
24
|
+
|
|
25
|
+
- **v0.1 / v0.2:** `auth.profile` (read the player's id, username, display name).
|
|
26
|
+
- **v0.3:** `auth.profile`, `multiplayer` (join the world's shared room), `voice.room` / `voice.proximity` (reserved), `camera.capture` (save a photo taken with the in-engine world camera to the player's gallery).
|
|
27
|
+
- Cross-field rules the validator enforces: `maxPlayers > 1` ⇒ `multiplayer` permission; `voice.*` ⇒ `multiplayer` permission; the `multiplayer` permission ⇒ `requiresAuth: true`; a `multiplayer` config block (state/entities/rules) ⇒ the `multiplayer` permission.
|
|
28
|
+
|
|
29
|
+
## v0.2+ — `systems` and `abilities` (catalog pins)
|
|
30
|
+
|
|
31
|
+
Maps of `slug -> semver range`, resolved against the platform catalog by `helix install` (which locks exact versions into `helix.lock.json` and copies code into `public/helix_modules/`):
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
"systems": { "humanoid-character": "^0.2" },
|
|
35
|
+
"abilities": { "fly": "^0.2", "swim": "^0.2" }
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Keep system and ability pins on the SAME minor — an ability bundle pins the system minor it was built for, so mixing e.g. system `^0.2` with an ability `^0.1` makes the ability refuse to load at runtime. Full walkthrough: the `character-world` recipe (`get_started { kind: "character" }`).
|
|
39
|
+
|
|
40
|
+
## v0.3 — the `multiplayer` block
|
|
41
|
+
|
|
42
|
+
The declarative multiplayer config (synced state, server-side `when/if/then` rules, entities, zones, timers, phases, `uploadHz`). It is validated at publish and interpreted by the platform's generic room — no server code ships with the world. The grammar is large; author it from the `multiplayer-world` recipe (`get_started { kind: "multiplayer" }`) and the `multiplayer-logic` reference (`read_doc { name: "multiplayer-logic" }`), never from memory.
|
|
43
|
+
|
|
44
|
+
## Bundle rules (enforced at validate AND publish)
|
|
45
|
+
|
|
46
|
+
- Max 200 files, 25 MB per file, 50 MB total, no empty files
|
|
47
|
+
- Paths are bundle-relative: no leading `/`, no `..`
|
|
48
|
+
- Allowed file extensions: html, js, mjs, css, json, map, wasm, png, jpg, jpeg, gif, webp, svg, ico, ktx2, glb, gltf, bin, mp3, ogg, wav, woff, woff2, ttf, txt, md
|
|
49
|
+
- `entry` must exist in the bundle and end in `.html`
|
|
50
|
+
- The manifest file itself must be at the bundle root as `helix.json`
|
|
51
|
+
- v0.2/v0.3: HELIX assets must stream from the CDN — a clip/texture under `helix_modules/` is rejected (only installed CODE lives there)
|