game-maker / suite (push) Failing after 3m59s
A shader is an asset carrying a node graph (Texture color, Output, Property, Color, Add/Subtract/Multiply/Divide, Mix, Luminance) that compiles to the GLSL fragment of a Pixi filter. The Shader tab reuses the Behavior canvas; a sprite wears a shader through its Shader picker, and the rows below it are the graph's Property nodes. Property schema, resolution and deviation take the project because a sprite's rows now live in another asset. The built-in registry of the prototype is gone; "Adjust colors" ships as a graph in the fixture and the three example games. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
150 lines
5.1 KiB
TypeScript
150 lines
5.1 KiB
TypeScript
import { Graphics } from 'pixi.js';
|
||
import type { Asset } from '../core/model/Asset';
|
||
import { findAsset } from '../core/model/Project';
|
||
import {
|
||
Button,
|
||
Dropdown,
|
||
HStack,
|
||
Label,
|
||
Menu,
|
||
type MenuItem,
|
||
Spacer,
|
||
Theme,
|
||
VStack,
|
||
type Widget,
|
||
} from '../ui';
|
||
import type { EditorState } from './state';
|
||
|
||
/** The canvas each tab wraps: the header's Fit button and the tab's own plumbing need this much. */
|
||
export interface TabCanvas {
|
||
readonly currentAssetId: string;
|
||
show(assetId: string): void;
|
||
fit(): void;
|
||
}
|
||
|
||
export interface AssetTabOptions {
|
||
/** Shown left of the picker, and in the empty state as "No <noun>". */
|
||
noun: string;
|
||
/** Width reserved for the right-hand readout; a stack measures the label once, while empty. */
|
||
infoWidth: number;
|
||
}
|
||
|
||
/**
|
||
* The Behavior, State machine, Texture and Container tabs; each keeps its last asset when the selection moves elsewhere.
|
||
* A subclass must call `mount()`: header and canvas depend on each other, so the base cannot build them.
|
||
*/
|
||
export abstract class AssetTabEditor<
|
||
A extends Asset,
|
||
C extends TabCanvas & Widget = TabCanvas & Widget,
|
||
> extends VStack {
|
||
/** Public for the check scripts. */
|
||
readonly picker: Dropdown;
|
||
protected readonly info: Label;
|
||
/** Assigned by `mount`. Public because the check hooks and the play loop reach for it. */
|
||
canvas!: C;
|
||
private readonly headerBg = new Graphics();
|
||
|
||
constructor(
|
||
protected readonly state: EditorState,
|
||
private readonly options: AssetTabOptions,
|
||
) {
|
||
super();
|
||
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
|
||
this.info = new Label('', { style: 'small', align: 'right' });
|
||
this.info.minWidth = options.infoWidth;
|
||
}
|
||
|
||
/** Builds the header (with `strip` below it) around `canvas`, wires the state events and shows the first asset. */
|
||
protected mount(canvas: C, extras: readonly Widget[] = [], strip?: Widget): void {
|
||
this.canvas = canvas;
|
||
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||
header.minHeight = Theme.controlHeight + Theme.padding * 2;
|
||
header.addChildAt(this.headerBg, 0);
|
||
header.add(new Label(this.options.noun, { style: 'muted' }));
|
||
header.add(this.picker);
|
||
for (const widget of extras) header.add(widget);
|
||
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
|
||
header.add(new Spacer(), 1);
|
||
header.add(this.info);
|
||
|
||
this.add(header);
|
||
if (strip) this.add(strip);
|
||
this.add(canvas, 1);
|
||
|
||
this.state.on('selection', () => this.followSelection());
|
||
this.state.on('project', () => this.refresh());
|
||
this.state.on('load', () => {
|
||
this.canvas.show('');
|
||
this.followSelection();
|
||
});
|
||
this.followSelection();
|
||
}
|
||
|
||
/** The asset currently shown, `''` for none – the check scripts read this. */
|
||
get currentAssetId(): string {
|
||
return this.canvas.currentAssetId;
|
||
}
|
||
|
||
/** The right-hand readout as shown – the check scripts read this. */
|
||
get shownInfo(): string {
|
||
return this.info.text;
|
||
}
|
||
|
||
/** The selection's id for this kind of asset, `''` when the selection is about something else. */
|
||
protected abstract selectedId(): string;
|
||
|
||
protected abstract isMine(asset: Asset): asset is A;
|
||
|
||
/** The right-hand readout; `null` is the empty state. */
|
||
protected abstract infoText(asset: A | null): string;
|
||
|
||
/** Enable/disable whatever `extras` the subclass passed to `mount`. */
|
||
protected syncExtras(_asset: A | null): void {}
|
||
|
||
protected asset(): A | null {
|
||
const asset = findAsset(this.state.project, this.canvas.currentAssetId);
|
||
return asset && this.isMine(asset) ? asset : null;
|
||
}
|
||
|
||
protected candidates(): A[] {
|
||
return this.state.project.assets
|
||
.filter((a): a is A => this.isMine(a))
|
||
.sort((a, b) => a.name.localeCompare(b.name));
|
||
}
|
||
|
||
protected followSelection(): void {
|
||
const id = this.selectedId();
|
||
// An empty id merely means "the selection is about something else"; the tab keeps its subject.
|
||
if (id) this.canvas.show(id);
|
||
this.refresh();
|
||
}
|
||
|
||
protected refresh(): void {
|
||
// A deleted asset leaves the canvas empty; fall back to whatever is left of this kind.
|
||
if (!this.asset()) this.canvas.show(this.candidates()[0]?.id ?? '');
|
||
const asset = this.asset();
|
||
this.picker.setText(asset ? asset.name : `No ${this.options.noun.toLowerCase()}`);
|
||
this.info.setText(asset ? this.infoText(asset) : '');
|
||
this.syncExtras(asset);
|
||
}
|
||
|
||
private openPicker(): void {
|
||
const current = this.canvas.currentAssetId;
|
||
const items: MenuItem[] = this.candidates().map((asset) => ({
|
||
label: asset.id === current ? `● ${asset.name}` : asset.name,
|
||
onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
|
||
}));
|
||
if (items.length === 0)
|
||
items.push({ label: `No ${this.options.noun.toLowerCase()}s yet`, onSelect: () => undefined });
|
||
new Menu(items, { searchable: true }).openBelow(this.picker);
|
||
}
|
||
|
||
override redraw(): void {
|
||
super.redraw();
|
||
this.headerBg.clear();
|
||
const h = Theme.controlHeight + Theme.padding * 2;
|
||
this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
|
||
this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
|
||
}
|
||
}
|