An Object variable could only be set by a graph while the game runs.
A placed object always showed "(null)", although the other objects of
its scene are there to choose from and the runtime can already find
any object by its id. Now the placed object stores a reference to one
of them, and the running game turns it into the real object.
- Stored as {"$object": "<id>"}. The empty placeholder stays as it
was, so old projects load unchanged and empty still means "(null)".
- The row of an Object variable on a placed object is a dropdown with
None and every object of the scene, indented like the Hierarchy.
The same goes for Object fields in structs and Object items in
lists, also in the Variables and List tabs.
- The behavior asset keeps "(null)". An asset has no scene, so "Apply
to asset" is gone from Object rows.
- The runtime resolves the reference when the Variables or Properties
node reads it. A destroyed target reads as null, a missing one shows
"(missing)" in the row.
- Inside a container only objects of the same container are offered.
The reference keeps the template id, so every placement finds its
own copy.
- Deleting the target clears the reference in the same undo step.
Paste points references inside the pasted group at the copies and
leaves outside references alone. Create container and Unpack carry
references along with the new ids.
- A value a graph writes while running stays in the run. The scene
keeps the picked object, also in "Run in editor".
Found on the way:
- Setting an override coerced every object slot to empty, in plain
values, struct fields and list items. The shared objectPlace() now
keeps the reference.
Not done:
- No eyedropper to pick in the scene, the dropdown is enough for now.
- Deleting a template object in the container editor does not clean up
references in other scenes. They read as null there.
Tests:
- ./check.sh: 187 files, 2724 tests, CHECK EXIT 0
- e2e 1-9, 51, 57: ALL PASSED, 389 checks
- e2e 1, 57 on the final bundle: ALL PASSED, 17 checks
- 13 unit tests in object-references.test.ts, 5 checks in e2e 57
- Counter-checks: 8 unit breaks fail their tests, the browser break
without a pick list fails 57b-57d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
252 lines
11 KiB
TypeScript
252 lines
11 KiB
TypeScript
/**
|
||
* An object's place names another object of the same scene (#258): picked on a placed object,
|
||
* stored as its id, the object itself once the game runs – and inside a container, each placement's
|
||
* own copy of it.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { CreateContainerCommand, UnpackContainerCommand } from '../../src/core/commands/containerCommands';
|
||
import { PasteObjectsCommand } from '../../src/core/commands/pasteCommands';
|
||
import { AddPortsCommand } from '../../src/core/commands/portCommands';
|
||
import {
|
||
PlaceAssetCommand,
|
||
RemoveSceneObjectsCommand,
|
||
RenameSceneObjectCommand,
|
||
SetOverrideCommand,
|
||
SetParentCommand,
|
||
} from '../../src/core/commands/sceneObjectCommands';
|
||
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||
import type { BehaviorAsset, ContainerAsset } from '../../src/core/model/Asset';
|
||
import { copyObjects } from '../../src/core/model/clipboard';
|
||
import { instanceChildId } from '../../src/core/model/instance';
|
||
import { objectChoices, objectReference, referencedId } from '../../src/core/model/objectRefs';
|
||
import { NO_OBJECT } from '../../src/core/model/Properties';
|
||
import { findAsset } from '../../src/core/model/Project';
|
||
import { makeItem } from '../../src/core/properties/listItems';
|
||
import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
|
||
import {
|
||
graphEditor,
|
||
makeBehavior,
|
||
makeSpriteScene,
|
||
place,
|
||
roundTrip,
|
||
type SceneFixture,
|
||
startWorld,
|
||
} from '../helpers';
|
||
|
||
/** A behavior whose object variable `target` is logged on every update. */
|
||
function watcher(f: SceneFixture): BehaviorAsset {
|
||
const behavior = makeBehavior(f, 'Watcher');
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'target', 'object'));
|
||
const edit = graphEditor(f, behavior);
|
||
const log = edit.addNode('Log');
|
||
edit.wire([
|
||
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
|
||
[edit.variables(['target']), 'target', log, 'message'],
|
||
]);
|
||
return behavior;
|
||
}
|
||
|
||
/** A Watcher and a box called "Crate" in the scene, the Watcher pointed at the box. */
|
||
function watching() {
|
||
const f = makeSpriteScene();
|
||
const behavior = watcher(f);
|
||
const watcherId = place(f, behavior.id, { x: 0, y: 0 });
|
||
const crate = place(f, f.spriteId, { x: 10, y: 0 });
|
||
f.stack.execute(new RenameSceneObjectCommand(f.sceneId, crate, 'Crate'));
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, watcherId, 'target', objectReference(crate)));
|
||
return { ...f, behavior, watcherId, crate };
|
||
}
|
||
|
||
describe('a picked object at rest', () => {
|
||
it('is kept by the override, the file, a struct field and a list item', () => {
|
||
const f = watching();
|
||
expect(f.object(f.watcherId).overrides.target).toEqual({ $object: f.crate });
|
||
const loaded = roundTrip(f.project);
|
||
const scene = loaded.assets.find((a) => a.id === f.sceneId) as {
|
||
objects: { id: string; overrides: object }[];
|
||
};
|
||
expect(scene.objects.find((o) => o.id === f.watcherId)!.overrides).toEqual({
|
||
target: { $object: f.crate },
|
||
});
|
||
const def = variableDef('held', 'struct', undefined, [{ key: 'who', kind: 'object' }]);
|
||
expect(coerceValue(def, { who: objectReference('x') })).toEqual({ who: { $object: 'x' } });
|
||
expect(makeItem('object', objectReference('x'))).toEqual({ $object: 'x' });
|
||
});
|
||
|
||
it('is nothing for anything that names no object', () => {
|
||
const def = variableDef('target', 'object');
|
||
expect(coerceValue(def, 'Crate')).toEqual(NO_OBJECT);
|
||
expect(coerceValue(def, objectReference(''))).toEqual(NO_OBJECT);
|
||
expect(referencedId('Crate')).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('a picked object in a running game', () => {
|
||
it('is the object it names, and nothing once that is destroyed', () => {
|
||
const f = watching();
|
||
const run = startWorld(f, { behaviors: true });
|
||
run.frame();
|
||
expect(run.world.log.at(-1)).toBe('Crate');
|
||
run.world.remove(run.world.findByObjectId(f.crate)!);
|
||
run.frame();
|
||
expect(run.world.log.at(-1)).toBe('');
|
||
});
|
||
|
||
it('is the object inside a struct field and a list item too', () => {
|
||
const f = makeSpriteScene();
|
||
const behavior = makeBehavior(f, 'Holder');
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'held', 'struct'));
|
||
f.stack.execute(new AddStructFieldCommand(behavior.id, 'held', { key: 'who', kind: 'object' }));
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'crowd', 'list'));
|
||
const edit = graphEditor(f, behavior);
|
||
const expand = edit.addNode('ExpandStruct');
|
||
f.stack.execute(new AddPortsCommand(behavior.id, expand.id, [{ key: 'who', kind: 'object' }]));
|
||
const first = edit.addNode('ItemAt', { index: 0 });
|
||
const join = edit.addNode('Join');
|
||
const log = edit.addNode('Log');
|
||
const get = edit.variables(['held', 'crowd']);
|
||
edit.wire([
|
||
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
|
||
[get, 'held', expand, 'value'],
|
||
[get, 'crowd', first, 'list'],
|
||
[expand, 'who', join, 'a'],
|
||
[first, 'item', join, 'b'],
|
||
[join, 'text', log, 'message'],
|
||
]);
|
||
const holder = place(f, behavior.id, { x: 0, y: 0 });
|
||
const crate = place(f, f.spriteId, { x: 0, y: 0 });
|
||
const barrel = place(f, f.spriteId, { x: 0, y: 0 });
|
||
f.stack.execute(new RenameSceneObjectCommand(f.sceneId, crate, 'Crate'));
|
||
f.stack.execute(new RenameSceneObjectCommand(f.sceneId, barrel, 'Barrel'));
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, holder, 'held', { who: objectReference(crate) }));
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, holder, 'crowd', [objectReference(barrel)]));
|
||
const run = startWorld(f, { behaviors: true });
|
||
run.frame();
|
||
expect(run.world.log.at(-1)).toBe('CrateBarrel');
|
||
});
|
||
|
||
it('stays in the scene when a graph puts another object into the place (editor run)', () => {
|
||
const f = watching();
|
||
const edit = graphEditor(f, f.behavior);
|
||
const spawn = edit.addNode('Spawn', { asset: f.spriteId });
|
||
const set = edit.variables(['target'], { write: true });
|
||
edit.wire([
|
||
[edit.addNode('OnStart'), 'exec', spawn, 'exec'],
|
||
[spawn, 'exec', set, 'exec'],
|
||
[spawn, 'object', set, 'target'],
|
||
]);
|
||
const run = startWorld(f, { behaviors: true });
|
||
run.frame();
|
||
run.world.syncToModel();
|
||
run.world.syncFromModel();
|
||
run.frame();
|
||
expect(run.world.log.at(-1)).toBe('Box');
|
||
expect(f.object(f.watcherId).overrides.target).toEqual({ $object: f.crate });
|
||
});
|
||
|
||
it('follows a pick made while the game runs', () => {
|
||
const f = watching();
|
||
const barrel = place(f, f.spriteId, { x: 0, y: 0 });
|
||
f.stack.execute(new RenameSceneObjectCommand(f.sceneId, barrel, 'Barrel'));
|
||
const run = startWorld(f, { behaviors: true });
|
||
run.frame();
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, f.watcherId, 'target', objectReference(barrel)));
|
||
run.world.syncFromModel();
|
||
run.frame();
|
||
expect(run.world.log.slice(-2)).toEqual(['Crate', 'Barrel']);
|
||
});
|
||
});
|
||
|
||
describe('a picked object inside a container', () => {
|
||
/** The Watcher and its Crate made into a container, placed a second time beside the first. */
|
||
function contained() {
|
||
const f = watching();
|
||
const create = new CreateContainerCommand(f.sceneId, [f.watcherId, f.crate]);
|
||
f.stack.execute(create);
|
||
const second = new PlaceAssetCommand(f.sceneId, create.assetId, { x: 500, y: 0 });
|
||
f.stack.execute(second);
|
||
const asset = findAsset(f.project, create.assetId) as ContainerAsset;
|
||
const [tWatcher, tCrate] = asset.objects.map((o) => o.id) as [string, string];
|
||
return { ...f, create, asset, tWatcher, tCrate, first: create.objectId, second: second.objectId };
|
||
}
|
||
|
||
it('names the template copy, and each placement reaches its own', () => {
|
||
const f = contained();
|
||
expect(f.asset.objects[0]!.overrides.target).toEqual({ $object: f.tCrate });
|
||
const run = startWorld(f);
|
||
for (const placed of [f.first, f.second]) {
|
||
const watcher = run.world.findByObjectId(instanceChildId(placed, f.tWatcher))!;
|
||
const found = run.world.findReferenced(watcher, f.tCrate);
|
||
expect(found?.objectId).toBe(instanceChildId(placed, f.tCrate));
|
||
}
|
||
});
|
||
|
||
it('offers the content of its own placement, named as the template names it', () => {
|
||
const f = contained();
|
||
const choices = objectChoices(f.project, f.sceneOf(), instanceChildId(f.second, f.tWatcher));
|
||
expect(choices.map((c) => [c.ref, c.depth])).toEqual([
|
||
[f.tWatcher, 0],
|
||
[f.tCrate, 0],
|
||
]);
|
||
});
|
||
|
||
it('points a reference from outside at what the placed container shows', () => {
|
||
const f = watching();
|
||
const other = place(f, f.behavior.id, { x: 0, y: 0 });
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, other, 'target', objectReference(f.crate)));
|
||
const create = new CreateContainerCommand(f.sceneId, [f.crate]);
|
||
f.stack.execute(create);
|
||
const tCrate = (findAsset(f.project, create.assetId) as ContainerAsset).objects[0]!.id;
|
||
expect(f.object(other).overrides.target).toEqual({ $object: instanceChildId(create.objectId, tCrate) });
|
||
f.stack.undo();
|
||
expect(f.object(other).overrides.target).toEqual({ $object: f.crate });
|
||
});
|
||
|
||
it('is spelled out as the copy it names when the container is unpacked', () => {
|
||
const f = contained();
|
||
f.stack.execute(new UnpackContainerCommand(f.sceneId, f.second));
|
||
const watcher = f.object(instanceChildId(f.second, f.tWatcher));
|
||
expect(watcher.overrides.target).toEqual({ $object: instanceChildId(f.second, f.tCrate) });
|
||
});
|
||
});
|
||
|
||
describe('the objects of a scene', () => {
|
||
it('are offered in hierarchy order, the object itself included', () => {
|
||
const f = watching();
|
||
const lid = place(f, f.spriteId, { x: 0, y: 0 });
|
||
f.stack.execute(new SetParentCommand(f.sceneId, lid, f.crate));
|
||
f.stack.execute(new RenameSceneObjectCommand(f.sceneId, lid, 'Lid'));
|
||
const choices = objectChoices(f.project, f.sceneOf(), f.watcherId);
|
||
expect(choices.map((c) => [c.label, c.depth])).toEqual([
|
||
['Watcher', 0],
|
||
['Crate', 0],
|
||
['Lid', 1],
|
||
]);
|
||
expect(choices[1]!.ref).toBe(f.crate);
|
||
});
|
||
});
|
||
|
||
describe('removing and pasting', () => {
|
||
it('drops a reference to a removed object, and undo brings it back', () => {
|
||
const f = watching();
|
||
f.stack.execute(new RemoveSceneObjectsCommand(f.sceneId, [f.crate]));
|
||
expect(f.object(f.watcherId).overrides.target).toEqual(NO_OBJECT);
|
||
f.stack.undo();
|
||
expect(f.object(f.watcherId).overrides.target).toEqual({ $object: f.crate });
|
||
});
|
||
|
||
it('points a pasted copy at the copy of what it named, and leaves one out of the copy alone', () => {
|
||
const f = watching();
|
||
const both = copyObjects(f.project, f.sceneOf(), [f.watcherId, f.crate])!;
|
||
const pasteBoth = new PasteObjectsCommand(f.sceneId, both, {}, { x: 5, y: 0 });
|
||
f.stack.execute(pasteBoth);
|
||
const [watcherCopy, crateCopy] = pasteBoth.rootIds as [string, string];
|
||
expect(f.object(watcherCopy).overrides.target).toEqual({ $object: crateCopy });
|
||
|
||
const alone = copyObjects(f.project, f.sceneOf(), [f.watcherId])!;
|
||
const pasteAlone = new PasteObjectsCommand(f.sceneId, alone, {}, { x: 5, y: 0 });
|
||
f.stack.execute(pasteAlone);
|
||
expect(f.object(pasteAlone.rootIds[0]!).overrides.target).toEqual({ $object: f.crate });
|
||
});
|
||
});
|