A frame that keeps its corners can be stretched to any size (#41)
game-maker / suite (push) Successful in 6m21s
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:
1 parent
b9f1a9d319
commit
be8f2331de
17 files changed
+1091
-31
No files matched your search
@@ -8,9 +8,13 @@ import type { Project } from '../model/Project';
|
||||
import {
|
||||
frameRect,
|
||||
type FrameRect,
|
||||
type FrameSlice,
|
||||
nextFrameNames,
|
||||
normalizeFrameRect,
|
||||
rawFrameSlice,
|
||||
restoreFrameSlice,
|
||||
setFrameRect,
|
||||
setFrameSlice,
|
||||
sliceFrames,
|
||||
type SliceOptions,
|
||||
uniqueFrameName,
|
||||
@@ -88,6 +92,32 @@ export class SetFrameRectCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The four borders and the fill of one frame (#41), in one command: dragging a border line and
|
||||
* picking a fill are the same edit to the reader of an undo list, and splitting them would make
|
||||
* "undo" take back half of what the eye saw change.
|
||||
*/
|
||||
export class SetFrameSliceCommand implements Command {
|
||||
readonly label = 'Nine-slice';
|
||||
private previous: Pick<TextureFrame, 'border' | 'fill'> = {};
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly frameId: string,
|
||||
private readonly slice: FrameSlice,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const frame = requireFrame(requireTexture(project, this.assetId), this.frameId);
|
||||
this.previous = rawFrameSlice(frame);
|
||||
setFrameSlice(frame, this.slice);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
restoreFrameSlice(requireFrame(requireTexture(project, this.assetId), this.frameId), this.previous);
|
||||
}
|
||||
}
|
||||
|
||||
export class RenameFrameCommand implements Command {
|
||||
readonly label = 'Rename frame';
|
||||
private previous = '';
|
||||
|
||||
+18
-1
@@ -61,7 +61,22 @@ export interface ContainerAsset extends AssetBase {
|
||||
objects: SceneObject[];
|
||||
}
|
||||
|
||||
/** `id` shares the generator with asset ids and is unique across the document (see `model/texture.ts`). */
|
||||
/** How the parts between the corners of a nine-slice frame fill the space they are given (#41). */
|
||||
export type FrameFill = 'stretch' | 'repeat';
|
||||
|
||||
/** Distances from the frame's own edges, in image pixels; all zero means the frame is not sliced. */
|
||||
export interface FrameBorders {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `id` shares the generator with asset ids and is unique across the document (see `model/texture.ts`).
|
||||
* `border` and `fill` are absent on a frame nobody sliced, which is what every document written
|
||||
* before #41 says – read them through `frameBorders`/`frameFill`, never off the object.
|
||||
*/
|
||||
export interface TextureFrame {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -69,6 +84,8 @@ export interface TextureFrame {
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
border?: FrameBorders;
|
||||
fill?: FrameFill;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+103
-1
@@ -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.
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||||
import { readScope } from '../model/Scope';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { isTextureFile, normalizeFrameRect } from '../model/texture';
|
||||
import { isTextureFile, normalizeBorders, normalizeFrameRect, setFrameSlice } from '../model/texture';
|
||||
import { hasParentCycle } from '../model/walk';
|
||||
import {
|
||||
DECLARED_PORT_KINDS,
|
||||
@@ -433,7 +433,24 @@ function normalizeTexture(
|
||||
width,
|
||||
height,
|
||||
);
|
||||
frames.push({ id, name: typeof f.name === 'string' ? f.name : '', ...rect });
|
||||
const frame: TextureFrame = { id, name: typeof f.name === 'string' ? f.name : '', ...rect };
|
||||
// Against the frame, not the image: the borders are distances inside the cut-out. A frame
|
||||
// without them is one nobody sliced, and keeps saying so.
|
||||
const border = asRecord(f.border);
|
||||
setFrameSlice(frame, {
|
||||
border: normalizeBorders(
|
||||
{
|
||||
left: toNumber(border.left),
|
||||
top: toNumber(border.top),
|
||||
right: toNumber(border.right),
|
||||
bottom: toNumber(border.bottom),
|
||||
},
|
||||
rect.w,
|
||||
rect.h,
|
||||
),
|
||||
fill: f.fill === 'repeat' ? 'repeat' : 'stretch',
|
||||
});
|
||||
frames.push(frame);
|
||||
}
|
||||
return { file, width, height, frames };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user