sindicate 0.17.0 → 0.18.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 CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0 — two regressions, caught from outside
4
+
5
+ Everything here came from a third game running against 0.17.0 and reporting back. One fix I
6
+ shipped broken, one fix I shipped incomplete, and both were found by a consumer rather than by
7
+ me. Nothing else changed, and western and fable do not move.
8
+
9
+ ### Fixed
10
+
11
+ - **`RETRIGGER_AT` was undeclared** (`core/anim.js`), so 0.17.0's own one-shot guard threw a
12
+ `ReferenceError` on the exact path it was written to protect — re-triggering a one-shot that is
13
+ still playing. Western was affected too: a revolver re-triggering its fire clip does that on
14
+ every second shot. Frame-hook isolation caught it rather than killing the frame, which is the
15
+ only reason it was not immediately fatal, but the one-shot silently did not play.
16
+ - **A split answer's overlay ran as an infinite loop.** `setOverlay` defaults to
17
+ `LoopRepeat/Infinity`, and the `{ base, overlay }` shape did not say otherwise, so a reload
18
+ overlay played for ever. The answer is now `{ base, overlay, once }`. It cannot be inferred: a
19
+ reload or holster overlay is a one-shot, a held aim pose must loop, and both are legitimate
20
+ answers to the same question.
21
+
22
+ ### Changed
23
+
24
+ - `locomotionClip` may answer `{ base, overlay, once }` as well as a clip name — a body split is
25
+ two simultaneous answers and one name cannot express both — and the engine now asks BEFORE
26
+ dropping the overlay rather than after. Dropping it first rewound the overlay sixty times a
27
+ second, which a game could only stop by patching `setOverlay`. A string answer behaves exactly
28
+ as in 0.17.0; `null`/`undefined` still mean the engine owns the slot.
29
+
30
+ ### The lesson, recorded because it caused both
31
+
32
+ Both regressions survived because I verified the wrong layer. `node --check` passes on an
33
+ undeclared identifier — it is a runtime `ReferenceError`, not a syntax error — and a test that
34
+ watches what was *asked for* is blind to what the ask *did*. The tests now drive the real
35
+ per-frame sequence and watch the animation across more than one clip length. An edit anchored on
36
+ `import * as THREE from 'three';` also silently matches nothing in a file that imports from
37
+ `'three/webgpu'`, which is how the constant went missing in the first place.
38
+
3
39
  ## 0.17.0 — towns, and a third game's report card
4
40
 
5
41
  Two halves. Cities — the thing the road system was always for. And the first pass through
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sindicate",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Sindicate — open-world game engine for three.js WebGPU. Streaming worlds, characters, mounts & vehicles, missions, crowds.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/core/anim.js CHANGED
@@ -2,6 +2,11 @@
2
2
  // with automatic retargeting per character rig.
3
3
  import * as THREE from 'three/webgpu';
4
4
  import { buildRetargetContext, retargetClip } from './retarget.js';
5
+
6
+ // How far a one-shot must have played before another play() will restart it, as a fraction of
7
+ // its duration. Below this the call is ignored and the clip runs on — see play(). A caller may
8
+ // override per call with `retrigger`.
9
+ const RETRIGGER_AT = 0.5;
5
10
  // Per-clip timing (start/speed/end trims) and the clip->source-rig table are GAME
6
11
  // DATA, not engine code — a game registers them once at boot:
7
12
  // setClipDefaults({ timing: CLIP_TIMING, rigs: CLIP_RIGS })
@@ -39,7 +39,11 @@
39
39
  export const POINTS = {
40
40
  locomotionClip: {
41
41
  args: '{ moving, sprinting, aiming, reloading, holstering, drawn, weapon, animator, has }',
42
- returns: 'a clip name to play, or null to let the engine choose',
42
+ returns: 'a clip name, or { base, overlay, once } when the body is split, or null to let '
43
+ + 'the engine choose. A split needs TWO simultaneous answers — a legs-only base and '
44
+ + 'an upper-body overlay — which a single name cannot express. `once` says whether '
45
+ + 'that overlay is a one-shot (a reload, a holster) or a held pose that loops (an '
46
+ + 'aim): it cannot be inferred, and both are legitimate answers to this question.',
43
47
  why: 'western\'s gunslinger pack has a full-body clip for every gun state. Kubold\'s rifle '
44
48
  + 'set has one standing reload and no walking reload, so a game on it needs the legs to '
45
49
  + 'keep moving through a reload the engine would otherwise stop dead.',
@@ -931,18 +931,41 @@ export class Player extends Character {
931
931
  // full-body clips — the holster timer still reads it, so it must still exist.)
932
932
  const gunPose = !!gunW && (this.aiming || this.reloading || this._drawT > 0 || this._gunRecoilT > 0);
933
933
  if (!this.busy && !this.airborne) {
934
- if (this.animator._overlay) this.animator.setOverlay(null, { fade: BASE_FADE });
935
934
  // WHICH CLIP IS THE BODY PLAYING? A decision point (core/overrides.js), because the
936
935
  // answer below is right for a pack that has a full-body clip for every gun state and
937
936
  // wrong for one that does not: Kubold's rifle set has a single standing reload, so
938
937
  // `gunReloadWalk` maps onto it and walking into a reload stops the legs dead. A game
939
938
  // whose pack differs answers for itself; one that says nothing gets exactly this.
940
- const chosen = this.game?.engine?.overrides?.ask('locomotionClip', {
939
+ //
940
+ // IT MAY ANSWER WITH BOTH HALVES. A body split is two simultaneous answers — a legs-only
941
+ // base AND an upper-body overlay — so a single clip name cannot express one. Returning
942
+ // `{ base, overlay }` says both. And the ask now happens BEFORE the overlay is dropped:
943
+ // dropping it first rewound the overlay sixty times a second, which a game could only
944
+ // stop by patching setOverlay. That patch was the last one Blacksand still carried.
945
+ const answer = this.game?.engine?.overrides?.ask('locomotionClip', {
941
946
  moving, sprinting, drawn: this.drawn, aiming: this.aiming,
942
947
  reloading: this.reloading, holstering: this._holstering,
943
948
  weapon: this.weapon, animator: this.animator,
944
949
  has: (n) => !!this.animator.actions[n],
945
950
  });
951
+ const chosen = typeof answer === 'string' ? answer : answer?.base ?? null;
952
+ const wantOverlay = typeof answer === 'object' && answer !== null ? answer.overlay ?? null : null;
953
+ // ONE-SHOT OR HELD? It cannot be inferred and the two are both legitimate answers to the
954
+ // same question: a reload or holster overlay plays once and stops, `gunAimUpper` is a
955
+ // held pose that must loop. setOverlay defaults to LoopRepeat/Infinity, so an unstated
956
+ // reload overlay ran for ever — the answer has to say.
957
+ const overlayOnce = typeof answer === 'object' && answer !== null ? !!answer.once : false;
958
+
959
+ // Only clear the overlay when nothing asked to keep one. `undefined` means the game did
960
+ // not answer at all, so the engine owns the slot exactly as before.
961
+ // setOverlay already returns early when the action is unchanged, so re-asserting the
962
+ // same overlay every frame costs nothing and cannot rewind it.
963
+ if (wantOverlay) {
964
+ this.animator.setOverlay(wantOverlay, { fade: BASE_FADE, once: overlayOnce });
965
+ } else if (this.animator._overlay) {
966
+ this.animator.setOverlay(null, { fade: BASE_FADE });
967
+ }
968
+
946
969
  if (chosen) this.animator.play(chosen);
947
970
  else if (this.drawn && gunW) {
948
971
  if (this.reloading) this.animator.play(moving ? 'gunReloadWalk' : 'gunReload');