Files
feuerwerk/tests/core/tile-triggers.test.ts
T
adminandClaude Opus 5.5 275e50af01 Let a tile report a touch instead of blocking it (#160)
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>
2026-10-02 07:28:39 +02:00

569 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});