Physics stage 2: colliders gain the shapes capsule and polygon, a polygon keeps its corners in the property points, turns, scales and mirrors with its object and is edited in the scene view (drag a corner, click an edge to insert one, double-click to delete, at least three stay, each step one undo step), a concave polygon is split into convex parts, a self-intersecting one is drawn red and collides with nothing, Rapier gets a capsule and one convex collider per part on the same body, and Move and the editor run use our own f64 geometry for the new shapes, where on a tie between two normals the one that blocks the move least wins, so a box no longer stops at the foot of a slope (#151); first version of a collision outline per texture frame, edited in the texture tab with the new Collision toggle, which a plain tile blocks with instead of its rectangle (#141, to be reworked)
game-maker / gate (push) Successful in 1m52s
game-maker / suite (push) Successful in 6m41s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 22:56:25 +02:00
1 parent 1b5e386b60
commit 32057a96fc
25 files changed
+2341 -122

No files matched your search

+113 -22
View File
@@ -11,12 +11,17 @@
* 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.
*
* A plain tile whose picture is a frame with a collision outline (#141) blocks with that outline
* instead, one polygon per cell, never merged: see `solidPolygons`.
*/
import type { TilemapAsset } from './Asset';
import type { TileAsset, TilemapAsset } from './Asset';
import { angleOf, apply, type Mat, scaleOf } from './matrix';
import type { PropertyBag } from './Properties';
import { convexPieces } from './polygon';
import type { PropertyBag, Vec2 } from './Properties';
import { findAssetOf, type Project } from './Project';
import { isTileSolid, tileCollisionScale } from './tile';
import { findFrame } from './texture';
import { isTileSolid, tileCollisionScale, tileKind } from './tile';
import { cellOrigin, inGrid, parseCellKey, tilemapTileSize } from './tilemap';
import type { ColliderShape } from '../properties/collider';
@@ -35,38 +40,110 @@ export interface SolidCell {
scale?: number;
}
/** A cell that blocks with an outline (#141): the outline and its convex pieces, in cells of the map. */
export interface CellPolygon {
x: number;
y: number;
outline: Vec2[];
pieces: Vec2[][];
}
/** A tile's outline across one cell, 0 to 1 on each axis, and its convex pieces. */
interface TileOutline {
outline: Vec2[];
pieces: Vec2[][];
}
/**
* 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.
* The outline a tile blocks with, or null when it blocks as a box. Only a plain tile has one – an
* autotile's cell is pieced together from quarters of a block, where no single frame is the cell –
* and only its first picture counts: a blocking shape that twitched with the animation is rarely
* wanted. An outline that is no polygon (see `polygonProblem`) blocks as the box it replaced.
*/
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;
export function tileOutline(project: Project, tile: TileAsset): TileOutline | null {
if (tileKind(tile) !== 'none') return null;
const id = tile.pictures[0];
const frame = id ? findFrame(project, id)?.frame : undefined;
if (!frame?.collision || frame.w <= 0 || frame.h <= 0) return null;
const outline = frame.collision.map((p) => ({ x: p.x / frame.w, y: p.y / frame.h }));
const pieces = convexPieces(outline);
return pieces.length > 0 ? { outline, pieces } : null;
}
/** What blocks in each cell: the largest box scale of a plain solid tile, and the outlines of the others. */
interface CellBlock {
x: number;
y: number;
box: number | null;
outlines: { outline: TileOutline; scale: number }[];
}
function blockingCells(project: Project, props: PropertyBag, asset: TilemapAsset): CellBlock[] {
const tiles = new Map<string, { scale: number; outline: TileOutline | 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) ? tileCollisionScale(tile) : null;
scales.set(id, value);
const value =
tile && isTileSolid(tile)
? { scale: tileCollisionScale(tile), outline: tileOutline(project, tile) }
: null;
tiles.set(id, value);
return value;
};
const found = new Map<string, Required<SolidCell>>();
const found = new Map<string, CellBlock>();
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 tile = tileId ? tileOf(tileId) : null;
if (!tile) continue;
let cell = found.get(key);
if (!cell) {
const at = parseCellKey(key);
if (!at || !inGrid(props, at.x, at.y)) continue;
found.set(key, (cell = { ...at, box: null, outlines: [] }));
}
const at = parseCellKey(key);
if (!at || !inGrid(props, at.x, at.y)) continue;
found.set(key, { ...at, scale });
if (tile.outline) cell.outlines.push({ outline: tile.outline, scale: tile.scale });
else cell.box = Math.max(cell.box ?? 0, tile.scale);
}
}
return [...found.values()];
}
/**
* Every cell of the map that blocks as a box, 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. A cell whose only solid tiles have outlines is left to `solidPolygons`.
*/
export function solidCells(project: Project, props: PropertyBag, asset: TilemapAsset): Required<SolidCell>[] {
return blockingCells(project, props, asset)
.filter((cell) => cell.box !== null)
.map(({ x, y, box }) => ({ x, y, scale: box! }));
}
/**
* The cells that block with an outline: one polygon per outlined tile lying there, sized by the
* tile's collision scale about the cell's centre (#140). A plain solid tile on another layer of the
* same cell covers the whole cell already, so there the outlines are left out.
*/
export function solidPolygons(project: Project, props: PropertyBag, asset: TilemapAsset): CellPolygon[] {
const polygons: CellPolygon[] = [];
for (const cell of blockingCells(project, props, asset)) {
if (cell.box !== null) continue;
for (const { outline, scale } of cell.outlines) {
const place = (p: Vec2) => ({
x: cell.x + 0.5 + (p.x - 0.5) * scale,
y: cell.y + 0.5 + (p.y - 0.5) * scale,
});
polygons.push({
x: cell.x,
y: cell.y,
outline: outline.outline.map(place),
pieces: outline.pieces.map((piece) => piece.map(place)),
});
}
}
return polygons.sort((a, b) => a.y - b.y || a.x - b.x);
}
/** A rectangle by its four sides, which is what the merge compares. */
interface Piece {
x0: number;
@@ -189,3 +266,17 @@ export function cellRectShape(props: PropertyBag, rect: CellRect, m: Mat): Colli
angle: angleOf(m),
};
}
/** An outlined cell as it sits in the world: a polygon turning and growing with the placed map. */
export function cellPolygonShape(props: PropertyBag, polygon: CellPolygon, m: Mat): ColliderShape {
const world = (p: Vec2) => apply(m, cellOrigin(props, p.x, p.y));
const centre = world({ x: polygon.x + 0.5, y: polygon.y + 0.5 });
return {
kind: 'polygon',
x: centre.x,
y: centre.y,
angle: angleOf(m),
outline: polygon.outline.map(world),
pieces: polygon.pieces.map((piece) => piece.map(world)),
};
}