import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { AddFrameCommand, SetFrameSortLineCommand } from '../../src/core/commands/frameCommands'; import { MoveSceneObjectCommand } from '../../src/core/commands/sceneObjectCommands'; import { AddTilemapLayerCommand, PaintTilesCommand, RenameTilemapLayerCommand, SetTilemapLayerSortByYCommand, } from '../../src/core/commands/tilemapCommands'; import type { TextureAsset, TileAsset, TilemapAsset } from '../../src/core/model/Asset'; import { findAsset } from '../../src/core/model/Project'; import { frameSortLine } from '../../src/core/model/texture'; import { TILE_SORT_OFFSET_KEY, tileSortOffset } from '../../src/core/model/tile'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { type DrawSlot, drawSlots } from '../../src/viewport/drawOrder'; import { collectHolderItems, collectWorldItems } from '../../src/viewport/items'; import { stackAt } from '../../src/viewport/pick'; import { sortLineAt, sortLineY } from '../../src/editor/texture/sheetView'; import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers'; /** * A 20 × 15 map of 48-unit cells at the origin, so row `r` ends at `-360 + (r + 1) * 48`: row 5 at * -72, row 8 at 72. "Ground" is a plain layer; "Trees" holds a trunk at (2,5), its crown at (2,4) * and a bush at (7,8). The sprite "Box" is 100 tall and stands with its bottom at `feet`. */ function makeScene(feet: number, crownOffset = 1) { const f = makeSpriteScene('Box'); const map = new CreateAssetCommand('tilemap', 'Forest'); f.stack.execute(map); const mapId = map.asset.id; const assetOf = () => findAsset(f.project, mapId) as TilemapAsset; const ground = assetOf().layers[0]!.id; f.stack.execute(new RenameTilemapLayerCommand(mapId, ground, 'Ground')); const treesAdd = new AddTilemapLayerCommand(mapId); f.stack.execute(treesAdd); const trees = treesAdd.layerId; f.stack.execute(new RenameTilemapLayerCommand(mapId, trees, 'Trees')); const tile = (name: string, props = {}) => { const created = new CreateAssetCommand('tile', name, props); f.stack.execute(created); return created.asset.id; }; const trunk = tile('Trunk'); const crown = tile('Crown', { [TILE_SORT_OFFSET_KEY]: crownOffset }); f.stack.execute(new PaintTilesCommand(mapId, ground, [{ x: 0, y: 0, tileId: trunk }])); f.stack.execute( new PaintTilesCommand(mapId, trees, [ { x: 2, y: 5, tileId: trunk }, { x: 2, y: 4, tileId: crown }, { x: 7, y: 8, tileId: trunk }, ]), ); const mapObject = place(f, mapId, { x: 0, y: 0, z: 0 }); const box = place(f, f.spriteId, { x: 0, y: feet - 50, z: 0 }); const names = new Map([ [mapObject, 'map'], [box, 'box'], ]); const layerNames = new Map([ [ground, 'Ground'], [trees, 'Trees'], ]); const sortTrees = () => f.stack.execute(new SetTilemapLayerSortByYCommand(mapId, trees, true)); return { f, mapId, mapObject, box, ground, trees, crown, assetOf, names, layerNames, sortTrees }; } type Scene = ReturnType; function readable(s: Scene, slots: DrawSlot[]): string[] { return slots.map((slot) => { if (!slot.layerId) return s.names.get(slot.id) ?? slot.id; const name = s.layerNames.get(slot.layerId)!; return slot.row === null ? name : `${name} ${slot.row}`; }); } function sceneOrder(s: Scene): string[] { return readable(s, drawSlots(collectHolderItems(s.f.project, s.f.sceneOf(), 0).items)); } describe('a tilemap layer sorted by Y (#228)', () => { it('is off by default, and the map then draws as one piece per layer as before', () => { const s = makeScene(-50); expect(s.assetOf().layers.map((l) => l.sortByY)).toEqual([undefined, undefined]); expect(sceneOrder(s)).toEqual(['map', 'Ground', 'Trees', 'box']); }); it('sets, undoes and redoes, and stores off as nothing', () => { const s = makeScene(-50); const trees = () => s.assetOf().layers[1]!; s.sortTrees(); expect(trees().sortByY).toBe(true); s.f.stack.execute(new SetTilemapLayerSortByYCommand(s.mapId, s.trees, false)); expect('sortByY' in trees()).toBe(false); s.f.stack.undo(); expect(trees().sortByY).toBe(true); s.f.stack.undo(); expect('sortByY' in trees()).toBe(false); s.f.stack.redo(); expect(trees().sortByY).toBe(true); }); it('survives a round trip, and loading keeps only a literal true', () => { const s = makeScene(-50); s.sortTrees(); const back = findAsset(roundTrip(s.f.project), s.mapId) as TilemapAsset; expect(back.layers.map((l) => l.sortByY)).toEqual([undefined, true]); const saved = serializeProject(s.f.project) as { assets: { id: string; layers?: Record[] }[]; }; const layers = saved.assets.find((a) => a.id === s.mapId)!.layers!; for (const [raw, kept] of [ [true, true], ['true', undefined], [1, undefined], [false, undefined], ] as const) { layers[1]!.sortByY = raw; const loaded = findAsset(deserializeProject(saved), s.mapId) as TilemapAsset; expect(loaded.layers[1]!.sortByY, `stored ${String(raw)}`).toBe(kept); } }); it('puts a figure in front of a row whose bottom is above its feet, behind one below', () => { const between = makeScene(-50); between.sortTrees(); expect(sceneOrder(between)).toEqual(['map', 'Ground', 'Trees 5', 'box', 'Trees 8']); const behind = makeScene(-100); behind.sortTrees(); expect(sceneOrder(behind)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']); const front = makeScene(100); front.sortTrees(); expect(sceneOrder(front)).toEqual(['map', 'Ground', 'Trees 5', 'Trees 8', 'box']); }); it('sorts a crown with the trunk its sort offset points to, and on its own row without one', () => { const offset = makeScene(-80); offset.sortTrees(); // Feet at -80: below the crown's own row (ends at -120), above the trunk's (ends at -72). expect(sceneOrder(offset)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']); const plain = makeScene(-80, 0); plain.sortTrees(); expect(sceneOrder(plain)).toEqual(['map', 'Ground', 'Trees 4', 'box', 'Trees 5', 'Trees 8']); }); it('sorts objects on that z among each other by their feet, too', () => { const s = makeScene(100); s.sortTrees(); const far = place(s.f, s.f.spriteId, { x: 0, y: -150, z: 0 }); s.names.set(far, 'far'); // `far` comes after `box` in the hierarchy but stands further up, so it is drawn first. expect(sceneOrder(s)).toEqual(['map', 'Ground', 'far', 'Trees 5', 'Trees 8', 'box']); }); it('leaves everything on another z as it was', () => { const s = makeScene(-50); s.sortTrees(); s.f.stack.execute(new MoveSceneObjectCommand(s.f.sceneId, s.box, { x: 0, y: -100, z: 3 })); expect(sceneOrder(s)).toEqual(['map', 'Ground', 'Trees 5', 'Trees 8', 'box']); }); it('holds in the running game, and follows a figure that walks past a row', () => { const s = makeScene(-100); s.sortTrees(); const { world } = startWorld(s.f); const gameOrder = () => readable(s, drawSlots(collectWorldItems(world).items)); expect(gameOrder()).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']); world.entities.find((e) => e.objectId === s.box)!.y = 0; expect(gameOrder()).toEqual(['map', 'Ground', 'Trees 5', 'box', 'Trees 8']); }); it('picks the figure over a row behind it and the row over a figure behind it', () => { const cellHit = (s: Scene) => ({ id: s.mapObject, layers: [s.ground, s.trees], rows: new Map([[s.trees, 5]]), }); const pick = (s: Scene) => stackAt(collectHolderItems(s.f.project, s.f.sceneOf(), 0).items, [ { id: s.box, layers: null }, cellHit(s), ]).map((id) => s.names.get(id)); const inFront = makeScene(-50); inFront.sortTrees(); expect(pick(inFront)).toEqual(['box', 'map']); const behind = makeScene(-100); behind.sortTrees(); expect(pick(behind)).toEqual(['map', 'box']); }); }); /** * The scene above with "Box" wearing a frame of a 50 px tall picture, so one image pixel is two * units of its 100-unit box. "Stand" has the sort line under test, "Plain" has none. */ function withFrames(feet: number, line: number) { const s = makeScene(feet); const texture = new CreateAssetCommand('texture', 'Hero'); s.f.stack.execute(texture); const sheet = findAsset(s.f.project, texture.asset.id) as TextureAsset; sheet.file = `${'a'.repeat(64)}.png`; sheet.width = 64; sheet.height = 50; const stand = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 32, h: 50 }, 'Stand'); const plain = new AddFrameCommand(texture.asset.id, { x: 32, y: 0, w: 32, h: 50 }, 'Plain'); s.f.stack.execute(stand); s.f.stack.execute(plain); s.f.stack.execute(new SetFrameSortLineCommand(texture.asset.id, stand.frameId, line)); const wear = (frameId: string) => s.f.stack.execute(new SetAssetPropertyCommand(s.f.spriteId, 'texture', frameId)); wear(stand.frameId); const frameOf = (id: string) => sheet.frames.find((fr) => fr.id === id)!; return { ...s, textureId: texture.asset.id, stand: stand.frameId, plain: plain.frameId, wear, frameOf }; } describe('the sort line of a texture frame (#228)', () => { it('lifts where a sprite wearing the frame stands by its share of the box', () => { // Feet at -50 are below row 5 (ends at -72); 15 px of 50 lift them 30 units, to -80, above it. const lifted = withFrames(-50, 15); lifted.sortTrees(); expect(sceneOrder(lifted)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']); const low = withFrames(-50, 5); low.sortTrees(); expect(sceneOrder(low)).toEqual(['map', 'Ground', 'Trees 5', 'box', 'Trees 8']); }); it('belongs to the frame: another frame of the same picture stands on its bottom edge', () => { const s = withFrames(-50, 15); s.sortTrees(); s.wear(s.plain); expect(sceneOrder(s)).toEqual(['map', 'Ground', 'Trees 5', 'box', 'Trees 8']); s.wear(s.stand); expect(sceneOrder(s)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']); }); it('holds in the running game', () => { const s = withFrames(-50, 15); s.sortTrees(); const { world } = startWorld(s.f); expect(readable(s, drawSlots(collectWorldItems(world).items))).toEqual([ 'map', 'Ground', 'box', 'Trees 5', 'Trees 8', ]); }); it('sets, undoes and redoes, and stores 0 as nothing', () => { const s = withFrames(0, 12); const stand = () => s.frameOf(s.stand); expect(stand().sortLine).toBe(12); s.f.stack.execute(new SetFrameSortLineCommand(s.textureId, s.stand, 0)); expect('sortLine' in stand()).toBe(false); s.f.stack.undo(); expect(stand().sortLine).toBe(12); s.f.stack.redo(); expect('sortLine' in stand()).toBe(false); expect('sortLine' in s.frameOf(s.plain)).toBe(false); const fresh = withFrames(0, 0); fresh.f.stack.execute(new SetFrameSortLineCommand(fresh.textureId, fresh.stand, 7)); fresh.f.stack.undo(); expect('sortLine' in fresh.frameOf(fresh.stand)).toBe(false); }); it('is held to whole pixels inside the frame, also when loaded', () => { const s = withFrames(0, 0); for (const [raw, kept] of [ [2.6, 3], [-4, 0], [80, 50], ] as const) { s.f.stack.execute(new SetFrameSortLineCommand(s.textureId, s.stand, raw)); expect(frameSortLine(s.frameOf(s.stand)), String(raw)).toBe(kept); } const saved = serializeProject(s.f.project) as { assets: { id: string; frames?: Record[] }[]; }; const frames = saved.assets.find((a) => a.id === s.textureId)!.frames!; frames[0]!.sortLine = 70; frames[1]!.sortLine = 'x'; const loaded = findAsset(deserializeProject(saved), s.textureId) as TextureAsset; expect(loaded.frames.map((fr) => fr.sortLine)).toEqual([50, undefined]); const back = findAsset(roundTrip(s.f.project), s.textureId) as TextureAsset; expect(back.frames.map((fr) => fr.sortLine)).toEqual([50, undefined]); }); it('is grabbed by its knob left of the frame, or on the line inside it', () => { const rect = { x: 10, y: 20, w: 30, h: 40 }; expect(sortLineY(rect, 0)).toBe(60); expect(sortLineY(rect, 6)).toBe(54); expect(sortLineAt(rect, 6, { x: 4, y: 54 }, 1, 8)).toBe('knob'); expect(sortLineAt(rect, 6, { x: 25, y: 54.5 }, 1, 8)).toBe('line'); expect(sortLineAt(rect, 6, { x: 25, y: 57 }, 1, 8)).toBeNull(); expect(sortLineAt(rect, 6, { x: 0, y: 54 }, 1, 8)).toBeNull(); expect(sortLineAt(rect, 6, { x: 45, y: 54 }, 1, 8)).toBeNull(); }); }); describe('the sort offset of a tile (#228)', () => { it('is 0 by default and held to whole rows between -16 and 16', () => { const s = makeScene(0); const crown = () => findAsset(s.f.project, s.crown) as TileAsset; expect(tileSortOffset({ ...crown(), props: {} })).toBe(0); for (const [raw, kept] of [ [1, 1], [2.4, 2], [-3, -3], [40, 16], [-40, -16], ['x', 0], ] as const) expect(tileSortOffset({ ...crown(), props: { [TILE_SORT_OFFSET_KEY]: raw } }), String(raw)).toBe(kept); }); });