Colliders take a frame's outline and tiles have their own surface (#193)
game-maker / gate (push) Successful in 2m11s
game-maker / suite (push) Successful in 7m33s

Issue #193 asked where friction, bounciness and density of a tile
live. Tiles had a fixed surface (friction 0.5, bounciness 0) and a
collider could not use the outline drawn on a texture frame (#141).
Both gaps are closed here.

- A collider has a fifth shape "Frame collision" with a Frame row.
  The frame is picked like a sprite's texture and stored as the
  value 'frame'.
- The shape is linked, not copied. Redrawing the outline in the
  texture tab changes every collider using that frame, in the editor
  and on the next play.
- The frame centre sits on the collider origin, one image pixel is
  one unit. The shape turns, scales and mirrors like any other.
- Every outline ring blocks. Each ring is split into convex parts the
  same way a polygon is.
- Without a frame, with a whole texture or with a frame that has no
  outline, the collider is drawn red as a box and blocks nothing.
- Move, Rapier, raycast, detection, hit test, drawing and footprint
  all resolve the frame. At runtime an entity looks the frame up
  live in the current project.
- A tile has Friction and Bounciness next to Solid and One-way. The
  rows only show when Solid is on. Defaults and mixing rules match
  the collider. Density is left out because a tilemap never moves.
- Tilemap cells are grouped by surface as well as by one-way angle.
  Ice and stone never merge into one rectangle, and every cell brings
  its surface to Move and Rapier.

Findings:

- A tile with a collision scale below 1 is no longer flush with a
  neighbour of another surface. It is inset there like at an open
  side, the same as next to one-way tiles today.
- A bounce test first measured 0.84. The ball had landed on the hero
  at the origin, not on the tile. On the tile it measures 0.993.

Tests:

- ./check.sh: 130 files, 1931 tests, exit 0 (was 129 / 1917).
- 14 new unit tests in tests/core/surface.test.ts. The Move tests run
  against the editor run and the physics system.
- 3 new e2e checks: 40l (surface rows follow Solid), 42e (collider
  has frame size, a walker climbs the slope in the frame).
- GM_STEPS=1-8,39-43 freshly seeded: ALL PASSED, 376 checks.
- Three counter-probes bite: Move ignores the tile surface (2 fail),
  Rapier ignores it (1 fails), frame shape falls back to a box
  (5 fail).
- Check sum 1198 is computed (1195 + 3), the full run belongs to CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-21 07:29:26 +02:00
1 parent 3ff7e91dbf
commit f4ec23d3d3
20 files changed
+729 -83

No files matched your search

+52 -6
View File
@@ -20,7 +20,8 @@
*
* One-way tiles (#155) block only from above. They are worked out on their own, by the same rules –
* each function takes `oneWay` – so a platform never merges with a wall into one rectangle, nor with
* a platform of another angle (#159).
* a platform of another angle (#159). The same holds for a tile's surface (#193): with a `material`,
* a function looks only at the tiles of that friction and bounciness.
*/
import type { TileAsset, TilemapAsset } from './Asset';
import { tileQuadrants } from './autotile';
@@ -40,6 +41,7 @@ import {
} from './tile';
import { cellOrigin, inGrid, neighboursOf, parseCellKey, tilemapSize, tilemapTileSize } from './tilemap';
import { type ColliderShape, oneWayAngle, type ShapeBounds } from '../properties/collider';
import { colliderMaterial } from '../properties/rigidbody';
/** A rectangle in cells: top-left corner and size. Whole cells at collision scale 1, parts of one otherwise. */
export interface CellRect {
@@ -192,11 +194,51 @@ function tileOneWayAngle(tile: TileAsset): number {
return oneWayAngle(tile.props[TILE_ONE_WAY_ANGLE_KEY]);
}
function passesFilter(tile: TileAsset, oneWay: OneWayFilter): boolean {
/** The surface of a solid tile (#193), read like a collider's. A map never moves, so no density. */
export interface TileMaterial {
friction: number;
bounciness: number;
}
export function tileMaterial(tile: TileAsset): TileMaterial {
const { friction, bounciness } = colliderMaterial(tile.props);
return { friction, bounciness };
}
function sameMaterial(a: TileMaterial, b: TileMaterial): boolean {
return a.friction === b.friction && a.bounciness === b.bounciness;
}
/**
* Which solid tiles a function looks at: those passing `oneWay`, and with a `material` only the
* tiles of that surface – so ice and rubber never merge into one rectangle (#193).
*/
function passesFilter(tile: TileAsset, oneWay: OneWayFilter, material?: TileMaterial): boolean {
if (material && !sameMaterial(tileMaterial(tile), material)) return false;
if (!isTileOneWay(tile)) return oneWay === false;
return oneWay === true || tileOneWayAngle(tile) === oneWay;
}
/** The surfaces of the solid tiles painted on the map that pass `oneWay`, each once. */
export function tileMaterials(
project: Project,
asset: TilemapAsset,
oneWay: OneWayFilter = false,
): TileMaterial[] {
const materials: TileMaterial[] = [];
const seen = new Set<string>();
for (const layer of asset.layers)
for (const tileId of Object.values(layer.cells)) {
if (!tileId || seen.has(tileId)) continue;
seen.add(tileId);
const tile = findAssetOf(project, tileId, 'tile');
if (!tile || !isTileSolid(tile) || !passesFilter(tile, oneWay)) continue;
const material = tileMaterial(tile);
if (!materials.some((known) => sameMaterial(known, material))) materials.push(material);
}
return materials.sort((a, b) => a.friction - b.friction || a.bounciness - b.bounciness);
}
/** The angles of the solid one-way tiles painted on the map, each once, smallest first. */
export function oneWayAngles(project: Project, asset: TilemapAsset): number[] {
const angles = new Set<number>();
@@ -216,13 +258,14 @@ function blockingCells(
props: PropertyBag,
asset: TilemapAsset,
oneWay: OneWayFilter,
material?: TileMaterial,
): CellBlock[] {
const tiles = new Map<string, { scale: number; shape: TileShape | null } | null>();
const tileOf = (id: string) => {
if (tiles.has(id)) return tiles.get(id)!;
const tile = findAssetOf(project, id, 'tile');
const value =
tile && isTileSolid(tile) && passesFilter(tile, oneWay)
tile && isTileSolid(tile) && passesFilter(tile, oneWay, material)
? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) }
: null;
tiles.set(id, value);
@@ -279,8 +322,9 @@ export function solidCells(
props: PropertyBag,
asset: TilemapAsset,
oneWay: OneWayFilter = false,
material?: TileMaterial,
): Required<SolidCell>[] {
return blockingCells(project, props, asset, oneWay)
return blockingCells(project, props, asset, oneWay, material)
.filter((cell) => cell.box !== null)
.map(({ x, y, box }) => ({ x, y, scale: box! }));
}
@@ -296,9 +340,10 @@ export function solidPolygons(
props: PropertyBag,
asset: TilemapAsset,
oneWay: OneWayFilter = false,
material?: TileMaterial,
): CellPolygon[] {
const polygons: CellPolygon[] = [];
for (const cell of blockingCells(project, props, asset, oneWay)) {
for (const cell of blockingCells(project, props, asset, oneWay, material)) {
if (cell.box !== null) continue;
for (const { outline, pieces } of cell.polygons) polygons.push({ x: cell.x, y: cell.y, outline, pieces });
}
@@ -410,8 +455,9 @@ export function solidRects(
props: PropertyBag,
asset: TilemapAsset,
oneWay: OneWayFilter = false,
material?: TileMaterial,
): CellRect[] {
return mergeCells(solidCells(project, props, asset, oneWay));
return mergeCells(solidCells(project, props, asset, oneWay, material));
}
/**