A frame's collision outline can be generated from its pixels (#161)
game-maker / gate (push) Successful in 2m13s
game-maker / suite (push) Successful in 7m42s

Drawing an outline by hand for every frame of a sheet is slow, and the
outline mostly follows the opaque part of the image anyway. The texture
tab now traces it: one click, a corner budget, and every Frame
collision collider and tile using that frame blocks with the result.

- "Generate collision..." sits in the texture tab header and is off
  while the texture has no frames.
- The dialog has Max points (default 8, 3 to 64), Solid from ... %
  alpha (default 50) and All frames of the texture. A live line says
  what Generate will write, and Generate stays off when nothing is
  solid enough.
- traceCollision walks each island of solid pixels along the pixel
  edges, then drops the least important corners until at most N are
  left and no stair step remains. A slope becomes one straight edge.
- An outline never crosses itself. Where the image would force that,
  the island's convex hull is taken instead.
- Crumbs under 1 % of the frame area are dropped unless they are the
  only island. Holes are not reproduced.
- Existing outlines are replaced. One frame or all frames is a single
  undo step.
- TextureLibrary hands out the sheet's RGBA data, and a pure function
  cuts a frame's region from it.

Findings:
- The wedge in the e2e fixture gets 4 corners, not 3. Its tip is one
  pixel wide in the image, and that real edge stays while corners are
  still free.

Tests:
- ./check.sh: 131 files, 1941 tests, CHECK EXIT 0 (was 130 / 1931).
- 10 new unit tests in tests/core/trace-collision.test.ts, including
  150 random shapes that must all be valid and within the budget.
- GM_STEPS=42, freshly seeded: ALL PASSED, 20 checks (was 16).
- Three counter-probes bite: no hull fallback, no stair threshold,
  dialog ignoring the alpha threshold.
- Check sum 1202 in README is computed (1198 + 4), CI measures it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-21 08:00:30 +02:00
1 parent f4ec23d3d3
commit 2d79230b55
10 files changed
+705 -5

No files matched your search

+1 -1
View File
@@ -43,7 +43,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, 1198 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1202 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+27
View File
@@ -150,6 +150,33 @@ export class SetFrameCollisionCommand implements Command {
}
}
/**
* Replaces the outlines of several frames at once – what "Generate collision…" writes (#161), one
* undo step whether it covered one frame or the whole sheet.
*/
export class SetFramesCollisionCommand implements Command {
readonly label = 'Generate collision';
private previous = new Map<string, Vec2[][]>();
constructor(
private readonly assetId: string,
private readonly outlines: ReadonlyMap<string, readonly (readonly Vec2[])[]>,
) {}
execute(project: Project): void {
const asset = requireTexture(project, this.assetId);
if (this.outlines.size === 0) throw new CommandError('No frame to generate a collision outline for');
const frames = [...this.outlines.keys()].map((id) => requireFrame(asset, id));
this.previous = new Map(frames.map((frame) => [frame.id, frameCollision(frame)]));
for (const frame of frames) setFrameCollision(frame, this.outlines.get(frame.id)!);
}
undo(project: Project): void {
const asset = requireTexture(project, this.assetId);
for (const [id, outlines] of this.previous) setFrameCollision(requireFrame(asset, id), outlines);
}
}
export class RenameFrameCommand implements Command {
readonly label = 'Rename frame';
private previous = '';
+1 -1
View File
@@ -67,7 +67,7 @@ export function cleanOutline(points: readonly Vec2[]): Vec2[] {
}
/** Whether the open segments ab and cd cross or touch. */
function segmentsMeet(a: Vec2, b: Vec2, c: Vec2, d: Vec2): boolean {
export function segmentsMeet(a: Vec2, b: Vec2, c: Vec2, d: Vec2): boolean {
const d1 = cross(c, d, a);
const d2 = cross(c, d, b);
const d3 = cross(a, b, c);
+204
View File
@@ -0,0 +1,204 @@
/**
* A frame's collision outlines read off its picture (#161): the pixels at least as opaque as the
* threshold are solid, every island of them is walked round along the pixel edges, and the walk is
* cut down to a few corners. Holes are not traced – an outline blocks with all it encloses.
*/
import { polygonProblem, segmentsMeet } from './polygon';
import type { Vec2 } from './Properties';
export interface TraceOptions {
/** The most corners one outline keeps. */
maxPoints: number;
/** Opacity from which a pixel is solid, 0 … 1. */
threshold: number;
}
export const DEFAULT_TRACE: TraceOptions = { maxPoints: 8, threshold: 0.5 };
export const MIN_TRACE_POINTS = 3;
export const MAX_TRACE_POINTS = 64;
/** An island below this share of the frame is a crumb and gets no outline – unless it is the largest. */
const CRUMB_SHARE = 0.01;
/** A corner making a triangle this small (px²) with its neighbours is a step of the pixel grid. */
const STEP_AREA = 1;
/**
* The outlines of the solid islands, in the frame's own pixels. `alpha` holds one opacity 0 … 255
* per pixel, row by row. Islands are 4-connected: pixels meeting at a corner only are two islands.
*/
export function traceOutlines(
alpha: ArrayLike<number>,
width: number,
height: number,
options: TraceOptions,
): Vec2[][] {
const w = Math.floor(width);
const h = Math.floor(height);
if (w <= 0 || h <= 0 || alpha.length < w * h) return [];
const threshold = Math.min(1, Math.max(0, Number.isFinite(options.threshold) ? options.threshold : 0.5));
const cut = Math.max(1, Math.ceil(threshold * 255 - 1e-9));
const solid = new Uint8Array(w * h);
for (let i = 0; i < w * h; i++) solid[i] = alpha[i]! >= cut ? 1 : 0;
const islands = islandsOf(solid, w, h);
if (islands.length === 0) return [];
const largest = Math.max(...islands.map((island) => island.area));
const maxPoints = clampPoints(options.maxPoints);
return islands
.filter((island) => island.area === largest || island.area >= CRUMB_SHARE * w * h)
.map((island) => {
const walk = walkRound(solid, w, h, island.start % w, Math.floor(island.start / w));
const outline = simplify(walk, maxPoints);
const kept = outline.length <= maxPoints && !polygonProblem(outline);
return kept ? outline : simplify(convexHull(walk), maxPoints);
});
}
/** The opacities of `rect` out of RGBA bytes of a picture `sheetWidth` wide; outside it is clear. */
export function alphaOfRect(
rgba: ArrayLike<number>,
sheetWidth: number,
sheetHeight: number,
rect: { x: number; y: number; w: number; h: number },
): Uint8Array {
const alpha = new Uint8Array(rect.w * rect.h);
for (let y = 0; y < rect.h; y++)
for (let x = 0; x < rect.w; x++) {
const sx = rect.x + x;
const sy = rect.y + y;
if (sx >= 0 && sy >= 0 && sx < sheetWidth && sy < sheetHeight)
alpha[y * rect.w + x] = rgba[(sy * sheetWidth + sx) * 4 + 3]!;
}
return alpha;
}
export function clampPoints(value: number): number {
const n = Math.round(Number.isFinite(value) ? value : DEFAULT_TRACE.maxPoints);
return Math.min(MAX_TRACE_POINTS, Math.max(MIN_TRACE_POINTS, n));
}
interface Island {
/** Index of its first pixel in reading order – the top-left corner of that pixel is on its rim. */
start: number;
area: number;
}
function islandsOf(solid: Uint8Array, w: number, h: number): Island[] {
const seen = new Uint8Array(w * h);
const islands: Island[] = [];
const stack: number[] = [];
for (let start = 0; start < w * h; start++) {
if (!solid[start] || seen[start]) continue;
seen[start] = 1;
stack.push(start);
let area = 0;
while (stack.length > 0) {
const i = stack.pop()!;
area++;
const x = i % w;
for (const j of [x > 0 ? i - 1 : -1, x < w - 1 ? i + 1 : -1, i - w, i + w])
if (j >= 0 && j < w * h && solid[j] && !seen[j]) {
seen[j] = 1;
stack.push(j);
}
}
islands.push({ start, area });
}
return islands;
}
/**
* The rim of the island whose first pixel is (sx, sy), as the corners where it turns, walked with
* the island on the right – clockwise on screen. Where two pixels meet at a corner only, the walk
* turns away from the diagonal one: it belongs to another island.
*/
function walkRound(solid: Uint8Array, w: number, h: number, sx: number, sy: number): Vec2[] {
const inside = (x: number, y: number) => x >= 0 && y >= 0 && x < w && y < h && solid[y * w + x] === 1;
const corners: Vec2[] = [];
let [x, y, dx, dy] = [sx, sy, 1, 0];
for (let guard = 4 * (w + 1) * (h + 1); guard > 0; guard--) {
x += dx;
y += dy;
const [rx, ry] = [-dy, dx];
const right = inside(Math.floor(x + (dx + rx) / 2), Math.floor(y + (dy + ry) / 2));
const left = inside(Math.floor(x + (dx - rx) / 2), Math.floor(y + (dy - ry) / 2));
if (right && left) [dx, dy] = [dy, -dx];
else if (!right) [dx, dy] = [-dy, dx];
if (!right || left) corners.push({ x, y });
if (x === sx && y === sy && dx === 1 && dy === 0) break;
}
return corners;
}
function triangleArea(a: Vec2, b: Vec2, c: Vec2): number {
return Math.abs((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) / 2;
}
/**
* Takes away the corner that changes the outline least (Visvalingam–Whyatt) until at most
* `maxPoints` are left and none is a mere step of the pixel grid. A corner whose removal would
* make the outline cross itself stays.
*/
function simplify(ring: readonly Vec2[], maxPoints: number): Vec2[] {
const points = [...ring];
const areaAt = (i: number) =>
triangleArea(
points[(i + points.length - 1) % points.length]!,
points[i]!,
points[(i + 1) % points.length]!,
);
const areas = points.map((_, i) => areaAt(i));
while (points.length > MIN_TRACE_POINTS) {
const refused = new Set<number>();
let removed = false;
for (;;) {
let best = -1;
for (let i = 0; i < points.length; i++)
if (!refused.has(i) && (best < 0 || areas[i]! < areas[best]!)) best = i;
if (best < 0 || (points.length <= maxPoints && areas[best]! >= STEP_AREA)) break;
if (!removable(points, best)) {
refused.add(best);
continue;
}
points.splice(best, 1);
areas.splice(best, 1);
const before = (best + points.length - 1) % points.length;
areas[before] = areaAt(before);
areas[best % points.length] = areaAt(best % points.length);
removed = true;
break;
}
if (!removed) break;
}
return points;
}
/** Whether the edge that replaces corner `i` stays clear of every edge it does not share a corner with. */
function removable(points: readonly Vec2[], i: number): boolean {
const n = points.length;
const a = (i + n - 1) % n;
const c = (i + 1) % n;
const from = points[a]!;
const to = points[c]!;
for (let j = 0; j < n; j++) {
if (j === (a + n - 1) % n || j === a || j === i || j === c) continue;
if (segmentsMeet(from, to, points[j]!, points[(j + 1) % n]!)) return false;
}
return true;
}
/** The hull of the points – the outline of last resort for a pinched island. */
function convexHull(points: readonly Vec2[]): Vec2[] {
const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y);
const turn = (o: Vec2, a: Vec2, b: Vec2) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
const half = (list: Vec2[]) => {
const out: Vec2[] = [];
for (const p of list) {
while (out.length >= 2 && turn(out[out.length - 2]!, out[out.length - 1]!, p) <= 0) out.pop();
out.push(p);
}
out.pop();
return out;
};
return [...half(sorted), ...half([...sorted].reverse())];
}
+2
View File
@@ -459,6 +459,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
rect: () => textureEditor.canvasRect(),
/** Whether a press edits the selected frame's collision outline (#141). */
collisionMode: () => textureEditor.canvas.collisionMode,
/** The open "Generate collision…" dialog (#161), or null. */
generateDialog: () => textureEditor.generateDialog.current()?.info ?? null,
},
/** The Vector art tab (#129, stage 3): where a canvas pixel is, and which tool a press uses. */
vectorArt: {
@@ -0,0 +1,125 @@
import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import {
DEFAULT_TRACE,
MAX_TRACE_POINTS,
MIN_TRACE_POINTS,
type TraceOptions,
} from '../../core/model/traceCollision';
import { type Button, Checkbox, Label, NumberField, type Widget } from '../../ui';
import { FormDialog } from '../FormDialog';
const FIELD_WIDTH = 76;
/** A frame's outlines as the picture gives them, or null while the picture is not in memory. */
export type TraceFrame = (frame: TextureFrame, options: TraceOptions) => Vec2[][] | null;
/**
* Reads the collision outlines of a frame – or of every frame – off the picture (#161). The
* summary traces as the numbers change, so what OK writes is known before it is pressed.
*/
export class GenerateCollisionDialog extends FormDialog {
private readonly options: TraceOptions = { ...DEFAULT_TRACE };
private all: boolean;
private readonly summary: Label;
private readonly confirm: Button;
private readonly controls: Record<'points' | 'threshold' | 'all', Widget>;
private result = new Map<string, Vec2[][]>();
constructor(
private readonly asset: TextureAsset,
private readonly selected: TextureFrame | null,
private readonly trace: TraceFrame,
onGenerate: (outlines: Map<string, Vec2[][]>) => void,
) {
super(340, `Generate collision for "${asset.name}"`);
this.all = !selected;
const points = new NumberField(this.options.maxPoints, {
min: MIN_TRACE_POINTS,
max: MAX_TRACE_POINTS,
step: 1,
decimals: 0,
onInput: (value) => this.setOption('maxPoints', value),
onChange: (value) => this.setOption('maxPoints', value),
});
points.minWidth = FIELD_WIDTH;
this.content.add(this.formRow(this.fieldLabel('Max points'), points));
const threshold = new NumberField(this.options.threshold * 100, {
min: 1,
max: 100,
step: 1,
decimals: 0,
onInput: (value) => this.setOption('threshold', value / 100),
onChange: (value) => this.setOption('threshold', value / 100),
});
threshold.minWidth = FIELD_WIDTH;
this.content.add(
this.formRow(this.fieldLabel('Solid from'), threshold, new Label('% alpha', { style: 'muted' })),
);
const box = new Checkbox(this.all);
box.setEnabled(selected !== null);
box.onChange = (value) => {
this.all = Boolean(value);
this.refreshSummary();
};
this.content.add(this.formRow(box, new Label('All frames of the texture', { style: 'muted' })));
this.content.add(new Label('Replaces the outlines these frames have.', { style: 'muted' }));
this.summary = new Label('', { style: 'muted' });
this.content.add(this.summary);
this.confirm = this.addButtons('Generate', 'primary', () => onGenerate(this.result));
this.controls = { points, threshold, all: box };
this.refreshSummary();
}
/** What the summary says, whether OK would write anything, and where the controls sit – for the check scripts. */
get info(): {
summary: string;
enabled: boolean;
all: boolean;
at: Record<'points' | 'threshold' | 'all', { x: number; y: number }>;
} {
const centre = (widget: Widget) => {
const { x, y } = widget.toGlobal({ x: widget.w / 2, y: widget.h / 2 });
return { x, y };
};
const { points, threshold, all } = this.controls;
const at = { points: centre(points), threshold: centre(threshold), all: centre(all) };
return { summary: this.summary.text, enabled: this.confirm.enabled, all: this.all, at };
}
private setOption<K extends keyof TraceOptions>(key: K, value: TraceOptions[K]): void {
this.options[key] = value;
this.refreshSummary();
}
private refreshSummary(): void {
const frames = this.all || !this.selected ? this.asset.frames : [this.selected];
this.result = new Map();
let waiting = false;
for (const frame of frames) {
const outlines = this.trace(frame, this.options);
if (outlines) this.result.set(frame.id, outlines);
else waiting = true;
}
const outlines = [...this.result.values()].flat();
const corners = outlines.reduce((sum, outline) => sum + outline.length, 0);
const scope = frames.length === 1 ? `Frame "${frames[0]!.name}"` : `${frames.length} frames`;
const text = waiting
? 'The picture is still loading.'
: outlines.length === 0
? 'No pixel is that opaque – no outline.'
: `${scope}: ${plural(outlines.length, 'outline')}, ${plural(corners, 'corner')}`;
this.summary.setText(text);
this.confirm.setEnabled(!waiting && outlines.length > 0);
this.refreshLayout();
}
}
function plural(count: number, word: string): string {
return `${count} ${word}${count === 1 ? '' : 's'}`;
}
+27 -2
View File
@@ -1,8 +1,12 @@
import { SliceFramesCommand } from '../../core/commands/frameCommands';
import { SetFramesCollisionCommand, SliceFramesCommand } from '../../core/commands/frameCommands';
import type { Asset, TextureAsset } from '../../core/model/Asset';
import { alphaOfRect, traceOutlines } from '../../core/model/traceCollision';
import { Button } from '../../ui';
import { textureLibrary } from '../../viewport/TextureLibrary';
import { AssetTabEditor } from '../AssetTabEditor';
import { dialogSlot } from '../FormDialog';
import type { EditorState } from '../state';
import { GenerateCollisionDialog, type TraceFrame } from './GenerateCollisionDialog';
import { SliceDialog } from './SliceDialog';
import { TextureCanvas } from './TextureCanvas';
@@ -13,6 +17,8 @@ export class TextureEditor extends AssetTabEditor<TextureAsset, TextureCanvas> {
private readonly sliceButton: Button;
/** Switches the canvas between editing frames and editing their collision outlines (#141). */
private readonly collisionButton: Button;
private readonly generateButton: Button;
readonly generateDialog = dialogSlot<GenerateCollisionDialog>();
constructor(state: EditorState) {
super(state, { infoWidth: INFO_WIDTH });
@@ -21,7 +27,8 @@ export class TextureEditor extends AssetTabEditor<TextureAsset, TextureCanvas> {
toggle: true,
onClick: () => this.setCollisionMode(!this.canvas.collisionMode),
});
this.mount(new TextureCanvas(state), [this.sliceButton, this.collisionButton]);
this.generateButton = new Button('Generate collision…', { onClick: () => this.openGenerate() });
this.mount(new TextureCanvas(state), [this.sliceButton, this.collisionButton, this.generateButton]);
}
setCollisionMode(on: boolean): void {
@@ -56,6 +63,24 @@ export class TextureEditor extends AssetTabEditor<TextureAsset, TextureCanvas> {
protected override syncExtras(asset: TextureAsset | null): void {
// Slicing needs a known picture size; without one it could only fail with a notice.
this.sliceButton.setEnabled(Boolean(asset && asset.width > 0 && asset.height > 0));
// Outlines belong to frames; a texture without one has nowhere to put them.
this.generateButton.setEnabled(Boolean(asset && asset.frames.length > 0));
}
private openGenerate(): void {
const asset = this.asset();
if (!asset || asset.frames.length === 0) return;
const s = this.state.selection;
const selected = (s?.kind === 'frame' && asset.frames.find((f) => f.id === s.frameId)) || null;
const pixels = textureLibrary.pixels(asset.file);
const trace: TraceFrame = (frame, options) =>
pixels &&
traceOutlines(alphaOfRect(pixels.data, pixels.width, pixels.height, frame), frame.w, frame.h, options);
this.generateDialog.open(
new GenerateCollisionDialog(asset, selected, trace, (outlines) =>
this.state.execute(new SetFramesCollisionCommand(asset.id, outlines)),
),
);
}
private openSlice(): void {
+15
View File
@@ -121,6 +121,21 @@ export class TextureLibrary extends FileLibrary<Texture> {
return texture;
}
/**
* The colours of an uploaded picture, read back from the image it was loaded from; null while it
* loads, and for a generated picture, which lives on the GPU only.
*/
pixels(file: string): ImageData | null {
const sheet = isGeneratedFile(file) ? null : this.load(file);
const picture = sheet?.source.resource as CanvasImageSource | null | undefined;
if (!sheet || !picture) return null;
const { pixelWidth: w, pixelHeight: h } = sheet.source;
const context = new OffscreenCanvas(w, h).getContext('2d', { willReadFrequently: true });
if (!context) return null;
context.drawImage(picture, 0, 0);
return context.getImageData(0, 0, w, h);
}
protected fetchFile(url: string): Promise<Texture> {
return Assets.load<Texture>(url);
}
+191
View File
@@ -0,0 +1,191 @@
import { describe, expect, it } from 'vitest';
import { CreateTextureCommand } from '../../src/core/commands/assetCommands';
import { AddFrameCommand, SetFramesCollisionCommand } from '../../src/core/commands/frameCommands';
import type { TextureAsset } from '../../src/core/model/Asset';
import { polygonProblem, signedArea } from '../../src/core/model/polygon';
import { findAsset } from '../../src/core/model/Project';
import type { Vec2 } from '../../src/core/model/Properties';
import { alphaOfRect, DEFAULT_TRACE, traceOutlines } from '../../src/core/model/traceCollision';
import { makeProject } from '../helpers';
const SIZE = 32;
/** A picture of SIZE × SIZE whose opacity at each pixel centre is `at(x, y)`. */
function picture(at: (x: number, y: number) => number, size = SIZE): number[] {
const alpha: number[] = [];
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) alpha.push(at(x + 0.5, y + 0.5));
return alpha;
}
const corners = (ring: readonly Vec2[]) => ring.map((p) => `${p.x},${p.y}`).sort();
const area = (ring: readonly Vec2[]) => Math.abs(signedArea(ring));
describe('collision outlines traced from a picture (#161)', () => {
it('outlines an opaque frame with its four corners', () => {
const rings = traceOutlines(
picture(() => 255),
SIZE,
SIZE,
DEFAULT_TRACE,
);
expect(rings).toHaveLength(1);
expect(corners(rings[0]!)).toEqual(
corners([
{ x: 0, y: 0 },
{ x: 32, y: 0 },
{ x: 32, y: 32 },
{ x: 0, y: 32 },
]),
);
});
it('gives nothing for a frame without a solid pixel', () => {
expect(
traceOutlines(
picture(() => 0),
SIZE,
SIZE,
DEFAULT_TRACE,
),
).toEqual([]);
});
it('straightens the stairs of a slope into one edge', () => {
const wedge = traceOutlines(
picture((x, y) => (x + y > SIZE ? 255 : 0)),
SIZE,
SIZE,
DEFAULT_TRACE,
);
expect(wedge).toHaveLength(1);
expect(wedge[0]!.length).toBeLessThanOrEqual(4);
expect(polygonProblem(wedge[0]!)).toBe('');
expect(Math.abs(area(wedge[0]!) - 512) / 512).toBeLessThan(0.05);
});
it('keeps at most the corners asked for, and the shape stays close to the picture', () => {
const round = picture((x, y) => (Math.hypot(x - 16, y - 16) <= 12 ? 255 : 0));
const eight = traceOutlines(round, SIZE, SIZE, DEFAULT_TRACE)[0]!;
const four = traceOutlines(round, SIZE, SIZE, { ...DEFAULT_TRACE, maxPoints: 4 })[0]!;
const many = traceOutlines(round, SIZE, SIZE, { ...DEFAULT_TRACE, maxPoints: 32 })[0]!;
expect([eight.length, four.length]).toEqual([8, 4]);
expect(many.length).toBeGreaterThan(8);
expect(many.length).toBeLessThanOrEqual(32);
const disc = Math.PI * 12 * 12;
expect(Math.abs(area(eight) - disc) / disc).toBeLessThan(0.1);
expect(Math.abs(area(many) - disc) / disc).toBeLessThan(Math.abs(area(eight) - disc) / disc);
for (const ring of [eight, four, many]) expect(polygonProblem(ring)).toBe('');
});
it('takes pixels from the threshold on as solid', () => {
// An opaque square with a faint band (alpha 100 of 255, 39 %) to its right.
const alpha = picture((x, y) => (y > 8 && y < 24 ? (x < 16 ? 255 : x < 24 ? 100 : 0) : 0));
const width = (threshold: number) => {
const rings = traceOutlines(alpha, SIZE, SIZE, { ...DEFAULT_TRACE, threshold });
return Math.max(...rings[0]!.map((p) => p.x)) - Math.min(...rings[0]!.map((p) => p.x));
};
expect(width(0.5)).toBe(16);
expect(width(0.3)).toBe(24);
expect(width(0.39)).toBe(24);
expect(width(0.4)).toBe(16);
});
it('outlines each island, leaves crumbs out and keeps the only crumb there is', () => {
const islands = picture((x, y) =>
(x < 10 && y < 10) || (x > 20 && y > 20) || (Math.floor(x) === 30 && Math.floor(y) === 2) ? 255 : 0,
);
const rings = traceOutlines(islands, SIZE, SIZE, DEFAULT_TRACE);
expect(rings.map(corners)).toEqual([
corners([
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 },
]),
corners([
{ x: 20, y: 20 },
{ x: 32, y: 20 },
{ x: 32, y: 32 },
{ x: 20, y: 32 },
]),
]);
const crumb = picture((x, y) => (Math.floor(x) === 30 && Math.floor(y) === 2 ? 255 : 0));
expect(traceOutlines(crumb, SIZE, SIZE, DEFAULT_TRACE)).toHaveLength(1);
});
it('outlines a ring by its outside and leaves the hole solid', () => {
const ring = picture((x, y) =>
Math.hypot(x - 16, y - 16) <= 14 && Math.hypot(x - 16, y - 16) > 6 ? 255 : 0,
);
const rings = traceOutlines(ring, SIZE, SIZE, DEFAULT_TRACE);
expect(rings).toHaveLength(1);
expect(area(rings[0]!)).toBeGreaterThan(Math.PI * 13 * 13);
});
it('gives a valid outline within the corner budget for any blob, pinched ones included', () => {
let seed = 7;
const random = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
for (let run = 0; run < 150; run++) {
const cells = Array.from({ length: 16 }, () => random() < 0.55);
const alpha = picture((x, y) => (cells[Math.floor(y / 4) * 4 + Math.floor(x / 4)] ? 255 : 0), 16);
const maxPoints = 3 + (run % 10);
for (const outline of traceOutlines(alpha, 16, 16, { ...DEFAULT_TRACE, maxPoints })) {
expect(outline.length).toBeLessThanOrEqual(maxPoints);
expect(polygonProblem(outline)).toBe('');
}
}
});
});
describe('a frame cut out of the sheet for tracing', () => {
it('reads the opacity of the frame’s pixels and leaves what lies past the sheet clear', () => {
// A 3 × 2 sheet whose alpha is 10 · (index + 1).
const rgba = Array.from({ length: 6 }, (_, i) => [0, 0, 0, 10 * (i + 1)]).flat();
expect([...alphaOfRect(rgba, 3, 2, { x: 1, y: 0, w: 2, h: 2 })]).toEqual([20, 30, 50, 60]);
expect([...alphaOfRect(rgba, 3, 2, { x: 2, y: 1, w: 2, h: 2 })]).toEqual([60, 0, 0, 0]);
});
});
describe('SetFramesCollisionCommand', () => {
it('replaces the outlines of several frames in one undo step, and undo gives back what was there', () => {
const fixture = makeProject();
const create = new CreateTextureCommand('Sheet', {
file: `${'ab'.repeat(32)}.png`,
width: 64,
height: 32,
});
fixture.stack.execute(create);
const add = (x: number) => {
const command = new AddFrameCommand(create.asset.id, { x, y: 0, w: 32, h: 32 });
fixture.stack.execute(command);
return command.frameId;
};
const [left, right] = [add(0), add(32)];
const frames = () => (findAsset(fixture.project, create.asset.id) as TextureAsset).frames;
const old = [
{ x: 0, y: 0 },
{ x: 4, y: 0 },
{ x: 0, y: 4 },
];
frames()[0]!.collision = [old];
const square = [
{ x: 0, y: 0 },
{ x: 32, y: 0 },
{ x: 32, y: 32 },
{ x: 0, y: 32 },
];
fixture.stack.execute(
new SetFramesCollisionCommand(
create.asset.id,
new Map([
[left, [square]],
[right, [square, old]],
]),
),
);
expect(frames().map((f) => f.collision)).toEqual([[square], [square, old]]);
fixture.stack.undo();
expect(frames()[0]!.collision).toEqual([old]);
expect('collision' in frames()[1]!).toBe(false);
});
});
+112 -1
View File
@@ -6,7 +6,8 @@ import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
* (#141). What only a running editor can show: the corners of a polygon collider are edited on
* its outline in the scene, the engine lets a capsule slide down a polygon ramp, the Texture tab
* draws a frame's outline with clicks, and a painted map blocks with that outline in the game – as
* does a collider that wears the frame's outline (#193).
* does a collider that wears the frame's outline (#193), and "Generate collision…" reads that
* outline off the picture (#161).
*/
/** A 32 × 32 picture, pasted like a file: the texture the outline is drawn on. */
@@ -22,6 +23,26 @@ const PASTE_PICTURE = `(async () => {
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
/**
* A 64 × 32 sheet, pasted like a file, painted pixel by pixel so no edge is smoothed: on the left a
* wedge (pixels with x + y ≥ 32) and a band of alpha 0.3 above it, on the right a 16 × 16 square.
*/
const PASTE_SHEET = `(async () => {
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 32;
const g = canvas.getContext('2d');
g.fillStyle = '#c04040';
for (let y = 0; y < 32; y++) for (let x = 0; x < 32; x++) if (x + y >= 32) g.fillRect(x, y, 1, 1);
g.fillRect(40, 8, 16, 16);
g.fillStyle = 'rgba(64, 64, 192, 0.3)';
g.fillRect(0, 0, 20, 4);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer();
transfer.items.add(new File([blob], 'generate.png', { type: 'image/png' }));
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
/** Walks its owner right and a little down, colliding with solids: 2 and 0.6 a frame at 60 fps. */
const climbGraph = {
nodes: [
@@ -421,4 +442,94 @@ export async function shapes(t) {
JSON.stringify({ frames: frameClimb.length, peak, top: F - 42, last: frameClimb.at(-1) }),
);
await clearShapes();
// 42f. "Generate collision…" reads the outlines off the picture (#161): a wedge and a faint band
// (alpha 30 %) in one frame, a square in the other. From 50 % the band is not solid, from
// 20 % it is an outline of its own; "All frames" writes both frames in one undo step.
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate(PASTE_SHEET);
await sleep(2500);
const sheet = await json(
"(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'texture').at(-1);" +
' return a ? { id: a.id, w: a.width, h: a.height } : null; })()',
);
await mutate(`
const sheet = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sheet?.id)});
sheet.frames.push({ id: 'gc-left', name: 'Left', x: 0, y: 0, w: 32, h: 32 },
{ id: 'gc-right', name: 'Right', x: 32, y: 0, w: 32, h: 32 });`);
await select({ kind: 'frame', assetId: sheet?.id, frameId: 'gc-left' });
await showTab('texture');
await settle();
await clickText('Generate collision…');
await settle();
const dialog = () => json('__gm.texture.generateDialog()');
const opened = await dialog();
check(
'Generate collision… opens on the selected frame and names what it would write from 50 % alpha',
sheet?.w === 64 &&
opened?.enabled === true &&
opened.all === false &&
/^Frame "Left": 1 outline, [34] corners$/.test(opened.summary),
JSON.stringify({ sheet, opened }),
);
const retype = async (at, text) => {
await click(at.x, at.y);
await press('a', { ctrl: true });
await t.typeText(text);
await press('Enter');
await settle();
};
await retype(opened.at.threshold, '20');
const faint = await dialog();
await shot('42-generate-dialog');
await retype(opened.at.threshold, '50');
check(
'from 20 % alpha the faint band is solid too and becomes an outline of its own',
/^Frame "Left": 2 outlines, \d+ corners$/.test(faint?.summary ?? '') &&
/^Frame "Left": 1 outline/.test((await dialog())?.summary ?? ''),
JSON.stringify(faint),
);
await click(opened.at.all.x, opened.at.all.y);
await settle();
const both = await dialog();
await clickText('Generate');
await settle();
const outlines = () =>
json(
`Object.fromEntries(__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sheet?.id)})` +
`.frames.map((f) => [f.id, f.collision ?? null]))`,
);
const written = await outlines();
const cornerSet = (ring) => (ring ?? []).map((p) => `${p.x},${p.y}`).sort();
const ringArea = (ring) =>
Math.abs(
ring.reduce(
(sum, p, i) => sum + p.x * ring[(i + 1) % ring.length].y - ring[(i + 1) % ring.length].x * p.y,
0,
),
) / 2;
check(
'with All frames, Generate outlines the wedge and the square of both frames',
both?.all === true &&
/^2 frames: 2 outlines/.test(both.summary) &&
written['gc-left']?.length === 1 &&
written['gc-left'][0].length <= 4 &&
Math.abs(ringArea(written['gc-left'][0]) - 512) < 26 &&
JSON.stringify(cornerSet(written['gc-right']?.[0])) ===
JSON.stringify(['24,24', '24,8', '8,24', '8,8']),
JSON.stringify({ both, written }),
);
await clickText('Collision');
await settle();
await shot('42-generated-outline');
await clickText('Collision');
await undo();
const undone = await outlines();
check(
'one undo takes the outlines of both frames back',
undone['gc-left'] === null && undone['gc-right'] === null,
JSON.stringify(undone),
);
await showTab('scene');
await settle();
}