Recurring cleanup round over the code since round 6 (056a622), 105
commits. Behaviour is unchanged. Duplicated code in src went from 29
clones with 229 lines (0.32 %) to 14 clones with 109 lines (0.15 %),
measured with jscpd and the same settings as round 6.
- BehaviorSystem.ts shrinks from 941 to 589 lines. Collecting the
pulses (keys, timers, collisions, animations) moved to
behaviorPulses.ts. The NodeContext the nodes receive moved to
behaviorContext.ts. Imports from outside stay the same, TimeScaled
is still exported from BehaviorSystem.ts.
- PointKindButton and drawPointGrip replace the "Point:" button with
its menu and the point and handle drawing that the curve and the
vector art tab each had on their own.
- lerpVec, cubicAt and splitCubic in src/core/util/vec.ts replace four
copies of the cubic curve and two de Casteljau splits.
- mapNested in Properties.ts is the one walk over lists and structs
that liveObjects and objectRefs did separately. withFieldRenamed
and withoutField replace three copies of renaming a struct field
and two of removing one.
- jsonBody and staleRevision build the JSON body and turn a 409 into
StaleRevisionError for all stores. The auth store turns a 401 into
its error in one place.
- Smaller helpers: structAssetFrom for the two "Make struct type"
commands, shownObject in the inspector, selectedItem for the size
gizmo and the polygon corners, solidTiles in the tilemap collision,
requireVariablesAsset and requireBehaviorVariable.
- Left as is on purpose: the plain forwarding from the collision and
physics systems to Solids, the pointer wiring of the canvases and
the identical last lines of the two make commands.
- Removed unused code: tilemapTileIds and tilemapsUsing were never
called since #128 and #131. EditorState.runtimeProject became unused
with GameRun in #266. The assetId getter of the make commands was
read only by a test.
- 60 values used only in their own file lost their export. The shader
and particle node catalogues stay exported as in round 6.
- Five comments that told history now describe the current state. The
doc comment of BehaviorSystem sits on the class again.
- README lists the struct, clip area and curve assets and mentions the
game link from #266.
Findings:
- Deleting a tile that is painted on a tilemap leaves its cells in
place, and they draw nothing. The delete dialog does not mention the
tilemaps. That was probably the job of the unused tilemapsUsing.
Left as is, because fixing it changes behaviour.
- The solid filter for tile surfaces was untested. A bent filter
stayed green. A new test in tests/core/surface.test.ts now fails
against it.
Tests:
- ./check.sh: 195 files, 2815 tests, exit 0 (one test removed with
tilemapsUsing, one added for tile surfaces).
- Full e2e run: ALL PASSED, 1580 checks.
- Smokes jumprun, tetris, actor: 24, 19 and 21 checks, all passed.
The example project files were restored byte for byte afterwards.
- Counter-checks, 12, all fail as expected: 10 in unit tests (among
them mapNested, splitCubic, cubicAt, staleRevision, the stay pulse,
the late timer and the time scale), 2 in the browser (Point button
always enabled fails 47, selectedItem without a hit fails 29).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
557 lines
21 KiB
TypeScript
557 lines
21 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
CreateAssetCommand,
|
||
CreateTextureCommand,
|
||
SetAssetPropertyCommand,
|
||
} from '../../src/core/commands/assetCommands';
|
||
import { AddTilePictureCommand } from '../../src/core/commands/tileCommands';
|
||
import {
|
||
AddTilemapLayerCommand,
|
||
DuplicateTilemapLayerCommand,
|
||
MoveTilemapLayerCommand,
|
||
PaintTilesCommand,
|
||
paintTiles,
|
||
RemoveTilemapLayerCommand,
|
||
RenameTilemapLayerCommand,
|
||
SetTilemapLayerHiddenCommand,
|
||
SetTilemapLayerZCommand,
|
||
} from '../../src/core/commands/tilemapCommands';
|
||
import type { TilemapAsset } from '../../src/core/model/Asset';
|
||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||
import { TILE_KIND_KEY, type TileKind } from '../../src/core/model/tile';
|
||
import { findAsset, type Project } from '../../src/core/model/Project';
|
||
import {
|
||
cellAt,
|
||
cellAtPoint,
|
||
cellKey,
|
||
cellOrigin,
|
||
cellsAlong,
|
||
cellsInRect,
|
||
floodFill,
|
||
inGrid,
|
||
layerTiles,
|
||
neighboursOf,
|
||
TILEMAP_COLUMNS_KEY,
|
||
TILEMAP_ROWS_KEY,
|
||
TILEMAP_TILE_KEY,
|
||
tilemapSize,
|
||
} from '../../src/core/model/tilemap';
|
||
import { DIMMED_LAYER_ALPHA, layerAlpha } from '../../src/viewport/tilemap';
|
||
import { groupByTile, tileMesh } from '../../src/viewport/tilemapMesh';
|
||
import { makeProject, roundTrip } from '../helpers';
|
||
|
||
const FILE = `${'a1b2c3d4'.repeat(8)}.png`;
|
||
|
||
/** A tilemap with a floor autotile to paint with, both sized like the RPG-Maker sheets. */
|
||
function makeTilemap(kind: TileKind = 'floor') {
|
||
const fixture = makeProject();
|
||
const texture = new CreateTextureCommand('Outside', {
|
||
file: FILE,
|
||
width: 96,
|
||
height: kind === 'floor' ? 144 : 96,
|
||
});
|
||
fixture.stack.execute(texture);
|
||
const tile = new CreateAssetCommand('tile', 'Grass');
|
||
fixture.stack.execute(tile);
|
||
fixture.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_KIND_KEY, kind));
|
||
fixture.stack.execute(new AddTilePictureCommand(tile.asset.id, texture.asset.id));
|
||
const tilemap = new CreateAssetCommand('tilemap', 'Ground');
|
||
fixture.stack.execute(tilemap);
|
||
const assetOf = () => findAsset(fixture.project, tilemap.asset.id) as TilemapAsset;
|
||
return {
|
||
...fixture,
|
||
tileId: tile.asset.id,
|
||
tilemapId: tilemap.asset.id,
|
||
assetOf,
|
||
layerId: () => assetOf().layers[0]!.id,
|
||
paint: (cells: { x: number; y: number }[], tileId = tile.asset.id) =>
|
||
fixture.stack.execute(
|
||
new PaintTilesCommand(
|
||
tilemap.asset.id,
|
||
assetOf().layers[0]!.id,
|
||
cells.map((at) => ({ ...at, tileId })),
|
||
),
|
||
),
|
||
};
|
||
}
|
||
|
||
function cellsOf(project: Project, assetId: string): Record<string, string> {
|
||
const asset = findAsset(project, assetId) as TilemapAsset;
|
||
return asset.layers[0]!.cells;
|
||
}
|
||
|
||
describe('the tilemap asset', () => {
|
||
it('is placed, unlike the tiles it draws with', () => {
|
||
expect(isPlaceableType('tilemap')).toBe(true);
|
||
expect(isPlaceableType('tile')).toBe(false);
|
||
});
|
||
|
||
it('starts with one layer and no cells', () => {
|
||
const f = makeTilemap();
|
||
expect(f.assetOf().layers).toHaveLength(1);
|
||
expect(f.assetOf().layers[0]!.cells).toEqual({});
|
||
});
|
||
|
||
it('is as big as its grid says, centred on its position', () => {
|
||
const f = makeTilemap();
|
||
const props = f.assetOf().props;
|
||
expect(tilemapSize(props)).toEqual({ w: 20 * 48, h: 15 * 48 });
|
||
expect(cellOrigin(props, 0, 0)).toEqual({ x: -480, y: -360 });
|
||
// The centre of the map is the corner of the four middle cells.
|
||
expect(cellAtPoint(props, 0, 0)).toEqual({ x: 10, y: 7 });
|
||
expect(cellAtPoint(props, -480, -360)).toEqual({ x: 0, y: 0 });
|
||
expect(cellAtPoint(props, -1, -1)).toEqual({ x: 9, y: 7 });
|
||
});
|
||
|
||
it('follows the grid and the cell size when they change', () => {
|
||
const f = makeTilemap();
|
||
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 4));
|
||
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 3));
|
||
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_TILE_KEY, 10));
|
||
expect(tilemapSize(f.assetOf().props)).toEqual({ w: 40, h: 30 });
|
||
expect(inGrid(f.assetOf().props, 3, 2)).toBe(true);
|
||
expect(inGrid(f.assetOf().props, 4, 2)).toBe(false);
|
||
expect(inGrid(f.assetOf().props, -1, 0)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('painting a tilemap', () => {
|
||
it('paints, erases and takes both back as one step', () => {
|
||
const f = makeTilemap();
|
||
f.paint([
|
||
{ x: 1, y: 1 },
|
||
{ x: 2, y: 1 },
|
||
]);
|
||
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.tileId, '2,1': f.tileId });
|
||
f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, tileId: '' }]));
|
||
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '2,1': f.tileId });
|
||
f.stack.undo();
|
||
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.tileId, '2,1': f.tileId });
|
||
f.stack.undo();
|
||
expect(cellsOf(f.project, f.tilemapId)).toEqual({});
|
||
});
|
||
|
||
it('puts back what a stroke painted over, not merely empty', () => {
|
||
const f = makeTilemap();
|
||
const other = new CreateAssetCommand('tile', 'Sand');
|
||
f.stack.execute(other);
|
||
f.paint([{ x: 3, y: 3 }]);
|
||
f.paint([{ x: 3, y: 3 }], other.asset.id);
|
||
expect(cellAt(f.assetOf().layers[0]!, 3, 3)).toBe(other.asset.id);
|
||
f.stack.undo();
|
||
expect(cellAt(f.assetOf().layers[0]!, 3, 3)).toBe(f.tileId);
|
||
});
|
||
|
||
it('knows when a stroke would change nothing', () => {
|
||
const f = makeTilemap();
|
||
f.paint([{ x: 0, y: 0 }]);
|
||
const layer = f.assetOf().layers[0]!;
|
||
expect(PaintTilesCommand.changes(layer, [{ x: 0, y: 0, tileId: f.tileId }])).toBe(false);
|
||
expect(PaintTilesCommand.changes(layer, [{ x: 0, y: 0, tileId: '' }])).toBe(true);
|
||
expect(PaintTilesCommand.changes(layer, [{ x: 5, y: 5, tileId: f.tileId }])).toBe(true);
|
||
});
|
||
|
||
it('makes no undo step for a rectangle over cells that already hold its tile', () => {
|
||
const f = makeTilemap();
|
||
const box = cellsInRect(f.assetOf().props, { x: 0, y: 0 }, { x: 2, y: 2 });
|
||
f.paint(box);
|
||
const again = box.map((at) => ({ ...at, tileId: f.tileId }));
|
||
expect(paintTiles(f.assetOf().layers[0]!, f.tilemapId, again)).toBeNull();
|
||
// The one undo left takes back the first rectangle, not an empty second one.
|
||
f.stack.undo();
|
||
expect(cellsOf(f.project, f.tilemapId)).toEqual({});
|
||
});
|
||
|
||
it('still makes one when a single cell of the rectangle changes', () => {
|
||
const f = makeTilemap();
|
||
f.paint(cellsInRect(f.assetOf().props, { x: 0, y: 0 }, { x: 1, y: 1 }));
|
||
const wider = cellsInRect(f.assetOf().props, { x: 0, y: 0 }, { x: 2, y: 1 });
|
||
const command = paintTiles(
|
||
f.assetOf().layers[0]!,
|
||
f.tilemapId,
|
||
wider.map((at) => ({ ...at, tileId: f.tileId })),
|
||
);
|
||
expect(command).not.toBeNull();
|
||
f.stack.execute(command!);
|
||
expect(Object.keys(cellsOf(f.project, f.tilemapId))).toHaveLength(6);
|
||
f.stack.undo();
|
||
expect(Object.keys(cellsOf(f.project, f.tilemapId)).sort()).toEqual(['0,0', '0,1', '1,0', '1,1']);
|
||
});
|
||
|
||
it('makes no undo step for a fill that finds nothing to fill', () => {
|
||
const f = makeTilemap();
|
||
f.paint([{ x: 0, y: 0 }]);
|
||
const layer = f.assetOf().layers[0]!;
|
||
expect(floodFill(f.assetOf().props, layer, { x: 0, y: 0 }, f.tileId)).toEqual([]);
|
||
expect(paintTiles(layer, f.tilemapId, [])).toBeNull();
|
||
});
|
||
|
||
it('covers the cells between two pointer events', () => {
|
||
expect(cellsAlong({ x: 0, y: 0 }, { x: 3, y: 0 })).toEqual([
|
||
{ x: 1, y: 0 },
|
||
{ x: 2, y: 0 },
|
||
{ x: 3, y: 0 },
|
||
]);
|
||
expect(cellsAlong({ x: 0, y: 0 }, { x: 2, y: 2 })).toEqual([
|
||
{ x: 1, y: 1 },
|
||
{ x: 2, y: 2 },
|
||
]);
|
||
expect(cellsAlong({ x: 4, y: 2 }, { x: 4, y: 2 })).toEqual([{ x: 4, y: 2 }]);
|
||
});
|
||
|
||
it('clips a rectangle to the grid', () => {
|
||
const f = makeTilemap();
|
||
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 3));
|
||
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 2));
|
||
const cells = cellsInRect(f.assetOf().props, { x: -2, y: 1 }, { x: 9, y: 9 });
|
||
expect(cells).toEqual([
|
||
{ x: 0, y: 1 },
|
||
{ x: 1, y: 1 },
|
||
{ x: 2, y: 1 },
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe('what a painted cell shows', () => {
|
||
it('joins only cells holding the same tile', () => {
|
||
const f = makeTilemap();
|
||
const other = new CreateAssetCommand('tile', 'Sand');
|
||
f.stack.execute(other);
|
||
f.paint([
|
||
{ x: 5, y: 5 },
|
||
{ x: 6, y: 5 },
|
||
]);
|
||
f.paint([{ x: 5, y: 4 }], other.asset.id);
|
||
const layer = f.assetOf().layers[0]!;
|
||
const props = f.assetOf().props;
|
||
const around = neighboursOf(props, layer, 5, 5, f.tileId);
|
||
expect(around.e).toBe(true);
|
||
// The cell above holds a *different* tile, so this one closes against it.
|
||
expect(around.n).toBe(false);
|
||
expect(around.w).toBe(false);
|
||
});
|
||
|
||
it('counts anything outside the grid as empty', () => {
|
||
const f = makeTilemap();
|
||
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 2));
|
||
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 2));
|
||
f.paint([
|
||
{ x: 0, y: 0 },
|
||
{ x: 1, y: 0 },
|
||
{ x: 0, y: 1 },
|
||
{ x: 1, y: 1 },
|
||
]);
|
||
const asset = f.assetOf();
|
||
const around = neighboursOf(asset.props, asset.layers[0]!, 1, 1, f.tileId);
|
||
expect(around.n).toBe(true);
|
||
expect(around.w).toBe(true);
|
||
expect(around.e).toBe(false);
|
||
expect(around.s).toBe(false);
|
||
});
|
||
|
||
it('gives a lone cell the four convex corners and a joined one the inside', () => {
|
||
const f = makeTilemap();
|
||
f.paint([{ x: 2, y: 2 }]);
|
||
const asset = f.assetOf();
|
||
const lone = layerTiles(f.project, asset.props, asset.layers[0]!)[0]!;
|
||
// The block's convex corners, as `FLOOR_CORNERS` names them.
|
||
expect(lone.quadrants).toEqual([
|
||
{ x: 0, y: 2 },
|
||
{ x: 3, y: 2 },
|
||
{ x: 0, y: 5 },
|
||
{ x: 3, y: 5 },
|
||
]);
|
||
f.paint([
|
||
{ x: 1, y: 1 },
|
||
{ x: 2, y: 1 },
|
||
{ x: 3, y: 1 },
|
||
{ x: 1, y: 2 },
|
||
{ x: 3, y: 2 },
|
||
{ x: 1, y: 3 },
|
||
{ x: 2, y: 3 },
|
||
{ x: 3, y: 3 },
|
||
]);
|
||
const after = layerTiles(f.project, f.assetOf().props, f.assetOf().layers[0]!);
|
||
const middle = after.find((tile) => tile.x === 2 && tile.y === 2)!;
|
||
expect(middle.quadrants).toEqual([
|
||
{ x: 2, y: 4 },
|
||
{ x: 1, y: 4 },
|
||
{ x: 2, y: 3 },
|
||
{ x: 1, y: 3 },
|
||
]);
|
||
});
|
||
|
||
it('draws a plain tile the same in every cell, whatever lies around it', () => {
|
||
const f = makeTilemap('none');
|
||
f.paint([
|
||
{ x: 1, y: 1 },
|
||
{ x: 2, y: 1 },
|
||
{ x: 1, y: 2 },
|
||
{ x: 5, y: 5 },
|
||
]);
|
||
const asset = f.assetOf();
|
||
const tiles = layerTiles(f.project, asset.props, asset.layers[0]!);
|
||
expect(tiles).toHaveLength(4);
|
||
for (const tile of tiles)
|
||
expect(tile.quadrants).toEqual([
|
||
{ x: 0, y: 0 },
|
||
{ x: 1, y: 0 },
|
||
{ x: 0, y: 1 },
|
||
{ x: 1, y: 1 },
|
||
]);
|
||
});
|
||
|
||
it('leaves out cells outside the grid and cells naming nothing', () => {
|
||
const f = makeTilemap();
|
||
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 2));
|
||
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 2));
|
||
f.paint([
|
||
{ x: 0, y: 0 },
|
||
{ x: 5, y: 0 },
|
||
]);
|
||
f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, tileId: 'gone' }]));
|
||
const asset = f.assetOf();
|
||
const tiles = layerTiles(f.project, asset.props, asset.layers[0]!);
|
||
expect(tiles.map((t) => `${t.x},${t.y}`)).toEqual(['0,0']);
|
||
// The cell naming a deleted tile is kept in the model – an undo may bring it back.
|
||
expect(asset.layers[0]!.cells[cellKey(1, 1)]).toBe('gone');
|
||
});
|
||
|
||
it('reads the same tiles twice, in reading order', () => {
|
||
const f = makeTilemap();
|
||
f.paint([
|
||
{ x: 4, y: 2 },
|
||
{ x: 1, y: 1 },
|
||
{ x: 2, y: 1 },
|
||
]);
|
||
const asset = f.assetOf();
|
||
const order = layerTiles(f.project, asset.props, asset.layers[0]!).map((t) => `${t.x},${t.y}`);
|
||
expect(order).toEqual(['1,1', '2,1', '4,2']);
|
||
});
|
||
});
|
||
|
||
describe('the layers of a tilemap', () => {
|
||
it('adds, renames, reorders, hides and removes, each with an undo', () => {
|
||
const f = makeTilemap();
|
||
const add = new AddTilemapLayerCommand(f.tilemapId);
|
||
f.stack.execute(add);
|
||
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 2']);
|
||
|
||
f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, add.layerId, 'Roofs'));
|
||
expect(f.assetOf().layers[1]!.name).toBe('Roofs');
|
||
|
||
f.stack.execute(new MoveTilemapLayerCommand(f.tilemapId, 1, 0));
|
||
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Roofs', 'Layer 1']);
|
||
f.stack.undo();
|
||
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Roofs']);
|
||
|
||
f.stack.execute(new SetTilemapLayerHiddenCommand(f.tilemapId, add.layerId, true));
|
||
expect(f.assetOf().layers[1]!.hidden).toBe(true);
|
||
f.stack.undo();
|
||
expect(f.assetOf().layers[1]!.hidden).toBeUndefined();
|
||
|
||
f.stack.execute(new RemoveTilemapLayerCommand(f.tilemapId, add.layerId));
|
||
expect(f.assetOf().layers).toHaveLength(1);
|
||
f.stack.undo();
|
||
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Roofs']);
|
||
});
|
||
|
||
it('duplicates a layer right above it, with its cells, its z and a name of its own', () => {
|
||
const f = makeTilemap();
|
||
f.paint([{ x: 1, y: 1 }]);
|
||
f.stack.execute(new SetTilemapLayerZCommand(f.tilemapId, f.layerId(), 3));
|
||
f.stack.execute(new AddTilemapLayerCommand(f.tilemapId));
|
||
const copy = new DuplicateTilemapLayerCommand(f.tilemapId, f.layerId());
|
||
f.stack.execute(copy);
|
||
const layers = f.assetOf().layers;
|
||
expect(layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 1 copy', 'Layer 2']);
|
||
expect(layers[1]).toMatchObject({ id: copy.layerId, z: 3, cells: layers[0]!.cells });
|
||
// Cells of its own: painting on the copy leaves the original alone.
|
||
f.stack.execute(new PaintTilesCommand(f.tilemapId, copy.layerId, [{ x: 1, y: 1, tileId: '' }]));
|
||
expect(f.assetOf().layers[0]!.cells[cellKey(1, 1)]).toBeTruthy();
|
||
|
||
f.stack.undo();
|
||
f.stack.undo();
|
||
expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 2']);
|
||
});
|
||
|
||
it('renames a layer without the spaces around it, refuses a blank name and undoes to the old one', () => {
|
||
const f = makeTilemap();
|
||
f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, f.layerId(), ' Ground '));
|
||
expect(f.assetOf().layers[0]!.name).toBe('Ground');
|
||
expect(() => f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, f.layerId(), ' '))).toThrow(
|
||
'A layer needs a name',
|
||
);
|
||
expect(f.assetOf().layers[0]!.name).toBe('Ground');
|
||
f.stack.undo();
|
||
expect(f.assetOf().layers[0]!.name).toBe('Layer 1');
|
||
});
|
||
|
||
it('keeps the last layer, because there has to be one to paint on', () => {
|
||
const f = makeTilemap();
|
||
expect(() => f.stack.execute(new RemoveTilemapLayerCommand(f.tilemapId, f.layerId()))).toThrow(
|
||
'A tilemap keeps at least one layer',
|
||
);
|
||
expect(f.assetOf().layers).toHaveLength(1);
|
||
});
|
||
|
||
it('takes the cells of a removed layer with it and brings them back', () => {
|
||
const f = makeTilemap();
|
||
const add = new AddTilemapLayerCommand(f.tilemapId);
|
||
f.stack.execute(add);
|
||
f.stack.execute(new PaintTilesCommand(f.tilemapId, add.layerId, [{ x: 2, y: 2, tileId: f.tileId }]));
|
||
f.stack.execute(new RemoveTilemapLayerCommand(f.tilemapId, add.layerId));
|
||
expect(f.assetOf().layers).toHaveLength(1);
|
||
f.stack.undo();
|
||
expect(f.assetOf().layers[1]!.cells).toEqual({ '2,2': f.tileId });
|
||
});
|
||
});
|
||
|
||
describe('how the scene view shows the layers next to the painted one', () => {
|
||
it('draws all of them whole, fades the others or leaves them out', () => {
|
||
expect(layerAlpha('a', null)).toBe(1);
|
||
expect(layerAlpha('b', { layerId: 'a', mode: 'all' })).toBe(1);
|
||
expect(layerAlpha('a', { layerId: 'a', mode: 'dim' })).toBe(1);
|
||
expect(layerAlpha('b', { layerId: 'a', mode: 'dim' })).toBe(DIMMED_LAYER_ALPHA);
|
||
expect(layerAlpha('a', { layerId: 'a', mode: 'solo' })).toBe(1);
|
||
expect(layerAlpha('b', { layerId: 'a', mode: 'solo' })).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('a tilemap in the document', () => {
|
||
it('survives a round trip with its layers and cells', () => {
|
||
const f = makeTilemap();
|
||
const add = new AddTilemapLayerCommand(f.tilemapId);
|
||
f.stack.execute(add);
|
||
f.stack.execute(new SetTilemapLayerHiddenCommand(f.tilemapId, add.layerId, true));
|
||
f.paint([{ x: 1, y: 2 }]);
|
||
const back = roundTrip(f.project);
|
||
const asset = findAsset(back, f.tilemapId) as TilemapAsset;
|
||
expect(asset.layers).toHaveLength(2);
|
||
expect(asset.layers[0]!.cells).toEqual({ '1,2': f.tileId });
|
||
expect(asset.layers[1]!.hidden).toBe(true);
|
||
});
|
||
|
||
it('drops cells that are not a pair of whole numbers, and doubled layers', () => {
|
||
const f = makeTilemap();
|
||
const asset = f.assetOf();
|
||
asset.layers[0]!.cells['nonsense'] = f.tileId;
|
||
asset.layers[0]!.cells['1.5,2'] = f.tileId;
|
||
asset.layers[0]!.cells['3,3'] = f.tileId;
|
||
asset.layers.push({ ...asset.layers[0]!, name: 'Twin' });
|
||
const back = findAsset(roundTrip(f.project), f.tilemapId) as TilemapAsset;
|
||
expect(back.layers).toHaveLength(1);
|
||
expect(back.layers[0]!.cells).toEqual({ '3,3': f.tileId });
|
||
});
|
||
});
|
||
|
||
describe('the mesh a layer becomes', () => {
|
||
const sheet = { quad: 24, block: { x: 0, y: 0, w: 96, h: 144 }, textureWidth: 96, textureHeight: 144 };
|
||
|
||
it('is one mesh per tile, not one per cell', () => {
|
||
const f = makeTilemap();
|
||
const other = new CreateAssetCommand('tile', 'Sand');
|
||
f.stack.execute(other);
|
||
f.paint([
|
||
{ x: 0, y: 0 },
|
||
{ x: 1, y: 0 },
|
||
{ x: 2, y: 0 },
|
||
]);
|
||
f.paint([{ x: 4, y: 0 }], other.asset.id);
|
||
const asset = f.assetOf();
|
||
const groups = groupByTile(layerTiles(f.project, asset.props, asset.layers[0]!));
|
||
expect(groups.size).toBe(2);
|
||
expect(groups.get(f.tileId)).toHaveLength(3);
|
||
});
|
||
|
||
it('builds four quads per cell, at the cell it belongs to', () => {
|
||
const tiles = [
|
||
{
|
||
x: 1,
|
||
y: 0,
|
||
tileId: 'a',
|
||
quadrants: [
|
||
{ x: 0, y: 0 },
|
||
{ x: 1, y: 0 },
|
||
{ x: 0, y: 1 },
|
||
{ x: 1, y: 1 },
|
||
] as [
|
||
{ x: number; y: number },
|
||
{ x: number; y: number },
|
||
{ x: number; y: number },
|
||
{ x: number; y: number },
|
||
],
|
||
},
|
||
];
|
||
const { positions, uvs, indices } = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: false });
|
||
expect(positions).toHaveLength(4 * 4 * 2);
|
||
expect(indices).toHaveLength(4 * 6);
|
||
// The cell starts at x = 48, and its top-left quarter is 24 wide.
|
||
expect([positions[0], positions[1]]).toEqual([48, 0]);
|
||
expect([positions[2], positions[3]]).toEqual([72, 0]);
|
||
// Quadrant (0, 0) of a 96 × 144 sheet cut into 24 px quarters is the first quarter of the uvs.
|
||
expect(uvs[0]).toBeCloseTo(0, 2);
|
||
expect(uvs[1]).toBeCloseTo(0, 2);
|
||
expect(uvs[2]).toBeCloseTo(24 / 96, 2);
|
||
expect(uvs[3]).toBeCloseTo(0, 2);
|
||
});
|
||
|
||
it('pulls the uvs half a texel in when the picture is sampled smoothly', () => {
|
||
const tiles = [
|
||
{
|
||
x: 0,
|
||
y: 0,
|
||
tileId: 'a',
|
||
quadrants: [
|
||
{ x: 0, y: 0 },
|
||
{ x: 0, y: 0 },
|
||
{ x: 0, y: 0 },
|
||
{ x: 0, y: 0 },
|
||
] as [
|
||
{ x: number; y: number },
|
||
{ x: number; y: number },
|
||
{ x: number; y: number },
|
||
{ x: number; y: number },
|
||
],
|
||
},
|
||
];
|
||
const hard = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: false });
|
||
const soft = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: true });
|
||
expect(hard.uvs[0]).toBeCloseTo(0.1 / 96, 6);
|
||
expect(soft.uvs[0]).toBeCloseTo(0.5 / 96, 6);
|
||
// The positions are untouched: only what is sampled moves, never where it lands.
|
||
expect([...soft.positions]).toEqual([...hard.positions]);
|
||
});
|
||
|
||
it('keeps nearest uvs a sliver inside the quarter so odd zooms never pick the neighbour', () => {
|
||
const tiles = [
|
||
{
|
||
x: 0,
|
||
y: 0,
|
||
tileId: 'a',
|
||
quadrants: [
|
||
{ x: 1, y: 1 },
|
||
{ x: 1, y: 1 },
|
||
{ x: 1, y: 1 },
|
||
{ x: 1, y: 1 },
|
||
] as [
|
||
{ x: number; y: number },
|
||
{ x: number; y: number },
|
||
{ x: number; y: number },
|
||
{ x: number; y: number },
|
||
],
|
||
},
|
||
];
|
||
const { uvs } = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: false });
|
||
// Quarter (1, 1) spans texels 24..48 on both axes. Every corner sits strictly inside it,
|
||
// but by far less than half a texel, so the texel picked at any pixel centre stays the same.
|
||
const us = [uvs[0]!, uvs[2]!, uvs[4]!, uvs[6]!].map((u) => u * 96);
|
||
const vs = [uvs[1]!, uvs[3]!, uvs[5]!, uvs[7]!].map((v) => v * 144);
|
||
for (const t of [...us, ...vs]) {
|
||
expect(t).toBeGreaterThan(24);
|
||
expect(t).toBeLessThan(48);
|
||
expect(Math.min(t - 24, 48 - t)).toBeLessThan(0.25);
|
||
}
|
||
});
|
||
});
|