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

+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 {