Collision outlines for autotiles: a frame holds several outlines (click adds a corner to the active one, Ctrl/Cmd-click starts a new one, clicking another outline makes it active, Delete removes the active outline and never the frame in collision mode), an autotile's outlines are drawn over the whole block, cut at the quarters and each cell takes the pieces of exactly the quarters its image is made of, so edges, outer corners and notches follow automatically; a tile of the block with nothing drawn blocks fully, cells of only full quarters merge back into rectangles, collision scale does not apply to an outlined autotile, and the previous format with one flat outline still loads (#141)
game-maker / gate (push) Successful in 1m53s
game-maker / suite (push) Successful in 6m41s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-19 01:56:20 +02:00
1 parent 32057a96fc
commit 947a6b1977
11 files changed
+545 -139

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1067 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1068 checks (GM_STEPS=20 for one step)
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs
+8 -7
View File
@@ -122,26 +122,27 @@ export class SetFrameSliceCommand implements Command {
} }
/** /**
* The collision outline of one frame (#141), whole: adding, moving and removing a corner are each * The collision outlines of one frame (#141), all of them: adding, moving and removing a corner,
* one edit, and the outline to revert to is read at execution time – a caller that previews a drag * starting or removing an outline are each one edit, and the outlines to revert to are read at
* in place restores the start before executing, as with `SetFrameRectCommand`. * execution time – a caller that previews a drag in place restores the start before executing, as
* with `SetFrameRectCommand`.
*/ */
export class SetFrameCollisionCommand implements Command { export class SetFrameCollisionCommand implements Command {
readonly label = 'Collision outline'; readonly label = 'Collision outline';
private previous: Vec2[] = []; private previous: Vec2[][] = [];
constructor( constructor(
private readonly assetId: string, private readonly assetId: string,
private readonly frameId: string, private readonly frameId: string,
private readonly points: readonly Vec2[], private readonly outlines: readonly (readonly Vec2[])[],
) {} ) {}
execute(project: Project): void { execute(project: Project): void {
const frame = requireFrame(requireTexture(project, this.assetId), this.frameId); const frame = requireFrame(requireTexture(project, this.assetId), this.frameId);
if (this.points.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y))) if (this.outlines.some((outline) => outline.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y))))
throw new CommandError('A corner of the outline is not a number'); throw new CommandError('A corner of the outline is not a number');
this.previous = frameCollision(frame); this.previous = frameCollision(frame);
setFrameCollision(frame, this.points); setFrameCollision(frame, this.outlines);
} }
undo(project: Project): void { undo(project: Project): void {
+4 -3
View File
@@ -95,10 +95,11 @@ export interface TextureFrame {
border?: FrameBorders; border?: FrameBorders;
fill?: FrameFill; fill?: FrameFill;
/** /**
* The outline a tile made of this frame blocks with (#141), in pixels from the frame's top-left * The outlines 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. * corner, so they move with the frame. Several, because an autotile's block holds several shapes
* – its bordered tile and its notches. Absent is none; one of fewer than three corners collides as none.
*/ */
collision?: Vec2[]; collision?: Vec2[][];
} }
/** /**
+39
View File
@@ -175,6 +175,45 @@ export function convexPieces(points: readonly Vec2[]): Vec2[][] {
return pieces.map((piece) => cleanOutline(piece.map((i) => ring[i]!))); return pieces.map((piece) => cleanOutline(piece.map((i) => ring[i]!)));
} }
/**
* The part of the outline inside the rectangle x0 … x1, y0 … y1 (Sutherland-Hodgman), cleaned; an
* empty list when nothing with area is left. Exact for a convex outline. A concave one that the cut
* splits in two comes back as one ring joined along the rectangle's edge – good enough to draw, but
* collision cuts the convex pieces instead.
*/
export function clipToRect(points: readonly Vec2[], x0: number, y0: number, x1: number, y1: number): Vec2[] {
const sides: [(p: Vec2) => number, (a: Vec2, b: Vec2) => Vec2][] = [
[(p) => p.x - x0, (a, b) => ({ x: x0, y: at(a.y, b.y, (x0 - a.x) / (b.x - a.x)) })],
[(p) => x1 - p.x, (a, b) => ({ x: x1, y: at(a.y, b.y, (x1 - a.x) / (b.x - a.x)) })],
[(p) => p.y - y0, (a, b) => ({ x: at(a.x, b.x, (y0 - a.y) / (b.y - a.y)), y: y0 })],
[(p) => y1 - p.y, (a, b) => ({ x: at(a.x, b.x, (y1 - a.y) / (b.y - a.y)), y: y1 })],
];
let ring: Vec2[] = [...points];
for (const [inside, cut] of sides) {
const next: Vec2[] = [];
for (let i = 0; i < ring.length; i++) {
const a = ring[i]!;
const b = ring[(i + 1) % ring.length]!;
const aIn = inside(a) >= 0;
const bIn = inside(b) >= 0;
if (aIn) next.push(a);
if (aIn !== bIn) next.push(cut(a, b));
}
ring = next;
if (ring.length === 0) return [];
}
const cleaned = cleanOutline(ring);
return cleaned.length >= 3 && Math.abs(signedArea(cleaned)) > EPSILON ? cleaned : [];
}
/**
* A share `t` of the way from a to b, on the axis the cut line crosses; the other axis is set to the
* line itself, so two neighbouring cuts along the same line meet exactly.
*/
function at(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
/** Two pieces (index rings of the same winding) joined across the edge they share, if they share one. */ /** 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 { function joinAlongSharedEdge(a: number[], b: number[]): number[] | null {
for (let i = 0; i < a.length; i++) { for (let i = 0; i < a.length; i++) {
+11 -7
View File
@@ -112,15 +112,19 @@ export const DEFAULT_FILL: FrameFill = 'repeat';
/** Whole pixels of the frame that must stay between two opposite borders (see `normalizeBorders`). */ /** Whole pixels of the frame that must stay between two opposite borders (see `normalizeBorders`). */
export const MIN_SLICE_MIDDLE = 1; export const MIN_SLICE_MIDDLE = 1;
/** The frame's collision outline (#141), a copy; empty when it has none. */ /** The frame's collision outlines (#141), a copy; empty when it has none. */
export function frameCollision(frame: TextureFrame): Vec2[] { export function frameCollision(frame: TextureFrame): Vec2[][] {
return (frame.collision ?? []).map((p) => ({ ...p })); return (frame.collision ?? []).map((outline) => outline.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 { * Sets the outlines. One without corners is dropped, and none left removes the key, so a frame
if (points.length === 0) delete frame.collision; * without an outline keeps saying so; one being drawn, with a corner or two, is kept.
else frame.collision = points.map((p) => ({ x: p.x, y: p.y })); */
export function setFrameCollision(frame: TextureFrame, outlines: readonly (readonly Vec2[])[]): void {
const kept = outlines.filter((outline) => outline.length > 0);
if (kept.length === 0) delete frame.collision;
else frame.collision = kept.map((outline) => outline.map((p) => ({ x: p.x, y: p.y })));
} }
export function frameBorders(frame: TextureFrame): FrameBorders { export function frameBorders(frame: TextureFrame): FrameBorders {
+143 -41
View File
@@ -12,17 +12,21 @@
* wall of bushes is a narrower wall and not a string of beads. A neighbour across the corner only * 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. * 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 * A tile whose picture is a frame with collision outlines (#141) blocks with those instead, as
* instead, one polygon per cell, never merged: see `solidPolygons`. * polygons, never merged: see `solidPolygons`. A plain tile lays its outlines over the cell. An
* autotile's cell is pieced together from four quarters of its block, picked by the neighbours – so
* its outlines are cut along the same quarters, and each cell takes the pieces of exactly the
* quarters it is drawn from (see `tileShape`).
*/ */
import type { TileAsset, TilemapAsset } from './Asset'; import type { TileAsset, TilemapAsset } from './Asset';
import { tileQuadrants } from './autotile';
import { angleOf, apply, type Mat, scaleOf } from './matrix'; import { angleOf, apply, type Mat, scaleOf } from './matrix';
import { convexPieces } from './polygon'; import { clipToRect, convexPieces, signedArea } from './polygon';
import type { PropertyBag, Vec2 } from './Properties'; import type { PropertyBag, Vec2 } from './Properties';
import { findAssetOf, type Project } from './Project'; import { findAssetOf, type Project } from './Project';
import { findFrame } from './texture'; import { findFrame } from './texture';
import { isTileSolid, tileCollisionScale, tileKind } from './tile'; import { isTileSolid, readTile, tileCollisionScale, tileKind, type TileKind } from './tile';
import { cellOrigin, inGrid, parseCellKey, tilemapTileSize } from './tilemap'; import { cellOrigin, inGrid, neighboursOf, parseCellKey, tilemapTileSize } from './tilemap';
import type { ColliderShape } from '../properties/collider'; import type { ColliderShape } from '../properties/collider';
/** A rectangle in cells: top-left corner and size. Whole cells at collision scale 1, parts of one otherwise. */ /** A rectangle in cells: top-left corner and size. Whole cells at collision scale 1, parts of one otherwise. */
@@ -48,45 +52,130 @@ export interface CellPolygon {
pieces: Vec2[][]; pieces: Vec2[][];
} }
/** A tile's outline across one cell, 0 to 1 on each axis, and its convex pieces. */ /** An outline and its convex pieces: 0 to 1 across a cell for a plain tile, across a quarter for an autotile. */
interface TileOutline { export interface TileOutline {
outline: Vec2[]; outline: Vec2[];
pieces: Vec2[][]; pieces: Vec2[][];
} }
/** What one quarter of an autotile's block blocks with: all of it, or the parts of the outlines in it – maybe none. */
export type QuarterBlock = 'full' | TileOutline[];
/** /**
* The outline a tile blocks with, or null when it blocks as a box. Only a plain tile has one – an * How a solid tile with outlines blocks: a plain tile with its outlines across the cell, an autotile
* autotile's cell is pieced together from quarters of a block, where no single frame is the cell – * with a block of every quarter of its block, by `x,y` in quarters.
* 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 tileOutline(project: Project, tile: TileAsset): TileOutline | null { export type TileShape =
if (tileKind(tile) !== 'none') return null; | { kind: 'plain'; outlines: TileOutline[] }
| { kind: 'auto'; tileKind: Exclude<TileKind, 'none'>; quarters: Map<string, QuarterBlock> };
const SQUARE: TileOutline = {
outline: [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 0, y: 1 },
],
pieces: [
[
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 0, y: 1 },
],
],
};
/** A share of a quarter's area this close to all of it is all of it. */
const FULL = 1 - 1e-9;
/** The outlines that are polygons (see `polygonProblem`), with their pieces; the others collide as none. */
function validOutlines(outlines: Vec2[][]): TileOutline[] {
return outlines
.map((outline) => ({ outline, pieces: convexPieces(outline) }))
.filter((outline) => outline.pieces.length > 0);
}
/**
* How the tile blocks, or null when it blocks as a box – when its first picture has no outline that
* is a polygon. Only the first picture counts: a blocking shape that twitched with the animation is
* rarely wanted.
*
* An autotile's outlines are drawn over its whole block and cut into its quarters, since a cell is
* drawn from four of them. Where a tile of the block has nothing drawn in it at all, its quarters
* block whole – so the notches of a floor nobody outlined leave no hole at the inner corners of a
* lake – but once anything is drawn in it, only what is drawn blocks, and a quarter can be left open.
* The collision scale does not apply here: the outline is the shape.
*/
export function tileShape(project: Project, tile: TileAsset): TileShape | null {
const id = tile.pictures[0]; const id = tile.pictures[0];
const frame = id ? findFrame(project, id)?.frame : undefined; const frame = id ? findFrame(project, id)?.frame : undefined;
if (!frame?.collision || frame.w <= 0 || frame.h <= 0) return null; 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 kind = tileKind(tile);
const pieces = convexPieces(outline); if (kind === 'none') {
return pieces.length > 0 ? { outline, pieces } : null; const across = frame.collision.map((o) => o.map((p) => ({ x: p.x / frame.w, y: p.y / frame.h })));
const outlines = validOutlines(across);
return outlines.length > 0 ? { kind: 'plain', outlines } : null;
}
const { quad } = readTile(project, tile);
if (quad <= 0) return null;
const outlines = validOutlines(
frame.collision.map((o) => o.map((p) => ({ x: p.x / quad, y: p.y / quad }))),
);
if (outlines.length === 0) return null;
const columns = Math.round(frame.w / quad);
const rows = Math.round(frame.h / quad);
const quarters = new Map<string, QuarterBlock>();
for (let qy = 0; qy < rows; qy++)
for (let qx = 0; qx < columns; qx++) quarters.set(`${qx},${qy}`, quarterBlock(outlines, qx, qy));
for (let ty = 0; ty < rows; ty += 2)
for (let tx = 0; tx < columns; tx += 2) {
const keys = [`${tx},${ty}`, `${tx + 1},${ty}`, `${tx},${ty + 1}`, `${tx + 1},${ty + 1}`];
if (keys.every((key) => (quarters.get(key) as QuarterBlock).length === 0))
for (const key of keys) quarters.set(key, 'full');
}
return { kind: 'auto', tileKind: kind, quarters };
} }
/** What blocks in each cell: the largest box scale of a plain solid tile, and the outlines of the others. */ /** The parts of the outlines inside the quarter at (qx, qy), moved to 0 … 1 across it. */
function quarterBlock(outlines: readonly TileOutline[], qx: number, qy: number): QuarterBlock {
const local = (p: Vec2) => ({ x: p.x - qx, y: p.y - qy });
const parts: TileOutline[] = [];
for (const { outline, pieces } of outlines) {
const cut = pieces.map((piece) => clipToRect(piece, qx, qy, qx + 1, qy + 1)).filter((p) => p.length > 0);
if (cut.length === 0) continue;
// The pieces of one outline never overlap, so their areas add up to what it covers.
if (cut.reduce((sum, piece) => sum + Math.abs(signedArea(piece)), 0) >= FULL) return 'full';
const drawn = clipToRect(outline, qx, qy, qx + 1, qy + 1);
parts.push({
outline: (drawn.length > 0 ? drawn : cut[0]!).map(local),
pieces: cut.map((p) => p.map(local)),
});
}
return parts;
}
/** What blocks in each cell: the largest box scale of the solid tiles blocking as boxes, and the outlines of the others, in cells. */
interface CellBlock { interface CellBlock {
x: number; x: number;
y: number; y: number;
box: number | null; box: number | null;
outlines: { outline: TileOutline; scale: number }[]; polygons: TileOutline[];
}
/** An outline of 0 to 1 across its square laid over the square of side `size` at (x, y). */
function placed(outline: TileOutline, x: number, y: number, size: number): TileOutline {
const place = (p: Vec2) => ({ x: x + p.x * size, y: y + p.y * size });
return { outline: outline.outline.map(place), pieces: outline.pieces.map((piece) => piece.map(place)) };
} }
function blockingCells(project: Project, props: PropertyBag, asset: TilemapAsset): CellBlock[] { function blockingCells(project: Project, props: PropertyBag, asset: TilemapAsset): CellBlock[] {
const tiles = new Map<string, { scale: number; outline: TileOutline | null } | null>(); const tiles = new Map<string, { scale: number; shape: TileShape | null } | null>();
const tileOf = (id: string) => { const tileOf = (id: string) => {
if (tiles.has(id)) return tiles.get(id)!; if (tiles.has(id)) return tiles.get(id)!;
const tile = findAssetOf(project, id, 'tile'); const tile = findAssetOf(project, id, 'tile');
const value = const value =
tile && isTileSolid(tile) tile && isTileSolid(tile) ? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) } : null;
? { scale: tileCollisionScale(tile), outline: tileOutline(project, tile) }
: null;
tiles.set(id, value); tiles.set(id, value);
return value; return value;
}; };
@@ -99,10 +188,32 @@ function blockingCells(project: Project, props: PropertyBag, asset: TilemapAsset
if (!cell) { if (!cell) {
const at = parseCellKey(key); const at = parseCellKey(key);
if (!at || !inGrid(props, at.x, at.y)) continue; if (!at || !inGrid(props, at.x, at.y)) continue;
found.set(key, (cell = { ...at, box: null, outlines: [] })); found.set(key, (cell = { ...at, box: null, polygons: [] }));
}
const { shape, scale } = tile;
if (!shape) {
cell.box = Math.max(cell.box ?? 0, scale);
} else if (shape.kind === 'plain') {
// The collision scale draws the outline in or out about the cell's centre (#140).
const at = 0.5 - scale / 2;
for (const outline of shape.outlines)
cell.polygons.push(placed(outline, cell.x + at, cell.y + at, scale));
} else {
const around = neighboursOf(props, layer, cell.x, cell.y, tileId);
const quarters = tileQuadrants(shape.tileKind, around).map(
(q) => shape.quarters.get(`${q.x},${q.y}`) ?? [],
);
if (quarters.every((q) => q === 'full')) {
cell.box = Math.max(cell.box ?? 0, 1);
continue;
}
for (const [corner, quarter] of quarters.entries()) {
const x = cell.x + (corner % 2) / 2;
const y = cell.y + Math.floor(corner / 2) / 2;
for (const outline of quarter === 'full' ? [SQUARE] : quarter)
cell.polygons.push(placed(outline, x, y, 0.5));
}
} }
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()]; return [...found.values()];
@@ -111,7 +222,8 @@ function blockingCells(project: Project, props: PropertyBag, asset: TilemapAsset
/** /**
* Every cell of the map that blocks as a box, once each however many layers hold a solid tile * 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 * 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`. * as any does. An autotile cell built only from quarters that block whole is a box too, at scale 1.
* A cell whose only solid tiles block with outlines is left to `solidPolygons`.
*/ */
export function solidCells(project: Project, props: PropertyBag, asset: TilemapAsset): Required<SolidCell>[] { export function solidCells(project: Project, props: PropertyBag, asset: TilemapAsset): Required<SolidCell>[] {
return blockingCells(project, props, asset) return blockingCells(project, props, asset)
@@ -120,26 +232,16 @@ export function solidCells(project: Project, props: PropertyBag, asset: TilemapA
} }
/** /**
* The cells that block with an outline: one polygon per outlined tile lying there, sized by the * The polygons of the cells that block with outlines: for a plain tile one per outline, for an
* tile's collision scale about the cell's centre (#140). A plain solid tile on another layer of the * autotile one per part of an outline in each of its quarters, and a square for a quarter that
* same cell covers the whole cell already, so there the outlines are left out. * blocks whole. A tile blocking as a box 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[] { export function solidPolygons(project: Project, props: PropertyBag, asset: TilemapAsset): CellPolygon[] {
const polygons: CellPolygon[] = []; const polygons: CellPolygon[] = [];
for (const cell of blockingCells(project, props, asset)) { for (const cell of blockingCells(project, props, asset)) {
if (cell.box !== null) continue; if (cell.box !== null) continue;
for (const { outline, scale } of cell.outlines) { for (const { outline, pieces } of cell.polygons) polygons.push({ x: cell.x, y: cell.y, outline, pieces });
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); return polygons.sort((a, b) => a.y - b.y || a.x - b.x);
} }
+13 -2
View File
@@ -39,6 +39,7 @@ import {
type PropertyValue, type PropertyValue,
type StructValue, type StructValue,
tagOf, tagOf,
type Vec2,
} from '../model/Properties'; } from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project'; import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import { readScope } from '../model/Scope'; import { readScope } from '../model/Scope';
@@ -482,13 +483,23 @@ function normalizeTexture(
fill: f.fill === 'stretch' ? 'stretch' : DEFAULT_FILL, fill: f.fill === 'stretch' ? 'stretch' : DEFAULT_FILL,
}); });
// Corners that are no numbers fall away; what is left is kept only as an outline (#141). // Corners that are no numbers fall away; what is left is kept only as an outline (#141).
const collision = pointsOf(f.collision as PropertyValue); const outlines = collisionOutlines(f.collision).filter((outline) => outline.length >= 3);
if (collision.length >= 3) setFrameCollision(frame, collision); if (outlines.length > 0) setFrameCollision(frame, outlines);
frames.push(frame); frames.push(frame);
} }
return { file, width, height, frames }; return { file, width, height, frames };
} }
/**
* A frame's stored outlines: a list of corner lists, or – as saved before a frame held several –
* one corner list, which is one outline.
*/
function collisionOutlines(raw: unknown): Vec2[][] {
if (!Array.isArray(raw)) return [];
if (raw.some((item) => !Array.isArray(item))) return [pointsOf(raw as PropertyValue)];
return raw.map((outline) => pointsOf(outline as PropertyValue));
}
/** /**
* The rows of an animation. Keys hold flat values only – a row animates one property – and are * The rows of an animation. Keys hold flat values only – a row animates one property – and are
* sorted onto whole frames; a second row on the same target and property, or with an id already * sorted onto whole frames; a second row on the same target and property, or with an id already
+12
View File
@@ -42,6 +42,18 @@ export function gripAt(points: readonly Vec2[], p: Vec2, tolerance: number): Pol
return best; return best;
} }
/** Whether `p` lies inside the outline (even-odd); fewer than three corners enclose nothing. */
export function insideOutline(points: readonly Vec2[], p: Vec2): boolean {
if (points.length < MIN_CORNERS) return false;
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const a = points[i]!;
const b = points[j]!;
if (a.y > p.y !== b.y > p.y && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x) inside = !inside;
}
return inside;
}
/** A corner put at `at` on the edge after corner `index`; it becomes corner `index + 1`. */ /** 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[] { export function withCornerAfter(points: readonly Vec2[], index: number, at: Vec2): Vec2[] {
return [...points.slice(0, index + 1), { ...at }, ...points.slice(index + 1)]; return [...points.slice(0, index + 1), { ...at }, ...points.slice(index + 1)];
+125 -53
View File
@@ -29,7 +29,15 @@ import {
setFrameSlice, setFrameSlice,
} from '../../core/model/texture'; } from '../../core/model/texture';
import { pressCount, 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 {
gripAt,
insideOutline,
MIN_CORNERS,
type PolygonGrip,
withCornerAfter,
withCornerMoved,
withoutCorner,
} from '../polygonEdit';
import { dashedLine } from '../../viewport/symbols'; import { dashedLine } from '../../viewport/symbols';
import { textureLibrary } from '../../viewport/TextureLibrary'; import { textureLibrary } from '../../viewport/TextureLibrary';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
@@ -79,13 +87,15 @@ interface FrameDrag {
} }
/** /**
* A corner of a frame's collision outline being dragged (#141) – also one just put down. The model * A corner of one of a frame's collision outlines being dragged (#141) – also one just put down. The
* holds the preview; `before` is put back on release so the command records one undo step. * model holds the preview; `before` is put back on release so the command records one undo step.
*/ */
interface CornerDrag { interface CornerDrag {
frameId: string; frameId: string;
before: Vec2[]; before: Vec2[][];
points: Vec2[]; outlines: Vec2[][];
/** Which outline, and which corner of it. */
outline: number;
index: number; index: number;
pointer: Vec2; pointer: Vec2;
added: boolean; added: boolean;
@@ -122,8 +132,10 @@ export class TextureCanvas extends Widget {
private dragging: FrameDrag | null = null; private dragging: FrameDrag | null = null;
private spanning: NewFrame | null = null; private spanning: NewFrame | null = null;
private corner: CornerDrag | null = null; private corner: CornerDrag | null = null;
/** Whether a press edits the selected frame's collision outline instead of the frames (#141). */ /** Whether a press edits the selected frame's collision outlines instead of the frames (#141). */
private collision = false; private collision = false;
/** The outline a click adds corners to; for another frame, or one gone, the frame's last one. */
private active = { frameId: '', index: -1 };
constructor(private readonly state: EditorState) { constructor(private readonly state: EditorState) {
super(); super();
@@ -202,6 +214,32 @@ export class TextureCanvas extends Widget {
return this.asset()?.frames.find((f) => f.id === frameId); return this.asset()?.frames.find((f) => f.id === frameId);
} }
private activeOutline(frame: TextureFrame, outlines: readonly Vec2[][]): number {
const { frameId, index } = this.active;
return frameId === frame.id && index < outlines.length ? index : outlines.length - 1;
}
/** Delete takes the active outline away whole, in collision mode – not the frame it belongs to. */
override onKeyDown(event: KeyboardEvent): boolean {
if (!this.collision || (event.key !== 'Delete' && event.key !== 'Backspace'))
return super.onKeyDown(event);
const frame = this.frame(this.selectedFrameId());
if (!frame) return super.onKeyDown(event);
const outlines = frameCollision(frame);
const index = this.activeOutline(frame, outlines);
// Nothing active is nothing to delete – and still not the frame, which Delete would take otherwise.
if (index < 0) return true;
this.state.execute(
new SetFrameCollisionCommand(
this.assetId,
frame.id,
outlines.filter((_, i) => i !== index),
),
);
this.active = { frameId: frame.id, index: -1 };
return true;
}
private onPointerDown(e: FederatedPointerEvent): void { private onPointerDown(e: FederatedPointerEvent): void {
const asset = this.asset(); const asset = this.asset();
// Without a known picture size `normalizeFrameRect` has no bounds, and would keep a frame // Without a known picture size `normalizeFrameRect` has no bounds, and would keep a frame
@@ -259,10 +297,12 @@ export class TextureCanvas extends Widget {
} }
/** /**
* A press in collision mode. On the selected frame: a double-click on a corner takes it away, a * A press in collision mode. On the selected frame, on any of its outlines: a double-click on a
* press on a corner drags it, on an edge puts a corner there, anywhere else inside the frame adds * corner takes it away, a press on a corner drags it, on an edge puts a corner there – and makes
* one after the last – how an outline is drawn from nothing. Another frame is only selected, and * that outline the active one. Anywhere else inside the frame adds a corner after the active
* no frame is moved or spanned while the mode is on. * outline's last, which is how an outline is drawn from nothing; with Ctrl (Cmd) held it starts a
* new outline, and a press inside another finished outline only makes it the active one. Another
* frame is only selected, and no frame is moved or spanned while the mode is on.
*/ */
private pressOutline( private pressOutline(
asset: TextureAsset, asset: TextureAsset,
@@ -274,12 +314,23 @@ export class TextureCanvas extends Widget {
const selected = this.frame(this.selectedFrameId()); const selected = this.frame(this.selectedFrameId());
if (selected) { if (selected) {
const at = { x: point.x - selected.x, y: point.y - selected.y }; const at = { x: point.x - selected.x, y: point.y - selected.y };
const points = frameCollision(selected); const outlines = frameCollision(selected);
const grip = gripAt(points, at, tolerance); const active = this.activeOutline(selected, outlines);
if (grip?.kind === 'corner' && pressCount(e, `outline ${selected.id} ${grip.index}`) === 2) { // The active outline first, so its corners stay reachable where two outlines meet.
this.state.execute( const order = outlines.map((_, i) => i).sort((a, b) => Number(b === active) - Number(a === active));
new SetFrameCollisionCommand(asset.id, selected.id, withoutCorner(points, grip.index, 0)!), let target = active;
); let grip: PolygonGrip | null = null;
for (const i of order) {
grip = gripAt(outlines[i]!, at, tolerance);
if (!grip) continue;
target = i;
break;
}
const activate = (index: number) => (this.active = { frameId: selected.id, index });
if (grip?.kind === 'corner' && pressCount(e, `outline ${selected.id} ${target} ${grip.index}`) === 2) {
const next = outlines.map((o, i) => (i === target ? withoutCorner(o, grip.index, 0)! : o));
activate(next[target]!.length > 0 ? target : -1);
this.state.execute(new SetFrameCollisionCommand(asset.id, selected.id, next));
return; return;
} }
// A corner often belongs on the frame's edge: a press just beside it still counts as in it. // A corner often belongs on the frame's edge: a press just beside it still counts as in it.
@@ -288,24 +339,40 @@ export class TextureCanvas extends Widget {
at.y >= -tolerance && at.y >= -tolerance &&
at.x <= selected.w + tolerance && at.x <= selected.w + tolerance &&
at.y <= selected.h + tolerance; at.y <= selected.h + tolerance;
const fresh = e.ctrlKey || e.metaKey || target < 0;
if (!grip && inside && !fresh && (outlines[active]?.length ?? 0) >= MIN_CORNERS) {
const other = outlines.findIndex((o, i) => i !== active && insideOutline(o, at));
if (other >= 0) {
activate(other);
this.redraw();
return;
}
}
if (grip || inside) { if (grip || inside) {
const corner = inFrame(selected, grip?.kind === 'edge' ? grip.at : at); const corner = inFrame(selected, grip?.kind === 'edge' ? grip.at : at);
const next = if (!grip && fresh) target = outlines.length;
const points = outlines[target] ?? [];
const changed =
grip?.kind === 'corner' grip?.kind === 'corner'
? points ? points
: grip?.kind === 'edge' : grip?.kind === 'edge'
? withCornerAfter(points, grip.index, corner) ? withCornerAfter(points, grip.index, corner)
: [...points, corner]; : [...points, corner];
const next = [...outlines];
next[target] = changed;
activate(target);
this.corner = { this.corner = {
frameId: selected.id, frameId: selected.id,
before: points, before: outlines,
points: next, outlines: next,
index: grip?.kind === 'corner' ? grip.index : grip ? grip.index + 1 : next.length - 1, outline: target,
index: grip?.kind === 'corner' ? grip.index : grip ? grip.index + 1 : changed.length - 1,
pointer: { x: local.x, y: local.y }, pointer: { x: local.x, y: local.y },
added: grip?.kind !== 'corner', added: grip?.kind !== 'corner',
moved: false, moved: false,
}; };
if (this.corner.added) this.previewOutline(); if (this.corner.added) this.previewOutline();
else this.redraw();
return; return;
} }
} }
@@ -322,7 +389,7 @@ export class TextureCanvas extends Widget {
const corner = this.corner; const corner = this.corner;
const frame = corner && this.frame(corner.frameId); const frame = corner && this.frame(corner.frameId);
if (!corner || !frame) return; if (!corner || !frame) return;
setFrameCollision(frame, corner.points); setFrameCollision(frame, corner.outlines);
this.state.preview(); this.state.preview();
} }
@@ -366,10 +433,14 @@ export class TextureCanvas extends Widget {
return; return;
corner.moved = true; corner.moved = true;
const point = localToImage(this.view, local.x, local.y); const point = localToImage(this.view, local.x, local.y);
corner.points = withCornerMoved( corner.outlines = corner.outlines.map((outline, i) =>
corner.points, i === corner.outline
corner.index, ? withCornerMoved(
inFrame(frame, { x: point.x - frame.x, y: point.y - frame.y }), outline,
corner.index,
inFrame(frame, { x: point.x - frame.x, y: point.y - frame.y }),
)
: outline,
); );
this.previewOutline(); this.previewOutline();
return; return;
@@ -420,7 +491,7 @@ export class TextureCanvas extends Widget {
if (corner && frame) { if (corner && frame) {
setFrameCollision(frame, corner.before); setFrameCollision(frame, corner.before);
if (corner.added || corner.moved) if (corner.added || corner.moved)
this.state.execute(new SetFrameCollisionCommand(this.assetId, corner.frameId, corner.points)); this.state.execute(new SetFrameCollisionCommand(this.assetId, corner.frameId, corner.outlines));
else this.state.preview(); else this.state.preview();
return; return;
} }
@@ -557,7 +628,7 @@ export class TextureCanvas extends Widget {
alive.add(frame.id); alive.add(frame.id);
this.drawFrame(frame, frame.id === selectedId); this.drawFrame(frame, frame.id === selectedId);
} }
for (const frame of asset.frames) this.drawOutline(frame, this.collision && frame.id === selectedId); for (const frame of asset.frames) this.drawOutlines(frame, this.collision && frame.id === selectedId);
const selected = this.frame(selectedId); const selected = this.frame(selectedId);
if (selected && !this.collision) { if (selected && !this.collision) {
this.drawBorders(selected); this.drawBorders(selected);
@@ -604,31 +675,32 @@ export class TextureCanvas extends Widget {
} }
/** /**
* A frame's collision outline (#141), always shown so one sees which frames block with one; the * A frame's collision outlines (#141), always shown so one sees which frames block with them; on
* edited one gets a handle on every corner. An outline that is no polygon is drawn in red. * the edited frame the active outline is drawn heavier and every corner of every outline gets a
* handle. An outline that is no polygon is drawn in red.
*/ */
private drawOutline(frame: TextureFrame, edited: boolean): void { private drawOutlines(frame: TextureFrame, edited: boolean): void {
const points = frameCollision(frame); const outlines = frameCollision(frame);
if (points.length === 0) return; const active = edited ? this.activeOutline(frame, outlines) : -1;
const screen = points.map((p) => imageToLocal(this.view, frame.x + p.x, frame.y + p.y)); for (const [index, points] of outlines.entries()) {
const color = convexPieces(points).length > 0 ? Theme.colors.collider : Theme.colors.danger; const screen = points.map((p) => imageToLocal(this.view, frame.x + p.x, frame.y + p.y));
if (screen.length >= 3) const color = convexPieces(points).length > 0 ? Theme.colors.collider : Theme.colors.danger;
this.overlay const width = index === active ? 2 : 1;
.poly(screen, true) if (screen.length >= 3)
.fill({ color, alpha: 0.15 }) this.overlay.poly(screen, true).fill({ color, alpha: 0.15 }).stroke({ width, color });
.stroke({ width: edited ? 2 : 1, color }); else if (screen.length === 2)
else if (screen.length === 2) this.overlay
this.overlay .moveTo(screen[0]!.x, screen[0]!.y)
.moveTo(screen[0]!.x, screen[0]!.y) .lineTo(screen[1]!.x, screen[1]!.y)
.lineTo(screen[1]!.x, screen[1]!.y) .stroke({ width: 2, color });
.stroke({ width: 2, color }); if (!edited) continue;
if (!edited) return; const half = HANDLE_SIZE / 2;
const half = HANDLE_SIZE / 2; for (const c of screen)
for (const c of screen) this.overlay
this.overlay .rect(c.x - half, c.y - half, HANDLE_SIZE, HANDLE_SIZE)
.rect(c.x - half, c.y - half, HANDLE_SIZE, HANDLE_SIZE) .fill(index === active ? Theme.colors.accentText : Theme.colors.viewport)
.fill(Theme.colors.accentText) .stroke({ width: 1, color });
.stroke({ width: 1, color }); }
} }
private drawHandles(frame: TextureFrame): void { private drawHandles(frame: TextureFrame): void {
@@ -673,8 +745,8 @@ export class TextureCanvas extends Widget {
? 'No texture selected – pick one in the Assets panel or import an image.' ? 'No texture selected – pick one in the Assets panel or import an image.'
: this.collision && asset.file && hasPicture : this.collision && asset.file && hasPicture
? this.selectedFrameId() ? 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: click adds a corner, Ctrl+click starts a new outline, Delete removes one.'
: 'Collision: select a frame to draw the outline its tiles block with.' : 'Collision: select a frame to draw the outlines its tiles block with.'
: !asset.file : !asset.file
? 'This texture has no picture yet.' ? 'This texture has no picture yet.'
: !hasPicture : !hasPicture
+152 -20
View File
@@ -9,8 +9,9 @@ import type { TextureAsset, TilemapAsset, TileAsset } from '../../src/core/model
import type { PropertyValue, Vec2 } from '../../src/core/model/Properties'; import type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
import { findAsset, type Project } from '../../src/core/model/Project'; 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 { TILE_COLLISION_SCALE_KEY, TILE_KIND_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile';
import { insideOutline } from '../../src/editor/polygonEdit';
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../../src/core/model/tilemap'; 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 { solidCells, solidPolygons, solidRects, tileShape } from '../../src/core/model/tilemapCollision';
import { contactBetween, shapesOverlap } from '../../src/core/properties/collider'; import { contactBetween, shapesOverlap } from '../../src/core/properties/collider';
import type { Entity } from '../../src/runtime/Entity'; import type { Entity } from '../../src/runtime/Entity';
import { loadPhysics } from '../../src/runtime/physics/engine'; import { loadPhysics } from '../../src/runtime/physics/engine';
@@ -174,16 +175,22 @@ function outlinedMap() {
const texture = new CreateAssetCommand('texture', 'Sheet'); const texture = new CreateAssetCommand('texture', 'Sheet');
const tile = new CreateAssetCommand('tile', 'Slope'); const tile = new CreateAssetCommand('tile', 'Slope');
const plain = new CreateAssetCommand('tile', 'Wall'); const plain = new CreateAssetCommand('tile', 'Wall');
const lake = new CreateAssetCommand('tile', 'Lake');
const map = new CreateAssetCommand('tilemap', 'Ground'); const map = new CreateAssetCommand('tilemap', 'Ground');
for (const command of [texture, tile, plain, map]) f.stack.execute(command); for (const command of [texture, tile, plain, lake, map]) f.stack.execute(command);
const sheet = findAsset(f.project, texture.asset.id) as TextureAsset; const sheet = findAsset(f.project, texture.asset.id) as TextureAsset;
sheet.file = `${'a'.repeat(64)}.png`; sheet.file = `${'a'.repeat(64)}.png`;
sheet.width = 64; sheet.width = 64;
sheet.height = 64; sheet.height = 128;
const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 32, h: 32 }); const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 32, h: 32 });
f.stack.execute(frame); f.stack.execute(frame);
f.stack.execute(new AddTilePictureCommand(tile.asset.id, frame.frameId)); f.stack.execute(new AddTilePictureCommand(tile.asset.id, frame.frameId));
for (const id of [tile.asset.id, plain.asset.id]) // A floor block of 32 px tiles below it: 2 × 3 tiles, quarters of 16 px (#141).
const block = new AddFrameCommand(texture.asset.id, { x: 0, y: 32, w: 64, h: 96 });
f.stack.execute(block);
f.stack.execute(new AddTilePictureCommand(lake.asset.id, block.frameId));
f.stack.execute(new SetAssetPropertyCommand(lake.asset.id, TILE_KIND_KEY, 'floor'));
for (const id of [tile.asset.id, plain.asset.id, lake.asset.id])
f.stack.execute(new SetAssetPropertyCommand(id, TILE_SOLID_KEY, true)); 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_COLUMNS_KEY, 12));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 4)); f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 4));
@@ -198,8 +205,10 @@ function outlinedMap() {
cells.map(([x, y]) => ({ x, y, tileId })), cells.map(([x, y]) => ({ x, y, tileId })),
), ),
); );
const outline = (points: Vec2[]) => const outline = (...outlines: Vec2[][]) =>
f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, points)); f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, outlines));
const blockOutline = (...outlines: Vec2[][]) =>
f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, block.frameId, outlines));
const tileAsset = () => findAsset(f.project, tile.asset.id) as TileAsset; const tileAsset = () => findAsset(f.project, tile.asset.id) as TileAsset;
return { return {
...f, ...f,
@@ -208,8 +217,10 @@ function outlinedMap() {
mapAsset, mapAsset,
paint, paint,
outline, outline,
blockOutline,
tileAsset, tileAsset,
plainId: plain.asset.id, plainId: plain.asset.id,
lakeId: lake.asset.id,
textureId: texture.asset.id, textureId: texture.asset.id,
frameId: frame.frameId, frameId: frame.frameId,
}; };
@@ -224,32 +235,69 @@ describe('collision outlines of tiles (#141)', () => {
m.outline(WEDGE); m.outline(WEDGE);
const frameOf = (project: Project) => const frameOf = (project: Project) =>
(findAsset(project, m.textureId) as TextureAsset).frames.find((f) => f.id === m.frameId)!; (findAsset(project, m.textureId) as TextureAsset).frames.find((f) => f.id === m.frameId)!;
expect(frameOf(roundTrip(m.project)).collision).toEqual(WEDGE); expect(frameOf(roundTrip(m.project)).collision).toEqual([WEDGE]);
frameOf(m.project).collision = [ frameOf(m.project).collision = [
{ x: 1, y: 1 }, [
{ x: Number.NaN, y: 0 }, { x: 1, y: 1 },
{ x: 3, y: 3 }, { x: Number.NaN, y: 0 },
] as Vec2[]; { x: 3, y: 3 },
],
];
expect(frameOf(roundTrip(m.project))).not.toHaveProperty('collision'); expect(frameOf(roundTrip(m.project))).not.toHaveProperty('collision');
m.stack.undo(); m.stack.undo();
expect(frameOf(m.project)).not.toHaveProperty('collision'); expect(frameOf(m.project)).not.toHaveProperty('collision');
m.stack.redo(); m.stack.redo();
expect(frameOf(m.project).collision).toEqual(WEDGE); 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', () => { it('a frame holds several outlines; one saved before that, as a plain corner list, loads as one', () => {
const m = outlinedMap(); const m = outlinedMap();
expect(tileOutline(m.project, m.tileAsset())).toBeNull(); const SQUARE_AT_0 = P(0, 0, 8, 0, 8, 8, 0, 8);
m.outline(WEDGE, SQUARE_AT_0);
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, SQUARE_AT_0]);
// Of two outlines, the one with too few corners falls away on load and the other stays.
frameOf(m.project).collision = [P(1, 1, 2, 2), WEDGE];
expect(frameOf(roundTrip(m.project)).collision).toEqual([WEDGE]);
// An outline emptied by the editor is dropped, and the frame without any keeps saying so.
m.outline([], []);
expect(frameOf(m.project)).not.toHaveProperty('collision');
(frameOf(m.project) as { collision: unknown }).collision = WEDGE;
expect(frameOf(roundTrip(m.project)).collision).toEqual([WEDGE]);
});
it('a plain tile blocks with its first picture’s outlines; an outline that is none, or a block of the wrong size, as a box', () => {
const m = outlinedMap();
expect(tileShape(m.project, m.tileAsset())).toBeNull();
m.outline(WEDGE); m.outline(WEDGE);
expect(tileOutline(m.project, m.tileAsset())).toEqual({ expect(tileShape(m.project, m.tileAsset())).toEqual({
outline: P(0, 1, 1, 0, 1, 1), kind: 'plain',
pieces: [P(0, 1, 1, 0, 1, 1)], outlines: [{ outline: P(0, 1, 1, 0, 1, 1), pieces: [P(0, 1, 1, 0, 1, 1)] }],
});
const BOW_TIE = P(0, 0, 32, 32, 32, 0, 0, 32);
m.outline(BOW_TIE);
expect(tileShape(m.project, m.tileAsset())).toBeNull();
m.outline(BOW_TIE, WEDGE); // the bow tie collides as none, the wedge still blocks
expect(tileShape(m.project, m.tileAsset())).toMatchObject({
kind: 'plain',
outlines: [{ outline: 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.outline(WEDGE);
// A floor needs a block of 2 × 3 tiles; the 32 px frame is none, so it blocks as a box.
m.stack.execute(new SetAssetPropertyCommand(m.tileAsset().id, TILE_KIND_KEY, 'floor')); m.stack.execute(new SetAssetPropertyCommand(m.tileAsset().id, TILE_KIND_KEY, 'floor'));
expect(tileOutline(m.project, m.tileAsset())).toBeNull(); expect(tileShape(m.project, m.tileAsset())).toBeNull();
});
it('a plain tile with two outlines blocks with both in every cell', () => {
const m = outlinedMap();
m.outline(P(0, 0, 16, 0, 16, 16, 0, 16), P(16, 16, 32, 16, 32, 32, 16, 32));
m.paint([[2, 3]]);
const props = { [TILEMAP_COLUMNS_KEY]: 12, [TILEMAP_ROWS_KEY]: 4 };
expect(solidPolygons(m.project, props, m.mapAsset()).map((p) => p.outline)).toEqual([
P(2, 3, 2.5, 3, 2.5, 3.5, 2, 3.5),
P(2.5, 3.5, 3, 3.5, 3, 4, 2.5, 4),
]);
}); });
it('outlined cells are polygons, not rectangles; a plain solid tile on the same cell covers it whole', () => { it('outlined cells are polygons, not rectangles; a plain solid tile on the same cell covers it whole', () => {
@@ -347,6 +395,90 @@ describe('collision outlines of tiles (#141)', () => {
}); });
}); });
/**
* The floor block's bordered 2 × 2 tile (frame y 32 … 96) outlined 8 px in from its edges – half a
* quarter. A cell of it blocks from a quarter of a cell in wherever it has no neighbour.
*/
const POND = P(8, 40, 56, 40, 56, 88, 8, 88);
describe('collision outlines of autotiles (#141)', () => {
const props = { [TILEMAP_COLUMNS_KEY]: 12, [TILEMAP_ROWS_KEY]: 4 };
/** Whether a point of the map, in cells, lies inside anything the map blocks with. */
const blocksAt = (m: ReturnType<typeof outlinedMap>, p: Vec2) =>
solidRects(m.project, props, m.mapAsset()).some(
(r) => p.x > r.x && p.x < r.x + r.w && p.y > r.y && p.y < r.y + r.h,
) ||
solidPolygons(m.project, props, m.mapAsset()).some((polygon) =>
polygon.pieces.some((piece) => insideOutline(piece, p)),
);
const cells = (x0: number, y0: number, x1: number, y1: number, skip = '') =>
Array.from(
{ length: (x1 - x0 + 1) * (y1 - y0 + 1) },
(_, i) => [x0 + (i % (x1 - x0 + 1)), y0 + Math.floor(i / (x1 - x0 + 1))] as [number, number],
).filter(([x, y]) => `${x},${y}` !== skip);
it('a lake blocks with the block’s outline pieced together like its picture: in by a quarter cell all round', () => {
const m = outlinedMap();
m.blockOutline(POND);
m.paint(cells(2, 0, 4, 2), m.lakeId);
const wrong: Vec2[] = [];
for (let x = 1.037; x < 6; x += 0.1)
for (let y = 0.037; y < 4; y += 0.1) {
const expected = x > 2.25 && x < 4.75 && y > 0.25 && y < 2.75;
if (blocksAt(m, { x, y }) !== expected) wrong.push({ x, y });
}
expect(wrong).toEqual([]);
// The middle cell is drawn from inside quarters only, which block whole: a box, not polygons.
expect(solidCells(m.project, props, m.mapAsset())).toEqual([{ x: 3, y: 1, scale: 1 }]);
expect(solidPolygons(m.project, props, m.mapAsset()).some((p) => p.x === 3 && p.y === 1)).toBe(false);
});
it('a notch tile with nothing drawn in it blocks whole; once anything is drawn there, only that', () => {
const m = outlinedMap();
m.blockOutline(POND);
m.paint(cells(2, 0, 4, 2, '3,1'), m.lakeId); // a ring round an empty cell: four inner corners
// Cell (2, 0)'s bottom right is a notch – the block's top right tile, which holds no outline.
expect(blocksAt(m, { x: 2.9, y: 0.9 })).toBe(true);
expect(blocksAt(m, { x: 3.5, y: 1.5 })).toBe(false);
// A small triangle in the notch tile's top-left quarter: the inner corner of cell (4, 2).
m.blockOutline(POND, P(32, 0, 40, 0, 32, 8));
expect(blocksAt(m, { x: 2.9, y: 0.9 })).toBe(false);
expect(blocksAt(m, { x: 4.05, y: 2.05 })).toBe(true);
expect(blocksAt(m, { x: 4.2, y: 2.2 })).toBe(false);
});
it('an autotile block without an outline still blocks as boxes, with its collision scale', () => {
const m = outlinedMap();
m.stack.execute(new SetAssetPropertyCommand(m.lakeId, TILE_COLLISION_SCALE_KEY, 0.5));
m.paint([[2, 3]], m.lakeId);
expect(solidCells(m.project, props, m.mapAsset())).toEqual([{ x: 2, y: 3, scale: 0.5 }]);
expect(solidPolygons(m.project, props, m.mapAsset())).toEqual([]);
});
describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => {
it('a crate slides along the shore of a row of lake cells without catching on a quarter seam', () => {
const m = outlinedMap();
m.blockOutline(POND);
m.paint(cells(0, 3, 11, 3), m.lakeId); // shore at y = 3.25 cells, 40 in the world
const crate = m.add({ x: -170, 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(40 - 15, 9);
expect(step.blockedX).toBe(false);
}
expect(mover.x).toBeGreaterThan(-170 + 5 * 32);
});
});
});
describe('outlined tiles in the engine (#141)', () => { describe('outlined tiles in the engine (#141)', () => {
it('a crate falls onto a wedge tile and slides down it to the floor', () => { it('a crate falls onto a wedge tile and slides down it to the floor', () => {
const m = outlinedMap(); const m = outlinedMap();
+37 -5
View File
@@ -53,7 +53,7 @@ const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
export async function shapes(t) { export async function shapes(t) {
const { check, json, evaluate, mutate, select, click, doubleClick, drag, clickText, undo } = t; const { check, json, evaluate, mutate, select, click, doubleClick, drag, clickText, undo } = t;
const { object, settle, sleep, play, stop, shot, showTab } = t; const { object, settle, sleep, play, stop, shot, showTab, press } = t;
const Y = LANES.shapes; const Y = LANES.shapes;
const put = ` const put = `
const put = (id, assetId, parentId, x, y, name, overrides = {}) => { const put = (id, assetId, parentId, x, y, name, overrides = {}) => {
@@ -227,9 +227,11 @@ export async function shapes(t) {
(await json('__gm.texture.collisionMode()')) === true && (await json('__gm.texture.collisionMode()')) === true &&
JSON.stringify(drawn) === JSON.stringify(drawn) ===
JSON.stringify([ JSON.stringify([
{ x: 0, y: 32 }, [
{ x: 32, y: 0 }, { x: 0, y: 32 },
{ x: 32, y: 32 }, { x: 32, y: 0 },
{ x: 32, y: 32 },
],
]), ]),
JSON.stringify(drawn), JSON.stringify(drawn),
); );
@@ -241,9 +243,39 @@ export async function shapes(t) {
await undo(); await undo();
check( check(
'a corner of the outline drags, and one undo puts it back', '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, moved?.[0]?.[1]?.x === 16 && moved[0][1].y === 0 && (await outline())?.[0]?.[1]?.x === 32,
JSON.stringify({ moved, after: await outline() }), JSON.stringify({ moved, after: await outline() }),
); );
// A frame holds several outlines: Ctrl+click starts a second one, and Delete takes the active
// outline away whole – not the frame, which Delete removes outside the mode.
for (const [i, [x, y]] of [
[2, 2],
[10, 2],
[2, 10],
].entries()) {
const at = await json(`__gm.texture.pointAt(${x}, ${y})`);
await click(at.x, at.y, 'left', i === 0 ? { ctrl: true } : {});
}
const two = await outline();
await shot('42-texture-two-outlines');
await press('Delete');
const left = await outline();
check(
'Ctrl+click starts a second outline in the frame, and Delete removes only that one',
two?.length === 2 &&
JSON.stringify(two[1]) ===
JSON.stringify([
{ x: 2, y: 2 },
{ x: 10, y: 2 },
{ x: 2, y: 10 },
]) &&
left?.length === 1 &&
left[0].length === 3 &&
(await json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture?.id)}).frames.some((f) => f.id === 'sh-frame')`,
)) === true,
JSON.stringify({ two, left }),
);
await clickText('Collision'); await clickText('Collision');
await showTab('scene'); await showTab('scene');
await settle(); await settle();