diff --git a/README.md b/README.md index f30ab8e6..2b15f97a 100644 --- a/README.md +++ b/README.md @@ -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: ```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 node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/containerCommands.ts b/src/core/commands/containerCommands.ts index ff65d74a..022ce55d 100644 --- a/src/core/commands/containerCommands.ts +++ b/src/core/commands/containerCommands.ts @@ -1,7 +1,8 @@ import { newId } from '../ids'; import type { ContainerAsset } from '../model/Asset'; 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 type { Position } from '../model/Properties'; import { findAsset, findObjectHolder, type Project } from '../model/Project'; @@ -14,6 +15,7 @@ import { worldTransform, } from '../properties/transform'; import { removeById, type Removed, restoreAt } from '../util/list'; +import { Recorder } from '../util/recorder'; import { buildAsset } from './assetCommands'; import { type Command, CommandError } from './Command'; import { nextAssetName } from './naming'; @@ -30,6 +32,7 @@ export class CreateContainerCommand implements Command { private removed: Removed[] = []; /** Kept across a redo: the ids of the resolved content are built from them. */ private readonly templateIds = new Map(); + private readonly references = new Recorder(); constructor( private readonly holderId: string, @@ -110,11 +113,21 @@ export class CreateContainerCommand implements Command { const groupId = rootGroups.size === 1 ? [...rootGroups][0] : undefined; if (!containerParentId && groupId) container.groupId = groupId; 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); holder.objects.push(container); } undo(project: Project): void { + this.references.revert(); const holder = findObjectHolder(project, this.holderId); if (holder) { holder.objects = removeById(holder.objects, this.objectId); @@ -147,6 +160,7 @@ export class UnpackContainerCommand implements Command { const created = materializeInstance(project, holder, object); if (created.length === 0) throw new CommandError('The container is empty, there is nothing to unpack'); this.createdIds = created.map((o) => o.id); + anchorReferences(created); this.edits = object.instanceEdits; object.instanceEdits = {}; holder.objects.push(...created); diff --git a/src/core/commands/pasteCommands.ts b/src/core/commands/pasteCommands.ts index b6924e6b..cd2da701 100644 --- a/src/core/commands/pasteCommands.ts +++ b/src/core/commands/pasteCommands.ts @@ -5,6 +5,8 @@ import { newId } from '../ids'; import type { GraphClip, ObjectClip } from '../model/clipboard'; import { findGroup } from '../model/hierarchy'; import { isLiveInstance } from '../model/instance'; +import { swapReferences } from '../model/objectRefs'; +import { Recorder } from '../util/recorder'; import { placementRejection } from '../model/placement'; import type { Vec2 } from '../model/Properties'; 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); 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); } diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts index c42b9c8f..40447aef 100644 --- a/src/core/commands/sceneObjectCommands.ts +++ b/src/core/commands/sceneObjectCommands.ts @@ -7,7 +7,9 @@ import { type ObjectRef, restoreObject, } from '../model/instance'; +import { swapReferences } from '../model/objectRefs'; import { placementRejection } from '../model/placement'; +import { Recorder } from '../util/recorder'; import { type Position, 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 { readonly label: string; private removed: Removed[] = []; + private readonly references = new Recorder(); constructor( private readonly holderId: string, @@ -114,9 +120,12 @@ export class RemoveSceneObjectsCommand implements Command { const holder = requireHolder(project, this.holderId); for (const id of this.objectIds) requireStructuralObject(project, holder, id); 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 { + this.references.revert(); restoreAt(requireHolder(project, this.holderId).objects, this.removed); } } diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index b0e8bf73..6e35ea61 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -144,6 +144,8 @@ export interface NodeContext { findByName(name: string): EntityHandle | null; /** Whether the object is still in the game – false once destroyed, or once its world has ended. */ 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. */ childByName(parent: EntityHandle, name: string): EntityHandle | null; isKeyDown(key: string): boolean; diff --git a/src/core/graph/liveObjects.ts b/src/core/graph/liveObjects.ts index b7453af8..94d8dd0d 100644 --- a/src/core/graph/liveObjects.ts +++ b/src/core/graph/liveObjects.ts @@ -4,9 +4,11 @@ import { NO_OBJECT, type PropertyBag, type PropertyValue, + sameValue, tagOf, } 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 { isEntity, isObjectPlace, type Value, writeTyped } from './values'; @@ -20,34 +22,77 @@ export function withoutObjects(value: T): 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 { const out: PropertyBag = {}; for (const [key, value] of Object.entries(bag)) out[key] = withoutObjects(value); 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 - * never goes on acting on one that has left the game. Alone it reads as nothing (`null`), inside a - * list or struct as the empty object. + * never goes on acting on one that has left the game, and a reference picked in the editor (#258) is + * 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( - value: PropertyValue | undefined, - isAlive: (target: EntityHandle) => boolean, -): Value { - if (value === undefined || tagOf(value) === 'object') return null; - if (isEntity(value)) return isAlive(value) ? value : null; - return mapObjects(value, (held) => (isAlive(held) ? held : NO_OBJECT)) as Value; +export function liveValue(value: PropertyValue | undefined, objects: ObjectLookup): Value { + if (value === undefined) return null; + if (tagOf(value) === 'object') return referencedObject(value, objects); + if (isEntity(value)) return objects.isAlive(value) ? value : null; + return mapObjects( + 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, + 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. */ export function readLive( bag: PropertyBag, outputs: readonly PortDef[], - isAlive: (target: EntityHandle) => boolean, + objects: ObjectLookup, ): Record { const values: Record = {}; - 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; } @@ -57,16 +102,24 @@ export function writePort(bag: PropertyBag, port: PortDef, value: Value | undefi 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 (place && tagOf(value) === 'object') return place(value as PropertyValue); 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; } if (isStruct(value) && tagOf(value) === undefined) { 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 value; diff --git a/src/core/graph/nodes/data/Properties.ts b/src/core/graph/nodes/data/Properties.ts index 482ee4eb..79d1e0f0 100644 --- a/src/core/graph/nodes/data/Properties.ts +++ b/src/core/graph/nodes/data/Properties.ts @@ -6,7 +6,7 @@ import { propertyPort, propertyTargetOf, } from '../../propertyPorts'; -import { readLive, writePort } from '../../liveObjects'; +import { objectsOf, readLive, writePort } from '../../liveObjects'; import { asEntity } from '../../values'; import { exec, obj } from '../shared'; @@ -30,7 +30,7 @@ export const Properties: NodeDef = { }, read: (ctx, _params, input, def) => { 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) => { const target = asEntity(inputs.object); diff --git a/src/core/graph/nodes/variable/Variables.ts b/src/core/graph/nodes/variable/Variables.ts index 00af54ad..1038011e 100644 --- a/src/core/graph/nodes/variable/Variables.ts +++ b/src/core/graph/nodes/variable/Variables.ts @@ -1,6 +1,6 @@ import { collapsedTitle, portDefOf, VARIABLES, VARIABLES_SETTING, variablesSourceOf } from '../../nested'; import { type NodeDef, withInlineDefault } from '../../NodeRegistry'; -import { readLive, writePort } from '../../liveObjects'; +import { objectsOf, readLive, writePort } from '../../liveObjects'; import { variableBag } from '../../sharedState'; import { exec } from '../shared'; @@ -26,7 +26,7 @@ export const Variables: NodeDef = { return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'variables') } : shape; }, 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) => { const bag = variableBag(ctx, variablesSourceOf(params)); for (const port of def.inputs) if (port.type !== 'exec') writePort(bag, port, inputs[port.key]); diff --git a/src/core/model/Properties.ts b/src/core/model/Properties.ts index 61d25bf9..507e7344 100644 --- a/src/core/model/Properties.ts +++ b/src/core/model/Properties.ts @@ -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 - * nothing, since an object exists only while the game runs, but tagged, so a run tells the place - * apart from an empty text (`graph/liveObjects.ts`). + * What an object's place holds at rest when it names no object – a variable, a struct field, a list + * item alike. Tagged, so a run tells the place apart from an empty text (`graph/liveObjects.ts`). */ 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. */ export function untag(value: PropertyValue | undefined): PropertyValue | undefined { const tag = tagOf(value); diff --git a/src/core/model/objectRefs.ts b/src/core/model/objectRefs.ts new file mode 100644 index 00000000..257fb273 --- /dev/null +++ b/src/core/model/objectRefs.ts @@ -0,0 +1,126 @@ +/** + * An object's place names another object of the same scene by its id (#258): `{"$object": ""}`, + * 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 (`~`) 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; +} diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 9340fc28..8cd1da0c 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -8,6 +8,7 @@ import { type PropertyBag, type PropertyValue, NO_OBJECT, + objectPlace, type StructValue, textOf, } from '../model/Properties'; @@ -832,7 +833,7 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal case 'struct': return structOf(def.fields, value); case 'object': - return NO_OBJECT; + return objectPlace(value); 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. */ 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); return field.kind === 'list' ? listOf(value) : coerceField(value, field.kind); } diff --git a/src/core/properties/kinds.ts b/src/core/properties/kinds.ts index 609361e4..54e21295 100644 --- a/src/core/properties/kinds.ts +++ b/src/core/properties/kinds.ts @@ -159,8 +159,9 @@ export const KINDS = [ port: true, }, /** - * A live object, held for the run only. At rest – in a file, a save, the inspector – it is always - * the empty one, tagged wherever it sits so a run can tell its place from a text (`NO_OBJECT`). + * A live object, held for the run only. At rest – in a file, a save, the inspector – it is the id + * 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', diff --git a/src/core/properties/listItems.ts b/src/core/properties/listItems.ts index a8424ca0..8d380df3 100644 --- a/src/core/properties/listItems.ts +++ b/src/core/properties/listItems.ts @@ -11,6 +11,7 @@ import { isList, isStruct, type ListValue, + objectPlace, type PropertyValue, type StructValue, tagged, @@ -36,6 +37,7 @@ export function itemKindOf(item: PropertyValue | undefined): NestedKind { export function makeItem(kind: NestedKind, value: PropertyValue | undefined): ListItem { if (kind === 'list') return listOf(value); if (kind === 'struct') return structItemOf(value); + if (kind === 'object') return objectPlace(value); if (isTaggedKind(kind)) return tagged(kind, coerceField(untag(value), kind)); return coerceField(untag(value), kind); } diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 9181138a..50adb89e 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -17,6 +17,7 @@ import { } from '../../core/properties/assetRef'; import { keyName } from '../../core/keys'; import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap'; +import { type ObjectChoice, objectReference, referencedId } from '../../core/model/objectRefs'; import { tilemapLayerNames } from '../../core/model/tilemap'; import { isKindWith, type ReferenceKind } from '../../core/properties/kinds'; import { @@ -66,6 +67,11 @@ export interface FieldContext { followReference?(assetId: string, e: FederatedPointerEvent): void; /** Items a list draws inline before "… N more" (#252); none means all of them. */ 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 { @@ -448,6 +454,43 @@ function referenceField( }; } +/** + * "Target: ▾" – 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. */ export function structFieldValue(value: PropertyValue, field: StructFieldDef): PropertyValue { 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])), }; } - case 'object': { - // Nothing to pick: the object is written by a graph while the game runs, and never stored. - return { widget: new Label('(null)', { style: 'muted' }), grow: true, setValue: () => {} }; - } + case 'object': + return objectField(ctx, value, handlers); case 'boolean': { const field = new Checkbox(Boolean(value)); field.onChange = handlers.onChange; diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 1527884a..1ced80c6 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -26,6 +26,7 @@ import { type ObjectRef, resolvedHolder, } from '../../core/model/instance'; +import { type ObjectChoice, objectChoices } from '../../core/model/objectRefs'; import { type PropertyValue, textOf } from '../../core/model/Properties'; import { findAsset, findObjectHolder, type Project } from '../../core/model/Project'; import type { SceneObject } from '../../core/model/SceneObject'; @@ -235,6 +236,7 @@ export function buildOwnRows(host: InspectorHost, holderId: string, objectId: st if (!object || !asset) return; const values = resolveSceneObjectProps(project, asset, object); const own = objectRows(project, asset, object).filter((def) => isRowShown(def, values)); + host.objectChoices = () => pickableObjects(host.state.project, holderId, objectId); const behavior = asset.type === 'behavior'; if (behavior) { 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)); } +/** 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 * 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', () => 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)), ), ]; diff --git a/src/editor/variables/VariablesEditor.ts b/src/editor/variables/VariablesEditor.ts index 14511a3c..4e077ae2 100644 --- a/src/editor/variables/VariablesEditor.ts +++ b/src/editor/variables/VariablesEditor.ts @@ -11,7 +11,12 @@ import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui'; import { assetSignature, buildVariables, refreshAssetSection } from '../inspector/assetSection'; import type { InspectorHost } from '../inspector/host'; 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 { type LiveVariables, resolveList } from '../list/slot'; import type { EditorState, ValuesTarget } from '../state'; @@ -135,6 +140,8 @@ export class VariablesEditor extends VStack { private buildList(slot: Extract['slot']): void { 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 head = new ListHead(access, (kind) => items.addItem(kind), false); items.head = head; diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 92131739..487ea317 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -1,12 +1,13 @@ import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from '../core/graph'; import { newId } from '../core/ids'; import { type Asset, isPrivateVariable, type TilemapAsset } from '../core/model/Asset'; -import { withoutObjects } from '../core/graph/liveObjects'; -import { bagWithoutItemTypes, type PropertyBag, sameValue, type Vec2 } from '../core/model/Properties'; +import { sameAtRest, withoutObjects } from '../core/graph/liveObjects'; +import { bagWithoutItemTypes, type PropertyBag, type Vec2 } from '../core/model/Properties'; import { isPlaceableType } from '../core/model/AssetTypes'; import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance'; import { treeOrder } from '../core/model/hierarchy'; import { liveTileAt, withTile } from '../core/model/liveTilemap'; +import { referenceCandidates } from '../core/model/objectRefs'; import type { Cell } from '../core/model/tilemap'; import { descendantsBelow } from '../core/model/walk'; 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. for (const [key, value] of Object.entries(resolved)) { 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; } @@ -331,7 +332,7 @@ export class World { k in base && !isPrivateVariable(asset, 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; // 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); } + /** 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. */ findByName(name: string): Entity | undefined { return this.entities.find((e) => e.name === name); diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index b97a149f..dd48349a 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -664,6 +664,10 @@ export class BehaviorSystem implements System { findByName: (name) => world.findByName(name) ?? null, // By identity, not by id: a restarted scene builds new entities under the same ids. 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) => { const found = resolve(parent); return (found && world.childByName(found, name)) ?? null; diff --git a/tests/core/nested-values.test.ts b/tests/core/nested-values.test.ts index b867645b..55b96f5e 100644 --- a/tests/core/nested-values.test.ts +++ b/tests/core/nested-values.test.ts @@ -200,8 +200,10 @@ describe('a nested value in a running game', () => { const held = asField([{ who: { at: [crate] } }] as unknown as Value); expect(valueAt(held, [0, 'who', 'at', 0])).toBe(heldObject(crate)); expect(withoutObjects(held)).toEqual([{ who: { at: [NO_OBJECT] } }]); - expect(liveValue(held, () => false)).toEqual([{ who: { at: [NO_OBJECT] } }]); - expect(liveValue(held, () => true)).toEqual(held); + expect(liveValue(held, { isAlive: () => false, referenced: () => null })).toEqual([ + { who: { at: [NO_OBJECT] } }, + ]); + expect(liveValue(held, { isAlive: () => true, referenced: () => null })).toEqual(held); }); }); diff --git a/tests/core/object-references.test.ts b/tests/core/object-references.test.ts new file mode 100644 index 00000000..bbf109fe --- /dev/null +++ b/tests/core/object-references.test.ts @@ -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 }); + }); +}); diff --git a/tests/core/object-variable.test.ts b/tests/core/object-variable.test.ts index 8612b5e7..394261b0 100644 --- a/tests/core/object-variable.test.ts +++ b/tests/core/object-variable.test.ts @@ -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', () => { 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(NO_OBJECT, () => true)).toBeNull(); + expect(liveValue(NO_OBJECT, alive)).toBeNull(); expect(liveValue([crate, 1] as never, dead)).toEqual([NO_OBJECT, 1]); 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', () => { diff --git a/tests/helpers.ts b/tests/helpers.ts index 708ec295..6bbee598 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -229,6 +229,7 @@ export function fakeContext(overrides: Partial = {}): FakeContext { stopped: false, findByName: () => null, isAlive: () => true, + findReferenced: () => null, childByName: () => null, isKeyDown: () => false, pointer: { ...NO_POINTER }, diff --git a/tools/e2e/57-object-references.mjs b/tools/e2e/57-object-references.mjs new file mode 100644 index 00000000..9b940671 --- /dev/null +++ b/tools/e2e/57-object-references.mjs @@ -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'); + `); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index e1e226be..aa361592 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -59,6 +59,7 @@ import { curves } from './53-curves.mjs'; import { structTypes } from './54-struct-types.mjs'; import { typedLists } from './55-typed-lists.mjs'; import { privateVariables } from './56-private-variables.mjs'; +import { objectReferences } from './57-object-references.mjs'; export const STEPS = [ hierarchy, @@ -117,4 +118,5 @@ export const STEPS = [ structTypes, typedLists, privateVariables, + objectReferences, ];