Pick an object of the same scene for an Object variable (#258)
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>
This commit is contained in:
1 parent
e8b3ac0a3b
commit
f84defb9b6
24 files changed
+720
-45
No files matched your search
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
|||||||
The browser suite and the three example games need a machine with a GPU:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1527 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1532 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { newId } from '../ids';
|
import { newId } from '../ids';
|
||||||
import type { ContainerAsset } from '../model/Asset';
|
import type { ContainerAsset } from '../model/Asset';
|
||||||
import { findObject, removeSubtrees, subtreeIds, subtreeRoots, treeOrder } from '../model/hierarchy';
|
import { findObject, removeSubtrees, subtreeIds, subtreeRoots, treeOrder } from '../model/hierarchy';
|
||||||
import { type InstanceEdit, isLiveInstance, materializeInstance } from '../model/instance';
|
import { instanceChildId, type InstanceEdit, isLiveInstance, materializeInstance } from '../model/instance';
|
||||||
|
import { anchorReferences, swapReferences } from '../model/objectRefs';
|
||||||
import { apply, invert, multiply, translation } from '../model/matrix';
|
import { apply, invert, multiply, translation } from '../model/matrix';
|
||||||
import type { Position } from '../model/Properties';
|
import type { Position } from '../model/Properties';
|
||||||
import { findAsset, findObjectHolder, type Project } from '../model/Project';
|
import { findAsset, findObjectHolder, type Project } from '../model/Project';
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
worldTransform,
|
worldTransform,
|
||||||
} from '../properties/transform';
|
} from '../properties/transform';
|
||||||
import { removeById, type Removed, restoreAt } from '../util/list';
|
import { removeById, type Removed, restoreAt } from '../util/list';
|
||||||
|
import { Recorder } from '../util/recorder';
|
||||||
import { buildAsset } from './assetCommands';
|
import { buildAsset } from './assetCommands';
|
||||||
import { type Command, CommandError } from './Command';
|
import { type Command, CommandError } from './Command';
|
||||||
import { nextAssetName } from './naming';
|
import { nextAssetName } from './naming';
|
||||||
@@ -30,6 +32,7 @@ export class CreateContainerCommand implements Command {
|
|||||||
private removed: Removed<SceneObject>[] = [];
|
private removed: Removed<SceneObject>[] = [];
|
||||||
/** Kept across a redo: the ids of the resolved content are built from them. */
|
/** Kept across a redo: the ids of the resolved content are built from them. */
|
||||||
private readonly templateIds = new Map<string, string>();
|
private readonly templateIds = new Map<string, string>();
|
||||||
|
private readonly references = new Recorder();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly holderId: string,
|
private readonly holderId: string,
|
||||||
@@ -110,11 +113,21 @@ export class CreateContainerCommand implements Command {
|
|||||||
const groupId = rootGroups.size === 1 ? [...rootGroups][0] : undefined;
|
const groupId = rootGroups.size === 1 ? [...rootGroups][0] : undefined;
|
||||||
if (!containerParentId && groupId) container.groupId = groupId;
|
if (!containerParentId && groupId) container.groupId = groupId;
|
||||||
this.removed = removeSubtrees(holder, [...roots]);
|
this.removed = removeSubtrees(holder, [...roots]);
|
||||||
|
// A reference to a member follows it (#258): inside the template to its template id, from the
|
||||||
|
// rest of the holder to what the placed container shows of it.
|
||||||
|
const templateIdOf = (ref: string) => templateIds.get(ref);
|
||||||
|
swapReferences(asset.objects, templateIdOf, this.references);
|
||||||
|
const shown = (ref: string) => {
|
||||||
|
const moved = templateIdOf(ref);
|
||||||
|
return moved && instanceChildId(this.objectId, moved);
|
||||||
|
};
|
||||||
|
swapReferences(holder.objects, shown, this.references);
|
||||||
project.assets.push(asset);
|
project.assets.push(asset);
|
||||||
holder.objects.push(container);
|
holder.objects.push(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(project: Project): void {
|
undo(project: Project): void {
|
||||||
|
this.references.revert();
|
||||||
const holder = findObjectHolder(project, this.holderId);
|
const holder = findObjectHolder(project, this.holderId);
|
||||||
if (holder) {
|
if (holder) {
|
||||||
holder.objects = removeById(holder.objects, this.objectId);
|
holder.objects = removeById(holder.objects, this.objectId);
|
||||||
@@ -147,6 +160,7 @@ export class UnpackContainerCommand implements Command {
|
|||||||
const created = materializeInstance(project, holder, object);
|
const created = materializeInstance(project, holder, object);
|
||||||
if (created.length === 0) throw new CommandError('The container is empty, there is nothing to unpack');
|
if (created.length === 0) throw new CommandError('The container is empty, there is nothing to unpack');
|
||||||
this.createdIds = created.map((o) => o.id);
|
this.createdIds = created.map((o) => o.id);
|
||||||
|
anchorReferences(created);
|
||||||
this.edits = object.instanceEdits;
|
this.edits = object.instanceEdits;
|
||||||
object.instanceEdits = {};
|
object.instanceEdits = {};
|
||||||
holder.objects.push(...created);
|
holder.objects.push(...created);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { newId } from '../ids';
|
|||||||
import type { GraphClip, ObjectClip } from '../model/clipboard';
|
import type { GraphClip, ObjectClip } from '../model/clipboard';
|
||||||
import { findGroup } from '../model/hierarchy';
|
import { findGroup } from '../model/hierarchy';
|
||||||
import { isLiveInstance } from '../model/instance';
|
import { isLiveInstance } from '../model/instance';
|
||||||
|
import { swapReferences } from '../model/objectRefs';
|
||||||
|
import { Recorder } from '../util/recorder';
|
||||||
import { placementRejection } from '../model/placement';
|
import { placementRejection } from '../model/placement';
|
||||||
import type { Vec2 } from '../model/Properties';
|
import type { Vec2 } from '../model/Properties';
|
||||||
import { findAsset, type Project } from '../model/Project';
|
import { findAsset, type Project } from '../model/Project';
|
||||||
@@ -76,6 +78,8 @@ export class PasteObjectsCommand implements Command {
|
|||||||
carryTransform(findAsset(project, object.assetId)!, copy, root.parentMatrix, after);
|
carryTransform(findAsset(project, object.assetId)!, copy, root.parentMatrix, after);
|
||||||
return copy;
|
return copy;
|
||||||
});
|
});
|
||||||
|
// A reference among the copied objects points at the copy; one out of them stays (#258).
|
||||||
|
swapReferences(pasted, (ref) => this.ids.get(ref), new Recorder());
|
||||||
holder.objects.push(...pasted);
|
holder.objects.push(...pasted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
type ObjectRef,
|
type ObjectRef,
|
||||||
restoreObject,
|
restoreObject,
|
||||||
} from '../model/instance';
|
} from '../model/instance';
|
||||||
|
import { swapReferences } from '../model/objectRefs';
|
||||||
import { placementRejection } from '../model/placement';
|
import { placementRejection } from '../model/placement';
|
||||||
|
import { Recorder } from '../util/recorder';
|
||||||
import {
|
import {
|
||||||
type Position,
|
type Position,
|
||||||
type PropertyBag,
|
type PropertyBag,
|
||||||
@@ -98,10 +100,14 @@ export class PlaceAssetCommand implements Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Removes objects together with everything parented below them, as one undo step. */
|
/**
|
||||||
|
* Removes objects together with everything parented below them, as one undo step. A reference the
|
||||||
|
* rest of the holder had on one of them (#258) goes with it.
|
||||||
|
*/
|
||||||
export class RemoveSceneObjectsCommand implements Command {
|
export class RemoveSceneObjectsCommand implements Command {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
private removed: Removed<SceneObject>[] = [];
|
private removed: Removed<SceneObject>[] = [];
|
||||||
|
private readonly references = new Recorder();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly holderId: string,
|
private readonly holderId: string,
|
||||||
@@ -114,9 +120,12 @@ export class RemoveSceneObjectsCommand implements Command {
|
|||||||
const holder = requireHolder(project, this.holderId);
|
const holder = requireHolder(project, this.holderId);
|
||||||
for (const id of this.objectIds) requireStructuralObject(project, holder, id);
|
for (const id of this.objectIds) requireStructuralObject(project, holder, id);
|
||||||
this.removed = removeSubtrees(holder, this.objectIds);
|
this.removed = removeSubtrees(holder, this.objectIds);
|
||||||
|
const gone = new Set(this.removed.map((r) => r.item.id));
|
||||||
|
swapReferences(holder.objects, (ref) => (gone.has(ref) ? '' : undefined), this.references);
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(project: Project): void {
|
undo(project: Project): void {
|
||||||
|
this.references.revert();
|
||||||
restoreAt(requireHolder(project, this.holderId).objects, this.removed);
|
restoreAt(requireHolder(project, this.holderId).objects, this.removed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ export interface NodeContext {
|
|||||||
findByName(name: string): EntityHandle | null;
|
findByName(name: string): EntityHandle | null;
|
||||||
/** Whether the object is still in the game – false once destroyed, or once its world has ended. */
|
/** Whether the object is still in the game – false once destroyed, or once its world has ended. */
|
||||||
isAlive(target: EntityHandle): boolean;
|
isAlive(target: EntityHandle): boolean;
|
||||||
|
/** The object a reference on `from` names (#258, see `model/objectRefs.ts`); null when there is none. */
|
||||||
|
findReferenced(from: EntityHandle, ref: string): EntityHandle | null;
|
||||||
/** The nearest object called `name` below `parent` – a child before a grandchild; null for none. */
|
/** The nearest object called `name` below `parent` – a child before a grandchild; null for none. */
|
||||||
childByName(parent: EntityHandle, name: string): EntityHandle | null;
|
childByName(parent: EntityHandle, name: string): EntityHandle | null;
|
||||||
isKeyDown(key: string): boolean;
|
isKeyDown(key: string): boolean;
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import {
|
|||||||
NO_OBJECT,
|
NO_OBJECT,
|
||||||
type PropertyBag,
|
type PropertyBag,
|
||||||
type PropertyValue,
|
type PropertyValue,
|
||||||
|
sameValue,
|
||||||
tagOf,
|
tagOf,
|
||||||
} from '../model/Properties';
|
} from '../model/Properties';
|
||||||
import type { EntityHandle } from './NodeContext';
|
import { referencedId } from '../model/objectRefs';
|
||||||
|
import type { EntityHandle, NodeContext } from './NodeContext';
|
||||||
import type { PortDef } from './NodeRegistry';
|
import type { PortDef } from './NodeRegistry';
|
||||||
import { isEntity, isObjectPlace, type Value, writeTyped } from './values';
|
import { isEntity, isObjectPlace, type Value, writeTyped } from './values';
|
||||||
|
|
||||||
@@ -20,34 +22,77 @@ export function withoutObjects<T extends PropertyValue | undefined>(value: T): T
|
|||||||
return mapObjects(value, () => NO_OBJECT) as T;
|
return mapObjects(value, () => NO_OBJECT) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a run's value still says what the model's does. What a graph put into an object's place
|
||||||
|
* stays in the run, so once it holds one, the objects on either side – the model's references
|
||||||
|
* included (#258) – no longer count.
|
||||||
|
*/
|
||||||
|
export function sameAtRest(model: PropertyValue | undefined, live: PropertyValue | undefined): boolean {
|
||||||
|
if (!holdsObjects(live)) return sameValue(model, live);
|
||||||
|
const none = () => NO_OBJECT;
|
||||||
|
const blank = (value: PropertyValue | undefined) =>
|
||||||
|
mapObjects(value, none, none) as PropertyValue | undefined;
|
||||||
|
return sameValue(blank(model), blank(live));
|
||||||
|
}
|
||||||
|
|
||||||
|
function holdsObjects(value: unknown): boolean {
|
||||||
|
if (isEntity(value as Value)) return true;
|
||||||
|
if (isList(value)) return value.some(holdsObjects);
|
||||||
|
return isStruct(value) && tagOf(value) === undefined && Object.values(value).some(holdsObjects);
|
||||||
|
}
|
||||||
|
|
||||||
export function bagWithoutObjects(bag: PropertyBag): PropertyBag {
|
export function bagWithoutObjects(bag: PropertyBag): PropertyBag {
|
||||||
const out: PropertyBag = {};
|
const out: PropertyBag = {};
|
||||||
for (const [key, value] of Object.entries(bag)) out[key] = withoutObjects(value);
|
for (const [key, value] of Object.entries(bag)) out[key] = withoutObjects(value);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How a run tells what a bag's objects are: whether one is still there, and which one a reference names. */
|
||||||
|
export interface ObjectLookup {
|
||||||
|
isAlive(target: EntityHandle): boolean;
|
||||||
|
/** The object `ref` names, looked up from where the bag sits (see `model/objectRefs.ts`); null when gone. */
|
||||||
|
referenced(ref: string): EntityHandle | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A bag's value as a graph reads it: an object destroyed since it was written is gone, so a graph
|
* A bag's value as a graph reads it: an object destroyed since it was written is gone, so a graph
|
||||||
* never goes on acting on one that has left the game. Alone it reads as nothing (`null`), inside a
|
* never goes on acting on one that has left the game, and a reference picked in the editor (#258) is
|
||||||
* list or struct as the empty object.
|
* the object it names. Alone either reads as nothing (`null`) when there is none, inside a list or
|
||||||
|
* struct as the empty object.
|
||||||
*/
|
*/
|
||||||
export function liveValue(
|
export function liveValue(value: PropertyValue | undefined, objects: ObjectLookup): Value {
|
||||||
value: PropertyValue | undefined,
|
if (value === undefined) return null;
|
||||||
isAlive: (target: EntityHandle) => boolean,
|
if (tagOf(value) === 'object') return referencedObject(value, objects);
|
||||||
): Value {
|
if (isEntity(value)) return objects.isAlive(value) ? value : null;
|
||||||
if (value === undefined || tagOf(value) === 'object') return null;
|
return mapObjects(
|
||||||
if (isEntity(value)) return isAlive(value) ? value : null;
|
value,
|
||||||
return mapObjects(value, (held) => (isAlive(held) ? held : NO_OBJECT)) as Value;
|
(held) => (objects.isAlive(held) ? held : NO_OBJECT),
|
||||||
|
(place) => referencedObject(place, objects) ?? NO_OBJECT,
|
||||||
|
) as Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lookup for a bag that sits on `owner`: its references are read from there. */
|
||||||
|
export function objectsOf(
|
||||||
|
ctx: Pick<NodeContext, 'isAlive' | 'findReferenced'>,
|
||||||
|
owner: EntityHandle,
|
||||||
|
): ObjectLookup {
|
||||||
|
return { isAlive: (target) => ctx.isAlive(target), referenced: (ref) => ctx.findReferenced(owner, ref) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function referencedObject(place: PropertyValue, objects: ObjectLookup): Value {
|
||||||
|
const ref = referencedId(place);
|
||||||
|
const found = ref ? objects.referenced(ref) : null;
|
||||||
|
return found && objects.isAlive(found) ? found : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What `outputs` read of a bag, each value live as `liveValue` says. */
|
/** What `outputs` read of a bag, each value live as `liveValue` says. */
|
||||||
export function readLive(
|
export function readLive(
|
||||||
bag: PropertyBag,
|
bag: PropertyBag,
|
||||||
outputs: readonly PortDef[],
|
outputs: readonly PortDef[],
|
||||||
isAlive: (target: EntityHandle) => boolean,
|
objects: ObjectLookup,
|
||||||
): Record<string, Value> {
|
): Record<string, Value> {
|
||||||
const values: Record<string, Value> = {};
|
const values: Record<string, Value> = {};
|
||||||
for (const port of outputs) if (port.key in bag) values[port.key] = liveValue(bag[port.key], isAlive);
|
for (const port of outputs) if (port.key in bag) values[port.key] = liveValue(bag[port.key], objects);
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,16 +102,24 @@ export function writePort(bag: PropertyBag, port: PortDef, value: Value | undefi
|
|||||||
writeTyped(bag, port.key, value);
|
writeTyped(bag, port.key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `value` with each object replaced by what `swap` says; untouched parts keep their identity. */
|
/**
|
||||||
function mapObjects(value: unknown, swap: (held: EntityHandle) => unknown): unknown {
|
* `value` with each object replaced by what `swap` says, and each object's place by what `place`
|
||||||
|
* says when given; untouched parts keep their identity.
|
||||||
|
*/
|
||||||
|
function mapObjects(
|
||||||
|
value: unknown,
|
||||||
|
swap: (held: EntityHandle) => unknown,
|
||||||
|
place?: (at: PropertyValue) => unknown,
|
||||||
|
): unknown {
|
||||||
if (isEntity(value as Value)) return swap(value as EntityHandle);
|
if (isEntity(value as Value)) return swap(value as EntityHandle);
|
||||||
|
if (place && tagOf(value) === 'object') return place(value as PropertyValue);
|
||||||
if (isList(value)) {
|
if (isList(value)) {
|
||||||
const items = value.map((item) => mapObjects(item, swap));
|
const items = value.map((item) => mapObjects(item, swap, place));
|
||||||
return items.some((item, i) => item !== value[i]) ? items : value;
|
return items.some((item, i) => item !== value[i]) ? items : value;
|
||||||
}
|
}
|
||||||
if (isStruct(value) && tagOf(value) === undefined) {
|
if (isStruct(value) && tagOf(value) === undefined) {
|
||||||
const entries = Object.entries(value);
|
const entries = Object.entries(value);
|
||||||
const mapped = entries.map(([key, field]) => [key, mapObjects(field, swap)] as const);
|
const mapped = entries.map(([key, field]) => [key, mapObjects(field, swap, place)] as const);
|
||||||
return mapped.some(([, field], i) => field !== entries[i]![1]) ? Object.fromEntries(mapped) : value;
|
return mapped.some(([, field], i) => field !== entries[i]![1]) ? Object.fromEntries(mapped) : value;
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
propertyPort,
|
propertyPort,
|
||||||
propertyTargetOf,
|
propertyTargetOf,
|
||||||
} from '../../propertyPorts';
|
} from '../../propertyPorts';
|
||||||
import { readLive, writePort } from '../../liveObjects';
|
import { objectsOf, readLive, writePort } from '../../liveObjects';
|
||||||
import { asEntity } from '../../values';
|
import { asEntity } from '../../values';
|
||||||
import { exec, obj } from '../shared';
|
import { exec, obj } from '../shared';
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ export const Properties: NodeDef = {
|
|||||||
},
|
},
|
||||||
read: (ctx, _params, input, def) => {
|
read: (ctx, _params, input, def) => {
|
||||||
const target = asEntity(input('object'));
|
const target = asEntity(input('object'));
|
||||||
return target ? readLive(target.props, def.outputs, ctx.isAlive) : {};
|
return target ? readLive(target.props, def.outputs, objectsOf(ctx, target)) : {};
|
||||||
},
|
},
|
||||||
run: (_ctx, inputs, _params, def) => {
|
run: (_ctx, inputs, _params, def) => {
|
||||||
const target = asEntity(inputs.object);
|
const target = asEntity(inputs.object);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { collapsedTitle, portDefOf, VARIABLES, VARIABLES_SETTING, variablesSourceOf } from '../../nested';
|
import { collapsedTitle, portDefOf, VARIABLES, VARIABLES_SETTING, variablesSourceOf } from '../../nested';
|
||||||
import { type NodeDef, withInlineDefault } from '../../NodeRegistry';
|
import { type NodeDef, withInlineDefault } from '../../NodeRegistry';
|
||||||
import { readLive, writePort } from '../../liveObjects';
|
import { objectsOf, readLive, writePort } from '../../liveObjects';
|
||||||
import { variableBag } from '../../sharedState';
|
import { variableBag } from '../../sharedState';
|
||||||
import { exec } from '../shared';
|
import { exec } from '../shared';
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export const Variables: NodeDef = {
|
|||||||
return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'variables') } : shape;
|
return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'variables') } : shape;
|
||||||
},
|
},
|
||||||
read: (ctx, params, _input, def) =>
|
read: (ctx, params, _input, def) =>
|
||||||
readLive(variableBag(ctx, variablesSourceOf(params)), def.outputs, ctx.isAlive),
|
readLive(variableBag(ctx, variablesSourceOf(params)), def.outputs, objectsOf(ctx, ctx.self)),
|
||||||
run: (ctx, inputs, params, def) => {
|
run: (ctx, inputs, params, def) => {
|
||||||
const bag = variableBag(ctx, variablesSourceOf(params));
|
const bag = variableBag(ctx, variablesSourceOf(params));
|
||||||
for (const port of def.inputs) if (port.type !== 'exec') writePort(bag, port, inputs[port.key]);
|
for (const port of def.inputs) if (port.type !== 'exec') writePort(bag, port, inputs[port.key]);
|
||||||
|
|||||||
@@ -88,12 +88,20 @@ export function tagged(kind: TaggedKind, value: number | string): TaggedItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What an object's place holds at rest – a variable, a struct field, a list item alike. Always
|
* What an object's place holds at rest when it names no object – a variable, a struct field, a list
|
||||||
* nothing, since an object exists only while the game runs, but tagged, so a run tells the place
|
* item alike. Tagged, so a run tells the place apart from an empty text (`graph/liveObjects.ts`).
|
||||||
* apart from an empty text (`graph/liveObjects.ts`).
|
|
||||||
*/
|
*/
|
||||||
export const NO_OBJECT: TaggedItem = tagged('object', '');
|
export const NO_OBJECT: TaggedItem = tagged('object', '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `value` as an object's place: the object of the same scene it names (#258, `model/objectRefs.ts`),
|
||||||
|
* or none – whatever else it was.
|
||||||
|
*/
|
||||||
|
export function objectPlace(value: PropertyValue | undefined): TaggedItem {
|
||||||
|
const id = tagOf(value) === 'object' ? (value as StructValue)[TAG + 'object'] : '';
|
||||||
|
return typeof id === 'string' && id ? tagged('object', id) : NO_OBJECT;
|
||||||
|
}
|
||||||
|
|
||||||
/** A tagged item as what it wraps; anything else as itself. */
|
/** A tagged item as what it wraps; anything else as itself. */
|
||||||
export function untag(value: PropertyValue | undefined): PropertyValue | undefined {
|
export function untag(value: PropertyValue | undefined): PropertyValue | undefined {
|
||||||
const tag = tagOf(value);
|
const tag = tagOf(value);
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* An object's place names another object of the same scene by its id (#258): `{"$object": "<id>"}`,
|
||||||
|
* empty for none. Inside a container the id is the one the template gives it, so every placement of
|
||||||
|
* the container points at its own copy – which is why a reference is looked up from where it sits.
|
||||||
|
*/
|
||||||
|
import { findAsset, type Project } from './Project';
|
||||||
|
import { treeOrder, type ObjectTree } from './hierarchy';
|
||||||
|
import { resolveObjects } from './instance';
|
||||||
|
import { isList, isStruct, type PropertyBag, type PropertyValue, tagged, tagOf, untag } from './Properties';
|
||||||
|
import type { SceneObject } from './SceneObject';
|
||||||
|
import { Recorder } from '../util/recorder';
|
||||||
|
|
||||||
|
/** The id an object's place names; '' for none and for anything that is no object's place. */
|
||||||
|
export function referencedId(value: PropertyValue | undefined): string {
|
||||||
|
if (tagOf(value) !== 'object') return '';
|
||||||
|
const id = untag(value);
|
||||||
|
return typeof id === 'string' ? id : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function objectReference(id: string): PropertyValue {
|
||||||
|
return tagged('object', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a derived id (`<instance>~<path>`) has in front of the template id of its own container:
|
||||||
|
* `inst~a/b` → `inst~a/`. A stored object has none.
|
||||||
|
*/
|
||||||
|
function scopeOf(objectId: string): string {
|
||||||
|
const at = Math.max(objectId.lastIndexOf('/'), objectId.lastIndexOf('~'));
|
||||||
|
return at < 0 ? '' : objectId.slice(0, at + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ids `ref`, written on the object `fromId`, may stand for, nearest first: the copy in the same
|
||||||
|
* container placement, then the id as written (a reference out of unpacked content keeps its id).
|
||||||
|
*/
|
||||||
|
export function referenceCandidates(fromId: string, ref: string): string[] {
|
||||||
|
if (!ref) return [];
|
||||||
|
const scope = scopeOf(fromId);
|
||||||
|
return scope ? [scope + ref, ref] : [ref];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectChoice {
|
||||||
|
/** What the reference stores. */
|
||||||
|
ref: string;
|
||||||
|
label: string;
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an object's place on `objectId` may name, in hierarchy order: every object of the holder,
|
||||||
|
* or – for content of a placed container – the objects of that container, named as its template
|
||||||
|
* names them. The object itself is one of them.
|
||||||
|
*/
|
||||||
|
export function objectChoices(project: Project, holder: ObjectTree, objectId: string): ObjectChoice[] {
|
||||||
|
const objects = resolveObjects(project, holder);
|
||||||
|
const stored = holder.objects.some((o) => o.id === objectId);
|
||||||
|
const scope = stored ? '' : scopeOf(objectId);
|
||||||
|
const choices: ObjectChoice[] = [];
|
||||||
|
for (const { object, depth } of treeOrder({ ...holder, objects })) {
|
||||||
|
if (!object.id.startsWith(scope)) continue;
|
||||||
|
const label = object.name || findAsset(project, object.assetId)?.name || object.id;
|
||||||
|
choices.push({ ref: object.id.slice(scope.length), label, depth });
|
||||||
|
}
|
||||||
|
if (!scope) return choices;
|
||||||
|
// The container's own content sits one level below it in the tree.
|
||||||
|
const top = Math.min(...choices.map((c) => c.depth));
|
||||||
|
return choices.map((c) => ({ ...c, depth: c.depth - top }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renames the references the objects' own values and their container edits hold, as `swap` says,
|
||||||
|
* through `recorder` so an undo takes it back.
|
||||||
|
*/
|
||||||
|
export function swapReferences(
|
||||||
|
objects: readonly SceneObject[],
|
||||||
|
swap: (ref: string) => string | undefined,
|
||||||
|
recorder: Recorder,
|
||||||
|
): void {
|
||||||
|
const inBag = (bag: PropertyBag) => {
|
||||||
|
for (const [key, value] of Object.entries(bag)) {
|
||||||
|
const next = withReferences(value, swap);
|
||||||
|
if (next !== value) recorder.set(bag, key, next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const object of objects) {
|
||||||
|
inBag(object.overrides);
|
||||||
|
for (const edit of Object.values(object.instanceEdits)) inBag(edit.overrides);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spells out the references of content just written into its holder (unpacked): a template id
|
||||||
|
* becomes the id of the copy among `objects`, so the inspector finds it among the holder's own.
|
||||||
|
*/
|
||||||
|
export function anchorReferences(objects: readonly SceneObject[]): void {
|
||||||
|
const ids = new Set(objects.map((o) => o.id));
|
||||||
|
for (const object of objects)
|
||||||
|
swapReferences(
|
||||||
|
[object],
|
||||||
|
(ref) => referenceCandidates(object.id, ref).find((id) => ids.has(id)),
|
||||||
|
new Recorder(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `value` with every reference `swap` has an answer for renamed – '' empties it. Untouched parts
|
||||||
|
* keep their identity, so a caller can tell whether anything changed.
|
||||||
|
*/
|
||||||
|
function withReferences(value: PropertyValue, swap: (ref: string) => string | undefined): PropertyValue {
|
||||||
|
if (tagOf(value) === 'object') {
|
||||||
|
const ref = referencedId(value);
|
||||||
|
const next = ref ? swap(ref) : undefined;
|
||||||
|
return next === undefined || next === ref ? value : objectReference(next);
|
||||||
|
}
|
||||||
|
if (isList(value)) {
|
||||||
|
const items = value.map((item) => withReferences(item, swap));
|
||||||
|
return items.some((item, i) => item !== value[i]) ? items : value;
|
||||||
|
}
|
||||||
|
if (isStruct(value) && tagOf(value) === undefined) {
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
const mapped = entries.map(([key, field]) => [key, withReferences(field, swap)] as const);
|
||||||
|
return mapped.some(([, field], i) => field !== entries[i]![1]) ? Object.fromEntries(mapped) : value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type PropertyBag,
|
type PropertyBag,
|
||||||
type PropertyValue,
|
type PropertyValue,
|
||||||
NO_OBJECT,
|
NO_OBJECT,
|
||||||
|
objectPlace,
|
||||||
type StructValue,
|
type StructValue,
|
||||||
textOf,
|
textOf,
|
||||||
} from '../model/Properties';
|
} from '../model/Properties';
|
||||||
@@ -832,7 +833,7 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
|
|||||||
case 'struct':
|
case 'struct':
|
||||||
return structOf(def.fields, value);
|
return structOf(def.fields, value);
|
||||||
case 'object':
|
case 'object':
|
||||||
return NO_OBJECT;
|
return objectPlace(value);
|
||||||
default:
|
default:
|
||||||
return coerceAs(value, def.kind, def.default);
|
return coerceAs(value, def.kind, def.default);
|
||||||
}
|
}
|
||||||
@@ -843,7 +844,7 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
|
|||||||
* a struct field is brought into its declared shape, however deep it sits.
|
* a struct field is brought into its declared shape, however deep it sits.
|
||||||
*/
|
*/
|
||||||
export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): FieldValue {
|
export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): FieldValue {
|
||||||
if (field.kind === 'object') return NO_OBJECT;
|
if (field.kind === 'object') return objectPlace(value);
|
||||||
if (field.kind === 'struct') return structOf(field.fields, value);
|
if (field.kind === 'struct') return structOf(field.fields, value);
|
||||||
return field.kind === 'list' ? listOf(value) : coerceField(value, field.kind);
|
return field.kind === 'list' ? listOf(value) : coerceField(value, field.kind);
|
||||||
}
|
}
|
||||||
@@ -159,8 +159,9 @@ export const KINDS = [
|
|||||||
port: true,
|
port: true,
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* A live object, held for the run only. At rest – in a file, a save, the inspector – it is always
|
* A live object, held for the run only. At rest – in a file, a save, the inspector – it is the id
|
||||||
* the empty one, tagged wherever it sits so a run can tell its place from a text (`NO_OBJECT`).
|
* of an object of the same scene (#258) or the empty one, tagged wherever it sits so a run can
|
||||||
|
* tell its place from a text (`NO_OBJECT`, `objectPlace`).
|
||||||
*/
|
*/
|
||||||
{
|
{
|
||||||
kind: 'object',
|
kind: 'object',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
isList,
|
isList,
|
||||||
isStruct,
|
isStruct,
|
||||||
type ListValue,
|
type ListValue,
|
||||||
|
objectPlace,
|
||||||
type PropertyValue,
|
type PropertyValue,
|
||||||
type StructValue,
|
type StructValue,
|
||||||
tagged,
|
tagged,
|
||||||
@@ -36,6 +37,7 @@ export function itemKindOf(item: PropertyValue | undefined): NestedKind {
|
|||||||
export function makeItem(kind: NestedKind, value: PropertyValue | undefined): ListItem {
|
export function makeItem(kind: NestedKind, value: PropertyValue | undefined): ListItem {
|
||||||
if (kind === 'list') return listOf(value);
|
if (kind === 'list') return listOf(value);
|
||||||
if (kind === 'struct') return structItemOf(value);
|
if (kind === 'struct') return structItemOf(value);
|
||||||
|
if (kind === 'object') return objectPlace(value);
|
||||||
if (isTaggedKind(kind)) return tagged(kind, coerceField(untag(value), kind));
|
if (isTaggedKind(kind)) return tagged(kind, coerceField(untag(value), kind));
|
||||||
return coerceField(untag(value), kind);
|
return coerceField(untag(value), kind);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '../../core/properties/assetRef';
|
} from '../../core/properties/assetRef';
|
||||||
import { keyName } from '../../core/keys';
|
import { keyName } from '../../core/keys';
|
||||||
import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap';
|
import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap';
|
||||||
|
import { type ObjectChoice, objectReference, referencedId } from '../../core/model/objectRefs';
|
||||||
import { tilemapLayerNames } from '../../core/model/tilemap';
|
import { tilemapLayerNames } from '../../core/model/tilemap';
|
||||||
import { isKindWith, type ReferenceKind } from '../../core/properties/kinds';
|
import { isKindWith, type ReferenceKind } from '../../core/properties/kinds';
|
||||||
import {
|
import {
|
||||||
@@ -66,6 +67,11 @@ export interface FieldContext {
|
|||||||
followReference?(assetId: string, e: FederatedPointerEvent): void;
|
followReference?(assetId: string, e: FederatedPointerEvent): void;
|
||||||
/** Items a list draws inline before "… N more" (#252); none means all of them. */
|
/** Items a list draws inline before "… N more" (#252); none means all of them. */
|
||||||
listLimit?: number;
|
listLimit?: number;
|
||||||
|
/**
|
||||||
|
* What an object's place may name (#258), asked anew on every open. Only a placed object has a
|
||||||
|
* scene to pick from; without it the place shows "(null)".
|
||||||
|
*/
|
||||||
|
objectChoices?: () => ObjectChoice[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FieldHandlers {
|
export interface FieldHandlers {
|
||||||
@@ -448,6 +454,43 @@ function referenceField(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Target: <name> ▾" – another object of the same scene, or none (#258). Where there is no scene –
|
||||||
|
* on an asset – nothing can be picked, and a graph is what fills the place while the game runs.
|
||||||
|
*/
|
||||||
|
function objectField(ctx: FieldContext, value: PropertyValue, handlers: FieldHandlers): Field {
|
||||||
|
const choices = ctx.objectChoices;
|
||||||
|
if (!choices) return { widget: new Label('(null)', { style: 'muted' }), grow: true, setValue: () => {} };
|
||||||
|
// A list item arrives unwrapped, as the id alone.
|
||||||
|
const idOf = (v: PropertyValue) => (typeof v === 'string' ? v : referencedId(v));
|
||||||
|
let current = idOf(value);
|
||||||
|
const labelOf = () => {
|
||||||
|
if (!current) return 'None';
|
||||||
|
return choices().find((c) => c.ref === current)?.label ?? '(missing)';
|
||||||
|
};
|
||||||
|
const button = new Dropdown(labelOf());
|
||||||
|
button.onClick = () => {
|
||||||
|
const pick = (ref: string) => () => handlers.onChange(objectReference(ref));
|
||||||
|
const items: MenuItem[] = [{ label: 'None', current: !current, onSelect: pick('') }];
|
||||||
|
for (const choice of choices())
|
||||||
|
items.push({
|
||||||
|
label: `${' '.repeat(choice.depth)}${choice.label}`,
|
||||||
|
current: choice.ref === current,
|
||||||
|
onSelect: pick(choice.ref),
|
||||||
|
});
|
||||||
|
// Searchable like the parent picker: it lists every object of the scene.
|
||||||
|
new Menu(items, { searchable: true }).openBelow(button);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
widget: button,
|
||||||
|
grow: true,
|
||||||
|
setValue: (v) => {
|
||||||
|
current = idOf(v);
|
||||||
|
button.setText(labelOf());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** One field out of a bundle; a field the value does not carry reads as the empty one of its kind. */
|
/** One field out of a bundle; a field the value does not carry reads as the empty one of its kind. */
|
||||||
export function structFieldValue(value: PropertyValue, field: StructFieldDef): PropertyValue {
|
export function structFieldValue(value: PropertyValue, field: StructFieldDef): PropertyValue {
|
||||||
return (isStruct(value) ? value[field.key] : undefined) ?? emptyFieldValue(field);
|
return (isStruct(value) ? value[field.key] : undefined) ?? emptyFieldValue(field);
|
||||||
@@ -526,10 +569,8 @@ export function createField(
|
|||||||
parts: new Map([...fields].map(([key, sub]) => [key, sub.widget])),
|
parts: new Map([...fields].map(([key, sub]) => [key, sub.widget])),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'object': {
|
case 'object':
|
||||||
// Nothing to pick: the object is written by a graph while the game runs, and never stored.
|
return objectField(ctx, value, handlers);
|
||||||
return { widget: new Label('(null)', { style: 'muted' }), grow: true, setValue: () => {} };
|
|
||||||
}
|
|
||||||
case 'boolean': {
|
case 'boolean': {
|
||||||
const field = new Checkbox(Boolean(value));
|
const field = new Checkbox(Boolean(value));
|
||||||
field.onChange = handlers.onChange;
|
field.onChange = handlers.onChange;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
type ObjectRef,
|
type ObjectRef,
|
||||||
resolvedHolder,
|
resolvedHolder,
|
||||||
} from '../../core/model/instance';
|
} from '../../core/model/instance';
|
||||||
|
import { type ObjectChoice, objectChoices } from '../../core/model/objectRefs';
|
||||||
import { type PropertyValue, textOf } from '../../core/model/Properties';
|
import { type PropertyValue, textOf } from '../../core/model/Properties';
|
||||||
import { findAsset, findObjectHolder, type Project } from '../../core/model/Project';
|
import { findAsset, findObjectHolder, type Project } from '../../core/model/Project';
|
||||||
import type { SceneObject } from '../../core/model/SceneObject';
|
import type { SceneObject } from '../../core/model/SceneObject';
|
||||||
@@ -235,6 +236,7 @@ export function buildOwnRows(host: InspectorHost, holderId: string, objectId: st
|
|||||||
if (!object || !asset) return;
|
if (!object || !asset) return;
|
||||||
const values = resolveSceneObjectProps(project, asset, object);
|
const values = resolveSceneObjectProps(project, asset, object);
|
||||||
const own = objectRows(project, asset, object).filter((def) => isRowShown(def, values));
|
const own = objectRows(project, asset, object).filter((def) => isRowShown(def, values));
|
||||||
|
host.objectChoices = () => pickableObjects(host.state.project, holderId, objectId);
|
||||||
const behavior = asset.type === 'behavior';
|
const behavior = asset.type === 'behavior';
|
||||||
if (behavior) {
|
if (behavior) {
|
||||||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
@@ -251,6 +253,12 @@ export function buildOwnRows(host: InspectorHost, holderId: string, objectId: st
|
|||||||
if (inTab) markObjectRows(host, object, asset, targetOf(project, holderId, objectId));
|
if (inTab) markObjectRows(host, object, asset, targetOf(project, holderId, objectId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What an object's place on this placed object may name (#258). */
|
||||||
|
export function pickableObjects(project: Project, holderId: string, objectId: string): ObjectChoice[] {
|
||||||
|
const holder = findObjectHolder(project, holderId);
|
||||||
|
return holder ? objectChoices(project, holder, objectId) : [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grows and shrinks the grid at any edge, one column or row per click, while the painted cells stay
|
* Grows and shrinks the grid at any edge, one column or row per click, while the painted cells stay
|
||||||
* where they are in the scene (#136). The cells belong to the asset, so they move in every other
|
* where they are in the scene (#136). The cells belong to the asset, so they move in every other
|
||||||
@@ -402,7 +410,8 @@ function propertyRow(
|
|||||||
...actionIf(template, 'Apply to container', () =>
|
...actionIf(template, 'Apply to container', () =>
|
||||||
host.state.execute(new ApplyOverrideToContainerCommand(holderId, objectId, def.key)),
|
host.state.execute(new ApplyOverrideToContainerCommand(holderId, objectId, def.key)),
|
||||||
),
|
),
|
||||||
...actionIf(applyToAsset, 'Apply to asset', () =>
|
// An asset has no scene, so an object picked from one means nothing there (#258).
|
||||||
|
...actionIf(applyToAsset && def.kind !== 'object', 'Apply to asset', () =>
|
||||||
host.state.execute(new ApplyOverrideToAssetCommand(holderId, objectId, def.key)),
|
host.state.execute(new ApplyOverrideToAssetCommand(holderId, objectId, def.key)),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui';
|
|||||||
import { assetSignature, buildVariables, refreshAssetSection } from '../inspector/assetSection';
|
import { assetSignature, buildVariables, refreshAssetSection } from '../inspector/assetSection';
|
||||||
import type { InspectorHost } from '../inspector/host';
|
import type { InspectorHost } from '../inspector/host';
|
||||||
import { viewOf } from '../inspector/objectQueries';
|
import { viewOf } from '../inspector/objectQueries';
|
||||||
import { buildOwnRows, objectSignature, refreshObjectSection } from '../inspector/objectSection';
|
import {
|
||||||
|
buildOwnRows,
|
||||||
|
objectSignature,
|
||||||
|
pickableObjects,
|
||||||
|
refreshObjectSection,
|
||||||
|
} from '../inspector/objectSection';
|
||||||
import { InlineList, ListHead } from '../list/InlineList';
|
import { InlineList, ListHead } from '../list/InlineList';
|
||||||
import { type LiveVariables, resolveList } from '../list/slot';
|
import { type LiveVariables, resolveList } from '../list/slot';
|
||||||
import type { EditorState, ValuesTarget } from '../state';
|
import type { EditorState, ValuesTarget } from '../state';
|
||||||
@@ -135,6 +140,8 @@ export class VariablesEditor extends VStack {
|
|||||||
|
|
||||||
private buildList(slot: Extract<ValuesTarget, { kind: 'list' }>['slot']): void {
|
private buildList(slot: Extract<ValuesTarget, { kind: 'list' }>['slot']): void {
|
||||||
const access = () => resolveList(this.state, slot, this.liveVariables);
|
const access = () => resolveList(this.state, slot, this.liveVariables);
|
||||||
|
if (slot.kind === 'object')
|
||||||
|
this.host.objectChoices = () => pickableObjects(this.state.project, slot.holderId, slot.objectId);
|
||||||
const items = new InlineList(this.host, access);
|
const items = new InlineList(this.host, access);
|
||||||
const head = new ListHead(access, (kind) => items.addItem(kind), false);
|
const head = new ListHead(access, (kind) => items.addItem(kind), false);
|
||||||
items.head = head;
|
items.head = head;
|
||||||
|
|||||||
+14
-4
@@ -1,12 +1,13 @@
|
|||||||
import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from '../core/graph';
|
import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from '../core/graph';
|
||||||
import { newId } from '../core/ids';
|
import { newId } from '../core/ids';
|
||||||
import { type Asset, isPrivateVariable, type TilemapAsset } from '../core/model/Asset';
|
import { type Asset, isPrivateVariable, type TilemapAsset } from '../core/model/Asset';
|
||||||
import { withoutObjects } from '../core/graph/liveObjects';
|
import { sameAtRest, withoutObjects } from '../core/graph/liveObjects';
|
||||||
import { bagWithoutItemTypes, type PropertyBag, sameValue, type Vec2 } from '../core/model/Properties';
|
import { bagWithoutItemTypes, type PropertyBag, type Vec2 } from '../core/model/Properties';
|
||||||
import { isPlaceableType } from '../core/model/AssetTypes';
|
import { isPlaceableType } from '../core/model/AssetTypes';
|
||||||
import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance';
|
import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance';
|
||||||
import { treeOrder } from '../core/model/hierarchy';
|
import { treeOrder } from '../core/model/hierarchy';
|
||||||
import { liveTileAt, withTile } from '../core/model/liveTilemap';
|
import { liveTileAt, withTile } from '../core/model/liveTilemap';
|
||||||
|
import { referenceCandidates } from '../core/model/objectRefs';
|
||||||
import type { Cell } from '../core/model/tilemap';
|
import type { Cell } from '../core/model/tilemap';
|
||||||
import { descendantsBelow } from '../core/model/walk';
|
import { descendantsBelow } from '../core/model/walk';
|
||||||
import { findAsset, findAssetOf, findScene, type Project } from '../core/model/Project';
|
import { findAsset, findAssetOf, findScene, type Project } from '../core/model/Project';
|
||||||
@@ -255,7 +256,7 @@ export class World {
|
|||||||
// A private value never reaches the scene, so the scene's is only where it starts.
|
// A private value never reaches the scene, so the scene's is only where it starts.
|
||||||
for (const [key, value] of Object.entries(resolved)) {
|
for (const [key, value] of Object.entries(resolved)) {
|
||||||
if (isPrivateVariable(asset, key) && key in entity.props) continue;
|
if (isPrivateVariable(asset, key) && key in entity.props) continue;
|
||||||
if (!sameValue(value, withoutObjects(entity.props[key]))) entity.props[key] = value;
|
if (!sameAtRest(value, entity.props[key])) entity.props[key] = value;
|
||||||
}
|
}
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
@@ -331,7 +332,7 @@ export class World {
|
|||||||
k in base &&
|
k in base &&
|
||||||
!isPrivateVariable(asset, k) &&
|
!isPrivateVariable(asset, k) &&
|
||||||
base[k] !== entity.props[k] &&
|
base[k] !== entity.props[k] &&
|
||||||
!sameValue(base[k], withoutObjects(entity.props[k])),
|
!sameAtRest(base[k], entity.props[k]),
|
||||||
);
|
);
|
||||||
if (!moved && drifted.length === 0) continue;
|
if (!moved && drifted.length === 0) continue;
|
||||||
// Only now, because a ref for resolved content costs a resolve of its own – and because a
|
// Only now, because a ref for resolved content costs a resolve of its own – and because a
|
||||||
@@ -432,6 +433,15 @@ export class World {
|
|||||||
return this.entities.find((e) => e.objectId === objectId);
|
return this.entities.find((e) => e.objectId === objectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The entity a reference on `from` names (#258): its own container placement's copy first. */
|
||||||
|
findReferenced(from: Entity, ref: string): Entity | undefined {
|
||||||
|
for (const id of referenceCandidates(from.objectId, ref)) {
|
||||||
|
const found = this.findByObjectId(id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** The first entity with that name; an object without a name of its own carries its asset's name. */
|
/** The first entity with that name; an object without a name of its own carries its asset's name. */
|
||||||
findByName(name: string): Entity | undefined {
|
findByName(name: string): Entity | undefined {
|
||||||
return this.entities.find((e) => e.name === name);
|
return this.entities.find((e) => e.name === name);
|
||||||
|
|||||||
@@ -664,6 +664,10 @@ export class BehaviorSystem implements System {
|
|||||||
findByName: (name) => world.findByName(name) ?? null,
|
findByName: (name) => world.findByName(name) ?? null,
|
||||||
// By identity, not by id: a restarted scene builds new entities under the same ids.
|
// By identity, not by id: a restarted scene builds new entities under the same ids.
|
||||||
isAlive: (target) => world.entities.some((e) => e === target),
|
isAlive: (target) => world.entities.some((e) => e === target),
|
||||||
|
findReferenced: (from, ref) => {
|
||||||
|
const found = resolve(from);
|
||||||
|
return (found && world.findReferenced(found, ref)) ?? null;
|
||||||
|
},
|
||||||
childByName: (parent, name) => {
|
childByName: (parent, name) => {
|
||||||
const found = resolve(parent);
|
const found = resolve(parent);
|
||||||
return (found && world.childByName(found, name)) ?? null;
|
return (found && world.childByName(found, name)) ?? null;
|
||||||
|
|||||||
@@ -200,8 +200,10 @@ describe('a nested value in a running game', () => {
|
|||||||
const held = asField([{ who: { at: [crate] } }] as unknown as Value);
|
const held = asField([{ who: { at: [crate] } }] as unknown as Value);
|
||||||
expect(valueAt(held, [0, 'who', 'at', 0])).toBe(heldObject(crate));
|
expect(valueAt(held, [0, 'who', 'at', 0])).toBe(heldObject(crate));
|
||||||
expect(withoutObjects(held)).toEqual([{ who: { at: [NO_OBJECT] } }]);
|
expect(withoutObjects(held)).toEqual([{ who: { at: [NO_OBJECT] } }]);
|
||||||
expect(liveValue(held, () => false)).toEqual([{ who: { at: [NO_OBJECT] } }]);
|
expect(liveValue(held, { isAlive: () => false, referenced: () => null })).toEqual([
|
||||||
expect(liveValue(held, () => true)).toEqual(held);
|
{ who: { at: [NO_OBJECT] } },
|
||||||
|
]);
|
||||||
|
expect(liveValue(held, { isAlive: () => true, referenced: () => null })).toEqual(held);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -307,12 +307,13 @@ describe('no object leaves the run', () => {
|
|||||||
|
|
||||||
it('reads a destroyed object as nothing alone, as the empty object inside a list or struct', () => {
|
it('reads a destroyed object as nothing alone, as the empty object inside a list or struct', () => {
|
||||||
const crate = fakeEntity('Crate');
|
const crate = fakeEntity('Crate');
|
||||||
const dead = () => false;
|
const dead = { isAlive: () => false, referenced: () => null };
|
||||||
|
const alive = { isAlive: () => true, referenced: () => null };
|
||||||
expect(liveValue(crate as never, dead)).toBeNull();
|
expect(liveValue(crate as never, dead)).toBeNull();
|
||||||
expect(liveValue(NO_OBJECT, () => true)).toBeNull();
|
expect(liveValue(NO_OBJECT, alive)).toBeNull();
|
||||||
expect(liveValue([crate, 1] as never, dead)).toEqual([NO_OBJECT, 1]);
|
expect(liveValue([crate, 1] as never, dead)).toEqual([NO_OBJECT, 1]);
|
||||||
expect(liveValue({ who: crate } as never, dead)).toEqual({ who: NO_OBJECT });
|
expect(liveValue({ who: crate } as never, dead)).toEqual({ who: NO_OBJECT });
|
||||||
expect(liveValue([crate] as never, () => true)).toEqual([crate]);
|
expect(liveValue([crate] as never, alive)).toEqual([crate]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('names the objects of a list or struct when it reads as text', () => {
|
it('names the objects of a list or struct when it reads as text', () => {
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
|||||||
stopped: false,
|
stopped: false,
|
||||||
findByName: () => null,
|
findByName: () => null,
|
||||||
isAlive: () => true,
|
isAlive: () => true,
|
||||||
|
findReferenced: () => null,
|
||||||
childByName: () => null,
|
childByName: () => null,
|
||||||
isKeyDown: () => false,
|
isKeyDown: () => false,
|
||||||
pointer: { ...NO_POINTER },
|
pointer: { ...NO_POINTER },
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Picking an object of the same scene for an object variable (#258), with the real mouse: the
|
||||||
|
* placed behavior offers None and the scene's objects, the pick is stored as the object's id and a
|
||||||
|
* run reads the object itself. Removing that object drops the reference; undo brings it back. On the
|
||||||
|
* behavior asset there is no scene, so the row still says "(null)".
|
||||||
|
*
|
||||||
|
* Everything it adds carries an `or57` id and goes again at the end.
|
||||||
|
*/
|
||||||
|
import { MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||||
|
|
||||||
|
export async function objectReferences(t) {
|
||||||
|
const { check, json, evaluate, mutate, click, press, sleep, shot, select, undo, stop } = t;
|
||||||
|
const press_ = async (at, what) => {
|
||||||
|
if (!at) throw new Error(`nothing to click: ${what}`);
|
||||||
|
await click(at.x, at.y);
|
||||||
|
await sleep(150);
|
||||||
|
};
|
||||||
|
const target = () =>
|
||||||
|
json("__gm.state.activeScene.objects.find((o) => o.id === 'or57-obj')?.overrides.target ?? null");
|
||||||
|
|
||||||
|
// On start the behavior logs the object its `target` names.
|
||||||
|
const graph = {
|
||||||
|
nodes: [
|
||||||
|
{ id: 'or57-start', type: 'OnStart', x: 0, y: 0, params: {} },
|
||||||
|
{
|
||||||
|
id: 'or57-vars',
|
||||||
|
type: 'Variables',
|
||||||
|
x: 0,
|
||||||
|
y: 200,
|
||||||
|
params: {},
|
||||||
|
ports: [{ key: 'target', kind: 'object' }],
|
||||||
|
},
|
||||||
|
{ id: 'or57-log', type: 'Log', x: 320, y: 0, params: {} },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: 'or57-e1', from: { node: 'or57-start', port: 'exec' }, to: { node: 'or57-log', port: 'exec' } },
|
||||||
|
{
|
||||||
|
id: 'or57-e2',
|
||||||
|
from: { node: 'or57-vars', port: 'target' },
|
||||||
|
to: { node: 'or57-log', port: 'message' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await mutate(`
|
||||||
|
const p = __gm.state.project;
|
||||||
|
const scene = __gm.state.activeScene;
|
||||||
|
p.assets.push({
|
||||||
|
id: 'or57-aim',
|
||||||
|
type: 'behavior',
|
||||||
|
name: 'Aim 57',
|
||||||
|
props: { enabled: true, runInEditor: false, target: { $object: '' } },
|
||||||
|
variables: [{ key: 'target', label: 'target', kind: 'object', default: { $object: '' } }],
|
||||||
|
graph: ${JSON.stringify(graph)},
|
||||||
|
});
|
||||||
|
${MAKE_OBJECT}
|
||||||
|
const sprite = p.assets.find((a) => a.type === 'sprite');
|
||||||
|
scene.objects.push(make('or57-obj', 'or57-aim', '', 400, 'Aim 57'));
|
||||||
|
scene.objects.push(make('or57-crate', sprite.id, '', 450, 'Crate 57'));
|
||||||
|
`);
|
||||||
|
const sceneId = await json('__gm.state.activeScene.id');
|
||||||
|
|
||||||
|
// 57a. The behavior asset has no scene: its object variable only says "(null)".
|
||||||
|
await select({ kind: 'asset', assetId: 'or57-aim' });
|
||||||
|
await sleep(200);
|
||||||
|
check(
|
||||||
|
'on the behavior asset the object variable says (null)',
|
||||||
|
(await json("__gm.locate('(null)')")) !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 57b. On the placed object the row is a dropdown: None, the object itself and the Crate. A click
|
||||||
|
// on the Crate stores its id.
|
||||||
|
await select({ kind: 'object', holderId: sceneId, objectId: 'or57-obj' });
|
||||||
|
await sleep(200);
|
||||||
|
await press_(await json("__gm.inspectorField('target')"), 'the target field');
|
||||||
|
const offered = (await json('__gm.overlayTexts()')) ?? [];
|
||||||
|
await shot('01-menu');
|
||||||
|
check(
|
||||||
|
'the placed object offers None, itself and the other objects of the scene',
|
||||||
|
['None', 'Aim 57', 'Crate 57'].every((label) => offered.includes(label)),
|
||||||
|
JSON.stringify(offered),
|
||||||
|
);
|
||||||
|
await press_(await json("__gm.locate('Crate 57', true)"), 'Crate 57 in the menu');
|
||||||
|
const picked = await target();
|
||||||
|
check(
|
||||||
|
'picking the Crate stores its id and the row shows its name',
|
||||||
|
JSON.stringify(picked) === JSON.stringify({ $object: 'or57-crate' }) &&
|
||||||
|
(await json("__gm.locate('Crate 57')")) !== null,
|
||||||
|
JSON.stringify(picked),
|
||||||
|
);
|
||||||
|
await shot('02-picked');
|
||||||
|
|
||||||
|
// 57c. A run reads the object itself.
|
||||||
|
await evaluate('__gm.state.select(null)');
|
||||||
|
const logFrom = t.consoleLog.length;
|
||||||
|
await t.play();
|
||||||
|
await sleep(600);
|
||||||
|
await stop();
|
||||||
|
const logged = t.consoleLog
|
||||||
|
.slice(logFrom)
|
||||||
|
.filter((line) => line.startsWith('[behavior Aim 57]'))
|
||||||
|
.map((line) => line.split(']').pop().trim());
|
||||||
|
check('in a run the variable is the picked object', logged.includes('Crate 57'), JSON.stringify(logged));
|
||||||
|
|
||||||
|
// 57d. Removing the Crate drops the reference with it; one undo brings both back.
|
||||||
|
await select({ kind: 'object', holderId: sceneId, objectId: 'or57-crate' });
|
||||||
|
await sleep(150);
|
||||||
|
await press('Delete');
|
||||||
|
await sleep(150);
|
||||||
|
const dropped = await target();
|
||||||
|
await undo();
|
||||||
|
const back = await target();
|
||||||
|
check(
|
||||||
|
'removing the picked object drops the reference, undo brings it back',
|
||||||
|
JSON.stringify(dropped) === JSON.stringify({ $object: '' }) &&
|
||||||
|
JSON.stringify(back) === JSON.stringify({ $object: 'or57-crate' }),
|
||||||
|
JSON.stringify({ dropped, back }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up.
|
||||||
|
await evaluate('__gm.state.select(null)');
|
||||||
|
await mutate(`
|
||||||
|
const p = __gm.state.project;
|
||||||
|
const scene = __gm.state.activeScene;
|
||||||
|
scene.objects = scene.objects.filter((o) => !o.id.startsWith('or57-'));
|
||||||
|
p.assets = p.assets.filter((a) => a.id !== 'or57-aim');
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ import { curves } from './53-curves.mjs';
|
|||||||
import { structTypes } from './54-struct-types.mjs';
|
import { structTypes } from './54-struct-types.mjs';
|
||||||
import { typedLists } from './55-typed-lists.mjs';
|
import { typedLists } from './55-typed-lists.mjs';
|
||||||
import { privateVariables } from './56-private-variables.mjs';
|
import { privateVariables } from './56-private-variables.mjs';
|
||||||
|
import { objectReferences } from './57-object-references.mjs';
|
||||||
|
|
||||||
export const STEPS = [
|
export const STEPS = [
|
||||||
hierarchy,
|
hierarchy,
|
||||||
@@ -117,4 +118,5 @@ export const STEPS = [
|
|||||||
structTypes,
|
structTypes,
|
||||||
typedLists,
|
typedLists,
|
||||||
privateVariables,
|
privateVariables,
|
||||||
|
objectReferences,
|
||||||
];
|
];
|
||||||
Reference in new issue
Block a user