rig-play 1.9.1 → 1.10.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 CHANGED
@@ -213,13 +213,97 @@ player.dispose();
213
213
  assets.dispose();
214
214
  ```
215
215
 
216
+ ### A minimal page
217
+
218
+ Layout is the caller's, so the first page is the place to see it: the root
219
+ element is the rig's world origin, Y up, and scaling it is a CSS transform the
220
+ player cannot see. This one loads a build, fits its stage box to a container,
221
+ tells the renderer about the scale, and plays the first animation:
222
+
223
+ ```html
224
+ <!doctype html>
225
+ <meta charset="utf-8">
226
+ <div id="stage" style="position: relative; width: 480px; height: 640px; overflow: hidden"></div>
227
+ <script type="module">
228
+ import { loadModel, RigPlayer } from 'https://cdn.jsdelivr.net/npm/@rig-c/play@1/dist/index.js';
229
+
230
+ const stage = document.getElementById('stage');
231
+ const assets = await loadModel({ modelUrl: 'rigs/look/skeleton.model.json' });
232
+ const root = stage.appendChild(document.createElement('div'));
233
+ const player = new RigPlayer(root, assets);
234
+ player.play(assets.animations[0].name);
235
+
236
+ // Fit the rig's stage box (world units, Y up) to the container: scale it, and
237
+ // put the origin where the box's left edge and top land.
238
+ const box = assets.stage ?? player.bounds();
239
+ const scale = Math.min(stage.clientWidth / box.width, stage.clientHeight / box.height);
240
+ root.style.cssText = `position: absolute; transform-origin: 0 0; transform: scale(${scale});
241
+ left: ${-box.x * scale}px; top: ${(box.y + box.height) * scale}px`;
242
+ player.renderer.syncPixelRatio(); // raster at the size on screen, not the unscaled one
243
+
244
+ let last = performance.now();
245
+ requestAnimationFrame(function tick(now) {
246
+ player.frame((now - last) / 1000);
247
+ last = now;
248
+ requestAnimationFrame(tick);
249
+ });
250
+ </script>
251
+ ```
252
+
253
+ `syncPixelRatio()` is what makes the scale cheap: without it the mesh canvases
254
+ raster at the device pixel ratio of the *unscaled* rig, `1 / scale` times more
255
+ pixels than the screen shows. Call it again after anything that changes the
256
+ scale. `tests/readme-page.spec.ts` runs this block as written, against the
257
+ package built from this tree.
258
+
259
+ ### Wind, gravity and dragging the rig
260
+
261
+ A rig's physics links answer the world the page sets (#121). `environment()`
262
+ gives them spine-core's skeleton-level wind and gravity — x right, **y down**,
263
+ the runtime's axes; each constraint's authored `wind` / `gravity` is its
264
+ response, so a link stating `wind: 0` feels none — and `moved()` tells them the
265
+ page moved or turned the rig, in the skeleton's world (y up), so a dragged rig
266
+ trails its cloth. Both apply from the next step, at any time. Added to the
267
+ minimal page above:
268
+
269
+ ```ts
270
+ // A wind slider, -1 (left) to 1 (right). The runtime starts at wind [1, 0], so set calm first.
271
+ const wind = stage.appendChild(Object.assign(document.createElement('input'),
272
+ { type: 'range', min: '-1', max: '1', step: '0.01', value: '0' }));
273
+ player.environment({ wind: [0, 0] });
274
+ wind.addEventListener('input', () => player.environment({ wind: [Number(wind.value), 0] }));
275
+
276
+ // Drag the rig: move the root, and tell the physics how far, in world units with y up.
277
+ let held = null; // the pointer's position while a drag is held
278
+ stage.addEventListener('pointerdown', (e) => { if (e.target !== wind) held = { x: e.clientX, y: e.clientY }; });
279
+ addEventListener('pointerup', () => { held = null; });
280
+ stage.addEventListener('pointermove', (e) => {
281
+ if (!held) return;
282
+ const dx = e.clientX - held.x, dy = e.clientY - held.y;
283
+ held = { x: e.clientX, y: e.clientY };
284
+ root.style.left = `${parseFloat(root.style.left) + dx}px`;
285
+ root.style.top = `${parseFloat(root.style.top) + dy}px`;
286
+ player.moved({ x: dx / scale, y: -dy / scale });
287
+ });
288
+ ```
289
+
290
+ The block is plain JavaScript, so it runs inside the page as written.
291
+ `environment(null)` returns to the runtime's start values, and `environment()`
292
+ reads what stands. The state is the player's, not a track's: every track and
293
+ layer answers the same world, and a `seek` replays under the environment as it
294
+ stands — no history is kept. A rig with no physics ignores both.
295
+ `tests/readme-page.spec.ts` runs this block in the minimal page.
296
+
216
297
  The player owns one track: `play(name, { loop })` opens an animation at its first
217
298
  pose, `frame(dt)` steps it (or `advance(dt)` then `draw()`, to time the two
218
299
  apart), `pause()` / `resume()` hold and release it, `timeScale` scales every
219
300
  step, and `seek(t)` moves it to a time. A seek is "reopen, then step to the
220
301
  time" in `seekStep` increments (1/60 s by default) — never a jump, because a
221
302
  physics rig has history and its pose at a time depends on the steps that
222
- reached it. `on('event', …)` receives the event keys each step crosses, in the
303
+ reached it. The physics integrates on rig-c's render clock (`physicsClockStep`:
304
+ each step and 2⁻³⁰ of it more), so stepping at a fixed rate — `frame(1/60)`, a
305
+ capture, a test — shows no single-frame spring jump where a frame ends on a
306
+ physics step; the animation, the events and `time` stay on the steps (#103). `on('event', …)` receives the event keys each step crosses, in the
223
307
  runtime's order across a loop's wrap; `on('complete', …)` fires once a step
224
308
  completes a cycle. `skin` (a constructor option) poses under a named skin;
225
309
  without it the rig is posed the way a fresh Spine skeleton is, with no skin
package/dist/index.d.ts CHANGED
@@ -2,6 +2,6 @@ export { type CanvasBackend, CanvasRenderer, type CanvasViewport } from './Canva
2
2
  export { DomRenderer, type MeshBackend, type MeshSourceRect } from './DomRenderer.js';
3
3
  export { type PageLayout, type PageRegion, type RegionEncoding, type RegionImage, revokeRegions, type UnpackRegionsOptions, unpackRegions } from './DomTexture.js';
4
4
  export { type LoadModelOptions, loadModel, type ModelAssets, type ModelDocument, type RegionLoading } from './loadModel.js';
5
- export { type AdjustableBones, type BoneAdjust, type BoneLocals, type BoneWorld, type PlayOptions, type PoseBounds, type QueueOptions, type RenderMode, type RigComplete, type RigEvent, RigPlayer, type RigPlayerOptions, type SlotFilter, type TrackEntry } from './player.js';
5
+ export { type AdjustableBones, type BoneAdjust, type BoneLocals, type BoneWorld, type PlayOptions, type PoseBounds, type QueueOptions, type RenderMode, type RigComplete, type RigEnvironment, type RigEvent, RigPlayer, type RigPlayerOptions, type SlotFilter, type TrackEntry } from './player.js';
6
6
  export type { BlendMode, PosedClip, PosedFrame, PosedMesh, PosedRegion, PosedSlot, } from './posedFrame.js';
7
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,aAAa,EAAE,cAAc,EAAE,KAAK,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAC9F,OAAO,EAAE,WAAW,EAAE,KAAK,WAAW,EAAE,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACtF,OAAO,EAAE,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,KAAK,cAAc,EAAE,KAAK,WAAW,EAAE,aAAa,EAAE,KAAK,oBAAoB,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACnK,OAAO,EAAE,KAAK,gBAAgB,EAAE,SAAS,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAE,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC5H,OAAO,EAAE,KAAK,eAAe,EAAE,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,KAAK,SAAS,EAAE,KAAK,WAAW,EAAE,KAAK,UAAU,EAAE,KAAK,YAAY,EAAE,KAAK,UAAU,EAAE,KAAK,WAAW,EAAE,KAAK,QAAQ,EAAE,SAAS,EAAE,KAAK,gBAAgB,EAAE,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AACjR,YAAY,EACV,SAAS,EACT,SAAS,EACT,UAAU,EACV,SAAS,EACT,WAAW,EACX,SAAS,GACV,MAAM,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,aAAa,EAAE,cAAc,EAAE,KAAK,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAC9F,OAAO,EAAE,WAAW,EAAE,KAAK,WAAW,EAAE,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACtF,OAAO,EAAE,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,KAAK,cAAc,EAAE,KAAK,WAAW,EAAE,aAAa,EAAE,KAAK,oBAAoB,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACnK,OAAO,EAAE,KAAK,gBAAgB,EAAE,SAAS,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAE,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC5H,OAAO,EAAE,KAAK,eAAe,EAAE,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,KAAK,SAAS,EAAE,KAAK,WAAW,EAAE,KAAK,UAAU,EAAE,KAAK,YAAY,EAAE,KAAK,UAAU,EAAE,KAAK,WAAW,EAAE,KAAK,cAAc,EAAE,KAAK,QAAQ,EAAE,SAAS,EAAE,KAAK,gBAAgB,EAAE,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AACtS,YAAY,EACV,SAAS,EACT,SAAS,EACT,UAAU,EACV,SAAS,EACT,WAAW,EACX,SAAS,GACV,MAAM,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -1741,6 +1741,7 @@ function slotBonePlan(bones, active, records, skipped) {
1741
1741
  });
1742
1742
  return plan;
1743
1743
  }
1744
+ const RUNTIME_RAD = RUNTIME_PI / 180;
1744
1745
  const PHYSICS_FIELDS = [
1745
1746
  "bone",
1746
1747
  "scaleY",
@@ -1949,6 +1950,30 @@ function resetPhysicsState(s, time) {
1949
1950
  s.rotateOffset = s.rotateLag = s.rotateVelocity = 0;
1950
1951
  s.scaleOffset = s.scaleLag = s.scaleVelocity = 0;
1951
1952
  }
1953
+ const DEFAULT_PHYSICS_ENVIRONMENT = Object.freeze({ wind: Object.freeze([1, 0]), gravity: Object.freeze([0, 1]) });
1954
+ function translatePhysics(ctx, x, y) {
1955
+ for (const s of ctx.states.values()) {
1956
+ s.ux -= x;
1957
+ s.uy -= y;
1958
+ s.cx -= x;
1959
+ s.cy -= y;
1960
+ }
1961
+ }
1962
+ function rotatePhysics(ctx, x, y, degrees) {
1963
+ const r = degrees * RUNTIME_RAD;
1964
+ const cos = Math.cos(r);
1965
+ const sin = Math.sin(r);
1966
+ for (const s of ctx.states.values()) {
1967
+ const dx = s.cx - x;
1968
+ const dy = s.cy - y;
1969
+ const tx = dx * cos - dy * sin - dx;
1970
+ const ty = dx * sin + dy * cos - dy;
1971
+ s.ux -= tx;
1972
+ s.uy -= ty;
1973
+ s.cx -= tx;
1974
+ s.cy -= ty;
1975
+ }
1976
+ }
1952
1977
  function physicsState(ctx, name) {
1953
1978
  let s = ctx.states.get(name);
1954
1979
  if (s === void 0) {
@@ -1975,6 +2000,7 @@ function stepPhysics(r, w, length, ctx) {
1975
2000
  s.lastTime = ctx.time;
1976
2001
  const bx = w.worldX;
1977
2002
  const by = w.worldY;
2003
+ const env = ctx.env ?? DEFAULT_PHYSICS_ENVIRONMENT;
1978
2004
  let z = 0;
1979
2005
  if (s.pending) {
1980
2006
  s.pending = false;
@@ -2005,16 +2031,18 @@ function stepPhysics(r, w, length, ctx) {
2005
2031
  const e = r.strength;
2006
2032
  const wind = r.wind * f;
2007
2033
  const g = r.gravity * f;
2034
+ const ax = wind * env.wind[0] + g * env.gravity[0];
2035
+ const ay = wind * env.wind[1] + g * env.gravity[1];
2008
2036
  const xs = s.xOffset;
2009
2037
  const ys = s.yOffset;
2010
2038
  do {
2011
2039
  if (x) {
2012
- s.xVelocity += (wind - s.xOffset * e) * m;
2040
+ s.xVelocity += (ax - s.xOffset * e) * m;
2013
2041
  s.xOffset += s.xVelocity * t;
2014
2042
  s.xVelocity *= d;
2015
2043
  }
2016
2044
  if (y) {
2017
- s.yVelocity -= (g + s.yOffset * e) * m;
2045
+ s.yVelocity -= (ay + s.yOffset * e) * m;
2018
2046
  s.yOffset += s.yVelocity * t;
2019
2047
  s.yVelocity *= d;
2020
2048
  }
@@ -2061,20 +2089,20 @@ function stepPhysics(r, w, length, ctx) {
2061
2089
  if (d === -1) d = Math.pow(r.damping, 60 * t);
2062
2090
  const m = r.massInverse * t;
2063
2091
  const e = r.strength;
2064
- const wind = r.wind;
2065
- const g = r.gravity;
2092
+ const ax = r.wind * env.wind[0] + r.gravity * env.gravity[0];
2093
+ const ay = r.wind * env.wind[1] + r.gravity * env.gravity[1];
2066
2094
  const h = l / f;
2067
2095
  const rs = s.rotateOffset;
2068
2096
  const ss = s.scaleOffset;
2069
2097
  for (; ; ) {
2070
2098
  a -= t;
2071
2099
  if (scaleX) {
2072
- s.scaleVelocity += (wind * c - g * sn - s.scaleOffset * e) * m;
2100
+ s.scaleVelocity += (ax * c - ay * sn - s.scaleOffset * e) * m;
2073
2101
  s.scaleOffset += s.scaleVelocity * t;
2074
2102
  s.scaleVelocity *= d;
2075
2103
  }
2076
2104
  if (rotateOrShearX) {
2077
- s.rotateVelocity -= ((wind * sn + g * c) * h + s.rotateOffset * e) * m;
2105
+ s.rotateVelocity -= ((ax * sn + ay * c) * h + s.rotateOffset * e) * m;
2078
2106
  s.rotateOffset += s.rotateVelocity * t;
2079
2107
  s.rotateVelocity *= d;
2080
2108
  if (a < t) break;
@@ -4927,6 +4955,49 @@ function setupPosed(doc, plant, mode) {
4927
4955
  if (world === null || shown === null || setup.slots === null || setup.drawOrder === null) throw new CoreInputError("the raw setup pose was not posed");
4928
4956
  return { world, shown, slots: setup.slots, drawOrder: setup.drawOrder };
4929
4957
  }
4958
+ const PHYSICS_CLOCK_LEAD = 2 ** -30;
4959
+ function physicsClockStep(step) {
4960
+ return step + step * PHYSICS_CLOCK_LEAD;
4961
+ }
4962
+ function checkedClock(clock, who) {
4963
+ return (dt) => {
4964
+ const moved = clock(dt);
4965
+ if (typeof moved !== "number" || !Number.isFinite(moved) || moved < 0) throw new CoreInputError(`${who} clock moved the physics by ${String(moved)} for a step of ${dt}, not a finite time at or above 0`);
4966
+ return moved;
4967
+ };
4968
+ }
4969
+ function setEnvironmentOn(ctx, env, who) {
4970
+ if (typeof env !== "object" || env === null || Array.isArray(env)) throw new CoreInputError(`${who} setEnvironment was given ${env === null ? "null" : Array.isArray(env) ? "an array" : typeof env}, not an object { wind?: [x, y], gravity?: [x, y] }`);
4971
+ const given = env;
4972
+ for (const key of Object.keys(given)) {
4973
+ if (key !== "wind" && key !== "gravity") throw new CoreInputError(`${who} setEnvironment names "${key}", which is not one of [wind, gravity]`);
4974
+ }
4975
+ const pair = (key) => {
4976
+ const v = given[key];
4977
+ if (v === void 0) return void 0;
4978
+ if (!Array.isArray(v) || v.length !== 2 || v.some((n) => typeof n !== "number" || !Number.isFinite(n))) throw new CoreInputError(`${who} setEnvironment's ${key} is ${JSON.stringify(v)}, not a pair of finite numbers [x, y] (x to the right, y down)`);
4979
+ return Object.freeze([v[0], v[1]]);
4980
+ };
4981
+ const wind = pair("wind");
4982
+ const gravity = pair("gravity");
4983
+ const standing = ctx.env ?? DEFAULT_PHYSICS_ENVIRONMENT;
4984
+ ctx.env = Object.freeze({ wind: wind ?? standing.wind, gravity: gravity ?? standing.gravity });
4985
+ }
4986
+ function environmentOf(ctx) {
4987
+ return ctx.env ?? DEFAULT_PHYSICS_ENVIRONMENT;
4988
+ }
4989
+ function translateOn(ctx, x, y, who) {
4990
+ for (const [k, v] of [["x", x], ["y", y]]) {
4991
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new CoreInputError(`${who} physicsTranslate's ${k} is ${String(v)}, not a finite distance in the skeleton's world`);
4992
+ }
4993
+ translatePhysics(ctx, x, y);
4994
+ }
4995
+ function rotateOn(ctx, x, y, degrees, who) {
4996
+ for (const [k, v] of [["x", x], ["y", y], ["degrees", degrees]]) {
4997
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new CoreInputError(`${who} physicsRotate's ${k} is ${String(v)}, not a finite number`);
4998
+ }
4999
+ rotatePhysics(ctx, x, y, degrees);
5000
+ }
4930
5001
  function loopedTime(trackTime, duration) {
4931
5002
  return duration === 0 ? 0 : trackTime % duration;
4932
5003
  }
@@ -5031,7 +5102,10 @@ function openTrack(doc, animation, options, plant = {}) {
5031
5102
  if (typeof options !== "object" || options === null || typeof options.loop !== "boolean") throw new CoreInputError(`the live track's options.loop is ${JSON.stringify(options?.loop)}, not true or false`);
5032
5103
  if (options.reset !== "animation" && options.reset !== "setup") throw new CoreInputError(`the live track's options.reset is ${JSON.stringify(options.reset)}, not "animation" or "setup"`);
5033
5104
  if (options.adjust !== void 0 && typeof options.adjust !== "function") throw new CoreInputError(`the live track's options.adjust is ${typeof options.adjust}, not a function`);
5034
- const { track, first } = openRawTrack(doc, animation, plant, options.reset, options.loop ? LOOPED : HELD, options.adjust);
5105
+ if (options.clock !== void 0 && typeof options.clock !== "function") throw new CoreInputError(`the live track's options.clock is ${typeof options.clock}, not a function`);
5106
+ const mode = options.loop ? LOOPED : HELD;
5107
+ const clock = options.clock === void 0 ? void 0 : checkedClock(options.clock, "the live track's");
5108
+ const { track, first } = openRawTrack(doc, animation, plant, options.reset, clock === void 0 ? mode : { ...mode, clock: (dt) => clock(dt) }, options.adjust);
5035
5109
  return new Track(track, first);
5036
5110
  }
5037
5111
  class Track {
@@ -5051,6 +5125,25 @@ class Track {
5051
5125
  get duration() {
5052
5126
  return this.track.duration;
5053
5127
  }
5128
+ get environment() {
5129
+ return environmentOf(this.track.ctx);
5130
+ }
5131
+ setEnvironment(environment) {
5132
+ this.live("setEnvironment");
5133
+ setEnvironmentOn(this.track.ctx, environment, "the live track's");
5134
+ }
5135
+ physicsTranslate(x, y) {
5136
+ this.live("physicsTranslate");
5137
+ translateOn(this.track.ctx, x, y, "the live track's");
5138
+ }
5139
+ physicsRotate(x, y, degrees) {
5140
+ this.live("physicsRotate");
5141
+ rotateOn(this.track.ctx, x, y, degrees, "the live track's");
5142
+ }
5143
+ /** A refused track refuses every call that would move it, naming the step it was refused at. */
5144
+ live(call) {
5145
+ if (this.refused !== null) throw new CoreInputError(`the live track was refused at step ${this.refused}, after its time and physics had moved; open the track again (${call} not made)`);
5146
+ }
5054
5147
  step(dt) {
5055
5148
  const n = this.steps + 1;
5056
5149
  if (this.refused !== null) throw new CoreInputError(`the live track was refused at step ${this.refused}, after its time and physics had moved; open the track again`);
@@ -5953,13 +6046,16 @@ function delayOf(v, call) {
5953
6046
  function openTracks(doc, options, plant = {}) {
5954
6047
  if (typeof options !== "object" || options === null || options.reset !== "animation" && options.reset !== "setup") throw new CoreInputError(`the live tracks' options.reset is ${JSON.stringify(options?.reset)}, not "animation" or "setup"`);
5955
6048
  if (options.adjust !== void 0 && typeof options.adjust !== "function") throw new CoreInputError(`the live tracks' options.adjust is ${typeof options.adjust}, not a function`);
5956
- return new Tracks(doc, options.reset, options.adjust, plant, openRawTracksPath(doc, plant));
6049
+ if (options.clock !== void 0 && typeof options.clock !== "function") throw new CoreInputError(`the live tracks' options.clock is ${typeof options.clock}, not a function`);
6050
+ const clock = options.clock === void 0 ? void 0 : checkedClock(options.clock, "the live tracks'");
6051
+ return new Tracks(doc, options.reset, options.adjust, clock, plant, openRawTracksPath(doc, plant));
5957
6052
  }
5958
6053
  class Tracks {
5959
- constructor(doc, reset, adjust, plant, path) {
6054
+ constructor(doc, reset, adjust, clock, plant, path) {
5960
6055
  this.doc = doc;
5961
6056
  this.reset = reset;
5962
6057
  this.adjust = adjust;
6058
+ this.clock = clock;
5963
6059
  this.plant = plant;
5964
6060
  this.path = path;
5965
6061
  this.started = false;
@@ -5971,6 +6067,21 @@ class Tracks {
5971
6067
  get trackTime() {
5972
6068
  return this.time;
5973
6069
  }
6070
+ get environment() {
6071
+ return environmentOf(this.path.ctx);
6072
+ }
6073
+ setEnvironment(environment) {
6074
+ this.live("setEnvironment");
6075
+ setEnvironmentOn(this.path.ctx, environment, "the live tracks'");
6076
+ }
6077
+ physicsTranslate(x, y) {
6078
+ this.live("physicsTranslate");
6079
+ translateOn(this.path.ctx, x, y, "the live tracks'");
6080
+ }
6081
+ physicsRotate(x, y, degrees) {
6082
+ this.live("physicsRotate");
6083
+ rotateOn(this.path.ctx, x, y, degrees, "the live tracks'");
6084
+ }
5974
6085
  live(call) {
5975
6086
  if (this.refused !== null) throw new CoreInputError(`the live tracks were refused at ${this.refused}, after their entries and physics had moved; open them again (${call} not made)`);
5976
6087
  }
@@ -6064,7 +6175,7 @@ class Tracks {
6064
6175
  const { doc, path } = this;
6065
6176
  const ctx = path.ctx;
6066
6177
  const before = ctx.time;
6067
- ctx.time += dt;
6178
+ ctx.time += this.clock === void 0 ? dt : this.clock(dt);
6068
6179
  const pose = new Pose(doc, this.plant);
6069
6180
  const animationTime = this.mixer.apply(pose);
6070
6181
  const mixed = new Map(doc.slots.map((s, k) => [s.name, { deform: pose.slots[k].deform, seen: pose.slots[k].seen }]));
@@ -8431,6 +8542,7 @@ function frameOf(source, pose, color = [1, 1, 1, 1], draws = () => true) {
8431
8542
  }
8432
8543
  return { order };
8433
8544
  }
8545
+ const DEFAULT_ENVIRONMENT = Object.freeze({ wind: Object.freeze([1, 0]), gravity: Object.freeze([0, 1]) });
8434
8546
  function predicateOf(names) {
8435
8547
  const allowed = new Set(names);
8436
8548
  return (slot) => allowed.has(slot);
@@ -8457,6 +8569,8 @@ class RigPlayer {
8457
8569
  read;
8458
8570
  skinName;
8459
8571
  seekStep;
8572
+ /** The environment the page set, or null for the runtime's start values (`environment`). */
8573
+ env = null;
8460
8574
  /**
8461
8575
  * The single live track (`openTrack`): what a plain `play` on track 0 runs
8462
8576
  * on, as every 1.x player did — one animation, every construct the core
@@ -8582,8 +8696,9 @@ class RigPlayer {
8582
8696
  if (this.tracks === null && plain && !this.trackMode) {
8583
8697
  this.animation = name;
8584
8698
  this.loop = loop;
8585
- this.track = openTrack(this.view, name, { loop, reset: "animation", adjust: this.adjustHook });
8699
+ this.track = openTrack(this.view, name, { loop, reset: "animation", adjust: this.adjustHook, clock: physicsClockStep });
8586
8700
  this.pose = this.track.first;
8701
+ this.applyEnvironment(this.track);
8587
8702
  return;
8588
8703
  }
8589
8704
  const tracks = this.compose();
@@ -8673,7 +8788,7 @@ class RigPlayer {
8673
8788
  */
8674
8789
  compose() {
8675
8790
  if (this.tracks !== null) return this.tracks;
8676
- const tracks = openTracks(this.view, { reset: "animation", adjust: this.adjustHook });
8791
+ const tracks = openTracks(this.view, { reset: "animation", adjust: this.adjustHook, clock: physicsClockStep });
8677
8792
  const single = this.track;
8678
8793
  const time = single?.trackTime ?? 0;
8679
8794
  if (single !== null && this.animation !== null) {
@@ -8683,9 +8798,58 @@ class RigPlayer {
8683
8798
  this.tracks = tracks;
8684
8799
  this.track = null;
8685
8800
  this.pose = tracks.start();
8801
+ this.applyEnvironment(tracks);
8686
8802
  if (time > 0) this.stepTo(time);
8687
8803
  return tracks;
8688
8804
  }
8805
+ /**
8806
+ * The world the rig's physics answers (#121): `wind` and `gravity`, as
8807
+ * spine-core's skeleton-level `windX/windY` and `gravityX/gravityY` — x to
8808
+ * the right and **y down**, the runtime's axes — to which each physics
8809
+ * constraint's authored `wind` / `gravity` is the response. The runtime's
8810
+ * start values are `{ wind: [1, 0], gravity: [0, 1] }`; a player never given
8811
+ * one poses exactly as it did without this method. A half left out keeps its
8812
+ * standing value, `null` returns both to the start values, and the call
8813
+ * applies from the next step on, at any time, with no ramp of the player's
8814
+ * own — the springs are the smoothing. It is the player's, not a track's:
8815
+ * every track and layer answers the same world, and a `seek` or a replay
8816
+ * poses under the environment as it stands (no history is kept). Returns the
8817
+ * environment now standing.
8818
+ */
8819
+ environment(environment) {
8820
+ if (environment === null) {
8821
+ this.env = null;
8822
+ this.stepper()?.setEnvironment(DEFAULT_ENVIRONMENT);
8823
+ } else if (environment !== void 0) {
8824
+ const live = this.stepper();
8825
+ if (live) {
8826
+ live.setEnvironment(environment);
8827
+ this.env = live.environment;
8828
+ } else {
8829
+ const base = this.env ?? DEFAULT_ENVIRONMENT;
8830
+ this.env = { wind: environment.wind ?? base.wind, gravity: environment.gravity ?? base.gravity };
8831
+ }
8832
+ }
8833
+ return this.stepper()?.environment ?? this.env ?? DEFAULT_ENVIRONMENT;
8834
+ }
8835
+ /**
8836
+ * The page moved or turned the whole rig since the last step (#121), in the
8837
+ * skeleton's world — units, y up, degrees counter-clockwise — so physics
8838
+ * sees it the way spine-core's `Skeleton.physicsTranslate` /
8839
+ * `physicsRotate(0, 0, Δ)` make it: a rig dragged across the page trails
8840
+ * its cloth. The root element's layout stays the caller's; this tells the
8841
+ * physics what the caller did to it (a page-pixel delta divided by the
8842
+ * root's scale, its y negated). Applies to the next step; before `play`
8843
+ * there is nothing to move.
8844
+ */
8845
+ moved(motion) {
8846
+ const live = this.stepper();
8847
+ if (!live) return;
8848
+ const x = motion.x ?? 0;
8849
+ const y = motion.y ?? 0;
8850
+ if (x !== 0 || y !== 0) live.physicsTranslate(x, y);
8851
+ if (motion.rotation) live.physicsRotate(0, 0, motion.rotation);
8852
+ }
8689
8853
  /** Stops stepping; `frame` keeps drawing the pose it holds. */
8690
8854
  pause() {
8691
8855
  this.paused = true;
@@ -8713,15 +8877,25 @@ class RigPlayer {
8713
8877
  /** Whatever is open, opened again with track 0's animation as `play` last set it, at pose 0: the single track, or the tracks under `tracks: true`. */
8714
8878
  reopen() {
8715
8879
  if (this.tracks !== null) {
8716
- const tracks = openTracks(this.view, { reset: "animation", adjust: this.adjustHook });
8880
+ const tracks = openTracks(this.view, { reset: "animation", adjust: this.adjustHook, clock: physicsClockStep });
8717
8881
  tracks.setAnimation(0, this.animation, { loop: this.loop, mixDuration: 0 });
8718
8882
  this.trackCount = 1;
8719
8883
  this.tracks = tracks;
8720
8884
  this.pose = tracks.start();
8885
+ this.applyEnvironment(tracks);
8721
8886
  return;
8722
8887
  }
8723
- this.track = openTrack(this.view, this.animation, { loop: this.loop, reset: "animation", adjust: this.adjustHook });
8888
+ this.track = openTrack(this.view, this.animation, { loop: this.loop, reset: "animation", adjust: this.adjustHook, clock: physicsClockStep });
8724
8889
  this.pose = this.track.first;
8890
+ this.applyEnvironment(this.track);
8891
+ }
8892
+ /** What is open: the tracks, the single track, or nothing before `play`. */
8893
+ stepper() {
8894
+ return this.tracks ?? this.track;
8895
+ }
8896
+ /** A newly opened track or tracks answer the environment the player stands in; one never set leaves it untouched. */
8897
+ applyEnvironment(live) {
8898
+ if (this.env !== null) live.setEnvironment(this.env);
8725
8899
  }
8726
8900
  /** Steps whatever is open forward to `time` in `seekStep` increments, the last one shorter. */
8727
8901
  stepTo(time) {
@@ -8748,7 +8922,11 @@ class RigPlayer {
8748
8922
  * Steps the track by `dt × timeScale` seconds and fires the step's listeners,
8749
8923
  * without drawing. A paused player, or a step of zero, keeps the pose it
8750
8924
  * holds without re-posing it, so a held rig costs the renderer's dirty
8751
- * checks and nothing else.
8925
+ * checks and nothing else. The physics clock moves by `physicsClockStep`
8926
+ * of the step — the step and 2⁻³⁰ of it more, rig-c's render clock — so a
8927
+ * caller stepping at a fixed rate whose frame is a whole number of physics
8928
+ * steps (`frame(1/60)`, a capture) shows no spring jump; the animation time,
8929
+ * the events and `time` stay on the steps themselves (#103).
8752
8930
  */
8753
8931
  advance(dt) {
8754
8932
  if (this.pose === null) return;