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

+31
View File
@@ -5,6 +5,7 @@
import { newId } from '../ids';
import type { TextureAsset, TextureFrame } from '../model/Asset';
import type { Project } from '../model/Project';
import type { Vec2 } from '../model/Properties';
import {
frameRect,
type FrameRect,
@@ -12,7 +13,9 @@ import {
nextFrameNames,
normalizeFrameRect,
rawFrameSlice,
frameCollision,
restoreFrameSlice,
setFrameCollision,
setFrameRect,
setFrameSlice,
sliceFrames,
@@ -118,6 +121,34 @@ export class SetFrameSliceCommand implements Command {
}
}
/**
* The collision outline of one frame (#141), whole: adding, moving and removing a corner are each
* one edit, and the outline to revert to is read at execution time – a caller that previews a drag
* in place restores the start before executing, as with `SetFrameRectCommand`.
*/
export class SetFrameCollisionCommand implements Command {
readonly label = 'Collision outline';
private previous: Vec2[] = [];
constructor(
private readonly assetId: string,
private readonly frameId: string,
private readonly points: readonly Vec2[],
) {}
execute(project: Project): void {
const frame = requireFrame(requireTexture(project, this.assetId), this.frameId);
if (this.points.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y)))
throw new CommandError('A corner of the outline is not a number');
this.previous = frameCollision(frame);
setFrameCollision(frame, this.points);
}
undo(project: Project): void {
setFrameCollision(requireFrame(requireTexture(project, this.assetId), this.frameId), this.previous);
}
}
export class RenameFrameCommand implements Command {
readonly label = 'Rename frame';
private previous = '';
+6 -1
View File
@@ -1,7 +1,7 @@
import type { Graph } from '../graph/Graph';
import type { PropertyDef } from '../properties/PropertySchema';
import type { AssetType } from './AssetTypes';
import type { PropertyBag, PropertyValue } from './Properties';
import type { PropertyBag, PropertyValue, Vec2 } from './Properties';
import type { Scope } from './Scope';
import type { SceneObject } from './SceneObject';
@@ -94,6 +94,11 @@ export interface TextureFrame {
h: number;
border?: FrameBorders;
fill?: FrameFill;
/**
* The outline a tile made of this frame blocks with (#141), in pixels from the frame's top-left
* corner, so it moves with the frame. Absent is no outline; fewer than three corners collide as none.
*/
collision?: Vec2[];
}
/**
+192
View File
@@ -0,0 +1,192 @@
/**
* Plain polygons as outlines of points: checking one, and cutting a concave one into convex pieces
* – what collision works with (#151), since the overlap of two convex shapes is a matter of a few
* axes and the overlap of two concave ones is not.
*/
import { isList, isStruct, type PropertyValue, type Vec2 } from './Properties';
/** Points closer than this are one point, and a turn smaller than this is a straight line. */
const EPSILON = 1e-9;
/** A coordinate as stored: a number, or a text spelling one. `Number(null)` would be 0, not missing. */
function coordinate(value: unknown): number {
if (typeof value === 'number') return value;
return typeof value === 'string' && value.trim() !== '' ? Number(value) : Number.NaN;
}
/** The points a stored list holds: `{x, y}` items with finite numbers; anything else is left out. */
export function pointsOf(value: PropertyValue | undefined): Vec2[] {
if (!isList(value)) return [];
const points: Vec2[] = [];
for (const item of value) {
if (!isStruct(item)) continue;
const x = coordinate(item.x);
const y = coordinate(item.y);
if (Number.isFinite(x) && Number.isFinite(y)) points.push({ x, y });
}
return points;
}
/** Positive when the points run clockwise on screen (y grows downwards), negative the other way. */
export function signedArea(points: readonly Vec2[]): number {
let sum = 0;
for (let i = 0; i < points.length; i++) {
const a = points[i]!;
const b = points[(i + 1) % points.length]!;
sum += a.x * b.y - b.x * a.y;
}
return sum / 2;
}
function cross(o: Vec2, a: Vec2, b: Vec2): number {
return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
}
/** The outline without repeated points and without points in the middle of a straight edge. */
export function cleanOutline(points: readonly Vec2[]): Vec2[] {
let ring = points.filter((p, i) => {
const next = points[(i + 1) % points.length]!;
return points.length < 2 || Math.hypot(p.x - next.x, p.y - next.y) > EPSILON;
});
let changed = true;
while (changed && ring.length >= 3) {
changed = false;
for (let i = 0; i < ring.length; i++) {
const prev = ring[(i + ring.length - 1) % ring.length]!;
const next = ring[(i + 1) % ring.length]!;
const p = ring[i]!;
const span = Math.hypot(next.x - prev.x, next.y - prev.y) * Math.hypot(p.x - prev.x, p.y - prev.y);
if (Math.abs(cross(prev, p, next)) <= EPSILON * Math.max(1, span)) {
ring = ring.filter((_, j) => j !== i);
changed = true;
break;
}
}
}
return ring;
}
/** Whether the open segments ab and cd cross or touch. */
function segmentsMeet(a: Vec2, b: Vec2, c: Vec2, d: Vec2): boolean {
const d1 = cross(c, d, a);
const d2 = cross(c, d, b);
const d3 = cross(a, b, c);
const d4 = cross(a, b, d);
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) return true;
const on = (p: Vec2, q: Vec2, r: Vec2, side: number) =>
Math.abs(side) <= EPSILON &&
Math.min(p.x, q.x) - EPSILON <= r.x &&
r.x <= Math.max(p.x, q.x) + EPSILON &&
Math.min(p.y, q.y) - EPSILON <= r.y &&
r.y <= Math.max(p.y, q.y) + EPSILON;
return on(c, d, a, d1) || on(c, d, b, d2) || on(a, b, c, d3) || on(a, b, d, d4);
}
/**
* Why the outline is no polygon one can collide with, or '' when it is one: at least three corners,
* some area, and no edge crossing or touching another that is not its neighbour.
*/
export function polygonProblem(points: readonly Vec2[]): string {
const ring = cleanOutline(points);
if (ring.length < 3) return 'A polygon needs at least three corners.';
if (Math.abs(signedArea(ring)) <= EPSILON) return 'The polygon has no area.';
const n = ring.length;
for (let i = 0; i < n; i++)
for (let j = i + 1; j < n; j++) {
if (j === i + 1 || (i === 0 && j === n - 1)) continue;
if (segmentsMeet(ring[i]!, ring[(i + 1) % n]!, ring[j]!, ring[(j + 1) % n]!))
return 'The outline crosses itself.';
}
return '';
}
/** Whether every corner turns the same way. */
export function isConvex(ring: readonly Vec2[]): boolean {
const sign = Math.sign(signedArea(ring));
for (let i = 0; i < ring.length; i++) {
const turn = cross(ring[i]!, ring[(i + 1) % ring.length]!, ring[(i + 2) % ring.length]!);
if (turn * sign < -EPSILON) return false;
}
return true;
}
/** Whether `p` lies inside the triangle abc of the given winding, its edges counting as inside. */
function inTriangle(p: Vec2, a: Vec2, b: Vec2, c: Vec2, sign: number): boolean {
return (
cross(a, b, p) * sign >= -EPSILON &&
cross(b, c, p) * sign >= -EPSILON &&
cross(c, a, p) * sign >= -EPSILON
);
}
/** Cuts a simple outline into triangles by clipping ears, as indices into `ring`. */
function triangulate(ring: readonly Vec2[]): [number, number, number][] {
const sign = Math.sign(signedArea(ring));
const left = ring.map((_, i) => i);
const triangles: [number, number, number][] = [];
let guard = ring.length * ring.length;
while (left.length > 3 && guard-- > 0) {
let clipped = false;
for (let k = 0; k < left.length; k++) {
const i0 = left[(k + left.length - 1) % left.length]!;
const i1 = left[k]!;
const i2 = left[(k + 1) % left.length]!;
const [a, b, c] = [ring[i0]!, ring[i1]!, ring[i2]!];
if (cross(a, b, c) * sign <= EPSILON) continue;
const blocked = left.some(
(j) => j !== i0 && j !== i1 && j !== i2 && inTriangle(ring[j]!, a, b, c, sign),
);
if (blocked) continue;
triangles.push([i0, i1, i2]);
left.splice(k, 1);
clipped = true;
break;
}
if (!clipped) break;
}
if (left.length === 3) triangles.push([left[0]!, left[1]!, left[2]!]);
return triangles;
}
/**
* The outline as convex pieces that together cover it exactly: itself when it is convex, else its
* triangles merged back along every shared edge whose removal leaves the piece convex
* (Hertel-Mehlhorn) – at most four times as many pieces as the fewest possible, and few in practice.
* An outline with a problem (see `polygonProblem`) has no pieces.
*/
export function convexPieces(points: readonly Vec2[]): Vec2[][] {
if (polygonProblem(points)) return [];
const ring = cleanOutline(points);
if (isConvex(ring)) return [ring];
let pieces = triangulate(ring).map((t) => [...t]);
let merged = true;
while (merged) {
merged = false;
search: for (let p = 0; p < pieces.length; p++)
for (let q = p + 1; q < pieces.length; q++) {
const joined = joinAlongSharedEdge(pieces[p]!, pieces[q]!);
if (!joined || !isConvex(cleanOutline(joined.map((i) => ring[i]!)))) continue;
pieces[p] = joined;
pieces = pieces.filter((_, k) => k !== q);
merged = true;
break search;
}
}
return pieces.map((piece) => cleanOutline(piece.map((i) => ring[i]!)));
}
/** Two pieces (index rings of the same winding) joined across the edge they share, if they share one. */
function joinAlongSharedEdge(a: number[], b: number[]): number[] | null {
for (let i = 0; i < a.length; i++) {
const u = a[i]!;
const v = a[(i + 1) % a.length]!;
const j = b.findIndex((x, k) => x === v && b[(k + 1) % b.length] === u);
if (j < 0) continue;
// Walk a from v round to u, then b from u round to v, skipping the shared edge.
const out: number[] = [];
for (let k = 0; k < a.length; k++) out.push(a[(i + 1 + k) % a.length]!);
for (let k = 2; k < b.length; k++) out.push(b[(j + k) % b.length]!);
return out;
}
return null;
}
+12 -1
View File
@@ -1,5 +1,5 @@
import type { FrameBorders, FrameFill, TextureAsset, TextureFrame } from './Asset';
import { type PropertyBag, textOf } from './Properties';
import { type PropertyBag, textOf, type Vec2 } from './Properties';
import { findAsset, type Project } from './Project';
/** Mirrors `TextureStore::isValidName` on the server, where the name is derived from the bytes. */
@@ -112,6 +112,17 @@ export const DEFAULT_FILL: FrameFill = 'repeat';
/** Whole pixels of the frame that must stay between two opposite borders (see `normalizeBorders`). */
export const MIN_SLICE_MIDDLE = 1;
/** The frame's collision outline (#141), a copy; empty when it has none. */
export function frameCollision(frame: TextureFrame): Vec2[] {
return (frame.collision ?? []).map((p) => ({ ...p }));
}
/** Sets the outline; an empty one removes the key, so a frame without one keeps saying so. */
export function setFrameCollision(frame: TextureFrame, points: readonly Vec2[]): void {
if (points.length === 0) delete frame.collision;
else frame.collision = points.map((p) => ({ x: p.x, y: p.y }));
}
export function frameBorders(frame: TextureFrame): FrameBorders {
return frame.border ? { ...frame.border } : { ...NO_BORDERS };
}
+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)),
};
}
+14
View File
@@ -349,11 +349,25 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
options: [
{ value: 'box', label: 'Box' },
{ value: 'circle', label: 'Circle' },
{ value: 'capsule', label: 'Capsule' },
{ value: 'polygon', label: 'Polygon' },
],
},
// A box and a capsule fill width × height; the capsule is rounded along the longer side.
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'radius', label: 'Radius', kind: 'number', default: 50, min: 1, step: 1 },
// A polygon's corners in the collider's own units, edited on the outline in the scene (#151).
{
key: 'points',
label: 'Points',
kind: 'list',
default: [
{ x: 0, y: -50 },
{ x: 50, y: 50 },
{ x: -50, y: 50 },
],
},
{ 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 },
+170 -15
View File
@@ -1,9 +1,20 @@
/**
* Collider geometry in world units, shared by the editor and the runtime so both agree on what
* "overlaps" means. A box is oriented: it turns and grows with the body it sits on.
* "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 { angleOf, type Mat, scaleOf } from '../model/matrix';
import type { PropertyBag, Vec2 } from '../model/Properties';
import { angleOf, apply, type Mat, scaleOf } from '../model/matrix';
import { convexPieces, pointsOf, signedArea } from '../model/polygon';
import type { PropertyBag, PropertyValue, Vec2 } from '../model/Properties';
import {
roundedContact,
roundedContains,
roundedExit,
roundedExtent,
type Contact,
preferredOnTie,
type Rounded,
} from './convex';
import { defaultsFor } from './PropertySchema';
const DEFAULTS = defaultsFor('collider');
@@ -13,12 +24,43 @@ const EPSILON = 1e-9;
export type ColliderShape =
| { kind: 'box'; x: number; y: number; w: number; h: number; angle: number }
| { kind: 'circle'; x: number; y: number; r: number };
| { 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[][] };
type Box = Extract<ColliderShape, { kind: 'box' }>;
type Circle = Extract<ColliderShape, { kind: 'circle' }>;
type Classic = Box | Circle;
/** The shape as it sits in the world: sized by the transform's scale, a box turned by its angle. */
/** Where a polygon collider keeps its corners (#151). */
export const COLLIDER_POINTS_KEY = 'points';
/** A polygon's pieces in its own units, cut once per stored list rather than on every test. */
const piecesCache = new WeakMap<object, Vec2[][]>();
/** The corners of a polygon collider in its own units – the stored ones, or the default triangle. */
export function colliderPoints(props: PropertyBag): Vec2[] {
return pointsOf(props[COLLIDER_POINTS_KEY] ?? DEFAULTS[COLLIDER_POINTS_KEY]);
}
function localPieces(value: PropertyValue | undefined): Vec2[][] {
const key = typeof value === 'object' ? value : null;
const known = key && piecesCache.get(key);
if (known) return known;
const pieces = convexPieces(pointsOf(value));
if (key) piecesCache.set(key, pieces);
return pieces;
}
/** The capsule in a `w` × `h` box: rounded along the longer side, the radius half the shorter one. */
function capsuleCore(w: number, h: number): { a: Vec2; b: Vec2; r: number } {
if (h >= w) return { a: { x: 0, y: -(h - w) / 2 }, b: { x: 0, y: (h - w) / 2 }, r: w / 2 };
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 {
const scale = scaleOf(m);
if (props.shape === 'circle')
@@ -28,14 +70,32 @@ export function colliderShape(props: PropertyBag, m: Mat): ColliderShape {
y: m.ty,
r: Math.max(0, Number(props.radius ?? DEFAULTS.radius)) * scale,
};
return {
kind: 'box',
x: m.tx,
y: m.ty,
w: Math.max(0, Number(props.width ?? DEFAULTS.width)) * scale,
h: Math.max(0, Number(props.height ?? DEFAULTS.height)) * scale,
angle: angleOf(m),
};
const w = Math.max(0, Number(props.width ?? DEFAULTS.width));
const h = Math.max(0, Number(props.height ?? DEFAULTS.height));
if (props.shape === 'capsule') {
const core = capsuleCore(w, h);
return {
kind: 'capsule',
x: m.tx,
y: m.ty,
angle: angleOf(m),
a: apply(m, core.a),
b: apply(m, core.b),
r: core.r * scale,
};
}
if (props.shape === 'polygon') {
const value = props[COLLIDER_POINTS_KEY] ?? DEFAULTS[COLLIDER_POINTS_KEY];
return {
kind: 'polygon',
x: m.tx,
y: m.ty,
angle: angleOf(m),
outline: pointsOf(value).map((p) => apply(m, p)),
pieces: localPieces(value).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) };
}
/** Whether the collider only reports overlaps (true) or also blocks movement (false). */
@@ -45,6 +105,14 @@ export function isTrigger(props: PropertyBag): boolean {
/** The size in the collider's own units, before any transform. */
export function colliderSize(props: PropertyBag): { w: number; h: number } {
// Centred like every footprint, so it reaches as far as the farthest corner on each axis.
if (props.shape === 'polygon') {
const points = colliderPoints(props);
return {
w: 2 * points.reduce((max, p) => Math.max(max, Math.abs(p.x)), 0),
h: 2 * points.reduce((max, p) => Math.max(max, Math.abs(p.y)), 0),
};
}
if (props.shape === 'circle') {
const d = Math.max(0, Number(props.radius ?? DEFAULTS.radius)) * 2;
return { w: d, h: d };
@@ -82,11 +150,63 @@ function reach(box: Box, n: Vec2): number {
}
/** How wide the shape is along the unit direction `n` – a turned box measures its own slant. */
/**
* How wide the shape is along the unit direction `n` – a turned box measures its own slant. A
* polygon measures its thinnest piece: that is what a step along `n` must not jump over.
*/
export function shapeExtent(shape: ColliderShape, n: Vec2): number {
return shape.kind === 'circle' ? shape.r * 2 : reach(shape, n) * 2;
if (shape.kind === 'circle') return shape.r * 2;
if (shape.kind === 'box') return reach(shape, n) * 2;
const pieces = piecesOf(shape);
return pieces.length ? Math.min(...pieces.map((piece) => roundedExtent(piece, n))) : 0;
}
/**
* The shape as rounded convex pieces (see `convex.ts`): a box its corners, a circle its centre and
* a capsule its segment, each grown by its radius, a polygon its pieces.
*/
export function piecesOf(shape: ColliderShape): Rounded[] {
switch (shape.kind) {
case 'box': {
const [ux, uy] = axesOf(shape);
const corner = (sx: number, sy: number) => ({
x: shape.x + (sx * shape.w * ux.x + sy * shape.h * uy.x) / 2,
y: shape.y + (sx * shape.w * ux.y + sy * shape.h * uy.y) / 2,
});
return [{ points: [corner(-1, -1), corner(1, -1), corner(1, 1), corner(-1, 1)], r: 0 }];
}
case 'circle':
return [{ points: [{ x: shape.x, y: shape.y }], r: shape.r }];
case 'capsule':
return [{ points: [shape.a, shape.b], r: shape.r }];
case 'polygon':
return shape.pieces.map((points) => ({ points, r: 0 }));
}
}
/** The area the shape covers, in world units squared. */
export function shapeArea(shape: ColliderShape): number {
switch (shape.kind) {
case 'box':
return shape.w * shape.h;
case 'circle':
return Math.PI * shape.r * shape.r;
case 'capsule':
return (
Math.PI * shape.r * shape.r + 2 * shape.r * Math.hypot(shape.b.x - shape.a.x, shape.b.y - shape.a.y)
);
case 'polygon':
return shape.pieces.reduce((sum, piece) => sum + Math.abs(signedArea(piece)), 0);
}
}
/** Box and circle keep their own exact tests; everything else goes through the rounded pieces. */
function isClassic(shape: ColliderShape): shape is Classic {
return shape.kind === 'box' || shape.kind === 'circle';
}
export function shapeContains(shape: ColliderShape, point: Vec2): boolean {
if (!isClassic(shape)) return piecesOf(shape).some((piece) => roundedContains(piece, point));
if (shape.kind === 'circle')
return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r;
const local = toBoxFrame(shape, point);
@@ -102,6 +222,12 @@ function distanceToBox(p: Vec2, hw: number, hh: number): number {
/** Shapes that merely touch along an edge do not count as overlapping. */
export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean {
if (!isClassic(a) || !isClassic(b)) {
const others = piecesOf(b);
return piecesOf(a).some((p) =>
others.some((q) => roundedContact(p, q, { x: 0, y: 0 }).separation < -EPSILON),
);
}
if (a.kind === 'box' && b.kind === 'box') {
// Separating axes: the sides of both boxes. Apart along any one of them is apart.
const centres = { x: a.x - b.x, y: a.y - b.y };
@@ -128,6 +254,7 @@ export function contactBetween(
obstacle: ColliderShape,
travel: Vec2,
): { separation: number; normal: Vec2 } {
if (!isClassic(mover) || !isClassic(obstacle)) return piecesContact(mover, obstacle, travel);
const against = (n: Vec2, offset: number): Vec2 => {
const sign = offset !== 0 ? Math.sign(offset) : dot(n, travel) > 0 ? -1 : 1;
return { x: sign * n.x + 0, y: sign * n.y + 0 };
@@ -206,6 +333,13 @@ export function exitDistance(mover: ColliderShape, obstacle: ColliderShape, dire
const length = Math.hypot(direction.x, direction.y);
if (length === 0 || !shapesOverlap(mover, obstacle)) return 0;
const d = { x: direction.x / length, y: direction.y / length };
if (!isClassic(mover) || !isClassic(obstacle)) {
// Every overlapping pair of pieces has to come clear; once out, a pair stays out along the line.
const others = piecesOf(obstacle);
let exit = 0;
for (const p of piecesOf(mover)) for (const q of others) exit = Math.max(exit, roundedExit(p, q, d));
return exit;
}
if (mover.kind === 'box' && obstacle.kind === 'box') return boxesExit(mover, obstacle, d);
if (mover.kind === 'circle' && obstacle.kind === 'circle') {
const sep = { x: mover.x - obstacle.x, y: mover.y - obstacle.y };
@@ -260,6 +394,27 @@ function circleExit(start: Vec2, v: Vec2, hw: number, hh: number, r: number): nu
return hi;
}
function boxAndCircle(a: ColliderShape, b: ColliderShape): { box: Box; circle: Circle } {
/**
* The contact of two shapes made of pieces: the pair of pieces that overlaps deepest (or lies
* closest); on a tie see `preferredOnTie`. A shape without pieces – a
* polygon that is none – meets nothing.
*/
function piecesContact(mover: ColliderShape, obstacle: ColliderShape, travel: Vec2): Contact {
let best: Contact = { separation: Infinity, normal: { x: 0, y: 0 } };
const others = piecesOf(obstacle);
for (const p of piecesOf(mover))
for (const q of others) {
const contact = roundedContact(p, q, travel);
if (
contact.separation < best.separation - EPSILON ||
(contact.separation < best.separation + EPSILON &&
preferredOnTie(contact.normal, best.normal, travel))
)
best = { separation: Math.min(contact.separation, best.separation), normal: contact.normal };
}
return best;
}
function boxAndCircle(a: Classic, b: Classic): { box: Box; circle: Circle } {
return a.kind === 'box' ? { box: a, circle: b as Circle } : { box: b as Box, circle: a };
}
+180
View File
@@ -0,0 +1,180 @@
/**
* Collision between rounded convex pieces (#151): a core – one point, a segment or a convex
* polygon – grown by a radius. A circle is a point grown by its radius, a capsule a segment, a box
* or a piece of a polygon a polygon grown by nothing. Every pair is worked out the same way: the
* separating axes of the two cores decide whether the cores overlap and, if so, how deep; apart,
* the real distance between them is taken. The radii are then taken off.
*/
import type { Vec2 } from '../model/Properties';
export interface Rounded {
points: readonly Vec2[];
r: number;
}
/** A contact as `contactBetween` reports it: gap (negative = overlap) and the normal towards the mover. */
export interface Contact {
separation: number;
normal: Vec2;
}
/** Ties between axes closer than this are settled by the travel. */
const TIE = 1e-9;
function dot(a: Vec2, b: Vec2): number {
return a.x * b.x + a.y * b.y;
}
function unit(v: Vec2): Vec2 {
const length = Math.hypot(v.x, v.y);
return length > 0 ? { x: v.x / length, y: v.y / length } : { x: 0, y: 0 };
}
/**
* Which of two normals a contact takes when both are as good (a corner on a corner, where any
* direction between them is right): the one that stands least in the travel's way while still
* standing in it – so a crate walking along the floor into the foot of a slope climbs it, and one
* that meets a block exactly corner to corner goes on along its top. A normal that does not stand
* against the travel at all only wins over another such one.
*/
export function preferredOnTie(a: Vec2, b: Vec2, travel: Vec2): boolean {
const da = dot(a, travel);
const db = dot(b, travel);
return da < 0 && db < 0 ? da > db : da < db;
}
/**
* The axes a core can be told apart along: a polygon's edge normals, a segment's normal and
* direction (so a point beyond its end is found apart too), nothing for a point.
*/
function axesOf(points: readonly Vec2[]): Vec2[] {
if (points.length < 2) return [];
const axes: Vec2[] = [];
const edges = points.length === 2 ? 1 : points.length;
for (let i = 0; i < edges; i++) {
const a = points[i]!;
const b = points[(i + 1) % points.length]!;
const d = unit({ x: b.x - a.x, y: b.y - a.y });
if (d.x === 0 && d.y === 0) continue;
axes.push({ x: -d.y, y: d.x });
if (points.length === 2) axes.push(d);
}
return axes;
}
function span(points: readonly Vec2[], n: Vec2): [number, number] {
let min = Infinity;
let max = -Infinity;
for (const p of points) {
const v = dot(p, n);
if (v < min) min = v;
if (v > max) max = v;
}
return [min, max];
}
/** The closest point to `p` on the segment ab. */
function closestOnSegment(p: Vec2, a: Vec2, b: Vec2): Vec2 {
const ab = { x: b.x - a.x, y: b.y - a.y };
const length = dot(ab, ab);
const t = length > 0 ? Math.min(1, Math.max(0, dot({ x: p.x - a.x, y: p.y - a.y }, ab) / length)) : 0;
return { x: a.x + ab.x * t, y: a.y + ab.y * t };
}
/** The edges of a core as segments; a point is one segment of no length. */
function edgesOf(points: readonly Vec2[]): [Vec2, Vec2][] {
if (points.length === 1) return [[points[0]!, points[0]!]];
if (points.length === 2) return [[points[0]!, points[1]!]];
return points.map((p, i) => [p, points[(i + 1) % points.length]!]);
}
/** The closest points of two cores that lie apart: every corner of one against every edge of the other. */
function closestPoints(a: readonly Vec2[], b: readonly Vec2[]): { onA: Vec2; onB: Vec2; distance: number } {
let best = { onA: a[0]!, onB: b[0]!, distance: Infinity };
for (const p of a)
for (const [s, t] of edgesOf(b)) {
const q = closestOnSegment(p, s, t);
const distance = Math.hypot(p.x - q.x, p.y - q.y);
if (distance < best.distance) best = { onA: p, onB: q, distance };
}
for (const q of b)
for (const [s, t] of edgesOf(a)) {
const p = closestOnSegment(q, s, t);
const distance = Math.hypot(p.x - q.x, p.y - q.y);
if (distance < best.distance) best = { onA: p, onB: q, distance };
}
return best;
}
/**
* How `mover` meets `obstacle`: the gap between the rounded pieces (negative when they overlap, its
* size then the shortest way out) and the unit normal from the obstacle towards the mover. Where
* nothing gives a direction – two cores on the same spot – the normal runs against `travel`.
*/
export function roundedContact(mover: Rounded, obstacle: Rounded, travel: Vec2): Contact {
const radii = mover.r + obstacle.r;
const against = (n: Vec2) => (dot(n, travel) > 0 ? { x: -n.x, y: -n.y } : n);
let best: Contact = { separation: -Infinity, normal: { x: 0, y: 0 } };
for (const n of [...axesOf(mover.points), ...axesOf(obstacle.points)]) {
const [a0, a1] = span(mover.points, n);
const [b0, b1] = span(obstacle.points, n);
// Pushed out along +n the mover needs to clear b1; along -n, b0.
const plus = a0 - b1;
const minus = b0 - a1;
const separation = Math.max(plus, minus);
const normal = Math.abs(plus - minus) <= TIE ? against(n) : plus > minus ? n : { x: -n.x, y: -n.y };
if (
separation > best.separation + TIE ||
(separation > best.separation - TIE && preferredOnTie(normal, best.normal, travel))
)
best = { separation: Math.max(separation, best.separation), normal };
}
// Apart, or two points (no axis at all): the real distance between the cores.
if (best.separation > 0 || best.separation === -Infinity) {
const { onA, onB, distance } = closestPoints(mover.points, obstacle.points);
if (distance > 0)
return {
separation: distance - radii,
normal: { x: (onA.x - onB.x) / distance + 0, y: (onA.y - onB.y) / distance + 0 },
};
// Two points on the same spot: out against the travel.
const back = unit({ x: -travel.x, y: -travel.y });
return { separation: -radii, normal: back.x === 0 && back.y === 0 ? { x: 0, y: -1 } : back };
}
return { separation: best.separation - radii, normal: { x: best.normal.x + 0, y: best.normal.y + 0 } };
}
/** Whether `p` lies on the rounded piece, its outline counting as on it. */
export function roundedContains(piece: Rounded, p: Vec2): boolean {
return roundedContact({ points: [p], r: 0 }, piece, { x: 0, y: 0 }).separation <= 0;
}
/** How wide the piece is along the unit direction `n`. */
export function roundedExtent(piece: Rounded, n: Vec2): number {
const [min, max] = span(piece.points, n);
return max - min + 2 * piece.r;
}
export function translated(piece: Rounded, by: Vec2): Rounded {
return { points: piece.points.map((p) => ({ x: p.x + by.x, y: p.y + by.y })), r: piece.r };
}
/**
* How far `mover` has to travel along the unit direction `d` until it clears `obstacle`; 0 when
* they are apart. Along a line the overlap of two convex pieces is one stretch, so its end is
* found by bisection.
*/
export function roundedExit(mover: Rounded, obstacle: Rounded, d: Vec2): number {
const inside = (t: number) =>
roundedContact(translated(mover, { x: d.x * t, y: d.y * t }), obstacle, d).separation < 0;
if (!inside(0)) return 0;
let hi = 1;
for (let i = 0; i < 64 && inside(hi); i++) hi *= 2;
let lo = 0;
for (let i = 0; i < 60; i++) {
const mid = (lo + hi) / 2;
if (inside(mid)) lo = mid;
else hi = mid;
}
return hi;
}
+5
View File
@@ -48,8 +48,10 @@ import {
isTextureFile,
normalizeBorders,
normalizeFrameRect,
setFrameCollision,
setFrameSlice,
} from '../model/texture';
import { pointsOf } from '../model/polygon';
import { hasParentCycle } from '../model/walk';
import {
DECLARED_PORT_KINDS,
@@ -479,6 +481,9 @@ function normalizeTexture(
),
fill: f.fill === 'stretch' ? 'stretch' : DEFAULT_FILL,
});
// Corners that are no numbers fall away; what is left is kept only as an outline (#141).
const collision = pointsOf(f.collision as PropertyValue);
if (collision.length >= 3) setFrameCollision(frame, collision);
frames.push(frame);
}
return { file, width, height, frames };