@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 +108 -0
- package/dist/CharacterController3D.d.ts +71 -0
- package/dist/CharacterController3D.js +83 -0
- package/dist/Input3D.d.ts +26 -0
- package/dist/Input3D.js +44 -0
- package/dist/SceneLoader3D.d.ts +49 -0
- package/dist/SceneLoader3D.js +238 -0
- package/dist/ThreeUmicat.d.ts +23 -0
- package/dist/ThreeUmicat.js +26 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +15 -0
- package/dist/scene3d.d.ts +152 -0
- package/dist/scene3d.js +62 -0
- package/package.json +36 -0
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
|
+
}
|
package/dist/Input3D.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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[];
|
package/dist/scene3d.js
ADDED
|
@@ -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
|
+
}
|