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)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1b5e386b60
commit
32057a96fc
25 files changed
+2341
-122
No files matched your search
@@ -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))),
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user