game-maker / suite (push) Successful in 6m21s
TextureFrame carries optional `border` (left/right/top/bottom) and `fill` (stretch | repeat); absent keys mean "not sliced", so no format bump and undo restores the plain frame instead of four zeroes. The sliced sprite builds its own geometry instead of the shared UNIT_QUAD: a 4x4 grid, and for `repeat` one quad per tile carrying the frame cell's real UVs -- tiling in the geometry, not in the sampler, so a frame out of a sheet cannot pull its neighbour in. Above 64 tiles per axis it stretches, to bound the buffer. A sprite narrower than left+right scales both borders down together until the middle is exactly 0, so the corners meet at a seam and never overlap. The texture editor gets four draggable border lines plus the same four numbers in the inspector. Hit order is resize handles, then the lines, then the frame body -- handleAt reports 'move' anywhere inside a frame, so lines placed after it were unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
519 lines
18 KiB
TypeScript
519 lines
18 KiB
TypeScript
import {
|
||
Container,
|
||
type FederatedPointerEvent,
|
||
type FederatedWheelEvent,
|
||
Graphics,
|
||
Sprite,
|
||
Text,
|
||
} from 'pixi.js';
|
||
import {
|
||
AddFrameCommand,
|
||
SetFrameRectCommand,
|
||
SetFrameSliceCommand,
|
||
} from '../../core/commands/frameCommands';
|
||
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,
|
||
frameFill,
|
||
type FrameRect,
|
||
MIN_FRAME_SIZE,
|
||
normalizeFrameRect,
|
||
rawFrameSlice,
|
||
restoreFrameSlice,
|
||
setFrameSlice,
|
||
} from '../../core/model/texture';
|
||
import { type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
|
||
import { dashedLine } from '../../viewport/symbols';
|
||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||
import type { EditorState } from '../state';
|
||
import { drawChecker } from './checker';
|
||
import {
|
||
borderAt,
|
||
borderCursor,
|
||
type BorderEdge,
|
||
BORDER_EDGES,
|
||
borderLine,
|
||
dragBorder,
|
||
dragFrame,
|
||
fitView,
|
||
type FrameHandle,
|
||
frameHandles,
|
||
handleAt,
|
||
handleCursor,
|
||
handlePoint,
|
||
imageToLocal,
|
||
localToImage,
|
||
rectBetween,
|
||
type SheetView,
|
||
zoomAbout,
|
||
} 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;
|
||
|
||
/** Either a handle of the frame's rectangle or one of its nine-slice border lines – never both. */
|
||
interface FrameDrag {
|
||
frameId: string;
|
||
handle: FrameHandle | null;
|
||
edge: BorderEdge | null;
|
||
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 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 Widget {
|
||
private readonly bg = new Graphics();
|
||
/** Everything the view can move: panned or zoomed, all of it reaches past the widget. */
|
||
private readonly content = new Container();
|
||
private readonly checker = new Graphics();
|
||
private readonly image = new Sprite();
|
||
private readonly overlay = new Graphics();
|
||
private readonly labels = new Map<string, Text>();
|
||
private readonly labelLayer = new Container();
|
||
private readonly hint = new Text({ text: '', style: textStyle('muted') });
|
||
private readonly clip = new Graphics();
|
||
private view: SheetView = { panX: 0, panY: 0, zoom: 1 };
|
||
private assetId = '';
|
||
/** Fit the picture once the widget has a real size and a picture to fit (it starts hidden). */
|
||
private needsFit = true;
|
||
private panning: { pointer: Vec2; panX: number; panY: number } | null = null;
|
||
private dragging: FrameDrag | null = null;
|
||
private spanning: NewFrame | null = null;
|
||
|
||
constructor(private readonly state: EditorState) {
|
||
super();
|
||
this.content.addChild(this.checker, this.image, this.overlay, this.labelLayer);
|
||
this.addChild(this.bg, this.content, this.hint, this.clip);
|
||
this.content.mask = this.clip;
|
||
this.content.eventMode = 'none';
|
||
this.hint.eventMode = 'none';
|
||
this.focusable = true;
|
||
|
||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
|
||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||
this.on('pointerup', () => this.onPointerUp());
|
||
this.on('pointerupoutside', () => this.onPointerUp());
|
||
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
|
||
|
||
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.
|
||
textureLibrary.onChange(() => this.redraw());
|
||
}
|
||
|
||
override measure(): Size {
|
||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||
}
|
||
|
||
/** 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();
|
||
}
|
||
|
||
get currentAssetId(): string {
|
||
return this.assetId;
|
||
}
|
||
|
||
fit(): void {
|
||
this.needsFit = true;
|
||
this.redraw();
|
||
}
|
||
|
||
/** Where an image pixel sits on screen; only the check scripts need it, to aim drags. */
|
||
imageToScreen(x: number, y: number): Vec2 {
|
||
const local = imageToLocal(this.view, x, y);
|
||
const origin = this.getGlobalPosition();
|
||
return { x: origin.x + local.x, y: origin.y + local.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 onPointerDown(e: FederatedPointerEvent): void {
|
||
const asset = this.asset();
|
||
// Without a known picture size `normalizeFrameRect` has no bounds, and would keep a frame
|
||
// that lies off the sheet.
|
||
if (!asset || asset.width <= 0 || asset.height <= 0) return;
|
||
const local = this.toLocal(e.global);
|
||
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
|
||
this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
if (e.button !== 0) return;
|
||
ui().focus.focus(this);
|
||
const point = localToImage(this.view, local.x, local.y);
|
||
const tolerance = HANDLE_SIZE / this.view.zoom;
|
||
|
||
// 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;
|
||
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 && 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();
|
||
}
|
||
|
||
private startDrag(
|
||
frame: TextureFrame,
|
||
handle: FrameHandle | null,
|
||
edge: BorderEdge | null,
|
||
local: Vec2,
|
||
): void {
|
||
this.dragging = {
|
||
frameId: frame.id,
|
||
handle,
|
||
edge,
|
||
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,
|
||
};
|
||
}
|
||
|
||
private onPointerMove(e: FederatedPointerEvent): void {
|
||
const local = this.toLocal(e.global);
|
||
if (this.panning) {
|
||
this.view = {
|
||
...this.view,
|
||
panX: this.panning.panX + (local.x - this.panning.pointer.x),
|
||
panY: this.panning.panY + (local.y - this.panning.pointer.y),
|
||
};
|
||
this.redraw();
|
||
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.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.state.preview();
|
||
}
|
||
|
||
private onPointerUp(): void {
|
||
this.panning = null;
|
||
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.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 onWheel(e: FederatedWheelEvent): void {
|
||
const local = this.toLocal(e.global);
|
||
this.view = zoomAbout(this.view, zoomFactor(e.deltaY), local.x, local.y);
|
||
this.needsFit = false;
|
||
this.redraw();
|
||
e.preventDefault();
|
||
}
|
||
|
||
private updateCursor(local: Vec2): void {
|
||
const asset = this.asset();
|
||
if (!asset) {
|
||
this.cursor = 'default';
|
||
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;
|
||
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 (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.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport);
|
||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
||
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);
|
||
}
|
||
|
||
private drawFrames(asset: TextureAsset | null): void {
|
||
this.overlay.clear();
|
||
const alive = new Set<string>();
|
||
if (asset) {
|
||
const selectedId = this.selectedFrameId();
|
||
for (const frame of asset.frames) {
|
||
alive.add(frame.id);
|
||
this.drawFrame(frame, frame.id === selectedId);
|
||
}
|
||
const selected = this.frame(selectedId);
|
||
if (selected) {
|
||
this.drawBorders(selected);
|
||
this.drawHandles(selected);
|
||
}
|
||
}
|
||
if (this.spanning?.active) {
|
||
const rect = rectBetween(this.spanning.start, this.spanning.current);
|
||
const a = imageToLocal(this.view, rect.x, rect.y);
|
||
this.overlay
|
||
.rect(a.x, a.y, rect.w * this.view.zoom, rect.h * this.view.zoom)
|
||
.fill({ color: Theme.colors.marquee, alpha: 0.15 })
|
||
.stroke({ width: 1, color: Theme.colors.marquee });
|
||
}
|
||
for (const [id, label] of this.labels) {
|
||
if (alive.has(id)) continue;
|
||
label.destroy();
|
||
this.labels.delete(id);
|
||
}
|
||
}
|
||
|
||
private drawFrame(frame: TextureFrame, selected: boolean): void {
|
||
const a = imageToLocal(this.view, frame.x, frame.y);
|
||
const w = frame.w * this.view.zoom;
|
||
const h = frame.h * this.view.zoom;
|
||
const color = selected ? Theme.colors.accent : Theme.colors.collider;
|
||
this.overlay.rect(a.x + 0.5, a.y + 0.5, w - 1, h - 1).stroke({ width: selected ? 2 : 1, color });
|
||
this.syncLabel(frame, a, w, h, selected);
|
||
}
|
||
|
||
/**
|
||
* 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(frame: TextureFrame): void {
|
||
const border = frameBorders(frame);
|
||
for (const edge of BORDER_EDGES) {
|
||
const line = borderLine(frame, border, edge);
|
||
const a = imageToLocal(this.view, line.x1, line.y1);
|
||
const b = imageToLocal(this.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 });
|
||
}
|
||
|
||
private drawHandles(frame: TextureFrame): void {
|
||
const half = HANDLE_SIZE / 2;
|
||
for (const handle of frameHandles()) {
|
||
const p = handlePoint(frame, handle);
|
||
const local = imageToLocal(this.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 });
|
||
}
|
||
}
|
||
|
||
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.overlay
|
||
.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.'
|
||
: !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);
|
||
}
|
||
}
|