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:
1 parent
1b3cb625a3
commit
923baf2557
18 files changed
+345
-24
No files matched your search
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 } };
|
||||
},
|
||||
};
|
||||
@@ -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 } };
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="kind-asset" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<path d="M0 -5.5 L5 -3 L5 3 L0 5.5 L-5 3 L-5 -3 Z" fill="none" stroke="currentColor" stroke-width="1.2" />
|
||||
<path d="M-5 -3 L0 -0.5 L5 -3 M0 -0.5 L0 5.5" fill="none" stroke="currentColor" stroke-width="1.2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -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<Record<Kind, string>> = {
|
||||
animation,
|
||||
asset,
|
||||
boolean,
|
||||
color,
|
||||
list,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user