Let a child sort with its parent so its Z stays local (#232)
game-maker / gate (push) Successful in 3m7s
game-maker / suite (push) Failing after 8m51s

An animated or set Z of -1 on a weapon put it behind everything on Z 0,
not just behind the player holding it. A child can now opt in to being
drawn at its parent's place, so its own Z only orders it against the
parent and its siblings.

- New property "Sort with parent" on sprites, texts and particle
  emitters, shown in the inspector right under Z. It is a normal
  property, set on the asset and overridable per object.
- With it on, the rest of the scene sees only the parent's Z and, where
  the layer sorts by Y, the parent's feet. Below 0 the child is drawn
  behind the parent, from 0 on in front.
- It works across levels: a grandchild with the switch hangs on the top
  object of the chain.
- It has no effect under a parent that is not drawn itself (container,
  behavior, camera, collider) or under a tilemap.
- New assets start with it on, saved ones read as off, so old projects
  draw unchanged. For that a schema row now has "initial" next to
  "default". The gold "changed" mark and reset follow "initial", so a
  fresh sprite is not marked as changed. No load migration is needed.
- parentGroups() in drawOrder.ts gives a slot only to the top of a
  chain and inserts the children there. Editor, play mode and picking
  all use it, so they agree.

Tests:
- 10 new unit tests in tests/viewport/sort-with-parent.test.ts.
- 8 existing tests list the sprite rows or defaults and got the row.
- New e2e check 5b-ii clicks the switch and checks order and undo.
- Counter-checks, all originals restored by cmp: switch ignored fails
  7, children not sorted by Z fails 6, no chain fails 1, "initial"
  ignored fails 9, row missing in the inspector fails e2e 5.
- ./check.sh: 166 files, 2426 tests, CHECK EXIT 0.
- e2e 5: ALL PASSED, 13 checks. Check total 1398, computed only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-25 23:08:09 +02:00
1 parent fae827c087
commit 8b44f97c35
13 files changed
+351 -15

No files matched your search

+1 -1
View File
@@ -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: The browser suite and the three example games need a machine with a GPU:
```bash ```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 # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+3 -3
View File
@@ -25,14 +25,14 @@ import {
} from '../model/Scope'; } from '../model/Scope';
import type { SceneObject } from '../model/SceneObject'; import type { SceneObject } from '../model/SceneObject';
import { isTextureFile } from '../model/texture'; 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 Removed, removeById, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command'; import { type Command, CommandError } from './Command';
import { requireAsset, requirePropDef, requireStateMachine } from './require'; import { requireAsset, requirePropDef, requireStateMachine } from './require';
import { SetBagValueCommand } from './SetBagValueCommand'; import { SetBagValueCommand } from './SetBagValueCommand';
export function buildAsset(type: AssetType, name: string, id = newId()): Asset { 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) { switch (type) {
case 'texture': case 'texture':
return buildTextureAsset(name, { file: '', width: 0, height: 0 }, id); 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 { protected override computeNext(asset: Asset, project: Project): PropertyValue {
return requirePropDef(project, asset, this.key).default; return initialOf(requirePropDef(project, asset, this.key));
} }
} }
+50 -4
View File
@@ -91,9 +91,22 @@ type Extras<K extends FlatKind> = K extends 'number'
* (see `isRowShown`), everything else – graph, defaults, files – always has it. * (see `isRowShown`), everything else – graph, defaults, files – always has it.
*/ */
export type FlatDef<K extends FlatKind = FlatKind> = { export type FlatDef<K extends FlatKind = FlatKind> = {
[P in K]: { key: string; label: string; kind: P; default: ValueOf<P>; shownWith?: string } & Extras<P>; [P in K]: {
key: string;
label: string;
kind: P;
default: ValueOf<P>;
/** What a new asset starts with when it differs from `default`, the value a missing key reads as. */
initial?: ValueOf<P>;
shownWith?: string;
} & Extras<P>;
}[K]; }[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. */ /** Whether the inspector shows the row, given the values of the bag it is on. */
export function isRowShown(def: PropertyDef, values: PropertyBag): boolean { export function isRowShown(def: PropertyDef, values: PropertyBag): boolean {
return !('shownWith' in def) || def.shownWith === undefined || values[def.shownWith] === true; 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); 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<AssetType> = 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 { export function isCommonProp(key: string): boolean {
return COMMON_KEYS.has(key); return COMMON_KEYS.has(key);
} }
@@ -585,7 +619,12 @@ const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [ (Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [
type, type,
isPlaceableType(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], : ASSET_SCHEMAS[type],
]), ]),
) as Record<AssetType, PropertySchema>; ) as Record<AssetType, PropertySchema>;
@@ -686,6 +725,13 @@ export function defaultsOf(schema: PropertySchema): PropertyBag {
return bag; 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( export function variableDef(
key: string, key: string,
kind: VariableKind, kind: VariableKind,
+3 -3
View File
@@ -2,7 +2,7 @@ import type { Asset } from '../model/Asset';
import { type PropertyBag, type PropertyValue, sameValue } from '../model/Properties'; import { type PropertyBag, type PropertyValue, sameValue } from '../model/Properties';
import type { Project } from '../model/Project'; import type { Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject'; 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 * 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; 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 { export function isModified(project: Project, asset: Asset, key: string): boolean {
const def = propDef(project, asset, key); const def = propDef(project, asset, key);
if (!def) return false; if (!def) return false;
const own = asset.props[key]; 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`. */ /** The value the object would have without its override for `key`. */
+4
View File
@@ -32,6 +32,7 @@ import {
COMMON_PROPS, COMMON_PROPS,
isCommonProp, isCommonProp,
isRowShown, isRowShown,
isSortProp,
isTransformProp, isTransformProp,
type PropertyDef, type PropertyDef,
schemaFor, 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. // 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)); const transform = schemaForObject(project, asset, object).filter((def) => isTransformProp(def.key));
+43 -2
View File
@@ -2,6 +2,7 @@ import { isSymbolType } from '../core/model/AssetTypes';
import { sortByZ } from '../core/model/Properties'; import { sortByZ } from '../core/model/Properties';
import { cellOrigin, type TileDraw, tilemapTileSize } from '../core/model/tilemap'; import { cellOrigin, type TileDraw, tilemapTileSize } from '../core/model/tilemap';
import { footprintExtent } from '../core/properties/bounds'; import { footprintExtent } from '../core/properties/bounds';
import { SORT_WITH_PARENT_KEY } from '../core/properties/PropertySchema';
import type { DrawItem, TilemapDraw, TilemapLayerDraw } from './items'; 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 * (#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 * 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. * 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[] { export function drawSlots(items: readonly DrawItem[]): DrawSlot[] {
const groups = parentGroups(items);
const slots: Candidate[] = []; const slots: Candidate[] = [];
for (const item of items) { 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 }); slots.push({ id: item.id, layerId: '', row: null, z: item.z, item });
for (const layer of item.tilemap?.layers ?? []) { for (const layer of item.tilemap?.layers ?? []) {
const z = item.z + layer.z; const z = item.z + layer.z;
@@ -83,7 +88,43 @@ export function drawSlots(items: readonly DrawItem[]): DrawSlot[] {
} }
start = end; 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<string, DrawItem[]> {
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<string, DrawItem[]>();
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. */ /** A row of a layer sorted by Y, or an object other than a map. */
+1
View File
@@ -28,6 +28,7 @@ describe('asset commands', () => {
rotation: 0, rotation: 0,
mirrorX: false, mirrorX: false,
mirrorY: false, mirrorY: false,
sortWithParent: true,
width: 100, width: 100,
height: 100, height: 100,
color: 0xffffff, color: 0xffffff,
+3
View File
@@ -29,6 +29,7 @@ describe('PropertySchema', () => {
rotation: 0, rotation: 0,
mirrorX: false, mirrorX: false,
mirrorY: false, mirrorY: false,
sortWithParent: false,
width: 100, width: 100,
height: 100, height: 100,
color: 0xffffff, color: 0xffffff,
@@ -59,6 +60,7 @@ describe('resolve', () => {
rotation: 0, rotation: 0,
mirrorX: false, mirrorX: false,
mirrorY: false, mirrorY: false,
sortWithParent: false,
width: 50, width: 50,
height: 100, height: 100,
color: 0xffffff, color: 0xffffff,
@@ -77,6 +79,7 @@ describe('resolve', () => {
rotation: 0, rotation: 0,
mirrorX: false, mirrorX: false,
mirrorY: false, mirrorY: false,
sortWithParent: true,
width: 100, width: 100,
height: 100, height: 100,
color: 0x00ff00, color: 0x00ff00,
+1
View File
@@ -177,6 +177,7 @@ describe('serialize', () => {
rotation: 0, rotation: 0,
mirrorX: false, mirrorX: false,
mirrorY: false, mirrorY: false,
sortWithParent: false,
width: 10, width: 10,
height: 100, height: 100,
color: 0xffffff, color: 0xffffff,
+4 -2
View File
@@ -612,6 +612,7 @@ describe('a sprite wearing a shader', () => {
'rotation', 'rotation',
'mirrorX', 'mirrorX',
'mirrorY', 'mirrorY',
'sortWithParent',
'width', 'width',
'height', 'height',
'color', 'color',
@@ -630,6 +631,7 @@ describe('a sprite wearing a shader', () => {
'rotation', 'rotation',
'mirrorX', 'mirrorX',
'mirrorY', 'mirrorY',
'sortWithParent',
'width', 'width',
'height', 'height',
'color', 'color',
@@ -642,14 +644,14 @@ describe('a sprite wearing a shader', () => {
expect(assetDeviates(f.project, f.sprite())).toBe(true); expect(assetDeviates(f.project, f.sprite())).toBe(true);
// A dangling id adds no rows and draws nothing – the picker still shows it. // A dangling id adds no rows and draws nothing – the picker still shows it.
f.sprite().props[SHADER_KEY] = 'gone'; 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', () => { it('gives a placed object the rows of the shader it overrides, filled with the node defaults', () => {
const f = wearing(); const f = wearing();
const id = place(f, f.spriteId, { x: 0, y: 0 }); const id = place(f, f.spriteId, { x: 0, y: 0 });
const object = () => f.object(id); 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)); 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(schemaForObject(f.project, f.sprite(), object()).map((d) => d.key)).toContain('saturation');
expect(resolveSceneObjectProps(f.project, f.sprite(), object())).toMatchObject({ expect(resolveSceneObjectProps(f.project, f.sprite(), object())).toMatchObject({
+1
View File
@@ -67,6 +67,7 @@ describe('CreateAssetCommand with starting props', () => {
rotation: 0, rotation: 0,
mirrorX: false, mirrorX: false,
mirrorY: false, mirrorY: false,
sortWithParent: true,
width: 64, width: 64,
height: 32, height: 32,
color: 0xffffff, color: 0xffffff,
+203
View File
@@ -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<string, string>();
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']);
});
});
+34
View File
@@ -51,6 +51,40 @@ export async function drawing(t) {
await undo(); await undo();
check('undo restores z', (await object('o1')).position.z === 0, `z=${(await object('o1')).position.z}`); 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 // 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 // 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 // (±12 world units round its centre). Each must show white and the outline, and none of the