Since the Instrument joined the + menu, its last entries can sit
below the fold of the test window. CI for 8a4e4a6 stopped in step 12
with 'widget with text "Texture" not found', and every step after it
was skipped.
- pickAssetType looked first with locate, which throws on a row
scrolled out of its list. So it never reached its own scroll. It
now looks with find and scrolls when the hit is not in the menu.
- A hit outside the menu counts as a miss, so the centre tab
"Texture" cannot take the click meant for the entry.
- Steps 12, 26 and 28 picked their entry by hand and now go through
pickAssetType ("Texture", "State machine", "Shader").
Tests:
- e2e 1-12, 26, 28 (melektaus-atx): ALL PASSED, 635 checks.
Before the fix step 12 failed there as in CI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1642 lines
71 KiB
JavaScript
1642 lines
71 KiB
JavaScript
import { colourDistance } from '../lib/harness.mjs';
|
||
import {
|
||
FIND_DOT,
|
||
FIND_HALVES,
|
||
HALVES_LEFT as LEFT,
|
||
HALVES_RIGHT as RIGHT,
|
||
PASTE_DOT,
|
||
PASTE_HALVES,
|
||
} from './lib/pictures.mjs';
|
||
import { pickAssetType } from './lib/scaffold.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 pickAssetType(t, '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-iv. A Vertex output moves the corners of the sprite's grid (#79), in pixels of the picture:
|
||
// 16 of this 32-px one is half the sprite. A constant moves the whole picture; a cut on v
|
||
// bends it only where the grid has corners, and with one cell there is just a shear.
|
||
const cornered = (cornerNodes, cornerEdges, subdivisions) =>
|
||
graph(
|
||
[
|
||
node('texel', 'ShaderTextureColor'),
|
||
node('out', 'ShaderOutput', { edge: 'transparent' }, 780, 0),
|
||
...cornerNodes,
|
||
node('vout', 'ShaderVertexOutput', { subdivisions }, 780, 260),
|
||
],
|
||
[edge('texel', 'color', 'out', 'color'), ...cornerEdges],
|
||
);
|
||
await cornered(
|
||
[node('by', 'ShaderNumber', { value: 16 }, 260, 260)],
|
||
[edge('by', 'value', 'vout', 'x')],
|
||
8,
|
||
);
|
||
const moved = {
|
||
left: await pixel(-0.3, -0.3),
|
||
right: await pixel(0.3, -0.3),
|
||
past: await pixel(0.8, -0.3),
|
||
};
|
||
check(
|
||
'a Vertex output fed 16 moves the picture half a sprite to the right, past its rectangle',
|
||
colourDistance(moved.left, LEFT) > 60 &&
|
||
colourDistance(moved.right, LEFT) < 12 &&
|
||
colourDistance(moved.past, RIGHT) < 12 &&
|
||
colourDistance(bare.left, LEFT) < 12,
|
||
{ moved, bare },
|
||
);
|
||
const lowerHalf = async (subdivisions) => {
|
||
await cornered(
|
||
[
|
||
node('uv', 'ShaderUV', {}, 0, 260),
|
||
node('cut', 'ShaderStep', { edge: 0.5 }, 260, 260),
|
||
node('far', 'ShaderMultiply', { b: 16 }, 520, 260),
|
||
],
|
||
[
|
||
edge('uv', 'v', 'cut', 'value'),
|
||
edge('cut', 'result', 'far', 'a'),
|
||
edge('far', 'result', 'vout', 'x'),
|
||
],
|
||
subdivisions,
|
||
);
|
||
return { upper: await pixel(0.6, -0.2), lower: await pixel(0.8, 0.35) };
|
||
};
|
||
const bent = await lowerHalf(8);
|
||
const sheared = await lowerHalf(1);
|
||
await shot('28-shader-vertex-scene');
|
||
check(
|
||
'with 8 subdivisions only the lower half moves; with 1 there are four corners, and the upper half leans along',
|
||
colourDistance(bent.lower, RIGHT) < 12 &&
|
||
colourDistance(bent.upper, RIGHT) > 60 &&
|
||
colourDistance(sheared.upper, RIGHT) < 12,
|
||
{ bent, sheared },
|
||
);
|
||
await cornered(
|
||
[node('opacity', 'ShaderTextureAlpha', {}, 260, 260)],
|
||
[edge('opacity', 'value', 'vout', 'x')],
|
||
8,
|
||
);
|
||
const perCorner = await json("__gm.shader.nodeProblem('vout')");
|
||
const perCornerReadout = await info();
|
||
check(
|
||
'what reads the picture cannot feed the Vertex output: the node says so, and the shader does not compile',
|
||
perCorner === 'Not available per corner: Texture alpha' &&
|
||
JSON.stringify(perCornerReadout).includes(perCorner),
|
||
{ perCorner, perCornerReadout },
|
||
);
|
||
|
||
// 28l-v. A Color property is a colour row on the sprite and a vec3 uniform (#298): unset, the
|
||
// sprite shows the declared default; set on the sprite, that colour.
|
||
await graph(
|
||
[
|
||
node('tint', 'ShaderColorProperty', { name: 'tint', default: 0x3cc85a }),
|
||
node('out', 'ShaderOutput', {}, 780, 0),
|
||
],
|
||
[edge('tint', 'color', 'out', 'color')],
|
||
);
|
||
const tinted = async () => ({ a: await pixel(-0.3, -0.3), b: await pixel(0.3, 0.3) });
|
||
const unsetTint = await tinted();
|
||
await assetProps({ tint: 0xe63c3c });
|
||
const setTint = await tinted();
|
||
check(
|
||
'a Color property draws its default, and the colour the sprite sets once it sets one',
|
||
colourDistance(unsetTint.a, '#3cc85a') < 12 &&
|
||
colourDistance(unsetTint.b, '#3cc85a') < 12 &&
|
||
colourDistance(setTint.a, '#e63c3c') < 12 &&
|
||
colourDistance(setTint.b, '#e63c3c') < 12,
|
||
{ unsetTint, setTint },
|
||
);
|
||
await mutate(`delete __gm.state.project.assets.find((a) => a.id === 'shade-sprite').props.tint`);
|
||
|
||
// 28l-vi. Blur softens the picture in one pass (#300), its radius in pixels of the picture. Texel 15
|
||
// of the halves is the last red one: a radius of 4 across takes in 5 red and 4 green.
|
||
// Nearest, so a screen pixel beside a texel's centre still reads that texel alone.
|
||
const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||
const texel = (i, j, size = 32) => pixel((i + 0.5) / size - 0.5, (j + 0.5) / size - 0.5);
|
||
const filterOf = (id, filter) =>
|
||
mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(id)});
|
||
if (a) a.props.filter = ${JSON.stringify(filter)};`);
|
||
await filterOf(picture?.id, 'nearest');
|
||
const blurred = (blurParams, outParams = {}, more = [[], []]) =>
|
||
graph(
|
||
[
|
||
node('blur', 'ShaderBlur', blurParams, 260, 0),
|
||
node('out', 'ShaderOutput', outParams, 780, 0),
|
||
...more[0],
|
||
],
|
||
[edge('blur', 'color', 'out', 'color'), ...more[1]],
|
||
);
|
||
await texelOnly({});
|
||
const unblurred = [await texel(15, 6), await texel(16, 6)];
|
||
await blurred({ direction: 'horizontal' }, {}, [
|
||
[node('soft', 'ShaderProperty', { name: 'soft', default: 0, min: 0, max: 8 }, 0, 0)],
|
||
[edge('soft', 'value', 'blur', 'radius')],
|
||
]);
|
||
const zero = [await texel(15, 6), await texel(16, 6)];
|
||
check('Blur with radius 0 draws the picture unchanged, byte for byte', zero.join() === unblurred.join(), {
|
||
unblurred,
|
||
zero,
|
||
});
|
||
const compilesBefore = await json('__gm.shaderCompiles()');
|
||
const softened = [];
|
||
for (const soft of [0.5, 1, 4]) {
|
||
await assetProps({ soft });
|
||
softened.push(rgb(await texel(15, 6))[0]);
|
||
}
|
||
const compilesAfter = await json('__gm.shaderCompiles()');
|
||
// Red share at texel 15: 0.5 weighs 0.5 + 1 + 0.5 (3/4 red), 1 is 2 of 3, 4 is 5 of 9.
|
||
const redAt = (share) => Math.round(230 * share + 60 * (1 - share));
|
||
check(
|
||
'a radius from a Property softens step by step, fractions included, without a new program',
|
||
rgb(zero[0])[0] > softened[0] &&
|
||
softened[0] > softened[1] &&
|
||
softened[1] > softened[2] &&
|
||
Math.abs(softened[0] - redAt(3 / 4)) < 10 &&
|
||
Math.abs(softened[2] - redAt(5 / 9)) < 10 &&
|
||
compilesAfter === compilesBefore,
|
||
{ zero, softened, expected: [redAt(3 / 4), redAt(2 / 3), redAt(5 / 9)], compilesBefore, compilesAfter },
|
||
);
|
||
await mutate(`delete __gm.state.project.assets.find((a) => a.id === 'shade-sprite').props.soft`);
|
||
const radiusFour = async (params) => {
|
||
await blurred({ radius: 4, ...params });
|
||
return rgb(await texel(15, 6));
|
||
};
|
||
const boxed = await radiusFour({ direction: 'horizontal' });
|
||
const gaussian = await radiusFour({ direction: 'horizontal', kind: 'gaussian' });
|
||
const vertical = await radiusFour({ direction: 'vertical' });
|
||
check(
|
||
'a Box of 4 mixes 5 red with 4 green, Vertical stays red, Gaussian leans to the middle',
|
||
Math.abs(boxed[0] - redAt(5 / 9)) < 10 &&
|
||
colourDistance(`#${vertical.map((c) => c.toString(16).padStart(2, '0')).join('')}`, LEFT) < 12 &&
|
||
gaussian[0] > boxed[0] + 4 &&
|
||
gaussian[1] < boxed[1] - 3,
|
||
{ boxed, gaussian, vertical },
|
||
);
|
||
// At the transparent outside the colour stays red; only the opacity drops, to 5 of 9.
|
||
const ground = rgb(await pixel(-0.62, -0.3));
|
||
await blurred({ radius: 4, direction: 'horizontal' }, { edge: 'transparent' });
|
||
const borderColour = await texel(0, 6);
|
||
await blurred({ radius: 4, direction: 'horizontal' }, { edge: 'transparent' }, [
|
||
[],
|
||
[edge('blur', 'alpha', 'out', 'alpha')],
|
||
]);
|
||
const borderShare = (rgb(await texel(0, 6))[0] - ground[0]) / (rgb(LEFT)[0] - ground[0]);
|
||
check(
|
||
'at a transparent border the colour stays and only the opacity drops: no dark seam',
|
||
colourDistance(borderColour, LEFT) < 12 && Math.abs(borderShare - 5 / 9) < 0.08,
|
||
{ borderColour, borderShare, ground },
|
||
);
|
||
// One white pixel blurred by 1 is a 3x3 spot of 1/9 each: its opacity adds up to the pixel's. The
|
||
// pixel sits off the sprite's middle, where a symbol may draw over it.
|
||
await evaluate(PASTE_DOT);
|
||
await sleep(2500);
|
||
const dot = await json(FIND_DOT);
|
||
await evaluate('__gm.state.select(null)');
|
||
await filterOf(dot, 'nearest');
|
||
await assetProps({ texture: dot ?? '' });
|
||
await blurred({ radius: 1 }, { edge: 'transparent' }, [[], [edge('blur', 'alpha', 'out', 'alpha')]]);
|
||
const spot = [];
|
||
for (let j = 2; j <= 6; j++)
|
||
for (let i = 2; i <= 6; i++) spot.push((rgb(await texel(i, j, 16))[0] - ground[0]) / (255 - ground[0]));
|
||
const lit = spot.filter((a) => a > 0.05).length;
|
||
const opacity = spot.reduce((sum, a) => sum + a, 0);
|
||
check(
|
||
'one white pixel blurred by 1 becomes a 3x3 spot whose opacity adds up to the pixel’s',
|
||
Boolean(dot) && lit === 9 && Math.abs(opacity - 1) < 0.1,
|
||
{ dot, lit, opacity: opacity.toFixed(3), spot: spot.map((a) => a.toFixed(2)).join(' ') },
|
||
);
|
||
|
||
// 28l-vii. A Pass draws first into a picture of its own (#301): blurred across, then that picture
|
||
// blurred down, is the spot a Blur of Both draws in one go.
|
||
const spotOf = async () => {
|
||
const values = [];
|
||
for (let j = 2; j <= 6; j++)
|
||
for (let i = 2; i <= 6; i++)
|
||
values.push((rgb(await texel(i, j, 16))[0] - ground[0]) / (255 - ground[0]));
|
||
return values;
|
||
};
|
||
await blurred({ radius: 2 }, { edge: 'transparent' }, [[], [edge('blur', 'alpha', 'out', 'alpha')]]);
|
||
const oneGo = await spotOf();
|
||
await graph(
|
||
[
|
||
node('across', 'ShaderBlur', { radius: 2, direction: 'horizontal' }, 0, 0),
|
||
node('pass', 'ShaderPass', {}, 260, 0),
|
||
node('down', 'ShaderBlur', { radius: 2, direction: 'vertical' }, 520, 0),
|
||
node('out', 'ShaderOutput', { edge: 'transparent' }, 780, 0),
|
||
],
|
||
[
|
||
edge('across', 'color', 'pass', 'color'),
|
||
edge('across', 'alpha', 'pass', 'alpha'),
|
||
edge('pass', 'picture', 'down', 'picture'),
|
||
edge('down', 'color', 'out', 'color'),
|
||
edge('down', 'alpha', 'out', 'alpha'),
|
||
],
|
||
);
|
||
const twoPasses = await spotOf();
|
||
const apart = Math.max(...oneGo.map((a, k) => Math.abs(a - twoPasses[k])));
|
||
check(
|
||
'a Blur across, a Pass and a Blur down draw the spot a Blur of Both draws in one pass',
|
||
oneGo.filter((a) => a > 0.01).length === 25 && apart < 0.02,
|
||
{
|
||
apart: apart.toFixed(3),
|
||
oneGo: oneGo.map((a) => a.toFixed(2)).join(' '),
|
||
twoPasses: twoPasses.map((a) => a.toFixed(2)).join(' '),
|
||
},
|
||
);
|
||
// Bloom's shape: the passed picture added to the untouched original. The original stays readable
|
||
// beside a pass, and the sprite's colour tints only the last part, not what the Pass drew.
|
||
await assetProps({ texture: picture?.id ?? '', color: 0x808080 });
|
||
await graph(
|
||
[
|
||
node('pass', 'ShaderPass', {}, 0, 0),
|
||
node('passed', 'ShaderTextureColor', {}, 260, 0),
|
||
node('own', 'ShaderTextureColor', {}, 260, 200),
|
||
node('add', 'ShaderAdd', {}, 520, 0),
|
||
node('out', 'ShaderOutput', {}, 780, 0),
|
||
],
|
||
[
|
||
edge('pass', 'picture', 'passed', 'picture'),
|
||
edge('passed', 'color', 'add', 'a'),
|
||
edge('own', 'color', 'add', 'b'),
|
||
edge('add', 'result', 'out', 'color'),
|
||
],
|
||
);
|
||
const bloomed = rgb(await texel(4, 6));
|
||
await mutate(`delete __gm.state.project.assets.find((a) => a.id === 'shade-sprite').props.color`);
|
||
// Grey halves red twice, once in the pass and once in the original: 230 / 2 + 230 / 2.
|
||
check(
|
||
'a Pass’s picture and the original read side by side, each tinted once at the end',
|
||
Math.abs(bloomed[0] - 230) < 8 && Math.abs(bloomed[1] - 60) < 8,
|
||
{ bloomed },
|
||
);
|
||
// The open Pass above is the very program of the first of two passes here. Pixi binds a program's
|
||
// pictures by position, so the second graph read a placeholder: white, until the page reloaded.
|
||
await graph(
|
||
[
|
||
node('first', 'ShaderPass', {}, 0, 0),
|
||
node('again', 'ShaderTextureColor', {}, 260, 0),
|
||
node('second', 'ShaderPass', {}, 520, 0),
|
||
node('passed', 'ShaderTextureColor', {}, 780, 0),
|
||
node('out', 'ShaderOutput', {}, 1040, 0),
|
||
],
|
||
[
|
||
edge('first', 'picture', 'again', 'picture'),
|
||
edge('again', 'color', 'second', 'color'),
|
||
edge('second', 'picture', 'passed', 'picture'),
|
||
edge('passed', 'color', 'out', 'color'),
|
||
],
|
||
);
|
||
const chained = [await texel(4, 6), await texel(27, 6)];
|
||
check(
|
||
'two Passes in a row show the picture, also where the first shares its program with an earlier graph',
|
||
colourDistance(chained[0], LEFT) < 12 && colourDistance(chained[1], RIGHT) < 12,
|
||
{ chained },
|
||
);
|
||
|
||
// 28l-viii. A shader function (#303): Grayscale as an asset of its own, called from the shader,
|
||
// draws what the same nodes draw in place. A change to the function reaches the sprite
|
||
// without the shader being touched, and a double-click on the call opens the function.
|
||
const FN = 'fn28-gray';
|
||
const fnEdge = (id, from, fromPort, to, toPort) => ({
|
||
id,
|
||
from: { node: from, port: fromPort },
|
||
to: { node: to, port: toPort },
|
||
});
|
||
await mutate(`
|
||
__gm.state.project.assets.push({
|
||
id: '${FN}',
|
||
type: 'shaderfunction',
|
||
name: 'Grayscale 28',
|
||
props: {},
|
||
graph: {
|
||
nodes: ${JSON.stringify([
|
||
{ ...node('in', 'ShaderFunctionInput', {}, 0, 0), ports: [{ key: 'color', kind: 'color' }] },
|
||
node('lum', 'ShaderLuminance', {}, 260, 0),
|
||
{ ...node('out', 'ShaderFunctionOutput', {}, 520, 0), ports: [{ key: 'gray', kind: 'color' }] },
|
||
])},
|
||
edges: ${JSON.stringify([fnEdge('e1', 'in', 'color', 'lum', 'color'), fnEdge('e2', 'lum', 'value', 'out', 'gray')])},
|
||
},
|
||
})`);
|
||
const ends = [node('own', 'ShaderTextureColor', {}, 0, 0), node('out', 'ShaderOutput', {}, 520, 0)];
|
||
await graph(
|
||
[...ends, node('lum', 'ShaderLuminance', {}, 260, 0)],
|
||
[edge('own', 'color', 'lum', 'color'), edge('lum', 'value', 'out', 'color')],
|
||
);
|
||
const inPlace = [await texel(4, 6), await texel(27, 6)];
|
||
await graph(
|
||
[...ends, node('call', 'ShaderCall', { function: FN }, 260, 0)],
|
||
[edge('own', 'color', 'call', 'color'), edge('call', 'gray', 'out', 'color')],
|
||
);
|
||
const called = [await texel(4, 6), await texel(27, 6)];
|
||
const isGrey = (hex) => new Set(rgb(hex)).size === 1;
|
||
check(
|
||
'a function called from the shader draws what its nodes draw in place, byte for byte',
|
||
called.join() === inPlace.join() && called.every(isGrey) && called[0] !== called[1],
|
||
{ inPlace, called },
|
||
);
|
||
await mutate(`
|
||
const g = __gm.state.project.assets.find((a) => a.id === '${FN}').graph;
|
||
g.nodes.push(${JSON.stringify(node('half', 'ShaderMultiply', { b: 0.5 }, 390, 140))});
|
||
g.edges = g.edges.filter((e) => e.id !== 'e2');
|
||
g.edges.push(...${JSON.stringify([fnEdge('e3', 'lum', 'value', 'half', 'a'), fnEdge('e4', 'half', 'result', 'out', 'gray')])})`);
|
||
const fnHalved = [await texel(4, 6), await texel(27, 6)];
|
||
check(
|
||
'a change to the function reaches the sprite, its shader untouched',
|
||
fnHalved.every((hex, k) => isGrey(hex) && Math.abs(rgb(hex)[0] - rgb(called[k])[0] / 2) <= 2),
|
||
{ called, fnHalved },
|
||
);
|
||
await select({ kind: 'asset', assetId: made.id });
|
||
await showTab('shader');
|
||
await clickText('Fit');
|
||
const callNode = await json("__gm.shader.nodeAt('call')");
|
||
const callLabel = await json("__gm.shader.nodeLabel('call')");
|
||
if (callNode) await doubleClick(callNode.x, callNode.y);
|
||
await sleep(200);
|
||
const openedFunction = {
|
||
current: await json('__gm.shader.current()'),
|
||
info: await json('__gm.shader.info()'),
|
||
subject: (await json('__gm.shader.preview()')).subject,
|
||
};
|
||
await mutate(`
|
||
__gm.state.project.assets.find((a) => a.id === '${FN}').graph.nodes.push(
|
||
${JSON.stringify(node('self', 'ShaderCall', { function: FN }, 260, 260))})`);
|
||
const selfProblem = await json("__gm.shader.nodeProblem('self')");
|
||
check(
|
||
'the call is named after the function, a double-click opens it, and a call of itself has a problem',
|
||
callLabel === 'Grayscale 28' &&
|
||
openedFunction.current === FN &&
|
||
openedFunction.info === '1 input, 1 output' &&
|
||
openedFunction.subject === 'A function shows in the shaders calling it' &&
|
||
selfProblem === 'A function cannot call itself',
|
||
JSON.stringify({ callNode, callLabel, openedFunction, selfProblem }),
|
||
);
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets = p.assets.filter((a) => a.id !== '${FN}')`);
|
||
await evaluate('__gm.state.select(null)');
|
||
await showTab('scene');
|
||
await assetProps({ texture: picture?.id ?? '' });
|
||
await filterOf(picture?.id, 'linear');
|
||
|
||
// 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,
|
||
);
|
||
}
|