@homie-rocks/studio 0.24.4 → 0.25.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/CHANGELOG.md +55 -1
- package/README.md +50 -3
- package/assets/animate.ts +521 -0
- package/assets/render-page.ts +77 -4
- package/bin/homie-studio.mjs +13 -4
- package/chat/CHAT.md +36 -5
- package/lib/art-cli.mjs +105 -5
- package/lib/art-tools.mjs +168 -1
- package/lib/asset-check.mjs +72 -5
- package/lib/build.mjs +3 -0
- package/lib/characters.mjs +380 -0
- package/lib/clips.mjs +417 -0
- package/lib/decisions.mjs +38 -8
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +2 -2
- package/lib/optimise.mjs +79 -3
- package/lib/render3d.mjs +2 -0
- package/lib/rig.mjs +431 -0
- package/lib/scaffold.mjs +17 -4
- package/lib/studio.mjs +18 -3
- package/lib/style-board.mjs +3 -1
- package/lib/template-history.json +78 -0
- package/mcp/ui/animation.js +56 -0
- package/mcp/ui/card.css +4 -0
- package/mcp/ui/cast.js +36 -2
- package/mcp/ui/lineup.js +7 -7
- package/netplay/NETPLAY.md +9 -5
- package/package.json +5 -1
- package/site/SITE.md +4 -0
- package/starters/hero-rush-3d/assets/RIGHTS.md +163 -0
- package/starters/hero-rush-3d/assets/manifest.json +1471 -0
- package/starters/hero-rush-3d/credits.json +40 -0
- package/starters/hero-rush-3d/game.json +11 -0
- package/starters/hero-rush-3d/index.html +20 -0
- package/starters/hero-rush-3d/lab.json +32 -0
- package/starters/hero-rush-3d/src/main.ts +2361 -0
- package/starters/hero-rush-3d/style.json +68 -0
- package/starters/hero-rush-3d/tunables.json +32 -0
- package/worker/chat-page.mjs +173 -28
- package/worker/chat.mjs +4 -1
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* @homie-rocks/studio/animate — CHARACTERS THAT MOVE WELL, FOR EVERY three.js GAME A STUDIO MAKES.
|
|
3
|
+
*
|
|
4
|
+
* import { createModels } from '@homie-rocks/studio/assets';
|
|
5
|
+
* import { loadCharacter } from '@homie-rocks/studio/animate';
|
|
6
|
+
* import { lab } from '@homie-rocks/studio/lab';
|
|
7
|
+
* import tuning from '../tunables.json';
|
|
8
|
+
*
|
|
9
|
+
* const T = lab.tunables(tuning); // the Motion group's numbers; sliders in the Game Lab
|
|
10
|
+
* const knight = await loadCharacter(models, './models/knight.glb', { tune: T });
|
|
11
|
+
* scene.add(knight.root);
|
|
12
|
+
* // every frame, after the game moved the body:
|
|
13
|
+
* knight.root.position.set(x, y, z); knight.face(yaw, dt);
|
|
14
|
+
* knight.move(groundSpeed); // idle, walk, run: blended by speed, feet at the speed the body moves
|
|
15
|
+
* knight.air(onGround, verticalSpeed); // jump, fall, land (call jump() on take-off)
|
|
16
|
+
* knight.update(dt);
|
|
17
|
+
* // events: knight.jump(), knight.act('attack', { at: 0.4, hit: () => ... }), knight.hit(dx, dz), knight.die(), knight.revive()
|
|
18
|
+
*
|
|
19
|
+
* A character's clips come from its skeleton's clip library (public/anims/<skeleton>.glb, made by `homie-studio assets
|
|
20
|
+
* add` and `anim add`): the model names it, so loadCharacter finds it. Every character with that skeleton shares it.
|
|
21
|
+
*
|
|
22
|
+
* What it does on top of three.js's AnimationMixer, each number a Game Lab tunable (ANIM_TUNING, the "Motion" group):
|
|
23
|
+
* locomotion idle, walk and run weighted by ground speed, each played at the rate the feet need (no skating)
|
|
24
|
+
* one-shots an action while running plays on the upper body only (the legs keep running); standing, on all of it
|
|
25
|
+
* hits an additive flinch over whatever is playing, a hit-stop that freezes the pose for a few frames
|
|
26
|
+
* air jump, fall, land, with stretch on the way up and squash on the way down (springs, frame-rate free)
|
|
27
|
+
* lean banks into turns and tips forward when speeding up
|
|
28
|
+
* look-at the head (and neck) turn toward a point within limits
|
|
29
|
+
* springs tails, ears, capes and hair lag and swing (bones whose names say so, or the ones you name)
|
|
30
|
+
* feet two-bone IK plants humanoid feet on uneven ground (give it the ground's height)
|
|
31
|
+
* crowds `lod` 0-3: far and off-screen characters update their pose less often and skip the layers
|
|
32
|
+
*
|
|
33
|
+
* Bodies are moved by the game (netplay: the host moves them); clips never move a body: they are baked in place.
|
|
34
|
+
*/
|
|
35
|
+
import {
|
|
36
|
+
AdditiveAnimationBlendMode, AnimationClip, AnimationMixer, AnimationUtils, Group, LoopOnce, LoopRepeat, Quaternion, Vector3,
|
|
37
|
+
type AnimationAction, type Bone, type Camera, type Object3D,
|
|
38
|
+
} from 'three';
|
|
39
|
+
|
|
40
|
+
/** What loadCharacter needs from @homie-rocks/studio/assets's createModels(). */
|
|
41
|
+
export interface ModelSource {
|
|
42
|
+
load(url: string, opts?: { budget?: { triangles?: number; texturePx?: number; materials?: number; bytes?: number } }): Promise<{ scene: Object3D; animations: AnimationClip[] }>;
|
|
43
|
+
instance(url: string, opts?: { budget?: { triangles?: number; texturePx?: number; materials?: number; bytes?: number } }): Promise<Object3D>;
|
|
44
|
+
}
|
|
45
|
+
/** A hero's phone budget (a prop's is the loader's default): what development warnings hold a character to. */
|
|
46
|
+
const HERO_BUDGET = { triangles: 8000, texturePx: 1024, materials: 2, bytes: 1536 * 1024 };
|
|
47
|
+
|
|
48
|
+
/** The Motion tunables (tunables.json shape): copy them into a game's tunables.json, then tune them in the Game Lab. */
|
|
49
|
+
export const ANIM_TUNING = Object.freeze({
|
|
50
|
+
fade: { value: 0.14, min: 0, max: 0.6, step: 0.01, unit: 's', group: 'Motion', note: 'How long one clip takes to blend into the next' },
|
|
51
|
+
walkSpeed: { value: 1.5, min: 0.3, max: 6, step: 0.05, unit: 'm/s', group: 'Motion', note: 'The ground speed the walk clip\'s feet move at (feet slide when this is wrong)' },
|
|
52
|
+
runSpeed: { value: 4.2, min: 1, max: 12, step: 0.05, unit: 'm/s', group: 'Motion', note: 'The ground speed the run clip\'s feet move at' },
|
|
53
|
+
runFrom: { value: 2.4, min: 0.5, max: 8, step: 0.05, unit: 'm/s', group: 'Motion', note: 'From this speed up, the walk becomes a run' },
|
|
54
|
+
actSpeed: { value: 1.25, min: 0.5, max: 3, step: 0.05, group: 'Motion', note: 'How fast actions (attacks, casts) play: higher is snappier' },
|
|
55
|
+
jumpStretch: { value: 0.16, min: 0, max: 0.5, step: 0.01, group: 'Motion', note: 'How tall and thin the body goes as it leaves the ground' },
|
|
56
|
+
landSquash: { value: 0.22, min: 0, max: 0.6, step: 0.01, group: 'Motion', note: 'How flat it goes when it lands' },
|
|
57
|
+
squashHz: { value: 5.5, min: 1, max: 14, step: 0.1, unit: 'Hz', group: 'Motion', note: 'How fast the squash springs back (lower wobbles longer)' },
|
|
58
|
+
lean: { value: 0.22, min: 0, max: 0.8, step: 0.01, group: 'Motion', note: 'How far it banks into a turn and tips into a sprint' },
|
|
59
|
+
hitStopMs: { value: 70, min: 0, max: 200, step: 5, unit: 'ms', group: 'Motion', note: 'How long a hit freezes the pose before it plays on' },
|
|
60
|
+
flinch: { value: 0.6, min: 0, max: 1.5, step: 0.05, group: 'Motion', note: 'How strongly a hit jolts the upper body' },
|
|
61
|
+
lookAt: { value: 0.7, min: 0, max: 1, step: 0.05, group: 'Motion', note: 'How much the head turns toward what it looks at' },
|
|
62
|
+
spring: { value: 0.5, min: 0, max: 1, step: 0.05, group: 'Motion', note: 'How loose tails, ears and capes are (0 stiff, 1 floppy)' },
|
|
63
|
+
});
|
|
64
|
+
export type AnimTuning = { -readonly [K in keyof typeof ANIM_TUNING]?: number };
|
|
65
|
+
|
|
66
|
+
export interface CharacterOptions {
|
|
67
|
+
/** The clip library's address (default: the one the model names, beside it in ./anims/). */
|
|
68
|
+
anims?: string | null;
|
|
69
|
+
/** Live numbers (lab.tunables(tuning)): any ANIM_TUNING key, read every frame. */
|
|
70
|
+
tune?: AnimTuning;
|
|
71
|
+
/** Bones that swing on springs (default: names with tail, ear, cape, hair, antenna). */
|
|
72
|
+
springs?: string[];
|
|
73
|
+
/** The ground's height at (x, z) in world metres, for foot planting on slopes and steps (humanoids). */
|
|
74
|
+
ground?: ((x: number, z: number) => number) | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ActOptions {
|
|
78
|
+
/** A fraction of the clip (0-1) at which `hit` is called: the moment a swing lands. */
|
|
79
|
+
at?: number;
|
|
80
|
+
hit?: () => void;
|
|
81
|
+
/** Called when the action has played (or was cut short by another). */
|
|
82
|
+
then?: () => void;
|
|
83
|
+
/** Whole body (default when standing) or upper body only (default when moving). */
|
|
84
|
+
body?: 'whole' | 'upper';
|
|
85
|
+
speed?: number;
|
|
86
|
+
/** Start this far into the clip (0-1): skip a slow wind-up the game does not wait for. */
|
|
87
|
+
from?: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface Character {
|
|
91
|
+
/** Place this in the scene; the game sets its position (and face() its turn). */
|
|
92
|
+
root: Group;
|
|
93
|
+
/** The model inside (its bones are named by the skeleton standard: hips, spine, head, leftHand, ...). */
|
|
94
|
+
model: Object3D;
|
|
95
|
+
mixer: AnimationMixer;
|
|
96
|
+
clips: AnimationClip[];
|
|
97
|
+
/** The verbs this character can play. */
|
|
98
|
+
verbs: string[];
|
|
99
|
+
/** The skeleton family: humanoid, mini, quadruped or parts. */
|
|
100
|
+
family: string;
|
|
101
|
+
/** 0 every layer every frame; 1 no springs, feet or look-at; 2 pose every 2nd frame; 3 every 4th (far, off screen). */
|
|
102
|
+
lod: number;
|
|
103
|
+
has(verb: string): boolean;
|
|
104
|
+
/** Ground speed in m/s (the body's own, after the game moved it). */
|
|
105
|
+
move(speed: number): void;
|
|
106
|
+
/** Turn to face a heading (radians about +Y, 0 facing +Z), smoothly; the turn rate feeds the lean. */
|
|
107
|
+
face(yaw: number, dt?: number): void;
|
|
108
|
+
air(onGround: boolean, verticalSpeed?: number): void;
|
|
109
|
+
jump(): void;
|
|
110
|
+
/** One action (attack, cast, emote, pickup, interact, ...). Returns its length in seconds, or 0 if it has none. */
|
|
111
|
+
act(verb: string, opts?: ActOptions): number;
|
|
112
|
+
/** Hit from a direction (world x, z): an additive flinch, a hit-stop, the hit clip on the upper body. */
|
|
113
|
+
hit(dx?: number, dz?: number, strength?: number): void;
|
|
114
|
+
die(): void;
|
|
115
|
+
revive(): void;
|
|
116
|
+
/** A loop until the next move (win, sit, block): the round's end, a pose. */
|
|
117
|
+
hold(verb: string): void;
|
|
118
|
+
lookAt(point: Vector3 | null): void;
|
|
119
|
+
/** An attachment point: rightHand, leftHand, head, back (a bone to add things to), or null. */
|
|
120
|
+
socket(name: string): Object3D | null;
|
|
121
|
+
bone(name: string): Object3D | null;
|
|
122
|
+
update(dt: number): void;
|
|
123
|
+
/** What is playing (for the Game Lab's phase line): IDLE, WALK, RUN, JUMP, FALL, LAND, the action's verb, HIT, DOWN. */
|
|
124
|
+
readonly state: string;
|
|
125
|
+
dispose(): void;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const SPRING_NAMES = /tail|ear|cape|cloak|hair|antenna|ponytail|scarf|skirt/i;
|
|
129
|
+
const clampN = (v: number, a: number, b: number): number => Math.max(a, Math.min(b, v));
|
|
130
|
+
const damp = (from: number, to: number, rate: number, dt: number): number => to + (from - to) * Math.exp(-rate * dt);
|
|
131
|
+
const sanitize = (s: string): string => s.replace(/\s/g, '_').replace(/[[\].:/]/g, '');
|
|
132
|
+
|
|
133
|
+
/** The upper and lower body of a rig by bone name: the spine's subtree is the upper body. */
|
|
134
|
+
function bodyHalves(model: Object3D): { upper: Set<string>; lower: Set<string> } {
|
|
135
|
+
const upper = new Set<string>(); const lower = new Set<string>();
|
|
136
|
+
const spine = model.getObjectByName('spine') ?? model.getObjectByName('chest') ?? null;
|
|
137
|
+
if (spine) spine.traverse((o) => upper.add(o.name));
|
|
138
|
+
model.traverse((o) => { if (!upper.has(o.name)) lower.add(o.name); });
|
|
139
|
+
return { upper, lower };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A clip with only the tracks of `keep` bones (track names are "<bone>.<property>"). */
|
|
143
|
+
function subclip(clip: AnimationClip, keep: Set<string>, suffix: string): AnimationClip {
|
|
144
|
+
const tracks = clip.tracks.filter((t) => keep.has(t.name.slice(0, t.name.lastIndexOf('.'))));
|
|
145
|
+
return new AnimationClip(`${clip.name}${suffix}`, clip.duration, tracks.map((t) => t.clone()));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Clip variants are made once per clip library (keyed by its first clip: loadCharacter shares the loaded clips).
|
|
149
|
+
const variants = new WeakMap<AnimationClip, Map<string, AnimationClip>>();
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Load a character: the model (a copy, skeleton cloned properly) and its skeleton's clip library, through the game's
|
|
153
|
+
* safe loader. A model with no clip library and no clips of its own still loads: it stands still (and says so in
|
|
154
|
+
* development).
|
|
155
|
+
*/
|
|
156
|
+
export async function loadCharacter(models: ModelSource, url: string, opts: CharacterOptions = {}): Promise<Character> {
|
|
157
|
+
const loaded = await models.load(url, { budget: HERO_BUDGET });
|
|
158
|
+
const info = (loaded.scene.userData?.homie ?? {}) as { anims?: string; family?: string; sockets?: Record<string, string> };
|
|
159
|
+
let clips = loaded.animations ?? [];
|
|
160
|
+
const animsUrl = opts.anims === null ? null : (opts.anims ?? (info.anims ? new URL(info.anims, new URL(url, typeof location !== 'undefined' ? location.href : 'http://localhost/')).href : null));
|
|
161
|
+
if (animsUrl) {
|
|
162
|
+
try { clips = [...(await models.load(animsUrl, { budget: { triangles: 0, bytes: 3 * 1024 * 1024 } })).animations, ...clips]; } catch (error) { if (!clips.length) console.warn(`[animate] ${url}: its clips did not load (${(error as Error).message})`); }
|
|
163
|
+
}
|
|
164
|
+
const model = await models.instance(url, { budget: HERO_BUDGET });
|
|
165
|
+
return createCharacter(model, clips, { ...opts, family: info.family ?? null, sockets: info.sockets ?? {} });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A character from a model already in hand and its clips (loadCharacter does this for you). */
|
|
169
|
+
export function createCharacter(model: Object3D, clips: AnimationClip[], opts: CharacterOptions & { family?: string | null; sockets?: Record<string, string> } = {}): Character {
|
|
170
|
+
const tune = (k: keyof typeof ANIM_TUNING): number => { const v = opts.tune?.[k]; return Number.isFinite(v) ? (v as number) : ANIM_TUNING[k].value; };
|
|
171
|
+
const root = new Group(); root.name = 'character';
|
|
172
|
+
const pose = new Group(); pose.name = 'pose';
|
|
173
|
+
root.add(pose); pose.add(model);
|
|
174
|
+
const mixer = new AnimationMixer(model);
|
|
175
|
+
const byName = new Map(clips.map((c) => [c.name, c]));
|
|
176
|
+
const has = (v: string): boolean => byName.has(v);
|
|
177
|
+
const family = opts.family ?? (model.getObjectByName('leftLowerLeg') ? 'humanoid' : model.getObjectByName('leftUpperLeg') ? 'mini' : model.getObjectByName('frontLeftLeg') ? 'quadruped' : 'parts');
|
|
178
|
+
// Upper-body and lower-body versions of each clip, made once per clip set.
|
|
179
|
+
const vkey = clips[0];
|
|
180
|
+
let vmap = vkey ? variants.get(vkey) : undefined;
|
|
181
|
+
if (!vmap) { vmap = new Map(); if (vkey) variants.set(vkey, vmap); }
|
|
182
|
+
const halves = bodyHalves(model);
|
|
183
|
+
const layered = halves.upper.size > 0 && halves.lower.size > 0;
|
|
184
|
+
const variant = (verb: string, part: 'upper' | 'lower' | 'add'): AnimationClip | null => {
|
|
185
|
+
const base = byName.get(verb); if (!base) return null;
|
|
186
|
+
const key = `${verb}:${part}`;
|
|
187
|
+
let c = vmap!.get(key);
|
|
188
|
+
if (!c) {
|
|
189
|
+
if (part === 'add') { c = AnimationUtils.makeClipAdditive(subclip(base, halves.upper, '.add'), 0); }
|
|
190
|
+
else c = subclip(base, part === 'upper' ? halves.upper : halves.lower, `.${part}`);
|
|
191
|
+
vmap!.set(key, c);
|
|
192
|
+
}
|
|
193
|
+
return c;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// Locomotion: idle, walk, run, each a whole-body and a lower-body action, weights set every frame.
|
|
197
|
+
const LOCO = ['idle', 'walk', 'run'].filter(has);
|
|
198
|
+
const loco = new Map<string, { whole: AnimationAction; lower: AnimationAction | null; w: number }>();
|
|
199
|
+
for (const v of LOCO) {
|
|
200
|
+
const whole = mixer.clipAction(byName.get(v) as AnimationClip); whole.setLoop(LoopRepeat, Infinity); whole.play(); whole.setEffectiveWeight(v === 'idle' ? 1 : 0);
|
|
201
|
+
const low = layered ? variant(v, 'lower') : null;
|
|
202
|
+
const lower = low ? mixer.clipAction(low) : null;
|
|
203
|
+
if (lower) { lower.setLoop(LoopRepeat, Infinity); lower.play(); lower.setEffectiveWeight(0); }
|
|
204
|
+
loco.set(v, { whole, lower, w: v === 'idle' ? 1 : 0 });
|
|
205
|
+
}
|
|
206
|
+
let speed = 0; let shownSpeed = 0;
|
|
207
|
+
let upperMix = 0; // 0: loco on the whole body; 1: loco on the legs only (an upper-body action plays)
|
|
208
|
+
let busy: { verb: string; action: AnimationAction; body: 'whole' | 'upper'; opts: ActOptions; fired: boolean; ends: number } | null = null;
|
|
209
|
+
let locoWeight = 1; // how much locomotion shows at all (0 during a whole-body action, a jump, a death)
|
|
210
|
+
let airState: 'ground' | 'jump' | 'fall' | 'land' = 'ground';
|
|
211
|
+
let airAction: AnimationAction | null = null;
|
|
212
|
+
let landUntil = 0;
|
|
213
|
+
let dead = false; let held: AnimationAction | null = null;
|
|
214
|
+
let squashV = 0; let squash = 0; // squash spring: + stretch, - squash
|
|
215
|
+
let yaw = 0; let yawRate = 0; let bank = 0; let tip = 0; let lastSpeed = 0;
|
|
216
|
+
let stopUntil = 0; let clock = 0;
|
|
217
|
+
let flinch: AnimationAction | null = null;
|
|
218
|
+
let look: Vector3 | null = null; let lookYaw = 0; let lookPitch = 0;
|
|
219
|
+
let frame = 0; let pendingDt = 0;
|
|
220
|
+
let stateName = 'IDLE';
|
|
221
|
+
|
|
222
|
+
const fadeOut = (a: AnimationAction | null, t: number): void => { if (a) a.fadeOut(Math.max(0.001, t)); };
|
|
223
|
+
const playOnce = (clip: AnimationClip, { fade, speed: sp = 1, clamp = false }: { fade: number; speed?: number; clamp?: boolean }): AnimationAction => {
|
|
224
|
+
const a = mixer.clipAction(clip);
|
|
225
|
+
a.reset(); a.setLoop(LoopOnce, 1); a.clampWhenFinished = clamp; a.timeScale = sp; a.setEffectiveWeight(1); a.fadeIn(Math.max(0.001, fade)); a.play();
|
|
226
|
+
return a;
|
|
227
|
+
};
|
|
228
|
+
const endBusy = (): void => {
|
|
229
|
+
if (!busy) return;
|
|
230
|
+
const b = busy; busy = null;
|
|
231
|
+
fadeOut(b.action, tune('fade'));
|
|
232
|
+
if (!b.fired) { b.fired = true; b.opts.hit?.(); }
|
|
233
|
+
b.opts.then?.();
|
|
234
|
+
};
|
|
235
|
+
mixer.addEventListener('finished', (e: { action: AnimationAction }) => {
|
|
236
|
+
if (busy && e.action === busy.action) endBusy();
|
|
237
|
+
if (airAction && e.action === airAction && airState === 'jump') { airState = 'fall'; startFall(); }
|
|
238
|
+
if (flinch && e.action === flinch) { flinch.stop(); flinch = null; }
|
|
239
|
+
});
|
|
240
|
+
const startFall = (): void => {
|
|
241
|
+
const f = byName.get('fall');
|
|
242
|
+
fadeOut(airAction, tune('fade'));
|
|
243
|
+
airAction = null;
|
|
244
|
+
if (f) { airAction = mixer.clipAction(f); airAction.reset(); airAction.setLoop(LoopRepeat, Infinity); airAction.setEffectiveWeight(1); airAction.fadeIn(tune('fade')); airAction.play(); }
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// Springs: bones that swing (each keeps a simulated tip).
|
|
248
|
+
const springBones: { bone: Object3D; len: number; tip: Vector3; vel: Vector3; ready: boolean }[] = [];
|
|
249
|
+
model.traverse((o) => {
|
|
250
|
+
const named = opts.springs ? opts.springs.includes(o.name) : SPRING_NAMES.test(o.name);
|
|
251
|
+
if (!named || !(o as Bone).isBone && !o.children.length && !(o as unknown as { isMesh?: boolean }).isMesh) return;
|
|
252
|
+
springBones.push({ bone: o, len: 0, tip: new Vector3(), vel: new Vector3(), ready: false });
|
|
253
|
+
});
|
|
254
|
+
const sockets = new Map<string, Object3D>();
|
|
255
|
+
for (const [s, b] of Object.entries(opts.sockets ?? {})) { const o = model.getObjectByName(sanitize(b)) ?? model.getObjectByName(b); if (o) sockets.set(s, o); }
|
|
256
|
+
const SOCKET_FALLBACK: Record<string, string[]> = { rightHand: ['rightHandSlot', 'rightHand', 'rightUpperArm'], leftHand: ['leftHandSlot', 'leftHand', 'leftUpperArm'], head: ['head'], back: ['upperChest', 'chest', 'spine', 'body'] };
|
|
257
|
+
|
|
258
|
+
const v1 = new Vector3(); const v2 = new Vector3(); const v3 = new Vector3(); const q1 = new Quaternion(); const q2 = new Quaternion(); const q3 = new Quaternion();
|
|
259
|
+
const UP = new Vector3(0, 1, 0);
|
|
260
|
+
// The bones the layers turn: their animated pose is kept after every mixer update and put back before the layers
|
|
261
|
+
// run, so a bone no clip moves (a tail, a neck) never accumulates a turn, and a frame the mixer skips (crowds,
|
|
262
|
+
// hit-stop) still starts from the clip's pose.
|
|
263
|
+
const touched = new Set<Object3D>();
|
|
264
|
+
for (const n of ['head', 'neck', 'hips', 'leftUpperLeg', 'leftLowerLeg', 'rightUpperLeg', 'rightLowerLeg']) { const o = model.getObjectByName(n); if (o) touched.add(o); }
|
|
265
|
+
for (const s of springBones) touched.add(s.bone);
|
|
266
|
+
const animated = new Map<Object3D, { q: Quaternion; p: Vector3 }>();
|
|
267
|
+
const keepPose = (): void => { for (const b of touched) { const a = animated.get(b); if (a) { a.q.copy(b.quaternion); a.p.copy(b.position); } else animated.set(b, { q: b.quaternion.clone(), p: b.position.clone() }); } };
|
|
268
|
+
const putPose = (): void => { for (const [b, a] of animated) { b.quaternion.copy(a.q); b.position.copy(a.p); } };
|
|
269
|
+
keepPose();
|
|
270
|
+
|
|
271
|
+
/** Turn a bone so that its world direction `from` becomes `to` (both from its joint), keeping its twist. */
|
|
272
|
+
function swing(bone: Object3D, from: Vector3, to: Vector3, weight = 1): void {
|
|
273
|
+
if (from.lengthSq() < 1e-12 || to.lengthSq() < 1e-12) return;
|
|
274
|
+
q1.setFromUnitVectors(from.clone().normalize(), to.clone().normalize());
|
|
275
|
+
if (weight < 1) q1.slerp(new Quaternion(), 1 - weight);
|
|
276
|
+
bone.getWorldQuaternion(q2);
|
|
277
|
+
const parentQ = bone.parent ? bone.parent.getWorldQuaternion(new Quaternion()) : new Quaternion();
|
|
278
|
+
bone.quaternion.copy(parentQ.invert().multiply(q1.multiply(q2)));
|
|
279
|
+
bone.updateMatrixWorld(true);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function layers(dt: number): void {
|
|
283
|
+
model.updateMatrixWorld(true);
|
|
284
|
+
// Look-at: head (and neck) turn within limits, smoothed.
|
|
285
|
+
const head = model.getObjectByName('head');
|
|
286
|
+
if (head && tune('lookAt') > 0) {
|
|
287
|
+
let wantYaw = 0; let wantPitch = 0;
|
|
288
|
+
if (look && !dead) {
|
|
289
|
+
head.getWorldPosition(v1);
|
|
290
|
+
root.updateMatrixWorld();
|
|
291
|
+
v2.copy(look).sub(v1).applyQuaternion(root.getWorldQuaternion(q2).invert());
|
|
292
|
+
wantYaw = clampN(Math.atan2(v2.x, v2.z), -1.1, 1.1);
|
|
293
|
+
wantPitch = clampN(-Math.atan2(v2.y, Math.hypot(v2.x, v2.z)), -0.5, 0.45);
|
|
294
|
+
}
|
|
295
|
+
lookYaw = damp(lookYaw, wantYaw, 8, dt); lookPitch = damp(lookPitch, wantPitch, 8, dt);
|
|
296
|
+
const w = tune('lookAt');
|
|
297
|
+
if (Math.abs(lookYaw) + Math.abs(lookPitch) > 1e-3 && w > 0) {
|
|
298
|
+
const neck = model.getObjectByName('neck');
|
|
299
|
+
const parts: [Object3D, number][] = neck ? [[neck, 0.4], [head, 0.6]] : [[head, 1]];
|
|
300
|
+
root.getWorldQuaternion(q3);
|
|
301
|
+
for (const [b, share] of parts) {
|
|
302
|
+
const local = new Quaternion().setFromAxisAngle(UP, lookYaw * share * w).multiply(new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), lookPitch * share * w));
|
|
303
|
+
const worldTurn = q3.clone().multiply(local).multiply(q3.clone().invert());
|
|
304
|
+
b.getWorldQuaternion(q2);
|
|
305
|
+
const parentQ = b.parent ? b.parent.getWorldQuaternion(new Quaternion()) : new Quaternion();
|
|
306
|
+
b.quaternion.copy(parentQ.invert().multiply(worldTurn.multiply(q2)));
|
|
307
|
+
b.updateMatrixWorld(true);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// Springs: each tip trails its animated place.
|
|
312
|
+
const loose = tune('spring');
|
|
313
|
+
for (const s of springBones) {
|
|
314
|
+
const child = s.bone.children.find((c) => (c as Bone).isBone) ?? null;
|
|
315
|
+
s.bone.getWorldPosition(v1);
|
|
316
|
+
if (child) child.getWorldPosition(v2); else v2.set(0, 0, -0.3).applyQuaternion(s.bone.getWorldQuaternion(q2)).add(v1);
|
|
317
|
+
const len = v2.distanceTo(v1) || 0.2;
|
|
318
|
+
if (!s.ready) { s.tip.copy(v2); s.vel.set(0, 0, 0); s.len = len; s.ready = true; }
|
|
319
|
+
const k = 260 * (1 - loose * 0.92); const c = 2 * Math.sqrt(k) * (0.55 + 0.4 * (1 - loose));
|
|
320
|
+
v3.copy(v2).sub(s.tip).multiplyScalar(k).addScaledVector(s.vel, -c);
|
|
321
|
+
v3.y -= 2.5 * loose;
|
|
322
|
+
s.vel.addScaledVector(v3, dt);
|
|
323
|
+
s.tip.addScaledVector(s.vel, dt);
|
|
324
|
+
// Keep the tip at the bone's length from its joint.
|
|
325
|
+
v3.copy(s.tip).sub(v1).setLength(s.len); s.tip.copy(v1).add(v3);
|
|
326
|
+
swing(s.bone, v2.clone().sub(v1), s.tip.clone().sub(v1));
|
|
327
|
+
}
|
|
328
|
+
// Feet: humanoid legs reach the ground's height (slopes, steps).
|
|
329
|
+
if (opts.ground && family === 'humanoid' && !dead) plantFeet();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function plantFeet(): void {
|
|
333
|
+
const ground = opts.ground as (x: number, z: number) => number;
|
|
334
|
+
const legs = (['left', 'right'] as const).map((side) => ({ up: model.getObjectByName(`${side}UpperLeg`), low: model.getObjectByName(`${side}LowerLeg`), foot: model.getObjectByName(`${side}Foot`) })).filter((l) => l.up && l.low && l.foot) as { up: Object3D; low: Object3D; foot: Object3D }[];
|
|
335
|
+
if (legs.length !== 2) return;
|
|
336
|
+
root.getWorldPosition(v1);
|
|
337
|
+
const base = v1.y;
|
|
338
|
+
// How far each foot must move (only feet near the ground: a foot in the air keeps its swing).
|
|
339
|
+
const reach = legs.map((l) => { const f = l.foot.getWorldPosition(new Vector3()); const above = f.y - base; const g = ground(f.x, f.z) - base; return { l, f, need: above < 0.25 ? g : 0 }; });
|
|
340
|
+
const drop = Math.min(0, ...reach.map((r) => r.need));
|
|
341
|
+
const hips = model.getObjectByName('hips');
|
|
342
|
+
if (hips && drop < 0) { hips.getWorldPosition(v2); v2.y += drop; hips.parent?.worldToLocal(v2); hips.position.copy(v2); model.updateMatrixWorld(true); }
|
|
343
|
+
for (const r of reach) {
|
|
344
|
+
const hip = r.l.up.getWorldPosition(new Vector3()); const knee = r.l.low.getWorldPosition(new Vector3()); const foot = r.l.foot.getWorldPosition(new Vector3());
|
|
345
|
+
const target = foot.clone(); target.y += r.need - drop;
|
|
346
|
+
const a = hip.distanceTo(knee); const b = knee.distanceTo(foot);
|
|
347
|
+
const want = clampN(hip.distanceTo(target), Math.abs(a - b) + 1e-4, a + b - 1e-4);
|
|
348
|
+
// The knee bends in its own plane to make the leg `want` long, then the hip swings the foot onto the target.
|
|
349
|
+
const now = hip.distanceTo(foot);
|
|
350
|
+
if (Math.abs(now - want) > 1e-4) {
|
|
351
|
+
const cosNow = clampN((a * a + b * b - now * now) / (2 * a * b), -1, 1);
|
|
352
|
+
const cosWant = clampN((a * a + b * b - want * want) / (2 * a * b), -1, 1);
|
|
353
|
+
const axis = v3.copy(knee).sub(hip).cross(foot.clone().sub(knee)).normalize();
|
|
354
|
+
if (axis.lengthSq() > 0.5) {
|
|
355
|
+
const turn = new Quaternion().setFromAxisAngle(axis, Math.acos(cosWant) - Math.acos(cosNow));
|
|
356
|
+
r.l.low.getWorldQuaternion(q2);
|
|
357
|
+
const parentQ = r.l.low.parent ? r.l.low.parent.getWorldQuaternion(new Quaternion()) : new Quaternion();
|
|
358
|
+
r.l.low.quaternion.copy(parentQ.invert().multiply(turn.multiply(q2)));
|
|
359
|
+
r.l.low.updateMatrixWorld(true);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
swing(r.l.up, r.l.foot.getWorldPosition(new Vector3()).sub(hip), target.clone().sub(hip));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const ch: Character = {
|
|
367
|
+
root, model, mixer, clips, family, lod: 0,
|
|
368
|
+
verbs: [...byName.keys()],
|
|
369
|
+
has,
|
|
370
|
+
get state() { return stateName; },
|
|
371
|
+
move(s: number): void { speed = Math.max(0, s || 0); },
|
|
372
|
+
face(y: number, dt = 1 / 60): void {
|
|
373
|
+
let d = y - yaw; d = Math.atan2(Math.sin(d), Math.cos(d));
|
|
374
|
+
const step = d * (1 - Math.exp(-14 * Math.max(0, dt)));
|
|
375
|
+
yawRate = dt > 0 ? step / dt : 0;
|
|
376
|
+
yaw += step;
|
|
377
|
+
root.rotation.y = yaw;
|
|
378
|
+
},
|
|
379
|
+
air(onGround: boolean, vy = 0): void {
|
|
380
|
+
if (dead) return;
|
|
381
|
+
if (!onGround && airState === 'ground') { airState = 'fall'; startFall(); }
|
|
382
|
+
else if (!onGround && airState === 'jump' && vy < -0.5 && !has('jump')) { airState = 'fall'; startFall(); }
|
|
383
|
+
else if (onGround && (airState === 'fall' || airState === 'jump')) {
|
|
384
|
+
airState = 'land';
|
|
385
|
+
fadeOut(airAction, 0.06); airAction = null;
|
|
386
|
+
const l = byName.get('land');
|
|
387
|
+
if (l) { airAction = playOnce(l, { fade: 0.05, speed: 1.4 }); landUntil = clock + l.duration / 1.4 * 0.7; } else landUntil = clock + 0.12;
|
|
388
|
+
squash = -tune('landSquash'); squashV = 0;
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
jump(): void {
|
|
392
|
+
if (dead) return;
|
|
393
|
+
airState = 'jump';
|
|
394
|
+
squash = tune('jumpStretch'); squashV = 0;
|
|
395
|
+
fadeOut(airAction, 0.05); airAction = null;
|
|
396
|
+
const j = byName.get('jump');
|
|
397
|
+
if (j) airAction = playOnce(j, { fade: 0.05, speed: 1.6, clamp: true }); else startFall();
|
|
398
|
+
},
|
|
399
|
+
act(verb: string, o: ActOptions = {}): number {
|
|
400
|
+
if (dead) return 0;
|
|
401
|
+
const base = byName.get(verb);
|
|
402
|
+
if (!base) { o.hit?.(); o.then?.(); return 0; }
|
|
403
|
+
endBusy();
|
|
404
|
+
const moving = speed > 0.4 || airState !== 'ground';
|
|
405
|
+
const body = o.body ?? (moving && layered ? 'upper' : 'whole');
|
|
406
|
+
const clip = body === 'upper' ? (variant(verb, 'upper') as AnimationClip) : base;
|
|
407
|
+
const sp = (o.speed ?? 1) * tune('actSpeed');
|
|
408
|
+
const action = playOnce(clip, { fade: Math.min(0.08, tune('fade')), speed: sp });
|
|
409
|
+
const skip = clampN(o.from ?? 0, 0, 0.9) * base.duration;
|
|
410
|
+
if (skip > 0) action.time = skip;
|
|
411
|
+
busy = { verb, action, body, opts: o, fired: false, ends: clock + (base.duration - skip) / sp };
|
|
412
|
+
return (base.duration - skip) / sp;
|
|
413
|
+
},
|
|
414
|
+
hit(dx = 0, dz = 0, strength = 1): void {
|
|
415
|
+
if (dead) return;
|
|
416
|
+
stopUntil = clock + tune('hitStopMs') / 1000;
|
|
417
|
+
// An additive jolt over whatever plays (the legs keep running), from the hit clip's upper body.
|
|
418
|
+
const add = variant('hit', 'add');
|
|
419
|
+
if (add) { if (flinch) flinch.stop(); flinch = mixer.clipAction(add); flinch.blendMode = AdditiveAnimationBlendMode; flinch.reset(); flinch.setLoop(LoopOnce, 1); flinch.setEffectiveWeight(clampN(tune('flinch') * strength, 0, 1.5)); flinch.timeScale = 1.3; flinch.play(); }
|
|
420
|
+
// Tip away from the hit.
|
|
421
|
+
if (dx || dz) { const local = new Vector3(dx, 0, dz).applyAxisAngle(UP, -yaw); bank += clampN(-local.x, -1, 1) * 0.25 * strength; tip += clampN(-local.z, -1, 1) * 0.25 * strength; }
|
|
422
|
+
squash = -tune('landSquash') * 0.6 * strength; squashV = 0;
|
|
423
|
+
},
|
|
424
|
+
die(): void {
|
|
425
|
+
if (dead) return;
|
|
426
|
+
dead = true; endBusy();
|
|
427
|
+
fadeOut(airAction, 0.05); airAction = null; airState = 'ground';
|
|
428
|
+
const d = byName.get('die');
|
|
429
|
+
if (d) { held = playOnce(d, { fade: 0.08, speed: 1, clamp: true }); }
|
|
430
|
+
},
|
|
431
|
+
revive(): void {
|
|
432
|
+
dead = false;
|
|
433
|
+
fadeOut(held, tune('fade')); held = null;
|
|
434
|
+
},
|
|
435
|
+
hold(verb: string): void {
|
|
436
|
+
const c = byName.get(verb);
|
|
437
|
+
fadeOut(held, tune('fade')); held = null;
|
|
438
|
+
if (!c) return;
|
|
439
|
+
endBusy();
|
|
440
|
+
held = mixer.clipAction(c); held.reset(); held.setLoop(LoopRepeat, Infinity); held.setEffectiveWeight(1); held.fadeIn(tune('fade')); held.play();
|
|
441
|
+
},
|
|
442
|
+
lookAt(p: Vector3 | null): void { look = p ? (look ?? new Vector3()).copy(p) : null; },
|
|
443
|
+
socket(name: string): Object3D | null {
|
|
444
|
+
if (sockets.has(name)) return sockets.get(name) as Object3D;
|
|
445
|
+
for (const b of SOCKET_FALLBACK[name] ?? [name]) { const o = model.getObjectByName(b); if (o) return o; }
|
|
446
|
+
return null;
|
|
447
|
+
},
|
|
448
|
+
bone(name: string): Object3D | null { return model.getObjectByName(name) ?? model.getObjectByName(sanitize(name)) ?? null; },
|
|
449
|
+
update(dt: number): void {
|
|
450
|
+
clock += dt;
|
|
451
|
+
const fade = Math.max(0.02, tune('fade'));
|
|
452
|
+
const rate = 1 / fade;
|
|
453
|
+
// A hold that is not a death ends when the character moves.
|
|
454
|
+
if (held && !dead && speed > 0.3) { fadeOut(held, fade); held = null; }
|
|
455
|
+
if (airState === 'land' && clock >= landUntil) { airState = 'ground'; fadeOut(airAction, fade); airAction = null; }
|
|
456
|
+
// Locomotion weights from the shown speed.
|
|
457
|
+
shownSpeed = damp(shownSpeed, speed, 10, dt);
|
|
458
|
+
const s = shownSpeed;
|
|
459
|
+
const walkS = tune('walkSpeed'); const runS = tune('runSpeed'); const runFrom = tune('runFrom');
|
|
460
|
+
const target = { idle: 0, walk: 0, run: 0 } as Record<string, number>;
|
|
461
|
+
const mover = has('walk') ? 'walk' : has('run') ? 'run' : null;
|
|
462
|
+
if (!mover || s < 0.15) target.idle = 1;
|
|
463
|
+
else if (mover === 'walk' && (!has('run') || s < runFrom)) { const u = clampN((s - 0.15) / 0.5, 0, 1); target.idle = 1 - u; target.walk = u; }
|
|
464
|
+
else if (mover === 'run' && !has('walk')) { const u = clampN((s - 0.15) / 0.5, 0, 1); target.idle = 1 - u; target.run = u; }
|
|
465
|
+
else { const u = clampN((s - runFrom) / Math.max(0.1, runFrom * 0.25), 0, 1); target.walk = 1 - u; target.run = u; }
|
|
466
|
+
const wholeBusy = Boolean(busy && busy.body === 'whole') || airState === 'jump' || airState === 'fall' || dead || Boolean(held);
|
|
467
|
+
locoWeight = damp(locoWeight, wholeBusy ? 0 : 1, rate * 2.2, dt);
|
|
468
|
+
upperMix = damp(upperMix, busy && busy.body === 'upper' ? 1 : 0, rate * 2.2, dt);
|
|
469
|
+
for (const [v, l] of loco) {
|
|
470
|
+
l.w = damp(l.w, target[v] ?? 0, rate * 2.2, dt);
|
|
471
|
+
const w = l.w * locoWeight;
|
|
472
|
+
l.whole.setEffectiveWeight(w * (l.lower ? 1 - upperMix : 1));
|
|
473
|
+
l.lower?.setEffectiveWeight(w * upperMix);
|
|
474
|
+
const ts = v === 'walk' ? clampN(s / walkS, 0.55, 1.7) : v === 'run' ? clampN(s / runS, 0.6, 1.6) : 1;
|
|
475
|
+
l.whole.timeScale = ts; if (l.lower) l.lower.timeScale = ts;
|
|
476
|
+
}
|
|
477
|
+
// An action's hit moment.
|
|
478
|
+
if (busy && !busy.fired && busy.opts.at !== undefined) {
|
|
479
|
+
const clip = busy.action.getClip();
|
|
480
|
+
if (busy.action.time >= clampN(busy.opts.at, 0, 1) * clip.duration) { busy.fired = true; busy.opts.hit?.(); }
|
|
481
|
+
}
|
|
482
|
+
// Hit-stop: the pose holds; everything else (springs, squash) keeps living.
|
|
483
|
+
const stopped = clock < stopUntil;
|
|
484
|
+
// Crowds: far characters update their pose less often.
|
|
485
|
+
frame += 1; pendingDt += dt;
|
|
486
|
+
const every = ch.lod >= 3 ? 4 : ch.lod >= 2 ? 2 : 1;
|
|
487
|
+
putPose();
|
|
488
|
+
if (!stopped && frame % every === 0) { mixer.update(pendingDt); pendingDt = 0; keepPose(); } else if (stopped) pendingDt = 0;
|
|
489
|
+
// Lean: bank into the turn, tip into a speed-up (springy).
|
|
490
|
+
const accel = dt > 0 ? (s - lastSpeed) / dt : 0; lastSpeed = s;
|
|
491
|
+
const L = tune('lean');
|
|
492
|
+
bank = damp(bank, clampN(-yawRate * s * 0.05 * L * 4, -0.45, 0.45), 9, dt);
|
|
493
|
+
tip = damp(tip, clampN(accel * 0.02 * L * 4, -0.2, 0.35), 7, dt);
|
|
494
|
+
// Squash and stretch: a spring back to 1.
|
|
495
|
+
const hz = tune('squashHz'); const k = (2 * Math.PI * hz) ** 2; const c = 2 * 0.45 * Math.sqrt(k);
|
|
496
|
+
squashV += (-k * squash - c * squashV) * dt; squash += squashV * dt;
|
|
497
|
+
const sy = 1 + squash; const sxz = 1 / Math.sqrt(Math.max(0.2, sy));
|
|
498
|
+
pose.scale.set(sxz, sy, sxz);
|
|
499
|
+
pose.rotation.set(tip, 0, bank);
|
|
500
|
+
if (ch.lod <= 0) layers(dt);
|
|
501
|
+
stateName = dead ? 'DOWN' : flinch && stopped ? 'HIT' : busy ? busy.verb.toUpperCase() : airState === 'jump' ? 'JUMP' : airState === 'fall' ? 'FALL' : airState === 'land' ? 'LAND' : held ? 'HOLD' : s > runFrom ? 'RUN' : s > 0.15 ? 'WALK' : 'IDLE';
|
|
502
|
+
},
|
|
503
|
+
dispose(): void { mixer.stopAllAction(); mixer.uncacheRoot(model); root.removeFromParent(); },
|
|
504
|
+
};
|
|
505
|
+
return ch;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Crowd mode for a room of many: each character's `lod` from its distance to the camera and whether it is on screen
|
|
510
|
+
* (0 near, 1 middle, 2 far, 3 off screen). Call once a frame before update(). `near` and `far` in metres.
|
|
511
|
+
*/
|
|
512
|
+
export function crowd(characters: Character[], camera: Camera, { near = 14, far = 30 }: { near?: number; far?: number } = {}): void {
|
|
513
|
+
const p = new Vector3(); const c = new Vector3(); camera.getWorldPosition(c);
|
|
514
|
+
const view = new Vector3(); camera.getWorldDirection(view);
|
|
515
|
+
for (const ch of characters) {
|
|
516
|
+
ch.root.getWorldPosition(p);
|
|
517
|
+
const d = p.distanceTo(c);
|
|
518
|
+
const ahead = p.clone().sub(c).normalize().dot(view) > 0.2;
|
|
519
|
+
ch.lod = !ahead ? 3 : d > far ? 2 : d > near ? 1 : 0;
|
|
520
|
+
}
|
|
521
|
+
}
|
package/assets/render-page.ts
CHANGED
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
|
22
22
|
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';
|
|
23
23
|
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
|
24
|
+
import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js';
|
|
25
|
+
import { AnimationMixer, LoopRepeat, LoopOnce, type AnimationClip } from 'three';
|
|
24
26
|
|
|
25
27
|
interface Palette { bg: string; ink: string; accent: string; accent2: string; danger: string; good: string; gold: string; ramp?: string[] }
|
|
26
28
|
interface Tokens {
|
|
@@ -33,7 +35,7 @@ interface Tokens {
|
|
|
33
35
|
shape?: { language?: string; bevel?: number };
|
|
34
36
|
proportions?: { heads?: number; heightM?: number };
|
|
35
37
|
}
|
|
36
|
-
interface ModelIn { id: string; glb: string; label?: string; place?: 'hero' | 'prop' | 'dressing'; scale?: number; retint?: boolean; tint?: string; pull?: number }
|
|
38
|
+
interface ModelIn { id: string; glb: string; label?: string; place?: 'hero' | 'prop' | 'dressing'; scale?: number; retint?: boolean; tint?: string; pull?: number; anims?: string; pose?: string; poseAt?: number }
|
|
37
39
|
|
|
38
40
|
const canvas = document.createElement('canvas');
|
|
39
41
|
document.body.appendChild(canvas);
|
|
@@ -46,8 +48,15 @@ loader.setMeshoptDecoder(MeshoptDecoder);
|
|
|
46
48
|
|
|
47
49
|
const b64 = (s: string): ArrayBuffer => { const bin = atob(s); const u = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i); return u.buffer; };
|
|
48
50
|
async function parse(m: ModelIn): Promise<Group> {
|
|
49
|
-
const gltf = await new Promise<{ scene: Group }>((res, rej) => loader.parse(b64(m.glb), '', res as never, rej));
|
|
51
|
+
const gltf = await new Promise<{ scene: Group; animations: AnimationClip[] }>((res, rej) => loader.parse(b64(m.glb), '', res as never, rej));
|
|
50
52
|
gltf.scene.name = m.id;
|
|
53
|
+
// A character with a clip library stands in a pose (idle, a little way in), not in its bind pose.
|
|
54
|
+
const clips = m.anims ? (await new Promise<{ animations: AnimationClip[] }>((res, rej) => loader.parse(b64(m.anims as string), '', res as never, rej))).animations : gltf.animations;
|
|
55
|
+
gltf.scene.userData.clips = clips;
|
|
56
|
+
if (clips?.length && (m.anims || m.pose)) {
|
|
57
|
+
const clip = clips.find((c) => c.name === (m.pose ?? 'idle')) ?? clips.find((c) => c.name === 'idle') ?? clips[0];
|
|
58
|
+
if (clip) { const mixer = new AnimationMixer(gltf.scene); mixer.clipAction(clip).play(); mixer.setTime(Math.min(clip.duration, m.poseAt ?? 0.25)); gltf.scene.updateMatrixWorld(true); }
|
|
59
|
+
}
|
|
51
60
|
return gltf.scene;
|
|
52
61
|
}
|
|
53
62
|
const col = (hex: string | undefined, fallback = '#888888'): Color => new Color(/^#[0-9a-f]{3,8}$/i.test(String(hex)) ? hex as string : fallback);
|
|
@@ -573,7 +582,8 @@ async function lineup(models: ModelIn[], t: Tokens, opts: { w?: number; h?: numb
|
|
|
573
582
|
const solo = new Scene(); solo.background = new Color('#ffffff');
|
|
574
583
|
for (let k = 0; k < parsed.length; k++) {
|
|
575
584
|
const it = parsed[k] as (typeof parsed)[number];
|
|
576
|
-
|
|
585
|
+
// A skinned character's copy needs its own bones (a plain clone would draw with the original's, elsewhere).
|
|
586
|
+
const clone = cloneSkinned(it.g);
|
|
577
587
|
clone.position.set(0, 0, 0);
|
|
578
588
|
clone.traverse((o) => { const m = o as Mesh; if (m.isMesh) m.material = black; });
|
|
579
589
|
solo.add(clone);
|
|
@@ -622,8 +632,71 @@ async function thumb(m: ModelIn, opts: { size?: number; bg?: string } = {}): Pro
|
|
|
622
632
|
return { image: shot.toDataURL('image/webp', 0.82), size: [size.x, size.y, size.z].map((v) => +v.toFixed(3)) };
|
|
623
633
|
}
|
|
624
634
|
|
|
635
|
+
/* ------------------------------------------------------------------ frames of a clip */
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* A character playing its clips: for each verb, `count` frames spread over the clip (a loop's last frame is its first,
|
|
639
|
+
* so it is left out), seen three-quarter on a ground under the game's light, each `size` pixels square. The card's
|
|
640
|
+
* looping previews are made from these (lib/characters.mjs joins them into an animated WebP); `sheet` puts every verb
|
|
641
|
+
* in a row on one picture for a person to look at.
|
|
642
|
+
*/
|
|
643
|
+
async function frames(m: ModelIn, t: Tokens, opts: { verbs?: string[]; count?: number; size?: number; sheet?: boolean; loops?: string[] } = {}): Promise<{ verbs: { verb: string; duration: number; frames: string[] }[]; sheet: string | null; missing: string[] }> {
|
|
644
|
+
const size = opts.size ?? 160; const count = Math.max(2, Math.min(24, opts.count ?? 12));
|
|
645
|
+
const g = await parse({ ...m, pose: undefined, anims: m.anims });
|
|
646
|
+
const clips = (g.userData.clips ?? []) as AnimationClip[];
|
|
647
|
+
const want = opts.verbs?.length ? opts.verbs : clips.map((c) => c.name);
|
|
648
|
+
const p = t.palette;
|
|
649
|
+
const scene = new Scene(); scene.background = col(mixHex(p.bg, '#ffffff', 0.1));
|
|
650
|
+
renderer.toneMapping = NoToneMapping;
|
|
651
|
+
const b = boxOf(g); const sz = b.getSize(new Vector3()); const c = b.getCenter(new Vector3());
|
|
652
|
+
g.position.sub(new Vector3(c.x, b.min.y, c.z));
|
|
653
|
+
const tall = Math.max(0.1, sz.y);
|
|
654
|
+
lights(scene, t, tall * 4);
|
|
655
|
+
const floor = new Mesh(new PlaneGeometry(tall * 40, tall * 40), new MeshStandardMaterial({ color: col(t.light?.ground ?? mixHex(p.accent2, p.bg, 0.5)), roughness: 1 }));
|
|
656
|
+
floor.rotation.x = -Math.PI / 2; floor.receiveShadow = true; scene.add(floor);
|
|
657
|
+
g.traverse((o) => { const mm = o as Mesh; if (mm.isMesh) { mm.castShadow = true; mm.receiveShadow = false; mm.frustumCulled = false; } });
|
|
658
|
+
scene.add(g);
|
|
659
|
+
const cam = new PerspectiveCamera(30, 1, tall * 0.02, tall * 60);
|
|
660
|
+
const d = tall * 3.1;
|
|
661
|
+
cam.position.set(d * 0.55, tall * 0.55 + d * 0.32, d * 0.78); cam.lookAt(0, tall * 0.45, 0);
|
|
662
|
+
const mixer = new AnimationMixer(g);
|
|
663
|
+
const out: { verb: string; duration: number; frames: string[] }[] = [];
|
|
664
|
+
const missing: string[] = [];
|
|
665
|
+
for (const verb of want) {
|
|
666
|
+
const clip = clips.find((x) => x.name === verb);
|
|
667
|
+
if (!clip) { missing.push(verb); continue; }
|
|
668
|
+
mixer.stopAllAction();
|
|
669
|
+
const action = mixer.clipAction(clip);
|
|
670
|
+
const loop = (opts.loops ?? []).includes(verb);
|
|
671
|
+
action.setLoop(loop ? LoopRepeat : LoopOnce, Infinity); action.clampWhenFinished = true; action.reset().play();
|
|
672
|
+
const shots: string[] = [];
|
|
673
|
+
for (let i = 0; i < count; i++) {
|
|
674
|
+
const at = loop ? (clip.duration * i) / count : (clip.duration * i) / (count - 1);
|
|
675
|
+
mixer.setTime(Math.min(clip.duration - 1e-4, at));
|
|
676
|
+
g.updateMatrixWorld(true);
|
|
677
|
+
shots.push(snapshot(size, size, scene, cam).toDataURL('image/png'));
|
|
678
|
+
}
|
|
679
|
+
action.stop();
|
|
680
|
+
out.push({ verb, duration: +clip.duration.toFixed(3), frames: shots });
|
|
681
|
+
}
|
|
682
|
+
let sheet: string | null = null;
|
|
683
|
+
if (opts.sheet && out.length) {
|
|
684
|
+
const sc = document.createElement('canvas'); sc.width = size * count + 90; sc.height = size * out.length;
|
|
685
|
+
const sg = sc.getContext('2d') as CanvasRenderingContext2D;
|
|
686
|
+
sg.fillStyle = '#ffffff'; sg.fillRect(0, 0, sc.width, sc.height);
|
|
687
|
+
for (let r = 0; r < out.length; r++) {
|
|
688
|
+
const row = out[r] as (typeof out)[number];
|
|
689
|
+
sg.fillStyle = '#222'; sg.font = '600 13px system-ui, sans-serif'; sg.fillText(row.verb, 6, r * size + size / 2);
|
|
690
|
+
for (let k = 0; k < row.frames.length; k++) { const im = new Image(); im.src = row.frames[k] as string; await im.decode(); sg.drawImage(im, 90 + k * size, r * size); }
|
|
691
|
+
}
|
|
692
|
+
sheet = sc.toDataURL('image/jpeg', 0.85);
|
|
693
|
+
}
|
|
694
|
+
disposeScene(scene);
|
|
695
|
+
return { verbs: out, sheet, missing };
|
|
696
|
+
}
|
|
697
|
+
|
|
625
698
|
function disposeScene(scene: Scene): void {
|
|
626
699
|
scene.traverse((o) => { const m = o as Mesh; if (m.isMesh) { m.geometry?.dispose(); const mats = Array.isArray(m.material) ? m.material : [m.material]; for (const x of mats) x?.dispose(); } });
|
|
627
700
|
}
|
|
628
701
|
|
|
629
|
-
(window as unknown as { homieRender: unknown }).homieRender = { swatch, lineup, thumb, ready: true };
|
|
702
|
+
(window as unknown as { homieRender: unknown }).homieRender = { swatch, lineup, thumb, frames, ready: true };
|