Files
feuerwerk/tests/core/tilemap-collision.test.ts
T

543 lines
23 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 } 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 { loadPhysics } from '../../src/runtime/physics/engine';
import {
COLLISION_SYSTEMS,
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.each(COLLISION_SYSTEMS)('$system', ({ physics }) => {
beforeAll(async () => {
await loadPhysics();
});
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, { physics, 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, { physics, 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, { physics, 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, { physics, 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, { physics, 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, { physics, 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, { physics, 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, { physics, 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, { physics, 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, { physics, 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, { physics, 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.each(COLLISION_SYSTEMS)('$system', ({ physics }) => {
beforeAll(async () => {
await loadPhysics();
});
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, { physics, 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, { physics, 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);
});
});
});