Let a shader declare a color the sprite sets (#298)
game-maker / gate (push) Successful in 3m1s
game-maker / suite (push) Successful in 14m11s

A shader could only ask its sprites for numbers. A tint, an outline
colour or a glow colour had to be built from three Property nodes.

- New node Color property next to Property: Name and Default (a
  colour, white unless picked), output `color`. It becomes a colour
  row below the Shader dropdown and a vec3 uniform in the program.
- Number and colour properties share one set of names. A clash is
  the existing "Another property is named" problem.
- Like Color it cannot feed the Vertex output.
- Placed from the palette it is called tint, then tint2.
- The preview in the Shader tab shows a colour field for it. Like
  the sliders it starts at the sprite's value and never writes into
  the project.
- `declaredRows` takes the row builder, so the particle graph keeps
  its number rows unchanged.

Tests:
- Gate: 222 files, 3452 tests (3445 + 7)
- e2e 28: 89 checks (28l-v: default colour drawn, then the colour
  the sprite sets)
- e2e total 1704, counted (1703 + 1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 11:43:54 +02:00
1 parent 4f4c149211
commit bf27e42e8a
13 files changed
+303 -57

No files matched your search

+11 -2
View File
@@ -27,7 +27,12 @@ import {
PROPERTY_NAME_PARAM as PARTICLE_NAME_PARAM,
propertyNameOf,
} from '../../core/properties/particles';
import { nextPropertyName, PROPERTY_NAME_PARAM, SHADER_PROPERTY } from '../../core/properties/shaders';
import {
nextPropertyName,
PROPERTY_NAME_PARAM,
SHADER_COLOR_PROPERTY,
SHADER_PROPERTY,
} from '../../core/properties/shaders';
import { nextParticlePropertyName, PARTICLE_NODES, PARTICLE_OUTPUT } from '../../core/particles/nodes';
import { SHADER_NODES, SHADER_OUTPUT, SHADER_VERTEX_OUTPUT } from '../../core/shader/nodes';
@@ -99,7 +104,11 @@ export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntr
const once = [SHADER_OUTPUT, SHADER_VERTEX_OUTPUT];
return SHADER_NODES.filter((def) => !once.includes(def.type) || !placed.has(def.type)).map((def) => {
const params: PropertyBag =
def.type === SHADER_PROPERTY ? { [PROPERTY_NAME_PARAM]: nextPropertyName(asset.graph) } : {};
def.type === SHADER_PROPERTY
? { [PROPERTY_NAME_PARAM]: nextPropertyName(asset.graph) }
: def.type === SHADER_COLOR_PROPERTY
? { [PROPERTY_NAME_PARAM]: nextPropertyName(asset.graph, 'tint') }
: {};
return { id: def.type, hint: def.category, def, params };
});
}
+39 -14
View File
@@ -1,8 +1,9 @@
import type { Asset, ShaderAsset } from '../../core/model/Asset';
import type { FlatDef } from '../../core/properties/PropertySchema';
import { shaderParamDefs } from '../../core/properties/shaders';
import { type ShaderParamDef, shaderParamDefs } from '../../core/properties/shaders';
import {
Button,
ColorField,
formatNumber,
HStack,
Label,
@@ -56,7 +57,8 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
private readonly subjectLabel = new Label('', { style: 'small' });
private readonly params = new VStack({ gap: Theme.gap, padding: Theme.padding });
private readonly sliders = new Map<string, { slider: Slider; value: Label }>();
/** What the sliders are set to – the preview's values, never anyone's rows. */
private readonly colors = new Map<string, ColorField>();
/** What the sliders and colour fields are set to – the preview's values, never anyone's rows. */
private values: Record<string, number> = {};
private subject: PictureSubject | null = null;
/** Rebuild the sliders only when the declared properties change, not on every edit of the graph. */
@@ -139,6 +141,11 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
return this.sliders.get(key)?.slider;
}
/** The colour field of one colour property, for the check scripts. */
colorFieldOf(key: string): ColorField | undefined {
return this.colors.get(key);
}
override selectedId(): string {
return this.state.selectedShaderId ?? '';
}
@@ -178,12 +185,15 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
this.syncControls();
}
private syncParams(defs: readonly FlatDef<'number'>[]): void {
const key = JSON.stringify(defs.map((d) => [d.key, d.min, d.max, d.default]));
private syncParams(defs: readonly ShaderParamDef[]): void {
const key = JSON.stringify(
defs.map((d) => (d.kind === 'color' ? [d.key, d.kind] : [d.key, d.min, d.max, d.default])),
);
if (key !== this.paramsKey) {
this.paramsKey = key;
for (const row of this.params.removeChildren()) row.destroy({ children: true });
this.sliders.clear();
this.colors.clear();
for (const def of defs) this.params.add(this.paramRow(def));
// Neither the column nor, with as many rows as before, the stack of rows changes its size,
// and a resize that changes nothing lays nothing out: both lay out anew, outside in.
@@ -191,14 +201,36 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
this.params.relayout();
}
for (const def of defs) {
const row = this.sliders.get(def.key);
const value = this.values[def.key] ?? def.default;
this.colors.get(def.key)?.setValue(value);
const row = this.sliders.get(def.key);
row?.slider.setValue(value);
row?.value.setText(formatNumber(value));
}
}
private paramRow(def: FlatDef<'number'>): Widget {
private paramRow(def: ShaderParamDef): Widget {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.controlHeight;
const name = new Label(def.label, { style: 'small' });
name.minWidth = PARAM_NAME_WIDTH;
row.add(name);
if (def.kind === 'color') {
const show = (color: number) => {
this.values[def.key] = color;
this.preview.invalidate();
};
const field = new ColorField(this.values[def.key] ?? def.default, { onInput: show, onChange: show });
this.colors.set(def.key, field);
row.add(field, 1);
return row;
}
row.add(this.slider(def), 1);
row.add(this.sliders.get(def.key)!.value);
return row;
}
private slider(def: FlatDef<'number'>): Slider {
const { min, max } = sliderRange(def);
const slider = new Slider(this.values[def.key] ?? def.default, min, max, 80);
const value = new Label('', { style: 'small', align: 'right' });
@@ -210,14 +242,7 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
this.preview.invalidate();
};
this.sliders.set(def.key, { slider, value });
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.controlHeight;
const name = new Label(def.label, { style: 'small' });
name.minWidth = PARAM_NAME_WIDTH;
row.add(name);
row.add(slider, 1);
row.add(value);
return row;
return slider;
}
/** Stands the preview at a second, or lets it run again with `null` – what Hold and Run do. */
+7 -5
View File
@@ -1,12 +1,12 @@
import { findObject } from '../../core/model/hierarchy';
import { resolvedHolder } from '../../core/model/instance';
import type { PropertyBag } from '../../core/model/Properties';
import { colorOf, type PropertyBag } from '../../core/model/Properties';
import { findAsset, findObjectHolder, type Project } from '../../core/model/Project';
import { objectName } from '../../core/model/SceneObject';
import { findFrame, resolvePicture, resolveTexture, type TextureRef } from '../../core/model/texture';
import type { FlatDef } from '../../core/properties/PropertySchema';
import { resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
import { shaderOf } from '../../core/properties/shaders';
import { type ShaderParamDef, shaderOf } from '../../core/properties/shaders';
import type { Selection } from '../state';
/** Something the Shader tab's preview can show: a sprite, placed or not, a texture or one of its frames (#168). */
@@ -85,15 +85,17 @@ function texturePicture(name: string, ref: TextureRef): PreviewPicture {
/** Where a slider starts: the subject's own value when it wears this shader, the declared default otherwise. */
export function startValues(
defs: readonly FlatDef<'number'>[],
defs: readonly ShaderParamDef[],
props: PropertyBag,
shaderId: string,
): Record<string, number> {
const wears = shaderOf(props) === shaderId;
const values: Record<string, number> = {};
for (const def of defs) {
const own = Number(props[def.key]);
values[def.key] = wears && Number.isFinite(own) ? own : def.default;
const own = props[def.key];
if (def.kind === 'color')
values[def.key] = wears && own !== undefined ? colorOf(own, def.default) : def.default;
else values[def.key] = wears && Number.isFinite(Number(own)) ? Number(own) : def.default;
}
return values;
}