From 4407c020780a7359e569937b2f3769267ab3928e Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 2 Oct 2026 23:15:21 +0200 Subject: [PATCH] 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 --- src/core/model/animation.ts | 33 +++++++++++++++++--------- src/runtime/systems/AnimationSystem.ts | 3 ++- tests/core/animation.test.ts | 17 +++++++++++++ 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 1deffbb8..3a4d5813 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -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(); + +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); } /** diff --git a/src/runtime/systems/AnimationSystem.ts b/src/runtime/systems/AnimationSystem.ts index fce72803..8afe12c9 100644 --- a/src/runtime/systems/AnimationSystem.ts +++ b/src/runtime/systems/AnimationSystem.ts @@ -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; diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index 41fcedf3..94ad2889 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -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 });