game-maker / suite (push) Successful in 9m16s
The default of the Edge param and the address mode the shared sheet physically
carries were the same constant; changing the value would have turned every
sentinel with it and given each shader sprite a full copy of its texture.
SHEET_EDGE ('mirror', what TextureLibrary uploads) is now separate from
DEFAULT_EDGE ('clamp'). A graph whose Sample reads nothing but its own pixel
draws with the plain sheet whatever the Edge says, so the common shader costs
no second copy.
Without a shader the quad's coordinates never leave 0..1, and mirror-repeat
reads like clamp within a texel - so clamp is what a sprite shows anyway.
739 lines
31 KiB
JavaScript
739 lines
31 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. 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,
|
||
} = 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);
|
||
await sleep(200);
|
||
};
|
||
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)');
|
||
await sleep(200);
|
||
|
||
// 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 sleep(200);
|
||
await clickLast('Shader');
|
||
await sleep(400);
|
||
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()');
|
||
await click(canvas.x + 80, canvas.y + 60, 'right');
|
||
await sleep(300);
|
||
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 sleep(150);
|
||
await clickLast('Texture color');
|
||
await sleep(300);
|
||
await click(canvas.x + 420, canvas.y + 60, 'right');
|
||
await sleep(300);
|
||
await clickLast('Output');
|
||
await sleep(300);
|
||
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(canvas.x + 420, canvas.y + 320, 'right');
|
||
await sleep(300);
|
||
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');
|
||
await sleep(200);
|
||
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');
|
||
await sleep(300);
|
||
// Property is the twelfth entry, below the palette's scroll mask: search it into view first.
|
||
await typeText('Property');
|
||
await sleep(150);
|
||
await clickLast('Property');
|
||
await sleep(300);
|
||
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');
|
||
await sleep(300);
|
||
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';`);
|
||
await sleep(300);
|
||
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';`);
|
||
await sleep(300);
|
||
|
||
// 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);
|
||
await sleep(300);
|
||
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);
|
||
await sleep(300);
|
||
const compiledAfterParam = await json('__gm.shaderCompiles()');
|
||
check(
|
||
'changing a param compiles it again, once',
|
||
compiledAfterParam === compiledAfterMove + 1,
|
||
JSON.stringify({ compiledAfterMove, compiledAfterParam }),
|
||
);
|
||
await setDefault(defaultBefore);
|
||
await sleep(300);
|
||
|
||
// 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 + 180, y: cam.y + 100, 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 evaluate("__gm.showTab('scene')");
|
||
await select({ kind: 'object', holderId: 'scene1', objectIds: ['oshade'], objectId: 'oshade' });
|
||
await sleep(400);
|
||
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.
|
||
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.
|
||
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);
|
||
await sleep(300);
|
||
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')));
|
||
await sleep(400);
|
||
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();
|
||
await sleep(300);
|
||
check('undoing the choice takes the rows away again', (await field('brightness')) === null);
|
||
await select(null);
|
||
await sleep(200);
|
||
|
||
// 28e. Each property is visible in the pixels, with 1 neutral.
|
||
await assetProps({ shader: ADJUST });
|
||
await sleep(300);
|
||
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: '' });
|
||
await sleep(300);
|
||
const passesOff = await json(passes);
|
||
await assetProps({ shader: ADJUST });
|
||
await sleep(300);
|
||
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 });
|
||
await sleep(300);
|
||
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 });
|
||
await sleep(300);
|
||
const flat = await centre();
|
||
check('contrast 0 flattens the colour to mid grey', colourDistance(flat, '#808080') < 24, flat);
|
||
await assetProps({ contrast: 1, saturation: 0 });
|
||
await sleep(300);
|
||
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();
|
||
await sleep(300);
|
||
|
||
// 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 });
|
||
await sleep(300);
|
||
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' } });`);
|
||
await sleep(400);
|
||
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' } });`);
|
||
await sleep(400);
|
||
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 });
|
||
await sleep(300);
|
||
const behind = await centre();
|
||
check('opacity 0 shows what is behind the sprite', colourDistance(behind, red) > 60, { red, behind });
|
||
await assetProps({ opacity: 0.5 });
|
||
await sleep(300);
|
||
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 });
|
||
await sleep(300);
|
||
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' } });`);
|
||
await sleep(400);
|
||
const dimmed = await centre();
|
||
check('a Number of 0.5 into a Multiply halves the red', colourDistance(dimmed, '#800000') < 6, {
|
||
red,
|
||
dimmed,
|
||
});
|
||
await evaluate("__gm.showTab('shader')");
|
||
await sleep(300);
|
||
check(
|
||
'the Number node draws its value as a setting row',
|
||
(await json("__gm.shader.nodeSettings('sh-half')"))?.join() === 'Value: 0.500',
|
||
JSON.stringify(await json("__gm.shader.nodeSettings('sh-half')")),
|
||
);
|
||
await shot('28-number-node');
|
||
await evaluate("__gm.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`);
|
||
await sleep(400);
|
||
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');
|
||
await sleep(400);
|
||
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');
|
||
await sleep(400);
|
||
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);
|
||
await sleep(400);
|
||
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);
|
||
await sleep(400);
|
||
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'),
|
||
],
|
||
);
|
||
await sleep(400);
|
||
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 },
|
||
);
|
||
|
||
// 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')],
|
||
);
|
||
await sleep(400);
|
||
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 },
|
||
);
|
||
await sleep(400);
|
||
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,
|
||
);
|
||
await sleep(400);
|
||
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 evaluate("__gm.showTab('shader')");
|
||
await sleep(300);
|
||
await shot('28-shader-scroll-graph');
|
||
await evaluate("__gm.showTab('scene')");
|
||
await sleep(300);
|
||
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')],
|
||
);
|
||
await sleep(400);
|
||
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 },
|
||
);
|
||
|
||
// 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 },
|
||
);
|
||
await play();
|
||
await sleep(500);
|
||
const playing = await json('__gm.animates()');
|
||
await stop();
|
||
await sleep(300);
|
||
check('and so does the game view while playing', playing.game === true, JSON.stringify(playing));
|
||
await assetProps({ shader: '' });
|
||
await sleep(400);
|
||
check(
|
||
'with the shader off the scene view stops redrawing',
|
||
(await json('__gm.animates()')).scene === false,
|
||
JSON.stringify(await json('__gm.animates()')),
|
||
);
|
||
await assetProps({ texture: '', color: 0x4080ff, shader: made.id });
|
||
await sleep(400);
|
||
|
||
// 28j. Back to None: no filter at all, the plain colour returns bit for bit.
|
||
await assetProps({ shader: '' });
|
||
await sleep(300);
|
||
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)');
|
||
await sleep(200);
|
||
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,
|
||
);
|
||
}
|