@moku-labs/game 0.14.8 → 0.14.10

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.
@@ -387,7 +387,7 @@ function stopLoop(state) {
387
387
  *
388
388
  * @param ctx - Domain context of the time plugin.
389
389
  */
390
- function startLoop$1(ctx) {
390
+ function startLoop$2(ctx) {
391
391
  if (typeof globalThis.requestAnimationFrame !== "function") return;
392
392
  ctx.state.running = true;
393
393
  ctx.state.rafId = globalThis.requestAnimationFrame(createFrameFunction(ctx));
@@ -451,7 +451,7 @@ const timePlugin = /*#__PURE__*/ createPlugin$1("time", {
451
451
  },
452
452
  createState: createTimeState,
453
453
  api: createTimeApi,
454
- onStart: startLoop$1,
454
+ onStart: startLoop$2,
455
455
  onStop: ({ state }) => stopLoop(state)
456
456
  });
457
457
  //#endregion
@@ -2941,7 +2941,7 @@ function continueSlot(frames, finished) {
2941
2941
  //#endregion
2942
2942
  //#region src/plugins/flow/runner/edge.ts
2943
2943
  /**
2944
- * Turns whatever a node failed with into an error the log takes.
2944
+ * Turns whatever a node, or an effect it started, failed with into an error the log takes.
2945
2945
  *
2946
2946
  * @param value - The thrown value or the rejection reason.
2947
2947
  * @returns The value itself when it is an error, a new error with its text otherwise.
@@ -3340,14 +3340,16 @@ function refuseEffect(signal) {
3340
3340
  return refused;
3341
3341
  }
3342
3342
  /**
3343
- * Builds the `fx` of a node context: awaited effects by call, cosmetic hints by `emit`. A `guide`
3344
- * narrows the gate while it runs; the runner lifts the narrow when the node is left. A body that
3345
- * was aborted keeps running, since the loop only stops waiting for it, so its `fx` starts nothing
3346
- * any more: the gate and the narrow belong to the node the loop entered after it. The `recover`
3347
- * of a rest node gets the same `fx`, bound to the signal of its recovery.
3343
+ * Builds the `fx` of a node context: awaited effects by call, effects the body does not wait for
3344
+ * by `start`, cosmetic hints by `emit`. A `guide` narrows the gate while it runs; the runner lifts
3345
+ * the narrow when the node is left. A body that was aborted keeps running, since the loop only
3346
+ * stops waiting for it, so its `fx` starts nothing any more: the gate and the narrow belong to the
3347
+ * node the loop entered after it. The `recover` of a rest node gets the same `fx`, bound to the
3348
+ * signal of its recovery.
3348
3349
  *
3349
3350
  * @param modules - Injected sibling APIs.
3350
- * @param step - The run in progress: its abort signal, and how it tells a walk that a gate opened.
3351
+ * @param step - The run in progress: its abort signal, how it tells a walk that a gate opened, and
3352
+ * the path and the log a failed `start` is reported with.
3351
3353
  * @returns The effects gateway of one node run.
3352
3354
  */
3353
3355
  function nodeFx(modules, step) {
@@ -3367,15 +3369,39 @@ function nodeFx(modules, step) {
3367
3369
  if (descriptor.answers !== void 0) step.gateOpened();
3368
3370
  return value;
3369
3371
  };
3370
- return Object.assign(run, {
3371
3372
  /**
3372
- * Buffers one cosmetic hint until the transaction of this node commits.
3373
+ * Starts one effect and does not wait for it. It takes the path of an awaited effect, so the
3374
+ * mode and the node's signal apply; only its end is nobody's wait. A failure is the handler's:
3375
+ * it goes to the log and never to the node. A `cosmetic` descriptor does not get that far: the
3376
+ * gateway swallows its failure. After the abort of the node nothing is started and nothing is
3377
+ * thrown, not even for a descriptor refused otherwise. An effect that ends with a rejection
3378
+ * after the abort did not fail: its handler was told to stop, so nothing is logged.
3373
3379
  *
3374
- * @param item - The hint.
3380
+ * @param descriptor - The descriptor the node starts.
3381
+ * @throws {Error} When the descriptor has `answers` or is a `guide`.
3375
3382
  */
3376
- emit: (item) => {
3377
- modules.fx.buffer(item);
3378
- } });
3383
+ const start = (descriptor) => {
3384
+ if (step.signal.aborted) return;
3385
+ if (descriptor.answers !== void 0 || descriptor.kind === "guide") throw new Error(`[game] fx.start() got the effect "${descriptor.kind}" in the node "${step.path}", and its answer has to be awaited.\n Write await fx(...) for a descriptor with answers and for a guide.`);
3386
+ modules.fx.run(descriptor, step.signal).catch((error) => {
3387
+ if (step.signal.aborted) return;
3388
+ step.log.error("flow:fx-start-failed", {
3389
+ path: step.path,
3390
+ kind: descriptor.kind
3391
+ }, asError$4(error));
3392
+ });
3393
+ };
3394
+ return Object.assign(run, {
3395
+ /**
3396
+ * Buffers one cosmetic hint until the transaction of this node commits.
3397
+ *
3398
+ * @param item - The hint.
3399
+ */
3400
+ emit: (item) => {
3401
+ modules.fx.buffer(item);
3402
+ },
3403
+ start
3404
+ });
3379
3405
  }
3380
3406
  /**
3381
3407
  * Builds the one object a node body receives.
@@ -3791,7 +3817,8 @@ async function recoverNode(ctx, modules, place, safe) {
3791
3817
  */
3792
3818
  gateOpened: () => {
3793
3819
  notifyGateOpen(state);
3794
- }
3820
+ },
3821
+ log: ctx.log
3795
3822
  };
3796
3823
  const outcome = await Promise.race([callRecover(modules, recovery), abortOutcome(abort.signal)]);
3797
3824
  modules.gate.narrow(void 0);
@@ -3926,7 +3953,8 @@ async function runNode(ctx, modules, location, node, safe) {
3926
3953
  */
3927
3954
  gateOpened: () => {
3928
3955
  notifyGateOpen(state);
3929
- }
3956
+ },
3957
+ log: ctx.log
3930
3958
  };
3931
3959
  const outcome = await nodeOutcome(modules, step, { event: void 0 });
3932
3960
  modules.gate.narrow(void 0);
@@ -6579,6 +6607,8 @@ function noFields$1() {
6579
6607
  function joinFields(driven, owned) {
6580
6608
  return /* @__PURE__ */ new Set([...driven ?? [], ...owned]);
6581
6609
  }
6610
+ /** What a component no loop adds to answers: no offset on any field. */
6611
+ const NO_OFFSETS = Object.freeze({});
6582
6612
  /** A handle for a motion that is already done. */
6583
6613
  const INERT = {
6584
6614
  finish: () => {},
@@ -6682,7 +6712,8 @@ function startTrack$2(pctx, entity, component, to, options) {
6682
6712
  if (handle.active()) state.tracks.push({
6683
6713
  entity,
6684
6714
  component: component.componentName,
6685
- handle
6715
+ handle,
6716
+ additive: options.additive === true
6686
6717
  });
6687
6718
  return handle;
6688
6719
  }
@@ -6708,7 +6739,9 @@ function forgetEndedTracks(pctx) {
6708
6739
  state.tracks = state.tracks.filter((track) => track.handle.active());
6709
6740
  }
6710
6741
  /**
6711
- * The component names a running track drives on one entity.
6742
+ * The component names a running track drives on one entity. An additive track drives none: the
6743
+ * loop of a view adds an offset to the rest pose and owns no component, so a settle may bring
6744
+ * the component home under it.
6712
6745
  *
6713
6746
  * @param pctx - Domain context of the projection module.
6714
6747
  * @param entity - The entity to ask about.
@@ -6717,10 +6750,23 @@ function forgetEndedTracks(pctx) {
6717
6750
  function drivenComponents(pctx, entity) {
6718
6751
  forgetEndedTracks(pctx);
6719
6752
  const driven = noFields$1();
6720
- for (const track of pctx.ctx.state.projection.tracks) if (track.entity === entity) driven.add(track.component);
6753
+ for (const track of pctx.ctx.state.projection.tracks) if (track.entity === entity && !track.additive) driven.add(track.component);
6721
6754
  return driven;
6722
6755
  }
6723
6756
  /**
6757
+ * What the additive tracks of the installed driver add to one component of one entity right now:
6758
+ * the offset of a loop. A driver that does not report it, and a world without a driver, answer
6759
+ * no offset.
6760
+ *
6761
+ * @param pctx - Domain context of the projection module.
6762
+ * @param entity - The entity to ask about.
6763
+ * @param component - The component to ask about.
6764
+ * @returns Field name to what is added to it, empty for a component no loop adds to.
6765
+ */
6766
+ function loopOffsets(pctx, entity, component) {
6767
+ return pctx.ctx.state.projection.driver?.offsetsOf?.(entity, component) ?? NO_OFFSETS;
6768
+ }
6769
+ /**
6724
6770
  * The components a running track drives on one entity, in start order, each name once. It only
6725
6771
  * reads: an ended track is skipped, not forgotten.
6726
6772
  *
@@ -6916,6 +6962,7 @@ function createViewRecord(entity, projection, key, item, rest) {
6916
6962
  handles: [],
6917
6963
  lifted: false,
6918
6964
  dropWhenStill: false,
6965
+ loopOwed: false,
6919
6966
  exiting: false
6920
6967
  };
6921
6968
  }
@@ -7055,7 +7102,69 @@ function runHook(pctx, view, hook, run) {
7055
7102
  }
7056
7103
  }
7057
7104
  /**
7058
- * The rest components that are off their rest value and that no running track drives.
7105
+ * The peers of a call outside a reconcile, as `settle` and the loop the sweep starts make it:
7106
+ * both sides are the items of the last reconcile.
7107
+ *
7108
+ * @param mounted - The mounted projection.
7109
+ * @returns The peers, the same item table as new and as old.
7110
+ */
7111
+ function restingPeers(mounted) {
7112
+ return {
7113
+ next: mounted.items,
7114
+ previous: mounted.items
7115
+ };
7116
+ }
7117
+ /**
7118
+ * Plays the `loop` hook of a view, when its projection has one. The motion is not kept with the
7119
+ * view's handles: a change cancels those, and the loop runs until the view dies or a flush ends
7120
+ * its tracks.
7121
+ *
7122
+ * @param pctx - Domain context of the projection module.
7123
+ * @param view - The view that loops.
7124
+ * @param spec - The projection spec.
7125
+ * @param peers - The new and the old items of the projection.
7126
+ */
7127
+ function startLoop$1(pctx, view, spec, peers) {
7128
+ const loop = spec.motion?.loop;
7129
+ if (loop === void 0) return;
7130
+ runHook(pctx, view, "loop", () => loop(createViewHandle(pctx, view, peers)));
7131
+ }
7132
+ /**
7133
+ * The stored value of one rest component as it stands under the loop of the view: what the
7134
+ * additive tracks of the driver add right now is taken off each numeric field. Where the result
7135
+ * is at rest within `REST_TOLERANCE`, the exact rest value stands in, so the float noise of the
7136
+ * subtraction is no difference. Without a loop on the component, and with a driver that reports
7137
+ * no offsets, it is the stored value itself.
7138
+ *
7139
+ * It is what every comparison with the rest pose reads: the loose components of a settle, the
7140
+ * question of a rollback and the convergence check. So a view that only sways or bobs is home,
7141
+ * and a view something moved is not, in whatever field.
7142
+ *
7143
+ * @param pctx - Domain context of the projection module.
7144
+ * @param view - The view to read.
7145
+ * @param value - The rest value of the component, which names its type.
7146
+ * @returns The value under the loop, or `undefined` for a tag and for a missing component.
7147
+ */
7148
+ function valueUnderLoop(pctx, view, value) {
7149
+ const stored = storedValue(pctx, view.entity, value.type);
7150
+ if (stored === void 0 || value.value === true) return stored;
7151
+ const rest = value.value;
7152
+ const offsets = loopOffsets(pctx, view.entity, asComponent$1(value.type));
7153
+ let under;
7154
+ for (const [field, offset] of Object.entries(offsets)) {
7155
+ const current = stored[field];
7156
+ if (typeof current !== "number") continue;
7157
+ const below = current - offset;
7158
+ under ??= { ...stored };
7159
+ under[field] = atRest(below, rest[field]) ? rest[field] : below;
7160
+ }
7161
+ return under ?? stored;
7162
+ }
7163
+ /**
7164
+ * The rest components that are off their rest value and that no running track drives. The loop
7165
+ * of a view counts for neither. It drives no component: it adds an offset to the rest pose and
7166
+ * owns nothing. And its offset is no distance from the rest pose: the value under the loop is
7167
+ * what is compared. So a looping view is loose exactly when a view without a loop would be.
7059
7168
  *
7060
7169
  * @param pctx - Domain context of the projection module.
7061
7170
  * @param view - The view to check.
@@ -7066,8 +7175,8 @@ function looseComponents(pctx, view) {
7066
7175
  const loose = [];
7067
7176
  for (const [name, value] of view.rest) {
7068
7177
  if (driven.has(name) || value.value === true) continue;
7069
- const stored = storedValue(pctx, view.entity, value.type);
7070
- if (stored !== void 0 && !deepEquals(stored, value.value)) loose.push(name);
7178
+ const under = valueUnderLoop(pctx, view, value);
7179
+ if (under !== void 0 && !deepEquals(under, value.value)) loose.push(name);
7071
7180
  }
7072
7181
  return loose;
7073
7182
  }
@@ -7094,13 +7203,69 @@ function playSettle(pctx, view, spec, peers, components) {
7094
7203
  }
7095
7204
  const played = runHook(pctx, view, "settle", () => custom(handle, components));
7096
7205
  if (played === void 0) {
7097
- writeRestNow(pctx, view, components);
7206
+ writeRestUnderLoops(pctx, view, components);
7098
7207
  return;
7099
7208
  }
7100
7209
  if (played.motion !== void 0) view.handles.push(played.motion);
7101
7210
  }
7102
7211
  /**
7103
- * Writes the rest values of the named components at once, leaving out muted fields.
7212
+ * Writes fields of one rest component at once while a loop of the view may run. A field a loop
7213
+ * adds to is written with the offset on top, so the picture does not lose the offset for a
7214
+ * frame. Its new value is first handed to the driver: the driver keeps its own base for such a
7215
+ * field and would write the old one back with its next frame. A track of 0 ms to the rest value
7216
+ * does that, and it is finished at once. So the base is moved before this call returns, whatever
7217
+ * cancels the motions of the view next and also in a world that is not live, and no handle is
7218
+ * left with the view. The shown value is written last: a driver that adds no offset on `finish`
7219
+ * must not have the last word. A field no loop adds to is written as it is, and muted fields are
7220
+ * left out.
7221
+ *
7222
+ * @param pctx - Domain context of the projection module.
7223
+ * @param view - The view to write.
7224
+ * @param value - The rest value of the component, which names its type.
7225
+ * @param patch - The fields to write, with their rest values.
7226
+ */
7227
+ function writeUnderLoop(pctx, view, value, patch) {
7228
+ const offsets = loopOffsets(pctx, view.entity, asComponent$1(value.type));
7229
+ const shown = {};
7230
+ const based = {};
7231
+ for (const [field, entry] of Object.entries(patch)) {
7232
+ const offset = offsets[field];
7233
+ if (offset === void 0 || typeof entry !== "number") {
7234
+ shown[field] = entry;
7235
+ continue;
7236
+ }
7237
+ shown[field] = entry + offset;
7238
+ based[field] = entry;
7239
+ }
7240
+ if (Object.keys(based).length > 0) startTrack$2(pctx, view.entity, asComponent$1(value.type), based, {
7241
+ ms: 0,
7242
+ ease: "linear",
7243
+ delayMs: 0,
7244
+ additive: false
7245
+ }).finish();
7246
+ writeUnmuted(pctx, view.entity, value, shown);
7247
+ }
7248
+ /**
7249
+ * Writes the rest values of the named components at once where a loop of the view may still
7250
+ * run: in play mode, and when `settle(entity)` writes in a world that is not live. A value under
7251
+ * a loop keeps the offset on top and reaches the driver too, as `writeUnderLoop` says. A direct
7252
+ * reconcile needs none of this and uses `writeRestNow`: its flush ends every loop.
7253
+ *
7254
+ * @param pctx - Domain context of the projection module.
7255
+ * @param view - The view to correct.
7256
+ * @param components - The component names to write.
7257
+ */
7258
+ function writeRestUnderLoops(pctx, view, components) {
7259
+ for (const name of components) {
7260
+ const value = view.rest.get(name);
7261
+ if (value === void 0 || value.value === true) continue;
7262
+ writeUnderLoop(pctx, view, value, value.value);
7263
+ }
7264
+ }
7265
+ /**
7266
+ * Writes the rest values of the named components at once, leaving out muted fields. It is the
7267
+ * write of a direct reconcile, whose flush ends every loop of the view: a value written under a
7268
+ * loop that runs on goes through `writeRestUnderLoops`.
7104
7269
  *
7105
7270
  * @param pctx - Domain context of the projection module.
7106
7271
  * @param view - The view to correct.
@@ -7159,23 +7324,54 @@ function atRest(stored, rest) {
7159
7324
  return deepEquals(stored, rest);
7160
7325
  }
7161
7326
  /**
7162
- * The convergence check of the last motion: every rest component is compared with the stored
7163
- * value, muted fields excluded, numbers within `REST_TOLERANCE`. A difference is written and
7164
- * reported.
7327
+ * The fields of one rest component that are off their rest value: muted fields left out, numbers
7328
+ * within `REST_TOLERANCE`. The value under the loop of the view is what is compared, so the
7329
+ * offset of a loop is no miss, and a miss in a field a loop adds to is one. A tag and a component
7330
+ * the entity does not carry answer nothing.
7331
+ *
7332
+ * @param pctx - Domain context of the projection module.
7333
+ * @param view - The view to check.
7334
+ * @param name - Name of the rest component.
7335
+ * @param value - The rest value of that component.
7336
+ * @returns The fields to write to bring the component home, by field name, with their rest
7337
+ * values.
7338
+ */
7339
+ function offRestFields(pctx, view, name, value) {
7340
+ const patch = {};
7341
+ if (value.value === true) return patch;
7342
+ const under = valueUnderLoop(pctx, view, value);
7343
+ if (under === void 0) return patch;
7344
+ const muted = mutedFields(pctx, view.entity, name);
7345
+ for (const [field, entry] of Object.entries(value.value)) if (!muted.has(field) && !atRest(under[field], entry)) patch[field] = entry;
7346
+ return patch;
7347
+ }
7348
+ /**
7349
+ * Tells whether a view carries a component off its rest value: a field another writer owns does
7350
+ * not count, a number counts as at rest within `REST_TOLERANCE`, and what a loop adds does not
7351
+ * count. A rollback asks it before it settles a view whose item did not change.
7352
+ *
7353
+ * @param pctx - Domain context of the projection module.
7354
+ * @param view - The view to check.
7355
+ * @returns True when at least one field of one rest component is off its rest value.
7356
+ */
7357
+ function standsOffRest(pctx, view) {
7358
+ for (const [name, value] of view.rest) if (Object.keys(offRestFields(pctx, view, name, value)).length > 0) return true;
7359
+ return false;
7360
+ }
7361
+ /**
7362
+ * The convergence check of the last motion: every rest component is compared with the value
7363
+ * under the loop of the view, muted fields excluded, numbers within `REST_TOLERANCE`. A
7364
+ * difference is written and reported. A field a loop adds to is handed to the driver and written
7365
+ * with the offset on top, so the correction does not jump and is not undone by the loop.
7165
7366
  *
7166
7367
  * @param pctx - Domain context of the projection module.
7167
7368
  * @param view - The view that came to rest.
7168
7369
  */
7169
7370
  function checkConvergence(pctx, view) {
7170
7371
  for (const [name, value] of view.rest) {
7171
- if (value.value === true) continue;
7172
- const stored = storedValue(pctx, view.entity, value.type);
7173
- if (stored === void 0) continue;
7174
- const muted = mutedFields(pctx, view.entity, name);
7175
- const patch = {};
7176
- for (const [field, entry] of Object.entries(value.value)) if (!muted.has(field) && !atRest(stored[field], entry)) patch[field] = entry;
7372
+ const patch = offRestFields(pctx, view, name, value);
7177
7373
  if (Object.keys(patch).length === 0) continue;
7178
- pctx.deps.ecs.set(view.entity, asComponent$1(value.type), patch);
7374
+ writeUnderLoop(pctx, view, value, patch);
7179
7375
  pctx.ctx.log.warn("world:view-corrected", {
7180
7376
  projection: view.projection,
7181
7377
  key: view.key,
@@ -7186,8 +7382,10 @@ function checkConvergence(pctx, view) {
7186
7382
  //#endregion
7187
7383
  //#region src/plugins/world/projection/queue.ts
7188
7384
  /**
7189
- * @file world/projection — the despawn queue: an exited view stays drawn until its last motion
7190
- * ends, and every flush empties it in the same call.
7385
+ * @file world/projection — the despawn queue and the frame sweep: an exited view stays drawn
7386
+ * until its last motion ends, and every flush empties the queue in the same call. The sweep and
7387
+ * the flush also apply a pending `lift(false)`, the sweep forgets what is kept of a dead element
7388
+ * and starts the loops the views owe.
7191
7389
  */
7192
7390
  /**
7193
7391
  * Creates the empty item table of a flushed projection. It lives in its own non-exported function
@@ -7249,6 +7447,32 @@ function dequeueRevive(pctx, mounted, view, spec) {
7249
7447
  mounted.live.set(view.key, view);
7250
7448
  }
7251
7449
  /**
7450
+ * Applies a pending `lift(false)`: the view leaves the lift layer for the layer of its
7451
+ * projection. A view the finger still holds carries no pending drop and stays lifted.
7452
+ *
7453
+ * @param pctx - Domain context of the projection module.
7454
+ * @param view - The view whose last motion ended.
7455
+ */
7456
+ function dropPending(pctx, view) {
7457
+ if (!view.dropWhenStill) return;
7458
+ const spec = pctx.ctx.state.projection.specs.get(view.projection);
7459
+ if (spec === void 0) return;
7460
+ view.dropWhenStill = false;
7461
+ view.lifted = false;
7462
+ writeLayer(pctx, view.entity, spec.layer);
7463
+ }
7464
+ /**
7465
+ * Applies the pending `lift(false)` of a view no motion moves any more. A play reconcile and
7466
+ * `settle(entity)` call it where they cancelled the motions of a view: with nothing loose no new
7467
+ * motion starts, the view holds no handle, and the sweep skips a view without handles.
7468
+ *
7469
+ * @param pctx - Domain context of the projection module.
7470
+ * @param view - The view whose motions were cancelled.
7471
+ */
7472
+ function dropIfStill(pctx, view) {
7473
+ if (!stillMoving(view)) dropPending(pctx, view);
7474
+ }
7475
+ /**
7252
7476
  * The sweep of phase `animate`: queued views whose motions ended are despawned, and a live view
7253
7477
  * that came to rest is checked against its rest pose and gets its pending `lift(false)`.
7254
7478
  *
@@ -7257,24 +7481,63 @@ function dequeueRevive(pctx, mounted, view, spec) {
7257
7481
  function sweepQueue(pctx) {
7258
7482
  const state = pctx.ctx.state.projection;
7259
7483
  forgetEndedTracks(pctx);
7260
- for (const [name, mounted] of state.mounted) {
7261
- const spec = state.specs.get(name);
7484
+ for (const mounted of state.mounted.values()) {
7262
7485
  for (const view of [...mounted.queue]) if (!stillMoving(view)) despawnView(pctx, mounted, view);
7263
7486
  for (const view of [...mounted.live.values()]) {
7264
7487
  if (view.handles.length === 0 || stillMoving(view)) continue;
7265
7488
  view.handles = [];
7266
7489
  checkConvergence(pctx, view);
7267
- if (view.dropWhenStill && spec !== void 0) {
7268
- view.dropWhenStill = false;
7269
- view.lifted = false;
7270
- writeLayer(pctx, view.entity, spec.layer);
7271
- }
7490
+ dropPending(pctx, view);
7491
+ }
7492
+ }
7493
+ }
7494
+ /**
7495
+ * Drops what the projection keeps about an element whose entity is gone: the rest pose `setRest`
7496
+ * or `viewOf` recorded, and the key `registerKey` published. An owner that despawns its element
7497
+ * without calling the key remover leaves both, and `entityOf` would go on answering with an
7498
+ * entity that is gone. The sweep calls it once per frame, and the converge step before it
7499
+ * writes. A key a newer element took over stays, and the remover that comes late is still safe.
7500
+ *
7501
+ * @param pctx - Domain context of the projection module.
7502
+ */
7503
+ function forgetDeadElements(pctx) {
7504
+ const { ecs } = pctx.deps;
7505
+ const state = pctx.ctx.state.projection;
7506
+ for (const entity of state.rests.keys()) if (ecs.ownerOf(entity) === void 0) state.rests.delete(entity);
7507
+ for (const [entity, { projection, key }] of state.keysByEntity) {
7508
+ if (ecs.ownerOf(entity) !== void 0) continue;
7509
+ state.keysByEntity.delete(entity);
7510
+ const byKey = state.keys.get(projection);
7511
+ if (byKey?.get(key) === entity) byKey.delete(key);
7512
+ }
7513
+ }
7514
+ /**
7515
+ * Starts the loop of every live view that owes one and clears the flag: a view that entered in a
7516
+ * direct reconcile, and a view whose tracks a flush ended. The sweep calls it, so it runs only
7517
+ * while the world is live, which also covers a world that comes back from `paused` or `fast`
7518
+ * with no reconcile of its own. Without a tween driver nothing starts and the views keep owing:
7519
+ * the instant driver would write the end pose of the loop once. A view of a projection without
7520
+ * a `loop` hook only has its flag cleared.
7521
+ *
7522
+ * @param pctx - Domain context of the projection module.
7523
+ */
7524
+ function startOwedLoops(pctx) {
7525
+ const state = pctx.ctx.state.projection;
7526
+ if (state.driver === void 0) return;
7527
+ for (const [name, mounted] of state.mounted) {
7528
+ const spec = state.specs.get(name);
7529
+ for (const view of mounted.live.values()) {
7530
+ if (!view.loopOwed) continue;
7531
+ view.loopOwed = false;
7532
+ if (spec !== void 0) startLoop$1(pctx, view, spec, restingPeers(mounted));
7272
7533
  }
7273
7534
  }
7274
7535
  }
7275
7536
  /**
7276
7537
  * Flushes one mounted projection: every motion is finished and the despawn queue is emptied in
7277
- * the same call.
7538
+ * the same call. The flush leaves a live view without a handle, and the sweep skips such a view,
7539
+ * so a pending `lift(false)` is applied here. It also ends the loop of a live view with its
7540
+ * other tracks, so the view owes its loop from here on.
7278
7541
  *
7279
7542
  * @param pctx - Domain context of the projection module.
7280
7543
  * @param mounted - The mounted projection.
@@ -7283,6 +7546,8 @@ function flushMounted(pctx, mounted) {
7283
7546
  for (const view of [...mounted.live.values()]) {
7284
7547
  finishHandles(view);
7285
7548
  cancelTracks(pctx, view.entity);
7549
+ dropPending(pctx, view);
7550
+ view.loopOwed = true;
7286
7551
  }
7287
7552
  for (const view of [...mounted.queue]) {
7288
7553
  finishHandles(view);
@@ -7410,6 +7675,7 @@ function hintFor$1(pctx, projection, key, used) {
7410
7675
  * A key that is new: spawn the entity with the view components and the projection's layer, then
7411
7676
  * play the `enter` hook and the `loop` hook when the reconcile plays motions. The loop is not
7412
7677
  * kept with the view's handles: a change cancels those, and the loop runs until the view dies.
7678
+ * A direct reconcile plays neither hook. The view then owes its loop, which the sweep starts.
7413
7679
  *
7414
7680
  * @param pctx - Domain context of the projection module.
7415
7681
  * @param spec - The projection spec.
@@ -7432,7 +7698,11 @@ function enterEntry(pctx, spec, mounted, entry) {
7432
7698
  pctx.ctx.state.projection.byEntity.set(entity, view);
7433
7699
  const hook = spec.motion?.enter;
7434
7700
  const loop = spec.motion?.loop;
7435
- if (entry.direct || hook === void 0 && loop === void 0) return;
7701
+ if (entry.direct) {
7702
+ view.loopOwed = true;
7703
+ return;
7704
+ }
7705
+ if (hook === void 0 && loop === void 0) return;
7436
7706
  const handle = createViewHandle(pctx, view, entry.peers);
7437
7707
  if (hook !== void 0) {
7438
7708
  const hint = hintFor$1(pctx, spec.name, entry.key, entry.used);
@@ -7443,22 +7713,37 @@ function enterEntry(pctx, spec, mounted, entry) {
7443
7713
  playSettle(pctx, view, spec, entry.peers, looseComponents(pctx, view));
7444
7714
  }
7445
7715
  /**
7446
- * A key whose item changed: structural changes are written, a changed component with a hook is
7447
- * handed to it and one without is written directly, then the retarget settles what is loose.
7716
+ * A key whose item is another object than before: structural changes are written, a changed
7717
+ * component with a hook is handed to it and one without is written directly, then the retarget
7718
+ * settles what is loose. A view no motion moves after that gets its pending `lift(false)`.
7719
+ *
7720
+ * In play mode an item with the same content is no change: when the component diff is empty the
7721
+ * new item is stored and nothing else happens. No motion of the view is cancelled, no hook runs,
7722
+ * nothing is written, no loose component is settled and no hint is used. A `from` that builds a
7723
+ * new object on every commit so keeps the motions of its views. A direct reconcile, and a forced
7724
+ * one, project the item as before.
7448
7725
  *
7449
7726
  * @param pctx - Domain context of the projection module.
7450
7727
  * @param spec - The projection spec.
7451
7728
  * @param view - The live view.
7452
- * @param entry - The item, the peers, the mode and the used hints.
7729
+ * @param entry - The item, the peers, the mode, the force flag, the used hints and the list a
7730
+ * direct reconcile fills.
7453
7731
  * @param entry.item - The new model item.
7454
7732
  * @param entry.peers - The new and the old items.
7455
7733
  * @param entry.direct - Whether the reconcile writes without motions.
7734
+ * @param entry.force - Whether an unchanged item is projected again.
7456
7735
  * @param entry.used - The hints that reached an entry.
7736
+ * @param entry.rewritten - The views a direct reconcile wrote, written again after its flush.
7737
+ * @returns False when the item has the same content as before and was only stored.
7457
7738
  */
7458
7739
  function changeEntry(pctx, spec, view, entry) {
7459
7740
  const next = collectView(pctx, spec, entry.item);
7460
7741
  const { added, changed, removed } = diffComponents(view.rest, next, treeName(pctx));
7461
7742
  const previous = view.item;
7743
+ if (!entry.direct && !entry.force && added.length + changed.length + removed.length === 0) {
7744
+ view.item = entry.item;
7745
+ return false;
7746
+ }
7462
7747
  for (const value of valuesOf(next, added)) writeValue(pctx, view.entity, value);
7463
7748
  for (const value of valuesOf(view.rest, removed)) pctx.deps.ecs.remove(view.entity, value.type);
7464
7749
  view.rest = next;
@@ -7466,7 +7751,8 @@ function changeEntry(pctx, spec, view, entry) {
7466
7751
  if (entry.direct) {
7467
7752
  finishHandles(view);
7468
7753
  writeRestNow(pctx, view, [...next.keys()]);
7469
- return;
7754
+ entry.rewritten.push(view);
7755
+ return true;
7470
7756
  }
7471
7757
  cancelHandles(view);
7472
7758
  const handle = createViewHandle(pctx, view, entry.peers);
@@ -7482,21 +7768,25 @@ function changeEntry(pctx, spec, view, entry) {
7482
7768
  if (played === void 0) written.push(name);
7483
7769
  else if (played.motion !== void 0) view.handles.push(played.motion);
7484
7770
  }
7485
- writeRestNow(pctx, view, written);
7771
+ writeRestUnderLoops(pctx, view, written);
7486
7772
  playSettle(pctx, view, spec, entry.peers, looseComponents(pctx, view));
7773
+ dropIfStill(pctx, view);
7774
+ return true;
7487
7775
  }
7488
7776
  /**
7489
7777
  * A key that came back while its exit still played: the SAME view is revived, no `enter` and no
7490
- * `change` hook runs, and one settle over the loose components brings it home.
7778
+ * `change` hook runs, and one settle over the loose components brings it home. A view no motion
7779
+ * moves after that gets its pending `lift(false)`.
7491
7780
  *
7492
7781
  * @param pctx - Domain context of the projection module.
7493
7782
  * @param spec - The projection spec.
7494
7783
  * @param mounted - The mounted projection.
7495
7784
  * @param view - The queued view.
7496
- * @param entry - The item, the peers and the mode.
7785
+ * @param entry - The item, the peers, the mode and the list a direct reconcile fills.
7497
7786
  * @param entry.item - The new model item.
7498
7787
  * @param entry.peers - The new and the old items.
7499
7788
  * @param entry.direct - Whether the reconcile writes without motions.
7789
+ * @param entry.rewritten - The views a direct reconcile wrote, written again after its flush.
7500
7790
  */
7501
7791
  function reviveEntry(pctx, spec, mounted, view, entry) {
7502
7792
  cancelHandles(view);
@@ -7509,9 +7799,11 @@ function reviveEntry(pctx, spec, mounted, view, entry) {
7509
7799
  view.item = entry.item;
7510
7800
  if (entry.direct) {
7511
7801
  writeRestNow(pctx, view, [...next.keys()]);
7802
+ entry.rewritten.push(view);
7512
7803
  return;
7513
7804
  }
7514
7805
  playSettle(pctx, view, spec, entry.peers, looseComponents(pctx, view));
7806
+ dropIfStill(pctx, view);
7515
7807
  }
7516
7808
  /**
7517
7809
  * A live key that is gone: despawned at once without an `exit` hook, otherwise moved into the
@@ -7542,27 +7834,57 @@ function exitEntry(pctx, spec, mounted, view, entry) {
7542
7834
  if (!stillMoving(view)) despawnView(pctx, mounted, view);
7543
7835
  }
7544
7836
  /**
7837
+ * A live key whose item did not change, on a rollback: the same object, or in play mode a new
7838
+ * one with the same content. The failed node committed nothing, so no entry brings back a view
7839
+ * it moved with a motion or a timeline. A view that stands off its rest pose is settled as
7840
+ * `projection.settle(entity)` does: its motions are cancelled and the settle is played for the
7841
+ * loose components. With nothing loose a pending `lift(false)` takes effect. A view that stands
7842
+ * on its rest pose is left alone, so its motions run on. That holds for a view that loops too:
7843
+ * the value under its loop is what is compared. A direct reconcile only remembers the view: its
7844
+ * rest pose is written after the flush, in `writeUnchanged`.
7845
+ *
7846
+ * @param pctx - Domain context of the projection module.
7847
+ * @param spec - The projection spec.
7848
+ * @param view - The live view whose item is the same as before.
7849
+ * @param entry - The peers, the mode and the list a direct reconcile fills.
7850
+ * @param entry.peers - The new and the old items.
7851
+ * @param entry.direct - Whether the reconcile writes without motions.
7852
+ * @param entry.unchanged - The unchanged views of a direct reconcile, written after its flush.
7853
+ */
7854
+ function settleUnchanged(pctx, spec, view, entry) {
7855
+ if (entry.direct) {
7856
+ entry.unchanged.push(view);
7857
+ return;
7858
+ }
7859
+ if (!standsOffRest(pctx, view)) return;
7860
+ cancelHandles(view);
7861
+ playSettle(pctx, view, spec, entry.peers, looseComponents(pctx, view));
7862
+ dropIfStill(pctx, view);
7863
+ }
7864
+ /**
7545
7865
  * Routes one key of the new state to the entry it belongs to: a live view changes, a queued view
7546
7866
  * revives, an unknown key enters.
7547
7867
  *
7548
7868
  * @param pctx - Domain context of the projection module.
7549
7869
  * @param spec - The projection spec.
7550
7870
  * @param mounted - The mounted projection.
7551
- * @param entry - The key, its item, the peers, the mode, the force flag and the used hints.
7871
+ * @param entry - The key, its item, the peers, the mode, the flags and the used hints.
7552
7872
  * @param entry.key - The model key.
7553
7873
  * @param entry.item - The model item.
7554
7874
  * @param entry.peers - The new and the old items.
7555
7875
  * @param entry.direct - Whether the reconcile writes without motions.
7556
7876
  * @param entry.force - Whether an unchanged item is projected again.
7877
+ * @param entry.settle - Whether an unchanged view that stands off its rest pose is settled.
7557
7878
  * @param entry.used - The hints that reached an entry.
7879
+ * @param entry.unchanged - The unchanged views of a direct reconcile that settles.
7880
+ * @param entry.rewritten - The views a direct reconcile wrote, written again after its flush.
7558
7881
  * @param counts - The counters of this reconcile.
7559
7882
  */
7560
7883
  function applyEntry(pctx, spec, mounted, entry, counts) {
7561
7884
  const live = mounted.live.get(entry.key);
7562
7885
  if (live !== void 0) {
7563
- if (!entry.force && live.item === entry.item) return;
7564
- changeEntry(pctx, spec, live, entry);
7565
- counts.changed += 1;
7886
+ if (!(!entry.force && live.item === entry.item) && changeEntry(pctx, spec, live, entry)) counts.changed += 1;
7887
+ else if (entry.settle) settleUnchanged(pctx, spec, live, entry);
7566
7888
  return;
7567
7889
  }
7568
7890
  const queued = mounted.queue.find((view) => view.key === entry.key);
@@ -7645,20 +7967,47 @@ function convergeMounted(pctx, mounted) {
7645
7967
  for (const view of mounted.live.values()) writeRestNow(pctx, view, [...view.rest.keys()]);
7646
7968
  }
7647
7969
  /**
7970
+ * The last step of a direct reconcile that does not converge: every view an entry wrote is
7971
+ * written to its rest pose once more. The flush cancels the loop of a view with its other
7972
+ * tracks, and the driver writes the base it kept for the fields the loop added to back: the old
7973
+ * rest value. So the write of the entry, which came before the flush, would be lost in those
7974
+ * fields. Muted fields are left out.
7975
+ *
7976
+ * @param pctx - Domain context of the projection module.
7977
+ * @param rewritten - The live views a changed or revived entry wrote in this reconcile.
7978
+ */
7979
+ function writeAgain(pctx, rewritten) {
7980
+ for (const view of rewritten) writeRestNow(pctx, view, [...view.rest.keys()]);
7981
+ }
7982
+ /**
7983
+ * The last step of a direct reconcile a rollback caused: an unchanged view that stands off its
7984
+ * rest pose is written to it. It runs after the flush, as `convergeMounted` does and for the same
7985
+ * reason: the flush finishes the motions of the view, and a cancelled additive track writes its
7986
+ * base back, so the rest write has to come last. Muted fields are left out.
7987
+ *
7988
+ * @param pctx - Domain context of the projection module.
7989
+ * @param unchanged - The live views whose item did not change in this reconcile.
7990
+ */
7991
+ function writeUnchanged(pctx, unchanged) {
7992
+ for (const view of unchanged) if (standsOffRest(pctx, view)) writeRestNow(pctx, view, [...view.rest.keys()]);
7993
+ }
7994
+ /**
7648
7995
  * The same last step for the elements another plugin owns, which no reconcile of a projection
7649
7996
  * meets: each is written to the rest pose its owner recorded with `setRest`. Every track on the
7650
7997
  * entity is ended first, so nothing writes after it. Only a component the entity still carries
7651
7998
  * is written and muted fields are left out. An element that is leaving has its tracks ended and
7652
- * keeps its place, and the recorded pose of an entity that is gone is skipped. So is one recorded
7653
- * for a projection view: its own rest pose wins, as in `restOf`, and it is already home.
7999
+ * keeps its place. The recorded pose and the registered key of an entity that is gone are
8000
+ * dropped before anything is written. A pose recorded for a projection view is skipped: its own
8001
+ * rest pose wins, as in `restOf`, and it is already home.
7654
8002
  *
7655
8003
  * @param pctx - Domain context of the projection module.
7656
8004
  */
7657
8005
  function convergeElements(pctx) {
7658
8006
  const { ecs, components } = pctx.deps;
7659
8007
  const state = pctx.ctx.state.projection;
8008
+ forgetDeadElements(pctx);
7660
8009
  for (const [entity, rest] of state.rests) {
7661
- if (ecs.ownerOf(entity) === void 0 || state.byEntity.has(entity)) continue;
8010
+ if (state.byEntity.has(entity)) continue;
7662
8011
  cancelTracks(pctx, entity);
7663
8012
  if (ecs.has(entity, components.Exiting)) continue;
7664
8013
  const carried = [];
@@ -7687,20 +8036,26 @@ function tellConverged(pctx) {
7687
8036
  * @param spec - The projection spec.
7688
8037
  * @param mounted - The mounted projection.
7689
8038
  * @param snapshot - The model snapshot of this reconcile.
7690
- * @param options - Mode, force flag and converge flag of this reconcile.
8039
+ * @param options - Mode, force flag, converge flag and settle flag of this reconcile.
7691
8040
  * @param counts - The counters of this reconcile.
7692
8041
  * @param used - The hints that reached an entry.
7693
8042
  */
7694
8043
  function reconcileOne(pctx, spec, mounted, snapshot, options, counts, used) {
7695
8044
  const next = keyedItems(pctx, spec, snapshot);
7696
8045
  if (next === void 0) return;
8046
+ const peers = {
8047
+ next,
8048
+ previous: mounted.items
8049
+ };
8050
+ const unchanged = [];
8051
+ const rewritten = [];
7697
8052
  const entry = {
7698
- peers: {
7699
- next,
7700
- previous: mounted.items
7701
- },
8053
+ peers,
7702
8054
  direct: options.direct,
7703
- used
8055
+ settle: options.settle,
8056
+ used,
8057
+ unchanged,
8058
+ rewritten
7704
8059
  };
7705
8060
  try {
7706
8061
  for (const [key, item] of next) applyEntry(pctx, spec, mounted, {
@@ -7717,6 +8072,10 @@ function reconcileOne(pctx, spec, mounted, snapshot, options, counts, used) {
7717
8072
  mounted.items = next;
7718
8073
  if (options.direct) flushMounted(pctx, mounted);
7719
8074
  if (options.converge) convergeMounted(pctx, mounted);
8075
+ else {
8076
+ writeAgain(pctx, rewritten);
8077
+ writeUnchanged(pctx, unchanged);
8078
+ }
7720
8079
  counts.queued += mounted.queue.length;
7721
8080
  }
7722
8081
  /**
@@ -7724,8 +8083,9 @@ function reconcileOne(pctx, spec, mounted, snapshot, options, counts, used) {
7724
8083
  * one then brings the elements other plugins own home and tells their owners.
7725
8084
  *
7726
8085
  * @param pctx - Domain context of the projection module.
7727
- * @param options - Which projections, play or direct, whether `view` is forced, and whether
7728
- * every live view and every element is brought to its rest pose at the end.
8086
+ * @param options - Which projections, play or direct, whether `view` is forced, whether every
8087
+ * live view and every element is brought to its rest pose at the end, and whether an unchanged
8088
+ * view that stands off its rest pose is settled.
7729
8089
  */
7730
8090
  function reconcile(pctx, options) {
7731
8091
  const state = pctx.ctx.state.projection;
@@ -7750,18 +8110,6 @@ function reconcile(pctx, options) {
7750
8110
  ...counts
7751
8111
  });
7752
8112
  }
7753
- /**
7754
- * Re-projects one view with the same item, used by `lift` and `settle` to reach the peers.
7755
- *
7756
- * @param mounted - The mounted projection.
7757
- * @returns The peers of a call outside a reconcile: the items of the last one.
7758
- */
7759
- function restingPeers(mounted) {
7760
- return {
7761
- next: mounted.items,
7762
- previous: mounted.items
7763
- };
7764
- }
7765
8113
  //#endregion
7766
8114
  //#region src/plugins/world/projection/api.ts
7767
8115
  /**
@@ -7925,8 +8273,9 @@ function createProjectionApi(ctx, deps) {
7925
8273
  };
7926
8274
  const state = ctx.state.projection;
7927
8275
  /**
7928
- * Reconciles what is dirty. The cause table decides play or direct, and whether every live view
7929
- * and every element with a recorded rest pose is brought to it: a restore and a load do that.
8276
+ * Reconciles what is dirty. The cause table decides play or direct, whether every live view
8277
+ * and every element with a recorded rest pose is brought to it (a restore and a load do that),
8278
+ * and whether an unchanged view that stands off its rest pose is settled (a rollback does).
7930
8279
  */
7931
8280
  const reconcileIfDirty = () => {
7932
8281
  const dirty = state.dirty;
@@ -7934,10 +8283,12 @@ function createProjectionApi(ctx, deps) {
7934
8283
  if (dirty !== void 0 && (dirty.roots.has("player") || dirty.roots.has("session"))) {
7935
8284
  const direct = dirty.force || dirty.causes.some((cause) => isDirectCause(cause)) || deps.ecs.mode() !== "live";
7936
8285
  const converge = dirty.causes.some((cause) => isConvergeCause(cause));
8286
+ const settle = dirty.causes.includes("rollback");
7937
8287
  reconcile(pctx, {
7938
8288
  direct,
7939
8289
  force: dirty.force,
7940
- converge
8290
+ converge,
8291
+ settle
7941
8292
  });
7942
8293
  }
7943
8294
  state.hints.length = 0;
@@ -7963,6 +8314,7 @@ function createProjectionApi(ctx, deps) {
7963
8314
  handles: [],
7964
8315
  lifted: false,
7965
8316
  dropWhenStill: false,
8317
+ loopOwed: false,
7966
8318
  exiting: false
7967
8319
  });
7968
8320
  return {
@@ -7998,7 +8350,8 @@ function createProjectionApi(ctx, deps) {
7998
8350
  names: fresh.map((spec) => spec.name),
7999
8351
  direct: true,
8000
8352
  force: true,
8001
- converge: false
8353
+ converge: false,
8354
+ settle: false
8002
8355
  });
8003
8356
  },
8004
8357
  unmount: (names) => {
@@ -8022,15 +8375,12 @@ function createProjectionApi(ctx, deps) {
8022
8375
  cancelHandles(view);
8023
8376
  forgetEndedTracks(pctx);
8024
8377
  if (deps.ecs.mode() !== "live") {
8025
- writeRestNow(pctx, view, [...view.rest.keys()]);
8026
- if (view.dropWhenStill) {
8027
- view.dropWhenStill = false;
8028
- view.lifted = false;
8029
- writeLayer(pctx, entity, spec.layer);
8030
- }
8378
+ writeRestUnderLoops(pctx, view, [...view.rest.keys()]);
8379
+ dropPending(pctx, view);
8031
8380
  return;
8032
8381
  }
8033
8382
  playSettle(pctx, view, spec, restingPeers(mounted), looseComponents(pctx, view));
8383
+ dropIfStill(pctx, view);
8034
8384
  },
8035
8385
  mute: (entity, component, fields) => {
8036
8386
  const byComponent = state.mutes.get(entity) ?? emptyMuteTable();
@@ -8137,6 +8487,8 @@ function createProjectionApi(ctx, deps) {
8137
8487
  sweep: () => {
8138
8488
  if (deps.ecs.mode() !== "live") return;
8139
8489
  sweepQueue(pctx);
8490
+ forgetDeadElements(pctx);
8491
+ startOwedLoops(pctx);
8140
8492
  },
8141
8493
  flushAll: () => {
8142
8494
  for (const view of state.byEntity.values()) {
@@ -14354,6 +14706,52 @@ function sumOffsets(actx, key) {
14354
14706
  return sum;
14355
14707
  }
14356
14708
  /**
14709
+ * What the running additive tracks of one component of one entity add to each of its fields
14710
+ * right now: the offset of a loop. The `TweenDriver` seam hands it to `world`, which takes it off
14711
+ * the stored value before it compares a view with its rest pose. A field no additive track adds
14712
+ * to is left out.
14713
+ *
14714
+ * A field another writer owns keeps the offset of the last write of its track. That is what
14715
+ * stands in the field while the writer only holds it, and what the track adds again when the
14716
+ * field is given back. So a view the hand held still reads as home, and a view the hand put
14717
+ * somewhere reads as moved.
14718
+ *
14719
+ * @param actx - Domain context of the anim plugin.
14720
+ * @param entity - The entity to ask about.
14721
+ * @param component - The component to ask about.
14722
+ * @returns Field name to the sum of the offsets on it.
14723
+ */
14724
+ function addedOffsets(actx, entity, component) {
14725
+ const sums = {};
14726
+ for (const track of actx.state.additive.get(entity) ?? []) {
14727
+ if (track.component.componentName !== component.componentName) continue;
14728
+ for (const field of Object.keys(track.to)) sums[field] = sumOffsets(actx, keyOf$1(track, field));
14729
+ }
14730
+ return sums;
14731
+ }
14732
+ /**
14733
+ * Files a new additive track under its entity, so `addedOffsets` finds it without a walk of the
14734
+ * whole track table.
14735
+ *
14736
+ * @param actx - Domain context of the anim plugin.
14737
+ * @param track - The additive track that starts.
14738
+ */
14739
+ function fileAdditive(actx, track) {
14740
+ const tracks = actx.state.additive.get(track.entity) ?? /* @__PURE__ */ new Set();
14741
+ tracks.add(track);
14742
+ actx.state.additive.set(track.entity, tracks);
14743
+ }
14744
+ /**
14745
+ * Takes an ended additive track out of the table of its entity, and the entity with its last one.
14746
+ *
14747
+ * @param actx - Domain context of the anim plugin.
14748
+ * @param track - The track that ended.
14749
+ */
14750
+ function unfileAdditive(actx, track) {
14751
+ const tracks = actx.state.additive.get(track.entity);
14752
+ if (tracks?.delete(track) === true && tracks.size === 0) actx.state.additive.delete(track.entity);
14753
+ }
14754
+ /**
14357
14755
  * Reads the start values of a track, once, when its delay has ended. The base of a field another
14358
14756
  * track already drives is used, so an additive offset is never folded into a start value.
14359
14757
  *
@@ -14424,6 +14822,53 @@ function fieldValue$1(track, field, start, target, fraction) {
14424
14822
  return walkValue(track.segments, track.ease, field, start, fraction);
14425
14823
  }
14426
14824
  /**
14825
+ * Tells whether another writer owns a field at this write, so the track leaves it alone. An
14826
+ * additive track remembers that it skipped the field: it has to find the value under it again
14827
+ * once the field is given back.
14828
+ *
14829
+ * @param track - The track that is about to write.
14830
+ * @param owned - The fields another writer owns now.
14831
+ * @param field - The field.
14832
+ * @returns True when the track must not write the field.
14833
+ */
14834
+ function skipsOwned(track, owned, field) {
14835
+ if (!owned.has(field)) return false;
14836
+ if (track.additive) track.masked.add(field);
14837
+ return true;
14838
+ }
14839
+ /**
14840
+ * How far a field may sit from the value the table last wrote into it and still count as
14841
+ * untouched by another writer. The same size as the rest tolerance of `world`.
14842
+ */
14843
+ const UNTOUCHED = 1e-6;
14844
+ /**
14845
+ * Books what an additive track adds to one field in this frame. On its first write after another
14846
+ * writer gave the field back it also moves the base of the field, when that writer moved the
14847
+ * field. While the field was owned, the track did not write it and the other writer put it where
14848
+ * it wanted, so the base the table kept is stale: a hand dragged a bobbing view away. The value
14849
+ * that stands in the field becomes what the base and the offsets add up to. So this write leaves
14850
+ * the field where it is, the track runs on from there, and a track that starts now to bring the
14851
+ * field home reads that value as its start. Without it the field would jump back to the old base
14852
+ * in one frame.
14853
+ *
14854
+ * A writer that only held the field left in it what the table last wrote: the base plus the
14855
+ * offsets as they stood. Then nothing moved the value under the track, and the base stays.
14856
+ *
14857
+ * @param actx - Domain context of the anim plugin.
14858
+ * @param track - The additive track.
14859
+ * @param field - The field.
14860
+ * @param delta - What the track adds to the field now.
14861
+ * @param left - The value that stands in the field before this write.
14862
+ */
14863
+ function addOffset(actx, track, field, delta, left) {
14864
+ const key = keyOf$1(track, field);
14865
+ const written = (actx.state.bases.get(key) ?? 0) + sumOffsets(actx, key);
14866
+ offsetsOf(actx, key).set(track.id, delta);
14867
+ if (!track.masked.delete(field) || typeof left !== "number") return;
14868
+ if (Math.abs(left - written) <= UNTOUCHED) return;
14869
+ actx.state.bases.set(key, left - sumOffsets(actx, key));
14870
+ }
14871
+ /**
14427
14872
  * Writes one frame of a track: the eased value for an absolute track, the contributed delta for
14428
14873
  * an additive one, and in both cases the base plus every offset of the field. A component the
14429
14874
  * entity no longer carries ends the track silently.
@@ -14432,6 +14877,9 @@ function fieldValue$1(track, field, start, target, fraction) {
14432
14877
  * delta it adds over the base, so a loop keeps its shape whatever pose it started on. A straight
14433
14878
  * additive track still adds the way from its start value to its target.
14434
14879
  *
14880
+ * A field another writer owns is left alone. An additive track remembers that it skipped the
14881
+ * field, and adds to the value it finds there once the field is given back.
14882
+ *
14435
14883
  * @param actx - Domain context of the anim plugin.
14436
14884
  * @param track - The track to write.
14437
14885
  * @param fraction - Normalised time of the track.
@@ -14449,12 +14897,12 @@ function writeFields(actx, track, fraction, final) {
14449
14897
  const patch = {};
14450
14898
  const walksOffsets = track.additive && track.segments !== void 0;
14451
14899
  for (const [field, target] of Object.entries(track.to)) {
14452
- if (owned.has(field)) continue;
14900
+ if (skipsOwned(track, owned, field)) continue;
14453
14901
  const key = keyOf$1(track, field);
14454
14902
  const start = from[field] ?? target;
14455
14903
  const origin = walksOffsets ? 0 : start;
14456
14904
  const value = final ? target : fieldValue$1(track, field, origin, target, fraction);
14457
- if (track.additive) offsetsOf(actx, key).set(track.id, value - origin);
14905
+ if (track.additive) addOffset(actx, track, field, value - origin, stored[field]);
14458
14906
  else actx.state.bases.set(key, value);
14459
14907
  patch[field] = (actx.state.bases.get(key) ?? start) + sumOffsets(actx, key);
14460
14908
  }
@@ -14496,6 +14944,7 @@ function endTrack(actx, track) {
14496
14944
  if (track.ended) return;
14497
14945
  track.ended = true;
14498
14946
  actx.state.tracks.delete(track.id);
14947
+ if (track.additive) unfileAdditive(actx, track);
14499
14948
  const clean = releaseFields(actx, track);
14500
14949
  const ecs = actx.deps.world.ecs;
14501
14950
  if (Object.keys(clean).length > 0 && ecs.get(track.entity, track.component) !== void 0) ecs.set(track.entity, track.component, clean);
@@ -14563,6 +15012,15 @@ function beginFrame(actx) {
14563
15012
  actx.state.frame += 1;
14564
15013
  }
14565
15014
  /**
15015
+ * The empty set of the fields a new track skipped. It lives in its own function because lint
15016
+ * rule L5 refuses a collection built inside an exported declaration.
15017
+ *
15018
+ * @returns An empty set of field names.
15019
+ */
15020
+ function noMaskedFields() {
15021
+ return /* @__PURE__ */ new Set();
15022
+ }
15023
+ /**
14566
15024
  * The target of a new track: the fields the caller passed, plus every field a keyframe segment
14567
15025
  * names at the last value a segment gives it, so `claimFields` books the whole walk at once.
14568
15026
  *
@@ -14630,11 +15088,13 @@ function startTrack$1(actx, entity, component, to, options, muted, driven = fals
14630
15088
  ended: Object.keys(target).length === 0,
14631
15089
  segments: options.segments,
14632
15090
  repeatsLeft: instant ? 0 : repeats,
14633
- held: false
15091
+ held: false,
15092
+ masked: noMaskedFields()
14634
15093
  };
14635
15094
  actx.state.nextId += 1;
14636
15095
  if (track.ended) return track;
14637
- if (!track.additive) claimFields(actx, track);
15096
+ if (track.additive) fileAdditive(actx, track);
15097
+ else claimFields(actx, track);
14638
15098
  actx.state.tracks.set(track.id, track);
14639
15099
  countPlaying(actx.deps.world.ecs, entity, 1);
14640
15100
  actx.deps.time.wake();
@@ -14763,12 +15223,19 @@ function advanceTracks(actx, deltaMs) {
14763
15223
  for (const track of open) if (!track.additive) advanceTrack(actx, track, deltaMs);
14764
15224
  }
14765
15225
  /**
14766
- * Finishes every track of the table, in table order.
15226
+ * Finishes every track of the table, in table order. With `keepLoops` a loop is left alone: it
15227
+ * has no end to finish on, so it stays in the table and stands where it is. The frame step of a
15228
+ * fast world asks for that, because nothing would start the loop again: a fast walk of the flow
15229
+ * that commits nothing flushes no view, so no view would owe its loop.
14767
15230
  *
14768
15231
  * @param actx - Domain context of the anim plugin.
15232
+ * @param keepLoops - True to leave every track that repeats forever running.
14769
15233
  */
14770
- function finishAllTracks(actx) {
14771
- for (const track of [...actx.state.tracks.values()]) finishTrack(actx, track);
15234
+ function finishAllTracks(actx, keepLoops = false) {
15235
+ for (const track of [...actx.state.tracks.values()]) {
15236
+ if (keepLoops && track.repeatsLeft === Number.POSITIVE_INFINITY) continue;
15237
+ finishTrack(actx, track);
15238
+ }
14772
15239
  }
14773
15240
  /**
14774
15241
  * Ends every track of one entity, absolute and additive, and removes its `Animation`.
@@ -14793,7 +15260,9 @@ function noFields() {
14793
15260
  }
14794
15261
  /**
14795
15262
  * Creates the tween engine `world` runs `ViewHandle.tween`, `toRest` and `all` on. Installed with
14796
- * `world.projection.setDriver` in `onStart`; the remover puts the instant writes back.
15263
+ * `world.projection.setDriver` in `onStart`; the remover puts the instant writes back. It also
15264
+ * reports what its additive tracks add to a component right now, so `world` can compare the
15265
+ * value under a loop with the rest pose.
14797
15266
  *
14798
15267
  * @param actx - Domain context of the anim plugin.
14799
15268
  * @returns The driver.
@@ -14801,7 +15270,8 @@ function noFields() {
14801
15270
  function createDriver(actx) {
14802
15271
  return {
14803
15272
  track: (entity, component, to, options, muted) => handleOf$1(actx, startTrack$1(actx, entity, component, to, options, muted)),
14804
- cancelAll: (entity) => cancelTracksOf(actx, entity)
15273
+ cancelAll: (entity) => cancelTracksOf(actx, entity),
15274
+ offsetsOf: (entity, component) => addedOffsets(actx, entity, component)
14805
15275
  };
14806
15276
  }
14807
15277
  /**
@@ -14927,7 +15397,9 @@ function createRuntime(actx) {
14927
15397
  }
14928
15398
  /**
14929
15399
  * Ends everything that moves: every timeline at its own end, then every track on its exact
14930
- * target. The frame step calls it in fast mode, the API exposes it, the teardown runs it last.
15400
+ * target, a loop too. The API exposes it and the teardown runs it last. It owes no loop by
15401
+ * itself; the next flush of `world` (a load, a restore, a commit in a paused or fast world) owes
15402
+ * the loop of a view again.
14931
15403
  *
14932
15404
  * @param actx - Domain context of the anim plugin.
14933
15405
  * @param rt - The timeline runtime.
@@ -14939,8 +15411,10 @@ function finishAllAnim(actx, rt) {
14939
15411
  /**
14940
15412
  * The one frame step: the timelines consume the delta first, so a track a step just started is
14941
15413
  * advanced by the remainder at once, then every other track advances, then every `Frames` loop.
14942
- * A paused world moves nothing; a fast world ends everything, and a loop, which has no end,
14943
- * stands where it is.
15414
+ * A paused world moves nothing. A fast world ends every timeline and every track that has an
15415
+ * end; a loop, which has none, stays and stands where it is, so it runs on when the world is live
15416
+ * again. That matters for a fast walk of the flow that commits nothing: no view is flushed then,
15417
+ * so no view would get its loop back.
14944
15418
  *
14945
15419
  * @param actx - Domain context of the anim plugin.
14946
15420
  * @param rt - The timeline runtime.
@@ -14950,7 +15424,8 @@ function stepAnim(actx, rt, time) {
14950
15424
  const mode = actx.deps.world.ecs.mode();
14951
15425
  if (mode === "paused") return;
14952
15426
  if (mode === "fast") {
14953
- finishAllAnim(actx, rt);
15427
+ finishAllTimelines(actx, rt);
15428
+ finishAllTracks(actx, true);
14954
15429
  return;
14955
15430
  }
14956
15431
  beginFrame(actx);
@@ -15114,6 +15589,7 @@ function stopAnim(state) {
15114
15589
  state.owner.clear();
15115
15590
  state.offsets.clear();
15116
15591
  state.bases.clear();
15592
+ state.additive.clear();
15117
15593
  state.timelines.clear();
15118
15594
  state.registry.clear();
15119
15595
  state.markListeners.clear();
@@ -15191,6 +15667,7 @@ function createAnimState(ctx) {
15191
15667
  owner: emptyMap$5(),
15192
15668
  offsets: emptyMap$5(),
15193
15669
  bases: emptyMap$5(),
15670
+ additive: emptyMap$5(),
15194
15671
  timelines: emptyMap$5(),
15195
15672
  registry: emptyMap$5(),
15196
15673
  markListeners: emptySet$3(),
@@ -35779,9 +36256,9 @@ function changeHooks(names, transition) {
35779
36256
  * transition is resolved and every name is checked here, at definition time, so every track it
35780
36257
  * starts carries a concrete duration and a wrong name fails where it is written. A `loop` builds
35781
36258
  * the `loop` hook: `world` and `ui` play it wherever they play `enter`, so a projection view loops
35782
- * once it entered with motion, never after a direct reconcile, and `ui` swaps it when the motion
35783
- * prop of an element changes. One cycle of the loop is `loop.ms`, or `transition.ms` when it is
35784
- * left out.
36259
+ * from its enter, or from the first live frame after a direct reconcile, and `ui` swaps it when
36260
+ * the motion prop of an element changes. One cycle of the loop is `loop.ms`, or `transition.ms`
36261
+ * when it is left out.
35785
36262
  *
35786
36263
  * @param spec - The named poses, the keyframe tracks, the transition, the loop and the hooks to
35787
36264
  * build.