Tilemaps are a new placeable asset type that is painted with autotiles in the scene view, with layers, brush, eraser and rectangle, and is drawn at runtime and in the export (#128)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0607221423
commit
33a7b1b63e
30 files changed
+2060
-20
No files matched your search
@@ -0,0 +1,433 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CreateAssetCommand,
|
||||
CreateTextureCommand,
|
||||
SetAssetPropertyCommand,
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import { AddAutotilePictureCommand } from '../../src/core/commands/autotileCommands';
|
||||
import {
|
||||
AddTilemapLayerCommand,
|
||||
MoveTilemapLayerCommand,
|
||||
PaintTilesCommand,
|
||||
RemoveTilemapLayerCommand,
|
||||
RenameTilemapLayerCommand,
|
||||
SetTilemapLayerHiddenCommand,
|
||||
} from '../../src/core/commands/tilemapCommands';
|
||||
import type { TilemapAsset } from '../../src/core/model/Asset';
|
||||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||||
import { AUTOTILE_KIND_KEY } from '../../src/core/model/autotile';
|
||||
import { findAsset, type Project } from '../../src/core/model/Project';
|
||||
import {
|
||||
cellAt,
|
||||
cellAtPoint,
|
||||
cellKey,
|
||||
cellOrigin,
|
||||
cellsAlong,
|
||||
cellsInRect,
|
||||
inGrid,
|
||||
layerTiles,
|
||||
neighboursOf,
|
||||
TILEMAP_COLUMNS_KEY,
|
||||
TILEMAP_ROWS_KEY,
|
||||
TILEMAP_TILE_KEY,
|
||||
tilemapAutotileIds,
|
||||
tilemapSize,
|
||||
} from '../../src/core/model/tilemap';
|
||||
import { tilemapsUsing } from '../../src/core/model/usages';
|
||||
import { tileMesh, tilesByAutotile } 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: 'floor' | 'wall' = 'floor') {
|
||||
const fixture = makeProject();
|
||||
const texture = new CreateTextureCommand('Outside', {
|
||||
file: FILE,
|
||||
width: 96,
|
||||
height: kind === 'floor' ? 144 : 96,
|
||||
});
|
||||
fixture.stack.execute(texture);
|
||||
const autotile = new CreateAssetCommand('autotile', 'Grass');
|
||||
fixture.stack.execute(autotile);
|
||||
fixture.stack.execute(new SetAssetPropertyCommand(autotile.asset.id, AUTOTILE_KIND_KEY, kind));
|
||||
fixture.stack.execute(new AddAutotilePictureCommand(autotile.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,
|
||||
autotileId: autotile.asset.id,
|
||||
tilemapId: tilemap.asset.id,
|
||||
assetOf,
|
||||
layerId: () => assetOf().layers[0]!.id,
|
||||
paint: (cells: { x: number; y: number }[], autotileId = autotile.asset.id) =>
|
||||
fixture.stack.execute(
|
||||
new PaintTilesCommand(
|
||||
tilemap.asset.id,
|
||||
assetOf().layers[0]!.id,
|
||||
cells.map((at) => ({ ...at, autotileId })),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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 autotiles it draws with', () => {
|
||||
expect(isPlaceableType('tilemap')).toBe(true);
|
||||
expect(isPlaceableType('autotile')).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.autotileId, '2,1': f.autotileId });
|
||||
f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, autotileId: '' }]));
|
||||
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '2,1': f.autotileId });
|
||||
f.stack.undo();
|
||||
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.autotileId, '2,1': f.autotileId });
|
||||
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('autotile', '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.autotileId);
|
||||
});
|
||||
|
||||
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, autotileId: f.autotileId }])).toBe(false);
|
||||
expect(PaintTilesCommand.changes(layer, [{ x: 0, y: 0, autotileId: '' }])).toBe(true);
|
||||
expect(PaintTilesCommand.changes(layer, [{ x: 5, y: 5, autotileId: f.autotileId }])).toBe(true);
|
||||
});
|
||||
|
||||
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 autotile', () => {
|
||||
const f = makeTilemap();
|
||||
const other = new CreateAssetCommand('autotile', '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.autotileId);
|
||||
expect(around.e).toBe(true);
|
||||
// The cell above holds a *different* autotile, 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.autotileId);
|
||||
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('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, autotileId: '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 autotile 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('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, autotileId: f.autotileId }]),
|
||||
);
|
||||
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.autotileId });
|
||||
});
|
||||
});
|
||||
|
||||
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.autotileId });
|
||||
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.autotileId;
|
||||
asset.layers[0]!.cells['1.5,2'] = f.autotileId;
|
||||
asset.layers[0]!.cells['3,3'] = f.autotileId;
|
||||
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.autotileId });
|
||||
});
|
||||
|
||||
it('counts as a use of the autotiles painted on it', () => {
|
||||
const f = makeTilemap();
|
||||
expect(tilemapsUsing(f.project, [f.autotileId])).toHaveLength(0);
|
||||
f.paint([{ x: 0, y: 0 }]);
|
||||
expect(tilemapsUsing(f.project, [f.autotileId]).map((a) => a.id)).toEqual([f.tilemapId]);
|
||||
expect(tilemapAutotileIds(f.assetOf())).toEqual([f.autotileId]);
|
||||
});
|
||||
});
|
||||
|
||||
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 autotile, not one per cell', () => {
|
||||
const f = makeTilemap();
|
||||
const other = new CreateAssetCommand('autotile', '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 = tilesByAutotile(layerTiles(f.project, asset.props, asset.layers[0]!));
|
||||
expect(groups.size).toBe(2);
|
||||
expect(groups.get(f.autotileId)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('builds four quads per cell, at the cell it belongs to', () => {
|
||||
const tiles = [
|
||||
{
|
||||
x: 1,
|
||||
y: 0,
|
||||
autotileId: '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], uvs[1]]).toEqual([0, 0]);
|
||||
expect([uvs[2], uvs[3]]).toEqual([24 / 96, 0]);
|
||||
});
|
||||
|
||||
it('pulls the uvs half a texel in when the picture is sampled smoothly', () => {
|
||||
const tiles = [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
autotileId: '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]).toBe(0);
|
||||
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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user