A tile could be solid, one-way and scaled, but not report without blocking. Water, lava, spikes or a goal zone needed a trigger object of their own laid over the map. Now a tile has a switch Trigger, and the collision events name the tile and the cell they met. - A tile has the switch "Trigger (reports, never blocks)" under Solid. Its cells hold nothing up: no Move, no falling body, no ray. They raise On collision enter, stay and exit with the tilemap as other. - The three events have the outputs tile, cell x and cell y. Without a tile, tile is empty and the cell is -1, -1. - The three events have the input per cell. Off, the default, gives one event per area: cells of the same tile that touch side by side. On gives one event per cell. - The scene view outlines trigger cells in yellow like a trigger collider, merged to rectangles but never with solid cells. - Is touching with the map counts trigger tiles, and Set tile works while the game runs. - Both systems find the touches with the same overlap check in runtime/systems/tileTriggers.ts, not through the physics engine. Decided here: - Trigger wins over Solid. A tile with both does not block, as with a collider. - Water beside lava is two areas, and so are two ponds that touch only at a corner. - An area names the cell nearest to the centre of the collider, and on exit the cell it named last. - The solid cells of a map still do not name their tile. - Normal, impulse and point are 0, as with a trigger collider. - Collision scale and outlines shape a trigger cell like a solid one. One-way, friction and bounciness have no effect. Tests: - Gate on stein2: 205 files, 3104 tests (3067 + 37), exit 0 - e2e 39,40: 106 checks. e2e 1-8,39-43: 490 checks. Both on melektaus-atx. No full run, the sum of 1648 (1645 + 3) is reckoned and CI measures it. - 37 unit tests in tests/core/tile-triggers.test.ts, nearly all against both systems - Three e2e checks with a real mouse in block 40l-iv - 24 unit counter-probes fail tests. Two did not at first, tests were added for them. - Two browser counter-probes fail their checks - Two screenshots looked at (yellow outline, the node with per cell) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
569 lines
22 KiB
TypeScript
569 lines
22 KiB
TypeScript
import { beforeAll, describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands';
|
||
import { AddFrameCommand, SetFrameCollisionCommand } from '../../src/core/commands/frameCommands';
|
||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import { AddTilePictureCommand } from '../../src/core/commands/tileCommands';
|
||
import { AddTilemapLayerCommand, PaintTilesCommand } from '../../src/core/commands/tilemapCommands';
|
||
import type { TextureAsset, TilemapAsset } from '../../src/core/model/Asset';
|
||
import { ALL_LAYERS, COLLIDES_WITH_KEY } from '../../src/core/model/collisionLayers';
|
||
import { findAsset } from '../../src/core/model/Project';
|
||
import {
|
||
isTileSolid,
|
||
isTileTrigger,
|
||
TILE_COLLISION_SCALE_KEY,
|
||
TILE_SOLID_KEY,
|
||
TILE_TRIGGER_KEY,
|
||
} from '../../src/core/model/tile';
|
||
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap';
|
||
import {
|
||
solidRects,
|
||
type TriggerCell,
|
||
triggerCells,
|
||
triggerPolygons,
|
||
triggerRects,
|
||
} from '../../src/core/model/tilemapCollision';
|
||
import { schemaFor } from '../../src/core/properties/PropertySchema';
|
||
import { loadPhysics } from '../../src/runtime/physics/engine';
|
||
import type { CollisionPair, CollisionSource } from '../../src/runtime/systems/solids';
|
||
import type { System } from '../../src/runtime/systems/System';
|
||
import {
|
||
COLLISION_SYSTEMS,
|
||
graphEditor,
|
||
makeBehavior,
|
||
makeSpriteScene,
|
||
place,
|
||
roundTrip,
|
||
startWorld,
|
||
} from '../helpers';
|
||
|
||
/**
|
||
* 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, standing in row 4 left of a pond:
|
||
* water in the cells 6, 7 and 8 of that row and lava in cell 9, both trigger tiles.
|
||
*/
|
||
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 water = new CreateAssetCommand('tile', 'Water');
|
||
const lava = new CreateAssetCommand('tile', 'Lava');
|
||
const wall = new CreateAssetCommand('tile', 'Wall');
|
||
const map = new CreateAssetCommand('tilemap', 'Ground');
|
||
for (const command of [water, lava, wall, map]) f.stack.execute(command);
|
||
f.stack.execute(new SetAssetPropertyCommand(water.asset.id, TILE_TRIGGER_KEY, true));
|
||
f.stack.execute(new SetAssetPropertyCommand(lava.asset.id, TILE_TRIGGER_KEY, true));
|
||
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: -24 });
|
||
const collider = new AddColliderCommand(f.sceneId, hero);
|
||
f.stack.execute(collider);
|
||
const assetOf = () => findAsset(f.project, map.asset.id) as TilemapAsset;
|
||
const paint = (at: [number, number][], tileId: string, layerId = assetOf().layers[0]!.id) =>
|
||
f.stack.execute(
|
||
new PaintTilesCommand(
|
||
map.asset.id,
|
||
layerId,
|
||
at.map(([x, y]) => ({ x, y, tileId })),
|
||
),
|
||
);
|
||
const s = {
|
||
...f,
|
||
waterId: water.asset.id,
|
||
lavaId: lava.asset.id,
|
||
wallId: wall.asset.id,
|
||
mapId: map.asset.id,
|
||
mapObject,
|
||
hero,
|
||
heroCollider: collider.objectId,
|
||
assetOf,
|
||
paint,
|
||
};
|
||
paint(
|
||
[
|
||
[6, 4],
|
||
[7, 4],
|
||
[8, 4],
|
||
],
|
||
s.waterId,
|
||
);
|
||
paint([[9, 4]], s.lavaId);
|
||
return s;
|
||
}
|
||
|
||
type Setup = ReturnType<typeof setup>;
|
||
|
||
const cellsOf = (s: Setup) => triggerCells(s.project, s.assetOf().props, s.assetOf());
|
||
const flat = (cells: ReadonlyMap<string, readonly TriggerCell[]>) => [...cells.values()].flat();
|
||
|
||
/** A pair as a word: `area` or `cell`, the tile's name and the cell. */
|
||
function told(s: Setup, pairs: readonly CollisionPair[]): string[] {
|
||
const name = (id: string) => (id === s.waterId ? 'water' : id === s.lavaId ? 'lava' : id);
|
||
return pairs
|
||
.filter((pair) => pair.tile)
|
||
.map(
|
||
({ tile }) =>
|
||
`${tile!.perCell ? 'cell' : 'area'} ${name(tile!.tileId)} ${tile!.cell.x},${tile!.cell.y}`,
|
||
)
|
||
.sort();
|
||
}
|
||
|
||
describe('trigger tiles: which cells report (#160)', () => {
|
||
it('a tile reports once Trigger is on, the switch survives a round trip, and it wins over Solid', () => {
|
||
const s = setup();
|
||
expect(schemaFor('tile').find((d) => d.key === TILE_TRIGGER_KEY)).toMatchObject({
|
||
kind: 'boolean',
|
||
default: false,
|
||
});
|
||
const tile = (id: string, project = s.project) => {
|
||
const found = findAsset(project, id);
|
||
if (found?.type !== 'tile') throw new Error('no tile');
|
||
return found;
|
||
};
|
||
expect(isTileTrigger(tile(s.wallId))).toBe(false);
|
||
expect(isTileTrigger(tile(s.waterId, roundTrip(s.project)))).toBe(true);
|
||
s.stack.execute(new SetAssetPropertyCommand(s.waterId, TILE_SOLID_KEY, true));
|
||
expect(isTileTrigger(tile(s.waterId))).toBe(true);
|
||
expect(isTileSolid(tile(s.waterId))).toBe(false);
|
||
expect(solidRects(s.project, s.assetOf().props, s.assetOf())).toEqual([]);
|
||
});
|
||
|
||
it('merges into rectangles like solid cells, but never with them', () => {
|
||
const s = setup();
|
||
s.paint([[5, 4]], s.wallId);
|
||
const props = s.assetOf().props;
|
||
// Water and lava are both triggers, so the outline runs round the two together.
|
||
expect(triggerRects(s.project, props, s.assetOf())).toEqual([{ x: 6, y: 4, w: 4, h: 1 }]);
|
||
expect(solidRects(s.project, props, s.assetOf())).toEqual([{ x: 5, y: 4, w: 1, h: 1 }]);
|
||
});
|
||
|
||
it('an area is the cells of one tile touching side to side, named after its top-left cell', () => {
|
||
const s = setup();
|
||
s.paint(
|
||
[
|
||
[7, 5],
|
||
[9, 5],
|
||
[2, 2],
|
||
[3, 3],
|
||
],
|
||
s.waterId,
|
||
);
|
||
const areas = Object.fromEntries(
|
||
flat(cellsOf(s)).map((cell) => [
|
||
`${cell.tileId === s.waterId ? 'w' : 'l'} ${cell.x},${cell.y}`,
|
||
cell.area,
|
||
]),
|
||
);
|
||
expect(areas).toEqual({
|
||
'w 6,4': '6,4',
|
||
'w 7,4': '6,4',
|
||
'w 8,4': '6,4',
|
||
'w 7,5': '6,4',
|
||
// Lava lies between this one and the pond, and lava is not water.
|
||
'w 9,5': '9,5',
|
||
'l 9,4': '9,4',
|
||
// Corner to corner is not side to side.
|
||
'w 2,2': '2,2',
|
||
'w 3,3': '3,3',
|
||
});
|
||
});
|
||
|
||
it('a cell holding two trigger tiles on two layers reports both; outside the grid nothing does', () => {
|
||
const s = setup();
|
||
const add = new AddTilemapLayerCommand(s.mapId);
|
||
s.stack.execute(add);
|
||
s.paint(
|
||
[
|
||
[6, 4],
|
||
[12, 4],
|
||
],
|
||
s.lavaId,
|
||
add.layerId,
|
||
);
|
||
const cells = cellsOf(s);
|
||
expect(
|
||
cells
|
||
.get('6,4')!
|
||
.map((cell) => cell.tileId)
|
||
.sort(),
|
||
).toEqual([s.lavaId, s.waterId].sort());
|
||
expect(cells.has('12,4')).toBe(false);
|
||
});
|
||
|
||
it('the collision scale draws a cell in on its open sides only, as for a solid tile', () => {
|
||
const s = setup();
|
||
s.stack.execute(new SetAssetPropertyCommand(s.waterId, TILE_COLLISION_SCALE_KEY, 0.5));
|
||
const pieces = (x: number) =>
|
||
cellsOf(s)
|
||
.get(`${x},4`)!
|
||
.find((cell) => cell.tileId === s.waterId)!.rects;
|
||
const span = (x: number) => {
|
||
const rects = pieces(x);
|
||
return [Math.min(...rects.map((r) => r.x)), Math.max(...rects.map((r) => r.x + r.w))];
|
||
};
|
||
// The left end is drawn in, the side towards the next water cell is flush; lava is no water.
|
||
expect(span(6)).toEqual([6.25, 7]);
|
||
expect(span(7)).toEqual([7, 8]);
|
||
expect(span(8)).toEqual([8, 8.75]);
|
||
expect(pieces(7).every((r) => r.y === 4.25 && r.h === 0.5)).toBe(true);
|
||
});
|
||
|
||
it('a trigger tile with an outline reports with that polygon', () => {
|
||
const s = setup();
|
||
const texture = new CreateAssetCommand('texture', 'Sheet');
|
||
s.stack.execute(texture);
|
||
const sheet = findAsset(s.project, texture.asset.id) as TextureAsset;
|
||
sheet.file = `${'a'.repeat(64)}.png`;
|
||
sheet.width = 32;
|
||
sheet.height = 32;
|
||
const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 32, h: 32 });
|
||
s.stack.execute(frame);
|
||
s.stack.execute(new AddTilePictureCommand(s.lavaId, frame.frameId));
|
||
// The lower half of the tile.
|
||
const half = [
|
||
{ x: 0, y: 16 },
|
||
{ x: 32, y: 16 },
|
||
{ x: 32, y: 32 },
|
||
{ x: 0, y: 32 },
|
||
];
|
||
s.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, [half]));
|
||
const lava = cellsOf(s)
|
||
.get('9,4')!
|
||
.find((cell) => cell.tileId === s.lavaId)!;
|
||
expect(lava.rects).toEqual([]);
|
||
expect(lava.polygons.map((p) => p.outline)).toEqual([
|
||
[
|
||
{ x: 9, y: 4.5 },
|
||
{ x: 10, y: 4.5 },
|
||
{ x: 10, y: 5 },
|
||
{ x: 9, y: 5 },
|
||
],
|
||
]);
|
||
const props = s.assetOf().props;
|
||
expect(triggerPolygons(s.project, props, s.assetOf())).toHaveLength(1);
|
||
expect(triggerRects(s.project, props, s.assetOf())).toEqual([{ x: 6, y: 4, w: 3, h: 1 }]);
|
||
});
|
||
});
|
||
|
||
describe.each(COLLISION_SYSTEMS)('$system: trigger tiles report and never block (#160)', ({ physics }) => {
|
||
beforeAll(async () => {
|
||
await loadPhysics();
|
||
});
|
||
|
||
const run = (s: Setup) => {
|
||
const started = startWorld(s, { physics, collisions: true });
|
||
const hero = started.world.findByObjectId(s.hero)!;
|
||
const map = started.world.findByObjectId(s.mapObject)!;
|
||
const goTo = (x: number, y = -24) => {
|
||
hero.x = x;
|
||
hero.y = y;
|
||
started.world.tick(1 / 60);
|
||
};
|
||
return { ...started, hero, map, goTo };
|
||
};
|
||
|
||
it('walking through the pond: each cell and each area enters, stays and exits on its own', () => {
|
||
const s = setup();
|
||
const { collisions, goTo } = run(s);
|
||
goTo(0);
|
||
expect(collisions.enters).toEqual([]);
|
||
// 40 … 80: over the first water cell (48 … 96).
|
||
goTo(60);
|
||
expect(told(s, collisions.enters)).toEqual(['area water 6,4', 'cell water 6,4']);
|
||
expect(told(s, collisions.stays)).toEqual([]);
|
||
// 80 … 120: over cells 6 and 7, nearer to 7. The area is the one entered before.
|
||
goTo(100);
|
||
expect(told(s, collisions.enters)).toEqual(['cell water 7,4']);
|
||
expect(told(s, collisions.stays)).toEqual(['area water 7,4', 'cell water 6,4']);
|
||
expect(told(s, collisions.exits)).toEqual([]);
|
||
// 160 … 200: over the last water cell and the lava next to it.
|
||
goTo(180);
|
||
expect(told(s, collisions.enters)).toEqual(['area lava 9,4', 'cell lava 9,4', 'cell water 8,4']);
|
||
expect(told(s, collisions.stays)).toEqual(['area water 8,4']);
|
||
expect(told(s, collisions.exits)).toEqual(['cell water 6,4', 'cell water 7,4']);
|
||
goTo(0);
|
||
expect(told(s, collisions.exits)).toEqual([
|
||
'area lava 9,4',
|
||
'area water 8,4',
|
||
'cell lava 9,4',
|
||
'cell water 8,4',
|
||
]);
|
||
goTo(0);
|
||
expect(collisions.enters.length + collisions.stays.length + collisions.exits.length).toBe(0);
|
||
});
|
||
|
||
it('the pair is the collider and the map, apart from the pair of the map’s solid cells', () => {
|
||
const s = setup();
|
||
const add = new AddTilemapLayerCommand(s.mapId);
|
||
s.stack.execute(add);
|
||
s.paint([[6, 4]], s.wallId, add.layerId);
|
||
// The same map once more, placed after the hero: the order in the scene has no say in the pair.
|
||
const later = place(s, s.mapId, { x: 0, y: 0 });
|
||
const { collisions, world, goTo, map } = run(s);
|
||
goTo(60);
|
||
const collider = world.findByObjectId(s.heroCollider)!;
|
||
const maps = [map, world.findByObjectId(later)!];
|
||
const tiles = collisions.enters.filter((pair) => pair.tile);
|
||
expect(tiles).toHaveLength(4);
|
||
expect(tiles.every((pair) => pair.a === collider && maps.includes(pair.b))).toBe(true);
|
||
// The wall under the water is each map's own pair, and it names no tile.
|
||
expect(collisions.enters.filter((pair) => !pair.tile)).toHaveLength(2);
|
||
// Nothing presses on a trigger tile, whatever the map's solid cells say of the same collider.
|
||
for (const pair of tiles) expect(collisions.contactOf(pair)).toBeNull();
|
||
});
|
||
|
||
it('touching says so on a trigger tile, and no more off it', () => {
|
||
const s = setup();
|
||
const { collisions, world, hero, map, goTo } = run(s);
|
||
goTo(0);
|
||
expect(collisions.touching(world, hero, map)).toBe(false);
|
||
goTo(100);
|
||
expect(collisions.touching(world, hero, map)).toBe(true);
|
||
expect(collisions.touching(world, map, hero)).toBe(true);
|
||
goTo(100, 100);
|
||
expect(collisions.touching(world, hero, map)).toBe(false);
|
||
});
|
||
|
||
it('a move goes through it and a ray does not meet it, even with Solid ticked', () => {
|
||
const s = setup();
|
||
s.stack.execute(new SetAssetPropertyCommand(s.waterId, TILE_SOLID_KEY, true));
|
||
const { collisions, world, hero, goTo } = run(s);
|
||
goTo(0);
|
||
expect(collisions.moveAndSlide(world, hero, 150, 0)).toMatchObject({ blockedX: false, hit: false });
|
||
expect(hero.x).toBe(150);
|
||
const ray = { ignore: hero, triggers: true, layers: ALL_LAYERS };
|
||
expect(collisions.raycast(world, { x: -200, y: -24 }, { x: 1, y: 0 }, 500, ray)).toBeNull();
|
||
});
|
||
|
||
it('nothing is told for a map switched off, a collider switched off, or layers that do not meet', () => {
|
||
const s = setup();
|
||
const { collisions, world, map, goTo } = run(s);
|
||
const collider = world.findByObjectId(s.heroCollider)!;
|
||
goTo(100);
|
||
expect(told(s, collisions.enters)).toHaveLength(3);
|
||
map.props.enabled = false;
|
||
goTo(100);
|
||
expect(told(s, collisions.exits)).toHaveLength(3);
|
||
map.props.enabled = true;
|
||
collider.props.enabled = false;
|
||
goTo(100);
|
||
expect(told(s, collisions.enters)).toEqual([]);
|
||
collider.props.enabled = true;
|
||
map.props[COLLIDES_WITH_KEY] = 0;
|
||
goTo(100);
|
||
expect(told(s, collisions.enters)).toEqual([]);
|
||
expect(collisions.touching(world, collider, map)).toBe(false);
|
||
map.props[COLLIDES_WITH_KEY] = ALL_LAYERS;
|
||
goTo(100);
|
||
expect(told(s, collisions.enters)).toHaveLength(3);
|
||
});
|
||
|
||
it('follows the placed map, and a tile set during the run', () => {
|
||
const s = setup();
|
||
const { collisions, world, map, goTo } = run(s);
|
||
goTo(0);
|
||
// Cell 4,4 spans -48 … 0: the hero stands on it and on 5,4.
|
||
expect(world.setTile(map, '', { x: 4, y: 4 }, s.lavaId)).toBe(true);
|
||
goTo(0);
|
||
expect(told(s, collisions.enters)).toEqual(['area lava 4,4', 'cell lava 4,4']);
|
||
world.setTile(map, '', { x: 4, y: 4 }, '');
|
||
goTo(0);
|
||
expect(told(s, collisions.exits)).toEqual(['area lava 4,4', 'cell lava 4,4']);
|
||
// The map moved 48 to the left: the pond now begins at the hero's cell.
|
||
map.x = -48;
|
||
goTo(20);
|
||
expect(told(s, collisions.enters)).toEqual(['area water 6,4', 'cell water 6,4']);
|
||
});
|
||
|
||
it('a cell grown by its collision scale is met from the cell next to it', () => {
|
||
const s = setup();
|
||
s.stack.execute(new SetAssetPropertyCommand(s.waterId, TILE_COLLISION_SCALE_KEY, 2));
|
||
const { collisions, goTo } = run(s);
|
||
// Cell 6 begins at 48 and reaches half a cell further out, to 24; the hero ends at 30, in cell 5.
|
||
goTo(10);
|
||
expect(told(s, collisions.enters)).toEqual(['area water 6,4', 'cell water 6,4']);
|
||
});
|
||
|
||
it('a system stopped and started again forgets what it told: the pond is entered anew', () => {
|
||
const s = setup();
|
||
const { collisions, world, goTo } = run(s);
|
||
goTo(100);
|
||
expect(told(s, collisions.enters)).toHaveLength(3);
|
||
const system = collisions as CollisionSource & System;
|
||
system.stop?.(world);
|
||
expect(collisions.enters.length + collisions.stays.length).toBe(0);
|
||
system.start?.(world);
|
||
goTo(100);
|
||
expect(told(s, collisions.enters)).toHaveLength(3);
|
||
expect(told(s, collisions.stays)).toEqual([]);
|
||
});
|
||
|
||
it('a collider under the map itself is told nothing', () => {
|
||
const s = setup();
|
||
const own = new AddColliderCommand(s.sceneId, s.mapObject);
|
||
s.stack.execute(own);
|
||
s.stack.execute(new SetOverrideCommand(s.sceneId, own.objectId, 'width', 400));
|
||
const { collisions, world, goTo } = run(s);
|
||
goTo(0);
|
||
const collider = world.findByObjectId(own.objectId)!;
|
||
expect(collisions.enters.filter((pair) => pair.tile && pair.a === collider)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe.each(COLLISION_SYSTEMS)('$system: the collision events tell the tile (#160)', ({ physics }) => {
|
||
beforeAll(async () => {
|
||
await loadPhysics();
|
||
});
|
||
|
||
/** A behavior on the hero logging five lines per event: the label, the tile, its cell and the other body. */
|
||
function logging(s: Setup, type: string, label: string, params = {}) {
|
||
const behavior = makeBehavior(s, `${label} listener`);
|
||
place(s, behavior.id, { x: 0, y: 0 }, s.hero);
|
||
const edit = graphEditor(s, behavior);
|
||
const event = edit.addNode(type, params);
|
||
const logs = ['tile', 'cellX', 'cellY', 'other'].map(() => edit.addNode('Log'));
|
||
const mark = edit.addNode('Log', { message: label });
|
||
edit.wire([
|
||
[event, 'exec', mark, 'exec'],
|
||
[mark, 'exec', logs[0]!, 'exec'],
|
||
[logs[0]!, 'exec', logs[1]!, 'exec'],
|
||
[logs[1]!, 'exec', logs[2]!, 'exec'],
|
||
[logs[2]!, 'exec', logs[3]!, 'exec'],
|
||
[event, 'tile', logs[0]!, 'message'],
|
||
[event, 'cellX', logs[1]!, 'message'],
|
||
[event, 'cellY', logs[2]!, 'message'],
|
||
[event, 'other', logs[3]!, 'message'],
|
||
]);
|
||
return { edit, event };
|
||
}
|
||
|
||
const walk = (s: Setup) => {
|
||
const started = startWorld(s, { physics, collisions: true, behaviors: true });
|
||
const hero = started.world.findByObjectId(s.hero)!;
|
||
const goTo = (x: number) => {
|
||
hero.x = x;
|
||
started.world.tick(1 / 60);
|
||
const said = started.world.log.splice(0);
|
||
// Five lines per event: the label, the tile, the cell and the other body.
|
||
const events: string[] = [];
|
||
for (let i = 0; i < said.length; i += 5) events.push(said.slice(i, i + 5).join(' '));
|
||
return events;
|
||
};
|
||
return { ...started, hero, goTo };
|
||
};
|
||
|
||
it('enter and exit run once for the pond by default, and once per cell with "per cell"', () => {
|
||
const s = setup();
|
||
logging(s, 'OnCollisionEnter', 'in');
|
||
logging(s, 'OnCollisionExit', 'out');
|
||
logging(s, 'OnCollisionEnter', 'in-cell', { perCell: true });
|
||
logging(s, 'OnCollisionExit', 'out-cell', { perCell: true });
|
||
const { goTo } = walk(s);
|
||
const w = s.waterId;
|
||
expect(goTo(0)).toEqual([]);
|
||
expect(goTo(60)).toEqual([`in ${w} 6 4 Ground`, `in-cell ${w} 6 4 Ground`]);
|
||
expect(goTo(100)).toEqual([`in-cell ${w} 7 4 Ground`]);
|
||
expect(goTo(140)).toEqual([`in-cell ${w} 8 4 Ground`, `out-cell ${w} 6 4 Ground`]);
|
||
// At 140 the hero was nearer to cell 7 than to 8, and that is the area's last word.
|
||
expect(goTo(0)).toEqual([`out ${w} 7 4 Ground`, `out-cell ${w} 7 4 Ground`, `out-cell ${w} 8 4 Ground`]);
|
||
});
|
||
|
||
it('stay tells the nearest cell of the area, or every cell lain on', () => {
|
||
const s = setup();
|
||
logging(s, 'OnCollisionStay', 'on');
|
||
logging(s, 'OnCollisionStay', 'on-cell', { perCell: true });
|
||
const { goTo } = walk(s);
|
||
const w = s.waterId;
|
||
expect(goTo(100)).toEqual([]);
|
||
expect(goTo(90)).toEqual([`on ${w} 6 4 Ground`, `on-cell ${w} 6 4 Ground`, `on-cell ${w} 7 4 Ground`]);
|
||
expect(goTo(110)).toEqual([`on ${w} 7 4 Ground`, `on-cell ${w} 6 4 Ground`, `on-cell ${w} 7 4 Ground`]);
|
||
});
|
||
|
||
it('"per cell" is read from what feeds it, each time the event comes', () => {
|
||
const s = setup();
|
||
const { edit, event } = logging(s, 'OnCollisionEnter', 'in');
|
||
const held = edit.addNode('KeyIsDown', { key: 'c' });
|
||
edit.connect(held, 'down', event, 'perCell');
|
||
const { goTo, input } = walk(s);
|
||
const w = s.waterId;
|
||
expect(goTo(60)).toEqual([`in ${w} 6 4 Ground`]);
|
||
input.press('c');
|
||
// With the key held the node hears cells: the area it stands in is not news to it, cell 7 is.
|
||
expect(goTo(100)).toEqual([`in ${w} 7 4 Ground`]);
|
||
});
|
||
|
||
it('a stay with an interval keeps one clock per cell', () => {
|
||
const s = setup();
|
||
logging(s, 'OnCollisionStay', 'on-cell', { perCell: true, interval: 0.04 });
|
||
const { goTo } = walk(s);
|
||
const w = s.waterId;
|
||
// Cell 6 is entered two frames before cell 7, so its 0.04 s are up while cell 7 still waits.
|
||
expect(goTo(60)).toEqual([]);
|
||
expect(goTo(60)).toEqual([]);
|
||
expect(goTo(100)).toEqual([]);
|
||
expect(goTo(100)).toEqual([`on-cell ${w} 6 4 Ground`]);
|
||
expect(goTo(100)).toEqual([]);
|
||
// Three frames on, cell 7 is due for the first time and cell 6, with 0.01 s left over, again.
|
||
expect(goTo(100)).toEqual([`on-cell ${w} 6 4 Ground`, `on-cell ${w} 7 4 Ground`]);
|
||
});
|
||
|
||
it('another collider and the solid cells of a map tell no tile: empty and -1, -1', () => {
|
||
const s = setup();
|
||
logging(s, 'OnCollisionEnter', 'in');
|
||
s.paint([[5, 4]], s.wallId);
|
||
const crate = place(s, s.spriteId, { x: -200, y: -24 });
|
||
s.stack.execute(new AddColliderCommand(s.sceneId, crate));
|
||
const { goTo } = walk(s);
|
||
// 0 … 40: over the wall in cell 5 (0 … 48), short of the water.
|
||
expect(goTo(20)).toEqual(['in -1 -1 Ground']);
|
||
expect(goTo(-170)).toEqual(['in -1 -1 Hero']);
|
||
});
|
||
|
||
it('a behavior on the map hears who walks onto its tiles', () => {
|
||
const s = setup();
|
||
const behavior = makeBehavior(s, 'Pond');
|
||
place(s, behavior.id, { x: 0, y: 0 }, s.mapObject);
|
||
const edit = graphEditor(s, behavior);
|
||
const enter = edit.addNode('OnCollisionEnter');
|
||
const who = edit.addNode('Log');
|
||
const where = edit.addNode('Log');
|
||
edit.wire([
|
||
[enter, 'exec', who, 'exec'],
|
||
[who, 'exec', where, 'exec'],
|
||
[enter, 'other', who, 'message'],
|
||
[enter, 'cellX', where, 'message'],
|
||
]);
|
||
const { world, hero } = walk(s);
|
||
hero.x = 180;
|
||
world.tick(1 / 60);
|
||
// Water and lava are two areas, so two events.
|
||
expect(world.log).toEqual(['Hero', '8', 'Hero', '9']);
|
||
});
|
||
});
|
||
|
||
describe('a dynamic body falls through trigger tiles and is told (#160)', () => {
|
||
beforeAll(async () => {
|
||
await loadPhysics();
|
||
});
|
||
|
||
it('keeps falling, and enters and leaves the pond on the way', () => {
|
||
const s = setup();
|
||
s.stack.execute(new AddRigidbodyCommand(s.sceneId, s.hero));
|
||
const { world, collisions } = startWorld(s, { physics: true, collisions: true });
|
||
const hero = world.findByObjectId(s.hero)!;
|
||
// Above the pond's middle cell (96 … 144), which lies at -48 … 0.
|
||
hero.x = 120;
|
||
hero.y = -150;
|
||
const seen: string[] = [];
|
||
for (let i = 0; i < 90; i++) {
|
||
world.tick(1 / 60);
|
||
for (const pair of collisions.enters) if (pair.tile && !pair.tile.perCell) seen.push('enter');
|
||
for (const pair of collisions.exits) if (pair.tile && !pair.tile.perCell) seen.push('exit');
|
||
}
|
||
expect(seen).toEqual(['enter', 'exit']);
|
||
expect(hero.y).toBeGreaterThan(100);
|
||
});
|
||
});
|