Z-order via position.z and reset-to-default for asset properties
- Position is now { x, y, z }; higher z draws on top, ties keep hierarchy order
- Viewport sorts the objects layer deterministically (syncDrawOrder)
- Inspector shows a Z field; asset properties that differ from the schema
default are marked bold with a reset button (ResetAssetPropertyCommand)
- isModified() helper, unit tests, E2E checks and seed updated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
aaa8154adf
commit
d4b1ffa051
18 files changed
+219
-50
No files matched your search
@@ -11,8 +11,9 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
|
|||||||
| Term | Meaning |
|
| Term | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`) and typed properties. Assets have **no position**. |
|
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`) and typed properties. Assets have **no position**. |
|
||||||
| **Scene object** | An asset placed into a scene: a link to the asset + a **position** + sparse **overrides** of the asset's properties. Scenes cannot be placed. |
|
| **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. Scenes cannot be placed. |
|
||||||
| **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 (↺). |
|
||||||
|
| **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. |
|
||||||
| **Edit mode** | The viewport draws the project model; panels, grid, gizmos and drag-to-move are active. |
|
| **Edit mode** | The viewport draws the project model; panels, grid, gizmos and drag-to-move are active. |
|
||||||
| **Play mode** | A `World` is instantiated from the active scene (one entity per scene object, resolved properties as mutable state). Systems tick it; nothing writes back. **Stop discards the world.** |
|
| **Play mode** | A `World` is instantiated from the active scene (one entity per scene object, resolved properties as mutable state). Systems tick it; nothing writes back. **Stop discards the world.** |
|
||||||
|
|
||||||
@@ -25,8 +26,11 @@ behaviour will come with scripting in a later phase.
|
|||||||
|
|
||||||
**Editor symbols are always on top.** The viewport draws scene content (sprites) in an
|
**Editor symbols are always on top.** The viewport draws scene content (sprites) in an
|
||||||
`objects` layer and editor symbols (camera icon, labels) in an `overlay` layer above it, so a
|
`objects` layer and editor symbols (camera icon, labels) in an `overlay` layer above it, so a
|
||||||
sprite can never hide a symbol regardless of hierarchy order. Z-order between sprites is not
|
sprite can never hide a symbol regardless of z or hierarchy order.
|
||||||
configurable yet.
|
|
||||||
|
**Z-order is part of the position.** `position.z` is the draw order of a scene object: higher
|
||||||
|
`z` is drawn on top; objects with equal `z` keep their hierarchy order. It is edited in the
|
||||||
|
inspector next to X/Y, undoable like any move, and applies in edit and play mode alike.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,30 @@ export class SetAssetPropertyCommand implements Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Puts an asset property back to its schema default (the asset-level "clear override"). */
|
||||||
|
export class ResetAssetPropertyCommand implements Command {
|
||||||
|
readonly label: string;
|
||||||
|
private previous: PropertyValue | undefined;
|
||||||
|
|
||||||
|
constructor(private readonly assetId: string, private readonly key: string) {
|
||||||
|
this.label = `Reset ${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
const asset = requireAsset(project, this.assetId);
|
||||||
|
const def = schemaFor(asset.type).find((d) => d.key === this.key);
|
||||||
|
if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`);
|
||||||
|
this.previous = asset.props[this.key];
|
||||||
|
asset.props[this.key] = def.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
const asset = requireAsset(project, this.assetId);
|
||||||
|
if (this.previous === undefined) delete asset.props[this.key];
|
||||||
|
else asset.props[this.key] = this.previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class SetActiveSceneCommand implements Command {
|
export class SetActiveSceneCommand implements Command {
|
||||||
readonly label = 'Switch scene';
|
readonly label = 'Switch scene';
|
||||||
private previous = '';
|
private previous = '';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isPlaceableType } from '../model/AssetTypes';
|
import { isPlaceableType } from '../model/AssetTypes';
|
||||||
import type { PropertyValue, Vec2 } from '../model/Properties';
|
import type { Position, PropertyValue, Vec2 } from '../model/Properties';
|
||||||
import { findScene, findSceneObject, type Project } from '../model/Project';
|
import { findScene, findSceneObject, type Project } from '../model/Project';
|
||||||
import { createSceneObject, type SceneObject } from '../model/SceneObject';
|
import { createSceneObject, type SceneObject } from '../model/SceneObject';
|
||||||
import { coerceValue, schemaFor } from '../properties/PropertySchema';
|
import { coerceValue, schemaFor } from '../properties/PropertySchema';
|
||||||
@@ -24,7 +24,7 @@ export class PlaceAssetCommand implements Command {
|
|||||||
readonly label = 'Place asset';
|
readonly label = 'Place asset';
|
||||||
readonly objectId = newId();
|
readonly objectId = newId();
|
||||||
|
|
||||||
constructor(private readonly sceneId: string, private readonly assetId: string, private readonly position: Vec2) {}
|
constructor(private readonly sceneId: string, private readonly assetId: string, private readonly position: Vec2 | Position) {}
|
||||||
|
|
||||||
execute(project: Project): void {
|
execute(project: Project): void {
|
||||||
const scene = requireScene(project, this.sceneId);
|
const scene = requireScene(project, this.sceneId);
|
||||||
@@ -60,15 +60,16 @@ export class RemoveSceneObjectCommand implements Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sets the full position (x, y and draw order z) of a scene object. */
|
||||||
export class MoveSceneObjectCommand implements Command {
|
export class MoveSceneObjectCommand implements Command {
|
||||||
readonly label = 'Move object';
|
readonly label = 'Move object';
|
||||||
private previous: Vec2 = { x: 0, y: 0 };
|
private previous: Position = { x: 0, y: 0, z: 0 };
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly sceneId: string,
|
private readonly sceneId: string,
|
||||||
private readonly objectId: string,
|
private readonly objectId: string,
|
||||||
private readonly position: Vec2,
|
private readonly position: Position,
|
||||||
previous?: Vec2,
|
previous?: Position,
|
||||||
) {
|
) {
|
||||||
if (previous) this.previous = { ...previous };
|
if (previous) this.previous = { ...previous };
|
||||||
}
|
}
|
||||||
@@ -76,7 +77,7 @@ export class MoveSceneObjectCommand implements Command {
|
|||||||
execute(project: Project): void {
|
execute(project: Project): void {
|
||||||
const object = requireObject(project, this.sceneId, this.objectId);
|
const object = requireObject(project, this.sceneId, this.objectId);
|
||||||
this.previous = { ...object.position };
|
this.previous = { ...object.position };
|
||||||
object.position = { x: this.position.x, y: this.position.y };
|
object.position = { x: this.position.x, y: this.position.y, z: this.position.z };
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(project: Project): void {
|
undo(project: Project): void {
|
||||||
|
|||||||
@@ -4,7 +4,21 @@ export type PropertyValue = number | string | boolean;
|
|||||||
/** Sparse map of property key → value (asset properties or scene-object overrides). */
|
/** Sparse map of property key → value (asset properties or scene-object overrides). */
|
||||||
export type PropertyBag = Record<string, PropertyValue>;
|
export type PropertyBag = Record<string, PropertyValue>;
|
||||||
|
|
||||||
|
/** A point in world or screen space. */
|
||||||
export interface Vec2 {
|
export interface Vec2 {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a scene object sits: `x`/`y` in world units, `z` is the draw order. Higher `z` is
|
||||||
|
* drawn on top; objects with equal `z` keep their hierarchy order.
|
||||||
|
*/
|
||||||
|
export interface Position extends Vec2 {
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable draw order: ascending `z`, ties keep the input (hierarchy) order. */
|
||||||
|
export function sortByZ<T extends { z: number }>(items: readonly T[]): T[] {
|
||||||
|
return [...items].sort((a, b) => a.z - b.z);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PropertyBag, Vec2 } from './Properties';
|
import type { Position, PropertyBag, Vec2 } from './Properties';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An asset placed inside a scene. It links to the asset by id, owns a position
|
* An asset placed inside a scene. It links to the asset by id, owns a position
|
||||||
@@ -10,10 +10,12 @@ export interface SceneObject {
|
|||||||
assetId: string;
|
assetId: string;
|
||||||
/** Optional display name; falls back to the asset name when empty. */
|
/** Optional display name; falls back to the asset name when empty. */
|
||||||
name: string;
|
name: string;
|
||||||
position: Vec2;
|
/** World position plus draw order (`z`). */
|
||||||
|
position: Position;
|
||||||
overrides: PropertyBag;
|
overrides: PropertyBag;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSceneObject(id: string, assetId: string, position: Vec2): SceneObject {
|
/** Creates a scene object; `z` defaults to 0 when the caller only has a 2D point. */
|
||||||
return { id, assetId, name: '', position: { x: position.x, y: position.y }, overrides: {} };
|
export function createSceneObject(id: string, assetId: string, position: Vec2 | Position): SceneObject {
|
||||||
|
return { id, assetId, name: '', position: { x: position.x, y: position.y, z: 'z' in position ? position.z : 0 }, overrides: {} };
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,17 @@ export function isOverridden(object: SceneObject, key: string): boolean {
|
|||||||
return object.overrides[key] !== undefined;
|
return object.overrides[key] !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the asset's own value for `key` differs from the schema default – the asset-level
|
||||||
|
* counterpart of an override. The inspector highlights such values and offers a reset.
|
||||||
|
*/
|
||||||
|
export function isModified(asset: Asset, key: string): boolean {
|
||||||
|
const def = schemaFor(asset.type).find((d) => d.key === key);
|
||||||
|
if (!def) return false;
|
||||||
|
const own = asset.props[key];
|
||||||
|
return own !== undefined && own !== def.default;
|
||||||
|
}
|
||||||
|
|
||||||
/** Value the object would have without its override for `key` (the asset's value). */
|
/** Value the object would have without its override for `key` (the asset's value). */
|
||||||
export function inheritedValue(asset: Asset, key: string): PropertyValue | undefined {
|
export function inheritedValue(asset: Asset, key: string): PropertyValue | undefined {
|
||||||
return resolveAssetProps(asset)[key];
|
return resolveAssetProps(asset)[key];
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function normalizeObjects(raw: unknown, types: Map<string, AssetType>): SceneObj
|
|||||||
id: o.id,
|
id: o.id,
|
||||||
assetId: o.assetId,
|
assetId: o.assetId,
|
||||||
name: typeof o.name === 'string' ? o.name : '',
|
name: typeof o.name === 'string' ? o.name : '',
|
||||||
position: { x: toNumber(p.x), y: toNumber(p.y) },
|
position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) },
|
||||||
overrides: normalizeBag(o.overrides, type, true),
|
overrides: normalizeBag(o.overrides, type, true),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ function installDebugHook(app: Application, state: EditorState, viewport: Viewpo
|
|||||||
const p = node.getGlobalPosition();
|
const p = node.getGlobalPosition();
|
||||||
return { x: p.x + node.width / 2, y: p.y + node.height / 2 };
|
return { x: p.x + node.width / 2, y: p.y + node.height / 2 };
|
||||||
};
|
};
|
||||||
(window as unknown as { __gm: unknown }).__gm = { state, locate, viewportCenter, viewportInfo: () => viewport.debugInfo(), layerOf: (id: string) => viewport.layerOf(id) };
|
(window as unknown as { __gm: unknown }).__gm = { state, locate, viewportCenter, viewportInfo: () => viewport.debugInfo(), layerOf: (id: string) => viewport.layerOf(id), drawOrder: () => viewport.drawOrder() };
|
||||||
}
|
}
|
||||||
|
|
||||||
class Editor {
|
class Editor {
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ export interface FieldRow {
|
|||||||
row: Widget;
|
row: Widget;
|
||||||
/** Push a model value into the widget (no events fired). */
|
/** Push a model value into the widget (no events fired). */
|
||||||
setValue(value: PropertyValue): void;
|
setValue(value: PropertyValue): void;
|
||||||
/** Highlight the row as overridden (scene objects) and show the reset button. */
|
/**
|
||||||
setOverridden(overridden: boolean): 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.
|
||||||
|
*/
|
||||||
|
setModified(modified: boolean): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,10 +79,10 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
|
|||||||
return {
|
return {
|
||||||
row,
|
row,
|
||||||
setValue,
|
setValue,
|
||||||
setOverridden(overridden) {
|
setModified(modified) {
|
||||||
label.setStyle(overridden ? 'bodyBold' : 'muted');
|
label.setStyle(modified ? 'bodyBold' : 'muted');
|
||||||
if (reset && reset.visible !== overridden) {
|
if (reset && reset.visible !== modified) {
|
||||||
reset.visible = overridden;
|
reset.visible = modified;
|
||||||
row.relayout();
|
row.relayout();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { RenameAssetCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||||||
import {
|
import {
|
||||||
ClearOverrideCommand,
|
ClearOverrideCommand,
|
||||||
MoveSceneObjectCommand,
|
MoveSceneObjectCommand,
|
||||||
RenameSceneObjectCommand,
|
RenameSceneObjectCommand,
|
||||||
SetOverrideCommand,
|
SetOverrideCommand,
|
||||||
} from '../../core/commands/sceneObjectCommands';
|
} from '../../core/commands/sceneObjectCommands';
|
||||||
import type { PropertyValue } from '../../core/model/Properties';
|
import type { Position, PropertyValue } from '../../core/model/Properties';
|
||||||
import { findAsset, findScene, findSceneObject } from '../../core/model/Project';
|
import { findAsset, findScene, findSceneObject } from '../../core/model/Project';
|
||||||
import { schemaFor } from '../../core/properties/PropertySchema';
|
import { schemaFor } from '../../core/properties/PropertySchema';
|
||||||
import { isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
|
import { isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
|
||||||
import { Button, HStack, Label, Panel, ScrollContainer, TextField, Theme, VStack } from '../../ui';
|
import { Button, HStack, Label, Panel, ScrollContainer, TextField, Theme, VStack } from '../../ui';
|
||||||
import { createFieldRow, type FieldRow } from '../inspector/fieldFactory';
|
import { createFieldRow, type FieldRow } from '../inspector/fieldFactory';
|
||||||
import type { EditorState, Selection } from '../state';
|
import type { EditorState, Selection } from '../state';
|
||||||
@@ -66,10 +66,14 @@ export class Inspector extends Panel {
|
|||||||
});
|
});
|
||||||
this.body.add(this.labelled('Name', this.nameField));
|
this.body.add(this.labelled('Name', this.nameField));
|
||||||
|
|
||||||
|
// Asset properties: values that differ from the schema default are marked and resettable.
|
||||||
const values = resolveAssetProps(asset);
|
const values = resolveAssetProps(asset);
|
||||||
for (const def of schemaFor(asset.type)) {
|
for (const def of schemaFor(asset.type)) {
|
||||||
let original: PropertyValue | undefined;
|
let original: PropertyValue | undefined;
|
||||||
const row = createFieldRow(def, values[def.key] ?? def.default, {
|
const row = createFieldRow(
|
||||||
|
def,
|
||||||
|
values[def.key] ?? def.default,
|
||||||
|
{
|
||||||
onInput: (value) => {
|
onInput: (value) => {
|
||||||
const target = findAsset(this.state.project, assetId);
|
const target = findAsset(this.state.project, assetId);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
@@ -84,7 +88,10 @@ export class Inspector extends Panel {
|
|||||||
original = undefined;
|
original = undefined;
|
||||||
this.state.execute(new SetAssetPropertyCommand(assetId, def.key, value));
|
this.state.execute(new SetAssetPropertyCommand(assetId, def.key, value));
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
() => this.state.execute(new ResetAssetPropertyCommand(assetId, def.key)),
|
||||||
|
);
|
||||||
|
row.setModified(isModified(asset, def.key));
|
||||||
this.fields.set(def.key, row);
|
this.fields.set(def.key, row);
|
||||||
this.body.add(row.row);
|
this.body.add(row.row);
|
||||||
}
|
}
|
||||||
@@ -110,10 +117,10 @@ export class Inspector extends Panel {
|
|||||||
assetRow.add(new Button(asset.name, { variant: 'ghost', onClick: () => this.state.select({ kind: 'asset', assetId: asset.id }) }), 1);
|
assetRow.add(new Button(asset.name, { variant: 'ghost', onClick: () => this.state.select({ kind: 'asset', assetId: asset.id }) }), 1);
|
||||||
this.body.add(assetRow);
|
this.body.add(assetRow);
|
||||||
|
|
||||||
// Position – belongs to the scene object only.
|
// Position – belongs to the scene object only. Z is the draw order (higher = on top).
|
||||||
this.body.add(new Label('POSITION', { style: 'title' }));
|
this.body.add(new Label('POSITION', { style: 'title' }));
|
||||||
for (const axis of ['x', 'y'] as const) {
|
for (const axis of ['x', 'y', 'z'] as const) {
|
||||||
let original: { x: number; y: number } | undefined;
|
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) => {
|
onInput: (value) => {
|
||||||
const target = this.currentObject(sceneId, objectId);
|
const target = this.currentObject(sceneId, objectId);
|
||||||
@@ -169,7 +176,7 @@ export class Inspector extends Panel {
|
|||||||
},
|
},
|
||||||
() => this.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)),
|
() => this.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)),
|
||||||
);
|
);
|
||||||
row.setOverridden(isOverridden(object, def.key));
|
row.setModified(isOverridden(object, def.key));
|
||||||
this.fields.set(def.key, row);
|
this.fields.set(def.key, row);
|
||||||
this.body.add(row.row);
|
this.body.add(row.row);
|
||||||
}
|
}
|
||||||
@@ -188,7 +195,10 @@ export class Inspector extends Panel {
|
|||||||
if (!asset) return this.rebuild();
|
if (!asset) return this.rebuild();
|
||||||
this.nameField?.setValue(asset.name);
|
this.nameField?.setValue(asset.name);
|
||||||
const values = resolveAssetProps(asset);
|
const values = resolveAssetProps(asset);
|
||||||
for (const [key, row] of this.fields) row.setValue(values[key] ?? 0);
|
for (const [key, row] of this.fields) {
|
||||||
|
row.setValue(values[key] ?? 0);
|
||||||
|
row.setModified(isModified(asset, key));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const object = this.currentObject(s.sceneId, s.objectId);
|
const object = this.currentObject(s.sceneId, s.objectId);
|
||||||
@@ -197,11 +207,12 @@ export class Inspector extends Panel {
|
|||||||
this.nameField?.setValue(object.name);
|
this.nameField?.setValue(object.name);
|
||||||
this.fields.get('x')?.setValue(object.position.x);
|
this.fields.get('x')?.setValue(object.position.x);
|
||||||
this.fields.get('y')?.setValue(object.position.y);
|
this.fields.get('y')?.setValue(object.position.y);
|
||||||
|
this.fields.get('z')?.setValue(object.position.z);
|
||||||
const values = resolveSceneObjectProps(asset, object);
|
const values = resolveSceneObjectProps(asset, object);
|
||||||
for (const def of schemaFor(asset.type)) {
|
for (const def of schemaFor(asset.type)) {
|
||||||
const row = this.fields.get(def.key);
|
const row = this.fields.get(def.key);
|
||||||
row?.setValue(values[def.key] ?? def.default);
|
row?.setValue(values[def.key] ?? def.default);
|
||||||
row?.setOverridden(isOverridden(object, def.key));
|
row?.setModified(isOverridden(object, def.key));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,5 +15,7 @@ export interface Entity {
|
|||||||
name: string;
|
name: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
/** Draw order; higher is drawn on top. */
|
||||||
|
z: number;
|
||||||
props: PropertyBag;
|
props: PropertyBag;
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,7 @@ export class World {
|
|||||||
name: object.name || asset.name,
|
name: object.name || asset.name,
|
||||||
x: object.position.x,
|
x: object.position.x,
|
||||||
y: object.position.y,
|
y: object.position.y,
|
||||||
|
z: object.position.z,
|
||||||
props: resolveSceneObjectProps(asset, object),
|
props: resolveSceneObjectProps(asset, object),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||||||
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
|
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
|
||||||
import type { AssetType } from '../core/model/AssetTypes';
|
import type { AssetType } from '../core/model/AssetTypes';
|
||||||
import type { PropertyBag, Vec2 } from '../core/model/Properties';
|
import { type Position, type PropertyBag, sortByZ, type Vec2 } from '../core/model/Properties';
|
||||||
import { findAsset } from '../core/model/Project';
|
import { findAsset } from '../core/model/Project';
|
||||||
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
|
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
|
||||||
import { resolveSceneObjectProps } from '../core/properties/resolve';
|
import { resolveSceneObjectProps } from '../core/properties/resolve';
|
||||||
@@ -16,6 +16,8 @@ interface DrawItem {
|
|||||||
name: string;
|
name: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
/** Draw order (see `Position.z`). */
|
||||||
|
z: number;
|
||||||
props: PropertyBag;
|
props: PropertyBag;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@ export class Viewport extends Widget {
|
|||||||
/** Everything that may extend past the viewport (world and gizmos) is clipped through `content`. */
|
/** Everything that may extend past the viewport (world and gizmos) is clipped through `content`. */
|
||||||
private readonly content = new Container();
|
private readonly content = new Container();
|
||||||
private readonly world = new Container();
|
private readonly world = new Container();
|
||||||
/** Scene objects (sprites) in hierarchy order. */
|
/** Scene objects (sprites), ordered by `z` then hierarchy order (see `syncDrawOrder`). */
|
||||||
private readonly objects = new Container();
|
private readonly objects = new Container();
|
||||||
/**
|
/**
|
||||||
* Editor symbols (camera icon, labels). Always drawn above `objects` so that a sprite can never
|
* Editor symbols (camera icon, labels). Always drawn above `objects` so that a sprite can never
|
||||||
@@ -63,7 +65,7 @@ export class Viewport extends Widget {
|
|||||||
/** Edit-mode view: world point at the viewport centre and zoom. */
|
/** Edit-mode view: world point at the viewport centre and zoom. */
|
||||||
private view = { x: 0, y: 0, zoom: 1 };
|
private view = { x: 0, y: 0, zoom: 1 };
|
||||||
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
|
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
|
||||||
private dragging: { objectId: string; sceneId: string; start: Vec2; pointer: Vec2; moved: boolean } | null = null;
|
private dragging: { objectId: string; sceneId: string; start: Position; pointer: Vec2; moved: boolean } | null = null;
|
||||||
private visibleCount = 0;
|
private visibleCount = 0;
|
||||||
|
|
||||||
constructor(private readonly state: EditorState) {
|
constructor(private readonly state: EditorState) {
|
||||||
@@ -95,7 +97,7 @@ export class Viewport extends Widget {
|
|||||||
this.dragging.moved = true;
|
this.dragging.moved = true;
|
||||||
const object = this.state.activeScene?.objects.find((o) => o.id === this.dragging?.objectId);
|
const object = this.state.activeScene?.objects.find((o) => o.id === this.dragging?.objectId);
|
||||||
if (!object) return;
|
if (!object) return;
|
||||||
object.position = { x: Math.round(this.dragging.start.x + dx), y: Math.round(this.dragging.start.y + dy) };
|
object.position = { x: Math.round(this.dragging.start.x + dx), y: Math.round(this.dragging.start.y + dy), z: this.dragging.start.z };
|
||||||
this.state.preview();
|
this.state.preview();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -154,6 +156,11 @@ export class Viewport extends Widget {
|
|||||||
return node.parent === this.overlay ? 'overlay' : 'objects';
|
return node.parent === this.overlay ? 'overlay' : 'objects';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Object ids in the order they are drawn, back to front (test hook). */
|
||||||
|
drawOrder(): string[] {
|
||||||
|
return this.objects.children.filter((node) => node.visible).map((node) => node.label);
|
||||||
|
}
|
||||||
|
|
||||||
override measure(): Size {
|
override measure(): Size {
|
||||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||||
}
|
}
|
||||||
@@ -199,7 +206,7 @@ export class Viewport extends Widget {
|
|||||||
if (this.playWorld) {
|
if (this.playWorld) {
|
||||||
return {
|
return {
|
||||||
background: this.playWorld.backgroundColor,
|
background: this.playWorld.backgroundColor,
|
||||||
items: this.playWorld.entities.filter((e) => e.type !== 'camera').map((e) => ({ id: e.objectId, type: e.type, name: e.name, x: e.x, y: e.y, props: e.props })),
|
items: this.playWorld.entities.filter((e) => e.type !== 'camera').map((e) => ({ id: e.objectId, type: e.type, name: e.name, x: e.x, y: e.y, z: e.z, props: e.props })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const scene = this.state.activeScene;
|
const scene = this.state.activeScene;
|
||||||
@@ -208,7 +215,7 @@ export class Viewport extends Widget {
|
|||||||
for (const object of scene.objects) {
|
for (const object of scene.objects) {
|
||||||
const asset = findAsset(this.state.project, object.assetId);
|
const asset = findAsset(this.state.project, object.assetId);
|
||||||
if (!asset) continue;
|
if (!asset) continue;
|
||||||
items.push({ id: object.id, type: asset.type, name: object.name || asset.name, x: object.position.x, y: object.position.y, props: resolveSceneObjectProps(asset, object) });
|
items.push({ id: object.id, type: asset.type, name: object.name || asset.name, x: object.position.x, y: object.position.y, z: object.position.z, props: resolveSceneObjectProps(asset, object) });
|
||||||
}
|
}
|
||||||
return { items, background: Number(scene.props.backgroundColor ?? Theme.colors.viewport) };
|
return { items, background: Number(scene.props.backgroundColor ?? Theme.colors.viewport) };
|
||||||
}
|
}
|
||||||
@@ -250,6 +257,7 @@ export class Viewport extends Widget {
|
|||||||
let node = this.nodes.get(item.id);
|
let node = this.nodes.get(item.id);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
node = new Graphics();
|
node = new Graphics();
|
||||||
|
node.label = item.id;
|
||||||
node.cursor = 'move';
|
node.cursor = 'move';
|
||||||
node.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e));
|
node.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e));
|
||||||
this.nodes.set(item.id, node);
|
this.nodes.set(item.id, node);
|
||||||
@@ -278,10 +286,25 @@ export class Viewport extends Widget {
|
|||||||
this.labels.delete(id);
|
this.labels.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.syncDrawOrder(items);
|
||||||
|
|
||||||
if (editing) this.drawGizmos(items, view);
|
if (editing) this.drawGizmos(items, view);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-attach the object nodes so that Pixi draws them by ascending `z`, ties in hierarchy
|
||||||
|
* order. Done explicitly (instead of Pixi's lazy `sortableChildren`) so the order is
|
||||||
|
* deterministic and hierarchy changes are honoured too.
|
||||||
|
*/
|
||||||
|
private syncDrawOrder(items: DrawItem[]): void {
|
||||||
|
const ordered = sortByZ(items.filter((item) => !this.isSymbol(item)));
|
||||||
|
this.objects.removeChildren();
|
||||||
|
for (const item of ordered) {
|
||||||
|
const node = this.nodes.get(item.id);
|
||||||
|
if (node) this.objects.addChild(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Items drawn as editor symbols rather than as scene content. */
|
/** Items drawn as editor symbols rather than as scene content. */
|
||||||
private isSymbol(item: DrawItem): boolean {
|
private isSymbol(item: DrawItem): boolean {
|
||||||
return item.type === 'camera';
|
return item.type === 'camera';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
CreateAssetCommand,
|
CreateAssetCommand,
|
||||||
DeleteAssetCommand,
|
DeleteAssetCommand,
|
||||||
RenameAssetCommand,
|
RenameAssetCommand,
|
||||||
|
ResetAssetPropertyCommand,
|
||||||
SetActiveSceneCommand,
|
SetActiveSceneCommand,
|
||||||
SetAssetPropertyCommand,
|
SetAssetPropertyCommand,
|
||||||
} from '../../src/core/commands/assetCommands';
|
} from '../../src/core/commands/assetCommands';
|
||||||
@@ -54,6 +55,19 @@ describe('asset commands', () => {
|
|||||||
it('rejects unknown properties', () => {
|
it('rejects unknown properties', () => {
|
||||||
const { stack, spriteId } = setup();
|
const { stack, spriteId } = setup();
|
||||||
expect(() => stack.execute(new SetAssetPropertyCommand(spriteId, 'x', 5))).toThrow(/not defined/);
|
expect(() => stack.execute(new SetAssetPropertyCommand(spriteId, 'x', 5))).toThrow(/not defined/);
|
||||||
|
expect(() => stack.execute(new ResetAssetPropertyCommand(spriteId, 'x'))).toThrow(/not defined/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets a property to its schema default with undo', () => {
|
||||||
|
const { project, stack, spriteId } = setup();
|
||||||
|
const sprite = () => project.assets.find((a) => a.id === spriteId)!;
|
||||||
|
stack.execute(new SetAssetPropertyCommand(spriteId, 'height', 60));
|
||||||
|
stack.execute(new ResetAssetPropertyCommand(spriteId, 'height'));
|
||||||
|
expect(sprite().props.height).toBe(100);
|
||||||
|
stack.undo();
|
||||||
|
expect(sprite().props.height).toBe(60);
|
||||||
|
stack.redo();
|
||||||
|
expect(sprite().props.height).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes an asset together with its scene objects and restores both on undo', () => {
|
it('deletes an asset together with its scene objects and restores both on undo', () => {
|
||||||
@@ -67,8 +81,8 @@ describe('asset commands', () => {
|
|||||||
stack.undo();
|
stack.undo();
|
||||||
expect(project.assets).toHaveLength(2);
|
expect(project.assets).toHaveLength(2);
|
||||||
expect(findScene(project, sceneId)!.objects.map((o) => o.position)).toEqual([
|
expect(findScene(project, sceneId)!.objects.map((o) => o.position)).toEqual([
|
||||||
{ x: 1, y: 2 },
|
{ x: 1, y: 2, z: 0 },
|
||||||
{ x: 3, y: 4 },
|
{ x: 3, y: 4, z: 0 },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,7 +106,7 @@ describe('scene object commands', () => {
|
|||||||
const object = findScene(project, sceneId)!.objects[0]!;
|
const object = findScene(project, sceneId)!.objects[0]!;
|
||||||
expect(object.id).toBe(place.objectId);
|
expect(object.id).toBe(place.objectId);
|
||||||
expect(object.assetId).toBe(spriteId);
|
expect(object.assetId).toBe(spriteId);
|
||||||
expect(object.position).toEqual({ x: 5, y: 6 });
|
expect(object.position).toEqual({ x: 5, y: 6, z: 0 });
|
||||||
expect(object.overrides).toEqual({});
|
expect(object.overrides).toEqual({});
|
||||||
|
|
||||||
expect(() => stack.execute(new PlaceAssetCommand(sceneId, sceneId, { x: 0, y: 0 }))).toThrow(/cannot be placed/);
|
expect(() => stack.execute(new PlaceAssetCommand(sceneId, sceneId, { x: 0, y: 0 }))).toThrow(/cannot be placed/);
|
||||||
@@ -105,8 +119,11 @@ describe('scene object commands', () => {
|
|||||||
stack.execute(place);
|
stack.execute(place);
|
||||||
const object = () => findScene(project, sceneId)!.objects.find((o) => o.id === place.objectId)!;
|
const object = () => findScene(project, sceneId)!.objects.find((o) => o.id === place.objectId)!;
|
||||||
|
|
||||||
stack.execute(new MoveSceneObjectCommand(sceneId, place.objectId, { x: 40, y: 50 }));
|
stack.execute(new MoveSceneObjectCommand(sceneId, place.objectId, { x: 40, y: 50, z: 3 }));
|
||||||
expect(object().position).toEqual({ x: 40, y: 50 });
|
expect(object().position).toEqual({ x: 40, y: 50, z: 3 });
|
||||||
|
stack.undo();
|
||||||
|
expect(object().position).toEqual({ x: 0, y: 0, z: 0 });
|
||||||
|
stack.redo();
|
||||||
|
|
||||||
stack.execute(new SetOverrideCommand(sceneId, place.objectId, 'color', 0x123456));
|
stack.execute(new SetOverrideCommand(sceneId, place.objectId, 'color', 0x123456));
|
||||||
expect(object().overrides).toEqual({ color: 0x123456 });
|
expect(object().overrides).toEqual({ color: 0x123456 });
|
||||||
@@ -120,7 +137,7 @@ describe('scene object commands', () => {
|
|||||||
stack.execute(new RemoveSceneObjectCommand(sceneId, place.objectId));
|
stack.execute(new RemoveSceneObjectCommand(sceneId, place.objectId));
|
||||||
expect(findScene(project, sceneId)!.objects).toHaveLength(0);
|
expect(findScene(project, sceneId)!.objects).toHaveLength(0);
|
||||||
stack.undo();
|
stack.undo();
|
||||||
expect(object().position).toEqual({ x: 40, y: 50 });
|
expect(object().position).toEqual({ x: 40, y: 50, z: 3 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears the redo stack on a new command', () => {
|
it('clears the redo stack on a new command', () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { buildAsset } from '../../src/core/commands/assetCommands';
|
import { buildAsset } from '../../src/core/commands/assetCommands';
|
||||||
import { createSceneObject } from '../../src/core/model/SceneObject';
|
import { createSceneObject } from '../../src/core/model/SceneObject';
|
||||||
import { coerceValue, defaultsFor, schemaFor } from '../../src/core/properties/PropertySchema';
|
import { coerceValue, defaultsFor, schemaFor } from '../../src/core/properties/PropertySchema';
|
||||||
import { inheritedValue, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../src/core/properties/resolve';
|
import { inheritedValue, isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../src/core/properties/resolve';
|
||||||
|
|
||||||
describe('PropertySchema', () => {
|
describe('PropertySchema', () => {
|
||||||
it('has no position on any asset type', () => {
|
it('has no position on any asset type', () => {
|
||||||
@@ -49,10 +49,20 @@ describe('resolve', () => {
|
|||||||
expect(inheritedValue(sprite, 'color')).toBe(0x00ff00);
|
expect(inheritedValue(sprite, 'color')).toBe(0x00ff00);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects asset values that differ from the schema default', () => {
|
||||||
|
const sprite = buildAsset('sprite', 'Box');
|
||||||
|
expect(isModified(sprite, 'width')).toBe(false); // explicit default counts as unmodified
|
||||||
|
sprite.props.width = 250;
|
||||||
|
expect(isModified(sprite, 'width')).toBe(true);
|
||||||
|
delete sprite.props.height; // missing → resolved from default → unmodified
|
||||||
|
expect(isModified(sprite, 'height')).toBe(false);
|
||||||
|
expect(isModified(sprite, 'bogus')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps position on the scene object only', () => {
|
it('keeps position on the scene object only', () => {
|
||||||
const sprite = buildAsset('sprite', 'Box');
|
const sprite = buildAsset('sprite', 'Box');
|
||||||
const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 });
|
const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 });
|
||||||
expect(object.position).toEqual({ x: 10, y: 20 });
|
expect(object.position).toEqual({ x: 10, y: 20, z: 0 });
|
||||||
expect('position' in sprite).toBe(false);
|
expect('position' in sprite).toBe(false);
|
||||||
expect(resolveSceneObjectProps(sprite, object)).not.toHaveProperty('x');
|
expect(resolveSceneObjectProps(sprite, object)).not.toHaveProperty('x');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ describe('serialize', () => {
|
|||||||
id: 'o1',
|
id: 'o1',
|
||||||
assetId: 'sp1',
|
assetId: 'sp1',
|
||||||
name: '',
|
name: '',
|
||||||
position: { x: 5, y: 0 },
|
position: { x: 5, y: 0, z: 0 },
|
||||||
overrides: { color: 0x00ff00, width: 1 },
|
overrides: { color: 0x00ff00, width: 1 },
|
||||||
});
|
});
|
||||||
expect(project.assets[1]!.name).toBe('sprite');
|
expect(project.assets[1]!.name).toBe('sprite');
|
||||||
|
|||||||
@@ -284,6 +284,52 @@ try {
|
|||||||
check('camera symbol is drawn in the overlay layer above sprites', layers === '["overlay","objects","objects"]', layers);
|
check('camera symbol is drawn in the overlay layer above sprites', layers === '["overlay","objects","objects"]', layers);
|
||||||
await shot('13-symbol-on-top');
|
await shot('13-symbol-on-top');
|
||||||
|
|
||||||
|
// 11. Z-order: put both crates on the origin. With equal z the hierarchy order wins
|
||||||
|
// (o1 before o2 → o2 on top); raising o1's z draws it above o2; undo restores it.
|
||||||
|
await evaluate(`(() => {
|
||||||
|
const o2 = __gm.state.activeScene.objects.find(o => o.id === 'o2');
|
||||||
|
o2.position.x = 0; o2.position.y = 0;
|
||||||
|
__gm.state.preview();
|
||||||
|
})()`);
|
||||||
|
await sleep(200);
|
||||||
|
const orderBefore = await evaluate(`JSON.stringify(__gm.drawOrder().filter(id => id === 'o1' || id === 'o2'))`);
|
||||||
|
check('equal z keeps hierarchy order', orderBefore === '["o1","o2"]', orderBefore);
|
||||||
|
await evaluate(`__gm.state.select({ kind: 'object', sceneId: __gm.state.activeScene.id, objectId: 'o1' })`);
|
||||||
|
await sleep(200);
|
||||||
|
const zLabel = await locate('Z');
|
||||||
|
await click(zLabel.x + 160, zLabel.y); // plain click → text editing with everything selected
|
||||||
|
await typeText('5');
|
||||||
|
await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 });
|
||||||
|
await sleep(200);
|
||||||
|
const zNow = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.z`);
|
||||||
|
check('Z field sets position.z', zNow === 5, `z=${zNow}`);
|
||||||
|
const orderAfter = await evaluate(`JSON.stringify(__gm.drawOrder().filter(id => id === 'o1' || id === 'o2'))`);
|
||||||
|
check('higher z is drawn on top', orderAfter === '["o2","o1"]', orderAfter);
|
||||||
|
await shot('14-z-order');
|
||||||
|
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
|
||||||
|
await sleep(200);
|
||||||
|
const zUndone = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.z`);
|
||||||
|
check('undo restores z', zUndone === 0, `z=${zUndone}`);
|
||||||
|
|
||||||
|
// 12. Asset defaults: the Crate asset has height 60 (schema default 100), width and
|
||||||
|
// color at their defaults → exactly one reset button is visible. Clicking it puts
|
||||||
|
// height back to 100 and hides the button; undo brings 60 back.
|
||||||
|
await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'crate' })`);
|
||||||
|
await sleep(200);
|
||||||
|
const resetButton = await evaluate(`__gm.locate('↺')`);
|
||||||
|
check('asset inspector shows a reset for the non-default height', Boolean(resetButton));
|
||||||
|
await click(resetButton.x, resetButton.y);
|
||||||
|
await sleep(200);
|
||||||
|
const heightReset = await evaluate(`__gm.state.project.assets.find(a => a.id === 'crate').props.height`);
|
||||||
|
check('reset puts the asset property back to its default', heightReset === 100, `height=${heightReset}`);
|
||||||
|
check('reset button hidden once the value is at default', !(await evaluate(`__gm.locate('↺')`)));
|
||||||
|
await shot('15-asset-reset');
|
||||||
|
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
|
||||||
|
await sleep(200);
|
||||||
|
const heightUndone = await evaluate(`__gm.state.project.assets.find(a => a.id === 'crate').props.height`);
|
||||||
|
check('undo restores the asset value', heightUndone === 60, `height=${heightUndone}`);
|
||||||
|
check('reset button visible again after undo', Boolean(await evaluate(`__gm.locate('↺')`)));
|
||||||
|
|
||||||
check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
|
check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
|
||||||
ws.close();
|
ws.close();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+3
-3
@@ -22,9 +22,9 @@ const project = {
|
|||||||
name: 'Level 1',
|
name: 'Level 1',
|
||||||
props: { backgroundColor: 0x22252c },
|
props: { backgroundColor: 0x22252c },
|
||||||
objects: [
|
objects: [
|
||||||
{ id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0 }, overrides: {} },
|
{ id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0, z: 0 }, overrides: {} },
|
||||||
{ id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80 }, overrides: { width: 140, color: 0x4488ff } },
|
{ id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80, z: 0 }, overrides: { width: 140, color: 0x4488ff } },
|
||||||
{ id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0 }, overrides: {} },
|
{ id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {} },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ id: 'crate', type: 'sprite', name: 'Crate', props: { width: 100, height: 60, color: 0xe0553f } },
|
{ id: 'crate', type: 'sprite', name: 'Crate', props: { width: 100, height: 60, color: 0xe0553f } },
|
||||||
|
|||||||
Reference in new issue
Block a user