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

+30
View File
@@ -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
View File
@@ -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
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.
+19 -2
View File
@@ -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 };
}
+22 -8
View File
@@ -227,6 +227,15 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const holder = findObjectHolder(state.project, holderId);
return holder ? resolveObjects(state.project, holder) : [];
};
/** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */
const pixelAt = (x: number, y: number): string => {
const { pixels } = app.renderer.extract.pixels({
target: app.stage,
frame: new Rectangle(x, y, 1, 1),
});
const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(2, '0');
return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`;
};
(window as unknown as { __gm: unknown }).__gm = {
state,
/** The Pixi application, for probes that need the renderer or the stage. */
@@ -490,14 +499,19 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
hierarchyRowColor: (id: string) => hierarchy.rowColor(id),
hierarchyExpandAll: () => hierarchy.expandAll(),
hierarchyCollapseAll: () => hierarchy.collapseAll(),
/** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */
pixelAt: (x: number, y: number) => {
const { pixels } = app.renderer.extract.pixels({
target: app.stage,
frame: new Rectangle(x, y, 1, 1),
});
const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(2, '0');
return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`;
pixelAt,
/**
* A horizontal run of pixels in one read. A check that has to tell a repeated pattern from a
* stretched one needs dozens of samples, and dozens of round trips would cost more than the
* step it belongs to.
*/
pixelRow: (x: number, y: number, count: number) => {
const width = Math.max(1, Math.min(1024, Math.round(count)));
const row: string[] = [];
// One extract per pixel, not one strip: a wider frame comes back resampled, and a row that
// blends its neighbours cannot tell a hard border from a soft one – which is what it is for.
for (let i = 0; i < width; i++) row.push(pixelAt(x + i, y));
return row;
},
/** The filter and wrap the GPU sampler actually got, next to the model's – only the driver knows the former. */
textureFilter: (file: string, rect: FrameRect | null = null) => {
+96 -2
View File
@@ -2,10 +2,22 @@ import {
RemoveFrameCommand,
RenameFrameCommand,
SetFrameRectCommand,
SetFrameSliceCommand,
} from '../../core/commands/frameCommands';
import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset';
import { type PropertyValue, textOf } from '../../core/model/Properties';
import { findAssetOf, type Project } from '../../core/model/Project';
import { frameRect, type FrameRect, normalizeFrameRect, setFrameRect } from '../../core/model/texture';
import {
frameBorders,
frameFill,
frameRect,
type FrameRect,
frameSlice,
type FrameSlice,
normalizeFrameRect,
setFrameRect,
setFrameSlice,
} from '../../core/model/texture';
import { Button } from '../../ui';
import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host';
@@ -18,6 +30,13 @@ const RECT_COMPONENTS = [
{ key: 'h', label: 'Height' },
] as const;
const BORDER_COMPONENTS = [
{ key: 'left', label: 'Left' },
{ key: 'top', label: 'Top' },
{ key: 'right', label: 'Right' },
{ key: 'bottom', label: 'Bottom' },
] as const;
/** Structure-defining facts of a frame selection: none – only the frame disappearing rebuilds. */
export function frameSignature(project: Project, assetId: string, frameId: string): string {
return currentFrame(project, assetId, frameId) ? 'frame' : 'missing';
@@ -44,6 +63,12 @@ export function buildFrameSection(host: InspectorHost, assetId: string, frameId:
for (const component of RECT_COMPONENTS)
rectRow(host, assetId, frameId, component.key, component.label, frame[component.key]);
addTitle(host, 'NINE-SLICE (IMAGE PIXELS)');
for (const component of BORDER_COMPONENTS)
borderRow(host, assetId, frameId, component.key, component.label);
fillRow(host, assetId, frameId);
addNote(host, 'All four at zero: the picture is drawn over the whole box, as it always was.');
addGap(host);
host.body.add(
new Button('Delete frame', {
@@ -59,9 +84,78 @@ export function refreshFrameSection(host: InspectorHost, assetId: string, frameI
if (!frame) return false;
host.nameField?.setValue(frame.name);
for (const component of RECT_COMPONENTS) host.fields.get(component.key)?.setValue(frame[component.key]);
const border = frameBorders(frame);
for (const component of BORDER_COMPONENTS)
host.fields.get(`border-${component.key}`)?.setValue(border[component.key]);
host.fields.get('fill')?.setValue(frameFill(frame));
return true;
}
/**
* One border of the nine-slice, in pixels of the frame. The row keeps the other three and the fill
* as they are at that moment, so two rows edited one after the other do not undo each other.
*/
function borderRow(
host: InspectorHost,
assetId: string,
frameId: string,
key: keyof FrameBorders,
label: string,
): void {
const frame = currentFrame(host.state.project, assetId, frameId);
const row = createFieldRow(
host,
{ key: `border-${key}`, label, kind: 'number', default: 0, min: 0, step: 1, decimals: 0 },
frame ? frameBorders(frame)[key] : 0,
sliceHandlers(host, assetId, frameId, (input, slice) => ({
...slice,
border: { ...slice.border, [key]: Number(input) },
})),
);
host.fields.set(`border-${key}`, row);
host.body.add(row.row);
}
/** How the parts between the corners fill their space – one choice for the edges and the middle. */
function fillRow(host: InspectorHost, assetId: string, frameId: string): void {
const frame = currentFrame(host.state.project, assetId, frameId);
const row = createFieldRow(
host,
{
key: 'fill',
label: 'Fill',
kind: 'enum',
default: 'stretch',
options: [
{ value: 'stretch', label: 'Stretch' },
{ value: 'repeat', label: 'Repeat' },
],
},
frame ? frameFill(frame) : 'stretch',
sliceHandlers(host, assetId, frameId, (input, slice) => ({
...slice,
fill: textOf(input) === 'repeat' ? 'repeat' : 'stretch',
})),
);
host.fields.set('fill', row);
host.body.add(row.row);
}
function sliceHandlers(
host: InspectorHost,
assetId: string,
frameId: string,
apply: (input: PropertyValue, slice: FrameSlice) => FrameSlice,
): FieldHandlers {
return previewingHandlers<TextureFrame, FrameSlice>(host, {
target: () => currentFrame(host.state.project, assetId, frameId),
read: frameSlice,
write: setFrameSlice,
toModel: (input, frame) => apply(input, frameSlice(frame)),
commit: (slice) => new SetFrameSliceCommand(assetId, frameId, slice),
});
}
/** A number row editing one component of the rectangle. Whole pixels: a frame indexes a bitmap. */
function rectRow(
host: InspectorHost,
+103 -15
View File
@@ -6,16 +6,36 @@ import {
Sprite,
Text,
} from 'pixi.js';
import { AddFrameCommand, SetFrameRectCommand } from '../../core/commands/frameCommands';
import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
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 { type FrameRect, MIN_FRAME_SIZE, normalizeFrameRect } from '../../core/model/texture';
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,
@@ -40,10 +60,15 @@ const MIN_LABEL_SIZE = 28;
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;
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;
}
@@ -162,10 +187,24 @@ export class TextureCanvas extends Widget {
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, local);
this.startDrag(selected, onSelected, null, local);
e.stopPropagation();
return;
}
@@ -174,7 +213,7 @@ export class TextureCanvas extends Widget {
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', local);
this.startDrag(frame, 'move', null, local);
e.stopPropagation();
return;
}
@@ -182,11 +221,19 @@ export class TextureCanvas extends Widget {
e.stopPropagation();
}
private startDrag(frame: TextureFrame, handle: FrameHandle, local: Vec2): void {
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,
};
@@ -228,10 +275,16 @@ export class TextureCanvas extends Widget {
const asset = this.asset();
const frame = drag && this.frame(drag.frameId);
if (!drag || !asset || !frame) return;
Object.assign(
frame,
normalizeFrameRect(dragFrame(drag.start, drag.handle, dx, dy), asset.width, asset.height),
);
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();
}
@@ -247,9 +300,15 @@ export class TextureCanvas extends Widget {
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.
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));
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;
}
@@ -288,6 +347,16 @@ export class TextureCanvas extends Widget {
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;
@@ -327,6 +396,7 @@ export class TextureCanvas extends Widget {
rect: null,
sourceWidth: asset.width,
sourceHeight: asset.height,
slice: null,
});
}
@@ -350,7 +420,10 @@ export class TextureCanvas extends Widget {
this.drawFrame(frame, frame.id === selectedId);
}
const selected = this.frame(selectedId);
if (selected) this.drawHandles(selected);
if (selected) {
this.drawBorders(selected);
this.drawHandles(selected);
}
}
if (this.spanning?.active) {
const rect = rectBetween(this.spanning.start, this.spanning.current);
@@ -376,6 +449,21 @@ export class TextureCanvas extends Widget {
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()) {
+63
View File
@@ -1,4 +1,5 @@
/** Geometry for the Texture tab: no Pixi and no editor state, so it stays unit-testable. */
import type { FrameBorders } from '../../core/model/Asset';
import type { FrameRect } from '../../core/model/texture';
import type { Vec2 } from '../../core/model/Properties';
@@ -108,6 +109,68 @@ export function dragFrame(start: FrameRect, handle: FrameHandle, dx: number, dy:
return rect;
}
/** The four nine-slice border lines of a frame (#41), named by the frame edge each runs beside. */
export type BorderEdge = 'left' | 'top' | 'right' | 'bottom';
export const BORDER_EDGES: readonly BorderEdge[] = ['left', 'top', 'right', 'bottom'];
/** Where a border line runs, in image pixels; vertical for left/right, horizontal for top/bottom. */
export function borderLine(
rect: FrameRect,
border: FrameBorders,
edge: BorderEdge,
): { x1: number; y1: number; x2: number; y2: number } {
if (edge === 'left' || edge === 'right') {
const x = edge === 'left' ? rect.x + border.left : rect.x + rect.w - border.right;
return { x1: x, y1: rect.y, x2: x, y2: rect.y + rect.h };
}
const y = edge === 'top' ? rect.y + border.top : rect.y + rect.h - border.bottom;
return { x1: rect.x, y1: y, x2: rect.x + rect.w, y2: y };
}
/**
* Which border line a press at `point` grabs, or null. Tested only within the frame, and after the
* frame's own handles: a border still sitting on the frame's edge would otherwise take the corner
* that resizes the frame itself.
*/
export function borderAt(
rect: FrameRect,
border: FrameBorders,
point: Vec2,
tolerance: number,
): BorderEdge | null {
if (
point.x < rect.x - tolerance ||
point.x > rect.x + rect.w + tolerance ||
point.y < rect.y - tolerance ||
point.y > rect.y + rect.h + tolerance
)
return null;
for (const edge of BORDER_EDGES) {
const line = borderLine(rect, border, edge);
const distance = line.x1 === line.x2 ? Math.abs(point.x - line.x1) : Math.abs(point.y - line.y1);
if (distance <= tolerance) return edge;
}
return null;
}
/**
* The borders a drag of one line produces. Raw: keeping them inside the frame is
* `normalizeBorders`' job, shared with the numbers typed into the inspector.
*/
export function dragBorder(border: FrameBorders, edge: BorderEdge, dx: number, dy: number): FrameBorders {
const moved = { ...border };
if (edge === 'left') moved.left += dx;
else if (edge === 'right') moved.right -= dx;
else if (edge === 'top') moved.top += dy;
else moved.bottom -= dy;
return moved;
}
export function borderCursor(edge: BorderEdge): string {
return edge === 'left' || edge === 'right' ? 'ew-resize' : 'ns-resize';
}
/** The rectangle spanned by two points, in either order. */
export function rectBetween(a: Vec2, b: Vec2): FrameRect {
return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) };
+112
View File
@@ -0,0 +1,112 @@
/**
* The geometry a nine-sliced frame draws with (#41): no Pixi and no project, so it stays
* unit-testable. A sprite is one quad per part rather than a shared 4×4 vertex grid, because a
* repeated part needs its own UVs per tile – a grid can only stretch.
*/
import type { FrameFill } from '../core/model/Asset';
import type { NineSlice } from '../core/model/texture';
/** How often a part may repeat before it stretches instead; a guard, not a design (see `sliceSpans`). */
export const MAX_TILES = 64;
/** One part along an axis: `p0`..`p1` in the sprite's units, `t0`..`t1` in the frame's 0..1 texture space. */
export interface Span {
p0: number;
p1: number;
t0: number;
t1: number;
}
/**
* The parts one axis is cut into. `near`/`far` are the two borders in source pixels, and they keep
* that size in the sprite's units – that is the whole point of slicing. A sprite too small to hold
* both is not left with overlapping corners: they are scaled down together until the middle is
* exactly zero, so the seam sits where the two corners meet.
*/
export function sliceSpans(
target: number,
source: number,
near: number,
far: number,
fill: FrameFill,
): Span[] {
if (!(target > 0) || !(source > 0)) return [];
const shrink = near + far > target ? target / (near + far) : 1;
const n = near * shrink;
const f = far * shrink;
const middle = Math.max(0, target - n - f);
const sourceMiddle = source - near - far;
const spans: Span[] = [];
if (n > 0) spans.push({ p0: 0, p1: n, t0: 0, t1: near / source });
if (middle > 0 && sourceMiddle > 0) {
const t0 = near / source;
const t1 = (source - far) / source;
// Whole tiles of the frame's own size, the last one cut off where the middle ends. Sampling
// with UVs past 1 would do it in one quad, but a frame is a cut-out of a sheet: past its edge
// lies the neighbouring cell, not the frame again.
const tiles = fill === 'repeat' ? Math.ceil(middle / sourceMiddle) : 1;
if (fill === 'repeat' && tiles <= MAX_TILES) {
for (let i = 0; i < tiles; i++) {
const p0 = n + i * sourceMiddle;
const p1 = Math.min(n + middle, p0 + sourceMiddle);
spans.push({ p0, p1, t0, t1: t0 + ((p1 - p0) / sourceMiddle) * (t1 - t0) });
}
} else {
spans.push({ p0: n, p1: n + middle, t0, t1 });
}
}
if (f > 0) spans.push({ p0: target - f, p1: target, t0: (source - far) / source, t1: 1 });
return spans;
}
/**
* Positions in 0..1 of the sprite's box, as the unit quad they replace: the mesh carries the
* sprite's size in its scale, so the geometry must not.
*/
export function sliceGeometry(
slice: NineSlice,
w: number,
h: number,
): { positions: Float32Array; uvs: Float32Array; indices: Uint32Array } {
const columns = sliceSpans(w, slice.sourceW, slice.border.left, slice.border.right, slice.fill);
const rows = sliceSpans(h, slice.sourceH, slice.border.top, slice.border.bottom, slice.fill);
const positions: number[] = [];
const uvs: number[] = [];
const indices: number[] = [];
for (const row of rows) {
for (const column of columns) {
const base = positions.length / 2;
positions.push(
column.p0 / w,
row.p0 / h,
column.p1 / w,
row.p0 / h,
column.p1 / w,
row.p1 / h,
column.p0 / w,
row.p1 / h,
);
uvs.push(column.t0, row.t0, column.t1, row.t0, column.t1, row.t1, column.t0, row.t1);
indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
}
}
return {
positions: new Float32Array(positions),
uvs: new Float32Array(uvs),
indices: new Uint32Array(indices),
};
}
/**
* What the geometry depends on. Rebuilding on every frame would throw away a buffer the GPU
* already has; a sprite that only moves keeps the one it was given.
*/
export function sliceKey(slice: NineSlice, w: number, h: number): string {
const b = slice.border;
return `${b.left},${b.top},${b.right},${b.bottom}|${slice.fill}|${slice.sourceW}×${slice.sourceH}|${round(w)}×${round(h)}`;
}
/** Sub-pixel changes of size do not change the picture, and would rebuild on every mouse move. */
function round(value: number): number {
return Math.round(value * 4) / 4;
}
+27 -1
View File
@@ -10,8 +10,10 @@ import {
} from '../core/properties/bounds';
import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider';
import { SHEET_EDGE } from '../core/shader/nodes';
import type { NineSlice } from '../core/model/texture';
import { Theme } from '../ui';
import type { DrawItem } from './items';
import { sliceGeometry, sliceKey } from './nineSlice';
import { disposeShader, type ShaderSource, syncShader, wornShader } from './shaders';
import type { TextureLibrary } from './TextureLibrary';
@@ -65,15 +67,39 @@ export function syncSpriteMesh(
const edge = wornShader(item.props, shaders)?.edge ?? SHEET_EDGE;
const texture = item.texture ? textures.get(item.texture, edge) : null;
placeNode(mesh, item.m, size.w, size.h);
// Only once there is a picture: slicing a placeholder would cut up the plain colour for nothing.
syncGeometry(mesh, texture ? (item.texture?.slice ?? null) : null, size);
mesh.texture = texture ?? Texture.WHITE;
mesh.tint = Number(item.props.color ?? 0xffffff);
syncShader(mesh, item.props, shaders);
}
/** `Mesh.destroy` leaves the shared geometry alone and only lets go of its shader. */
/**
* The geometry a sliced sprite needs, built only when what it depends on changed. Keyed outside
* the mesh so nothing of this leaks into Pixi's own object, and so a mesh that loses its slice
* goes back to the shared quad rather than keeping a private copy of it.
*/
const sliceKeys = new WeakMap<Mesh, string>();
function syncGeometry(mesh: Mesh, slice: NineSlice | null, size: { w: number; h: number }): void {
const key = slice ? sliceKey(slice, size.w, size.h) : '';
if ((sliceKeys.get(mesh) ?? '') === key) return;
const previous = mesh.geometry;
mesh.geometry = slice ? new MeshGeometry(sliceGeometry(slice, size.w, size.h)) : UNIT_QUAD;
if (previous !== UNIT_QUAD) previous.destroy();
if (key) sliceKeys.set(mesh, key);
else sliceKeys.delete(mesh);
}
/**
* `Mesh.destroy` never destroys its geometry, shared or not – so the shared quad survives, and a
* sliced sprite's own buffers have to be let go of here.
*/
export function disposeSpriteMesh(mesh: Mesh): void {
disposeShader(mesh);
const geometry = mesh.geometry;
mesh.destroy();
if (geometry !== UNIT_QUAD) geometry.destroy();
}
/**
+34
View File
@@ -6,6 +6,7 @@ import {
RemoveFrameCommand,
RenameFrameCommand,
SetFrameRectCommand,
SetFrameSliceCommand,
SliceFramesCommand,
} from '../../src/core/commands/frameCommands';
import type { TextureAsset, TextureFrame } from '../../src/core/model/Asset';
@@ -266,6 +267,39 @@ describe('SliceFramesCommand', () => {
expect(sheet.frames()).toEqual([]);
});
it('slices a frame, and undo takes the keys away again rather than zeroing them', () => {
const sheet = makeSheet();
const frameId = addFrame(sheet, { x: 0, y: 0, w: 24, h: 24 });
const frame = () => sheet.frames().find((f) => f.id === frameId)!;
sheet.stack.execute(
new SetFrameSliceCommand(sheet.textureId, frameId, {
border: { left: 8, top: 8, right: 8, bottom: 8 },
fill: 'repeat',
}),
);
expect(frame().border).toEqual({ left: 8, top: 8, right: 8, bottom: 8 });
expect(frame().fill).toBe('repeat');
sheet.stack.undo();
// Absent, not zero: a frame nobody sliced saves exactly as it did before #41.
expect(frame().border).toBeUndefined();
expect(frame().fill).toBeUndefined();
});
it('keeps two opposite borders from eating the whole frame', () => {
const sheet = makeSheet();
const frameId = addFrame(sheet, { x: 0, y: 0, w: 24, h: 24 });
sheet.stack.execute(
new SetFrameSliceCommand(sheet.textureId, frameId, {
border: { left: 40, top: -5, right: 40, bottom: 2.6 },
fill: 'stretch',
}),
);
// Scaled down together to leave one pixel between them; a negative one is none.
expect(sheet.frames()[0]?.border).toEqual({ left: 12, top: 0, right: 11, bottom: 3 });
});
it('refuses an asset that is missing, and one that is not a texture', () => {
const sheet = makeSheet();
const sprite = new CreateAssetCommand('sprite', 'Box');
+51
View File
@@ -54,6 +54,57 @@ describe('serialize', () => {
expect(machine.scope).toBeUndefined();
});
/** #41: the borders are part of the frame, and a frame nobody sliced still saves without them. */
it('round-trips the nine-slice of a frame and repairs an impossible one', () => {
const file = `${'ab'.repeat(32)}.png`;
const loaded = deserializeProject({
assets: [
{
id: 't1',
type: 'texture',
name: 'Sheet',
file,
width: 64,
height: 64,
frames: [
{
id: 'f1',
name: 'Button',
x: 0,
y: 0,
w: 24,
h: 24,
border: { left: 8, right: 8 },
fill: 'repeat',
},
{ id: 'f2', name: 'Plain', x: 24, y: 0, w: 24, h: 24 },
{
id: 'f3',
name: 'Greedy',
x: 0,
y: 24,
w: 8,
h: 8,
border: { top: 99, bottom: 99 },
fill: 'wat',
},
],
},
],
});
const texture = loaded.assets[0]!;
if (texture.type !== 'texture') throw new Error('wrong types');
const [sliced, plain, greedy] = texture.frames;
expect(sliced?.border).toEqual({ left: 8, top: 0, right: 8, bottom: 0 });
expect(sliced?.fill).toBe('repeat');
expect(plain?.border).toBeUndefined();
expect(plain?.fill).toBeUndefined();
// Measured against the frame, not the image, and never leaving it without a middle.
expect(greedy?.border).toEqual({ left: 0, top: 4, right: 0, bottom: 3 });
expect(greedy?.fill).toBeUndefined();
expect(roundTrip(loaded)).toEqual(loaded);
});
it('repairs broken documents', () => {
const project = deserializeProject(
{
+15 -1
View File
@@ -52,7 +52,7 @@ describe('resolveTexture', () => {
sprite.props.texture = texture.id;
// A whole-texture reference carries no rectangle.
const whole = { file: FILE, rect: null, sourceWidth: 64, sourceHeight: 48 };
const whole = { file: FILE, rect: null, sourceWidth: 64, sourceHeight: 48, slice: null };
expect(resolveTexture(fixture.project, sprite.props)).toEqual({ ...whole, smooth: true });
texture.props.filter = 'nearest';
expect(resolveTexture(fixture.project, sprite.props)).toEqual({ ...whole, smooth: false });
@@ -71,8 +71,22 @@ describe('resolveTexture', () => {
rect: { x: 16, y: 8, w: 32, h: 24 },
sourceWidth: 64,
sourceHeight: 48,
// An unsliced frame carries no slice: what every document written before #41 says.
slice: null,
});
// Sliced, it carries the borders and the fill the renderer needs, measured in the frame.
texture.frames[0]!.border = { left: 4, top: 3, right: 4, bottom: 3 };
texture.frames[0]!.fill = 'repeat';
expect(resolveTexture(fixture.project, sprite.props)?.slice).toEqual({
border: { left: 4, top: 3, right: 4, bottom: 3 },
fill: 'repeat',
sourceW: 32,
sourceH: 24,
});
delete texture.frames[0]!.border;
delete texture.frames[0]!.fill;
// A frame that is gone dangles like any other reference: no picture, not a wrong one.
texture.frames.length = 0;
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
+33
View File
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest';
import type { FrameRect } from '../../src/core/model/texture';
import {
borderAt,
borderLine,
dragBorder,
dragFrame,
fitView,
type FrameHandle,
@@ -138,3 +141,33 @@ describe('dragging a frame', () => {
expect(dragFrame(dragFrame(RECT, handle, 3, 4), handle, -3, -4)).toEqual(RECT);
});
});
describe('nine-slice border lines', () => {
const BORDER = { left: 4, top: 6, right: 8, bottom: 10 };
it('runs each line beside its own edge, the far ones measured from the far side', () => {
expect(borderLine(RECT, BORDER, 'left')).toEqual({ x1: 14, y1: 20, x2: 14, y2: 50 });
expect(borderLine(RECT, BORDER, 'right')).toEqual({ x1: 42, y1: 20, x2: 42, y2: 50 });
expect(borderLine(RECT, BORDER, 'top')).toEqual({ x1: 10, y1: 26, x2: 50, y2: 26 });
expect(borderLine(RECT, BORDER, 'bottom')).toEqual({ x1: 10, y1: 40, x2: 50, y2: 40 });
});
it('grabs a line only near it and only within the frame', () => {
expect(borderAt(RECT, BORDER, { x: 14, y: 35 }, 2)).toBe('left');
expect(borderAt(RECT, BORDER, { x: 30, y: 40 }, 2)).toBe('bottom');
expect(borderAt(RECT, BORDER, { x: 30, y: 35 }, 2)).toBeNull();
// Far outside the frame the lines are not offered, however close the axis would be.
expect(borderAt(RECT, BORDER, { x: 14, y: 200 }, 2)).toBeNull();
});
it('moves the near border with the pointer and the far one against it', () => {
// Both grow when the pointer goes towards the middle, which is what the eye expects of a line
// that is dragged inwards.
expect(dragBorder(BORDER, 'left', 3, 0).left).toBe(7);
expect(dragBorder(BORDER, 'right', -3, 0).right).toBe(11);
expect(dragBorder(BORDER, 'top', 0, 3).top).toBe(9);
expect(dragBorder(BORDER, 'bottom', 0, -3).bottom).toBe(13);
// And each line moves only its own border.
expect(dragBorder(BORDER, 'left', 3, 3)).toEqual({ ...BORDER, left: 7 });
});
});
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest';
import type { NineSlice } from '../../src/core/model/texture';
import { MAX_TILES, sliceGeometry, sliceKey, sliceSpans } from '../../src/viewport/nineSlice';
const slice = (border: Partial<NineSlice['border']>, fill: NineSlice['fill'] = 'stretch'): NineSlice => ({
border: { left: 0, top: 0, right: 0, bottom: 0, ...border },
fill,
sourceW: 24,
sourceH: 24,
});
describe('sliceSpans', () => {
it('keeps the corners at their source size and gives the rest to the middle', () => {
const spans = sliceSpans(200, 24, 8, 8, 'stretch');
expect(spans.map((s) => [s.p0, s.p1])).toEqual([
[0, 8],
[8, 192],
[192, 200],
]);
// The UVs cut the frame at the same pixels, so the corner shows the corner.
expect(spans.map((s) => [s.t0, s.t1])).toEqual([
[0, 1 / 3],
[1 / 3, 2 / 3],
[2 / 3, 1],
]);
});
it('leaves out a part that has no room: a border of zero is no quad at all', () => {
expect(sliceSpans(200, 24, 0, 8, 'stretch').map((s) => [s.p0, s.p1])).toEqual([
[0, 192],
[192, 200],
]);
// Exactly as wide as its two borders: middle gone, the two corners meet.
expect(sliceSpans(16, 24, 8, 8, 'stretch').map((s) => [s.p0, s.p1])).toEqual([
[0, 8],
[8, 16],
]);
});
it('scales both borders down together when the sprite is smaller than they are', () => {
const spans = sliceSpans(8, 24, 8, 8, 'stretch');
// Never overlapping corners: 4 + 4 rather than 8 + 8 on a box of 8.
expect(spans.map((s) => [s.p0, s.p1])).toEqual([
[0, 4],
[4, 8],
]);
// The UVs are untouched by the squeeze: each corner still shows its own eight pixels.
expect(spans.map((s) => [s.t0, s.t1])).toEqual([
[0, 1 / 3],
[2 / 3, 1],
]);
});
it('repeats the middle in whole tiles of the frame and cuts the last one', () => {
const spans = sliceSpans(50, 24, 8, 8, 'repeat');
const middle = spans.slice(1, -1);
expect(middle.map((s) => [s.p0, s.p1])).toEqual([
[8, 16],
[16, 24],
[24, 32],
[32, 40],
[40, 42],
]);
// The cut tile shows only as much of the picture as it covers – a quarter of the eight pixels.
expect(middle.at(-1)?.t1).toBeCloseTo(1 / 3 + (2 / 8) * (1 / 3), 10);
// Every tile starts at the middle's own first pixel; none samples past the frame.
expect(middle.every((s) => s.t0 === 1 / 3 && s.t1 <= 2 / 3)).toBe(true);
});
it('stretches rather than building a tile for every pixel of a huge sprite', () => {
// A middle of 14 source pixels: exactly `MAX_TILES` of them still tile, one more stretches.
const many = sliceSpans(10 + MAX_TILES * 14, 24, 5, 5, 'repeat');
expect(many.length).toBe(2 + MAX_TILES);
const tooMany = sliceSpans(10 + (MAX_TILES + 1) * 14, 24, 5, 5, 'repeat');
expect(tooMany.length).toBe(3);
});
it('answers nothing for a box or a picture of no size', () => {
expect(sliceSpans(0, 24, 8, 8, 'stretch')).toEqual([]);
expect(sliceSpans(200, 0, 8, 8, 'stretch')).toEqual([]);
});
});
describe('sliceGeometry', () => {
it('builds one quad per part, in the 0..1 box the unit quad covers', () => {
const { positions, uvs, indices } = sliceGeometry(
slice({ left: 8, top: 8, right: 8, bottom: 8 }),
200,
200,
);
expect(indices.length).toBe(9 * 6);
expect(positions.length).toBe(9 * 8);
expect(uvs.length).toBe(positions.length);
// Fractions, not units: the sprite's size lives in the mesh's scale.
expect(Math.min(...positions)).toBe(0);
expect(Math.max(...positions)).toBe(1);
// The first quad is the top-left corner: 8 of 200 units, showing 8 of 24 pixels.
// Close, not equal: the buffers are Float32, where neither 0.04 nor a third is exact.
expect(positions[2]).toBeCloseTo(0.04, 6);
expect(uvs[2]).toBeCloseTo(1 / 3, 6);
expect([positions[0], positions[1], positions[3]]).toEqual([0, 0, 0]);
});
it('drops the rows and columns that have no room, keeping the quads rectangular', () => {
const { indices } = sliceGeometry(slice({ left: 8, right: 8 }), 200, 200);
// Three columns, one row – nothing is cut horizontally.
expect(indices.length).toBe(3 * 6);
});
});
describe('sliceKey', () => {
it('changes with anything the geometry is built from, and not with a sub-pixel of size', () => {
const base = slice({ left: 8, right: 8 });
expect(sliceKey(base, 200, 100)).toBe(sliceKey(base, 200.1, 100));
expect(sliceKey(base, 200, 100)).not.toBe(sliceKey(base, 201, 100));
expect(sliceKey(base, 200, 100)).not.toBe(sliceKey(slice({ left: 8, right: 7 }), 200, 100));
expect(sliceKey(base, 200, 100)).not.toBe(sliceKey(slice({ left: 8, right: 8 }, 'repeat'), 200, 100));
});
});
+244
View File
@@ -0,0 +1,244 @@
import { colourDistance } from '../lib/harness.mjs';
/**
* Nine-slice frames (#41). The borders live on the frame of a texture, and what only a running
* editor can answer is in here: that a corner keeps its pixel size while the box grows, that the
* same picture without borders does not, and that "repeat" really tiles the middle instead of
* stretching it. All three are read off the screen, because all three are about the geometry a
* sprite is drawn with – no unit test can see that.
*/
/**
* A 24×24 button: an 8-pixel red ring around a blue middle, and a 2-pixel white stripe down the
* left of that middle. The stripe is what tells a tiled middle from a stretched one – tiled it
* comes back every 8 units, stretched there is exactly one of it.
*/
const PASTE_BUTTON = `(async () => {
const canvas = document.createElement('canvas');
canvas.width = 24;
canvas.height = 24;
const g = canvas.getContext('2d');
g.fillStyle = '#e63c3c'; g.fillRect(0, 0, 24, 24);
g.fillStyle = '#466ef0'; g.fillRect(8, 8, 8, 8);
g.fillStyle = '#ffffff'; g.fillRect(8, 8, 2, 8);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer();
transfer.items.add(new File([blob], 'button.png', { type: 'image/png' }));
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
})()`;
const RED = '#e63c3c';
const BLUE = '#466ef0';
const WHITE = '#ffffff';
const SIZE = 200;
export async function nineSlice(t) {
const { check, sleep, json, evaluate, mutate, clickText, drag, press, shot, showTab, settle } = t;
const { undo, redo } = t;
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate('__gm.state.select(null)');
// 37a. A picture of its own, and one frame over the whole of it: the borders are a property of
// the frame, so a texture nobody cut has nothing to slice.
await evaluate(PASTE_BUTTON);
await sleep(2500);
const texture = await json(
"(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'texture').at(-1);" +
' return a ? { id: a.id, w: a.width, h: a.height } : null; })()',
);
check(
'the button picture is imported at its own size',
texture?.w === 24 && texture?.h === 24,
JSON.stringify(texture),
);
await mutate(`
const texture = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)});
texture.props.filter = 'nearest';
texture.frames.push({ id: 'ns-frame', name: 'Button', x: 0, y: 0, w: 24, h: 24 });`);
await evaluate(
`__gm.state.select({ kind: 'frame', assetId: ${JSON.stringify(texture.id)}, frameId: 'ns-frame' })`,
);
await showTab('texture');
await settle();
const frame = () =>
json(
`(() => { const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})` +
`.frames.find((f) => f.id === 'ns-frame');` +
' return { x: f.x, y: f.y, w: f.w, h: f.h, border: f.border ?? null, fill: f.fill ?? null }; })()',
);
check('a frame starts unsliced, with no borders at all', (await frame()).border === null);
// 37b. Dragging the border lines in the Texture tab. Grabbed away from the frame's own handles:
// at zero the line sits on the frame's edge, and the handle there resizes the frame itself.
const before = await frame();
const from = await json('__gm.texture.pointAt(0, 6)');
const to = await json('__gm.texture.pointAt(8, 6)');
await drag(from.x, from.y, to.x, to.y);
await settle();
const topFrom = await json('__gm.texture.pointAt(6, 0)');
const topTo = await json('__gm.texture.pointAt(6, 8)');
await drag(topFrom.x, topFrom.y, topTo.x, topTo.y);
await settle();
const dragged = await frame();
check(
'dragging the two lines sets the borders and leaves the frame where it was',
dragged.border?.left === 8 &&
dragged.border?.top === 8 &&
dragged.x === before.x &&
dragged.y === before.y &&
dragged.w === before.w &&
dragged.h === before.h,
JSON.stringify(dragged),
);
await shot('37-frame-borders');
// One drag, one entry – and the last one takes the keys away again rather than writing zeros:
// a frame nobody sliced must read exactly as it did before, or every document would grow four
// numbers it never asked for.
await undo();
const halfUndone = await frame();
await undo();
const undone = await frame();
check(
'each border drag is one undo step, and the last one leaves no slice at all',
halfUndone.border?.left === 8 && halfUndone.border?.top === 0 && undone.border === null,
JSON.stringify({ halfUndone, undone }),
);
await redo();
await redo();
await mutate(`
const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})
.frames.find((f) => f.id === 'ns-frame');
f.border = { left: 8, top: 8, right: 8, bottom: 8 };`);
// 37c. A sprite wearing the frame, far bigger than the picture. Cloned from one that exists,
// because a literal misses the fields the viewport needs (see step 12).
await mutate(`
const project = __gm.state.project;
const sprite = structuredClone(project.assets.find((a) => a.type === 'sprite'));
sprite.id = 'ns-sprite';
sprite.name = 'Button';
sprite.props = { ...sprite.props, width: ${SIZE}, height: ${SIZE}, color: 0xffffff, texture: 'ns-frame' };
project.assets.push(sprite);
const scene = __gm.state.activeScene;
const object = structuredClone(scene.objects.find((o) => o.id === 'otex') ?? scene.objects[0]);
object.id = 'ons';
object.name = 'Button';
object.assetId = 'ns-sprite';
object.overrides = {};
object.position = { ...object.position, z: 950 };
scene.objects.push(object);
// The stage to itself: the fixture's other objects draw gizmos over everything – a collider
// outline, the behavior icons – and a pixel reading cannot tell those from the picture.
window.__nsOthers = scene.objects.filter((o) => o.id !== 'ons');
scene.objects = scene.objects.filter((o) => o.id === 'ons');
__gm.state.select(null)`);
await showTab('scene');
await sleep(600);
const box = await json("__gm.objectRect('ons')");
check('the nine-sliced sprite is on screen', Boolean(box) && box.w > 100, JSON.stringify(box));
if (!box) return;
const scale = box.w / SIZE;
// A run of pixels from just inside the left edge: one read rather than a hundred round trips.
// Across the middle of the sprite, which is where the parts between the corners are.
const rowY = Math.round(box.y);
const rowAcross = (count) => json(`__gm.pixelRow(${Math.round(box.x - box.w / 2 + 1)}, ${rowY}, ${count})`);
const runOf = (row, colour) => {
let n = 0;
while (n < row.length && colourDistance(row[n], colour) < 60) n++;
return n;
};
const sliced = await rowAcross(120);
const slicedEdge = runOf(sliced, RED);
check(
'the border keeps its own pixel size while the sprite is eight times the picture',
Math.abs(slicedEdge - 8 * scale) <= 2 && colourDistance(sliced[Math.round(100 * scale)], BLUE) < 60,
JSON.stringify({ run: slicedEdge, expected: 8 * scale, row: sliced.slice(0, 16) }),
);
await shot('38-nine-sliced-sprite');
// The counter-proof: the very same picture without borders. Without it, "the edge is 8 wide"
// could just as well mean the measurement is wrong.
await mutate(`
const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})
.frames.find((f) => f.id === 'ns-frame');
delete f.border;`);
await sleep(400);
const stretched = await rowAcross(120);
const stretchedEdge = runOf(stretched, RED);
check(
'and without the borders the very same picture scales its edge with the box',
stretchedEdge > 8 * scale * 4 && Math.abs(stretchedEdge - (8 / 24) * SIZE * scale) <= 4,
JSON.stringify({ sliced: slicedEdge, stretched: stretchedEdge }),
);
// 37d. Stretch against repeat, picked in the inspector. The white stripe down the left of the
// middle is the witness: stretched there is one of it, tiled there is one per 8 units.
await mutate(`
const f = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)})
.frames.find((f) => f.id === 'ns-frame');
f.border = { left: 8, top: 8, right: 8, bottom: 8 };`);
const stripes = async () => {
const row = await rowAcross(Math.round(SIZE * scale) - 2);
let runs = 0;
for (let i = 0; i < row.length; i++) {
const white = colourDistance(row[i], WHITE) < 60;
const wasWhite = i > 0 && colourDistance(row[i - 1], WHITE) < 60;
if (white && !wasWhite) runs++;
}
return runs;
};
const stretchedStripes = await stripes();
await evaluate(
`__gm.state.select({ kind: 'frame', assetId: ${JSON.stringify(texture.id)}, frameId: 'ns-frame' })`,
);
await showTab('texture');
await clickText('Stretch');
await clickText('Repeat');
await settle();
const picked = await frame();
check(
'the inspector offers the fill beside the borders, and picking it writes the frame',
picked.fill === 'repeat',
JSON.stringify(picked),
);
await showTab('scene');
await sleep(400);
const repeatedStripes = await stripes();
check(
'a repeated middle tiles the picture instead of stretching it once',
stretchedStripes === 1 && repeatedStripes > 15,
JSON.stringify({ stretched: stretchedStripes, repeated: repeatedStripes }),
);
await shot('39-nine-slice-repeat');
// 37e. Narrower than its own two borders: they are scaled down together until the corners meet,
// so the row across it is all corner and no middle. Kept tall, so the reading stays in the
// band the gizmos leave alone.
await mutate(`
Object.assign(__gm.state.project.assets.find((a) => a.id === 'ns-sprite').props, { width: 8 });`);
await sleep(400);
const small = await json("__gm.objectRect('ons')");
const seam = await json(
`__gm.pixelRow(${Math.round(small.x - small.w / 2 + 1)}, ${rowY}, ${Math.max(2, Math.round(small.w) - 2)})`,
);
check(
'a sprite narrower than its two borders is all corner, with no middle between them',
seam.length > 2 && seam.every((c) => colourDistance(c, RED) < 60),
JSON.stringify({ w: small.w, seam }),
);
// The scene back as it was, without the sprite this step brought: later readers count what is in it.
await mutate(`
__gm.state.activeScene.objects = window.__nsOthers ?? [];
delete window.__nsOthers;
__gm.state.select(null)`);
await settle();
}
+2
View File
@@ -39,6 +39,7 @@ import { renderOnChange } from './33-render-on-change.mjs';
import { sound } from './34-sound.mjs';
import { loadScene } from './35-load-scene.mjs';
import { graphGroups } from './36-graph-groups.mjs';
import { nineSlice } from './37-nine-slice.mjs';
export const STEPS = [
hierarchy,
@@ -77,4 +78,5 @@ export const STEPS = [
sound,
loadScene,
graphGroups,
nineSlice,
];