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 { PaintTilesCommand } from '../../src/core/commands/tilemapCommands'; import type { TextureAsset, TilemapAsset, TileAsset } from '../../src/core/model/Asset'; import { translation } from '../../src/core/model/matrix'; import type { PropertyValue, Vec2 } from '../../src/core/model/Properties'; import { findAsset } from '../../src/core/model/Project'; import { TILE_BOUNCINESS_KEY, TILE_FRICTION_KEY, TILE_ONE_WAY_ANGLE_KEY, TILE_ONE_WAY_KEY, TILE_SOLID_KEY, } from '../../src/core/model/tile'; import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap'; import { oneWayAngles, solidRects, tileMaterial, tileMaterials } from '../../src/core/model/tilemapCollision'; import { colliderFrame, colliderShape, colliderSize, outlinesOf, shapeContains, } from '../../src/core/properties/collider'; import { isRowShown, schemaFor } from '../../src/core/properties/PropertySchema'; import { loadPhysics } from '../../src/runtime/physics/engine'; import { COLLISION_SYSTEMS, makeSpriteScene, place, roundTrip, startWorld } from '../helpers'; beforeAll(async () => { await loadPhysics(); }); const P = (...xy: number[]) => Array.from({ length: xy.length / 2 }, (_, i) => ({ x: xy[2 * i]!, y: xy[2 * i + 1]! })); /** * A scene with a texture of one 40 × 40 frame, and `add`, which places an object with a collider * (and maybe a rigidbody) and sets the collider's properties. */ function frameScene() { const f = makeSpriteScene('Thing'); const texture = new CreateAssetCommand('texture', 'Sheet'); f.stack.execute(texture); const sheet = findAsset(f.project, texture.asset.id) as TextureAsset; sheet.file = `${'a'.repeat(64)}.png`; sheet.width = 64; sheet.height = 64; const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 40, h: 40 }); f.stack.execute(frame); const outline = (...outlines: Vec2[][]) => f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, outlines)); const add = (at: Vec2, collider: Record, body?: 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)); if (body) f.stack.execute(new AddRigidbodyCommand(f.sceneId, id)); return id; }; return { ...f, textureId: texture.asset.id, frameId: frame.frameId, outline, add }; } /** The right half of the 40 × 40 frame: in the collider's units x runs 0 … 20, y −20 … 20. */ const RIGHT_HALF = P(20, 0, 40, 0, 40, 40, 20, 40); /** A triangle in the top left corner and a square in the bottom right: two rings. */ const TRIANGLE = P(0, 0, 16, 0, 0, 16); const SQUARE = P(24, 24, 36, 24, 36, 36, 24, 36); describe('a collider wearing a frame outline (#193)', () => { it('is a shape of its own with a frame row, the frame picked like a texture', () => { const schema = schemaFor('collider'); const shape = schema.find((d) => d.key === 'shape')!; expect(shape.kind === 'enum' && shape.options.map((o) => o.value)).toContain('frame'); expect(schema.find((d) => d.key === 'frame')).toMatchObject({ kind: 'texture', default: '' }); }); it('takes every ring of the frame, centred on its origin, a pixel to a unit, and the frame size', () => { const s = frameScene(); s.outline(TRIANGLE, SQUARE); const props = { shape: 'frame', frame: s.frameId }; const frame = colliderFrame(s.project, props); expect(frame).toMatchObject({ w: 40, h: 40 }); expect(colliderSize(props, frame)).toEqual({ w: 40, h: 40 }); const shape = colliderShape(props, translation(100, 50), frame); if (shape.kind !== 'polygon') throw new Error(shape.kind); expect(outlinesOf(shape)).toEqual([P(80, 30, 96, 30, 80, 46), P(104, 54, 116, 54, 116, 66, 104, 66)]); // Inside a ring collides, the gap between them does not. expect(shapeContains(shape, { x: 83, y: 33 })).toBe(true); expect(shapeContains(shape, { x: 110, y: 60 })).toBe(true); expect(shapeContains(shape, { x: 100, y: 50 })).toBe(false); }); it('stays linked: a redrawn outline is what the collider wears next, and undo brings the old one back', () => { const s = frameScene(); s.outline(TRIANGLE); const props = { shape: 'frame', frame: s.frameId }; const ringsNow = () => colliderFrame(s.project, props)!.rings; expect(ringsNow()).toHaveLength(1); s.outline(TRIANGLE, SQUARE); expect(ringsNow()).toHaveLength(2); s.stack.undo(); expect(ringsNow()).toEqual([P(-20, -20, -4, -20, -20, -4)]); }); it('without a frame, with a whole texture or with nothing drawn it is a box that collides with nothing', () => { const s = frameScene(); const shapeOf = (frame: string) => { const props = { shape: 'frame', frame, width: 30, height: 10 }; return colliderShape(props, translation(0, 0), colliderFrame(s.project, props)); }; // No frame, or an id that is no frame: the collider's own width × height. for (const id of ['', s.textureId, 'gone']) expect(shapeOf(id), id).toMatchObject({ kind: 'polygon', outline: P(-15, -5, 15, -5, 15, 5, -15, 5), pieces: [], }); // A frame nobody drew an outline on: the frame's box. expect(shapeOf(s.frameId)).toMatchObject({ outline: P(-20, -20, 20, -20, 20, 20, -20, 20), pieces: [] }); }); it('keeps shape and frame through a round trip', () => { const s = frameScene(); s.outline(RIGHT_HALF); s.add({ x: 0, y: 0 }, { shape: 'frame', frame: s.frameId }); const collider = roundTrip(s.project).assets.find((a) => a.type === 'collider')!; const object = roundTrip(s.project) .assets.flatMap((a) => (a.type === 'scene' ? a.objects : [])) .find((o) => o.assetId === collider.id)!; expect(object.overrides).toMatchObject({ shape: 'frame', frame: s.frameId }); }); }); describe.each(COLLISION_SYSTEMS)( '$system: a frame outline blocks where it is drawn (#193)', ({ physics }) => { it('Move stops at the outline, not at the frame box, and a click hits only the outline', () => { const s = frameScene(); s.outline(RIGHT_HALF); const wall = s.add({ x: 100, y: 0 }, { shape: 'frame', frame: s.frameId }); const hero = s.add({ x: 0, y: 0 }, { width: 20, height: 20 }); const { world, collisions } = startWorld(s, { physics, collisions: true }); const mover = world.findByObjectId(hero)!; expect(collisions.moveAndSlide(world, mover, 200, 0)).toMatchObject({ blockedX: true, hit: true }); // The outline's left face is at 100 (the frame's would be at 80): the hero's right side stops there. expect(mover.x).toBeCloseTo(90, 6); const target = world.findByObjectId(wall)!; expect(world.hitTest(target, 110, 0)).toBe(true); expect(world.hitTest(target, 90, 0)).toBe(false); }); }, ); describe('physics: a body lands on a frame outline (#193)', () => { it('a dropped box comes to rest on the drawn top, not on the frame box', () => { const s = frameScene(); // The lower half of the frame: its top face lies at the collider's origin. s.outline(P(0, 20, 40, 20, 40, 40, 0, 40)); s.add({ x: 0, y: 200 }, { shape: 'frame', frame: s.frameId }); const box = s.add({ x: 0, y: 100 }, { width: 10, height: 10 }, {}); const started = startWorld(s, { physics: true, collisions: true }); for (let i = 0; i < 120; i++) started.frame(1 / 60); // Resting on y = 200, half its 10 up; the frame box would hold it at 175. expect(started.world.findByObjectId(box)!.y).toBeCloseTo(195, 0); }); }); /** * A scene with a 10 × 10 map of 48 px cells at the origin – cell (x, y) spans x·48 − 240 … – a * solid Wall and a solid Ice tile, and a 40 × 40 hero with a collider at the origin. */ function tileScene() { 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 ice = new CreateAssetCommand('tile', 'Ice'); const map = new CreateAssetCommand('tilemap', 'Ground'); for (const command of [wall, ice, map]) f.stack.execute(command); for (const tile of [wall, ice]) f.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_SOLID_KEY, true)); f.stack.execute(new SetAssetPropertyCommand(ice.asset.id, TILE_FRICTION_KEY, 0)); f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10)); f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10)); 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 mapAsset = () => findAsset(f.project, map.asset.id) as TilemapAsset; const paint = (at: [number, number][], tileId: string) => f.stack.execute( new PaintTilesCommand( map.asset.id, mapAsset().layers[0]!.id, at.map(([x, y]) => ({ x, y, tileId })), ), ); const tile = (id: string) => findAsset(f.project, id) as TileAsset; return { ...f, wallId: wall.asset.id, iceId: ice.asset.id, mapAsset, paint, hero, tile }; } const COLUMN_SIX = Array.from({ length: 10 }, (_, y) => [6, y] as [number, number]); describe('the surface of a solid tile (#193)', () => { it('has the rows and defaults of a collider, no density, shown only while the tile is solid', () => { const tile = schemaFor('tile'); expect(tile.find((d) => d.key === TILE_FRICTION_KEY)).toMatchObject({ kind: 'number', default: 0.5, min: 0, }); expect(tile.find((d) => d.key === TILE_BOUNCINESS_KEY)).toMatchObject({ default: 0, min: 0, max: 1 }); expect(tile.some((d) => d.key === 'density')).toBe(false); const row = tile.find((d) => d.key === TILE_FRICTION_KEY)!; expect(isRowShown(row, { [TILE_SOLID_KEY]: false })).toBe(false); expect(isRowShown(row, { [TILE_SOLID_KEY]: true })).toBe(true); }); it('is read like a collider material and survives a round trip', () => { const s = tileScene(); expect(tileMaterial(s.tile(s.wallId))).toEqual({ friction: 0.5, bounciness: 0 }); s.stack.execute(new SetAssetPropertyCommand(s.iceId, TILE_BOUNCINESS_KEY, 0.8)); const ice = findAsset(roundTrip(s.project), s.iceId) as TileAsset; expect(tileMaterial(ice)).toEqual({ friction: 0, bounciness: 0.8 }); // Out of range or unreadable, held like a collider's. s.tile(s.iceId).props[TILE_BOUNCINESS_KEY] = 7; s.tile(s.iceId).props[TILE_FRICTION_KEY] = 'x'; expect(tileMaterial(s.tile(s.iceId))).toEqual({ friction: 0.5, bounciness: 1 }); }); it('ice and wall never merge into one rectangle; each surface is its own group', () => { const s = tileScene(); s.paint( [ [0, 0], [1, 0], ], s.wallId, ); s.paint( [ [2, 0], [3, 0], ], s.iceId, ); const asset = s.mapAsset(); // Drawn, the row is one rectangle; for collision, one per surface. expect(solidRects(s.project, asset.props, asset)).toEqual([{ x: 0, y: 0, w: 4, h: 1 }]); const materials = tileMaterials(s.project, asset); expect(materials).toEqual([ { friction: 0, bounciness: 0 }, { friction: 0.5, bounciness: 0 }, ]); expect(solidRects(s.project, asset.props, asset, false, materials[0])).toEqual([ { x: 2, y: 0, w: 2, h: 1 }, ]); expect(solidRects(s.project, asset.props, asset, false, materials[1])).toEqual([ { x: 0, y: 0, w: 2, h: 1 }, ]); }); it('a tile that does not block brings no surface and no one-way angle of its own', () => { const s = tileScene(); s.stack.execute(new SetAssetPropertyCommand(s.iceId, TILE_SOLID_KEY, false)); s.stack.execute(new SetAssetPropertyCommand(s.iceId, TILE_ONE_WAY_KEY, true)); s.stack.execute(new SetAssetPropertyCommand(s.iceId, TILE_ONE_WAY_ANGLE_KEY, 30)); s.paint([[0, 0]], s.wallId); s.paint([[1, 0]], s.iceId); const asset = s.mapAsset(); expect(tileMaterials(s.project, asset)).toEqual([{ friction: 0.5, bounciness: 0 }]); expect(oneWayAngles(s.project, asset)).toEqual([]); }); }); describe.each(COLLISION_SYSTEMS)('$system: Move slides by the tile surface (#193)', ({ physics }) => { /** The hero pressed against column six while walking down it, 60 times: how far down it got. */ function slideDown(tileId: 'wall' | 'ice'): number { const s = tileScene(); s.paint(COLUMN_SIX, tileId === 'wall' ? s.wallId : s.iceId); 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++) collisions.moveAndSlide(world, hero, 3.1, 6.7); expect(hero.x).toBeCloseTo(28, 6); return hero.y + 200; } it('the pair averages the hero’s 0.5 with the tile’s own friction', () => { expect(slideDown('wall')).toBeCloseTo(60 * (6.7 - 0.5 * 3.1), 6); expect(slideDown('ice')).toBeCloseTo(60 * (6.7 - 0.25 * 3.1), 6); }); }); describe('physics: a ball bounces by the tile it lands on (#193)', () => { /** How high a ball dropped onto the bottom row climbs again after its first bounce. */ function rebound(bounciness: number): number { const s = tileScene(); s.stack.execute(new SetAssetPropertyCommand(s.iceId, TILE_BOUNCINESS_KEY, bounciness)); s.paint( Array.from({ length: 10 }, (_, x) => [x, 9] as [number, number]), s.iceId, ); // top face at y = 192 // Clear of the hero, who stands on the origin with a collider of its own. const ball = place(s, s.spriteId, { x: 120, y: 0 }); const collider = new AddColliderCommand(s.sceneId, ball); s.stack.execute(collider); for (const [key, value] of Object.entries({ shape: 'circle', radius: 16 })) s.stack.execute(new SetOverrideCommand(s.sceneId, collider.objectId, key, value)); s.stack.execute(new AddRigidbodyCommand(s.sceneId, ball)); const started = startWorld(s, { physics: true, collisions: true }); const y = () => started.world.findByObjectId(ball)!.y; let landed = false; let highest = Infinity; for (let i = 0; i < 240; i++) { started.frame(1 / 60); if (y() > 192 - 16 - 1) landed = true; else if (landed) highest = Math.min(highest, y()); } return Number.isFinite(highest) ? 192 - 16 - highest : 0; } it('a dead tile keeps it down, 0.5 sends it back a quarter of the drop, 1 nearly all of it', () => { const drop = 192 - 16; expect(rebound(0)).toBeLessThan(1); const half = rebound(0.5) / drop; expect(half).toBeGreaterThan(0.2); expect(half).toBeLessThan(0.3); expect(rebound(1) / drop).toBeGreaterThan(0.9); }); });