A behavior could move sprites around but had no way to look at or
change the tilemap it was standing on. Doors, breakable blocks and
"is there ground ahead?" checks all need that.
- New nodes Tile at, Set tile, World to cell and Cell to world. The
conversion honours position, rotation, scale and mirroring of the
placed map. A small Tile literal node lets a graph compare what it
read against a fixed tile.
- The layer is picked by name from all tilemap layers in the project
through a new field kind. "First layer" takes the map's first one.
Because layer is an input, a text can also be wired in.
- Each placement keeps only its edited cells in liveTilemap.ts,
layered over the asset's cells. A second placement of the same map
stays untouched, and the asset itself is never written.
- A read sees a Set tile from earlier in the same flow. Drawing picks
up the edit at once and neighbouring autotile edges adapt.
- Solids see the edits straight away, so a Move in the same frame
already hits the new cell. Rapier rebuilds its shapes on the next
step.
- After Stop the map is back to what was painted. The editor run shows
the edit in the scene view while it lasts and does not touch the
project.
- An unwired tilemap input means no map, like Get position. Cells
outside the grid are refused and done reports false.
Tests:
- ./check.sh: 155 files, 2271 tests, CHECK EXIT 0 (before 2245).
- tests/core/tile-access.test.ts (14), one physics test for a floor
set at runtime, catalog entries for the five nodes.
- e2e 39,40: all passed, 86 checks (before 81), five new in 40s.
- Counter-checks: collision ignoring edits fails 3 unit tests, the
solids cache missing same-tick edits fails only the "at once" test,
drawing ignoring edits fails the 2 drawing checks, the editor run
not reporting a new tile fails only its check.
- 40s uses a still tile. The animated fixture tile redraws every frame
and hid the last counter-check at first.
- Check total 1351 + 5 = 1356, computed. CI confirmed 1351 at cb32b4b.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
234 lines
11 KiB
TypeScript
234 lines
11 KiB
TypeScript
import { beforeAll, describe, expect, it } from 'vitest';
|
|
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
|
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
|
import {
|
|
AddTilemapLayerCommand,
|
|
PaintTilesCommand,
|
|
RenameTilemapLayerCommand,
|
|
} from '../../src/core/commands/tilemapCommands';
|
|
import type { TilemapAsset } from '../../src/core/model/Asset';
|
|
import {
|
|
cellToWorld,
|
|
layerNamed,
|
|
liveLayers,
|
|
liveTileAt,
|
|
withTile,
|
|
worldToCell,
|
|
} from '../../src/core/model/liveTilemap';
|
|
import { transformMatrix, translation } from '../../src/core/model/matrix';
|
|
import { findAsset } from '../../src/core/model/Project';
|
|
import { TILE_KIND_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile';
|
|
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, tilemapLayerNames } from '../../src/core/model/tilemap';
|
|
import { loadPhysics } from '../../src/runtime/physics/engine';
|
|
import { collectHolderItems, collectWorldItems } from '../../src/viewport/items';
|
|
import { COLLISION_SYSTEMS, makeSpriteScene, place, startWorld } from '../helpers';
|
|
|
|
const layer = (id: string, name: string, cells: Record<string, string> = {}) => ({ id, name, cells });
|
|
const asset = (): TilemapAsset => ({
|
|
id: 'map',
|
|
type: 'tilemap',
|
|
name: 'Map',
|
|
props: {},
|
|
layers: [layer('l1', 'Ground', { '0,0': 'grass', '1,0': 'grass' }), layer('l2', 'Top'), layer('l3', 'Top')],
|
|
});
|
|
const GRID = { [TILEMAP_COLUMNS_KEY]: 4, [TILEMAP_ROWS_KEY]: 2, tileSize: 10 };
|
|
|
|
describe('the cells a run sees (#135)', () => {
|
|
it('a layer is found by its name, no name is the first, two of one name are the upper one', () => {
|
|
const map = asset();
|
|
expect(layerNamed(map, 'Ground')?.id).toBe('l1');
|
|
expect(layerNamed(map, '')?.id).toBe('l1');
|
|
expect(layerNamed(map, 'Top')?.id).toBe('l2');
|
|
expect(layerNamed(map, 'ground')).toBeUndefined();
|
|
expect(layerNamed({ ...map, layers: [] }, '')).toBeUndefined();
|
|
});
|
|
|
|
it('edits lie over the cells, an empty one erasing, and leave the asset alone', () => {
|
|
const map = asset();
|
|
let edits = withTile(map, undefined, GRID, 'Ground', { x: 2, y: 1 }, 'water')!;
|
|
edits = withTile(map, edits, GRID, '', { x: 0, y: 0 }, '')!;
|
|
expect(edits).toEqual({ l1: { '2,1': 'water', '0,0': '' } });
|
|
expect(liveLayers(map, edits)[0]!.cells).toEqual({ '1,0': 'grass', '2,1': 'water' });
|
|
// The other layers are the asset's own, untouched.
|
|
expect(liveLayers(map, edits)[1]).toBe(map.layers[1]);
|
|
expect(map.layers[0]!.cells).toEqual({ '0,0': 'grass', '1,0': 'grass' });
|
|
expect(liveTileAt(map, edits, GRID, 'Ground', { x: 2, y: 1 })).toBe('water');
|
|
expect(liveTileAt(map, edits, GRID, 'Ground', { x: 0, y: 0 })).toBe('');
|
|
expect(liveTileAt(map, edits, GRID, 'Ground', { x: 1, y: 0 })).toBe('grass');
|
|
});
|
|
|
|
it('a cell off the grid or on a missing layer cannot be read or set; setting what is there changes nothing', () => {
|
|
const map = asset();
|
|
for (const cell of [
|
|
{ x: 4, y: 0 },
|
|
{ x: 0, y: 2 },
|
|
{ x: -1, y: 0 },
|
|
]) {
|
|
expect(liveTileAt(map, undefined, GRID, '', cell)).toBeNull();
|
|
expect(withTile(map, undefined, GRID, '', cell, 'water')).toBeNull();
|
|
}
|
|
expect(withTile(map, undefined, GRID, 'Sky', { x: 0, y: 0 }, 'water')).toBeNull();
|
|
const edits = withTile(map, undefined, GRID, '', { x: 3, y: 1 }, 'water')!;
|
|
expect(withTile(map, edits, GRID, '', { x: 3, y: 1 }, 'water')).toBe(edits);
|
|
});
|
|
|
|
it('offers the layer names of every tilemap, each once', () => {
|
|
const f = makeSpriteScene('Hero');
|
|
const a = new CreateAssetCommand('tilemap', 'A');
|
|
const b = new CreateAssetCommand('tilemap', 'B');
|
|
f.stack.execute(a);
|
|
f.stack.execute(b);
|
|
const add = new AddTilemapLayerCommand(b.asset.id);
|
|
f.stack.execute(add);
|
|
f.stack.execute(new RenameTilemapLayerCommand(b.asset.id, add.layerId, 'Walls'));
|
|
expect(tilemapLayerNames(f.project)).toEqual(['Layer 1', 'Walls']);
|
|
});
|
|
});
|
|
|
|
describe('world and cell (#135)', () => {
|
|
it('a map moved by (100, 50): cells from the top-left corner, the middle of each cell back', () => {
|
|
const m = translation(100, 50);
|
|
// 4 x 2 cells of 10 around (100, 50): the grid runs from (80, 40) to (120, 60).
|
|
expect(worldToCell(GRID, m, { x: 80, y: 40 })).toEqual({ x: 0, y: 0, inside: true });
|
|
expect(worldToCell(GRID, m, { x: 119.9, y: 59.9 })).toEqual({ x: 3, y: 1, inside: true });
|
|
expect(worldToCell(GRID, m, { x: 120, y: 50 })).toEqual({ x: 4, y: 1, inside: false });
|
|
expect(worldToCell(GRID, m, { x: 79, y: 39 })).toEqual({ x: -1, y: -1, inside: false });
|
|
expect(cellToWorld(GRID, m, { x: 0, y: 0 })).toEqual({ x: 85, y: 45 });
|
|
expect(cellToWorld(GRID, m, { x: 3, y: 1 })).toEqual({ x: 115, y: 55 });
|
|
});
|
|
|
|
it('a turned, scaled and mirrored map: every cell middle comes back to its cell', () => {
|
|
for (const m of [
|
|
transformMatrix(30, -20, 90, 2, false, false),
|
|
transformMatrix(-7, 3, 33, 0.5, true, false),
|
|
transformMatrix(0, 0, 180, 3, false, true),
|
|
])
|
|
for (let y = 0; y < 2; y++)
|
|
for (let x = 0; x < 4; x++)
|
|
expect(worldToCell(GRID, m, cellToWorld(GRID, m, { x, y }))).toEqual({ x, y, inside: true });
|
|
});
|
|
|
|
it('turned a quarter clockwise and doubled, the top-left cell lies to the right of the origin, at the top', () => {
|
|
const m = transformMatrix(0, 0, 90, 2, false, false);
|
|
// Its middle (-15, -5) in the map's frame turns to (5, -15) and doubles to (10, -30).
|
|
const middle = cellToWorld(GRID, m, { x: 0, y: 0 });
|
|
expect(middle.x).toBeCloseTo(10);
|
|
expect(middle.y).toBeCloseTo(-30);
|
|
expect(worldToCell(GRID, m, { x: 10, y: -30 })).toEqual({ x: 0, y: 0, inside: true });
|
|
// Scaled to nothing, there are no cells to fall into.
|
|
expect(worldToCell(GRID, transformMatrix(0, 0, 0, 0, false, false), { x: 0, y: 0 }).inside).toBe(false);
|
|
});
|
|
});
|
|
|
|
/** A 10 x 10 map of cells of 48 around the origin, placed twice, and a hero with a collider on it. */
|
|
function setup() {
|
|
const f = makeSpriteScene('Hero');
|
|
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'width', 40));
|
|
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 40));
|
|
const wall = new CreateAssetCommand('tile', 'Wall');
|
|
const map = new CreateAssetCommand('tilemap', 'Ground');
|
|
for (const command of [wall, map]) f.stack.execute(command);
|
|
f.stack.execute(new SetAssetPropertyCommand(wall.asset.id, TILE_SOLID_KEY, true));
|
|
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10));
|
|
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10));
|
|
const first = place(f, map.asset.id, { x: 0, y: 0 });
|
|
const second = place(f, map.asset.id, { x: 2000, y: 0 });
|
|
const hero = place(f, f.spriteId, { x: 0, y: 0 });
|
|
f.stack.execute(new AddColliderCommand(f.sceneId, hero));
|
|
const assetOf = () => findAsset(f.project, map.asset.id) as TilemapAsset;
|
|
return { ...f, wallId: wall.asset.id, mapId: map.asset.id, first, second, hero, assetOf };
|
|
}
|
|
|
|
/** Cell column 6: its left face lies at x = 48, 28 to the right of the hero's edge. */
|
|
const COLUMN_SIX = Array.from({ length: 10 }, (_, y) => ({ x: 6, y }));
|
|
|
|
describe.each(COLLISION_SYSTEMS)('$system: a tile set in a run (#135)', ({ physics }) => {
|
|
beforeAll(async () => {
|
|
await loadPhysics();
|
|
});
|
|
|
|
it('blocks at once, only on its own placement, and never reaches the asset', () => {
|
|
const s = setup();
|
|
const { world, collisions } = startWorld(s, { physics, collisions: true });
|
|
const map = world.findByObjectId(s.first)!;
|
|
const other = world.findByObjectId(s.second)!;
|
|
const hero = world.findByObjectId(s.hero)!;
|
|
for (const cell of COLUMN_SIX) expect(world.setTile(map, '', cell, s.wallId)).toBe(true);
|
|
// No tick in between: the move already meets the new wall.
|
|
expect(collisions.moveAndSlide(world, hero, 97.3, 0).blockedX).toBe(true);
|
|
expect(hero.x).toBeCloseTo(28, 9);
|
|
expect(world.tileAt(map, 'Layer 1', { x: 6, y: 3 })).toBe(s.wallId);
|
|
expect(world.tileAt(other, 'Layer 1', { x: 6, y: 3 })).toBe('');
|
|
expect(s.assetOf().layers[0]!.cells).toEqual({});
|
|
// Emptied again, the way is free.
|
|
for (const cell of COLUMN_SIX) world.setTile(map, '', cell, '');
|
|
expect(collisions.moveAndSlide(world, hero, 97.3, 0).blockedX).toBe(false);
|
|
});
|
|
|
|
it('refuses what it cannot set: no tile, no tilemap, no such layer, off the grid', () => {
|
|
const s = setup();
|
|
const { world } = startWorld(s, { physics, collisions: true });
|
|
const map = world.findByObjectId(s.first)!;
|
|
const hero = world.findByObjectId(s.hero)!;
|
|
expect(world.setTile(map, '', { x: 0, y: 0 }, s.spriteId)).toBe(false);
|
|
expect(world.setTile(hero, '', { x: 0, y: 0 }, s.wallId)).toBe(false);
|
|
expect(world.setTile(map, 'Sky', { x: 0, y: 0 }, s.wallId)).toBe(false);
|
|
expect(world.setTile(map, '', { x: 10, y: 0 }, s.wallId)).toBe(false);
|
|
expect(world.tileAt(hero, '', { x: 0, y: 0 })).toBeNull();
|
|
expect(world.isTilemap(map) && !world.isTilemap(hero)).toBe(true);
|
|
expect(world.takeTileChange()).toBe(false);
|
|
world.setTile(map, '', { x: 0, y: 0 }, s.wallId);
|
|
expect(world.takeTileChange()).toBe(true);
|
|
expect(world.takeTileChange()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('a tile set in a run is drawn (#135)', () => {
|
|
const cellsDrawn = (tilemap: { layers: { tiles: { x: number; y: number }[] }[] } | null) =>
|
|
tilemap?.layers[0]!.tiles.map(({ x, y }) => `${x},${y}`);
|
|
|
|
it('in the played world, on its own placement only', () => {
|
|
const s = setup();
|
|
s.stack.execute(
|
|
new PaintTilesCommand(s.mapId, s.assetOf().layers[0]!.id, [{ x: 1, y: 1, tileId: s.wallId }]),
|
|
);
|
|
const { world } = startWorld(s);
|
|
world.setTile(world.findByObjectId(s.first)!, '', { x: 2, y: 1 }, s.wallId);
|
|
world.setTile(world.findByObjectId(s.first)!, '', { x: 1, y: 1 }, '');
|
|
const items = collectWorldItems(world).items;
|
|
expect(cellsDrawn(items.find((i) => i.id === s.first)!.tilemap)).toEqual(['2,1']);
|
|
expect(cellsDrawn(items.find((i) => i.id === s.second)!.tilemap)).toEqual(['1,1']);
|
|
});
|
|
|
|
it('the autotile edges of the cells around it follow the live cells', () => {
|
|
const s = setup();
|
|
s.stack.execute(new SetAssetPropertyCommand(s.wallId, TILE_KIND_KEY, 'floor'));
|
|
const { world } = startWorld(s);
|
|
const map = world.findByObjectId(s.first)!;
|
|
const quarters = () =>
|
|
collectWorldItems(world)
|
|
.items.find((i) => i.id === s.first)!
|
|
.tilemap!.layers[0]!.tiles.find((t) => t.x === 2 && t.y === 1)!.quadrants;
|
|
world.setTile(map, '', { x: 2, y: 1 }, s.wallId);
|
|
const alone = quarters();
|
|
world.setTile(map, '', { x: 3, y: 1 }, s.wallId);
|
|
// A neighbour to the east turns the two right quarters from an edge into a joint.
|
|
expect(quarters()).not.toEqual(alone);
|
|
expect([quarters()[0], quarters()[2]]).toEqual([alone[0], alone[2]]);
|
|
world.setTile(map, '', { x: 3, y: 1 }, '');
|
|
expect(quarters()).toEqual(alone);
|
|
});
|
|
|
|
it('in the editor run over the scene it runs on, and nowhere else', () => {
|
|
const s = setup();
|
|
const { world } = startWorld(s);
|
|
world.setTile(world.findByObjectId(s.first)!, '', { x: 4, y: 4 }, s.wallId);
|
|
const drawn = (run: typeof world | null) =>
|
|
cellsDrawn(
|
|
collectHolderItems(s.project, s.sceneOf(), 0, run).items.find((i) => i.id === s.first)!.tilemap,
|
|
);
|
|
expect(drawn(world)).toEqual(['4,4']);
|
|
expect(drawn(null)).toEqual([]);
|
|
});
|
|
});
|