@n1h/web-characters 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,24 @@
1
- # Temporary Holding Version
1
+ # @n1h/web-characters
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Animated glTF characters for any web page. The core of [catwalk](https://github.com/n0t-invented-here/catwalk).
4
+
5
+ - A see-through canvas per character, over the page.
6
+ - Drops in, falls, lands on the page's bottom edge or on another character's head.
7
+ - Idles, walks, runs, turns at the sides, bumps into others, can be picked up and thrown.
8
+ - Clicks beside the model reach the page.
9
+ - Models come from packs (`@n1h/web-characters-cube-pets`, `@n1h/web-characters-block-characters`) or your own `ModelSpec`.
10
+
11
+ ```ts
12
+ import { createCharacter } from "@n1h/web-characters";
13
+ import { cat } from "@n1h/web-characters-cube-pets/cat";
14
+
15
+ createCharacter({ model: cat, spawn: { x: 300, y: 0 } });
16
+ ```
17
+
18
+ | Entry | Exports |
19
+ |---|---|
20
+ | `@n1h/web-characters` | `createCharacter`, settings, the spec helpers, the life and framing for anyone building on them |
21
+ | `@n1h/web-characters/react` | `<Character>` |
22
+ | `@n1h/web-characters/model` | the `ModelSpec` type |
23
+
24
+ Reference: [the core](https://github.com/n0t-invented-here/catwalk/blob/main/docs/core.md), [the model spec](https://github.com/n0t-invented-here/catwalk/blob/main/docs/model-spec.md), [how it works](https://github.com/n0t-invented-here/catwalk/blob/main/docs/internals.md).
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@n1h/web-characters",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Animated glTF characters for any web page: a see-through canvas that drops a model in at a point, lands it on the bottom edge or on another character's head and lets it live there, idle, walk, run, bump, be picked up and thrown. Babylon.js underneath; a framework-free createCharacter() and a React <Character>. Models come from packs (@n1h/web-characters-cube-pets, @n1h/web-characters-block-characters) or your own ModelSpec.",
6
+ "license": "MIT OR 0BSD",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/n0t-invented-here/catwalk.git",
10
+ "directory": "packages/web-characters"
11
+ },
12
+ "homepage": "https://github.com/n0t-invented-here/catwalk#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/n0t-invented-here/catwalk/issues"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "!src/**/*.test.ts",
19
+ "README.md"
20
+ ],
21
+ "sideEffects": false,
22
+ "exports": {
23
+ ".": "./src/index.ts",
24
+ "./react": "./src/react.tsx",
25
+ "./model": "./src/model.ts",
26
+ "./assets": {
27
+ "types": "./src/assets.d.ts"
28
+ }
29
+ },
30
+ "dependencies": {
31
+ "@babylonjs/core": "^9.35.0",
32
+ "@babylonjs/loaders": "^9.35.0"
33
+ },
34
+ "peerDependencies": {
35
+ "react": ">=19"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "react": {
39
+ "optional": true
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,11 @@
1
+ // A model file imported by path: the import resolves to a URL the page can
2
+ // fetch, and the bundler ships the file. Bun, Vite and esbuild do this by
3
+ // default; webpack needs asset modules for .glb and .png.
4
+ declare module "*.glb" {
5
+ const url: string;
6
+ export default url;
7
+ }
8
+ declare module "*.png" {
9
+ const url: string;
10
+ export default url;
11
+ }
@@ -0,0 +1,235 @@
1
+ // A character on a page: a see-through canvas the model is drawn on, placed over the
2
+ // page and moved every frame as the life says. Characters on the same ground step
3
+ // together (ground.ts) and bump into each other. Clicks reach the page through
4
+ // the canvas everywhere the model is not; over the model, the hand cursor
5
+ // shows and a press picks the character up.
6
+
7
+ import { groundFor } from "./ground";
8
+ import { BODY, FEET, HEAD, createLife, type Box } from "./life";
9
+ import type { ModelSpec } from "./model";
10
+ import { createModelScene } from "./scene";
11
+ import { DEFAULT_CHARACTER_SETTINGS, sanitizeCharacterSettings, type CharacterSettings } from "./settings";
12
+
13
+ /** The character's box edge at scale 1, in CSS pixels. */
14
+ export const CHARACTER_SIZE = 192;
15
+
16
+ export type Point = { x: number; y: number };
17
+
18
+ export type CharacterOptions = {
19
+ /** A spec from one of the packs, or your own. */
20
+ model: ModelSpec;
21
+ /**
22
+ * Where the character's centre appears before it falls, in CSS pixels from the
23
+ * top-left of its bounds (the viewport, or `host`). By default somewhere
24
+ * along the top edge, so it drops in from above.
25
+ */
26
+ spawn?: Point;
27
+ scale?: number;
28
+ bottom?: number;
29
+ /**
30
+ * The element the character lives in: its bounds are the element's box, the character
31
+ * is positioned inside it (the element becomes `position: relative` when
32
+ * it was static). Without one the character is fixed to the viewport and stays
33
+ * on screen while the page scrolls.
34
+ */
35
+ host?: HTMLElement;
36
+ /** Stacking of the canvas over the page's content. */
37
+ zIndex?: number;
38
+ /** Something failed after the character was created (a model that would not load). `console.error` by default. */
39
+ onFault?: (message: string, error?: unknown) => void;
40
+ };
41
+
42
+ export type CharacterHandle = {
43
+ /** The model on show. */
44
+ readonly model: ModelSpec;
45
+ readonly settings: CharacterSettings;
46
+ /** The box's top-left in bounds coordinates, and its edge. */
47
+ readonly position: Point;
48
+ readonly size: number;
49
+ /** Switch model; resolves when the new one is up (or failed and reported). */
50
+ setModel(model: ModelSpec): Promise<void>;
51
+ /** Change the scale or the bottom; the reply is the clamped result. */
52
+ setSettings(patch: Partial<CharacterSettings>): CharacterSettings;
53
+ /** Put the character's centre here and let it fall. */
54
+ spawn(at: Point): void;
55
+ dispose(): void;
56
+ };
57
+
58
+ /** Creates the canvas and starts the character. Throws when WebGL is not available. */
59
+ export function createCharacter(options: CharacterOptions): CharacterHandle {
60
+ const fault = options.onFault ?? ((message: string, error?: unknown) => console.error(message, error ?? ""));
61
+ const host = options.host;
62
+ if (host && getComputedStyle(host).position === "static") host.style.position = "relative";
63
+
64
+ const canvas = document.createElement("canvas");
65
+ canvas.className = "web-character";
66
+ Object.assign(canvas.style, {
67
+ position: host ? "absolute" : "fixed",
68
+ left: "0",
69
+ top: "0",
70
+ display: "block",
71
+ pointerEvents: "none", // on only while the pointer is over the model (see hover below)
72
+ touchAction: "none",
73
+ userSelect: "none",
74
+ willChange: "transform",
75
+ zIndex: String(options.zIndex ?? 2147483000),
76
+ } satisfies Partial<CSSStyleDeclaration>);
77
+ (host ?? document.body).append(canvas);
78
+
79
+ let scene: ReturnType<typeof createModelScene>;
80
+ try {
81
+ scene = createModelScene(canvas);
82
+ } catch (err) {
83
+ canvas.remove();
84
+ throw err;
85
+ }
86
+
87
+ let model = options.model;
88
+ let settings = sanitizeCharacterSettings({ scale: options.scale, bottom: options.bottom }, DEFAULT_CHARACTER_SETTINGS);
89
+ let size = Math.round(CHARACTER_SIZE * settings.scale);
90
+
91
+ const bounds = (): Box => (host ? { x: 0, y: 0, width: host.clientWidth, height: host.clientHeight } : { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight });
92
+ const life = createLife({
93
+ bounds,
94
+ size: () => size,
95
+ scale: () => settings.scale,
96
+ bottom: () => settings.bottom,
97
+ model: () => model,
98
+ has: (clip) => scene.has(clip),
99
+ clipSeconds: (clip) => scene.clipSeconds(clip),
100
+ body: () => scene.proportions()?.body ?? BODY,
101
+ head: () => scene.proportions()?.head ?? HEAD,
102
+ feet: () => scene.proportions()?.feet ?? FEET,
103
+ });
104
+
105
+ scene.resize(size, settings.scale);
106
+
107
+ const spawn = (at: Point) => life.spawn(at.x - size / 2, at.y - size / 2);
108
+ spawn(options.spawn ?? { x: bounds().width * (0.2 + Math.random() * 0.6), y: -size / 2 });
109
+
110
+ const show = async (next: ModelSpec) => {
111
+ model = next;
112
+ try {
113
+ await scene.show(model);
114
+ const measured = scene.proportions();
115
+ if (measured) Object.assign(canvas.dataset, { model: model.name, body: measured.body.toFixed(3), head: measured.head.toFixed(3), feet: measured.feet.toFixed(3) }); // what the page can read about the fit
116
+ } catch (err) {
117
+ fault(`loading the ${model.name} model (${model.url}) failed`, err);
118
+ }
119
+ };
120
+
121
+ // ---- pointer: a press on the model picks it up; everywhere else the canvas is not there ----
122
+ // The canvas takes no pointer events, so the page under it works as if it were
123
+ // not there. On every move the pointer is tested against the model with a
124
+ // triangle pick; over the model the canvas takes events and shows the hand,
125
+ // so the press lands here. One move of lag, never a stolen click.
126
+
127
+ /** The pointer's place in bounds coordinates. */
128
+ const toBounds = (e: PointerEvent): Point => {
129
+ if (!host) return { x: e.clientX, y: e.clientY };
130
+ const r = host.getBoundingClientRect();
131
+ return { x: e.clientX - r.left + host.scrollLeft, y: e.clientY - r.top + host.scrollTop };
132
+ };
133
+ const overModel = (e: PointerEvent): boolean => {
134
+ const r = canvas.getBoundingClientRect();
135
+ const cx = e.clientX - r.left;
136
+ const cy = e.clientY - r.top;
137
+ return cx >= 0 && cy >= 0 && cx < r.width && cy < r.height && scene.solidAt(cx, cy);
138
+ };
139
+ const hover = (e: PointerEvent) => {
140
+ if (life.state.kind === "carried") return;
141
+ const on = overModel(e);
142
+ canvas.style.pointerEvents = on ? "auto" : "none";
143
+ canvas.style.cursor = on ? "grab" : "";
144
+ };
145
+ document.addEventListener("pointermove", hover, { passive: true });
146
+
147
+ let grab = { dx: 0, dy: 0 };
148
+ let lastDrag = { x: 0, y: 0, t: 0, vy: 0 };
149
+ const onDown = (e: PointerEvent) => {
150
+ if (e.button !== 0 || !overModel(e)) return;
151
+ e.preventDefault();
152
+ canvas.setPointerCapture(e.pointerId);
153
+ const p = toBounds(e);
154
+ grab = { dx: p.x - life.x, dy: p.y - life.y };
155
+ lastDrag = { x: p.x, y: p.y, t: performance.now(), vy: 0 };
156
+ canvas.style.cursor = "grabbing";
157
+ life.pickUp();
158
+ };
159
+ const onMove = (e: PointerEvent) => {
160
+ if (life.state.kind !== "carried") return;
161
+ const p = toBounds(e);
162
+ const now = performance.now();
163
+ const dt = Math.max(1, now - lastDrag.t) / 1000;
164
+ const dx = p.x - lastDrag.x;
165
+ lastDrag = { x: p.x, y: p.y, t: now, vy: (p.y - lastDrag.y) / dt };
166
+ life.carryTo(p.x - grab.dx, p.y - grab.dy);
167
+ if (Math.abs(dx) > 2) life.face(dx < 0 ? -1 : 1);
168
+ };
169
+ const onUp = (e: PointerEvent) => {
170
+ if (life.state.kind !== "carried") return;
171
+ life.drop(lastDrag.vy);
172
+ canvas.style.cursor = "grab";
173
+ hover(e);
174
+ };
175
+ canvas.addEventListener("pointerdown", onDown);
176
+ canvas.addEventListener("pointermove", onMove);
177
+ canvas.addEventListener("pointerup", onUp);
178
+ canvas.addEventListener("pointercancel", onUp);
179
+ canvas.addEventListener("contextmenu", (e) => e.preventDefault());
180
+
181
+ // ---- loop: the ground steps every character on this ground, then each draws itself ----
182
+ let lastPlaced = { x: NaN, y: NaN };
183
+ const leave = groundFor(host ?? document).add({
184
+ life,
185
+ frame(dt) {
186
+ scene.play(life.animation(), life.animationSpeed());
187
+ scene.face(life.facing);
188
+ scene.render(dt);
189
+ const rx = Math.round(life.x);
190
+ const ry = Math.round(life.y);
191
+ if (rx !== lastPlaced.x || ry !== lastPlaced.y) {
192
+ lastPlaced = { x: rx, y: ry };
193
+ canvas.style.transform = `translate3d(${rx}px, ${ry}px, 0)`;
194
+ }
195
+ },
196
+ });
197
+
198
+ void show(model);
199
+
200
+ return {
201
+ get model() {
202
+ return model;
203
+ },
204
+ get settings() {
205
+ return settings;
206
+ },
207
+ get position() {
208
+ return { x: life.x, y: life.y };
209
+ },
210
+ get size() {
211
+ return size;
212
+ },
213
+ setModel: show,
214
+ setSettings(patch) {
215
+ settings = sanitizeCharacterSettings(patch, settings);
216
+ const nextSize = Math.round(CHARACTER_SIZE * settings.scale);
217
+ if (nextSize !== size) {
218
+ // Grow and shrink about the feet, so a standing character stays where it stood.
219
+ const feetX = life.x + size / 2;
220
+ size = nextSize;
221
+ if (life.state.kind === "carried") life.carryTo(feetX - size / 2, life.y);
222
+ else if (life.state.kind === "fall") life.spawn(feetX, life.y);
223
+ }
224
+ scene.resize(size, settings.scale);
225
+ return settings;
226
+ },
227
+ spawn,
228
+ dispose() {
229
+ leave();
230
+ document.removeEventListener("pointermove", hover);
231
+ scene.dispose();
232
+ canvas.remove();
233
+ },
234
+ };
235
+ }
package/src/framing.ts ADDED
@@ -0,0 +1,132 @@
1
+ // The camera, as numbers, and what a model's box looks like through it:
2
+ // where its feet, its head and its sides land on the square canvas. The
3
+ // scene aims its camera from these; the life uses the fractions for bumping
4
+ // into other characters and standing on their heads.
5
+
6
+ import type { Direction } from "./model";
7
+
8
+ /** A level look from the character's own height. */
9
+ export const CAMERA_DISTANCE = 4.4;
10
+ export const CAMERA_FOV = 0.5;
11
+ /** How far the models reach toward the camera; the front of that footprint sits on the canvas's bottom edge. */
12
+ export const FOOTPRINT_Z = 0.8;
13
+ // A fitted model faces +z, away from the camera at -z, and its face is on
14
+ // the front, so a pure side view hides it. Facing right is a quarter turn
15
+ // plus a bit toward the viewer; facing left is the mirror on the far side of
16
+ // pi (facing the camera), so a turn swings the face past the viewer, never
17
+ // the back. (Rotation about y by θ takes +z to (sin θ, 0, cos θ) and +x to
18
+ // (cos θ, 0, -sin θ); checked against Babylon, not derived.)
19
+ const TOWARD = 0.5;
20
+ export const yawFor = (dir: Direction) => (dir === 1 ? Math.PI / 2 + TOWARD : (3 * Math.PI) / 2 - TOWARD);
21
+
22
+ export type Bounds = { min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } };
23
+
24
+ /** Where a model-space point lands on the canvas, as fractions of its edge (0,0 top-left), at any scale. Ignores the two-pixel ground margin. */
25
+ export function projectToCanvas(p: { x: number; y: number; z: number }): { x: number; y: number } {
26
+ const t = Math.tan(CAMERA_FOV / 2);
27
+ const cameraHeight = (CAMERA_DISTANCE - FOOTPRINT_Z) * t;
28
+ const depth = p.z + CAMERA_DISTANCE;
29
+ const nx = p.x / (depth * t);
30
+ const ny = (p.y - cameraHeight) / (depth * t);
31
+ return { x: (1 + nx) / 2, y: (1 - ny) / 2 };
32
+ }
33
+
34
+ export type Proportions = {
35
+ /** How much of the canvas's width the model spans, as seen facing along the ground: its lower three quarters, so a raised tail or a hat does not count. */
36
+ body: number;
37
+ /** How far down the canvas the top of the model's head is: the surface straight above the model's middle, so a character standing on it stands on the head, not on a tail tip. */
38
+ head: number;
39
+ /** How far down the canvas the model's feet are, at its centre: above the bottom edge, which the front of the footprint touches. */
40
+ feet: number;
41
+ };
42
+
43
+ /** Where the ground at a model's centre lands on the canvas: the feet of a fitted model. */
44
+ export const FEET = projectToCanvas({ x: 0, y: 0, z: 0 }).y;
45
+
46
+ /** The lower share of the model's height that counts as its body, side to side. */
47
+ const BAND = 0.75;
48
+
49
+ /**
50
+ * The highest surface of the model straight above the point (x, z): the
51
+ * top of whatever is there, found through the triangles, so a box with
52
+ * only corner vertices still has a top in its middle. Nothing there: undefined.
53
+ */
54
+ export function topAt(points: ArrayLike<number>, triangles: ArrayLike<number>, x: number, z: number): number | undefined {
55
+ let top: number | undefined;
56
+ for (let i = 0; i + 2 < triangles.length; i += 3) {
57
+ const a = triangles[i]! * 3;
58
+ const b = triangles[i + 1]! * 3;
59
+ const c = triangles[i + 2]! * 3;
60
+ const ax = points[a]! - x;
61
+ const az = points[a + 2]! - z;
62
+ const bx = points[b]! - x;
63
+ const bz = points[b + 2]! - z;
64
+ const cx = points[c]! - x;
65
+ const cz = points[c + 2]! - z;
66
+ // Barycentric test in the ground plane.
67
+ const d = (bz - cz) * ax + (cx - bx) * az + bx * cz - cx * bz;
68
+ if (Math.abs(d) < 1e-12) continue;
69
+ const u = ((bz - cz) * 0 + (cx - bx) * 0 + bx * cz - cx * bz) / d;
70
+ const v = ((cz - az) * 0 + (ax - cx) * 0 + cx * az - ax * cz) / d;
71
+ const w = 1 - u - v;
72
+ if (u < -1e-9 || v < -1e-9 || w < -1e-9) continue;
73
+ const y = u * points[a + 1]! + v * points[b + 1]! + w * points[c + 1]!;
74
+ if (top === undefined || y > top) top = y;
75
+ }
76
+ return top;
77
+ }
78
+
79
+ /**
80
+ * A fitted model's points (x, y, z triples, centred on its box with its
81
+ * feet at y = 0) and triangles (point indices), turned to `yaw` about y and
82
+ * seen through the camera.
83
+ */
84
+ export function proportionsOf(points: ArrayLike<number>, triangles: ArrayLike<number>, yaw: number): Proportions {
85
+ let minX = Infinity;
86
+ let maxX = -Infinity;
87
+ let minY = Infinity;
88
+ let maxY = -Infinity;
89
+ let minZ = Infinity;
90
+ let maxZ = -Infinity;
91
+ for (let i = 0; i + 2 < points.length; i += 3) {
92
+ minX = Math.min(minX, points[i]!);
93
+ maxX = Math.max(maxX, points[i]!);
94
+ minY = Math.min(minY, points[i + 1]!);
95
+ maxY = Math.max(maxY, points[i + 1]!);
96
+ minZ = Math.min(minZ, points[i + 2]!);
97
+ maxZ = Math.max(maxZ, points[i + 2]!);
98
+ }
99
+ if (minX === Infinity) return { body: 0, head: 0, feet: FEET };
100
+ // The head: the top above the middle, or above the nearest of a few spots around it; the model's top when none has anything.
101
+ const spots = [
102
+ [0, 0],
103
+ [(maxX - minX) * 0.1, 0],
104
+ [-(maxX - minX) * 0.1, 0],
105
+ [0, (maxZ - minZ) * 0.1],
106
+ [0, -(maxZ - minZ) * 0.1],
107
+ ];
108
+ let headTop = -Infinity;
109
+ for (const [sx, sz] of spots) {
110
+ const t = topAt(points, triangles, sx!, sz!);
111
+ if (t !== undefined) headTop = Math.max(headTop, t);
112
+ }
113
+ if (headTop === -Infinity) headTop = maxY;
114
+ const bandTop = minY + (maxY - minY) * BAND;
115
+ const c = Math.cos(yaw);
116
+ const s = Math.sin(yaw);
117
+ let left = Infinity;
118
+ let right = -Infinity;
119
+ for (let i = 0; i + 2 < points.length; i += 3) {
120
+ const x = points[i]!;
121
+ const y = points[i + 1]!;
122
+ const z = points[i + 2]!;
123
+ if (y <= bandTop) {
124
+ const px = projectToCanvas({ x: x * c + z * s, y, z: -x * s + z * c }).x;
125
+ left = Math.min(left, px);
126
+ right = Math.max(right, px);
127
+ }
128
+ }
129
+ const head = projectToCanvas({ x: 0, y: headTop, z: 0 }).y;
130
+ const feet = projectToCanvas({ x: 0, y: minY, z: 0 }).y;
131
+ return { body: Math.min(1, Math.max(0, right - left)), head: Math.max(0, head), feet: Math.min(1, feet) };
132
+ }
package/src/ground.ts ADDED
@@ -0,0 +1,61 @@
1
+ // Every character on the same ground steps in one loop, so they can land on
2
+ // each other and bump into each other. One ground per host element (or per
3
+ // document, for characters fixed to the viewport); it runs while it has
4
+ // members.
5
+
6
+ import { separate, type Life } from "./life";
7
+
8
+ export type Member = {
9
+ life: Life;
10
+ /** Draw this character after the step; `dt` in seconds. */
11
+ frame(dt: number): void;
12
+ };
13
+
14
+ export type Ground = {
15
+ /** Join; the reply leaves. */
16
+ add(member: Member): () => void;
17
+ };
18
+
19
+ const grounds = new WeakMap<object, Ground>();
20
+
21
+ /** The ground for characters that share one: a host element, or the document for the viewport. */
22
+ export function groundFor(on: object): Ground {
23
+ let ground = grounds.get(on);
24
+ if (!ground) {
25
+ ground = createGround();
26
+ grounds.set(on, ground);
27
+ }
28
+ return ground;
29
+ }
30
+
31
+ function createGround(): Ground {
32
+ const members = new Set<Member>();
33
+ let frame: number | undefined;
34
+ let last = 0;
35
+ const loop = () => {
36
+ const now = performance.now();
37
+ const dt = Math.min(0.05, (now - last) / 1000);
38
+ last = now;
39
+ const lives = [...members].map((m) => m.life);
40
+ for (const life of lives) life.step(now, dt, lives);
41
+ separate(lives);
42
+ for (const m of members) m.frame(dt);
43
+ frame = members.size ? requestAnimationFrame(loop) : undefined;
44
+ };
45
+ return {
46
+ add(member) {
47
+ members.add(member);
48
+ if (frame === undefined) {
49
+ last = performance.now();
50
+ frame = requestAnimationFrame(loop);
51
+ }
52
+ return () => {
53
+ members.delete(member);
54
+ if (members.size === 0 && frame !== undefined) {
55
+ cancelAnimationFrame(frame);
56
+ frame = undefined;
57
+ }
58
+ };
59
+ },
60
+ };
61
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ /// <reference path="./assets.d.ts" />
2
+ export { createCharacter, CHARACTER_SIZE, type CharacterHandle, type CharacterOptions, type Point } from "./character";
3
+ export { clipFor, clipNames, drawMood, sameModel, type ClipRef, type ClipSpec, type Direction, type MoodCommand, type MoodSpec, type ModelSpec, type Role } from "./model";
4
+ export { DEFAULT_CHARACTER_SETTINGS, SCALE_MIN, SCALE_MAX, BOTTOM_MIN, BOTTOM_MAX, clampNumber, sanitizeCharacterSettings, type CharacterSettings } from "./settings";
5
+ export { createLife, separate, BODY, HEAD, FEET, GRAVITY, MAX_THROW, type Box, type Life, type LifeState, type World } from "./life";
6
+ export { projectToCanvas, proportionsOf, topAt, type Bounds, type Proportions } from "./framing";
package/src/life.ts ADDED
@@ -0,0 +1,318 @@
1
+ // The character's little life, with no screen in it: a square box that falls,
2
+ // lands, walks and runs along the bottom edge of its bounds, turns at the
3
+ // sides, and can be picked up and thrown. With other lives on the same
4
+ // ground it lands on their heads, rides along, walks off, and bumps into the
5
+ // ones at its level. Units are whatever the world's are (CSS pixels on a
6
+ // page); the page draws the model in the box.
7
+
8
+ import { FEET } from "./framing";
9
+ import { clipFor, drawMood, type ClipRef, type Direction, type MoodCommand, type MoodSpec, type ModelSpec } from "./model";
10
+
11
+ export type Box = { x: number; y: number; width: number; height: number };
12
+
13
+ export type LifeState =
14
+ | { kind: "ground"; mood: MoodSpec; dir: Direction; until: number }
15
+ | { kind: "fall"; vy: number }
16
+ | { kind: "carried" }
17
+ | { kind: "landed"; until: number };
18
+
19
+ /** What the life reads each step; every value may change between steps (a resize, a settings change). */
20
+ export type World = {
21
+ /** Where the character may be. The ground is the bottom edge. */
22
+ bounds: () => Box;
23
+ /** Edge of the character's square box. */
24
+ size: () => number;
25
+ /** The model's scale: speeds follow it, so the stride matches the walk cycle at every size. */
26
+ scale: () => number;
27
+ /** Lift above the bounds' bottom edge. */
28
+ bottom: () => number;
29
+ /** Which model this is: its moods, its clips. */
30
+ model: () => ModelSpec;
31
+ /** Which clips the loaded file has; a mood whose clip it lacks is never drawn. All, when not given. */
32
+ has?: (clip: string) => boolean;
33
+ /** How long a clip takes; a `landed` clip played once sets how long the landing lasts. */
34
+ clipSeconds?: (clip: string) => number | undefined;
35
+ /** How much of the box the model fills side to side, how far down its head is, and how far down its feet are; the defaults when not given. */
36
+ body?: () => number;
37
+ head?: () => number;
38
+ feet?: () => number;
39
+ /** What to do next on the ground; a weighted draw from the model's moods by default. */
40
+ nextCommand?: (model: ModelSpec) => MoodCommand;
41
+ };
42
+
43
+ export type Life = {
44
+ /** The box's top-left. */
45
+ readonly x: number;
46
+ readonly y: number;
47
+ readonly facing: Direction;
48
+ readonly state: LifeState;
49
+ /** The box's edge, as the world says. */
50
+ readonly size: number;
51
+ /** How much of the box the body fills side to side, how far down the head is, and how far down the feet are, as fractions of the box. */
52
+ readonly body: number;
53
+ readonly head: number;
54
+ readonly feet: number;
55
+ /** How far the last step moved it sideways (walking, riding); what a character on its head follows. */
56
+ readonly dx: number;
57
+ /** The character it stands on, when not on the ground. */
58
+ readonly standingOn: Life | undefined;
59
+ /** Where the box's top is when the character stands on the ground. */
60
+ groundY(): number;
61
+ /** Put the box's top-left here and let the character fall. */
62
+ spawn(x: number, y: number): void;
63
+ pickUp(): void;
64
+ /** While carried: the box's new top-left. */
65
+ carryTo(x: number, y: number): void;
66
+ /** Turn to face this way; walking and running set it themselves. */
67
+ face(dir: Direction): void;
68
+ /** Let go, moving at this vertical speed (units per second, positive is down); clamped to a throw. */
69
+ drop(vy: number): void;
70
+ /** Pushed sideways by another character, on the ground only; a walker pushed against its way turns around. The reply is how far it went (less than asked at a side edge). */
71
+ bump(dx: number): number;
72
+ /** Advance by `dt` seconds at time `now` (milliseconds, as `performance.now()`), among the other lives on this ground (heads to land on, backs to ride). */
73
+ step(now: number, dt: number, others?: readonly Life[]): void;
74
+ /** The clip for the current state, and its speed. */
75
+ animation(): ClipRef;
76
+ animationSpeed(): number;
77
+ };
78
+
79
+ export const GRAVITY = 2400;
80
+ export const MAX_THROW = 800;
81
+ const LANDED_MS = 1200; // the landing clip, when it loops
82
+ const REST_AFTER_LANDING_MS = 800;
83
+ /** How much of its box the character may hang past a side edge: the model does not fill the square. */
84
+ const OVERHANG = 0.2;
85
+ /** How much of its box a character's body fills side to side, until the model is measured: for bumping into other characters and standing on them. */
86
+ export const BODY = 0.6;
87
+ /** How far down its box the top of a character's head is, until the model is measured: where another character's feet rest. */
88
+ export const HEAD = 0.25;
89
+ export { FEET };
90
+ const isStanding = (l: Life) => l.state.kind === "ground" || l.state.kind === "landed";
91
+
92
+ export function createLife(world: World): Life {
93
+ const pickNext = world.nextCommand ?? ((model: ModelSpec) => drawMood(model, world.has));
94
+ const moving = (mood: MoodSpec) => (mood.speed ?? 0) > 0;
95
+ /** Standing still after a landing, before the first mood: the idle clip. */
96
+ const rest = (): MoodSpec => ({ name: "idle", clip: clipFor(world.model(), "idle").name, seconds: [0, 0] });
97
+ /** How long a landing lasts: the landed clip's length when it plays once, else a fixed moment. */
98
+ const landedMs = () => {
99
+ const clip = clipFor(world.model(), "landed");
100
+ const seconds = clip.once ? world.clipSeconds?.(clip.name) : undefined;
101
+ return seconds ? seconds * 1000 : LANDED_MS;
102
+ };
103
+ let x = 0;
104
+ let y = 0;
105
+ let dx = 0;
106
+ let facing: Direction = 1;
107
+ let state: LifeState = { kind: "fall", vy: 0 };
108
+ let on: Life | undefined;
109
+
110
+ const groundY = () => {
111
+ const b = world.bounds();
112
+ return b.y + b.height - world.size() - world.bottom();
113
+ };
114
+ const sideLimits = () => {
115
+ const b = world.bounds();
116
+ const size = world.size();
117
+ return { minX: b.x - size * OVERHANG, maxX: b.x + b.width - size * (1 - OVERHANG) };
118
+ };
119
+ const clampX = () => {
120
+ const { minX, maxX } = sideLimits();
121
+ x = Math.max(minX, Math.min(maxX, x));
122
+ };
123
+ const apply = (cmd: MoodCommand, now: number) => {
124
+ state = { kind: "ground", mood: cmd.mood, dir: cmd.dir, until: now + cmd.seconds * 1000 };
125
+ if (moving(cmd.mood)) facing = cmd.dir;
126
+ };
127
+ /**
128
+ * Where this character's box top rests: the ground, or the head of the highest
129
+ * standing character whose body is under this character's centre and whose head was
130
+ * not above the feet (at `feetBefore`) when the step began, so a fall
131
+ * never passes through a head and a character beside another never climbs it.
132
+ */
133
+ const floorAmong = (others: readonly Life[], feetBefore: number): { y: number; on: Life | undefined } => {
134
+ const size = world.size();
135
+ const cx = x + size / 2;
136
+ const feetOffset = size * self.feet; // the feet are drawn this far down the box
137
+ let best: { y: number; on: Life | undefined } = { y: groundY(), on: undefined };
138
+ for (const o of others) {
139
+ if (o === self || !isStanding(o)) continue;
140
+ if (cx < o.x + o.size * (0.5 - o.body / 2) || cx > o.x + o.size * (0.5 + o.body / 2)) continue;
141
+ const head = o.y + o.size * o.head;
142
+ if (head < feetBefore - 1) continue;
143
+ if (head - feetOffset < best.y) best = { y: head - feetOffset, on: o };
144
+ }
145
+ return best;
146
+ };
147
+ /** Standing: follow the floor; when it is gone (the support walked away or was picked up), fall. The reply is whether the character still stands. */
148
+ const stand = (others: readonly Life[], feetBefore: number): boolean => {
149
+ const floor = floorAmong(others, feetBefore);
150
+ if (floor.y > y + 1) {
151
+ state = { kind: "fall", vy: 0 };
152
+ on = undefined;
153
+ return false;
154
+ }
155
+ y = floor.y;
156
+ on = floor.on;
157
+ if (on) x += on.dx; // ride
158
+ return true;
159
+ };
160
+
161
+ const self: Life = {
162
+ get x() {
163
+ return x;
164
+ },
165
+ get y() {
166
+ return y;
167
+ },
168
+ get dx() {
169
+ return dx;
170
+ },
171
+ get facing() {
172
+ return facing;
173
+ },
174
+ get state() {
175
+ return state;
176
+ },
177
+ get size() {
178
+ return world.size();
179
+ },
180
+ get body() {
181
+ return world.body?.() ?? BODY;
182
+ },
183
+ get head() {
184
+ return world.head?.() ?? HEAD;
185
+ },
186
+ get feet() {
187
+ return world.feet?.() ?? FEET;
188
+ },
189
+ get standingOn() {
190
+ return on;
191
+ },
192
+ groundY,
193
+ spawn(nx, ny) {
194
+ x = nx;
195
+ y = ny;
196
+ clampX();
197
+ state = { kind: "fall", vy: 0 };
198
+ on = undefined;
199
+ },
200
+ pickUp() {
201
+ state = { kind: "carried" };
202
+ on = undefined;
203
+ },
204
+ carryTo(nx, ny) {
205
+ if (state.kind !== "carried") return;
206
+ x = nx;
207
+ y = ny;
208
+ },
209
+ face(dir) {
210
+ facing = dir;
211
+ },
212
+ drop(vy) {
213
+ if (state.kind !== "carried") return;
214
+ state = { kind: "fall", vy: Math.max(-MAX_THROW, Math.min(MAX_THROW, vy)) };
215
+ },
216
+ bump(dx) {
217
+ if (!isStanding(self)) return 0;
218
+ const from = x;
219
+ x += dx;
220
+ clampX();
221
+ if (state.kind === "ground" && moving(state.mood) && Math.sign(dx) === -state.dir) {
222
+ state = { ...state, dir: (-state.dir) as Direction };
223
+ facing = state.dir;
224
+ }
225
+ return x - from;
226
+ },
227
+ step(now, dt, others = []) {
228
+ const xBefore = x;
229
+ const feetBefore = y + world.size() * self.feet;
230
+ switch (state.kind) {
231
+ case "ground": {
232
+ if (!stand(others, feetBefore)) break;
233
+ if (now >= state.until) apply(pickNext(world.model()), now);
234
+ if (state.kind === "ground" && moving(state.mood)) {
235
+ x += state.dir * state.mood.speed! * world.scale() * dt;
236
+ const { minX, maxX } = sideLimits();
237
+ if (x < minX || x > maxX) {
238
+ clampX();
239
+ state = { ...state, dir: (-state.dir) as Direction };
240
+ facing = state.dir;
241
+ }
242
+ } else {
243
+ clampX(); // the bounds or the size may have changed under a standing character
244
+ }
245
+ break;
246
+ }
247
+ case "fall": {
248
+ state.vy += GRAVITY * dt;
249
+ y += state.vy * dt;
250
+ const floor = floorAmong(others, feetBefore);
251
+ if (y >= floor.y) {
252
+ y = floor.y;
253
+ on = floor.on;
254
+ clampX(); // dropped past a side edge: back inside
255
+ state = { kind: "landed", until: now + landedMs() };
256
+ }
257
+ break;
258
+ }
259
+ case "landed":
260
+ if (!stand(others, feetBefore)) break;
261
+ if (now >= state.until) state = { kind: "ground", mood: rest(), dir: facing, until: now + REST_AFTER_LANDING_MS };
262
+ break;
263
+ case "carried":
264
+ break;
265
+ }
266
+ dx = x - xBefore;
267
+ },
268
+ animation() {
269
+ switch (state.kind) {
270
+ case "ground":
271
+ return { name: state.mood.clip, once: false };
272
+ case "carried":
273
+ return clipFor(world.model(), "carried");
274
+ case "fall":
275
+ return clipFor(world.model(), "falling");
276
+ case "landed":
277
+ return clipFor(world.model(), "landed");
278
+ }
279
+ },
280
+ animationSpeed() {
281
+ return state.kind === "carried" ? 1.5 : 1;
282
+ },
283
+ };
284
+ return self;
285
+ }
286
+
287
+ /**
288
+ * Keeps characters standing at the same level (on the ground, or on the same head)
289
+ * from overlapping: any two whose bodies overlap are pushed apart by half
290
+ * the overlap each (one that cannot give way, at a side edge, leaves its
291
+ * half to the other), and a walker pushed against its way turns around. A
292
+ * character on another's head is not pushed off it; characters in the air or in hand
293
+ * pass freely. Passes until nothing moves, because a push can make a new
294
+ * overlap further along; a pile spreads out over a few of them.
295
+ */
296
+ export function separate(lives: readonly Life[]): void {
297
+ const standing = lives.filter(isStanding);
298
+ if (standing.length < 2) return;
299
+ const sameLevel = (a: Life, b: Life) => Math.abs(a.y + a.size * a.feet - (b.y + b.size * b.feet)) < 0.5 * Math.min(a.size, b.size);
300
+ for (let pass = 0; pass < 32; pass++) {
301
+ standing.sort((a, b) => a.x + a.size / 2 - (b.x + b.size / 2));
302
+ let moved = 0;
303
+ for (let i = 0; i + 1 < standing.length; i++) {
304
+ for (let j = i + 1; j < standing.length; j++) {
305
+ const a = standing[i]!;
306
+ const b = standing[j]!;
307
+ if (!sameLevel(a, b)) continue;
308
+ const overlap = a.x + a.size * (0.5 + a.body / 2) - (b.x + b.size * (0.5 - b.body / 2));
309
+ if (overlap <= 0) continue;
310
+ const wentA = a.bump(-overlap / 2);
311
+ const wentB = b.bump(overlap + wentA);
312
+ if (wentB < overlap + wentA) a.bump(-(overlap + wentA - wentB)); // b was at its edge too: a takes what b could not
313
+ moved += Math.abs(wentA) + Math.abs(wentB);
314
+ }
315
+ }
316
+ if (moved < 1e-6) return;
317
+ }
318
+ }
package/src/model.ts ADDED
@@ -0,0 +1,104 @@
1
+ // What a character is made of, as data: which file, how to fit it, which clips
2
+ // play for which states, and which moods it draws from on the ground. The
3
+ // packs in src/packs are specs of this shape, one named export per model;
4
+ // a page passes its own for any glTF with animations.
5
+
6
+ export type Direction = -1 | 1;
7
+
8
+ /** A clip in the model's file, by its animation-group name. */
9
+ export type ClipSpec =
10
+ | string
11
+ | {
12
+ name: string;
13
+ /** Play through once rather than loop. A `landed` clip played once sets how long the landing lasts. */
14
+ once?: boolean;
15
+ };
16
+
17
+ /** Something to do on the ground: a clip, for a while, with a weight in the draw and a speed when it moves. */
18
+ export type MoodSpec = {
19
+ name: string;
20
+ clip: string;
21
+ /** How likely, against the other moods. 1 by default. */
22
+ weight?: number;
23
+ /** How long it lasts, drawn between these two, in seconds. */
24
+ seconds: [number, number];
25
+ /** Movement along the ground while it plays, in CSS pixels per second at scale 1; tuned to the clip's stride so the feet do not slide. None: stands still. */
26
+ speed?: number;
27
+ };
28
+
29
+ export type ModelSpec = {
30
+ /** For labels. */
31
+ name: string;
32
+ /** Where the glTF is fetched from: an imported file (`import url from "./robot.glb"`), so the build ships it, or any URL. */
33
+ url: string;
34
+ /**
35
+ * Where the textures the file references by relative path are fetched
36
+ * from, by that path: `{ "Textures/colormap.png": colormapUrl }`. A
37
+ * bundler renames files, so a texture beside the glTF is found only
38
+ * through this. Not needed for a glTF with its textures embedded.
39
+ */
40
+ textures?: Record<string, string>;
41
+ /** Fit the model to this height in scene units (the cube pets are about 1 to 1.4). None: as modelled. */
42
+ height?: number;
43
+ /** Which way the model faces as modelled. `+z` by default. */
44
+ forward?: "+z" | "-z" | "+x" | "-x";
45
+ clips: {
46
+ idle: ClipSpec;
47
+ /** While held by the pointer. `idle` when missing. */
48
+ carried?: ClipSpec;
49
+ /** While falling. `carried` when missing. */
50
+ falling?: ClipSpec;
51
+ /** Just after a landing. `idle` when missing. */
52
+ landed?: ClipSpec;
53
+ };
54
+ /** What it does on the ground. At least one; a mood whose clip the file lacks is dropped at load. */
55
+ moods: MoodSpec[];
56
+ };
57
+
58
+ export type Role = "idle" | "carried" | "falling" | "landed";
59
+ export type ClipRef = { name: string; once: boolean };
60
+
61
+ const asRef = (spec: ClipSpec): ClipRef => (typeof spec === "string" ? { name: spec, once: false } : { name: spec.name, once: spec.once ?? false });
62
+
63
+ /** The clip for a state, with the fallbacks (falling → carried → idle, landed → idle). */
64
+ export function clipFor(model: ModelSpec, role: Role): ClipRef {
65
+ const c = model.clips;
66
+ switch (role) {
67
+ case "idle":
68
+ return asRef(c.idle);
69
+ case "carried":
70
+ return asRef(c.carried ?? c.idle);
71
+ case "falling":
72
+ return asRef(c.falling ?? c.carried ?? c.idle);
73
+ case "landed":
74
+ return asRef(c.landed ?? c.idle);
75
+ }
76
+ }
77
+
78
+ /** Every clip name the model uses. */
79
+ export const clipNames = (model: ModelSpec): string[] => [
80
+ ...new Set([...(["idle", "carried", "falling", "landed"] as const).map((r) => clipFor(model, r).name), ...model.moods.map((m) => m.clip)]),
81
+ ];
82
+
83
+ export type MoodCommand = { mood: MoodSpec; dir: Direction; seconds: number };
84
+
85
+ /** A weighted draw from the model's moods, with a direction and a length. `has` leaves out moods whose clip is not in the file. */
86
+ export function drawMood(model: ModelSpec, has: (clip: string) => boolean = () => true): MoodCommand {
87
+ const moods = model.moods.filter((m) => has(m.clip));
88
+ const pool = moods.length ? moods : model.moods;
89
+ const total = pool.reduce((sum, m) => sum + (m.weight ?? 1), 0);
90
+ let r = Math.random() * total;
91
+ let mood = pool[pool.length - 1]!;
92
+ for (const m of pool) {
93
+ r -= m.weight ?? 1;
94
+ if (r < 0) {
95
+ mood = m;
96
+ break;
97
+ }
98
+ }
99
+ const [lo, hi] = mood.seconds;
100
+ return { mood, dir: Math.random() < 0.5 ? -1 : 1, seconds: lo + Math.random() * (hi - lo) };
101
+ }
102
+
103
+ /** The same model, by what it loads from. */
104
+ export const sameModel = (a: ModelSpec, b: ModelSpec): boolean => a.url === b.url;
package/src/react.tsx ADDED
@@ -0,0 +1,41 @@
1
+ // The character as a React component. It renders nothing itself: the canvas is
2
+ // appended to `host` (or the body) outside React's tree, and removed on
3
+ // unmount. Props after the first render reach the character through its handle.
4
+ // `model` is compared by its URL, so an object literal in the render body does
5
+ // not reload it.
6
+
7
+ import { useEffect, useImperativeHandle, useRef, useState, type Ref } from "react";
8
+ import { sameModel } from "./model";
9
+ import { createCharacter, type CharacterHandle, type CharacterOptions } from "./character";
10
+
11
+ export type CharacterProps = CharacterOptions & {
12
+ /** The running character, for `spawn`, `setModel`, `setSettings` and `position`. Null until the first effect has run. */
13
+ ref?: Ref<CharacterHandle>;
14
+ };
15
+
16
+ export function Character({ ref, model, scale, bottom, spawn, host, zIndex, onFault }: CharacterProps) {
17
+ const [handle, setHandle] = useState<CharacterHandle | null>(null);
18
+ // The latest of every prop, for the one-time creation below, so changing a prop does not re-create the character.
19
+ const latest = useRef({ model, scale, bottom, spawn, onFault });
20
+ latest.current = { model, scale, bottom, spawn, onFault };
21
+
22
+ useEffect(() => {
23
+ const created = createCharacter({ ...latest.current, host, zIndex, onFault: (m, e) => latest.current.onFault?.(m, e) ?? console.error(m, e ?? "") });
24
+ setHandle(created);
25
+ return () => {
26
+ created.dispose();
27
+ setHandle(null);
28
+ };
29
+ }, [host, zIndex]);
30
+
31
+ useEffect(() => {
32
+ if (handle && !sameModel(model, handle.model)) void handle.setModel(model);
33
+ }, [handle, model]);
34
+
35
+ useEffect(() => {
36
+ handle?.setSettings({ scale, bottom });
37
+ }, [handle, scale, bottom]);
38
+
39
+ useImperativeHandle(ref, () => handle as CharacterHandle, [handle]); // a RefObject's current is nullable; it is null until the character is up
40
+ return null;
41
+ }
package/src/scene.ts ADDED
@@ -0,0 +1,226 @@
1
+ // The picture: a Babylon.js scene on one canvas, with a level camera aimed so
2
+ // the model's feet sit on the canvas's bottom edge at every scale, one model
3
+ // at a time, fitted (centred, feet at zero, to the spec's height, turned to
4
+ // face +z), its clips by name, and a triangle pick for "is the character under
5
+ // this point".
6
+
7
+ import "@babylonjs/loaders/glTF/2.0";
8
+ import "@babylonjs/core/Culling/ray"; // scene.pick
9
+ import type { AssetContainer } from "@babylonjs/core/assetContainer";
10
+ import { TargetCamera } from "@babylonjs/core/Cameras/targetCamera";
11
+ import { Engine } from "@babylonjs/core/Engines/engine";
12
+ import { DirectionalLight } from "@babylonjs/core/Lights/directionalLight";
13
+ import { HemisphericLight } from "@babylonjs/core/Lights/hemisphericLight";
14
+ import { LoadAssetContainerAsync } from "@babylonjs/core/Loading/sceneLoader";
15
+ import { Color3, Color4 } from "@babylonjs/core/Maths/math.color";
16
+ import { Vector3 } from "@babylonjs/core/Maths/math.vector";
17
+ import { TransformNode } from "@babylonjs/core/Meshes/transformNode";
18
+ import { Scene } from "@babylonjs/core/scene";
19
+ import { CAMERA_DISTANCE, CAMERA_FOV, FOOTPRINT_Z, proportionsOf, yawFor, type Bounds, type Proportions } from "./framing";
20
+ import { clipFor, type ClipRef, type Direction, type ModelSpec } from "./model";
21
+
22
+ export type ModelScene = {
23
+ /** Fit the canvas to a `size` CSS-pixel square showing the model at `scale`, feet on the bottom edge. */
24
+ resize(size: number, scale: number): void;
25
+ /** Load the model and show it in place of the current one, fitted as the spec says. A later call wins over an earlier one still loading. */
26
+ show(model: ModelSpec): Promise<void>;
27
+ /** Is this clip in the loaded file? */
28
+ has(clip: string): boolean;
29
+ /** How long one pass of the clip takes at speed 1. */
30
+ clipSeconds(clip: string): number | undefined;
31
+ /** The loaded model's box through the camera: how wide it is and how far down its head is, as fractions of the canvas. */
32
+ proportions(): Proportions | undefined;
33
+ play(clip: ClipRef, speed: number): void;
34
+ /** Turn toward this side; the turn is eased over the next frames. */
35
+ face(dir: Direction): void;
36
+ /** Draw a frame; `dt` in seconds, for the turn. */
37
+ render(dt: number): void;
38
+ /** Is any of the model's geometry under this point of the canvas (CSS pixels)? Triangle-accurate. */
39
+ solidAt(cssX: number, cssY: number): boolean;
40
+ dispose(): void;
41
+ };
42
+
43
+ const GROUND_MARGIN_PX = 2;
44
+ /** The turn that makes a model face +z: rotation about y by θ takes +x to (cos θ, -sin θ), so +x needs -π/2 and -x needs π/2. */
45
+ const forwardYaw = (forward: ModelSpec["forward"]): number => (forward === "-z" ? Math.PI : forward === "+x" ? -Math.PI / 2 : forward === "-x" ? Math.PI / 2 : 0);
46
+
47
+ /** Throws when WebGL is not available: there is no character without it. */
48
+ export function createModelScene(canvas: HTMLCanvasElement): ModelScene {
49
+ const engine = new Engine(canvas, true, { premultipliedAlpha: false, alpha: true }, true);
50
+ const scene = new Scene(engine);
51
+ // A scene attaches its own pointer handling to the canvas (a pick on every
52
+ // move, and the cursor reset to its default each time). The character does its own
53
+ // picking and sets its own cursor, so that goes.
54
+ scene.detachControl();
55
+ scene.clearColor = new Color4(0, 0, 0, 0);
56
+
57
+ const camera = new TargetCamera("camera", new Vector3(0, 0, -CAMERA_DISTANCE), scene);
58
+ camera.fov = CAMERA_FOV;
59
+ camera.minZ = 0.1;
60
+ const key = new DirectionalLight("key", new Vector3(-0.6, -1, 0.8), scene);
61
+ key.intensity = 1.6;
62
+ const fill = new HemisphericLight("fill", new Vector3(0, 1, 0), scene);
63
+ fill.intensity = 0.9;
64
+ fill.groundColor = new Color3(0.45, 0.42, 0.5);
65
+
66
+ const root = new TransformNode("character", scene); // the fitted model hangs here; its feet are at y = 0
67
+ let yaw = yawFor(1);
68
+ let yawTarget = yaw;
69
+ let scale = 1;
70
+
71
+ // The model's ground (y = 0 across its footprint) sits on the canvas's bottom
72
+ // edge. With a level camera a ground point at depth z projects to
73
+ // -height / ((distance + z) tan(fov / 2)) in clip space, so the camera
74
+ // height that puts the front of the footprint on the last pixel row is exact.
75
+ // The scale scales the model, the camera's distance and the canvas together,
76
+ // so the framing is the same at every size.
77
+ const aimAtGround = () => {
78
+ const margin = (2 * GROUND_MARGIN_PX) / engine.getRenderHeight();
79
+ const height = (1 - margin) * (CAMERA_DISTANCE - FOOTPRINT_Z) * scale * Math.tan(CAMERA_FOV / 2);
80
+ camera.position.set(0, height, -CAMERA_DISTANCE * scale);
81
+ camera.setTarget(new Vector3(0, height, 0));
82
+ root.scaling.setAll(scale);
83
+ };
84
+
85
+ let container: AssetContainer | undefined;
86
+ let mount: TransformNode | undefined;
87
+ let animations = new Map<string, AssetContainer["animationGroups"][number]>();
88
+ let playing: string | undefined;
89
+ let measured: Proportions | undefined;
90
+ let loadSeq = 0;
91
+
92
+ /**
93
+ * Every vertex of every mesh, in the space the nodes hang in now, posed on
94
+ * the first frame of `pose`: a model's bind pose is a T-pose with the arms
95
+ * out, twice as wide as the figure standing, and a skinned mesh's own
96
+ * vertices are that bind pose until they are run through its skeleton.
97
+ */
98
+ const measure = (loaded: AssetContainer, pose: AssetContainer["animationGroups"][number] | undefined): { bounds: Bounds; points: number[]; triangles: number[] } => {
99
+ if (pose) {
100
+ pose.start(true, 1);
101
+ pose.goToFrame(pose.from);
102
+ }
103
+ for (const node of loaded.transformNodes) node.computeWorldMatrix(true);
104
+ const min = { x: Infinity, y: Infinity, z: Infinity };
105
+ const max = { x: -Infinity, y: -Infinity, z: -Infinity };
106
+ const points: number[] = [];
107
+ const triangles: number[] = [];
108
+ const p = new Vector3();
109
+ for (const mesh of loaded.meshes) {
110
+ if (mesh.getTotalVertices() === 0) continue;
111
+ mesh.computeWorldMatrix(true);
112
+ if (mesh.skeleton) mesh.skeleton.prepare();
113
+ const data = mesh.getPositionData(!!mesh.skeleton, true);
114
+ if (!data) continue;
115
+ const world = mesh.getWorldMatrix();
116
+ const base = points.length / 3;
117
+ const indices = mesh.getIndices();
118
+ if (indices) for (const i of indices) triangles.push(base + i);
119
+ else for (let i = 0; i < data.length / 3; i++) triangles.push(base + i);
120
+ for (let i = 0; i + 2 < data.length; i += 3) {
121
+ Vector3.TransformCoordinatesFromFloatsToRef(data[i]!, data[i + 1]!, data[i + 2]!, world, p);
122
+ points.push(p.x, p.y, p.z);
123
+ min.x = Math.min(min.x, p.x);
124
+ min.y = Math.min(min.y, p.y);
125
+ min.z = Math.min(min.z, p.z);
126
+ max.x = Math.max(max.x, p.x);
127
+ max.y = Math.max(max.y, p.y);
128
+ max.z = Math.max(max.z, p.z);
129
+ }
130
+ }
131
+ return { bounds: { min, max }, points, triangles };
132
+ };
133
+
134
+ return {
135
+ resize(size, nextScale) {
136
+ scale = nextScale;
137
+ canvas.style.width = `${size}px`;
138
+ canvas.style.height = `${size}px`;
139
+ engine.resize();
140
+ aimAtGround();
141
+ },
142
+ async show(model) {
143
+ const seq = ++loadSeq;
144
+ // A texture the file references by relative path is fetched from the spec's URL for that path, when it has one.
145
+ const textures = Object.entries(model.textures ?? {});
146
+ const loaded = await LoadAssetContainerAsync(model.url, scene, {
147
+ pluginOptions: {
148
+ gltf: {
149
+ preprocessUrlAsync: async (url) => textures.find(([path]) => url.endsWith(path))?.[1] ?? url,
150
+ },
151
+ },
152
+ });
153
+ if (seq !== loadSeq) {
154
+ loaded.dispose(); // another model was asked for while this one loaded
155
+ return;
156
+ }
157
+ container?.dispose();
158
+ mount?.dispose();
159
+ container = loaded;
160
+ animations = new Map(loaded.animationGroups.map((g) => [g.name, g]));
161
+ for (const g of loaded.animationGroups) {
162
+ g.stop();
163
+ g.enableBlending = true;
164
+ g.blendingSpeed = 0.08;
165
+ }
166
+ loaded.addAllToScene(); // before any re-parenting: the container checks its hierarchy here
167
+ // Fit: as modelled, measure; then centre it, put its feet at y = 0 and
168
+ // scale it to the spec's height; then turn it to face +z; then hang it
169
+ // under the root, which carries the character's own turn and scale.
170
+ const fit = new TransformNode("fit", scene);
171
+ for (const node of loaded.rootNodes) node.parent = fit;
172
+ const pose = animations.get(clipFor(model, "idle").name);
173
+ const { bounds: as, points, triangles } = measure(loaded, pose);
174
+ pose?.stop();
175
+ const k = model.height ? model.height / (as.max.y - as.min.y) : 1;
176
+ const cx = (as.min.x + as.max.x) / 2;
177
+ const cz = (as.min.z + as.max.z) / 2;
178
+ fit.scaling.setAll(k);
179
+ fit.position.set(-cx * k, -as.min.y * k, -cz * k);
180
+ const turn = new TransformNode("turn", scene);
181
+ turn.rotation.y = forwardYaw(model.forward);
182
+ fit.parent = turn;
183
+ turn.parent = root;
184
+ mount = turn;
185
+ for (let i = 0; i + 2 < points.length; i += 3) {
186
+ points[i] = (points[i]! - cx) * k;
187
+ points[i + 1] = (points[i + 1]! - as.min.y) * k;
188
+ points[i + 2] = (points[i + 2]! - cz) * k;
189
+ }
190
+ measured = proportionsOf(points, triangles, yawFor(1) + turn.rotation.y);
191
+ playing = undefined;
192
+ },
193
+ has: (clip) => animations.has(clip),
194
+ clipSeconds(clip) {
195
+ const g = animations.get(clip);
196
+ if (!g) return undefined;
197
+ const fps = g.targetedAnimations[0]?.animation.framePerSecond ?? 60;
198
+ return (g.to - g.from) / fps;
199
+ },
200
+ proportions: () => measured,
201
+ play(clip, speed) {
202
+ if (clip.name === playing) return;
203
+ animations.get(playing ?? "")?.stop();
204
+ const g = animations.get(clip.name) ?? animations.values().next().value; // a clip the file lacks: the file's first
205
+ g?.start(!clip.once, speed);
206
+ playing = clip.name;
207
+ },
208
+ face(dir) {
209
+ yawTarget = yawFor(dir);
210
+ },
211
+ render(dt) {
212
+ yaw += (yawTarget - yaw) * Math.min(1, dt * 10);
213
+ root.rotation.y = yaw;
214
+ scene.render();
215
+ },
216
+ solidAt(cssX, cssY) {
217
+ return container !== undefined && scene.pick(cssX, cssY, undefined, false).hit;
218
+ },
219
+ dispose() {
220
+ loadSeq++;
221
+ container?.dispose();
222
+ scene.dispose();
223
+ engine.dispose();
224
+ },
225
+ };
226
+ }
@@ -0,0 +1,27 @@
1
+ // What a page can adjust on a character while it runs.
2
+
3
+ export type CharacterSettings = {
4
+ /** Size of the character; 1 is the model's own size, 192 CSS pixels tall. The canvas scales with it. */
5
+ scale: number;
6
+ /** How far above the bottom edge of its bounds the character stands, in CSS pixels; negative sinks it below the edge. */
7
+ bottom: number;
8
+ };
9
+
10
+ export const DEFAULT_CHARACTER_SETTINGS: CharacterSettings = { scale: 1, bottom: 0 };
11
+ export const SCALE_MIN = 0.25;
12
+ export const SCALE_MAX = 8;
13
+ export const BOTTOM_MIN = -2000;
14
+ export const BOTTOM_MAX = 4000;
15
+
16
+ /** A finite number kept in range, or the fallback. */
17
+ export const clampNumber = (v: unknown, fallback: number, min: number, max: number): number =>
18
+ typeof v === "number" && Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : fallback;
19
+
20
+ /** Fills gaps with `current` (the defaults when not given) and keeps every value in range, whatever the source. */
21
+ export function sanitizeCharacterSettings(input: unknown, current: CharacterSettings = DEFAULT_CHARACTER_SETTINGS): CharacterSettings {
22
+ const s = (typeof input === "object" && input !== null ? input : {}) as Partial<Record<keyof CharacterSettings, unknown>>;
23
+ return {
24
+ scale: Math.round(clampNumber(s.scale, current.scale, SCALE_MIN, SCALE_MAX) * 100) / 100,
25
+ bottom: Math.round(clampNumber(s.bottom, current.bottom, BOTTOM_MIN, BOTTOM_MAX)),
26
+ };
27
+ }