Files
feuerwerk/src/editor/state.ts
T
admin 1e9d21cd5d
game-maker / suite (push) Failing after 11m6s
A game that is more than one scene needs a way from one to the next, so the graph can load one (#39)
Load scene sets a flag on the World like Stop and Restart do; the swap
happens after the tick, and the last wish of a tick wins. Exactly one
thing crosses over: the SharedState, so global variables and global
machines keep what they know while everything local is built anew.
Loading the scene that is already running rebuilds it, which is what
"reset the level" means when the score must survive.

A run starts either from the start scene (a new project field, set from
the asset panel and marked with an arrow in the list) or from whatever
scene is open. The toggle next to Play says which question is asked and
remembers the answer in localStorage: it is a way of working, not part
of the game.

Sounds no longer end because a scene ends. A voice belongs to the
behavior that started it and stops right after On destroy, so the scene
swap is no longer a special case at all. For the enemy that plays its
explosion and destroys itself in the same breath there is now a
detached input on Play sound.

The scene selector stays usable while a run plays, because a double
click in the asset panel already did the same thing unguarded. It moves
what the editor looks at, not what the run plays.
2026-09-15 13:24:26 +02:00

486 lines
18 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 Command, CommandError } from '../core/commands/Command';
import { CommandStack } from '../core/commands/CommandStack';
import type { Asset, SceneAsset } from '../core/model/Asset';
import { findObject } from '../core/model/hierarchy';
import { resolvedHolder } from '../core/model/instance';
import {
createEmptyProject,
findAsset,
findAssetOf,
findGraphAsset,
findObjectHolder,
findScene,
type Project,
startScene,
} from '../core/model/Project';
import { deserializeProject, serializeProject } from '../core/store/serialize';
import { requestRender } from '../ui';
import { consoleLog } from './console/log';
import { type PlayFrom, settings, updateSettings } from './settings';
export type Selection =
| { kind: 'asset'; assetId: string }
/**
* One or more objects of a scene or of a container's template (`holderId` names which).
* `objectId` is the primary one (the last picked – the inspector shows it); `objectIds`
* holds the whole selection, primary included.
*/
| { kind: 'object'; holderId: string; objectId: string; objectIds: string[] }
/** One or more nodes of a behavior's graph, `nodeId` being the primary one (as with objects). */
| { kind: 'node'; assetId: string; nodeId: string; nodeIds: string[] }
/** A frame rectangle inside a texture asset (the Texture tab). */
| { kind: 'frame'; assetId: string; frameId: string }
| null;
/**
* Which list the List tab edits. Deliberately not a `Selection`: a list is not something one
* selects, it is a value inside something already selected, and exactly three places hold one.
* `field` names a list held by a struct field one level down; a struct item of a list needs no
* such step, because the List tab renders it inline.
*/
export type ListSlot = { field?: string } & (
| { kind: 'asset'; assetId: string; key: string }
| { kind: 'object'; holderId: string; objectId: string; key: string }
| { kind: 'node'; assetId: string; nodeId: string; key: string }
);
export function sameSlot(a: ListSlot | null, b: ListSlot | null): boolean {
if (!a || !b) return a === b;
if (a.field !== b.field) return false;
switch (a.kind) {
case 'asset':
return b.kind === 'asset' && b.assetId === a.assetId && b.key === a.key;
case 'object':
return b.kind === 'object' && b.holderId === a.holderId && b.objectId === a.objectId && b.key === a.key;
case 'node':
return b.kind === 'node' && b.assetId === a.assetId && b.nodeId === a.nodeId && b.key === a.key;
}
}
/** Builds an object selection; the last id is the primary one unless given explicitly. */
export function objectSelection(holderId: string, objectIds: readonly string[], primary?: string): Selection {
const picked = pickIds(objectIds, primary);
return picked && { kind: 'object', holderId, objectId: picked.primary, objectIds: picked.ids };
}
/** Builds a node selection; the last id is the primary one unless given explicitly. */
export function nodeSelection(assetId: string, nodeIds: readonly string[], primary?: string): Selection {
const picked = pickIds(nodeIds, primary);
return picked && { kind: 'node', assetId, nodeId: picked.primary, nodeIds: picked.ids };
}
function pickIds(input: readonly string[], primary?: string): { ids: string[]; primary: string } | null {
const ids = [...new Set(input)];
if (ids.length === 0) return null;
return { ids, primary: primary && ids.includes(primary) ? primary : ids[ids.length - 1]! };
}
export type EditorMode = 'edit' | 'play';
export type SaveStatus = 'saved' | 'dirty' | 'saving' | 'error' | 'conflict';
export type EditorEvent =
/** The model changed (command, undo, preview, load). */
| 'project'
/** A different project was loaded. */
| 'load'
| 'selection'
/** The List tab was pointed at another list (or at none). */
| 'list'
/** An asset was opened (double-click, Enter, or just created): its tab should show. */
| 'open'
| 'mode'
| 'save'
| 'notice'
/** The running world advanced a frame (play mode): values may have changed without a command. */
| 'runtime';
type Listener = () => void;
/** How a notice reaches the console panel: as a line, as a red line, or not at all. */
export type NoticeLevel = 'info' | 'error' | 'quiet';
/**
* Single source of truth for the editor. Play mode swaps `project` for a deep clone (same ids)
* with its own undo stack; panels and commands keep working on it, and `exitPlay()` discards it.
*/
export class EditorState {
project: Project = createEmptyProject('', 'Untitled');
revision = 0;
commands = new CommandStack(this.project);
selection: Selection = null;
listSlot: ListSlot | null = null;
mode: EditorMode = 'edit';
/**
* A sub-state of play rather than a third mode: the runtime copy, the keyboard and unsaved edits all
* still apply.
*/
paused = false;
saveStatus: SaveStatus = 'saved';
/** Last user-facing message (errors, hints). */
notice = '';
/** The real (saved) model and its undo stack while `project` is the play-mode copy. */
private edit: { project: Project; commands: CommandStack } | null = null;
private readonly listeners = new Map<EditorEvent, Set<Listener>>();
on(event: EditorEvent, listener: Listener): () => void {
let set = this.listeners.get(event);
if (!set) this.listeners.set(event, (set = new Set()));
set.add(listener);
return () => set.delete(listener);
}
emit(event: EditorEvent): void {
// The one choke point every model change passes: what a listener repaints is drawn even when
// it repaints without going through `invalidate()`.
requestRender();
for (const listener of this.listeners.get(event) ?? []) listener();
}
loadProject(project: Project, revision: number): void {
this.edit = null;
this.mode = 'edit';
this.paused = false;
this.project = project;
this.revision = revision;
this.commands = new CommandStack(project);
this.selection = null;
this.listSlot = null;
this.saveStatus = 'saved';
this.emit('load');
this.emit('project');
this.emit('selection');
this.emit('save');
this.emit('mode');
}
/** Returns the runtime copy; the selection survives because ids are identical. */
enterPlay(): Project {
if (this.mode === 'play') return this.project;
const copy = deserializeProject(serializeProject(this.project), this.project.id);
this.edit = { project: this.project, commands: this.commands };
this.project = copy;
this.commands = new CommandStack(copy);
this.mode = 'play';
this.paused = false;
this.emit('mode');
this.emit('project');
return copy;
}
/** Ignored outside play mode. */
setPaused(paused: boolean): void {
if (this.mode !== 'play' || this.paused === paused) return;
this.paused = paused;
this.emit('mode');
}
exitPlay(): void {
if (this.mode !== 'play') return;
if (this.edit) {
this.project = this.edit.project;
this.commands = this.edit.commands;
this.edit = null;
}
this.mode = 'edit';
this.paused = false;
this.emit('mode');
this.emit('project');
this.validateSelection();
// A save postponed while playing runs now.
this.emit('save');
}
get activeScene(): SceneAsset | undefined {
return findScene(this.project, this.project.activeSceneId);
}
/** The play-mode copy while a run is on, else null – what a scene swap rebuilds its world from. */
get runtimeProject(): Project | null {
return this.mode === 'play' ? this.project : null;
}
/**
* Which scene Play builds its world from. Two answers rather than one, because the project has
* two: where the game begins, and what you are working on this minute. The switch says which
* question Play is asking; without a start scene the two are the same thing anyway.
*/
sceneToPlay(): SceneAsset | undefined {
return this.playFrom === 'active' ? this.activeScene : startScene(this.project);
}
get playFrom(): PlayFrom {
return settings().playFrom;
}
setPlayFrom(playFrom: PlayFrom): void {
if (playFrom === this.playFrom) return;
updateSettings({ playFrom });
this.emit('mode');
}
/**
* Runs a command through the undo stack. Returns false (and sets a notice) when invalid.
* In play mode the command changes the runtime copy only (see `enterPlay`).
*/
execute(command: Command): boolean {
return this.runEdit(() => {
this.commands.execute(command);
return true;
});
}
undo(): void {
this.runEdit(() => this.commands.undo());
}
redo(): void {
this.runEdit(() => this.commands.redo());
}
/**
* Runs one history operation. A `CommandError` is a command rejecting the edit, so it becomes
* a notice; anything else is a real defect and keeps travelling.
*/
private runEdit(operation: () => boolean): boolean {
try {
if (!operation()) return false;
} catch (error) {
if (error instanceof CommandError) {
this.setNotice(error.message);
return false;
}
throw error;
}
this.markDirty();
return true;
}
/**
* The model was mutated directly (live preview while scrubbing). Notifies listeners
* but does not touch the undo stack or the save status – the committing command does.
*/
preview(): void {
this.emit('project');
}
/** The world ticked: panels showing live values refresh (no model event, nothing to save). */
runtimeTick(): void {
this.emit('runtime');
}
/**
* A behavior running outside play mode (see `EditorRun`) wrote into the saved model. The change
* is saved like any other, but nothing was pushed onto the undo stack – it cannot be undone.
*/
runtimeEdit(): void {
this.setSaveStatus('dirty');
this.emit('runtime');
}
private markDirty(): void {
this.validateSelection();
// Play-mode edits live in the runtime copy and are never saved.
if (this.mode === 'edit') this.saveStatus = 'dirty';
this.emit('project');
this.emit('save');
}
/**
* Points the List tab at a list. No tab knows the `Editor`, so this is how the button on a
* list row gets there: the state changes, the editor switches.
*/
openList(slot: ListSlot | null): void {
if (sameSlot(this.listSlot, slot)) {
if (slot) this.emit('list');
return;
}
this.listSlot = slot;
this.emit('list');
}
/**
* Selects the asset and asks for its tab. Selecting alone never switches tabs (a click in the
* Assets panel is a look at the inspector); this is the way a double-click or a fresh asset gets
* there, and like `openList` it works without knowing the `Editor`.
*/
openAsset(assetId: string): void {
this.select({ kind: 'asset', assetId });
this.emit('open');
}
select(selection: Selection): void {
// Test hooks pass the short form `{ kind: 'object', holderId, objectId }` without `objectIds`.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selection?.kind === 'object' && !selection.objectIds)
selection = objectSelection(selection.holderId, [selection.objectId]);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selection?.kind === 'node' && !selection.nodeIds)
selection = nodeSelection(selection.assetId, [selection.nodeId]);
if (sameSelection(this.selection, selection)) return;
this.selection = selection;
this.emit('selection');
}
toggleObject(holderId: string, objectId: string): void {
const s = this.selection;
const current = s?.kind === 'object' && s.holderId === holderId ? s.objectIds : [];
const next = current.includes(objectId)
? current.filter((id) => id !== objectId)
: [...current, objectId];
this.select(objectSelection(holderId, next, current.includes(objectId) ? undefined : objectId));
}
toggleNode(assetId: string, nodeId: string): void {
const s = this.selection;
const current = s?.kind === 'node' && s.assetId === assetId ? s.nodeIds : [];
const held = current.includes(nodeId);
const next = held ? current.filter((id) => id !== nodeId) : [...current, nodeId];
// Emptied by the toggle, the behavior itself stays selected – the graph must not close.
this.select(nodeSelection(assetId, next, held ? undefined : nodeId) ?? { kind: 'asset', assetId });
}
get selectedObjectIds(): string[] {
return this.selection?.kind === 'object' ? this.selection.objectIds : [];
}
get selectedNodeIds(): string[] {
return this.selection?.kind === 'node' ? this.selection.nodeIds : [];
}
/** Drops selected targets that no longer exist (after delete/undo). */
private validateSelection(): void {
const s = this.selection;
if (!s) return;
if (s.kind === 'asset' && !findAsset(this.project, s.assetId)) this.select(null);
if (s.kind === 'object') {
const holder = findObjectHolder(this.project, s.holderId);
const view = holder && resolvedHolder(this.project, holder);
const alive = view ? s.objectIds.filter((id) => findObject(view, id)) : [];
if (alive.length !== s.objectIds.length) this.select(objectSelection(s.holderId, alive, s.objectId));
}
if (s.kind === 'node') {
const asset = findGraphAsset(this.project, s.assetId);
// Fall back to the asset so the graph stays open when its node vanished (undo).
if (!asset) this.select(null);
else {
const alive = s.nodeIds.filter((id) => asset.graph.nodes.some((n) => n.id === id));
if (alive.length !== s.nodeIds.length)
this.select(nodeSelection(s.assetId, alive, s.nodeId) ?? { kind: 'asset', assetId: s.assetId });
}
}
if (s.kind === 'frame') {
const asset = findAssetOf(this.project, s.assetId, 'texture');
// Same fallback as a graph node: the texture stays open when its frame vanished.
if (!asset) this.select(null);
else if (!asset.frames.some((f) => f.id === s.frameId))
this.select({ kind: 'asset', assetId: s.assetId });
}
}
/** The texture asset the current selection refers to (the asset itself or one of its frames). */
get selectedTextureId(): string | null {
const s = this.selection;
if (!s) return null;
if (s.kind !== 'asset' && s.kind !== 'frame') return null;
return findAssetOf(this.project, s.assetId, 'texture')?.id ?? null;
}
get selectedAnimationId(): string | null {
const s = this.selection;
return s?.kind === 'asset' ? (findAssetOf(this.project, s.assetId, 'animation')?.id ?? null) : null;
}
/** The behavior asset the current selection refers to (asset, placed object or node), if any. */
get selectedBehaviorId(): string | null {
return this.selectedGraphId('behavior');
}
/** The state machine the current selection refers to: the asset, or one of its states. */
get selectedStateMachineId(): string | null {
return this.selectedGraphId('statemachine');
}
/** The shader the current selection refers to: the asset, or one of its nodes. */
get selectedShaderId(): string | null {
return this.selectedGraphId('shader');
}
/** The particle asset the selection refers to: the asset, a placed source, or one of its nodes. */
get selectedParticlesId(): string | null {
return this.selectedGraphId('particles');
}
private selectedGraphId(type: 'behavior' | 'statemachine' | 'shader' | 'particles'): string | null {
const s = this.selection;
if (!s) return null;
if (s.kind === 'frame') return null;
// A node names its asset; which tab that is depends on what the asset is.
const asset = s.kind === 'node' ? findAsset(this.project, s.assetId) : this.assetBehind(s);
return asset && asset.type === type ? asset.id : null;
}
/**
* The container the current selection refers to: the asset itself, a placed container, or the
* template being edited – selecting one of its objects keeps the Container tab on it.
*/
get selectedContainerId(): string | null {
const s = this.selection;
if (!s) return null;
if (s.kind === 'object' && findAsset(this.project, s.holderId)?.type === 'container') return s.holderId;
const asset = this.assetBehind(s);
return asset && asset.type === 'container' ? asset.id : null;
}
/** The asset a selection stands for: itself, or the one a selected object was placed from. */
private assetBehind(s: NonNullable<Selection>): Asset | undefined {
if (s.kind === 'asset') return findAsset(this.project, s.assetId);
if (s.kind !== 'object') return undefined;
const holder = findObjectHolder(this.project, s.holderId);
const view = holder && resolvedHolder(this.project, holder);
const assetId = view && findObject(view, s.objectId)?.assetId;
return assetId ? findAsset(this.project, assetId) : undefined;
}
setSaveStatus(status: SaveStatus): void {
if (this.saveStatus === status) return;
this.saveStatus = status;
this.emit('save');
}
/**
* Shows `message` in the toolbar and, unless `quiet`, keeps it in the console panel. Quiet is
* for the play status lines, which state a mode rather than report an event.
*/
setNotice(message: string, level: NoticeLevel = 'info'): void {
this.notice = message;
if (message !== '' && level !== 'quiet') consoleLog.push(level, message);
this.emit('notice');
}
}
/** Whether two selections mean the same thing (same kind, same ids, same order). */
export function sameSelection(a: Selection, b: Selection): boolean {
if (a === b) return true;
if (!a || !b || a.kind !== b.kind) return false;
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
if (a.kind === 'object' && b.kind === 'object') {
return (
a.holderId === b.holderId &&
a.objectId === b.objectId &&
a.objectIds.length === b.objectIds.length &&
a.objectIds.every((id, i) => id === b.objectIds[i])
);
}
if (a.kind === 'node' && b.kind === 'node') {
return (
a.assetId === b.assetId &&
a.nodeId === b.nodeId &&
a.nodeIds.length === b.nodeIds.length &&
a.nodeIds.every((id, i) => id === b.nodeIds[i])
);
}
if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId;
return false;
}