import { Graphics } from 'pixi.js'; import type { BehaviorAsset } from '../../core/model/Asset'; import { findAsset } from '../../core/model/Project'; import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui'; import type { EditorState } from '../state'; import { GraphCanvas } from './GraphCanvas'; /** Room kept for the "N nodes · M links" readout, wide enough for a three-digit graph. */ const INFO_WIDTH = 150; /** * The Graph tab. It follows the selection, but keeps showing the last behavior when the * selection moves elsewhere – clicking a sprite must not empty the graph being edited. */ export class GraphEditor extends VStack { /** The canvas itself: the live redraw while playing and the check scripts reach for it. */ readonly canvas: GraphCanvas; private readonly picker: Dropdown; private readonly info: Label; private readonly addButton: Button; private readonly headerBg = new Graphics(); constructor(private readonly state: EditorState) { super(); 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('Behavior', { style: 'muted' })); this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() }); header.add(this.picker); this.addButton = new Button('Add node…', { onClick: () => this.canvas.addNodeFrom(this.addButton) }); header.add(this.addButton); header.add(new Button('Fit', { onClick: () => this.canvas.fit() })); header.add(new Spacer(), 1); this.info = new Label('', { style: 'small', align: 'right' }); // Reserved, not measured: a stack sizes the label once, from the empty text it was built with. this.info.minWidth = INFO_WIDTH; header.add(this.info); this.canvas = new GraphCanvas(state); this.add(header); this.add(this.canvas, 1); state.on('selection', () => this.followSelection()); state.on('project', () => this.refresh()); // Adding nodes is an edit, and edits made while playing would be thrown away with the run. state.on('mode', () => this.refresh()); state.on('load', () => { this.canvas.show(null); this.followSelection(); }); this.followSelection(); } /** The behavior currently shown – the check scripts read this. */ get currentAssetId(): string | null { return this.canvas.currentAssetId; } /** For the check scripts: the picker's width must not follow the name it happens to show. */ get pickerWidth(): number { return this.picker.w; } private followSelection(): void { const id = this.state.selectedBehaviorId; // Null merely means "the selection is about something else"; the tab keeps its subject. if (id) this.canvas.show(id); this.refresh(); } private asset(): BehaviorAsset | null { const id = this.canvas.currentAssetId; const asset = id ? findAsset(this.state.project, id) : undefined; return asset && asset.type === 'behavior' ? asset : null; } private behaviors(): BehaviorAsset[] { return this.state.project.assets .filter((a): a is BehaviorAsset => a.type === 'behavior') .sort((a, b) => a.name.localeCompare(b.name)); } private openPicker(): void { const current = this.canvas.currentAssetId; const items: MenuItem[] = this.behaviors().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 behaviors yet', onSelect: () => undefined }); new Menu(items, { searchable: true }).openBelow(this.picker); } private refresh(): void { // A deleted behavior leaves the canvas empty; fall back to whatever behavior is left. if (!this.asset()) this.canvas.show(this.behaviors()[0]?.id ?? null); const asset = this.asset(); this.picker.setText(asset ? asset.name : 'No behavior'); const nodes = asset?.graph.nodes.length ?? 0; const links = asset?.graph.edges.length ?? 0; this.info.setText( asset ? `${nodes} ${nodes === 1 ? 'node' : 'nodes'} · ${links} ${links === 1 ? 'link' : 'links'}` : '', ); this.addButton.setEnabled(Boolean(asset) && this.state.mode === 'edit'); } 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); } }