@umicat/three-sdk 0.1.0

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 ADDED
@@ -0,0 +1,108 @@
1
+ # @umicat/three-sdk
2
+
3
+ The three.js runtime for Umicat games, per ADR-033. Platform services come from
4
+ `@umicat/platform-sdk` untouched; what lives here is the engine layer we own.
5
+
6
+ **Status: seed, not a product.** It loads a scene, runs physics, and proves the
7
+ platform seam. It has no editor, no input system, no character controller, no
8
+ audio, and no published package. Nothing in production uses it.
9
+
10
+ ```
11
+ @umicat/platform-sdk identity · saves · gameData · rooms · ai · voice · dialogue
12
+ ▲ (shared with @umicat/phaser-sdk)
13
+ │
14
+ @umicat/three-sdk ThreeUmicat · scene3d format · loadScene3D · physics wiring
15
+ ▲
16
+ your game gameplay
17
+ ```
18
+
19
+ ## What is proven, and by what
20
+
21
+ `npm test` runs a real three.js game in a real browser against a host speaking
22
+ the real wire protocol. **Only the host page and the backend behind it are
23
+ mocked — every line of SDK is the shipped code.**
24
+
25
+ | claim | test |
26
+ |---|---|
27
+ | a 3D game does the real handshake and gets identity | `host` is `umicat-home-ui`, not `standalone` |
28
+ | cloud saves go over RPC, not to localStorage | round-trip, plus the host's RPC log |
29
+ | runtime AI uses the same channel | `ai.complete` returns `{ok, text}` per the protocol |
30
+ | scene3d builds the authored scene | entity count, parenting, the right clip playing |
31
+ | physics runs | a crate dropped from y=6 settles on the ground and does not fall through |
32
+ | design mode is render-only | same entities, zero mixers, zero bodies |
33
+ | authoring mistakes fail loudly | duplicate ids and missing clips throw at load, naming the clips that exist |
34
+
35
+ `slice.png` is the scene those assertions describe, rendered.
36
+
37
+ ## The scene3d format
38
+
39
+ `src/scene3d.ts`. It inherits ADR-021's answer to *what does the editor edit* —
40
+ design data on disk, no save loaded — because that answer is engine-neutral. It
41
+ does **not** inherit the 2D schema, which does not survive three dimensions.
42
+
43
+ Each rule is a decision:
44
+
45
+ - **Rotation is a quaternion**, not Euler angles — those are order-dependent and
46
+ interpolate badly, so an editor round-tripping them drifts.
47
+ - **Ids are authored and stable.** The editor, the runtime and saves all refer to
48
+ entities by id; regenerating them on load breaks every reference on first edit.
49
+ - **Transforms are local to `parent`.** Storing world transforms makes
50
+ reparenting a lie.
51
+ - **Assets are referenced by id**, never by path — paths change on re-import.
52
+ - **Colliders are explicit.** A render mesh used as a dynamic collider is the
53
+ classic way to ship a game that is correct and unplayably slow.
54
+ - **Animation clips are mapped semantically per asset** (`{ walk: 'Walk' }`).
55
+ Guessing that every model calls its walk cycle `Walk` fails silently; an
56
+ independent review's cross-rig retarget returned zero matched bones and zero
57
+ tracks, which is the same class of failure, quieter.
58
+
59
+ `validateScene` refuses duplicate ids, dangling parents, entities that would
60
+ render nothing, trimesh colliders on dynamic bodies, and malformed quaternions —
61
+ at load, because every one of them otherwise appears as a blank screen later.
62
+
63
+ ## Solved: the character that disappeared once the camera moved
64
+
65
+ Worth keeping, because the symptom pointed everywhere except at the cause.
66
+
67
+ In the Courtyard sample the fox rendered at boot and was gone after walking,
68
+ leaving flat ground. Every measurement said it should be visible: 18 draw calls
69
+ and 5,042 triangles that frame, `hero.visible === true`, bones at sensible world
70
+ positions, and three's own `Vector3.project(camera)` putting the character at
71
+ NDC **(0, 0)** — dead centre — while the centre of the frame was a single flat
72
+ colour. A canvas read-back agreed with the screenshot, so it was the render and
73
+ not the capture.
74
+
75
+ **It was the ground.** `makePrimitive` laid the plane down with
76
+ `mesh.rotation.x = -Math.PI / 2`, and `applyTransform` then set the object's
77
+ quaternion from the entity's authored rotation — identity — **discarding it**.
78
+ So every "ground" stood upright as a 40x40 wall. At boot the camera and the
79
+ character were on the same side of it and the picture looked right, with the
80
+ wall reading as ground. Walk past it and the camera is behind a wall, still
81
+ pointing correctly at a character it can no longer see. A raycast through the
82
+ frame centre said it in one line: `ground` at 9.79m, `fox` at 13.89m.
83
+
84
+ Fixed by rotating the **geometry** (`PlaneGeometry(...).rotateX(-Math.PI/2)`)
85
+ rather than the object, so an entity's authored rotation is never overwritten.
86
+ After the fix the same raycast returns `fox` at 13.89m then `ground` at 18.05m —
87
+ which matches the hand-computed ground crossing exactly.
88
+
89
+ Two lessons kept deliberately. **A correct-looking measurement can be measuring
90
+ the right thing about the wrong scene**: NDC (0,0) was true the whole time, the
91
+ character *was* centred, behind a wall. And **the first render looked fine**,
92
+ which is how a construction bug this total survived a screenshot.
93
+
94
+ One real bug was fixed while chasing it and is unrelated but worth having: a
95
+ `SkinnedMesh`'s bounding sphere comes from the bind pose and does not follow the
96
+ bones, so three.js culls a character against a stale volume once it moves.
97
+ `loadScene3D` sets `frustumCulled = false` on skinned meshes.
98
+
99
+ ## What is deliberately missing
100
+
101
+ No character controller (Rapier's `KinematicCharacterController` is the intended
102
+ basis), no input, no audio, no editor, no HUD, no asset pipeline integration, no
103
+ published build. Those are the next slice's scope, and pretending otherwise
104
+ would be a worse lie than the gap.
105
+
106
+ `ThreeUmicat.dialogue` has **no default renderer**: a game passes `opts.renderer`
107
+ or it throws. A 3D dialogue box is a design question nobody has answered, and
108
+ shipping a broken default would be worse than requiring an explicit one.
@@ -0,0 +1,71 @@
1
+ import type * as THREE from 'three';
2
+ /** The Rapier surface this controller needs, typed structurally. */
3
+ interface RapierRuntime {
4
+ ColliderDesc: any;
5
+ RigidBodyDesc: any;
6
+ }
7
+ export interface CharacterOptions {
8
+ /** Capsule half-height (excluding the caps) and radius. */
9
+ halfHeight?: number;
10
+ radius?: number;
11
+ /** Metres per second on flat ground. */
12
+ speed?: number;
13
+ /** Max step the character walks up without jumping. */
14
+ stepHeight?: number;
15
+ /** Steeper than this and they slide instead of climbing. Degrees. */
16
+ maxSlopeDegrees?: number;
17
+ gravity?: number;
18
+ /** Start position. */
19
+ position?: {
20
+ x: number;
21
+ y: number;
22
+ z: number;
23
+ };
24
+ }
25
+ /**
26
+ * A kinematic character: walks, collides with walls, climbs steps, falls.
27
+ *
28
+ * Rapier ships the hard part (`KinematicCharacterController` with autostep,
29
+ * ground snapping and slope limits), so this is glue — but glue with one trap
30
+ * in it, which an independent review hit and documented before I did:
31
+ *
32
+ * **Gravity is an acceleration, not a displacement.** Feeding the controller
33
+ * `-9.81 / 60` each frame asks it to move down at a constant 0.16 m per step,
34
+ * which looks like falling and is not. It passes a wall test and fails a step
35
+ * test, because a constant downward push fights autostep every frame. So
36
+ * vertical velocity accumulates while airborne and resets on landing, and the
37
+ * grounded state applies only a small downward bias — enough for snap-to-ground
38
+ * to keep contact on slopes and stairs, not enough to pin the character down.
39
+ */
40
+ export declare class CharacterController3D {
41
+ private readonly world;
42
+ readonly body: any;
43
+ readonly collider: any;
44
+ private readonly controller;
45
+ private verticalVelocity;
46
+ private readonly opts;
47
+ constructor(world: any, RAPIER: RapierRuntime, options?: CharacterOptions);
48
+ get grounded(): boolean;
49
+ get position(): {
50
+ x: number;
51
+ y: number;
52
+ z: number;
53
+ };
54
+ /**
55
+ * Move for one step. `dir` is a desired direction in world space (y ignored);
56
+ * it is normalised here so diagonals aren't faster than cardinals — a bug old
57
+ * enough to have a name.
58
+ */
59
+ update(dt: number, dir: {
60
+ x: number;
61
+ z: number;
62
+ }): void;
63
+ /** Copy the simulated position onto the rendered object. */
64
+ syncTo(object: THREE.Object3D, yOffset?: number): void;
65
+ /** Face the direction of travel; ignores tiny inputs so idle doesn't spin. */
66
+ faceTowards(object: THREE.Object3D, dir: {
67
+ x: number;
68
+ z: number;
69
+ }, dt: number, turnRate?: number): void;
70
+ }
71
+ export {};
@@ -0,0 +1,83 @@
1
+ /**
2
+ * A kinematic character: walks, collides with walls, climbs steps, falls.
3
+ *
4
+ * Rapier ships the hard part (`KinematicCharacterController` with autostep,
5
+ * ground snapping and slope limits), so this is glue — but glue with one trap
6
+ * in it, which an independent review hit and documented before I did:
7
+ *
8
+ * **Gravity is an acceleration, not a displacement.** Feeding the controller
9
+ * `-9.81 / 60` each frame asks it to move down at a constant 0.16 m per step,
10
+ * which looks like falling and is not. It passes a wall test and fails a step
11
+ * test, because a constant downward push fights autostep every frame. So
12
+ * vertical velocity accumulates while airborne and resets on landing, and the
13
+ * grounded state applies only a small downward bias — enough for snap-to-ground
14
+ * to keep contact on slopes and stairs, not enough to pin the character down.
15
+ */
16
+ export class CharacterController3D {
17
+ constructor(world, RAPIER, options = {}) {
18
+ this.world = world;
19
+ this.verticalVelocity = 0;
20
+ this.opts = {
21
+ halfHeight: options.halfHeight ?? 0.5,
22
+ radius: options.radius ?? 0.35,
23
+ speed: options.speed ?? 4,
24
+ stepHeight: options.stepHeight ?? 0.4,
25
+ maxSlopeDegrees: options.maxSlopeDegrees ?? 50,
26
+ gravity: options.gravity ?? 9.81,
27
+ };
28
+ const p = options.position ?? { x: 0, y: 2, z: 0 };
29
+ this.body = world.createRigidBody(RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(p.x, p.y, p.z));
30
+ this.collider = world.createCollider(RAPIER.ColliderDesc.capsule(this.opts.halfHeight, this.opts.radius), this.body);
31
+ // The offset is the skin the solver keeps between the character and
32
+ // geometry; too small and it jitters against walls, too large and it floats.
33
+ this.controller = world.createCharacterController(0.02);
34
+ this.controller.setUp({ x: 0, y: 1, z: 0 });
35
+ this.controller.enableAutostep(this.opts.stepHeight, this.opts.radius * 0.5, true);
36
+ this.controller.enableSnapToGround(this.opts.stepHeight * 0.75);
37
+ this.controller.setMaxSlopeClimbAngle((this.opts.maxSlopeDegrees * Math.PI) / 180);
38
+ this.controller.setApplyImpulsesToDynamicBodies(true);
39
+ }
40
+ get grounded() { return this.controller.computedGrounded(); }
41
+ get position() { return this.body.translation(); }
42
+ /**
43
+ * Move for one step. `dir` is a desired direction in world space (y ignored);
44
+ * it is normalised here so diagonals aren't faster than cardinals — a bug old
45
+ * enough to have a name.
46
+ */
47
+ update(dt, dir) {
48
+ const len = Math.hypot(dir.x, dir.z);
49
+ const nx = len > 0 ? (dir.x / len) * this.opts.speed * dt : 0;
50
+ const nz = len > 0 ? (dir.z / len) * this.opts.speed * dt : 0;
51
+ if (this.grounded) {
52
+ // Small constant bias, not accumulated gravity: enough for snap-to-ground
53
+ // to hold contact over stairs and slopes without pinning the character.
54
+ this.verticalVelocity = -this.opts.gravity * dt;
55
+ }
56
+ else {
57
+ this.verticalVelocity -= this.opts.gravity * dt;
58
+ }
59
+ this.controller.computeColliderMovement(this.collider, {
60
+ x: nx, y: this.verticalVelocity * dt, z: nz,
61
+ });
62
+ const move = this.controller.computedMovement();
63
+ const t = this.body.translation();
64
+ this.body.setNextKinematicTranslation({ x: t.x + move.x, y: t.y + move.y, z: t.z + move.z });
65
+ }
66
+ /** Copy the simulated position onto the rendered object. */
67
+ syncTo(object, yOffset = 0) {
68
+ const t = this.body.translation();
69
+ object.position.set(t.x, t.y + yOffset, t.z);
70
+ }
71
+ /** Face the direction of travel; ignores tiny inputs so idle doesn't spin. */
72
+ faceTowards(object, dir, dt, turnRate = 10) {
73
+ if (Math.hypot(dir.x, dir.z) < 0.01)
74
+ return;
75
+ const want = Math.atan2(dir.x, dir.z);
76
+ let delta = want - object.rotation.y;
77
+ while (delta > Math.PI)
78
+ delta -= Math.PI * 2;
79
+ while (delta < -Math.PI)
80
+ delta += Math.PI * 2;
81
+ object.rotation.y += delta * Math.min(1, turnRate * dt);
82
+ }
83
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Keyboard movement, in the smallest form that is still correct.
3
+ *
4
+ * Reads key STATE rather than key events, because an event-driven controller
5
+ * drops input whenever a frame lands between keydown and the read, and holds a
6
+ * direction forever if the keyup is lost (alt-tab is the classic way to lose
7
+ * one). `dispose()` matters: a listener left on window outlives the scene.
8
+ */
9
+ export declare class Input3D {
10
+ private readonly target;
11
+ private readonly held;
12
+ private readonly onDown;
13
+ private readonly onUp;
14
+ private readonly onBlur;
15
+ constructor(target?: Window);
16
+ isDown(...codes: string[]): boolean;
17
+ /** Camera-relative would need the camera; this is world-axis movement. */
18
+ direction(): {
19
+ x: number;
20
+ z: number;
21
+ };
22
+ /** Test seam: drive the controller without synthesising DOM events. */
23
+ press(code: string): void;
24
+ release(code: string): void;
25
+ dispose(): void;
26
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Keyboard movement, in the smallest form that is still correct.
3
+ *
4
+ * Reads key STATE rather than key events, because an event-driven controller
5
+ * drops input whenever a frame lands between keydown and the read, and holds a
6
+ * direction forever if the keyup is lost (alt-tab is the classic way to lose
7
+ * one). `dispose()` matters: a listener left on window outlives the scene.
8
+ */
9
+ export class Input3D {
10
+ constructor(target = window) {
11
+ this.target = target;
12
+ this.held = new Set();
13
+ this.onDown = (e) => { this.held.add(e.code); };
14
+ this.onUp = (e) => { this.held.delete(e.code); };
15
+ this.onBlur = () => { this.held.clear(); };
16
+ target.addEventListener('keydown', this.onDown);
17
+ target.addEventListener('keyup', this.onUp);
18
+ // Losing focus mid-press would otherwise leave the character walking.
19
+ target.addEventListener('blur', this.onBlur);
20
+ }
21
+ isDown(...codes) { return codes.some((c) => this.held.has(c)); }
22
+ /** Camera-relative would need the camera; this is world-axis movement. */
23
+ direction() {
24
+ let x = 0, z = 0;
25
+ if (this.isDown('KeyW', 'ArrowUp'))
26
+ z -= 1;
27
+ if (this.isDown('KeyS', 'ArrowDown'))
28
+ z += 1;
29
+ if (this.isDown('KeyA', 'ArrowLeft'))
30
+ x -= 1;
31
+ if (this.isDown('KeyD', 'ArrowRight'))
32
+ x += 1;
33
+ return { x, z };
34
+ }
35
+ /** Test seam: drive the controller without synthesising DOM events. */
36
+ press(code) { this.held.add(code); }
37
+ release(code) { this.held.delete(code); }
38
+ dispose() {
39
+ this.target.removeEventListener('keydown', this.onDown);
40
+ this.target.removeEventListener('keyup', this.onUp);
41
+ this.target.removeEventListener('blur', this.onBlur);
42
+ this.held.clear();
43
+ }
44
+ }
@@ -0,0 +1,49 @@
1
+ import * as THREE from 'three';
2
+ import { type Scene3D, type Manifest3D } from './scene3d.js';
3
+ export interface LoadSceneOptions {
4
+ /** Base url assets resolve against (the project's asset host). */
5
+ assetBase?: string;
6
+ /** Provide the physics module to get colliders; omit for a render-only load
7
+ * (which is what an editor's design view wants — see ADR-021). */
8
+ rapier?: RapierLike;
9
+ /** Design view: spawn the authored scene, run no game logic, play no clips. */
10
+ designMode?: boolean;
11
+ }
12
+ /** The slice of Rapier we use, typed structurally so this module needn't depend on it. */
13
+ export interface RapierLike {
14
+ World: new (gravity: {
15
+ x: number;
16
+ y: number;
17
+ z: number;
18
+ }) => any;
19
+ RigidBodyDesc: any;
20
+ ColliderDesc: any;
21
+ }
22
+ export interface LoadedScene3D {
23
+ scene: THREE.Scene;
24
+ camera: THREE.PerspectiveCamera;
25
+ /** Entity id → the object spawned for it. The stable handle game code uses. */
26
+ entities: Map<string, THREE.Object3D>;
27
+ mixers: THREE.AnimationMixer[];
28
+ /** Entity id → its mixer, for games that drive clips themselves. */
29
+ mixerFor: Map<string, THREE.AnimationMixer>;
30
+ /** Model asset id → every clip that model shipped with. A game switching
31
+ * between idle and walk needs the clips, not just the one playing. */
32
+ clips: Map<string, THREE.AnimationClip[]>;
33
+ /** Entity id → rigid body, when physics was supplied. */
34
+ bodies: Map<string, any>;
35
+ world?: any;
36
+ /** Advance animation + physics. Call once per frame with seconds. */
37
+ update(dt: number): void;
38
+ dispose(): void;
39
+ }
40
+ /**
41
+ * Turn scene3d design data into a running three.js scene.
42
+ *
43
+ * The 2D SDK's `loadWorldScene` → `spawnEntity` path, in three dimensions and
44
+ * with the same contract: **this reads authored design data and nothing else.**
45
+ * No save is loaded, no game code runs. That separation is ADR-021's, already
46
+ * settled and shipped for 2D, and it is the reason an editor can render a scene
47
+ * without starting a game.
48
+ */
49
+ export declare function loadScene3D(scene3d: Scene3D, manifest: Manifest3D, opts?: LoadSceneOptions): Promise<LoadedScene3D>;
@@ -0,0 +1,238 @@
1
+ import * as THREE from 'three';
2
+ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
3
+ import { validateScene, IDENTITY_QUAT, } from './scene3d.js';
4
+ function toVec(v, d = 0) {
5
+ return new THREE.Vector3(v?.x ?? d, v?.y ?? d, v?.z ?? d);
6
+ }
7
+ function applyTransform(obj, e) {
8
+ const t = e.transform;
9
+ obj.position.copy(toVec(t.position));
10
+ const q = t.rotation ?? IDENTITY_QUAT;
11
+ obj.quaternion.set(q[0], q[1], q[2], q[3]);
12
+ if (typeof t.scale === 'number')
13
+ obj.scale.setScalar(t.scale);
14
+ else if (t.scale)
15
+ obj.scale.copy(toVec(t.scale, 1));
16
+ }
17
+ function makePrimitive(e) {
18
+ const p = e.primitive;
19
+ const s = p.size ?? { x: 1, y: 1, z: 1 };
20
+ const geom = p.kind === 'box' ? new THREE.BoxGeometry(s.x, s.y, s.z)
21
+ : p.kind === 'sphere' ? new THREE.SphereGeometry(s.x / 2, 24, 16)
22
+ : p.kind === 'cylinder' ? new THREE.CylinderGeometry(s.x / 2, s.x / 2, s.y, 24)
23
+ // Lay the plane down by rotating the GEOMETRY, not the object. Rotating the
24
+ // object is the obvious move and it is wrong: applyTransform sets the
25
+ // object's quaternion from the entity's authored rotation straight
26
+ // afterwards, silently discarding it — which left every "ground" standing
27
+ // upright like a wall. It renders convincingly until the camera crosses to
28
+ // the far side, at which point the scene becomes a flat fill and every
29
+ // measurement still says the character is dead centre, because it is —
30
+ // behind a 40x40 wall.
31
+ : new THREE.PlaneGeometry(s.x, s.z).rotateX(-Math.PI / 2);
32
+ const mesh = new THREE.Mesh(geom, new THREE.MeshStandardMaterial({
33
+ color: new THREE.Color(p.color ?? '#c8c8c8'), roughness: 0.85,
34
+ }));
35
+ mesh.castShadow = p.kind !== 'plane';
36
+ mesh.receiveShadow = true;
37
+ return mesh;
38
+ }
39
+ /**
40
+ * Turn scene3d design data into a running three.js scene.
41
+ *
42
+ * The 2D SDK's `loadWorldScene` → `spawnEntity` path, in three dimensions and
43
+ * with the same contract: **this reads authored design data and nothing else.**
44
+ * No save is loaded, no game code runs. That separation is ADR-021's, already
45
+ * settled and shipped for 2D, and it is the reason an editor can render a scene
46
+ * without starting a game.
47
+ */
48
+ export async function loadScene3D(scene3d, manifest, opts = {}) {
49
+ const problems = validateScene(scene3d);
50
+ if (problems.length) {
51
+ // Loudly, at load. A scene with a dangling parent or a duplicate id fails
52
+ // as a blank screen an hour later otherwise.
53
+ throw new Error(`scene3d '${scene3d.id}' is invalid:\n - ${problems.join('\n - ')}`);
54
+ }
55
+ const scene = new THREE.Scene();
56
+ if (scene3d.environment?.background)
57
+ scene.background = new THREE.Color(scene3d.environment.background);
58
+ if (scene3d.environment?.fog) {
59
+ const f = scene3d.environment.fog;
60
+ scene.fog = new THREE.Fog(new THREE.Color(f.color), f.near, f.far);
61
+ }
62
+ for (const l of scene3d.lights ?? []) {
63
+ const colour = new THREE.Color(l.color ?? '#ffffff');
64
+ const light = l.kind === 'directional' ? new THREE.DirectionalLight(colour, l.intensity ?? 1)
65
+ : l.kind === 'hemisphere' ? new THREE.HemisphereLight(colour, new THREE.Color(l.groundColor ?? '#444444'), l.intensity ?? 1)
66
+ : l.kind === 'point' ? new THREE.PointLight(colour, l.intensity ?? 1)
67
+ : new THREE.AmbientLight(colour, l.intensity ?? 1);
68
+ if (l.position && 'position' in light)
69
+ light.position.copy(toVec(l.position));
70
+ if (l.castShadow && 'castShadow' in light)
71
+ light.castShadow = true;
72
+ light.name = l.id;
73
+ scene.add(light);
74
+ }
75
+ // Load each referenced model ONCE and clone per entity. Two entities sharing
76
+ // a model must not download it twice.
77
+ const models = new Map((manifest.models ?? []).map((m) => [m.id, m]));
78
+ const needed = new Set(scene3d.entities.map((e) => e.modelAssetId).filter(Boolean));
79
+ const loaded = new Map();
80
+ if (needed.size) {
81
+ const loader = new GLTFLoader();
82
+ await Promise.all([...needed].map(async (id) => {
83
+ const asset = models.get(id);
84
+ if (!asset)
85
+ throw new Error(`scene3d references model '${id}', which the manifest does not define`);
86
+ const url = (opts.assetBase ?? '') + asset.path;
87
+ loaded.set(id, await loader.loadAsync(url));
88
+ }));
89
+ }
90
+ const entities = new Map();
91
+ const mixers = [];
92
+ const mixerFor = new Map();
93
+ const clips = new Map();
94
+ for (const [id, gltf] of loaded)
95
+ clips.set(id, gltf.animations);
96
+ for (const e of scene3d.entities) {
97
+ let obj;
98
+ if (e.modelAssetId) {
99
+ const gltf = loaded.get(e.modelAssetId);
100
+ const asset = models.get(e.modelAssetId);
101
+ // SkeletonUtils.clone would be needed for skinned meshes sharing a model;
102
+ // a single instance per asset is the case the slice covers, so clone only
103
+ // when a second entity wants the same asset.
104
+ const used = [...entities.values()].some((o) => o.userData.modelAssetId === e.modelAssetId);
105
+ obj = used ? gltf.scene.clone(true) : gltf.scene;
106
+ obj.userData.modelAssetId = e.modelAssetId;
107
+ if (asset.importScale)
108
+ obj.scale.setScalar(asset.importScale);
109
+ obj.traverse((o) => {
110
+ const mesh = o;
111
+ if (!mesh.isMesh)
112
+ return;
113
+ mesh.castShadow = true;
114
+ mesh.receiveShadow = true;
115
+ // A SkinnedMesh's bounding sphere is computed from the BIND pose and
116
+ // does not follow the bones. Once the object moves, three.js culls it
117
+ // against a stale volume and the character vanishes — at exactly the
118
+ // moment it starts walking, which is the worst possible time and reads
119
+ // as "the model disappeared" rather than "culling is wrong". Cost is
120
+ // one extra draw call per character; the alternative is recomputing the
121
+ // bounds every frame.
122
+ if (mesh.isSkinnedMesh)
123
+ mesh.frustumCulled = false;
124
+ });
125
+ if (!opts.designMode && e.animation?.play && gltf.animations.length) {
126
+ const clipName = asset.animations?.[e.animation.play] ?? e.animation.play;
127
+ const clip = gltf.animations.find((c) => c.name === clipName);
128
+ if (!clip) {
129
+ // Say which names exist. "Walk not found" with no list is the single
130
+ // most annoying asset error there is.
131
+ throw new Error(`entity '${e.id}' wants clip '${e.animation.play}' → '${clipName}', but ` +
132
+ `model '${e.modelAssetId}' has: ${gltf.animations.map((c) => c.name).join(', ') || '(none)'}`);
133
+ }
134
+ const mixer = new THREE.AnimationMixer(obj);
135
+ const action = mixer.clipAction(clip);
136
+ action.setLoop(e.animation.loop === false ? THREE.LoopOnce : THREE.LoopRepeat, Infinity);
137
+ action.play();
138
+ mixers.push(mixer);
139
+ mixerFor.set(e.id, mixer);
140
+ }
141
+ }
142
+ else {
143
+ obj = makePrimitive(e);
144
+ }
145
+ obj.name = e.name ?? e.id;
146
+ obj.userData.entityId = e.id;
147
+ if (e.properties)
148
+ obj.userData.properties = e.properties;
149
+ if (e.visible === false)
150
+ obj.visible = false;
151
+ applyTransform(obj, e);
152
+ entities.set(e.id, obj);
153
+ }
154
+ // Parent after every entity exists, so declaration order doesn't matter.
155
+ for (const e of scene3d.entities) {
156
+ const obj = entities.get(e.id);
157
+ if (e.parent)
158
+ entities.get(e.parent).add(obj);
159
+ else
160
+ scene.add(obj);
161
+ }
162
+ // Physics, when asked for. A design-time load skips it entirely.
163
+ const bodies = new Map();
164
+ let world;
165
+ if (opts.rapier && !opts.designMode) {
166
+ const g = scene3d.gravity ?? { x: 0, y: -9.81, z: 0 };
167
+ world = new opts.rapier.World(g);
168
+ for (const e of scene3d.entities) {
169
+ if (!e.collider)
170
+ continue;
171
+ const R = opts.rapier;
172
+ const p = e.transform.position;
173
+ const off = e.collider.offset ?? { x: 0, y: 0, z: 0 };
174
+ const desc = e.collider.body === 'fixed' ? R.RigidBodyDesc.fixed()
175
+ : e.collider.body === 'kinematic' ? R.RigidBodyDesc.kinematicPositionBased()
176
+ : R.RigidBodyDesc.dynamic();
177
+ desc.setTranslation(p.x + off.x, p.y + off.y, p.z + off.z);
178
+ const q = e.transform.rotation ?? IDENTITY_QUAT;
179
+ desc.setRotation({ x: q[0], y: q[1], z: q[2], w: q[3] });
180
+ const body = world.createRigidBody(desc);
181
+ const s = e.collider.shape;
182
+ const shape = s.kind === 'box' ? R.ColliderDesc.cuboid(s.halfExtents.x, s.halfExtents.y, s.halfExtents.z)
183
+ : s.kind === 'sphere' ? R.ColliderDesc.ball(s.radius)
184
+ : s.kind === 'capsule' ? R.ColliderDesc.capsule(s.halfHeight, s.radius)
185
+ : null;
186
+ if (!shape)
187
+ throw new Error(`entity '${e.id}': trimesh colliders are not supported yet`);
188
+ world.createCollider(shape, body);
189
+ bodies.set(e.id, body);
190
+ }
191
+ }
192
+ const camera = new THREE.PerspectiveCamera(scene3d.camera?.fov ?? 55, 1, 0.1, 500);
193
+ const camOffset = toVec(scene3d.camera?.offset ?? { x: 0, y: 4, z: 8 });
194
+ camera.position.copy(camOffset);
195
+ const followTarget = scene3d.camera?.kind === 'follow' && scene3d.camera.target
196
+ ? entities.get(scene3d.camera.target) : undefined;
197
+ return {
198
+ scene, camera, entities, mixers, mixerFor, clips, bodies, world,
199
+ update(dt) {
200
+ for (const m of mixers)
201
+ m.update(dt);
202
+ if (world) {
203
+ world.step();
204
+ // Dynamic bodies drive their objects; fixed ones never move, and a
205
+ // kinematic body is driven BY the game, so neither is read back.
206
+ for (const [id, body] of bodies) {
207
+ const e = scene3d.entities.find((x) => x.id === id);
208
+ if (e.collider?.body !== 'dynamic')
209
+ continue;
210
+ const obj = entities.get(id);
211
+ const t = body.translation();
212
+ const r = body.rotation();
213
+ obj.position.set(t.x, t.y, t.z);
214
+ obj.quaternion.set(r.x, r.y, r.z, r.w);
215
+ }
216
+ }
217
+ if (followTarget) {
218
+ const want = followTarget.position.clone().add(camOffset);
219
+ camera.position.lerp(want, Math.min(1, dt * 6));
220
+ camera.lookAt(followTarget.position.x, followTarget.position.y + 1, followTarget.position.z);
221
+ }
222
+ },
223
+ dispose() {
224
+ for (const m of mixers)
225
+ m.stopAllAction();
226
+ scene.traverse((o) => {
227
+ const mesh = o;
228
+ if (mesh.geometry)
229
+ mesh.geometry.dispose();
230
+ const mat = mesh.material;
231
+ if (Array.isArray(mat))
232
+ mat.forEach((m) => m.dispose());
233
+ else
234
+ mat?.dispose();
235
+ });
236
+ },
237
+ };
238
+ }
@@ -0,0 +1,23 @@
1
+ import { UmicatCore } from '@umicat/platform-sdk/core/UmicatCore.js';
2
+ import type { UmicatInitOptions } from '@umicat/platform-sdk/core/UmicatCore.js';
3
+ import { DialogueModule } from '@umicat/platform-sdk/dialogue/DialogueModule.js';
4
+ export type { UmicatInitOptions };
5
+ /**
6
+ * Umicat platform services for a three.js game.
7
+ *
8
+ * The entire point of ADR-033's extraction: identity, cloud saves, shared game
9
+ * data, multiplayer, runtime AI and voice arrive here **unchanged** from
10
+ * `@umicat/platform-sdk` — the same code paths a Phaser game uses, talking the same
11
+ * protocol to the same host. This class exists only to add what needs a
12
+ * renderer, and to report its own version in the handshake.
13
+ *
14
+ * Dialogue starts with no default renderer. A three.js game supplies one (DOM
15
+ * overlay or in-scene), or passes `opts.renderer` per call; there is no
16
+ * built-in box yet, and pretending otherwise would ship a broken default.
17
+ */
18
+ export declare class ThreeUmicat extends UmicatCore {
19
+ /** Scripted (authored, non-AI) dialogue. Supply a renderer when playing. */
20
+ readonly dialogue: DialogueModule<unknown, unknown>;
21
+ private constructor();
22
+ static init(options?: UmicatInitOptions): Promise<ThreeUmicat>;
23
+ }
@@ -0,0 +1,26 @@
1
+ import { UmicatCore } from '@umicat/platform-sdk/core/UmicatCore.js';
2
+ import { DialogueModule } from '@umicat/platform-sdk/dialogue/DialogueModule.js';
3
+ /** Kept in sync with package.json on each publish. */
4
+ const SDK_VERSION = '0.0.1';
5
+ /**
6
+ * Umicat platform services for a three.js game.
7
+ *
8
+ * The entire point of ADR-033's extraction: identity, cloud saves, shared game
9
+ * data, multiplayer, runtime AI and voice arrive here **unchanged** from
10
+ * `@umicat/platform-sdk` — the same code paths a Phaser game uses, talking the same
11
+ * protocol to the same host. This class exists only to add what needs a
12
+ * renderer, and to report its own version in the handshake.
13
+ *
14
+ * Dialogue starts with no default renderer. A three.js game supplies one (DOM
15
+ * overlay or in-scene), or passes `opts.renderer` per call; there is no
16
+ * built-in box yet, and pretending otherwise would ship a broken default.
17
+ */
18
+ export class ThreeUmicat extends UmicatCore {
19
+ constructor(transport) {
20
+ super(transport);
21
+ this.dialogue = new DialogueModule(this.saves, () => transport.locale ?? 'en', () => transport.user?.name ?? null);
22
+ }
23
+ static async init(options = {}) {
24
+ return new ThreeUmicat(await UmicatCore.connect(SDK_VERSION, options));
25
+ }
26
+ }
@@ -0,0 +1,12 @@
1
+ export { ThreeUmicat } from './ThreeUmicat.js';
2
+ export type { UmicatInitOptions } from './ThreeUmicat.js';
3
+ export * from './scene3d.js';
4
+ export { loadScene3D } from './SceneLoader3D.js';
5
+ export { CharacterController3D } from './CharacterController3D.js';
6
+ export type { CharacterOptions } from './CharacterController3D.js';
7
+ export { Input3D } from './Input3D.js';
8
+ export type { LoadedScene3D, LoadSceneOptions } from './SceneLoader3D.js';
9
+ export { ORIENTATION_DIMENSIONS } from '@umicat/platform-sdk/orientation.js';
10
+ export type { Orientation } from '@umicat/platform-sdk/orientation.js';
11
+ export { RpcError } from '@umicat/platform-sdk/core/Transport.js';
12
+ export type { UmicatUser } from '@umicat/platform-sdk/protocol.js';
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ // @umicat/three-sdk — the three.js runtime for Umicat games.
2
+ //
3
+ // Platform services come from @umicat/platform-sdk untouched. What lives here is
4
+ // the engine layer ADR-033 says we own: the scene3d design format, the loader
5
+ // that turns it into a three.js scene with physics, and the runtime glue.
6
+ export { ThreeUmicat } from './ThreeUmicat.js';
7
+ export * from './scene3d.js';
8
+ export { loadScene3D } from './SceneLoader3D.js';
9
+ export { CharacterController3D } from './CharacterController3D.js';
10
+ export { Input3D } from './Input3D.js';
11
+ // Re-exported so a game imports one package for the common case. A game should
12
+ // not have to know that identity and saves come from a different package than
13
+ // the renderer.
14
+ export { ORIENTATION_DIMENSIONS } from '@umicat/platform-sdk/orientation.js';
15
+ export { RpcError } from '@umicat/platform-sdk/core/Transport.js';
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The `scene3d` design-data format.
3
+ *
4
+ * ADR-021 settled *what* the editor edits — design data on disk, no save loaded
5
+ * — and that answer is engine-neutral, so this format inherits it directly. What
6
+ * it does NOT inherit is the 2D schema: an independent review was right that
7
+ * `{x, y, rotation, scaleX, scaleY, depth}` does not survive the jump. Three
8
+ * dimensions need rotation that composes without gimbal problems, a parent
9
+ * hierarchy, materials that can be shared, and colliders that are authored
10
+ * rather than derived.
11
+ *
12
+ * Design rules, each of which is a decision rather than an accident:
13
+ *
14
+ * - **Rotation is a quaternion.** Euler angles are order-dependent and
15
+ * interpolate badly; an editor that round-trips them will drift.
16
+ * - **Ids are stable and authored.** The editor, the runtime and the save file
17
+ * all refer to entities by id, so regenerating them on load would break every
18
+ * reference the moment a scene is edited.
19
+ * - **Transforms are local, relative to `parent`.** World transforms are derived.
20
+ * Storing world transforms makes reparenting a lie.
21
+ * - **Assets are referenced by id, never by path.** Paths change when an asset
22
+ * is re-imported or re-hosted; the manifest owns the mapping.
23
+ * - **Colliders are explicit.** Using a render mesh as a dynamic collider is the
24
+ * classic way to make a game that is correct and unplayably slow.
25
+ */
26
+ export interface Vec3 {
27
+ x: number;
28
+ y: number;
29
+ z: number;
30
+ }
31
+ /** `[x, y, z, w]` — a unit quaternion. */
32
+ export type Quat = [number, number, number, number];
33
+ export interface Transform3D {
34
+ position: Vec3;
35
+ /** Defaults to identity when absent. */
36
+ rotation?: Quat;
37
+ /** Uniform or per-axis; defaults to 1. */
38
+ scale?: number | Vec3;
39
+ }
40
+ export type ColliderShape = {
41
+ kind: 'box';
42
+ halfExtents: Vec3;
43
+ } | {
44
+ kind: 'sphere';
45
+ radius: number;
46
+ } | {
47
+ kind: 'capsule';
48
+ halfHeight: number;
49
+ radius: number;
50
+ }
51
+ /** Static geometry only. Never use this for a dynamic body. */
52
+ | {
53
+ kind: 'trimesh';
54
+ };
55
+ export interface Collider3D {
56
+ shape: ColliderShape;
57
+ /** `fixed` walls and ground, `dynamic` props, `kinematic` things the game moves. */
58
+ body: 'fixed' | 'dynamic' | 'kinematic';
59
+ /** Offset from the entity's own origin. */
60
+ offset?: Vec3;
61
+ }
62
+ export interface Entity3D {
63
+ /** Stable, authored, unique within the scene. */
64
+ id: string;
65
+ name?: string;
66
+ /** Id of another entity in this scene; the transform is relative to it. */
67
+ parent?: string;
68
+ transform: Transform3D;
69
+ /** Manifest asset id for a glTF model. */
70
+ modelAssetId?: string;
71
+ /** A built-in shape, for blocking out a scene before art exists. */
72
+ primitive?: {
73
+ kind: 'box' | 'sphere' | 'plane' | 'cylinder';
74
+ size?: Vec3;
75
+ color?: string;
76
+ };
77
+ collider?: Collider3D;
78
+ /** Which clip to play on spawn, by SEMANTIC name — see `AnimationMap`. */
79
+ animation?: {
80
+ play?: string;
81
+ loop?: boolean;
82
+ };
83
+ /** Free-form, read by game code. The 2D SDK's `properties` equivalent. */
84
+ properties?: Record<string, unknown>;
85
+ visible?: boolean;
86
+ }
87
+ /**
88
+ * Semantic clip name → the clip actually inside the model.
89
+ *
90
+ * Authored per asset, never guessed. The review's cross-rig retarget experiment
91
+ * returned a clip with zero matched bones and zero tracks, and assuming every
92
+ * model calls its walk cycle `Walk` fails the same way, only more quietly.
93
+ */
94
+ export type AnimationMap = Record<string, string>;
95
+ export interface ModelAsset3D {
96
+ id: string;
97
+ /** Resolved by the runtime against the project's asset base url. */
98
+ path: string;
99
+ animations?: AnimationMap;
100
+ /** Authoring correction applied at load: most models are not born 1 unit tall. */
101
+ importScale?: number;
102
+ }
103
+ export interface Light3D {
104
+ id: string;
105
+ kind: 'directional' | 'hemisphere' | 'ambient' | 'point';
106
+ color?: string;
107
+ /** Second colour for `hemisphere` (the ground half). */
108
+ groundColor?: string;
109
+ intensity?: number;
110
+ position?: Vec3;
111
+ castShadow?: boolean;
112
+ }
113
+ export interface Scene3D {
114
+ schemaVersion: 1;
115
+ id: string;
116
+ name?: string;
117
+ environment?: {
118
+ background?: string;
119
+ fog?: {
120
+ color: string;
121
+ near: number;
122
+ far: number;
123
+ };
124
+ };
125
+ gravity?: Vec3;
126
+ lights?: Light3D[];
127
+ entities: Entity3D[];
128
+ camera?: {
129
+ kind: 'follow' | 'fixed';
130
+ /** Entity id to follow, for `follow`. */
131
+ target?: string;
132
+ offset?: Vec3;
133
+ fov?: number;
134
+ };
135
+ }
136
+ export interface Manifest3D {
137
+ schemaVersion: 1;
138
+ initialScene: string;
139
+ scenes: {
140
+ id: string;
141
+ file: string;
142
+ }[];
143
+ models?: ModelAsset3D[];
144
+ }
145
+ /** Identity quaternion, spelled out so callers don't have to remember the order. */
146
+ export declare const IDENTITY_QUAT: Quat;
147
+ export declare function vec3(x?: number, y?: number, z?: number): Vec3;
148
+ /**
149
+ * Validate a scene enough to fail loudly at author time rather than as a blank
150
+ * screen at play time. Returns the problems; empty means usable.
151
+ */
152
+ export declare function validateScene(scene: Scene3D): string[];
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The `scene3d` design-data format.
3
+ *
4
+ * ADR-021 settled *what* the editor edits — design data on disk, no save loaded
5
+ * — and that answer is engine-neutral, so this format inherits it directly. What
6
+ * it does NOT inherit is the 2D schema: an independent review was right that
7
+ * `{x, y, rotation, scaleX, scaleY, depth}` does not survive the jump. Three
8
+ * dimensions need rotation that composes without gimbal problems, a parent
9
+ * hierarchy, materials that can be shared, and colliders that are authored
10
+ * rather than derived.
11
+ *
12
+ * Design rules, each of which is a decision rather than an accident:
13
+ *
14
+ * - **Rotation is a quaternion.** Euler angles are order-dependent and
15
+ * interpolate badly; an editor that round-trips them will drift.
16
+ * - **Ids are stable and authored.** The editor, the runtime and the save file
17
+ * all refer to entities by id, so regenerating them on load would break every
18
+ * reference the moment a scene is edited.
19
+ * - **Transforms are local, relative to `parent`.** World transforms are derived.
20
+ * Storing world transforms makes reparenting a lie.
21
+ * - **Assets are referenced by id, never by path.** Paths change when an asset
22
+ * is re-imported or re-hosted; the manifest owns the mapping.
23
+ * - **Colliders are explicit.** Using a render mesh as a dynamic collider is the
24
+ * classic way to make a game that is correct and unplayably slow.
25
+ */
26
+ /** Identity quaternion, spelled out so callers don't have to remember the order. */
27
+ export const IDENTITY_QUAT = [0, 0, 0, 1];
28
+ export function vec3(x = 0, y = 0, z = 0) { return { x, y, z }; }
29
+ /**
30
+ * Validate a scene enough to fail loudly at author time rather than as a blank
31
+ * screen at play time. Returns the problems; empty means usable.
32
+ */
33
+ export function validateScene(scene) {
34
+ const problems = [];
35
+ if (scene.schemaVersion !== 1)
36
+ problems.push(`unknown schemaVersion ${scene.schemaVersion}`);
37
+ const ids = new Set();
38
+ for (const e of scene.entities ?? []) {
39
+ if (!e.id)
40
+ problems.push('an entity has no id');
41
+ else if (ids.has(e.id))
42
+ problems.push(`duplicate entity id '${e.id}'`);
43
+ ids.add(e.id);
44
+ if (!e.modelAssetId && !e.primitive) {
45
+ problems.push(`entity '${e.id}' has neither a model nor a primitive — it would render nothing`);
46
+ }
47
+ if (e.collider?.body === 'dynamic' && e.collider.shape.kind === 'trimesh') {
48
+ problems.push(`entity '${e.id}' uses a trimesh collider on a dynamic body; use a convex shape`);
49
+ }
50
+ if (e.transform?.rotation && e.transform.rotation.length !== 4) {
51
+ problems.push(`entity '${e.id}' rotation must be a quaternion [x,y,z,w]`);
52
+ }
53
+ }
54
+ for (const e of scene.entities ?? []) {
55
+ if (e.parent && !ids.has(e.parent))
56
+ problems.push(`entity '${e.id}' parents to missing '${e.parent}'`);
57
+ }
58
+ if (scene.camera?.kind === 'follow' && scene.camera.target && !ids.has(scene.camera.target)) {
59
+ problems.push(`camera follows missing entity '${scene.camera.target}'`);
60
+ }
61
+ return problems;
62
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@umicat/three-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Three.js runtime for Umicat games: the scene3d design format, its loader with physics, a kinematic character controller, and the Umicat platform via @umicat/platform-sdk.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "test": "npm run build && npm run build:example && node --test scripts/*.test.mjs",
17
+ "build:example": "../umicat-template/node_modules/esbuild/bin/esbuild example/src/main.js --bundle --format=esm --outfile=example/public/game.js --alias:@umicat/three-sdk=./dist/index.js --log-level=warning"
18
+ },
19
+ "dependencies": {
20
+ "@dimforge/rapier3d-compat": "^0.14.0",
21
+ "three": "0.186.0",
22
+ "@umicat/platform-sdk": "^0.2.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/three": "^0.185.4",
26
+ "typescript": "^5.3.3"
27
+ },
28
+ "license": "UNLICENSED",
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "README.md"
35
+ ]
36
+ }