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>
75 lines
3.1 KiB
TypeScript
75 lines
3.1 KiB
TypeScript
/**
|
||
* A list declares nothing about what it holds: every item carries its own kind, read back off the
|
||
* value. JSON says what a number, a text, a boolean, a list and a struct are; a tagged kind says so
|
||
* itself (`tagOf`), which keeps every list ever stored readable.
|
||
*
|
||
* Items nest to any depth (#239): the items of a list item and the fields of a struct item go
|
||
* through the same coercion again, so nothing deeper is dropped.
|
||
*/
|
||
import {
|
||
ITEM_TYPE_KEY,
|
||
isList,
|
||
isStruct,
|
||
type ListValue,
|
||
objectPlace,
|
||
type PropertyValue,
|
||
type StructValue,
|
||
tagged,
|
||
tagOf,
|
||
untag,
|
||
} from '../model/Properties';
|
||
import { coerceField, isKindWith, isTaggedKind, type NestedKind, plainKindOf } from './kinds';
|
||
|
||
/** What a list holds. Identical to `ListValue[number]`, named for the places that mean one item. */
|
||
export type ListItem = PropertyValue;
|
||
|
||
/** The kind an item is, taken from the item itself – there is nowhere else it could come from. */
|
||
export function itemKindOf(item: PropertyValue | undefined): NestedKind {
|
||
const tag = tagOf(item);
|
||
if (tag !== undefined && isTaggedKind(tag)) return tag;
|
||
if (isList(item)) return 'list';
|
||
if (isStruct(item)) return 'struct';
|
||
const plain = plainKindOf(typeof item);
|
||
return plain !== undefined && isKindWith('field', plain) ? plain : 'number';
|
||
}
|
||
|
||
/** `value` as an item of `kind`, which is how a row changes what it holds. */
|
||
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);
|
||
}
|
||
|
||
/** `value` as an item of the kind the slot already holds, so writing a color keeps it a color. */
|
||
export function itemLike(current: PropertyValue | undefined, value: PropertyValue | undefined): ListItem {
|
||
return makeItem(itemKindOf(current), value);
|
||
}
|
||
|
||
/** Any value as a valid item; the one coercion a list value goes through. */
|
||
export function listItemOf(value: PropertyValue | undefined): ListItem {
|
||
return makeItem(itemKindOf(value), value);
|
||
}
|
||
|
||
/** The items of a list, each as the kind its own value says; anything else holds none. */
|
||
export function listOf(value: PropertyValue | undefined): ListValue {
|
||
return isList(value) ? value.map(listItemOf) : [];
|
||
}
|
||
|
||
/** A struct item keeps the fields it carries, each as the kind its own value says. */
|
||
export function structItemOf(value: PropertyValue | undefined): StructValue {
|
||
if (!isStruct(value) || tagOf(value) !== undefined) return {};
|
||
const fields: Record<string, PropertyValue> = {};
|
||
for (const [key, item] of Object.entries(value)) fields[key] = listItemOf(item);
|
||
return fields;
|
||
}
|
||
|
||
/** The shape a struct item has right now: its keys in order, each with the kind of its value. */
|
||
export function structItemFields(item: PropertyValue | undefined): { key: string; kind: NestedKind }[] {
|
||
if (!isStruct(item) || tagOf(item) !== undefined) return [];
|
||
return Object.entries(item)
|
||
.filter(([key]) => key !== ITEM_TYPE_KEY)
|
||
.map(([key, value]) => ({ key, kind: itemKindOf(value) }));
|
||
}
|