From 923baf25575ac625975da7f7fbcca136cc702c37 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 26 Sep 2026 19:36:49 +0200 Subject: [PATCH] Let Spawn take an asset over an edge and find a child by name (#237) An asset could only be picked as a setting on the Spawn node. It could not be wired in, held in a variable or put into a list. Finding an object below the own one needed a scene-wide search by name. - "Asset" is now a kind like Animation or Sound: port, variable, list item and a new "Asset" literal node, with its own cube icon. - Spawn and Is asset have an `asset` input. It uses the key of the old setting, so a saved asset reads as the value of the input and older projects keep spawning without a migration. - New node "Child by name" searches below an object level by level. A child wins over a grandchild of the same name. Nothing found or no object gives null. Tests: - ./check.sh: 168 files, 2453 tests, CHECK EXIT 0. - 9 new unit tests in tests/core/asset-kind.test.ts, catalog cases for Spawn, Is asset, Child by name and the literal. - e2e 6z-ii (2 checks): node rows and a real drag of the literal onto `asset`. e2e 6: 119 checks passed. Spawn checks in 3 and 9 passed. - Counter-checks: depth-first search fails 1, Spawn reading only the old setting fails 3, runtime ignoring the parent fails 1. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/graph/NodeContext.ts | 2 + src/core/graph/nodes/action/Spawn.ts | 16 +-- src/core/graph/nodes/data/ChildByName.ts | 20 +++ src/core/graph/nodes/data/IsAsset.ts | 13 +- src/core/graph/nodes/data/literals.ts | 5 + src/core/graph/nodes/index.ts | 4 + src/core/graph/nodes/shared.ts | 6 + src/core/properties/kinds.ts | 18 ++- src/editor/panels/icons/kind-asset.svg | 4 + src/editor/panels/markIcons.ts | 2 + src/runtime/World.ts | 13 ++ src/runtime/systems/BehaviorSystem.ts | 4 + tests/core/asset-kind.test.ts | 159 +++++++++++++++++++++++ tests/core/asset-ref.test.ts | 10 +- tests/core/node-catalog.test.ts | 45 ++++++- tests/helpers.ts | 1 + tools/e2e/06-graph.mjs | 45 +++++++ 18 files changed, 345 insertions(+), 24 deletions(-) create mode 100644 src/core/graph/nodes/data/ChildByName.ts create mode 100644 src/editor/panels/icons/kind-asset.svg create mode 100644 tests/core/asset-kind.test.ts diff --git a/README.md b/README.md index 931d5a43..896c975d 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, 1401 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1403 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/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 4cbd311c..3c25b914 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -142,6 +142,8 @@ export interface NodeContext { time: number; dt: number; findByName(name: string): EntityHandle | null; + /** The nearest object called `name` below `parent` – a child before a grandchild; null for none. */ + childByName(parent: EntityHandle, name: string): EntityHandle | null; isKeyDown(key: string): boolean; pointer: PointerState; /** diff --git a/src/core/graph/nodes/action/Spawn.ts b/src/core/graph/nodes/action/Spawn.ts index 66bc8795..f1ac8b18 100644 --- a/src/core/graph/nodes/action/Spawn.ts +++ b/src/core/graph/nodes/action/Spawn.ts @@ -1,20 +1,20 @@ import type { NodeDef } from '../../NodeRegistry'; -import { asEntity, asNumber } from '../../values'; -import { assetParam, exec, num, obj } from '../shared'; +import { asEntity, asNumber, asString } from '../../values'; +import { assetPort, exec, num, obj } from '../shared'; -import { textOf } from '../../../model/Properties'; export const Spawn: NodeDef = { type: 'Spawn', label: 'Spawn', category: 'action', description: 'Creates a new instance of an asset in the running scene at (x, y, z) – local to the parent when one is ' + - 'connected, world coordinates otherwise. Outputs the new object.', - inputs: [exec(), num('x'), num('y'), num('z'), obj('parent')], + 'connected, world coordinates otherwise. The asset is picked on the node or arrives over an edge, from ' + + 'an Asset literal, a variable or a list item. Outputs the new object.', + inputs: [exec(), assetPort(), num('x'), num('y'), num('z'), obj('parent')], outputs: [exec(), obj('object')], - params: [assetParam], - run: (ctx, inputs, params) => { - const assetId = textOf(params.asset ?? ''); + params: [], + run: (ctx, inputs) => { + const assetId = asString(inputs.asset); const position = { x: asNumber(inputs.x), y: asNumber(inputs.y), z: asNumber(inputs.z) }; const spawned = assetId ? ctx.spawn(assetId, position, asEntity(inputs.parent)) : null; if (!spawned) ctx.log(assetId ? `Spawn: asset ${assetId} not found` : 'Spawn: no asset selected'); diff --git a/src/core/graph/nodes/data/ChildByName.ts b/src/core/graph/nodes/data/ChildByName.ts new file mode 100644 index 00000000..81fc4269 --- /dev/null +++ b/src/core/graph/nodes/data/ChildByName.ts @@ -0,0 +1,20 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asEntity, asString } from '../../values'; +import { obj, str } from '../shared'; + +export const ChildByName: NodeDef = { + type: 'ChildByName', + label: 'Child by name', + category: 'data', + description: + 'Finds an object by its name below the given one – among its children, their children and so on. ' + + 'The nearest level wins; within a level, the first in hierarchy order. Nothing when none is called so.', + inputs: [obj('object'), str('name')], + outputs: [obj('child')], + params: [], + run: (ctx, inputs) => { + const parent = asEntity(inputs.object); + const name = asString(inputs.name); + return { outputs: { child: parent ? ctx.childByName(parent, name) : null } }; + }, +}; diff --git a/src/core/graph/nodes/data/IsAsset.ts b/src/core/graph/nodes/data/IsAsset.ts index 50de7843..61ef2c5e 100644 --- a/src/core/graph/nodes/data/IsAsset.ts +++ b/src/core/graph/nodes/data/IsAsset.ts @@ -1,18 +1,17 @@ import type { NodeDef } from '../../NodeRegistry'; -import { asEntity } from '../../values'; -import { assetParam, bool, obj } from '../shared'; +import { asEntity, asString } from '../../values'; +import { assetPort, bool, obj } from '../shared'; -import { textOf } from '../../../model/Properties'; export const IsAsset: NodeDef = { type: 'IsAsset', label: 'Is asset', category: 'data', description: 'Whether an object is an instance of the chosen asset – the way to tell what you collided with.', - inputs: [obj('object')], + inputs: [obj('object'), assetPort()], outputs: [bool('result')], - params: [assetParam], - run: (_ctx, inputs, params) => { - const assetId = textOf(params.asset ?? ''); + params: [], + run: (_ctx, inputs) => { + const assetId = asString(inputs.asset); return { outputs: { result: Boolean(assetId) && asEntity(inputs.object)?.assetId === assetId } }; }, }; diff --git a/src/core/graph/nodes/data/literals.ts b/src/core/graph/nodes/data/literals.ts index 57326f04..aa7bddbe 100644 --- a/src/core/graph/nodes/data/literals.ts +++ b/src/core/graph/nodes/data/literals.ts @@ -50,6 +50,11 @@ export const AnimationLiteral = literal( 'animation', 'A constant animation, for a "Play animation", "Pause animation" or "Stop animation" to work on.', ); +export const AssetLiteral = literal( + 'Asset', + 'asset', + 'A constant placeable asset, for a "Spawn" to place or an "Is asset" to compare with.', +); export const ListLiteral = literal( 'List', 'list', diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 57ee7f69..36725920 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -38,6 +38,7 @@ import { FindText } from './data/FindText'; import { FirstNotEmpty } from './data/FirstNotEmpty'; import { Floor } from './data/Floor'; import { GetPosition } from './data/GetPosition'; +import { ChildByName } from './data/ChildByName'; import { IsAsset } from './data/IsAsset'; import { IsEnabled } from './data/IsEnabled'; import { IsHovered } from './data/IsHovered'; @@ -48,6 +49,7 @@ import { KeyIsDown } from './data/KeyIsDown'; import { Length } from './data/Length'; import { AnimationLiteral, + AssetLiteral, BooleanLiteral, ListLiteral, NumberLiteral, @@ -160,6 +162,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ Owner, Parent, ObjectByName, + ChildByName, NameOf, IsAsset, IsEnabled, @@ -173,6 +176,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ SoundLiteral, SceneLiteral, AnimationLiteral, + AssetLiteral, Add, Subtract, Multiply, diff --git a/src/core/graph/nodes/shared.ts b/src/core/graph/nodes/shared.ts index 16d18e97..7c2b4348 100644 --- a/src/core/graph/nodes/shared.ts +++ b/src/core/graph/nodes/shared.ts @@ -49,6 +49,12 @@ export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', de /** No `assetTypes`: any placeable asset is a valid answer here. */ export const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'asset', default: '' }; +/** + * The `asset` input of `Spawn` and `Is asset`; unconnected, it shows the asset picker. The key is the + * one the setting had before it became a port, so a saved pick is the inline value now. + */ +export const assetPort = (): PortDef => ({ key: 'asset', label: 'asset', type: 'string', field: assetParam }); + /** * The `sound` input of the two sound nodes; unconnected, it shows the sound picker – the way * `machinePort` shows the machine picker. A `Sound` literal is what feeds it through an edge. diff --git a/src/core/properties/kinds.ts b/src/core/properties/kinds.ts index 87fa8860..6aaaaa8b 100644 --- a/src/core/properties/kinds.ts +++ b/src/core/properties/kinds.ts @@ -142,6 +142,22 @@ export const KINDS = [ field: true, port: true, }, + /** + * An asset's id, `''` = none – what `Spawn` places. Declared by a user it offers every placeable + * asset; a schema row narrows that with `assetTypes`. Ids are not validated on load, so it can dangle. + */ + { + kind: 'asset', + label: 'Asset', + travels: 'string', + empty: '', + coerce: text, + tagged: true, + reference: true, + variable: true, + field: true, + port: true, + }, { kind: 'object', label: 'Object', travels: 'object', port: true }, { kind: 'list', label: 'List', travels: 'list', variable: true, port: true }, { kind: 'struct', label: 'Struct', travels: 'struct', variable: true, port: true }, @@ -149,8 +165,6 @@ export const KINDS = [ { kind: 'enum', label: 'Choice', travels: 'string', empty: '', coerce: text }, /** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */ { kind: 'key', label: 'Key', travels: 'string', empty: '', coerce: text }, - /** An asset id, `''` = none. Ids are not validated on load, so a reference can dangle. */ - { kind: 'asset', label: 'Asset', travels: 'string', empty: '', coerce: text, reference: true }, /** One collision layer by number (#152); the names come from the project, not from the row. */ { kind: 'layer', label: 'Layer', travels: 'number', empty: 0, coerce: numberOf }, /** A set of collision layers as the 16 bits of a mask (#152). */ diff --git a/src/editor/panels/icons/kind-asset.svg b/src/editor/panels/icons/kind-asset.svg new file mode 100644 index 00000000..82a019c1 --- /dev/null +++ b/src/editor/panels/icons/kind-asset.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/markIcons.ts b/src/editor/panels/markIcons.ts index 07623b75..ef8777bd 100644 --- a/src/editor/panels/markIcons.ts +++ b/src/editor/panels/markIcons.ts @@ -9,6 +9,7 @@ import arrowOut from './icons/arrow-out.svg?raw'; import fitHeight from './icons/fit-height.svg?raw'; import fitWidth from './icons/fit-width.svg?raw'; import animation from './icons/kind-animation.svg?raw'; +import asset from './icons/kind-asset.svg?raw'; import boolean from './icons/kind-boolean.svg?raw'; import color from './icons/kind-color.svg?raw'; import list from './icons/kind-list.svg?raw'; @@ -23,6 +24,7 @@ import reset from './icons/reset.svg?raw'; /** Kinds shown as a picture; the rest keep their mark in letters (`#`, `Aa`). */ export const KIND_ICON_SVG: Partial> = { animation, + asset, boolean, color, list, diff --git a/src/runtime/World.ts b/src/runtime/World.ts index b153209e..4cdf16dc 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -410,6 +410,19 @@ export class World { return this.entities.find((e) => e.name === name); } + /** The nearest entity with that name below `parent`, level by level – a child before a grandchild. */ + childByName(parent: Entity, name: string): Entity | undefined { + for ( + let level = this.childrenOf(parent); + level.length > 0; + level = level.flatMap((e) => this.childrenOf(e)) + ) { + const found = level.find((e) => e.name === name); + if (found) return found; + } + return undefined; + } + /** * The camera the game is seen through: of the cameras that are switched on (itself and every * parent, see `properties/enabled.ts`) the first one in hierarchy order. Undefined while none diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index fc901b2a..174520b3 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -662,6 +662,10 @@ export class BehaviorSystem implements System { time: world.time, dt, findByName: (name) => world.findByName(name) ?? null, + childByName: (parent, name) => { + const found = resolve(parent); + return (found && world.childByName(found, name)) ?? null; + }, isKeyDown: (key) => this.input.isDown(key), pointer: world.pointer, hitTest: (target, x, y) => { diff --git a/tests/core/asset-kind.test.ts b/tests/core/asset-kind.test.ts new file mode 100644 index 00000000..cb1e5d9e --- /dev/null +++ b/tests/core/asset-kind.test.ts @@ -0,0 +1,159 @@ +/** + * An asset as a value (#237): Spawn and Is asset take it on a port, fed by the Asset literal, an Asset + * variable or a list item – and a pick saved before the setting became a port still spawns. Plus + * Child by name, which finds the nearest object of a name below another. + */ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { AddVariableCommand } from '../../src/core/commands/variableCommands'; +import { isKindWith, VARIABLE_KINDS } from '../../src/core/properties/kinds'; +import { tagged } from '../../src/core/model/Properties'; +import { + makeBehavior, + graphEditor, + makeSpriteScene, + place, + roundTrip, + startWorld, + type SceneFixture, +} from '../helpers'; + +function sprite(f: SceneFixture, name: string): string { + const command = new CreateAssetCommand('sprite', name); + f.stack.execute(command); + return command.asset.id; +} + +describe('the asset kind', () => { + it('can be declared, picked and tagged in a list', () => { + expect(VARIABLE_KINDS).toContain('asset'); + for (const role of ['field', 'port', 'tagged', 'reference']) expect(isKindWith(role, 'asset')).toBe(true); + }); + + it('keeps an Asset variable and its value through saving', () => { + const f = makeSpriteScene('Crate'); + const behavior = makeBehavior(f, 'Spawner'); + f.stack.execute(new AddVariableCommand(behavior.id, 'what', 'asset')); + behavior.props.what = f.spriteId; + const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id)!; + expect(loaded.type === 'behavior' && loaded.variables.find((v) => v.key === 'what')?.kind).toBe('asset'); + expect(loaded.props.what).toBe(f.spriteId); + }); +}); + +describe('Spawn with an asset input', () => { + const spawnedOf = (f: SceneFixture, world: ReturnType['world']) => + world.entities.filter((e) => e.assetId === f.spriteId).length; + + it('places the asset an Asset literal hands it over an edge', () => { + const f = makeSpriteScene('Crate'); + const behavior = makeBehavior(f, 'Spawner'); + const edit = graphEditor(f, behavior); + const start = edit.addNode('OnStart'); + const literal = edit.addNode('Asset', { value: f.spriteId }); + const spawn = edit.addNode('Spawn'); + edit.wire([ + [start, 'exec', spawn, 'exec'], + [literal, 'value', spawn, 'asset'], + ]); + place(f, behavior.id, { x: 0, y: 0 }); + const run = startWorld(f, { behaviors: true }); + run.frame(); + expect(spawnedOf(f, run.world)).toBe(1); + }); + + it('places the asset a list item tagged $asset names', () => { + const f = makeSpriteScene('Crate'); + const behavior = makeBehavior(f, 'Spawner'); + const edit = graphEditor(f, behavior); + const start = edit.addNode('OnStart'); + const list = edit.addNode('List', { value: [tagged('asset', f.spriteId)] }); + const item = edit.addNode('ItemAt'); + const spawn = edit.addNode('Spawn'); + edit.wire([ + [start, 'exec', spawn, 'exec'], + [list, 'value', item, 'list'], + [item, 'item', spawn, 'asset'], + ]); + place(f, behavior.id, { x: 0, y: 0 }); + const run = startWorld(f, { behaviors: true }); + run.frame(); + expect(spawnedOf(f, run.world)).toBe(1); + }); + + it('still spawns what a project saved before the pick became a port', () => { + const f = makeSpriteScene('Crate'); + const behavior = makeBehavior(f, 'Spawner'); + const edit = graphEditor(f, behavior); + const start = edit.addNode('OnStart'); + // The old setting and the new inline value share the key `asset`. + const spawn = edit.addNode('Spawn', { asset: f.spriteId }); + edit.wire([[start, 'exec', spawn, 'exec']]); + place(f, behavior.id, { x: 0, y: 0 }); + const loaded = { ...f, project: roundTrip(f.project) }; + const run = startWorld(loaded, { behaviors: true }); + run.frame(); + expect(spawnedOf(loaded, run.world)).toBe(1); + }); +}); + +describe('Child by name', () => { + /** + * Hitbox (top level, first in the scene) · Player › Weapon › Hitbox · Player › Weapon › Tag · + * Player › Tag (placed after the deeper one). + */ + function scene() { + const f = makeSpriteScene('Player'); + const hitbox = sprite(f, 'Hitbox'); + const weapon = sprite(f, 'Weapon'); + const tag = sprite(f, 'Tag'); + const loose = place(f, hitbox, { x: 0, y: 0 }); + const player = place(f, f.spriteId, { x: 0, y: 0 }); + const blade = place(f, weapon, { x: 0, y: 0 }, player); + const inner = place(f, hitbox, { x: 0, y: 0 }, blade); + const deepTag = place(f, tag, { x: 0, y: 0 }, blade); + const nearTag = place(f, tag, { x: 0, y: 0 }, player); + return { f, loose, player, inner, deepTag, nearTag }; + } + + it('finds a grandchild below the object, not a namesake elsewhere', () => { + const { f, loose, player, inner } = scene(); + const { world } = startWorld(f); + const found = world.childByName(world.findByObjectId(player)!, 'Hitbox'); + expect(found?.objectId).toBe(inner); + expect(world.findByName('Hitbox')?.objectId).toBe(loose); + }); + + it('takes a child before a grandchild of the same name', () => { + const { f, player, nearTag } = scene(); + const { world } = startWorld(f); + expect(world.childByName(world.findByObjectId(player)!, 'Tag')?.objectId).toBe(nearTag); + }); + + it('finds nothing above or beside the object', () => { + const { f, inner } = scene(); + const { world } = startWorld(f); + expect(world.childByName(world.findByObjectId(inner)!, 'Player')).toBeUndefined(); + expect(world.childByName(world.findByObjectId(inner)!, 'Hitbox')).toBeUndefined(); + }); + + it('hands the found child on in a running graph', () => { + const { f, loose, player, inner } = scene(); + const behavior = makeBehavior(f, 'Cleaner'); + const edit = graphEditor(f, behavior); + const start = edit.addNode('OnStart'); + const owner = edit.addNode('Owner'); + const child = edit.addNode('ChildByName', { name: 'Hitbox' }); + const destroy = edit.addNode('Destroy'); + edit.wire([ + [start, 'exec', destroy, 'exec'], + [owner, 'object', child, 'object'], + [child, 'child', destroy, 'object'], + ]); + place(f, behavior.id, { x: 0, y: 0 }, player); + const run = startWorld(f, { behaviors: true }); + run.frame(); + expect(run.world.findByObjectId(inner)).toBeUndefined(); + expect(run.world.findByObjectId(loose)).toBeDefined(); + }); +}); diff --git a/tests/core/asset-ref.test.ts b/tests/core/asset-ref.test.ts index c68e43ef..e0364a6e 100644 --- a/tests/core/asset-ref.test.ts +++ b/tests/core/asset-ref.test.ts @@ -120,11 +120,13 @@ describe('assetRefChoices', () => { }); }); -describe('node asset params', () => { - // An asset param uses the asset kind, never an enum with empty options filled in by the inspector. +describe('node asset inputs', () => { + // An asset input's field uses the asset kind, never an enum with empty options filled in by the inspector. it('use the asset kind and offer every placeable asset', () => { - const params = BUILT_IN_NODES.flatMap((def) => def.params).filter((p) => p.key === 'asset'); - expect(params.length).toBeGreaterThan(0); + const params = BUILT_IN_NODES.flatMap((def) => def.inputs) + .filter((p) => p.key === 'asset') + .map((p) => p.field!); + expect(params.map((p) => p.key)).toEqual(['asset', 'asset']); for (const param of params) { expect(param.kind).toBe('asset'); expect(param.kind === 'asset' && param.assetTypes).toBeUndefined(); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index a7b7e4de..eb9e2ab2 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -423,6 +423,23 @@ const CASES: Record = { }, { name: 'an unknown name is null', inputs: { name: 'Ghost' }, outputs: { object: null } }, ], + ChildByName: [ + { + name: 'asks the context below the object it is given', + inputs: { object: fakeEntity('Player'), name: 'Hitbox' }, + context: { + childByName: (parent, name) => + parent.name === 'Player' && name === 'Hitbox' ? fakeEntity('Hitbox') : null, + }, + outputs: { child: fakeEntity('Hitbox') }, + }, + { + name: 'an unknown name is null', + inputs: { object: fakeEntity('Player'), name: 'Ghost' }, + outputs: { child: null }, + }, + { name: 'no object is null', inputs: { object: null, name: 'Hitbox' }, outputs: { child: null } }, + ], NameOf: [ { name: 'reads the name', inputs: { object: fakeEntity('Coin') }, outputs: { name: 'Coin' } }, { name: 'no object is the empty name', inputs: { object: null }, outputs: { name: '' } }, @@ -430,10 +447,14 @@ const CASES: Record = { IsAsset: [ { name: 'matches the asset id', - inputs: { object: fakeEntity('Coin', 0, 'coin') }, - params: { asset: 'coin' }, + inputs: { object: fakeEntity('Coin', 0, 'coin'), asset: 'coin' }, outputs: { result: true }, }, + { + name: 'another asset does not match', + inputs: { object: fakeEntity('Coin', 0, 'coin'), asset: 'gem' }, + outputs: { result: false }, + }, { name: 'no asset chosen never matches', inputs: { object: fakeEntity('Coin', 0, 'coin') }, @@ -504,6 +525,10 @@ const CASES: Record = { { name: 'outputs the id it was set to', params: { value: 'anim-1' }, outputs: { value: 'anim-1' } }, { name: 'unset is no animation', outputs: { value: '' } }, ], + Asset: [ + { name: 'outputs the id it was set to', params: { value: 'crate' }, outputs: { value: 'crate' } }, + { name: 'unset is no asset', outputs: { value: '' } }, + ], Add: [ { name: 'adds', inputs: { a: 2, b: 3 }, outputs: { result: 5 } }, { name: 'numeric text is coerced', inputs: { a: '2', b: 3 }, outputs: { result: 5 } }, @@ -1322,6 +1347,22 @@ const CASES: Record = { ], Tile: [{ name: 'hands out the tile chosen', params: { value: 'door' }, outputs: { value: 'door' } }], Spawn: [ + { + name: 'places the asset that arrives on its input, local to the parent', + inputs: { asset: 'crate', x: 1, y: 2, z: 3, parent: fakeEntity('Box') }, + context: { + spawn: (assetId, position, parent) => + assetId === 'crate' && + position.x === 1 && + position.y === 2 && + position.z === 3 && + parent?.name === 'Box' + ? fakeEntity('Crate') + : null, + }, + next: 'exec', + outputs: { object: fakeEntity('Crate') }, + }, { name: 'no asset chosen logs a hint and continues with no object', next: 'exec', diff --git a/tests/helpers.ts b/tests/helpers.ts index 22f1c465..d5a83162 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -224,6 +224,7 @@ export function fakeContext(overrides: Partial = {}): FakeContext { logs, stopped: false, findByName: () => null, + childByName: () => null, isKeyDown: () => false, pointer: { ...NO_POINTER }, hitTest: () => false, diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index 5439935d..057d7c8a 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -1053,6 +1053,51 @@ export async function graph(t) { await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'picky'); + `); + + // 6z-ii. An asset is a value (#237): Spawn keeps a pick saved as its old setting as the inline + // value of its new `asset` input, an Asset literal plugs into that input, and Child by + // name shows its two inputs. + await mutate(` + const p = __gm.state.project; + p.assets.push({ + id: 'spawny', + type: 'behavior', + name: 'Spawny', + props: { enabled: true, runInEditor: false }, + variables: [], + graph: { + nodes: [ + { id: 'sp-spawn', type: 'Spawn', x: 300, y: 0, params: { asset: 'crate' } }, + { id: 'sp-lit', type: 'Asset', x: 0, y: 0, params: { value: 'crate' } }, + { id: 'sp-child', type: 'ChildByName', x: 300, y: 300, params: {} }, + ], + edges: [], + }, + }); + __gm.state.select({ kind: 'asset', assetId: 'spawny' }); + `); + await showTab('graph'); + const spawnRows = await json(`__gm.graph.nodeInputs('sp-spawn')`); + const childRows = await json(`__gm.graph.nodeInputs('sp-child')`); + check( + 'Spawn shows the asset saved before as its input; Child by name shows object and name', + JSON.stringify(spawnRows) === + JSON.stringify(['', 'asset Crate', 'x 0', 'y 0', 'z 0', 'parent (null)']) && + JSON.stringify(childRows) === JSON.stringify(['object (null)', 'name (empty)']), + JSON.stringify({ spawnRows, childRows }), + ); + await wire('sp-lit', 'value', 'sp-spawn', 'asset'); + const assetEdge = (await asset('spawny')).graph.edges.find((e) => e.to.node === 'sp-spawn'); + check( + 'an Asset literal connects to the asset input of Spawn', + assetEdge?.from.node === 'sp-lit' && assetEdge.to.port === 'asset', + JSON.stringify(assetEdge ?? null), + ); + await shot('21-asset-input'); + await mutate(` + const p = __gm.state.project; + p.assets = p.assets.filter((a) => a.id !== 'spawny'); __gm.state.select({ kind: 'asset', assetId: 'mover' }); `);