import { describe, expect, it } from 'vitest'; import { buildAsset, CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands'; import { CommandStack } from '../../src/core/commands/CommandStack'; import { AddColliderCommand } from '../../src/core/commands/colliderCommands'; import { PlaceAssetCommand, SetOverrideCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; import { AddVariableCommand } from '../../src/core/commands/variableCommands'; import { type EntityHandle, isHandleEnabled, requireNodeDef, type NodeContext, type Value } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import type { PropertyBag } from '../../src/core/model/Properties'; import { createEmptyProject, findScene } from '../../src/core/model/Project'; import { footprintOf, textSize } from '../../src/core/properties/bounds'; import { schemaFor } from '../../src/core/properties/PropertySchema'; import { resolveSceneObjectProps } from '../../src/core/properties/resolve'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { BehaviorSystem } from '../../src/runtime/systems/BehaviorSystem'; import { CollisionSystem } from '../../src/runtime/systems/CollisionSystem'; import { Input } from '../../src/runtime/systems/Input'; import { World } from '../../src/runtime/World'; /** Runs one node definition in isolation – enough for the small data nodes. */ function run(type: string, inputs: Record, params: PropertyBag = {}, ctx: Partial = {}): Record { return requireNodeDef(type).run!(ctx as NodeContext, inputs, params).outputs ?? {}; } function handle(props: PropertyBag, parent: EntityHandle | null = null): EntityHandle { return { id: 'h', assetId: 'a', name: 'h', x: 0, y: 0, z: 0, props, parent }; } describe('the enabled switch', () => { it('is a common property of every placeable type, defaults to on and overrides per object', () => { expect(schemaFor('sprite')[0]).toMatchObject({ key: 'enabled', kind: 'boolean', default: true }); expect(schemaFor('behavior').map((d) => d.key)).toEqual(['enabled']); expect(schemaFor('scene').some((d) => d.key === 'enabled')).toBe(false); const project = createEmptyProject('p', 'P'); const stack = new CommandStack(project); const scene = new CreateAssetCommand('scene', 'Level'); const sprite = new CreateAssetCommand('sprite', 'Box'); stack.execute(scene); stack.execute(sprite); const placed = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 }); stack.execute(placed); const object = findScene(project, scene.asset.id)!.objects[0]!; expect(resolveSceneObjectProps(sprite.asset, object).enabled).toBe(true); // Switching an instance off is an ordinary override – and survives a round trip. stack.execute(new SetOverrideCommand(scene.asset.id, placed.objectId, 'enabled', false)); expect(object.overrides).toEqual({ enabled: false }); const loaded = deserializeProject(serializeProject(project), 'p'); expect(findScene(loaded, scene.asset.id)!.objects[0]!.overrides).toEqual({ enabled: false }); stack.undo(); expect(object.overrides).toEqual({}); }); it('is inherited: a child of a switched-off parent is off as well', () => { const parent = handle({ enabled: false }); const child = handle({ enabled: true }, parent); expect(isHandleEnabled(child)).toBe(false); expect(child.props.enabled).toBe(true); // its own switch is untouched parent.props.enabled = true; expect(isHandleEnabled(child)).toBe(true); expect(run('IsEnabled', { object: child })).toEqual({ result: true, own: true }); }); it('cannot be shadowed by a behavior variable', () => { const project = createEmptyProject('p', 'P'); const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset; project.assets.push(behavior); expect(() => new AddVariableCommand('b', 'enabled', 'number').execute(project)).toThrow(); }); it('takes a collider out of the collision system while it is off', () => { const { world, wall, mover } = collisionSetup(); const collisions = new CollisionSystem(); world.addSystem(collisions); // The wall blocks: moving 200 to the right stops at 100 units of distance (two 100-wide boxes). collisions.moveAndSlide(world, mover, 200, 0); expect(mover.x).toBeCloseTo(100, 5); // Switched off, the wall is not there at all. mover.x = 0; wall.props.enabled = false; collisions.moveAndSlide(world, mover, 200, 0); expect(mover.x).toBeCloseTo(200, 5); }); it('stops a behavior while it is off and runs On start when it is switched on', () => { const project = createEmptyProject('p', 'P'); const stack = new CommandStack(project); const scene = new CreateAssetCommand('scene', 'Level'); const behavior = new CreateAssetCommand('behavior', 'Counter'); stack.execute(scene); stack.execute(behavior); stack.execute(new AddVariableCommand(behavior.asset.id, 'ticks', 'number')); const start = new AddNodeCommand(behavior.asset.id, 'OnStart', 0, 0); const update = new AddNodeCommand(behavior.asset.id, 'OnUpdate', 0, 100); const one = new AddNodeCommand(behavior.asset.id, 'Number', 0, 200, { value: 1 }); const add = new AddNodeCommand(behavior.asset.id, 'Add', 100, 200); const get = new AddNodeCommand(behavior.asset.id, 'GetVariable', 0, 300, { variable: 'ticks' }); const set = new AddNodeCommand(behavior.asset.id, 'SetVariable', 200, 100, { variable: 'ticks' }); for (const c of [start, update, one, add, get, set]) stack.execute(c); for (const [from, fromPort, to, toPort] of [ [update, 'exec', set, 'exec'], [get, 'value', add, 'a'], [one, 'value', add, 'b'], [add, 'result', set, 'value'], [start, 'exec', set, 'exec'], ] as const) { stack.execute(new ConnectCommand(behavior.asset.id, { node: from.node.id, port: fromPort }, { node: to.node.id, port: toPort })); } const placed = new PlaceAssetCommand(scene.asset.id, behavior.asset.id, { x: 0, y: 0 }); stack.execute(placed); stack.execute(new SetOverrideCommand(scene.asset.id, placed.objectId, 'enabled', false)); const world = World.instantiate(project, scene.asset.id); world.addSystem(new BehaviorSystem(project, new Input(), null)); const entity = world.findByObjectId(placed.objectId)!; world.tick(0.1); world.tick(0.1); expect(entity.props.ticks).toBe(0); // off: no On start, no ticks entity.props.enabled = true; // what "Set enabled" does world.tick(0.1); expect(entity.props.ticks).toBe(2); // On start plus this frame's On update world.tick(0.1); expect(entity.props.ticks).toBe(3); entity.props.enabled = false; world.tick(0.1); expect(entity.props.ticks).toBe(3); // frozen again, state kept }); }); describe('text objects and the mouse', () => { it('measures a text footprint from its content and font size', () => { expect(textSize({ text: 'ab', fontSize: 100 }).w).toBeCloseTo(110, 5); expect(textSize({ text: 'ab', fontSize: 100 }).h).toBeCloseTo(125, 5); expect(footprintOf('text', { text: 'abcd', fontSize: 100 }).w).toBeCloseTo(220, 5); expect(run('SetText', { object: handle({ text: 'old' }), text: 42 })).toEqual({}); }); it('hit-tests an object through its colliders, else through its sprites and texts', () => { const project = createEmptyProject('p', 'P'); const stack = new CommandStack(project); const scene = new CreateAssetCommand('scene', 'Level'); const sprite = new CreateAssetCommand('sprite', 'Button'); stack.execute(scene); stack.execute(sprite); const button = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 }); stack.execute(button); const label = new CreateAssetCommand('text', 'Label'); stack.execute(label); const text = new PlaceAssetCommand(scene.asset.id, label.asset.id, { x: 0, y: 200 }); stack.execute(text); stack.execute(new SetParentCommand(scene.asset.id, text.objectId, button.objectId)); const world = World.instantiate(project, scene.asset.id); const entity = world.findByObjectId(button.objectId)!; expect(world.hitTest(entity, 10, 10)).toBe(true); // inside the 100×100 sprite expect(world.hitTest(entity, 10, 200)).toBe(true); // on the label parented below it expect(world.hitTest(entity, 400, 400)).toBe(false); // A collider takes over the hit test for the whole subtree. stack.execute(new AddColliderCommand(scene.asset.id, button.objectId)); stack.execute(new SetOverrideCommand(scene.asset.id, findScene(project, scene.asset.id)!.objects.at(-1)!.id, 'width', 20)); world.syncFromModel(); expect(world.hitTest(entity, 5, 5)).toBe(true); expect(world.hitTest(entity, 40, 0)).toBe(false); // outside the 20-wide collider expect(world.hitTest(entity, 10, 200)).toBe(false); // the label no longer answers // Switched off, nothing of it can be hit. entity.props.enabled = false; expect(world.hitTest(entity, 5, 5)).toBe(false); }); it('reports pointer edges for exactly one tick', () => { const project = createEmptyProject('p', 'P'); const stack = new CommandStack(project); const scene = new CreateAssetCommand('scene', 'Level'); stack.execute(scene); const world = World.instantiate(project, scene.asset.id); world.movePointer(12, 34, true); world.setPointerDown(true); expect(world.pointer).toMatchObject({ x: 12, y: 34, inside: true, down: true, pressed: true }); world.tick(0.1); expect(world.pointer).toMatchObject({ down: true, pressed: false, released: false }); world.setPointerDown(false); expect(world.pointer).toMatchObject({ down: false, released: true }); }); }); describe('nodes added for game flow', () => { it('formats numbers and joins text', () => { expect(run('ToText', { value: 12.345, decimals: 1 })).toEqual({ text: '12.3' }); expect(run('ToText', { value: 'x', decimals: 0 })).toEqual({ text: 'x' }); expect(run('Join', { a: 'Coins: ', b: 3 })).toEqual({ text: 'Coins: 3' }); expect(run('Modulo', { a: 125, b: 60 })).toEqual({ result: 5 }); expect(run('Modulo', { a: 5, b: 0 })).toEqual({ result: 0 }); expect(run('Floor', { value: 2.9 })).toEqual({ result: 2 }); }); it('compares values and identifies assets', () => { const coin = handle({}); coin.assetId = 'coin'; expect(run('IsAsset', { object: coin }, { asset: 'coin' })).toEqual({ result: true }); expect(run('IsAsset', { object: coin }, { asset: 'other' })).toEqual({ result: false }); expect(run('IsAsset', { object: null }, { asset: 'coin' })).toEqual({ result: false }); expect(run('Equals', { a: 'a', b: 'a' })).toEqual({ result: true }); expect(run('Equals', { a: coin, b: coin })).toEqual({ result: true }); expect(run('Equals', { a: coin, b: null })).toEqual({ result: false }); expect(run('NameOf', { object: coin })).toEqual({ name: 'h' }); }); it('switches objects on and off and asks for a restart', () => { const target = handle({ enabled: true }); requireNodeDef('SetEnabled').run!({} as NodeContext, { object: target, enabled: false }, {}); expect(target.props.enabled).toBe(false); let restarted = false; requireNodeDef('RestartGame').run!({ restart: () => { restarted = true; } } as NodeContext, {}, {}); expect(restarted).toBe(true); }); it('fires On click when the mouse is pressed on the owner', () => { const project = createEmptyProject('p', 'P'); const stack = new CommandStack(project); const scene = new CreateAssetCommand('scene', 'Level'); const sprite = new CreateAssetCommand('sprite', 'Button'); const behavior = new CreateAssetCommand('behavior', 'OnPress'); for (const c of [scene, sprite, behavior]) stack.execute(c); stack.execute(new AddVariableCommand(behavior.asset.id, 'clicks', 'number')); const click = new AddNodeCommand(behavior.asset.id, 'OnClick', 0, 0); const get = new AddNodeCommand(behavior.asset.id, 'GetVariable', 0, 100, { variable: 'clicks' }); const one = new AddNodeCommand(behavior.asset.id, 'Number', 0, 200, { value: 1 }); const add = new AddNodeCommand(behavior.asset.id, 'Add', 100, 150); const set = new AddNodeCommand(behavior.asset.id, 'SetVariable', 200, 0, { variable: 'clicks' }); for (const c of [click, get, one, add, set]) stack.execute(c); for (const [from, fromPort, to, toPort] of [ [click, 'exec', set, 'exec'], [get, 'value', add, 'a'], [one, 'value', add, 'b'], [add, 'result', set, 'value'], ] as const) { stack.execute(new ConnectCommand(behavior.asset.id, { node: from.node.id, port: fromPort }, { node: to.node.id, port: toPort })); } const button = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 }); stack.execute(button); const logic = new PlaceAssetCommand(scene.asset.id, behavior.asset.id, { x: 0, y: 0 }); stack.execute(logic); stack.execute(new SetParentCommand(scene.asset.id, logic.objectId, button.objectId)); const world = World.instantiate(project, scene.asset.id); world.addSystem(new BehaviorSystem(project, new Input(), null)); const entity = world.findByObjectId(logic.objectId)!; world.movePointer(500, 500, true); // beside the button world.setPointerDown(true); world.tick(0.1); expect(entity.props.clicks).toBe(0); world.setPointerDown(false); world.movePointer(10, 10, true); // on the button world.setPointerDown(true); world.tick(0.1); expect(entity.props.clicks).toBe(1); world.tick(0.1); // still held – the press was one frame expect(entity.props.clicks).toBe(1); }); }); /** A 100×100 wall at x = 200 and a 100×100 mover at the origin, both with box colliders. */ function collisionSetup() { const project = createEmptyProject('p', 'P'); const stack = new CommandStack(project); const scene = new CreateAssetCommand('scene', 'Level'); const sprite = new CreateAssetCommand('sprite', 'Box'); stack.execute(scene); stack.execute(sprite); const moverObject = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 }); const wallObject = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 200, y: 0 }); stack.execute(moverObject); stack.execute(wallObject); const moverCollider = new AddColliderCommand(scene.asset.id, moverObject.objectId); const wallCollider = new AddColliderCommand(scene.asset.id, wallObject.objectId); stack.execute(moverCollider); stack.execute(wallCollider); const world = World.instantiate(project, scene.asset.id); return { world, mover: world.findByObjectId(moverObject.objectId)!, wall: world.findByObjectId(wallCollider.objectId)!, }; }