diff --git a/src/core/graph/nodes/action/SetZ.ts b/src/core/graph/nodes/action/SetZ.ts new file mode 100644 index 00000000..a46cadcb --- /dev/null +++ b/src/core/graph/nodes/action/SetZ.ts @@ -0,0 +1,18 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity, asNumber } from '../../values'; +import { exec, num, obj } from '../shared'; + +export const SetZ: NodeDef = { + type: 'SetZ', + label: 'Set Z', + category: 'action', + description: 'Sets the z of an object, local to its parent: higher is drawn in front.', + inputs: [exec(), obj('object'), num('z')], + outputs: [exec()], + params: [], + run: (_ctx, inputs) => { + const target = asEntity(inputs.object); + if (target) target.z = asNumber(inputs.z); + return { next: 'exec' }; + }, +}; diff --git a/src/core/graph/nodes/data/GetZ.ts b/src/core/graph/nodes/data/GetZ.ts new file mode 100644 index 00000000..123bbc4d --- /dev/null +++ b/src/core/graph/nodes/data/GetZ.ts @@ -0,0 +1,19 @@ +import { worldTransformOf } from '../../NodeContext'; +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity } from '../../values'; +import { num, obj } from '../shared'; + +export const GetZ: NodeDef = { + type: 'GetZ', + label: 'Get Z', + category: 'data', + description: + 'The z of an object, which decides what is drawn in front: local to its parent (z) and summed up (world z).', + inputs: [obj('object')], + outputs: [num('z'), num('worldZ', 'world z')], + params: [], + run: (_ctx, inputs) => { + const target = asEntity(inputs.object); + return { outputs: { z: target?.z ?? 0, worldZ: target ? worldTransformOf(target).z : 0 } }; + }, +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index b1d5be87..7e2754b1 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -23,6 +23,7 @@ import { Raycast } from './action/Raycast'; import { RaycastAll } from './action/RaycastAll'; import { RestartGame } from './action/RestartGame'; import { SetPosition } from './action/SetPosition'; +import { SetZ } from './action/SetZ'; import { Spawn } from './action/Spawn'; import { StopGame } from './action/StopGame'; import { StopSound } from './action/StopSound'; @@ -43,6 +44,7 @@ import { FindText } from './data/FindText'; import { FirstNotEmpty } from './data/FirstNotEmpty'; import { Floor } from './data/Floor'; import { GetPosition } from './data/GetPosition'; +import { GetZ } from './data/GetZ'; import { ChildByName } from './data/ChildByName'; import { Children } from './data/Children'; import { ChildrenByName } from './data/ChildrenByName'; @@ -199,6 +201,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ IsEnabled, Properties, GetPosition, + GetZ, TextureSize, AnimationPlaying, AnimationLength, @@ -266,6 +269,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ Move, MoveToward, SetPosition, + SetZ, GetVelocity, ApplyForce, ApplyImpulse, diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index f1f0362c..da103638 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -752,6 +752,14 @@ const CASES: Record = { outputs: { x: 0, y: 0, worldX: 0, worldY: 0 }, }, ], + GetZ: [ + { + name: 'a child reports its own z and the z summed up with its parents', + inputs: { object: { ...at('child', 0, 0), z: 2, parent: { ...at('parent', 0, 0), z: 5 } } }, + outputs: { z: 2, worldZ: 7 }, + }, + { name: 'no object is 0', inputs: { object: null }, outputs: { z: 0, worldZ: 0 } }, + ], Number: [ { name: 'outputs its setting', params: { value: 7.5 }, outputs: { value: 7.5 } }, @@ -1500,6 +1508,10 @@ const CASES: Record = { }, { name: 'no object is a no-op', inputs: { object: null, x: 1, y: 1 }, next: 'exec' }, ], + SetZ: [ + { name: 'sets the z and continues', inputs: { object: at('z', 0, 0), z: 3 }, next: 'exec' }, + { name: 'no object is a no-op', inputs: { object: null, z: 3 }, next: 'exec' }, + ], ApplyForce: [ { name: 'pushes for the length of the frame: force × dt, handed on as an impulse', @@ -2142,6 +2154,12 @@ describe('action nodes write through to the object they are given', () => { expect({ x: target.x, y: target.y }).toEqual({ x: 30, y: -40 }); }); + it('SetZ sets only the z', () => { + const target = { ...at('z', 10, 20), z: 1 }; + runDef('SetZ', fakeContext(), { object: target, z: -4 }, {}); + expect({ x: target.x, y: target.y, z: target.z }).toEqual({ x: 10, y: 20, z: -4 }); + }); + it('Destroy hands the object to the context, children and all unless told to keep them', () => { const removed: string[] = []; const moved = [fakeEntity('Lid'), fakeEntity('Nail')]; diff --git a/tests/viewport/set-z.test.ts b/tests/viewport/set-z.test.ts new file mode 100644 index 00000000..575de98a --- /dev/null +++ b/tests/viewport/set-z.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { PlaceAssetCommand } from '../../src/core/commands/sceneObjectCommands'; +import { drawSlots } from '../../src/viewport/drawOrder'; +import { collectWorldItems } from '../../src/viewport/items'; +import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers'; + +/** + * Two sprites on the same spot, "front" at z 1 and "back" at z 0. On `f` a behavior on "back" sets + * its z to 2 with `Set Z` and logs `Get Z` of it: z, then world z. + */ +function start() { + const f = makeSpriteScene(); + const back = place(f, f.spriteId, { x: 0, y: 0, z: 0 }); + const front = place(f, f.spriteId, { x: 0, y: 0, z: 1 }); + const behavior = makeBehavior(f, 'Raise'); + f.stack.execute(new PlaceAssetCommand(f.sceneId, behavior.id, { x: 0, y: 0 }, back)); + const edit = graphEditor(f, behavior); + const owner = edit.addNode('Owner'); + const set = edit.addNode('SetZ', { z: 2 }); + edit.connect(edit.addNode('OnKeyDown', { key: 'f' }), 'exec', set, 'exec'); + edit.connect(owner, 'object', set, 'object'); + const get = edit.addNode('GetZ'); + edit.connect(owner, 'object', get, 'object'); + let from = set; + for (const output of ['z', 'worldZ']) { + const log = edit.addNode('Log'); + edit.connect(get, output, log, 'message'); + edit.connect(from, 'exec', log, 'exec'); + from = log; + } + + const started = startWorld(f, { behaviors: true }); + const name = (id: string) => (id === back ? 'back' : id === front ? 'front' : id); + const order = () => + drawSlots(collectWorldItems(started.world).items.filter((item) => item.type === 'sprite')).map((slot) => + name(slot.id), + ); + started.frame(); + return { ...started, order }; +} + +describe('a graph changes the z of an object during the run', () => { + it('draws the object in front once Set Z has raised it, and Get Z reads the new z', () => { + const r = start(); + expect(r.order()).toEqual(['back', 'front']); + r.input.press('f'); + r.frame(); + expect(r.order()).toEqual(['front', 'back']); + expect(r.world.log).toEqual(['2', '2']); + }); +});