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

+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();