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)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
32057a96fc
commit
947a6b1977
11 files changed
+545
-139
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, 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-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
|
||||
|
||||
@@ -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
|
||||
* 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`.
|
||||
* The collision outlines of one frame (#141), all of them: adding, moving and removing a corner,
|
||||
* starting or removing an outline are each one edit, and the outlines to revert to are 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[] = [];
|
||||
private previous: Vec2[][] = [];
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly frameId: string,
|
||||
private readonly points: readonly Vec2[],
|
||||
private readonly outlines: readonly (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)))
|
||||
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');
|
||||
this.previous = frameCollision(frame);
|
||||
setFrameCollision(frame, this.points);
|
||||
setFrameCollision(frame, this.outlines);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
|
||||
@@ -95,10 +95,11 @@ export interface TextureFrame {
|
||||
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.
|
||||
* The outlines a tile made of this frame blocks with (#141), in pixels from the frame's top-left
|
||||
* 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[][];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -175,6 +175,45 @@ export function convexPieces(points: readonly Vec2[]): Vec2[][] {
|
||||
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. */
|
||||
function joinAlongSharedEdge(a: number[], b: number[]): number[] | null {
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
|
||||
@@ -112,15 +112,19 @@ 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 }));
|
||||
/** The frame's collision outlines (#141), a copy; empty when it has none. */
|
||||
export function frameCollision(frame: TextureFrame): Vec2[][] {
|
||||
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 {
|
||||
if (points.length === 0) delete frame.collision;
|
||||
else frame.collision = points.map((p) => ({ x: p.x, y: p.y }));
|
||||
/**
|
||||
* Sets the outlines. One without corners is dropped, and none left removes the key, so a frame
|
||||
* without an outline keeps saying so; one being drawn, with a corner or two, is kept.
|
||||
*/
|
||||
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 {
|
||||
|
||||
@@ -12,17 +12,21 @@
|
||||
* 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`.
|
||||
* A tile whose picture is a frame with collision outlines (#141) blocks with those instead, as
|
||||
* 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 { tileQuadrants } from './autotile';
|
||||
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 { findAssetOf, type Project } from './Project';
|
||||
import { findFrame } from './texture';
|
||||
import { isTileSolid, tileCollisionScale, tileKind } from './tile';
|
||||
import { cellOrigin, inGrid, parseCellKey, tilemapTileSize } from './tilemap';
|
||||
import { isTileSolid, readTile, tileCollisionScale, tileKind, type TileKind } from './tile';
|
||||
import { cellOrigin, inGrid, neighboursOf, parseCellKey, tilemapTileSize } from './tilemap';
|
||||
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. */
|
||||
@@ -48,45 +52,130 @@ export interface CellPolygon {
|
||||
pieces: Vec2[][];
|
||||
}
|
||||
|
||||
/** A tile's outline across one cell, 0 to 1 on each axis, and its convex pieces. */
|
||||
interface TileOutline {
|
||||
/** An outline and its convex pieces: 0 to 1 across a cell for a plain tile, across a quarter for an autotile. */
|
||||
export interface TileOutline {
|
||||
outline: 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
|
||||
* 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.
|
||||
* How a solid tile with outlines blocks: a plain tile with its outlines across the cell, an autotile
|
||||
* with a block of every quarter of its block, by `x,y` in quarters.
|
||||
*/
|
||||
export function tileOutline(project: Project, tile: TileAsset): TileOutline | null {
|
||||
if (tileKind(tile) !== 'none') return null;
|
||||
export type TileShape =
|
||||
| { 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 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;
|
||||
const kind = tileKind(tile);
|
||||
if (kind === 'none') {
|
||||
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 {
|
||||
x: number;
|
||||
y: number;
|
||||
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[] {
|
||||
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) => {
|
||||
if (tiles.has(id)) return tiles.get(id)!;
|
||||
const tile = findAssetOf(project, id, 'tile');
|
||||
const value =
|
||||
tile && isTileSolid(tile)
|
||||
? { scale: tileCollisionScale(tile), outline: tileOutline(project, tile) }
|
||||
: null;
|
||||
tile && isTileSolid(tile) ? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) } : null;
|
||||
tiles.set(id, value);
|
||||
return value;
|
||||
};
|
||||
@@ -99,10 +188,32 @@ function blockingCells(project: Project, props: PropertyBag, asset: TilemapAsset
|
||||
if (!cell) {
|
||||
const at = parseCellKey(key);
|
||||
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()];
|
||||
@@ -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
|
||||
* 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>[] {
|
||||
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
|
||||
* 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.
|
||||
* The polygons of the cells that block with outlines: for a plain tile one per outline, for an
|
||||
* autotile one per part of an outline in each of its quarters, and a square for a quarter that
|
||||
* 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[] {
|
||||
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)),
|
||||
});
|
||||
}
|
||||
for (const { outline, pieces } of cell.polygons) polygons.push({ x: cell.x, y: cell.y, outline, pieces });
|
||||
}
|
||||
return polygons.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
tagOf,
|
||||
type Vec2,
|
||||
} from '../model/Properties';
|
||||
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||||
import { readScope } from '../model/Scope';
|
||||
@@ -482,13 +483,23 @@ 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);
|
||||
const outlines = collisionOutlines(f.collision).filter((outline) => outline.length >= 3);
|
||||
if (outlines.length > 0) setFrameCollision(frame, outlines);
|
||||
frames.push(frame);
|
||||
}
|
||||
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
|
||||
* sorted onto whole frames; a second row on the same target and property, or with an id already
|
||||
|
||||
@@ -42,6 +42,18 @@ export function gripAt(points: readonly Vec2[], p: Vec2, tolerance: number): Pol
|
||||
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`. */
|
||||
export function withCornerAfter(points: readonly Vec2[], index: number, at: Vec2): Vec2[] {
|
||||
return [...points.slice(0, index + 1), { ...at }, ...points.slice(index + 1)];
|
||||
|
||||
@@ -29,7 +29,15 @@ import {
|
||||
setFrameSlice,
|
||||
} from '../../core/model/texture';
|
||||
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 { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
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
|
||||
* holds the preview; `before` is put back on release so the command records one undo step.
|
||||
* A corner of one of a frame's collision outlines 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[];
|
||||
before: Vec2[][];
|
||||
outlines: Vec2[][];
|
||||
/** Which outline, and which corner of it. */
|
||||
outline: number;
|
||||
index: number;
|
||||
pointer: Vec2;
|
||||
added: boolean;
|
||||
@@ -122,8 +132,10 @@ export class TextureCanvas extends Widget {
|
||||
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). */
|
||||
/** Whether a press edits the selected frame's collision outlines instead of the frames (#141). */
|
||||
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) {
|
||||
super();
|
||||
@@ -202,6 +214,32 @@ export class TextureCanvas extends Widget {
|
||||
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 {
|
||||
const asset = this.asset();
|
||||
// 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
|
||||
* 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.
|
||||
* A press in collision mode. On the selected frame, on any of its outlines: a double-click on a
|
||||
* corner takes it away, a press on a corner drags it, on an edge puts a corner there – and makes
|
||||
* that outline the active one. Anywhere else inside the frame adds a corner after the active
|
||||
* 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(
|
||||
asset: TextureAsset,
|
||||
@@ -274,12 +314,23 @@ export class TextureCanvas extends Widget {
|
||||
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)!),
|
||||
);
|
||||
const outlines = frameCollision(selected);
|
||||
const active = this.activeOutline(selected, outlines);
|
||||
// The active outline first, so its corners stay reachable where two outlines meet.
|
||||
const order = outlines.map((_, i) => i).sort((a, b) => Number(b === active) - Number(a === active));
|
||||
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;
|
||||
}
|
||||
// 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.x <= selected.w + 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) {
|
||||
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'
|
||||
? points
|
||||
: grip?.kind === 'edge'
|
||||
? withCornerAfter(points, grip.index, corner)
|
||||
: [...points, corner];
|
||||
const next = [...outlines];
|
||||
next[target] = changed;
|
||||
activate(target);
|
||||
this.corner = {
|
||||
frameId: selected.id,
|
||||
before: points,
|
||||
points: next,
|
||||
index: grip?.kind === 'corner' ? grip.index : grip ? grip.index + 1 : next.length - 1,
|
||||
before: outlines,
|
||||
outlines: next,
|
||||
outline: target,
|
||||
index: grip?.kind === 'corner' ? grip.index : grip ? grip.index + 1 : changed.length - 1,
|
||||
pointer: { x: local.x, y: local.y },
|
||||
added: grip?.kind !== 'corner',
|
||||
moved: false,
|
||||
};
|
||||
if (this.corner.added) this.previewOutline();
|
||||
else this.redraw();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -322,7 +389,7 @@ export class TextureCanvas extends Widget {
|
||||
const corner = this.corner;
|
||||
const frame = corner && this.frame(corner.frameId);
|
||||
if (!corner || !frame) return;
|
||||
setFrameCollision(frame, corner.points);
|
||||
setFrameCollision(frame, corner.outlines);
|
||||
this.state.preview();
|
||||
}
|
||||
|
||||
@@ -366,10 +433,14 @@ export class TextureCanvas extends Widget {
|
||||
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 }),
|
||||
corner.outlines = corner.outlines.map((outline, i) =>
|
||||
i === corner.outline
|
||||
? withCornerMoved(
|
||||
outline,
|
||||
corner.index,
|
||||
inFrame(frame, { x: point.x - frame.x, y: point.y - frame.y }),
|
||||
)
|
||||
: outline,
|
||||
);
|
||||
this.previewOutline();
|
||||
return;
|
||||
@@ -420,7 +491,7 @@ export class TextureCanvas extends Widget {
|
||||
if (corner && frame) {
|
||||
setFrameCollision(frame, corner.before);
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@@ -557,7 +628,7 @@ 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);
|
||||
for (const frame of asset.frames) this.drawOutlines(frame, this.collision && frame.id === selectedId);
|
||||
const selected = this.frame(selectedId);
|
||||
if (selected && !this.collision) {
|
||||
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
|
||||
* edited one gets a handle on every corner. An outline that is no polygon is drawn in red.
|
||||
* A frame's collision outlines (#141), always shown so one sees which frames block with them; on
|
||||
* 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 {
|
||||
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 drawOutlines(frame: TextureFrame, edited: boolean): void {
|
||||
const outlines = frameCollision(frame);
|
||||
const active = edited ? this.activeOutline(frame, outlines) : -1;
|
||||
for (const [index, points] of outlines.entries()) {
|
||||
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;
|
||||
const width = index === active ? 2 : 1;
|
||||
if (screen.length >= 3)
|
||||
this.overlay.poly(screen, true).fill({ color, alpha: 0.15 }).stroke({ width, 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) continue;
|
||||
const half = HANDLE_SIZE / 2;
|
||||
for (const c of screen)
|
||||
this.overlay
|
||||
.rect(c.x - half, c.y - half, HANDLE_SIZE, HANDLE_SIZE)
|
||||
.fill(index === active ? Theme.colors.accentText : Theme.colors.viewport)
|
||||
.stroke({ width: 1, color });
|
||||
}
|
||||
}
|
||||
|
||||
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.'
|
||||
: 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.'
|
||||
? 'Collision: click adds a corner, Ctrl+click starts a new outline, Delete removes one.'
|
||||
: 'Collision: select a frame to draw the outlines its tiles block with.'
|
||||
: !asset.file
|
||||
? 'This texture has no picture yet.'
|
||||
: !hasPicture
|
||||
|
||||
+152
-20
@@ -9,8 +9,9 @@ import type { TextureAsset, TilemapAsset, TileAsset } from '../../src/core/model
|
||||
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 { insideOutline } from '../../src/editor/polygonEdit';
|
||||
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 type { Entity } from '../../src/runtime/Entity';
|
||||
import { loadPhysics } from '../../src/runtime/physics/engine';
|
||||
@@ -174,16 +175,22 @@ function outlinedMap() {
|
||||
const texture = new CreateAssetCommand('texture', 'Sheet');
|
||||
const tile = new CreateAssetCommand('tile', 'Slope');
|
||||
const plain = new CreateAssetCommand('tile', 'Wall');
|
||||
const lake = new CreateAssetCommand('tile', 'Lake');
|
||||
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;
|
||||
sheet.file = `${'a'.repeat(64)}.png`;
|
||||
sheet.width = 64;
|
||||
sheet.height = 64;
|
||||
sheet.height = 128;
|
||||
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])
|
||||
// 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(map.asset.id, TILEMAP_COLUMNS_KEY, 12));
|
||||
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 })),
|
||||
),
|
||||
);
|
||||
const outline = (points: Vec2[]) =>
|
||||
f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, points));
|
||||
const outline = (...outlines: Vec2[][]) =>
|
||||
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;
|
||||
return {
|
||||
...f,
|
||||
@@ -208,8 +217,10 @@ function outlinedMap() {
|
||||
mapAsset,
|
||||
paint,
|
||||
outline,
|
||||
blockOutline,
|
||||
tileAsset,
|
||||
plainId: plain.asset.id,
|
||||
lakeId: lake.asset.id,
|
||||
textureId: texture.asset.id,
|
||||
frameId: frame.frameId,
|
||||
};
|
||||
@@ -224,32 +235,69 @@ describe('collision outlines of tiles (#141)', () => {
|
||||
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);
|
||||
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[];
|
||||
[
|
||||
{ x: 1, y: 1 },
|
||||
{ x: Number.NaN, y: 0 },
|
||||
{ x: 3, y: 3 },
|
||||
],
|
||||
];
|
||||
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);
|
||||
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();
|
||||
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);
|
||||
expect(tileOutline(m.project, m.tileAsset())).toEqual({
|
||||
outline: P(0, 1, 1, 0, 1, 1),
|
||||
pieces: [P(0, 1, 1, 0, 1, 1)],
|
||||
expect(tileShape(m.project, m.tileAsset())).toEqual({
|
||||
kind: 'plain',
|
||||
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);
|
||||
// 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'));
|
||||
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', () => {
|
||||
@@ -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)', () => {
|
||||
it('a crate falls onto a wedge tile and slides down it to the floor', () => {
|
||||
const m = outlinedMap();
|
||||
|
||||
+37
-5
@@ -53,7 +53,7 @@ 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 { object, settle, sleep, play, stop, shot, showTab, press } = t;
|
||||
const Y = LANES.shapes;
|
||||
const put = `
|
||||
const put = (id, assetId, parentId, x, y, name, overrides = {}) => {
|
||||
@@ -227,9 +227,11 @@ export async function shapes(t) {
|
||||
(await json('__gm.texture.collisionMode()')) === true &&
|
||||
JSON.stringify(drawn) ===
|
||||
JSON.stringify([
|
||||
{ x: 0, y: 32 },
|
||||
{ x: 32, y: 0 },
|
||||
{ x: 32, y: 32 },
|
||||
[
|
||||
{ x: 0, y: 32 },
|
||||
{ x: 32, y: 0 },
|
||||
{ x: 32, y: 32 },
|
||||
],
|
||||
]),
|
||||
JSON.stringify(drawn),
|
||||
);
|
||||
@@ -241,9 +243,39 @@ export async function shapes(t) {
|
||||
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,
|
||||
moved?.[0]?.[1]?.x === 16 && moved[0][1].y === 0 && (await outline())?.[0]?.[1]?.x === 32,
|
||||
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 showTab('scene');
|
||||
await settle();
|
||||
|
||||
Reference in new issue
Block a user