import { beforeAll, describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { AddColliderCommand } 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 { PaintTilesCommand } from '../../src/core/commands/tilemapCommands'; import type { TextureAsset, TilemapAsset } from '../../src/core/model/Asset'; import { IDENTITY, transformMatrix } from '../../src/core/model/matrix'; import type { PropertyValue, Vec2 } from '../../src/core/model/Properties'; import { findAsset } from '../../src/core/model/Project'; import { TILE_SOLID_KEY } from '../../src/core/model/tile'; import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../../src/core/model/tilemap'; import { cellsMeet, cellsWithin } from '../../src/core/model/tilemapCollision'; import { boundsMeet, grownBounds, shapeBounds } from '../../src/core/properties/collider'; import type { Entity } from '../../src/runtime/Entity'; import { loadPhysics } from '../../src/runtime/physics/engine'; import { Solids } from '../../src/runtime/systems/solids'; import { COLLISION_SYSTEMS, makeSpriteScene, place, startWorld } from '../helpers'; beforeAll(async () => { await loadPhysics(); }); /** A jagged ring inside a 32 px cell, like the lake outlines of #184. */ function jagged(corners: number, r: number): Vec2[] { return Array.from({ length: corners }, (_, i) => { const a = (i / corners) * Math.PI * 2; const rad = r * (i % 2 === 0 ? 1 : 0.7); return { x: 16 + Math.cos(a) * rad, y: 16 + Math.sin(a) * rad }; }); } function scene() { const f = makeSpriteScene('Thing'); const add = (at: Vec2, collider: Record) => { const id = place(f, f.spriteId, at); const command = new AddColliderCommand(f.sceneId, id); f.stack.execute(command); for (const [key, value] of Object.entries(collider)) f.stack.execute(new SetOverrideCommand(f.sceneId, command.objectId, key, value)); return { id, collider: command.objectId }; }; return { ...f, add }; } /** * A map of `cells` × `cells` cells of 32 px, every one painted with a tile whose picture carries an * eight-cornered outline – the shape of #184. The map sits at the origin, so it spans * −16·`cells` … 16·`cells` on both axes. */ function outlinedMap(cells: number, angle = 0, only?: Vec2) { const f = scene(); const texture = new CreateAssetCommand('texture', 'Sheet'); const tile = new CreateAssetCommand('tile', 'Lake'); const map = new CreateAssetCommand('tilemap', 'Ground'); for (const command of [texture, tile, map]) f.stack.execute(command); const sheet = findAsset(f.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 }); f.stack.execute(frame); f.stack.execute(new AddTilePictureCommand(tile.asset.id, frame.frameId)); f.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_SOLID_KEY, true)); f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, [jagged(8, 15)])); f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, cells)); f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, cells)); f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_TILE_KEY, 32)); const mapId = place(f, map.asset.id, { x: 0, y: 0 }); if (angle !== 0) f.stack.execute(new SetOverrideCommand(f.sceneId, mapId, 'rotation', angle)); const asset = findAsset(f.project, map.asset.id) as TilemapAsset; const painted: { x: number; y: number; tileId: string }[] = only ? [{ x: only.x, y: only.y, tileId: tile.asset.id }] : []; if (!only) for (let x = 0; x < cells; x++) for (let y = 0; y < cells; y++) painted.push({ x, y, tileId: tile.asset.id }); f.stack.execute(new PaintTilesCommand(map.asset.id, asset.layers[0]!.id, painted)); return { ...f, mapId }; } describe('the broad phase of a move and a ray (#184)', () => { it('a rectangle around a shape holds every corner of it, turned as it is', () => { const square = shapeBounds({ kind: 'box', x: 0, y: 0, w: 10, h: 10, angle: Math.PI / 4 }); expect(square.right).toBeCloseTo(Math.hypot(5, 5), 9); expect(square.left).toBeCloseTo(-Math.hypot(5, 5), 9); expect(shapeBounds({ kind: 'circle', x: 3, y: -2, r: 4 })).toEqual({ left: -1, top: -6, right: 7, bottom: 2, }); const capsule = shapeBounds({ kind: 'capsule', x: 0, y: 0, angle: 0, a: { x: 0, y: -5 }, b: { x: 0, y: 5 }, r: 2, }); expect(capsule).toEqual({ left: -2, top: -7, right: 2, bottom: 7 }); const polygon = shapeBounds({ kind: 'polygon', x: 0, y: 0, angle: 0, outline: [ { x: -3, y: 0 }, { x: 4, y: 1 }, { x: 0, y: 9 }, ], pieces: [], }); expect(polygon).toEqual({ left: -3, top: 0, right: 4, bottom: 9 }); }); it('two rectangles that share an edge still count as meeting, so nothing near is dropped', () => { const a = { left: 0, top: 0, right: 10, bottom: 10 }; expect(boundsMeet(a, { left: 10, top: 10, right: 20, bottom: 20 })).toBe(true); expect(boundsMeet(a, { left: 10.001, top: 0, right: 20, bottom: 10 })).toBe(false); expect(boundsMeet(a, grownBounds({ left: 12, top: 0, right: 20, bottom: 10 }, 2))).toBe(true); }); it('the cells of a map within a rectangle are read in cells, a turned map through its own axes', () => { // A 4 × 4 map of 32 px cells at the origin spans −64 … 64; cell (0,0) is its top left. const props = { [TILEMAP_COLUMNS_KEY]: 4, [TILEMAP_ROWS_KEY]: 4, [TILEMAP_TILE_KEY]: 32 }; const straight = cellsWithin(props, IDENTITY, { left: -64, top: -64, right: -32, bottom: -32 }); expect(straight).toEqual({ left: 0, top: 0, right: 1, bottom: 1 }); expect(cellsMeet(straight, 0, 0)).toBe(true); expect(cellsMeet(straight, 2, 2)).toBe(false); // Turned by 90°, the world rectangle of the top left corner covers the map's top right one. const turned = cellsWithin(props, transformMatrix(0, 0, 90, 1, false, false), { left: -64, top: -64, right: -32, bottom: -32, }); expect(turned.left).toBeCloseTo(0, 9); expect(turned.right).toBeCloseTo(1, 9); expect(turned.top).toBeCloseTo(3, 9); expect(turned.bottom).toBeCloseTo(4, 9); }); it('a map hands out only the cells of the rectangle it is asked about, and all of them without one', () => { const f = outlinedMap(20); const { world } = startWorld(f); const map = world.findByObjectId(f.mapId)!; const solids = new Solids(); expect(solids.solidShapesOf(world, map)).toHaveLength(400); // One cell of the map, grown by a hair: its own shape and those of the cells it touches. const near = solids.solidShapesOf(world, map, { left: -320, top: -320, right: -300, bottom: -300 }); expect(near.length).toBeGreaterThan(0); expect(near.length).toBeLessThan(10); expect(solids.solidShapesOf(world, map, { left: 1e6, top: 1e6, right: 2e6, bottom: 2e6 })).toEqual([]); }); }); describe.each(COLLISION_SYSTEMS)('$system: a move sees everything it can reach (#184)', ({ physics }) => { const mover = (f: ReturnType, at: Vec2) => f.add(at, { shape: 'box', width: 16, height: 16 }); it('a long move into a map far away still stops at its first cell, however far it starts', () => { const f = outlinedMap(20); const m = mover(f, { x: -1000, y: 0 }); const started = startWorld(f, { physics, collisions: true }); const e: Entity = started.world.findByObjectId(m.id)!; // The map spans −320 … 320, so the move of 800 units has to be stopped well before its end. const result = started.collisions.moveAndSlide(started.world, e, 800, 0); expect(result.blockedX).toBe(true); expect(e.x).toBeGreaterThan(-400); expect(e.x).toBeLessThan(-300); }); it('a turned map blocks where its one painted cell has turned to, not where it lies in the asset', () => { // Turned by 90°, a point of the map lands at (−y, x): the cell at the left middle of the asset // (own units −304, −16) sits at the top middle of the world, so a move down runs into it. const f = outlinedMap(20, 90, { x: 0, y: 9 }); const m = mover(f, { x: 16, y: -1000 }); const started = startWorld(f, { physics, collisions: true }); const e: Entity = started.world.findByObjectId(m.id)!; const result = started.collisions.moveAndSlide(started.world, e, 0, 800); expect(result.blockedY).toBe(true); expect(e.y).toBeLessThan(-300); expect(e.y).toBeGreaterThan(-340); }); it('a ray across the whole map still meets its first cell', () => { const f = outlinedMap(20); const started = startWorld(f, { physics, collisions: true }); const hit = started.collisions.raycast(started.world, { x: -1000, y: -16 }, { x: 1, y: 0 }, 2000, { ignore: null, triggers: false, layers: 0xffff, }); expect(hit).not.toBeNull(); expect(hit!.distance).toBeGreaterThan(600); expect(hit!.distance).toBeLessThan(700); }); }); describe('the editor run over a big map (#184)', () => { it('a collider on a far cell of a big map still counts as touching it', () => { // The only painted cell is the bottom right one: its middle sits at (304, 304). const f = outlinedMap(20, 0, { x: 19, y: 19 }); const on = f.add({ x: 304, y: 304 }, { shape: 'box', width: 16, height: 16 }); const off = f.add({ x: 304, y: 100 }, { shape: 'box', width: 16, height: 16 }); const started = startWorld(f, { collisions: true }); const map = started.world.findByObjectId(f.mapId)!; expect(started.collisions.touching(started.world, started.world.findByObjectId(on.id)!, map)).toBe(true); expect(started.collisions.touching(started.world, started.world.findByObjectId(off.id)!, map)).toBe( false, ); }); });