A sprite sorted by Y stood on the bottom of its box. With a shadow or empty rows under the feet that is too low, so a figure slipped in front of a tree it should stand behind. The line belongs to the frame, not the texture, because one texture can hold several sprite sheets. - New field `sortLine` on a frame: whole image pixels up from the frame's bottom edge, 0 to the frame height. 0 is stored as nothing, so old projects stay the same. Shrinking a frame keeps the line inside it. - `feetOf` in drawOrder.ts lifts a sprite by the same share of its box. Scene view, game view, play and picking all use it. In an animation the line of the frame shown right now counts. - The texture tab draws the selected frame's line dashed in yellow with a knob left of the frame. At 0 the line shares the bottom edge with the nine-slice border and a resize handle, so the knob is tested first. A line inside the frame can be dragged directly, tested after the nine-slice lines. - The inspector has a new "Sorting" section with a "Sort line" field. Dragging or typing is one undo step each. Limit: frames of one walk cycle need the same value, set by hand. Otherwise the figure changes its place in the order while walking. Tests: - ./check.sh: 160 files, 2356 tests, CHECK EXIT 0 (before 2350). - 6 new unit tests in tests/viewport/sort-by-y.test.ts. - e2e 37 and 39-43: ALL PASSED, 162 checks. New check 37b-ii drags the knob up 6 px and undoes it, plus the inspector row. - Counter checks: if feetOf ignores the line, exactly the 3 order tests fail. If the knob cannot be grabbed, 37b-ii fails. - The e2e sum in the README is 1380, counted, not yet seen in CI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
881 lines
33 KiB
TypeScript
881 lines
33 KiB
TypeScript
import { Container, type FederatedPointerEvent, Graphics, Sprite, Text } from 'pixi.js';
|
||
import {
|
||
AddFrameCommand,
|
||
SetFrameCollisionCommand,
|
||
SetFrameRectCommand,
|
||
SetFrameSliceCommand,
|
||
SetFrameSortLineCommand,
|
||
} from '../../core/commands/frameCommands';
|
||
import { polygonProblem } 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,
|
||
frameSortLine,
|
||
MIN_FRAME_SIZE,
|
||
normalizeFrameRect,
|
||
normalizeSortLine,
|
||
rawFrameSlice,
|
||
restoreFrameSlice,
|
||
setFrameCollision,
|
||
setFrameSlice,
|
||
setFrameSortLine,
|
||
} from '../../core/model/texture';
|
||
import { pressCount, Theme, textStyle, type Widget } from '../../ui';
|
||
import {
|
||
gripAt,
|
||
insideOutline,
|
||
MIN_CORNERS,
|
||
type PolygonGrip,
|
||
withCornerAfter,
|
||
withCornerMoved,
|
||
withoutCorner,
|
||
} from '../polygonEdit';
|
||
import { COLLIDER_FILL_ALPHA, dashedLine } from '../../viewport/symbols';
|
||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||
import { FramePreview } from '../framePreview';
|
||
import type { EditorState } from '../state';
|
||
import { drawChecker } from './checker';
|
||
import { SheetCanvas } from './SheetCanvas';
|
||
import {
|
||
borderAt,
|
||
borderCursor,
|
||
type BorderEdge,
|
||
BORDER_EDGES,
|
||
borderLine,
|
||
dragBorder,
|
||
dragFrame,
|
||
fitView,
|
||
type FrameHandle,
|
||
frameHandles,
|
||
handleAt,
|
||
handleCursor,
|
||
handlePoint,
|
||
imageToLocal,
|
||
localToImage,
|
||
rectBetween,
|
||
type SheetView,
|
||
sortLineAt,
|
||
sortLineY,
|
||
} from './sheetView';
|
||
|
||
/** Side of a resize handle in screen pixels – constant, so handles stay grabbable at any zoom. */
|
||
const HANDLE_SIZE = 8;
|
||
/** Pixels the pointer must travel before a press becomes a drag rather than a click. */
|
||
const DRAG_THRESHOLD = 3;
|
||
/** Below this on-screen frame size the name is illegible clutter and is left out. */
|
||
const MIN_LABEL_SIZE = 28;
|
||
/** Inset of a frame's name plate from the frame's top-left corner, and its own padding. */
|
||
const LABEL_INSET = 3;
|
||
const LABEL_PAD = 2;
|
||
/** The sort line's knob, left of the frame: its gap to the frame and its size, in screen pixels. */
|
||
const SORT_KNOB_GAP = 4;
|
||
const SORT_KNOB_SIZE = 8;
|
||
|
||
/** A handle of the frame's rectangle, one of its nine-slice border lines, or its sort line. */
|
||
interface FrameDrag {
|
||
frameId: string;
|
||
handle: FrameHandle | null;
|
||
edge: BorderEdge | null;
|
||
sortLine: boolean;
|
||
/** The line as it was, raw, so the command records the state that was really there. */
|
||
startSortLine: number | undefined;
|
||
start: FrameRect;
|
||
startBorder: FrameBorders;
|
||
/** The slice keys as they were, so the command records the state that was really there. */
|
||
startSlice: Pick<TextureFrame, 'border' | 'fill'>;
|
||
pointer: Vec2;
|
||
moved: boolean;
|
||
}
|
||
|
||
/**
|
||
* 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[][];
|
||
outlines: Vec2[][];
|
||
/** Which outline, and which corner of it. */
|
||
outline: number;
|
||
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;
|
||
current: Vec2;
|
||
active: boolean;
|
||
}
|
||
|
||
/**
|
||
* The Texture tab's canvas. Everything but the picture is drawn in widget-local pixels rather
|
||
* than in a zoomed container: outlines, handles and names must keep a constant size on screen.
|
||
*/
|
||
export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||
private readonly image = new Sprite();
|
||
/**
|
||
* Outlines and handles, drawn with the pan left out so that a pan only moves them (#220). `sheet`
|
||
* holds every frame but the selected one and is only redrawn when one of those changes; `overlay`
|
||
* holds what a gesture changes.
|
||
*/
|
||
private readonly marks = new Container();
|
||
private readonly sheet = new Graphics();
|
||
private readonly overlay = new Graphics();
|
||
private sheetKey: unknown[] = [];
|
||
private sheetBuilds = 0;
|
||
private sheetOutlines = 0;
|
||
/** Outlines drawn tinted, on the sheet and on the overlay – only the selected frame's should be. */
|
||
private sheetFilled = 0;
|
||
private overlayFilled = 0;
|
||
private readonly labels = new Map<string, Text>();
|
||
private readonly labelLayer = new Container();
|
||
private readonly plates = new Graphics();
|
||
private dragging: FrameDrag | null = null;
|
||
private spanning: NewFrame | null = null;
|
||
private corner: CornerDrag | null = null;
|
||
/** 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 };
|
||
private readonly stopPictures: () => void;
|
||
private readonly perFrame: FramePreview;
|
||
|
||
constructor(private readonly state: EditorState) {
|
||
super();
|
||
this.perFrame = new FramePreview(state, 'texture-drag');
|
||
this.marks.addChild(this.sheet, this.overlay);
|
||
// Plates under the outlines, as before the split: a corner handle must not sink under a name.
|
||
this.content.addChild(this.image, this.plates, this.marks, this.labelLayer);
|
||
|
||
state.on('project', () => this.redraw());
|
||
state.on('selection', () => this.redraw());
|
||
state.on('load', () => {
|
||
this.needsFit = true;
|
||
this.redraw();
|
||
});
|
||
// A picture arrives asynchronously; until then there is nothing to fit the view to.
|
||
this.stopPictures = textureLibrary.onChange(() => this.redraw());
|
||
}
|
||
|
||
/** The library outlives a signed-out editor; a stale listener would redraw destroyed graphics. */
|
||
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
|
||
this.stopPictures();
|
||
super.destroy(options);
|
||
}
|
||
|
||
/** Switching resets the view, since the next picture is a new subject. */
|
||
show(assetId: string): void {
|
||
if (this.assetId === assetId) return;
|
||
this.assetId = assetId;
|
||
this.needsFit = true;
|
||
this.redraw();
|
||
}
|
||
|
||
setCollisionMode(on: boolean): void {
|
||
if (this.collision === on) return;
|
||
this.collision = on;
|
||
this.redraw();
|
||
}
|
||
|
||
get collisionMode(): boolean {
|
||
return this.collision;
|
||
}
|
||
|
||
/** How the marks were drawn: the check scripts prove with it that a pan or a drag rebuilds nothing. */
|
||
get drawInfo() {
|
||
const origin = this.marks.getGlobalPosition();
|
||
return {
|
||
sheetBuilds: this.sheetBuilds,
|
||
sheetOutlines: this.sheetOutlines,
|
||
filled: { sheet: this.sheetFilled, overlay: this.overlayFilled },
|
||
origin: { x: origin.x, y: origin.y },
|
||
sortKnob: this.sortKnob(origin),
|
||
labels: [...this.labels.values()]
|
||
.filter((label) => label.visible)
|
||
.map((label) => ({ text: label.text, ...label.getGlobalPosition() })),
|
||
};
|
||
}
|
||
|
||
/** The middle of the selected frame's sort-line knob on screen, or null without one to grab. */
|
||
private sortKnob(origin: Vec2): Vec2 | null {
|
||
const frame = this.frame(this.selectedFrameId());
|
||
if (!frame || this.collision) return null;
|
||
const at = imageToLocal(
|
||
{ panX: 0, panY: 0, zoom: this.view.zoom },
|
||
frame.x,
|
||
sortLineY(frame, frameSortLine(frame)),
|
||
);
|
||
return { x: origin.x + at.x - SORT_KNOB_GAP - SORT_KNOB_SIZE / 2, y: origin.y + at.y };
|
||
}
|
||
|
||
private asset(): TextureAsset | null {
|
||
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
||
return asset && asset.type === 'texture' ? asset : null;
|
||
}
|
||
|
||
private selectedFrameId(): string {
|
||
const s = this.state.selection;
|
||
return s?.kind === 'frame' && s.assetId === this.assetId ? s.frameId : '';
|
||
}
|
||
|
||
private frame(frameId: string): TextureFrame | undefined {
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Without a known picture size `normalizeFrameRect` has no bounds, and would keep a frame that
|
||
* lies off the sheet.
|
||
*/
|
||
protected pressTarget(): TextureAsset | null {
|
||
const asset = this.asset();
|
||
return asset && asset.width > 0 && asset.height > 0 ? asset : null;
|
||
}
|
||
|
||
protected press(asset: TextureAsset, e: FederatedPointerEvent, local: Vec2): void {
|
||
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
|
||
// not take the grip that resizes the frame – but both before "move", which is the whole inside
|
||
// of the frame and would otherwise swallow every line it holds.
|
||
const selected = this.frame(this.selectedFrameId());
|
||
const onSelected = selected ? handleAt(selected, point, tolerance) : null;
|
||
const onBorder = selected ? borderAt(selected, frameBorders(selected), point, tolerance) : null;
|
||
const onSortLine = selected ? this.sortLineAt(selected, point, tolerance) : null;
|
||
if (selected && onSortLine === 'knob') {
|
||
this.startDrag(selected, null, null, local, true);
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
if (selected && onSelected && onSelected !== 'move') {
|
||
this.startDrag(selected, onSelected, null, local);
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
if (selected && onBorder) {
|
||
this.startDrag(selected, null, onBorder, local);
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
if (selected && onSortLine) {
|
||
this.startDrag(selected, null, null, local, true);
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
if (selected && onSelected) {
|
||
this.startDrag(selected, onSelected, null, local);
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
// Last first: later frames are drawn on top, so they are what a click on an overlap means.
|
||
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 });
|
||
this.startDrag(frame, 'move', null, local);
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
this.spanning = { start: point, current: point, active: false };
|
||
e.stopPropagation();
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
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 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.
|
||
const inside =
|
||
at.x >= -tolerance &&
|
||
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);
|
||
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: 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;
|
||
}
|
||
}
|
||
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.outlines);
|
||
this.perFrame.request();
|
||
}
|
||
|
||
private startDrag(
|
||
frame: TextureFrame,
|
||
handle: FrameHandle | null,
|
||
edge: BorderEdge | null,
|
||
local: Vec2,
|
||
sortLine = false,
|
||
): void {
|
||
this.dragging = {
|
||
frameId: frame.id,
|
||
handle,
|
||
edge,
|
||
sortLine,
|
||
startSortLine: frame.sortLine,
|
||
start: { x: frame.x, y: frame.y, w: frame.w, h: frame.h },
|
||
startBorder: frameBorders(frame),
|
||
startSlice: rawFrameSlice(frame),
|
||
pointer: { x: local.x, y: local.y },
|
||
moved: false,
|
||
};
|
||
}
|
||
|
||
protected move(local: Vec2): void {
|
||
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.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;
|
||
}
|
||
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;
|
||
if (!this.dragging.moved && Math.hypot(dx, dy) * this.view.zoom < DRAG_THRESHOLD) return;
|
||
this.dragging.moved = true;
|
||
this.previewDrag(dx, dy);
|
||
return;
|
||
}
|
||
if (this.spanning) {
|
||
this.spanning.current = localToImage(this.view, local.x, local.y);
|
||
const size = rectBetween(this.spanning.start, this.spanning.current);
|
||
if (!this.spanning.active && Math.max(size.w, size.h) * this.view.zoom < DRAG_THRESHOLD) return;
|
||
this.spanning.active = true;
|
||
this.redraw();
|
||
return;
|
||
}
|
||
this.updateCursor(local);
|
||
}
|
||
|
||
/** Applies a drag to the model directly, as a preview: no undo entry until the pointer is up. */
|
||
private previewDrag(dx: number, dy: number): void {
|
||
const drag = this.dragging;
|
||
const asset = this.asset();
|
||
const frame = drag && this.frame(drag.frameId);
|
||
if (!drag || !asset || !frame) return;
|
||
if (drag.sortLine) setFrameSortLine(frame, normalizeSortLine(drag.startSortLine ?? 0, drag.start.h) - dy);
|
||
else if (drag.edge)
|
||
setFrameSlice(frame, {
|
||
border: dragBorder(drag.startBorder, drag.edge, dx, dy),
|
||
fill: frameFill(frame),
|
||
});
|
||
else if (drag.handle)
|
||
Object.assign(
|
||
frame,
|
||
normalizeFrameRect(dragFrame(drag.start, drag.handle, dx, dy), asset.width, asset.height),
|
||
);
|
||
this.perFrame.request();
|
||
}
|
||
|
||
protected release(): void {
|
||
this.perFrame.cancel();
|
||
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.outlines));
|
||
else this.state.preview();
|
||
return;
|
||
}
|
||
const drag = this.dragging;
|
||
const span = this.spanning;
|
||
this.dragging = null;
|
||
this.spanning = null;
|
||
|
||
if (drag) {
|
||
const asset = this.asset();
|
||
const frame = this.frame(drag.frameId);
|
||
if (drag.moved && asset && frame) {
|
||
// The preview moved it already; put it back so the command records the real start.
|
||
if (drag.sortLine) {
|
||
const next = frameSortLine(frame);
|
||
if (drag.startSortLine === undefined) delete frame.sortLine;
|
||
else frame.sortLine = drag.startSortLine;
|
||
this.state.execute(new SetFrameSortLineCommand(asset.id, drag.frameId, next));
|
||
} else if (drag.edge) {
|
||
const next = { border: frameBorders(frame), fill: frameFill(frame) };
|
||
restoreFrameSlice(frame, drag.startSlice);
|
||
this.state.execute(new SetFrameSliceCommand(asset.id, drag.frameId, next));
|
||
} else {
|
||
const next: FrameRect = { x: frame.x, y: frame.y, w: frame.w, h: frame.h };
|
||
Object.assign(frame, drag.start);
|
||
this.state.execute(new SetFrameRectCommand(asset.id, drag.frameId, next));
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if (!span) return;
|
||
if (!span.active) {
|
||
// A click on empty space clears the selection, like everywhere else in the editor.
|
||
this.state.select({ kind: 'asset', assetId: this.assetId });
|
||
this.redraw();
|
||
return;
|
||
}
|
||
const asset = this.asset();
|
||
if (!asset) return;
|
||
const rect = normalizeFrameRect(rectBetween(span.start, span.current), asset.width, asset.height);
|
||
if (rect.w < MIN_FRAME_SIZE || rect.h < MIN_FRAME_SIZE) return;
|
||
const command = new AddFrameCommand(asset.id, rect);
|
||
if (this.state.execute(command))
|
||
this.state.select({ kind: 'frame', assetId: asset.id, frameId: command.frameId });
|
||
this.redraw();
|
||
}
|
||
|
||
private updateCursor(local: Vec2): void {
|
||
const asset = this.asset();
|
||
if (!asset) {
|
||
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());
|
||
const handle = selected ? handleAt(selected, point, tolerance) : null;
|
||
const onSortLine = selected ? this.sortLineAt(selected, point, tolerance) : null;
|
||
if (onSortLine === 'knob') {
|
||
this.cursor = 'ns-resize';
|
||
return;
|
||
}
|
||
if (handle && handle !== 'move') {
|
||
this.cursor = handleCursor(handle);
|
||
return;
|
||
}
|
||
// Same order as the press: a line inside the frame is a line, not the frame's own body.
|
||
const edge = selected ? borderAt(selected, frameBorders(selected), point, tolerance) : null;
|
||
if (edge) {
|
||
this.cursor = borderCursor(edge);
|
||
return;
|
||
}
|
||
if (onSortLine) {
|
||
this.cursor = 'ns-resize';
|
||
return;
|
||
}
|
||
if (handle) {
|
||
this.cursor = handleCursor(handle);
|
||
return;
|
||
}
|
||
const over = asset.frames.some((f) => handleAt(f, point, tolerance) !== null);
|
||
this.cursor = over ? 'move' : 'crosshair';
|
||
}
|
||
|
||
override redraw(): void {
|
||
this.drawGround();
|
||
const asset = this.asset();
|
||
const texture = asset && asset.file ? this.pictureOf(asset) : null;
|
||
|
||
if (asset && texture && this.needsFit && this.w > 0 && this.h > 0) {
|
||
this.view = fitView(asset.width, asset.height, this.w, this.h);
|
||
this.needsFit = false;
|
||
}
|
||
|
||
this.image.visible = Boolean(texture);
|
||
if (asset && texture) {
|
||
this.image.texture = texture;
|
||
this.image.position.set(this.view.panX, this.view.panY);
|
||
this.image.width = asset.width * this.view.zoom;
|
||
this.image.height = asset.height * this.view.zoom;
|
||
}
|
||
|
||
this.drawChecker(asset, Boolean(texture));
|
||
this.drawFrames(asset);
|
||
this.syncHint(asset, Boolean(texture));
|
||
}
|
||
|
||
private pictureOf(asset: TextureAsset) {
|
||
return textureLibrary.get({
|
||
file: asset.file,
|
||
smooth: textOf(asset.props.filter ?? 'linear') !== 'nearest',
|
||
rect: null,
|
||
sourceWidth: asset.width,
|
||
sourceHeight: asset.height,
|
||
slice: null,
|
||
});
|
||
}
|
||
|
||
/** Clipped to the picture rather than filling the widget, which would hide where it ends. */
|
||
private drawChecker(asset: TextureAsset | null, hasPicture: boolean): void {
|
||
this.checker.clear();
|
||
if (!asset || !hasPicture || asset.width <= 0) return;
|
||
const origin = imageToLocal(this.view, 0, 0);
|
||
const w = asset.width * this.view.zoom;
|
||
const h = asset.height * this.view.zoom;
|
||
drawChecker(this.checker, origin.x, origin.y, w, h, this.w, this.h);
|
||
}
|
||
|
||
/** A pan leaves every outline where it is on the sheet: move them, and place the names anew. */
|
||
protected override panned(): void {
|
||
const asset = this.asset();
|
||
this.image.position.set(this.view.panX, this.view.panY);
|
||
this.drawChecker(asset, this.image.visible);
|
||
this.marks.position.set(this.view.panX, this.view.panY);
|
||
this.syncLabels(asset?.frames ?? [], this.selectedFrameId());
|
||
}
|
||
|
||
private drawFrames(asset: TextureAsset | null): void {
|
||
const view: SheetView = { panX: 0, panY: 0, zoom: this.view.zoom };
|
||
this.marks.position.set(this.view.panX, this.view.panY);
|
||
const frames = asset?.frames ?? [];
|
||
const selectedId = this.selectedFrameId();
|
||
const key = sheetKey(frames, selectedId, view.zoom);
|
||
if (!sameKey(key, this.sheetKey)) {
|
||
this.sheetKey = key;
|
||
this.sheet.clear();
|
||
const others = frames.filter((f) => f.id !== selectedId);
|
||
for (const frame of others) this.drawFrame(this.sheet, view, frame, false);
|
||
this.sheetFilled = 0;
|
||
for (const frame of others)
|
||
this.sheetFilled += this.drawOutlines(this.sheet, view, frame, false, false);
|
||
this.sheetBuilds += 1;
|
||
this.sheetOutlines = others.reduce((n, f) => n + (f.collision?.length ?? 0), 0);
|
||
}
|
||
this.overlay.clear();
|
||
this.overlayFilled = 0;
|
||
const selected = frames.find((f) => f.id === selectedId);
|
||
if (selected) {
|
||
this.drawFrame(this.overlay, view, selected, true);
|
||
this.overlayFilled = this.drawOutlines(this.overlay, view, selected, this.collision, true);
|
||
if (!this.collision) {
|
||
this.drawBorders(view, selected);
|
||
this.drawSortLine(view, selected);
|
||
this.drawHandles(view, selected);
|
||
}
|
||
}
|
||
if (this.spanning?.active) {
|
||
const rect = rectBetween(this.spanning.start, this.spanning.current);
|
||
const a = imageToLocal(view, rect.x, rect.y);
|
||
this.overlay
|
||
.rect(a.x, a.y, rect.w * view.zoom, rect.h * view.zoom)
|
||
.fill({ color: Theme.colors.marquee, alpha: 0.15 })
|
||
.stroke({ width: 1, color: Theme.colors.marquee });
|
||
}
|
||
this.syncLabels(frames, selectedId);
|
||
}
|
||
|
||
private drawFrame(g: Graphics, view: SheetView, frame: TextureFrame, selected: boolean): void {
|
||
const a = imageToLocal(view, frame.x, frame.y);
|
||
const w = frame.w * view.zoom;
|
||
const h = frame.h * view.zoom;
|
||
const color = selected ? Theme.colors.accent : Theme.colors.collider;
|
||
g.rect(a.x + 0.5, a.y + 0.5, w - 1, h - 1).stroke({ width: selected ? 2 : 1, color });
|
||
}
|
||
|
||
/**
|
||
* The four nine-slice lines of the selected frame (#41). Drawn even when they all sit on its
|
||
* edges: a frame nobody sliced still has them, and a line one cannot see is one nobody drags.
|
||
*/
|
||
private drawBorders(view: SheetView, frame: TextureFrame): void {
|
||
const border = frameBorders(frame);
|
||
for (const edge of BORDER_EDGES) {
|
||
const line = borderLine(frame, border, edge);
|
||
const a = imageToLocal(view, line.x1, line.y1);
|
||
const b = imageToLocal(view, line.x2, line.y2);
|
||
dashedLine(this.overlay, a.x, a.y, b.x, b.y, 4, 3);
|
||
}
|
||
this.overlay.stroke({ width: 1, color: Theme.colors.marquee });
|
||
}
|
||
|
||
/** The sort line (#228) across the selected frame, with its knob left of it: a line at 0 is on the edge. */
|
||
private drawSortLine(view: SheetView, frame: TextureFrame): void {
|
||
const y = sortLineY(frame, frameSortLine(frame));
|
||
const a = imageToLocal(view, frame.x, y);
|
||
const b = imageToLocal(view, frame.x + frame.w, y);
|
||
dashedLine(this.overlay, a.x, a.y, b.x, b.y, 6, 3);
|
||
this.overlay.stroke({ width: 1, color: Theme.colors.hot });
|
||
const tip = a.x - SORT_KNOB_GAP;
|
||
const half = SORT_KNOB_SIZE / 2;
|
||
this.overlay
|
||
.poly([tip, a.y, tip - SORT_KNOB_SIZE, a.y - half, tip - SORT_KNOB_SIZE, a.y + half], true)
|
||
.fill({ color: Theme.colors.hot })
|
||
.stroke({ width: 1, color: Theme.colors.symbolOutline });
|
||
}
|
||
|
||
private sortLineAt(frame: TextureFrame, point: Vec2, tolerance: number): 'knob' | 'line' | null {
|
||
const reach = (SORT_KNOB_GAP + SORT_KNOB_SIZE + 2) / this.view.zoom;
|
||
return sortLineAt(frame, frameSortLine(frame), point, tolerance, reach);
|
||
}
|
||
|
||
/**
|
||
* 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. Only the selected frame's outlines are
|
||
* `filled`, so the tint does not lie over the art of every other frame. Returns how many were tinted.
|
||
*/
|
||
private drawOutlines(
|
||
g: Graphics,
|
||
view: SheetView,
|
||
frame: TextureFrame,
|
||
edited: boolean,
|
||
filled: boolean,
|
||
): number {
|
||
const outlines = frame.collision ?? [];
|
||
const active = edited ? this.activeOutline(frame, outlines) : -1;
|
||
let tinted = 0;
|
||
for (const [index, points] of outlines.entries()) {
|
||
const screen = points.map((p) => imageToLocal(view, frame.x + p.x, frame.y + p.y));
|
||
const color = isPolygon(points) ? Theme.colors.collider : Theme.colors.danger;
|
||
const width = index === active ? 2 : 1;
|
||
if (screen.length >= 3) {
|
||
g.poly(screen, true);
|
||
if (filled) {
|
||
g.fill({ color, alpha: COLLIDER_FILL_ALPHA });
|
||
tinted += 1;
|
||
}
|
||
g.stroke({ width, color });
|
||
} else if (screen.length === 2)
|
||
g.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)
|
||
g.rect(c.x - half, c.y - half, HANDLE_SIZE, HANDLE_SIZE)
|
||
.fill(index === active ? Theme.colors.accentText : Theme.colors.viewport)
|
||
.stroke({ width: 1, color });
|
||
}
|
||
return tinted;
|
||
}
|
||
|
||
private drawHandles(view: SheetView, frame: TextureFrame): void {
|
||
const half = HANDLE_SIZE / 2;
|
||
for (const handle of frameHandles()) {
|
||
const p = handlePoint(frame, handle);
|
||
const local = imageToLocal(view, p.x, p.y);
|
||
this.overlay
|
||
.rect(local.x - half, local.y - half, HANDLE_SIZE, HANDLE_SIZE)
|
||
.fill(Theme.colors.accentText)
|
||
.stroke({ width: 1, color: Theme.colors.accent });
|
||
}
|
||
}
|
||
|
||
/** Names stay in screen space and only on frames in view, so a pan places them anew. */
|
||
private syncLabels(frames: readonly TextureFrame[], selectedId: string): void {
|
||
this.plates.clear();
|
||
const alive = new Set<string>();
|
||
for (const frame of frames) {
|
||
alive.add(frame.id);
|
||
const at = imageToLocal(this.view, frame.x, frame.y);
|
||
this.syncLabel(frame, at, frame.w * this.view.zoom, frame.h * this.view.zoom, frame.id === selectedId);
|
||
}
|
||
for (const [id, label] of this.labels) {
|
||
if (alive.has(id)) continue;
|
||
label.destroy();
|
||
this.labels.delete(id);
|
||
}
|
||
}
|
||
|
||
private syncLabel(frame: TextureFrame, at: Vec2, w: number, h: number, selected: boolean): void {
|
||
let label = this.labels.get(frame.id);
|
||
// A name that does not fit inside its frame would be read as belonging to the neighbour.
|
||
const show = Math.min(w, h) >= MIN_LABEL_SIZE && at.x < this.w && at.y < this.h && at.x + w > 0;
|
||
if (!show) {
|
||
if (label) label.visible = false;
|
||
return;
|
||
}
|
||
if (!label) {
|
||
label = new Text({ style: textStyle('viewportLabel') });
|
||
this.labels.set(frame.id, label);
|
||
this.labelLayer.addChild(label);
|
||
}
|
||
label.visible = true;
|
||
if (label.text !== frame.name) label.text = frame.name;
|
||
const x = at.x + LABEL_INSET;
|
||
const y = at.y + LABEL_INSET;
|
||
label.position.set(x + LABEL_PAD, y + LABEL_PAD);
|
||
// A dark plate, not just a text colour: the name is drawn on the user's picture, where no
|
||
// fixed colour is legible against every background.
|
||
this.plates
|
||
.roundRect(x, y, label.width + LABEL_PAD * 2, label.height + LABEL_PAD * 2, 2)
|
||
.fill({ color: 0x000000, alpha: selected ? 0.75 : 0.55 });
|
||
}
|
||
|
||
private syncHint(asset: TextureAsset | null, hasPicture: boolean): void {
|
||
const text = !asset
|
||
? 'No texture selected – pick one in the Assets panel or import an image.'
|
||
: this.collision && asset.file && hasPicture
|
||
? this.selectedFrameId()
|
||
? '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
|
||
? '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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* What the unselected frames are drawn from. The model replaces a frame's outline list when it
|
||
* changes rather than editing it, so the list itself stands for its corners.
|
||
*/
|
||
function sheetKey(frames: readonly TextureFrame[], selectedId: string, zoom: number): unknown[] {
|
||
const key: unknown[] = [zoom, selectedId];
|
||
for (const f of frames) if (f.id !== selectedId) key.push(f, f.x, f.y, f.w, f.h, f.collision);
|
||
return key;
|
||
}
|
||
|
||
function sameKey(a: readonly unknown[], b: readonly unknown[]): boolean {
|
||
return a.length === b.length && a.every((v, i) => v === b[i]);
|
||
}
|
||
|
||
/**
|
||
* Whether an outline is a polygon, remembered per outline for the same reason. Working it out on
|
||
* every redraw was half of what a sheet with many outlines cost per pan (#220).
|
||
*/
|
||
const polygonOutlines = new WeakMap<readonly Vec2[], boolean>();
|
||
function isPolygon(points: readonly Vec2[]): boolean {
|
||
let valid = polygonOutlines.get(points);
|
||
if (valid === undefined) polygonOutlines.set(points, (valid = polygonProblem(points) === ''));
|
||
return valid;
|
||
}
|
||
|
||
/** 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))),
|
||
};
|
||
}
|