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

+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,