Tilemaps can block movement: a tile gains a Solid switch, the solid cells of all layers are merged into rectangles and the placed tilemap takes part in the collision system as one body, reported as other in collision events (#132)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
10b49fac46
commit
4c411c1ff3
10 files changed
+715
-43
No files matched your search
@@ -0,0 +1,355 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import {
|
||||
AddTilemapLayerCommand,
|
||||
PaintTilesCommand,
|
||||
SetTilemapLayerHiddenCommand,
|
||||
} from '../../src/core/commands/tilemapCommands';
|
||||
import type { TilemapAsset } from '../../src/core/model/Asset';
|
||||
import { transformMatrix, translation } from '../../src/core/model/matrix';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { isTileSolid, TILE_SOLID_KEY } from '../../src/core/model/tile';
|
||||
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap';
|
||||
import {
|
||||
type CellRect,
|
||||
cellRectShape,
|
||||
mergeCells,
|
||||
solidCells,
|
||||
solidRects,
|
||||
} from '../../src/core/model/tilemapCollision';
|
||||
import { schemaFor } from '../../src/core/properties/PropertySchema';
|
||||
import { graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
|
||||
|
||||
const cells = (...pairs: [number, number][]) => pairs.map(([x, y]) => ({ x, y }));
|
||||
|
||||
/** Every cell of a rectangle list, so a check can see nothing was lost and nothing doubled. */
|
||||
function covered(rects: CellRect[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const r of rects)
|
||||
for (let y = r.y; y < r.y + r.h; y++) for (let x = r.x; x < r.x + r.w; x++) out.push(`${x},${y}`);
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
describe('merging solid cells into rectangles', () => {
|
||||
it('nothing, one cell, a run and a block', () => {
|
||||
expect(mergeCells([])).toEqual([]);
|
||||
expect(mergeCells(cells([3, 4]))).toEqual([{ x: 3, y: 4, w: 1, h: 1 }]);
|
||||
expect(mergeCells(cells([2, 0], [0, 0], [1, 0]))).toEqual([{ x: 0, y: 0, w: 3, h: 1 }]);
|
||||
expect(mergeCells(cells([0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1]))).toEqual([
|
||||
{ x: 0, y: 0, w: 3, h: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('a column is one rectangle; a gap in the rows or a different run starts a new one', () => {
|
||||
expect(mergeCells(cells([5, 0], [5, 1], [5, 2], [5, 3]))).toEqual([{ x: 5, y: 0, w: 1, h: 4 }]);
|
||||
expect(mergeCells(cells([5, 0], [5, 1], [5, 3]))).toEqual([
|
||||
{ x: 5, y: 0, w: 1, h: 2 },
|
||||
{ x: 5, y: 3, w: 1, h: 1 },
|
||||
]);
|
||||
// An L: the upright and the foot are two rectangles, the foot spanning its whole row.
|
||||
expect(mergeCells(cells([0, 0], [0, 1], [0, 2], [1, 2], [2, 2]))).toEqual([
|
||||
{ x: 0, y: 0, w: 1, h: 2 },
|
||||
{ x: 0, y: 2, w: 3, h: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('a ring keeps its hole; negative cells and doubles are fine', () => {
|
||||
const ring = cells([0, 0], [1, 0], [2, 0], [0, 1], [2, 1], [0, 2], [1, 2], [2, 2]);
|
||||
expect(mergeCells(ring)).toEqual([
|
||||
{ x: 0, y: 0, w: 3, h: 1 },
|
||||
{ x: 0, y: 1, w: 1, h: 1 },
|
||||
{ x: 2, y: 1, w: 1, h: 1 },
|
||||
{ x: 0, y: 2, w: 3, h: 1 },
|
||||
]);
|
||||
expect(mergeCells(cells([-2, -1], [-1, -1], [-2, -1]))).toEqual([{ x: -2, y: -1, w: 2, h: 1 }]);
|
||||
});
|
||||
|
||||
it('covers every cell exactly once, for any scatter', () => {
|
||||
let seed = 7;
|
||||
const random = () => (seed = (seed * 48271) % 2147483647) / 2147483647;
|
||||
for (let round = 0; round < 20; round++) {
|
||||
const scatter = Array.from({ length: 150 }, () => ({
|
||||
x: Math.floor(random() * 20) - 5,
|
||||
y: Math.floor(random() * 20) - 5,
|
||||
}));
|
||||
const unique = [...new Set(scatter.map((c) => `${c.x},${c.y}`))].sort();
|
||||
const rects = mergeCells(scatter);
|
||||
expect(covered(rects)).toEqual(unique);
|
||||
// Fewer shapes than cells, or the merge did nothing.
|
||||
expect(rects.length).toBeLessThan(unique.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A scene with a 10 × 10 map of 48 px cells placed at the origin – cell (x, y) spans
|
||||
* x·48 − 240 … x·48 − 192 – and a 40 × 40 hero with a collider at the origin.
|
||||
*/
|
||||
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 grass = new CreateAssetCommand('tile', 'Grass');
|
||||
const map = new CreateAssetCommand('tilemap', 'Ground');
|
||||
for (const command of [wall, grass, 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 mapObject = place(f, map.asset.id, { x: 0, 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;
|
||||
const paint = (at: [number, number][], tileId = wall.asset.id, layerId = assetOf().layers[0]!.id) =>
|
||||
f.stack.execute(
|
||||
new PaintTilesCommand(
|
||||
map.asset.id,
|
||||
layerId,
|
||||
cells(...at).map((c) => ({ ...c, tileId })),
|
||||
),
|
||||
);
|
||||
return {
|
||||
...f,
|
||||
wallId: wall.asset.id,
|
||||
grassId: grass.asset.id,
|
||||
mapId: map.asset.id,
|
||||
mapObject,
|
||||
hero,
|
||||
assetOf,
|
||||
paint,
|
||||
};
|
||||
}
|
||||
|
||||
/** Cell column 6, all ten rows: its left face lies at x = 48. */
|
||||
const COLUMN_SIX = Array.from({ length: 10 }, (_, y) => [6, y] as [number, number]);
|
||||
|
||||
describe('which cells block', () => {
|
||||
it('a tile is not solid until switched on, and the switch survives a round trip', () => {
|
||||
const s = setup();
|
||||
expect(schemaFor('tile').find((d) => d.key === TILE_SOLID_KEY)).toMatchObject({
|
||||
kind: 'boolean',
|
||||
default: false,
|
||||
});
|
||||
const grass = findAsset(s.project, s.grassId);
|
||||
expect(grass?.type === 'tile' && isTileSolid(grass)).toBe(false);
|
||||
const wall = findAsset(roundTrip(s.project), s.wallId);
|
||||
expect(wall?.type === 'tile' && isTileSolid(wall)).toBe(true);
|
||||
});
|
||||
|
||||
it('solid tiles on every layer, a hidden one too; not grass, not outside the grid, not a deleted tile', () => {
|
||||
const s = setup();
|
||||
s.paint([
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[12, 0],
|
||||
[-1, 3],
|
||||
]); // two outside the 10 × 10 grid
|
||||
s.paint([[2, 0]], s.grassId);
|
||||
const add = new AddTilemapLayerCommand(s.mapId);
|
||||
s.stack.execute(add);
|
||||
s.paint(
|
||||
[
|
||||
[1, 0],
|
||||
[0, 5],
|
||||
],
|
||||
s.wallId,
|
||||
add.layerId,
|
||||
); // (1, 0) doubles the first layer
|
||||
s.stack.execute(new SetTilemapLayerHiddenCommand(s.mapId, add.layerId, true));
|
||||
s.paint([[4, 4]], 'no-such-tile');
|
||||
const props = s.assetOf().props;
|
||||
const found = solidCells(s.project, props, s.assetOf())
|
||||
.map((c) => `${c.x},${c.y}`)
|
||||
.sort();
|
||||
expect(found).toEqual(['0,0', '0,5', '1,0']);
|
||||
expect(solidRects(s.project, props, s.assetOf())).toEqual([
|
||||
{ x: 0, y: 0, w: 2, h: 1 },
|
||||
{ x: 0, y: 5, w: 1, h: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('a rectangle sits in the world as the placed map does: moved, grown and turned', () => {
|
||||
const props = { [TILEMAP_COLUMNS_KEY]: 10, [TILEMAP_ROWS_KEY]: 10 };
|
||||
const rect = { x: 6, y: 0, w: 1, h: 10 };
|
||||
expect(cellRectShape(props, rect, translation(0, 0))).toEqual({
|
||||
kind: 'box',
|
||||
x: 72,
|
||||
y: 0,
|
||||
w: 48,
|
||||
h: 480,
|
||||
angle: 0,
|
||||
});
|
||||
expect(cellRectShape(props, rect, translation(100, -50))).toMatchObject({ x: 172, y: -50 });
|
||||
const turned = cellRectShape(props, rect, transformMatrix(0, 0, 90, 2, false, false));
|
||||
expect(turned).toMatchObject({ kind: 'box', w: 96, h: 960 });
|
||||
// Turned a quarter clockwise and doubled, the column's centre (72, 0) lands at (0, 144).
|
||||
expect(turned.x).toBeCloseTo(0);
|
||||
expect(turned.y).toBeCloseTo(144);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a tilemap in the CollisionSystem', () => {
|
||||
it('stops a move flush at a solid column and lets it leave again', () => {
|
||||
const s = setup();
|
||||
s.paint(COLUMN_SIX);
|
||||
const { world, collisions } = startWorld(s, { collisions: true });
|
||||
const hero = world.findByObjectId(s.hero)!;
|
||||
// Right edge at 20, the wall's face at 48: 28 of travel.
|
||||
expect(collisions.moveAndSlide(world, hero, 97.3, 0)).toEqual({ blockedX: true, blockedY: false });
|
||||
expect(hero.x).toBeCloseTo(28, 9);
|
||||
expect(collisions.moveAndSlide(world, hero, 5, 0)).toEqual({ blockedX: true, blockedY: false });
|
||||
expect(hero.x).toBeCloseTo(28, 9);
|
||||
expect(collisions.moveAndSlide(world, hero, -30, 0)).toEqual({ blockedX: false, blockedY: false });
|
||||
expect(hero.x).toBeCloseTo(-2, 9);
|
||||
});
|
||||
|
||||
it('a step larger than a cell does not jump over the wall', () => {
|
||||
const s = setup();
|
||||
s.paint(COLUMN_SIX);
|
||||
const { world, collisions } = startWorld(s, { collisions: true });
|
||||
const hero = world.findByObjectId(s.hero)!;
|
||||
expect(collisions.moveAndSlide(world, hero, 5000, 0)).toEqual({ blockedX: true, blockedY: false });
|
||||
expect(hero.x).toBeCloseTo(28, 6);
|
||||
});
|
||||
|
||||
it('slides along a wall made of several rectangles without catching on their seams', () => {
|
||||
const s = setup();
|
||||
// The column with a bump on its far side in row 5: three rectangles stacked along the face.
|
||||
s.paint([...COLUMN_SIX, [7, 5]]);
|
||||
expect(solidRects(s.project, s.assetOf().props, s.assetOf())).toHaveLength(3);
|
||||
const { world, collisions } = startWorld(s, { collisions: true });
|
||||
const hero = world.findByObjectId(s.hero)!;
|
||||
hero.y = -200;
|
||||
collisions.moveAndSlide(world, hero, 97.3, 0);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
// Pressed against the face while walking down past both seams.
|
||||
const { blockedX, blockedY } = collisions.moveAndSlide(world, hero, 3.1, 6.7);
|
||||
expect(blockedX).toBe(true);
|
||||
expect(blockedY).toBe(false);
|
||||
}
|
||||
expect(hero.x).toBeCloseTo(28, 6);
|
||||
expect(hero.y).toBeCloseTo(-200 + 60 * 6.7, 6);
|
||||
});
|
||||
|
||||
it('grass blocks nothing, a switched-off map blocks nothing', () => {
|
||||
const s = setup();
|
||||
s.paint(COLUMN_SIX, s.grassId);
|
||||
let started = startWorld(s, { collisions: true });
|
||||
let hero = started.world.findByObjectId(s.hero)!;
|
||||
expect(started.collisions.moveAndSlide(started.world, hero, 100, 0).blockedX).toBe(false);
|
||||
s.paint(COLUMN_SIX);
|
||||
s.stack.execute(new SetOverrideCommand(s.sceneId, s.mapObject, 'enabled', false));
|
||||
started = startWorld(s, { collisions: true });
|
||||
hero = started.world.findByObjectId(s.hero)!;
|
||||
expect(started.collisions.moveAndSlide(started.world, hero, 100, 0).blockedX).toBe(false);
|
||||
});
|
||||
|
||||
it('follows the placed map: moved, grown and turned', () => {
|
||||
const s = setup();
|
||||
s.paint([[6, 5]]); // one cell: x 48…96, y 0…48 at scale 1
|
||||
s.stack.execute(new SetOverrideCommand(s.sceneId, s.mapObject, 'scale', 2));
|
||||
// Doubled: the cell spans x 96…192, y 0…96. The hero at y = 40 is in its rows.
|
||||
const { world, collisions } = startWorld(s, { collisions: true });
|
||||
const hero = world.findByObjectId(s.hero)!;
|
||||
hero.y = 40;
|
||||
collisions.moveAndSlide(world, hero, 300, 0);
|
||||
expect(hero.x).toBeCloseTo(76, 6);
|
||||
// The map moved on by 100 takes its cell along at once: the shapes follow its current place.
|
||||
const map = world.findByObjectId(s.mapObject)!;
|
||||
map.x = 100;
|
||||
collisions.moveAndSlide(world, hero, 300, 0);
|
||||
expect(hero.x).toBeCloseTo(176, 6);
|
||||
// Turned a quarter clockwise about its centre, the cell lies at x −96…0, y 96…192: out of the way.
|
||||
map.x = 0;
|
||||
map.props.rotation = 90;
|
||||
hero.x = 0;
|
||||
expect(collisions.moveAndSlide(world, hero, 300, 0).blockedX).toBe(false);
|
||||
});
|
||||
|
||||
it('picks up cells painted while running on the next tick', () => {
|
||||
const s = setup();
|
||||
const { world, collisions } = startWorld(s, { collisions: true });
|
||||
const hero = world.findByObjectId(s.hero)!;
|
||||
world.tick(0.1);
|
||||
s.paint(COLUMN_SIX);
|
||||
world.tick(0.1);
|
||||
expect(collisions.moveAndSlide(world, hero, 100, 0).blockedX).toBe(true);
|
||||
});
|
||||
|
||||
it('reports enter, stay and exit with the tilemap as a body of its own; two maps never meet', () => {
|
||||
const s = setup();
|
||||
s.paint(COLUMN_SIX);
|
||||
place(s, s.mapId, { x: 0, y: 0 }); // a second placement right on top of the first
|
||||
const { world, collisions } = startWorld(s, { collisions: true });
|
||||
const hero = world.findByObjectId(s.hero)!;
|
||||
world.tick(0.1);
|
||||
expect(collisions.enters).toHaveLength(0);
|
||||
hero.x = 40;
|
||||
world.tick(0.1);
|
||||
expect(collisions.enters).toHaveLength(2); // one per placed map, none between the maps
|
||||
expect(collisions.enters.every((p) => p.a.type === 'tilemap' || p.b.type === 'tilemap')).toBe(true);
|
||||
world.tick(0.1);
|
||||
expect(collisions.stays).toHaveLength(2);
|
||||
const map = world.findByObjectId(s.mapObject)!;
|
||||
expect(collisions.touching(world, hero, map)).toBe(true);
|
||||
hero.x = 0;
|
||||
world.tick(0.1);
|
||||
expect(collisions.exits).toHaveLength(2);
|
||||
expect(collisions.touching(world, hero, map)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tilemap collisions in behaviors', () => {
|
||||
it('On collision enter names the tilemap as the other body; Is touching agrees', () => {
|
||||
const s = setup();
|
||||
s.paint(COLUMN_SIX);
|
||||
const behavior = makeBehavior(s, 'Bump');
|
||||
place(s, behavior.id, { x: 0, y: 0 }, s.hero);
|
||||
const edit = graphEditor(s, behavior);
|
||||
const enter = edit.addNode('OnCollisionEnter');
|
||||
const logEnter = edit.addNode('Log');
|
||||
const update = edit.addNode('OnUpdate');
|
||||
const owner = edit.addNode('Owner');
|
||||
const byName = edit.addNode('ObjectByName', { name: 'Ground' });
|
||||
const touching = edit.addNode('IsTouching');
|
||||
const logTouch = edit.addNode('Log');
|
||||
edit.wire([
|
||||
[enter, 'exec', logEnter, 'exec'],
|
||||
[enter, 'other', logEnter, 'message'],
|
||||
[update, 'exec', logTouch, 'exec'],
|
||||
[owner, 'object', touching, 'a'],
|
||||
[byName, 'object', touching, 'b'],
|
||||
[touching, 'result', logTouch, 'message'],
|
||||
]);
|
||||
const { world } = startWorld(s, { collisions: true, behaviors: true });
|
||||
world.tick(0.1);
|
||||
expect(world.log).toEqual(['false']);
|
||||
world.findByObjectId(s.hero)!.x = 40;
|
||||
world.tick(0.1);
|
||||
expect(world.log).toEqual(['false', 'Ground', 'true']);
|
||||
});
|
||||
|
||||
it('the Move node with "Collide with solids" stops at the painted wall', () => {
|
||||
const s = setup();
|
||||
s.paint(COLUMN_SIX);
|
||||
const behavior = makeBehavior(s, 'Walk');
|
||||
place(s, behavior.id, { x: 0, y: 0 }, s.hero);
|
||||
const edit = graphEditor(s, behavior);
|
||||
const update = edit.addNode('OnUpdate');
|
||||
const owner = edit.addNode('Owner');
|
||||
const move = edit.addNode('Move', { dx: 200, collide: true });
|
||||
edit.wire([
|
||||
[update, 'exec', move, 'exec'],
|
||||
[update, 'dt', move, 'dt'],
|
||||
[owner, 'object', move, 'object'],
|
||||
]);
|
||||
const { world } = startWorld(s, { collisions: true, behaviors: true });
|
||||
const hero = world.findByObjectId(s.hero)!;
|
||||
world.tick(0.1);
|
||||
expect(hero.x).toBeCloseTo(20, 9);
|
||||
world.tick(0.1);
|
||||
expect(hero.x).toBeCloseTo(28, 9);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user