Let an AI agent see the open editor through MCP (#429)
Claude Code should be able to work inside the user's open editor tab. This first stage connects the two and lets the agent look: what the user sees and a picture of it. Changing the project comes in stage 2. - An agent token per account opens only /mcp, no API route. The dialog "Agent tokens..." in the account menu creates it once, shows the ready "claude mcp add" line and revokes it. The server keeps only its sha256 (api/lib/AgentTokens.php). - Wrong tokens count into the sign-in brake from #427. Changing the password, renaming or deleting an account ends its tokens. - The relay (tools/mcp/) is a small Node service without packages, WebSocket server included. It runs in the container feuerwerk-mcp because PHP-FPM cannot hold connections. Whether a token or a tab ticket is valid is still decided by the PHP API, through two routes that answer only with the relay key. - The tab joins over WebSocket on the same origin with the normal session, an Origin check and a one-time ticket. It connects only after "Agent" is switched on in the account menu. One tab per account is active, a new one takes over. Guests get nothing. - Tools: get_view (project, tab, document, selection with names, camera, play state, open dialog) and screenshot. The resource feuerwerk://view can be subscribed. Claude Code does not act on that reliably, so a changed view is also noted on the next answer. - A badge next to the account menu shows the connected agents. It counts only agents with an open stream or a request in the last two minutes. - CI starts the relay in front of php -S (tools/ci/serve.sh), and e2e 71 runs the whole path in the browser. Found on the live test: the badge showed four agents because old sessions of revoked tokens were still counted. Tests: - Gate on atx: 316 files, 4888 tests - relay protocol test with fake API and fake tabs: 23/23 - get_view, tools, socketUrl: 16/16 - PHP agents 29, rights 103 checks - e2e 71 on atx 10/10, against the live site 10/10, no CSP violation - real Claude Code against the live site: get_view answered - ZAP against /mcp: nothing at High or Medium - every new check fails when the guard it covers is taken out (6) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
edef2b6323
commit
48ec722660
32 files changed
+3324
-12
No files matched your search
@@ -14,6 +14,14 @@ export function sitePath(pagePath: string | undefined): string {
|
||||
/** The API's one entry point. */
|
||||
export const API_ENDPOINT = `${SITE_PATH}api/index.php`;
|
||||
|
||||
/** The MCP relay (#429): agents post to it, the editor's tab opens its WebSocket there. */
|
||||
export const MCP_PATH = `${SITE_PATH}mcp`;
|
||||
|
||||
/** A WebSocket address on the page's own host: `wss:` on an `https:` page. */
|
||||
export function socketUrl(path: string, page: { protocol: string; host: string }): string {
|
||||
return `${page.protocol === 'https:' ? 'wss:' : 'ws:'}//${page.host}${path}`;
|
||||
}
|
||||
|
||||
/** Where the uploaded files of one kind are served, e.g. `dataPath('textures')`. */
|
||||
export function dataPath(kind: string): string {
|
||||
return `${SITE_PATH}data/${kind}`;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { apiRequest, jsonBody } from './http';
|
||||
|
||||
/** An agent token as listed (#429): everything but the token itself, which is shown once. */
|
||||
export interface AgentToken {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Unix seconds, like everything the API stamps. */
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
usedAt: number | null;
|
||||
}
|
||||
|
||||
export interface MadeAgentToken extends AgentToken {
|
||||
token: string;
|
||||
}
|
||||
|
||||
/** The account's agent tokens, and the tickets its tab gets onto the MCP relay with. */
|
||||
export interface AgentStore {
|
||||
list(): Promise<AgentToken[]>;
|
||||
create(name: string, days: number): Promise<MadeAgentToken>;
|
||||
revoke(id: string): Promise<void>;
|
||||
ticket(): Promise<string>;
|
||||
}
|
||||
|
||||
/** The API's refusal as its bare sentence (a name too long, too many tokens). */
|
||||
function refusal(status: number, data: unknown): Error | undefined {
|
||||
const message = (data as { error?: unknown } | null)?.error;
|
||||
return status >= 400 && status < 500 && typeof message === 'string' ? new Error(message) : undefined;
|
||||
}
|
||||
|
||||
export class HttpAgentStore implements AgentStore {
|
||||
constructor(private readonly endpoint: string) {}
|
||||
|
||||
list(): Promise<AgentToken[]> {
|
||||
return apiRequest<AgentToken[]>(this.endpoint, 'GET', 'agent-tokens', {}, refusal);
|
||||
}
|
||||
|
||||
create(name: string, days: number): Promise<MadeAgentToken> {
|
||||
return apiRequest<MadeAgentToken>(
|
||||
this.endpoint,
|
||||
'POST',
|
||||
'agent-tokens',
|
||||
jsonBody({ name, days }),
|
||||
refusal,
|
||||
);
|
||||
}
|
||||
|
||||
async revoke(id: string): Promise<void> {
|
||||
await apiRequest<void>(this.endpoint, 'DELETE', `agent-tokens/${id}`, {}, refusal);
|
||||
}
|
||||
|
||||
async ticket(): Promise<string> {
|
||||
return (await apiRequest<{ ticket: string }>(this.endpoint, 'POST', 'agent/ticket', {}, refusal)).ticket;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
import type { AuthStore, SignedIn } from '../core/store/AuthStore';
|
||||
import { HttpAgentStore } from '../core/store/AgentStore';
|
||||
import { type DemoStore, type DemoSummary, HttpDemoStore } from '../core/store/DemoStore';
|
||||
import { HttpAuthStore } from '../core/store/HttpAuthStore';
|
||||
import { HttpFileStore, HttpVideoStore } from '../core/store/HttpFileStore';
|
||||
@@ -153,6 +154,7 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
||||
role,
|
||||
users: new HttpUsersStore(API_ENDPOINT),
|
||||
demos: admin ? demos : undefined,
|
||||
agents: new HttpAgentStore(API_ENDPOINT),
|
||||
logOut,
|
||||
// Straight through to the store: the server checks the old password, and the session
|
||||
// this tab holds is the one it keeps – so there is nothing to do here afterwards.
|
||||
|
||||
+185
-1
@@ -3,7 +3,9 @@ import type { Asset } from '../core/model/Asset';
|
||||
import { findAsset, findObjectHolder, ROUTING_ID } from '../core/model/Project';
|
||||
import { assetTitle } from '../core/music/embedded';
|
||||
import { mixOf, videoBusesOf } from '../core/music/mixer';
|
||||
import type { AgentStore, MadeAgentToken } from '../core/store/AgentStore';
|
||||
import type { Role } from '../core/store/AuthStore';
|
||||
import { MCP_PATH, socketUrl } from '../core/site';
|
||||
import type { DemoStore } from '../core/store/DemoStore';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
import { pluginsOf } from '../core/store/projectPlugins';
|
||||
@@ -19,10 +21,12 @@ import {
|
||||
clamp,
|
||||
HStack,
|
||||
isRendering,
|
||||
Label,
|
||||
lastRenderTime,
|
||||
Menu,
|
||||
type MenuEntry,
|
||||
Panel,
|
||||
Popup,
|
||||
renderCount,
|
||||
requestRender,
|
||||
Splitter,
|
||||
@@ -112,6 +116,11 @@ import { RoutingEditor } from './routing/RoutingEditor';
|
||||
import { CompressorEditor } from './soundfx/CompressorEditor';
|
||||
import { EqEditor } from './soundfx/EqEditor';
|
||||
import { NODE_TAB_KINDS, nodeOfTab, nodeTabLabel, type NodeTabKind, nodeTabOf } from './soundfx/nodeTabs';
|
||||
import { AgentLink } from './agent/AgentLink';
|
||||
import { runTool } from './agent/agentTools';
|
||||
import { AgentTokensDialog } from './agent/AgentTokensDialog';
|
||||
import { type AgentView, agentView, viewSummary } from './agent/agentView';
|
||||
import { screenshot } from './agent/screenshot';
|
||||
|
||||
export type CentreTab =
|
||||
| 'scene'
|
||||
@@ -164,6 +173,8 @@ export interface EditorAccount {
|
||||
users: UsersStore;
|
||||
/** An administrator's: making the demo copy of the open project (#428). */
|
||||
demos?: DemoStore;
|
||||
/** The agent tokens and the relay's tickets (#429); a guest has none. */
|
||||
agents?: AgentStore;
|
||||
logOut: () => void;
|
||||
/** Rejects with `WrongCredentialsError` when the current password does not match (#182). */
|
||||
changePassword: (current: string, next: string) => Promise<void>;
|
||||
@@ -178,6 +189,15 @@ type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'routing' | NodeTab
|
||||
|
||||
const FIXED_TABS = ['scene', 'game'] as const;
|
||||
|
||||
/** `sessionStorage` key: the agent was on in this browser tab (#429). */
|
||||
const AGENT_KEY = 'feuerwerk.agent';
|
||||
|
||||
/** A dialog's bold first line, or a menu's first entry: what tells one popup from another. */
|
||||
function popupTitle(popup: Popup): string {
|
||||
const first = popup.content.children.find((child) => child instanceof Label);
|
||||
return first instanceof Label ? first.text : '';
|
||||
}
|
||||
|
||||
const PANE_ORDER = [
|
||||
'scene',
|
||||
'game',
|
||||
@@ -361,6 +381,9 @@ export class Editor implements PlayHost {
|
||||
private readonly passwordDialog = dialogSlot<PasswordDialog>();
|
||||
private readonly generalDialog = dialogSlot<GeneralSettingsDialog>();
|
||||
private readonly recordingDialog = dialogSlot<EditorRecordingDialog>();
|
||||
private readonly agentTokensDialog = dialogSlot<AgentTokensDialog>();
|
||||
/** The tab's end of the MCP relay (#429); null for a guest. */
|
||||
readonly agent: AgentLink | null;
|
||||
/** Drawn-frame count at the last measurement, not a counter of its own. */
|
||||
private fpsFrames = 0;
|
||||
private fpsElapsed = 0;
|
||||
@@ -495,9 +518,11 @@ export class Editor implements PlayHost {
|
||||
togglePause: () => this.play.togglePause(),
|
||||
step: () => this.play.step(),
|
||||
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
|
||||
stopAgent: () => this.switchAgent(false),
|
||||
},
|
||||
account.guest === true,
|
||||
);
|
||||
this.agent = account.agents ? this.makeAgentLink(app, account.agents) : null;
|
||||
root.add(this.toolbar);
|
||||
|
||||
this.workspace = new HStack();
|
||||
@@ -894,16 +919,172 @@ export class Editor implements PlayHost {
|
||||
onSelect: () => {
|
||||
const dialog = this.passwordDialog.open(
|
||||
new PasswordDialog(account.changePassword, () =>
|
||||
this.state.setNotice('Password changed – your other sessions were signed out'),
|
||||
this.state.setNotice('Password changed – your other sessions and agent tokens were ended'),
|
||||
),
|
||||
);
|
||||
dialog.focusFirstField();
|
||||
},
|
||||
},
|
||||
...this.agentEntries(account),
|
||||
{ separator: true },
|
||||
{ label: 'Logout', onSelect: () => account.logOut() },
|
||||
]).openBelow(anchor);
|
||||
}
|
||||
|
||||
/** The agent's switch and its tokens, in the account menu (#429). */
|
||||
private agentEntries(account: EditorAccount): MenuEntry[] {
|
||||
const link = this.agent;
|
||||
const agents = account.agents;
|
||||
if (!link || !agents) return [];
|
||||
return [
|
||||
{ separator: true },
|
||||
{ label: 'Agent', checked: () => link.on, onSelect: () => this.switchAgent(!link.on) },
|
||||
{ label: 'Agent tokens…', onSelect: () => void this.openAgentTokens(agents) },
|
||||
];
|
||||
}
|
||||
|
||||
private makeAgentLink(app: Application, agents: AgentStore): AgentLink {
|
||||
const link = new AgentLink(socketUrl(MCP_PATH, location), {
|
||||
ticket: () => agents.ticket(),
|
||||
view: () => {
|
||||
const view = this.agentView();
|
||||
return { view, summary: viewSummary(view) };
|
||||
},
|
||||
summary: () => viewSummary(this.agentView(false)),
|
||||
run: (tool, args) =>
|
||||
runTool(tool, args, {
|
||||
view: () => this.agentView(),
|
||||
screenshot: (maxWidth) => screenshot(app, maxWidth),
|
||||
}),
|
||||
replaced: () => {
|
||||
sessionStorage.removeItem(AGENT_KEY);
|
||||
this.state.setNotice('Agent switched off: another tab of your account has it now');
|
||||
},
|
||||
});
|
||||
link.onChange = () => this.syncAgentBadge();
|
||||
return link;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the agent on or off. Kept for this browser tab alone, so a reload – a deployment –
|
||||
* comes back the way it was, and another tab does not take over by itself.
|
||||
*/
|
||||
switchAgent(on: boolean): void {
|
||||
if (!this.agent) return;
|
||||
if (on) {
|
||||
sessionStorage.setItem(AGENT_KEY, '1');
|
||||
this.agent.start();
|
||||
} else {
|
||||
sessionStorage.removeItem(AGENT_KEY);
|
||||
this.agent.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private syncAgentBadge(): void {
|
||||
const link = this.agent;
|
||||
if (!link?.on) {
|
||||
this.toolbar.showAgent(null);
|
||||
return;
|
||||
}
|
||||
const off = ' Click to switch it off.';
|
||||
if (link.status === 'on') {
|
||||
this.toolbar.showAgent(
|
||||
link.agents > 0 ? `Agent · ${link.agents}` : 'Agent on',
|
||||
`An agent may work in this tab (${link.agents} connected).${off}`,
|
||||
);
|
||||
} else if (link.status === 'retrying') {
|
||||
this.toolbar.showAgent('Agent offline', `The agent relay is out of reach; trying again.${off}`);
|
||||
} else {
|
||||
this.toolbar.showAgent('Agent…', `Connecting to the agent relay.${off}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** "Agent tokens…": the list, and the token just made when there is one (shown once). */
|
||||
private async openAgentTokens(agents: AgentStore, made: MadeAgentToken | null = null): Promise<void> {
|
||||
const reopen = (next: MadeAgentToken | null = null) => void this.openAgentTokens(agents, next);
|
||||
const attempt = async (work: () => Promise<void>) => {
|
||||
try {
|
||||
await work();
|
||||
} catch (error) {
|
||||
this.state.setNotice(
|
||||
`Agent tokens: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'error',
|
||||
);
|
||||
reopen();
|
||||
}
|
||||
};
|
||||
await attempt(async () => {
|
||||
const tokens = await agents.list();
|
||||
const dialog = this.agentTokensDialog.open(
|
||||
new AgentTokensDialog(tokens, made, `${location.origin}${MCP_PATH}`, {
|
||||
onCreate: (name, days) =>
|
||||
void attempt(async () => {
|
||||
const token = await agents.create(name, days);
|
||||
this.state.setNotice(`Agent token "${token.name}" made – copy it now`);
|
||||
reopen(token);
|
||||
}),
|
||||
onRevoke: (token) =>
|
||||
void attempt(async () => {
|
||||
await agents.revoke(token.id);
|
||||
this.state.setNotice(`Agent token "${token.name}" revoked`);
|
||||
reopen();
|
||||
}),
|
||||
onCopy: (text, what) =>
|
||||
void navigator.clipboard.writeText(text).then(
|
||||
() => this.state.setNotice(`${what} copied`),
|
||||
() => this.state.setNotice(`${what} could not be copied – select it and press Ctrl+C`, 'error'),
|
||||
),
|
||||
}),
|
||||
);
|
||||
if (!made) dialog.focusName();
|
||||
});
|
||||
}
|
||||
|
||||
/** The agent's badge in the toolbar, null while hidden (test hook). */
|
||||
agentBadge(): string | null {
|
||||
return this.toolbar.agentBadge();
|
||||
}
|
||||
|
||||
/** The open "Agent tokens" dialog, for the check scripts. */
|
||||
agentTokens(): AgentTokensDialog | null {
|
||||
return this.agentTokensDialog.current();
|
||||
}
|
||||
|
||||
/**
|
||||
* What the user sees, for the agent (#429). `withCamera` is off for the line the link compares
|
||||
* twice a second: the camera is not news.
|
||||
*/
|
||||
agentView(withCamera = true): AgentView {
|
||||
const { state } = this;
|
||||
const view = agentView({
|
||||
project: state.project,
|
||||
mode: state.mode,
|
||||
paused: state.paused,
|
||||
saveStatus: state.saveStatus,
|
||||
tab: { id: this.tab, label: this.tabLabel(this.tab) },
|
||||
document: this.documents.active,
|
||||
windows: [...this.floats].map(([kind, float]) => ({ kind, document: float.doc })),
|
||||
selection: state.selection,
|
||||
popups: ui()
|
||||
.overlay.children.filter((child): child is Popup => child instanceof Popup && child.visible)
|
||||
.map((popup) => ({ kind: popup instanceof Menu ? 'menu' : 'dialog', title: popupTitle(popup) })),
|
||||
});
|
||||
const camera = withCamera ? this.camera() : undefined;
|
||||
return camera ? { ...view, camera } : view;
|
||||
}
|
||||
|
||||
private tabLabel(tab: TabId): string {
|
||||
return tab.startsWith('plugin:') ? tab.slice('plugin:'.length) : TAB_LABELS[tab as CentreTab];
|
||||
}
|
||||
|
||||
/** The camera of the tab showing, where it has one: the scene, a container, a graph. */
|
||||
private camera(): Record<string, number> | undefined {
|
||||
if (this.tab === 'scene') return this.viewport.camera();
|
||||
const editor = (this.assetEditors as Partial<Record<TabId, AssetTabEditor<Asset>>>)[this.tab];
|
||||
const canvas = editor?.canvas as { camera?: () => Record<string, number> } | undefined;
|
||||
return typeof canvas?.camera === 'function' ? canvas.camera() : undefined;
|
||||
}
|
||||
|
||||
/** The open "Change password" dialog, for the check scripts. */
|
||||
accountDialog(): PasswordDialog | null {
|
||||
return this.passwordDialog.current();
|
||||
@@ -1373,6 +1554,7 @@ export class Editor implements PlayHost {
|
||||
|
||||
async boot(): Promise<void> {
|
||||
await this.projects.boot();
|
||||
if (sessionStorage.getItem(AGENT_KEY) === '1') this.agent?.start();
|
||||
if (this.guest) {
|
||||
this.state.setNotice(
|
||||
'You are trying a demo as a guest: change anything, play it, record it – nothing is saved. Sign in from the menu at the far right to keep your work.',
|
||||
@@ -1395,6 +1577,8 @@ export class Editor implements PlayHost {
|
||||
this.autosave.stop();
|
||||
if (this.play.isPlaying) this.play.stop();
|
||||
this.editorRecorder.stop();
|
||||
// Signed out, the next account in this tab starts without the agent.
|
||||
this.switchAgent(false);
|
||||
this.editorRun.shutDown();
|
||||
await this.plugins?.stopAll();
|
||||
for (const cleanup of this.cleanups.splice(0).reverse()) cleanup();
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { failure, type ToolResult } from './agentTools';
|
||||
|
||||
/** Where the link stands: off, on its way, on the relay, or waiting to try again. */
|
||||
export type AgentStatus = 'off' | 'connecting' | 'on' | 'retrying';
|
||||
|
||||
/** What the link needs of the editor. */
|
||||
export interface AgentLinkHost {
|
||||
/** A one-minute ticket from the API, got with this tab's session. */
|
||||
ticket(): Promise<string>;
|
||||
/** What the user sees, and its one line (see `viewSummary`). */
|
||||
view(): { view: object; summary: string };
|
||||
/** The one line alone, cheap enough to ask twice a second. */
|
||||
summary(): string;
|
||||
run(tool: string, args: Record<string, unknown>): Promise<ToolResult>;
|
||||
/** Another tab of the account switched the agent on; this one is off now. */
|
||||
replaced(): void;
|
||||
}
|
||||
|
||||
/** How often the view is looked at for a change the agent should hear about. */
|
||||
const VIEW_POLL_MS = 500;
|
||||
const MAX_RETRY_MS = 30_000;
|
||||
/** The relay's close codes (tools/mcp/relay.mjs). */
|
||||
const REPLACED = 4000;
|
||||
|
||||
/**
|
||||
* The editor's end of the MCP relay (#429): a WebSocket that the agent's tool calls come in on.
|
||||
* It is on only while the user has switched it on, and tries again after a lost connection – a
|
||||
* deployment restarts nothing here, but a network hiccup or a relay restart would end it.
|
||||
*/
|
||||
export class AgentLink {
|
||||
status: AgentStatus = 'off';
|
||||
/** Agents on the relay for this account, as the relay last said. */
|
||||
agents = 0;
|
||||
onChange: () => void = () => {};
|
||||
private socket: WebSocket | null = null;
|
||||
private wanted = false;
|
||||
private failures = 0;
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lastSummary = '';
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly host: AgentLinkHost,
|
||||
) {}
|
||||
|
||||
get on(): boolean {
|
||||
return this.wanted;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.wanted) return;
|
||||
this.wanted = true;
|
||||
void this.connect();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.wanted = false;
|
||||
this.halt();
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
socket?.close(1000, 'switched off');
|
||||
this.agents = 0;
|
||||
this.set('off');
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
this.set('connecting');
|
||||
let ticket: string;
|
||||
try {
|
||||
ticket = await this.host.ticket();
|
||||
} catch (error) {
|
||||
console.warn('[agent] No ticket for the relay:', error);
|
||||
this.later();
|
||||
return;
|
||||
}
|
||||
if (!this.wanted) return;
|
||||
const socket = new WebSocket(this.url);
|
||||
this.socket = socket;
|
||||
socket.onopen = () => socket.send(JSON.stringify({ type: 'hello', ticket }));
|
||||
socket.onmessage = (event) => {
|
||||
if (this.socket === socket) void this.receive(socket, JSON.parse(String(event.data)) as RelayMessage);
|
||||
};
|
||||
socket.onclose = (event) => {
|
||||
if (this.socket !== socket) return;
|
||||
this.socket = null;
|
||||
this.halt();
|
||||
this.agents = 0;
|
||||
if (event.code === REPLACED) {
|
||||
this.wanted = false;
|
||||
this.set('off');
|
||||
this.host.replaced();
|
||||
return;
|
||||
}
|
||||
if (this.wanted) this.later();
|
||||
};
|
||||
}
|
||||
|
||||
private async receive(socket: WebSocket, message: RelayMessage): Promise<void> {
|
||||
switch (message.type) {
|
||||
case 'welcome':
|
||||
this.failures = 0;
|
||||
this.set('on');
|
||||
this.lastSummary = '';
|
||||
this.look(socket);
|
||||
this.pollTimer = setInterval(() => this.look(socket), VIEW_POLL_MS);
|
||||
return;
|
||||
case 'agents':
|
||||
this.agents = message.count ?? 0;
|
||||
this.onChange();
|
||||
return;
|
||||
case 'call': {
|
||||
let result: ToolResult;
|
||||
try {
|
||||
result = await this.host.run(message.tool ?? '', message.args ?? {});
|
||||
} catch (error) {
|
||||
result = failure(`The editor failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (socket.readyState === WebSocket.OPEN)
|
||||
socket.send(JSON.stringify({ type: 'result', id: message.id, ...result }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends the view along when its line changed since the last time. */
|
||||
private look(socket: WebSocket): void {
|
||||
const summary = this.host.summary();
|
||||
if (summary === this.lastSummary || socket.readyState !== WebSocket.OPEN) return;
|
||||
this.lastSummary = summary;
|
||||
const { view } = this.host.view();
|
||||
socket.send(JSON.stringify({ type: 'view', view, summary }));
|
||||
}
|
||||
|
||||
private later(): void {
|
||||
this.set('retrying');
|
||||
const wait = Math.min(MAX_RETRY_MS, 1000 * 2 ** this.failures++);
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
if (this.wanted) void this.connect();
|
||||
}, wait);
|
||||
}
|
||||
|
||||
private halt(): void {
|
||||
if (this.pollTimer !== null) clearInterval(this.pollTimer);
|
||||
if (this.retryTimer !== null) clearTimeout(this.retryTimer);
|
||||
this.pollTimer = null;
|
||||
this.retryTimer = null;
|
||||
}
|
||||
|
||||
private set(status: AgentStatus): void {
|
||||
this.status = status;
|
||||
this.onChange();
|
||||
}
|
||||
}
|
||||
|
||||
interface RelayMessage {
|
||||
type: string;
|
||||
id?: number;
|
||||
tool?: string;
|
||||
args?: Record<string, unknown>;
|
||||
count?: number;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { AgentToken, MadeAgentToken } from '../../core/store/AgentStore';
|
||||
import { Button, HStack, Label, Popup, Spacer, TextField, Theme, ui } from '../../ui';
|
||||
|
||||
const WIDTH = 520;
|
||||
const LIFETIMES = [
|
||||
{ days: 30, label: '30 days' },
|
||||
{ days: 90, label: '90 days' },
|
||||
{ days: 365, label: '1 year' },
|
||||
] as const;
|
||||
|
||||
/** `2026-10-17`: the day is enough for a token that lasts months. */
|
||||
function day(seconds: number): string {
|
||||
return new Date(seconds * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** What one token is: when it was made, when it ends and when an agent last used it. */
|
||||
export function tokenLine(token: AgentToken): string {
|
||||
const used = token.usedAt === null ? 'never used' : `last used ${day(token.usedAt)}`;
|
||||
return `made ${day(token.createdAt)} · expires ${day(token.expiresAt)} · ${used}`;
|
||||
}
|
||||
|
||||
/** The line that sets the editor up in Claude Code, with the token in it. */
|
||||
export function claudeCommand(endpoint: string, token: string): string {
|
||||
return `claude mcp add --transport http feuerwerk ${endpoint} --header "Authorization: Bearer ${token}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Agent tokens…" in the account menu (#429): the account's tokens with Revoke, and a new one made
|
||||
* from a name and a lifetime. A token made is shown here once, with the line that sets it up in
|
||||
* Claude Code; the server keeps only its hash. Every action closes the dialog and hands the choice
|
||||
* on, and the caller opens it again on the new state, like the Release dialog.
|
||||
*/
|
||||
export class AgentTokensDialog extends Popup {
|
||||
private readonly nameField: TextField;
|
||||
private days: number = LIFETIMES[1].days;
|
||||
private readonly made: MadeAgentToken | null;
|
||||
private readonly command: string | null;
|
||||
|
||||
constructor(
|
||||
private readonly tokens: readonly AgentToken[],
|
||||
made: MadeAgentToken | null,
|
||||
endpoint: string,
|
||||
actions: {
|
||||
onCreate(name: string, days: number): void;
|
||||
onRevoke(token: AgentToken): void;
|
||||
onCopy(text: string, what: string): void;
|
||||
},
|
||||
) {
|
||||
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
|
||||
this.minWidth = WIDTH;
|
||||
this.made = made;
|
||||
this.command = made && claudeCommand(endpoint, made.token);
|
||||
this.content.add(new Label('Agent tokens', { style: 'bodyBold' }));
|
||||
this.content.add(
|
||||
new Label(
|
||||
'A token lets an agent such as Claude Code work in this editor while "Agent" is switched on in ' +
|
||||
'the account menu. It opens nothing else.',
|
||||
{ style: 'muted', wrap: true },
|
||||
),
|
||||
);
|
||||
|
||||
if (made && this.command) {
|
||||
this.content.add(
|
||||
new Label(`"${made.name}" – copy it now, it is not shown again:`, { style: 'warning' }),
|
||||
);
|
||||
this.content.add(this.copyRow(made.token, 'Copy token', () => actions.onCopy(made.token, 'Token')));
|
||||
this.content.add(new Label('Set it up in Claude Code:', { style: 'muted' }));
|
||||
const command = this.command;
|
||||
this.content.add(this.copyRow(command, 'Copy command', () => actions.onCopy(command, 'Command')));
|
||||
}
|
||||
|
||||
if (tokens.length === 0) this.content.add(new Label('No tokens yet.', { style: 'muted' }));
|
||||
for (const token of tokens) {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
row.add(new Label(token.name));
|
||||
row.add(new Label(tokenLine(token), { style: 'small' }), 1);
|
||||
row.add(
|
||||
new Button('Revoke', { variant: 'danger', onClick: this.closeThen(() => actions.onRevoke(token)) }),
|
||||
);
|
||||
this.content.add(row);
|
||||
}
|
||||
|
||||
const create = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
this.nameField = new TextField('', { placeholder: 'Name, e.g. Claude Code', width: 200 });
|
||||
create.add(this.nameField, 1);
|
||||
const lifetimes = LIFETIMES.map(({ days, label }) => {
|
||||
const button = new Button(label, { toggle: true });
|
||||
button.setToggled(days === this.days);
|
||||
return { days, button };
|
||||
});
|
||||
for (const { days, button } of lifetimes) {
|
||||
button.onClick = () => {
|
||||
this.days = days;
|
||||
for (const other of lifetimes) other.button.setToggled(other.days === days);
|
||||
};
|
||||
create.add(button);
|
||||
}
|
||||
const make = () => {
|
||||
const name = this.nameField.value.trim();
|
||||
if (name !== '') this.closeThen(() => actions.onCreate(name, this.days))();
|
||||
};
|
||||
this.nameField.onSubmit = make;
|
||||
create.add(new Button('New token', { variant: 'primary', onClick: make }));
|
||||
this.content.add(create);
|
||||
|
||||
const end = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
end.add(new Spacer(), 1);
|
||||
end.add(new Button('Close', { onClick: this.closeThen() }));
|
||||
this.content.add(end);
|
||||
}
|
||||
|
||||
focusName(): void {
|
||||
ui().focus.focus(this.nameField);
|
||||
}
|
||||
|
||||
/** What the dialog shows, for the check scripts. */
|
||||
info(): { tokens: string[]; made: string | null; command: string | null } {
|
||||
return {
|
||||
tokens: this.tokens.map((token) => token.name),
|
||||
made: this.made?.token ?? null,
|
||||
command: this.command,
|
||||
};
|
||||
}
|
||||
|
||||
private copyRow(text: string, caption: string, onCopy: () => void): HStack {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.add(new TextField(text, { readOnly: true, width: WIDTH - 130 }), 1);
|
||||
row.add(new Button(caption, { onClick: onCopy }));
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { AgentView } from './agentView';
|
||||
|
||||
/** What a tool answers, as the relay hands it on to the agent (MCP's `CallToolResult`). */
|
||||
export interface ToolResult {
|
||||
content: ({ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string })[];
|
||||
isError?: boolean;
|
||||
structuredContent?: object;
|
||||
}
|
||||
|
||||
/** What the tools need of the editor. */
|
||||
export interface ToolHost {
|
||||
view(): AgentView;
|
||||
/** The editor as drawn, as a base64 PNG at most `maxWidth` pixels wide. */
|
||||
screenshot(maxWidth: number): Promise<{ data: string; width: number; height: number }>;
|
||||
}
|
||||
|
||||
const DEFAULT_SCREENSHOT_WIDTH = 1280;
|
||||
|
||||
export function failure(text: string): ToolResult {
|
||||
return { content: [{ type: 'text', text }], isError: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one of the tools of `tools.json` (#429). The relay has checked the name against the same
|
||||
* list; the arguments are checked here, where their meaning is known.
|
||||
*/
|
||||
export async function runTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
host: ToolHost,
|
||||
): Promise<ToolResult> {
|
||||
switch (name) {
|
||||
case 'get_view': {
|
||||
const view = host.view();
|
||||
return { content: [{ type: 'text', text: JSON.stringify(view) }], structuredContent: view };
|
||||
}
|
||||
case 'screenshot': {
|
||||
const maxWidth = args.maxWidth ?? DEFAULT_SCREENSHOT_WIDTH;
|
||||
if (typeof maxWidth !== 'number' || !Number.isInteger(maxWidth) || maxWidth < 320 || maxWidth > 3840) {
|
||||
return failure('maxWidth has to be a whole number from 320 to 3840');
|
||||
}
|
||||
const shot = await host.screenshot(maxWidth);
|
||||
return {
|
||||
content: [
|
||||
{ type: 'image', data: shot.data, mimeType: 'image/png' },
|
||||
{ type: 'text', text: `The editor as the user sees it, ${shot.width}x${shot.height} pixels.` },
|
||||
],
|
||||
};
|
||||
}
|
||||
default:
|
||||
return failure(`This editor has no tool ${name}; reload it to get the newest.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
findAsset,
|
||||
findGraphAsset,
|
||||
findObjectHolder,
|
||||
type Project,
|
||||
ROUTING_ID,
|
||||
} from '../../core/model/Project';
|
||||
import type { EditorMode, SaveStatus, Selection } from '../state';
|
||||
|
||||
/** What the editor tells about itself for `get_view` (#429); `agentView` makes the answer of it. */
|
||||
export interface ViewFacts {
|
||||
project: Project;
|
||||
mode: EditorMode;
|
||||
paused: boolean;
|
||||
saveStatus: SaveStatus;
|
||||
/** The tab showing, by its id, and its caption. */
|
||||
tab: { id: string; label: string };
|
||||
/** The document of that tab (`asset:<id>`, `values:<key>`, …), null for Scene, Game or a plugin's tab. */
|
||||
document: string | null;
|
||||
/** Tabs in windows of their own (#371). */
|
||||
windows: { kind: string; document: string }[];
|
||||
selection: Selection;
|
||||
/** Menus and dialogs open over the editor, topmost last. */
|
||||
popups: { kind: 'menu' | 'dialog'; title: string }[];
|
||||
}
|
||||
|
||||
export interface AssetRef {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection with names: an asset is `asset`, everything else lies `in` an asset. Objects and
|
||||
* nodes are listed with their names and types, other kinds keep the ids of the selection as they are.
|
||||
*/
|
||||
export interface SelectionView {
|
||||
kind: string;
|
||||
asset?: AssetRef;
|
||||
in?: AssetRef;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AgentView {
|
||||
project: { id: string; name: string };
|
||||
tab: { id: string; label: string };
|
||||
document: { id: string; asset?: AssetRef } | null;
|
||||
windows: { kind: string; document: string }[];
|
||||
selection: SelectionView | null;
|
||||
play: 'editing' | 'playing' | 'paused';
|
||||
saved: SaveStatus;
|
||||
popups: { kind: 'menu' | 'dialog'; title: string }[];
|
||||
/** The camera of the tab showing, where it has one. */
|
||||
camera?: Record<string, number>;
|
||||
}
|
||||
|
||||
export function agentView(facts: ViewFacts): AgentView {
|
||||
const { project } = facts;
|
||||
return {
|
||||
project: { id: project.id, name: project.name },
|
||||
tab: facts.tab,
|
||||
document: documentView(project, facts.document),
|
||||
windows: facts.windows,
|
||||
selection: selectionView(project, facts.selection),
|
||||
play: facts.mode === 'edit' ? 'editing' : facts.paused ? 'paused' : 'playing',
|
||||
saved: facts.saveStatus,
|
||||
popups: facts.popups,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of what the user sees: the agent is told it when it changes. The camera is left out, or
|
||||
* every scroll of the wheel would be news.
|
||||
*/
|
||||
export function viewSummary(view: AgentView): string {
|
||||
const parts = [`project "${view.project.name}"`, `tab ${view.tab.label}`];
|
||||
const asset = view.document?.asset;
|
||||
if (asset) parts[1] += ` showing ${asset.type} "${asset.name}"`;
|
||||
else if (view.document) parts[1] += ` showing ${view.document.id}`;
|
||||
parts.push(`selection: ${selectionText(view.selection)}`);
|
||||
parts.push(view.play);
|
||||
const top = view.popups.at(-1);
|
||||
if (top) parts.push(top.kind === 'menu' ? 'a menu is open' : `dialog "${top.title}" is open`);
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
function selectionText(selection: SelectionView | null): string {
|
||||
if (!selection) return 'nothing';
|
||||
if (selection.asset) return `${selection.asset.type} "${selection.asset.name}"`;
|
||||
const where = selection.in ? ` in "${selection.in.name}"` : '';
|
||||
if (selection.kind === 'object') {
|
||||
const objects = selection.objects as { name: string }[];
|
||||
return `${countOf(objects.length, 'object')} (${objects.map((o) => `"${o.name}"`).join(', ')})${where}`;
|
||||
}
|
||||
if (selection.kind === 'node') {
|
||||
const nodes = selection.nodes as { type: string }[];
|
||||
return `${countOf(nodes.length, 'node')} (${nodes.map((n) => n.type).join(', ')})${where}`;
|
||||
}
|
||||
return `${selection.kind}${where}`;
|
||||
}
|
||||
|
||||
function countOf(n: number, noun: string): string {
|
||||
return `${n} ${noun}${n === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function assetRef(project: Project, id: string): AssetRef {
|
||||
if (id === ROUTING_ID) return { id, name: 'Master routing', type: 'routing' };
|
||||
const asset = findAsset(project, id);
|
||||
return { id, name: asset?.name ?? '', type: asset?.type ?? 'missing' };
|
||||
}
|
||||
|
||||
function documentView(project: Project, document: string | null): AgentView['document'] {
|
||||
if (document === null) return null;
|
||||
return document.startsWith('asset:')
|
||||
? { id: document, asset: assetRef(project, document.slice('asset:'.length)) }
|
||||
: { id: document };
|
||||
}
|
||||
|
||||
function selectionView(project: Project, selection: Selection): SelectionView | null {
|
||||
if (!selection) return null;
|
||||
switch (selection.kind) {
|
||||
case 'asset':
|
||||
return { kind: 'asset', asset: assetRef(project, selection.assetId) };
|
||||
case 'object': {
|
||||
const holder = findObjectHolder(project, selection.holderId);
|
||||
return {
|
||||
kind: 'object',
|
||||
in: assetRef(project, selection.holderId),
|
||||
primary: selection.objectId,
|
||||
objects: selection.objectIds.map((id) => {
|
||||
const object = holder?.objects.find((candidate) => candidate.id === id);
|
||||
const name = object ? object.name || (findAsset(project, object.assetId)?.name ?? '') : '';
|
||||
return { id, name, asset: object?.assetId ?? '' };
|
||||
}),
|
||||
};
|
||||
}
|
||||
case 'node': {
|
||||
const graph = findGraphAsset(project, selection.assetId)?.graph;
|
||||
return {
|
||||
kind: 'node',
|
||||
in: assetRef(project, selection.assetId),
|
||||
primary: selection.nodeId,
|
||||
nodes: selection.nodeIds.map((id) => ({
|
||||
id,
|
||||
type: graph?.nodes.find((node) => node.id === id)?.type ?? '',
|
||||
})),
|
||||
edges: [...selection.edgeIds],
|
||||
};
|
||||
}
|
||||
default: {
|
||||
const { kind, assetId, ...rest } = selection;
|
||||
return { kind, in: assetRef(project, assetId), ...rest };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
|
||||
/**
|
||||
* The editor as drawn, for the agent's `screenshot` (#429): a frame is drawn right before the copy,
|
||||
* because a WebGL canvas holds its picture only until the browser has shown it.
|
||||
*/
|
||||
export async function screenshot(
|
||||
app: Application,
|
||||
maxWidth: number,
|
||||
): Promise<{ data: string; width: number; height: number }> {
|
||||
app.render();
|
||||
const source = app.canvas;
|
||||
const scale = Math.min(1, maxWidth / source.width);
|
||||
const width = Math.max(1, Math.round(source.width * scale));
|
||||
const height = Math.max(1, Math.round(source.height * scale));
|
||||
const picture = new OffscreenCanvas(width, height);
|
||||
picture.getContext('2d')!.drawImage(source, 0, 0, width, height);
|
||||
const blob = await picture.convertToBlob({ type: 'image/png' });
|
||||
return { data: await base64Of(blob), width, height };
|
||||
}
|
||||
|
||||
function base64Of(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const url = reader.result as string;
|
||||
resolve(url.slice(url.indexOf(',') + 1));
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Cannot read the picture'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"name": "get_view",
|
||||
"title": "What the user sees",
|
||||
"description": "What the user is looking at in the editor right now: the project, the open tab and document, the selection (asset, scene objects, graph nodes, keys, ...), the camera of the view showing, whether the game is playing or paused, and an open dialog. Call it first, and again whenever you were told the view changed.",
|
||||
"inputSchema": { "type": "object", "properties": {}, "additionalProperties": false },
|
||||
"annotations": { "readOnlyHint": true, "openWorldHint": false }
|
||||
},
|
||||
{
|
||||
"name": "screenshot",
|
||||
"title": "Screenshot of the editor",
|
||||
"description": "The whole editor as the user sees it, as a PNG: toolbar, panels, the open tab, menus and dialogs included. Scaled down to maxWidth pixels (default 1280) if it is wider.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"maxWidth": {
|
||||
"type": "integer",
|
||||
"minimum": 320,
|
||||
"maximum": 3840,
|
||||
"description": "Widest the picture may be, in pixels."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"annotations": { "readOnlyHint": true, "openWorldHint": false }
|
||||
}
|
||||
]
|
||||
@@ -120,6 +120,17 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
planned: () => [...music.planned],
|
||||
latency: () => soundLibrary.latency(),
|
||||
},
|
||||
/** The agent's link (#429): where it stands, the badge in the toolbar, and what `get_view` answers. */
|
||||
agent: () => ({
|
||||
on: editor.agent?.on ?? false,
|
||||
status: editor.agent?.status ?? 'off',
|
||||
agents: editor.agent?.agents ?? 0,
|
||||
badge: editor.agentBadge(),
|
||||
view: editor.agentView(),
|
||||
}),
|
||||
switchAgent: (on: boolean) => editor.switchAgent(on),
|
||||
/** The "Agent tokens" dialog as shown; null while it is closed. */
|
||||
agentTokens: () => editor.agentTokens()?.info() ?? null,
|
||||
world: () => editor.worldSnapshot(),
|
||||
playInfo: () => editor.playInfo(),
|
||||
/** Which scene Play would start, and under which of the two rules (#183: set in the settings). */
|
||||
|
||||
@@ -262,6 +262,11 @@ export class GraphCanvas extends Widget {
|
||||
});
|
||||
}
|
||||
|
||||
/** Pan in screen pixels and zoom, as they are this frame – for the agent's `get_view` (#429). */
|
||||
camera(): { panX: number; panY: number; zoom: number } {
|
||||
return { panX: this.pan.x, panY: this.pan.y, zoom: this.zoom };
|
||||
}
|
||||
|
||||
saveView(): GraphViewState {
|
||||
this.tween.land();
|
||||
return { pan: { ...this.pan }, zoom: this.zoom, needsFit: this.needsFit };
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface ToolbarActions {
|
||||
step(): void;
|
||||
/** The account menu at the far right: who is signed in, changing the password, signing out. */
|
||||
openAccountMenu(anchor: Widget): void;
|
||||
/** Switches the agent off (#429): the badge beside the account menu is that switch. */
|
||||
stopAgent(): void;
|
||||
}
|
||||
|
||||
const SAVE_LABELS: Record<SaveStatus, string> = {
|
||||
@@ -63,6 +65,9 @@ export class Toolbar extends HStack {
|
||||
private readonly status: Label;
|
||||
private readonly notice: Label;
|
||||
private readonly fps: Label;
|
||||
/** Beside the account menu while an agent may work in this tab (#429); a click switches it off. */
|
||||
private readonly agentButton: Button;
|
||||
private agentTip = '';
|
||||
/** The picture each run button currently wears; `refresh` swaps them with the mode. */
|
||||
private playIcon: IconName = 'play';
|
||||
private pauseIcon: IconName = 'pause';
|
||||
@@ -120,6 +125,13 @@ export class Toolbar extends HStack {
|
||||
this.add(this.fps);
|
||||
// Far right, where the account lives. A menu rather than a Logout button (#182): it holds
|
||||
// everything about the account without asking the row for more width, which it has none of.
|
||||
this.agentButton = new Button('Agent', {
|
||||
variant: 'primary',
|
||||
onClick: () => actions.stopAgent(),
|
||||
tooltip: () => this.agentTip,
|
||||
});
|
||||
this.agentButton.visible = false;
|
||||
this.add(this.agentButton);
|
||||
const account = new Dropdown('Account', { onClick: () => actions.openAccountMenu(account) });
|
||||
this.add(account);
|
||||
|
||||
@@ -177,6 +189,21 @@ export class Toolbar extends HStack {
|
||||
return { x: at.x, y: this.menuButton.getGlobalPosition().y, w: RECORDING_ROOM, h: this.menuButton.h };
|
||||
}
|
||||
|
||||
/** Shows the agent's badge with `text`, or hides it for null. */
|
||||
showAgent(text: string | null, tooltip = ''): void {
|
||||
this.agentTip = tooltip;
|
||||
const visible = text !== null;
|
||||
if (visible) this.agentButton.setText(text);
|
||||
if (this.agentButton.visible === visible && !visible) return;
|
||||
this.agentButton.visible = visible;
|
||||
this.relayout();
|
||||
}
|
||||
|
||||
/** The agent's badge as shown, null while hidden (test hook). */
|
||||
agentBadge(): string | null {
|
||||
return this.agentButton.visible ? this.agentButton.text : null;
|
||||
}
|
||||
|
||||
/** Frame rate readout; `null` while no measurement is available yet. */
|
||||
setFps(fps: number | null): void {
|
||||
this.fps.setText(fps === null ? '– fps' : `${fps} fps`);
|
||||
|
||||
@@ -102,6 +102,11 @@ export abstract class FreeViewport extends WorldViewport<EditorState> {
|
||||
this.screenOrigin = this.getGlobalPosition();
|
||||
}
|
||||
|
||||
/** World point at the centre and zoom, as they are this frame – for the agent's `get_view` (#429). */
|
||||
camera(): { x: number; y: number; zoom: number } {
|
||||
return { ...this.view };
|
||||
}
|
||||
|
||||
/** Where the camera stands, to come back to (#171). */
|
||||
saveView(): unknown {
|
||||
this.tween.land();
|
||||
|
||||
Reference in new issue
Block a user