Add a Texture tab for defining frames
A new Texture tab next to Scene/Game/Graph shows a texture sheet and lets frames be drawn on it: drag to create, move, resize via eight handles, edit name and rect in the inspector, Delete to remove. Every change is a command, so one gesture is one undo step. "Slice..." cuts a grid from columns and rows with optional offset and spacing, previewing the resulting frame count and cell size live. Sprites pick a frame instead of the whole texture through the same `texture` property: it now holds either a texture id or a frame id. Frame ids come from the same generator and are document-wide unique, so one picker, one property and one set of instance overrides cover both cases, and every sprite written before this still means the same thing. A Graphics fill ignores a Pixi sub-texture's frame and draws the whole image, because it derives its uvs from the shape bounds and the inverted style matrix. The cutout is therefore a matrix (`viewport/textureFill.ts`), which keeps a sheet at a single GPU upload no matter how many of its frames are on screen at once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5a6af74e44
commit
35a1a23af8
29 files changed
+2650
-53
No files matched your search
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* The Texture tab: cutting frames out of a sheet, and a sprite wearing one.
|
||||
*
|
||||
* Runs after the texture step, whose four-quadrant 64x48 picture is what makes this checkable
|
||||
* at all – a frame over one quadrant has a colour of its own, so "which part of the sheet is
|
||||
* on screen" is a question `pixelAt` can answer. Nothing here is stubbed: the frame is made by
|
||||
* dragging on the canvas, and the sprite is read back off the rendered pixels.
|
||||
*/
|
||||
|
||||
/** How far apart two `#rrggbb` colours are, summed over the channels. */
|
||||
function colourDistance(a, b) {
|
||||
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||||
const [ar, ag, ab] = channels(a);
|
||||
const [br, bg, bb] = channels(b);
|
||||
return Math.abs(ar - br) + Math.abs(ag - bg) + Math.abs(ab - bb);
|
||||
}
|
||||
|
||||
/** The four quadrants of the sheet the texture step pastes, plus the sprite's own white. */
|
||||
const PALETTE = {
|
||||
red: '#e63c3c',
|
||||
green: '#3cc85a',
|
||||
blue: '#466ef0',
|
||||
white: '#f5f5f5',
|
||||
/**
|
||||
* The sprite's own colour for the fallback check, deliberately a colour the sheet does not
|
||||
* contain. The sheet's fourth quadrant is near-white, so leaving the sprite white would make
|
||||
* "it fell back to its colour" and "it is showing that quadrant" the same measurement.
|
||||
*/
|
||||
plain: '#ff00ff',
|
||||
};
|
||||
|
||||
/**
|
||||
* Which of the known colours a sampled pixel is: the nearest one by channel distance.
|
||||
*
|
||||
* Asked this way rather than "is it exactly green" on purpose. The scene carries editor gizmos
|
||||
* by this point – a collider's fill is drawn over its body at 12 % alpha – so a sample can be
|
||||
* tinted by a few percent without anything being wrong. That shifts a colour slightly; it
|
||||
* cannot make one quadrant of the sheet read as another, which is the only question here.
|
||||
*/
|
||||
function nearestColour(sample) {
|
||||
let best = '';
|
||||
let bestDistance = Infinity;
|
||||
for (const [name, hex] of Object.entries(PALETTE)) {
|
||||
const distance = colourDistance(sample, hex);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = name;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export async function frames(t) {
|
||||
const { check, sleep, json, evaluate, shot, clickText, drag, press, undo } = t;
|
||||
|
||||
// 20. The tab exists and shows the texture the selection points at.
|
||||
await press('Escape');
|
||||
await evaluate('__gm.state.select(null)');
|
||||
const texture = await json("(__gm.state.project.assets.find((a) => a.type === 'texture') ?? {}).id ?? ''");
|
||||
await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(texture)} })`);
|
||||
await clickText('Texture');
|
||||
await sleep(600);
|
||||
check('the Texture tab opens', (await json('__gm.tab()')) === 'texture');
|
||||
check(
|
||||
'and shows the selected sheet',
|
||||
(await json('__gm.texture.current()')) === texture,
|
||||
`${await json('__gm.texture.current()')} vs ${texture}`,
|
||||
);
|
||||
await shot('33-texture-tab');
|
||||
|
||||
// 21. Dragging on the picture cuts a frame. The drag is aimed at the top-right quadrant of
|
||||
// the sheet (32,0)-(64,24), inset by a pixel so the press cannot land on the edge of a
|
||||
// neighbouring frame and be read as a resize instead of a new rectangle.
|
||||
const from = await json('__gm.texture.pointAt(33, 1)');
|
||||
const to = await json('__gm.texture.pointAt(63, 23)');
|
||||
check('the sheet is on screen', Boolean(from) && Boolean(to), JSON.stringify({ from, to }));
|
||||
await drag(from.x, from.y, to.x, to.y);
|
||||
await sleep(500);
|
||||
|
||||
const cut = await json(
|
||||
`(__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture)}) ?? {}).frames ?? []`,
|
||||
);
|
||||
check('dragging on the sheet cuts a frame', cut.length === 1, JSON.stringify(cut));
|
||||
const frame = cut[0] ?? {};
|
||||
check(
|
||||
'the frame lands on the quadrant it was dragged over',
|
||||
frame.x >= 32 && frame.y >= 0 && frame.x + frame.w <= 64 && frame.y + frame.h <= 48,
|
||||
JSON.stringify(frame),
|
||||
);
|
||||
check(
|
||||
'and is stored in whole pixels',
|
||||
Number.isInteger(frame.x) && Number.isInteger(frame.y) && Number.isInteger(frame.w),
|
||||
JSON.stringify(frame),
|
||||
);
|
||||
check(
|
||||
'cutting a frame selects it, so the inspector edits what was just made',
|
||||
(await json('__gm.state.selection?.kind')) === 'frame',
|
||||
JSON.stringify(await json('__gm.state.selection')),
|
||||
);
|
||||
await shot('34-frame-cut');
|
||||
|
||||
// 21a. It is one undo step, not one per pointer move.
|
||||
await undo();
|
||||
await sleep(400);
|
||||
check(
|
||||
'undo removes the frame in one step',
|
||||
(await json(
|
||||
`((__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture)}) ?? {}).frames ?? []).length`,
|
||||
)) === 0,
|
||||
);
|
||||
await t.redo();
|
||||
await sleep(400);
|
||||
|
||||
// 22. A sprite wearing the frame shows that part of the sheet and nothing else. This is the
|
||||
// check the whole feature exists for: the model difference between "whole picture" and
|
||||
// "this frame" is one id, so only the drawn pixels can tell them apart.
|
||||
// The sprite from the texture step is reused, white and unparented, but its texture
|
||||
// reference was left dangling there – it is pointed at the frame here.
|
||||
await evaluate(`(() => {
|
||||
const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite');
|
||||
sprite.props.color = 0xffffff;
|
||||
sprite.props.texture = ${JSON.stringify(frame.id)};
|
||||
__gm.state.preview();
|
||||
})()`);
|
||||
await evaluate("__gm.showTab('scene')");
|
||||
await sleep(900);
|
||||
|
||||
const box = await json("__gm.objectRect('otex')");
|
||||
check('the textured sprite is on screen again', Boolean(box) && box.w > 8, JSON.stringify(box));
|
||||
if (box) {
|
||||
const at = async (fx, fy) =>
|
||||
json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`);
|
||||
const corners = [await at(-0.3, -0.3), await at(0.3, -0.3), await at(-0.3, 0.3), await at(0.3, 0.3)];
|
||||
const read = corners.map(nearestColour);
|
||||
// Without the frame these four corners read red / green / blue / white – that is what the
|
||||
// texture step asserts one screen earlier. Reading green four times is therefore the exact
|
||||
// statement that the fill was confined to one quadrant of the sheet.
|
||||
check(
|
||||
'the whole sprite shows the one quadrant the frame cuts out',
|
||||
read.every((c) => c === 'green'),
|
||||
JSON.stringify({ corners, read }),
|
||||
);
|
||||
await shot('35-sprite-wearing-a-frame');
|
||||
}
|
||||
|
||||
// 23. Slicing offers a live count before it commits – the readout is what makes the numbers
|
||||
// answerable without undoing.
|
||||
// Repainted magenta before slicing: with the frame gone the sprite falls back to this, and
|
||||
// magenta is nowhere in the sheet, so the fallback cannot be confused with a part of it.
|
||||
await evaluate(`(() => {
|
||||
__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.color = 0xff00ff;
|
||||
__gm.state.preview();
|
||||
})()`);
|
||||
await evaluate("__gm.showTab('texture')");
|
||||
await sleep(400);
|
||||
await clickText('Slice…');
|
||||
await sleep(500);
|
||||
const dialog = await json('__gm.overlayTexts()');
|
||||
check(
|
||||
'the slice dialog states what the numbers would produce',
|
||||
dialog.some((text) => /^\d+ frames of \d+ × \d+ px$/.test(text)),
|
||||
JSON.stringify(dialog),
|
||||
);
|
||||
check(
|
||||
'and names the picture size it is cutting',
|
||||
dialog.some((text) => text === 'Picture is 64 × 48 px'),
|
||||
JSON.stringify(dialog),
|
||||
);
|
||||
await clickText('Slice');
|
||||
await sleep(700);
|
||||
|
||||
const sliced = await json(
|
||||
`((__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture)}) ?? {}).frames ?? []).map((f) => f.name + ' ' + f.w + 'x' + f.h)`,
|
||||
);
|
||||
check(
|
||||
'slicing 4x4 replaces the frames with sixteen 16x12 cells',
|
||||
sliced.length === 16 && sliced.every((s) => s.endsWith('16x12')),
|
||||
JSON.stringify(sliced.slice(0, 3)),
|
||||
);
|
||||
check('and numbers them in reading order', sliced[0]?.startsWith('Frame 1 '), JSON.stringify(sliced[0]));
|
||||
await shot('36-sliced-sheet');
|
||||
|
||||
// 23a. The sprite pointed at the frame that slicing removed falls back to its colour rather
|
||||
// than to a wrong part of the picture.
|
||||
await evaluate("__gm.showTab('scene')");
|
||||
await sleep(700);
|
||||
if (box) {
|
||||
const fallback = await json(
|
||||
`__gm.pixelAt(${Math.round(box.x + 0.3 * box.w)}, ${Math.round(box.y + 0.3 * box.h)})`,
|
||||
);
|
||||
check(
|
||||
'a sprite whose frame was sliced away falls back to its plain colour',
|
||||
nearestColour(fallback) === 'plain',
|
||||
JSON.stringify({ fallback, read: nearestColour(fallback) }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { containers } from './09-containers.mjs';
|
||||
import { textAndEnabled } from './10-text-and-enabled.mjs';
|
||||
import { menus } from './11-menus.mjs';
|
||||
import { textures } from './12-textures.mjs';
|
||||
import { frames } from './13-frames.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -29,4 +30,5 @@ export const STEPS = [
|
||||
textAndEnabled,
|
||||
menus,
|
||||
textures,
|
||||
frames,
|
||||
];
|
||||
Reference in new issue
Block a user