import { colourDistance } from '../lib/harness.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, 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', 'Mix', 'Luminance'].every((text) => offered.includes(text)) && !offered.some((text) => /On start|Number|Branch|Set state/.test(text)), JSON.stringify(offered), ); 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); 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); // 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, y: cam.y, 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; // Off centre: the camera symbol sits on the very same spot, and symbols draw above objects. const centre = () => json(`__gm.pixelAt(${Math.round(box.x + 0.3 * box.w)}, ${Math.round(box.y + 0.3 * box.h)})`); const base = await centre(); check('with no shader the sprite is its plain colour', colourDistance(base, BASE) < 12, base); // 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 }), ); 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. 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; project.assets = project.assets.filter((a) => a.id !== 'shade-sprite' && a.id !== ${JSON.stringify(made.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 shaders()).map((s) => s.id).join(',') === ADJUST, ); }