sindicate 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -2
- package/docs/api/README.md +28 -0
- package/docs/api/anim.md +125 -0
- package/docs/api/assets.md +208 -0
- package/docs/api/collision.md +101 -0
- package/docs/api/fbxloader.md +50 -0
- package/docs/api/grass.md +103 -0
- package/docs/api/input.md +164 -0
- package/docs/api/quality.md +159 -0
- package/docs/api/renderer.md +115 -0
- package/docs/api/retarget.md +149 -0
- package/docs/api/shatter.md +121 -0
- package/docs/api/utils-decals.md +188 -0
- package/docs/api/water.md +143 -0
- package/docs/api/wind-weather-uniforms.md +105 -0
- package/docs/contracts/render.md +32 -0
- package/docs/contracts/time-feel.md +52 -0
- package/package.json +1 -1
- package/src/core/anim.js +184 -0
- package/src/core/assets.js +596 -0
- package/src/core/engine.js +284 -0
- package/src/core/input/bindings.js +30 -0
- package/src/core/input/gamepad.js +92 -0
- package/src/core/input/index.js +62 -0
- package/src/core/input/keyboard.js +16 -0
- package/src/core/input/mouse.js +48 -0
- package/src/core/quality.js +5 -1
- package/src/core/renderer.js +104 -0
- package/src/core/retarget.js +282 -0
- package/src/index.js +19 -2
- package/src/systems/aim.js +114 -0
- package/src/systems/campfire.js +150 -0
- package/src/systems/climbing.js +136 -0
- package/src/systems/deadeye.js +332 -0
- package/src/systems/encounters.js +123 -0
- package/src/systems/entity.js +524 -0
- package/src/systems/fistCurl.js +38 -0
- package/src/systems/footsteps.js +161 -0
- package/src/systems/glass.js +67 -0
- package/src/systems/herd.js +277 -0
- package/src/systems/horse.js +361 -0
- package/src/systems/mountedTraveller.js +518 -0
- package/src/systems/nav.js +343 -0
- package/src/systems/npc.js +880 -0
- package/src/systems/pathFollow.js +107 -0
- package/src/systems/platform.js +484 -0
- package/src/systems/riding.js +580 -0
- package/src/systems/seatedRider.js +396 -0
- package/src/systems/skybirds.js +129 -0
- package/src/systems/trainCamera.js +414 -0
- package/src/systems/traveller.js +254 -0
- package/src/systems/tumbleweeds.js +92 -0
- package/src/systems/vat.js +327 -0
- package/src/systems/vfx.js +472 -0
- package/src/world/blobShadows.js +109 -0
- package/src/world/clouds.js +61 -0
- package/src/world/flora.js +87 -0
- package/src/world/footprints.js +117 -0
- package/src/world/lampField.js +58 -0
- package/src/world/lampGlow.js +92 -0
- package/src/world/scatter.js +1077 -0
- package/src/world/sky.js +363 -0
- package/src/world/tileManager.js +197 -0
- package/src/world/walkHumps.js +74 -0
- package/src/world/weather.js +312 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Wind & Weather Uniforms
|
|
2
|
+
|
|
3
|
+
Modules: `src/world/wind.js`, `src/world/weatherUniforms.js`
|
|
4
|
+
Both are re-exported from the engine root (`src/index.js`).
|
|
5
|
+
|
|
6
|
+
Together these two files are the single source of weather truth for the whole scene. `Wind` is a global wind simulation ticked once per frame on the CPU; `weatherUniforms.js` is a set of module-level TSL uniform singletons that carry the current weather/sky state into every shader that opts in. Everything that should lean on the weather reads from here instead of rolling its own — CPU consumers (e.g. tumbleweeds) read `wind.vx/vz` directly, GPU consumers (grass sway, rain slant, water tint, ground wetness) read the shared uniforms.
|
|
7
|
+
|
|
8
|
+
## Why uniforms decouple build order
|
|
9
|
+
|
|
10
|
+
The uniforms are **module-level singletons** created at import time. Terrain, grass, water, and the Weather/sky systems may be constructed in any order — some materials are built before the weather system exists, some after — but because every one of them imports the *same* uniform node objects, they all reference identical GPU state no matter who was built first. The writer (Weather, sky, `Wind`) just mutates `.value` each frame and every material that baked the node into its shader graph sees the change. No registration step, no "pass the weather system into the terrain constructor" plumbing, no ordering constraint.
|
|
11
|
+
|
|
12
|
+
## Exports — `world/wind.js`
|
|
13
|
+
|
|
14
|
+
### `class Wind`
|
|
15
|
+
|
|
16
|
+
One slowly-wandering heading plus a gusting strength. Deterministic layered sines drive both the drift and the gusts — smooth, no per-frame RNG jerks, and identical after a reload.
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
const wind = new Wind(); // publishes initial state to uWindDir/uWindStr immediately
|
|
20
|
+
wind.update(dt); // call once per frame; dt in seconds
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**`constructor()`** — initializes state and immediately publishes to the shared uniforms (so shaders see sane wind even before the first `update`).
|
|
24
|
+
|
|
25
|
+
**`update(dt)`** — advances the internal clock, recomputes heading/gust/speed, republishes. Call exactly once per frame.
|
|
26
|
+
|
|
27
|
+
Instance fields (all readable; treat as read-only outside the class):
|
|
28
|
+
|
|
29
|
+
| Field | Meaning |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `t` | accumulated sim time (seconds) |
|
|
32
|
+
| `baseAngle` | the PREVAILING heading, fixed at `0.7` rad (~SE-ish); the wind only veers gently around it |
|
|
33
|
+
| `angle` | current heading the wind blows **toward** — radians on the ground plane, `atan2(z, x)` convention. Oscillates `baseAngle ± ~0.47` rad (~±27°) |
|
|
34
|
+
| `base` | mean speed, `1.6` m/s — a light breeze for the CPU consumers, not a gale |
|
|
35
|
+
| `gust` | current gust factor, swells and lulls between ~`0.12` and ~`1.05` |
|
|
36
|
+
| `speed` | current speed, `base * (0.5 + 0.85 * gust)` ≈ 0.96–2.36 m/s |
|
|
37
|
+
| `x`, `z` | unit heading components (`cos(angle)`, `sin(angle)`) |
|
|
38
|
+
| `vx`, `vz` | velocity in m/s (`x * speed`, `z * speed`) — what CPU consumers integrate against |
|
|
39
|
+
|
|
40
|
+
**`_publish()`** *(private)* — copies `x/z` into `uWindDir` and `gust` into `uWindStr`. Called from the constructor and every `update`; you never call it yourself.
|
|
41
|
+
|
|
42
|
+
## Exports — `world/weatherUniforms.js`
|
|
43
|
+
|
|
44
|
+
Every export is a `uniform(...)` node from `three/tsl` (three/webgpu build). Writers mutate `.value` on the CPU; readers compose the node into TSL material graphs.
|
|
45
|
+
|
|
46
|
+
| Uniform | Initial value | Range / meaning | Written by | Read by |
|
|
47
|
+
|---|---|---|---|---|
|
|
48
|
+
| `uWet` | `0` | 0 dry … 1 soaked — rain darkens the ground | Weather system, **eased** each frame so the ground soaks/dries gradually | `grass.js` (blade colour darkens toward ×0.7 when soaked); terrain ground darkening, puddles, and sheen (per source comments — host-game side) |
|
|
49
|
+
| `uCover` | `0` | 0 bare … 1 snow-covered | Weather system, eased so snow builds/melts gradually | `grass.js` (whitens blade tips toward `vec3(0.95, 0.96, 1.0)`); up-facing terrain whitening (host-game side) |
|
|
50
|
+
| `uDayness` | `1` | 0 night … 1 day | `sky.js` (host-game side) | UNLIT baked-albedo materials (impostors) — dims them so they stop glowing at night |
|
|
51
|
+
| `uSkyCol` | `Color(0x7fb2e5)` | current sky-phase zenith colour | `sky.js` | `water.js` — underwater inscatter tint, sky-reflection gradient, and a luminance clamp so water doesn't glow at dusk/night |
|
|
52
|
+
| `uHorizonCol` | `Color(0xdfe9d8)` | current horizon colour | `sky.js` | `water.js` reflections — water tracks day/dusk/night for free |
|
|
53
|
+
| `uSunDir` | `Vector3(0.3, 0.8, 0.3)` | current key-light direction (sun by day, **moon by night**) | sky/lighting (host-game side) | `grass.js` backlight (`dot(viewDir, uSunDir)^4` rim on blade tips), etc. |
|
|
54
|
+
| `uRain` | `0` | 0 dry … 1 downpour — the rain **falling right now** | Weather system | splash effects (host-game side). See gotcha below — this is deliberately *not* `uWet` |
|
|
55
|
+
| `uRainT` | `0` | seconds, running **while it rains** — the ground's rain clock | Weather system | puddle ripple rings on the ground, so they beat in time with the rain actually falling |
|
|
56
|
+
| `uCloudiness` | `0` | 0 fair … 1 storm deck | `weather.js` — **leads** the rain/snow | sky/cloud rendering, so the sky thickens *before* precipitation and clears after |
|
|
57
|
+
| `uWindDir` | `Vector2(1, 0)` | unit heading the wind blows **toward**, on the ground plane (XZ) | `world/wind.js`, every frame | `grass.js` sway direction + gust-front advection; rain slant (host-game side) |
|
|
58
|
+
| `uWindStr` | `0.4` | 0 calm … ~1 gusting | `world/wind.js`, every frame | `grass.js` gust amplitude; rain slant scaling. CPU consumers (tumbleweeds) read `wind.vx/vz` instead |
|
|
59
|
+
|
|
60
|
+
In this repository the in-engine importers are `wind.js` (writer), `grass.js`, and `water.js`. The Weather/sky/terrain writers and readers named in the source comments live host-game side (the systems ported onto the engine); the whole point of the singleton design is that they wire up to these exact nodes whenever they're built.
|
|
61
|
+
|
|
62
|
+
## Usage example
|
|
63
|
+
|
|
64
|
+
A frame loop driving the wind, a CPU consumer riding it, and a custom TSL material opting into the shared weather:
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
import * as THREE from 'three/webgpu';
|
|
68
|
+
import { mix, float } from 'three/tsl';
|
|
69
|
+
import { Wind, uWet, uWindDir, uWindStr } from 'sindicate';
|
|
70
|
+
|
|
71
|
+
const wind = new Wind(); // uniforms are already valid after this line
|
|
72
|
+
|
|
73
|
+
// A custom material that darkens when the ground is soaked and shivers with the
|
|
74
|
+
// global gusts — same weather every other material sees, regardless of when
|
|
75
|
+
// this material was built relative to the Weather system.
|
|
76
|
+
const mat = new THREE.MeshStandardNodeMaterial();
|
|
77
|
+
mat.colorNode = baseAlbedo.mul(mix(float(1.0), float(0.7), uWet));
|
|
78
|
+
// ...positionNode can offset along uWindDir scaled by uWindStr for sway...
|
|
79
|
+
|
|
80
|
+
function tick(dt) {
|
|
81
|
+
wind.update(dt); // once per frame, before consumers integrate
|
|
82
|
+
|
|
83
|
+
// CPU consumer: roll a prop downwind
|
|
84
|
+
crate.position.x += wind.vx * dt * 0.2;
|
|
85
|
+
crate.position.z += wind.vz * dt * 0.2;
|
|
86
|
+
|
|
87
|
+
// Host-game weather system easing a shared value (shaders pick it up next draw)
|
|
88
|
+
uWet.value += (stormTargetWet - uWet.value) * Math.min(1, dt * 0.3);
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Gotchas
|
|
93
|
+
|
|
94
|
+
- **Veer, don't circle (bug history).** `Wind.update` sets the heading *directly* as prevailing ± a bounded oscillation (~±27°). The old code *integrated* sines (`angle += … * dt`); the low-frequency term summed to several radians over time and swept the wind all the way around the compass ("pushed things in a 360, weird"). If you touch the heading math, keep it a bounded function of `t`, never an accumulation.
|
|
95
|
+
- **`uRain` is not `uWet`, and the difference is the whole point.** The ground stays dark and slick for a good while after a storm passes (`uWet` eases down slowly), but the moment the rain stops, drops stop landing (`uRain` drops). Splashes read `uRain`; puddles and sheen read `uWet`. Don't collapse them into one value.
|
|
96
|
+
- **Why `uRainT` exists at all.** The Weather system had a `uTime` of its own, but it was private to the rain particles — the ground could not see it, so the puddle rings could not move. `uRainT` is the shared rain clock: it only advances while it rains, so ground ripples beat in time with the falling rain.
|
|
97
|
+
- **`uCloudiness` leads precipitation.** The weather system raises cloudiness *before* rain/snow starts and clears it after they stop, so the sky thickens ahead of a storm. Preserve that lead if you write your own weather driver.
|
|
98
|
+
- **Wind speed is tuned, not physical.** `base = 1.6` m/s is deliberately a light breeze — at higher values the tumbleweeds "rolled too fast". Retune consumers, not the shared wind, if something moves at the wrong speed.
|
|
99
|
+
- **`angle` is the heading the wind blows TOWARD** (radians on the XZ ground plane, `atan2(z, x)`), and `uWindDir` follows the same convention. Note `uWindDir` is a `Vector2` where `.y` holds world **Z** — grass uses `uWindDir.y` for its Z sway.
|
|
100
|
+
- **`uWindStr` publishes the raw gust factor** (`wind.gust`, 0.12–1.05), not `wind.speed`. Shader consumers scale it themselves; CPU consumers wanting real velocity must use `wind.vx/vz`.
|
|
101
|
+
- **Deterministic on purpose.** The wind is layered sines of `t` with no RNG — smooth (no per-frame jerks) and identical after a reload. Don't add randomness to it.
|
|
102
|
+
- **Tick once, early.** Call `wind.update(dt)` exactly once per frame, before anything integrates against `vx/vz`, or CPU and GPU consumers will see different wind for that frame. The constructor publishes too, so uniforms are never stale-default even before the first update.
|
|
103
|
+
- **Ease the weather uniform writes.** `uWet`/`uCover` are meant to be eased toward targets each frame (soak/dry, build/melt), not snapped — materials render whatever `.value` holds, so a snap is a visible pop.
|
|
104
|
+
- **`uSunDir` is the key light, not literally the sun** — it points at the moon at night. Grass backlight and similar effects rely on it always being the current key light.
|
|
105
|
+
- **Water clamps `uSkyCol` luminance** (floor 0.22) so lit water doesn't go pitch black when the sky colour goes near-black at night, and doesn't glow white at dusk — keep sky colours flowing through `uSkyCol`/`uHorizonCol` rather than baking them into water.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Contract: render & scene
|
|
2
|
+
|
|
3
|
+
**Status:** draft (step 3 — extraction from western main.js in progress).
|
|
4
|
+
|
|
5
|
+
## The facet
|
|
6
|
+
|
|
7
|
+
- `engine.render.scene` — attach/detach root for game objects (systems stop touching a raw `game.scene`).
|
|
8
|
+
- `engine.render.camera` — read access. Writing the camera happens ONLY in the camera-overrides phase (time-feel contract §7); anything else is an ordering bug by definition.
|
|
9
|
+
- `engine.render.frustum` — the per-frame culling frustum, built ONCE per unpaused frame after camera-overrides. **May be null; null means "don't cull".** Consumers must treat null as visible — the engine nulls it deliberately when a stale frustum would mis-cull (e.g. a hand-ticked NPC during a paused dialogue whose two-shot faces away from last frame's camera).
|
|
10
|
+
- `engine.render.project(v3, out2)` — world→screen for HUD nameplates/markers (UI never does its own matrix math).
|
|
11
|
+
- `engine.render.post` — the PostProcessing instance; `post.gradeAmount` is the game's one live dial (renderer API doc).
|
|
12
|
+
|
|
13
|
+
## The single render path
|
|
14
|
+
|
|
15
|
+
`renderFrame()` is the ONE call that puts pixels on screen — shared by the boot warm-up and the loop tick. Never add a second render call: every code path that renders must pay the same pipeline state, or the first frame through the "other" path compiles pipelines mid-play.
|
|
16
|
+
|
|
17
|
+
## Exposure write-gating
|
|
18
|
+
|
|
19
|
+
`renderer.toneMappingExposure` (and any renderer-level uniform-adjacent state) is written **only when the value actually changes**. Redundant writes are not free on WebGPU — the gate lives engine-side so games can "set" exposure every frame safely.
|
|
20
|
+
|
|
21
|
+
## Warm-up phases (PERF LAW, mechanical)
|
|
22
|
+
|
|
23
|
+
Boot runs, behind the loading screen, in order:
|
|
24
|
+
1. `warmTextures()` — GPU pre-upload of every cached texture (kills ~75ms first-sight upload hitches).
|
|
25
|
+
2. `warmRenderHidden()` / `warmUpFX()` — force-visible + `compileAsync` every hidden, culled, or count-0 pipeline (pooled VFX, UI-triggered materials) so NO pipeline compiles after boot.
|
|
26
|
+
3. The 360° settle sweep — the camera sweeps a full turn rendering via `renderFrame()`, initializing per-object GPU state along every bearing.
|
|
27
|
+
|
|
28
|
+
A dev-mode watchdog flags any pipeline compile after warm-up completes, with a stack — that's a spawned-uncached asset or a lazily-built material, and it WILL become a multi-second WebGPU freeze on a player's machine.
|
|
29
|
+
|
|
30
|
+
## Freeze profiler
|
|
31
|
+
|
|
32
|
+
The engine stamps phase timings each frame into a ring buffer; a frame over budget dumps the per-phase breakdown (and, with the dev plugin, sinks JSONL to disk). Games get it for free — it exists because "the game hitched once on my machine" is otherwise undebuggable.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Contract: time & feel
|
|
2
|
+
|
|
3
|
+
**Status:** draft (step 3 — extraction from western main.js in progress). Field names final, wiring lands with the Engine base.
|
|
4
|
+
|
|
5
|
+
## The two clocks
|
|
6
|
+
|
|
7
|
+
Every frame has TWO dt values, and using the wrong one is a shipped-bug class:
|
|
8
|
+
|
|
9
|
+
- **`rawDt`** — wall-clock frame time, never scaled. Consumers: camera look, aim IK (aiming stays crisp inside slow-mo), the fps meter (scaled dt inflates it during hit-stop), and any *meter that must drain in real time* (a slow-mo meter timed in slow-mo drains 3× slower the instant it activates — the engine's slow-mo system runs on rawDt for exactly this reason).
|
|
10
|
+
- **`dt`** — the world dt: `rawDt × Π(timeScale contributions)`, clamped at 0.1s. Drives mixers, physics, AI, projectiles, VFX, game-time timers.
|
|
11
|
+
|
|
12
|
+
## timeScale contributions (the multiplier chain)
|
|
13
|
+
|
|
14
|
+
Systems never write loop fields. They register contributors; the engine multiplies them in registration order each frame *after* the pre-scale phase:
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
engine.time.addTimeScale(() => (hitstopActive ? 0.12 : 1)) // combat's hit-stop
|
|
18
|
+
engine.time.addTimeScale(() => slowMo.scale) // Dead-Eye-style slow-mo
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- Contributors run every frame; return 1 when inactive.
|
|
22
|
+
- Systems that *decide* the frame's scale (e.g. slow-mo reading its meter) update in the **pre-scale phase** on rawDt, so the scale they return lands on THIS frame, not the next.
|
|
23
|
+
- `engine.time.worldDtScale` exposes the product (read-only; the dev drive-bus asserts on it).
|
|
24
|
+
|
|
25
|
+
## Game-time timers
|
|
26
|
+
|
|
27
|
+
`engine.time.after(seconds, fn)` — one-shots that tick on **world dt inside the unpaused guard**: they freeze with the pause menu (a pause can never eat a death-beat landing) and stretch under slow-mo. Not setTimeout. Errors in `fn` are caught and logged, never thrown into the loop.
|
|
28
|
+
|
|
29
|
+
`engine.time.playSeconds` — accumulated unpaused world time (save-slot playtime readout).
|
|
30
|
+
|
|
31
|
+
## Feel write APIs
|
|
32
|
+
|
|
33
|
+
- `engine.feel.hitstop(seconds)` — refreshes the hit-stop window (max with current, never additive stacking).
|
|
34
|
+
- `engine.feel.shake(strength)` — camera shake impulse, consumed by the camera phase.
|
|
35
|
+
|
|
36
|
+
## Frame phases (execution order is the contract)
|
|
37
|
+
|
|
38
|
+
1. **frame-start** — clock advance, input `pollGamepad(ui.anyPanelOpen)`.
|
|
39
|
+
2. **pre-scale** *(rawDt)* — systems that produce this frame's timeScale.
|
|
40
|
+
3. **scale** — multiplier chain → world dt.
|
|
41
|
+
4. **ui-modal** — global keys, panel nav, modal key routing (see ui contract). May consume input.
|
|
42
|
+
5. **sim** *(world dt, skipped when paused)* — timers, world update, then game-registered sim hooks in registration order.
|
|
43
|
+
6. **actors** — player update (writes the follow camera), then game actor hooks. Runs even paused (movement is pause-gated inside the player; a hand-ticked NPC keeps breathing during dialogue).
|
|
44
|
+
7. **camera-overrides** — ordered, **last writer wins**, after everything that can move a platform or a vehicle seat: dialogue two-shot → scripted scenes → cinematics (each registered with an order key). The engine builds the culling frustum AFTER this phase, once, when unpaused (`engine.render.frustum`; null while a stale frustum would mis-cull — see render contract).
|
|
45
|
+
8. **late-sim** *(unpaused)* — combat resolution, missions, crowd systems, ambient ticks.
|
|
46
|
+
9. **render** — exposure write-gating, `renderFrame()` (the ONE render path shared by boot warm-up and the loop), freeze-profiler phase stamps.
|
|
47
|
+
|
|
48
|
+
The pause predicate is supplied by the ui facet (`ui.anyPanelOpen`) and holds phases 5 and 8; 6, 7 and 9 always run.
|
|
49
|
+
|
|
50
|
+
## Why this shape
|
|
51
|
+
|
|
52
|
+
Western's main.js encoded all of the above as a single 400-line closure with ordering comments ("It runs HERE — not one line earlier"). The phases make the ordering *structural*: a system registered in the wrong phase is a visible design error, not a subtle frame-lag bug discovered in play.
|
package/package.json
CHANGED
package/src/core/anim.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Character animation state machine over THREE.AnimationMixer,
|
|
2
|
+
// with automatic retargeting per character rig.
|
|
3
|
+
import * as THREE from 'three/webgpu';
|
|
4
|
+
import { buildRetargetContext, retargetClip } from './retarget.js';
|
|
5
|
+
// Per-clip timing (start/speed/end trims) and the clip->source-rig table are GAME
|
|
6
|
+
// DATA, not engine code — a game registers them once at boot:
|
|
7
|
+
// setClipDefaults({ timing: CLIP_TIMING, rigs: CLIP_RIGS })
|
|
8
|
+
// Unregistered, clips play untrimmed and every Animator needs explicit clipRigs.
|
|
9
|
+
let CLIP_TIMING = {};
|
|
10
|
+
let CLIP_RIGS = null;
|
|
11
|
+
export function setClipDefaults({ timing = null, rigs = null } = {}) {
|
|
12
|
+
if (timing) CLIP_TIMING = timing;
|
|
13
|
+
if (rigs) CLIP_RIGS = rigs;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class Animator {
|
|
17
|
+
get needsRetarget() { return true; } // RawAnimator says no (see the bottom of this file)
|
|
18
|
+
|
|
19
|
+
// clipRigs: optional { clipName -> sourceRigKey }. A clip is authored against ONE
|
|
20
|
+
// skeleton's bind pose; clips from a foreign pack (different bind, same bone names)
|
|
21
|
+
// must retarget from THAT bind or they play folded in half. One ctx per source rig.
|
|
22
|
+
constructor(root, clips, clipRigs = null) {
|
|
23
|
+
this.mixer = new THREE.AnimationMixer(root);
|
|
24
|
+
this.root = root;
|
|
25
|
+
this.clipRigs = clipRigs ?? CLIP_RIGS;
|
|
26
|
+
this._ctxByRig = new Map();
|
|
27
|
+
// A RawAnimator's clips target the model's OWN skeleton, so it needs no retarget context —
|
|
28
|
+
// and building one THREW if no source rig had been registered yet. In the game that was
|
|
29
|
+
// invisible (the player registers the Synty rig long before a horse loads); anywhere else —
|
|
30
|
+
// a bench that only wants a horse — it was a hard crash on construction. Ask, don't assume.
|
|
31
|
+
this.ctx = this.needsRetarget ? this._ctxFor(null) : null; // (other code reads .ctx)
|
|
32
|
+
this.actions = {};
|
|
33
|
+
this.current = null;
|
|
34
|
+
this.currentName = null;
|
|
35
|
+
this._overlay = null;
|
|
36
|
+
for (const [name, clip] of Object.entries(clips)) this.register(name, clip);
|
|
37
|
+
|
|
38
|
+
// completion callbacks are PER-ACTION (action._onceDone), not a shared slot: with a
|
|
39
|
+
// single slot, any play({once}) stomped the PREVIOUS one-shot's pending callback —
|
|
40
|
+
// e.g. a bow release during a dodge wiped the dodge's busy=false and soft-locked
|
|
41
|
+
// the player. Per-action, each one-shot can only ever replace its own callback.
|
|
42
|
+
this.mixer.addEventListener('finished', (e) => {
|
|
43
|
+
const cb = e.action._onceDone;
|
|
44
|
+
e.action._onceDone = null;
|
|
45
|
+
cb?.();
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_ctxFor(rigKey) {
|
|
50
|
+
const k = rigKey ?? '__default';
|
|
51
|
+
let c = this._ctxByRig.get(k);
|
|
52
|
+
if (!c) { c = buildRetargetContext(this.root, rigKey); this._ctxByRig.set(k, c); }
|
|
53
|
+
return c;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
register(name, clip) {
|
|
57
|
+
const ctx = this._ctxFor(this.clipRigs?.[name] ?? null);
|
|
58
|
+
const action = this.mixer.clipAction(retargetClip(clip, ctx));
|
|
59
|
+
this.actions[name] = action;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Build a bone-masked copy of an already-registered clip (keeps only tracks whose
|
|
63
|
+
// bone passes keepFn). Used to split locomotion across body halves — e.g. legs
|
|
64
|
+
// from `walk` + torso/arms from `blockLoop` so you can walk with the guard up.
|
|
65
|
+
registerMasked(name, srcName, keepFn) {
|
|
66
|
+
const src = this.actions[srcName];
|
|
67
|
+
if (!src) return;
|
|
68
|
+
const clip = src.getClip().clone();
|
|
69
|
+
clip.tracks = clip.tracks.filter((t) => keepFn(t.name.replace(/\.(position|quaternion|scale)$/, '')));
|
|
70
|
+
clip.name = name;
|
|
71
|
+
this.actions[name] = this.mixer.clipAction(clip);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A persistent overlay action that plays ALONGSIDE the base FSM action (play()
|
|
75
|
+
// only fades `this.current`, never the overlay). Non-overlapping tracks compose.
|
|
76
|
+
//
|
|
77
|
+
// WEIGHTS MUST SUM TO 1 ON EVERY BONE, ALWAYS. three's PropertyMixer fills any
|
|
78
|
+
// cumulative-weight deficit from the binding's ORIGINAL saved value — captured at the
|
|
79
|
+
// mixer's first activation, i.e. the model's BIND POSE. So a weight gap does not blend
|
|
80
|
+
// between two poses: it blends toward a T-POSE. A 0.12s overlay fadeOut racing a 0.18s
|
|
81
|
+
// base fadeIn dipped the sum to ~0.58 and flashed the arms 42% toward T-pose on every
|
|
82
|
+
// aim release. Hence: overlay swaps of the SAME mask crossfade (sum stays 1); starting
|
|
83
|
+
// or dropping an overlay is INSTANT, and the caller switches the base to/from the
|
|
84
|
+
// complementary mask in the SAME frame. A hard pose cut is survivable; a T-pose is not.
|
|
85
|
+
setOverlay(name, opts = {}) {
|
|
86
|
+
const next = name ? this.actions[name] : null;
|
|
87
|
+
const prev = this._overlay;
|
|
88
|
+
if (prev === next && !opts.restart) return next;
|
|
89
|
+
if (next) {
|
|
90
|
+
next.reset(); // reset() calls stopFading() — so it MUST come first
|
|
91
|
+
if (opts.once) { next.setLoop(THREE.LoopOnce, 1); next.clampWhenFinished = true; }
|
|
92
|
+
else next.setLoop(THREE.LoopRepeat, Infinity);
|
|
93
|
+
next.enabled = true;
|
|
94
|
+
next.setEffectiveWeight(1);
|
|
95
|
+
next.play();
|
|
96
|
+
// same-mask swap (aim -> fire -> aim): crossFadeFrom keeps the pair summing to 1
|
|
97
|
+
if (prev && prev !== next && opts.fade) next.crossFadeFrom(prev, opts.fade, false);
|
|
98
|
+
else if (prev && prev !== next) { prev.stop(); prev.setEffectiveWeight(0); }
|
|
99
|
+
} else if (prev) {
|
|
100
|
+
// DROPPING the overlay. stop() zeroes its weight instantly — but the base is still the
|
|
101
|
+
// LOWER-only mask for a beat, so the upper body is left with ZERO cumulative weight and
|
|
102
|
+
// three fills it from the bind pose: a ~0.2s T-POSE flash at the end of every one-shot.
|
|
103
|
+
// Instead fade it out over EXACTLY the duration the caller fades its full-body base in
|
|
104
|
+
// (opts.fade). Upper: overlay(1-t) + base(t) = 1. Lower: lowerBase(1-t) + base(t) = 1.
|
|
105
|
+
const d = opts.fade ?? 0;
|
|
106
|
+
if (d > 0) prev.fadeOut(d);
|
|
107
|
+
else { prev.stop(); prev.setEffectiveWeight(0); }
|
|
108
|
+
}
|
|
109
|
+
this._overlay = next;
|
|
110
|
+
return next;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// play(name) — loop. play(name, {once, fade, timeScale, onDone}) — one-shot.
|
|
114
|
+
// startFrac (0..1) skips into the clip past wind-up. CLIP_TIMING (tuned via
|
|
115
|
+
// /anim.html) supplies per-clip start/speed DEFAULTS; explicit opts override.
|
|
116
|
+
play(name, opts = {}) {
|
|
117
|
+
const action = this.actions[name];
|
|
118
|
+
if (!action) return null;
|
|
119
|
+
const tm = CLIP_TIMING[name] || {};
|
|
120
|
+
const once = opts.once ?? false;
|
|
121
|
+
const fade = opts.fade ?? 0.18;
|
|
122
|
+
const restart = opts.restart ?? false;
|
|
123
|
+
const onDone = opts.onDone ?? null;
|
|
124
|
+
const startFrac = opts.startFrac ?? tm.start ?? 0;
|
|
125
|
+
const timeScale = opts.timeScale ?? tm.speed ?? 1;
|
|
126
|
+
if (this.currentName === name && !restart && !once) return action;
|
|
127
|
+
|
|
128
|
+
action.reset();
|
|
129
|
+
if (startFrac > 0) action.time = action.getClip().duration * startFrac;
|
|
130
|
+
action.timeScale = timeScale;
|
|
131
|
+
if (once) {
|
|
132
|
+
action.setLoop(THREE.LoopOnce, 1);
|
|
133
|
+
action.clampWhenFinished = true;
|
|
134
|
+
action._onceDone = onDone;
|
|
135
|
+
} else {
|
|
136
|
+
action.setLoop(THREE.LoopRepeat, Infinity);
|
|
137
|
+
action._onceDone = null; // re-played as a loop: drop any stale one-shot callback
|
|
138
|
+
}
|
|
139
|
+
action.fadeIn(fade).play();
|
|
140
|
+
if (this.current && this.current !== action) this.current.fadeOut(fade);
|
|
141
|
+
this.current = action;
|
|
142
|
+
this.currentName = name;
|
|
143
|
+
this._curTiming = tm; // for end-trim handling in update()
|
|
144
|
+
this._curOnce = once;
|
|
145
|
+
return action;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
stopAll() {
|
|
149
|
+
this.mixer.stopAllAction();
|
|
150
|
+
this.current = null;
|
|
151
|
+
this.currentName = null;
|
|
152
|
+
this._overlay = null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
update(dt) {
|
|
156
|
+
this.mixer.update(dt);
|
|
157
|
+
// CLIP_TIMING end-trim: cut the tail of the clip. One-shots clamp at `end`
|
|
158
|
+
// (and fire onDone there); loops cycle back to `start`.
|
|
159
|
+
const a = this.current, tm = this._curTiming;
|
|
160
|
+
if (a && tm && tm.end != null && tm.end < 1) {
|
|
161
|
+
const dur = a.getClip().duration;
|
|
162
|
+
if (a.time >= tm.end * dur) {
|
|
163
|
+
if (this._curOnce) {
|
|
164
|
+
a.time = tm.end * dur;
|
|
165
|
+
a.paused = true;
|
|
166
|
+
const cb = a._onceDone; a._onceDone = null; cb?.();
|
|
167
|
+
} else {
|
|
168
|
+
a.time = (tm.start ?? 0) * dur;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Self-animated rigs (PolyPerfect creatures, like the horse): the clips target the
|
|
176
|
+
// model's OWN skeleton, so registration skips the Synty retarget entirely — everything
|
|
177
|
+
// else (play/overlay/once-callbacks/end-trim) behaves identically.
|
|
178
|
+
export class RawAnimator extends Animator {
|
|
179
|
+
get needsRetarget() { return false; } // its clips already speak this skeleton's language
|
|
180
|
+
|
|
181
|
+
register(name, clip) {
|
|
182
|
+
this.actions[name] = this.mixer.clipAction(clip);
|
|
183
|
+
}
|
|
184
|
+
}
|