A frame that keeps its corners can be stretched to any size (#41)
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>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-16 12:04:09 +02:00
1 parent b9f1a9d319
commit be8f2331de
17 files changed
+1091 -31

No files matched your search

+103 -1
View File
@@ -1,5 +1,5 @@
import { animationFrameAt } from './animation';
import type { TextureAsset, TextureFrame } from './Asset';
import type { FrameBorders, FrameFill, TextureAsset, TextureFrame } from './Asset';
import { type PropertyBag, textOf } from './Properties';
import { findAsset, type Project } from './Project';
@@ -30,6 +30,21 @@ export interface TextureRef {
rect: FrameRect | null;
sourceWidth: number;
sourceHeight: number;
/** Set when the frame this ref names is sliced (#41); null means "draw it over the whole box". */
slice: NineSlice | null;
}
/**
* A sliced frame as the renderer needs it: the borders, how the parts between them fill, and the
* size they are measured in. Carried on the ref rather than looked up while drawing, for the same
* reason the rectangle is – drawing has no access to the project.
*/
export interface NineSlice {
border: FrameBorders;
fill: FrameFill;
/** Size of the frame in image pixels; the borders are distances inside it. */
sourceW: number;
sourceH: number;
}
export interface FrameRef {
@@ -87,9 +102,96 @@ function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | n
rect: frame ? { x: frame.x, y: frame.y, w: frame.w, h: frame.h } : null,
sourceWidth: asset.width,
sourceHeight: asset.height,
slice: frame ? nineSliceOf(frame) : null,
};
}
export const NO_BORDERS: FrameBorders = { left: 0, top: 0, right: 0, bottom: 0 };
export const DEFAULT_FILL: FrameFill = 'stretch';
/** Whole pixels of the frame that must stay between two opposite borders (see `normalizeBorders`). */
export const MIN_SLICE_MIDDLE = 1;
export function frameBorders(frame: TextureFrame): FrameBorders {
return frame.border ? { ...frame.border } : { ...NO_BORDERS };
}
export function frameFill(frame: TextureFrame): FrameFill {
return frame.fill === 'repeat' ? 'repeat' : DEFAULT_FILL;
}
export function hasBorders(border: FrameBorders): boolean {
return border.left > 0 || border.top > 0 || border.right > 0 || border.bottom > 0;
}
/** What a frame is sliced into, or null when nobody sliced it – then a sprite draws it as one piece. */
export function nineSliceOf(frame: TextureFrame): NineSlice | null {
const border = normalizeBorders(frameBorders(frame), frame.w, frame.h);
if (!hasBorders(border)) return null;
return { border, fill: frameFill(frame), sourceW: frame.w, sourceH: frame.h };
}
/**
* Whole pixels, never negative, and never eating the whole frame: two opposite borders are scaled
* down together until at least one pixel is left between them. A middle of no pixels has nothing
* to draw the space between the corners with, which would show as a hole rather than as an error.
*/
export function normalizeBorders(border: FrameBorders, w: number, h: number): FrameBorders {
const [left, right] = fitPair(border.left, border.right, w);
const [top, bottom] = fitPair(border.top, border.bottom, h);
return { left, top, right, bottom };
}
function fitPair(near: number, far: number, size: number): [number, number] {
const a = Math.max(0, Math.round(Number.isFinite(near) ? near : 0));
const b = Math.max(0, Math.round(Number.isFinite(far) ? far : 0));
const room = Math.max(0, Math.floor(size) - MIN_SLICE_MIDDLE);
if (a + b <= room) return [a, b];
if (room <= 0) return [0, 0];
// Proportional, so a frame that is dragged small keeps the shape of its corners.
const scaled = Math.round((a / (a + b)) * room);
return [scaled, room - scaled];
}
/** The slice settings of a frame, as one value – what an inspector row and a command pass around. */
export interface FrameSlice {
border: FrameBorders;
fill: FrameFill;
}
export function frameSlice(frame: TextureFrame): FrameSlice {
return { border: frameBorders(frame), fill: frameFill(frame) };
}
/**
* Writes the slice back, dropping the keys again once they mean nothing: a frame nobody sliced
* reads and saves exactly as it did before #41, so undo of the first slice restores the old shape.
*/
export function setFrameSlice(frame: TextureFrame, slice: FrameSlice): void {
const border = normalizeBorders(slice.border, frame.w, frame.h);
if (hasBorders(border)) frame.border = border;
else delete frame.border;
// Kept even without borders: a fill chosen before the borders are dragged would otherwise
// snap back to "stretch" under the hand that just set it.
if (slice.fill === 'repeat') frame.fill = 'repeat';
else delete frame.fill;
}
/** The slice keys exactly as the frame carries them – absence included, which undo owes back. */
export function rawFrameSlice(frame: TextureFrame): Pick<TextureFrame, 'border' | 'fill'> {
const raw: Pick<TextureFrame, 'border' | 'fill'> = {};
if (frame.border) raw.border = { ...frame.border };
if (frame.fill) raw.fill = frame.fill;
return raw;
}
export function restoreFrameSlice(frame: TextureFrame, raw: Pick<TextureFrame, 'border' | 'fill'>): void {
if (raw.border) frame.border = { ...raw.border };
else delete frame.border;
if (raw.fill) frame.fill = raw.fill;
else delete frame.fill;
}
/**
* Rounds to whole pixels and forces the rectangle inside the image: a fractional frame samples
* half of a neighbouring pixel along every edge, the classic sprite-sheet seam.