Shader assets: graph-built shaders, the Shader tab and sprite rows from the graph (#62)
game-maker / suite (push) Failing after 3m59s
game-maker / suite (push) Failing after 3m59s
A shader is an asset carrying a node graph (Texture color, Output, Property, Color, Add/Subtract/Multiply/Divide, Mix, Luminance) that compiles to the GLSL fragment of a Pixi filter. The Shader tab reuses the Behavior canvas; a sprite wears a shader through its Shader picker, and the rows below it are the graph's Property nodes. Property schema, resolution and deviation take the project because a sprite's rows now live in another asset. The built-in registry of the prototype is gone; "Adjust colors" ships as a graph in the fixture and the three example games. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
6141d07d4f
commit
42cafa7543
62 files changed
+1682
-144
No files matched your search
@@ -0,0 +1,302 @@
|
||||
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 eleven nodes',
|
||||
Boolean(seeded) && seeded.nodes.length === 11,
|
||||
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: 0.5 });
|
||||
await sleep(300);
|
||||
const darker = await centre();
|
||||
check('brightness 0.5 halves every channel', colourDistance(darker, '#204080') < 24, darker);
|
||||
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: 0.5 });
|
||||
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, '#204080') < 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. 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,
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { propertyNodes } from './24-property-nodes.mjs';
|
||||
import { animation } from './25-animation.mjs';
|
||||
import { stateMachine } from './26-state-machine.mjs';
|
||||
import { consolePanel } from './27-console.mjs';
|
||||
import { shader } from './28-shader.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -59,4 +60,5 @@ export const STEPS = [
|
||||
animation,
|
||||
stateMachine,
|
||||
consolePanel,
|
||||
shader,
|
||||
];
|
||||
@@ -0,0 +1,45 @@
|
||||
import { edge, node } from './builders.mjs';
|
||||
|
||||
/**
|
||||
* The "Adjust colors" shader as an asset document: brightness, contrast and saturation, 1 neutral,
|
||||
* on the straight colour – rgb *= brightness; rgb = (rgb - 0.5) * contrast + 0.5;
|
||||
* rgb = mix(luma, rgb, saturation). The recipe the built-in prototype of #62 had, now eleven nodes.
|
||||
*/
|
||||
export function adjustColorsShader(id = 'adjust-colors', name = 'Adjust colors') {
|
||||
const property = (nodeId, propertyName, x, y) =>
|
||||
node(nodeId, 'ShaderProperty', x, y, { name: propertyName, default: 1, min: 0, max: 4 });
|
||||
return {
|
||||
id,
|
||||
type: 'shader',
|
||||
name,
|
||||
props: {},
|
||||
graph: {
|
||||
nodes: [
|
||||
node(`${id}-texel`, 'ShaderTextureColor', 0, 0),
|
||||
property(`${id}-brightness`, 'brightness', 0, 160),
|
||||
property(`${id}-contrast`, 'contrast', 0, 320),
|
||||
property(`${id}-saturation`, 'saturation', 0, 480),
|
||||
node(`${id}-bright`, 'ShaderMultiply', 300, 0),
|
||||
node(`${id}-shift`, 'ShaderSubtract', 560, 0, { b: 0.5 }),
|
||||
node(`${id}-scale`, 'ShaderMultiply', 820, 0),
|
||||
node(`${id}-centre`, 'ShaderAdd', 1080, 0, { b: 0.5 }),
|
||||
node(`${id}-luma`, 'ShaderLuminance', 1340, 160),
|
||||
node(`${id}-mix`, 'ShaderMix', 1600, 0),
|
||||
node(`${id}-out`, 'ShaderOutput', 1860, 0),
|
||||
],
|
||||
edges: [
|
||||
edge(`${id}-e1`, `${id}-texel`, 'color', `${id}-bright`, 'a'),
|
||||
edge(`${id}-e2`, `${id}-brightness`, 'value', `${id}-bright`, 'b'),
|
||||
edge(`${id}-e3`, `${id}-bright`, 'result', `${id}-shift`, 'a'),
|
||||
edge(`${id}-e4`, `${id}-shift`, 'result', `${id}-scale`, 'a'),
|
||||
edge(`${id}-e5`, `${id}-contrast`, 'value', `${id}-scale`, 'b'),
|
||||
edge(`${id}-e6`, `${id}-scale`, 'result', `${id}-centre`, 'a'),
|
||||
edge(`${id}-e7`, `${id}-centre`, 'result', `${id}-luma`, 'color'),
|
||||
edge(`${id}-e8`, `${id}-luma`, 'value', `${id}-mix`, 'a'),
|
||||
edge(`${id}-e9`, `${id}-centre`, 'result', `${id}-mix`, 'b'),
|
||||
edge(`${id}-e10`, `${id}-saturation`, 'value', `${id}-mix`, 't'),
|
||||
edge(`${id}-e11`, `${id}-mix`, 'result', `${id}-out`, 'color'),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { edge, node, port, propertiesOf, sceneObject, tagged, variable, written } from './lib/builders.mjs';
|
||||
import { PROJECTS, cliArgs, uploadTexture, writeProject } from './lib/projects.mjs';
|
||||
import { adjustColorsShader } from './lib/shaders.mjs';
|
||||
|
||||
const { projectId, baseUrl } = cliArgs('seed-actor.mjs', PROJECTS.actor);
|
||||
|
||||
@@ -237,6 +238,8 @@ const project = {
|
||||
frames,
|
||||
},
|
||||
...walkAnimations,
|
||||
// Present but worn by nothing: the smoke reads the actor's pixels, a filter pass must not touch them.
|
||||
adjustColorsShader(),
|
||||
{
|
||||
id: 'actor-sprite',
|
||||
type: 'sprite',
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import { edge, node, port, sceneObject, variable } from './lib/builders.mjs';
|
||||
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
|
||||
import { adjustColorsShader } from './lib/shaders.mjs';
|
||||
|
||||
const { projectId, baseUrl } = cliArgs('seed-e2e.mjs', PROJECTS.fixture);
|
||||
|
||||
@@ -95,6 +96,8 @@ const project = {
|
||||
graph: moverGraph,
|
||||
},
|
||||
{ id: 'spawner', type: 'behavior', name: 'Spawner', props: {}, variables: [], graph: spawnerGraph },
|
||||
// Worn by nothing here; the shader step assigns it and reads the pixels.
|
||||
adjustColorsShader(),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
import { edge, node, port, propertiesOf, sceneObject, variable, written } from './lib/builders.mjs';
|
||||
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
|
||||
import { adjustColorsShader } from './lib/shaders.mjs';
|
||||
|
||||
const { projectId, baseUrl } = cliArgs('seed-jumprun.mjs', PROJECTS.jumpRun);
|
||||
|
||||
@@ -388,7 +389,14 @@ const project = {
|
||||
],
|
||||
},
|
||||
{ id: 'cam', type: 'camera', name: 'Main Camera', props: { width: 640, height: 360, zoom: 1 } },
|
||||
{ id: 'box', type: 'sprite', name: 'Box', props: { width: 100, height: 100, color: STONE } },
|
||||
// The boxes wear the shader at its neutral values: the pass runs, the picture stays the same.
|
||||
{
|
||||
id: 'box',
|
||||
type: 'sprite',
|
||||
name: 'Box',
|
||||
props: { width: 100, height: 100, color: STONE, shader: 'adjust-colors' },
|
||||
},
|
||||
adjustColorsShader(),
|
||||
{
|
||||
id: 'player-sprite',
|
||||
type: 'sprite',
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
written,
|
||||
} from './lib/builders.mjs';
|
||||
import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs';
|
||||
import { adjustColorsShader } from './lib/shaders.mjs';
|
||||
|
||||
const { projectId, baseUrl } = cliArgs('seed-tetris.mjs', PROJECTS.tetris);
|
||||
|
||||
@@ -936,8 +937,10 @@ const project = {
|
||||
id: 'well-sprite',
|
||||
type: 'sprite',
|
||||
name: 'Well',
|
||||
props: { width: COLS * CELL, height: ROWS * CELL, color: WELL },
|
||||
// Wears the shader at its neutral values: the pass runs, the picture stays the same.
|
||||
props: { width: COLS * CELL, height: ROWS * CELL, color: WELL, shader: 'adjust-colors' },
|
||||
},
|
||||
adjustColorsShader(),
|
||||
{ id: 'cell-sprite', type: 'sprite', name: 'Cell', props: { width: CELL - 1, height: CELL - 1 } },
|
||||
{
|
||||
id: 'label',
|
||||
|
||||
Reference in new issue
Block a user