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

+8 -8
View File
@@ -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');
+20
View File
@@ -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 } };
},
};
+6 -7
View File
@@ -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 } };
},
};
+5
View File
@@ -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',
+4
View File
@@ -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,
+6
View File
@@ -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.