A shader ran only inside the rectangle of what wears it: the quad has the size of the sprite, and outside of it no fragment runs. An outline or a glow built with Sample texture was cut off at the border. The issue still spoke of Filter.padding, but since #77 the shader is no filter any more, the limit is the geometry. - The Output node has a number Padding next to Edge: so many pixels of the picture per side the wearer draws beyond it. 0 is the default and draws as before, 256 is the most. - The quad grows and its UVs grow with it, so the picture keeps its place and size. In the margin the UVs run on below 0 and above 1. - Transparent is a fourth Edge mode: outside of the picture there is nothing. It is cut in the fragment (texelAt), so it needs no copy of the picture with an address mode of its own. - Edge holds everywhere alike, also for Texture color, Texture alpha and unconnected inputs. - Padding above 0 with Edge on Clamp puts the hint "Padding with Clamp smears the border" on the Output. The shader still compiles. A chosen Clamp and the default cannot be told apart. - All four wearers draw the margin. A sprite counts in pixels of its frame, a text in its units, a particle source grows every particle about its centre. - A nine-slice sprite gets a ring around its nine fields, as wide as so many border pixels. The fields themselves stay. - Without a picture a pixel is one unit of the wearer's own size, or of the particle size. - Particles now take the Edge mode of their shader. It had no effect on them before. - An object wearing a padded shader is never culled, or its margin would vanish at the edge of the screen. - The preview in the shader tab shows the margin and leaves room for it. - Picking, the selection frame, the size and the collider stay with the rectangle without the margin. Tests: - Gate on melektaus-atx right before this commit: 216 files, 3336 tests (3319 + 17), exit 0. - e2e 28,30,31,37,41 on melektaus-atx: 178 checks, all pass. e2e 1-37,47,48,58 ran before the last change (no culling with padding): 1281 checks, all pass. - 17 new unit tests: the compiler (padding, Transparent, the hint) and the geometry (quad, nine-slice ring). - Seven new e2e checks: four at the sprite in module 28, one each for text, particles and nine-slice. - 17 unit counter-probes all fail their tests. Three browser counter-probes fail their checks (5, 3 and 1 of them). - Three screenshots looked at: the sprite with Clamp and margin in the scene, the preview in the shader tab, the text. - The e2e sum of 1687 is counted (1680 + 7), the full suite and the smokes did not run. - Not seen: the hint at the node in a picture, particles and nine-slice in a picture, the game view while playing, sprites without a picture, vector and pixel art pictures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1269 lines
55 KiB
JavaScript
1269 lines
55 KiB
JavaScript
import { colourDistance } from '../lib/harness.mjs';
|
||
import { FIND_HALVES, HALVES_LEFT as LEFT, HALVES_RIGHT as RIGHT, PASTE_HALVES } from './lib/pictures.mjs';
|
||
|
||
/**
|
||
* Shader assets: made from the + menu, built from nodes in the Shader tab, worn by a sprite through
|
||
* its Shader dropdown – whose Property nodes are the rows below it – and visible in the pixels, in
|
||
* the scene view and in the game – and in the tab's own preview of the picture selected last (28n).
|
||
* The fixture ships "Adjust colors"; the step removes what it made.
|
||
*/
|
||
|
||
/** (64, 128, 255): three distinct channels, so brightness, contrast and saturation each read differently. */
|
||
const BASE = '#4080ff';
|
||
const ADJUST = 'adjust-colors';
|
||
|
||
export async function shader(t) {
|
||
const {
|
||
check,
|
||
sleep,
|
||
json,
|
||
evaluate,
|
||
mutate,
|
||
drag,
|
||
click,
|
||
clickText,
|
||
locate,
|
||
press,
|
||
select,
|
||
undo,
|
||
play,
|
||
stop,
|
||
shot,
|
||
showTab,
|
||
} = t;
|
||
const { doubleClick, typeText } = t;
|
||
const field = (label) => json(`__gm.inspectorField(${JSON.stringify(label)})`);
|
||
const info = () => json('__gm.shader.info()');
|
||
const shaders = () =>
|
||
json(`__gm.state.project.assets
|
||
.filter((a) => a.type === 'shader')
|
||
.map((a) => ({ id: a.id, name: a.name, nodes: a.graph.nodes.map((n) => ({ id: n.id, type: n.type, name: n.params.name })) }))`);
|
||
/** The overlay is drawn last, so a menu entry wins over a tab button or a row saying the same. */
|
||
const clickLast = async (text) => {
|
||
const p = await locate(text, true);
|
||
await click(p.x, p.y);
|
||
};
|
||
const assetProps = (patch) =>
|
||
mutate(
|
||
`Object.assign(__gm.state.project.assets.find((a) => a.id === 'shade-sprite').props, ${JSON.stringify(patch)})`,
|
||
);
|
||
|
||
await press('Escape');
|
||
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
|
||
await evaluate('__gm.state.select(null)');
|
||
|
||
// 28a. The fixture ships Adjust colors as an asset; the + menu makes another and opens its tab on it.
|
||
const seeded = (await shaders()).find((s) => s.id === ADJUST);
|
||
check(
|
||
'the fixture carries the Adjust colors shader as an asset of twelve nodes',
|
||
Boolean(seeded) && seeded.nodes.length === 12,
|
||
JSON.stringify(seeded),
|
||
);
|
||
await clickText('+');
|
||
await clickLast('Shader');
|
||
const made = (await shaders()).find((s) => s.id !== ADJUST);
|
||
const selection = await json('__gm.state.selection');
|
||
check(
|
||
'the + menu creates a shader asset, selected, and its tab opens on it',
|
||
Boolean(made) &&
|
||
selection?.kind === 'asset' &&
|
||
selection.assetId === made.id &&
|
||
(await json('__gm.tab()')) === 'shader' &&
|
||
(await json('__gm.shader.current()')) === made.id,
|
||
JSON.stringify({ made, selection, tab: await json('__gm.tab()') }),
|
||
);
|
||
if (!made) return;
|
||
check('the tab button has room for its label', (await json("__gm.shownLabel('Shader')")) === 'Shader');
|
||
// Named after its type, the asset's row in the Assets panel would say "Shader" like the inspector row.
|
||
await mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).name = 'Glow'`);
|
||
made.name = 'Glow';
|
||
check('an empty shader says what it lacks', (await info()) === 'No Output node', await info());
|
||
|
||
// 28b. Right-click offers shader nodes only; Texture color and Output placed from the palette.
|
||
const canvas = await json('__gm.shader.canvasRect()');
|
||
// The preview takes the right of the tab (#168); the second column stays inside what is left.
|
||
const right = canvas.x + Math.min(420, canvas.w - 100);
|
||
await click(canvas.x + 80, canvas.y + 60, 'right');
|
||
const offered = await json('__gm.overlayTexts()');
|
||
check(
|
||
'right-click offers shader nodes and no behavior node',
|
||
['Texture color', 'Output', 'Property', 'Number', 'Mix', 'Luminance'].every((text) =>
|
||
offered.includes(text),
|
||
) && !offered.some((text) => /On start|Boolean|Branch|Set state/.test(text)),
|
||
JSON.stringify(offered),
|
||
);
|
||
// A dozen entries scroll; searched, the row is the only one and in view.
|
||
await typeText('Texture color');
|
||
await clickLast('Texture color');
|
||
await click(right, canvas.y + 60, 'right');
|
||
await clickLast('Output');
|
||
const madeNodes = async () => (await shaders()).find((s) => s.id === made.id)?.nodes ?? [];
|
||
check(
|
||
'both nodes are in the graph, the Output where its right-click was',
|
||
(await madeNodes()).map((n) => n.type).join(',') === 'ShaderTextureColor,ShaderOutput',
|
||
JSON.stringify(await madeNodes()),
|
||
);
|
||
await click(right, canvas.y + 320, 'right');
|
||
const again = await json('__gm.overlayTexts()');
|
||
check(
|
||
'the palette offers the Output only once',
|
||
!again.includes('Output') && again.includes('Property'),
|
||
JSON.stringify(again),
|
||
);
|
||
await press('Escape');
|
||
check(
|
||
'an Output with nothing connected compiles: the texel passes through, no property declared',
|
||
(await info()) === '0 properties',
|
||
await info(),
|
||
);
|
||
|
||
// 28c. A Property node: its name is the row a sprite shows; a bad name is a problem on the node.
|
||
await click(canvas.x + 80, canvas.y + 320, 'right');
|
||
// Property is the twelfth entry, below the palette's scroll mask: search it into view first.
|
||
await typeText('Property');
|
||
await clickLast('Property');
|
||
const propertyNode = (await madeNodes()).find((n) => n.type === 'ShaderProperty');
|
||
check(
|
||
'the placed property is named "value" and counted',
|
||
propertyNode?.name === 'value' && (await info()) === '1 property',
|
||
JSON.stringify({ propertyNode, info: await info() }),
|
||
);
|
||
const nameField = await json("__gm.inspectorField('Name', true)");
|
||
check('the inspector shows a Name field for the property', Boolean(nameField), JSON.stringify(nameField));
|
||
await doubleClick(nameField.x, nameField.y);
|
||
await typeText('glow');
|
||
await press('Enter');
|
||
check(
|
||
'typing in the Name field renames the property, on the node too',
|
||
(await madeNodes()).find((n) => n.id === propertyNode?.id)?.name === 'glow' &&
|
||
(await json(`__gm.shader.nodeLabel(${JSON.stringify(propertyNode?.id)})`)) === 'glow',
|
||
JSON.stringify(await madeNodes()),
|
||
);
|
||
await shot('28-shader-tab');
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
a.graph.nodes.find((n) => n.id === ${JSON.stringify(propertyNode?.id)}).params.name = 'no good';`);
|
||
const problem = await json(`__gm.shader.nodeProblem(${JSON.stringify(propertyNode?.id)})`);
|
||
check(
|
||
'a name that is not one word is a problem on the node and the reason in the readout',
|
||
typeof problem === 'string' && problem.startsWith('Name must be') && (await info()) === problem,
|
||
JSON.stringify({ problem, info: await info() }),
|
||
);
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
a.graph.nodes.find((n) => n.id === ${JSON.stringify(propertyNode?.id)}).params.name = 'glow';`);
|
||
|
||
// 28c-ii. Where a node sits is the editor's business: dropping it somewhere else compiles nothing
|
||
// again, a changed param does – once (#65).
|
||
const nodeRef = JSON.stringify(propertyNode?.id);
|
||
const compiledBefore = await json('__gm.shaderCompiles()');
|
||
const grab = await json(`__gm.shader.nodeAt(${nodeRef})`);
|
||
await drag(grab.x, grab.y, grab.x + 60, grab.y + 40);
|
||
const dropped = await json(`__gm.shader.nodeAt(${nodeRef})`);
|
||
const compiledAfterMove = await json('__gm.shaderCompiles()');
|
||
check(
|
||
'dropping a node somewhere else moves it without compiling the graph again',
|
||
dropped.x > grab.x + 30 && compiledAfterMove === compiledBefore,
|
||
JSON.stringify({ grab, dropped, compiledBefore, compiledAfterMove }),
|
||
);
|
||
const defaultBefore = await json(
|
||
`__gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}).graph.nodes.find((n) => n.id === ${nodeRef}).params.default`,
|
||
);
|
||
const setDefault = (value) =>
|
||
mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
a.graph.nodes.find((n) => n.id === ${nodeRef}).params.default = ${JSON.stringify(value)};`);
|
||
await setDefault(2);
|
||
const compiledAfterParam = await json('__gm.shaderCompiles()');
|
||
check(
|
||
'changing a param compiles it again, once',
|
||
compiledAfterParam === compiledAfterMove + 1,
|
||
JSON.stringify({ compiledAfterMove, compiledAfterParam }),
|
||
);
|
||
await setDefault(defaultBefore);
|
||
|
||
// 28d. A flat sprite on the camera, drawn on top of everything; its Shader dropdown lists the assets.
|
||
await mutate(`
|
||
const project = __gm.state.project;
|
||
const sprite = structuredClone(project.assets.find((a) => a.id === 'crate'));
|
||
sprite.id = 'shade-sprite';
|
||
sprite.name = 'Shaded';
|
||
sprite.props = { ...sprite.props, width: 160, height: 120, color: 0x4080ff, texture: '', shader: '' };
|
||
project.assets.push(sprite);
|
||
const scene = __gm.state.activeScene;
|
||
const cam = scene.objects.find((o) => o.id === 'o3').position;
|
||
scene.objects.push({ id: 'oshade', assetId: 'shade-sprite', name: '', position: { x: cam.x - 190, y: cam.y - 105, z: 950 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} });
|
||
__gm.state.select(null)`);
|
||
await sleep(600);
|
||
// The pixels are the scene view's; the tab is still the shader's.
|
||
await showTab('scene');
|
||
await select({ kind: 'object', holderId: 'scene1', objectIds: ['oshade'], objectId: 'oshade' });
|
||
const box = await json("__gm.objectRect('oshade')");
|
||
check('the shaded sprite is on screen', Boolean(box) && box.w > 8, JSON.stringify(box));
|
||
if (!box) return;
|
||
const sample = (fx, fy) =>
|
||
json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`);
|
||
// Off centre: a symbol may sit on the sprite's middle, and symbols draw above objects.
|
||
const centre = () => sample(0.3, 0.3);
|
||
const base = await centre();
|
||
check('with no shader the sprite is its plain colour', colourDistance(base, BASE) < 12, base);
|
||
|
||
// Every later check reads pixels at its own spot on this sprite, so nothing may be drawn over it.
|
||
// The step parks the sprite away from the camera for that reason: symbols draw above objects, and
|
||
// by this point the earlier steps have left containers and colliders around the origin. Without
|
||
// this guard their outlines show up as a wrong colour in whichever check happens to sample them.
|
||
// Since names stand on plates, a plate covers what a thin name let through: the sprite sat right
|
||
// of the camera once, where the Crate pair container's plate reaches into it, so it sits up left.
|
||
const dirty = [];
|
||
for (let fx = -0.4; fx <= 0.401; fx += 0.1)
|
||
for (let fy = -0.4; fy <= 0.401; fy += 0.1) {
|
||
const seen = await sample(fx, fy);
|
||
if (colourDistance(seen, BASE) >= 12) dirty.push(`${fx.toFixed(1)},${fy.toFixed(1)}:${seen}`);
|
||
}
|
||
check('nothing is drawn over the sprite the pixel checks read', dirty.length === 0, dirty.join(' '));
|
||
// The Shader row's dropdown reads "None"; it is the last such text in the inspector (Parent and
|
||
// Texture say it above). The label itself is no anchor: the tab button says "Shader" too.
|
||
// With its groups under bands (#290) the inspector is taller than the window: scrolled to its end.
|
||
const panelRect = await json('__gm.inspectorRect()');
|
||
await t.wheel(panelRect.x + 40, panelRect.y + panelRect.h / 2, 2000);
|
||
await t.settle();
|
||
const shaderField = await locate('None', true);
|
||
check(
|
||
'a sprite has a Shader row reading None, and no shader brings no parameter rows',
|
||
shaderField.x > box.x + box.w && (await field('brightness')) === null,
|
||
JSON.stringify(shaderField),
|
||
);
|
||
await click(shaderField.x, shaderField.y);
|
||
const choices = await json('__gm.overlayTexts()');
|
||
check(
|
||
'the dropdown offers None and every shader asset',
|
||
choices.some((text) => text.includes('None')) &&
|
||
choices.some((text) => text.includes('Adjust colors')) &&
|
||
choices.some((text) => text.includes(made.name)),
|
||
JSON.stringify({ choices, shaderField }),
|
||
);
|
||
await clickLast(choices.find((text) => text.includes('Adjust colors')));
|
||
const rows = await Promise.all([field('brightness'), field('contrast'), field('saturation')]);
|
||
check(
|
||
'picking Adjust colors adds its three property rows below the dropdown',
|
||
rows.every((r) => r !== null) &&
|
||
(await json("__gm.state.activeScene.objects.find((o) => o.id === 'oshade').overrides.shader")) ===
|
||
ADJUST,
|
||
JSON.stringify(rows),
|
||
);
|
||
await shot('28-shader-rows');
|
||
await undo();
|
||
check('undoing the choice takes the rows away again', (await field('brightness')) === null);
|
||
await select(null);
|
||
|
||
// 28e. Each property is visible in the pixels, with 1 neutral.
|
||
await assetProps({ shader: ADJUST });
|
||
const neutral = await centre();
|
||
check(
|
||
'Adjust colors at its defaults draws exactly what no shader draws',
|
||
colourDistance(neutral, base) < 6,
|
||
JSON.stringify({ base, neutral }),
|
||
);
|
||
// What the cost was made of (#67) and is not any more (#77): the shader draws in the sprite's own
|
||
// pass, there is no filter and so no extra render pass.
|
||
const passes = '({ shaders: __gm.shaderCount(), filters: __gm.filterCount() })';
|
||
const passesOn = await json(passes);
|
||
await assetProps({ shader: '' });
|
||
const passesOff = await json(passes);
|
||
await assetProps({ shader: ADJUST });
|
||
check(
|
||
'a shaded sprite draws with its own shader and no filter pass – and plain without',
|
||
passesOn.shaders === 1 && passesOn.filters === 0 && passesOff.shaders === 0 && passesOff.filters === 0,
|
||
{
|
||
on: passesOn,
|
||
off: passesOff,
|
||
},
|
||
);
|
||
await assetProps({ brightness: 1.25 });
|
||
const lighter = await centre();
|
||
check('brightness 1.25 adds a quarter to every channel', colourDistance(lighter, '#80c0ff') < 24, lighter);
|
||
await assetProps({ brightness: 1, contrast: 0 });
|
||
const flat = await centre();
|
||
check('contrast 0 flattens the colour to mid grey', colourDistance(flat, '#808080') < 24, flat);
|
||
await assetProps({ contrast: 1, saturation: 0 });
|
||
const grey = await centre();
|
||
check('saturation 0 leaves the luma as a grey', colourDistance(grey, '#7c7c7c') < 24, grey);
|
||
await shot('28-shader-desaturated');
|
||
|
||
// 28f. The game draws through the same path, so play shows the shader too.
|
||
await assetProps({ saturation: 1, brightness: 1.25 });
|
||
await play();
|
||
await sleep(500);
|
||
const at = await json("__gm.gameScreen('oshade')");
|
||
const played = at ? await json(`__gm.pixelAt(${Math.round(at.x + 20)}, ${Math.round(at.y + 20)})`) : null;
|
||
check(
|
||
'during play the sprite is drawn with its shader',
|
||
played !== null && colourDistance(played, '#80c0ff') < 24,
|
||
JSON.stringify({ at, played }),
|
||
);
|
||
await stop();
|
||
|
||
// 28g. Editing a shader's graph reaches every sprite wearing it: a Color wired into the new one's Output.
|
||
await assetProps({ brightness: 1, shader: made.id });
|
||
const through = await centre();
|
||
check(
|
||
'the new shader passes the texel through',
|
||
colourDistance(through, base) < 6,
|
||
JSON.stringify({ base, through }),
|
||
);
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
const out = a.graph.nodes.find((n) => n.type === 'ShaderOutput');
|
||
a.graph.nodes.push({ id: 'sh-red', type: 'ShaderColor', x: 0, y: 200, params: { value: 0xff0000 } });
|
||
a.graph.edges.push({ id: 'sh-red-edge', from: { node: 'sh-red', port: 'color' }, to: { node: out.id, port: 'color' } });`);
|
||
const red = await centre();
|
||
check(
|
||
'wiring a red Color into the Output recompiles: the sprite turns red',
|
||
colourDistance(red, '#ff0000') < 24,
|
||
red,
|
||
);
|
||
|
||
// 28h. Alpha: Texture alpha times a Property into the Output's alpha blends the sprite with what
|
||
// is behind it; at 1 the pixel is the one from before, byte for byte.
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
const out = a.graph.nodes.find((n) => n.type === 'ShaderOutput');
|
||
a.graph.nodes.push(
|
||
{ id: 'sh-alpha', type: 'ShaderTextureAlpha', x: 0, y: 300, params: {} },
|
||
{ id: 'sh-opacity', type: 'ShaderProperty', x: 0, y: 400, params: { name: 'opacity', default: 1, min: 0, max: 1 } },
|
||
{ id: 'sh-fade', type: 'ShaderMultiply', x: 200, y: 350, params: {} });
|
||
a.graph.edges.push(
|
||
{ id: 'sh-fade-a', from: { node: 'sh-alpha', port: 'value' }, to: { node: 'sh-fade', port: 'a' } },
|
||
{ id: 'sh-fade-b', from: { node: 'sh-opacity', port: 'value' }, to: { node: 'sh-fade', port: 'b' } },
|
||
{ id: 'sh-fade-out', from: { node: 'sh-fade', port: 'result' }, to: { node: out.id, port: 'alpha' } });`);
|
||
const opaque = await centre();
|
||
check(
|
||
'an opacity property at its default 1 changes nothing',
|
||
opaque === red,
|
||
JSON.stringify({ red, opaque, info: await info() }),
|
||
);
|
||
await assetProps({ opacity: 0 });
|
||
const behind = await centre();
|
||
check('opacity 0 shows what is behind the sprite', colourDistance(behind, red) > 60, { red, behind });
|
||
await assetProps({ opacity: 0.5 });
|
||
const half = await centre();
|
||
const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16);
|
||
const expected =
|
||
'#' +
|
||
[1, 3, 5]
|
||
.map((i) =>
|
||
Math.round((channel(red, i) + channel(behind, i)) / 2)
|
||
.toString(16)
|
||
.padStart(2, '0'),
|
||
)
|
||
.join('');
|
||
check('opacity 0.5 blends the sprite half with what is behind', colourDistance(half, expected) < 24, {
|
||
red,
|
||
behind,
|
||
half,
|
||
expected,
|
||
});
|
||
await assetProps({ opacity: 1 });
|
||
check('opacity back at 1 draws the pixel from before, byte for byte', (await centre()) === red);
|
||
|
||
// 28i. A Number node is a constant in the fragment: red times 0.5 is half red; typed back to 1,
|
||
// the pixel is the red from before. Its value is a setting row on the node.
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
const out = a.graph.nodes.find((n) => n.type === 'ShaderOutput');
|
||
a.graph.nodes.push(
|
||
{ id: 'sh-half', type: 'ShaderNumber', x: 200, y: 200, params: { value: 0.5 } },
|
||
{ id: 'sh-dim', type: 'ShaderMultiply', x: 400, y: 200, params: {} });
|
||
a.graph.edges = a.graph.edges.filter((e) => e.id !== 'sh-red-edge');
|
||
a.graph.edges.push(
|
||
{ id: 'sh-dim-a', from: { node: 'sh-red', port: 'color' }, to: { node: 'sh-dim', port: 'a' } },
|
||
{ id: 'sh-dim-b', from: { node: 'sh-half', port: 'value' }, to: { node: 'sh-dim', port: 'b' } },
|
||
{ id: 'sh-dim-out', from: { node: 'sh-dim', port: 'result' }, to: { node: out.id, port: 'color' } });`);
|
||
const dimmed = await centre();
|
||
check('a Number of 0.5 into a Multiply halves the red', colourDistance(dimmed, '#800000') < 6, {
|
||
red,
|
||
dimmed,
|
||
});
|
||
await showTab('shader');
|
||
check(
|
||
'the Number node draws its value as a setting row',
|
||
(await json("__gm.shader.nodeSettings('sh-half')"))?.join() === 'Value: 0.5',
|
||
JSON.stringify(await json("__gm.shader.nodeSettings('sh-half')")),
|
||
);
|
||
await shot('28-number-node');
|
||
await showTab('scene');
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
a.graph.nodes.find((n) => n.id === 'sh-half').params.value = 1`);
|
||
check('the Number typed to 1 gives the red from before, byte for byte', (await centre()) === red, {
|
||
red,
|
||
now: await centre(),
|
||
});
|
||
|
||
// 28k. UV into Step: the pixel's place on the picture decides its colour – a hard cut at u = 0.5,
|
||
// then at v = 0.5, which shows v runs top to bottom. `box` is the sprite's centre and size.
|
||
const node = (id, type, params = {}, x = 0, y = 0) => ({ id, type, x, y, params });
|
||
const edge = (from, fromPort, to, toPort) => ({
|
||
id: `${from}-${to}-${toPort}`,
|
||
from: { node: from, port: fromPort },
|
||
to: { node: to, port: toPort },
|
||
});
|
||
const graph = (nodes, edges) =>
|
||
mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
a.graph.nodes = ${JSON.stringify(nodes)};
|
||
a.graph.edges = ${JSON.stringify(edges)};`);
|
||
const pixel = (fx, fy) =>
|
||
json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`);
|
||
const redOrBlue = (t, edges) =>
|
||
graph(
|
||
[
|
||
...t,
|
||
node('red', 'ShaderColor', { value: 0xff0000 }, 260, 200),
|
||
node('blue', 'ShaderColor', { value: 0x0000ff }, 260, 340),
|
||
node('mix', 'ShaderMix', {}, 520, 200),
|
||
node('out', 'ShaderOutput', {}, 780, 200),
|
||
],
|
||
[
|
||
...edges,
|
||
edge('red', 'color', 'mix', 'a'),
|
||
edge('blue', 'color', 'mix', 'b'),
|
||
edge('mix', 'result', 'out', 'color'),
|
||
],
|
||
);
|
||
const cutAt = (port) =>
|
||
redOrBlue(
|
||
[node('uv', 'ShaderUV'), node('cut', 'ShaderStep', { edge: 0.5 }, 260, 0)],
|
||
[edge('uv', port, 'cut', 'value'), edge('cut', 'result', 'mix', 't')],
|
||
);
|
||
await cutAt('u');
|
||
const uLeft = await pixel(-0.3, -0.3);
|
||
const uRight = await pixel(0.3, -0.3);
|
||
check(
|
||
'UV into Step: left of u = 0.5 the sprite is red, right of it blue',
|
||
colourDistance(uLeft, '#ff0000') < 6 && colourDistance(uRight, '#0000ff') < 6,
|
||
{ uLeft, uRight },
|
||
);
|
||
await cutAt('v');
|
||
const vTop = await pixel(-0.3, -0.3);
|
||
const vBottom = await pixel(-0.3, 0.3);
|
||
check(
|
||
'v runs top to bottom: above v = 0.5 red, below it blue',
|
||
colourDistance(vTop, '#ff0000') < 6 && colourDistance(vBottom, '#0000ff') < 6,
|
||
{ vTop, vBottom },
|
||
);
|
||
|
||
// 28k-ii. Pow bends the cut: u squared crosses 0.5 at u = 0.707, its square root at u = 0.25.
|
||
const cutAfterPow = (exponent) =>
|
||
redOrBlue(
|
||
[
|
||
node('uv', 'ShaderUV'),
|
||
node('pow', 'ShaderPow', { exponent }, 260, 0),
|
||
node('cut', 'ShaderStep', { edge: 0.5 }, 260, 120),
|
||
],
|
||
[
|
||
edge('uv', 'u', 'pow', 'value'),
|
||
edge('pow', 'result', 'cut', 'value'),
|
||
edge('cut', 'result', 'mix', 't'),
|
||
],
|
||
);
|
||
await cutAfterPow(2);
|
||
const squaredNear = await pixel(0.1, -0.3);
|
||
const squaredFar = await pixel(0.3, -0.3);
|
||
check(
|
||
'Pow 2 moves the cut to u = 0.707: still red at u = 0.6, blue at 0.8',
|
||
colourDistance(squaredNear, '#ff0000') < 6 && colourDistance(squaredFar, '#0000ff') < 6,
|
||
{ squaredNear, squaredFar },
|
||
);
|
||
await cutAfterPow(0.5);
|
||
const rootNear = await pixel(-0.3, -0.3);
|
||
const rootFar = await pixel(-0.15, -0.3);
|
||
check(
|
||
'Pow 0.5 pulls it to u = 0.25: red at u = 0.2, blue at 0.35',
|
||
colourDistance(rootNear, '#ff0000') < 6 && colourDistance(rootFar, '#0000ff') < 6,
|
||
{ rootNear, rootFar },
|
||
);
|
||
|
||
// 28k-iii. Abs folds u around 0.5 into a V: |u - 0.5| is below 0.25 only in the middle half.
|
||
await redOrBlue(
|
||
[
|
||
node('uv', 'ShaderUV'),
|
||
node('centred', 'ShaderSubtract', { b: 0.5 }, 260, 0),
|
||
node('fold', 'ShaderAbs', {}, 260, 120),
|
||
node('cut', 'ShaderStep', { edge: 0.25 }, 260, 240),
|
||
],
|
||
[
|
||
edge('uv', 'u', 'centred', 'a'),
|
||
edge('centred', 'result', 'fold', 'value'),
|
||
edge('fold', 'result', 'cut', 'value'),
|
||
edge('cut', 'result', 'mix', 't'),
|
||
],
|
||
);
|
||
const foldLeft = await pixel(-0.4, -0.3);
|
||
const foldMiddle = await pixel(0, -0.3);
|
||
const foldRight = await pixel(0.4, -0.3);
|
||
check(
|
||
'Abs folds the cut into a V: red in the middle, blue at both edges',
|
||
colourDistance(foldMiddle, '#ff0000') < 6 &&
|
||
colourDistance(foldLeft, '#0000ff') < 6 &&
|
||
colourDistance(foldRight, '#0000ff') < 6,
|
||
{ foldLeft, foldMiddle, foldRight },
|
||
);
|
||
|
||
// 28k-iv. A property named like a row the sprite has anyway is a problem on the node (#63): both
|
||
// rows would sit on the same bag key and the uniform would read the sprite's width, 160 here,
|
||
// instead of the default the node declares. Renaming it compiles the same graph.
|
||
const namedProperty = (name) =>
|
||
graph(
|
||
[
|
||
node('named', 'ShaderProperty', { name, default: 0.25, min: 0, max: 1 }),
|
||
node('red', 'ShaderColor', { value: 0xff0000 }, 260, 200),
|
||
node('dim', 'ShaderMultiply', {}, 520, 0),
|
||
node('out', 'ShaderOutput', {}, 780, 0),
|
||
],
|
||
[
|
||
edge('named', 'value', 'dim', 'b'),
|
||
edge('red', 'color', 'dim', 'a'),
|
||
edge('dim', 'result', 'out', 'color'),
|
||
],
|
||
);
|
||
await namedProperty('width');
|
||
const spriteProp = await info();
|
||
check(
|
||
'a property named like a sprite row is a problem on the node, not a second width row (#63)',
|
||
(await json("__gm.shader.nodeProblem('named')")) === '"width" is a sprite property' &&
|
||
spriteProp === '"width" is a sprite property',
|
||
JSON.stringify({ spriteProp, problem: await json("__gm.shader.nodeProblem('named')") }),
|
||
);
|
||
await namedProperty('amount');
|
||
check(
|
||
'the same graph under a free name compiles: a quarter of the red',
|
||
(await info()) === '1 property' && colourDistance(await pixel(-0.3, -0.3), '#400000') < 6,
|
||
JSON.stringify({ info: await info(), pixel: await pixel(-0.3, -0.3) }),
|
||
);
|
||
|
||
// 28k-v. Dividing by zero has an answer (#64). `a / 0.0` is NaN, and the epilog's clamp(NaN) is
|
||
// undefined in GLSL – the pixel would be the driver's choice. The guard makes it 0, so the sprite
|
||
// goes black instead of "whatever this machine does".
|
||
const dividedBy = (value) =>
|
||
graph(
|
||
[
|
||
node('red', 'ShaderColor', { value: 0xff0000 }),
|
||
node('by', 'ShaderNumber', { value }, 260, 200),
|
||
node('div', 'ShaderDivide', {}, 520, 0),
|
||
node('out', 'ShaderOutput', {}, 780, 0),
|
||
],
|
||
[
|
||
edge('red', 'color', 'div', 'a'),
|
||
edge('by', 'value', 'div', 'b'),
|
||
edge('div', 'result', 'out', 'color'),
|
||
],
|
||
);
|
||
await dividedBy(2);
|
||
const halved = await pixel(-0.3, -0.3);
|
||
await dividedBy(0);
|
||
const byZero = await pixel(-0.3, -0.3);
|
||
check(
|
||
'Divide by 2 halves the red, and dividing by 0 is 0 – a black pixel, not the driver’s choice (#64)',
|
||
colourDistance(halved, '#800000') < 6 && colourDistance(byZero, '#000000') < 6,
|
||
JSON.stringify({ halved, byZero }),
|
||
);
|
||
|
||
// 28k-vi. Clamp holds a value inside a range, per channel for a colour.
|
||
await graph(
|
||
[
|
||
node('red', 'ShaderColor', { value: 0xff0000 }),
|
||
node('hold', 'ShaderClamp', { min: 0, max: 0.25 }, 260, 0),
|
||
node('out', 'ShaderOutput', {}, 520, 0),
|
||
],
|
||
[edge('red', 'color', 'hold', 'value'), edge('hold', 'result', 'out', 'color')],
|
||
);
|
||
const held = await pixel(-0.3, -0.3);
|
||
check('Clamp at 0..0.25 holds full red down to a quarter', colourDistance(held, '#400000') < 6, held);
|
||
|
||
// 28k-vii. An arithmetic node shows the type that really comes out (#219): a number until a
|
||
// colour is dragged into it, a colour from then on – along the chain behind it too. A result
|
||
// that is a colour no longer lands on a number input, and an edge wired while it was a number
|
||
// stays and turns red.
|
||
await graph(
|
||
[
|
||
node('tex', 'ShaderTextureColor', {}, 0, 0),
|
||
node('times', 'ShaderMultiply', {}, 0, 140),
|
||
node('sum', 'ShaderAdd', {}, 0, 300),
|
||
node('out', 'ShaderOutput', {}, 0, 460),
|
||
],
|
||
[edge('times', 'result', 'sum', 'a'), edge('times', 'result', 'out', 'alpha')],
|
||
);
|
||
await showTab('shader');
|
||
await clickText('Fit');
|
||
await sleep(150);
|
||
const types = (id) => json(`__gm.shader.portTypes(${JSON.stringify(id)})`);
|
||
const portOf = (id, port, isOutput) =>
|
||
json(`__gm.shader.portAt(${JSON.stringify(id)}, ${JSON.stringify(port)}, ${isOutput})`);
|
||
const wire = async (from, fromPort, to, toPort) => {
|
||
const a = await portOf(from, fromPort, true);
|
||
const b = await portOf(to, toPort, false);
|
||
await drag(a.x, a.y, b.x, b.y, 16);
|
||
};
|
||
const edgeIds = () =>
|
||
json(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).graph.edges.map((e) => e.from.node + '.' + e.from.port + '>' + e.to.node + '.' + e.to.port)`,
|
||
);
|
||
const edgeColor = (id) => json(`__gm.shader.edgeColor(${JSON.stringify(id)})`);
|
||
const theme = (name) => json(`__gm.themeColor(${JSON.stringify(name)})`);
|
||
const plain = {
|
||
times: await types('times'),
|
||
sum: await types('sum'),
|
||
toAlpha: await edgeColor('times-out-alpha'),
|
||
info: await info(),
|
||
};
|
||
await wire('tex', 'color', 'times', 'a');
|
||
const lifted = {
|
||
times: await types('times'),
|
||
sum: await types('sum'),
|
||
chain: await edgeColor('times-sum-a'),
|
||
toAlpha: await edgeColor('times-out-alpha'),
|
||
info: await info(),
|
||
problem: await json("__gm.shader.nodeProblem('out')"),
|
||
};
|
||
check(
|
||
'a Multiply is a number until a colour is dragged into it, then a colour, and so is the Add behind it',
|
||
plain.times.outputs.result === 'number' &&
|
||
plain.sum.outputs.result === 'number' &&
|
||
lifted.times.outputs.result === 'vec3' &&
|
||
lifted.times.inputs.a === 'any' &&
|
||
lifted.times.paint.a === 'vec3' &&
|
||
lifted.sum.outputs.result === 'vec3' &&
|
||
lifted.sum.paint.a === 'vec3' &&
|
||
lifted.chain === (await theme('portVec3')),
|
||
JSON.stringify({ plain, lifted }),
|
||
);
|
||
check(
|
||
'the edge wired to alpha while the result was a number stays, turns red and is the Output’s problem',
|
||
plain.toAlpha === (await theme('portNumber')) &&
|
||
plain.info === '0 properties' &&
|
||
lifted.toAlpha === (await theme('danger')) &&
|
||
lifted.problem === '"alpha" needs a number, not a color' &&
|
||
lifted.info === lifted.problem &&
|
||
(await edgeIds()).includes('times.result>out.alpha'),
|
||
JSON.stringify({ plain, lifted, edges: await edgeIds() }),
|
||
);
|
||
await shot('28-lifted-result');
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
a.graph.edges = a.graph.edges.filter((e) => e.to.node !== 'out');`);
|
||
const before = await edgeIds();
|
||
await wire('sum', 'result', 'out', 'alpha');
|
||
const refused = await edgeIds();
|
||
await press('Escape');
|
||
await wire('sum', 'result', 'out', 'color');
|
||
const taken = await edgeIds();
|
||
check(
|
||
'a result that is a colour does not land on the number input alpha, and does on color',
|
||
before.length === 2 &&
|
||
refused.length === 2 &&
|
||
taken.length === 3 &&
|
||
taken.includes('sum.result>out.color') &&
|
||
(await info()) === '0 properties',
|
||
JSON.stringify({ before, refused, taken, info: await info() }),
|
||
);
|
||
await showTab('scene');
|
||
|
||
// 28l. Sample texture reads the picture where the graph says. On a pasted two-colour picture:
|
||
// flipped u swaps the halves; u + 0.5 past the border shows the Output's Edge – Repeat continues
|
||
// with the far half, Mirror with the near one.
|
||
await evaluate(PASTE_HALVES);
|
||
await sleep(2500);
|
||
const picture = await json(FIND_HALVES);
|
||
check(
|
||
'a two-colour picture is pasted in for the sampling checks',
|
||
Boolean(picture),
|
||
JSON.stringify(picture),
|
||
);
|
||
await evaluate('__gm.state.select(null)');
|
||
await assetProps({ texture: picture?.id ?? '', color: 0xffffff });
|
||
await graph(
|
||
[node('texel', 'ShaderTextureColor'), node('out', 'ShaderOutput')],
|
||
[edge('texel', 'color', 'out', 'color')],
|
||
);
|
||
await sleep(600);
|
||
const plainLeft = await pixel(-0.3, -0.3);
|
||
const plainRight = await pixel(0.3, -0.3);
|
||
check(
|
||
'through Texture color the picture is on: red left, green right',
|
||
colourDistance(plainLeft, LEFT) < 12 && colourDistance(plainRight, RIGHT) < 12,
|
||
{ plainLeft, plainRight },
|
||
);
|
||
const sampleAt = (uNodes, uEdges, outParams = {}) =>
|
||
graph(
|
||
[
|
||
node('uv', 'ShaderUV'),
|
||
...uNodes,
|
||
node('sample', 'ShaderSample', {}, 520, 0),
|
||
node('out', 'ShaderOutput', outParams, 780, 0),
|
||
],
|
||
[...uEdges, edge('sample', 'color', 'out', 'color')],
|
||
);
|
||
await sampleAt(
|
||
[node('flip', 'ShaderSubtract', { a: 1 }, 260, 0)],
|
||
[edge('uv', 'u', 'flip', 'b'), edge('flip', 'result', 'sample', 'u')],
|
||
);
|
||
const flipLeft = await pixel(-0.3, -0.3);
|
||
const flipRight = await pixel(0.3, -0.3);
|
||
check(
|
||
'sampling at 1 - u mirrors the picture: green left, red right',
|
||
colourDistance(flipLeft, RIGHT) < 12 && colourDistance(flipRight, LEFT) < 12,
|
||
{ flipLeft, flipRight },
|
||
);
|
||
const scrolled = async (edgeMode) => {
|
||
await sampleAt(
|
||
[node('shift', 'ShaderAdd', { b: 0.5 }, 260, 0)],
|
||
[edge('uv', 'u', 'shift', 'a'), edge('shift', 'result', 'sample', 'u')],
|
||
{ edge: edgeMode },
|
||
);
|
||
return { left: await pixel(-0.3, -0.3), right: await pixel(0.3, -0.3) };
|
||
};
|
||
const repeat = await scrolled('repeat');
|
||
check(
|
||
'u + 0.5 with Edge Repeat: the near half shows the far one, and past the border the picture starts over',
|
||
colourDistance(repeat.left, RIGHT) < 12 && colourDistance(repeat.right, LEFT) < 12,
|
||
repeat,
|
||
);
|
||
const mirror = await scrolled('mirror');
|
||
check(
|
||
'the same with Edge Mirror: past the border the picture turns back on itself',
|
||
colourDistance(mirror.left, RIGHT) < 12 && colourDistance(mirror.right, RIGHT) < 12,
|
||
mirror,
|
||
);
|
||
// u + 0.5 cannot tell Clamp from Mirror: both hold green past the border. u + 0.8 puts the near
|
||
// half on 1.6, where Mirror is already back in the far half and Clamp still holds the last texel.
|
||
const scrolledFar = async (outParams) => {
|
||
await sampleAt(
|
||
[node('far', 'ShaderAdd', { b: 0.8 }, 260, 0)],
|
||
[edge('uv', 'u', 'far', 'a'), edge('far', 'result', 'sample', 'u')],
|
||
outParams,
|
||
);
|
||
return pixel(0.3, -0.3);
|
||
};
|
||
const byDefault = await scrolledFar({});
|
||
const byMirror = await scrolledFar({ edge: 'mirror' });
|
||
check(
|
||
'with nothing set on the Output the edge is Clamp: at u + 0.8 the border texel goes on, where Mirror shows the near half again',
|
||
colourDistance(byDefault, RIGHT) < 12 && colourDistance(byMirror, LEFT) < 12,
|
||
{ byDefault, byMirror },
|
||
);
|
||
await showTab('shader');
|
||
await shot('28-shader-scroll-graph');
|
||
await showTab('scene');
|
||
const sizeAtLeast = async (px) => {
|
||
await redOrBlue(
|
||
[node('size', 'ShaderTextureSize'), node('cut', 'ShaderStep', { edge: px }, 260, 0)],
|
||
[edge('size', 'width', 'cut', 'value'), edge('cut', 'result', 'mix', 't')],
|
||
);
|
||
return pixel(-0.3, -0.3);
|
||
};
|
||
const wide = await sizeAtLeast(31.5);
|
||
const wider = await sizeAtLeast(32.5);
|
||
check(
|
||
'Texture size reads the picture’s pixel width: at least 31.5, not 32.5',
|
||
colourDistance(wide, '#0000ff') < 6 && colourDistance(wider, '#ff0000') < 6,
|
||
{ wide, wider },
|
||
);
|
||
|
||
// 28l-iii. Padding is room around the picture the sprite draws into as well (#69), counted in
|
||
// pixels of the picture: 8 of this 32-px one is a quarter of the sprite per side. The
|
||
// picture itself stays where it is, and what the margin reads is the Output's Edge.
|
||
const texelOnly = (outParams) =>
|
||
graph(
|
||
[node('texel', 'ShaderTextureColor'), node('out', 'ShaderOutput', outParams, 780, 0)],
|
||
[edge('texel', 'color', 'out', 'color')],
|
||
);
|
||
const around = async () => ({
|
||
left: await pixel(-0.3, -0.3),
|
||
right: await pixel(0.3, -0.3),
|
||
marginLeft: await pixel(-0.62, -0.3),
|
||
marginRight: await pixel(0.62, -0.3),
|
||
beyond: await pixel(0.9, -0.3),
|
||
});
|
||
await texelOnly({ edge: 'transparent' });
|
||
const bare = await around();
|
||
await texelOnly({ edge: 'transparent', padding: 8 });
|
||
const roomy = await around();
|
||
check(
|
||
'Padding with Edge Transparent leaves the picture where it is and the margin empty',
|
||
colourDistance(roomy.left, LEFT) < 12 &&
|
||
colourDistance(roomy.right, RIGHT) < 12 &&
|
||
roomy.marginLeft === bare.marginLeft &&
|
||
roomy.marginRight === bare.marginRight,
|
||
{ bare, roomy },
|
||
);
|
||
// A sprite far off screen is not drawn at all; one whose shader has padding is, because its
|
||
// margin may reach in while its rectangle is still outside.
|
||
const awayBy = (dx) =>
|
||
mutate(`__gm.state.activeScene.objects.find((o) => o.id === 'oshade').position.x += ${dx};`);
|
||
await awayBy(100000);
|
||
const farPadded = await json('__gm.shaderCount()');
|
||
await texelOnly({ edge: 'transparent' });
|
||
const farPlain = await json('__gm.shaderCount()');
|
||
await awayBy(-100000);
|
||
check(
|
||
'off screen a shaded sprite is left out, a padded one is still drawn for its margin',
|
||
farPadded === farPlain + 1,
|
||
{ farPadded, farPlain },
|
||
);
|
||
const shiftedRight = async (padding) => {
|
||
await sampleAt(
|
||
[node('shift', 'ShaderAdd', { b: -0.25 }, 260, 0)],
|
||
// The sample's own opacity: in the margin the pixel's own is none, with Edge Transparent.
|
||
[
|
||
edge('uv', 'u', 'shift', 'a'),
|
||
edge('shift', 'result', 'sample', 'u'),
|
||
edge('sample', 'alpha', 'out', 'alpha'),
|
||
],
|
||
{ edge: 'transparent', padding },
|
||
);
|
||
return around();
|
||
};
|
||
const clipped = await shiftedRight(0);
|
||
const drawn = await shiftedRight(8);
|
||
check(
|
||
'a picture shifted right is cut at the sprite’s rectangle without padding, and drawn past it with',
|
||
clipped.marginRight === bare.marginRight &&
|
||
colourDistance(drawn.marginRight, RIGHT) < 12 &&
|
||
drawn.marginLeft === bare.marginLeft &&
|
||
drawn.beyond === bare.beyond,
|
||
{ bare, clipped, drawn },
|
||
);
|
||
await texelOnly({ padding: 8 });
|
||
const smeared = await around();
|
||
await showTab('shader');
|
||
await clickText('Fit');
|
||
await sleep(300);
|
||
const hint = await json("__gm.shader.nodeProblem('out')");
|
||
const readout = await info();
|
||
await shot('28-shader-padding-hint');
|
||
await showTab('scene');
|
||
check(
|
||
'with Edge Clamp the border pixel smears into the margin, and the Output says so while the shader still compiles',
|
||
colourDistance(smeared.marginLeft, LEFT) < 12 &&
|
||
colourDistance(smeared.marginRight, RIGHT) < 12 &&
|
||
smeared.beyond === bare.beyond &&
|
||
hint === 'Padding with Clamp smears the border' &&
|
||
readout !== hint,
|
||
{ smeared, hint, readout },
|
||
);
|
||
await shot('28-shader-padding-scene');
|
||
|
||
// 28l-ii. The sprite wears a picture and a shader: both open from the object's menu, from the
|
||
// asset's menu and from the inspector, whose Texture and Shader labels are links (#221).
|
||
const shadedProps = await json("__gm.state.project.assets.find((a) => a.id === 'shade-sprite').props");
|
||
const docs = () => json('__gm.documents()');
|
||
const openBefore = (await docs()).docs;
|
||
const opened = async () => (await docs()).docs.filter((doc) => !openBefore.includes(doc));
|
||
const closeAll = async () => {
|
||
for (const doc of await opened()) await evaluate(`__gm.closeDocument(${JSON.stringify(doc)})`);
|
||
await showTab('scene');
|
||
};
|
||
// A missing entry is a failed check, not a dead run: picked only when it is there.
|
||
const pick = async (text) => {
|
||
const entry = await t.find(text, true);
|
||
if (entry) await click(entry.x, entry.y);
|
||
else await press('Escape');
|
||
};
|
||
await showTab('scene');
|
||
await click(Math.round(box.x + 0.3 * box.w), Math.round(box.y + 0.3 * box.h), 'right');
|
||
const objectRows = ((await json('__gm.menuRows()')) ?? []).filter(Boolean);
|
||
await pick('Open shader');
|
||
const shaderOpened = await docs();
|
||
check(
|
||
'the object menu offers Open texture and Open shader, and Open shader opens the one it wears',
|
||
objectRows.slice(0, 4).join('|') === 'Select asset|Open asset|Open texture|Open shader' &&
|
||
shaderOpened.active === `asset:${shadedProps.shader}`,
|
||
JSON.stringify({ objectRows, shaderOpened, shadedProps }),
|
||
);
|
||
await closeAll();
|
||
const spriteRow = await json("__gm.assetRow('shade-sprite')");
|
||
await click(spriteRow.x, spriteRow.y, 'right');
|
||
const assetRows = ((await json('__gm.menuRows()')) ?? []).filter(Boolean);
|
||
await pick('Open texture');
|
||
const textureOpened = await docs();
|
||
check(
|
||
'the asset menu starts with the same two entries, and Open texture opens the picture',
|
||
assetRows.slice(0, 2).join('|') === 'Open texture|Open shader' &&
|
||
textureOpened.active === `asset:${picture?.id}`,
|
||
JSON.stringify({ assetRows, textureOpened }),
|
||
);
|
||
await closeAll();
|
||
|
||
await select({ kind: 'object', holderId: 'scene1', objectIds: ['oshade'], objectId: 'oshade' });
|
||
const link = (text) => json(`__gm.inspectorLink(${JSON.stringify(text)})`);
|
||
const textureLink = await link('Texture');
|
||
const nameLink = await link('Name');
|
||
check(
|
||
'a set Texture reads as a link in the accent colour, underlined; a plain row does not',
|
||
textureLink?.underlined === true &&
|
||
textureLink.pressable === true &&
|
||
textureLink.color === (await json("__gm.themeColor('accent')")) &&
|
||
nameLink?.underlined === false &&
|
||
nameLink.pressable === false,
|
||
JSON.stringify({ textureLink, nameLink }),
|
||
);
|
||
await shot('28-shader-links');
|
||
// Folded first, so the ping has to find the row itself.
|
||
const textures = await json("__gm.assetRow('group:texture')");
|
||
await click(textures.x, textures.y);
|
||
const foldedBefore =
|
||
(await json('__gm.assetGroups()')).find((g) => g.type === 'texture').expanded === false;
|
||
const rebuildsBefore = (await json('__gm.inspectorInfo()')).rebuilds;
|
||
await click(textureLink.x, textureLink.y);
|
||
const afterClick = {
|
||
ping: await json('__gm.assetPing()'),
|
||
row: await json(`__gm.assetRowShown(${JSON.stringify(picture?.id)})`),
|
||
selection: await json('__gm.state.selection'),
|
||
rebuilds: (await json('__gm.inspectorInfo()')).rebuilds - rebuildsBefore,
|
||
opened: await opened(),
|
||
tab: await json('__gm.tab()'),
|
||
};
|
||
await shot('28-shader-ping');
|
||
check(
|
||
'a click on the link pings the picture in the asset panel: its group unfolds, its row lights up, the inspector stays',
|
||
foldedBefore &&
|
||
afterClick.ping?.id === picture?.id &&
|
||
afterClick.ping.alpha > 0 &&
|
||
Boolean(afterClick.row) &&
|
||
afterClick.selection?.kind === 'object' &&
|
||
afterClick.selection.objectId === 'oshade' &&
|
||
afterClick.rebuilds === 0 &&
|
||
afterClick.opened.length === 0 &&
|
||
afterClick.tab === 'scene',
|
||
JSON.stringify({ foldedBefore, afterClick }),
|
||
);
|
||
// Past the glow and the double-click window, so the next press is a fresh one.
|
||
await sleep(1200);
|
||
const faded = await json('__gm.assetPing()');
|
||
check('the glow fades out by itself', faded === null, JSON.stringify(faded));
|
||
// Still the sprite's inspector after the ping; were it not, the link is missing and the check fails.
|
||
const shaderLink = await link('Shader');
|
||
if (shaderLink) await doubleClick(shaderLink.x, shaderLink.y);
|
||
const afterDouble = { selection: await json('__gm.state.selection'), docs: await docs() };
|
||
check(
|
||
'a double-click on the Shader link opens the shader',
|
||
afterDouble.selection?.assetId === shadedProps.shader &&
|
||
afterDouble.docs.active === `asset:${shadedProps.shader}`,
|
||
JSON.stringify({ shaderLink, afterDouble }),
|
||
);
|
||
await closeAll();
|
||
|
||
// What the object was placed from is a link too: its name beside it only reads.
|
||
await select({ kind: 'object', holderId: 'scene1', objectIds: ['oshade'], objectId: 'oshade' });
|
||
const spriteName = await json("__gm.state.project.assets.find((a) => a.id === 'shade-sprite').name");
|
||
const assetLink = await link('Asset');
|
||
if (assetLink?.field) await click(assetLink.x + 150, assetLink.y);
|
||
const focusedAfter = await json('__gm.focusedRect()');
|
||
check(
|
||
'the Asset row is a link with the name in a read-only field that takes no focus',
|
||
assetLink?.underlined === true &&
|
||
assetLink.pressable === true &&
|
||
assetLink.color === (await json("__gm.themeColor('accent')")) &&
|
||
assetLink.field?.value === spriteName &&
|
||
assetLink.field.readOnly === true &&
|
||
focusedAfter === null,
|
||
JSON.stringify({ assetLink, spriteName, focusedAfter }),
|
||
);
|
||
const assetRebuilds = (await json('__gm.inspectorInfo()')).rebuilds;
|
||
if (assetLink) await click(assetLink.x, assetLink.y);
|
||
const assetPing = {
|
||
ping: await json('__gm.assetPing()'),
|
||
selection: await json('__gm.state.selection'),
|
||
rebuilds: (await json('__gm.inspectorInfo()')).rebuilds - assetRebuilds,
|
||
};
|
||
await mutate("__gm.state.project.assets.find((a) => a.id === 'shade-sprite').name = 'Renamed sprite'");
|
||
const renamedField = (await link('Asset'))?.field?.value;
|
||
await mutate(
|
||
`__gm.state.project.assets.find((a) => a.id === 'shade-sprite').name = ${JSON.stringify(spriteName)}`,
|
||
);
|
||
check(
|
||
'a click pings the sprite and the inspector stays; renaming the sprite updates the field',
|
||
assetPing.ping?.id === 'shade-sprite' &&
|
||
assetPing.selection?.objectId === 'oshade' &&
|
||
assetPing.rebuilds === 0 &&
|
||
renamedField === 'Renamed sprite',
|
||
JSON.stringify({ assetPing, renamedField }),
|
||
);
|
||
await sleep(1200);
|
||
const assetAgain = await link('Asset');
|
||
if (assetAgain) await doubleClick(assetAgain.x, assetAgain.y);
|
||
const assetOpened = await json('__gm.state.selection');
|
||
check(
|
||
'a double-click on the Asset link opens the sprite',
|
||
assetOpened?.kind === 'asset' && assetOpened.assetId === 'shade-sprite',
|
||
JSON.stringify({ assetAgain, assetOpened }),
|
||
);
|
||
await closeAll();
|
||
await select(null);
|
||
|
||
// 28m. Time: with the clock held the pixel is a function of the second; released, a sprite wearing
|
||
// a Time shader redraws by itself in the scene view, where nothing else ticks, and in play.
|
||
await redOrBlue(
|
||
[
|
||
node('time', 'ShaderTime'),
|
||
node('saw', 'ShaderFract', {}, 260, 0),
|
||
node('cut', 'ShaderStep', { edge: 0.5 }, 260, 120),
|
||
],
|
||
[
|
||
edge('time', 'value', 'saw', 'value'),
|
||
edge('saw', 'result', 'cut', 'value'),
|
||
edge('cut', 'result', 'mix', 't'),
|
||
],
|
||
);
|
||
await evaluate('__gm.shaderTime(0.2)');
|
||
await sleep(400);
|
||
const early = await pixel(-0.3, -0.3);
|
||
await evaluate('__gm.shaderTime(0.7)');
|
||
await sleep(400);
|
||
const late = await pixel(-0.3, -0.3);
|
||
check(
|
||
'Time into Fract and Step: red at 0.2 s, blue at 0.7 s',
|
||
colourDistance(early, '#ff0000') < 6 && colourDistance(late, '#0000ff') < 6,
|
||
{ early, late },
|
||
);
|
||
await redOrBlue(
|
||
[node('time', 'ShaderTime'), node('saw', 'ShaderFract', {}, 260, 0)],
|
||
[edge('time', 'value', 'saw', 'value'), edge('saw', 'result', 'mix', 't')],
|
||
);
|
||
await evaluate('__gm.shaderTime(null)');
|
||
await sleep(400);
|
||
const first = await pixel(-0.3, -0.3);
|
||
await sleep(400);
|
||
const second = await pixel(-0.3, -0.3);
|
||
const animates = await json('__gm.animates()');
|
||
check(
|
||
'released, the scene view redraws every tick for the Time shader: the colour moves with nothing running',
|
||
animates.scene === true && animates.game === false && first !== second,
|
||
{ animates, first, second },
|
||
);
|
||
// (#90) The shader clock is the global one: a scale of 0 holds the colour where it is without
|
||
// freezing anything shader-specific, and 1 lets it move on.
|
||
await evaluate('__gm.setTimeScale(0)');
|
||
await sleep(400);
|
||
const frozenEarly = await pixel(-0.3, -0.3);
|
||
await sleep(400);
|
||
const frozenLate = await pixel(-0.3, -0.3);
|
||
await evaluate('__gm.setTimeScale(1)');
|
||
await sleep(400);
|
||
const thawed = await pixel(-0.3, -0.3);
|
||
check(
|
||
'a global time scale of 0 stops the shader clock, and 1 starts it again',
|
||
frozenEarly === frozenLate && thawed !== frozenLate,
|
||
{ frozenEarly, frozenLate, thawed },
|
||
);
|
||
await play();
|
||
await sleep(500);
|
||
const playing = await json('__gm.animates()');
|
||
await stop();
|
||
check('and so does the game view while playing', playing.game === true, JSON.stringify(playing));
|
||
await assetProps({ shader: '' });
|
||
check(
|
||
'with the shader off the scene view stops redrawing',
|
||
(await json('__gm.animates()')).scene === false,
|
||
JSON.stringify(await json('__gm.animates()')),
|
||
);
|
||
// 28n. The Shader tab previews the picture selected last (#168), through sliders of its own that
|
||
// never touch the project, and falls back to a built-in test image once that picture is gone.
|
||
await graph(
|
||
[
|
||
node('uv', 'ShaderUV'),
|
||
node('flip', 'ShaderSubtract', { a: 1 }, 260, 0),
|
||
node('sample', 'ShaderSample', {}, 520, 0),
|
||
node('level', 'ShaderProperty', { name: 'level', default: 1, min: 0, max: 1 }, 520, 200),
|
||
node('dim', 'ShaderMultiply', {}, 780, 0),
|
||
node('out', 'ShaderOutput', {}, 1040, 0),
|
||
],
|
||
[
|
||
edge('uv', 'u', 'flip', 'b'),
|
||
edge('flip', 'result', 'sample', 'u'),
|
||
edge('sample', 'color', 'dim', 'a'),
|
||
edge('level', 'value', 'dim', 'b'),
|
||
edge('dim', 'result', 'out', 'color'),
|
||
],
|
||
);
|
||
const openPreview = async (selection) => {
|
||
await select(selection);
|
||
await select({ kind: 'asset', assetId: made.id });
|
||
await showTab('shader');
|
||
await sleep(400);
|
||
return json('__gm.shader.preview()');
|
||
};
|
||
const inPicture = async (fx, fy) => {
|
||
const r = await json('__gm.shader.pictureRect()');
|
||
return json(`__gm.pixelAt(${Math.floor(r.x + fx * r.w)}, ${Math.floor(r.y + fy * r.h)})`);
|
||
};
|
||
const halvesName = await json(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(picture?.id ?? '')})?.name ?? null`,
|
||
);
|
||
const onHalves = await openPreview({ kind: 'asset', assetId: picture?.id ?? '' });
|
||
const flippedLeft = await inPicture(0.25, 0.5);
|
||
const flippedRight = await inPicture(0.75, 0.5);
|
||
check(
|
||
'the preview shows the texture selected last, through the shader: 1 - u puts green left, red right',
|
||
onHalves.subject === halvesName &&
|
||
(await json('__gm.shader.current()')) === made.id &&
|
||
colourDistance(flippedLeft, RIGHT) < 12 &&
|
||
colourDistance(flippedRight, LEFT) < 12,
|
||
{ halvesName, onHalves, flippedLeft, flippedRight },
|
||
);
|
||
check(
|
||
'the preview has a slider for the property at its default, and no clock without a Time node',
|
||
onHalves.values.level === 1 && onHalves.timed === false && onHalves.time === '',
|
||
onHalves,
|
||
);
|
||
await shot('28-shader-preview');
|
||
const levelSlider = await json("__gm.shader.sliderAt('level')");
|
||
await click(levelSlider.x + 2, levelSlider.y + levelSlider.h / 2);
|
||
await sleep(300);
|
||
const pulled = await json('__gm.shader.preview()');
|
||
const dark = await inPicture(0.25, 0.5);
|
||
const written = await json(`({
|
||
sprite: __gm.state.project.assets.find((a) => a.id === 'shade-sprite').props.level ?? null,
|
||
declared: __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).graph.nodes.find((n) => n.id === 'level').params.default,
|
||
})`);
|
||
check(
|
||
'the slider pulled to its left end darkens the preview to black and writes nothing into the project',
|
||
pulled.values.level === 0 &&
|
||
colourDistance(dark, '#000000') < 12 &&
|
||
written.sprite === null &&
|
||
written.declared === 1,
|
||
{ pulled, dark, written },
|
||
);
|
||
// A changed range rebuilds the row while the column keeps its height: the new slider still gets
|
||
// its width, and its right end is the new max.
|
||
await mutate(`
|
||
__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)})
|
||
.graph.nodes.find((n) => n.id === 'level').params.max = 2;`);
|
||
await sleep(300);
|
||
const widened = await json("__gm.shader.sliderAt('level')");
|
||
await click(widened.x + widened.w - 2, widened.y + widened.h / 2);
|
||
await sleep(300);
|
||
const pushed = await json('__gm.shader.preview()');
|
||
check(
|
||
'a new max rebuilds the slider at full width, and its right end is the new max',
|
||
widened.w > 40 && Math.abs(widened.w - levelSlider.w) < 1 && pushed.values.level === 2,
|
||
{ levelSlider, widened, pushed },
|
||
);
|
||
|
||
await assetProps({ texture: picture?.id ?? '', color: 0xffffff, shader: made.id, level: 0.5 });
|
||
const onSprite = await openPreview({
|
||
kind: 'object',
|
||
holderId: 'scene1',
|
||
objectIds: ['oshade'],
|
||
objectId: 'oshade',
|
||
});
|
||
const halfGreen = await inPicture(0.25, 0.5);
|
||
const halfOfRight = `#${[1, 3, 5]
|
||
.map((i) =>
|
||
Math.round(parseInt(RIGHT.slice(i, i + 2), 16) / 2)
|
||
.toString(16)
|
||
.padStart(2, '0'),
|
||
)
|
||
.join('')}`;
|
||
check(
|
||
'a sprite wearing the shader starts the slider at its own value: level 0.5 halves the green',
|
||
onSprite.subject === 'Shaded' &&
|
||
onSprite.values.level === 0.5 &&
|
||
colourDistance(halfGreen, halfOfRight) < 12,
|
||
{ onSprite, halfGreen, halfOfRight },
|
||
);
|
||
|
||
await redOrBlue(
|
||
[
|
||
node('time', 'ShaderTime'),
|
||
node('saw', 'ShaderFract', {}, 260, 0),
|
||
node('cut', 'ShaderStep', { edge: 0.5 }, 260, 120),
|
||
],
|
||
[
|
||
edge('time', 'value', 'saw', 'value'),
|
||
edge('saw', 'result', 'cut', 'value'),
|
||
edge('cut', 'result', 'mix', 't'),
|
||
],
|
||
);
|
||
await evaluate('__gm.shader.hold(0.2)');
|
||
await sleep(300);
|
||
const heldEarly = { preview: await json('__gm.shader.preview()'), pixel: await inPicture(0.5, 0.5) };
|
||
await evaluate('__gm.shader.hold(0.7)');
|
||
await sleep(300);
|
||
const heldLate = { preview: await json('__gm.shader.preview()'), pixel: await inPicture(0.5, 0.5) };
|
||
check(
|
||
'with a Time node the preview has a clock of its own: held at 0.2 s red, at 0.7 s blue, the button reads Run',
|
||
heldEarly.preview.timed &&
|
||
heldLate.preview.hold === 'Run' &&
|
||
heldLate.preview.time === '0.70 s' &&
|
||
colourDistance(heldEarly.pixel, '#ff0000') < 6 &&
|
||
colourDistance(heldLate.pixel, '#0000ff') < 6,
|
||
{ heldEarly, heldLate },
|
||
);
|
||
await evaluate('__gm.shader.hold(null)');
|
||
await sleep(300);
|
||
const runningFirst = await json('__gm.shader.preview()');
|
||
await sleep(600);
|
||
const runningLater = await json('__gm.shader.preview()');
|
||
check(
|
||
'let go, the clock runs on from the held second',
|
||
runningFirst.hold === 'Hold' &&
|
||
runningFirst.seconds >= 0.7 &&
|
||
runningLater.seconds > runningFirst.seconds &&
|
||
runningLater.time !== runningFirst.time,
|
||
{ runningFirst, runningLater },
|
||
);
|
||
|
||
// The halves texture selected once more, then deleted: the preview has nothing left to show.
|
||
await graph(
|
||
[node('texel', 'ShaderTextureColor'), node('out', 'ShaderOutput', {}, 260, 0)],
|
||
[edge('texel', 'color', 'out', 'color')],
|
||
);
|
||
await openPreview({ kind: 'asset', assetId: picture?.id ?? '' });
|
||
await mutate(`
|
||
const project = __gm.state.project;
|
||
project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(picture?.id ?? '')});
|
||
project.assets.find((a) => a.id === 'shade-sprite').props.texture = '';`);
|
||
await sleep(400);
|
||
const fallback = await json('__gm.shader.preview()');
|
||
// Texel (16, 48) of the 64-px test image, hit in its middle: u 0.26, v 0.76, a light checker cell.
|
||
const testTexel = await inPicture(16.5 / 64, 48.5 / 64);
|
||
const testEdge = await inPicture(0.5 / 64, 0.5);
|
||
check(
|
||
'with that picture gone the preview shows the test image: colour runs with u and v, the edge fades out',
|
||
fallback.subject === 'Test image' &&
|
||
colourDistance(testTexel, '#42c1bd') < 12 &&
|
||
colourDistance(testEdge, testTexel) > 60,
|
||
{ fallback, testTexel, testEdge },
|
||
);
|
||
await select(null);
|
||
await showTab('scene');
|
||
await assetProps({ texture: '', color: 0x4080ff, shader: made.id });
|
||
|
||
// 28j. Back to None: no filter at all, the plain colour returns bit for bit.
|
||
await assetProps({ shader: '' });
|
||
const back = await centre();
|
||
check(
|
||
'switching back to None restores the plain colour',
|
||
colourDistance(back, base) < 6,
|
||
JSON.stringify({ base, back }),
|
||
);
|
||
|
||
await mutate(`
|
||
const project = __gm.state.project;
|
||
const gone = new Set(['shade-sprite', ${JSON.stringify(made.id)}, ${JSON.stringify(picture?.id ?? '')}]);
|
||
project.assets = project.assets.filter((a) => !gone.has(a.id));
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter((o) => o.id !== 'oshade')`);
|
||
await evaluate('__gm.state.select(null)');
|
||
check(
|
||
'the step leaves nothing behind but the fixture’s shader',
|
||
(await json("__gm.state.project.assets.some((a) => a.id === 'shade-sprite')")) === false &&
|
||
(await json("__gm.state.activeScene.objects.some((o) => o.id === 'oshade')")) === false &&
|
||
(await json(`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(picture?.id ?? '')})`)) ===
|
||
false &&
|
||
(await shaders()).map((s) => s.id).join(',') === ADJUST,
|
||
);
|
||
}
|