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 ?? [];