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>(); 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): 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; }