A container is not drawn, so it had no place in the draw order. The chain from its content to the parent of the container broke there, and a container under a character could not be drawn together with it. - A container gets the switch Sort with parent under Z. It defaults to off, also for new containers, so projects draw as they did. - With the switch on, the whole content is drawn in the slot of the container's parent. The content's own switch does not count there. - Containers in a row pass the content on as long as each has the switch on. On the top level or under a tilemap it has no effect. - drawSlots follows the new sortsWith field of a draw item. Scene view, game and picking all use drawSlots, so all three agree. Found on the way: a scene object hung directly under a container spells the container out, and its template content is dropped. That is the existing spelledOut rule, the tests now keep both cases apart. Tests: - ./check.sh: 178 files, 2573 tests, CHECK EXIT 0 - 7 new unit tests in sort-with-parent.test.ts: the switch, template content, override per container, a chain, top level and tilemap, game and picking - e2e 5b-iii: a real click on the switch in the inspector, with undo - 4 counter-probes over #242 and #243 bite (2 unit, 2 browser) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
301 lines
14 KiB
TypeScript
301 lines
14 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
CreateAssetCommand,
|
||
ResetAssetPropertyCommand,
|
||
SetAssetPropertyCommand,
|
||
} from '../../src/core/commands/assetCommands';
|
||
import {
|
||
PaintTilesCommand,
|
||
RenameTilemapLayerCommand,
|
||
SetTilemapLayerSortByYCommand,
|
||
} from '../../src/core/commands/tilemapCommands';
|
||
import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import type { TilemapAsset } from '../../src/core/model/Asset';
|
||
import { instanceChildId } from '../../src/core/model/instance';
|
||
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', '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);
|
||
// Loading must not write the old reading out, or every older asset would be marked.
|
||
expect(loaded.props[SORT_WITH_PARENT_KEY]).toBeUndefined();
|
||
expect(isModified(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']);
|
||
});
|
||
});
|
||
|
||
describe('a container sorted with its parent (#243)', () => {
|
||
/**
|
||
* "other" and "box" on 0, a container "group" below the box, and in the container's template a
|
||
* "weapon" on -1.
|
||
*/
|
||
function withContainer() {
|
||
const s = makeScene();
|
||
const groupAsset = s.asset('container', 'Group');
|
||
const inTemplate = new PlaceAssetCommand(groupAsset, s.weaponId, { x: 0, y: 0, z: -1 }, '');
|
||
s.f.stack.execute(inTemplate);
|
||
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 group = s.put('group', groupAsset, { x: 0, y: 0, z: 0 }, box);
|
||
s.names.set(instanceChildId(group, inTemplate.objectId), 'weapon');
|
||
const sortGroup = (on: boolean) =>
|
||
s.f.stack.execute(new SetAssetPropertyCommand(groupAsset, SORT_WITH_PARENT_KEY, on));
|
||
return { ...s, groupAsset, group, box, sortGroup };
|
||
}
|
||
|
||
it('has the switch, off for a new container', () => {
|
||
const s = makeScene();
|
||
const id = s.asset('container', 'Group');
|
||
expect(schemaFor('container').some((d) => d.key === SORT_WITH_PARENT_KEY)).toBe(true);
|
||
expect(resolveAssetProps(s.f.project, findAsset(s.f.project, id)!)[SORT_WITH_PARENT_KEY]).toBe(false);
|
||
});
|
||
|
||
it('draws its content in the place of its parent once switched on', () => {
|
||
const s = withContainer();
|
||
expect(s.order()).toEqual(['weapon', 'other', 'box']);
|
||
s.sortGroup(true);
|
||
expect(s.order()).toEqual(['other', 'weapon', 'box']);
|
||
s.f.stack.undo();
|
||
expect(s.order()).toEqual(['weapon', 'other', 'box']);
|
||
});
|
||
|
||
it('passes content on whatever the content says itself', () => {
|
||
const s = withContainer();
|
||
s.sortWithParent(s.weaponId, false);
|
||
s.sortGroup(true);
|
||
expect(s.order()).toEqual(['other', 'weapon', 'box']);
|
||
});
|
||
|
||
it('can be switched per placed container', () => {
|
||
const s = withContainer();
|
||
s.f.stack.execute(new SetOverrideCommand(s.f.sceneId, s.group, SORT_WITH_PARENT_KEY, true));
|
||
expect(s.order()).toEqual(['other', 'weapon', 'box']);
|
||
});
|
||
|
||
it('passes through unpacked containers in a row, and stops at one that is off', () => {
|
||
const s = makeScene();
|
||
const outerAsset = s.asset('container', 'Outer');
|
||
const innerAsset = s.asset('container', 'Inner');
|
||
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 outer = s.put('outer', outerAsset, { x: 0, y: 0, z: 0 }, box);
|
||
const inner = s.put('inner', innerAsset, { x: 0, y: 0, z: 0 }, outer);
|
||
s.put('weapon', s.weaponId, { x: 0, y: 0, z: -1 }, inner);
|
||
s.f.stack.execute(new SetAssetPropertyCommand(innerAsset, SORT_WITH_PARENT_KEY, true));
|
||
expect(s.order()).toEqual(['weapon', 'other', 'box']);
|
||
s.f.stack.execute(new SetAssetPropertyCommand(outerAsset, SORT_WITH_PARENT_KEY, true));
|
||
expect(s.order()).toEqual(['other', 'weapon', 'box']);
|
||
});
|
||
|
||
it('draws alone at the top level, and not with a tilemap', () => {
|
||
const s = makeScene();
|
||
const groupAsset = s.asset('container', 'Group');
|
||
const map = s.put('map', s.asset('tilemap', 'Map'), { x: 0, y: 0, z: 0 });
|
||
const top = s.put('top', groupAsset, { x: 0, y: 0, z: 0 });
|
||
const underMap = s.put('underMap', groupAsset, { x: 0, y: 0, z: 0 }, map);
|
||
s.put('a', s.weaponId, { x: 0, y: 0, z: 1 }, top);
|
||
s.put('b', s.weaponId, { x: 0, y: 0, z: -1 }, underMap);
|
||
s.f.stack.execute(new SetAssetPropertyCommand(groupAsset, SORT_WITH_PARENT_KEY, true));
|
||
expect(s.order()).toEqual(['b', 'map', 'map', 'a']);
|
||
});
|
||
|
||
it('holds in the running game and when picking', () => {
|
||
const s = withContainer();
|
||
s.sortGroup(true);
|
||
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 = ['other', 'box', 'weapon'].map((name) => ({
|
||
id: [...s.names].find(([, n]) => n === name)![0],
|
||
layers: null,
|
||
}));
|
||
expect(stackAt(items, hits).map((id) => s.names.get(id))).toEqual(['box', 'weapon', 'other']);
|
||
});
|
||
});
|