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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.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.
|
// to do it, and its states move just the same.
|
||||||
if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
|
if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
|
||||||
if (this.tab === 'particles') this.particlesEditor.tick();
|
if (this.tab === 'particles') this.particlesEditor.tick();
|
||||||
|
if (this.tab === 'shader') this.shaderEditor.tick();
|
||||||
if (this.movesByItself()) requestRender();
|
if (this.movesByItself()) requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What changes without anyone touching it, and therefore cannot be woken by an event: the
|
* 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 {
|
private movesByItself(): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -728,6 +729,7 @@ export class Editor implements PlayHost {
|
|||||||
(this.viewport.visible && this.viewport.animates) ||
|
(this.viewport.visible && this.viewport.animates) ||
|
||||||
(this.gameView.visible && this.gameView.animates) ||
|
(this.gameView.visible && this.gameView.animates) ||
|
||||||
this.tab === 'particles' ||
|
this.tab === 'particles' ||
|
||||||
|
(this.tab === 'shader' && this.shaderEditor.animates) ||
|
||||||
(this.tab === 'animation' && this.animationEditor.isPlaying) ||
|
(this.tab === 'animation' && this.animationEditor.isPlaying) ||
|
||||||
(this.tab === 'tile' && this.tileEditor.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),
|
center: () => centreOf(editor.shaderEditor.canvas),
|
||||||
snapping: () => editor.shaderEditor.canvas.snapEnabled,
|
snapping: () => editor.shaderEditor.canvas.snapEnabled,
|
||||||
snapButton: () => snapButton(editor.shaderEditor.snap),
|
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. */
|
/** The console panel: its lines as kept, its drawn height, and the divider above it. */
|
||||||
console: {
|
console: {
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import type { Asset, ParticlesAsset } from '../../core/model/Asset';
|
|||||||
import { LIFETIME_KEY, TIME_SCALE_KEY } from '../../core/properties/particles';
|
import { LIFETIME_KEY, TIME_SCALE_KEY } from '../../core/properties/particles';
|
||||||
import { defaultsFor } from '../../core/properties/PropertySchema';
|
import { defaultsFor } from '../../core/properties/PropertySchema';
|
||||||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
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 { AssetTabEditor } from '../AssetTabEditor';
|
||||||
import { GraphCanvas } from '../graph/GraphCanvas';
|
import { GraphCanvas } from '../graph/GraphCanvas';
|
||||||
|
import { PreviewSplit } from '../PreviewSplit';
|
||||||
import { SnapToggle } from '../graph/SnapToggle';
|
import { SnapToggle } from '../graph/SnapToggle';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import { ParticlePreview } from './ParticlePreview';
|
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. */
|
/** How far the time slider reaches: three lifetimes, so a particle's whole arc fits with room after. */
|
||||||
const SPAN_LIFETIMES = 3;
|
const SPAN_LIFETIMES = 3;
|
||||||
const MIN_SPAN = 2;
|
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. */
|
/** The button reads "Hold" or "Run"; a floor keeps the row from moving when it changes. */
|
||||||
const HOLD_WIDTH = 52;
|
const HOLD_WIDTH = 52;
|
||||||
|
|
||||||
@@ -35,11 +33,9 @@ export class ParticlesEditor extends AssetTabEditor<ParticlesAsset, GraphCanvas>
|
|||||||
onClick: () => this.toggleHold(),
|
onClick: () => this.toggleHold(),
|
||||||
});
|
});
|
||||||
private readonly timeLabel = new Label('0.00 s', { style: 'small' });
|
private readonly timeLabel = new Label('0.00 s', { style: 'small' });
|
||||||
readonly splitter = new Splitter('x');
|
/** Assigned by `body`; public for the check scripts, which drag it. */
|
||||||
private readonly side = new VStack();
|
splitter!: Splitter;
|
||||||
private row = new HStack();
|
|
||||||
private span = MIN_SPAN;
|
private span = MIN_SPAN;
|
||||||
private previewWidth = 280;
|
|
||||||
|
|
||||||
constructor(state: EditorState) {
|
constructor(state: EditorState) {
|
||||||
super(state, { infoWidth: INFO_WIDTH });
|
super(state, { infoWidth: INFO_WIDTH });
|
||||||
@@ -71,22 +67,12 @@ export class ParticlesEditor extends AssetTabEditor<ParticlesAsset, GraphCanvas>
|
|||||||
bar.add(buttons);
|
bar.add(buttons);
|
||||||
bar.add(time);
|
bar.add(time);
|
||||||
|
|
||||||
this.side.add(bar);
|
const side = new VStack();
|
||||||
this.side.add(this.preview, 1);
|
side.add(bar);
|
||||||
this.side.minWidth = this.previewWidth;
|
side.add(this.preview, 1);
|
||||||
// Draggable like the console: how much of the tab is graph and how much is picture is a matter
|
const split = new PreviewSplit(canvas, side);
|
||||||
// of what is being worked on, and on a narrow window a fixed panel eats the canvas.
|
this.splitter = split.splitter;
|
||||||
this.splitter.onDrag = (delta) => {
|
return split;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called every frame while the tab is showing: the panel is a moving picture. */
|
/** 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 { Asset, ShaderAsset } from '../../core/model/Asset';
|
||||||
|
import type { FlatDef } from '../../core/properties/PropertySchema';
|
||||||
import { shaderParamDefs } from '../../core/properties/shaders';
|
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 { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||||
import { AssetTabEditor } from '../AssetTabEditor';
|
import { AssetTabEditor } from '../AssetTabEditor';
|
||||||
import { GraphCanvas } from '../graph/GraphCanvas';
|
import { GraphCanvas } from '../graph/GraphCanvas';
|
||||||
import { SnapToggle } from '../graph/SnapToggle';
|
import { SnapToggle } from '../graph/SnapToggle';
|
||||||
|
import { PreviewSplit } from '../PreviewSplit';
|
||||||
import type { EditorState } from '../state';
|
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. */
|
/** Room kept for "N properties", or for the reason the graph does not compile. */
|
||||||
const INFO_WIDTH = 220;
|
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
|
* The nodes of one shader asset, on the graph canvas the Behavior tab uses, and beside them a picture
|
||||||
* on every change (see `ShaderLibrary`); the readout says whether it did.
|
* 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> {
|
export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
|
||||||
readonly snap = new SnapToggle(() => this.canvas);
|
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) {
|
constructor(state: EditorState) {
|
||||||
super(state, { infoWidth: INFO_WIDTH });
|
super(state, { infoWidth: INFO_WIDTH });
|
||||||
|
this.preview = new ShaderPreview(state);
|
||||||
this.mount(new GraphCanvas(state, 'shader'), this.snap.widgets);
|
this.mount(new GraphCanvas(state, 'shader'), this.snap.widgets);
|
||||||
// Play swaps in the runtime copy of the project, and back.
|
// Play swaps in the runtime copy of the project, and back.
|
||||||
state.on('mode', () => this.refresh());
|
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 {
|
override selectedId(): string {
|
||||||
@@ -40,5 +156,85 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
|
|||||||
|
|
||||||
protected override syncExtras(asset: ShaderAsset | null): void {
|
protected override syncExtras(asset: ShaderAsset | null): void {
|
||||||
this.snap.setEnabled(Boolean(asset));
|
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
|
* 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
|
* 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. */
|
/** (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.
|
// 28b. Right-click offers shader nodes only; Texture color and Output placed from the palette.
|
||||||
const canvas = await json('__gm.shader.canvasRect()');
|
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');
|
await click(canvas.x + 80, canvas.y + 60, 'right');
|
||||||
const offered = await json('__gm.overlayTexts()');
|
const offered = await json('__gm.overlayTexts()');
|
||||||
check(
|
check(
|
||||||
@@ -92,7 +95,7 @@ export async function shader(t) {
|
|||||||
// A dozen entries scroll; searched, the row is the only one and in view.
|
// A dozen entries scroll; searched, the row is the only one and in view.
|
||||||
await typeText('Texture color');
|
await typeText('Texture color');
|
||||||
await clickLast('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');
|
await clickLast('Output');
|
||||||
const madeNodes = async () => (await shaders()).find((s) => s.id === made.id)?.nodes ?? [];
|
const madeNodes = async () => (await shaders()).find((s) => s.id === made.id)?.nodes ?? [];
|
||||||
check(
|
check(
|
||||||
@@ -100,7 +103,7 @@ export async function shader(t) {
|
|||||||
(await madeNodes()).map((n) => n.type).join(',') === 'ShaderTextureColor,ShaderOutput',
|
(await madeNodes()).map((n) => n.type).join(',') === 'ShaderTextureColor,ShaderOutput',
|
||||||
JSON.stringify(await madeNodes()),
|
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()');
|
const again = await json('__gm.overlayTexts()');
|
||||||
check(
|
check(
|
||||||
'the palette offers the Output only once',
|
'the palette offers the Output only once',
|
||||||
@@ -739,6 +742,162 @@ export async function shader(t) {
|
|||||||
(await json('__gm.animates()')).scene === false,
|
(await json('__gm.animates()')).scene === false,
|
||||||
JSON.stringify(await json('__gm.animates()')),
|
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 });
|
await assetProps({ texture: '', color: 0x4080ff, shader: made.id });
|
||||||
|
|
||||||
// 28j. Back to None: no filter at all, the plain colour returns bit for bit.
|
// 28j. Back to None: no filter at all, the plain colour returns bit for bit.
|
||||||
|
|||||||
Reference in new issue
Block a user