The groups of the inspector had a small grey label as their title, in the same grid as a property row, so with many rows one group hardly stood apart from the next. Now every group has a band as its head, from edge to edge of the panel, and a click on it folds the group. - A band runs across the padding of its column in the header colour, with the title and a mark that says whether the group is open. The titles stay as they are, as answered in the issue. - A click on a band folds its group: the rows go, the band stays and the next band moves up against it. A control in the band (Open, + Variable, + Property) does its own job and folds nothing. - A placed object has a group SWITCHES under its name, asset and parent: Enabled, Keep on scene load and the switches of a behavior. An asset has SWITCHES and PROPERTIES, and a group without rows is left out. - The same bands head the tilemap panel, the Variables tab, the side of the Pixel art tab and the shape list of vector art. A list with a title (Layers, Frames, Shapes) folds with a click on its header, clear of its plus. - ui/sections.ts holds the rules as pure functions, SectionStack is the column and SectionHeader the band. Every title of the inspector goes through addTitle or sectionHeader in inspector/host.ts. Decided here: - A fold is kept for good, in localStorage of this browser. The issue asked whether for the session or for good and the answer left it open. - A fold is remembered by title, per panel. PROPERTIES folded once is folded on every object, asset and node. A number in a title does not count. - A behavior asset has all its rows under SWITCHES. A Variables asset and a state machine get a group SCOPE. - A greyed out control in a band takes the click too. Without that the click fell through to the band and folded the group. - The Curve tab has no column of this kind and is unchanged. Found on the way: - The inspector of a sprite is taller than the 800 pixel window of the checks now. The hooks that hand out a row scroll it into view first, and module 28 scrolls before it looks for a row by its text. Tests: - Gate: 202 files, 3008 tests (2982 + 26), exit 0 - e2e: no complete run for this state. Modules 28 to 61 passed alone (749 checks). A full run was cut off in the accounts module at 1492 passes without a failure, the modules after it and the smokes are missing. The sum of 1639 (1635 + 4) is reckoned, CI measures it. - 26 unit tests in tests/editor/sections.test.ts - Four e2e checks with a real mouse, three in module 02 and one in module 48 - 25 unit counter-probes all fail tests, run on stein2 - Screenshots of the bands looked at before the last build, none after it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1097 lines
47 KiB
JavaScript
1097 lines
47 KiB
JavaScript
import { colourDistance } from '../lib/harness.mjs';
|
||
import { FIND_HALVES, HALVES_LEFT as LEFT, HALVES_RIGHT as RIGHT, PASTE_HALVES } from './lib/pictures.mjs';
|
||
|
||
/**
|
||
* Shader assets: made from the + menu, built from nodes in the Shader tab, worn by a sprite through
|
||
* its Shader dropdown – whose Property nodes are the rows below it – and visible in the pixels, in
|
||
* the scene view and in the game – and in the tab's own preview of the picture selected last (28n).
|
||
* The fixture ships "Adjust colors"; the step removes what it made.
|
||
*/
|
||
|
||
/** (64, 128, 255): three distinct channels, so brightness, contrast and saturation each read differently. */
|
||
const BASE = '#4080ff';
|
||
const ADJUST = 'adjust-colors';
|
||
|
||
export async function shader(t) {
|
||
const {
|
||
check,
|
||
sleep,
|
||
json,
|
||
evaluate,
|
||
mutate,
|
||
drag,
|
||
click,
|
||
clickText,
|
||
locate,
|
||
press,
|
||
select,
|
||
undo,
|
||
play,
|
||
stop,
|
||
shot,
|
||
showTab,
|
||
} = t;
|
||
const { doubleClick, typeText } = t;
|
||
const field = (label) => json(`__gm.inspectorField(${JSON.stringify(label)})`);
|
||
const info = () => json('__gm.shader.info()');
|
||
const shaders = () =>
|
||
json(`__gm.state.project.assets
|
||
.filter((a) => a.type === 'shader')
|
||
.map((a) => ({ id: a.id, name: a.name, nodes: a.graph.nodes.map((n) => ({ id: n.id, type: n.type, name: n.params.name })) }))`);
|
||
/** The overlay is drawn last, so a menu entry wins over a tab button or a row saying the same. */
|
||
const clickLast = async (text) => {
|
||
const p = await locate(text, true);
|
||
await click(p.x, p.y);
|
||
};
|
||
const assetProps = (patch) =>
|
||
mutate(
|
||
`Object.assign(__gm.state.project.assets.find((a) => a.id === 'shade-sprite').props, ${JSON.stringify(patch)})`,
|
||
);
|
||
|
||
await press('Escape');
|
||
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
|
||
await evaluate('__gm.state.select(null)');
|
||
|
||
// 28a. The fixture ships Adjust colors as an asset; the + menu makes another and opens its tab on it.
|
||
const seeded = (await shaders()).find((s) => s.id === ADJUST);
|
||
check(
|
||
'the fixture carries the Adjust colors shader as an asset of twelve nodes',
|
||
Boolean(seeded) && seeded.nodes.length === 12,
|
||
JSON.stringify(seeded),
|
||
);
|
||
await clickText('+');
|
||
await clickLast('Shader');
|
||
const made = (await shaders()).find((s) => s.id !== ADJUST);
|
||
const selection = await json('__gm.state.selection');
|
||
check(
|
||
'the + menu creates a shader asset, selected, and its tab opens on it',
|
||
Boolean(made) &&
|
||
selection?.kind === 'asset' &&
|
||
selection.assetId === made.id &&
|
||
(await json('__gm.tab()')) === 'shader' &&
|
||
(await json('__gm.shader.current()')) === made.id,
|
||
JSON.stringify({ made, selection, tab: await json('__gm.tab()') }),
|
||
);
|
||
if (!made) return;
|
||
check('the tab button has room for its label', (await json("__gm.shownLabel('Shader')")) === 'Shader');
|
||
// Named after its type, the asset's row in the Assets panel would say "Shader" like the inspector row.
|
||
await mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).name = 'Glow'`);
|
||
made.name = 'Glow';
|
||
check('an empty shader says what it lacks', (await info()) === 'No Output node', await info());
|
||
|
||
// 28b. Right-click offers shader nodes only; Texture color and Output placed from the palette.
|
||
const canvas = await json('__gm.shader.canvasRect()');
|
||
// The preview takes the right of the tab (#168); the second column stays inside what is left.
|
||
const right = canvas.x + Math.min(420, canvas.w - 100);
|
||
await click(canvas.x + 80, canvas.y + 60, 'right');
|
||
const offered = await json('__gm.overlayTexts()');
|
||
check(
|
||
'right-click offers shader nodes and no behavior node',
|
||
['Texture color', 'Output', 'Property', 'Number', 'Mix', 'Luminance'].every((text) =>
|
||
offered.includes(text),
|
||
) && !offered.some((text) => /On start|Boolean|Branch|Set state/.test(text)),
|
||
JSON.stringify(offered),
|
||
);
|
||
// A dozen entries scroll; searched, the row is the only one and in view.
|
||
await typeText('Texture color');
|
||
await clickLast('Texture color');
|
||
await click(right, canvas.y + 60, 'right');
|
||
await clickLast('Output');
|
||
const madeNodes = async () => (await shaders()).find((s) => s.id === made.id)?.nodes ?? [];
|
||
check(
|
||
'both nodes are in the graph, the Output where its right-click was',
|
||
(await madeNodes()).map((n) => n.type).join(',') === 'ShaderTextureColor,ShaderOutput',
|
||
JSON.stringify(await madeNodes()),
|
||
);
|
||
await click(right, canvas.y + 320, 'right');
|
||
const again = await json('__gm.overlayTexts()');
|
||
check(
|
||
'the palette offers the Output only once',
|
||
!again.includes('Output') && again.includes('Property'),
|
||
JSON.stringify(again),
|
||
);
|
||
await press('Escape');
|
||
check(
|
||
'an Output with nothing connected compiles: the texel passes through, no property declared',
|
||
(await info()) === '0 properties',
|
||
await info(),
|
||
);
|
||
|
||
// 28c. A Property node: its name is the row a sprite shows; a bad name is a problem on the node.
|
||
await click(canvas.x + 80, canvas.y + 320, 'right');
|
||
// Property is the twelfth entry, below the palette's scroll mask: search it into view first.
|
||
await typeText('Property');
|
||
await clickLast('Property');
|
||
const propertyNode = (await madeNodes()).find((n) => n.type === 'ShaderProperty');
|
||
check(
|
||
'the placed property is named "value" and counted',
|
||
propertyNode?.name === 'value' && (await info()) === '1 property',
|
||
JSON.stringify({ propertyNode, info: await info() }),
|
||
);
|
||
const nameField = await json("__gm.inspectorField('Name', true)");
|
||
check('the inspector shows a Name field for the property', Boolean(nameField), JSON.stringify(nameField));
|
||
await doubleClick(nameField.x, nameField.y);
|
||
await typeText('glow');
|
||
await press('Enter');
|
||
check(
|
||
'typing in the Name field renames the property, on the node too',
|
||
(await madeNodes()).find((n) => n.id === propertyNode?.id)?.name === 'glow' &&
|
||
(await json(`__gm.shader.nodeLabel(${JSON.stringify(propertyNode?.id)})`)) === 'glow',
|
||
JSON.stringify(await madeNodes()),
|
||
);
|
||
await shot('28-shader-tab');
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
a.graph.nodes.find((n) => n.id === ${JSON.stringify(propertyNode?.id)}).params.name = 'no good';`);
|
||
const problem = await json(`__gm.shader.nodeProblem(${JSON.stringify(propertyNode?.id)})`);
|
||
check(
|
||
'a name that is not one word is a problem on the node and the reason in the readout',
|
||
typeof problem === 'string' && problem.startsWith('Name must be') && (await info()) === problem,
|
||
JSON.stringify({ problem, info: await info() }),
|
||
);
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
a.graph.nodes.find((n) => n.id === ${JSON.stringify(propertyNode?.id)}).params.name = 'glow';`);
|
||
|
||
// 28c-ii. Where a node sits is the editor's business: dropping it somewhere else compiles nothing
|
||
// again, a changed param does – once (#65).
|
||
const nodeRef = JSON.stringify(propertyNode?.id);
|
||
const compiledBefore = await json('__gm.shaderCompiles()');
|
||
const grab = await json(`__gm.shader.nodeAt(${nodeRef})`);
|
||
await drag(grab.x, grab.y, grab.x + 60, grab.y + 40);
|
||
const dropped = await json(`__gm.shader.nodeAt(${nodeRef})`);
|
||
const compiledAfterMove = await json('__gm.shaderCompiles()');
|
||
check(
|
||
'dropping a node somewhere else moves it without compiling the graph again',
|
||
dropped.x > grab.x + 30 && compiledAfterMove === compiledBefore,
|
||
JSON.stringify({ grab, dropped, compiledBefore, compiledAfterMove }),
|
||
);
|
||
const defaultBefore = await json(
|
||
`__gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}).graph.nodes.find((n) => n.id === ${nodeRef}).params.default`,
|
||
);
|
||
const setDefault = (value) =>
|
||
mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
a.graph.nodes.find((n) => n.id === ${nodeRef}).params.default = ${JSON.stringify(value)};`);
|
||
await setDefault(2);
|
||
const compiledAfterParam = await json('__gm.shaderCompiles()');
|
||
check(
|
||
'changing a param compiles it again, once',
|
||
compiledAfterParam === compiledAfterMove + 1,
|
||
JSON.stringify({ compiledAfterMove, compiledAfterParam }),
|
||
);
|
||
await setDefault(defaultBefore);
|
||
|
||
// 28d. A flat sprite on the camera, drawn on top of everything; its Shader dropdown lists the assets.
|
||
await mutate(`
|
||
const project = __gm.state.project;
|
||
const sprite = structuredClone(project.assets.find((a) => a.id === 'crate'));
|
||
sprite.id = 'shade-sprite';
|
||
sprite.name = 'Shaded';
|
||
sprite.props = { ...sprite.props, width: 160, height: 120, color: 0x4080ff, texture: '', shader: '' };
|
||
project.assets.push(sprite);
|
||
const scene = __gm.state.activeScene;
|
||
const cam = scene.objects.find((o) => o.id === 'o3').position;
|
||
scene.objects.push({ id: 'oshade', assetId: 'shade-sprite', name: '', position: { x: cam.x - 190, y: cam.y - 105, z: 950 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} });
|
||
__gm.state.select(null)`);
|
||
await sleep(600);
|
||
// The pixels are the scene view's; the tab is still the shader's.
|
||
await showTab('scene');
|
||
await select({ kind: 'object', holderId: 'scene1', objectIds: ['oshade'], objectId: 'oshade' });
|
||
const box = await json("__gm.objectRect('oshade')");
|
||
check('the shaded sprite is on screen', Boolean(box) && box.w > 8, JSON.stringify(box));
|
||
if (!box) return;
|
||
const sample = (fx, fy) =>
|
||
json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`);
|
||
// Off centre: a symbol may sit on the sprite's middle, and symbols draw above objects.
|
||
const centre = () => sample(0.3, 0.3);
|
||
const base = await centre();
|
||
check('with no shader the sprite is its plain colour', colourDistance(base, BASE) < 12, base);
|
||
|
||
// Every later check reads pixels at its own spot on this sprite, so nothing may be drawn over it.
|
||
// The step parks the sprite away from the camera for that reason: symbols draw above objects, and
|
||
// by this point the earlier steps have left containers and colliders around the origin. Without
|
||
// this guard their outlines show up as a wrong colour in whichever check happens to sample them.
|
||
// Since names stand on plates, a plate covers what a thin name let through: the sprite sat right
|
||
// of the camera once, where the Crate pair container's plate reaches into it, so it sits up left.
|
||
const dirty = [];
|
||
for (let fx = -0.4; fx <= 0.401; fx += 0.1)
|
||
for (let fy = -0.4; fy <= 0.401; fy += 0.1) {
|
||
const seen = await sample(fx, fy);
|
||
if (colourDistance(seen, BASE) >= 12) dirty.push(`${fx.toFixed(1)},${fy.toFixed(1)}:${seen}`);
|
||
}
|
||
check('nothing is drawn over the sprite the pixel checks read', dirty.length === 0, dirty.join(' '));
|
||
// The Shader row's dropdown reads "None"; it is the last such text in the inspector (Parent and
|
||
// Texture say it above). The label itself is no anchor: the tab button says "Shader" too.
|
||
// With its groups under bands (#290) the inspector is taller than the window: scrolled to its end.
|
||
const panelRect = await json('__gm.inspectorRect()');
|
||
await t.wheel(panelRect.x + 40, panelRect.y + panelRect.h / 2, 2000);
|
||
await t.settle();
|
||
const shaderField = await locate('None', true);
|
||
check(
|
||
'a sprite has a Shader row reading None, and no shader brings no parameter rows',
|
||
shaderField.x > box.x + box.w && (await field('brightness')) === null,
|
||
JSON.stringify(shaderField),
|
||
);
|
||
await click(shaderField.x, shaderField.y);
|
||
const choices = await json('__gm.overlayTexts()');
|
||
check(
|
||
'the dropdown offers None and every shader asset',
|
||
choices.some((text) => text.includes('None')) &&
|
||
choices.some((text) => text.includes('Adjust colors')) &&
|
||
choices.some((text) => text.includes(made.name)),
|
||
JSON.stringify({ choices, shaderField }),
|
||
);
|
||
await clickLast(choices.find((text) => text.includes('Adjust colors')));
|
||
const rows = await Promise.all([field('brightness'), field('contrast'), field('saturation')]);
|
||
check(
|
||
'picking Adjust colors adds its three property rows below the dropdown',
|
||
rows.every((r) => r !== null) &&
|
||
(await json("__gm.state.activeScene.objects.find((o) => o.id === 'oshade').overrides.shader")) ===
|
||
ADJUST,
|
||
JSON.stringify(rows),
|
||
);
|
||
await shot('28-shader-rows');
|
||
await undo();
|
||
check('undoing the choice takes the rows away again', (await field('brightness')) === null);
|
||
await select(null);
|
||
|
||
// 28e. Each property is visible in the pixels, with 1 neutral.
|
||
await assetProps({ shader: ADJUST });
|
||
const neutral = await centre();
|
||
check(
|
||
'Adjust colors at its defaults draws exactly what no shader draws',
|
||
colourDistance(neutral, base) < 6,
|
||
JSON.stringify({ base, neutral }),
|
||
);
|
||
// What the cost was made of (#67) and is not any more (#77): the shader draws in the sprite's own
|
||
// pass, there is no filter and so no extra render pass.
|
||
const passes = '({ shaders: __gm.shaderCount(), filters: __gm.filterCount() })';
|
||
const passesOn = await json(passes);
|
||
await assetProps({ shader: '' });
|
||
const passesOff = await json(passes);
|
||
await assetProps({ shader: ADJUST });
|
||
check(
|
||
'a shaded sprite draws with its own shader and no filter pass – and plain without',
|
||
passesOn.shaders === 1 && passesOn.filters === 0 && passesOff.shaders === 0 && passesOff.filters === 0,
|
||
{
|
||
on: passesOn,
|
||
off: passesOff,
|
||
},
|
||
);
|
||
await assetProps({ brightness: 1.25 });
|
||
const lighter = await centre();
|
||
check('brightness 1.25 adds a quarter to every channel', colourDistance(lighter, '#80c0ff') < 24, lighter);
|
||
await assetProps({ brightness: 1, contrast: 0 });
|
||
const flat = await centre();
|
||
check('contrast 0 flattens the colour to mid grey', colourDistance(flat, '#808080') < 24, flat);
|
||
await assetProps({ contrast: 1, saturation: 0 });
|
||
const grey = await centre();
|
||
check('saturation 0 leaves the luma as a grey', colourDistance(grey, '#7c7c7c') < 24, grey);
|
||
await shot('28-shader-desaturated');
|
||
|
||
// 28f. The game draws through the same path, so play shows the shader too.
|
||
await assetProps({ saturation: 1, brightness: 1.25 });
|
||
await play();
|
||
await sleep(500);
|
||
const at = await json("__gm.gameScreen('oshade')");
|
||
const played = at ? await json(`__gm.pixelAt(${Math.round(at.x + 20)}, ${Math.round(at.y + 20)})`) : null;
|
||
check(
|
||
'during play the sprite is drawn with its shader',
|
||
played !== null && colourDistance(played, '#80c0ff') < 24,
|
||
JSON.stringify({ at, played }),
|
||
);
|
||
await stop();
|
||
|
||
// 28g. Editing a shader's graph reaches every sprite wearing it: a Color wired into the new one's Output.
|
||
await assetProps({ brightness: 1, shader: made.id });
|
||
const through = await centre();
|
||
check(
|
||
'the new shader passes the texel through',
|
||
colourDistance(through, base) < 6,
|
||
JSON.stringify({ base, through }),
|
||
);
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
const out = a.graph.nodes.find((n) => n.type === 'ShaderOutput');
|
||
a.graph.nodes.push({ id: 'sh-red', type: 'ShaderColor', x: 0, y: 200, params: { value: 0xff0000 } });
|
||
a.graph.edges.push({ id: 'sh-red-edge', from: { node: 'sh-red', port: 'color' }, to: { node: out.id, port: 'color' } });`);
|
||
const red = await centre();
|
||
check(
|
||
'wiring a red Color into the Output recompiles: the sprite turns red',
|
||
colourDistance(red, '#ff0000') < 24,
|
||
red,
|
||
);
|
||
|
||
// 28h. Alpha: Texture alpha times a Property into the Output's alpha blends the sprite with what
|
||
// is behind it; at 1 the pixel is the one from before, byte for byte.
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
const out = a.graph.nodes.find((n) => n.type === 'ShaderOutput');
|
||
a.graph.nodes.push(
|
||
{ id: 'sh-alpha', type: 'ShaderTextureAlpha', x: 0, y: 300, params: {} },
|
||
{ id: 'sh-opacity', type: 'ShaderProperty', x: 0, y: 400, params: { name: 'opacity', default: 1, min: 0, max: 1 } },
|
||
{ id: 'sh-fade', type: 'ShaderMultiply', x: 200, y: 350, params: {} });
|
||
a.graph.edges.push(
|
||
{ id: 'sh-fade-a', from: { node: 'sh-alpha', port: 'value' }, to: { node: 'sh-fade', port: 'a' } },
|
||
{ id: 'sh-fade-b', from: { node: 'sh-opacity', port: 'value' }, to: { node: 'sh-fade', port: 'b' } },
|
||
{ id: 'sh-fade-out', from: { node: 'sh-fade', port: 'result' }, to: { node: out.id, port: 'alpha' } });`);
|
||
const opaque = await centre();
|
||
check(
|
||
'an opacity property at its default 1 changes nothing',
|
||
opaque === red,
|
||
JSON.stringify({ red, opaque, info: await info() }),
|
||
);
|
||
await assetProps({ opacity: 0 });
|
||
const behind = await centre();
|
||
check('opacity 0 shows what is behind the sprite', colourDistance(behind, red) > 60, { red, behind });
|
||
await assetProps({ opacity: 0.5 });
|
||
const half = await centre();
|
||
const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16);
|
||
const expected =
|
||
'#' +
|
||
[1, 3, 5]
|
||
.map((i) =>
|
||
Math.round((channel(red, i) + channel(behind, i)) / 2)
|
||
.toString(16)
|
||
.padStart(2, '0'),
|
||
)
|
||
.join('');
|
||
check('opacity 0.5 blends the sprite half with what is behind', colourDistance(half, expected) < 24, {
|
||
red,
|
||
behind,
|
||
half,
|
||
expected,
|
||
});
|
||
await assetProps({ opacity: 1 });
|
||
check('opacity back at 1 draws the pixel from before, byte for byte', (await centre()) === red);
|
||
|
||
// 28i. A Number node is a constant in the fragment: red times 0.5 is half red; typed back to 1,
|
||
// the pixel is the red from before. Its value is a setting row on the node.
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
const out = a.graph.nodes.find((n) => n.type === 'ShaderOutput');
|
||
a.graph.nodes.push(
|
||
{ id: 'sh-half', type: 'ShaderNumber', x: 200, y: 200, params: { value: 0.5 } },
|
||
{ id: 'sh-dim', type: 'ShaderMultiply', x: 400, y: 200, params: {} });
|
||
a.graph.edges = a.graph.edges.filter((e) => e.id !== 'sh-red-edge');
|
||
a.graph.edges.push(
|
||
{ id: 'sh-dim-a', from: { node: 'sh-red', port: 'color' }, to: { node: 'sh-dim', port: 'a' } },
|
||
{ id: 'sh-dim-b', from: { node: 'sh-half', port: 'value' }, to: { node: 'sh-dim', port: 'b' } },
|
||
{ id: 'sh-dim-out', from: { node: 'sh-dim', port: 'result' }, to: { node: out.id, port: 'color' } });`);
|
||
const dimmed = await centre();
|
||
check('a Number of 0.5 into a Multiply halves the red', colourDistance(dimmed, '#800000') < 6, {
|
||
red,
|
||
dimmed,
|
||
});
|
||
await showTab('shader');
|
||
check(
|
||
'the Number node draws its value as a setting row',
|
||
(await json("__gm.shader.nodeSettings('sh-half')"))?.join() === 'Value: 0.5',
|
||
JSON.stringify(await json("__gm.shader.nodeSettings('sh-half')")),
|
||
);
|
||
await shot('28-number-node');
|
||
await showTab('scene');
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
a.graph.nodes.find((n) => n.id === 'sh-half').params.value = 1`);
|
||
check('the Number typed to 1 gives the red from before, byte for byte', (await centre()) === red, {
|
||
red,
|
||
now: await centre(),
|
||
});
|
||
|
||
// 28k. UV into Step: the pixel's place on the picture decides its colour – a hard cut at u = 0.5,
|
||
// then at v = 0.5, which shows v runs top to bottom. `box` is the sprite's centre and size.
|
||
const node = (id, type, params = {}, x = 0, y = 0) => ({ id, type, x, y, params });
|
||
const edge = (from, fromPort, to, toPort) => ({
|
||
id: `${from}-${to}-${toPort}`,
|
||
from: { node: from, port: fromPort },
|
||
to: { node: to, port: toPort },
|
||
});
|
||
const graph = (nodes, edges) =>
|
||
mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||
a.graph.nodes = ${JSON.stringify(nodes)};
|
||
a.graph.edges = ${JSON.stringify(edges)};`);
|
||
const pixel = (fx, fy) =>
|
||
json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`);
|
||
const redOrBlue = (t, edges) =>
|
||
graph(
|
||
[
|
||
...t,
|
||
node('red', 'ShaderColor', { value: 0xff0000 }, 260, 200),
|
||
node('blue', 'ShaderColor', { value: 0x0000ff }, 260, 340),
|
||
node('mix', 'ShaderMix', {}, 520, 200),
|
||
node('out', 'ShaderOutput', {}, 780, 200),
|
||
],
|
||
[
|
||
...edges,
|
||
edge('red', 'color', 'mix', 'a'),
|
||
edge('blue', 'color', 'mix', 'b'),
|
||
edge('mix', 'result', 'out', 'color'),
|
||
],
|
||
);
|
||
const cutAt = (port) =>
|
||
redOrBlue(
|
||
[node('uv', 'ShaderUV'), node('cut', 'ShaderStep', { edge: 0.5 }, 260, 0)],
|
||
[edge('uv', port, 'cut', 'value'), edge('cut', 'result', 'mix', 't')],
|
||
);
|
||
await cutAt('u');
|
||
const uLeft = await pixel(-0.3, -0.3);
|
||
const uRight = await pixel(0.3, -0.3);
|
||
check(
|
||
'UV into Step: left of u = 0.5 the sprite is red, right of it blue',
|
||
colourDistance(uLeft, '#ff0000') < 6 && colourDistance(uRight, '#0000ff') < 6,
|
||
{ uLeft, uRight },
|
||
);
|
||
await cutAt('v');
|
||
const vTop = await pixel(-0.3, -0.3);
|
||
const vBottom = await pixel(-0.3, 0.3);
|
||
check(
|
||
'v runs top to bottom: above v = 0.5 red, below it blue',
|
||
colourDistance(vTop, '#ff0000') < 6 && colourDistance(vBottom, '#0000ff') < 6,
|
||
{ vTop, vBottom },
|
||
);
|
||
|
||
// 28k-ii. Pow bends the cut: u squared crosses 0.5 at u = 0.707, its square root at u = 0.25.
|
||
const cutAfterPow = (exponent) =>
|
||
redOrBlue(
|
||
[
|
||
node('uv', 'ShaderUV'),
|
||
node('pow', 'ShaderPow', { exponent }, 260, 0),
|
||
node('cut', 'ShaderStep', { edge: 0.5 }, 260, 120),
|
||
],
|
||
[
|
||
edge('uv', 'u', 'pow', 'value'),
|
||
edge('pow', 'result', 'cut', 'value'),
|
||
edge('cut', 'result', 'mix', 't'),
|
||
],
|
||
);
|
||
await cutAfterPow(2);
|
||
const squaredNear = await pixel(0.1, -0.3);
|
||
const squaredFar = await pixel(0.3, -0.3);
|
||
check(
|
||
'Pow 2 moves the cut to u = 0.707: still red at u = 0.6, blue at 0.8',
|
||
colourDistance(squaredNear, '#ff0000') < 6 && colourDistance(squaredFar, '#0000ff') < 6,
|
||
{ squaredNear, squaredFar },
|
||
);
|
||
await cutAfterPow(0.5);
|
||
const rootNear = await pixel(-0.3, -0.3);
|
||
const rootFar = await pixel(-0.15, -0.3);
|
||
check(
|
||
'Pow 0.5 pulls it to u = 0.25: red at u = 0.2, blue at 0.35',
|
||
colourDistance(rootNear, '#ff0000') < 6 && colourDistance(rootFar, '#0000ff') < 6,
|
||
{ rootNear, rootFar },
|
||
);
|
||
|
||
// 28k-iii. Abs folds u around 0.5 into a V: |u - 0.5| is below 0.25 only in the middle half.
|
||
await redOrBlue(
|
||
[
|
||
node('uv', 'ShaderUV'),
|
||
node('centred', 'ShaderSubtract', { b: 0.5 }, 260, 0),
|
||
node('fold', 'ShaderAbs', {}, 260, 120),
|
||
node('cut', 'ShaderStep', { edge: 0.25 }, 260, 240),
|
||
],
|
||
[
|
||
edge('uv', 'u', 'centred', 'a'),
|
||
edge('centred', 'result', 'fold', 'value'),
|
||
edge('fold', 'result', 'cut', 'value'),
|
||
edge('cut', 'result', 'mix', 't'),
|
||
],
|
||
);
|
||
const foldLeft = await pixel(-0.4, -0.3);
|
||
const foldMiddle = await pixel(0, -0.3);
|
||
const foldRight = await pixel(0.4, -0.3);
|
||
check(
|
||
'Abs folds the cut into a V: red in the middle, blue at both edges',
|
||
colourDistance(foldMiddle, '#ff0000') < 6 &&
|
||
colourDistance(foldLeft, '#0000ff') < 6 &&
|
||
colourDistance(foldRight, '#0000ff') < 6,
|
||
{ foldLeft, foldMiddle, foldRight },
|
||
);
|
||
|
||
// 28k-iv. A property named like a row the sprite has anyway is a problem on the node (#63): both
|
||
// rows would sit on the same bag key and the uniform would read the sprite's width, 160 here,
|
||
// instead of the default the node declares. Renaming it compiles the same graph.
|
||
const namedProperty = (name) =>
|
||
graph(
|
||
[
|
||
node('named', 'ShaderProperty', { name, default: 0.25, min: 0, max: 1 }),
|
||
node('red', 'ShaderColor', { value: 0xff0000 }, 260, 200),
|
||
node('dim', 'ShaderMultiply', {}, 520, 0),
|
||
node('out', 'ShaderOutput', {}, 780, 0),
|
||
],
|
||
[
|
||
edge('named', 'value', 'dim', 'b'),
|
||
edge('red', 'color', 'dim', 'a'),
|
||
edge('dim', 'result', 'out', 'color'),
|
||
],
|
||
);
|
||
await namedProperty('width');
|
||
const spriteProp = await info();
|
||
check(
|
||
'a property named like a sprite row is a problem on the node, not a second width row (#63)',
|
||
(await json("__gm.shader.nodeProblem('named')")) === '"width" is a sprite property' &&
|
||
spriteProp === '"width" is a sprite property',
|
||
JSON.stringify({ spriteProp, problem: await json("__gm.shader.nodeProblem('named')") }),
|
||
);
|
||
await namedProperty('amount');
|
||
check(
|
||
'the same graph under a free name compiles: a quarter of the red',
|
||
(await info()) === '1 property' && colourDistance(await pixel(-0.3, -0.3), '#400000') < 6,
|
||
JSON.stringify({ info: await info(), pixel: await pixel(-0.3, -0.3) }),
|
||
);
|
||
|
||
// 28k-v. Dividing by zero has an answer (#64). `a / 0.0` is NaN, and the epilog's clamp(NaN) is
|
||
// undefined in GLSL – the pixel would be the driver's choice. The guard makes it 0, so the sprite
|
||
// goes black instead of "whatever this machine does".
|
||
const dividedBy = (value) =>
|
||
graph(
|
||
[
|
||
node('red', 'ShaderColor', { value: 0xff0000 }),
|
||
node('by', 'ShaderNumber', { value }, 260, 200),
|
||
node('div', 'ShaderDivide', {}, 520, 0),
|
||
node('out', 'ShaderOutput', {}, 780, 0),
|
||
],
|
||
[
|
||
edge('red', 'color', 'div', 'a'),
|
||
edge('by', 'value', 'div', 'b'),
|
||
edge('div', 'result', 'out', 'color'),
|
||
],
|
||
);
|
||
await dividedBy(2);
|
||
const halved = await pixel(-0.3, -0.3);
|
||
await dividedBy(0);
|
||
const byZero = await pixel(-0.3, -0.3);
|
||
check(
|
||
'Divide by 2 halves the red, and dividing by 0 is 0 – a black pixel, not the driver’s choice (#64)',
|
||
colourDistance(halved, '#800000') < 6 && colourDistance(byZero, '#000000') < 6,
|
||
JSON.stringify({ halved, byZero }),
|
||
);
|
||
|
||
// 28k-vi. Clamp holds a value inside a range, per channel for a colour.
|
||
await graph(
|
||
[
|
||
node('red', 'ShaderColor', { value: 0xff0000 }),
|
||
node('hold', 'ShaderClamp', { min: 0, max: 0.25 }, 260, 0),
|
||
node('out', 'ShaderOutput', {}, 520, 0),
|
||
],
|
||
[edge('red', 'color', 'hold', 'value'), edge('hold', 'result', 'out', 'color')],
|
||
);
|
||
const held = await pixel(-0.3, -0.3);
|
||
check('Clamp at 0..0.25 holds full red down to a quarter', colourDistance(held, '#400000') < 6, held);
|
||
|
||
// 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-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,
|
||
);
|
||
}
|