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

+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',