diff --git a/README.md b/README.md index e9273e77..102ef1a1 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1040 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1042 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/model/tile.ts b/src/core/model/tile.ts index 776e472b..ce8e646b 100644 --- a/src/core/model/tile.ts +++ b/src/core/model/tile.ts @@ -20,6 +20,10 @@ export const TILE_FPS_KEY = 'fps'; export const TILE_PLAYBACK_KEY = 'playback'; /** Whether a cell of this tile blocks movement in the running game (#132). */ export const TILE_SOLID_KEY = 'solid'; +/** How big the blocking box of a cell is against the cell, 1 = the whole cell (#140). */ +export const TILE_COLLISION_SCALE_KEY = 'collisionScale'; +export const TILE_COLLISION_SCALE_MIN = 0.1; +export const TILE_COLLISION_SCALE_MAX = 2; /** Only the two that make sense for a tile that is always on screen; there is no end to hold. */ export const TILE_PLAYBACKS = ['loop', 'pingpong'] as const; @@ -45,6 +49,13 @@ export function isTileSolid(asset: TileAsset): boolean { return asset.props[TILE_SOLID_KEY] === true; } +/** The collision scale, held to the range the field offers; anything unreadable is the whole cell. */ +export function tileCollisionScale(asset: TileAsset): number { + const scale = Number(asset.props[TILE_COLLISION_SCALE_KEY] ?? 1); + if (!Number.isFinite(scale)) return 1; + return Math.min(TILE_COLLISION_SCALE_MAX, Math.max(TILE_COLLISION_SCALE_MIN, scale)); +} + export function tileFps(asset: TileAsset): number { const fps = Number(asset.props[TILE_FPS_KEY]); return Number.isFinite(fps) && fps > 0 ? fps : 0; diff --git a/src/core/model/tilemapCollision.ts b/src/core/model/tilemapCollision.ts index d09e8c00..93518f3d 100644 --- a/src/core/model/tilemapCollision.ts +++ b/src/core/model/tilemapCollision.ts @@ -6,16 +6,21 @@ * The blocking cells are merged into rectangles rather than handed on one box each, so a wall is * one shape and the collision tests stay few – and so an object sliding along a wall does not * catch on the seam between two of its cells. + * + * A tile's collision scale (#140) draws the box in or out, but only on the sides *without* a + * blocking neighbour: between two blocking cells the side stays flush whatever their values, so a + * wall of bushes is a narrower wall and not a string of beads. A neighbour across the corner only + * does not count – that corner stays drawn in. */ import type { TilemapAsset } from './Asset'; import { angleOf, apply, type Mat, scaleOf } from './matrix'; import type { PropertyBag } from './Properties'; import { findAssetOf, type Project } from './Project'; -import { isTileSolid } from './tile'; +import { isTileSolid, tileCollisionScale } from './tile'; import { cellOrigin, inGrid, parseCellKey, tilemapTileSize } from './tilemap'; import type { ColliderShape } from '../properties/collider'; -/** A rectangle of cells: top-left cell and size, all in cells. */ +/** A rectangle in cells: top-left corner and size. Whole cells at collision scale 1, parts of one otherwise. */ export interface CellRect { x: number; y: number; @@ -23,84 +28,142 @@ export interface CellRect { h: number; } -/** Every blocking cell of the map, once each however many layers hold a solid tile there. */ -export function solidCells( - project: Project, - props: PropertyBag, - asset: TilemapAsset, -): { x: number; y: number }[] { - const solid = new Map(); - const isSolid = (id: string): boolean => { - const known = solid.get(id); - if (known !== undefined) return known; - const tile = findAssetOf(project, id, 'tile'); - const value = Boolean(tile && isTileSolid(tile)); - solid.set(id, value); - return value; - }; - const seen = new Set(); - const cells: { x: number; y: number }[] = []; - for (const layer of asset.layers) { - for (const [key, tileId] of Object.entries(layer.cells)) { - if (!tileId || seen.has(key) || !isSolid(tileId)) continue; - const at = parseCellKey(key); - if (!at || !inGrid(props, at.x, at.y)) continue; - seen.add(key); - cells.push(at); - } - } - return cells; +/** A blocking cell and the collision scale of its tile; left out, the scale is 1. */ +export interface SolidCell { + x: number; + y: number; + scale?: number; } /** - * The cells merged into rectangles, row by row: each row is cut into runs, and a run that spans - * exactly the columns of a rectangle ending right above it makes that rectangle one row taller. - * Every cell lands in exactly one rectangle; the result is ordered top-down, then left to right. + * Every blocking cell of the map, once each however many layers hold a solid tile there. Where + * several do, the largest scale wins: each of them blocks, so the cell blocks as far as any does. */ -export function mergeCells(cells: readonly { x: number; y: number }[]): CellRect[] { - const rows = new Map(); - for (const { x, y } of cells) { - const row = rows.get(y); - if (row) row.push(x); - else rows.set(y, [x]); - } - const done: CellRect[] = []; - // Rectangles still open at the bottom, by the columns they span. - let open = new Map(); - let previous = Number.NaN; - for (const y of [...rows.keys()].sort((a, b) => a - b)) { - if (y !== previous + 1) { - done.push(...open.values()); - open = new Map(); +export function solidCells(project: Project, props: PropertyBag, asset: TilemapAsset): Required[] { + const scales = new Map(); + const scaleOf = (id: string): number | null => { + const known = scales.get(id); + if (known !== undefined) return known; + const tile = findAssetOf(project, id, 'tile'); + const value = tile && isTileSolid(tile) ? tileCollisionScale(tile) : null; + scales.set(id, value); + return value; + }; + const found = new Map>(); + for (const layer of asset.layers) { + for (const [key, tileId] of Object.entries(layer.cells)) { + const scale = tileId ? scaleOf(tileId) : null; + if (scale === null) continue; + const known = found.get(key); + if (known) { + known.scale = Math.max(known.scale, scale); + continue; + } + const at = parseCellKey(key); + if (!at || !inGrid(props, at.x, at.y)) continue; + found.set(key, { ...at, scale }); } - const next = new Map(); - for (const [x0, w] of runsOf(rows.get(y)!)) { - const key = `${x0},${w}`; - const above = open.get(key); - if (above) { - above.h++; - open.delete(key); - next.set(key, above); - } else next.set(key, { x: x0, y, w, h: 1 }); - } - done.push(...open.values()); - open = next; - previous = y; } - done.push(...open.values()); - return done.sort((a, b) => a.y - b.y || a.x - b.x); + return [...found.values()]; } -/** Runs of neighbouring columns as `[first column, length]`; doubles count once. */ -function runsOf(columns: number[]): [number, number][] { - const sorted = [...new Set(columns)].sort((a, b) => a - b); - const runs: [number, number][] = []; - for (const x of sorted) { - const last = runs[runs.length - 1]; - if (last && last[0] + last[1] === x) last[1]++; - else runs.push([x, 1]); +/** A rectangle by its four sides, which is what the merge compares. */ +interface Piece { + x0: number; + x1: number; + y0: number; + y1: number; +} + +/** + * The part of the map one cell blocks, as pieces. At scale 1 that is the cell. Below 1 the cell is + * cut at the inset `e` into a 3 × 3 grid: the middle always blocks, a side strip only towards a + * blocking neighbour, a corner only when both its sides *and* the cell across the corner block – + * which is what closes the inner corner of an L cleanly instead of leaving a notch filled. Above 1 + * the cell grows by the same amount on every side without a blocking neighbour. + */ +function cellPieces(cell: SolidCell, blocks: (x: number, y: number) => boolean): Piece[] { + const { x, y } = cell; + const e = (1 - (cell.scale ?? 1)) / 2; + const left = blocks(x - 1, y); + const right = blocks(x + 1, y); + const up = blocks(x, y - 1); + const down = blocks(x, y + 1); + if (e <= 0) { + const g = -e; + return [ + { + x0: x - (left ? 0 : g), + x1: x + 1 + (right ? 0 : g), + y0: y - (up ? 0 : g), + y1: y + 1 + (down ? 0 : g), + }, + ]; } - return runs; + // Bands along each axis: [start, end, neighbour side (-1, 0, 1)]; the same expressions on both + // sides of a cut keep touching pieces exactly equal, so they merge. + const bands = (at: number): [number, number, -1 | 0 | 1][] => [ + [at, at + e, -1], + [at + e, at + (1 - e), 0], + [at + (1 - e), at + 1, 1], + ]; + const pieces: Piece[] = []; + for (const [y0, y1, dy] of bands(y)) { + for (const [x0, x1, dx] of bands(x)) { + const side = (dx === 0 || (dx < 0 ? left : right)) && (dy === 0 || (dy < 0 ? up : down)); + if (side && (dx === 0 || dy === 0 || blocks(x + dx, y + dy))) pieces.push({ x0, x1, y0, y1 }); + } + } + return pieces; +} + +/** + * The cells merged into rectangles. Each cell is cut into its pieces (see `cellPieces`); pieces on + * the same strip – same top and bottom – that touch or overlap join into one, and then strips of + * exactly the same left and right that meet top to bottom join into one taller rectangle. At scale 1 + * every piece is a cell, so a run joins across a row and runs of the same columns stack. The result + * is ordered top-down, then left to right. + */ +export function mergeCells(cells: readonly SolidCell[]): CellRect[] { + const unique = new Map(); + for (const cell of cells) unique.set(`${cell.x},${cell.y}`, cell); + const blocks = (x: number, y: number) => unique.has(`${x},${y}`); + const strips = new Map(); + for (const cell of unique.values()) { + for (const piece of cellPieces(cell, blocks)) { + const key = `${piece.y0},${piece.y1}`; + const strip = strips.get(key); + if (strip) strip.push(piece); + else strips.set(key, [piece]); + } + } + const runs: Piece[] = []; + for (const strip of strips.values()) { + strip.sort((a, b) => a.x0 - b.x0); + let run: Piece | null = null; + for (const piece of strip) { + if (run && piece.x0 <= run.x1) run.x1 = Math.max(run.x1, piece.x1); + else runs.push((run = { ...piece })); + } + } + runs.sort((a, b) => a.y0 - b.y0 || a.x0 - b.x0); + const done: Piece[] = []; + // Rectangles still open at the bottom, by the left and right they span. + const open = new Map(); + for (const run of runs) { + const key = `${run.x0},${run.x1}`; + const above = open.get(key); + if (above && above.y1 === run.y0) { + above.y1 = run.y1; + continue; + } + if (above) done.push(above); + open.set(key, run); + } + done.push(...open.values()); + return done + .sort((a, b) => a.y0 - b.y0 || a.x0 - b.x0) + .map((p) => ({ x: p.x0, y: p.y0, w: p.x1 - p.x0, h: p.y1 - p.y0 })); } /** The blocking rectangles of a map, in cells. */ diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 23082548..35e6c85c 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -20,7 +20,15 @@ import { type VariableKind, } from './kinds'; import { findAssetOf, type Project } from '../model/Project'; -import { TILE_FPS_KEY, TILE_KIND_KEY, TILE_PLAYBACK_KEY, TILE_SOLID_KEY } from '../model/tile'; +import { + TILE_COLLISION_SCALE_KEY, + TILE_COLLISION_SCALE_MAX, + TILE_COLLISION_SCALE_MIN, + TILE_FPS_KEY, + TILE_KIND_KEY, + TILE_PLAYBACK_KEY, + TILE_SOLID_KEY, +} from '../model/tile'; import { TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap'; import { SLICE_SCALE_KEY } from '../model/texture'; import type { SceneObject } from '../model/SceneObject'; @@ -249,6 +257,17 @@ const ASSET_SCHEMAS: Record = { }, // Solid is what the tile *is* – water, a wall – so it holds wherever the tile lies (#132). { key: TILE_SOLID_KEY, label: 'Solid', kind: 'boolean', default: false }, + // Below 1 a cell blocks less than its square, above 1 more – but only on the sides without a + // blocking neighbour, so a wall of such cells stays closed (#140). + { + key: TILE_COLLISION_SCALE_KEY, + label: 'Collision scale', + kind: 'number', + default: 1, + min: TILE_COLLISION_SCALE_MIN, + max: TILE_COLLISION_SCALE_MAX, + step: 0.05, + }, ], // The layers and their cells are intrinsic data on the asset (see TilemapAsset); these three say // how far the grid reaches and how big one cell is drawn. A tile's own pictures are scaled onto diff --git a/tests/core/tilemap-collision.test.ts b/tests/core/tilemap-collision.test.ts index a8ae1982..f890e4df 100644 --- a/tests/core/tilemap-collision.test.ts +++ b/tests/core/tilemap-collision.test.ts @@ -10,7 +10,12 @@ import { 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_SOLID_KEY } from '../../src/core/model/tile'; +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, @@ -83,6 +88,91 @@ describe('merging solid cells into rectangles', () => { }); }); +/** 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. @@ -170,6 +260,46 @@ describe('which cells block', () => { ]); }); + 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 }; @@ -233,6 +363,42 @@ describe('a tilemap in the CollisionSystem', () => { 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, { 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, { 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, { 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); diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index d539a3b6..c62930de 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -735,6 +735,39 @@ export async function tilemap(t) { stopped && Math.abs(stopped.x - -170) < 0.01, `world x=${stopped?.x}`, ); + + // Collision scale (#140), typed into the tile's field: 0.5 draws the boxes in by a quarter cell on + // their open sides only – the 3 × 3 block stays one rectangle – and lets the crate that much closer. + await select({ kind: 'asset', assetId: 'at-grass' }); + await settle(); + const scaleRow = await json("__gm.inspectorField('Collision scale')"); + if (scaleRow) { + await click(scaleRow.x, scaleRow.y); + await press('a', { ctrl: true }); + await typeText('0.5'); + await press('Enter'); + } + await settle(); + const drawnIn = await json(`__gm.tilemap.solids(${JSON.stringify(MAP_OBJECT)})`); + check( + 'a tile has a Collision scale field; at 0.5 the outline is drawn in on the open sides, the block still one rectangle', + (await t.asset('at-grass'))?.props.collisionScale === 0.5 && + JSON.stringify(drawnIn?.rects) === + JSON.stringify([ + { x: 0.25, y: 0.25, w: 2.5, h: 2.5 }, + { x: 4.25, y: 0.25, w: 0.5, h: 0.5 }, + ]), + JSON.stringify({ scaleRow, props: (await t.asset('at-grass'))?.props, drawnIn }), + ); + await evaluate('__gm.state.select(null)'); + await settle(); + const closer = await playAgainstMap(); + check( + 'with the smaller box the crate comes a quarter cell closer before it stops', + closer && Math.abs(closer.x - -158) < 0.01, + `world x=${closer?.x}`, + ); + await mutate("delete __gm.state.project.assets.find((a) => a.id === 'at-grass').props.collisionScale"); await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').props.solid = false"); const passed = await playAgainstMap(); check(