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);