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)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1b5e386b60
commit
32057a96fc
25 files changed
+2341
-122
No files matched your search
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user