From d4b1ffa051538c0e32f9f97ef7cda9457eebc017 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 7 Sep 2026 11:42:18 +0200 Subject: [PATCH] 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 --- README.md | 10 ++-- src/core/commands/assetCommands.ts | 24 ++++++++++ src/core/commands/sceneObjectCommands.ts | 13 +++--- src/core/model/Properties.ts | 14 ++++++ src/core/model/SceneObject.ts | 10 ++-- src/core/properties/resolve.ts | 11 +++++ src/core/store/serialize.ts | 2 +- src/editor/App.ts | 2 +- src/editor/inspector/fieldFactory.ts | 15 +++--- src/editor/panels/Inspector.ts | 59 ++++++++++++++---------- src/runtime/Entity.ts | 2 + src/runtime/World.ts | 1 + src/viewport/Viewport.ts | 35 +++++++++++--- tests/core/commands.test.ts | 29 +++++++++--- tests/core/resolve.test.ts | 14 +++++- tests/core/serialize.test.ts | 2 +- tools/e2e.mjs | 46 ++++++++++++++++++ tools/seed-demo.mjs | 6 +-- 18 files changed, 232 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index f9dac6a0..9b5ca45c 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ Live: · Widget playground: 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 { readonly label = 'Switch scene'; private previous = ''; diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts index 032b620d..ed846707 100644 --- a/src/core/commands/sceneObjectCommands.ts +++ b/src/core/commands/sceneObjectCommands.ts @@ -1,5 +1,5 @@ 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 { createSceneObject, type SceneObject } from '../model/SceneObject'; import { coerceValue, schemaFor } from '../properties/PropertySchema'; @@ -24,7 +24,7 @@ export class PlaceAssetCommand implements Command { readonly label = 'Place asset'; 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 { 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 { readonly label = 'Move object'; - private previous: Vec2 = { x: 0, y: 0 }; + private previous: Position = { x: 0, y: 0, z: 0 }; constructor( private readonly sceneId: string, private readonly objectId: string, - private readonly position: Vec2, - previous?: Vec2, + private readonly position: Position, + previous?: Position, ) { if (previous) this.previous = { ...previous }; } @@ -76,7 +77,7 @@ export class MoveSceneObjectCommand implements Command { execute(project: Project): void { const object = requireObject(project, this.sceneId, this.objectId); 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 { diff --git a/src/core/model/Properties.ts b/src/core/model/Properties.ts index 23d976c1..84633cca 100644 --- a/src/core/model/Properties.ts +++ b/src/core/model/Properties.ts @@ -4,7 +4,21 @@ export type PropertyValue = number | string | boolean; /** Sparse map of property key → value (asset properties or scene-object overrides). */ export type PropertyBag = Record; +/** A point in world or screen space. */ export interface Vec2 { x: 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(items: readonly T[]): T[] { + return [...items].sort((a, b) => a.z - b.z); +} diff --git a/src/core/model/SceneObject.ts b/src/core/model/SceneObject.ts index 37951d04..8bc5f450 100644 --- a/src/core/model/SceneObject.ts +++ b/src/core/model/SceneObject.ts @@ -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 @@ -10,10 +10,12 @@ export interface SceneObject { assetId: string; /** Optional display name; falls back to the asset name when empty. */ name: string; - position: Vec2; + /** World position plus draw order (`z`). */ + position: Position; overrides: PropertyBag; } -export function createSceneObject(id: string, assetId: string, position: Vec2): SceneObject { - return { id, assetId, name: '', position: { x: position.x, y: position.y }, overrides: {} }; +/** Creates a scene object; `z` defaults to 0 when the caller only has a 2D point. */ +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: {} }; } diff --git a/src/core/properties/resolve.ts b/src/core/properties/resolve.ts index 76de0377..82d1603a 100644 --- a/src/core/properties/resolve.ts +++ b/src/core/properties/resolve.ts @@ -33,6 +33,17 @@ export function isOverridden(object: SceneObject, key: string): boolean { 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). */ export function inheritedValue(asset: Asset, key: string): PropertyValue | undefined { return resolveAssetProps(asset)[key]; diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 834a1764..73133917 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -67,7 +67,7 @@ function normalizeObjects(raw: unknown, types: Map): SceneObj id: o.id, assetId: o.assetId, 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), }); } diff --git a/src/editor/App.ts b/src/editor/App.ts index 94872659..88bcfd57 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -50,7 +50,7 @@ function installDebugHook(app: Application, state: EditorState, viewport: Viewpo const p = node.getGlobalPosition(); 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 { diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 0bbfadd7..8fba5aac 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -13,8 +13,11 @@ export interface FieldRow { row: Widget; /** Push a model value into the widget (no events fired). */ 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 { row, setValue, - setOverridden(overridden) { - label.setStyle(overridden ? 'bodyBold' : 'muted'); - if (reset && reset.visible !== overridden) { - reset.visible = overridden; + setModified(modified) { + label.setStyle(modified ? 'bodyBold' : 'muted'); + if (reset && reset.visible !== modified) { + reset.visible = modified; row.relayout(); } }, diff --git a/src/editor/panels/Inspector.ts b/src/editor/panels/Inspector.ts index 97082f18..b0c498b1 100644 --- a/src/editor/panels/Inspector.ts +++ b/src/editor/panels/Inspector.ts @@ -1,14 +1,14 @@ -import { RenameAssetCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands'; +import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands'; import { ClearOverrideCommand, MoveSceneObjectCommand, RenameSceneObjectCommand, SetOverrideCommand, } 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 { 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 { createFieldRow, type FieldRow } from '../inspector/fieldFactory'; import type { EditorState, Selection } from '../state'; @@ -66,25 +66,32 @@ export class Inspector extends Panel { }); 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); for (const def of schemaFor(asset.type)) { let original: PropertyValue | undefined; - const row = createFieldRow(def, values[def.key] ?? def.default, { - onInput: (value) => { - const target = findAsset(this.state.project, assetId); - if (!target) return; - if (original === undefined) original = target.props[def.key]; - target.props[def.key] = value; - this.state.preview(); + const row = createFieldRow( + def, + values[def.key] ?? def.default, + { + onInput: (value) => { + const target = findAsset(this.state.project, assetId); + if (!target) return; + if (original === undefined) original = target.props[def.key]; + target.props[def.key] = value; + this.state.preview(); + }, + onChange: (value) => { + const target = findAsset(this.state.project, assetId); + if (!target) return; + if (original !== undefined) target.props[def.key] = original; + original = undefined; + this.state.execute(new SetAssetPropertyCommand(assetId, def.key, value)); + }, }, - onChange: (value) => { - const target = findAsset(this.state.project, assetId); - if (!target) return; - if (original !== undefined) target.props[def.key] = original; - original = undefined; - 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.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); 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' })); - for (const axis of ['x', 'y'] as const) { - let original: { x: number; y: number } | undefined; + 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], { onInput: (value) => { const target = this.currentObject(sceneId, objectId); @@ -169,7 +176,7 @@ export class Inspector extends Panel { }, () => 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.body.add(row.row); } @@ -188,7 +195,10 @@ export class Inspector extends Panel { if (!asset) return this.rebuild(); this.nameField?.setValue(asset.name); 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; } const object = this.currentObject(s.sceneId, s.objectId); @@ -197,11 +207,12 @@ export class Inspector extends Panel { 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 schemaFor(asset.type)) { const row = this.fields.get(def.key); row?.setValue(values[def.key] ?? def.default); - row?.setOverridden(isOverridden(object, def.key)); + row?.setModified(isOverridden(object, def.key)); } } diff --git a/src/runtime/Entity.ts b/src/runtime/Entity.ts index 331ce70d..97f4a10d 100644 --- a/src/runtime/Entity.ts +++ b/src/runtime/Entity.ts @@ -15,5 +15,7 @@ export interface Entity { name: string; x: number; y: number; + /** Draw order; higher is drawn on top. */ + z: number; props: PropertyBag; } diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 3149804f..3d5db1fc 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -34,6 +34,7 @@ export class World { name: object.name || asset.name, x: object.position.x, y: object.position.y, + z: object.position.z, props: resolveSceneObjectProps(asset, object), }); } diff --git a/src/viewport/Viewport.ts b/src/viewport/Viewport.ts index e589d60f..6a635de7 100644 --- a/src/viewport/Viewport.ts +++ b/src/viewport/Viewport.ts @@ -1,7 +1,7 @@ import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands'; 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 { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera'; import { resolveSceneObjectProps } from '../core/properties/resolve'; @@ -16,6 +16,8 @@ interface DrawItem { name: string; x: number; y: number; + /** Draw order (see `Position.z`). */ + z: number; 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`. */ private readonly content = 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(); /** * 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. */ private view = { x: 0, y: 0, zoom: 1 }; 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; constructor(private readonly state: EditorState) { @@ -95,7 +97,7 @@ export class Viewport extends Widget { this.dragging.moved = true; const object = this.state.activeScene?.objects.find((o) => o.id === this.dragging?.objectId); 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(); } }); @@ -154,6 +156,11 @@ export class Viewport extends Widget { 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 { 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) { return { 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; @@ -208,7 +215,7 @@ export class Viewport extends Widget { for (const object of scene.objects) { const asset = findAsset(this.state.project, object.assetId); 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) }; } @@ -250,6 +257,7 @@ export class Viewport extends Widget { let node = this.nodes.get(item.id); if (!node) { node = new Graphics(); + node.label = item.id; node.cursor = 'move'; node.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e)); this.nodes.set(item.id, node); @@ -278,10 +286,25 @@ export class Viewport extends Widget { this.labels.delete(id); } } + this.syncDrawOrder(items); 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. */ private isSymbol(item: DrawItem): boolean { return item.type === 'camera'; diff --git a/tests/core/commands.test.ts b/tests/core/commands.test.ts index b6422744..2538cf3e 100644 --- a/tests/core/commands.test.ts +++ b/tests/core/commands.test.ts @@ -3,6 +3,7 @@ import { CreateAssetCommand, DeleteAssetCommand, RenameAssetCommand, + ResetAssetPropertyCommand, SetActiveSceneCommand, SetAssetPropertyCommand, } from '../../src/core/commands/assetCommands'; @@ -54,6 +55,19 @@ describe('asset commands', () => { it('rejects unknown properties', () => { const { stack, spriteId } = setup(); 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', () => { @@ -67,8 +81,8 @@ describe('asset commands', () => { stack.undo(); expect(project.assets).toHaveLength(2); expect(findScene(project, sceneId)!.objects.map((o) => o.position)).toEqual([ - { x: 1, y: 2 }, - { x: 3, y: 4 }, + { x: 1, y: 2, z: 0 }, + { x: 3, y: 4, z: 0 }, ]); }); @@ -92,7 +106,7 @@ describe('scene object commands', () => { const object = findScene(project, sceneId)!.objects[0]!; expect(object.id).toBe(place.objectId); 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(() => 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); const object = () => findScene(project, sceneId)!.objects.find((o) => o.id === place.objectId)!; - stack.execute(new MoveSceneObjectCommand(sceneId, place.objectId, { x: 40, y: 50 })); - expect(object().position).toEqual({ 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, 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)); expect(object().overrides).toEqual({ color: 0x123456 }); @@ -120,7 +137,7 @@ describe('scene object commands', () => { stack.execute(new RemoveSceneObjectCommand(sceneId, place.objectId)); expect(findScene(project, sceneId)!.objects).toHaveLength(0); 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', () => { diff --git a/tests/core/resolve.test.ts b/tests/core/resolve.test.ts index c75cbe03..f5ec1106 100644 --- a/tests/core/resolve.test.ts +++ b/tests/core/resolve.test.ts @@ -2,7 +2,7 @@ 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, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../src/core/properties/resolve'; +import { inheritedValue, isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../src/core/properties/resolve'; describe('PropertySchema', () => { it('has no position on any asset type', () => { @@ -49,10 +49,20 @@ describe('resolve', () => { 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', () => { const sprite = buildAsset('sprite', 'Box'); 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(resolveSceneObjectProps(sprite, object)).not.toHaveProperty('x'); }); diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts index a1a1946e..724566d1 100644 --- a/tests/core/serialize.test.ts +++ b/tests/core/serialize.test.ts @@ -50,7 +50,7 @@ describe('serialize', () => { id: 'o1', assetId: 'sp1', name: '', - position: { x: 5, y: 0 }, + position: { x: 5, y: 0, z: 0 }, overrides: { color: 0x00ff00, width: 1 }, }); expect(project.assets[1]!.name).toBe('sprite'); diff --git a/tools/e2e.mjs b/tools/e2e.mjs index 9c955f03..714f61dc 100644 --- a/tools/e2e.mjs +++ b/tools/e2e.mjs @@ -284,6 +284,52 @@ try { check('camera symbol is drawn in the overlay layer above sprites', layers === '["overlay","objects","objects"]', layers); 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(' | ')); ws.close(); } catch (error) { diff --git a/tools/seed-demo.mjs b/tools/seed-demo.mjs index fc64f55d..20846e7c 100644 --- a/tools/seed-demo.mjs +++ b/tools/seed-demo.mjs @@ -22,9 +22,9 @@ const project = { name: 'Level 1', props: { backgroundColor: 0x22252c }, objects: [ - { id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0 }, overrides: {} }, - { id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80 }, overrides: { width: 140, color: 0x4488ff } }, - { id: 'o3', assetId: 'cam', name: '', position: { x: 0, 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, z: 0 }, overrides: { width: 140, color: 0x4488ff } }, + { 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 } },