Physics stage 2: colliders gain the shapes capsule and polygon, a polygon keeps its corners in the property points, turns, scales and mirrors with its object and is edited in the scene view (drag a corner, click an edge to insert one, double-click to delete, at least three stay, each step one undo step), a concave polygon is split into convex parts, a self-intersecting one is drawn red and collides with nothing, Rapier gets a capsule and one convex collider per part on the same body, and Move and the editor run use our own f64 geometry for the new shapes, where on a tie between two normals the one that blocks the move least wins, so a box no longer stops at the foot of a slope (#151); first version of a collision outline per texture frame, edited in the texture tab with the new Collision toggle, which a plain tile blocks with instead of its rectangle (#141, to be reworked)
game-maker / gate (push) Successful in 1m52s
game-maker / suite (push) Successful in 6m41s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 22:56:25 +02:00
1 parent 1b5e386b60
commit 32057a96fc
25 files changed
+2341 -122

No files matched your search

+188 -9
View File
@@ -8,23 +8,28 @@ import {
} from 'pixi.js';
import {
AddFrameCommand,
SetFrameCollisionCommand,
SetFrameRectCommand,
SetFrameSliceCommand,
} from '../../core/commands/frameCommands';
import { convexPieces } from '../../core/model/polygon';
import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset';
import { textOf, type Vec2 } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import {
frameBorders,
frameCollision,
frameFill,
type FrameRect,
MIN_FRAME_SIZE,
normalizeFrameRect,
rawFrameSlice,
restoreFrameSlice,
setFrameCollision,
setFrameSlice,
} from '../../core/model/texture';
import { type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
import { pressCount, type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit';
import { dashedLine } from '../../viewport/symbols';
import { textureLibrary } from '../../viewport/TextureLibrary';
import type { EditorState } from '../state';
@@ -73,6 +78,20 @@ interface FrameDrag {
moved: boolean;
}
/**
* A corner of a frame's collision outline being dragged (#141) – also one just put down. The model
* holds the preview; `before` is put back on release so the command records one undo step.
*/
interface CornerDrag {
frameId: string;
before: Vec2[];
points: Vec2[];
index: number;
pointer: Vec2;
added: boolean;
moved: boolean;
}
/** A new frame being spanned, in image pixels; nothing is in the model yet. */
interface NewFrame {
start: Vec2;
@@ -102,6 +121,9 @@ export class TextureCanvas extends Widget {
private panning: { pointer: Vec2; panX: number; panY: number } | null = null;
private dragging: FrameDrag | null = null;
private spanning: NewFrame | null = null;
private corner: CornerDrag | null = null;
/** Whether a press edits the selected frame's collision outline instead of the frames (#141). */
private collision = false;
constructor(private readonly state: EditorState) {
super();
@@ -144,6 +166,16 @@ export class TextureCanvas extends Widget {
return this.assetId;
}
setCollisionMode(on: boolean): void {
if (this.collision === on) return;
this.collision = on;
this.redraw();
}
get collisionMode(): boolean {
return this.collision;
}
fit(): void {
this.needsFit = true;
this.redraw();
@@ -185,6 +217,11 @@ export class TextureCanvas extends Widget {
ui().focus.focus(this);
const point = localToImage(this.view, local.x, local.y);
const tolerance = HANDLE_SIZE / this.view.zoom;
if (this.collision) {
this.pressOutline(asset, point, local, tolerance, e);
e.stopPropagation();
return;
}
// The selected frame is tested first, so its handles stay reachable under a frame drawn later.
// Its resize handles before its border lines – a border still sitting on the frame's edge must
@@ -221,6 +258,74 @@ export class TextureCanvas extends Widget {
e.stopPropagation();
}
/**
* A press in collision mode. On the selected frame: a double-click on a corner takes it away, a
* press on a corner drags it, on an edge puts a corner there, anywhere else inside the frame adds
* one after the last – how an outline is drawn from nothing. Another frame is only selected, and
* no frame is moved or spanned while the mode is on.
*/
private pressOutline(
asset: TextureAsset,
point: Vec2,
local: Vec2,
tolerance: number,
e: FederatedPointerEvent,
): void {
const selected = this.frame(this.selectedFrameId());
if (selected) {
const at = { x: point.x - selected.x, y: point.y - selected.y };
const points = frameCollision(selected);
const grip = gripAt(points, at, tolerance);
if (grip?.kind === 'corner' && pressCount(e, `outline ${selected.id} ${grip.index}`) === 2) {
this.state.execute(
new SetFrameCollisionCommand(asset.id, selected.id, withoutCorner(points, grip.index, 0)!),
);
return;
}
// A corner often belongs on the frame's edge: a press just beside it still counts as in it.
const inside =
at.x >= -tolerance &&
at.y >= -tolerance &&
at.x <= selected.w + tolerance &&
at.y <= selected.h + tolerance;
if (grip || inside) {
const corner = inFrame(selected, grip?.kind === 'edge' ? grip.at : at);
const next =
grip?.kind === 'corner'
? points
: grip?.kind === 'edge'
? withCornerAfter(points, grip.index, corner)
: [...points, corner];
this.corner = {
frameId: selected.id,
before: points,
points: next,
index: grip?.kind === 'corner' ? grip.index : grip ? grip.index + 1 : next.length - 1,
pointer: { x: local.x, y: local.y },
added: grip?.kind !== 'corner',
moved: false,
};
if (this.corner.added) this.previewOutline();
return;
}
}
for (let i = asset.frames.length - 1; i >= 0; i--) {
const frame = asset.frames[i]!;
if (handleAt(frame, point, tolerance) === null) continue;
this.state.select({ kind: 'frame', assetId: asset.id, frameId: frame.id });
return;
}
this.state.select({ kind: 'asset', assetId: this.assetId });
}
private previewOutline(): void {
const corner = this.corner;
const frame = corner && this.frame(corner.frameId);
if (!corner || !frame) return;
setFrameCollision(frame, corner.points);
this.state.preview();
}
private startDrag(
frame: TextureFrame,
handle: FrameHandle | null,
@@ -250,6 +355,25 @@ export class TextureCanvas extends Widget {
this.redraw();
return;
}
if (this.corner) {
const corner = this.corner;
const frame = this.frame(corner.frameId);
if (!frame) return;
if (
!corner.moved &&
Math.hypot(local.x - corner.pointer.x, local.y - corner.pointer.y) < DRAG_THRESHOLD
)
return;
corner.moved = true;
const point = localToImage(this.view, local.x, local.y);
corner.points = withCornerMoved(
corner.points,
corner.index,
inFrame(frame, { x: point.x - frame.x, y: point.y - frame.y }),
);
this.previewOutline();
return;
}
if (this.dragging) {
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
@@ -290,6 +414,16 @@ export class TextureCanvas extends Widget {
private onPointerUp(): void {
this.panning = null;
const corner = this.corner;
this.corner = null;
const frame = corner && this.frame(corner.frameId);
if (corner && frame) {
setFrameCollision(frame, corner.before);
if (corner.added || corner.moved)
this.state.execute(new SetFrameCollisionCommand(this.assetId, corner.frameId, corner.points));
else this.state.preview();
return;
}
const drag = this.dragging;
const span = this.spanning;
this.dragging = null;
@@ -343,6 +477,10 @@ export class TextureCanvas extends Widget {
this.cursor = 'default';
return;
}
if (this.collision) {
this.cursor = 'crosshair';
return;
}
const point = localToImage(this.view, local.x, local.y);
const tolerance = HANDLE_SIZE / this.view.zoom;
const selected = this.frame(this.selectedFrameId());
@@ -419,8 +557,9 @@ export class TextureCanvas extends Widget {
alive.add(frame.id);
this.drawFrame(frame, frame.id === selectedId);
}
for (const frame of asset.frames) this.drawOutline(frame, this.collision && frame.id === selectedId);
const selected = this.frame(selectedId);
if (selected) {
if (selected && !this.collision) {
this.drawBorders(selected);
this.drawHandles(selected);
}
@@ -464,6 +603,34 @@ export class TextureCanvas extends Widget {
this.overlay.stroke({ width: 1, color: Theme.colors.marquee });
}
/**
* A frame's collision outline (#141), always shown so one sees which frames block with one; the
* edited one gets a handle on every corner. An outline that is no polygon is drawn in red.
*/
private drawOutline(frame: TextureFrame, edited: boolean): void {
const points = frameCollision(frame);
if (points.length === 0) return;
const screen = points.map((p) => imageToLocal(this.view, frame.x + p.x, frame.y + p.y));
const color = convexPieces(points).length > 0 ? Theme.colors.collider : Theme.colors.danger;
if (screen.length >= 3)
this.overlay
.poly(screen, true)
.fill({ color, alpha: 0.15 })
.stroke({ width: edited ? 2 : 1, color });
else if (screen.length === 2)
this.overlay
.moveTo(screen[0]!.x, screen[0]!.y)
.lineTo(screen[1]!.x, screen[1]!.y)
.stroke({ width: 2, color });
if (!edited) return;
const half = HANDLE_SIZE / 2;
for (const c of screen)
this.overlay
.rect(c.x - half, c.y - half, HANDLE_SIZE, HANDLE_SIZE)
.fill(Theme.colors.accentText)
.stroke({ width: 1, color });
}
private drawHandles(frame: TextureFrame): void {
const half = HANDLE_SIZE / 2;
for (const handle of frameHandles()) {
@@ -504,15 +671,27 @@ export class TextureCanvas extends Widget {
private syncHint(asset: TextureAsset | null, hasPicture: boolean): void {
const text = !asset
? 'No texture selected – pick one in the Assets panel or import an image.'
: !asset.file
? 'This texture has no picture yet.'
: !hasPicture
? 'Loading…'
: asset.frames.length === 0
? 'Drag on the picture to cut a frame, or use Slice to cut a grid at once.'
: '';
: this.collision && asset.file && hasPicture
? this.selectedFrameId()
? 'Collision: click in the frame to add a corner, drag one to move it, click an edge to insert, double-click to remove.'
: 'Collision: select a frame to draw the outline its tiles block with.'
: !asset.file
? 'This texture has no picture yet.'
: !hasPicture
? 'Loading…'
: asset.frames.length === 0
? 'Drag on the picture to cut a frame, or use Slice to cut a grid at once.'
: '';
this.hint.text = text;
this.hint.visible = text !== '';
this.hint.position.set(Theme.padding * 2, Theme.padding * 2);
}
}
/** A corner of an outline: on whole pixels, and inside its frame. */
function inFrame(frame: TextureFrame, p: Vec2): Vec2 {
return {
x: Math.min(frame.w, Math.max(0, Math.round(p.x))),
y: Math.min(frame.h, Math.max(0, Math.round(p.y))),
};
}
+12 -1
View File
@@ -11,11 +11,22 @@ const INFO_WIDTH = 190;
export class TextureEditor extends AssetTabEditor<TextureAsset, TextureCanvas> {
private readonly sliceButton: Button;
/** Switches the canvas between editing frames and editing their collision outlines (#141). */
private readonly collisionButton: Button;
constructor(state: EditorState) {
super(state, { noun: 'Texture', infoWidth: INFO_WIDTH });
this.sliceButton = new Button('Slice…', { onClick: () => this.openSlice() });
this.mount(new TextureCanvas(state), [this.sliceButton]);
this.collisionButton = new Button('Collision', {
toggle: true,
onClick: () => this.setCollisionMode(!this.canvas.collisionMode),
});
this.mount(new TextureCanvas(state), [this.sliceButton, this.collisionButton]);
}
setCollisionMode(on: boolean): void {
this.canvas.setCollisionMode(on);
this.collisionButton.setToggled(on);
}
/** Where an image pixel of the shown sheet sits on screen – for the check scripts. */