Files
feuerwerk/tests/viewport/sort-by-y.test.ts
T
adminandClaude Opus 5.5 3a3401f8ac
game-maker / gate (push) Successful in 3m19s
game-maker / suite (push) Successful in 9m57s
Let the sort knob win over the corner handle it sits on (#228)
The CI run for 30de4d3 failed on e2e 37b-ii: dragging the sort knob of
a nine-slice frame resized the frame instead (h 18, lifted null). The
knob's middle sits exactly 8 px left of the frame, which is also the
edge of the corner handle's 8 px tolerance. Until now only the rounding
of the pointer decided which of the two won. The slimmer header bars of
#245 moved that rounding to the corner handle's side. The bug itself
has been there since #228.

- sortLineAt bounds the knob where it is drawn, from a gap to the reach
  left of the frame, instead of by the handle tolerance.
- TextureCanvas passes that gap in image pixels, scaled by the zoom.
- The corner handle stays reachable at the corner itself and to the
  right of and below it.

Tests:
- ./check.sh: 178 files, 2576 tests, CHECK EXIT 0
- GM_STEPS=1,37: ALL PASSED, 26 checks (37b-ii: lifted 6, h 24)
- New unit test: the knob's middle belongs to the knob even inside
  the corner handle's reach. It fails with the old bound.
- Full e2e run not repeated. 37b-ii was the only failure in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:36:40 +02:00

332 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof makeScene>;
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<string, unknown>[] }[];
};
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<string, unknown>[] }[];
};
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, 1, 8)).toBe('knob');
expect(sortLineAt(rect, 6, { x: 25, y: 54.5 }, 1, 1, 8)).toBe('line');
expect(sortLineAt(rect, 6, { x: 25, y: 57 }, 1, 1, 8)).toBeNull();
expect(sortLineAt(rect, 6, { x: 0, y: 54 }, 1, 1, 8)).toBeNull();
expect(sortLineAt(rect, 6, { x: 45, y: 54 }, 1, 1, 8)).toBeNull();
});
it('takes the middle of its knob even where the corner handle reaches over it', () => {
// The editor's sizes at zoom 1: handles grab 8 px around the corner, the knob is drawn from
// 4 to 12 px left of the frame, so its middle sits exactly 8 px out – on the handle's edge.
const rect = { x: 100, y: 20, w: 24, h: 24 };
expect(sortLineAt(rect, 0, { x: 92, y: 44 }, 8, 4, 14)).toBe('knob');
expect(sortLineAt(rect, 0, { x: 97, y: 44 }, 8, 4, 14)).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);
});
});