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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-12 21:55:35 +02:00
1 parent 33891acfb3
commit 64d0d472e5
5 files changed
+95 -3

No files matched your search

+1 -1
View File
@@ -362,7 +362,7 @@ export interface NodeContext {
loadScene(sceneId: string): void; loadScene(sceneId: string): void;
/** /**
* The position is local to `parent` (world when none); null when the asset does not exist or * 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( spawn(
assetId: string, assetId: string,
+16 -1
View File
@@ -51,9 +51,23 @@ export class AnimationSystem implements System {
update(world: World, dt: number): void { update(world: World, dt: number): void {
this.finished = []; 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 }[] = []; const running: { entity: Entity; player: AnimationPlayer }[] = [];
for (const entity of world.entities) 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); 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. // What the players before this one wrote this tick – a blend mixes from that value, live.
const written = new Set<string>(); const written = new Set<string>();
@@ -102,6 +116,7 @@ export class AnimationSystem implements System {
b.write(value); b.write(value);
written.add(b.base); written.add(b.base);
} }
if (onlyNew) continue;
if (blend && share >= 1) delete player.blend; if (blend && share >= 1) delete player.blend;
if (fade) { if (fade) {
if (fadeShare >= 1) drop(entity, player); if (fadeShare >= 1) drop(entity, player);
+4 -1
View File
@@ -670,7 +670,10 @@ export class BehaviorSystem implements System {
run(pulsesOf(ticking, 'OnFixedUpdate', { dt: FIXED_STEP }), this.contexts(world, FIXED_STEP)); run(pulsesOf(ticking, 'OnFixedUpdate', { dt: FIXED_STEP }), this.contexts(world, FIXED_STEP));
} }
run(pulsesOf(ticking, 'OnUpdate', { dt })); 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.flushStateChanges();
this.persist(); this.persist();
} }
+40
View File
@@ -1515,6 +1515,46 @@ describe('the animation nodes', () => {
expect(startersOf(holder.animations![1]!)).toEqual([]); 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)', () => { it('passes Blend on to the player it starts (#106)', () => {
const f = makeSpriteScene(); const f = makeSpriteScene();
const box = place(f, f.spriteId, { x: 0, y: 0 }); const box = place(f, f.spriteId, { x: 0, y: 0 });
+34
View File
@@ -205,6 +205,40 @@ describe('a composition played in a scene', () => {
expect(armOf?.props.rotation).toBe(50); 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', () => { it('drives the video an item wears at its video time', () => {
const stage = makeStage(); const stage = makeStage();
director(stage); director(stage);