import type { Application } from 'pixi.js'; import { findAsset, findGraphAsset, findObjectHolder } from '../../core/model/Project'; import { Dialog } from '../../ui'; import { activateAsset, deleteQuestion } from '../actions'; import { measuredNodes } from '../graph/measureNodes'; import { type EditorState, nodeSelection, objectSelection } from '../state'; import type { AgentSelection, PlayAction, ToolHost } from './agentTools'; import type { AgentView } from './agentView'; import type { StepTarget } from './commandCatalog'; import { screenshot } from './screenshot'; /** What the agent's tools reach of the editor beyond its state. */ export interface EditorParts { state: EditorState; app: Application; view(): AgentView; showTab(tab: 'scene' | 'game'): void; play: { readonly isPlaying: boolean; play(paused: boolean): void; stop(): void; togglePause(): void }; /** The user's "Follow agent" switch. */ follows(): boolean; } /** How long the user has to answer an agent's question before it counts as a no. */ export const CONFIRM_MS = 110_000; /** * The editor as the agent's tools see it (#429). `wait` tells the relay that an answer will take a * while – the user is being asked something. */ export function editorToolHost(parts: EditorParts, wait: () => void): ToolHost { const { state } = parts; return { view: () => parts.view(), screenshot: (maxWidth) => screenshot(parts.app, maxWidth), project: () => state.project, execute: (command) => state.attempt(command), confirmDeletes: (assetIds) => confirmDeletes(state, assetIds, wait), open: ({ asset, tab }) => { if (asset !== undefined) { if (!findAsset(state.project, asset)) return `There is no asset ${asset}`; activateAsset(state, asset); return null; } if (tab === 'scene' || tab === 'game') { parts.showTab(tab); return null; } return 'Name an asset or the tab (scene or game)'; }, select: (selection) => select(state, selection), play: (action) => play(parts, action), undo: () => { const label = state.commands.undoLabel; if (label !== null) state.undo(); return label; }, redo: () => { const label = state.commands.redoLabel; if (label !== null) state.redo(); return label; }, measure: (assetId) => measuredNodes(state, assetId), // A running game keeps the user's eyes: an edit then goes to the running copy anyway. follows: () => parts.follows() && !parts.play.isPlaying, show: (focus) => show(parts, focus), }; } /** Brings up what an edit worked on: a graph's tab, the scene or container, the asset in the inspector. */ function show(parts: EditorParts, focus: StepTarget): void { const { state } = parts; if (focus.kind === 'asset') { state.select({ kind: 'asset', assetId: focus.asset }); return; } const holder = focus.kind === 'objects' ? focus.in : focus.asset; if (focus.ids.length === 0) { state.openAsset(holder); return; } if (focus.kind === 'nodes') { state.select(nodeSelection(holder, focus.ids, focus.ids[0], [], [])); // A tab that was open already keeps its camera; what was added off to one side comes into it. state.revealNodes(holder, focus.ids); return; } if (findAsset(state.project, holder)?.type === 'scene') parts.showTab('scene'); state.select(objectSelection(holder, focus.ids)); } function select(state: EditorState, selection: AgentSelection): string | null { switch (selection.kind) { case 'none': state.select(null); return null; case 'asset': if (!selection.asset || !findAsset(state.project, selection.asset)) return `There is no asset ${selection.asset}`; state.select({ kind: 'asset', assetId: selection.asset }); return null; case 'objects': { const holder = selection.in ? findObjectHolder(state.project, selection.in) : undefined; if (!holder) return `There is no scene or container ${selection.in}`; const ids = selection.objects ?? []; const unknown = ids.find((id) => !holder.objects.some((object) => object.id === id)); if (ids.length === 0 || unknown !== undefined) return `Name objects of ${holder.name}${unknown ? `; ${unknown} is none` : ''}`; state.select(objectSelection(holder.id, ids)); return null; } case 'nodes': { const holder = selection.asset ? findGraphAsset(state.project, selection.asset) : undefined; if (!holder) return `There is no graph asset ${selection.asset}`; const ids = selection.nodes ?? []; const unknown = ids.find((id) => !holder.graph.nodes.some((node) => node.id === id)); if (ids.length === 0 || unknown !== undefined) return `Name nodes of ${holder.name}${unknown ? `; ${unknown} is none` : ''}`; state.select(nodeSelection(holder.id, ids, ids[0], [], [])); return null; } } } function play(parts: EditorParts, action: PlayAction): string | null { const { state, play: session } = parts; switch (action) { case 'play': if (session.isPlaying) return 'The game is running already'; if (!state.sceneToPlay()) return 'There is no scene to play'; session.play(false); // `play` waits for the physics engine when it is not loaded yet, and starts nothing then. return isRunning(session) ? null : 'The game could not start yet (the physics engine is still loading); try again'; case 'stop': if (!session.isPlaying) return 'The game is not running'; session.stop(); return null; case 'pause': case 'resume': if (!session.isPlaying) return 'The game is not running'; if (state.paused === (action === 'pause')) return `The game is ${state.paused ? 'paused' : 'running'} already`; session.togglePause(); return null; } } function isRunning(session: EditorParts['play']): boolean { return session.isPlaying; } /** One question for all the assets an edit would delete; the user's no, a refusal or the time running out is the answer. */ function confirmDeletes( state: EditorState, assetIds: readonly string[], wait: () => void, ): Promise { const questions = []; for (const id of assetIds) { const question = deleteQuestion(state.project, id); if (!question) return Promise.resolve(`Nothing was changed. There is no asset ${id}`); if ('refusal' in question) return Promise.resolve(`Nothing was changed. ${question.refusal}`); questions.push(question); } const title = questions.length === 1 ? `The agent asks: ${questions[0]!.title}` : `The agent asks: delete ${questions.length} assets?`; const detail = questions .map((q) => (questions.length === 1 ? q.detail : `${q.title} ${q.detail}`)) .join('\n'); wait(); return new Promise((resolve) => { let answered = false; const answer = (refusal: string | null) => { if (answered) return; answered = true; clearTimeout(timer); resolve(refusal); }; const dialog = new Dialog(title, detail, [ { label: 'Cancel', onClick: () => answer('The user said no; nothing was changed.') }, { label: 'Delete', danger: true, onClick: () => answer(null) }, ]); const timer = setTimeout(() => { answer(`The user did not answer within ${CONFIRM_MS / 1000} s; nothing was changed.`); dialog.close(); }, CONFIRM_MS); dialog.openCentered(); }); }