Reset/apply buttons only for real deviations, collapsible hierarchy tree

- Inspector rows compute their reset/apply entries through providers, so an
  entry that would not change anything is dropped; a single remaining entry
  renders as a direct button instead of a menu, none removes it entirely.
- Bold and buttons now follow the same rule (deviatesFromAsset /
  deviatesFromTemplate in core/properties/resolve.ts).
- Hierarchy panel is a collapsible tree: chevrons, arrow keys, double click,
  expand/collapse all. Everything starts collapsed; selecting an object
  expands its ancestors and scrolls it into view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 06:56:10 +02:00
1 parent 76aa441dce
commit 2511483a67
13 files changed
+458 -56

No files matched your search

+8 -3
View File
@@ -18,9 +18,9 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
| **Selection** | One or more scene objects. Click selects, `Ctrl`-click adds/removes, dragging on empty scene space draws a rubber band (objects whose position lies inside are selected). Dragging a selected object moves the whole selection; `Delete` removes it; the inspector offers **Create container** and **Remove from scene** for several objects. |
| **Behavior** | Logic as an asset: a **node graph** plus the **variables** it declares. Placed like anything else (a diamond symbol in the scene); its parent is the **owner** the graph acts on. |
| **Collider** | A shape (box or circle) for collision detection, placed as a **child** of the object it belongs to (its *body*). Drawn as a green outline in edit mode only. |
| **Override** | A per-object value that replaces the asset's value for one property. Only changed keys are stored; the inspector marks them bold and offers a reset (↺) and an **apply** (↑) that writes the value into the asset instead. |
| **Override** | A per-object value that replaces the asset's value for one property. Only changed keys are stored; the inspector marks them bold and offers a reset (↺) and an **apply** (↑) that writes the value into the asset instead. Both appear only where the value really differs from its base – an override carrying the asset's own value is none. |
| **Default** | Every property has a schema default. Asset values that differ from it are marked bold in the inspector and can be reset (↺) the same way an override can. |
| **Instance** | An object that is part of a placed container. Its base is the **template**, not just the asset: position and overrides that differ from the template are bold, ↺ reverts to the template, ↑ ▾ offers **Apply to container** (template; copies still at the old value follow, copies changed on their own keep theirs) or **Apply to asset**. Position rows apply to the container directly. |
| **Instance** | An object that is part of a placed container. It has two bases: the **template** it was instantiated from and the asset behind it. A row lists only the bases its value really differs from – ↺ ▾ offers **Reset to container** (template value) and/or **Reset to asset** (drop the override – the template is only read when the container is placed, never at draw time), ↑ ▾ **Apply to container** (template; copies still at the old value follow, copies changed on their own keep theirs) and/or **Apply to asset**. With one base left the menu collapses to a plain ↺ / ↑, with none the buttons disappear. Bold marks the same thing the buttons offer. Position rows compare against the template only. Right after *Create container* an instance matches its template (the overrides were copied into it) but may still override the asset – then only the asset entry shows. |
| **Edit mode** | The Scene tab draws the project model; panels, grid, gizmos and drag-to-move are active. |
| **Play mode** | The editor switches to a **runtime copy** of the project (same ids). A `World` is instantiated from its active scene (one entity per scene object, resolved properties as mutable state) and shown in the **Game** tab. Systems tick it and mirror their changes into the copy; every panel stays open and edits the copy, which the world picks up immediately. **Stop discards world and copy** – the saved model is never touched. |
@@ -108,7 +108,12 @@ parent and are removed with it (also when the parent's asset is deleted); undo b
subtree back. Re-parenting keeps the world position by recomputing the local one, and cycles
are rejected. In the **Scene objects** panel children are indented below their parent; drag a
row onto another row to parent it, onto the empty space below the rows (or right-click →
Unparent) to move it to the top level; the inspector offers the same as a **Parent** menu. In
Unparent) to move it to the top level; the inspector offers the same as a **Parent** menu.
Rows with children **fold**: click the chevron (or press ←/→, or double-click the row) to open
and close a subtree, right-click → **Expand all** / **Collapse all** for the whole scene. A
scene opens fully collapsed – which rows are open is view state of the panel, never stored in
the project – and selecting an object anywhere (viewport, `Add collider`, `Create container`)
unfolds the way down to it and scrolls it into view, so a selection is never hidden. In
play mode entities carry the same `parent` link: moving a parent entity moves its subtree,
`Destroy` removes it. `Get position` exposes both the local and the world coordinates.
+43
View File
@@ -35,6 +35,21 @@ export function descendantsOf(tree: ObjectTree, id: string): SceneObject[] {
return result;
}
/** Ids of the ancestors of `id`, nearest parent first. Empty for a top-level object. */
export function ancestorIdsOf(tree: ObjectTree, id: string): string[] {
const result: string[] = [];
let current = findObject(tree, id);
const seen = new Set<string>();
while (current?.parentId && !seen.has(current.id)) {
seen.add(current.id);
const parent = findObject(tree, current.parentId);
if (!parent || seen.has(parent.id)) break;
result.push(parent.id);
current = parent;
}
return result;
}
/** Whether `ancestorId` is `objectId` itself or one of its ancestors (guards against cycles). */
export function isSelfOrAncestor(tree: ObjectTree, ancestorId: string, objectId: string): boolean {
let current: SceneObject | undefined = findObject(tree, objectId);
@@ -114,3 +129,31 @@ export function treeOrder(tree: ObjectTree): TreeEntry[] {
}
return result;
}
export interface VisibleEntry extends TreeEntry {
/** Whether the object has children at all – only those rows offer a expand/collapse chevron. */
hasChildren: boolean;
}
/**
* Tree order with the subtrees of collapsed objects left out: a row is shown when *every*
* ancestor is expanded. Expansion is a view state (which ids are open), never stored in the
* project – anything not listed counts as collapsed, so a freshly loaded scene shows only its
* top level.
*/
export function visibleTreeOrder(tree: ObjectTree, expanded: ReadonlySet<string>): VisibleEntry[] {
const parents = new Set(tree.objects.map((o) => o.parentId).filter(Boolean));
const result: VisibleEntry[] = [];
// Depth of the collapsed row we are currently skipping the subtree of (-1 = showing rows).
let collapsedAt = -1;
for (const entry of treeOrder(tree)) {
if (collapsedAt >= 0) {
if (entry.depth > collapsedAt) continue;
collapsedAt = -1;
}
const hasChildren = parents.has(entry.object.id);
result.push({ ...entry, hasChildren });
if (hasChildren && !expanded.has(entry.object.id)) collapsedAt = entry.depth;
}
return result;
}
+19
View File
@@ -48,3 +48,22 @@ export function isModified(asset: Asset, key: string): boolean {
export function inheritedValue(asset: Asset, key: string): PropertyValue | undefined {
return resolveAssetProps(asset)[key];
}
/**
* Whether the object's value for `key` really differs from the asset's – an override that
* happens to carry the asset's own value is none. The inspector offers "reset"/"apply" only
* where they would change something, so this is what decides whether the buttons appear.
*/
export function deviatesFromAsset(object: SceneObject, asset: Asset, key: string): boolean {
const own = object.overrides[key];
return own !== undefined && own !== inheritedValue(asset, key);
}
/**
* Whether the object's override for `key` differs from the template it was instantiated from.
* Compared as overrides, not as effective values: both sides resolve against the same asset,
* so "neither overrides" means equal even when the asset value changed since.
*/
export function deviatesFromTemplate(object: SceneObject, template: SceneObject, key: string): boolean {
return object.overrides[key] !== template.overrides[key];
}
+4
View File
@@ -81,7 +81,11 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor):
},
},
world: () => editor.worldSnapshot(),
/** Rows the hierarchy shows – collapsed subtrees are not in here. */
hierarchyOrder: () => editor.hierarchy.rowOrder(),
hierarchyChevron: (id: string) => editor.hierarchy.chevronPosition(id),
hierarchyExpandAll: () => editor.hierarchy.expandAll(),
hierarchyCollapseAll: () => editor.hierarchy.collapseAll(),
fps: () => editor.fps,
tab: () => editor.tab,
showTab: (tab: CentreTab) => editor.showTab(tab),
+46 -21
View File
@@ -14,29 +14,36 @@ export interface FieldRow {
/** Push a model value into the widget (no events fired). */
setValue(value: PropertyValue): void;
/**
* Highlight the row as deviating from its base value and show the reset button:
* an override on scene objects, a non-default value on assets.
* Highlight the row as deviating from its base value (an override on scene objects, a
* non-default value on assets) and re-evaluate which actions currently apply.
*/
setModified(modified: boolean): void;
}
export const FIELD_LABEL_WIDTH = 64;
/** One way of writing a changed value back to its source ("Apply to asset", "Apply to container"). */
export interface ApplyAction {
/** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */
export interface FieldAction {
label: string;
onSelect: () => void;
}
/**
* The actions available *right now* – asked again on every refresh and on every click.
* Anything that would not change the value is left out, so a row never offers a dead entry.
*/
export type FieldActions = () => FieldAction[];
/**
* Maps one PropertySchema entry to a labelled row containing the matching widget.
* The inspector is entirely generated through this function: adding a new property
* kind means adding a case here, adding a property means adding a schema line.
*
* A modified row shows its actions at the right: ↺ resets to the base value, ↑ applies the
* value to its source – directly with one action, via a menu (↑ ▾) with several.
* A row shows its actions at the right: ↺ resets to the base value, ↑ applies the value to
* its source. Which of them exist is asked from `reset`/`apply` on every refresh: no entry
* means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾).
*/
export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, onReset?: () => void, apply?: ApplyAction[]): FieldRow {
export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, reset?: FieldActions, apply?: FieldActions): FieldRow {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const label = new Label(def.label, { style: 'muted' });
@@ -47,32 +54,50 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Label(''), 1);
const actions: Button[] = [];
if (apply && apply.length > 0) {
const single = apply.length === 1;
const button = new Button(single ? '↑' : '↑ ▾', { variant: 'ghost', width: single ? Theme.controlHeight : undefined });
const actions: { button: Button; glyph: string; available: FieldActions }[] = [];
const addActions = (glyph: string, available: FieldActions): void => {
const button = new Button(glyph, { variant: 'ghost', width: Theme.controlHeight });
// Asked again on click: the menu must not act on a state that has moved on since the
// last refresh (an undo between refreshes, a value edited in the viewport).
button.onClick = () => {
if (single) return apply[0]!.onSelect();
const items = available();
if (items.length === 0) return;
if (items.length === 1) return items[0]!.onSelect();
const g = button.getGlobalPosition();
new Menu(apply.map((a) => ({ label: a.label, onSelect: a.onSelect }))).open(g.x, g.y + button.h + 2);
new Menu(items.map((a) => ({ label: a.label, onSelect: a.onSelect }))).open(g.x, g.y + button.h + 2);
};
actions.push(button);
}
if (onReset) actions.push(new Button('↺', { variant: 'ghost', width: Theme.controlHeight, onClick: onReset }));
for (const button of actions) {
button.visible = false;
actions.push({ button, glyph, available });
row.add(button);
};
if (apply) addActions('↑', apply);
if (reset) addActions('↺', reset);
const refreshActions = (): void => {
let changed = false;
for (const { button, glyph, available } of actions) {
const count = available().length;
const text = count > 1 ? `${glyph} ▾` : glyph;
const visible = count > 0;
if (button.visible !== visible) {
button.visible = visible;
changed = true;
}
if (visible && button.text !== text) {
button.setText(text);
changed = true;
}
}
if (changed) row.relayout();
};
refreshActions();
return {
row,
setValue: field.setValue,
setModified(modified) {
label.setStyle(modified ? 'bodyBold' : 'muted');
if (actions.length > 0 && actions[0]!.visible !== modified) {
for (const button of actions) button.visible = modified;
row.relayout();
}
refreshActions();
},
};
}
+68 -6
View File
@@ -1,6 +1,6 @@
import { AddColliderCommand } from '../../core/commands/colliderCommands';
import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { treeOrder } from '../../core/model/hierarchy';
import { ancestorIdsOf, visibleTreeOrder } from '../../core/model/hierarchy';
import { findAsset } from '../../core/model/Project';
import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { ListView, Menu, Panel, ScrollContainer } from '../../ui';
@@ -12,10 +12,16 @@ import { type EditorState, objectSelection } from '../state';
* Click selects, Ctrl/Shift-click adds to or removes from the selection. Drag a row onto
* another to parent it there; drop it on the empty space below the rows to move it to the
* top level. The object keeps its world position either way.
*
* Rows with children fold: the chevron (or ←/→, or a double-click on the row) toggles them.
* Everything starts collapsed – which ids are open is view state kept here, never part of the
* project, and selecting an object always unfolds the way down to it.
*/
export class HierarchyPanel extends Panel {
private readonly list = new ListView();
private readonly scroll = new ScrollContainer();
/** Ids of the objects whose children are shown. Anything missing is collapsed. */
private readonly expanded = new Set<string>();
constructor(private readonly state: EditorState) {
super({ title: 'Scene objects', padding: 0, gap: 0 });
@@ -32,8 +38,12 @@ export class HierarchyPanel extends Panel {
if (!scene) return;
const object = scene.objects.find((o) => o.id === draggedId);
if (!object || object.parentId === (targetId ?? '')) return;
// Unfold the new parent, otherwise the dragged row would seem to vanish on drop.
if (targetId) this.expanded.add(targetId);
state.execute(new SetParentCommand(scene.id, draggedId, targetId ?? ''));
};
this.list.onToggle = (id, expand) => this.setExpanded(id, expand);
this.list.onActivate = (id) => this.setExpanded(id, !this.expanded.has(id));
this.list.onContextMenu = (id, x, y) => {
const scene = state.activeScene;
if (!scene) return;
@@ -54,6 +64,8 @@ export class HierarchyPanel extends Panel {
},
{ label: many ? `Create container from ${selected.length} objects` : 'Create container', onSelect: () => createContainer(state, scene.id, selected) },
{ label: 'Unparent (move to top level)', disabled: !object?.parentId || many, onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')) },
{ label: 'Expand all', onSelect: () => this.expandAll() },
{ label: 'Collapse all', onSelect: () => this.collapseAll() },
{ label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene', danger: true, onSelect: () => removeObjects(state, scene.id, selected) },
]).open(x, y);
};
@@ -61,22 +73,62 @@ export class HierarchyPanel extends Panel {
this.content.add(this.scroll, 1);
state.on('project', () => this.refresh());
state.on('load', () => this.refresh());
state.on('selection', () => this.syncSelection());
// A different project brings different objects – start folded again.
state.on('load', () => { this.expanded.clear(); this.refresh(); });
state.on('selection', () => (this.revealSelection() ? this.refresh() : this.syncSelection()));
this.refresh();
}
/** Ids in the order the rows are shown (test hook). */
/** Ids of the rows currently shown – subtrees of collapsed objects are not among them (test hook). */
rowOrder(): string[] {
const scene = this.state.activeScene;
return scene ? treeOrder(scene).map((e) => e.object.id) : [];
return scene ? visibleTreeOrder(scene, this.expanded).map((e) => e.object.id) : [];
}
/** Global centre of a row's chevron, so tests can click the real hit area (test hook). */
chevronPosition(id: string): { x: number; y: number } | null {
return this.list.chevronGlobal(id);
}
expandAll(): void {
const scene = this.state.activeScene;
for (const object of scene?.objects ?? []) this.expanded.add(object.id);
this.refresh();
}
collapseAll(): void {
this.expanded.clear();
// Keep the selected object reachable instead of folding it away under the user's hands.
this.revealSelection();
this.refresh();
}
private setExpanded(id: string, expand: boolean): void {
if (expand) this.expanded.add(id);
else this.expanded.delete(id);
this.refresh();
}
/** Unfolds the ancestors of every selected object. Returns whether that changed anything. */
private revealSelection(): boolean {
const scene = this.state.activeScene;
if (!scene) return false;
let changed = false;
for (const id of this.state.selectedObjectIds) {
for (const ancestorId of ancestorIdsOf(scene, id)) {
if (this.expanded.has(ancestorId)) continue;
this.expanded.add(ancestorId);
changed = true;
}
}
return changed;
}
private refresh(): void {
const scene = this.state.activeScene;
this.setTitle(scene ? `Scene: ${scene.name}` : 'Scene objects');
this.list.setItems(
(scene ? treeOrder(scene) : []).map(({ object, depth }) => {
(scene ? visibleTreeOrder(scene, this.expanded) : []).map(({ object, depth, hasChildren }) => {
const asset = findAsset(this.state.project, object.assetId);
const props = asset ? resolveSceneObjectProps(asset, object) : {};
return {
@@ -85,11 +137,13 @@ export class HierarchyPanel extends Panel {
hint: asset?.type ?? '',
depth,
swatch: asset?.type === 'sprite' ? Number(props.color ?? 0) : undefined,
expanded: hasChildren ? this.expanded.has(object.id) : undefined,
};
}),
);
this.syncSelection();
this.scroll.relayoutContent();
this.scrollToSelection();
}
private syncSelection(): void {
@@ -97,4 +151,12 @@ export class HierarchyPanel extends Panel {
this.list.select(s?.kind === 'object' ? s.objectId : null, true);
this.list.highlight(s?.kind === 'object' ? s.objectIds : []);
}
/** Keeps the primary selection in view after unfolding brought it back into the list. */
private scrollToSelection(): void {
const s = this.state.selection;
if (s?.kind !== 'object' || !s.objectId) return;
const index = this.rowOrder().indexOf(s.objectId);
if (index >= 0) this.scroll.ensureVisible(index * this.list.rowHeight, this.list.rowHeight);
}
}
+73 -15
View File
@@ -26,7 +26,7 @@ import type { Position, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, findSceneObject } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
import { COMMON_PROPS, isCommonProp, type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
import { isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
import { deviatesFromAsset, deviatesFromTemplate, isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
import { Button, HStack, Label, Menu, type MenuItem, Panel, ScrollContainer, TextField, Theme, ui, VStack } from '../../ui';
import { createContainer, removeObjects } from '../actions';
import { createField, createFieldRow, FIELD_LABEL_WIDTH, type FieldRow } from '../inspector/fieldFactory';
@@ -148,7 +148,11 @@ export class Inspector extends Panel {
const assetId = asset.id;
const values = resolveAssetProps(asset);
for (const def of schema) {
const row = createFieldRow(def, values[def.key] ?? def.default, this.assetHandlers(assetId, def), () => this.state.execute(new ResetAssetPropertyCommand(assetId, def.key)));
const row = createFieldRow(def, values[def.key] ?? def.default, this.assetHandlers(assetId, def), () => {
const target = findAsset(this.state.project, assetId);
if (!target || !isModified(target, def.key)) return [];
return [{ label: 'Reset to default', onSelect: () => this.state.execute(new ResetAssetPropertyCommand(assetId, def.key)) }];
});
row.setModified(isModified(asset, def.key));
this.fields.set(def.key, row);
this.body.add(row.row);
@@ -277,7 +281,7 @@ export class Inspector extends Panel {
// The master switch, right where it is looked for: above everything type-specific.
for (const def of COMMON_PROPS) {
this.body.add(this.objectPropertyRow(sceneId, objectId, def, resolveSceneObjectProps(asset, object), templateLinkOf(this.state.project, scene, object) ?? null).row);
this.body.add(this.objectPropertyRow(sceneId, objectId, def, resolveSceneObjectProps(asset, object)).row);
}
// Position – belongs to the scene object only, local to the parent. Z is the draw order (higher = on top).
@@ -306,8 +310,14 @@ export class Inspector extends Panel {
this.state.execute(new MoveSceneObjectCommand(sceneId, objectId, next, previous));
},
},
link ? () => this.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)) : undefined,
link ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)) }] : undefined,
() =>
this.positionMoved(sceneId, objectId, axis)
? [{ label: 'Reset to container', onSelect: () => this.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)) }]
: [],
() =>
this.positionMoved(sceneId, objectId, axis)
? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)) }]
: [],
);
this.fields.set(axis, row);
this.body.add(row.row);
@@ -321,7 +331,7 @@ export class Inspector extends Panel {
const own = schema.filter((def) => !isCommonProp(def.key));
if (own.length > 0 || asset.type === 'behavior') this.body.add(new Label(asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES', { style: 'title' }));
if (own.length === 0 && asset.type === 'behavior') this.body.add(new Label('The behavior declares no variables.', { style: 'muted' }));
for (const def of own) this.body.add(this.objectPropertyRow(sceneId, objectId, def, values, link ?? null).row);
for (const def of own) this.body.add(this.objectPropertyRow(sceneId, objectId, def, values).row);
this.markObjectRows(object, asset, link);
if (asset.type === 'container') {
@@ -350,8 +360,14 @@ export class Inspector extends Panel {
* One editable property of a scene object: writes an override, offers ↺ (back to the asset
* or, inside a container, to the template) and ↑ (apply upwards). Registered in `fields`
* so live refreshes and `markObjectRows` find it.
*
* Inside a container a value has two bases – the template it was instantiated from and, one
* step further up, the asset – so ↺/↑ can become a menu. Only bases the value really differs
* from are listed, so a menu never contains an entry that would change nothing. Dropping the
* override is what makes the object follow the asset again: the template is only read when
* the container is placed, never at draw time.
*/
private objectPropertyRow(sceneId: string, objectId: string, def: PropertyDef, values: Record<string, PropertyValue>, link: TemplateLink | null): FieldRow {
private objectPropertyRow(sceneId: string, objectId: string, def: PropertyDef, values: Record<string, PropertyValue>): FieldRow {
let original: PropertyValue | undefined;
let hadOverride = false;
const row = createFieldRow(
@@ -379,11 +395,20 @@ export class Inspector extends Panel {
this.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value));
},
},
() => this.state.execute(link ? new RevertOverrideToTemplateCommand(sceneId, objectId, def.key) : new ClearOverrideCommand(sceneId, objectId, def.key)),
[
...(link ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []),
{ label: 'Apply to asset', onSelect: () => this.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) },
],
() => {
const { template, asset } = this.deviations(sceneId, objectId, def.key);
return [
...(template ? [{ label: 'Reset to container', onSelect: () => this.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)) }] : []),
...(asset ? [{ label: 'Reset to asset', onSelect: () => this.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)) }] : []),
];
},
() => {
const { template, asset } = this.deviations(sceneId, objectId, def.key);
return [
...(template ? [{ label: 'Apply to container', onSelect: () => this.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []),
...(asset ? [{ label: 'Apply to asset', onSelect: () => this.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) }] : []),
];
},
);
this.fields.set(def.key, row);
return row;
@@ -539,21 +564,54 @@ export class Inspector extends Panel {
/**
* Which rows deviate from their base: for a container instance the base is the template
* (position and overrides), for any other object only overrides count as a deviation.
* (position and overrides), for any other object the asset. Bold marks exactly what the
* ↺/↑ buttons offer – a value that matches its base is neither highlighted nor actionable.
*/
private markObjectRows(object: SceneObject, asset: Asset, link: TemplateLink | undefined): void {
for (const axis of ['x', 'y', 'z'] as const) this.fields.get(axis)?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]);
for (const def of schemaForAsset(asset)) {
const modified = link ? object.overrides[def.key] !== link.template.overrides[def.key] : isOverridden(object, def.key);
this.fields.get(def.key)?.setModified(modified);
const deviates = link ? deviatesFromTemplate(object, link.template, def.key) : deviatesFromAsset(object, asset, def.key);
this.fields.get(def.key)?.setModified(deviates);
}
}
/**
* The two bases a property of a scene object can be reset to or applied upwards, each only
* listed when it actually differs: the template of the container it belongs to (a value
* copied when the container was placed) and the asset behind it. Inside a container both
* can apply at once – then the row shows a menu, otherwise a single button, and nothing
* at all when the value matches everywhere.
*/
private deviations(sceneId: string, objectId: string, key: string): { template: boolean; asset: boolean } {
const object = this.currentObject(sceneId, objectId);
const asset = object && findAsset(this.state.project, object.assetId);
if (!object || !asset) return { template: false, asset: false };
const link = this.linkOf(sceneId, objectId);
return {
template: Boolean(link) && deviatesFromTemplate(object, link!.template, key),
asset: deviatesFromAsset(object, asset, key),
};
}
/** Whether the object sits somewhere else on `axis` than the container template says. */
private positionMoved(sceneId: string, objectId: string, axis: 'x' | 'y' | 'z'): boolean {
const object = this.currentObject(sceneId, objectId);
const link = this.linkOf(sceneId, objectId);
return Boolean(object && link) && object!.position[axis] !== link!.template.position[axis];
}
private currentObject(sceneId: string, objectId: string) {
const scene = findScene(this.state.project, sceneId);
return scene && findSceneObject(scene, objectId);
}
/** The object's template link as it is *now* – actions must not work off a stale snapshot. */
private linkOf(sceneId: string, objectId: string): TemplateLink | undefined {
const scene = findScene(this.state.project, sceneId);
const object = this.currentObject(sceneId, objectId);
return scene && object ? templateLinkOf(this.state.project, scene, object) : undefined;
}
private currentNode(assetId: string, nodeId: string) {
const asset = findAsset(this.state.project, assetId);
return asset?.type === 'behavior' ? asset.graph.nodes.find((n) => n.id === nodeId) : undefined;
+60 -4
View File
@@ -12,11 +12,19 @@ export interface ListItem {
depth?: number;
/** Optional marker color drawn as a small square before the label. */
swatch?: number;
/**
* Rows that have children: whether their subtree is currently shown. Such rows get a
* chevron that reports clicks through `onToggle`. Left undefined for leaves.
*/
expanded?: boolean;
}
/** Pixels the pointer must travel before a press turns into a drag. */
const DRAG_THRESHOLD = 4;
/** Width reserved in front of every row of a tree list for the expand/collapse chevron. */
const CHEVRON_SLOT = 14;
/** Modifier keys held while a row was clicked. */
export interface PickModifiers {
/** Ctrl (or ⌘) – toggle the row in a multi-selection. */
@@ -41,6 +49,11 @@ export class ListView extends Widget {
onContextMenu?: (id: string, globalX: number, globalY: number) => void;
/** Called when a dragged row is released over another row (`targetId`) or over empty space (null). */
onDrop?: (draggedId: string, targetId: string | null) => void;
/**
* Called when a row's chevron is clicked or ←/→ is pressed on it. Setting this makes the list
* a tree: every row reserves the chevron slot, so labels stay aligned across a level.
*/
onToggle?: (id: string, expand: boolean) => void;
rowHeight: number = Theme.rowHeight;
emptyText = 'Nothing here yet';
private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') });
@@ -74,10 +87,13 @@ export class ListView extends Widget {
this.items = items;
for (const row of this.rows) row.destroy({ children: true });
this.rows = items.map((item, index) => {
const row = new Row(item, this.rowHeight);
const row = new Row(item, this.rowHeight, Boolean(this.onToggle));
row.on('pointerdown', (e: FederatedPointerEvent) => {
const modifiers: PickModifiers = { ctrl: e.ctrlKey || e.metaKey, shift: e.shiftKey };
if (e.button === 2) {
// The chevron only folds the row – it never selects, so folding keeps the selection put.
if (e.button === 0 && row.hitsChevron(row.toLocal(e.global).x)) {
this.onToggle?.(item.id, !item.expanded);
} else if (e.button === 2) {
// A right-click inside a multi-selection keeps it (the menu acts on all of it).
if (!this.highlighted.has(item.id)) this.select(item.id);
this.onContextMenu?.(item.id, e.global.x, e.global.y);
@@ -117,6 +133,11 @@ export class ListView extends Widget {
return [...this.highlighted];
}
/** Global centre of a row's chevron – null when the row has none (test hook). */
chevronGlobal(id: string): { x: number; y: number } | null {
return this.rows.find((r) => r.item.id === id)?.chevronGlobal() ?? null;
}
override measure(): Size {
return { w: this.minWidth, h: Math.max(this.minHeight, this.items.length * this.rowHeight) };
}
@@ -133,6 +154,13 @@ export class ListView extends Widget {
if (prev) this.select(prev.id);
return true;
}
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
const item = this.items[index];
const expand = event.key === 'ArrowRight';
if (!item || item.expanded === undefined || item.expanded === expand) return false;
this.onToggle?.(item.id, expand);
return true;
}
if (event.key === 'Enter' && this.selectedId) {
this.onActivate?.(this.selectedId);
return true;
@@ -200,7 +228,7 @@ class Row extends Widget {
private selected = false;
private listFocused = false;
constructor(readonly item: ListItem, height: number) {
constructor(readonly item: ListItem, height: number, private readonly chevronSlot: boolean) {
super();
this.cursor = 'pointer';
this.minHeight = height;
@@ -216,10 +244,27 @@ class Row extends Widget {
this.invalidate();
}
/** Left edge of the chevron slot (or of the content itself in a flat list). */
private get indent(): number {
return Theme.padding + (this.item.depth ?? 0) * 14;
}
chevronGlobal(): { x: number; y: number } | null {
if (!this.chevronSlot || this.item.expanded === undefined) return null;
const p = this.getGlobalPosition();
return { x: p.x + this.indent + CHEVRON_SLOT / 2, y: p.y + this.h / 2 };
}
/** Whether a click at this local x lands on the chevron rather than on the row. */
hitsChevron(localX: number): boolean {
if (!this.chevronSlot || this.item.expanded === undefined) return false;
return localX >= this.indent - 2 && localX < this.indent + CHEVRON_SLOT;
}
override redraw(): void {
const c = Theme.colors;
const depth = this.item.depth ?? 0;
const indent = Theme.padding + depth * 14;
const indent = this.indent;
this.bg.clear();
if (this.selected) this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive);
else if (this.hovered) this.bg.rect(0, 0, this.w, this.h).fill(c.controlHover);
@@ -229,6 +274,10 @@ class Row extends Widget {
this.bg.moveTo(gx, 0).lineTo(gx, this.h / 2).lineTo(indent - 2, this.h / 2).stroke({ width: 1, color: c.border });
}
let x = indent;
if (this.chevronSlot) {
if (this.item.expanded !== undefined) this.drawChevron(x + 4, this.h / 2, this.item.expanded);
x += CHEVRON_SLOT;
}
if (this.item.swatch !== undefined) {
this.bg.roundRect(x, (this.h - 12) / 2, 12, 12, 2).fill(this.item.swatch);
this.bg.roundRect(x + 0.5, (this.h - 12) / 2 + 0.5, 11, 11, 2).stroke({ width: 1, color: c.border });
@@ -240,4 +289,11 @@ class Row extends Widget {
this.labelText.position.set(x, Math.round((this.h - this.labelText.height) / 2));
this.hint.position.set(Math.round(this.w - Theme.padding - this.hint.width), Math.round((this.h - this.hint.height) / 2));
}
/** ▸ when collapsed, ▾ when expanded – drawn, not glyphs, so it stays crisp at any size. */
private drawChevron(x: number, y: number, expanded: boolean): void {
const color = this.selected ? Theme.colors.text : Theme.colors.textMuted;
if (expanded) this.bg.moveTo(x - 3, y - 2).lineTo(x + 5, y - 2).lineTo(x + 1, y + 3).fill(color);
else this.bg.moveTo(x - 1, y - 4).lineTo(x + 4, y).lineTo(x - 1, y + 4).fill(color);
}
}
+6
View File
@@ -64,6 +64,12 @@ export class ScrollContainer extends Widget {
this.applyScroll();
}
/** Scrolls just far enough for the content band [top, top + height) to be inside the view. */
ensureVisible(top: number, height: number): void {
if (top < this.scrollY) this.scrollTo(top);
else if (top + height > this.scrollY + this.h) this.scrollTo(top + height - this.h);
}
/** Re-measure the content after it changed (rows added/removed). */
relayoutContent(): void {
if (!this.content) return;
+24 -1
View File
@@ -3,7 +3,7 @@ import { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/
import { CommandStack } from '../../src/core/commands/CommandStack';
import { MoveSceneObjectCommand, PlaceAssetCommand, RemoveSceneObjectCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
import { GraphInterpreter, type NodeContext, worldPositionOf } from '../../src/core/graph';
import { descendantsOf, localPositionFor, treeOrder, worldPosition } from '../../src/core/model/hierarchy';
import { ancestorIdsOf, descendantsOf, localPositionFor, treeOrder, visibleTreeOrder, worldPosition } from '../../src/core/model/hierarchy';
import { createEmptyProject, findScene } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { World } from '../../src/runtime/World';
@@ -72,6 +72,29 @@ describe('scene hierarchy', () => {
expect(descendantsOf(sceneOf(), a).map((o) => o.id)).toEqual([c, b]);
});
it('hides the subtrees of collapsed objects and marks the rows that can fold', () => {
const { stack, sceneId, a, b, c, sceneOf } = setup();
// a > c > b: two levels, so folding a hides both descendants.
stack.execute(new SetParentCommand(sceneId, c, a));
stack.execute(new SetParentCommand(sceneId, b, c));
const rows = (expanded: string[]) => visibleTreeOrder(sceneOf(), new Set(expanded)).map((e) => [e.object.id, e.depth, e.hasChildren]);
// Nothing expanded (the default when a scene is opened) – only the top level is listed.
expect(rows([])).toEqual([[a, 0, true]]);
expect(rows([a])).toEqual([[a, 0, true], [c, 1, true]]);
expect(rows([a, c])).toEqual([[a, 0, true], [c, 1, true], [b, 2, false]]);
// Expanding a nested object without its parent shows nothing extra: every ancestor must be open.
expect(rows([c])).toEqual([[a, 0, true]]);
});
it('lists the ancestors of an object nearest first, so a selection can be unfolded', () => {
const { stack, sceneId, a, b, c, sceneOf } = setup();
stack.execute(new SetParentCommand(sceneId, c, a));
stack.execute(new SetParentCommand(sceneId, b, c));
expect(ancestorIdsOf(sceneOf(), b)).toEqual([c, a]);
expect(ancestorIdsOf(sceneOf(), a)).toEqual([]);
});
it('removing an object or deleting an asset takes the subtree along, undo restores it', () => {
const { project, stack, sceneId, spriteId, a, b, c, sceneOf } = setup();
const behavior = new CreateAssetCommand('behavior', 'Logic');
+43 -1
View File
@@ -2,7 +2,15 @@ import { describe, expect, it } from 'vitest';
import { buildAsset } from '../../src/core/commands/assetCommands';
import { createSceneObject } from '../../src/core/model/SceneObject';
import { coerceValue, defaultsFor, schemaFor } from '../../src/core/properties/PropertySchema';
import { inheritedValue, isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../src/core/properties/resolve';
import {
deviatesFromAsset,
deviatesFromTemplate,
inheritedValue,
isModified,
isOverridden,
resolveAssetProps,
resolveSceneObjectProps,
} from '../../src/core/properties/resolve';
describe('PropertySchema', () => {
it('has no position on any asset type', () => {
@@ -59,6 +67,40 @@ describe('resolve', () => {
expect(isModified(sprite, 'bogus')).toBe(false);
});
it('ignores an override that carries the asset value – nothing to reset or apply', () => {
const sprite = buildAsset('sprite', 'Box');
sprite.props.width = 250;
const object = createSceneObject('o1', sprite.id, { x: 0, y: 0 });
expect(deviatesFromAsset(object, sprite, 'width')).toBe(false); // no override at all
object.overrides.width = 250; // same value as the asset → not a deviation
expect(isOverridden(object, 'width')).toBe(true);
expect(deviatesFromAsset(object, sprite, 'width')).toBe(false);
object.overrides.width = 300;
expect(deviatesFromAsset(object, sprite, 'width')).toBe(true);
// The asset moving to the object's value settles the difference again.
sprite.props.width = 300;
expect(deviatesFromAsset(object, sprite, 'width')).toBe(false);
});
it('compares an instance against its template as overrides, not as values', () => {
const sprite = buildAsset('sprite', 'Box');
const template = createSceneObject('t1', sprite.id, { x: 0, y: 0 });
const object = createSceneObject('o1', sprite.id, { x: 0, y: 0 });
expect(deviatesFromTemplate(object, template, 'width')).toBe(false); // neither overrides
template.overrides.width = 140;
expect(deviatesFromTemplate(object, template, 'width')).toBe(true);
object.overrides.width = 140;
expect(deviatesFromTemplate(object, template, 'width')).toBe(false);
object.overrides.width = 77;
expect(deviatesFromTemplate(object, template, 'width')).toBe(true);
});
it('keeps position on the scene object only', () => {
const sprite = buildAsset('sprite', 'Box');
const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 });
+62 -4
View File
@@ -25,6 +25,33 @@ await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async
check('page loaded without exceptions', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).join(' | '));
check('debug hook present', Boolean(await evaluate('Boolean(window.__gm)')));
// 0a. Hierarchy folding. A freshly opened scene is fully collapsed, so only its top-level
// objects are listed (o4/oc1 hang under o1, oc2 under o2). The chevron folds a row open
// and shut without touching the selection, and selecting a nested object unfolds the way
// down to it.
const rowIds = () => evaluate('JSON.stringify(__gm.hierarchyOrder())').then(JSON.parse);
const selectionBefore = JSON.parse(await stateJson()).selection;
check('hierarchy starts collapsed – only top-level objects are listed', JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o2', 'o3', 'o5']), JSON.stringify(await rowIds()));
check('a leaf row offers no chevron', (await evaluate(`__gm.hierarchyChevron('o3')`)) === null);
const chevron = JSON.parse(await evaluate(`JSON.stringify(__gm.hierarchyChevron('o2'))`));
await click(chevron.x, chevron.y);
await sleep(150);
check(
'clicking the chevron unfolds the row, leaving the selection alone',
JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o2', 'oc2', 'o3', 'o5']) && JSON.stringify(JSON.parse(await stateJson()).selection) === JSON.stringify(selectionBefore),
JSON.stringify(await rowIds()),
);
await shot('01a-hierarchy-unfolded');
await click(chevron.x, chevron.y);
await sleep(150);
check('clicking it again folds the subtree away', JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o2', 'o3', 'o5']), JSON.stringify(await rowIds()));
await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'oc1' })`);
await sleep(150);
check('selecting a nested object unfolds its ancestors', JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o4', 'oc1', 'o2', 'o3', 'o5']), JSON.stringify(await rowIds()));
await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()');
await sleep(150);
check('Collapse all folds everything back', JSON.stringify(await rowIds()) === JSON.stringify(['o1', 'o2', 'o3', 'o5']), JSON.stringify(await rowIds()));
// 1. Select the blue crate in the hierarchy.
await clickText('Blue crate');
let s = JSON.parse(await stateJson());
@@ -129,7 +156,7 @@ await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async
await clickText('▶ Play');
await sleep(300);
check('play opens the Game tab', (await evaluate('__gm.tab()')) === 'game');
check('panels stay visible while playing', Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && (await evaluate('__gm.hierarchyOrder().length')) === 7);
check('panels stay visible while playing', Boolean(await evaluate(`__gm.locate('INSPECTOR')`)) && (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 7);
await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o2' })`);
await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 400; __gm.state.preview()`);
await sleep(100);
@@ -155,7 +182,7 @@ await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async
check('Spawn node adds a crate object to the runtime scene at (0, 150)', spawned && spawned.assetId === 'crate' && spawned.parentId === '' && spawned.position.x === 0 && spawned.position.y === 150 && spawnedObjects.length === 8, JSON.stringify(spawned));
const spawnWorld = JSON.parse(await evaluate('JSON.stringify(__gm.world())'));
check('the spawned entity lives in the world and its name reached the Log node', spawnWorld.entities.length === 8 && spawnWorld.log.includes('Crate'), JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log }));
check('hierarchy lists the spawned object', (await evaluate('__gm.hierarchyOrder().length')) === 8);
check('hierarchy lists the spawned object', (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 8);
info = await evaluate('JSON.stringify(__gm.gameInfo())');
check('the Game view renders the spawned crate', JSON.parse(info).visibleItems === visibleBeforeSpawn + 1, `${info} before=${visibleBeforeSpawn}`);
await shot('08c-spawned');
@@ -426,7 +453,7 @@ await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async
const objectJson = (id) => evaluate(`JSON.stringify(__gm.state.activeScene.objects.find(o => o.id === '${id}'))`);
let o2 = JSON.parse(await objectJson('o2'));
check('dropping a row onto another parents it, keeping the world position', o2.parentId === 'o1' && o2.position.x === 270 && o2.position.y === -80, JSON.stringify(o2));
const order = JSON.parse(await evaluate(`JSON.stringify(__gm.hierarchyOrder())`));
const order = JSON.parse(await evaluate(`__gm.hierarchyExpandAll(); JSON.stringify(__gm.hierarchyOrder())`));
// o1's subtree: o2 (list order before o4) with its collider child, then o4, then o1's collider.
const o1At = order.indexOf('o1');
check('children are listed directly below their parent', JSON.stringify(order.slice(o1At + 1, o1At + 5)) === JSON.stringify(['o2', 'oc2', 'o4', 'oc1']), JSON.stringify(order));
@@ -609,7 +636,7 @@ await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async
const o2c = JSON.parse(await objectJson('o2'));
check('the crates are re-parented below the container with local positions relative to its centre', o1c.parentId === containerObj?.id && o1c.position.x === -135 && o1c.position.y === 40 && o2c.parentId === containerObj?.id && o2c.position.x === 135 && o2c.position.y === -40, JSON.stringify({ o1: o1c.position, o2: o2c.position }));
check('the template holds the whole subtree (2 crates, 2 colliders, the Mover)', containerAsset?.objects.length === 5 && containerAsset.objects.filter((t) => t.parentId === '').length === 2 && o1c.templateId && containerAsset.objects.some((t) => t.id === o1c.templateId), JSON.stringify(containerAsset?.objects.map((t) => [t.assetId, t.parentId === '' ? 'root' : 'child'])));
const orderC = JSON.parse(await evaluate('JSON.stringify(__gm.hierarchyOrder())'));
const orderC = JSON.parse(await evaluate('__gm.hierarchyExpandAll(); JSON.stringify(__gm.hierarchyOrder())'));
const cAt = orderC.indexOf(containerObj?.id);
check('hierarchy lists the container with its content indented below', cAt >= 0 && JSON.stringify(orderC.slice(cAt)) === JSON.stringify([containerObj.id, 'o1', 'o4', 'oc1', 'o2', 'oc2']), JSON.stringify(orderC));
check('container symbol is drawn in the overlay layer', containerObj && (await evaluate(`__gm.layerOf('${containerObj.id}')`)) === 'overlay');
@@ -715,6 +742,37 @@ await runChecks({ projectId, baseUrl, port: 9333, shotPrefix: 'gm-e2e' }, async
await sleep(200);
check('Apply to container writes the override into the template and the copy', (await templateA()).overrides.width === 77 && JSON.parse(await objectJson(secondCrate.id)).overrides.width === 77);
// 16i. o1 now matches its template (both 77) but still overrides the asset (100). Only bases
// the value really differs from are offered, so "Reset to container" is left out and the
// menu collapses to a direct ↺. Deviating from both brings the menu back.
await sleep(200);
check(
'a reset that would change nothing is left out – a direct button instead of a menu',
Boolean(await evaluate(`__gm.locate('↺')`)) && !(await evaluate(`__gm.locate('↺ ▾')`)) && !(await evaluate(`__gm.locate('↑ ▾')`)),
);
await evaluate(`(() => { __gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 55; __gm.state.preview(); })()`);
await sleep(200);
const resetMenu = await evaluate(`__gm.locate('↺ ▾')`);
check('deviating from template and asset brings the reset menu back', Boolean(resetMenu));
await click(resetMenu.x, resetMenu.y);
await sleep(150);
check('the reset menu offers container and asset', Boolean(await evaluate(`__gm.locate('Reset to container')`)) && Boolean(await evaluate(`__gm.locate('Reset to asset')`)));
await shot('28b-reset-menu');
await clickText('Reset to container');
await sleep(200);
check('Reset to container puts the override back on the template value', JSON.parse(await objectJson('o1')).overrides.width === 77);
const singleReset = await evaluate(`__gm.locate('↺')`);
check('with only the asset left to differ from, the menu is a single button again', Boolean(singleReset) && !(await evaluate(`__gm.locate('↺ ▾')`)));
await click(singleReset.x, singleReset.y);
await sleep(200);
check('↺ drops the override so the object follows the asset again', JSON.parse(await objectJson('o1')).overrides.width === undefined && (await templateA()).overrides.width === 77);
// Dropping the override left the object on the asset value (100) while the template still
// says 77 – that is a real deviation, so exactly one entry remains: "Reset to container".
check('the remaining deviation from the template keeps a single button, not a menu', Boolean(await evaluate(`__gm.locate('↺')`)) && !(await evaluate(`__gm.locate('↺ ▾')`)));
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(200);
check('undo restores the override', JSON.parse(await objectJson('o1')).overrides.width === 77);
// 17. Text assets: created through the + menu, placed, and rendered as real text in the scene.
await clickText('+');
await sleep(150);
+2 -1
View File
@@ -39,7 +39,8 @@ await runChecks({ projectId, baseUrl, port: 9335, shotPrefix: 'gm-smoke' }, asyn
const objs = await objects();
const roots = objs.filter((o) => o.parentId === '').map((o) => o.id);
check('demo scene has the seeded objects and roots', objs.length === OBJECT_COUNT && ROOTS.every((id) => roots.includes(id)), `${objs.length} objects, roots=${JSON.stringify(roots)}`);
check('hierarchy lists every object', (await evaluate('__gm.hierarchyOrder().length')) === OBJECT_COUNT);
check('hierarchy opens collapsed – one row per root', (await evaluate('__gm.hierarchyOrder().length')) === ROOTS.length, JSON.stringify(await evaluate('JSON.stringify(__gm.hierarchyOrder())')));
check('hierarchy lists every object once expanded', (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === OBJECT_COUNT);
// 2. Every child of a container instance points at an object of that container's template.
const templateLinks = JSON.parse(await evaluate(`JSON.stringify((() => {