@homie-rocks/studio 0.24.5 → 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 +32 -1
- package/README.md +47 -1
- package/assets/animate.ts +521 -0
- package/assets/render-page.ts +77 -4
- package/bin/homie-studio.mjs +13 -4
- package/lib/art-cli.mjs +105 -5
- package/lib/art-tools.mjs +168 -1
- package/lib/asset-check.mjs +72 -5
- 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 +7 -0
- package/lib/studio.mjs +18 -3
- package/lib/style-board.mjs +3 -1
- package/lib/template-history.json +39 -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/package.json +5 -1
- 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/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,9 +17,40 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.25.0] - 2026-10-03
|
|
21
|
+
|
|
22
|
+
**Plugin 0.26.0** · [#38](https://github.com/homie-rocks/homie/pull/38)
|
|
23
|
+
|
|
24
|
+
Animated characters for 3D games: free CC0 heroes or one generated and auto-rigged on your fal account, on one skeleton standard with the library's clips retargeted onto it, and a shared player that runs, jumps and swings with IK feet and springs.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- A skeleton standard (`lib/rig.mjs`): a humanoid family with VRM 1.0 bone names, a mini family for one-piece limbs (Kenney's mini and blocky characters), a quadruped and a rigid "parts" family. A rig from Mixamo, KayKit, Blender, Unreal or Meshy is mapped onto it by name and by its hierarchy, and recorded with a skeleton id (`humanoid-3fa2c1`): characters with the same skeleton share one clip library.
|
|
29
|
+
- `homie-studio assets add` takes animated characters from the free library (KayKit's adventurers and skeletons, Kenney's mini and blocky characters, all CC0) and `--file <model> --kind character --rigged` for your own. The shipped character is phone-sized: joints renamed to the standard, helper bones dropped, skinned parts and the accessories you keep (`--keep sword,shield`) merged into one draw call, its clips moved out.
|
|
30
|
+
- Clip libraries: `public/anims/<skeleton>.glb` holds one clip per verb (idle, walk, run, jump, fall, land, attack, cast, hit, die, ...), 30 frames a second, compressed, with where each clip came from. Clips are retargeted at build time from the library's CC0 sources onto any humanoid or mini skeleton (rest-pose alignment per bone, hips scaled by leg height, loops kept in place).
|
|
31
|
+
- `homie-studio cast <id>`: the characters, with their proportions, silhouette, palette, skeleton family and clips. `homie-studio anim plan|add|preview <id>`: each character's clips against the verbs the game needs, more verbs retargeted on, and looping previews (animated WebP) with a sheet.
|
|
32
|
+
- `@homie-rocks/studio/animate`: one shared three.js `AnimationMixer` player. `loadCharacter` finds a character's clip library from the model; `createCharacter` blends idle, walk and run by speed, plays upper-body actions while running, an additive hit flinch and hit-stop, jump, fall and land with squash and stretch on a spring, lean into turns, look-at, springs and two-bone IK feet, and a cheaper crowd mode past a dozen characters. `ANIM_TUNING` is what the Game Lab tunes.
|
|
33
|
+
- A 3D starter with animated heroes, `hero-rush-3d`: knights, mages, rogues and barbarians race for coins in a clearing, jump, dodge and swing at each other, on a phone (Jump and Swing buttons) and a computer, multiplayer with skeleton bots in empty seats. Each round opens on a close-up of your own hero that pulls back to play, and the Game Lab has takes for the jump and the swing.
|
|
34
|
+
- Generated characters (paid, on your own fal account): the models skill's `character` command makes an A-pose concept in the game's style (`--like` a library character's thumbnail), then Meshy 7.1 image-to-3D with its auto-rig (about US$1.40 with textures and the rig), then free: made phone-sized, mapped to the standard and given the library's clips. Both calls have receipts under the budget.
|
|
35
|
+
- MCP tools and cards: `cast_plan` (the cast card), `character_make` (the lineup with a character beside the others), `anim_plan`, `anim_add` and `anim_preview` (an Animation card of looping clips: Add is free, Feel opens the Game Lab on that move, New against Today).
|
|
36
|
+
- An `animate` skill: the skeleton standard, retargeting, clip rights (KayKit and Kenney CC0; Quaternius's own licence since 2026-08-28 is not CC0 and is not used; text-to-motion models are not offered, because the ones available are non-commercial in effect).
|
|
37
|
+
- In Claude Code, `/cast` and `/clips`, and the Studio pane's Art tab shows the characters (skeleton, bones, clips) and the skinning cost on a phone.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- `assets check` checks skinned meshes: more than four bone influences per vertex, a character without its clip library or missing a verb the game needs, clip libraries over 1 MB (3 MB is a problem) or over 30 frames a second, and the skinning cost of every player's character at once against a phone's budget (skinned vertices and bones), with crowd mode past twelve characters.
|
|
42
|
+
- A rigged model is measured by its skin (its joints' positions), so a character exported in centimetres is no longer 100 times too big, and it is centred by a wrapper node instead of moving its skeleton.
|
|
43
|
+
- The lineup poses rigged characters in their idle clip and draws their silhouettes from the posed skin.
|
|
44
|
+
- `style init` on a starter keeps what the starter already draws (its `style.json`: render, palette, materials, light, camera, fonts) as the automatic picks, and the clip decision lists the clips the game has.
|
|
45
|
+
- The fal price check adds a model page's add-ons (Meshy's textures and rig) and quotes the higher of fal's pricing API and the page.
|
|
46
|
+
|
|
47
|
+
### Upgrade notes
|
|
48
|
+
|
|
49
|
+
- Nothing to do: games without characters are unchanged. To animate a 3D game's characters, tell Claude: "Give my game animated characters."
|
|
50
|
+
|
|
20
51
|
## [0.24.5] - 2026-10-03
|
|
21
52
|
|
|
22
|
-
**Plugin 0.25.5** · [#39](https://github.com/homie-rocks/homie/pull/39)
|
|
53
|
+
**Plugin 0.25.5** · [#39](https://github.com/homie-rocks/homie/pull/39) · [release-2026-10-03-studio-0.24.5](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-03-studio-0.24.5)
|
|
23
54
|
|
|
24
55
|
Room chat keeps off your game's HUD: on a phone the Chat pill is a round icon, as the room button is, and your game can say where the strip of new lines sits, or keep new lines in the Chat sheet, so a game with a busy HUD keeps typed chat.
|
|
25
56
|
|
package/README.md
CHANGED
|
@@ -601,7 +601,7 @@ npx homie-studio assets remove crown-thief lantern # the record and the co
|
|
|
601
601
|
|
|
602
602
|
A game's look is a set of decisions (`games/<id>/codex/decisions.json`, private like the codex, drawn in its Art
|
|
603
603
|
direction tab): render style, palette, shape, proportions, materials, light, camera, fonts and effects; the cast, its
|
|
604
|
-
library family, scale and phone budgets;
|
|
604
|
+
library family, scale and phone budgets; the skeleton standard and the clips each role needs. Each starts as an automatic pick with a
|
|
605
605
|
why. A person's nudge makes it steered, their lock freezes it, and the first asset built on an automatic one pins it.
|
|
606
606
|
Every asset records the revision of each decision it was made under, so changing one lists exactly what went stale and
|
|
607
607
|
what remaking it would cost; nothing is remade by itself. `games/<id>/style.json` (public) is the palette, fonts, light
|
|
@@ -631,6 +631,52 @@ The local MCP's `style_explore`, `decision_set`, `assets_plan`, `assets_find`, `
|
|
|
631
631
|
`asset_check`, `asset_lineup` and `asset_rights` do the same with cards. Generated props (on the person's own fal
|
|
632
632
|
account, under a budget, receipted) are the Homie plugin's `models` skill.
|
|
633
633
|
|
|
634
|
+
## Characters, rigs and clips (`homie-studio cast`, `homie-studio anim`)
|
|
635
|
+
|
|
636
|
+
```sh
|
|
637
|
+
npx homie-studio assets find "knight" --kind character # animated CC0 characters: KayKit, Kenney
|
|
638
|
+
npx homie-studio assets add heroes kaykit-adventurers/knight --as knight --height 1.45 \
|
|
639
|
+
--keep "1H_Sword,Round_Shield,Knight_Helmet,Knight_Cape" --verbs idle,walk,run,jump,attack,hit,die
|
|
640
|
+
npx homie-studio assets add heroes --file ./hero.glb --kind character --rigged --license own --as hero
|
|
641
|
+
npx homie-studio cast heroes # proportions, palette, skeleton family, every character
|
|
642
|
+
npx homie-studio anim plan heroes # each character's clips against the verbs the game needs
|
|
643
|
+
npx homie-studio anim add heroes knight --verbs block,dodge # more, retargeted onto its skeleton (free)
|
|
644
|
+
npx homie-studio anim preview heroes --asset knight # looping previews and a sheet: .studio/art/<id>/anim/
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
A rigged character comes in as two files: `public/models/<asset>.glb` (its joints renamed to the skeleton standard:
|
|
648
|
+
VRM 1.0 humanoid names, a mini family for one-piece limbs, a quadruped family; helper bones that move nothing removed;
|
|
649
|
+
every part and every held thing it keeps merged into one skinned mesh, one draw call; phone-sized) and its skeleton's
|
|
650
|
+
clip library `public/anims/<skeleton>.glb`, shared by every character with that skeleton. Clips are its own where it
|
|
651
|
+
has them, else retargeted at build time from the starter library's CC0 humanoid clips (KayKit's): every bone turns in
|
|
652
|
+
the world the way the source's turned from its rest, after the two rest poses are lined up bone by bone, so a T-pose
|
|
653
|
+
clip plays on an A-pose rig; a one-piece limb aims where the source's whole limb points. It is plain matrix math in
|
|
654
|
+
Node (no renderer), so it runs in CI and a cloud session too. `assets check` holds characters to four influences a
|
|
655
|
+
vertex, the tier's bones, a clip library of 1 MB at 30 samples a second, every verb the game's `anim.clips` names,
|
|
656
|
+
and the skinning a room costs on a phone (players times the heaviest character: 60,000 vertices and 1,200 bones a
|
|
657
|
+
frame).
|
|
658
|
+
|
|
659
|
+
In the game, `@homie-rocks/studio/animate`:
|
|
660
|
+
|
|
661
|
+
```ts
|
|
662
|
+
import { crowd, loadCharacter, ANIM_TUNING } from '@homie-rocks/studio/animate';
|
|
663
|
+
const knight = await loadCharacter(models, './models/knight.glb', { tune: T }); // finds its clip library itself
|
|
664
|
+
scene.add(knight.root);
|
|
665
|
+
// each frame: knight.root.position.set(x, h, z); knight.face(yaw, dt); knight.move(speed); knight.air(onGround, vy); knight.update(dt);
|
|
666
|
+
// events: knight.jump(); knight.act('attack', { from: 0.15 }); knight.hit(dx, dz); knight.die(); knight.hold('win');
|
|
667
|
+
crowd(characters, camera); // far and off-screen characters pose less often
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
Idle, walk and run blend by ground speed at the rate the feet need; an action while moving plays on the upper body;
|
|
671
|
+
a hit is an additive flinch with a hit-stop; jump, fall and land squash and stretch on springs; it leans into turns,
|
|
672
|
+
turns its head toward a point, swings tails and capes on springs and plants humanoid feet on slopes. Every number is
|
|
673
|
+
an `ANIM_TUNING` entry for the game's tunables.json, so the Game Lab tunes it New beside Today.
|
|
674
|
+
`game new <id> --from hero-rush-3d` is the arena with KayKit's animated heroes and skeletons: run, jump, swing, a
|
|
675
|
+
cheer for the winner. The local MCP's `cast_plan`, `anim_plan` (looping previews; Feel opens the Game Lab on the
|
|
676
|
+
move), `anim_add`, `anim_preview` and `character_make` do the same with cards. A generated, rigged character (a concept
|
|
677
|
+
in the locked style, then Meshy's mesh and auto-rig on the person's own fal account, about US$1.44, priced and
|
|
678
|
+
receipted) is the Homie plugin's `models` skill; rigs, clips and feel are its `animate` skill.
|
|
679
|
+
|
|
634
680
|
## Checks on a computer without a GPU
|
|
635
681
|
|
|
636
682
|
`check`, `port check` and `look` run Chrome headless. On a Mac they use its GPU. On Linux (a Claude Code cloud
|
|
@@ -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
|
+
}
|