import type { KeyClipboard } from '../core/commands/animationCommands'; import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/animation'; import { type Command, CommandError } from '../core/commands/Command'; import { CommandStack } from '../core/commands/CommandStack'; import type { Asset, SceneAsset } from '../core/model/Asset'; import { findAnnotation } from '../core/graph'; 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). * `annotationIds` holds the groups and comments caught alongside them: a rubber band spans * whatever lies under it, and the two sorts move and die together from then on. */ | { kind: 'node'; assetId: string; nodeId: string; nodeIds: string[]; annotationIds: string[] } /** A frame rectangle inside a texture asset (the Texture tab). */ | { kind: 'frame'; assetId: string; frameId: string } /** One or more groups or comments of a graph, `annotationId` being the primary one. */ | { kind: 'annotation'; assetId: string; annotationId: string; annotationIds: string[] } /** * A row of an animation's timeline (the Animation tab), and the keys picked on it or on other * rows: `keys` is empty when the row itself was clicked. `trackId` is the row the inspector * describes – of several keys, the one picked last. */ | { kind: 'track'; assetId: string; trackId: string; keys: KeyRef[] } | 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 } ); 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, annotationIds: readonly string[] = [], ): Selection { const picked = pickIds(nodeIds, primary); return ( picked && { kind: 'node', assetId, nodeId: picked.primary, nodeIds: picked.ids, annotationIds: [...new Set(annotationIds)], } ); } /** Builds an annotation selection; the last id is the primary one unless given explicitly. */ export function annotationSelection( assetId: string, annotationIds: readonly string[], primary?: string, ): Selection { const picked = pickIds(annotationIds, primary); return picked && { kind: 'annotation', assetId, annotationId: picked.primary, annotationIds: picked.ids }; } /** * What a rubber band leaves selected: nodes win the kind when it caught any, so the inspector keeps * showing a node where it did before, and a band over boxes alone still selects them. */ export function graphSelection( assetId: string, nodeIds: readonly string[], annotationIds: readonly string[], ): Selection { return ( nodeSelection(assetId, nodeIds, undefined, annotationIds) ?? annotationSelection(assetId, annotationIds) ); } /** * Builds a track selection. Without keys it is the row itself; with keys the row is the one of the * key picked last, unless `trackId` says otherwise. Duplicate keys collapse to one. */ export function trackSelection(assetId: string, keys: readonly KeyRef[], trackId?: string): Selection { const unique: KeyRef[] = []; for (const key of keys) if (!hasKey(unique, key)) unique.push(key); const last = unique[unique.length - 1]; const row = trackId ?? last?.trackId; return row ? { kind: 'track', assetId, trackId: row, keys: unique } : null; } 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]! }; } /** * Select leaves the scene view as it is – the map is picked up and moved like any other object. * The others paint: brush the chosen tile cell by cell, erase clears, rect fills the box a drag * spans, fill the connected cells of one tile (#133). Pick paints nothing – it takes the tile of the * cell clicked. Painting is a mode on purpose; a map one cannot move would be worse than a click. */ export type TilemapTool = 'select' | 'brush' | 'erase' | 'rect' | 'fill' | 'pick'; /** * How the scene view shows the layers of the selected tilemap next to the one painted on (#138): * all as they are, the others faded, or the painted one alone. A way of looking while painting, * so it leaves the model – and with it the game – untouched. */ export type TilemapFocus = 'all' | 'dim' | 'solo'; export interface TilemapPaint { tool: TilemapTool; tileId: string; layerId: string; focus: TilemapFocus; } 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 = ''; /** Keys copied in the Animation tab (#102). Lives for the session, across animations and projects. */ keyClipboard: KeyClipboard | null = null; /** * What the scene view paints a tilemap with (#128): the tool, the tile and the layer it goes * on. Editor state, not model state – like the picked tile of the Animation tab, it is a way of * working rather than something the project remembers. */ tilemapPaint: TilemapPaint = { tool: 'select', tileId: '', layerId: '', focus: 'all' }; /** 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]); // A graph selection carries two lists, and a short form may leave out either – `{ nodeId }` // alone, or `{ nodeIds }` without the boxes. Filling both here keeps every reader of them // from having to ask whether they are there. if (selection?.kind === 'node') selection = nodeSelection( selection.assetId, // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition selection.nodeIds ?? [selection.nodeId], selection.nodeId, // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition selection.annotationIds ?? [], ); if (selection?.kind === 'annotation') selection = annotationSelection( selection.assetId, // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition selection.annotationIds ?? [selection.annotationId], selection.annotationId, ); // The short form `{ trackId }` names a row without keys. if (selection?.kind === 'track') // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition selection = trackSelection(selection.assetId, selection.keys ?? [], selection.trackId); 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]; // Boxes caught alongside stay held: the toggle speaks about this node, not about them. const boxes = s?.kind === 'node' && s.assetId === assetId ? s.annotationIds : []; // Emptied by the toggle, the behavior itself stays selected – the graph must not close. this.select( nodeSelection(assetId, next, held ? undefined : nodeId, boxes) ?? annotationSelection(assetId, boxes) ?? { 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 : []; } /** The groups and comments selected, whether alongside nodes or on their own. */ get selectedAnnotationIds(): string[] { const s = this.selection; if (s?.kind === 'node') return s.annotationIds; return s?.kind === 'annotation' ? s.annotationIds : []; } /** 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)); const boxes = s.annotationIds.filter((id) => findAnnotation(asset.graph, id)); if (alive.length !== s.nodeIds.length || boxes.length !== s.annotationIds.length) this.select( nodeSelection(s.assetId, alive, s.nodeId, boxes) ?? annotationSelection(s.assetId, boxes) ?? { kind: 'asset', assetId: s.assetId }, ); } } if (s.kind === 'annotation') { const asset = findGraphAsset(this.project, s.assetId); // Same fallback as a node: the graph stays open when the box it held vanished (undo). if (!asset) this.select(null); else { const alive = s.annotationIds.filter((id) => findAnnotation(asset.graph, id)); if (alive.length !== s.annotationIds.length) this.select( annotationSelection(s.assetId, alive, s.annotationId) ?? { 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 }); } if (s.kind === 'track') { const asset = findAssetOf(this.project, s.assetId, 'animation'); if (!asset) this.select(null); else { // A key that moved or died leaves the selection; a row that died takes its keys with it. const alive = s.keys.filter((key) => findKey(asset, key)); const rowAlive = findTrack(asset, s.trackId) !== undefined; if (alive.length !== s.keys.length || !rowAlive) this.select( trackSelection(s.assetId, alive, rowAlive ? s.trackId : undefined) ?? { 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; } /** The animation the selection refers to: the asset, or a row or key of its timeline. */ get selectedAnimationId(): string | null { const s = this.selection; if (s?.kind !== 'asset' && s?.kind !== 'track') return null; return findAssetOf(this.project, s.assetId, 'animation')?.id ?? null; } /** The tile the selection refers to; only the asset itself, it has nothing inside to pick. */ get selectedTileId(): string | null { const s = this.selection; if (s?.kind !== 'asset') return null; return findAssetOf(this.project, s.assetId, 'tile')?.id ?? null; } /** The timeline row the selection is about; undefined when it is about something else. */ get selectedTrackId(): string | undefined { return this.selection?.kind === 'track' ? this.selection.trackId : undefined; } /** The keys picked in the Animation tab, none when the selection is about something else. */ get selectedKeys(): KeyRef[] { return this.selection?.kind === 'track' ? this.selection.keys : []; } /** 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. A group or comment // names it the same way, so selecting one keeps its graph on screen. const asset = s.kind === 'node' || s.kind === 'annotation' ? 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. */ /** Changes what the scene view paints with; the inspector marks it and the view repaints. */ setTilemapPaint(patch: Partial): void { this.tilemapPaint = { ...this.tilemapPaint, ...patch }; this.emit('selection'); } 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 && sameIds(a.nodeIds, b.nodeIds) && sameIds(a.annotationIds, b.annotationIds) ); } if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId; if (a.kind === 'track' && b.kind === 'track') return ( a.assetId === b.assetId && a.trackId === b.trackId && a.keys.length === b.keys.length && a.keys.every((key, i) => sameKey(key, b.keys[i]!)) ); if (a.kind === 'annotation' && b.kind === 'annotation') return ( a.assetId === b.assetId && a.annotationId === b.annotationId && sameIds(a.annotationIds, b.annotationIds) ); return false; } /** Same ids in the same order – the order carries which one was picked last. */ function sameIds(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && a.every((id, i) => id === b[i]); }