At 0.02 texel the lines between tiles of a nearest tilemap were gone at the zooms tried first, but still showed at others. The user raised the inset to 0.1 by hand and checked it in the browser. - A tenth of a texel is still far below half a texel, so the texel picked at every pixel centre stays the same. - The comment names the new value. Tests: - tilemap.test.ts follows the value: the inset test expects 0.1/96, the sliver test allows up to 0.25 texel from the edge, and the quad test checks its uvs to 2 digits instead of 3. - ./check.sh: 156 files, 2314 tests, CHECK EXIT 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
567 lines
21 KiB
TypeScript
567 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,
|
||
tilemapTileIds,
|
||
tilemapSize,
|
||
} from '../../src/core/model/tilemap';
|
||
import { tilemapsUsing } from '../../src/core/model/usages';
|
||
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 });
|
||
});
|
||
|
||
it('counts as a use of the tiles painted on it', () => {
|
||
const f = makeTilemap();
|
||
expect(tilemapsUsing(f.project, [f.tileId])).toHaveLength(0);
|
||
f.paint([{ x: 0, y: 0 }]);
|
||
expect(tilemapsUsing(f.project, [f.tileId]).map((a) => a.id)).toEqual([f.tilemapId]);
|
||
expect(tilemapTileIds(f.assetOf())).toEqual([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);
|
||
}
|
||
});
|
||
});
|