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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-26 19:36:49 +02:00
1 parent 1b3cb625a3
commit 923baf2557
18 files changed
+345 -24

No files matched your search

+159
View File
@@ -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<typeof startWorld>['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();
});
});
+6 -4
View File
@@ -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();
+43 -2
View File
@@ -423,6 +423,23 @@ const CASES: Record<string, Case[]> = {
},
{ 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<string, Case[]> = {
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<string, Case[]> = {
{ 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<string, Case[]> = {
],
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',