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>
160 lines
6.3 KiB
TypeScript
160 lines
6.3 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|
||
});
|