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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user