A tile gains Collision scale: below 1 each blocking cell pulls in on the sides without a blocking neighbour and stays flush towards its neighbours, so a wall of bushes becomes a narrower wall without gaps and the inner corner of an L closes cleanly; above 1 the cell grows at its open sides; on several layers the largest value counts, and the merged rectangles work in fractions of a cell with exactly the old result at 1 (#140)
game-maker / gate (push) Successful in 1m43s
game-maker / suite (push) Successful in 6m27s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 15:00:07 +02:00
1 parent 4728f4fab5
commit bffa508ee7
6 files changed
+365 -73

No files matched your search

+1 -1
View File
@@ -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
+11
View File
@@ -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;
+133 -70
View File
@@ -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<string, boolean>();
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<string>();
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<number, number[]>();
for (const { x, y } of cells) {
const row = rows.get(y);
if (row) row.push(x);
else rows.set(y, [x]);
export function solidCells(project: Project, props: PropertyBag, asset: TilemapAsset): Required<SolidCell>[] {
const scales = new Map<string, number | null>();
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<string, Required<SolidCell>>();
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 done: CellRect[] = [];
// Rectangles still open at the bottom, by the columns they span.
let open = new Map<string, CellRect>();
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();
const at = parseCellKey(key);
if (!at || !inGrid(props, at.x, at.y)) continue;
found.set(key, { ...at, scale });
}
const next = new Map<string, CellRect>();
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<string, SolidCell>();
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<string, Piece[]>();
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<string, Piece>();
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. */
+20 -1
View File
@@ -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<AssetType, PropertySchema> = {
},
// 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
+167 -1
View File
@@ -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);
+33
View File
@@ -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(