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' });
`);