Apply/revert buttons for overrides, container instances and child positions

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-07 17:59:35 +02:00
1 parent 0b78382bcc
commit 3379232e64
7 files changed
+529 -22

No files matched your search

+5 -4
View File
@@ -12,12 +12,13 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
|---|---|
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `behavior`, `collider`, `container`) and typed properties. Assets have **no position**. |
| **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties + an optional **parent** (another object of the scene). Scenes cannot be placed. |
| **Container** | A reusable group ("prefab"): the asset holds a **template** of objects (asset link, local position, overrides, parent links) centred on its origin. Placing a container creates a container object plus fresh copies of the template below it – ordinary objects, each remembering its template id. **Create container** turns the current selection into such an asset and replaces the selected objects by one placed container at their centre (world positions unchanged). Editing the template itself comes later. |
| **Container** | A reusable group ("prefab"): the asset holds a **template** of objects (asset link, local position, overrides, parent links) centred on its origin. Placing a container creates a container object plus fresh copies of the template below it – ordinary objects, each remembering its template id. **Create container** turns the current selection into such an asset and replaces the selected objects by one placed container at their centre (world positions unchanged). The template is edited from its instances (see *Instance*). |
| **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 (↺). |
| **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. |
| **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. |
| **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. |
@@ -198,5 +199,5 @@ object in the selection (hierarchy rows too).
## Not yet
Textures on sprites, editing a container's template (changes reaching all instances),
reordering the hierarchy, auth, touch-optimised layout, graph comments/groups.
Textures on sprites, editing a container's template directly (today changes reach it only
via **Apply to container** from an instance), reordering the hierarchy, auth, touch-optimised layout, graph comments/groups.
+196
View File
@@ -0,0 +1,196 @@
import { instancesOf, type TemplateLink, templateLinkOf } from '../model/container';
import type { Position, PropertyValue } from '../model/Properties';
import { findScene, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { resolveSceneObjectProps } from '../properties/resolve';
import { schemaForAsset } from '../properties/PropertySchema';
import { type Command, CommandError } from './Command';
import { requireAsset } from './assetCommands';
import { requireObject } from './sceneObjectCommands';
/**
* "Apply" is the opposite direction of an override: a value the user changed on a placed
* object is written back to where it came from – the asset, or (for objects that are part
* of a placed container) the container's template. The rule for instances follows the usual
* prefab semantics: copies that were still at the template's value follow the change,
* copies the user modified on their own keep their value.
*/
type Axis = 'x' | 'y' | 'z';
function requireLink(project: Project, sceneId: string, object: SceneObject): TemplateLink {
const scene = findScene(project, sceneId);
const link = scene && templateLinkOf(project, scene, object);
if (!link) throw new CommandError(`${object.name || object.id} is not part of a placed container`);
return link;
}
function setOverride(object: SceneObject, key: string, value: PropertyValue | undefined): void {
if (value === undefined) delete object.overrides[key];
else object.overrides[key] = value;
}
/** Remembered override values of several objects, for undo. */
interface OverrideMemo {
object: SceneObject;
previous: PropertyValue | undefined;
}
/**
* Sets the template's override for `key` and moves every instance that still had the old
* template value along with it. Returns what to restore on undo.
*/
function updateTemplateOverride(project: Project, link: TemplateLink, key: string, next: PropertyValue | undefined): OverrideMemo[] {
const previous = link.template.overrides[key];
const memos: OverrideMemo[] = [{ object: link.template, previous }];
for (const { object } of instancesOf(project, link.template.id)) {
if (object.overrides[key] !== previous) continue;
memos.push({ object, previous: object.overrides[key] });
setOverride(object, key, next);
}
setOverride(link.template, key, next);
return memos;
}
function restoreOverrides(memos: OverrideMemo[], key: string): void {
for (const memo of [...memos].reverse()) setOverride(memo.object, key, memo.previous);
}
/**
* Writes the object's value for `key` into its asset and drops the override – the object
* follows the asset again, as does everything else without an override of its own. For a
* container instance the template's override is dropped too (and instances that shared it).
*/
export class ApplyOverrideToAssetCommand implements Command {
readonly label: string;
private previousAssetValue: PropertyValue | undefined;
private previousOverride: PropertyValue | undefined;
private templateMemos: OverrideMemo[] = [];
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
this.label = `Apply ${key} to asset`;
}
execute(project: Project): void {
const scene = findScene(project, this.sceneId);
const object = requireObject(project, this.sceneId, this.objectId);
const asset = requireAsset(project, object.assetId);
if (!schemaForAsset(asset).some((d) => d.key === this.key)) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`);
const value = resolveSceneObjectProps(asset, object)[this.key];
this.previousAssetValue = asset.props[this.key];
this.previousOverride = object.overrides[this.key];
asset.props[this.key] = value!;
delete object.overrides[this.key];
const link = scene && templateLinkOf(project, scene, object);
this.templateMemos = link ? updateTemplateOverride(project, link, this.key, undefined) : [];
}
undo(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
const asset = requireAsset(project, object.assetId);
restoreOverrides(this.templateMemos, this.key);
setOverride(object, this.key, this.previousOverride);
if (this.previousAssetValue === undefined) delete asset.props[this.key];
else asset.props[this.key] = this.previousAssetValue;
}
}
/** Writes the object's override for `key` into its container template (instances at the old value follow). */
export class ApplyOverrideToContainerCommand implements Command {
readonly label: string;
private memos: OverrideMemo[] = [];
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
this.label = `Apply ${key} to container`;
}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
const link = requireLink(project, this.sceneId, object);
this.memos = updateTemplateOverride(project, link, this.key, object.overrides[this.key]);
}
undo(): void {
restoreOverrides(this.memos, this.key);
}
}
/** Puts the object's override for `key` back to what its container template says. */
export class RevertOverrideToTemplateCommand implements Command {
readonly label: string;
private previous: PropertyValue | undefined;
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
this.label = `Revert ${key}`;
}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
const link = requireLink(project, this.sceneId, object);
this.previous = object.overrides[this.key];
setOverride(object, this.key, link.template.overrides[this.key]);
}
undo(project: Project): void {
setOverride(requireObject(project, this.sceneId, this.objectId), this.key, this.previous);
}
}
/** Remembered positions of several objects, for undo. */
interface PositionMemo {
object: SceneObject;
previous: Position;
}
/**
* Writes the object's local position (one axis, or all) into its container template.
* Instances whose axis still matched the template follow; the others keep their own value.
*/
export class ApplyPositionToContainerCommand implements Command {
readonly label: string;
private memos: PositionMemo[] = [];
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly axis?: Axis) {
this.label = axis ? `Apply ${axis} to container` : 'Apply position to container';
}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
const link = requireLink(project, this.sceneId, object);
const axes: Axis[] = this.axis ? [this.axis] : ['x', 'y', 'z'];
const before = { ...link.template.position };
this.memos = [{ object: link.template, previous: before }];
for (const instance of instancesOf(project, link.template.id)) {
const changed = axes.filter((a) => instance.object.position[a] === before[a]);
if (changed.length === 0) continue;
this.memos.push({ object: instance.object, previous: { ...instance.object.position } });
for (const a of changed) instance.object.position[a] = object.position[a];
}
for (const a of axes) link.template.position[a] = object.position[a];
}
undo(): void {
for (const memo of [...this.memos].reverse()) memo.object.position = { ...memo.previous };
}
}
/** Puts the object's local position (one axis, or all) back to its container template. */
export class RevertPositionToTemplateCommand implements Command {
readonly label: string;
private previous: Position = { x: 0, y: 0, z: 0 };
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly axis?: Axis) {
this.label = axis ? `Revert ${axis}` : 'Revert position';
}
execute(project: Project): void {
const object = requireObject(project, this.sceneId, this.objectId);
const link = requireLink(project, this.sceneId, object);
this.previous = { ...object.position };
for (const a of this.axis ? [this.axis] : (['x', 'y', 'z'] as Axis[])) object.position[a] = link.template.position[a];
}
undo(project: Project): void {
requireObject(project, this.sceneId, this.objectId).position = { ...this.previous };
}
}
+40 -1
View File
@@ -1,8 +1,47 @@
import { newId } from '../ids';
import type { ContainerAsset } from './Asset';
import type { ContainerAsset, SceneAsset } from './Asset';
import { findObject, type ObjectTree } from './hierarchy';
import { findAsset, type Project } from './Project';
import type { SceneObject } from './SceneObject';
/** What a placed container's content object is linked to: the container and its template object. */
export interface TemplateLink {
containerObject: SceneObject;
asset: ContainerAsset;
template: SceneObject;
}
/**
* For an object that belongs to a placed container: the nearest container ancestor whose
* asset holds the template this object was instantiated from. Undefined for everything else
* (no template id, or the link is stale).
*/
export function templateLinkOf(project: Project, tree: ObjectTree, object: SceneObject): TemplateLink | undefined {
if (!object.templateId) return undefined;
const seen = new Set<string>([object.id]);
let current = object.parentId ? findObject(tree, object.parentId) : undefined;
while (current && !seen.has(current.id)) {
const asset = findAsset(project, current.assetId);
if (asset?.type === 'container') {
const template = asset.objects.find((t) => t.id === object.templateId);
if (template) return { containerObject: current, asset, template };
}
seen.add(current.id);
current = current.parentId ? findObject(tree, current.parentId) : undefined;
}
return undefined;
}
/** Every placed copy of a template object, across all scenes (template ids are unique per project). */
export function instancesOf(project: Project, templateId: string): { scene: SceneAsset; object: SceneObject }[] {
const result: { scene: SceneAsset; object: SceneObject }[] = [];
for (const asset of project.assets) {
if (asset.type !== 'scene') continue;
for (const object of asset.objects) if (object.templateId === templateId) result.push({ scene: asset, object });
}
return result;
}
/** Nesting depth at which a container inside a container inside … stops being expanded. */
const MAX_NESTING = 8;
+27 -8
View File
@@ -22,12 +22,21 @@ export interface FieldRow {
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 {
label: string;
onSelect: () => void;
}
/**
* 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.
*/
export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, onReset?: () => void): FieldRow {
export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, onReset?: () => void, apply?: ApplyAction[]): FieldRow {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const label = new Label(def.label, { style: 'muted' });
@@ -38,11 +47,21 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Label(''), 1);
let reset: Button | null = null;
if (onReset) {
reset = new Button('↺', { variant: 'ghost', width: Theme.controlHeight, onClick: onReset });
reset.visible = false;
row.add(reset);
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 });
button.onClick = () => {
if (single) return apply[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);
};
actions.push(button);
}
if (onReset) actions.push(new Button('↺', { variant: 'ghost', width: Theme.controlHeight, onClick: onReset }));
for (const button of actions) {
button.visible = false;
row.add(button);
}
return {
@@ -50,8 +69,8 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
setValue: field.setValue,
setModified(modified) {
label.setStyle(modified ? 'bodyBold' : 'muted');
if (reset && reset.visible !== modified) {
reset.visible = modified;
if (actions.length > 0 && actions[0]!.visible !== modified) {
for (const button of actions) button.visible = modified;
row.relayout();
}
},
+48 -9
View File
@@ -1,5 +1,12 @@
import type { Container } from 'pixi.js';
import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import {
ApplyOverrideToAssetCommand,
ApplyOverrideToContainerCommand,
ApplyPositionToContainerCommand,
RevertOverrideToTemplateCommand,
RevertPositionToTemplateCommand,
} from '../../core/commands/applyCommands';
import { AddColliderCommand } from '../../core/commands/colliderCommands';
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
import {
@@ -13,6 +20,7 @@ import { AddVariableCommand, nextVariableName, RemoveVariableCommand, RenameVari
import { edgesInto, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph';
import type { Asset, BehaviorAsset, SceneAsset } from '../../core/model/Asset';
import { isPlaceableType } from '../../core/model/AssetTypes';
import { type TemplateLink, templateLinkOf } from '../../core/model/container';
import { isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
import type { Position, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, findSceneObject } from '../../core/model/Project';
@@ -101,7 +109,7 @@ export class Inspector extends Panel {
if (s.objectIds.length > 1) return `multi|${s.objectIds.join(',')}`;
const object = this.currentObject(s.sceneId, s.objectId);
const asset = object && findAsset(this.state.project, object.assetId);
return asset ? `${object.parentId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
return asset ? `${object.parentId}|${object.templateId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
}
const asset = findAsset(this.state.project, s.assetId);
if (!asset || asset.type !== 'behavior') return 'missing';
@@ -250,12 +258,26 @@ export class Inspector extends Panel {
this.body.add(this.parentRow(scene, object, sceneId));
// Part of a placed container: values compare against the template, not just the asset.
const link = templateLinkOf(this.state.project, scene, object);
if (link) {
const linkRow = new HStack({ gap: Theme.gap, align: 'center' });
const linkLabel = new Label('Part of', { style: 'muted' });
linkLabel.minWidth = FIELD_LABEL_WIDTH;
linkRow.add(linkLabel);
linkRow.add(new Button(link.asset.name, { variant: 'ghost', onClick: () => this.state.select({ kind: 'asset', assetId: link.asset.id }) }), 1);
this.body.add(linkRow);
}
// Position – belongs to the scene object only, local to the parent. Z is the draw order (higher = on top).
const parent = object.parentId ? findSceneObject(scene, object.parentId) : undefined;
this.body.add(new Label(parent ? 'POSITION (LOCAL TO PARENT)' : 'POSITION', { style: 'title' }));
for (const axis of ['x', 'y', 'z'] as const) {
let original: Position | undefined;
const row = createFieldRow({ key: axis, label: axis.toUpperCase(), kind: 'number', default: 0, step: 1 }, object.position[axis], {
const row = createFieldRow(
{ key: axis, label: axis.toUpperCase(), kind: 'number', default: 0, step: 1 },
object.position[axis],
{
onInput: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
@@ -272,7 +294,10 @@ export class Inspector extends Panel {
const next = { ...previous, [axis]: Number(value) };
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.fields.set(axis, row);
this.body.add(row.row);
}
@@ -310,12 +335,16 @@ export class Inspector extends Panel {
this.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value));
},
},
() => this.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)),
() => 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)) },
],
);
row.setModified(isOverridden(object, def.key));
this.fields.set(def.key, row);
this.body.add(row.row);
}
this.markObjectRows(object, asset, link);
if (asset.type === 'container') {
const count = asset.objects.length;
@@ -474,18 +503,28 @@ export class Inspector extends Panel {
}
return;
}
const scene = findScene(this.state.project, s.sceneId);
const object = this.currentObject(s.sceneId, s.objectId);
const asset = object && findAsset(this.state.project, object.assetId);
if (!object || !asset) return this.rebuild();
if (!scene || !object || !asset) return this.rebuild();
this.nameField?.setValue(object.name);
this.fields.get('x')?.setValue(object.position.x);
this.fields.get('y')?.setValue(object.position.y);
this.fields.get('z')?.setValue(object.position.z);
const values = resolveSceneObjectProps(asset, object);
for (const def of schemaForAsset(asset)) this.fields.get(def.key)?.setValue(values[def.key] ?? def.default);
this.markObjectRows(object, asset, templateLinkOf(this.state.project, scene, object));
}
/**
* 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.
*/
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 row = this.fields.get(def.key);
row?.setValue(values[def.key] ?? def.default);
row?.setModified(isOverridden(object, def.key));
const modified = link ? object.overrides[def.key] !== link.template.overrides[def.key] : isOverridden(object, def.key);
this.fields.get(def.key)?.setModified(modified);
}
}
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest';
import {
ApplyOverrideToAssetCommand,
ApplyOverrideToContainerCommand,
ApplyPositionToContainerCommand,
RevertOverrideToTemplateCommand,
RevertPositionToTemplateCommand,
} from '../../src/core/commands/applyCommands';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { CommandStack } from '../../src/core/commands/CommandStack';
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
import { MoveSceneObjectCommand, PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import type { ContainerAsset } from '../../src/core/model/Asset';
import { templateLinkOf } from '../../src/core/model/container';
import { createEmptyProject, findAsset, findScene } from '../../src/core/model/Project';
/**
* A box asset, a container made from two boxes (a at (100,100), b at (10,0)), and a second
* placed instance of that container. `a` is the original, `a2` its copy in the instance.
*/
function setup() {
const project = createEmptyProject('p', 'Apply');
const stack = new CommandStack(project);
const scene = new CreateAssetCommand('scene', 'Level');
const sprite = new CreateAssetCommand('sprite', 'Box');
stack.execute(scene);
stack.execute(sprite);
const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 100, y: 100 });
const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 10, y: 0 });
stack.execute(a);
stack.execute(b);
const create = new CreateContainerCommand(scene.asset.id, [a.objectId, b.objectId]);
stack.execute(create);
const place = new PlaceAssetCommand(scene.asset.id, create.assetId, { x: 1000, y: 0 });
stack.execute(place);
const sceneOf = () => findScene(project, scene.asset.id)!;
const object = (id: string) => sceneOf().objects.find((o) => o.id === id)!;
const container = () => findAsset(project, create.assetId) as ContainerAsset;
const templateOfA = () => container().objects.find((t) => t.id === object(a.objectId).templateId)!;
const a2 = sceneOf().objects.find((o) => o.parentId === place.objectId && o.templateId === object(a.objectId).templateId)!.id;
return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, a: a.objectId, b: b.objectId, a2, object, container, templateOfA };
}
describe('template link', () => {
it('resolves the container and template for instance objects only', () => {
const { project, sceneId, a, a2, object, container, templateOfA } = setup();
const scene = findScene(project, sceneId)!;
const link = templateLinkOf(project, scene, object(a2));
expect(link?.asset.id).toBe(container().id);
expect(link?.template).toBe(templateOfA());
expect(templateLinkOf(project, scene, object(a))?.template).toBe(templateOfA());
const containerObject = scene.objects.find((o) => o.assetId === container().id)!;
expect(templateLinkOf(project, scene, containerObject)).toBeUndefined();
});
});
describe('position apply / revert', () => {
it('applies one axis to the template; instances still at the template value follow', () => {
const { stack, sceneId, a, a2, object, templateOfA } = setup();
expect(templateOfA().position).toEqual({ x: 45, y: 50, z: 0 });
// a2 was moved by hand on x → it keeps that; on y it still matches the template.
stack.execute(new MoveSceneObjectCommand(sceneId, a2, { x: 0, y: 50, z: 0 }));
stack.execute(new MoveSceneObjectCommand(sceneId, a, { x: 60, y: 70, z: 0 }));
stack.execute(new ApplyPositionToContainerCommand(sceneId, a, 'x'));
expect(templateOfA().position.x).toBe(60);
expect(object(a2).position.x).toBe(0); // modified on its own → untouched
stack.execute(new ApplyPositionToContainerCommand(sceneId, a, 'y'));
expect(templateOfA().position.y).toBe(70);
expect(object(a2).position.y).toBe(70); // was at the template value → follows
stack.undo();
expect(templateOfA().position.y).toBe(50);
expect(object(a2).position.y).toBe(50);
stack.undo();
expect(templateOfA().position.x).toBe(45);
expect(object(a).position.x).toBe(60); // the source object itself is not changed by apply
});
it('reverts an axis (or the whole position) to the template', () => {
const { stack, sceneId, a, object } = setup();
stack.execute(new MoveSceneObjectCommand(sceneId, a, { x: 1, y: 2, z: 3 }));
stack.execute(new RevertPositionToTemplateCommand(sceneId, a, 'z'));
expect(object(a).position).toEqual({ x: 1, y: 2, z: 0 });
stack.execute(new RevertPositionToTemplateCommand(sceneId, a));
expect(object(a).position).toEqual({ x: 45, y: 50, z: 0 });
stack.undo();
expect(object(a).position).toEqual({ x: 1, y: 2, z: 0 });
});
it('rejects objects that are not part of a container', () => {
const { project, stack, sceneId, spriteId } = setup();
const loose = new PlaceAssetCommand(sceneId, spriteId, { x: 0, y: 0 });
stack.execute(loose);
expect(() => stack.execute(new ApplyPositionToContainerCommand(sceneId, loose.objectId))).toThrow(/not part of a placed container/);
expect(findScene(project, sceneId)!.objects.find((o) => o.id === loose.objectId)).toBeDefined();
});
});
describe('override apply / revert', () => {
it('applies an override to the container template and to instances that had no own value', () => {
const { stack, sceneId, a, a2, object, templateOfA } = setup();
stack.execute(new SetOverrideCommand(sceneId, a, 'width', 77));
stack.execute(new ApplyOverrideToContainerCommand(sceneId, a, 'width'));
expect(templateOfA().overrides.width).toBe(77);
expect(object(a2).overrides.width).toBe(77);
expect(object(a).overrides.width).toBe(77);
stack.undo();
expect(templateOfA().overrides.width).toBeUndefined();
expect(object(a2).overrides.width).toBeUndefined();
expect(object(a).overrides.width).toBe(77);
});
it('leaves instances alone that already had their own override', () => {
const { stack, sceneId, a, a2, object, templateOfA } = setup();
stack.execute(new SetOverrideCommand(sceneId, a2, 'width', 5));
stack.execute(new SetOverrideCommand(sceneId, a, 'width', 77));
stack.execute(new ApplyOverrideToContainerCommand(sceneId, a, 'width'));
expect(templateOfA().overrides.width).toBe(77);
expect(object(a2).overrides.width).toBe(5);
});
it('applies an override to the asset, dropping the override on object and template', () => {
const { project, stack, sceneId, spriteId, a, a2, object, templateOfA } = setup();
stack.execute(new SetOverrideCommand(sceneId, a, 'width', 77));
stack.execute(new ApplyOverrideToContainerCommand(sceneId, a, 'width'));
stack.execute(new ApplyOverrideToAssetCommand(sceneId, a, 'width'));
const sprite = () => findAsset(project, spriteId)!;
expect(sprite().props.width).toBe(77);
expect(object(a).overrides.width).toBeUndefined();
expect(templateOfA().overrides.width).toBeUndefined();
expect(object(a2).overrides.width).toBeUndefined(); // shared the template value → follows the asset now
stack.undo();
expect(sprite().props.width).toBe(100);
expect(object(a).overrides.width).toBe(77);
expect(templateOfA().overrides.width).toBe(77);
expect(object(a2).overrides.width).toBe(77);
});
it('applies to the asset for a plain object too', () => {
const { project, stack, sceneId, spriteId, object } = setup();
const loose = new PlaceAssetCommand(sceneId, spriteId, { x: 0, y: 0 });
stack.execute(loose);
stack.execute(new SetOverrideCommand(sceneId, loose.objectId, 'height', 33));
stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose.objectId, 'height'));
expect(findAsset(project, spriteId)!.props.height).toBe(33);
expect(object(loose.objectId).overrides.height).toBeUndefined();
expect(() => stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose.objectId, 'nope'))).toThrow(/not defined/);
});
it('reverts an override to the template value (or removes it when the template has none)', () => {
const { stack, sceneId, a, a2, object, templateOfA } = setup();
stack.execute(new SetOverrideCommand(sceneId, a, 'width', 77));
stack.execute(new ApplyOverrideToContainerCommand(sceneId, a, 'width'));
stack.execute(new SetOverrideCommand(sceneId, a2, 'width', 5));
stack.execute(new RevertOverrideToTemplateCommand(sceneId, a2, 'width'));
expect(object(a2).overrides.width).toBe(77);
expect(templateOfA().overrides.width).toBe(77);
stack.execute(new SetOverrideCommand(sceneId, a2, 'height', 9));
stack.execute(new RevertOverrideToTemplateCommand(sceneId, a2, 'height'));
expect(object(a2).overrides.height).toBeUndefined();
stack.undo();
expect(object(a2).overrides.height).toBe(9);
});
});
+48
View File
@@ -769,6 +769,54 @@ try {
check('placing the container asset instantiates its content below a new container object', afterPlace.length === objectsBeforeDelete + 6 && secondCrate && secondCrate.position.x === -135 && secondCrate.position.y === 40, JSON.stringify({ count: afterPlace.length, secondCrate }));
await shot('26-container-placed');
// 16g. Apply / revert on a container instance. The red crate (o1) is part of the first
// container; the second instance has an unmodified copy of it. Moving o1 on x shows
// ↑ and ↺ on the X row; ↑ writes x into the template and the copy follows; undo takes
// both back; ↺ puts o1 back to the template value.
const templateA = () => evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === '${containerAsset.id}').objects.find(t => t.id === '${o1c.templateId}'))`).then(JSON.parse);
await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o1' })`);
await sleep(200);
check('inspector names the container the object is part of', Boolean(await evaluate(`__gm.locate(${JSON.stringify(containerAsset.name)})`)));
check('no apply/revert while the instance matches its template', !(await evaluate(`__gm.locate('↑')`)) && !(await evaluate(`__gm.locate('↺')`)));
await evaluate(`(() => { __gm.state.activeScene.objects.find(o => o.id === 'o1').position.x = -100; __gm.state.preview(); })()`);
await sleep(200);
const applyButton = await evaluate(`__gm.locate('↑')`);
check('a moved instance shows apply and revert on its X row', Boolean(applyButton) && Boolean(await evaluate(`__gm.locate('↺')`)));
await shot('27-apply-buttons');
await click(applyButton.x, applyButton.y);
await sleep(200);
check('apply writes x into the template and the unmodified copy follows', (await templateA()).position.x === -100 && JSON.parse(await objectJson(secondCrate.id)).position.x === -100, JSON.stringify({ template: (await templateA()).position, copy: JSON.parse(await objectJson(secondCrate.id)).position }));
check('apply hides the buttons again', !(await evaluate(`__gm.locate('↑')`)));
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(200);
check('undo restores template and copy', (await templateA()).position.x === -135 && JSON.parse(await objectJson(secondCrate.id)).position.x === -135);
const revertButton = await evaluate(`__gm.locate('↺')`);
await click(revertButton.x, revertButton.y);
await sleep(200);
check('revert puts the instance back to the template position', JSON.parse(await objectJson('o1')).position.x === -135);
// 16h. Overrides on an instance offer a menu: "Apply to container" or "Apply to asset".
await evaluate(`(() => { __gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 77; __gm.state.preview(); })()`);
await sleep(200);
const applyMenu = await evaluate(`__gm.locate('↑ ▾')`);
check('an overridden property on an instance shows the apply menu button', Boolean(applyMenu));
await click(applyMenu.x, applyMenu.y);
await sleep(150);
check('the menu offers container and asset', Boolean(await evaluate(`__gm.locate('Apply to container')`)) && Boolean(await evaluate(`__gm.locate('Apply to asset')`)));
await shot('28-apply-menu');
await clickText('Apply to asset');
await sleep(200);
const crateAfterApply = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'crate').props)`));
check('Apply to asset writes the value into the asset and drops the override', crateAfterApply.width === 77 && JSON.parse(await objectJson('o1')).overrides.width === undefined, JSON.stringify(crateAfterApply));
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(200);
check('undo restores asset and override', (await evaluate(`__gm.state.project.assets.find(a => a.id === 'crate').props.width`)) === 100 && JSON.parse(await objectJson('o1')).overrides.width === 77);
await click(applyMenu.x, applyMenu.y);
await sleep(150);
await clickText('Apply to container');
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);
check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
ws.close();
} catch (error) {