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
@@ -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: <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. */
|
||||
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;
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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<ValuesTarget, { kind: 'list' }>['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;
|
||||
|
||||
Reference in new issue
Block a user