The shader tab previews the effect on the last picked image (#168)
A shader could only be judged by placing it on a sprite and running the scene. The shader tab now shows its effect next to the graph, on the image the user picked last, without a picker to maintain. - The preview follows the last selection that has an image: a placed sprite with its overrides, size and tint, a sprite asset, a texture or a frame. Clicking the shader, a node, a behavior or a scene keeps the picture. - Without such a selection, or when the picked image is deleted, a built-in 64 px test image is shown. Its colour runs with u and v, a checkerboard shows where a UV lands and the border fades for alpha. - Each property gets a slider. The sliders belong to the preview and never write to the project. They start at the sprite's values when it carries this shader, otherwise at the default. - The preview has its own clock. Hold and Restart are only enabled when a Time node is in the graph. - The graph | splitter | panel layout of the particles tab moves into PreviewSplit, and both tabs use it. - A nine-sliced frame is previewed unsliced. This is documented. Findings: - The slider row stayed squeezed to a few pixels when the properties changed while the tab was hidden. The layout was triggered one level too high. The slider check in block 28n holds this. - The graph canvas is narrower now, 385 px at a 1280 px window, the same as in the particles tab. Block 28b clicked at a fixed x+420 and now uses the measured width. Tests: - ./check.sh: 132 files, 1952 tests (was 131 / 1941), exit 0 - npm run build: exit 0 - GM_STEPS=28,31 freshly seeded: ALL PASSED, 105 checks - 11 new unit tests in tests/editor/shader-preview.test.ts - 7 new e2e checks in block 28n - Counter-probes: start values ignoring the sprite fail 1 unit test, a preview ignoring the selection fails 2 e2e checks - Check total 1209 in README is computed (1202 + 7), CI measures it Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2d79230b55
commit
73a71473f1
10 files changed
+843
-31
No files matched your search
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1202 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1209 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -714,12 +714,13 @@ export class Editor implements PlayHost {
|
||||
// to do it, and its states move just the same.
|
||||
if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
|
||||
if (this.tab === 'particles') this.particlesEditor.tick();
|
||||
if (this.tab === 'shader') this.shaderEditor.tick();
|
||||
if (this.movesByItself()) requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* What changes without anyone touching it, and therefore cannot be woken by an event: the
|
||||
* running world, a Time shader on screen, the two previews that are moving pictures.
|
||||
* running world, a Time shader on screen, the previews that are moving pictures.
|
||||
*/
|
||||
private movesByItself(): boolean {
|
||||
return (
|
||||
@@ -728,6 +729,7 @@ export class Editor implements PlayHost {
|
||||
(this.viewport.visible && this.viewport.animates) ||
|
||||
(this.gameView.visible && this.gameView.animates) ||
|
||||
this.tab === 'particles' ||
|
||||
(this.tab === 'shader' && this.shaderEditor.animates) ||
|
||||
(this.tab === 'animation' && this.animationEditor.isPlaying) ||
|
||||
(this.tab === 'tile' && this.tileEditor.isPlaying)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { clamp, HStack, Splitter, type Widget } from '../ui';
|
||||
|
||||
/** The panel can be dragged in to the width of its own controls, and open to most of a wide window. */
|
||||
const MIN_PREVIEW = 200;
|
||||
const MAX_PREVIEW = 640;
|
||||
|
||||
/**
|
||||
* A graph with its preview panel right of it, the two dragged apart like the console: how much of
|
||||
* the tab is graph and how much is picture depends on what is being worked on, and on a narrow
|
||||
* window a fixed panel eats the canvas. The Particles and Shader tabs (#86, #168).
|
||||
*/
|
||||
export class PreviewSplit extends HStack {
|
||||
readonly splitter = new Splitter('x');
|
||||
private panelWidth = 280;
|
||||
|
||||
constructor(canvas: Widget, side: Widget) {
|
||||
super();
|
||||
side.minWidth = this.panelWidth;
|
||||
this.splitter.onDrag = (delta) => {
|
||||
this.panelWidth = clamp(this.panelWidth - delta, MIN_PREVIEW, MAX_PREVIEW);
|
||||
side.minWidth = this.panelWidth;
|
||||
// The row keeps its own size, so resizing it would not lay out again: ask it directly.
|
||||
this.relayout();
|
||||
};
|
||||
this.add(canvas, 1);
|
||||
this.add(this.splitter);
|
||||
this.add(side);
|
||||
}
|
||||
}
|
||||
@@ -417,6 +417,21 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
center: () => centreOf(editor.shaderEditor.canvas),
|
||||
snapping: () => editor.shaderEditor.canvas.snapEnabled,
|
||||
snapButton: () => snapButton(editor.shaderEditor.snap),
|
||||
/** The panel beside the graph (#168): what it shows, its clock and the sliders' values. */
|
||||
preview: () => ({
|
||||
...editor.shaderEditor.controls,
|
||||
holding: editor.shaderEditor.preview.holding,
|
||||
seconds: Math.round(editor.shaderEditor.preview.time * 100) / 100,
|
||||
}),
|
||||
previewRect: () => rectOf(editor.shaderEditor.preview),
|
||||
/** Where the picture sits inside the panel, in screen pixels. */
|
||||
pictureRect: () => editor.shaderEditor.preview.pictureRect,
|
||||
sliderAt: (key: string) => {
|
||||
const slider = editor.shaderEditor.sliderOf(key);
|
||||
return slider ? rectOf(slider) : null;
|
||||
},
|
||||
splitterAt: () => centreOf(editor.shaderEditor.splitter),
|
||||
hold: (at: number | null) => editor.shaderEditor.hold(at),
|
||||
},
|
||||
/** The console panel: its lines as kept, its drawn height, and the divider above it. */
|
||||
console: {
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { Asset, ParticlesAsset } from '../../core/model/Asset';
|
||||
import { LIFETIME_KEY, TIME_SCALE_KEY } from '../../core/properties/particles';
|
||||
import { defaultsFor } from '../../core/properties/PropertySchema';
|
||||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||
import { Button, clamp, HStack, Label, Slider, Spacer, Splitter, Theme, VStack, type Widget } from '../../ui';
|
||||
import { Button, HStack, Label, Slider, Spacer, type Splitter, Theme, VStack, type Widget } from '../../ui';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { GraphCanvas } from '../graph/GraphCanvas';
|
||||
import { PreviewSplit } from '../PreviewSplit';
|
||||
import { SnapToggle } from '../graph/SnapToggle';
|
||||
import type { EditorState } from '../state';
|
||||
import { ParticlePreview } from './ParticlePreview';
|
||||
@@ -14,9 +15,6 @@ const INFO_WIDTH = 220;
|
||||
/** How far the time slider reaches: three lifetimes, so a particle's whole arc fits with room after. */
|
||||
const SPAN_LIFETIMES = 3;
|
||||
const MIN_SPAN = 2;
|
||||
/** The panel can be dragged in to the width of its own controls, and open to most of a wide window. */
|
||||
const MIN_PREVIEW = 200;
|
||||
const MAX_PREVIEW = 640;
|
||||
/** The button reads "Hold" or "Run"; a floor keeps the row from moving when it changes. */
|
||||
const HOLD_WIDTH = 52;
|
||||
|
||||
@@ -35,11 +33,9 @@ export class ParticlesEditor extends AssetTabEditor<ParticlesAsset, GraphCanvas>
|
||||
onClick: () => this.toggleHold(),
|
||||
});
|
||||
private readonly timeLabel = new Label('0.00 s', { style: 'small' });
|
||||
readonly splitter = new Splitter('x');
|
||||
private readonly side = new VStack();
|
||||
private row = new HStack();
|
||||
/** Assigned by `body`; public for the check scripts, which drag it. */
|
||||
splitter!: Splitter;
|
||||
private span = MIN_SPAN;
|
||||
private previewWidth = 280;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
@@ -71,22 +67,12 @@ export class ParticlesEditor extends AssetTabEditor<ParticlesAsset, GraphCanvas>
|
||||
bar.add(buttons);
|
||||
bar.add(time);
|
||||
|
||||
this.side.add(bar);
|
||||
this.side.add(this.preview, 1);
|
||||
this.side.minWidth = this.previewWidth;
|
||||
// Draggable like the console: how much of the tab is graph and how much is picture is a matter
|
||||
// of what is being worked on, and on a narrow window a fixed panel eats the canvas.
|
||||
this.splitter.onDrag = (delta) => {
|
||||
this.previewWidth = clamp(this.previewWidth - delta, MIN_PREVIEW, MAX_PREVIEW);
|
||||
this.side.minWidth = this.previewWidth;
|
||||
// The row keeps its own size, so resizing it would not lay out again: ask it directly.
|
||||
this.row.relayout();
|
||||
};
|
||||
this.row = new HStack();
|
||||
this.row.add(canvas, 1);
|
||||
this.row.add(this.splitter);
|
||||
this.row.add(this.side);
|
||||
return this.row;
|
||||
const side = new VStack();
|
||||
side.add(bar);
|
||||
side.add(this.preview, 1);
|
||||
const split = new PreviewSplit(canvas, side);
|
||||
this.splitter = split.splitter;
|
||||
return split;
|
||||
}
|
||||
|
||||
/** Called every frame while the tab is showing: the panel is a moving picture. */
|
||||
|
||||
@@ -1,26 +1,142 @@
|
||||
import type { Asset, ShaderAsset } from '../../core/model/Asset';
|
||||
import type { FlatDef } from '../../core/properties/PropertySchema';
|
||||
import { shaderParamDefs } from '../../core/properties/shaders';
|
||||
import {
|
||||
Button,
|
||||
formatNumber,
|
||||
HStack,
|
||||
Label,
|
||||
Slider,
|
||||
snapToStep,
|
||||
Spacer,
|
||||
type Splitter,
|
||||
Theme,
|
||||
VStack,
|
||||
type Widget,
|
||||
} from '../../ui';
|
||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { GraphCanvas } from '../graph/GraphCanvas';
|
||||
import { SnapToggle } from '../graph/SnapToggle';
|
||||
import { PreviewSplit } from '../PreviewSplit';
|
||||
import type { EditorState } from '../state';
|
||||
import {
|
||||
type PictureSubject,
|
||||
pictureSubjectOf,
|
||||
previewPicture,
|
||||
sliderRange,
|
||||
startValues,
|
||||
} from './previewPicture';
|
||||
import { ShaderPreview } from './ShaderPreview';
|
||||
|
||||
/** Room kept for "N properties", or for the reason the graph does not compile. */
|
||||
const INFO_WIDTH = 220;
|
||||
/** The button reads "Hold" or "Run"; a floor keeps the row from moving when it changes. */
|
||||
const HOLD_WIDTH = 52;
|
||||
const PARAM_NAME_WIDTH = 72;
|
||||
const PARAM_VALUE_WIDTH = 44;
|
||||
|
||||
/**
|
||||
* The nodes of one shader asset, on the graph canvas the Behavior tab uses. The graph is compiled
|
||||
* on every change (see `ShaderLibrary`); the readout says whether it did.
|
||||
* The nodes of one shader asset, on the graph canvas the Behavior tab uses, and beside them a picture
|
||||
* wearing it (#168). The picture is whatever was last selected that has one – a sprite, a texture, a
|
||||
* frame – so the shader is judged on the image it is meant for; before that, a built-in test image.
|
||||
*/
|
||||
export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
|
||||
readonly snap = new SnapToggle(() => this.canvas);
|
||||
readonly preview: ShaderPreview;
|
||||
/** Assigned by `body`; public for the check scripts, which drag it. */
|
||||
splitter!: Splitter;
|
||||
private readonly side = new VStack();
|
||||
private readonly holdButton = new Button('Hold', {
|
||||
minWidth: HOLD_WIDTH,
|
||||
onClick: () => this.toggleHold(),
|
||||
});
|
||||
private readonly restartButton = new Button('Restart', { onClick: () => this.restart() });
|
||||
private readonly timeLabel = new Label('0.00 s', { style: 'small', align: 'right' });
|
||||
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 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. */
|
||||
private paramsKey = '';
|
||||
private valuesFor = '';
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.preview = new ShaderPreview(state);
|
||||
this.mount(new GraphCanvas(state, 'shader'), this.snap.widgets);
|
||||
// Play swaps in the runtime copy of the project, and back.
|
||||
state.on('mode', () => this.refresh());
|
||||
// Remembered while anything else is selected: selecting the shader itself is how one gets here.
|
||||
state.on('selection', () => {
|
||||
const subject = pictureSubjectOf(state.project, state.selection);
|
||||
if (!subject) return;
|
||||
this.subject = subject;
|
||||
this.valuesFor = '';
|
||||
this.refresh();
|
||||
});
|
||||
state.on('load', () => {
|
||||
this.subject = null;
|
||||
this.valuesFor = '';
|
||||
});
|
||||
}
|
||||
|
||||
protected override body(canvas: GraphCanvas): Widget {
|
||||
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
buttons.minHeight = Theme.controlHeight;
|
||||
buttons.add(new Label('Preview', { style: 'muted' }));
|
||||
buttons.add(new Spacer(), 1);
|
||||
this.timeLabel.minWidth = 48;
|
||||
buttons.add(this.timeLabel);
|
||||
buttons.add(this.holdButton);
|
||||
buttons.add(this.restartButton);
|
||||
const bar = new VStack({ gap: Theme.gap, padding: Theme.padding });
|
||||
bar.add(buttons);
|
||||
bar.add(this.subjectLabel);
|
||||
|
||||
this.side.add(bar);
|
||||
this.side.add(this.preview, 1);
|
||||
this.side.add(this.params);
|
||||
const split = new PreviewSplit(canvas, this.side);
|
||||
this.splitter = split.splitter;
|
||||
return split;
|
||||
}
|
||||
|
||||
/** Whether the panel is a moving picture: a Time node reaches the output and the clock runs. */
|
||||
get animates(): boolean {
|
||||
const asset = this.asset();
|
||||
return Boolean(asset && !this.preview.holding && shaderLibrary.buildFor(asset).compiled?.time);
|
||||
}
|
||||
|
||||
/** Called every frame while the tab is showing. */
|
||||
tick(): void {
|
||||
if (!this.animates) return;
|
||||
this.preview.invalidate();
|
||||
this.syncControls();
|
||||
}
|
||||
|
||||
/** What the panel shows and what its controls say – the check scripts read this. */
|
||||
get controls(): {
|
||||
subject: string;
|
||||
hold: string;
|
||||
time: string;
|
||||
timed: boolean;
|
||||
values: Record<string, number>;
|
||||
} {
|
||||
return {
|
||||
subject: this.subjectLabel.text,
|
||||
hold: this.holdButton.text,
|
||||
time: this.timeLabel.text,
|
||||
timed: this.holdButton.enabled,
|
||||
values: { ...this.values },
|
||||
};
|
||||
}
|
||||
|
||||
/** The slider of one property, for the check scripts to drag. */
|
||||
sliderOf(key: string): Slider | undefined {
|
||||
return this.sliders.get(key)?.slider;
|
||||
}
|
||||
|
||||
override selectedId(): string {
|
||||
@@ -40,5 +156,85 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
|
||||
|
||||
protected override syncExtras(asset: ShaderAsset | null): void {
|
||||
this.snap.setEnabled(Boolean(asset));
|
||||
const picture = this.subject && previewPicture(this.state.project, this.subject);
|
||||
this.subjectLabel.setText(asset ? (picture?.name ?? 'Test image') : '');
|
||||
const defs = asset ? shaderParamDefs(asset.graph) : [];
|
||||
// A new picture or another shader starts over from what that sprite wears; an edit of the graph
|
||||
// keeps what the sliders were moved to.
|
||||
const valuesFor = `${asset?.id ?? ''}\u0000${picture ? JSON.stringify(this.subject) : ''}`;
|
||||
const fresh = startValues(defs, picture?.props ?? {}, asset?.id ?? '');
|
||||
if (valuesFor !== this.valuesFor) {
|
||||
this.valuesFor = valuesFor;
|
||||
this.values = fresh;
|
||||
} else {
|
||||
this.values = Object.fromEntries(
|
||||
defs.map((d) => [d.key, this.values[d.key] ?? fresh[d.key] ?? d.default]),
|
||||
);
|
||||
}
|
||||
this.syncParams(defs);
|
||||
this.preview.show(asset, picture, this.values);
|
||||
this.holdButton.setEnabled(Boolean(asset && shaderLibrary.buildFor(asset).compiled?.time));
|
||||
this.restartButton.setEnabled(this.holdButton.enabled);
|
||||
this.syncControls();
|
||||
}
|
||||
|
||||
private syncParams(defs: readonly FlatDef<'number'>[]): void {
|
||||
const key = JSON.stringify(defs.map((d) => [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();
|
||||
for (const def of defs) this.params.add(this.paramRow(def));
|
||||
// The column keeps its size, so a resize would not reach the rows: the column lays out anew.
|
||||
this.side.relayout();
|
||||
}
|
||||
for (const def of defs) {
|
||||
const row = this.sliders.get(def.key);
|
||||
const value = this.values[def.key] ?? def.default;
|
||||
row?.slider.setValue(value);
|
||||
row?.value.setText(formatNumber(value));
|
||||
}
|
||||
}
|
||||
|
||||
private paramRow(def: FlatDef<'number'>): Widget {
|
||||
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' });
|
||||
value.minWidth = PARAM_VALUE_WIDTH;
|
||||
slider.onChange = (raw) => {
|
||||
const snapped = snapToStep(raw, def.step ?? 0);
|
||||
this.values[def.key] = snapped;
|
||||
value.setText(formatNumber(snapped));
|
||||
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;
|
||||
}
|
||||
|
||||
/** Stands the preview at a second, or lets it run again with `null` – what Hold and Run do. */
|
||||
hold(at: number | null): void {
|
||||
this.preview.hold(at);
|
||||
this.syncControls();
|
||||
}
|
||||
|
||||
private toggleHold(): void {
|
||||
this.hold(this.preview.holding ? null : this.preview.time);
|
||||
}
|
||||
|
||||
private restart(): void {
|
||||
this.preview.restart();
|
||||
this.syncControls();
|
||||
}
|
||||
|
||||
private syncControls(): void {
|
||||
this.holdButton.setText(this.preview.holding ? 'Run' : 'Hold');
|
||||
this.timeLabel.setText(this.holdButton.enabled ? `${this.preview.time.toFixed(2)} s` : '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Container, Graphics, Texture } from 'pixi.js';
|
||||
import type { ShaderAsset } from '../../core/model/Asset';
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import { SHADER_KEY } from '../../core/properties/shaders';
|
||||
import { type EdgeMode, SHEET_EDGE } from '../../core/shader/nodes';
|
||||
import { type Size, Theme, Widget } from '../../ui';
|
||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||
import { syncShader } from '../../viewport/shaders';
|
||||
import { disposeSpriteMesh, newSpriteMesh } from '../../viewport/symbols';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import type { EditorState } from '../state';
|
||||
import { type PreviewPicture, TEST_IMAGE_SIZE, testImagePixels } from './previewPicture';
|
||||
|
||||
const MIN_HEIGHT = 40;
|
||||
const MARGIN = 12;
|
||||
/** A 16-px sprite fills a big panel only blurred by the zoom; past this it stops growing. */
|
||||
const MAX_ZOOM = 8;
|
||||
|
||||
/**
|
||||
* One picture wearing the shown shader, with the sliders' values instead of anyone's rows (#168).
|
||||
* The shader is the scene's own (`syncShader`), so what the panel shows is what a sprite would.
|
||||
*/
|
||||
export class ShaderPreview extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly content = new Container();
|
||||
private readonly clip = new Graphics();
|
||||
private readonly mesh = newSpriteMesh();
|
||||
private shader: ShaderAsset | null = null;
|
||||
private picture: PreviewPicture | null = null;
|
||||
private values: PropertyBag = {};
|
||||
/** The library second the preview counts from; Restart moves it. */
|
||||
private clockOrigin = 0;
|
||||
/** Null: the clock runs. A number: the preview stands at that second. */
|
||||
private held: number | null = null;
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
this.minHeight = MIN_HEIGHT;
|
||||
this.content.addChild(this.mesh);
|
||||
this.content.mask = this.clip;
|
||||
this.addChild(this.bg, this.content, this.clip);
|
||||
textureLibrary.onChange(() => this.invalidate());
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: Math.max(this.minWidth, 100), h: Math.max(this.minHeight, MIN_HEIGHT) };
|
||||
}
|
||||
|
||||
show(shader: ShaderAsset | null, picture: PreviewPicture | null, values: PropertyBag): void {
|
||||
this.shader = shader;
|
||||
this.picture = picture;
|
||||
this.values = values;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** Seconds a Time node reads in the panel, whether held or running. */
|
||||
get time(): number {
|
||||
return this.held ?? Math.max(0, shaderLibrary.now() - this.clockOrigin);
|
||||
}
|
||||
|
||||
get holding(): boolean {
|
||||
return this.held !== null;
|
||||
}
|
||||
|
||||
hold(at: number | null): void {
|
||||
// Letting go carries on from the held second rather than jumping to where the clock got to.
|
||||
if (at === null && this.held !== null) this.clockOrigin = shaderLibrary.now() - this.held;
|
||||
this.held = at;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
restart(): void {
|
||||
this.clockOrigin = shaderLibrary.now();
|
||||
if (this.held !== null) this.held = 0;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** Where the picture sits in the panel and how big – what the checks sample. */
|
||||
get pictureRect(): { x: number; y: number; w: number; h: number } {
|
||||
const w = Math.abs(this.mesh.scale.x);
|
||||
const h = Math.abs(this.mesh.scale.y);
|
||||
const at = this.toGlobal({ x: this.mesh.x - w / 2, y: this.mesh.y - h / 2 });
|
||||
return { x: at.x, y: at.y, w, h };
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const c = Theme.colors;
|
||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.panel);
|
||||
this.bg.rect(0, 0, this.w, 1).fill(c.border);
|
||||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
||||
this.mesh.visible = this.shader !== null;
|
||||
if (!this.shader) return;
|
||||
const source = { ...shaderLibrary.source(this.state.project), now: this.time };
|
||||
const edge = source.compiled(this.shader.id)?.edge ?? SHEET_EDGE;
|
||||
const picture = this.picture;
|
||||
const w = picture?.w ?? TEST_IMAGE_SIZE;
|
||||
const h = picture?.h ?? TEST_IMAGE_SIZE;
|
||||
const zoom = Math.max(0, Math.min((this.w - 2 * MARGIN) / w, (this.h - 2 * MARGIN) / h, MAX_ZOOM));
|
||||
this.mesh.position.set(Math.round(this.w / 2), Math.round(this.h / 2));
|
||||
this.mesh.scale.set(w * zoom, h * zoom);
|
||||
// A sprite whose picture is still loading shows its plain colour, like the scene does.
|
||||
this.mesh.texture = picture
|
||||
? ((picture.texture && textureLibrary.get(picture.texture, edge)) ?? Texture.WHITE)
|
||||
: testTexture(edge);
|
||||
this.mesh.tint = picture?.tint ?? 0xffffff;
|
||||
syncShader(this.mesh, { ...this.values, [SHADER_KEY]: this.shader.id }, source);
|
||||
}
|
||||
|
||||
override destroy(): void {
|
||||
disposeSpriteMesh(this.mesh);
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_ADDRESS = { clamp: 'clamp-to-edge', mirror: 'mirror-repeat', repeat: 'repeat' } as const;
|
||||
const testTextures = new Map<EdgeMode, Texture>();
|
||||
|
||||
/** The test image, one copy per edge mode: the sampler decides what a UV past the edge reads. */
|
||||
function testTexture(edge: EdgeMode): Texture {
|
||||
let texture = testTextures.get(edge);
|
||||
if (texture) return texture;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = TEST_IMAGE_SIZE;
|
||||
const image = new ImageData(testImagePixels(), TEST_IMAGE_SIZE, TEST_IMAGE_SIZE);
|
||||
canvas.getContext('2d')!.putImageData(image, 0, 0);
|
||||
texture = Texture.from(canvas);
|
||||
texture.source.style.addressMode = TEST_ADDRESS[edge];
|
||||
texture.source.style.scaleMode = 'nearest';
|
||||
testTextures.set(edge, texture);
|
||||
return texture;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { findObject } from '../../core/model/hierarchy';
|
||||
import { resolvedHolder } from '../../core/model/instance';
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import { findAsset, findObjectHolder, type Project } from '../../core/model/Project';
|
||||
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 { Selection } from '../state';
|
||||
|
||||
/** Something the Shader tab's preview can show: a sprite, placed or not, a texture or one of its frames (#168). */
|
||||
export type PictureSubject =
|
||||
| { kind: 'object'; holderId: string; objectId: string }
|
||||
| { kind: 'asset'; assetId: string }
|
||||
| { kind: 'frame'; frameId: string };
|
||||
|
||||
/** What the preview draws for a subject, resolved again on every redraw so an edit shows at once. */
|
||||
export interface PreviewPicture {
|
||||
name: string;
|
||||
/** Null for a sprite without a picture: the preview shows its plain colour, as the scene does. */
|
||||
texture: TextureRef | null;
|
||||
w: number;
|
||||
h: number;
|
||||
tint: number;
|
||||
/** The rows the subject carries; a sprite already wearing the shader starts the sliders at its values. */
|
||||
props: PropertyBag;
|
||||
}
|
||||
|
||||
export const TEST_IMAGE_SIZE = 64;
|
||||
const TEST_IMAGE_CELL = 8;
|
||||
|
||||
/** The subject a selection names, if it has a picture; anything else leaves the preview where it was. */
|
||||
export function pictureSubjectOf(project: Project, selection: Selection): PictureSubject | null {
|
||||
let subject: PictureSubject | null = null;
|
||||
if (selection?.kind === 'object')
|
||||
subject = { kind: 'object', holderId: selection.holderId, objectId: selection.objectId };
|
||||
else if (selection?.kind === 'asset') subject = { kind: 'asset', assetId: selection.assetId };
|
||||
else if (selection?.kind === 'frame') subject = { kind: 'frame', frameId: selection.frameId };
|
||||
return subject && previewPicture(project, subject) ? subject : null;
|
||||
}
|
||||
|
||||
/** Null once the subject is gone or no longer has a picture – the preview then falls back to the test image. */
|
||||
export function previewPicture(project: Project, subject: PictureSubject): PreviewPicture | null {
|
||||
if (subject.kind === 'object') {
|
||||
const holder = findObjectHolder(project, subject.holderId);
|
||||
const object = holder && findObject(resolvedHolder(project, holder), subject.objectId);
|
||||
const asset = object && findAsset(project, object.assetId);
|
||||
if (!object || asset?.type !== 'sprite') return null;
|
||||
return spritePicture(project, object.name || asset.name, resolveSceneObjectProps(project, asset, object));
|
||||
}
|
||||
if (subject.kind === 'frame') {
|
||||
const found = findFrame(project, subject.frameId);
|
||||
const ref = resolvePicture(project, subject.frameId);
|
||||
return found && ref ? texturePicture(`${found.asset.name} / ${found.frame.name}`, ref) : null;
|
||||
}
|
||||
const asset = findAsset(project, subject.assetId);
|
||||
if (!asset) return null;
|
||||
if (asset.type === 'sprite') return spritePicture(project, asset.name, resolveAssetProps(project, asset));
|
||||
const ref = resolvePicture(project, asset.id);
|
||||
return ref ? texturePicture(asset.name, ref) : null;
|
||||
}
|
||||
|
||||
function spritePicture(project: Project, name: string, props: PropertyBag): PreviewPicture {
|
||||
return {
|
||||
name,
|
||||
texture: resolveTexture(project, props),
|
||||
w: Number(props.width) || 1,
|
||||
h: Number(props.height) || 1,
|
||||
tint: Number(props.color ?? 0xffffff),
|
||||
props,
|
||||
};
|
||||
}
|
||||
|
||||
function texturePicture(name: string, ref: TextureRef): PreviewPicture {
|
||||
return {
|
||||
name,
|
||||
texture: ref,
|
||||
w: ref.rect?.w ?? ref.sourceWidth,
|
||||
h: ref.rect?.h ?? ref.sourceHeight,
|
||||
tint: 0xffffff,
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
|
||||
/** 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'>[],
|
||||
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;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* A property declares its min, its max only when it is above the min. Without one the slider
|
||||
* reaches twice the default's distance, so the default sits in the middle.
|
||||
*/
|
||||
export function sliderRange(def: FlatDef<'number'>): { min: number; max: number } {
|
||||
const min = def.min ?? 0;
|
||||
if (def.max !== undefined && def.max > min) return { min, max: def.max };
|
||||
return { min, max: def.default > min ? min + 2 * (def.default - min) : min + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in picture for when nothing with a picture was selected: colour runs from left to right
|
||||
* and top to bottom, a checker shows where a UV lands, and the edge fades out so alpha has something
|
||||
* to act on. Straight RGBA, row by row.
|
||||
*/
|
||||
export function testImagePixels(size = TEST_IMAGE_SIZE): Uint8ClampedArray<ArrayBuffer> {
|
||||
const border = size / 8;
|
||||
const pixels = new Uint8ClampedArray(size * size * 4);
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const u = (x + 0.5) / size;
|
||||
const v = (y + 0.5) / size;
|
||||
const shade = (Math.floor(x / TEST_IMAGE_CELL) + Math.floor(y / TEST_IMAGE_CELL)) % 2 ? 0.55 : 1;
|
||||
const edge = Math.min(x, y, size - 1 - x, size - 1 - y);
|
||||
const i = (y * size + x) * 4;
|
||||
pixels[i] = Math.round(255 * u * shade);
|
||||
pixels[i + 1] = Math.round(255 * v * shade);
|
||||
pixels[i + 2] = Math.round(255 * (1 - u) * shade);
|
||||
pixels[i + 3] = edge >= border ? 255 : Math.round((255 * (edge + 0.5)) / border);
|
||||
}
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddFrameCommand } from '../../src/core/commands/frameCommands';
|
||||
import { RemoveSceneObjectsCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import type { FlatDef } from '../../src/core/properties/PropertySchema';
|
||||
import {
|
||||
pictureSubjectOf,
|
||||
previewPicture,
|
||||
sliderRange,
|
||||
startValues,
|
||||
TEST_IMAGE_SIZE,
|
||||
testImagePixels,
|
||||
} from '../../src/editor/shader/previewPicture';
|
||||
import type { Selection } from '../../src/editor/state';
|
||||
import { makeSpriteScene, place } from '../helpers';
|
||||
|
||||
const FILE = `${'cd'.repeat(32)}.png`;
|
||||
|
||||
/** A scene with a sprite "Box", a 64×32 texture "Sheet" with a 16×8 frame "Idle", and one placed Box. */
|
||||
function setup() {
|
||||
const fixture = makeSpriteScene();
|
||||
const texture = new CreateTextureCommand('Sheet', { file: FILE, width: 64, height: 32 });
|
||||
fixture.stack.execute(texture);
|
||||
const frame = new AddFrameCommand(texture.asset.id, { x: 16, y: 8, w: 16, h: 8 }, 'Idle');
|
||||
fixture.stack.execute(frame);
|
||||
const objectId = place(fixture, fixture.spriteId, { x: 0, y: 0 });
|
||||
const stored = () => fixture.sceneOf().objects.find((o) => o.id === objectId)!;
|
||||
return { ...fixture, textureId: texture.asset.id, frameId: frame.frameId, objectId, stored };
|
||||
}
|
||||
|
||||
function numberRow(key: string, extra: Partial<FlatDef<'number'>> = {}): FlatDef<'number'> {
|
||||
return { key, label: key, kind: 'number', default: 1, min: 0, max: 2, ...extra };
|
||||
}
|
||||
|
||||
describe('the picture the Shader tab previews (#168)', () => {
|
||||
it('is named by a selected sprite, placed or not, a texture or a frame', () => {
|
||||
const f = setup();
|
||||
const object: Selection = {
|
||||
kind: 'object',
|
||||
holderId: f.sceneId,
|
||||
objectId: f.objectId,
|
||||
objectIds: [f.objectId],
|
||||
};
|
||||
expect(pictureSubjectOf(f.project, object)).toEqual({
|
||||
kind: 'object',
|
||||
holderId: f.sceneId,
|
||||
objectId: f.objectId,
|
||||
});
|
||||
expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: f.spriteId })).toEqual({
|
||||
kind: 'asset',
|
||||
assetId: f.spriteId,
|
||||
});
|
||||
expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: f.textureId })).not.toBeNull();
|
||||
expect(pictureSubjectOf(f.project, { kind: 'frame', assetId: f.textureId, frameId: f.frameId })).toEqual({
|
||||
kind: 'frame',
|
||||
frameId: f.frameId,
|
||||
});
|
||||
});
|
||||
|
||||
it('is not named by anything without a picture, so the preview keeps the one before', () => {
|
||||
const f = setup();
|
||||
const behavior = new CreateAssetCommand('behavior', 'Mover');
|
||||
f.stack.execute(behavior);
|
||||
expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: behavior.asset.id })).toBeNull();
|
||||
expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: f.sceneId })).toBeNull();
|
||||
expect(pictureSubjectOf(f.project, null)).toBeNull();
|
||||
const node: Selection = {
|
||||
kind: 'node',
|
||||
assetId: behavior.asset.id,
|
||||
nodeId: 'n',
|
||||
nodeIds: ['n'],
|
||||
annotationIds: [],
|
||||
};
|
||||
expect(pictureSubjectOf(f.project, node)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a frame at its own size, a whole texture at the sheet size', () => {
|
||||
const f = setup();
|
||||
const frame = previewPicture(f.project, { kind: 'frame', frameId: f.frameId })!;
|
||||
expect(frame).toMatchObject({ name: 'Sheet / Idle', w: 16, h: 8, tint: 0xffffff, props: {} });
|
||||
expect(frame.texture?.rect).toEqual({ x: 16, y: 8, w: 16, h: 8 });
|
||||
const sheet = previewPicture(f.project, { kind: 'asset', assetId: f.textureId })!;
|
||||
expect(sheet).toMatchObject({ name: 'Sheet', w: 64, h: 32 });
|
||||
expect(sheet.texture?.rect).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a placed sprite as the scene does: its overrides, size and colour', () => {
|
||||
const f = setup();
|
||||
Object.assign(f.stored().overrides, { texture: f.frameId, width: 48, height: 24, color: 0xff8040 });
|
||||
const picture = previewPicture(f.project, { kind: 'object', holderId: f.sceneId, objectId: f.objectId })!;
|
||||
expect(picture).toMatchObject({ name: 'Box', w: 48, h: 24, tint: 0xff8040 });
|
||||
expect(picture.texture?.rect).toEqual({ x: 16, y: 8, w: 16, h: 8 });
|
||||
// The asset itself is untouched by the object's overrides.
|
||||
const asset = previewPicture(f.project, { kind: 'asset', assetId: f.spriteId })!;
|
||||
expect(asset).toMatchObject({ name: 'Box', w: 100, h: 100, texture: null });
|
||||
});
|
||||
|
||||
it('follows an edit, and is gone with its object – the tab then shows the test image', () => {
|
||||
const f = setup();
|
||||
const subject = { kind: 'object', holderId: f.sceneId, objectId: f.objectId } as const;
|
||||
f.stored().overrides.width = 20;
|
||||
expect(previewPicture(f.project, subject)?.w).toBe(20);
|
||||
f.stack.execute(new RemoveSceneObjectsCommand(f.sceneId, [f.objectId]));
|
||||
expect(previewPicture(f.project, subject)).toBeNull();
|
||||
f.stack.undo();
|
||||
expect(previewPicture(f.project, subject)?.w).toBe(20);
|
||||
expect(findAsset(f.project, f.spriteId)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the preview sliders (#168)', () => {
|
||||
const defs = [numberRow('glow'), numberRow('speed', { default: 0.5 })];
|
||||
|
||||
it('start at the values of a sprite wearing this shader', () => {
|
||||
expect(startValues(defs, { shader: 'neon', glow: 1.75, speed: 0.25 }, 'neon')).toEqual({
|
||||
glow: 1.75,
|
||||
speed: 0.25,
|
||||
});
|
||||
// A row the sprite does not carry yet starts at its default.
|
||||
expect(startValues(defs, { shader: 'neon', glow: 0.2 }, 'neon')).toEqual({ glow: 0.2, speed: 0.5 });
|
||||
});
|
||||
|
||||
it('start at the defaults when the picture wears another shader or none', () => {
|
||||
expect(startValues(defs, { shader: 'other', glow: 1.75 }, 'neon')).toEqual({ glow: 1, speed: 0.5 });
|
||||
expect(startValues(defs, {}, 'neon')).toEqual({ glow: 1, speed: 0.5 });
|
||||
});
|
||||
|
||||
it('reach the declared range, or twice the default without a max', () => {
|
||||
expect(sliderRange(numberRow('a', { min: -1, max: 3 }))).toEqual({ min: -1, max: 3 });
|
||||
expect(sliderRange(numberRow('b', { max: undefined, default: 1.5 }))).toEqual({ min: 0, max: 3 });
|
||||
expect(sliderRange(numberRow('c', { min: 2, max: undefined, default: 2 }))).toEqual({ min: 2, max: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the built-in test image (#168)', () => {
|
||||
const pixels = testImagePixels();
|
||||
const at = (x: number, y: number) =>
|
||||
Array.from(pixels.slice((y * TEST_IMAGE_SIZE + x) * 4, (y * TEST_IMAGE_SIZE + x) * 4 + 4));
|
||||
|
||||
it('runs red from left to right and green from top to bottom', () => {
|
||||
expect(pixels.length).toBe(TEST_IMAGE_SIZE * TEST_IMAGE_SIZE * 4);
|
||||
const [r1, g1, b1] = at(16, 16);
|
||||
const [r2, g2, b2] = at(48, 48);
|
||||
expect(r2).toBeGreaterThan(r1!);
|
||||
expect(g2).toBeGreaterThan(g1!);
|
||||
expect(b2).toBeLessThan(b1!);
|
||||
});
|
||||
|
||||
it('has a checker, so a moved UV shows', () => {
|
||||
// Two neighbours across a cell border: same place in the gradient, a different shade.
|
||||
expect(at(24, 20)[0]! / at(23, 20)[0]!).toBeCloseTo(0.55, 1);
|
||||
});
|
||||
|
||||
it('fades out at the edge and is opaque inside', () => {
|
||||
expect(at(0, 32)[3]).toBe(16);
|
||||
expect(at(32, TEST_IMAGE_SIZE - 1)[3]).toBe(16);
|
||||
expect(at(4, 32)[3]).toBe(143);
|
||||
expect(at(8, 32)[3]).toBe(255);
|
||||
expect(at(32, 32)[3]).toBe(255);
|
||||
});
|
||||
});
|
||||
+162
-3
@@ -4,7 +4,8 @@ import { FIND_HALVES, HALVES_LEFT as LEFT, HALVES_RIGHT as RIGHT, PASTE_HALVES }
|
||||
/**
|
||||
* 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.
|
||||
* 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. */
|
||||
@@ -80,6 +81,8 @@ export async function shader(t) {
|
||||
|
||||
// 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(
|
||||
@@ -92,7 +95,7 @@ export async function shader(t) {
|
||||
// A dozen entries scroll; searched, the row is the only one and in view.
|
||||
await typeText('Texture color');
|
||||
await clickLast('Texture color');
|
||||
await click(canvas.x + 420, canvas.y + 60, 'right');
|
||||
await click(right, canvas.y + 60, 'right');
|
||||
await clickLast('Output');
|
||||
const madeNodes = async () => (await shaders()).find((s) => s.id === made.id)?.nodes ?? [];
|
||||
check(
|
||||
@@ -100,7 +103,7 @@ export async function shader(t) {
|
||||
(await madeNodes()).map((n) => n.type).join(',') === 'ShaderTextureColor,ShaderOutput',
|
||||
JSON.stringify(await madeNodes()),
|
||||
);
|
||||
await click(canvas.x + 420, canvas.y + 320, 'right');
|
||||
await click(right, canvas.y + 320, 'right');
|
||||
const again = await json('__gm.overlayTexts()');
|
||||
check(
|
||||
'the palette offers the Output only once',
|
||||
@@ -739,6 +742,162 @@ export async function shader(t) {
|
||||
(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 },
|
||||
);
|
||||
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user