diff --git a/README.md b/README.md index a7b7e891..bb24e0df 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1397 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1398 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index d0c75fc2..e5b51085 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -25,14 +25,14 @@ import { } from '../model/Scope'; import type { SceneObject } from '../model/SceneObject'; import { isTextureFile } from '../model/texture'; -import { coerceValue, defaultsFor } from '../properties/PropertySchema'; +import { coerceValue, defaultsFor, initialOf, initialPropsFor } from '../properties/PropertySchema'; import { type Removed, removeById, removeFirst, restoreAt } from '../util/list'; import { type Command, CommandError } from './Command'; import { requireAsset, requirePropDef, requireStateMachine } from './require'; import { SetBagValueCommand } from './SetBagValueCommand'; export function buildAsset(type: AssetType, name: string, id = newId()): Asset { - const base = { id, name, props: defaultsFor(type) }; + const base = { id, name, props: initialPropsFor(type) }; switch (type) { case 'texture': return buildTextureAsset(name, { file: '', width: 0, height: 0 }, id); @@ -436,7 +436,7 @@ export class ResetAssetPropertyCommand extends AssetPropertyCommand { } protected override computeNext(asset: Asset, project: Project): PropertyValue { - return requirePropDef(project, asset, this.key).default; + return initialOf(requirePropDef(project, asset, this.key)); } } diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index d9330790..92f3d9b1 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -91,9 +91,22 @@ type Extras = K extends 'number' * (see `isRowShown`), everything else – graph, defaults, files – always has it. */ export type FlatDef = { - [P in K]: { key: string; label: string; kind: P; default: ValueOf

; shownWith?: string } & Extras

; + [P in K]: { + key: string; + label: string; + kind: P; + default: ValueOf

; + /** What a new asset starts with when it differs from `default`, the value a missing key reads as. */ + initial?: ValueOf

; + shownWith?: string; + } & Extras

; }[K]; +/** The value a new asset starts with, which is also what Reset goes back to. */ +export function initialOf(def: PropertyDef): PropertyValue { + return 'initial' in def && def.initial !== undefined ? def.initial : def.default; +} + /** Whether the inspector shows the row, given the values of the bag it is on. */ export function isRowShown(def: PropertyDef, values: PropertyBag): boolean { return !('shownWith' in def) || def.shownWith === undefined || values[def.shownWith] === true; @@ -233,9 +246,30 @@ export function isTransformProp(key: string): boolean { return TRANSFORM_KEYS.has(key); } -const COMMON_KEYS = new Set([...COMMON_PROPS, ...TRANSFORM_PROPS].map((d) => d.key)); +export const SORT_WITH_PARENT_KEY = 'sortWithParent'; -/** The switch and the transform rows: reserved names, shown apart from the type's own properties. */ +/** + * Whether a child is drawn in its parent's place instead of on its own (#232, see `drawSlots`). + * A project stored before it reads as off, so it draws as it did; a new asset starts with it on. + */ +export const SORT_PROPS: PropertySchema = [ + { key: SORT_WITH_PARENT_KEY, label: 'Sort with parent', kind: 'boolean', default: false, initial: true }, +]; + +/** A tilemap has no single place in the draw order, and the other types are not drawn at all. */ +const SORT_TYPES: ReadonlySet = new Set(['sprite', 'text', 'particles']); + +export function hasSortProps(type: AssetType): boolean { + return SORT_TYPES.has(type); +} + +export function isSortProp(key: string): boolean { + return key === SORT_WITH_PARENT_KEY; +} + +const COMMON_KEYS = new Set([...COMMON_PROPS, ...TRANSFORM_PROPS, ...SORT_PROPS].map((d) => d.key)); + +/** The switches, the transform and sort rows: reserved names, shown apart from the type's own properties. */ export function isCommonProp(key: string): boolean { return COMMON_KEYS.has(key); } @@ -585,7 +619,12 @@ const FULL_SCHEMAS: Record = Object.fromEntries( (Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [ type, isPlaceableType(type) - ? [...COMMON_PROPS, ...(hasTransformProps(type) ? TRANSFORM_PROPS : []), ...ASSET_SCHEMAS[type]] + ? [ + ...COMMON_PROPS, + ...(hasTransformProps(type) ? TRANSFORM_PROPS : []), + ...(hasSortProps(type) ? SORT_PROPS : []), + ...ASSET_SCHEMAS[type], + ] : ASSET_SCHEMAS[type], ]), ) as Record; @@ -686,6 +725,13 @@ export function defaultsOf(schema: PropertySchema): PropertyBag { return bag; } +/** The bag a new asset of the type is created with. */ +export function initialPropsFor(type: AssetType): PropertyBag { + const bag: PropertyBag = {}; + for (const def of FULL_SCHEMAS[type]) bag[def.key] = initialOf(def); + return bag; +} + export function variableDef( key: string, kind: VariableKind, diff --git a/src/core/properties/resolve.ts b/src/core/properties/resolve.ts index 9b325d93..5b5c2a88 100644 --- a/src/core/properties/resolve.ts +++ b/src/core/properties/resolve.ts @@ -2,7 +2,7 @@ import type { Asset } from '../model/Asset'; import { type PropertyBag, type PropertyValue, sameValue } from '../model/Properties'; import type { Project } from '../model/Project'; import type { SceneObject } from '../model/SceneObject'; -import { propDef, schemaForAsset, schemaForObject } from './PropertySchema'; +import { initialOf, propDef, schemaForAsset, schemaForObject } from './PropertySchema'; /** * Schema defaults filled with the asset's own values. Keys unknown to the schema are ignored, so @@ -38,12 +38,12 @@ function valuesOver( return bag; } -/** Whether the asset's own value differs from the schema default – the asset-level override. */ +/** Whether the asset's own value differs from what a new asset starts with – the asset-level override. */ export function isModified(project: Project, asset: Asset, key: string): boolean { const def = propDef(project, asset, key); if (!def) return false; const own = asset.props[key]; - return own !== undefined && !sameValue(own, def.default); + return own !== undefined && !sameValue(own, initialOf(def)); } /** The value the object would have without its override for `key`. */ diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 03076506..8999d599 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -32,6 +32,7 @@ import { COMMON_PROPS, isCommonProp, isRowShown, + isSortProp, isTransformProp, type PropertyDef, schemaFor, @@ -180,6 +181,9 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object ), }); } + // Right under Z, which it changes the meaning of. + for (const def of schemaForObject(project, asset, object).filter((d) => isSortProp(d.key))) + propertyRow(host, holderId, objectId, asset, def, resolved); // Turn, size and mirror sit with the position: together they are where the object is. const transform = schemaForObject(project, asset, object).filter((def) => isTransformProp(def.key)); diff --git a/src/viewport/drawOrder.ts b/src/viewport/drawOrder.ts index 9f005aca..e3fb2af4 100644 --- a/src/viewport/drawOrder.ts +++ b/src/viewport/drawOrder.ts @@ -2,6 +2,7 @@ import { isSymbolType } from '../core/model/AssetTypes'; import { sortByZ } from '../core/model/Properties'; import { cellOrigin, type TileDraw, tilemapTileSize } from '../core/model/tilemap'; import { footprintExtent } from '../core/properties/bounds'; +import { SORT_WITH_PARENT_KEY } from '../core/properties/PropertySchema'; import type { DrawItem, TilemapDraw, TilemapLayerDraw } from './items'; /** @@ -52,11 +53,15 @@ interface Candidate extends DrawSlot { * (#228): an object by the bottom of its box or by its frame's sort line, a row of the layer by the * bottom of the row. Ties keep the order above. A layer drawn as one piece and the map's own node * stay behind them, in that order, since neither has a single place to stand. + * + * A child that sorts with its parent takes no part in any of this: it is drawn in its parent's + * place, and its `z` only orders it against the parent and their siblings (#232). */ export function drawSlots(items: readonly DrawItem[]): DrawSlot[] { + const groups = parentGroups(items); const slots: Candidate[] = []; for (const item of items) { - if (isSymbolType(item.type)) continue; + if (isSymbolType(item.type) || !groups.has(item.id)) continue; slots.push({ id: item.id, layerId: '', row: null, z: item.z, item }); for (const layer of item.tilemap?.layers ?? []) { const z = item.z + layer.z; @@ -83,7 +88,43 @@ export function drawSlots(items: readonly DrawItem[]): DrawSlot[] { } start = end; } - return order.map(({ id, layerId, row, z }) => ({ id, layerId, row, z })); + return order.flatMap(({ id, layerId, row, z }) => + layerId === '' && row === null + ? groups.get(id)!.map((member) => ({ id: member.id, layerId, row, z: member.z })) + : [{ id, layerId, row, z }], + ); +} + +/** + * Every object that is sorted into the scene on its own, with what is drawn in its place (#232): + * itself and the children that sort with it, at any depth, back to front by their `z` above its + * own – ties in hierarchy order, so the parent comes before a child on its `z`. An object that + * sorts with its parent is no key here. + */ +function parentGroups(items: readonly DrawItem[]): Map { + const drawn = new Map(items.filter((item) => !isSymbolType(item.type)).map((item) => [item.id, item])); + const leaderOf = (item: DrawItem): DrawItem => { + let leader = item; + for (let parent = drawn.get(leader.parentId); parent && joinsParent(leader, parent);) { + leader = parent; + parent = drawn.get(leader.parentId); + } + return leader; + }; + const groups = new Map(); + for (const item of drawn.values()) { + const leader = leaderOf(item); + const group = groups.get(leader.id); + if (group) group.push(item); + else groups.set(leader.id, [item]); + } + for (const group of groups.values()) group.sort((a, b) => a.z - b.z); + return groups; +} + +/** A map has no single place to share, neither as the child nor as the parent. */ +function joinsParent(child: DrawItem, parent: DrawItem): boolean { + return child.props[SORT_WITH_PARENT_KEY] === true && child.type !== 'tilemap' && parent.type !== 'tilemap'; } /** A row of a layer sorted by Y, or an object other than a map. */ diff --git a/tests/core/commands.test.ts b/tests/core/commands.test.ts index 9d64c7de..ba36b56b 100644 --- a/tests/core/commands.test.ts +++ b/tests/core/commands.test.ts @@ -28,6 +28,7 @@ describe('asset commands', () => { rotation: 0, mirrorX: false, mirrorY: false, + sortWithParent: true, width: 100, height: 100, color: 0xffffff, diff --git a/tests/core/resolve.test.ts b/tests/core/resolve.test.ts index 46a7e304..0efc3386 100644 --- a/tests/core/resolve.test.ts +++ b/tests/core/resolve.test.ts @@ -29,6 +29,7 @@ describe('PropertySchema', () => { rotation: 0, mirrorX: false, mirrorY: false, + sortWithParent: false, width: 100, height: 100, color: 0xffffff, @@ -59,6 +60,7 @@ describe('resolve', () => { rotation: 0, mirrorX: false, mirrorY: false, + sortWithParent: false, width: 50, height: 100, color: 0xffffff, @@ -77,6 +79,7 @@ describe('resolve', () => { rotation: 0, mirrorX: false, mirrorY: false, + sortWithParent: true, width: 100, height: 100, color: 0x00ff00, diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts index fd08acb3..1c0f1e1d 100644 --- a/tests/core/serialize.test.ts +++ b/tests/core/serialize.test.ts @@ -177,6 +177,7 @@ describe('serialize', () => { rotation: 0, mirrorX: false, mirrorY: false, + sortWithParent: false, width: 10, height: 100, color: 0xffffff, diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index 7900f65a..2b8bdfd4 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -612,6 +612,7 @@ describe('a sprite wearing a shader', () => { 'rotation', 'mirrorX', 'mirrorY', + 'sortWithParent', 'width', 'height', 'color', @@ -630,6 +631,7 @@ describe('a sprite wearing a shader', () => { 'rotation', 'mirrorX', 'mirrorY', + 'sortWithParent', 'width', 'height', 'color', @@ -642,14 +644,14 @@ describe('a sprite wearing a shader', () => { expect(assetDeviates(f.project, f.sprite())).toBe(true); // A dangling id adds no rows and draws nothing – the picker still shows it. f.sprite().props[SHADER_KEY] = 'gone'; - expect(keys()).toHaveLength(10); + expect(keys()).toHaveLength(11); }); it('gives a placed object the rows of the shader it overrides, filled with the node defaults', () => { const f = wearing(); const id = place(f, f.spriteId, { x: 0, y: 0 }); const object = () => f.object(id); - expect(schemaForObject(f.project, f.sprite(), object())).toHaveLength(10); + expect(schemaForObject(f.project, f.sprite(), object())).toHaveLength(11); f.stack.execute(new SetOverrideCommand(f.sceneId, id, SHADER_KEY, f.s.id)); expect(schemaForObject(f.project, f.sprite(), object()).map((d) => d.key)).toContain('saturation'); expect(resolveSceneObjectProps(f.project, f.sprite(), object())).toMatchObject({ diff --git a/tests/core/texture-commands.test.ts b/tests/core/texture-commands.test.ts index 1540eaeb..59976e67 100644 --- a/tests/core/texture-commands.test.ts +++ b/tests/core/texture-commands.test.ts @@ -67,6 +67,7 @@ describe('CreateAssetCommand with starting props', () => { rotation: 0, mirrorX: false, mirrorY: false, + sortWithParent: true, width: 64, height: 32, color: 0xffffff, diff --git a/tests/viewport/sort-with-parent.test.ts b/tests/viewport/sort-with-parent.test.ts new file mode 100644 index 00000000..ca613f09 --- /dev/null +++ b/tests/viewport/sort-with-parent.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest'; +import { + CreateAssetCommand, + ResetAssetPropertyCommand, + SetAssetPropertyCommand, +} from '../../src/core/commands/assetCommands'; +import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { + PaintTilesCommand, + RenameTilemapLayerCommand, + SetTilemapLayerSortByYCommand, +} from '../../src/core/commands/tilemapCommands'; +import type { TilemapAsset } from '../../src/core/model/Asset'; +import type { AssetType } from '../../src/core/model/AssetTypes'; +import { findAsset } from '../../src/core/model/Project'; +import { schemaFor, SORT_WITH_PARENT_KEY } from '../../src/core/properties/PropertySchema'; +import { isModified, resolveAssetProps } from '../../src/core/properties/resolve'; +import { drawSlots } from '../../src/viewport/drawOrder'; +import { collectHolderItems, collectWorldItems } from '../../src/viewport/items'; +import { stackAt } from '../../src/viewport/pick'; +import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers'; + +/** "Box" (the player, 100 units tall) and "Weapon", its child, two sprite assets of their own. */ +function makeScene() { + const f = makeSpriteScene('Box'); + const names = new Map(); + const asset = (type: AssetType, name: string) => { + const created = new CreateAssetCommand(type, name); + f.stack.execute(created); + return created.asset.id; + }; + const weaponId = asset('sprite', 'Weapon'); + const put = (name: string, assetId: string, at: { x: number; y: number; z: number }, parentId = '') => { + const id = place(f, assetId, at, parentId); + names.set(id, name); + return id; + }; + const sortWithParent = (assetId: string, on: boolean) => + f.stack.execute(new SetAssetPropertyCommand(assetId, SORT_WITH_PARENT_KEY, on)); + const order = () => + drawSlots(collectHolderItems(f.project, f.sceneOf(), 0).items).map((s) => + s.layerId ? `${names.get(s.id)} ${s.row ?? ''}`.trim() : names.get(s.id)!, + ); + return { f, names, asset, weaponId, put, sortWithParent, order }; +} + +describe('a child sorted with its parent (#232)', () => { + it('is on for a new sprite, text and source, off for one stored without it', () => { + const s = makeScene(); + for (const type of ['sprite', 'text', 'particles'] as const) { + const id = s.asset(type, type); + expect(findAsset(s.f.project, id)!.props[SORT_WITH_PARENT_KEY], type).toBe(true); + expect( + schemaFor(type).some((d) => d.key === SORT_WITH_PARENT_KEY), + type, + ).toBe(true); + } + for (const type of ['tilemap', 'container', 'camera', 'collider', 'behavior'] as const) + expect( + schemaFor(type).some((d) => d.key === SORT_WITH_PARENT_KEY), + type, + ).toBe(false); + + const weapon = findAsset(s.f.project, s.weaponId)!; + delete weapon.props[SORT_WITH_PARENT_KEY]; + const loaded = findAsset(roundTrip(s.f.project), s.weaponId)!; + expect(resolveAssetProps(s.f.project, loaded)[SORT_WITH_PARENT_KEY]).toBe(false); + }); + + it('marks an asset only once it is switched off, and Reset switches it on again', () => { + const s = makeScene(); + const modified = () => isModified(s.f.project, findAsset(s.f.project, s.weaponId)!, SORT_WITH_PARENT_KEY); + expect(modified()).toBe(false); + s.sortWithParent(s.weaponId, false); + expect(modified()).toBe(true); + s.f.stack.execute(new ResetAssetPropertyCommand(s.weaponId, SORT_WITH_PARENT_KEY)); + expect(findAsset(s.f.project, s.weaponId)!.props[SORT_WITH_PARENT_KEY]).toBe(true); + expect(modified()).toBe(false); + delete findAsset(s.f.project, s.weaponId)!.props[SORT_WITH_PARENT_KEY]; + expect(modified()).toBe(false); + }); + + it('keeps a child below 0 right behind its parent instead of behind everything on 0', () => { + const s = makeScene(); + s.put('other', s.f.spriteId, { x: 300, y: 0, z: 0 }); + const box = s.put('box', s.f.spriteId, { x: 0, y: 0, z: 0 }); + s.put('weapon', s.weaponId, { x: 0, y: 0, z: -1 }, box); + expect(s.order()).toEqual(['other', 'weapon', 'box']); + s.sortWithParent(s.weaponId, false); + expect(s.order()).toEqual(['weapon', 'other', 'box']); + }); + + it('keeps a child above 0 right in front of its parent, below what is in front of the parent', () => { + const s = makeScene(); + const box = s.put('box', s.f.spriteId, { x: 0, y: 0, z: 0 }); + s.put('weapon', s.weaponId, { x: 0, y: 0, z: 2 }, box); + s.put('front', s.f.spriteId, { x: 300, y: 0, z: 1 }); + expect(s.order()).toEqual(['box', 'weapon', 'front']); + s.sortWithParent(s.weaponId, false); + expect(s.order()).toEqual(['box', 'front', 'weapon']); + }); + + it('orders the children of one parent by their own z, ties in hierarchy order', () => { + const s = makeScene(); + const box = s.put('box', s.f.spriteId, { x: 0, y: 0, z: 5 }); + s.put('a', s.weaponId, { x: 0, y: 0, z: 6 }, box); + s.put('b', s.weaponId, { x: 0, y: 0, z: 3 }, box); + s.put('c', s.weaponId, { x: 0, y: 0, z: 5 }, box); + s.put('d', s.weaponId, { x: 0, y: 0, z: 4 }, box); + s.put('under', s.f.spriteId, { x: 300, y: 0, z: 4.5 }); + expect(s.order()).toEqual(['under', 'b', 'd', 'box', 'c', 'a']); + }); + + it('chains up to the topmost parent, and stops where a link sorts on its own', () => { + const s = makeScene(); + const hand = s.asset('sprite', 'Hand'); + s.put('other', s.f.spriteId, { x: 300, y: 0, z: 0 }); + const box = s.put('box', s.f.spriteId, { x: 0, y: 0, z: 0 }); + const handId = s.put('hand', hand, { x: 0, y: 0, z: 0 }, box); + s.put('weapon', s.weaponId, { x: 0, y: 0, z: -1 }, handId); + expect(s.order()).toEqual(['other', 'weapon', 'box', 'hand']); + s.sortWithParent(hand, false); + // The hand is sorted on its own now; the weapon still sorts with it, behind it and not behind all. + expect(s.order()).toEqual(['other', 'box', 'weapon', 'hand']); + }); + + it('can be switched per placed object', () => { + const s = makeScene(); + s.put('other', s.f.spriteId, { x: 300, y: 0, z: 0 }); + const box = s.put('box', s.f.spriteId, { x: 0, y: 0, z: 0 }); + const weapon = s.put('weapon', s.weaponId, { x: 0, y: 0, z: -1 }, box); + s.f.stack.execute(new SetOverrideCommand(s.f.sceneId, weapon, SORT_WITH_PARENT_KEY, false)); + expect(s.order()).toEqual(['weapon', 'other', 'box']); + s.f.stack.undo(); + expect(s.order()).toEqual(['other', 'weapon', 'box']); + }); + + it('does nothing under a parent that is not drawn or is a tilemap', () => { + const s = makeScene(); + const map = s.asset('tilemap', 'Map'); + const group = s.asset('container', 'Group'); + s.put('other', s.f.spriteId, { x: 300, y: 0, z: 0 }); + const mapObject = s.put('map', map, { x: 0, y: 0, z: 0 }); + const groupObject = s.put('group', group, { x: 0, y: 0, z: 0 }); + s.put('underMap', s.weaponId, { x: 0, y: 0, z: -1 }, mapObject); + s.put('underGroup', s.weaponId, { x: 0, y: 0, z: -2 }, groupObject); + // The map comes twice: its own node, then its one layer drawn as a piece. + expect(s.order()).toEqual(['underGroup', 'underMap', 'other', 'map', 'map']); + }); + + /** + * A 20 × 15 map of 48-unit cells at the origin with "Trees" sorted by Y: a trunk on row 5 (ends at + * -72) and one on row 8 (ends at 72). The player stands with its feet at -50, between the two. + */ + function onSortedMap() { + const s = makeScene(); + const mapId = s.asset('tilemap', 'Forest'); + const trees = (findAsset(s.f.project, mapId) as TilemapAsset).layers[0]!.id; + s.f.stack.execute(new RenameTilemapLayerCommand(mapId, trees, 'Trees')); + const trunk = s.asset('tile', 'Trunk'); + s.f.stack.execute( + new PaintTilesCommand(mapId, trees, [ + { x: 2, y: 5, tileId: trunk }, + { x: 7, y: 8, tileId: trunk }, + ]), + ); + s.f.stack.execute(new SetTilemapLayerSortByYCommand(mapId, trees, true)); + s.put('map', mapId, { x: 0, y: 0, z: 0 }); + const box = s.put('box', s.f.spriteId, { x: 0, y: -100, z: 0 }); + return { ...s, box }; + } + + it("stands on its parent's feet on a z sorted by Y", () => { + const behind = onSortedMap(); + behind.put('weapon', behind.weaponId, { x: 0, y: -100, z: -1 }, behind.box); + expect(behind.order()).toEqual(['map', 'map 5', 'weapon', 'box', 'map 8']); + behind.sortWithParent(behind.weaponId, false); + expect(behind.order()).toEqual(['weapon', 'map', 'map 5', 'box', 'map 8']); + + // Held high above the head its own box ends above row 5, but it is drawn with the player. + const raised = onSortedMap(); + raised.put('weapon', raised.weaponId, { x: 0, y: -200, z: 0 }, raised.box); + expect(raised.order()).toEqual(['map', 'map 5', 'box', 'weapon', 'map 8']); + raised.sortWithParent(raised.weaponId, false); + expect(raised.order()).toEqual(['map', 'weapon', 'map 5', 'box', 'map 8']); + }); + + it('holds in the running game and when picking', () => { + const s = makeScene(); + s.put('other', s.f.spriteId, { x: 0, y: 0, z: 0 }); + const box = s.put('box', s.f.spriteId, { x: 0, y: 0, z: 0 }); + s.put('weapon', s.weaponId, { x: 0, y: 0, z: -1 }, box); + const { world } = startWorld(s.f); + expect(drawSlots(collectWorldItems(world).items).map((slot) => s.names.get(slot.id))).toEqual([ + 'other', + 'weapon', + 'box', + ]); + const items = collectHolderItems(s.f.project, s.f.sceneOf(), 0).items; + const hits = [...s.names.keys()].map((id) => ({ id, layers: null })); + expect(stackAt(items, hits).map((id) => s.names.get(id))).toEqual(['box', 'weapon', 'other']); + }); +}); diff --git a/tools/e2e/05-drawing.mjs b/tools/e2e/05-drawing.mjs index 7438b895..840f657c 100644 --- a/tools/e2e/05-drawing.mjs +++ b/tools/e2e/05-drawing.mjs @@ -51,6 +51,40 @@ export async function drawing(t) { await undo(); check('undo restores z', (await object('o1')).position.z === 0, `z=${(await object('o1')).position.z}`); + // 5b-ii. Sort with parent (#232): o2 hangs under o1 at z -1, and a third crate stands before o1 + // in the hierarchy. The fixture's crate is stored without the switch, so o2 first drops + // behind everything on 0; switched on in the inspector it is drawn right behind o1. Undo, + // and the scene is put back for the blocks below. + await mutate(` + const objs = __gm.state.activeScene.objects; + const o2 = objs.find(o => o.id === 'o2'); + o2.parentId = 'o1'; o2.position.z = -1; + objs.splice(objs.findIndex(o => o.id === 'o1'), 0, { id: 'o-sort', assetId: 'crate', name: '', position: { x: 0, y: 0, z: 0 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} });`); + const sortOrder = () => json(`__gm.drawOrder().filter(id => ['o-sort', 'o1', 'o2'].includes(id))`); + const orderOff = await sortOrder(); + await select({ kind: 'object', holderId: 'scene1', objectId: 'o2' }); + const sortRow = await json("__gm.inspectorField('Sort with parent')"); + if (sortRow) await click(sortRow.x, sortRow.y); + const sortOverride = (await object('o2')).overrides.sortWithParent; + const orderOn = await sortOrder(); + await undo(); + const orderUndone = await sortOrder(); + check( + 'Sort with parent draws a child below 0 right behind its parent, and undo takes it back', + JSON.stringify(orderOff) === '["o2","o-sort","o1"]' && + sortOverride === true && + JSON.stringify(orderOn) === '["o-sort","o2","o1"]' && + JSON.stringify(orderUndone) === JSON.stringify(orderOff), + JSON.stringify({ sortRow, orderOff, sortOverride, orderOn, orderUndone }), + ); + await shot('14b-sort-with-parent'); + await mutate(` + const objs = __gm.state.activeScene.objects; + const o2 = objs.find(o => o.id === 'o2'); + o2.parentId = ''; o2.position.z = 0; + objs.splice(objs.findIndex(o => o.id === 'o-sort'), 1); + __gm.state.select(null)`); + // 5c. The symbols are the asset panel's icons, white in a dark outline and without a plate // behind them: one of each in a lane of its own, the pixels read on a grid over each icon // (±12 world units round its centre). Each must show white and the outline, and none of the