Keep who played an animation beside its player, not on it

d6fd000 wrote the behaviors that played an animation on the player
itself. A player is plain data, and those behaviors are entities of
the run with their parents and children. The actor smoke reads the
world as JSON and threw "Converting circular structure to JSON" as
soon as its graph had played a walk animation.

- The starters live in a WeakMap next to the player and are read
  with startersOf. The player carries no field for them any more.
- The animation system reads them from there when a pass ends. What
  a behavior hears does not change.
- Only the hook the check scripts read the world through was hit.
  The editor and a running game never serialise a player.

Tests:
- Gate on melektaus-atx: 219 files, 3400 tests, CHECK EXIT 0. It
  ran on the working tree, which also held the fix to e2e 47 and
  the kept panel sizes that are not committed yet. Seven of the
  tests belong to those.
- One new unit test: a player played by an asker that reaches back
  to its holder stays plain data, through JSON and structuredClone,
  and names the asker once. Its counter-probe bites.
- Smokes on melektaus-atx: 24, 19 and 21 checks, all pass. Before
  the fix the third stopped after 9 checks.
- e2e 6,17-26 on melektaus-atx: 480 checks, all pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-02 23:15:21 +02:00
1 parent d6fd000160
commit 4407c02078
3 files changed
+41 -12

No files matched your search

+22 -11
View File
@@ -548,11 +548,22 @@ export interface AnimationPlayer {
/** Seconds a `once-reset` pass takes to blend back to its starting values when its frames run out. */
blendOut?: number;
fade?: AnimationFade;
/**
* Who asked for it – the behaviors that played it, which hear its end wherever the object stands.
* Opaque here: the runtime hands in its behavior entities.
*/
startedBy?: object[];
}
/**
* Who asked for a player – the behaviors that played it, which hear its end wherever the object
* stands. Kept beside the player, not on it: a player is plain data, read as JSON by the check
* scripts, and what asks is an entity of the run with its parents and children.
*/
const starters = new WeakMap<AnimationPlayer, object[]>();
export function startersOf(player: AnimationPlayer): readonly object[] {
return starters.get(player) ?? [];
}
function noteStarter(player: AnimationPlayer, startedBy: object | undefined): void {
if (!startedBy || startersOf(player).includes(startedBy)) return;
starters.set(player, [...startersOf(player), startedBy]);
}
/**
@@ -601,7 +612,7 @@ let startCount = 0;
* when a `once-reset` pass already ran out, starts over. `blendOutSeconds` is the fade at the end of
* a `once-reset` pass. `restart` starts a known one over from its first frame in any case – blended in
* from where it stands when `blendSeconds` is set – and keeps its starting values, so a relative row
* does not add up. `startedBy` is noted on the player next to whoever asked before, so each of them
* does not add up. `startedBy` is noted for the player next to whoever asked before, so each of them
* hears it finish.
*/
export function playAnimation(
@@ -617,8 +628,7 @@ export function playAnimation(
const blend = blendSeconds > 0 ? { seconds: blendSeconds, elapsed: 0 } : undefined;
const blendOut = blendOutSeconds > 0 ? blendOutSeconds : undefined;
if (known) {
if (startedBy && !known.startedBy?.includes(startedBy))
known.startedBy = [...(known.startedBy ?? []), startedBy];
noteStarter(known, startedBy);
const leads = players.every((p) => p.order <= known.order);
const fade = known.fade;
known.playing = true;
@@ -635,15 +645,16 @@ export function playAnimation(
} else if (fade || !leads) known.blend = blend;
return;
}
players.push({
const player: AnimationPlayer = {
animationId,
time: 0,
playing: true,
order: ++startCount,
...(blend && { blend }),
...(blendOut && { blendOut }),
...(startedBy && { startedBy: [startedBy] }),
});
};
noteStarter(player, startedBy);
players.push(player);
}
/**
+2 -1
View File
@@ -9,6 +9,7 @@ import {
blendValue,
isPositionRow,
playbackOf,
startersOf,
trackWriteAt,
} from '../../core/model/animation';
import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
@@ -106,7 +107,7 @@ export class AnimationSystem implements System {
continue;
}
if (!head.finished) continue;
this.finished.push({ entity, animationId: asset.id, startedBy: player.startedBy ?? [] });
this.finished.push({ entity, animationId: asset.id, startedBy: startersOf(player) });
if (playbackOf(asset) !== 'once-reset') {
drop(entity, player);
continue;
+17
View File
@@ -45,6 +45,7 @@ import {
playerStatus,
type PlayerStatus,
playheadAt,
startersOf,
stopAnimation,
targetDefs,
trackDef,
@@ -1359,6 +1360,22 @@ describe('the animation nodes', () => {
expect(above('plays too')).toEqual({ done: [1, 1], x: 20 });
});
it('keeps who asked beside the player, which stays plain data', () => {
// What asks is an entity of the run: it reaches the holder again, as a behavior reaches its owner.
const holder: { animations?: AnimationPlayer[]; children: object[] } = { children: [] };
const asker = { parent: holder };
holder.children.push(asker);
playAnimation(holder, 'walk', 0, 0, false, asker);
playAnimation(holder, 'walk', 0, 0, false, asker);
const player = holder.animations![0]!;
expect(startersOf(player)).toEqual([asker]);
expect(Object.values(player).every((value) => typeof value !== 'object')).toBe(true);
expect(JSON.parse(JSON.stringify(holder.animations))).toEqual([{ ...player }]);
expect(structuredClone(holder.animations)).toEqual([{ ...player }]);
playAnimation(holder, 'idle');
expect(startersOf(holder.animations![1]!)).toEqual([]);
});
it('passes Blend on to the player it starts (#106)', () => {
const f = makeSpriteScene();
const box = place(f, f.spriteId, { x: 0, y: 0 });