@genex-ai/cli-demo 0.11.0 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/index.js +203 -4
- package/package.json +7 -2
- package/templates/controllers/NOTICE.md +65 -0
- package/templates/controllers/assets/animation-library.glb +0 -0
- package/templates/controllers/assets/character.glb +0 -0
- package/templates/controllers/assets/default-avatar.vrm +0 -0
- package/templates/controllers/character/character-animations.ts +682 -0
- package/templates/controllers/character/character-controller.ts +1636 -0
- package/templates/controllers/character/follow-camera.ts +644 -0
- package/templates/controllers/character/keyboard-input.ts +277 -0
- package/templates/controllers/character/presets.ts +176 -0
- package/templates/controllers/character/touch-joystick.ts +387 -0
- package/templates/controllers/character/vrm/capsule-fit.ts +52 -0
- package/templates/controllers/character/vrm/foot-ik.ts +341 -0
- package/templates/controllers/character/vrm/vrm-loader.ts +44 -0
- package/templates/controllers/character/vrm/vrm-retarget.ts +195 -0
- package/templates/controllers/drone/drone-controller.ts +1073 -0
- package/templates/controllers/drone/presets.ts +225 -0
- package/templates/controllers/interact/enter-exit.ts +502 -0
- package/templates/controllers/shared/colliders.ts +456 -0
- package/templates/controllers/shared/math.ts +230 -0
- package/templates/controllers/shared/physics-world.ts +622 -0
- package/templates/controllers/vehicle/presets.ts +297 -0
- package/templates/controllers/vehicle/vehicle-controller.ts +615 -0
- package/templates/controllers/vehicle/wheel.ts +1200 -0
- package/templates/skills/genex-getting-started/SKILL.md +5 -0
- package/templates/skills/genex-threejs-character-controller/SKILL.md +205 -0
- package/templates/skills/genex-threejs-character-controller/references/animations.md +235 -0
- package/templates/skills/genex-threejs-character-controller/references/tuning-and-presets.md +102 -0
- package/templates/skills/genex-threejs-character-controller/references/wiring.md +198 -0
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +126 -54
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +17 -11
- package/templates/skills/genex-threejs-physics-rapier/SKILL.md +128 -0
- package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +202 -0
- package/templates/skills/genex-threejs-physics-rapier/references/physics-setup.md +207 -0
- package/templates/skills/genex-threejs-skill-router/SKILL.md +3 -0
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +15 -7
- package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +110 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/car.md +162 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/drone.md +150 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +199 -0
|
@@ -47,6 +47,11 @@ Each has a focused skill with the exact loader code — `$genex-ai-model`,
|
|
|
47
47
|
|
|
48
48
|
## Publishing
|
|
49
49
|
|
|
50
|
+
Before your first `genex preview`, the play URL (`https://<slug>.genex.technology/`)
|
|
51
|
+
already serves a **standard placeholder world** — the owner's avatar in a small
|
|
52
|
+
playground, unrelated to your code. It is NOT in your repo; never treat it as a
|
|
53
|
+
starting point or try to modify it. Your first `preview` replaces it automatically.
|
|
54
|
+
|
|
50
55
|
`npx genex preview` deploys to your unlisted draft URL; `npx genex publish`
|
|
51
56
|
lists the game in the public gallery. When publishing, pick 1–3 gallery
|
|
52
57
|
categories from what you actually built — `games`, `assets`, `physics`,
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: genex-threejs-character-controller
|
|
3
|
+
description: Add a tuned physics character controller to a Genex Three.js game with `npx genex controller character` — dynamic-capsule movement (walk/run/jump, slopes, stairs, moving platforms), follow camera, keyboard + touch input, and animation binding. Use for any on-foot player or third-person movement, and whenever the user asks for ecctrl — this is that controller, ported to plain Three.js.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Genex Three.js Character Controller
|
|
7
|
+
|
|
8
|
+
## Run the command first — never hand-write the controller
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx genex controller character
|
|
12
|
+
npm i @dimforge/rapier3d-compat @pixiv/three-vrm # three is already in the scaffold
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The command vendors tested, tuned controller code into the game: TypeScript
|
|
16
|
+
modules into `src/controllers/` (including `character/vrm/` — VRM loading,
|
|
17
|
+
animation retargeting, capsule auto-fit, foot IK) and the 46-clip
|
|
18
|
+
`animation-library.glb` into `public/assets/`. It also writes the player's
|
|
19
|
+
avatar to `public/assets/avatar.vrm` — **your** avatar when you're signed in,
|
|
20
|
+
otherwise a bundled CC0 default (attribution in `src/controllers/NOTICE.md`).
|
|
21
|
+
The character plays as that VRM. The copied files are then owned by the game —
|
|
22
|
+
edit them freely; re-running skips existing files unless `--force`. Do not write
|
|
23
|
+
a character controller from scratch and do not swap in a kinematic-controller
|
|
24
|
+
tutorial: this one is a real dynamic body that pushes crates, rides moving
|
|
25
|
+
platforms, climbs stairs and slides on too-steep slopes out of the box.
|
|
26
|
+
|
|
27
|
+
## What you get
|
|
28
|
+
|
|
29
|
+
| Module (under `src/controllers/`) | Exports you use | Job |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| `shared/physics-world.ts` | `PhysicsWorld` | Rapier WASM init, fixed-timestep loop, body↔Object3D sync, collision events |
|
|
32
|
+
| `shared/colliders.ts` | `cuboidCollider`, `collidersFromObject`, … | colliders for level geometry and GLB props |
|
|
33
|
+
| `character/character-controller.ts` | `CharacterController` | the floating-capsule movement brain |
|
|
34
|
+
| `character/presets.ts` | `characterPresets` | six named tunings |
|
|
35
|
+
| `character/follow-camera.ts` | `FollowCamera` | orbit/zoom chase camera with collision pullback |
|
|
36
|
+
| `character/keyboard-input.ts` | `KeyboardInput` | WASD/arrows/Shift/Space/F state, no per-frame polling setup |
|
|
37
|
+
| `character/touch-joystick.ts` | `TouchJoystick`, `VirtualButton` | mobile controls |
|
|
38
|
+
| `character/character-animations.ts` | `CharacterAnimations` | animation state machine + fuzzy clip binding + `playOneShot` + procedural fallback |
|
|
39
|
+
| `character/vrm/*` | `loadVrm`, `retargetClips`, `capsuleFromModel`, `FootIK` | load the VRM avatar, retarget the 46 UAL clips onto its humanoid rig, auto-fit the capsule, ground the feet |
|
|
40
|
+
|
|
41
|
+
## Minimal wiring
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
|
45
|
+
import { PhysicsWorld } from "./controllers/shared/physics-world.ts";
|
|
46
|
+
import { CharacterController } from "./controllers/character/character-controller.ts";
|
|
47
|
+
import { CharacterAnimations } from "./controllers/character/character-animations.ts";
|
|
48
|
+
import { characterPresets } from "./controllers/character/presets.ts";
|
|
49
|
+
import { FollowCamera } from "./controllers/character/follow-camera.ts";
|
|
50
|
+
import { KeyboardInput } from "./controllers/character/keyboard-input.ts";
|
|
51
|
+
import { loadVrm } from "./controllers/character/vrm/vrm-loader.ts";
|
|
52
|
+
import { retargetClips } from "./controllers/character/vrm/vrm-retarget.ts";
|
|
53
|
+
import { capsuleFromModel } from "./controllers/character/vrm/capsule-fit.ts";
|
|
54
|
+
|
|
55
|
+
const physics = await PhysicsWorld.create(); // nothing RAPIER-related may run before this resolves
|
|
56
|
+
|
|
57
|
+
// Load the player's avatar + the animation library, then retarget onto the VRM.
|
|
58
|
+
const { scene: avatar, vrm } = await loadVrm("./assets/avatar.vrm");
|
|
59
|
+
const lib = await new GLTFLoader().loadAsync("./assets/animation-library.glb");
|
|
60
|
+
|
|
61
|
+
const fit = capsuleFromModel(avatar); // collider fits THIS avatar's bounds
|
|
62
|
+
const character = new CharacterController(physics.world, camera, {
|
|
63
|
+
...characterPresets["default"].options,
|
|
64
|
+
...fit,
|
|
65
|
+
position: { x: 0, y: 2, z: 0 },
|
|
66
|
+
userData: { controller: { excludeVehicleRay: true } }, // car wheels must never drive on the player
|
|
67
|
+
});
|
|
68
|
+
scene.add(character.root);
|
|
69
|
+
character.root.add(avatar); // parent the avatar under the character root
|
|
70
|
+
avatar.position.y = fit.modelOffsetY; // root = capsule CENTER; drop the model so feet touch the floor
|
|
71
|
+
physics.registerBody(character.body, character.root); // root now follows the body, interpolated
|
|
72
|
+
|
|
73
|
+
const anims = new CharacterAnimations(avatar, retargetClips(vrm, lib.scene, lib.animations));
|
|
74
|
+
addEventListener("pointerdown", () => anims.playOneShot("Punch_Jab")); // punch on click
|
|
75
|
+
|
|
76
|
+
const kb = new KeyboardInput();
|
|
77
|
+
const followCam = new FollowCamera(camera, {
|
|
78
|
+
domElement: renderer.domElement,
|
|
79
|
+
colliderMeshes: staticWallMeshes, // static environment ONLY — never the character mesh
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Fixed-substep phase: input + controller brain, BEFORE world.step().
|
|
83
|
+
physics.onBeforeStep(() => {
|
|
84
|
+
character.setMovement(kb.getCharacterMovement()); // send the COMPLETE intent every step
|
|
85
|
+
character.update(); // ignores any dt argument — uses the fixed world.timestep internally
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Render phase: step physics, then camera, then animations.
|
|
89
|
+
const pivot = new THREE.Vector3();
|
|
90
|
+
renderer.setAnimationLoop(() => {
|
|
91
|
+
const delta = clock.getDelta();
|
|
92
|
+
physics.step(delta); // runs the fixed substeps + world.step() + mesh sync
|
|
93
|
+
|
|
94
|
+
pivot.copy(character.currPos).addScaledVector(character.bodyYAxis, 0.5);
|
|
95
|
+
followCam.moveTo(pivot.x, pivot.y, pivot.z, true);
|
|
96
|
+
followCam.setUp(character.upAxis);
|
|
97
|
+
if (physics.stepsLastFrame > 0 && character.isOnPlatform) {
|
|
98
|
+
followCam.applyPlatformTurn(character.turnOnYQuat); // per-physics-step delta — gate on steps
|
|
99
|
+
}
|
|
100
|
+
followCam.update(delta);
|
|
101
|
+
|
|
102
|
+
anims.update(character, delta); // see the animations reference
|
|
103
|
+
vrm.update(delta); // REQUIRED — ticks the humanoid rig + spring bones
|
|
104
|
+
renderer.render(scene, camera);
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The loop contract is strict: the controller's `update()` runs once per fixed
|
|
109
|
+
physics substep **before** `world.step()` (that is what `onBeforeStep` gives
|
|
110
|
+
you), and camera + animations run once per **render** frame with the render
|
|
111
|
+
delta, after `physics.step(delta)`. Read
|
|
112
|
+
[references/wiring.md](references/wiring.md) for the full walkthrough — level
|
|
113
|
+
colliders, model placement, moving platforms, camera details, enter/exit
|
|
114
|
+
hooks, and disposal.
|
|
115
|
+
|
|
116
|
+
## Presets and tuning
|
|
117
|
+
|
|
118
|
+
Spread a preset into the options and override only what feels wrong:
|
|
119
|
+
`"default"`, `"heavy-body-reference"`, `"platformer-snappy"`, `"souls-heavy"`,
|
|
120
|
+
`"moon-bounce"` (needs world gravity `(0, -1.62, 0)`), `"ice-slide"`. Read
|
|
121
|
+
[references/tuning-and-presets.md](references/tuning-and-presets.md) for the
|
|
122
|
+
preset table with provenance, the density/spring scaling rule, and the
|
|
123
|
+
"user says X → tune Y" map. Two traps worth knowing up front:
|
|
124
|
+
|
|
125
|
+
- `slopeMaxAngle` defaults to `Math.PI / 2.5` (72°) — a 50° ramp is **walkable
|
|
126
|
+
out of the box**. Cap it (e.g. `Math.PI / 4`) if steep slopes should slide.
|
|
127
|
+
- The capsule ships with friction `-0.5` **on purpose** (grip is synthesized by
|
|
128
|
+
the controller). Do not "fix" it to a positive value.
|
|
129
|
+
|
|
130
|
+
## Animations
|
|
131
|
+
|
|
132
|
+
`CharacterAnimations` resolves seven locomotion states (IDLE / WALK / RUN /
|
|
133
|
+
JUMP_START / JUMP_IDLE / JUMP_FALL / JUMP_LAND) from the controller's live flags
|
|
134
|
+
and crossfades mixer actions. Every OTHER library clip — punches, sword swings,
|
|
135
|
+
pistol fire, spells, sit, dance, hit reactions — plays through
|
|
136
|
+
`anims.playOneShot("Punch_Jab")`, which layers over locomotion and returns to it
|
|
137
|
+
when done (punch-on-click is the default). Read
|
|
138
|
+
[references/animations.md](references/animations.md) for the VRM load + retarget
|
|
139
|
+
wiring, the **full 46-clip catalog** with genre hints, `playOneShot` options,
|
|
140
|
+
overrides, foot IK, and remote-player animation.
|
|
141
|
+
|
|
142
|
+
## Mobile: TouchJoystick + VirtualButton
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { TouchJoystick, VirtualButton } from "./controllers/character/touch-joystick.ts";
|
|
146
|
+
|
|
147
|
+
const joy = new TouchJoystick({ wrapperStyle: { left: "20px", bottom: "20px" } }); // position is REQUIRED
|
|
148
|
+
const btnJump = new VirtualButton({ label: "Jump", wrapperStyle: { right: "30px", bottom: "30px" } });
|
|
149
|
+
|
|
150
|
+
physics.onBeforeStep(() => {
|
|
151
|
+
character.setMovement({
|
|
152
|
+
...kb.getCharacterMovement(),
|
|
153
|
+
jump: kb.space || btnJump.pressed,
|
|
154
|
+
joystick: { x: joy.x, y: joy.y }, // non-zero joystick overrides the digital keys
|
|
155
|
+
});
|
|
156
|
+
character.update();
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Show them only on touch devices: `joy.setVisible(navigator.maxTouchPoints > 0)`.
|
|
161
|
+
Give the canvas `touch-action: none` so camera drags aren't hijacked by page
|
|
162
|
+
scrolling. Joystick deflection sets direction only — the controller normalizes
|
|
163
|
+
it, so half-deflection is not half-speed.
|
|
164
|
+
|
|
165
|
+
## Multiplayer rule (mandatory)
|
|
166
|
+
|
|
167
|
+
**The local player is physics-authoritative; remote players are interpolated
|
|
168
|
+
visuals only.** Exactly one `CharacterController` exists — yours. For every
|
|
169
|
+
remote player: create a plain mesh (or the same GLB), move it with the
|
|
170
|
+
interpolator from `$genex-threejs-multiplayer`, and **never** create a rigid
|
|
171
|
+
body, a `CharacterController`, or any physics for it. Simulating remote
|
|
172
|
+
players' physics locally guarantees divergence — every client would compute a
|
|
173
|
+
different world.
|
|
174
|
+
|
|
175
|
+
- Publish your own `currPos` + yaw on the fixed 10–20 Hz tick, not per frame.
|
|
176
|
+
- To animate remotes, sync the five animation booleans and feed them to a
|
|
177
|
+
per-remote `CharacterAnimations` — see the animations reference.
|
|
178
|
+
- Load `$genex-threejs-multiplayer` before writing any networking code; it is
|
|
179
|
+
mandatory for any 2+ player game.
|
|
180
|
+
|
|
181
|
+
## If the user asks for ecctrl
|
|
182
|
+
|
|
183
|
+
ecctrl is a React / React Three Fiber component; a Genex game is plain
|
|
184
|
+
Three.js with no React, so the package cannot run here. Tell the user plainly:
|
|
185
|
+
*"the library you named requires React, and this game is plain Three.js —
|
|
186
|
+
Genex ships the same controller, ported for your setup."* This vendored
|
|
187
|
+
controller **is** ecctrl's floating-capsule controller translated to plain
|
|
188
|
+
TypeScript classes (same physics model, same tuning options, same feel; see
|
|
189
|
+
`src/controllers/NOTICE.md`). Run `npx genex controller character` and carry
|
|
190
|
+
on — never add React or install the React package to satisfy the request.
|
|
191
|
+
|
|
192
|
+
## Known benign warning
|
|
193
|
+
|
|
194
|
+
`@dimforge/rapier3d-compat` logs `using deprecated parameters for the
|
|
195
|
+
initialization function` once at boot. It comes from the library's own
|
|
196
|
+
embedded WASM loader, is harmless, and cannot be fixed from user code — do not
|
|
197
|
+
spend time chasing it.
|
|
198
|
+
|
|
199
|
+
## Vehicles
|
|
200
|
+
|
|
201
|
+
For a drivable car or flyable drone — and character ↔ vehicle enter/exit —
|
|
202
|
+
run `npx genex controller car` / `npx genex controller drone` and load the
|
|
203
|
+
`$genex-threejs-vehicle-controllers` skill. The character side of enter/exit is
|
|
204
|
+
already built in: `character.park()`, `character.unpark(position, rotation)`
|
|
205
|
+
and the `isParked` getter (skip `character.update()` while parked).
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# Character animations
|
|
2
|
+
|
|
3
|
+
`CharacterAnimations` turns the controller's live flags into crossfaded
|
|
4
|
+
`THREE.AnimationMixer` playback: seven states, alias-based clip binding that
|
|
5
|
+
works with the bundled library, Mixamo exports, or arbitrary rigs, and a
|
|
6
|
+
procedural bob/lean fallback when a model has no usable clips at all.
|
|
7
|
+
|
|
8
|
+
## The bundled assets
|
|
9
|
+
|
|
10
|
+
`npx genex controller character` sets the game up to play as a **VRM avatar**:
|
|
11
|
+
|
|
12
|
+
- `public/assets/avatar.vrm` — the player's avatar (yours when signed in, else a
|
|
13
|
+
bundled CC0 default). Always present; always one path.
|
|
14
|
+
- `public/assets/animation-library.glb` (6.4 MB) — 46 clips on a shared
|
|
15
|
+
Quaternius rig (provenance in `src/controllers/NOTICE.md`).
|
|
16
|
+
|
|
17
|
+
VRM helpers live in `src/controllers/character/vrm/`. Install three-vrm once:
|
|
18
|
+
`npm i @pixiv/three-vrm`.
|
|
19
|
+
|
|
20
|
+
Load the avatar, retarget the library clips onto its humanoid rig, and auto-fit
|
|
21
|
+
the capsule — all with **relative** paths so the published game works under its
|
|
22
|
+
subpath:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
|
26
|
+
import { loadVrm } from "./controllers/character/vrm/vrm-loader.ts";
|
|
27
|
+
import { retargetClips } from "./controllers/character/vrm/vrm-retarget.ts";
|
|
28
|
+
import { capsuleFromModel } from "./controllers/character/vrm/capsule-fit.ts";
|
|
29
|
+
import { CharacterController } from "./controllers/character/character-controller.ts";
|
|
30
|
+
import { CharacterAnimations } from "./controllers/character/character-animations.ts";
|
|
31
|
+
import { characterPresets } from "./controllers/character/presets.ts";
|
|
32
|
+
|
|
33
|
+
const { scene, vrm } = await loadVrm("./assets/avatar.vrm");
|
|
34
|
+
const lib = await new GLTFLoader().loadAsync("./assets/animation-library.glb");
|
|
35
|
+
|
|
36
|
+
// capsuleFromModel derives the collider from the avatar's bounds — no manual
|
|
37
|
+
// per-avatar tuning even as heights/proportions vary across the library.
|
|
38
|
+
const fit = capsuleFromModel(scene);
|
|
39
|
+
const character = new CharacterController(physics.world, camera, {
|
|
40
|
+
...characterPresets["default"].options,
|
|
41
|
+
...fit,
|
|
42
|
+
position: { x: 0, y: 2, z: 0 },
|
|
43
|
+
});
|
|
44
|
+
character.root.add(scene);
|
|
45
|
+
scene.position.y = fit.modelOffsetY; // root = capsule CENTER; drop the model so feet touch the floor
|
|
46
|
+
|
|
47
|
+
// retargetClips maps the Quaternius rig onto the VRM's normalized humanoid rig
|
|
48
|
+
// (VRM 0.x and 1.0 alike); the result feeds CharacterAnimations unchanged.
|
|
49
|
+
const anims = new CharacterAnimations(scene, retargetClips(vrm, lib.scene, lib.animations));
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Per render frame, **after** `physics.step(delta)`:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
anims.update(character, delta); // the controller itself satisfies the snapshot type
|
|
56
|
+
vrm.update(delta); // REQUIRED — ticks the humanoid rig + spring bones
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`vrm.update(delta)` MUST run every frame, AFTER `anims.update`: it applies the
|
|
60
|
+
animated normalized pose onto the render mesh and advances spring bones (hair,
|
|
61
|
+
cloth). `anims.update` takes the RAW render delta — pause/slow-motion go through
|
|
62
|
+
`anims.setPaused(true)` / `anims.setTimeScale(0.5)` (fade durations stretch with
|
|
63
|
+
the time scale so slow motion doesn't pop).
|
|
64
|
+
|
|
65
|
+
### Foot IK (optional)
|
|
66
|
+
|
|
67
|
+
`vrm/foot-ik.ts` plants feet on uneven ground (no skating/floating on steps and
|
|
68
|
+
slopes). It's **opt-in** — locomotion and combat work without it; enable it once
|
|
69
|
+
the avatar's animations look right, injecting a ground query backed by rapier:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { FootIK } from "./controllers/character/vrm/foot-ik.ts";
|
|
73
|
+
const footIK = new FootIK(vrm, (foot) => {
|
|
74
|
+
const hit = physics.world.castRay(
|
|
75
|
+
new RAPIER.Ray({ x: foot.x, y: foot.y + 0.5, z: foot.z }, { x: 0, y: -1, z: 0 }),
|
|
76
|
+
1.5, true);
|
|
77
|
+
return hit ? foot.y + 0.5 - hit.timeOfImpact : null;
|
|
78
|
+
});
|
|
79
|
+
// each frame, after vrm.update(delta):
|
|
80
|
+
footIK.update(delta);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## States and default clips
|
|
84
|
+
|
|
85
|
+
The pure resolver (`resolveAnimationState`) maps controller flags to one of:
|
|
86
|
+
|
|
87
|
+
| State | Bundled clip | Notes |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| `IDLE` | `Idle_Loop` | starts playing immediately on construction |
|
|
90
|
+
| `WALK` | `Walk_Loop` | |
|
|
91
|
+
| `RUN` | `Jog_Fwd_Loop` | **the default run clip**; `Sprint_Loop` also binds via alias — force it with an override if the user wants an all-out sprint look |
|
|
92
|
+
| `JUMP_START` | `Jump_Start` | one-shot, played at 1.6× so it finishes inside the hop |
|
|
93
|
+
| `JUMP_IDLE` | `Jump_Loop` | airborne, moving up |
|
|
94
|
+
| `JUMP_FALL` | `Jump_Loop` | airborne, moving down (shares the clip — no restart mid-air) |
|
|
95
|
+
| `JUMP_LAND` | `Jump_Land` | one-shot |
|
|
96
|
+
|
|
97
|
+
## One-shot actions + the 46-clip catalog
|
|
98
|
+
|
|
99
|
+
The seven states above cover locomotion. **Every other clip** plays through
|
|
100
|
+
`anims.playOneShot(clipName, options?)`: it crossfades the clip over the current
|
|
101
|
+
motion, plays it once, then hands control back to the state machine. Returns
|
|
102
|
+
`false` if the clip name isn't in the set you passed to the constructor.
|
|
103
|
+
|
|
104
|
+
Punch-on-click is the controller default:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
let jab = true;
|
|
108
|
+
addEventListener("pointerdown", () => {
|
|
109
|
+
anims.playOneShot(jab ? "Punch_Jab" : "Punch_Cross");
|
|
110
|
+
jab = !jab;
|
|
111
|
+
// Raycast forward from the character; if it hits another player, YOUR game
|
|
112
|
+
// decides the reaction — e.g. remoteAnims.playOneShot("Hit_Chest").
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`options`: `fadeIn` (default 0.1 s), `timeScale`, `clamp` (hold the final pose —
|
|
117
|
+
for deaths), `onDone`.
|
|
118
|
+
|
|
119
|
+
Full catalog (clip → motion → reach for it when):
|
|
120
|
+
|
|
121
|
+
| Clip(s) | Motion | Use for |
|
|
122
|
+
| --- | --- | --- |
|
|
123
|
+
| `Punch_Jab` / `Punch_Cross` | quick / heavy punch | melee, click-to-attack (default) |
|
|
124
|
+
| `Punch_Enter` | raise fists | enter a fighting stance |
|
|
125
|
+
| `Hit_Chest` / `Hit_Head` | flinch | taking damage (multiplayer-friendly reactions) |
|
|
126
|
+
| `Death01` | collapse | death — pair with `clamp: true` |
|
|
127
|
+
| `Sword_Idle` / `Sword_Attack` / `Sword_Attack_RM` | ready / swing / swing+step | melee weapons (`_RM` = root motion, travels) |
|
|
128
|
+
| `Pistol_Idle_Loop` / `Pistol_Aim_Up` / `_Neutral` / `_Down` | aim poses | shooters — pick by camera pitch |
|
|
129
|
+
| `Pistol_Shoot` / `Pistol_Reload` | fire / reload | shooter actions |
|
|
130
|
+
| `Spell_Simple_Enter` / `_Idle_Loop` / `_Shoot` / `_Exit` | cast cycle | magic / RPG |
|
|
131
|
+
| `Roll` / `Roll_RM` | dodge roll | dodge (`_RM` travels) |
|
|
132
|
+
| `Interact` / `PickUp_Table` / `Fixing_Kneeling` | reach / pick up / kneel-work | pickups, levers, crafting |
|
|
133
|
+
| `Sitting_Enter` / `_Idle_Loop` / `_Talking_Loop` / `_Exit` | sit / sit-idle / chat / stand | seats, vehicles, dialogue |
|
|
134
|
+
| `Dance_Loop` / `Idle_Talking_Loop` / `Idle_Torch_Loop` | dance / gesture / torch idle | emotes, NPCs, ambience |
|
|
135
|
+
| `Push_Loop` / `Crouch_Idle_Loop` / `Crouch_Fwd_Loop` | push / crouch / crouch-walk | pushing, stealth |
|
|
136
|
+
| `Swim_Idle_Loop` / `Swim_Fwd_Loop` | tread / swim | water |
|
|
137
|
+
| `Walk_Formal_Loop` | stiff walk | override `WALK` for a formal gait |
|
|
138
|
+
| `Driving_Loop` | seated at a wheel | vehicles (see `$genex-threejs-vehicle-controllers`) |
|
|
139
|
+
| `A_TPose` | rest pose | reference only |
|
|
140
|
+
|
|
141
|
+
For looping poses that should **persist** (aiming, sitting, swimming) rather than
|
|
142
|
+
play once, drive them through the public `anims.mixer` escape hatch instead; the
|
|
143
|
+
`$genex-threejs-vehicle-controllers` skill shows the seated pattern.
|
|
144
|
+
|
|
145
|
+
## Binding arbitrary rigs (Mixamo included)
|
|
146
|
+
|
|
147
|
+
`buildClipMap(clips, overrides?)` resolves each state in priority order:
|
|
148
|
+
explicit override (exact, then case-insensitive) → library exact names →
|
|
149
|
+
case-insensitive → each alias as a case-insensitive **substring**, shortest
|
|
150
|
+
matching clip name wins (so `Walk_Loop` beats `Walk_Bwd_Loop`, and Mixamo's
|
|
151
|
+
`walking` beats `walking_backwards`). Aliases include `idle`, `walk`, `run`,
|
|
152
|
+
`jog`, `sprint`, `jump_start`, `takeoff`, `fall`, `land`, and a bare `jump`
|
|
153
|
+
catch-all so a rig whose only airborne clip is "Jumping" still binds all four
|
|
154
|
+
jump states. Unbound loop states chain (RUN↔WALK, JUMP_IDLE↔JUMP_FALL);
|
|
155
|
+
unbound one-shots stay silent so the previous loop keeps playing.
|
|
156
|
+
|
|
157
|
+
If a name refuses to bind, pass overrides:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
const anims = new CharacterAnimations(model, gltf.animations, {
|
|
161
|
+
clipMap: { RUN: "Sprint_Loop", JUMP_START: "MyTakeoff" },
|
|
162
|
+
onChange: (state) => { if (state === "JUMP_LAND") playLandSfx(); },
|
|
163
|
+
});
|
|
164
|
+
console.log(anims.clipMap); // inspect what actually bound, per state
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Rig mismatch guard
|
|
168
|
+
|
|
169
|
+
Clips whose tracks target bones the model doesn't have spam PropertyBinding
|
|
170
|
+
warnings. When mixing a custom model (e.g. from `$genex-ai-model` — note
|
|
171
|
+
generated props are usually rig-less) with the bundled library, filter first:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
const clips = libGltf.animations.filter((clip) =>
|
|
175
|
+
clip.tracks.every((track) => {
|
|
176
|
+
const { nodeName } = THREE.PropertyBinding.parseTrackName(track.name);
|
|
177
|
+
return nodeName !== undefined && model.getObjectByName(nodeName) !== undefined;
|
|
178
|
+
})
|
|
179
|
+
);
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Rig-less models: the procedural fallback
|
|
183
|
+
|
|
184
|
+
Pass an empty clip array (or clips that bind nothing) and the default
|
|
185
|
+
`fallback: "auto"` mode drives the model with a procedural walk/run bob, a
|
|
186
|
+
forward lean while moving, an air lean, and a landing dip — additive over the
|
|
187
|
+
model's transform at construction time, so any capsule, robot, or generated
|
|
188
|
+
prop reads as alive with zero animation work. `fallback: "procedural"` forces
|
|
189
|
+
it even when clips exist; `"none"` leaves the model static. Check
|
|
190
|
+
`anims.usingProceduralFallback` to see which path is live.
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
const anims = new CharacterAnimations(placeholderMesh, []); // procedural fallback kicks in
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Remote players (multiplayer)
|
|
197
|
+
|
|
198
|
+
The snapshot type is structural — **anything** with the five booleans works,
|
|
199
|
+
which is exactly what remote players need. Remote players have no physics and
|
|
200
|
+
no `CharacterController` (see the SKILL's multiplayer rule): sync the five
|
|
201
|
+
flags from the owner and feed them straight in.
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
// Sender (local player), on the 10-20 Hz tick — alongside position/yaw:
|
|
205
|
+
const flags = {
|
|
206
|
+
isOnGround: character.isOnGround,
|
|
207
|
+
isFalling: character.isFalling,
|
|
208
|
+
isMoving: character.isMoving,
|
|
209
|
+
runActive: character.runActive,
|
|
210
|
+
jumpActive: character.jumpActive,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// Receiver: one CharacterAnimations per remote model, fed the synced flags.
|
|
214
|
+
remoteAnims.update(remoteState.flags, delta);
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The mixer crossfades exactly as it does locally, so remote players animate
|
|
218
|
+
correctly without simulating anything. In v1 every player loads the same
|
|
219
|
+
`./assets/avatar.vrm` (the game owner's avatar), so remotes look like the owner —
|
|
220
|
+
load each remote through `loadVrm` + `retargetClips` just like the local one, and
|
|
221
|
+
call `vrm.update(delta)` per remote each frame. Relay one-shot events (punch, hit)
|
|
222
|
+
alongside the flags and call `remoteAnims.playOneShot("Hit_Chest")` on receipt.
|
|
223
|
+
Full networking patterns: `$genex-threejs-multiplayer`.
|
|
224
|
+
|
|
225
|
+
## Gotchas
|
|
226
|
+
|
|
227
|
+
- Call `anims.update` once per render frame, never inside
|
|
228
|
+
`physics.onBeforeStep`.
|
|
229
|
+
- The state machine reads **input-based** `isMoving` — a character shoved by
|
|
230
|
+
physics while the player is idle stays in IDLE by design (matches the feel
|
|
231
|
+
players expect).
|
|
232
|
+
- One-shots lock transitions until they finish (`JUMP_START` → the lock
|
|
233
|
+
releases into `JUMP_IDLE`); this is internal — don't try to manage it.
|
|
234
|
+
- `dispose()` stops all actions and releases the mixer listener; call it when
|
|
235
|
+
the character leaves the scene.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Presets and tuning
|
|
2
|
+
|
|
3
|
+
Every option has a tuned default — start from a preset and override only what
|
|
4
|
+
feels wrong. Spread the preset's `options` into the constructor:
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { characterPresets } from "./controllers/character/presets.ts";
|
|
8
|
+
|
|
9
|
+
const character = new CharacterController(world, camera, {
|
|
10
|
+
...characterPresets["platformer-snappy"].options,
|
|
11
|
+
maxRunVel: 8, // your overrides win
|
|
12
|
+
userData: { controller: { excludeVehicleRay: true } },
|
|
13
|
+
});
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Preset table
|
|
17
|
+
|
|
18
|
+
| Preset | Provenance | Assumed density | Feel |
|
|
19
|
+
| --- | --- | --- | --- |
|
|
20
|
+
| `default` | ported library defaults, verbatim | 1 | balanced third-person: walk 2 m/s, run 5 m/s, decisive jump, moderate grip |
|
|
21
|
+
| `heavy-body-reference` | ported demo tuning, verbatim | **200** | heavy body with proportionally stiff springs — the reference for the scaling rule below |
|
|
22
|
+
| `platformer-snappy` | Genex-authored | 1 | quick starts/stops, strong jump + heavy fall, extra air control, hold-to-run |
|
|
23
|
+
| `souls-heavy` | Genex-authored | 1 | weighty, committed movement; low deliberate jump; pronounced run lean |
|
|
24
|
+
| `moon-bounce` | Genex-authored | 1 | long floaty jumps — **requires world gravity `(0, -1.62, 0)`**; the preset does not set world gravity for you |
|
|
25
|
+
| `ice-slide` | Genex-authored | 1 | near-zero grip, wide sliding turns |
|
|
26
|
+
|
|
27
|
+
Every preset states the collider `density` it was tuned for
|
|
28
|
+
(`assumedDensity`, mirrored into `options.density`). That matters because of:
|
|
29
|
+
|
|
30
|
+
## The density/spring scaling rule
|
|
31
|
+
|
|
32
|
+
The float spring and auto-balance springs apply raw impulses, so they scale
|
|
33
|
+
roughly **linearly with body mass** (mass = density × capsule volume). Change
|
|
34
|
+
`density` (or the capsule size) and the springs must scale in the same
|
|
35
|
+
proportion or the character sinks/oscillates/faceplants. The two shipped
|
|
36
|
+
anchor points:
|
|
37
|
+
|
|
38
|
+
| Option | density 1 (`default`) | density 200 (`heavy-body-reference`) |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| `springK` | 80 | 6400 |
|
|
41
|
+
| `dampingC` | 6 | 860 |
|
|
42
|
+
| `autoBalanceSpringK` | 0.5 | 50 |
|
|
43
|
+
| `autoBalanceDampingC` | 0.03 | 3 |
|
|
44
|
+
| `autoBalanceSpringOnY` | 0.08 | 8 |
|
|
45
|
+
| `autoBalanceDampingOnY` | 0.006 | 0.76 |
|
|
46
|
+
|
|
47
|
+
Recipe for "make the character feel heavier": raise `density`, multiply those
|
|
48
|
+
six constants by the density ratio as a starting estimate (the reference
|
|
49
|
+
values are hand-tuned near that line, not exactly on it), then fine-tune —
|
|
50
|
+
pogo bounce means `dampingC` too low, sticky landings too high, slow-motion
|
|
51
|
+
tip-overs mean the `autoBalance*` pair is too soft.
|
|
52
|
+
|
|
53
|
+
## "User says X → tune Y" map
|
|
54
|
+
|
|
55
|
+
| The user says | Change |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| "it's slippery / skates around" | raise `slideGripFactor` (default 0.5; `ice-slide` uses 0.05) |
|
|
58
|
+
| "falling feels floaty" | raise `fallingGravityScale` (default 3) |
|
|
59
|
+
| "jump too weak / too strong" | `jumpVel` (default 5 m/s); `jumpDuration` (default 0.1 s) stretches the takeoff window |
|
|
60
|
+
| "it walks up cliffs / slopes that should slide" | lower `slopeMaxAngle` — **default is `Math.PI / 2.5` = 72°, so a 50° ramp is walkable out of the box**; `Math.PI / 4` makes 45°+ slide |
|
|
61
|
+
| "should climb steeper slopes" | raise `slopeMaxAngle` |
|
|
62
|
+
| "feels heavier / like a tank" | `souls-heavy` preset, or raise `density` + apply the spring scaling rule above |
|
|
63
|
+
| "sluggish to start / stop" | raise `accDeltaTime` / `decDeltaTime` (responsiveness in (0, 1]; default 0.2 — higher is snappier) |
|
|
64
|
+
| "drifts sideways through turns" | raise `rejectVelFactor` toward 1 (default 1; `ice-slide` lowers it to 0.2) |
|
|
65
|
+
| "too slow / too fast" | `maxWalkVel` (default 2) / `maxRunVel` (default 5) |
|
|
66
|
+
| "run should be hold, not toggle" | `enableToggleRun: false` (default true = Shift toggles) |
|
|
67
|
+
| "no control in the air" | raise `airDragFactor` (default 0.1) |
|
|
68
|
+
| "falls too fast at terminal velocity" | `fallingMaxVel` (default 20 m/s) |
|
|
69
|
+
| "leans too much when running" | lower `moveImpulsePointOffset` (default 0.5; 0 = no lean) |
|
|
70
|
+
| "wobbles / tips over" | raise `autoBalanceSpringK` + `autoBalanceDampingC` |
|
|
71
|
+
| "turns to face direction too slowly" | raise `autoBalanceSpringOnY` |
|
|
72
|
+
| "grounded flag flickers on stairs / ledges" | raise `rayHitForgiveness` (default 0.28) |
|
|
73
|
+
| "bounces on landing (pogo)" | raise `dampingC`; "sticks to the ground on landing" → lower it |
|
|
74
|
+
| "hovers too high / feet in the floor" | `floatHeight` (default 0.2) — and re-check the model's foot offset (wiring reference) |
|
|
75
|
+
| "should always face the camera (strafe/shooter)" | `lockForward: true`, or `setLockForward(true)` at runtime |
|
|
76
|
+
| "jump should push off slopes" | `slopeJumpFactor` (default 0 = straight up, 1 = off the slope normal) |
|
|
77
|
+
| "moon / low gravity" | `moon-bounce` preset **plus** `PhysicsWorld.create({ gravity: [0, -1.62, 0] })` |
|
|
78
|
+
| "camera feels laggy / rubber-bandy" | `FollowCamera` `smoothTime` (0.05 snappy → 0.25 cinematic) |
|
|
79
|
+
| "camera clips through walls" | add the static level meshes to `FollowCamera` `colliderMeshes` |
|
|
80
|
+
|
|
81
|
+
## Sizing a different character
|
|
82
|
+
|
|
83
|
+
`capsuleHalfHeight` (default 0.3) and `capsuleRadius` (default 0.3) define the
|
|
84
|
+
collider: total capsule height = `2 * (capsuleHalfHeight + capsuleRadius)` =
|
|
85
|
+
1.2 by default, floating `floatHeight` above the ground. For a bigger
|
|
86
|
+
character scale both, remember the ground-query defaults derive from them
|
|
87
|
+
(`rayLength = capsuleRadius + 1`, `rayRadius = capsuleRadius / 2`,
|
|
88
|
+
`rayOriginOffset = -capsuleHalfHeight`), and re-tune the springs — a bigger
|
|
89
|
+
capsule is a heavier body at the same density.
|
|
90
|
+
|
|
91
|
+
## Do-not-touch list
|
|
92
|
+
|
|
93
|
+
- `friction: -0.5` on the capsule is intentional (negative averages against
|
|
94
|
+
the ground and keeps the capsule from grabbing walls); traction comes from
|
|
95
|
+
the controller's own grip model, not collider friction.
|
|
96
|
+
- Ground detection: `"shapeCast"` (default) is forgiving on stairs and ledge
|
|
97
|
+
edges; `"rayCast"` is cheaper and stricter. Switch at runtime with
|
|
98
|
+
`setGroundDetection(...)` — don't hand-roll a third scheme.
|
|
99
|
+
- Platform behavior (`followPlatform`, `applyCounterMass`,
|
|
100
|
+
`applyCounterJumpImp`, `applyCounterMoveImp`) defaults to physically-honest
|
|
101
|
+
and already handles moving/rotating platforms; only touch these for
|
|
102
|
+
deliberate arcade effects.
|