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(); 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']); }); });