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

+2 -2
View File
@@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1057 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1067 checks (GM_STEPS=20 for one step)
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
@@ -56,7 +56,7 @@ beside it – so locally take the gate plus the step your change touches and lea
| [Scenes](https://git.nockal.com/meleki/game-maker/wiki/Scenes) | The edited scene, the start scene, and switching scenes from a graph |
| [Scene editing](https://git.nockal.com/meleki/game-maker/wiki/Scene-editing) | Placing, z-order, hierarchy, the editor offset |
| [Behaviors](https://git.nockal.com/meleki/game-maker/wiki/Behaviors) | Node graphs: variables, the node set, structs and lists, loops, nesting, editing |
| [Colliders](https://git.nockal.com/meleki/game-maker/wiki/Colliders) | Collision events and blocking movement |
| [Colliders](https://git.nockal.com/meleki/game-maker/wiki/Colliders) | Shapes (box, circle, capsule, polygon), collision events and blocking movement |
| [Physics](https://git.nockal.com/meleki/game-maker/wiki/Physics) | Rigidbodies, the collider material, gravity, the fixed step |
| [Textures](https://git.nockal.com/meleki/game-maker/wiki/Textures) | Images, frames, nine-slice, the filter, upload limits |
| [Fonts](https://git.nockal.com/meleki/game-maker/wiki/Fonts) | Font assets, the picker, what a text falls back to |
+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 };
+4
View File
@@ -311,6 +311,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
gameInfo: () => gameView.debugInfo(),
/** Selection outlines in the Scene tab: a switched-off object is invisible but still framed. */
gizmos: () => viewport.gizmoInfo(),
/** The corner handles of the polygon collider being edited, in global pixels (#151). */
polygonCorners: () => viewport.cornerInfo(),
layerOf: (id: string) => viewport.layerOf(id),
/** The icon a particle source wears in the Scene tab, with its name and its owner link. */
sceneIcon: (id: string) => viewport.iconInfo(id),
@@ -474,6 +476,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
/** Where an image pixel of the shown sheet sits on screen (frames are dragged in image coordinates). */
pointAt: (x: number, y: number) => textureEditor.imageToScreen(x, y),
rect: () => textureEditor.canvasRect(),
/** Whether a press edits the selected frame's collision outline (#141). */
collisionMode: () => textureEditor.canvas.collisionMode,
},
animation: {
current: () => editor.animationEditor.currentAssetId,
+58
View File
@@ -0,0 +1,58 @@
/**
* Editing an outline by its corners (#141, #151), the same in the Scene view (a polygon collider)
* and in the Texture tab (a frame's collision outline): drag a corner, press on an edge to put a
* corner there, double-click a corner to take it away. Pure, so the rules are testable without a view.
*/
import type { Vec2 } from '../core/model/Properties';
/** What lies under the pointer: a corner, or the edge from corner `index` to the next one. */
export type PolygonGrip = { kind: 'corner'; index: number } | { kind: 'edge'; index: number; at: Vec2 };
/** Fewer corners than this are no polygon; the last three cannot be taken away. */
export const MIN_CORNERS = 3;
function closestOnSegment(p: Vec2, a: Vec2, b: Vec2): Vec2 {
const ab = { x: b.x - a.x, y: b.y - a.y };
const length = ab.x * ab.x + ab.y * ab.y;
const t = length > 0 ? Math.min(1, Math.max(0, ((p.x - a.x) * ab.x + (p.y - a.y) * ab.y) / length)) : 0;
return { x: a.x + ab.x * t, y: a.y + ab.y * t };
}
/**
* The corner or edge within `tolerance` of `p`, the nearest one; a corner wins over an edge, since
* every corner lies on two edges. An outline of one or two corners has no edge to press on yet.
*/
export function gripAt(points: readonly Vec2[], p: Vec2, tolerance: number): PolygonGrip | null {
let best: PolygonGrip | null = null;
let nearest = tolerance;
for (const [index, corner] of points.entries()) {
const distance = Math.hypot(corner.x - p.x, corner.y - p.y);
if (distance > nearest) continue;
best = { kind: 'corner', index };
nearest = distance;
}
if (best || points.length < MIN_CORNERS) return best;
for (const [index, a] of points.entries()) {
const at = closestOnSegment(p, a, points[(index + 1) % points.length]!);
const distance = Math.hypot(at.x - p.x, at.y - p.y);
if (distance > nearest) continue;
best = { kind: 'edge', index, at };
nearest = distance;
}
return best;
}
/** A corner put at `at` on the edge after corner `index`; it becomes corner `index + 1`. */
export function withCornerAfter(points: readonly Vec2[], index: number, at: Vec2): Vec2[] {
return [...points.slice(0, index + 1), { ...at }, ...points.slice(index + 1)];
}
export function withCornerMoved(points: readonly Vec2[], index: number, to: Vec2): Vec2[] {
return points.map((p, i) => (i === index ? { ...to } : { ...p }));
}
/** The outline without corner `index`; null when that would leave it fewer than `min` corners. */
export function withoutCorner(points: readonly Vec2[], index: number, min = MIN_CORNERS): Vec2[] | null {
if (points.length - 1 < min) return null;
return points.filter((_, i) => i !== index).map((p) => ({ ...p }));
}
+188 -9
View File
@@ -8,23 +8,28 @@ import {
} from 'pixi.js';
import {
AddFrameCommand,
SetFrameCollisionCommand,
SetFrameRectCommand,
SetFrameSliceCommand,
} from '../../core/commands/frameCommands';
import { convexPieces } from '../../core/model/polygon';
import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset';
import { textOf, type Vec2 } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import {
frameBorders,
frameCollision,
frameFill,
type FrameRect,
MIN_FRAME_SIZE,
normalizeFrameRect,
rawFrameSlice,
restoreFrameSlice,
setFrameCollision,
setFrameSlice,
} from '../../core/model/texture';
import { type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
import { pressCount, type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit';
import { dashedLine } from '../../viewport/symbols';
import { textureLibrary } from '../../viewport/TextureLibrary';
import type { EditorState } from '../state';
@@ -73,6 +78,20 @@ interface FrameDrag {
moved: boolean;
}
/**
* A corner of a frame's collision outline being dragged (#141) – also one just put down. The model
* holds the preview; `before` is put back on release so the command records one undo step.
*/
interface CornerDrag {
frameId: string;
before: Vec2[];
points: Vec2[];
index: number;
pointer: Vec2;
added: boolean;
moved: boolean;
}
/** A new frame being spanned, in image pixels; nothing is in the model yet. */
interface NewFrame {
start: Vec2;
@@ -102,6 +121,9 @@ export class TextureCanvas extends Widget {
private panning: { pointer: Vec2; panX: number; panY: number } | null = null;
private dragging: FrameDrag | null = null;
private spanning: NewFrame | null = null;
private corner: CornerDrag | null = null;
/** Whether a press edits the selected frame's collision outline instead of the frames (#141). */
private collision = false;
constructor(private readonly state: EditorState) {
super();
@@ -144,6 +166,16 @@ export class TextureCanvas extends Widget {
return this.assetId;
}
setCollisionMode(on: boolean): void {
if (this.collision === on) return;
this.collision = on;
this.redraw();
}
get collisionMode(): boolean {
return this.collision;
}
fit(): void {
this.needsFit = true;
this.redraw();
@@ -185,6 +217,11 @@ export class TextureCanvas extends Widget {
ui().focus.focus(this);
const point = localToImage(this.view, local.x, local.y);
const tolerance = HANDLE_SIZE / this.view.zoom;
if (this.collision) {
this.pressOutline(asset, point, local, tolerance, e);
e.stopPropagation();
return;
}
// The selected frame is tested first, so its handles stay reachable under a frame drawn later.
// Its resize handles before its border lines – a border still sitting on the frame's edge must
@@ -221,6 +258,74 @@ export class TextureCanvas extends Widget {
e.stopPropagation();
}
/**
* A press in collision mode. On the selected frame: a double-click on a corner takes it away, a
* press on a corner drags it, on an edge puts a corner there, anywhere else inside the frame adds
* one after the last – how an outline is drawn from nothing. Another frame is only selected, and
* no frame is moved or spanned while the mode is on.
*/
private pressOutline(
asset: TextureAsset,
point: Vec2,
local: Vec2,
tolerance: number,
e: FederatedPointerEvent,
): void {
const selected = this.frame(this.selectedFrameId());
if (selected) {
const at = { x: point.x - selected.x, y: point.y - selected.y };
const points = frameCollision(selected);
const grip = gripAt(points, at, tolerance);
if (grip?.kind === 'corner' && pressCount(e, `outline ${selected.id} ${grip.index}`) === 2) {
this.state.execute(
new SetFrameCollisionCommand(asset.id, selected.id, withoutCorner(points, grip.index, 0)!),
);
return;
}
// A corner often belongs on the frame's edge: a press just beside it still counts as in it.
const inside =
at.x >= -tolerance &&
at.y >= -tolerance &&
at.x <= selected.w + tolerance &&
at.y <= selected.h + tolerance;
if (grip || inside) {
const corner = inFrame(selected, grip?.kind === 'edge' ? grip.at : at);
const next =
grip?.kind === 'corner'
? points
: grip?.kind === 'edge'
? withCornerAfter(points, grip.index, corner)
: [...points, corner];
this.corner = {
frameId: selected.id,
before: points,
points: next,
index: grip?.kind === 'corner' ? grip.index : grip ? grip.index + 1 : next.length - 1,
pointer: { x: local.x, y: local.y },
added: grip?.kind !== 'corner',
moved: false,
};
if (this.corner.added) this.previewOutline();
return;
}
}
for (let i = asset.frames.length - 1; i >= 0; i--) {
const frame = asset.frames[i]!;
if (handleAt(frame, point, tolerance) === null) continue;
this.state.select({ kind: 'frame', assetId: asset.id, frameId: frame.id });
return;
}
this.state.select({ kind: 'asset', assetId: this.assetId });
}
private previewOutline(): void {
const corner = this.corner;
const frame = corner && this.frame(corner.frameId);
if (!corner || !frame) return;
setFrameCollision(frame, corner.points);
this.state.preview();
}
private startDrag(
frame: TextureFrame,
handle: FrameHandle | null,
@@ -250,6 +355,25 @@ export class TextureCanvas extends Widget {
this.redraw();
return;
}
if (this.corner) {
const corner = this.corner;
const frame = this.frame(corner.frameId);
if (!frame) return;
if (
!corner.moved &&
Math.hypot(local.x - corner.pointer.x, local.y - corner.pointer.y) < DRAG_THRESHOLD
)
return;
corner.moved = true;
const point = localToImage(this.view, local.x, local.y);
corner.points = withCornerMoved(
corner.points,
corner.index,
inFrame(frame, { x: point.x - frame.x, y: point.y - frame.y }),
);
this.previewOutline();
return;
}
if (this.dragging) {
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
@@ -290,6 +414,16 @@ export class TextureCanvas extends Widget {
private onPointerUp(): void {
this.panning = null;
const corner = this.corner;
this.corner = null;
const frame = corner && this.frame(corner.frameId);
if (corner && frame) {
setFrameCollision(frame, corner.before);
if (corner.added || corner.moved)
this.state.execute(new SetFrameCollisionCommand(this.assetId, corner.frameId, corner.points));
else this.state.preview();
return;
}
const drag = this.dragging;
const span = this.spanning;
this.dragging = null;
@@ -343,6 +477,10 @@ export class TextureCanvas extends Widget {
this.cursor = 'default';
return;
}
if (this.collision) {
this.cursor = 'crosshair';
return;
}
const point = localToImage(this.view, local.x, local.y);
const tolerance = HANDLE_SIZE / this.view.zoom;
const selected = this.frame(this.selectedFrameId());
@@ -419,8 +557,9 @@ export class TextureCanvas extends Widget {
alive.add(frame.id);
this.drawFrame(frame, frame.id === selectedId);
}
for (const frame of asset.frames) this.drawOutline(frame, this.collision && frame.id === selectedId);
const selected = this.frame(selectedId);
if (selected) {
if (selected && !this.collision) {
this.drawBorders(selected);
this.drawHandles(selected);
}
@@ -464,6 +603,34 @@ export class TextureCanvas extends Widget {
this.overlay.stroke({ width: 1, color: Theme.colors.marquee });
}
/**
* A frame's collision outline (#141), always shown so one sees which frames block with one; the
* edited one gets a handle on every corner. An outline that is no polygon is drawn in red.
*/
private drawOutline(frame: TextureFrame, edited: boolean): void {
const points = frameCollision(frame);
if (points.length === 0) return;
const screen = points.map((p) => imageToLocal(this.view, frame.x + p.x, frame.y + p.y));
const color = convexPieces(points).length > 0 ? Theme.colors.collider : Theme.colors.danger;
if (screen.length >= 3)
this.overlay
.poly(screen, true)
.fill({ color, alpha: 0.15 })
.stroke({ width: edited ? 2 : 1, color });
else if (screen.length === 2)
this.overlay
.moveTo(screen[0]!.x, screen[0]!.y)
.lineTo(screen[1]!.x, screen[1]!.y)
.stroke({ width: 2, color });
if (!edited) return;
const half = HANDLE_SIZE / 2;
for (const c of screen)
this.overlay
.rect(c.x - half, c.y - half, HANDLE_SIZE, HANDLE_SIZE)
.fill(Theme.colors.accentText)
.stroke({ width: 1, color });
}
private drawHandles(frame: TextureFrame): void {
const half = HANDLE_SIZE / 2;
for (const handle of frameHandles()) {
@@ -504,15 +671,27 @@ export class TextureCanvas extends Widget {
private syncHint(asset: TextureAsset | null, hasPicture: boolean): void {
const text = !asset
? 'No texture selected – pick one in the Assets panel or import an image.'
: !asset.file
? 'This texture has no picture yet.'
: !hasPicture
? 'Loading…'
: asset.frames.length === 0
? 'Drag on the picture to cut a frame, or use Slice to cut a grid at once.'
: '';
: this.collision && asset.file && hasPicture
? this.selectedFrameId()
? 'Collision: click in the frame to add a corner, drag one to move it, click an edge to insert, double-click to remove.'
: 'Collision: select a frame to draw the outline its tiles block with.'
: !asset.file
? 'This texture has no picture yet.'
: !hasPicture
? 'Loading…'
: asset.frames.length === 0
? 'Drag on the picture to cut a frame, or use Slice to cut a grid at once.'
: '';
this.hint.text = text;
this.hint.visible = text !== '';
this.hint.position.set(Theme.padding * 2, Theme.padding * 2);
}
}
/** A corner of an outline: on whole pixels, and inside its frame. */
function inFrame(frame: TextureFrame, p: Vec2): Vec2 {
return {
x: Math.min(frame.w, Math.max(0, Math.round(p.x))),
y: Math.min(frame.h, Math.max(0, Math.round(p.y))),
};
}
+12 -1
View File
@@ -11,11 +11,22 @@ const INFO_WIDTH = 190;
export class TextureEditor extends AssetTabEditor<TextureAsset, TextureCanvas> {
private readonly sliceButton: Button;
/** Switches the canvas between editing frames and editing their collision outlines (#141). */
private readonly collisionButton: Button;
constructor(state: EditorState) {
super(state, { noun: 'Texture', infoWidth: INFO_WIDTH });
this.sliceButton = new Button('Slice…', { onClick: () => this.openSlice() });
this.mount(new TextureCanvas(state), [this.sliceButton]);
this.collisionButton = new Button('Collision', {
toggle: true,
onClick: () => this.setCollisionMode(!this.canvas.collisionMode),
});
this.mount(new TextureCanvas(state), [this.sliceButton, this.collisionButton]);
}
setCollisionMode(on: boolean): void {
this.canvas.setCollisionMode(on);
this.collisionButton.setToggled(on);
}
/** Where an image pixel of the shown sheet sits on screen – for the check scripts. */
+197 -7
View File
@@ -5,7 +5,11 @@
*/
import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
import { oneStep } from '../../core/commands/CompoundCommand';
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
import {
MoveSceneObjectCommand,
PlaceAssetCommand,
SetOverrideCommand,
} from '../../core/commands/sceneObjectCommands';
import { type CellEdit, PaintTilesCommand } from '../../core/commands/tilemapCommands';
import type { TilemapAsset, TilemapLayer } from '../../core/model/Asset';
import { hasSceneIcon } from '../../core/model/AssetTypes';
@@ -21,7 +25,7 @@ import {
scaleOf,
} from '../../core/model/matrix';
import { placementRejection } from '../../core/model/placement';
import type { Position, Vec2 } from '../../core/model/Properties';
import type { ListValue, Position, PropertyValue, Vec2 } from '../../core/model/Properties';
import { findAsset, findAssetOf, type ObjectHolder } from '../../core/model/Project';
import {
cellAt,
@@ -39,10 +43,21 @@ import {
} from '../../core/model/tilemap';
import type { CellRect } from '../../core/model/tilemapCollision';
import { footprintCorners } from '../../core/properties/bounds';
import { colliderPoints, COLLIDER_POINTS_KEY } from '../../core/properties/collider';
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
import { type EditorState, objectSelection, type TilemapTool } from '../state';
import { paintedLayerId, paintedTilemap, pickTile } from '../panels/TilemapPanel';
import { activeDrag, drawBand, drawGrid, endDrag, onDragChange, Theme, textStyle } from '../../ui';
import {
activeDrag,
drawBand,
drawGrid,
endDrag,
onDragChange,
pressCount,
Theme,
textStyle,
} from '../../ui';
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit';
import { collectHolderItems, type DrawItem } from '../../viewport/items';
import type { LayerFocus } from '../../viewport/tilemap';
import { dashedLine, drawSourceIcon, placeNode } from '../../viewport/symbols';
@@ -50,6 +65,11 @@ import { screenToWorld, type View, worldToScreen } from '../../viewport/view';
import { FreeViewport } from './FreeViewport';
import type { Frame } from './WorldViewport';
/** Screen pixels around a polygon's corner or edge that grab it. */
const CORNER_GRIP = 7;
/** Side of a corner handle in screen pixels. */
const CORNER_SIZE = 8;
/** Pixels of travel before a press turns into a drag. */
const MARQUEE_THRESHOLD = 3;
const DRAG_THRESHOLD = 2;
@@ -65,6 +85,24 @@ interface ObjectDrag {
moved: boolean;
}
/**
* A corner of a polygon collider being dragged (#151) – one just put on an edge, too. Like a move
* drag it writes the model as a preview; `before` is the override that was there, put back on
* release so the command records the edit as one undo step.
*/
interface CornerDrag {
holderId: string;
objectId: string;
before: PropertyValue | undefined;
points: Vec2[];
index: number;
/** From the world into the collider's own units. */
toLocal: Mat;
pointer: Vec2;
inserted: boolean;
moved: boolean;
}
/** An asset hovering over the view on its way in: where it would land, and whether it may. */
interface DropPreview {
assetId: string;
@@ -124,6 +162,9 @@ export class SceneViewport extends FreeViewport {
/** Where each placed tilemap blocks, outlined like a collider (#132). */
private readonly solids = new Map<string, Graphics>();
private dragging: ObjectDrag | null = null;
private corner: CornerDrag | null = null;
/** The corners drawn for the edited polygon, in global pixels (test hook). */
private lastCorners: { objectId: string; corners: Vec2[] } | null = null;
private stroke: PaintStroke | null = null;
private marquee: Marquee | null = null;
private dropPreview: DropPreview | null = null;
@@ -206,6 +247,7 @@ export class SceneViewport extends FreeViewport {
this.startPan(e);
return;
}
if (e.button === 0 && this.startCornerEdit(e)) return;
if (e.button === 0 && e.target === this) {
// Empty space: a click deselects, a drag becomes a rubber band – decided on release.
const local = this.toLocal(e.global);
@@ -225,6 +267,10 @@ export class SceneViewport extends FreeViewport {
return;
}
if (this.panTo(e)) return;
if (this.corner) {
this.dragCorner(e);
return;
}
if (this.stroke) {
const item = this.lastItems.find((i) => i.id === this.stroke!.objectId);
if (!item) return;
@@ -315,6 +361,10 @@ export class SceneViewport extends FreeViewport {
return;
}
this.endPan();
if (this.corner) {
this.endCorner();
return;
}
if (this.stroke) {
this.endStroke();
return;
@@ -504,8 +554,104 @@ export class SceneViewport extends FreeViewport {
this.redraw();
}
/**
* The polygon collider whose corners can be edited: the primary selection, if it is one and
* belongs to this view's holder – a placed container's content is edited in its template.
*/
private editedPolygon(): DrawItem | null {
const s = this.state.selection;
const holder = this.holder();
if (s?.kind !== 'object' || !holder || !objectRef(this.state.project, holder, s.objectId)) return null;
const item = this.lastItems.find((i) => i.id === s.objectId);
return item?.type === 'collider' && item.props.shape === 'polygon' ? item : null;
}
/**
* A press on a corner or an edge of the edited polygon: a double-click takes the corner away,
* a press on an edge puts a corner there, and either can then be dragged. True when it was one.
*/
private startCornerEdit(e: FederatedPointerEvent): boolean {
const item = this.editedPolygon();
const holder = this.holder();
const ref = item && holder && objectRef(this.state.project, holder, item.id);
if (!item || !holder || !ref) return false;
const toLocal = invert(item.m);
const pointer = this.toLocal(e.global);
const at = applyPoint(toLocal, screenToWorld(this.currentView(), pointer.x, pointer.y));
const points = colliderPoints(item.props);
const tolerance = CORNER_GRIP / (this.currentView().scale * (scaleOf(item.m) || 1));
const grip = gripAt(points, at, tolerance);
if (!grip) return false;
e.stopPropagation();
if (grip.kind === 'corner' && pressCount(e, `corner ${item.id} ${grip.index}`) === 2) {
const next = withoutCorner(points, grip.index);
if (next)
this.state.execute(new SetOverrideCommand(holder.id, item.id, COLLIDER_POINTS_KEY, listOf(next)));
else this.state.setNotice('A polygon keeps at least three corners.');
return true;
}
const inserted = grip.kind === 'edge';
this.corner = {
holderId: holder.id,
objectId: item.id,
before: ref.object.overrides[COLLIDER_POINTS_KEY],
points: inserted ? withCornerAfter(points, grip.index, rounded(grip.at)) : points,
index: inserted ? grip.index + 1 : grip.index,
toLocal,
pointer: { x: pointer.x, y: pointer.y },
inserted,
moved: false,
};
if (inserted) this.previewCorners();
return true;
}
private dragCorner(e: FederatedPointerEvent): void {
const corner = this.corner!;
const pointer = this.toLocal(e.global);
if (
!corner.moved &&
Math.hypot(pointer.x - corner.pointer.x, pointer.y - corner.pointer.y) < DRAG_THRESHOLD
)
return;
corner.moved = true;
const at = applyPoint(corner.toLocal, screenToWorld(this.currentView(), pointer.x, pointer.y));
corner.points = withCornerMoved(corner.points, corner.index, rounded(at));
this.previewCorners();
}
private previewCorners(): void {
const corner = this.corner;
const holder = this.holder();
const ref = corner && holder && objectRef(this.state.project, holder, corner.objectId);
if (!corner || !ref) return;
ref.setOverride(COLLIDER_POINTS_KEY, listOf(corner.points));
this.state.preview();
}
/** Puts the preview back and lets the command do the edit, so one drag is one undo step. */
private endCorner(): void {
const corner = this.corner!;
this.corner = null;
const holder = this.holder();
const ref = holder && objectRef(this.state.project, holder, corner.objectId);
if (!ref) return;
ref.setOverride(COLLIDER_POINTS_KEY, corner.before);
if (corner.moved || corner.inserted)
this.state.execute(
new SetOverrideCommand(corner.holderId, corner.objectId, COLLIDER_POINTS_KEY, listOf(corner.points)),
);
else this.state.preview();
}
/** The corners of the polygon being edited, where they are drawn (test hook). */
cornerInfo(): { objectId: string; corners: Vec2[] } | null {
return this.lastCorners;
}
protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
if (e.button !== 0) return;
if (this.startCornerEdit(e)) return;
const holder = this.resolved();
const object = holder && findObject(holder, objectId);
if (!holder || !object) return;
@@ -580,12 +726,16 @@ export class SceneViewport extends FreeViewport {
this.solids.delete(id);
}
/** The blocking rectangles of a placed tilemap as outlined, in cells (test hook). */
solidInfo(id: string): { visible: boolean; rects: CellRect[] } | null {
/** The blocking rectangles and outlined cells of a placed tilemap as outlined, in cells (test hook). */
solidInfo(id: string): { visible: boolean; rects: CellRect[]; outlines: Vec2[][] } | null {
const node = this.solids.get(id);
const item = this.lastItems.find((i) => i.id === id);
if (!node || !item?.tilemap) return null;
return { visible: node.visible, rects: item.tilemap.solids.map((rect) => ({ ...rect })) };
return {
visible: node.visible,
rects: item.tilemap.solids.map((rect) => ({ ...rect })),
outlines: item.tilemap.outlines.map((polygon) => polygon.outline.map((p) => ({ ...p }))),
};
}
/**
@@ -596,7 +746,8 @@ export class SceneViewport extends FreeViewport {
private syncSolids(item: DrawItem, show: boolean, scale: number): void {
let node = this.solids.get(item.id);
const rects = item.tilemap?.solids ?? [];
if (!show || rects.length === 0) {
const outlines = item.tilemap?.outlines ?? [];
if (!show || rects.length + outlines.length === 0) {
if (node) node.visible = false;
return;
}
@@ -615,6 +766,11 @@ export class SceneViewport extends FreeViewport {
const corner = cellOrigin(item.props, rect.x, rect.y);
node.rect(corner.x, corner.y, rect.w * tile, rect.h * tile);
}
for (const polygon of outlines)
node.poly(
polygon.outline.map((p) => cellOrigin(item.props, p.x, p.y)),
true,
);
node.fill({ color: Theme.colors.collider, alpha: 0.12 }).stroke({ width, color: Theme.colors.collider });
}
@@ -778,6 +934,30 @@ export class SceneViewport extends FreeViewport {
.stroke({ width: 2, color: Theme.colors.accent });
}
/** A handle on every corner of the polygon collider being edited (#151). */
private drawCorners(view: View): void {
const item = this.editedPolygon();
this.lastCorners = null;
if (!item) return;
const half = CORNER_SIZE / 2;
const corners = colliderPoints(item.props).map((p) => {
const world = applyPoint(item.m, p);
return worldToScreen(view, world.x, world.y);
});
for (const c of corners)
this.gizmos
.rect(c.x - half, c.y - half, CORNER_SIZE, CORNER_SIZE)
.fill(Theme.colors.accentText)
.stroke({ width: 1, color: Theme.colors.accent });
this.lastCorners = {
objectId: item.id,
corners: corners.map((c) => {
const g = this.gizmos.toGlobal(c);
return { x: g.x, y: g.y };
}),
};
}
private screenCorners(corners: readonly Vec2[], view: View): Vec2[] {
return corners.map((c) => worldToScreen(view, c.x, c.y));
}
@@ -806,9 +986,19 @@ export class SceneViewport extends FreeViewport {
this.gizmos.rect(c.x - 3, c.y - 3, 6, 6).fill({ color: Theme.colors.gizmo, alpha });
}
}
this.drawCorners(view);
this.drawDropPreview(view);
this.drawPaintOverlay(items, view);
const m = this.marquee;
if (m?.active) drawBand(this.gizmos, m.start, m.current);
}
}
/** A corner lands on whole units, as a dragged object does. */
function rounded(p: Vec2): Vec2 {
return { x: Math.round(p.x), y: Math.round(p.y) };
}
function listOf(points: readonly Vec2[]): ListValue {
return points.map((p) => ({ x: p.x, y: p.y }));
}
+112 -42
View File
@@ -8,8 +8,9 @@ import type {
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
import { apply, determinant, IDENTITY, invert, type Mat } from '../../core/model/matrix';
import type { Vec2 } from '../../core/model/Properties';
import { signedArea } from '../../core/model/polygon';
import { parentChain } from '../../core/model/walk';
import { type ColliderShape, isTrigger, shapesOverlap } from '../../core/properties/collider';
import { type ColliderShape, isTrigger, shapeArea, shapesOverlap } from '../../core/properties/collider';
import { hasTransformProps } from '../../core/properties/PropertySchema';
import {
type BodyType,
@@ -55,7 +56,9 @@ interface Pose {
interface Body {
kind: 'rigid' | 'free' | 'tilemap';
body: RigidBody;
/** The engine's colliders – several for a polygon, one per convex piece – and whose each is. */
colliders: Collider[];
sources: Entity[];
/** What the colliders were built from; they are built again when it changes (a scaled object). */
shapeKey: string;
settingsKey: string;
@@ -277,7 +280,7 @@ export class PhysicsSystem implements System, CollisionSource {
const mass = rigidbody ? rigidbodySettings(rigidbody.props).mass : 0;
if (mass > 0) return mass;
return blockingCollidersOf(world, entity).reduce(
(sum, c) => sum + colliderMaterial(c.props).density * areaOf(shapeOf(c)) * toMetres(1) ** 2,
(sum, c) => sum + colliderMaterial(c.props).density * shapeArea(shapeOf(c)) * toMetres(1) ** 2,
0,
);
}
@@ -351,6 +354,7 @@ export class PhysicsSystem implements System, CollisionSource {
kind: want.kind,
body: this.engine!.createRigidBody(desc),
colliders: [],
sources: [],
shapeKey: '',
settingsKey: '',
turns: false,
@@ -372,7 +376,7 @@ export class PhysicsSystem implements System, CollisionSource {
if (want.kind === 'tilemap') return { x: 0, y: 0, angle: 0 };
if (want.kind === 'free') {
const shape = shapeOf(key);
return { x: shape.x, y: shape.y, angle: shape.kind === 'box' ? shape.angle : 0 };
return { x: shape.x, y: shape.y, angle: shape.kind === 'circle' ? 0 : shape.angle };
}
return matrixPose(worldMatrixOf(key));
}
@@ -433,15 +437,12 @@ export class PhysicsSystem implements System, CollisionSource {
private build(body: Body, want: Wanted, key: Entity, world: World): void {
const shapes = this.shapesWanted(want, key, world);
const origin = body.placed;
const local = shapes.map(({ entity, shape }) => ({ entity, shape, at: relativeTo(origin, shape) }));
const local = shapes.map(({ entity, shape }) => ({ entity, shape, pieces: piecesInBody(origin, shape) }));
const shapeKey =
JSON.stringify(
local.map(({ entity, shape, at }) => [
local.map(({ entity, pieces }) => [
entity.id,
shape.kind === 'box' ? [round(shape.w), round(shape.h)] : round(shape.r),
round(at.x),
round(at.y),
round(at.angle),
pieces.map(pieceKey),
entity.type === 'collider' ? [isTrigger(entity.props), colliderMaterial(entity.props)] : 0,
]),
) + JSON.stringify(want.settings?.mass ?? 0);
@@ -452,26 +453,28 @@ export class PhysicsSystem implements System, CollisionSource {
this.engine!.removeCollider(collider, true);
}
body.colliders = [];
body.sources = [];
const mass = want.settings?.mass ?? 0;
const area = local.reduce((sum, { entity, shape }) => sum + (isSolid(entity) ? areaOf(shape) : 0), 0);
for (const { entity, shape, at } of local) {
if (areaOf(shape) <= 0) continue;
const desc = this.colliderDesc(
entity,
shape,
at,
mass > 0 && area > 0 ? (mass * areaOf(shape)) / area : 0,
);
const collider = this.engine!.createCollider(desc, body.body);
body.colliders.push(collider);
this.parts.set(collider.handle, { entity, shape, owner: body });
}
const area = local.reduce(
(sum, { entity, shape }) => sum + (isSolid(entity) ? shapeArea(shape) : 0),
0,
);
for (const { entity, shape, pieces } of local)
for (const piece of pieces) {
const share = mass > 0 && area > 0 ? (mass * pieceArea(piece)) / area : 0;
const desc = this.colliderDesc(entity, piece, share);
if (!desc) continue;
const collider = this.engine!.createCollider(desc, body.body);
body.colliders.push(collider);
body.sources.push(entity);
this.parts.set(collider.handle, { entity, shape, owner: body });
}
}
// Shapes are refreshed every tick even when the colliders stay: the overlap test reads them.
const built = local.filter(({ shape }) => areaOf(shape) > 0);
const shapeOfEntity = new Map(local.map(({ entity, shape }) => [entity, shape]));
body.colliders.forEach((collider, i) => {
const { entity, shape } = built[i]!;
this.parts.set(collider.handle, { entity, shape, owner: body });
const entity = body.sources[i]!;
this.parts.set(collider.handle, { entity, shape: shapeOfEntity.get(entity)!, owner: body });
const on = isHandleEnabled(entity);
if (collider.isEnabled() === on) return;
collider.setEnabled(on);
@@ -480,7 +483,7 @@ export class PhysicsSystem implements System, CollisionSource {
});
if (body.kind !== 'rigid') return;
// Without a collider the engine would weigh it nothing, and a thing of no weight never falls.
const weighed = built.some(({ entity }) => isSolid(entity) && isHandleEnabled(entity));
const weighed = body.sources.some((entity) => isSolid(entity) && isHandleEnabled(entity));
const own = weighed ? 0 : want.settings?.mass || 1;
if (own !== body.ownMass) {
body.ownMass = own;
@@ -494,13 +497,30 @@ export class PhysicsSystem implements System, CollisionSource {
return want.colliders.map((entity) => ({ entity, shape: shapeOf(entity) }));
}
private colliderDesc(entity: Entity, shape: ColliderShape, at: Pose, mass: number): ColliderDesc {
/** The engine collider of one piece; null for a piece of no size, which the engine would refuse. */
private colliderDesc(entity: Entity, piece: Piece, mass: number): ColliderDesc | null {
const R = this.rapier;
const desc =
shape.kind === 'circle'
? R.ColliderDesc.ball(toMetres(shape.r))
: R.ColliderDesc.cuboid(toMetres(shape.w / 2), toMetres(shape.h / 2));
desc.setTranslation(toMetres(at.x), toMetres(at.y)).setRotation(at.angle);
if (pieceArea(piece) <= 0) return null;
let desc: ColliderDesc | null;
switch (piece.kind) {
case 'ball':
desc = R.ColliderDesc.ball(toMetres(piece.r));
break;
case 'cuboid':
desc = R.ColliderDesc.cuboid(toMetres(piece.hw), toMetres(piece.hh));
break;
case 'capsule':
desc = R.ColliderDesc.capsule(toMetres(piece.half), toMetres(piece.r));
break;
case 'convex':
desc = R.ColliderDesc.convexHull(
new Float32Array(piece.points.flatMap((p) => [toMetres(p.x), toMetres(p.y)])),
);
break;
}
if (!desc) return null;
if (piece.kind !== 'convex')
desc.setTranslation(toMetres(piece.x), toMetres(piece.y)).setRotation(piece.angle);
// A tile has no material of its own; it gets what a new collider starts with.
const material = colliderMaterial(entity.type === 'collider' ? entity.props : {});
desc.setFriction(material.friction).setRestitution(material.bounciness);
@@ -639,21 +659,71 @@ function blend(a: Pose, b: Pose, t: number): Pose {
};
}
/** A world shape as seen from a body at `origin`: offset and turn in the body's own frame. */
function relativeTo(origin: Pose, shape: ColliderShape): Pose {
const dx = shape.x - origin.x;
const dy = shape.y - origin.y;
/**
* One engine collider in the body's own frame (world units): a ball, a box or a capsule placed and
* turned there, or a convex piece given by its corners.
*/
type Piece =
| { kind: 'ball'; x: number; y: number; angle: number; r: number }
| { kind: 'cuboid'; x: number; y: number; angle: number; hw: number; hh: number }
| { kind: 'capsule'; x: number; y: number; angle: number; half: number; r: number }
| { kind: 'convex'; points: Vec2[] };
/** A world shape as seen from a body at `origin`, as the engine colliders that make it up. */
function piecesInBody(origin: Pose, shape: ColliderShape): Piece[] {
const cos = Math.cos(origin.angle);
const sin = Math.sin(origin.angle);
return {
x: dx * cos + dy * sin,
y: -dx * sin + dy * cos,
angle: (shape.kind === 'box' ? shape.angle : 0) - origin.angle,
const toBody = (p: Vec2): Vec2 => {
const dx = p.x - origin.x;
const dy = p.y - origin.y;
return { x: dx * cos + dy * sin, y: -dx * sin + dy * cos };
};
switch (shape.kind) {
case 'circle':
return [{ kind: 'ball', ...toBody(shape), angle: 0, r: shape.r }];
case 'box':
return [
{
kind: 'cuboid',
...toBody(shape),
angle: shape.angle - origin.angle,
hw: shape.w / 2,
hh: shape.h / 2,
},
];
case 'capsule': {
const a = toBody(shape.a);
const b = toBody(shape.b);
const half = Math.hypot(b.x - a.x, b.y - a.y) / 2;
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
if (half === 0) return [{ kind: 'ball', ...mid, angle: 0, r: shape.r }];
// The engine's capsule lies along its own y axis.
const angle = Math.atan2(b.y - a.y, b.x - a.x) - Math.PI / 2;
return [{ kind: 'capsule', ...mid, angle, half, r: shape.r }];
}
case 'polygon':
return shape.pieces.map((piece) => ({ kind: 'convex', points: piece.map(toBody) }));
}
}
function areaOf(shape: ColliderShape): number {
return shape.kind === 'circle' ? Math.PI * shape.r * shape.r : shape.w * shape.h;
function pieceArea(piece: Piece): number {
switch (piece.kind) {
case 'ball':
return Math.PI * piece.r * piece.r;
case 'cuboid':
return 4 * piece.hw * piece.hh;
case 'capsule':
return Math.PI * piece.r * piece.r + 4 * piece.half * piece.r;
case 'convex':
return Math.abs(signedArea(piece.points));
}
}
/** What a piece was built from, rounded, so a rebuild happens when – and only when – it changed. */
function pieceKey(piece: Piece): unknown {
if (piece.kind === 'convex') return piece.points.map((p) => [round(p.x), round(p.y)]);
const { kind, ...values } = piece;
return [kind, ...Object.values(values).map(round)];
}
/** A tilemap's cells always block; a collider unless it is a trigger. */
+33 -16
View File
@@ -2,7 +2,14 @@ import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
import { applyVector, determinant, IDENTITY, invert, linearOf, type Mat } from '../../core/model/matrix';
import type { Vec2 } from '../../core/model/Properties';
import { findAssetOf } from '../../core/model/Project';
import { type CellRect, cellRectShape, solidRects } from '../../core/model/tilemapCollision';
import {
type CellPolygon,
cellPolygonShape,
type CellRect,
cellRectShape,
solidPolygons,
solidRects,
} from '../../core/model/tilemapCollision';
import { parentChain } from '../../core/model/walk';
import {
type ColliderShape,
@@ -97,35 +104,45 @@ export interface CollisionSource {
*/
export class Solids {
/**
* The blocking rectangles of each tilemap, in cells. Worked out once per tick – painting, or a
* behavior changing the map, is picked up by the next one – and turned into world shapes only
* when asked, since the map may move in between.
* The blocking rectangles and outlined cells of each tilemap, in cells. Worked out once per tick –
* painting, or a behavior changing the map, is picked up by the next one – and turned into world
* shapes only when asked, since the map may move in between.
*/
private rects = new Map<Entity, CellRect[]>();
private blocks = new Map<Entity, { rects: CellRect[]; polygons: CellPolygon[] }>();
/** Forgets the rectangles, so the next tick works them out afresh. */
/** Forgets the cells, so the next tick works them out afresh. */
reset(): void {
this.rects = new Map();
this.blocks = new Map();
}
rectsOf(world: World, tilemap: Entity): CellRect[] {
let rects = this.rects.get(tilemap);
if (!rects) {
private blocksOf(world: World, tilemap: Entity): { rects: CellRect[]; polygons: CellPolygon[] } {
let blocks = this.blocks.get(tilemap);
if (!blocks) {
const asset = findAssetOf(world.project, tilemap.assetId, 'tilemap');
rects = asset ? solidRects(world.project, tilemap.props, asset) : [];
this.rects.set(tilemap, rects);
blocks = asset
? {
rects: solidRects(world.project, tilemap.props, asset),
polygons: solidPolygons(world.project, tilemap.props, asset),
}
: { rects: [], polygons: [] };
this.blocks.set(tilemap, blocks);
}
return rects;
return blocks;
}
/**
* The shapes a body occupies in the world: one for a collider, one box per merged rectangle
* for a tilemap. A tilemap placed before the first tick is worked out on the spot.
* The shapes a body occupies in the world: one for a collider; for a tilemap one box per merged
* rectangle and one polygon per outlined cell (#141). A tilemap placed before the first tick is
* worked out on the spot.
*/
shapesOf(world: World, body: Entity): ColliderShape[] {
if (body.type !== 'tilemap') return [shapeOf(body)];
const m = worldMatrixOf(body);
return this.rectsOf(world, body).map((rect) => cellRectShape(body.props, rect, m));
const { rects, polygons } = this.blocksOf(world, body);
return [
...rects.map((rect) => cellRectShape(body.props, rect, m)),
...polygons.map((polygon) => cellPolygonShape(body.props, polygon, m)),
];
}
/**
+5 -1
View File
@@ -16,6 +16,8 @@ export interface ButtonOptions {
padding?: number;
/** Text style of the label when the button is not on an accent fill. */
style?: TextStyleToken;
/** Switched on and off by its owner (`setToggled`): measured for the bold label it shows when on. */
toggle?: boolean;
onClick?: () => void;
}
@@ -32,6 +34,7 @@ export class Button extends Widget {
protected readonly fixedWidth: number;
private readonly padding: number;
private readonly style: TextStyleToken;
private readonly toggle: boolean;
onClick?: () => void;
constructor(text: string, options: ButtonOptions = {}) {
@@ -47,6 +50,7 @@ export class Button extends Widget {
this.padding = options.padding ?? Theme.padding;
this.fullText = text;
this.style = options.style ?? 'body';
this.toggle = options.toggle ?? false;
this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : this.style) });
this.addChild(this.bg, this.labelText);
@@ -104,7 +108,7 @@ export class Button extends Widget {
// From the full label, never the on-screen one: measuring truncated text would keep a button
// that was once too narrow too narrow for good.
// Measured bold where it can be drawn bold (on an accent fill), or a danger or toggled tab truncates.
const style = textStyle(this.variant === 'default' ? this.style : 'accent');
const style = textStyle(this.variant === 'default' && !this.toggle ? this.style : 'accent');
const metrics = CanvasTextMetrics.measureText(this.fullText, style);
return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2), h };
}
+9 -2
View File
@@ -7,7 +7,7 @@ import type { PropertyBag } from '../core/model/Properties';
import type { ParticlesAsset } from '../core/model/Asset';
import { readTile, tileFps, type TileInfo, type TilePlayback, tilePlayback } from '../core/model/tile';
import { isLayerVisible, layerTiles, type TileDraw } from '../core/model/tilemap';
import { type CellRect, solidRects } from '../core/model/tilemapCollision';
import { type CellPolygon, type CellRect, solidPolygons, solidRects } from '../core/model/tilemapCollision';
import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project';
import { fontFileOf } from '../core/model/font';
import { resolveTexture, type TextureRef } from '../core/model/texture';
@@ -68,6 +68,8 @@ export interface TilemapDraw {
tiles: Map<string, TilemapTile>;
/** Where the map blocks, in cells – the scene view outlines it like a collider (#132). */
solids: CellRect[];
/** The cells that block with an outline (#141), outlined the same way. */
outlines: CellPolygon[];
}
/**
@@ -95,7 +97,12 @@ function resolveTilemap(project: Project, assetId: string, props: PropertyBag):
});
}
}
return { layers, tiles: used, solids: solidRects(project, props, asset) };
return {
layers,
tiles: used,
solids: solidRects(project, props, asset),
outlines: solidPolygons(project, props, asset),
};
}
/** Both kinds of object that name a picture in `texture`: the sprite that wears it and the source
+49 -3
View File
@@ -12,6 +12,7 @@ import {
import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider';
import { SHEET_EDGE } from '../core/shader/nodes';
import type { NineSlice } from '../core/model/texture';
import type { Vec2 } from '../core/model/Properties';
import { Theme } from '../ui';
import type { DrawItem } from './items';
import { sliceGeometry, sliceKey } from './nineSlice';
@@ -182,9 +183,14 @@ export function drawItem(
// Only the outline band is hit-testable, so clicks inside still reach the sprite that the
// collider sits on.
const shape = colliderShape(item.props, IDENTITY);
if (shape.kind === 'circle') node.circle(0, 0, shape.r);
else node.rect(-shape.w / 2, -shape.h / 2, shape.w, shape.h);
const color = isTrigger(item.props) ? Theme.colors.trigger : Theme.colors.collider;
traceShape(node, shape);
// A polygon that crosses itself collides with nothing, and says so in red.
const color =
shape.kind === 'polygon' && shape.pieces.length === 0
? Theme.colors.danger
: isTrigger(item.props)
? Theme.colors.trigger
: Theme.colors.collider;
node.fill({ color, alpha: 0.12 }).stroke({ width: 2 / scale, color });
node.hitArea = colliderOutlineHitArea(shape, COLLIDER_GRAB_BAND / scale);
return;
@@ -217,12 +223,52 @@ const DIAGONALS = [
[-Math.SQRT1_2, -Math.SQRT1_2],
] as const;
/** Adds the outline of a collider shape in its own units (see `colliderShape` with `IDENTITY`). */
function traceShape(node: Graphics, shape: ColliderShape): void {
switch (shape.kind) {
case 'circle':
node.circle(0, 0, shape.r);
return;
case 'box':
node.rect(-shape.w / 2, -shape.h / 2, shape.w, shape.h);
return;
// In its own units a capsule's segment lies along an axis, so it is a fully rounded rectangle.
case 'capsule': {
const x = Math.min(shape.a.x, shape.b.x) - shape.r;
const y = Math.min(shape.a.y, shape.b.y) - shape.r;
const w = Math.abs(shape.b.x - shape.a.x) + 2 * shape.r;
const h = Math.abs(shape.b.y - shape.a.y) + 2 * shape.r;
node.roundRect(x, y, w, h, shape.r);
return;
}
case 'polygon':
if (shape.outline.length >= 2) node.poly(shape.outline, true);
return;
}
}
function distanceToSegment(p: Vec2, a: Vec2, b: Vec2): number {
const ab = { x: b.x - a.x, y: b.y - a.y };
const length = ab.x * ab.x + ab.y * ab.y;
const t = length > 0 ? Math.min(1, Math.max(0, ((p.x - a.x) * ab.x + (p.y - a.y) * ab.y) / length)) : 0;
return Math.hypot(p.x - (a.x + ab.x * t), p.y - (a.y + ab.y * t));
}
/** Hit area covering only the outline band, in local coordinates and world units. */
function colliderOutlineHitArea(
shape: ColliderShape,
band: number,
): { contains(x: number, y: number): boolean } {
if (shape.kind === 'circle') return { contains: (x, y) => Math.abs(Math.hypot(x, y) - shape.r) <= band };
if (shape.kind === 'capsule')
return { contains: (x, y) => Math.abs(distanceToSegment({ x, y }, shape.a, shape.b) - shape.r) <= band };
if (shape.kind === 'polygon') {
const ring = shape.outline;
return {
contains: (x, y) =>
ring.some((a, i) => distanceToSegment({ x, y }, a, ring[(i + 1) % ring.length]!) <= band),
};
}
const hw = shape.w / 2;
const hh = shape.h / 2;
return {
+266
View File
@@ -0,0 +1,266 @@
import { describe, expect, it } from 'vitest';
import { transformMatrix, translation } from '../../src/core/model/matrix';
import {
cleanOutline,
convexPieces,
isConvex,
pointsOf,
polygonProblem,
signedArea,
} from '../../src/core/model/polygon';
import type { Vec2 } from '../../src/core/model/Properties';
import {
type ColliderShape,
colliderShape,
colliderSize,
contactBetween,
exitDistance,
shapeArea,
shapeContains,
shapesOverlap,
} from '../../src/core/properties/collider';
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../../src/editor/polygonEdit';
const P = (...xy: number[]) =>
Array.from({ length: xy.length / 2 }, (_, i) => ({ x: xy[2 * i]!, y: xy[2 * i + 1]! }));
const area = (points: Vec2[]) => Math.abs(signedArea(points));
/** An L: 3 wide at the bottom, 1 wide going up – concave at (1, 1). Area 3 + 2 = 5. */
const L = P(0, 0, 1, 0, 1, 2, 3, 2, 3, 3, 0, 3);
let seed = 11;
const random = () => (seed = (seed * 48271) % 2147483647) / 2147483647;
/** A star-shaped outline: corners round a centre at random radii, so it is simple and mostly concave. */
function randomStar(corners: number): Vec2[] {
return Array.from({ length: corners }, (_, i) => {
const angle = (i / corners) * 2 * Math.PI;
const r = 20 + random() * 80;
return { x: Math.cos(angle) * r, y: Math.sin(angle) * r };
});
}
describe('polygon outlines (#151)', () => {
it('reads {x, y} items and leaves out what is no point', () => {
expect(
pointsOf([
{ x: 1, y: 2 },
{ x: 'a', y: 3 },
5,
{ x: 4 },
{ x: '6', y: 7 },
{ x: '', y: 1 },
{ x: true, y: 1 },
]),
).toEqual([
{ x: 1, y: 2 },
{ x: 6, y: 7 },
]);
expect(pointsOf(undefined)).toEqual([]);
expect(pointsOf('nope')).toEqual([]);
});
it('says what is wrong: too few corners, no area, an outline that crosses itself', () => {
expect(polygonProblem(P(0, 0, 10, 0))).toMatch(/three corners/);
expect(polygonProblem(P(0, 0, 10, 0, 20, 0))).toMatch(/three corners/); // straight: cleaned to two
expect(polygonProblem(P(0, 0, 10, 0, 10, 10, 10, 10, 0, 10))).toBe(''); // a repeated corner is fine
expect(polygonProblem(P(0, 0, 10, 10, 10, 0, 0, 10))).toMatch(/no area/); // a bow tie: halves cancel
// A pentagram: every other corner of a pentagon, so each edge crosses two others.
const pentagram = [0, 2, 4, 1, 3].map((i) => ({
x: Math.cos((i * 2 * Math.PI) / 5) * 50,
y: Math.sin((i * 2 * Math.PI) / 5) * 50,
}));
expect(polygonProblem(pentagram)).toMatch(/crosses itself/);
// Touching itself at a corner counts as crossing: the two halves would share only a point.
expect(polygonProblem(P(0, 0, 10, 0, 5, 5, 10, 10, 0, 10, 5, 5))).toMatch(/crosses itself/);
expect(polygonProblem(L)).toBe('');
expect(cleanOutline(P(0, 0, 5, 0, 10, 0, 10, 10))).toEqual(P(0, 0, 10, 0, 10, 10));
});
it('a convex outline is one piece, in either winding; one with a problem has none', () => {
const square = P(0, 0, 10, 0, 10, 10, 0, 10);
expect(convexPieces(square)).toEqual([square]);
expect(convexPieces([...square].reverse())).toHaveLength(1);
expect(convexPieces(P(0, 0, 10, 10, 10, 0, 0, 10))).toEqual([]);
});
it('an L is cut into two convex pieces that cover it exactly', () => {
for (const outline of [L, [...L].reverse()]) {
const pieces = convexPieces(outline);
expect(pieces).toHaveLength(2);
expect(pieces.every(isConvex)).toBe(true);
expect(pieces.reduce((sum, p) => sum + area(p), 0)).toBeCloseTo(5, 12);
}
});
it('random star outlines: convex pieces, the whole area, only the outline’s own corners, few pieces', () => {
for (let round = 0; round < 200; round++) {
const outline = randomStar(5 + Math.floor(random() * 12));
const pieces = convexPieces(outline);
expect(pieces.length).toBeGreaterThan(0);
expect(pieces.every(isConvex)).toBe(true);
expect(pieces.reduce((sum, p) => sum + area(p), 0)).toBeCloseTo(area(outline), 6);
const corners = new Set(outline.map((p) => `${p.x},${p.y}`));
expect(pieces.flat().every((p) => corners.has(`${p.x},${p.y}`))).toBe(true);
// Triangles alone would be n − 2; merging must have joined some whenever there was room.
expect(pieces.length).toBeLessThanOrEqual(outline.length - 2);
}
});
});
const box = (x: number, y: number, w: number, h: number, degrees = 0): ColliderShape => ({
kind: 'box',
x,
y,
w,
h,
angle: (degrees * Math.PI) / 180,
});
const circle = (x: number, y: number, r: number): ColliderShape => ({ kind: 'circle', x, y, r });
/** The same box as a polygon collider, so the new path can be held against the old exact one. */
const boxAsPolygon = (x: number, y: number, w: number, h: number, degrees = 0): ColliderShape =>
colliderShape(
{ shape: 'polygon', points: P(-w / 2, -h / 2, w / 2, -h / 2, w / 2, h / 2, -w / 2, h / 2) },
transformMatrix(x, y, degrees, 1, false, false),
);
describe('collider geometry of capsules and polygons (#151)', () => {
it('a capsule fills width × height, rounded along the longer side, and turns with the object', () => {
expect(colliderShape({ shape: 'capsule', width: 20, height: 60 }, translation(5, 0))).toMatchObject({
kind: 'capsule',
a: { x: 5, y: -20 },
b: { x: 5, y: 20 },
r: 10,
});
expect(colliderShape({ shape: 'capsule', width: 60, height: 20 }, translation(0, 0))).toMatchObject({
a: { x: -20, y: 0 },
b: { x: 20, y: 0 },
r: 10,
});
const turned = colliderShape(
{ shape: 'capsule', width: 20, height: 60 },
transformMatrix(0, 0, 90, 2, false, false),
);
expect(turned.kind === 'capsule' && turned.a.x).toBeCloseTo(40, 9);
expect(turned.kind === 'capsule' && turned.r).toBe(20);
// As wide as tall: no segment left, a circle.
const round = colliderShape({ shape: 'capsule', width: 30, height: 30 }, translation(0, 0));
expect(round).toMatchObject({ a: { x: 0, y: 0 }, b: { x: 0, y: 0 }, r: 15 });
expect(shapeArea(round)).toBeCloseTo(Math.PI * 225, 9);
});
it('a polygon keeps its corners in its own units: moved, sized, turned and mirrored with the object', () => {
const props = { shape: 'polygon', points: P(0, -10, 10, 10, -10, 10) };
expect(colliderShape(props, translation(100, 0))).toMatchObject({
outline: P(100, -10, 110, 10, 90, 10),
pieces: [P(100, -10, 110, 10, 90, 10)],
});
const mirrored = colliderShape(
{ shape: 'polygon', points: P(0, 0, 10, 0, 0, 10) },
transformMatrix(0, 0, 0, 2, true, false),
);
expect(mirrored.kind === 'polygon' && mirrored.outline).toEqual(P(0, 0, -20, 0, 0, 20));
expect(colliderSize(props)).toEqual({ w: 20, h: 20 });
// Without points of its own it is the default triangle.
expect(colliderShape({ shape: 'polygon' }, translation(0, 0))).toMatchObject({
outline: P(0, -50, 50, 50, -50, 50),
});
// One that crosses itself is drawn but has no pieces, so it meets nothing.
const bowTie = colliderShape(
{ shape: 'polygon', points: P(0, 0, 10, 10, 10, 0, 0, 10) },
translation(0, 0),
);
expect(bowTie).toMatchObject({ pieces: [] });
expect(shapesOverlap(bowTie, box(5, 5, 100, 100))).toBe(false);
expect(contactBetween(bowTie, box(5, 5, 100, 100), { x: 1, y: 0 }).separation).toBe(Infinity);
});
it('a square polygon meets boxes and circles exactly as the box it stands for', () => {
for (let round = 0; round < 400; round++) {
const [x, y, deg] = [random() * 160 - 80, random() * 160 - 80, random() * 360];
const [w, h] = [20 + random() * 60, 20 + random() * 60];
const other = random() < 0.5 ? box(0, 0, 50, 70, random() * 90) : circle(0, 0, 10 + random() * 40);
const classic = box(x, y, w, h, deg);
const polygon = boxAsPolygon(x, y, w, h, deg);
const travel = { x: random() - 0.5, y: random() - 0.5 };
expect(shapesOverlap(polygon, other)).toBe(shapesOverlap(classic, other));
const want = contactBetween(classic, other, travel);
const got = contactBetween(polygon, other, travel);
// Apart, box against box measures along its best axis rather than the real distance.
if (other.kind === 'circle' || want.separation <= 0) {
expect(got.separation).toBeCloseTo(want.separation, 9);
expect(got.normal.x).toBeCloseTo(want.normal.x, 9);
expect(got.normal.y).toBeCloseTo(want.normal.y, 9);
}
expect(exitDistance(polygon, other, travel)).toBeCloseTo(exitDistance(classic, other, travel), 7);
}
});
it('a capsule standing 10 above a floor, and sunk 5 into it: the gap, the normal and the way out', () => {
const floor = box(0, 100, 400, 40); // top face at 80
const above = colliderShape({ shape: 'capsule', width: 20, height: 60 }, translation(0, 40)); // bottom at 70
expect(contactBetween(above, floor, { x: 0, y: 1 })).toEqual({ separation: 10, normal: { x: 0, y: -1 } });
const sunk = colliderShape({ shape: 'capsule', width: 20, height: 60 }, translation(0, 55));
expect(shapesOverlap(sunk, floor)).toBe(true);
expect(contactBetween(sunk, floor, { x: 0, y: 1 }).separation).toBeCloseTo(-5, 12);
expect(exitDistance(sunk, floor, { x: 0, y: -1 })).toBeCloseTo(5, 9);
// On its side against a wall, the round end is what touches.
const side = colliderShape({ shape: 'capsule', width: 60, height: 20 }, translation(0, 0));
const wall = box(40, 0, 20, 200); // left face at 30
expect(shapesOverlap(side, wall)).toBe(false);
expect(shapesOverlap(side, box(39, 0, 20, 200))).toBe(true);
expect(shapeContains(side, { x: 29, y: 0 })).toBe(true);
expect(shapeContains(side, { x: 29, y: 9 })).toBe(false); // outside the rounding
});
it('a circle in line with a capsule, beyond its end: apart by the real distance, not by the side axis', () => {
const capsule = colliderShape({ shape: 'capsule', width: 60, height: 20 }, translation(0, 0)); // ends at ±30
expect(contactBetween(circle(45, 0, 10), capsule, { x: -1, y: 0 })).toEqual({
separation: 5,
normal: { x: 1, y: 0 },
});
expect(shapesOverlap(circle(45, 0, 10), capsule)).toBe(false);
expect(shapesOverlap(circle(35, 0, 10), capsule)).toBe(true);
// Two capsules end to end on one line.
const next = colliderShape({ shape: 'capsule', width: 60, height: 20 }, translation(65, 0));
expect(contactBetween(next, capsule, { x: -1, y: 0 }).separation).toBeCloseTo(5, 12);
});
it('a concave polygon: a box in the notch of a U touches nothing, one on its rim does', () => {
// A U 120 wide, 100 tall, walls 20 thick: the notch is 80 wide and 80 deep.
const u = colliderShape(
{ shape: 'polygon', points: P(-60, -50, -40, -50, -40, 30, 40, 30, 40, -50, 60, -50, 60, 50, -60, 50) },
translation(0, 0),
);
expect(u.kind === 'polygon' && u.pieces.length).toBeGreaterThan(1);
expect(shapesOverlap(box(0, -10, 78, 78), u)).toBe(false); // inside the notch, 1 clear of every face
expect(shapesOverlap(box(0, -10, 82, 60), u)).toBe(true); // wider than the notch
// A box resting on the inner floor (top face at 30): touching, not overlapping; its way out is up.
const resting = box(0, 10, 40, 40);
expect(shapesOverlap(resting, u)).toBe(false);
expect(contactBetween(resting, u, { x: 0, y: 1 }).normal).toEqual({ x: 0, y: -1 });
expect(exitDistance(box(0, 13, 40, 40), u, { x: 0, y: -1 })).toBeCloseTo(3, 9);
});
});
describe('editing an outline by its corners', () => {
const square = P(0, 0, 100, 0, 100, 100, 0, 100);
it('a corner wins over the edges through it; an edge gives the point on it', () => {
expect(gripAt(square, { x: 3, y: 2 }, 5)).toEqual({ kind: 'corner', index: 0 });
expect(gripAt(square, { x: 50, y: 3 }, 5)).toEqual({ kind: 'edge', index: 0, at: { x: 50, y: 0 } });
expect(gripAt(square, { x: 3, y: 50 }, 5)).toEqual({ kind: 'edge', index: 3, at: { x: 0, y: 50 } });
expect(gripAt(square, { x: 50, y: 50 }, 5)).toBeNull();
// Two corners are no outline yet: only the corners can be taken.
expect(gripAt(P(0, 0, 100, 0), { x: 50, y: 0 }, 5)).toBeNull();
});
it('puts, moves and takes away corners; the last three stay unless told otherwise', () => {
expect(withCornerAfter(square, 3, { x: 0, y: 50 })).toEqual([...square, { x: 0, y: 50 }]);
expect(withCornerAfter(square, 0, { x: 50, y: 0 })[1]).toEqual({ x: 50, y: 0 });
expect(withCornerMoved(square, 2, { x: 90, y: 80 })[2]).toEqual({ x: 90, y: 80 });
expect(withoutCorner(square, 1)).toEqual(P(0, 0, 100, 100, 0, 100));
expect(withoutCorner(P(0, 0, 1, 0, 0, 1), 0)).toBeNull();
expect(withoutCorner(P(0, 0, 1, 0), 0, 0)).toEqual(P(1, 0));
});
});
+365
View File
@@ -0,0 +1,365 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands';
import { AddFrameCommand, SetFrameCollisionCommand } from '../../src/core/commands/frameCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { AddTilePictureCommand } from '../../src/core/commands/tileCommands';
import { AddTilemapLayerCommand, PaintTilesCommand } from '../../src/core/commands/tilemapCommands';
import type { TextureAsset, TilemapAsset, TileAsset } from '../../src/core/model/Asset';
import type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
import { findAsset, type Project } from '../../src/core/model/Project';
import { TILE_COLLISION_SCALE_KEY, TILE_KIND_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile';
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../../src/core/model/tilemap';
import { solidCells, solidPolygons, tileOutline } from '../../src/core/model/tilemapCollision';
import { contactBetween, shapesOverlap } from '../../src/core/properties/collider';
import type { Entity } from '../../src/runtime/Entity';
import { loadPhysics } from '../../src/runtime/physics/engine';
import { shapeOf, Solids } from '../../src/runtime/systems/solids';
import {
COLLISION_SYSTEMS,
makeSpriteScene,
place,
roundTrip,
type SceneFixture,
startWorld,
} from '../helpers';
beforeAll(async () => {
await loadPhysics();
});
const P = (...xy: number[]) =>
Array.from({ length: xy.length / 2 }, (_, i) => ({ x: xy[2 * i]!, y: xy[2 * i + 1]! }));
/** A scene of objects with one collider each; `add` sets the collider's properties and returns the ids. */
function scene() {
const f = makeSpriteScene('Thing');
const add = (at: Vec2, collider: Record<string, PropertyValue>, body?: Record<string, PropertyValue>) => {
const id = place(f, f.spriteId, at);
const command = new AddColliderCommand(f.sceneId, id);
f.stack.execute(command);
for (const [key, value] of Object.entries(collider))
f.stack.execute(new SetOverrideCommand(f.sceneId, command.objectId, key, value));
if (body) {
const rigidbody = new AddRigidbodyCommand(f.sceneId, id);
f.stack.execute(rigidbody);
for (const [key, value] of Object.entries(body))
f.stack.execute(new SetOverrideCommand(f.sceneId, rigidbody.objectId, key, value));
}
return { id, collider: command.objectId };
};
return { ...f, add };
}
function run(f: SceneFixture, physics: boolean) {
const started = startWorld(f, { physics, collisions: true });
const entity = (id: string) => started.world.findByObjectId(id)!;
const colliderOf = (e: Entity) => started.world.childrenOf(e).find((c) => c.type === 'collider')!;
const move = (e: Entity, dx: number, dy: number) =>
started.collisions.moveAndSlide(started.world, e, dx, dy);
const seconds = (s: number) => {
for (let i = 0; i < Math.round(s * 60); i++) started.frame(1 / 60);
};
return { ...started, entity, colliderOf, move, seconds };
}
/** A ramp at 30°, falling to the right: its top face runs through the origin. */
const TAN30 = Math.tan(Math.PI / 6);
const RAMP = P(-500, -500 * TAN30, 500, 500 * TAN30, 500, 400, -500, 400);
describe.each(COLLISION_SYSTEMS)('$system: Move against the new shapes (#151)', ({ physics }) => {
it('a circle pressed onto a polygon ramp slides as far as friction lets it: g·sin − μ·g·cos', () => {
const sin = Math.sin(Math.PI / 6);
const cos = Math.cos(Math.PI / 6);
for (const friction of [0, 0.5, 0.6]) {
const s = scene();
const ramp = s.add({ x: 0, y: 0 }, { shape: 'polygon', points: RAMP, friction });
const ball = s.add({ x: 0, y: -100 }, { shape: 'circle', radius: 10, friction });
const r = run(s, physics);
const mover = r.entity(ball.id);
for (let i = 0; i < 30; i++) r.move(mover, 0, 5);
const from = { x: mover.x, y: mover.y };
const results = Array.from({ length: 10 }, () => r.move(mover, 0, 5));
const along = 10 * Math.max(0, 5 * sin - friction * 5 * cos);
expect(mover.x - from.x, `friction ${friction}`).toBeCloseTo(along * cos, 6);
expect(mover.y - from.y, `friction ${friction}`).toBeCloseTo(along * sin, 6);
expect(results[0]!.normal.x).toBeCloseTo(sin, 9);
expect(results[0]!.normal.y).toBeCloseTo(-cos, 9);
const surface = shapeOf(r.colliderOf(r.entity(ramp.id)));
const gap = contactBetween(shapeOf(r.colliderOf(mover)), surface, { x: 0, y: 1 }).separation;
expect(Math.abs(gap)).toBeLessThan(1e-6);
}
});
it('a capsule runs into a box wall, stops flush and slides down it', () => {
const s = scene();
s.add({ x: 100, y: 0 }, { width: 20, height: 400 }); // face at x = 90
const hero = s.add({ x: 0, y: 0 }, { shape: 'capsule', width: 20, height: 60, friction: 0 });
const r = run(s, physics);
const mover = r.entity(hero.id);
const hit = r.move(mover, 100, 0);
expect(hit).toMatchObject({ hit: true, blockedX: true, blockedY: false });
expect(hit.normal.x).toBeCloseTo(-1, 12);
expect(hit.normal.y).toBeCloseTo(0, 12);
expect(mover.x).toBeCloseTo(80, 9);
r.move(mover, 3, 4); // the wall's friction is the default 0.5, the capsule's 0: the pair has 0.25
expect(mover.x).toBeCloseTo(80, 9);
expect(mover.y).toBeCloseTo(4 - 0.25 * 3, 9);
});
it('a box dropped into a concave U lands on its inner floor, clear of the walls', () => {
const s = scene();
s.add(
{ x: 0, y: 0 },
{ shape: 'polygon', points: P(-60, -50, -40, -50, -40, 30, 40, 30, 40, -50, 60, -50, 60, 50, -60, 50) },
);
const crate = s.add({ x: 0, y: -120 }, { width: 40, height: 40 });
const r = run(s, physics);
const mover = r.entity(crate.id);
for (let i = 0; i < 40; i++) r.move(mover, 0, 5);
expect(mover.y).toBeCloseTo(10, 9); // bottom face on the inner floor at 30
expect(mover.x).toBe(0);
});
});
describe('the physics engine with the new shapes (#151)', () => {
it('a capsule falls onto a box floor and comes to rest standing on its round end', () => {
const s = scene();
s.add({ x: 0, y: 200 }, { width: 400, height: 40 }); // top at 180
const hero = s.add({ x: 0, y: 0 }, { shape: 'capsule', width: 20, height: 60 }, { fixedRotation: true });
const r = run(s, true);
r.seconds(3);
expect(r.entity(hero.id).y).toBeCloseTo(180 - 30, 0);
});
it('a crate falls into a concave U and rests on its inner floor – not on the hull over the notch', () => {
const s = scene();
s.add(
{ x: 0, y: 200 },
{ shape: 'polygon', points: P(-60, -50, -40, -50, -40, 30, 40, 30, 40, -50, 60, -50, 60, 50, -60, 50) },
);
const crate = s.add({ x: 0, y: 0 }, { width: 40, height: 40 }, { fixedRotation: true });
const r = run(s, true);
r.seconds(3);
expect(r.entity(crate.id).y).toBeCloseTo(200 + 30 - 20, 0);
expect(Math.abs(r.entity(crate.id).x)).toBeLessThan(0.5);
});
it('a ball rolls down a polygon ramp; a polygon body falls and rests with its own mass', () => {
const s = scene();
const ramp = s.add({ x: 0, y: 300 }, { shape: 'polygon', points: RAMP });
const ball = s.add({ x: -100, y: 0 }, { shape: 'circle', radius: 10 }, {});
const triangle = s.add({ x: 300, y: -200 }, { shape: 'polygon', points: P(0, -20, 20, 20, -20, 20) }, {});
const r = run(s, true);
const start = r.entity(ball.id).x;
r.seconds(1);
expect(r.entity(ball.id).x).toBeGreaterThan(start + 50); // downhill is to the right
r.seconds(4);
// The triangle came to rest on the ramp, and did not sink into it.
const surface = shapeOf(r.colliderOf(r.entity(ramp.id)));
const resting = r.entity(triangle.id);
expect(resting.y).toBeGreaterThan(-100); // it fell
expect(
contactBetween(shapeOf(r.colliderOf(resting)), surface, { x: 0, y: 1 }).separation,
).toBeGreaterThan(-1);
});
});
/**
* A texture with one 32 × 32 frame, a plain tile showing it, a solid switch on that tile and a
* 12 × 4 map of 32 px cells at the origin – cell (x, y) spans x·32 − 192 … and y·32 − 64 ….
*/
function outlinedMap() {
const f = scene();
const texture = new CreateAssetCommand('texture', 'Sheet');
const tile = new CreateAssetCommand('tile', 'Slope');
const plain = new CreateAssetCommand('tile', 'Wall');
const map = new CreateAssetCommand('tilemap', 'Ground');
for (const command of [texture, tile, plain, map]) f.stack.execute(command);
const sheet = findAsset(f.project, texture.asset.id) as TextureAsset;
sheet.file = `${'a'.repeat(64)}.png`;
sheet.width = 64;
sheet.height = 64;
const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 32, h: 32 });
f.stack.execute(frame);
f.stack.execute(new AddTilePictureCommand(tile.asset.id, frame.frameId));
for (const id of [tile.asset.id, plain.asset.id])
f.stack.execute(new SetAssetPropertyCommand(id, TILE_SOLID_KEY, true));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 12));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 4));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_TILE_KEY, 32));
const mapObject = place(f, map.asset.id, { x: 0, y: 0 });
const mapAsset = () => findAsset(f.project, map.asset.id) as TilemapAsset;
const paint = (cells: [number, number][], tileId = tile.asset.id, layer = 0) =>
f.stack.execute(
new PaintTilesCommand(
map.asset.id,
mapAsset().layers[layer]!.id,
cells.map(([x, y]) => ({ x, y, tileId })),
),
);
const outline = (points: Vec2[]) =>
f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, points));
const tileAsset = () => findAsset(f.project, tile.asset.id) as TileAsset;
return {
...f,
mapId: map.asset.id,
mapObject,
mapAsset,
paint,
outline,
tileAsset,
plainId: plain.asset.id,
textureId: texture.asset.id,
frameId: frame.frameId,
};
}
/** The lower right half of a cell: a slope rising to the right, as a frame outline in pixels. */
const WEDGE = P(0, 32, 32, 0, 32, 32);
describe('collision outlines of tiles (#141)', () => {
it('a frame keeps its outline through a round trip; broken corners and short outlines fall away', () => {
const m = outlinedMap();
m.outline(WEDGE);
const frameOf = (project: Project) =>
(findAsset(project, m.textureId) as TextureAsset).frames.find((f) => f.id === m.frameId)!;
expect(frameOf(roundTrip(m.project)).collision).toEqual(WEDGE);
frameOf(m.project).collision = [
{ x: 1, y: 1 },
{ x: Number.NaN, y: 0 },
{ x: 3, y: 3 },
] as Vec2[];
expect(frameOf(roundTrip(m.project))).not.toHaveProperty('collision');
m.stack.undo();
expect(frameOf(m.project)).not.toHaveProperty('collision');
m.stack.redo();
expect(frameOf(m.project).collision).toEqual(WEDGE);
});
it('a plain tile blocks with its first picture’s outline; an autotile, or an outline that is none, as a box', () => {
const m = outlinedMap();
expect(tileOutline(m.project, m.tileAsset())).toBeNull();
m.outline(WEDGE);
expect(tileOutline(m.project, m.tileAsset())).toEqual({
outline: P(0, 1, 1, 0, 1, 1),
pieces: [P(0, 1, 1, 0, 1, 1)],
});
m.outline(P(0, 0, 32, 32, 32, 0, 0, 32)); // a bow tie
expect(tileOutline(m.project, m.tileAsset())).toBeNull();
m.outline(WEDGE);
m.stack.execute(new SetAssetPropertyCommand(m.tileAsset().id, TILE_KIND_KEY, 'floor'));
expect(tileOutline(m.project, m.tileAsset())).toBeNull();
});
it('outlined cells are polygons, not rectangles; a plain solid tile on the same cell covers it whole', () => {
const m = outlinedMap();
m.outline(WEDGE);
m.paint([
[2, 3],
[3, 3],
]);
m.paint([[5, 3]], m.plainId);
const props = { [TILEMAP_COLUMNS_KEY]: 12, [TILEMAP_ROWS_KEY]: 4 };
expect(solidCells(m.project, props, m.mapAsset())).toEqual([{ x: 5, y: 3, scale: 1 }]);
expect(solidPolygons(m.project, props, m.mapAsset()).map((p) => p.outline)).toEqual([
P(2, 4, 3, 3, 3, 4),
P(3, 4, 4, 3, 4, 4),
]);
m.stack.execute(new AddTilemapLayerCommand(m.mapId));
m.paint([[2, 3]], m.plainId, 1);
expect(solidPolygons(m.project, props, m.mapAsset())).toHaveLength(1);
expect(solidCells(m.project, props, m.mapAsset())).toHaveLength(2);
// The collision scale draws the outline in about the cell's centre (#140).
m.stack.execute(new SetAssetPropertyCommand(m.tileAsset().id, TILE_COLLISION_SCALE_KEY, 0.5));
expect(solidPolygons(m.project, props, m.mapAsset())[0]!.outline).toEqual(
P(3.25, 3.75, 3.75, 3.25, 3.75, 3.75),
);
});
describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => {
it('a crate on a floor of outlined cells slides over every seam', () => {
const m = outlinedMap();
m.outline(P(0, 0, 32, 0, 32, 32, 0, 32)); // every cell outlined as a whole square: twelve polygons
m.paint(Array.from({ length: 12 }, (_, x) => [x, 3] as [number, number])); // top face at y = 32
const crate = m.add({ x: -180, y: 0 }, { width: 30, height: 30 });
const r = run(m, physics);
const mover = r.entity(crate.id);
const steps: { dx: number; y: number; blockedX: boolean }[] = [];
for (let i = 0; i < 100; i++) {
const x = mover.x;
const { blockedX } = r.move(mover, 3, 1.5);
steps.push({ dx: mover.x - x, y: mover.y, blockedX });
}
for (const step of steps.slice(20)) {
expect(step.dx).toBeCloseTo(3 - 0.5 * 1.5, 9);
expect(step.y).toBeCloseTo(32 - 15, 9);
expect(step.blockedX).toBe(false);
}
expect(mover.x).toBeGreaterThan(-180 + 5 * 32);
});
it('a crate walking along the floor into the foot of a wedge tile climbs it instead of stopping there', () => {
const m = outlinedMap();
m.outline(WEDGE);
m.paint([[6, 2]]); // x 0 … 32, its foot on the floor at (0, 32), its top at (32, 0)
m.paint(
Array.from({ length: 12 }, (_, x) => [x, 3] as [number, number]),
m.plainId,
);
const crate = m.add({ x: -100, y: 22 }, { width: 20, height: 20, friction: 0 });
const r = run(m, physics);
const mover = r.entity(crate.id);
let highest = mover.y;
for (let i = 0; i < 300; i++) {
r.move(mover, 2, 0.6);
highest = Math.min(highest, mover.y);
}
// At the foot the crate's corner meets the wedge's corner: the slope, not a wall, is the contact.
// Its bottom reached the wedge's top at y = 0; the rest of that frame's slide ran on up-slope.
expect(highest).toBeLessThanOrEqual(-10 + 1e-9);
expect(highest).toBeGreaterThan(-10 - 0.5);
expect(mover.x).toBeGreaterThan(45); // and it went on past the wedge's right side at x = 32
});
it('a ball rolls up and over a wedge tile instead of stopping at it', () => {
const m = outlinedMap();
m.outline(WEDGE);
m.paint([[6, 2]]); // x 0 … 32, rising from the floor at its left to its top at its right
m.paint(
Array.from({ length: 12 }, (_, x) => [x, 3] as [number, number]),
m.plainId,
); // floor top at 32
const ball = m.add({ x: -100, y: 22 }, { shape: 'circle', radius: 10, friction: 0 });
const r = run(m, physics);
const mover = r.entity(ball.id);
let highest = mover.y;
for (let i = 0; i < 200; i++) {
r.move(mover, 3, 1);
highest = Math.min(highest, mover.y);
}
expect(highest).toBeLessThan(0); // it climbed to the wedge's top at y = 0
expect(mover.x).toBeGreaterThan(60); // and went on past its right side at x = 32
const map = r.world.entities.find((e) => e.type === 'tilemap')!;
const own = shapeOf(r.colliderOf(mover));
expect(new Solids().shapesOf(r.world, map).some((shape) => shapesOverlap(own, shape))).toBe(false);
});
});
});
describe('outlined tiles in the engine (#141)', () => {
it('a crate falls onto a wedge tile and slides down it to the floor', () => {
const m = outlinedMap();
m.outline(P(0, 0, 32, 32, 0, 32)); // falling to the right
m.paint([[5, 2]]); // x −32 … 0: from (−32, 0) down to (0, 32)
m.paint(
Array.from({ length: 12 }, (_, x) => [x, 3] as [number, number]),
m.plainId,
);
const crate = m.add({ x: -26, y: -30 }, { width: 10, height: 10, friction: 0 }, {});
const r = run(m, true);
r.seconds(3);
expect(r.entity(crate.id).x).toBeGreaterThan(5); // off the wedge's foot at x = 0
expect(r.entity(crate.id).y).toBeCloseTo(32 - 5, 0); // on the floor
});
});
+315
View File
@@ -0,0 +1,315 @@
import { edge, node } from '../lib/builders.mjs';
import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* Capsules and polygons (#151), and the collision outline of a frame that a tile blocks with
* (#141). What only a running editor can show: the corners of a polygon collider are edited on
* its outline in the scene, the engine lets a capsule slide down a polygon ramp, the Texture tab
* draws a frame's outline with clicks, and a painted map blocks with that outline in the game.
*/
/** A 32 × 32 picture, pasted like a file: the texture the outline is drawn on. */
const PASTE_PICTURE = `(async () => {
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const g = canvas.getContext('2d');
g.fillStyle = '#3c9a4a'; g.fillRect(0, 0, 32, 32);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer();
transfer.items.add(new File([blob], 'wedge.png', { type: 'image/png' }));
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
/** Walks its owner right and a little down, colliding with solids: 2 and 0.6 a frame at 60 fps. */
const climbGraph = {
nodes: [
node('sc-update', 'OnUpdate', 0, 0),
node('sc-owner', 'Owner', 0, 160),
node('sc-move', 'Move', 300, 0, { dx: 1, dy: 0.3, scale: 120, collide: true }),
],
edges: [
edge('sc-e1', 'sc-update', 'exec', 'sc-move', 'exec'),
edge('sc-e2', 'sc-update', 'dt', 'sc-move', 'dt'),
edge('sc-e3', 'sc-owner', 'object', 'sc-move', 'object'),
],
};
const TRIANGLE = [
{ x: 0, y: -80 },
{ x: 80, y: 80 },
{ x: -80, y: 80 },
];
const TAN30 = Math.tan(Math.PI / 6);
/** A ramp falling right at 30°; its top face runs through the object's origin. */
const RAMP = [
{ x: -200, y: -200 * TAN30 },
{ x: 200, y: 200 * TAN30 },
{ x: 200, y: 150 },
{ x: -200, y: 150 },
];
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
export async function shapes(t) {
const { check, json, evaluate, mutate, select, click, doubleClick, drag, clickText, undo } = t;
const { object, settle, sleep, play, stop, shot, showTab } = t;
const Y = LANES.shapes;
const put = `
const put = (id, assetId, parentId, x, y, name, overrides = {}) => {
const o = make(id, assetId, parentId, y, name);
o.position.x = x;
o.overrides = overrides;
scene.objects.push(o);
};`;
// 42a. A polygon collider on a crate, selected: a handle on each corner. A corner is dragged, a
// press on an edge puts a corner there, a double-click takes it away – one undo step each.
await mutate(`
const scene = __gm.state.activeScene;
const assets = __gm.state.project.assets;
${MAKE_OBJECT}
${put}
assets.push({ id: 'sh-col-poly', type: 'collider', name: 'sh-col-poly',
props: { shape: 'polygon', points: ${JSON.stringify(TRIANGLE)}, isTrigger: false } });
put('sh-poly', 'crate', '', 0, ${Y}, 'Polygon crate', { width: 160, height: 160 });
put('sh-poly-col', 'sh-col-poly', 'sh-poly', 0, 0, 'Polygon collider');
__gm.state.select(null)`);
await evaluate(`__gm.lookAt(0, ${Y})`);
await select({ kind: 'object', holderId: 'scene1', objectId: 'sh-poly-col', objectIds: ['sh-poly-col'] });
await settle();
const corners = async () => (await json('__gm.polygonCorners()'))?.corners ?? [];
const points = async () => (await object('sh-poly-col'))?.overrides?.points;
const start = await corners();
check(
'a selected polygon collider shows a handle on each of its corners',
start.length === 3 && start[0].y < start[1].y && start[1].x > start[2].x,
JSON.stringify(start),
);
const zoom = distance(start[0], start[1]) / distance(TRIANGLE[0], TRIANGLE[1]);
await drag(start[1].x, start[1].y, start[1].x + 40, start[1].y);
const dragged = await points();
check(
"dragging a corner moves it in the collider's own units, and one undo puts it back",
dragged?.length === 3 &&
Math.abs(dragged[1].x - (80 + 40 / zoom)) <= 1 &&
dragged[1].y === 80 &&
(await undo(), (await points()) === undefined),
JSON.stringify({ dragged, zoom }),
);
const [a, b] = await corners();
await click((a.x + b.x) / 2, (a.y + b.y) / 2);
const inserted = await points();
check(
'a press on an edge puts a corner there',
inserted?.length === 4 && Math.abs(inserted[1].x - 40) <= 1 && Math.abs(inserted[1].y) <= 1,
JSON.stringify(inserted),
);
await shot('42-polygon-corners');
const fourth = (await corners())[1];
await doubleClick(fourth.x, fourth.y);
check(
'a double-click on a corner takes it away again',
JSON.stringify(await points()) === JSON.stringify(TRIANGLE),
JSON.stringify(await points()),
);
const clearShapes = () =>
mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('sh-'));
p.assets = p.assets.filter((a) => !a.id.startsWith('sh-'));
__gm.state.select(null)`);
await clearShapes();
// Every step clears its objects, so each has the lane to itself.
const RAMP_Y = Y + 100;
// 42b. The engine with the new shapes: a capsule with a rigidbody (no turning) falls onto a
// polygon ramp and slides down it, on the surface and never into it.
await mutate(`
const scene = __gm.state.activeScene;
const assets = __gm.state.project.assets;
${MAKE_OBJECT}
${put}
assets.push({ id: 'sh-col-ramp', type: 'collider', name: 'sh-col-ramp',
props: { shape: 'polygon', points: ${JSON.stringify(RAMP)}, isTrigger: false } });
assets.push({ id: 'sh-col-capsule', type: 'collider', name: 'sh-col-capsule',
props: { shape: 'capsule', width: 20, height: 40, friction: 0, isTrigger: false } });
assets.push({ id: 'sh-rb', type: 'rigidbody', name: 'sh-rb',
props: { bodyType: 'dynamic', mass: 0, fixedRotation: true } });
put('sh-ramp', 'crate', '', 0, ${RAMP_Y}, 'Ramp', { width: 20, height: 20 });
put('sh-ramp-col', 'sh-col-ramp', 'sh-ramp', 0, 0);
put('sh-capsule', 'crate', '', -150, ${RAMP_Y - 200}, 'Capsule', { width: 20, height: 40 });
put('sh-capsule-col', 'sh-col-capsule', 'sh-capsule', 0, 0);
put('sh-capsule-rb', 'sh-rb', 'sh-capsule', 0, 0);
__gm.state.select(null)`);
await evaluate(`__gm.lookAt(0, ${RAMP_Y - 100})`);
await settle();
await shot('42-capsule-ramp');
await play();
await evaluate(`(() => {
const out = (window.__shapeSamples = []);
const begin = performance.now();
const sample = () => {
const w = __gm.world();
if (!w) return;
const e = w.entities.find((x) => x.objectId === 'sh-capsule');
out.push({ x: e?.worldX, y: e?.worldY });
if (performance.now() - begin < 2500) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
})()`);
await sleep(2800);
const samples = await json('window.__shapeSamples');
await stop();
// The lower round end's centre is 10 above the capsule's centre; its distance to the ramp's face
// (through (0, RAMP_Y), normal (sin 30°, −cos 30°)) is what keeps it on the surface.
const n = { x: 0.5, y: -Math.cos(Math.PI / 6) };
const gap = (s) => (s.x - 0) * n.x + (s.y + 10 - RAMP_Y) * n.y - 10;
// Only while it is over the ramp, which ends at x = 200: past it the capsule falls free.
const landed = samples.filter((s) => s.x < 190 && gap(s) < 1);
check(
'a capsule falls onto a polygon ramp and slides down it on the surface',
samples.length > 60 &&
landed.length > 30 &&
Math.max(...landed.map((s) => s.x)) > -150 + 60 &&
// The landing sinks it in a little (measured 0.9 at ~6 a frame); the engine pushes it back out.
landed.every((s) => gap(s) > -2) &&
landed.slice(30).every((s) => Math.abs(gap(s)) < 0.1),
JSON.stringify({
frames: samples.length,
landed: landed.length,
furthest: Math.max(...landed.map((s) => s.x)),
deepest: Math.min(...landed.map(gap)),
settled: Math.max(...landed.slice(30).map((s) => Math.abs(gap(s)))),
}),
);
await clearShapes();
// 42c. The Texture tab draws a frame's collision outline (#141): with "Collision" on, three
// clicks in the selected frame are three corners; a corner drags; undo takes the drag back.
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate(PASTE_PICTURE);
await sleep(2500);
const texture = await json(
"(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'texture').at(-1);" +
' return a ? { id: a.id, w: a.width, h: a.height } : null; })()',
);
check(
'the picture is imported at its own size',
texture?.w === 32 && texture?.h === 32,
JSON.stringify(texture),
);
await mutate(`
const texture = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture?.id)});
texture.frames.push({ id: 'sh-frame', name: 'Wedge', x: 0, y: 0, w: 32, h: 32 });`);
await select({ kind: 'frame', assetId: texture?.id, frameId: 'sh-frame' });
await showTab('texture');
await settle();
await clickText('Collision');
const outline = () =>
json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture?.id)})` +
`.frames.find((f) => f.id === 'sh-frame').collision ?? null`,
);
for (const [x, y] of [
[0, 32],
[32, 0],
[32, 32],
]) {
const at = await json(`__gm.texture.pointAt(${x}, ${y})`);
await click(at.x, at.y);
}
const drawn = await outline();
check(
'with Collision on, clicks in the selected frame draw its outline corner by corner',
(await json('__gm.texture.collisionMode()')) === true &&
JSON.stringify(drawn) ===
JSON.stringify([
{ x: 0, y: 32 },
{ x: 32, y: 0 },
{ x: 32, y: 32 },
]),
JSON.stringify(drawn),
);
const from = await json('__gm.texture.pointAt(32, 0)');
const to = await json('__gm.texture.pointAt(16, 0)');
await drag(from.x, from.y, to.x, to.y);
const moved = await outline();
await shot('42-texture-outline');
await undo();
check(
'a corner of the outline drags, and one undo puts it back',
moved?.[1]?.x === 16 && moved[1].y === 0 && (await outline())?.[1]?.x === 32,
JSON.stringify({ moved, after: await outline() }),
);
await clickText('Collision');
await showTab('scene');
await settle();
// 42d. A plain tile of that frame blocks with the outline: a wedge on a floor of plain solid
// tiles. The scene view outlines it, and a walker whose Move collides climbs over it.
const MAP_Y = Y; // cell (x, y) spans x·32 − 192 … and y·32 − 64 + MAP_Y …; the floor's top is MAP_Y + 32
const floor = Object.fromEntries(Array.from({ length: 12 }, (_, x) => [`${x},3`, 'sh-wall']));
await mutate(`
const scene = __gm.state.activeScene;
const assets = __gm.state.project.assets;
${MAKE_OBJECT}
${put}
const behavior = structuredClone(assets.find((a) => a.id === 'mover'));
Object.assign(behavior, { id: 'sh-climb', name: 'Climb', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(climbGraph)} });
assets.push(behavior);
assets.push({ id: 'sh-wedge', type: 'tile', name: 'Wedge', props: { kind: 'none', solid: true }, pictures: ['sh-frame'] });
assets.push({ id: 'sh-wall', type: 'tile', name: 'Floor', props: { kind: 'none', solid: true }, pictures: [] });
assets.push({ id: 'sh-map', type: 'tilemap', name: 'Wedge map', props: { columns: 12, rows: 4, tileSize: 32 },
layers: [{ id: 'sh-layer', name: 'Layer 1', cells: { ...${JSON.stringify(floor)}, '6,2': 'sh-wedge' } }] });
assets.push({ id: 'sh-col-walker', type: 'collider', name: 'sh-col-walker',
props: { shape: 'box', width: 20, height: 20, friction: 0, isTrigger: false } });
put('sh-map-obj', 'sh-map', '', 0, ${MAP_Y}, 'Wedge map');
put('sh-walker', 'crate', '', -100, ${MAP_Y + 22}, 'Walker', { width: 20, height: 20 });
put('sh-walker-col', 'sh-col-walker', 'sh-walker', 0, 0);
put('sh-walker-climb', 'sh-climb', 'sh-walker', 0, 0, 'Climb');
__gm.state.select(null)`);
await evaluate(`__gm.lookAt(0, ${MAP_Y})`);
await settle();
const solids = await json(`__gm.tilemap.solids('sh-map-obj')`);
check(
'the scene view outlines the wedge cell with its outline, the floor as one rectangle',
solids?.visible === true &&
JSON.stringify(solids.rects) === JSON.stringify([{ x: 0, y: 3, w: 12, h: 1 }]) &&
JSON.stringify(solids.outlines) ===
JSON.stringify([
[
{ x: 6, y: 3 },
{ x: 7, y: 2 },
{ x: 7, y: 3 },
],
]),
JSON.stringify(solids),
);
await shot('42-wedge-map');
await play();
await evaluate(`(() => {
const out = (window.__climb = []);
const begin = performance.now();
const sample = () => {
const w = __gm.world();
if (!w) return;
const e = w.entities.find((x) => x.objectId === 'sh-walker');
out.push({ x: e?.worldX, y: e?.worldY });
if (performance.now() - begin < 4500) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
})()`);
await sleep(4800);
const climb = await json('window.__climb');
await stop();
const highest = Math.min(...climb.map((s) => s.y));
check(
"a walker whose Move collides climbs the wedge tile's slope and goes on past it",
climb.length > 100 && highest < MAP_Y - 10 + 1 && highest > MAP_Y - 10 - 3 && climb.at(-1).x > 45,
JSON.stringify({ frames: climb.length, highest, top: MAP_Y - 10, last: climb.at(-1) }),
);
await clearShapes();
}
+2
View File
@@ -44,6 +44,7 @@ import { plugins } from './38-plugins.mjs';
import { tile } from './39-tile.mjs';
import { tilemap } from './40-tilemap.mjs';
import { physics } from './41-physics.mjs';
import { shapes } from './42-shapes.mjs';
export const STEPS = [
hierarchy,
@@ -87,4 +88,5 @@ export const STEPS = [
tile,
tilemap,
physics,
shapes,
];
+1
View File
@@ -22,6 +22,7 @@ export const LANES = {
sound: -2700,
tilemap: -3100,
physics: -3500,
shapes: -3900,
};
/**