game-maker / suite (push) Successful in 10m58s
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>
405 lines
15 KiB
TypeScript
405 lines
15 KiB
TypeScript
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 };
|
||
}
|
||
}
|