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

+46
View File
@@ -0,0 +1,46 @@
/**
* A frame's collision outlines (#141) as a collider wears them (#193): centred on the collider's
* origin, one image pixel to one unit – the size a sprite of that frame is placed with.
*/
import type { TextureFrame } from './Asset';
import { convexPieces } from './polygon';
import type { Project } from './Project';
import type { Vec2 } from './Properties';
import { findFrame } from './texture';
export interface FrameOutline {
/** The frame's size in pixels, which is the collider's footprint. */
w: number;
h: number;
/** Every outline the frame has, in the collider's own units. */
rings: Vec2[][];
/** What collides: the convex pieces of every ring that is a polygon. */
pieces: Vec2[][];
}
/** One per drawn outline list, so a frame is cut into pieces once and not on every test. */
const cache = new WeakMap<TextureFrame, { from: TextureFrame['collision']; outline: FrameOutline }>();
function isCurrent(
known: { from: TextureFrame['collision']; outline: FrameOutline },
frame: TextureFrame,
): boolean {
return known.from === frame.collision && known.outline.w === frame.w && known.outline.h === frame.h;
}
/**
* What the frame with that id blocks with, or null when the id names no frame. A frame without a
* drawn outline has no rings: the collider then collides with nothing, like a broken polygon.
*/
export function frameOutline(project: Project | null, frameId: string): FrameOutline | null {
const found = project ? findFrame(project, frameId) : null;
if (!found) return null;
const { frame } = found;
const known = cache.get(frame);
if (known && isCurrent(known, frame)) return known.outline;
const centre = (p: Vec2): Vec2 => ({ x: p.x - frame.w / 2, y: p.y - frame.h / 2 });
const rings = (frame.collision ?? []).map((ring) => ring.map(centre));
const outline = { w: frame.w, h: frame.h, rings, pieces: rings.flatMap((ring) => convexPieces(ring)) };
cache.set(frame, { from: frame.collision, outline });
return outline;
}
+3
View File
@@ -26,6 +26,9 @@ export const TILE_ONE_WAY_KEY = 'oneWay';
export const TILE_ONE_WAY_ANGLE_KEY = 'oneWayAngle';
/** How big the blocking box of a cell is against the cell, 1 = the whole cell (#140). */
export const TILE_COLLISION_SCALE_KEY = 'collisionScale';
/** The surface of a solid tile (#193): the same rows, and the same defaults, as a collider's material. */
export const TILE_FRICTION_KEY = 'friction';
export const TILE_BOUNCINESS_KEY = 'bounciness';
export const TILE_COLLISION_SCALE_MIN = 0.1;
export const TILE_COLLISION_SCALE_MAX = 2;
+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));
}
/**
+29 -2
View File
@@ -22,10 +22,12 @@ import {
import { findAssetOf, type Project } from '../model/Project';
import { ALL_LAYERS, COLLIDES_WITH_KEY, COLLISION_LAYER_KEY } from '../model/collisionLayers';
import {
TILE_BOUNCINESS_KEY,
TILE_COLLISION_SCALE_KEY,
TILE_COLLISION_SCALE_MAX,
TILE_COLLISION_SCALE_MIN,
TILE_FPS_KEY,
TILE_FRICTION_KEY,
TILE_KIND_KEY,
TILE_ONE_WAY_ANGLE_KEY,
TILE_ONE_WAY_KEY,
@@ -105,6 +107,25 @@ const ONE_WAY_ANGLE_ROW: FlatDef<'number'> = {
shownWith: 'oneWay',
};
/** The surface a collider or a solid tile (#193) offers, read by the physics engine and by `Move` (#146, #150). */
const FRICTION_ROW: FlatDef<'number'> = {
key: 'friction',
label: 'Friction',
kind: 'number',
default: 0.5,
min: 0,
step: 0.05,
};
const BOUNCINESS_ROW: FlatDef<'number'> = {
key: 'bounciness',
label: 'Bounciness',
kind: 'number',
default: 0,
min: 0,
max: 1,
step: 0.05,
};
/**
* Which colliders may meet (#152). Both rows sit on colliders and on tilemaps, so a map can be
* ground that only some things walk on. The defaults are layer 0 against every layer, which is
@@ -343,6 +364,9 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
// A solid tile that only blocks from above, like a platform one jumps through (#155).
{ key: TILE_ONE_WAY_KEY, label: 'One-way (from above)', kind: 'boolean', default: false },
{ ...ONE_WAY_ANGLE_ROW, key: TILE_ONE_WAY_ANGLE_KEY, shownWith: TILE_ONE_WAY_KEY },
// No density: a map never moves, so what its cells would weigh counts for nothing.
{ ...FRICTION_ROW, key: TILE_FRICTION_KEY, shownWith: TILE_SOLID_KEY },
{ ...BOUNCINESS_ROW, key: TILE_BOUNCINESS_KEY, shownWith: TILE_SOLID_KEY },
// 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).
{
@@ -433,6 +457,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ value: 'circle', label: 'Circle' },
{ value: 'capsule', label: 'Capsule' },
{ value: 'polygon', label: 'Polygon' },
{ value: 'frame', label: 'Frame collision' },
],
},
// A box and a capsule fill width × height; the capsule is rounded along the longer side.
@@ -450,10 +475,12 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ x: -50, y: 50 },
],
},
// The frame whose collision outlines (#141) the "Frame collision" shape wears, kept linked (#193).
{ key: 'frame', label: 'Frame', kind: 'texture', default: '' },
{ key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false },
// The material, read by the physics engine where a rigidbody takes part (#146).
{ key: 'friction', label: 'Friction', kind: 'number', default: 0.5, min: 0, step: 0.05 },
{ key: 'bounciness', label: 'Bounciness', kind: 'number', default: 0, min: 0, max: 1, step: 0.05 },
FRICTION_ROW,
BOUNCINESS_ROW,
{ key: 'density', label: 'Density', kind: 'number', default: 1, min: 0, step: 0.1 },
// Blocks only what comes from that side, turned with the object; the rest passes through (#155).
{ key: 'oneWay', label: 'One-way', kind: 'boolean', default: false },
+26 -7
View File
@@ -4,6 +4,7 @@
* where its corners are.
*/
import { type AssetType, hasSceneIcon } from '../model/AssetTypes';
import type { FrameOutline } from '../model/frameOutline';
import {
apply,
boxCorners,
@@ -49,15 +50,22 @@ export function textAnchor(props: PropertyBag): number {
return align === 'left' ? 0 : align === 'right' ? 1 : 0.5;
}
/** Centred on the object's position; text is anchored instead (see `textAnchor`). */
export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h: number } {
/**
* Centred on the object's position; text is anchored instead (see `textAnchor`). A collider that
* wears a frame (#193) is that frame's size, which only the caller can look up.
*/
export function footprintOf(
type: AssetType,
props: PropertyBag,
frame?: FrameOutline | null,
): { w: number; h: number } {
switch (type) {
case 'sprite':
return { w: Number(props.width ?? 1), h: Number(props.height ?? 1) };
case 'text':
return textSize(props);
case 'collider':
return colliderSize(props);
return colliderSize(props, frame);
case 'behavior':
return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
case 'container':
@@ -93,16 +101,27 @@ function anchorOffset(type: AssetType, props: PropertyBag, w: number): number {
}
/** The footprint's corners in world units, grown by `pad` world units on every side. */
export function footprintCorners(type: AssetType, props: PropertyBag, m: Mat, pad = 0): Vec2[] {
export function footprintCorners(
type: AssetType,
props: PropertyBag,
m: Mat,
pad = 0,
frame?: FrameOutline | null,
): Vec2[] {
const placed = placementOf(type, m);
const { w, h } = footprintOf(type, props);
const { w, h } = footprintOf(type, props, frame);
const local = pad / (scaleOf(placed) || 1);
return boxCorners(placed, w + 2 * local, h + 2 * local, anchorOffset(type, props, w), 0);
}
/** The world rectangle around the drawn footprint – what culling, framing and labels need. */
export function footprintExtent(type: AssetType, props: PropertyBag, m: Mat): Extent {
return extentOfPoints(footprintCorners(type, props, m));
export function footprintExtent(
type: AssetType,
props: PropertyBag,
m: Mat,
frame?: FrameOutline | null,
): Extent {
return extentOfPoints(footprintCorners(type, props, m, 0, frame));
}
export function footprintContains(type: AssetType, props: PropertyBag, m: Mat, point: Vec2): boolean {
+69 -7
View File
@@ -3,8 +3,10 @@
* "overlaps" means. A box is oriented: it turns and grows with the body it sits on. A capsule and a
* polygon (#151) carry their points in the world already, and a polygon its convex pieces.
*/
import { type FrameOutline, frameOutline } from '../model/frameOutline';
import { angleOf, apply, type Mat, scaleOf } from '../model/matrix';
import { convexPieces, pointsOf, signedArea } from '../model/polygon';
import type { Project } from '../model/Project';
import { type PropertyBag, type PropertyValue, textOf, type Vec2 } from '../model/Properties';
import { dot } from '../util/vec';
import {
@@ -28,15 +30,55 @@ export type ColliderShape =
| { kind: 'circle'; x: number; y: number; r: number }
/** The segment `a`–`b` grown by `r`. */
| { kind: 'capsule'; x: number; y: number; angle: number; a: Vec2; b: Vec2; r: number }
/** `outline` as drawn; `pieces` what collides – none when the outline is no polygon (see `polygonProblem`). */
| { kind: 'polygon'; x: number; y: number; angle: number; outline: Vec2[]; pieces: Vec2[][] };
/**
* `outline` as drawn; `pieces` what collides – none when the outline is no polygon (see
* `polygonProblem`). A frame's outline (#193) may be several rings: then `rings` holds them all,
* `outline` the first. Read them through `outlinesOf`.
*/
| {
kind: 'polygon';
x: number;
y: number;
angle: number;
outline: Vec2[];
rings?: Vec2[][];
pieces: Vec2[][];
};
type Box = Extract<ColliderShape, { kind: 'box' }>;
type Circle = Extract<ColliderShape, { kind: 'circle' }>;
type Polygon = Extract<ColliderShape, { kind: 'polygon' }>;
type Classic = Box | Circle;
/** Every outline a polygon is drawn with – one, or a frame's several (#193). */
export function outlinesOf(shape: Polygon): Vec2[][] {
return shape.rings ?? [shape.outline];
}
/** Where a polygon collider keeps its corners (#151). */
export const COLLIDER_POINTS_KEY = 'points';
/** Which frame a collider of the shape "Frame collision" wears (#193): a frame id, as a sprite's texture holds it. */
export const COLLIDER_FRAME_KEY = 'frame';
/** Whether the collider wears a frame's outline (#193), which only the project can resolve. */
export function wearsFrame(props: PropertyBag): boolean {
return props.shape === 'frame';
}
/** The frame a collider names, looked up in `project` – null for another shape or a dangling id. */
export function colliderFrame(project: Project | null, props: PropertyBag): FrameOutline | null {
return wearsFrame(props) ? frameOutline(project, textOf(props[COLLIDER_FRAME_KEY] ?? '')) : null;
}
/** The four corners of a centred `w` × `h` box, clockwise on screen. */
function boxRing(w: number, h: number): Vec2[] {
return [
{ x: -w / 2, y: -h / 2 },
{ x: w / 2, y: -h / 2 },
{ x: w / 2, y: h / 2 },
{ x: -w / 2, y: h / 2 },
];
}
/** A polygon's pieces in its own units, cut once per stored list rather than on every test. */
const piecesCache = new WeakMap<object, Vec2[][]>();
@@ -61,8 +103,12 @@ function capsuleCore(w: number, h: number): { a: Vec2; b: Vec2; r: number } {
return { a: { x: -(w - h) / 2, y: 0 }, b: { x: (w - h) / 2, y: 0 }, r: h / 2 };
}
/** The shape as it sits in the world: sized by the transform's scale, turned (and mirrored) with it. */
export function colliderShape(props: PropertyBag, m: Mat): ColliderShape {
/**
* The shape as it sits in the world: sized by the transform's scale, turned (and mirrored) with it.
* A collider of the shape "Frame collision" (#193) needs its frame from `colliderFrame`. Without one it is drawn
* as the frame's box, or its own width × height, and collides with nothing.
*/
export function colliderShape(props: PropertyBag, m: Mat, frame?: FrameOutline | null): ColliderShape {
const scale = scaleOf(m);
if (props.shape === 'circle')
return {
@@ -96,6 +142,20 @@ export function colliderShape(props: PropertyBag, m: Mat): ColliderShape {
pieces: localPieces(value).map((piece) => piece.map((p) => apply(m, p))),
};
}
if (wearsFrame(props)) {
const worn = frame && frame.pieces.length > 0;
const rings = worn ? frame.rings : [frame ? boxRing(frame.w, frame.h) : boxRing(w, h)];
const placed = rings.map((ring) => ring.map((p) => apply(m, p)));
return {
kind: 'polygon',
x: m.tx,
y: m.ty,
angle: angleOf(m),
outline: placed[0] ?? [],
rings: placed,
pieces: worn ? frame.pieces.map((piece) => piece.map((p) => apply(m, p))) : [],
};
}
return { kind: 'box', x: m.tx, y: m.ty, w: w * scale, h: h * scale, angle: angleOf(m) };
}
@@ -167,13 +227,15 @@ export function movedShape(shape: ColliderShape, move: (p: Vec2) => Vec2, turn:
...centre,
angle: shape.angle + turn,
outline: shape.outline.map(move),
...(shape.rings && { rings: shape.rings.map((ring) => ring.map(move)) }),
pieces: shape.pieces.map((piece) => piece.map(move)),
};
}
}
/** The size in the collider's own units, before any transform. */
export function colliderSize(props: PropertyBag): { w: number; h: number } {
/** The size in the collider's own units, before any transform; a worn frame (#193) is its own size. */
export function colliderSize(props: PropertyBag, frame?: FrameOutline | null): { w: number; h: number } {
if (wearsFrame(props) && frame) return { w: frame.w, h: frame.h };
// Centred like every footprint, so it reaches as far as the farthest corner on each axis.
if (props.shape === 'polygon') {
const points = colliderPoints(props);
@@ -407,7 +469,7 @@ export function shapeBounds(shape: ColliderShape): ShapeBounds {
bottom: Math.max(shape.a.y, shape.b.y) + shape.r,
};
case 'polygon':
return boundsOfPoints(shape.outline);
return boundsOfPoints(outlinesOf(shape).flat());
}
}