522 lines
22 KiB
TypeScript
522 lines
22 KiB
TypeScript
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_COLLISION_SCALE_KEY,
|
||
TILE_SOLID_KEY,
|
||
tileCollisionScale,
|
||
} 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);
|
||
}
|
||
});
|
||
});
|
||
|
||
/** How many rectangles hold the point – 1 inside, 0 outside, more where they overlap. */
|
||
function hits(rects: CellRect[], px: number, py: number): number {
|
||
return rects.filter((r) => px > r.x && px < r.x + r.w && py > r.y && py < r.y + r.h).length;
|
||
}
|
||
|
||
const scaled = (scale: number, ...pairs: [number, number][]) => pairs.map(([x, y]) => ({ x, y, scale }));
|
||
|
||
describe('collision scale: drawn in or out, flush towards blocking neighbours (#140)', () => {
|
||
it('one cell, a run and a block at 0.5 are one rectangle each, drawn in only on the outside', () => {
|
||
expect(mergeCells(scaled(0.5, [3, 4]))).toEqual([{ x: 3.25, y: 4.25, w: 0.5, h: 0.5 }]);
|
||
expect(mergeCells(scaled(0.5, [0, 0], [1, 0], [2, 0]))).toEqual([{ x: 0.25, y: 0.25, w: 2.5, h: 0.5 }]);
|
||
const block = Array.from({ length: 9 }, (_, i) => [i % 3, Math.floor(i / 3)] as [number, number]);
|
||
expect(mergeCells(scaled(0.5, ...block))).toEqual([{ x: 0.25, y: 0.25, w: 2.5, h: 2.5 }]);
|
||
});
|
||
|
||
it('the inner corner of an L closes cleanly: no notch filled, no gap', () => {
|
||
const rects = mergeCells(scaled(0.5, [0, 0], [0, 1], [1, 1]));
|
||
expect(rects).toEqual([
|
||
{ x: 0.25, y: 0.25, w: 0.5, h: 1 },
|
||
{ x: 0.25, y: 1.25, w: 1.5, h: 0.5 },
|
||
]);
|
||
expect(hits(rects, 0.9, 1.1)).toBe(0); // the inner corner stays open …
|
||
expect(hits(rects, 0.7, 1.1)).toBe(1); // … the upright runs down into the foot
|
||
expect(hits(rects, 0.9, 1.3)).toBe(1);
|
||
});
|
||
|
||
it('two tiles with different values keep their common side flush', () => {
|
||
const rects = mergeCells([
|
||
{ x: 0, y: 0, scale: 0.5 },
|
||
{ x: 1, y: 0, scale: 1 },
|
||
]);
|
||
expect(rects).toEqual([
|
||
{ x: 1, y: 0, w: 1, h: 1 },
|
||
{ x: 0.25, y: 0.25, w: 0.75, h: 0.5 },
|
||
]);
|
||
expect(hits(rects, 0.99, 0.5) + hits(rects, 1.01, 0.5)).toBe(2);
|
||
});
|
||
|
||
it('above 1 a cell grows on its open sides; a run stays one rectangle', () => {
|
||
expect(mergeCells(scaled(1.5, [0, 0]))).toEqual([{ x: -0.25, y: -0.25, w: 1.5, h: 1.5 }]);
|
||
expect(mergeCells(scaled(1.5, [0, 0], [1, 0]))).toEqual([{ x: -0.25, y: -0.25, w: 2.5, h: 1.5 }]);
|
||
});
|
||
|
||
it('cells meeting only at a corner are not joined: the corner stays drawn in', () => {
|
||
expect(mergeCells(scaled(0.5, [0, 0], [1, 1]))).toEqual([
|
||
{ x: 0.25, y: 0.25, w: 0.5, h: 0.5 },
|
||
{ x: 1.25, y: 1.25, w: 0.5, h: 0.5 },
|
||
]);
|
||
});
|
||
|
||
/**
|
||
* For one value on every cell the rule is exactly erosion (below 1) or growth (above 1) of the
|
||
* painted area by the inset: a point blocks when the square of that half-width around it lies
|
||
* wholly on blocking cells – or, growing, touches any. Sampled off every cut line.
|
||
*/
|
||
it('matches erosion below 1 and growth above 1 for any scatter, without overlap when drawn in', () => {
|
||
let seed = 11;
|
||
const random = () => (seed = (seed * 48271) % 2147483647) / 2147483647;
|
||
for (const scale of [0.5, 1.5]) {
|
||
const e = Math.abs(1 - scale) / 2;
|
||
for (let round = 0; round < 10; round++) {
|
||
const scatter = Array.from({ length: 40 }, () => ({
|
||
x: Math.floor(random() * 8),
|
||
y: Math.floor(random() * 8),
|
||
scale,
|
||
}));
|
||
const solid = new Set(scatter.map((c) => `${c.x},${c.y}`));
|
||
const rects = mergeCells(scatter);
|
||
for (let py = -1 + 1 / 16; py < 9; py += 1 / 8) {
|
||
for (let px = -1 + 1 / 16; px < 9; px += 1 / 8) {
|
||
const touched: boolean[] = [];
|
||
for (let cy = Math.floor(py - e); cy <= Math.floor(py + e); cy++)
|
||
for (let cx = Math.floor(px - e); cx <= Math.floor(px + e); cx++)
|
||
touched.push(solid.has(`${cx},${cy}`));
|
||
const want = scale < 1 ? touched.every(Boolean) : touched.some(Boolean);
|
||
const got = hits(rects, px, py);
|
||
if (scale < 1) expect(got, `${px},${py}`).toBe(want ? 1 : 0);
|
||
else expect(got > 0, `${px},${py}`).toBe(want);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 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('collision scale: 1 by default, held to its range, surviving a round trip; the largest of two layers wins', () => {
|
||
const s = setup();
|
||
expect(schemaFor('tile').find((d) => d.key === TILE_COLLISION_SCALE_KEY)).toMatchObject({
|
||
kind: 'number',
|
||
default: 1,
|
||
min: 0.1,
|
||
max: 2,
|
||
});
|
||
const wallOf = (project = s.project) => {
|
||
const wall = findAsset(project, s.wallId);
|
||
if (wall?.type !== 'tile') throw new Error('no wall');
|
||
return wall;
|
||
};
|
||
expect(tileCollisionScale(wallOf())).toBe(1);
|
||
// The field is held to the schema's range; a value written into the file by hand is held too.
|
||
s.stack.execute(new SetAssetPropertyCommand(s.wallId, TILE_COLLISION_SCALE_KEY, 7));
|
||
expect(tileCollisionScale(wallOf())).toBe(2);
|
||
for (const [value, read] of [
|
||
[0.5, 0.5],
|
||
[0, 0.1],
|
||
[7, 2],
|
||
['x', 1],
|
||
] as const) {
|
||
wallOf().props[TILE_COLLISION_SCALE_KEY] = value;
|
||
expect(tileCollisionScale(wallOf())).toBe(read);
|
||
}
|
||
s.stack.execute(new SetAssetPropertyCommand(s.wallId, TILE_COLLISION_SCALE_KEY, 0.5));
|
||
expect(tileCollisionScale(wallOf(roundTrip(s.project)))).toBe(0.5);
|
||
// A second solid tile at 1.5 on another layer over the same cell.
|
||
const rock = new CreateAssetCommand('tile', 'Rock');
|
||
s.stack.execute(rock);
|
||
s.stack.execute(new SetAssetPropertyCommand(rock.asset.id, TILE_SOLID_KEY, true));
|
||
s.stack.execute(new SetAssetPropertyCommand(rock.asset.id, TILE_COLLISION_SCALE_KEY, 1.5));
|
||
const add = new AddTilemapLayerCommand(s.mapId);
|
||
s.stack.execute(add);
|
||
s.paint([[2, 2]]);
|
||
s.paint([[2, 2]], rock.asset.id, add.layerId);
|
||
expect(solidCells(s.project, s.assetOf().props, s.assetOf())).toEqual([{ x: 2, y: 2, scale: 1.5 }]);
|
||
});
|
||
|
||
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('a drawn-in wall lets the hero closer and stays one rectangle; slides past a bump on it', () => {
|
||
const s = setup();
|
||
s.stack.execute(new SetAssetPropertyCommand(s.wallId, TILE_COLLISION_SCALE_KEY, 0.5));
|
||
s.paint(COLUMN_SIX);
|
||
// Inset a quarter cell (12 px) on the open sides: the face moves from 48 to 60.
|
||
expect(solidRects(s.project, s.assetOf().props, s.assetOf())).toHaveLength(1);
|
||
let started = startWorld(s, { collisions: true });
|
||
let hero = started.world.findByObjectId(s.hero)!;
|
||
expect(started.collisions.moveAndSlide(started.world, hero, 97.3, 0).blockedX).toBe(true);
|
||
expect(hero.x).toBeCloseTo(40, 9);
|
||
// A bump on the far side cuts the column into several rectangles; the face stays one plane.
|
||
s.paint([[7, 5]]);
|
||
expect(solidRects(s.project, s.assetOf().props, s.assetOf()).length).toBeGreaterThan(1);
|
||
started = startWorld(s, { collisions: true });
|
||
hero = started.world.findByObjectId(s.hero)!;
|
||
hero.y = -200;
|
||
started.collisions.moveAndSlide(started.world, hero, 97.3, 0);
|
||
for (let i = 0; i < 60; i++) {
|
||
const { blockedX, blockedY } = started.collisions.moveAndSlide(started.world, hero, 3.1, 6.7);
|
||
expect(blockedX).toBe(true);
|
||
expect(blockedY).toBe(false);
|
||
}
|
||
expect(hero.x).toBeCloseTo(40, 6);
|
||
expect(hero.y).toBeCloseTo(-200 + 60 * 6.7, 6);
|
||
});
|
||
|
||
it('a grown wall keeps the hero further off', () => {
|
||
const s = setup();
|
||
s.stack.execute(new SetAssetPropertyCommand(s.wallId, TILE_COLLISION_SCALE_KEY, 1.5));
|
||
s.paint(COLUMN_SIX);
|
||
const { world, collisions } = startWorld(s, { collisions: true });
|
||
const hero = world.findByObjectId(s.hero)!;
|
||
collisions.moveAndSlide(world, hero, 97.3, 0);
|
||
expect(hero.x).toBeCloseTo(16, 9); // face at 48 − 12 = 36
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|