Files
feuerwerk/src/editor/Editor.ts
T
adminandClaude Opus 5 7babf88636
game-maker / suite (push) Successful in 10m58s
A state change is something the graph can react to, so the machine announces it (#53, #54, #55, #57)
Four issues that all reach for the same line: how the current state is
read and written. Splitting them into four commits would mean four
states of the tree that do not compile, so they arrive together.

#55 start state — the machine asset carries an optional `start` holding
a State node's id. Right-click a state to set or remove it, a ring in
the node's title says which one it is. A dead id (the state was deleted)
reads as "no start", so nothing has to clean up after a deletion.

#53 entered/exited — `On state entered` and `On state exited`, one exec
output per state. Every change now goes through a single `setState`
that reports it; BehaviorSystem delivers it after the exec flow that
caused it, `exited` of the old before `entered` of the new, round after
round until the queue is empty, with a cap that stops a ping-pong graph
with an endless-loop error. A local machine tells the instance that
switched, a global one tells every instance holding a matching event
node. The start state fires `entered` too: per instance for a local
machine, once per run for a global one.

#57 reading — `Current state` with `name` for showing and `id` for
comparing, `Is in state` with one boolean per state. Neither has a
`run`, so `read` learned `def` and `lookup`, and the catalog test knows
this third kind of node.

#54 seeing it — during a run the state machine tab marks every state
some instance is holding faintly, and the one held by the selected
scene object's instance strongly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 06:18:20 +02:00

405 lines
15 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 type { Application } from 'pixi.js';
import type { AssetType } from '../core/model/AssetTypes';
import { findAsset, findObjectHolder } from '../core/model/Project';
import type { ProjectStore } from '../core/store/ProjectStore';
import type { FontStore } from '../core/store/FontStore';
import type { TextureStore } from '../core/store/TextureStore';
import { gameClock } from '../core/time';
import {
clamp,
HStack,
isRendering,
Panel,
renderCount,
requestRender,
Splitter,
Theme,
ui,
VStack,
type Widget,
} from '../ui';
import { TabStrip, type TabPlace } from './TabStrip';
import { ConsolePanel } from './console/ConsolePanel';
import { GameViewport } from './viewport/GameViewport';
import { SceneViewport } from './viewport/SceneViewport';
import { attachAutosave } from './autosave';
import { ContainerEditor } from './container/ContainerEditor';
import { EditorRun } from './EditorRun';
import { GraphEditor } from './graph/GraphEditor';
import { Inspector } from './inspector/Inspector';
import { ListEditor } from './list/ListEditor';
import { AnimationEditor } from './animation/AnimationEditor';
import { AssetPanel } from './panels/AssetPanel';
import { HierarchyPanel } from './panels/HierarchyPanel';
import { Toolbar } from './panels/Toolbar';
import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
import { ProjectSession } from './ProjectSession';
import { ParticlesEditor } from './particles/ParticlesEditor';
import { ShaderEditor } from './shader/ShaderEditor';
import { StateMachineEditor } from './statemachine/StateMachineEditor';
import { installShortcuts } from './shortcuts';
import type { EditorState } from './state';
import { TextureEditor } from './texture/TextureEditor';
import { installFileImport } from './fileImport';
export type CentreTab =
| 'scene'
| 'graph'
| 'statemachine'
| 'shader'
| 'particles'
| 'game'
| 'texture'
| 'animation'
| 'container'
| 'list';
const TAB_ORDER = [
'scene',
'game',
'graph',
'statemachine',
'shader',
'particles',
'texture',
'animation',
'container',
'list',
] as const;
const TAB_LABELS: Record<CentreTab, string> = {
scene: 'Scene',
game: 'Game',
graph: 'Behavior',
statemachine: 'State machine',
shader: 'Shader',
particles: 'Particles',
texture: 'Texture',
animation: 'Animation',
container: 'Container',
list: 'List',
};
/** Where an opened asset goes; a placeable one (sprite, camera, text, collider) has no tab of its own. */
const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
scene: 'scene',
behavior: 'graph',
statemachine: 'statemachine',
shader: 'shader',
particles: 'particles',
texture: 'texture',
animation: 'animation',
container: 'container',
};
const MIN_LEFT_WIDTH = 160;
const MAX_LEFT_WIDTH = 500;
const MIN_RIGHT_WIDTH = 200;
const MAX_RIGHT_WIDTH = 600;
// The floor is the console's own header: dragged all the way shut it stays visible to drag open.
const MIN_CONSOLE_HEIGHT = Panel.HEADER_HEIGHT;
const MAX_CONSOLE_HEIGHT = 600;
/**
* Composes toolbar, panels, viewports and graph editor into the one application that edits and
* plays. What it owns itself is the layout, the tab strip and the frame ticker; running the
* project is `PlaySession`, loading it is `ProjectSession`.
*/
export class Editor implements PlayHost {
/** Scene tab: the editable model view (live positions while playing). */
readonly viewport: SceneViewport;
/** Game tab: the running world through its camera. */
readonly gameView: GameViewport;
readonly graphEditor: GraphEditor;
readonly stateMachineEditor: StateMachineEditor;
readonly shaderEditor: ShaderEditor;
readonly particlesEditor: ParticlesEditor;
readonly textureEditor: TextureEditor;
readonly animationEditor: AnimationEditor;
readonly containerEditor: ContainerEditor;
readonly listEditor: ListEditor;
readonly hierarchy: HierarchyPanel;
readonly assets: AssetPanel;
readonly inspector: Inspector;
readonly console = new ConsolePanel();
tab: CentreTab = 'scene';
/** Frame rate, refreshed twice a second; null until the first measurement. */
fps: number | null = null;
private readonly play: PlaySession;
private readonly editorRun: EditorRun;
private readonly projects: ProjectSession;
private readonly leftColumn: VStack;
readonly leftSplitter = new Splitter('x');
readonly rightSplitter = new Splitter('x');
readonly consoleSplitter = new Splitter('y');
private readonly workspace: HStack;
private readonly centre: VStack;
private readonly tabs: TabStrip<CentreTab>;
private readonly panes: Record<CentreTab, Widget>;
private readonly toolbar: Toolbar;
/** Drawn-frame count at the last measurement, not a counter of its own. */
private fpsFrames = 0;
private fpsElapsed = 0;
private leftWidth = 250;
private rightWidth = 350;
private consoleHeight = 300;
constructor(
app: Application,
root: VStack,
private readonly state: EditorState,
store: ProjectStore,
textures: TextureStore,
fonts: FontStore,
) {
this.viewport = new SceneViewport(state);
this.gameView = new GameViewport(state);
this.graphEditor = new GraphEditor(state);
this.stateMachineEditor = new StateMachineEditor(state);
this.shaderEditor = new ShaderEditor(state);
this.particlesEditor = new ParticlesEditor(state);
this.textureEditor = new TextureEditor(state);
this.animationEditor = new AnimationEditor(state);
this.containerEditor = new ContainerEditor(state);
this.listEditor = new ListEditor(state);
this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state);
this.play = new PlaySession(state, this);
this.editorRun = new EditorRun(state, {
// The game view is the one place where no key means anything to the editor, so that is where
// a behavior may have them – unless a widget in it is focused and wants them itself.
focused: () => this.tab === 'game' && ui().focus.focused === null,
onWorld: (world) => this.gameView.setEditorWorld(world),
});
this.projects = new ProjectSession(state, store, () => {
if (this.play.isPlaying) this.play.stop();
});
this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId);
this.graphEditor.canvas.timeProvider = () => this.play.time;
// Only one of the two ever runs a given machine, for the reason the live variables are one call.
this.stateMachineEditor.canvas.stateProvider = (assetId) => {
const playing = this.play.machineStates(assetId);
return playing.length > 0 ? playing : this.editorRun.machineStates(assetId);
};
// Only one of the two ever runs a given asset: playing stops the editor run (see `EditorRun.tick`).
this.inspector.liveVariables = (assetId) =>
this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId);
this.toolbar = new Toolbar(state, {
openProjectMenu: (anchor) => this.projects.openProjectMenu(anchor),
openSceneMenu: (anchor) => this.projects.openSceneMenu(anchor),
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
togglePause: () => this.play.togglePause(),
});
root.add(this.toolbar);
this.workspace = new HStack();
this.leftColumn = new VStack({ gap: 1 });
this.assets = new AssetPanel(state, textures, fonts);
this.leftColumn.add(this.assets, 1);
installFileImport(app.canvas, { state, textures, fonts });
this.leftColumn.add(this.hierarchy, 1);
this.centre = new VStack();
this.tabs = new TabStrip(TAB_ORDER, TAB_LABELS, (tab) => this.showTab(tab));
this.tabs.minHeight = Theme.controlHeight + 8;
// Written out per tab rather than derived, because a `Record<CentreTab, …>` is what makes
// adding a tab a type error everywhere it has to be handled instead of a silent omission.
this.panes = {
scene: this.viewport,
game: this.gameView,
graph: this.graphEditor,
statemachine: this.stateMachineEditor,
shader: this.shaderEditor,
particles: this.particlesEditor,
texture: this.textureEditor,
animation: this.animationEditor,
container: this.containerEditor,
list: this.listEditor,
};
this.centre.add(this.tabs);
for (const tab of TAB_ORDER) {
this.centre.add(this.panes[tab], 1);
this.panes[tab].visible = tab === 'scene';
}
// The console sits below whichever tab is showing, so it is a sibling of the panes, not of
// the workspace: switching tabs must not move it.
this.centre.add(this.consoleSplitter);
this.centre.add(this.console);
this.console.minHeight = this.consoleHeight;
this.consoleSplitter.onDrag = (delta) => {
this.consoleHeight = clamp(this.consoleHeight - delta, MIN_CONSOLE_HEIGHT, MAX_CONSOLE_HEIGHT);
this.console.minHeight = this.consoleHeight;
this.centre.relayout();
};
this.leftColumn.minWidth = this.leftWidth;
this.inspector.minWidth = this.rightWidth;
this.leftSplitter.onDrag = (delta) => {
this.leftWidth = Math.max(MIN_LEFT_WIDTH, Math.min(MAX_LEFT_WIDTH, this.leftWidth + delta));
this.leftColumn.minWidth = this.leftWidth;
this.workspace.relayout();
};
this.rightSplitter.onDrag = (delta) => {
this.rightWidth = Math.max(MIN_RIGHT_WIDTH, Math.min(MAX_RIGHT_WIDTH, this.rightWidth - delta));
this.inspector.minWidth = this.rightWidth;
this.workspace.relayout();
};
this.workspace.add(this.leftColumn);
this.workspace.add(this.leftSplitter);
this.workspace.add(this.centre, 1);
this.workspace.add(this.rightSplitter);
this.workspace.add(this.inspector);
root.add(this.workspace, 1);
root.resize(app.screen.width, app.screen.height);
attachAutosave(state, store);
installShortcuts(state, {
play: () => this.play.play(),
stop: () => this.play.stop(),
togglePause: () => this.play.togglePause(),
});
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
// A node, a frame and a template object exist only in their own tab, so selecting one
// switches to it. Selecting an asset never does: that is what opening it (double-click,
// Enter, creating it) is for – see the `open` event below.
state.on('selection', () => {
const s = state.selection;
if (s?.kind === 'node') {
const tab = state.selectedStateMachineId
? 'statemachine'
: state.selectedShaderId
? 'shader'
: state.selectedParticlesId
? 'particles'
: 'graph';
if (this.tab !== tab) this.showTab(tab);
}
if (s?.kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
if (
s?.kind === 'object' &&
this.tab !== 'container' &&
findAsset(state.project, s.holderId)?.type === 'container'
)
this.showTab('container');
});
state.on('open', () => {
const s = state.selection;
const tab =
s?.kind === 'asset' ? TAB_OF_ASSET[findAsset(state.project, s.assetId)?.type ?? 'sprite'] : undefined;
if (tab) this.showTab(tab);
});
// Opening a list is a request to look at it, so this switches even when the slot is unchanged.
state.on('list', () => {
if (state.listSlot) this.showTab('list');
else if (this.tab === 'list') this.showTab('scene');
});
state.on('project', () => {
this.play.syncFromModel();
this.editorRun.invalidate();
});
state.on('load', () => this.editorRun.invalidate());
state.on('mode', () => this.editorRun.invalidate());
}
showTab(tab: CentreTab): void {
this.tab = tab;
// The hierarchy lists what the visible tab edits; every other tab is about the scene.
this.hierarchy.showObjectsOf(
tab === 'container'
? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId)
: () => this.state.activeScene,
);
for (const key of TAB_ORDER) this.panes[key].visible = key === tab;
this.tabs.setActive(tab);
this.centre.relayout();
ui().focus.validate();
}
/**
* Global x-extent of the tab buttons in the row, from the widget box – not `getBounds()`, which
* on an untoggled ghost tab sees nothing but the label (the background is not drawn at all).
*/
tabRow(): { shown: TabPlace<CentreTab>[]; hidden: CentreTab[] } {
return { shown: this.tabs.places(), hidden: this.tabs.hidden() };
}
boot(): Promise<void> {
return this.projects.boot();
}
showGame(): void {
this.showTab('game');
}
leaveGame(): void {
if (this.tab === 'game') this.showTab('scene');
}
private tick(dt: number): void {
this.measureFps(dt);
// One step for everything below: a world gets exactly the seconds the particle, shader and
// animation clocks moved, so the global time scale cannot pull them apart.
const step = gameClock.advance(dt, this.state.paused);
this.play.tick(step);
this.editorRun.tick(step);
this.viewport.animate();
this.gameView.animate();
this.console.refresh();
if (this.tab === 'animation') this.animationEditor.tick(step);
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
// 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.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.
*/
private movesByItself(): boolean {
return (
this.play.isPlaying ||
this.editorRun.isRunning ||
(this.viewport.visible && this.viewport.animates) ||
(this.gameView.visible && this.gameView.animates) ||
this.tab === 'particles' ||
(this.tab === 'animation' && this.animationEditor.isPlaying)
);
}
/**
* Counts drawn frames over the time the renderer was awake. Idle seconds are not slow seconds:
* since the editor stopped drawing what nobody changed, counting them would drag the readout to
* zero while everything is fine. It answers "how smooth is it while it moves", and holds its
* last answer in between.
*/
private measureFps(dt: number): void {
if (!isRendering()) return;
this.fpsElapsed += dt;
if (this.fpsElapsed < 0.5) return;
const drawn = renderCount();
this.fps = Math.round((drawn - this.fpsFrames) / this.fpsElapsed);
this.fpsFrames = drawn;
this.fpsElapsed = 0;
this.toolbar.setFps(this.fps);
}
/** Read-only view of the running world for the check scripts. */
worldSnapshot(): WorldSnapshot | null {
return this.play.snapshot();
}
/** What is running without Play, for the check scripts. */
editorRunInfo(): { running: boolean; time: number; behaviors: string[]; keyboard: boolean } {
return this.editorRun.debugInfo();
}
/** Play state for the check scripts; `time` is the world clock a pause has to freeze. */
playInfo(): { playing: boolean; paused: boolean; time: number } {
return { playing: this.play.isPlaying, paused: this.play.isPaused, time: this.play.time };
}
}