Physics stage 2: colliders gain the shapes capsule and polygon, a polygon keeps its corners in the property points, turns, scales and mirrors with its object and is edited in the scene view (drag a corner, click an edge to insert one, double-click to delete, at least three stay, each step one undo step), a concave polygon is split into convex parts, a self-intersecting one is drawn red and collides with nothing, Rapier gets a capsule and one convex collider per part on the same body, and Move and the editor run use our own f64 geometry for the new shapes, where on a tie between two normals the one that blocks the move least wins, so a box no longer stops at the foot of a slope (#151); first version of a collision outline per texture frame, edited in the texture tab with the new Collision toggle, which a plain tile blocks with instead of its rectangle (#141, to be reworked)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1b5e386b60
commit
32057a96fc
25 files changed
+2341
-122
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Graph } from '../graph/Graph';
|
||||
import type { PropertyDef } from '../properties/PropertySchema';
|
||||
import type { AssetType } from './AssetTypes';
|
||||
import type { PropertyBag, PropertyValue } from './Properties';
|
||||
import type { PropertyBag, PropertyValue, Vec2 } from './Properties';
|
||||
import type { Scope } from './Scope';
|
||||
import type { SceneObject } from './SceneObject';
|
||||
|
||||
@@ -94,6 +94,11 @@ export interface TextureFrame {
|
||||
h: number;
|
||||
border?: FrameBorders;
|
||||
fill?: FrameFill;
|
||||
/**
|
||||
* The outline a tile made of this frame blocks with (#141), in pixels from the frame's top-left
|
||||
* corner, so it moves with the frame. Absent is no outline; fewer than three corners collide as none.
|
||||
*/
|
||||
collision?: Vec2[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Plain polygons as outlines of points: checking one, and cutting a concave one into convex pieces
|
||||
* – what collision works with (#151), since the overlap of two convex shapes is a matter of a few
|
||||
* axes and the overlap of two concave ones is not.
|
||||
*/
|
||||
import { isList, isStruct, type PropertyValue, type Vec2 } from './Properties';
|
||||
|
||||
/** Points closer than this are one point, and a turn smaller than this is a straight line. */
|
||||
const EPSILON = 1e-9;
|
||||
|
||||
/** A coordinate as stored: a number, or a text spelling one. `Number(null)` would be 0, not missing. */
|
||||
function coordinate(value: unknown): number {
|
||||
if (typeof value === 'number') return value;
|
||||
return typeof value === 'string' && value.trim() !== '' ? Number(value) : Number.NaN;
|
||||
}
|
||||
|
||||
/** The points a stored list holds: `{x, y}` items with finite numbers; anything else is left out. */
|
||||
export function pointsOf(value: PropertyValue | undefined): Vec2[] {
|
||||
if (!isList(value)) return [];
|
||||
const points: Vec2[] = [];
|
||||
for (const item of value) {
|
||||
if (!isStruct(item)) continue;
|
||||
const x = coordinate(item.x);
|
||||
const y = coordinate(item.y);
|
||||
if (Number.isFinite(x) && Number.isFinite(y)) points.push({ x, y });
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** Positive when the points run clockwise on screen (y grows downwards), negative the other way. */
|
||||
export function signedArea(points: readonly Vec2[]): number {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const a = points[i]!;
|
||||
const b = points[(i + 1) % points.length]!;
|
||||
sum += a.x * b.y - b.x * a.y;
|
||||
}
|
||||
return sum / 2;
|
||||
}
|
||||
|
||||
function cross(o: Vec2, a: Vec2, b: Vec2): number {
|
||||
return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
||||
}
|
||||
|
||||
/** The outline without repeated points and without points in the middle of a straight edge. */
|
||||
export function cleanOutline(points: readonly Vec2[]): Vec2[] {
|
||||
let ring = points.filter((p, i) => {
|
||||
const next = points[(i + 1) % points.length]!;
|
||||
return points.length < 2 || Math.hypot(p.x - next.x, p.y - next.y) > EPSILON;
|
||||
});
|
||||
let changed = true;
|
||||
while (changed && ring.length >= 3) {
|
||||
changed = false;
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
const prev = ring[(i + ring.length - 1) % ring.length]!;
|
||||
const next = ring[(i + 1) % ring.length]!;
|
||||
const p = ring[i]!;
|
||||
const span = Math.hypot(next.x - prev.x, next.y - prev.y) * Math.hypot(p.x - prev.x, p.y - prev.y);
|
||||
if (Math.abs(cross(prev, p, next)) <= EPSILON * Math.max(1, span)) {
|
||||
ring = ring.filter((_, j) => j !== i);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
/** Whether the open segments ab and cd cross or touch. */
|
||||
function segmentsMeet(a: Vec2, b: Vec2, c: Vec2, d: Vec2): boolean {
|
||||
const d1 = cross(c, d, a);
|
||||
const d2 = cross(c, d, b);
|
||||
const d3 = cross(a, b, c);
|
||||
const d4 = cross(a, b, d);
|
||||
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) return true;
|
||||
const on = (p: Vec2, q: Vec2, r: Vec2, side: number) =>
|
||||
Math.abs(side) <= EPSILON &&
|
||||
Math.min(p.x, q.x) - EPSILON <= r.x &&
|
||||
r.x <= Math.max(p.x, q.x) + EPSILON &&
|
||||
Math.min(p.y, q.y) - EPSILON <= r.y &&
|
||||
r.y <= Math.max(p.y, q.y) + EPSILON;
|
||||
return on(c, d, a, d1) || on(c, d, b, d2) || on(a, b, c, d3) || on(a, b, d, d4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the outline is no polygon one can collide with, or '' when it is one: at least three corners,
|
||||
* some area, and no edge crossing or touching another that is not its neighbour.
|
||||
*/
|
||||
export function polygonProblem(points: readonly Vec2[]): string {
|
||||
const ring = cleanOutline(points);
|
||||
if (ring.length < 3) return 'A polygon needs at least three corners.';
|
||||
if (Math.abs(signedArea(ring)) <= EPSILON) return 'The polygon has no area.';
|
||||
const n = ring.length;
|
||||
for (let i = 0; i < n; i++)
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
if (j === i + 1 || (i === 0 && j === n - 1)) continue;
|
||||
if (segmentsMeet(ring[i]!, ring[(i + 1) % n]!, ring[j]!, ring[(j + 1) % n]!))
|
||||
return 'The outline crosses itself.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Whether every corner turns the same way. */
|
||||
export function isConvex(ring: readonly Vec2[]): boolean {
|
||||
const sign = Math.sign(signedArea(ring));
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
const turn = cross(ring[i]!, ring[(i + 1) % ring.length]!, ring[(i + 2) % ring.length]!);
|
||||
if (turn * sign < -EPSILON) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Whether `p` lies inside the triangle abc of the given winding, its edges counting as inside. */
|
||||
function inTriangle(p: Vec2, a: Vec2, b: Vec2, c: Vec2, sign: number): boolean {
|
||||
return (
|
||||
cross(a, b, p) * sign >= -EPSILON &&
|
||||
cross(b, c, p) * sign >= -EPSILON &&
|
||||
cross(c, a, p) * sign >= -EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
/** Cuts a simple outline into triangles by clipping ears, as indices into `ring`. */
|
||||
function triangulate(ring: readonly Vec2[]): [number, number, number][] {
|
||||
const sign = Math.sign(signedArea(ring));
|
||||
const left = ring.map((_, i) => i);
|
||||
const triangles: [number, number, number][] = [];
|
||||
let guard = ring.length * ring.length;
|
||||
while (left.length > 3 && guard-- > 0) {
|
||||
let clipped = false;
|
||||
for (let k = 0; k < left.length; k++) {
|
||||
const i0 = left[(k + left.length - 1) % left.length]!;
|
||||
const i1 = left[k]!;
|
||||
const i2 = left[(k + 1) % left.length]!;
|
||||
const [a, b, c] = [ring[i0]!, ring[i1]!, ring[i2]!];
|
||||
if (cross(a, b, c) * sign <= EPSILON) continue;
|
||||
const blocked = left.some(
|
||||
(j) => j !== i0 && j !== i1 && j !== i2 && inTriangle(ring[j]!, a, b, c, sign),
|
||||
);
|
||||
if (blocked) continue;
|
||||
triangles.push([i0, i1, i2]);
|
||||
left.splice(k, 1);
|
||||
clipped = true;
|
||||
break;
|
||||
}
|
||||
if (!clipped) break;
|
||||
}
|
||||
if (left.length === 3) triangles.push([left[0]!, left[1]!, left[2]!]);
|
||||
return triangles;
|
||||
}
|
||||
|
||||
/**
|
||||
* The outline as convex pieces that together cover it exactly: itself when it is convex, else its
|
||||
* triangles merged back along every shared edge whose removal leaves the piece convex
|
||||
* (Hertel-Mehlhorn) – at most four times as many pieces as the fewest possible, and few in practice.
|
||||
* An outline with a problem (see `polygonProblem`) has no pieces.
|
||||
*/
|
||||
export function convexPieces(points: readonly Vec2[]): Vec2[][] {
|
||||
if (polygonProblem(points)) return [];
|
||||
const ring = cleanOutline(points);
|
||||
if (isConvex(ring)) return [ring];
|
||||
let pieces = triangulate(ring).map((t) => [...t]);
|
||||
let merged = true;
|
||||
while (merged) {
|
||||
merged = false;
|
||||
search: for (let p = 0; p < pieces.length; p++)
|
||||
for (let q = p + 1; q < pieces.length; q++) {
|
||||
const joined = joinAlongSharedEdge(pieces[p]!, pieces[q]!);
|
||||
if (!joined || !isConvex(cleanOutline(joined.map((i) => ring[i]!)))) continue;
|
||||
pieces[p] = joined;
|
||||
pieces = pieces.filter((_, k) => k !== q);
|
||||
merged = true;
|
||||
break search;
|
||||
}
|
||||
}
|
||||
return pieces.map((piece) => cleanOutline(piece.map((i) => ring[i]!)));
|
||||
}
|
||||
|
||||
/** Two pieces (index rings of the same winding) joined across the edge they share, if they share one. */
|
||||
function joinAlongSharedEdge(a: number[], b: number[]): number[] | null {
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const u = a[i]!;
|
||||
const v = a[(i + 1) % a.length]!;
|
||||
const j = b.findIndex((x, k) => x === v && b[(k + 1) % b.length] === u);
|
||||
if (j < 0) continue;
|
||||
// Walk a from v round to u, then b from u round to v, skipping the shared edge.
|
||||
const out: number[] = [];
|
||||
for (let k = 0; k < a.length; k++) out.push(a[(i + 1 + k) % a.length]!);
|
||||
for (let k = 2; k < b.length; k++) out.push(b[(j + k) % b.length]!);
|
||||
return out;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FrameBorders, FrameFill, TextureAsset, TextureFrame } from './Asset';
|
||||
import { type PropertyBag, textOf } from './Properties';
|
||||
import { type PropertyBag, textOf, type Vec2 } from './Properties';
|
||||
import { findAsset, type Project } from './Project';
|
||||
|
||||
/** Mirrors `TextureStore::isValidName` on the server, where the name is derived from the bytes. */
|
||||
@@ -112,6 +112,17 @@ export const DEFAULT_FILL: FrameFill = 'repeat';
|
||||
/** Whole pixels of the frame that must stay between two opposite borders (see `normalizeBorders`). */
|
||||
export const MIN_SLICE_MIDDLE = 1;
|
||||
|
||||
/** The frame's collision outline (#141), a copy; empty when it has none. */
|
||||
export function frameCollision(frame: TextureFrame): Vec2[] {
|
||||
return (frame.collision ?? []).map((p) => ({ ...p }));
|
||||
}
|
||||
|
||||
/** Sets the outline; an empty one removes the key, so a frame without one keeps saying so. */
|
||||
export function setFrameCollision(frame: TextureFrame, points: readonly Vec2[]): void {
|
||||
if (points.length === 0) delete frame.collision;
|
||||
else frame.collision = points.map((p) => ({ x: p.x, y: p.y }));
|
||||
}
|
||||
|
||||
export function frameBorders(frame: TextureFrame): FrameBorders {
|
||||
return frame.border ? { ...frame.border } : { ...NO_BORDERS };
|
||||
}
|
||||
|
||||
@@ -11,12 +11,17 @@
|
||||
* blocking neighbour: between two blocking cells the side stays flush whatever their values, so a
|
||||
* wall of bushes is a narrower wall and not a string of beads. A neighbour across the corner only
|
||||
* does not count – that corner stays drawn in.
|
||||
*
|
||||
* A plain tile whose picture is a frame with a collision outline (#141) blocks with that outline
|
||||
* instead, one polygon per cell, never merged: see `solidPolygons`.
|
||||
*/
|
||||
import type { TilemapAsset } from './Asset';
|
||||
import type { TileAsset, TilemapAsset } from './Asset';
|
||||
import { angleOf, apply, type Mat, scaleOf } from './matrix';
|
||||
import type { PropertyBag } from './Properties';
|
||||
import { convexPieces } from './polygon';
|
||||
import type { PropertyBag, Vec2 } from './Properties';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
import { isTileSolid, tileCollisionScale } from './tile';
|
||||
import { findFrame } from './texture';
|
||||
import { isTileSolid, tileCollisionScale, tileKind } from './tile';
|
||||
import { cellOrigin, inGrid, parseCellKey, tilemapTileSize } from './tilemap';
|
||||
import type { ColliderShape } from '../properties/collider';
|
||||
|
||||
@@ -35,38 +40,110 @@ export interface SolidCell {
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
/** A cell that blocks with an outline (#141): the outline and its convex pieces, in cells of the map. */
|
||||
export interface CellPolygon {
|
||||
x: number;
|
||||
y: number;
|
||||
outline: Vec2[];
|
||||
pieces: Vec2[][];
|
||||
}
|
||||
|
||||
/** A tile's outline across one cell, 0 to 1 on each axis, and its convex pieces. */
|
||||
interface TileOutline {
|
||||
outline: Vec2[];
|
||||
pieces: Vec2[][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every blocking cell of the map, once each however many layers hold a solid tile there. Where
|
||||
* several do, the largest scale wins: each of them blocks, so the cell blocks as far as any does.
|
||||
* The outline a tile blocks with, or null when it blocks as a box. Only a plain tile has one – an
|
||||
* autotile's cell is pieced together from quarters of a block, where no single frame is the cell –
|
||||
* and only its first picture counts: a blocking shape that twitched with the animation is rarely
|
||||
* wanted. An outline that is no polygon (see `polygonProblem`) blocks as the box it replaced.
|
||||
*/
|
||||
export function solidCells(project: Project, props: PropertyBag, asset: TilemapAsset): Required<SolidCell>[] {
|
||||
const scales = new Map<string, number | null>();
|
||||
const scaleOf = (id: string): number | null => {
|
||||
const known = scales.get(id);
|
||||
if (known !== undefined) return known;
|
||||
export function tileOutline(project: Project, tile: TileAsset): TileOutline | null {
|
||||
if (tileKind(tile) !== 'none') return null;
|
||||
const id = tile.pictures[0];
|
||||
const frame = id ? findFrame(project, id)?.frame : undefined;
|
||||
if (!frame?.collision || frame.w <= 0 || frame.h <= 0) return null;
|
||||
const outline = frame.collision.map((p) => ({ x: p.x / frame.w, y: p.y / frame.h }));
|
||||
const pieces = convexPieces(outline);
|
||||
return pieces.length > 0 ? { outline, pieces } : null;
|
||||
}
|
||||
|
||||
/** What blocks in each cell: the largest box scale of a plain solid tile, and the outlines of the others. */
|
||||
interface CellBlock {
|
||||
x: number;
|
||||
y: number;
|
||||
box: number | null;
|
||||
outlines: { outline: TileOutline; scale: number }[];
|
||||
}
|
||||
|
||||
function blockingCells(project: Project, props: PropertyBag, asset: TilemapAsset): CellBlock[] {
|
||||
const tiles = new Map<string, { scale: number; outline: TileOutline | null } | null>();
|
||||
const tileOf = (id: string) => {
|
||||
if (tiles.has(id)) return tiles.get(id)!;
|
||||
const tile = findAssetOf(project, id, 'tile');
|
||||
const value = tile && isTileSolid(tile) ? tileCollisionScale(tile) : null;
|
||||
scales.set(id, value);
|
||||
const value =
|
||||
tile && isTileSolid(tile)
|
||||
? { scale: tileCollisionScale(tile), outline: tileOutline(project, tile) }
|
||||
: null;
|
||||
tiles.set(id, value);
|
||||
return value;
|
||||
};
|
||||
const found = new Map<string, Required<SolidCell>>();
|
||||
const found = new Map<string, CellBlock>();
|
||||
for (const layer of asset.layers) {
|
||||
for (const [key, tileId] of Object.entries(layer.cells)) {
|
||||
const scale = tileId ? scaleOf(tileId) : null;
|
||||
if (scale === null) continue;
|
||||
const known = found.get(key);
|
||||
if (known) {
|
||||
known.scale = Math.max(known.scale, scale);
|
||||
continue;
|
||||
const tile = tileId ? tileOf(tileId) : null;
|
||||
if (!tile) continue;
|
||||
let cell = found.get(key);
|
||||
if (!cell) {
|
||||
const at = parseCellKey(key);
|
||||
if (!at || !inGrid(props, at.x, at.y)) continue;
|
||||
found.set(key, (cell = { ...at, box: null, outlines: [] }));
|
||||
}
|
||||
const at = parseCellKey(key);
|
||||
if (!at || !inGrid(props, at.x, at.y)) continue;
|
||||
found.set(key, { ...at, scale });
|
||||
if (tile.outline) cell.outlines.push({ outline: tile.outline, scale: tile.scale });
|
||||
else cell.box = Math.max(cell.box ?? 0, tile.scale);
|
||||
}
|
||||
}
|
||||
return [...found.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every cell of the map that blocks as a box, once each however many layers hold a solid tile
|
||||
* there. Where several do, the largest scale wins: each of them blocks, so the cell blocks as far
|
||||
* as any does. A cell whose only solid tiles have outlines is left to `solidPolygons`.
|
||||
*/
|
||||
export function solidCells(project: Project, props: PropertyBag, asset: TilemapAsset): Required<SolidCell>[] {
|
||||
return blockingCells(project, props, asset)
|
||||
.filter((cell) => cell.box !== null)
|
||||
.map(({ x, y, box }) => ({ x, y, scale: box! }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The cells that block with an outline: one polygon per outlined tile lying there, sized by the
|
||||
* tile's collision scale about the cell's centre (#140). A plain solid tile on another layer of the
|
||||
* same cell covers the whole cell already, so there the outlines are left out.
|
||||
*/
|
||||
export function solidPolygons(project: Project, props: PropertyBag, asset: TilemapAsset): CellPolygon[] {
|
||||
const polygons: CellPolygon[] = [];
|
||||
for (const cell of blockingCells(project, props, asset)) {
|
||||
if (cell.box !== null) continue;
|
||||
for (const { outline, scale } of cell.outlines) {
|
||||
const place = (p: Vec2) => ({
|
||||
x: cell.x + 0.5 + (p.x - 0.5) * scale,
|
||||
y: cell.y + 0.5 + (p.y - 0.5) * scale,
|
||||
});
|
||||
polygons.push({
|
||||
x: cell.x,
|
||||
y: cell.y,
|
||||
outline: outline.outline.map(place),
|
||||
pieces: outline.pieces.map((piece) => piece.map(place)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return polygons.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
}
|
||||
|
||||
/** A rectangle by its four sides, which is what the merge compares. */
|
||||
interface Piece {
|
||||
x0: number;
|
||||
@@ -189,3 +266,17 @@ export function cellRectShape(props: PropertyBag, rect: CellRect, m: Mat): Colli
|
||||
angle: angleOf(m),
|
||||
};
|
||||
}
|
||||
|
||||
/** An outlined cell as it sits in the world: a polygon turning and growing with the placed map. */
|
||||
export function cellPolygonShape(props: PropertyBag, polygon: CellPolygon, m: Mat): ColliderShape {
|
||||
const world = (p: Vec2) => apply(m, cellOrigin(props, p.x, p.y));
|
||||
const centre = world({ x: polygon.x + 0.5, y: polygon.y + 0.5 });
|
||||
return {
|
||||
kind: 'polygon',
|
||||
x: centre.x,
|
||||
y: centre.y,
|
||||
angle: angleOf(m),
|
||||
outline: polygon.outline.map(world),
|
||||
pieces: polygon.pieces.map((piece) => piece.map(world)),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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))),
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -22,6 +22,7 @@ export const LANES = {
|
||||
sound: -2700,
|
||||
tilemap: -3100,
|
||||
physics: -3500,
|
||||
shapes: -3900,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user