From 64d0d472e5d2cc03fe2c8d01b9d469a2fbe8007a Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Mon, 12 Oct 2026 21:55:35 +0200 Subject: [PATCH] Show a spawned object's first animation frame right away An object that appears during a frame and plays an animation in its On start was drawn once in its original state. A composition item showed frame 0 one frame late. An object from the Spawn node even ran its On start only on the next frame, after it had been drawn. - BehaviorSystem: at the end of the behavior pass, objects that came to be in this frame hear On start and On enable. This covers spawned objects and behaviors switched on for the first time. Their On update starts with the next frame. - AnimationSystem.showStarted: right after that, every animation started in this frame shows its frame 0. The clock runs from the next frame, and an end is told no earlier than then. A paused frame skips this, its animations stand still anyway. - What such a late On start spawns in turn starts in the next frame. Otherwise a spawn chain could stretch one frame without end. - Scene start is unchanged. There On start already ran before the first frame. - Wiki Behaviors, Lifecycle: the new timing. Tests: gate green on stein2 (melektaus-atx went offline mid-run), 305 files, 4716 tests (2 new). e2e 68 on melektaus-atx green, 10 checks. Without the late pass the Spawn test reads 7 instead of 0, without showStarted both new tests are red. Co-Authored-By: Claude Opus 5.5 --- src/core/graph/NodeContext.ts | 2 +- src/runtime/systems/AnimationSystem.ts | 17 ++++++++++- src/runtime/systems/BehaviorSystem.ts | 5 +++- tests/core/animation.test.ts | 40 ++++++++++++++++++++++++++ tests/core/composition-play.test.ts | 34 ++++++++++++++++++++++ 5 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 54c2f28e..aadb05b7 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -362,7 +362,7 @@ export interface NodeContext { loadScene(sceneId: string): void; /** * The position is local to `parent` (world when none); null when the asset does not exist or - * cannot be placed. A spawned behavior runs its `On start` on the next tick. + * cannot be placed. A spawned behavior runs its `On start` at the end of this tick, before the frame is drawn. */ spawn( assetId: string, diff --git a/src/runtime/systems/AnimationSystem.ts b/src/runtime/systems/AnimationSystem.ts index 3dd5e488..c18bf061 100644 --- a/src/runtime/systems/AnimationSystem.ts +++ b/src/runtime/systems/AnimationSystem.ts @@ -51,9 +51,23 @@ export class AnimationSystem implements System { update(world: World, dt: number): void { this.finished = []; + this.play(world, dt, false); + } + + /** + * Shows frame 0 of what was started since this frame's tick – by the `On start` of an object that has + * only just appeared, say – so the frame is drawn with it rather than with the values from before. + * The clock still runs from the next tick, and an end this early is told then. + */ + showStarted(world: World): void { + this.play(world, 0, true); + } + + private play(world: World, dt: number, onlyNew: boolean): void { const running: { entity: Entity; player: AnimationPlayer }[] = []; for (const entity of world.entities) - for (const player of entity.animations ?? []) running.push({ entity, player }); + for (const player of entity.animations ?? []) + if (!onlyNew || !player.bases) running.push({ entity, player }); running.sort((a, b) => a.player.order - b.player.order); // What the players before this one wrote this tick – a blend mixes from that value, live. const written = new Set(); @@ -102,6 +116,7 @@ export class AnimationSystem implements System { b.write(value); written.add(b.base); } + if (onlyNew) continue; if (blend && share >= 1) delete player.blend; if (fade) { if (fadeShare >= 1) drop(entity, player); diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 59c66ad9..f5bcbb70 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -670,7 +670,10 @@ export class BehaviorSystem implements System { run(pulsesOf(ticking, 'OnFixedUpdate', { dt: FIXED_STEP }), this.contexts(world, FIXED_STEP)); } run(pulsesOf(ticking, 'OnUpdate', { dt })); - this.reap(world); + // What appeared or was switched on this frame starts before it is drawn, and so does what its + // `On start` plays. Objects spawned by such an `On start` wait for the next frame. + this.syncInstances(world); + if (!frozen) this.animations?.showStarted(world); this.flushStateChanges(); this.persist(); } diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index b192c457..69447070 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -1515,6 +1515,46 @@ describe('the animation nodes', () => { expect(startersOf(holder.animations![1]!)).toEqual([]); }); + it('shows frame 0 in the frame a spawned behavior plays it from On start, before it is drawn', () => { + const f = makeSpriteScene(); + const box = place(f, f.spriteId, { x: 7, y: 0 }); + const walk = addAnimation(f, f.spriteId, { fps: 10 }); + addRow(f, walk, 'x', [ + [0, 0], + [9, 90], + ]); + const logic = makeBehavior(f, 'Logic', []); + const edit = graphEditor(f, logic); + const start = edit.addNode('OnStart'); + const owner = edit.addNode('Owner'); + const play = edit.addNode('PlayAnimation', { animation: walk.id }); + edit.wire([ + [start, 'exec', play, 'exec'], + [owner, 'object', play, 'object'], + ]); + // A spawner below the box puts Logic below the box too on a key. + const spawner = makeBehavior(f, 'Spawner', []); + place(f, spawner.id, { x: 0, y: 0 }, box); + const spawning = graphEditor(f, spawner); + const key = spawning.addNode('OnKeyDown', { key: 'x' }); + const here = spawning.addNode('Owner'); + const spawn = spawning.addNode('Spawn', { asset: logic.id }); + spawning.wire([ + [key, 'exec', spawn, 'exec'], + [here, 'object', spawn, 'parent'], + ]); + const { world, input, frame } = startWorld(f, { animations: true, behaviors: true }); + const sprite = world.findByObjectId(box)!; + frame(0.1); + expect(sprite.x).toBe(7); + input.press('x'); + frame(0.1); + expect(sprite.animations).toHaveLength(1); + expect(sprite.x).toBe(0); + frame(0.1); + expect(sprite.x).toBeCloseTo(10); + }); + it('passes Blend on to the player it starts (#106)', () => { const f = makeSpriteScene(); const box = place(f, f.spriteId, { x: 0, y: 0 }); diff --git a/tests/core/composition-play.test.ts b/tests/core/composition-play.test.ts index a4d63335..3c7ec45e 100644 --- a/tests/core/composition-play.test.ts +++ b/tests/core/composition-play.test.ts @@ -205,6 +205,40 @@ describe('a composition played in a scene', () => { expect(armOf?.props.rotation).toBe(50); }); + it('shows frame 0 of what an item’s own On start plays in the frame the item appears', () => { + const stage = makeStage(); + director(stage); + const rig = new CreateAssetCommand('container', 'Rig'); + const arm = new CreateAssetCommand('sprite', 'Arm'); + stage.stack.execute(rig); + stage.stack.execute(arm); + const wave = stage.move(arm.asset.id); + const logic = makeBehavior(stage, 'Wave'); + const edit = graphEditor(stage, logic); + const start = edit.addNode('OnStart'); + const owner = edit.addNode('Owner'); + const playIt = edit.addNode('PlayAnimation', { animation: wave }); + edit.connect(start, 'exec', playIt, 'exec'); + edit.connect(owner, 'object', playIt, 'object'); + (findAssetOf(stage.project, rig.asset.id, 'container') as ContainerAsset).objects.push( + createSceneObject('arm', arm.asset.id, { x: 5, y: 0 }), + createSceneObject('wave', logic.id, { x: 0, y: 0 }, 'arm'), + ); + const item = stage.item(first(stage), rig.asset.id, 2, 30); + const run = play(stage); + const armOf = () => run.world.findByObjectId(contentId(contentId(stage.placement, item), 'arm')); + let ticks = 0; + while (!armOf() && ticks < 10) { + run.frame(); + ticks++; + } + // Begun mid-run, on the third tick: drawn with frame 0 of its animation, not where it stood. + expect(ticks).toBe(3); + expect(armOf()!.x).toBe(0); + run.frame(); + expect(armOf()!.x).toBeCloseTo(10); + }); + it('drives the video an item wears at its video time', () => { const stage = makeStage(); director(stage);