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
@@ -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;
|
||||
|
||||
@@ -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<T extends PropertyValue | undefined>(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<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. */
|
||||
export function readLive(
|
||||
bag: PropertyBag,
|
||||
outputs: readonly PortDef[],
|
||||
isAlive: (target: EntityHandle) => boolean,
|
||||
objects: ObjectLookup,
|
||||
): 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in new issue
Block a user