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>
204 lines
9.3 KiB
TypeScript
204 lines
9.3 KiB
TypeScript
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']);
|
||
});
|
||
});
|