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
+366 -74

No files matched your search

+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);