From fae827c087afb90db4d8645263db7686002ffa95 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 25 Sep 2026 22:08:00 +0200 Subject: [PATCH] Let an animation key Z so an object can pass behind another The timeline offered X and Y as position rows but never Z. The list of position rows was simply written without it when animations came in with #97. A swinging weapon could therefore not go behind its character. - Z is the third position row, after X and Y, on every placed root or object. A scene and a behavior still offer no position. - Play and preview already read and write any position row by name, so the one entry in POSITION_KEYS is all the code change. - The draw order is sorted on every render, and play writes z back into the scene each frame. A keyed Z therefore takes effect at once. A blended 4.5 sits above 4 and below 5. Tests: - ./check.sh: 165 files, 2416 tests, CHECK EXIT 0. - 3 new unit tests in tests/core/animation.test.ts: Z is offered after X and Y and not on a behavior or scene, a running game moves the object from behind to in front of another one, and play, scene write-back and preview agree on z. - e2e 25: ALL PASSED, 29 checks. The new check reads X, Y, Z as the first rows of the row dialog. Check sum 1396 -> 1397. - Counter-check: without z in the list all 3 new unit tests fail. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/model/animation.ts | 7 ++-- tests/core/animation.test.ts | 62 ++++++++++++++++++++++++++++++++++++ tools/e2e/25-animation.mjs | 8 ++++- 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8296c5e4..a7b7e891 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1396 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1397 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 04bcfd0e..15f1b89b 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -25,8 +25,11 @@ export type Playback = (typeof PLAYBACKS)[number]; /** What an animation can be built over: something placed, or a whole scene. */ export const ANIMATION_ROOT_TYPES: readonly AssetType[] = ['sprite', 'text', 'container', 'scene']; -/** Position lives on the object, not in its property bag, so the timeline offers it by name. */ -const POSITION_KEYS = ['x', 'y'] as const; +/** + * Position lives on the object, not in its property bag, so the timeline offers it by name. `z` is + * the draw order, which is sorted on every render, so a keyed `z` moves the object in front or behind. + */ +const POSITION_KEYS = ['x', 'y', 'z'] as const; function isPositionKey(key: string): key is (typeof POSITION_KEYS)[number] { return (POSITION_KEYS as readonly string[]).includes(key); diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index 4e7a365d..e68b090e 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -29,8 +29,10 @@ import { AddNodeCommand } from '../../src/core/commands/graphCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { RenameVariableCommand } from '../../src/core/commands/variableCommands'; import { + animatableDefs, animationDuration, frameCount, + isPositionRow, pauseAnimation, PLACED_ROOT_ID, playAnimation, @@ -55,6 +57,8 @@ import { animationsUsing } from '../../src/core/model/usages'; import { deserializeProject } from '../../src/core/store/serialize'; import { AnimationSystem } from '../../src/runtime/systems/AnimationSystem'; import { World } from '../../src/runtime/World'; +import { drawSlots } from '../../src/viewport/drawOrder'; +import { collectWorldItems } from '../../src/viewport/items'; import { graphEditor, makeBehavior, @@ -1349,3 +1353,61 @@ describe('the animation nodes', () => { ]); }); }); + +describe('a row on z, the draw order', () => { + /** The sprite placed twice: `box` at z 0 plays a row moving z 0 → 10, `wall` stands at z 5. */ + function climb() { + const f = makeSpriteScene(); + const box = place(f, f.spriteId, { x: 0, y: 0, z: 0 }); + const wall = place(f, f.spriteId, { x: 0, y: 0, z: 5 }); + const walk = addAnimation(f, f.spriteId, { fps: 10 }); + addRow(f, walk, 'z', [ + [0, 0], + [10, 10], + ]); + const started = startWorld(f, { animations: true }); + const order = () => + drawSlots(collectWorldItems(started.world).items) + .map((slot) => (slot.id === box ? 'box' : slot.id === wall ? 'wall' : '')) + .filter(Boolean); + return { f, walk, box, sprite: started.world.findByObjectId(box)!, order, ...started }; + } + + it('is offered after X and Y on a placed asset, and not on a behavior', () => { + expect(animatableDefs('sprite').slice(0, 3)).toMatchObject([ + { key: 'x', label: 'X' }, + { key: 'y', label: 'Y' }, + { key: 'z', label: 'Z', kind: 'number' }, + ]); + expect(animatableDefs('text').some((d) => d.key === 'z')).toBe(true); + expect(animatableDefs('behavior').some((d) => d.key === 'z')).toBe(false); + expect(isPositionRow('sprite', 'z')).toBe(true); + expect(isPositionRow('scene', 'z')).toBe(false); + }); + + it('moves the object behind and in front of another one while the game runs', () => { + const { walk, sprite, frame, order } = climb(); + expect(order()).toEqual(['box', 'wall']); + playAnimation(sprite, walk.id); + frame(0.05); + frame(0.3); + expect(sprite.z).toBeCloseTo(3); + expect(order()).toEqual(['box', 'wall']); + frame(0.4); + expect(sprite.z).toBeCloseTo(7); + expect(order()).toEqual(['wall', 'box']); + }); + + it('writes z into the scene as the preview shows it', () => { + const { f, walk, box, sprite, world, frame } = climb(); + playAnimation(sprite, walk.id); + frame(0.05); + frame(0.45); + world.syncToModel(); + const previewed = previewHolder(f.project, findAssetOf(f.project, walk.id, 'animation')!, 4.5)!; + const root = previewed.objects.find((o) => o.id === PLACED_ROOT_ID)!; + expect(sprite.z).toBeCloseTo(4.5); + expect(root.position.z).toBeCloseTo(4.5); + expect(f.sceneOf().objects.find((o) => o.id === box)!.position.z).toBeCloseTo(4.5); + }); +}); diff --git a/tools/e2e/25-animation.mjs b/tools/e2e/25-animation.mjs index 8798b827..bc374195 100644 --- a/tools/e2e/25-animation.mjs +++ b/tools/e2e/25-animation.mjs @@ -56,7 +56,13 @@ export async function animation(t) { ); await evaluate("__gm.animation.rowDialogPick('an-arm', 'x')"); await settle(); - const armProperties = (await json('__gm.animation.rowDialog()')).properties.length; + const armOffered = (await json('__gm.animation.rowDialog()')).properties; + const armProperties = armOffered.length; + check( + 'the position rows come first, Z (the draw order) after X and Y', + JSON.stringify(armOffered.slice(0, 3)) === JSON.stringify(['X', 'Y', 'Z']), + JSON.stringify(armOffered), + ); const addButton = await find('Add row', true); await click(addButton.x, addButton.y); const row = (await walk()).tracks;