From 94a0a578edbb4ef28465bfa8520404a5f6cd11b5 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Wed, 16 Sep 2026 18:09:43 +0200 Subject: [PATCH] A border drawn in pixels is as wide as the texture says a pixel is, so a small sheet can frame a large sprite (#96) A sliced frame drew its borders one source pixel to one sprite unit. Pixel-art sheets are small: a 16px corner on a 400-unit button is a thin line, and the only way around it was a larger image. The texture now carries `Nine-slice scale` (default 1), next to `Filter`. It sets how many sprite units one border pixel takes, for every sliced frame of that texture. The UVs stay as they are - a corner shows the same pixels, only larger or smaller. When the sprite is too small for both borders they are still scaled down together, now starting from the scaled size. With `Repeat` the tile size scales too, otherwise a doubled border would sit beside a pattern of the old size. The value is a schema row, so inspector field, saving and undo come without their own code. Anything that is not a size above 0 reads as 1, and documents without it draw as before. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- src/core/model/texture.ts | 16 ++++++++--- src/core/properties/PropertySchema.ts | 3 +++ src/viewport/nineSlice.ts | 36 ++++++++++++++----------- tests/core/texture-commands.test.ts | 2 +- tests/core/texture.test.ts | 7 +++++ tests/viewport/nine-slice.test.ts | 38 +++++++++++++++++++++++++++ tools/e2e/37-nine-slice.mjs | 22 +++++++++++++++- 8 files changed, 105 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 928b1cc9..89cd8459 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 958 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 960 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts index bf956d6f..6c8f70d3 100644 --- a/src/core/model/texture.ts +++ b/src/core/model/texture.ts @@ -45,6 +45,16 @@ export interface NineSlice { /** Size of the frame in image pixels; the borders are distances inside it. */ sourceW: number; sourceH: number; + /** Sprite units one border pixel is drawn with – the texture's, shared by all its frames (#96). */ + scale: number; +} + +export const SLICE_SCALE_KEY = 'sliceScale'; + +/** The texture's nine-slice scale; anything that is not a size above zero reads as the default 1. */ +export function sliceScaleOf(asset: TextureAsset): number { + const value = Number(asset.props[SLICE_SCALE_KEY] ?? 1); + return Number.isFinite(value) && value > 0 ? value : 1; } export interface FrameRef { @@ -102,7 +112,7 @@ 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, + slice: frame ? nineSliceOf(frame, sliceScaleOf(asset)) : null, }; } @@ -125,10 +135,10 @@ export function hasBorders(border: FrameBorders): boolean { } /** 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 { +export function nineSliceOf(frame: TextureFrame, scale = 1): 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 }; + return { border, fill: frameFill(frame), sourceW: frame.w, sourceH: frame.h, scale }; } /** diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index d42c7415..89c795bd 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -20,6 +20,7 @@ import { type VariableKind, } from './kinds'; import { findAssetOf, type Project } from '../model/Project'; +import { SLICE_SCALE_KEY } from '../model/texture'; import type { SceneObject } from '../model/SceneObject'; import { flatListOf, listItemOf } from './listItems'; import { FONT_KEY } from './fonts'; @@ -197,6 +198,8 @@ const ASSET_SCHEMAS: Record = { { value: 'nearest', label: 'Nearest' }, ], }, + // How big the borders of every sliced frame are drawn: 2 makes a 16 px border 32 units wide (#96). + { key: SLICE_SCALE_KEY, label: 'Nine-slice scale', kind: 'number', default: 1, min: 0.01, step: 0.1 }, ], // The pictures are intrinsic data on the asset (see AnimationAsset). animation: [ diff --git a/src/viewport/nineSlice.ts b/src/viewport/nineSlice.ts index 2bf30186..0461335b 100644 --- a/src/viewport/nineSlice.ts +++ b/src/viewport/nineSlice.ts @@ -19,9 +19,9 @@ export interface Span { /** * 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. + * that size times `scale` 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, @@ -29,11 +29,14 @@ export function sliceSpans( near: number, far: number, fill: FrameFill, + scale = 1, ): Span[] { - if (!(target > 0) || !(source > 0)) return []; - const shrink = near + far > target ? target / (near + far) : 1; - const n = near * shrink; - const f = far * shrink; + if (!(target > 0) || !(source > 0) || !(scale > 0)) return []; + const nearUnits = near * scale; + const farUnits = far * scale; + const shrink = nearUnits + farUnits > target ? target / (nearUnits + farUnits) : 1; + const n = nearUnits * shrink; + const f = farUnits * shrink; const middle = Math.max(0, target - n - f); const sourceMiddle = source - near - far; const spans: Span[] = []; @@ -43,13 +46,15 @@ export function sliceSpans( 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; + // lies the neighbouring cell, not the frame again. The tile scales with the borders (#96), + // or a border drawn twice as big would sit beside a pattern drawn at its old size. + const tile = sourceMiddle * scale; + const tiles = fill === 'repeat' ? Math.ceil(middle / tile) : 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) }); + const p0 = n + i * tile; + const p1 = Math.min(n + middle, p0 + tile); + spans.push({ p0, p1, t0, t1: t0 + ((p1 - p0) / tile) * (t1 - t0) }); } } else { spans.push({ p0: n, p1: n + middle, t0, t1 }); @@ -68,8 +73,9 @@ export function sliceGeometry( 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 { border, fill, scale } = slice; + const columns = sliceSpans(w, slice.sourceW, border.left, border.right, fill, scale); + const rows = sliceSpans(h, slice.sourceH, border.top, border.bottom, fill, scale); const positions: number[] = []; const uvs: number[] = []; const indices: number[] = []; @@ -103,7 +109,7 @@ export function sliceGeometry( */ 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)}`; + return `${b.left},${b.top},${b.right},${b.bottom}|${slice.fill}|×${slice.scale}|${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. */ diff --git a/tests/core/texture-commands.test.ts b/tests/core/texture-commands.test.ts index 13c01918..1540eaeb 100644 --- a/tests/core/texture-commands.test.ts +++ b/tests/core/texture-commands.test.ts @@ -16,7 +16,7 @@ describe('CreateTextureCommand', () => { const asset = findAsset(project, command.asset.id); expect(asset).toMatchObject({ type: 'texture', name: 'Hero', file: FILE, width: 64, height: 32 }); // The schema default comes along, so the new texture is a complete asset – nothing to fill in later. - expect(asset?.props).toEqual({ filter: 'linear' }); + expect(asset?.props).toEqual({ filter: 'linear', sliceScale: 1 }); stack.undo(); expect(project.assets).toHaveLength(0); diff --git a/tests/core/texture.test.ts b/tests/core/texture.test.ts index dd0a04a7..3fa0185f 100644 --- a/tests/core/texture.test.ts +++ b/tests/core/texture.test.ts @@ -83,7 +83,14 @@ describe('resolveTexture', () => { fill: 'repeat', sourceW: 32, sourceH: 24, + scale: 1, }); + // The scale is the texture's, not the frame's (#96); a value that is no size falls back to 1. + texture.props.sliceScale = 2.5; + expect(resolveTexture(fixture.project, sprite.props)?.slice?.scale).toBe(2.5); + texture.props.sliceScale = 0; + expect(resolveTexture(fixture.project, sprite.props)?.slice?.scale).toBe(1); + delete texture.props.sliceScale; delete texture.frames[0]!.border; delete texture.frames[0]!.fill; diff --git a/tests/viewport/nine-slice.test.ts b/tests/viewport/nine-slice.test.ts index f0cc536c..15e936b2 100644 --- a/tests/viewport/nine-slice.test.ts +++ b/tests/viewport/nine-slice.test.ts @@ -7,6 +7,7 @@ const slice = (border: Partial, fill: NineSlice['fill'] = ' fill, sourceW: 24, sourceH: 24, + scale: 1, }); describe('sliceSpans', () => { @@ -78,6 +79,42 @@ describe('sliceSpans', () => { 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([]); + expect(sliceSpans(200, 24, 8, 8, 'stretch', 0)).toEqual([]); + }); + + it('draws the borders scaled, and samples the same pixels for them (#96)', () => { + const spans = sliceSpans(200, 24, 8, 8, 'stretch', 2); + expect(spans.map((s) => [s.p0, s.p1])).toEqual([ + [0, 16], + [16, 184], + [184, 200], + ]); + // Bigger on screen, not more of the picture: the corner still shows its eight pixels. + expect(spans.map((s) => [s.t0, s.t1])).toEqual([ + [0, 1 / 3], + [1 / 3, 2 / 3], + [2 / 3, 1], + ]); + // Below 1 the borders shrink the same way. + expect(sliceSpans(200, 24, 8, 8, 'stretch', 0.5)[0]).toMatchObject({ p0: 0, p1: 4 }); + }); + + it('scales the repeated tile with the borders, so the pattern keeps its proportion', () => { + const middle = sliceSpans(80, 24, 8, 8, 'repeat', 2).slice(1, -1); + // A middle of 48 units in tiles of 8 × 2 = 16. + expect(middle.map((s) => [s.p0, s.p1])).toEqual([ + [16, 32], + [32, 48], + [48, 64], + ]); + }); + + it('squeezes scaled borders like unscaled ones when the sprite is too small for them', () => { + // 16 + 16 on a box of 20: both shrink to 10, the middle is gone. + expect(sliceSpans(20, 24, 8, 8, 'stretch', 2).map((s) => [s.p0, s.p1])).toEqual([ + [0, 10], + [10, 20], + ]); }); }); @@ -115,5 +152,6 @@ describe('sliceKey', () => { 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)); + expect(sliceKey(base, 200, 100)).not.toBe(sliceKey({ ...base, scale: 2 }, 200, 100)); }); }); diff --git a/tools/e2e/37-nine-slice.mjs b/tools/e2e/37-nine-slice.mjs index c7778507..d952ecc5 100644 --- a/tools/e2e/37-nine-slice.mjs +++ b/tools/e2e/37-nine-slice.mjs @@ -34,7 +34,7 @@ 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; + const { undo, redo, select, has } = t; await press('Escape'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); @@ -235,6 +235,26 @@ export async function nineSlice(t) { JSON.stringify({ w: small.w, seam }), ); + // 37f. The texture's nine-slice scale (#96): the same 8-pixel border, drawn 3 units per pixel. + // Set on the texture and not on the frame, so the sprite must follow a value it never names. + await mutate(` + Object.assign(__gm.state.project.assets.find((a) => a.id === 'ns-sprite').props, { width: ${SIZE} }); + __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.sliceScale = 3;`); + await sleep(400); + const tripled = runOf(await rowAcross(120), RED); + await mutate(` + __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.sliceScale = 1;`); + await sleep(400); + const single = runOf(await rowAcross(120), RED); + check( + "the texture's nine-slice scale draws every border that many times bigger, and 1 undoes it", + Math.abs(tripled - 24 * scale) <= 2 && Math.abs(single - 8 * scale) <= 2, + JSON.stringify({ tripled, single, expected: [24 * scale, 8 * scale] }), + ); + await shot('40-nine-slice-scale'); + await select({ kind: 'asset', assetId: texture.id }); + check("the scale is a row of the texture's inspector", await has('Nine-slice scale')); + // 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 ?? [];