Files
feuerwerk/src/editor/AssetTabEditor.ts
T
rootandClaude Fable 5.1 42cafa7543
game-maker / suite (push) Failing after 3m59s
Shader assets: graph-built shaders, the Shader tab and sprite rows from the graph (#62)
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>
2026-09-12 22:41:14 +02:00

150 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}