778 lines
24 KiB
TypeScript
778 lines
24 KiB
TypeScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||
import { z } from "zod/v4";
|
||
import { ProjectStore } from "../engine/store.ts";
|
||
import { entity, validateGeometry } from "../engine/schema.ts";
|
||
import { defaultProject } from "../engine/templates.ts";
|
||
import { arena, character, extrude, lathe } from "../engine/geometry.ts";
|
||
export interface EngineService {
|
||
builds?: import("./builds.ts").BuildManager;
|
||
store: ProjectStore;
|
||
status: () => any;
|
||
save: () => Promise<any>;
|
||
exportWeb: () => Promise<any>;
|
||
importImage?: (a: any) => Promise<any>;
|
||
importModel: (a: any) => Promise<any>;
|
||
runtime: (action: string, args?: any) => Promise<any>;
|
||
}
|
||
export const commandReference = {
|
||
transactions:
|
||
"1–1000 commands apply atomically. Read project_read first. Mutation expectedRevision must match current revision. requestId deduplicates successful retried transactions. On REVISION_CONFLICT reread project before editing.",
|
||
coordinates:
|
||
"Y up, meters, radians. Entity transforms local to parent. Reparent preserves local coordinates unless you provide transform. Kinematic move uses world displacement; other move uses local.",
|
||
commands: {
|
||
"project.rename": "{name}",
|
||
"project.settings":
|
||
'{background:"#dedbd2",ambient:0.85,shadows:true,renderScale:1}',
|
||
"scene.create": "{id?,name,mode?:2d|3d}",
|
||
"scene.configure":
|
||
"{sceneId?,mode:2d|3d}; default editor view and fallback game camera",
|
||
"scene.activate": "{id}",
|
||
"scene.rename": "{sceneId,name}",
|
||
"node.create":
|
||
"{name,id?,position?,parentId?,components?,sceneId?} OR {entity:{id,name,parentId:null,enabled:true,transform:{position:[0,0,0],rotation:[0,0,0],scale:[1,1,1]},components:{}}}",
|
||
"node.patch":
|
||
"{id,patch:{name?,enabled?,transform?,components?},sceneId?}; deep merge, arrays replace, id/parentId immutable here",
|
||
"node.reparent": "{id,parentId:null|string,transform?,sceneId?}",
|
||
"node.delete": "{id,sceneId?}; subtree",
|
||
"node.duplicate": "{id,sceneId?}; subtree and internal references",
|
||
"component.set": "{id,type,value,sceneId?}; replaces component",
|
||
"component.remove": "{id,type,sceneId?}",
|
||
"asset.upsert":
|
||
'{asset:{id,name,kind:"model"|"geometry"|"prefab"|"image",uri?,geometry?,entities?,metadata?,image?}}',
|
||
"asset.delete": "{id}; fails if referenced",
|
||
"script.upsert":
|
||
'{script:{id,name,source,fields:{speed:{type:"number",default:5,label:"Speed",min:0,max:30}}}}',
|
||
"prefab.create": "{id,name?,sceneId?}",
|
||
"prefab.instantiate": "{assetId,position?,sceneId?}",
|
||
},
|
||
components: {
|
||
sprite: {
|
||
assetId: "image asset or empty for white quad",
|
||
frame: 0,
|
||
size: "optional [width,height], otherwise frame pixels / PPU",
|
||
color: "#ffffff",
|
||
alpha: 1,
|
||
layer: 0,
|
||
order: 0,
|
||
flipX: false,
|
||
flipY: false,
|
||
sortY: false,
|
||
lit: false,
|
||
},
|
||
spriteAnimator: {
|
||
autoplay: "idle",
|
||
autoStates: false,
|
||
clips: [{ name: "idle", frames: [0, 1], fps: 8, loop: true }],
|
||
},
|
||
tilemap: {
|
||
assetId: "image asset",
|
||
tileSize: [1, 1],
|
||
width: 32,
|
||
height: 18,
|
||
cells: [{ x: 0, y: 0, frame: 0, solid: true }],
|
||
collisions: false,
|
||
layer: 0,
|
||
order: 0,
|
||
},
|
||
collider2d: {
|
||
shape: "box | circle | capsule | polygon",
|
||
size: [1, 1],
|
||
radius: 0.5,
|
||
height: 1.8,
|
||
points: "convex polygon vertices [[x,y],...]",
|
||
offset: [0, 0],
|
||
sensor: false,
|
||
oneWay: false,
|
||
membership: 1,
|
||
mask: 65535,
|
||
},
|
||
rigidbody2d: {
|
||
type: "fixed | dynamic | kinematic",
|
||
mass: 1,
|
||
friction: 0.5,
|
||
restitution: 0,
|
||
gravityScale: 1,
|
||
lockRotation: true,
|
||
ccd: true,
|
||
requires: "collider2d or colliding tilemap",
|
||
},
|
||
character2d: {
|
||
mode: "platformer | topDown",
|
||
controls: true,
|
||
speed: 5,
|
||
jumpSpeed: 8,
|
||
gravity: 20,
|
||
autostep: 0.2,
|
||
requires: "kinematic rigidbody2d + collider2d",
|
||
},
|
||
joint2d: {
|
||
type: "fixed | revolute | rope | spring",
|
||
targetId: "another body2d",
|
||
anchor: [0, 0],
|
||
targetAnchor: [0, 0],
|
||
length: 1,
|
||
stiffness: 50,
|
||
damping: 5,
|
||
},
|
||
mesh: {
|
||
type: "box | sphere | cylinder | icosphere | torus | model | geometry | custom",
|
||
size: [1, 1, 1],
|
||
assetId: "for model/geometry types",
|
||
geometry: "{positions,indices,normals?,uvs?} for custom type",
|
||
},
|
||
material: {
|
||
color: "#91a697",
|
||
roughness: 0.8,
|
||
metallic: 0,
|
||
emissive: 0,
|
||
override: false,
|
||
},
|
||
collider: {
|
||
shape: "box | ball | capsule",
|
||
size: [1, 1, 1],
|
||
radius: 0.32,
|
||
height: 1.8,
|
||
offset: [0, 0.9, 0],
|
||
sensor: false,
|
||
enabled: true,
|
||
},
|
||
rigidbody: {
|
||
type: "fixed | dynamic | kinematic",
|
||
mass: 1,
|
||
restitution: 0.1,
|
||
},
|
||
camera: {
|
||
mode: "follow | firstPerson | fixed | imported | 2d",
|
||
orthoWidth: 20,
|
||
pixelPerfect: false,
|
||
pixelsPerUnit: 100,
|
||
targetId: "subject",
|
||
offset: [0, 13, -10],
|
||
fov: 0.72,
|
||
yaw: 0,
|
||
pitch: 0,
|
||
},
|
||
character: {
|
||
gravity: 24,
|
||
autostep: 0.25,
|
||
requires: "kinematic rigidbody + capsule collider",
|
||
},
|
||
sign: { text: "Text in the scene", color: "#d7f34b", width: 5 },
|
||
light: { color: "#fff1da", intensity: 2 },
|
||
animator: {
|
||
idle: "Idle",
|
||
run: "Run",
|
||
attack: "Attack",
|
||
death: "Death",
|
||
speed: 1,
|
||
},
|
||
script: {
|
||
scriptId: "script_rotate",
|
||
params: { speed: 1 },
|
||
},
|
||
data: { customValue: 1, label: "Application-defined properties" },
|
||
},
|
||
};
|
||
export const scriptReference = {
|
||
source:
|
||
"JavaScript expression returning {start(api), update(api,dt)}. No imports or TypeScript. Worker watchdog 1500ms. Imported project scripts are TRUSTED code; Worker is responsiveness isolation, not a security sandbox.",
|
||
api: {
|
||
state: "Persistent per-instance mutable data during one play run",
|
||
params: "Field defaults + component.script.params",
|
||
input:
|
||
"{x,y,z,attack,pointer,aim,jump,dash,sprint,jumpPressed,dashPressed,resetPressed,yaw,pitch}; yaw=0 faces -Z in first person",
|
||
get: "api.get(id?) -> clone of entity or null",
|
||
entities: "api.entities() -> clones of entity states",
|
||
position: "api.position(id?) -> local coordinates",
|
||
move: "api.move([dx,dy,dz]); real collisions for kinematic body",
|
||
physics:
|
||
"api.physics(id?) -> {grounded,velocity:{x,y,z},contacts:[{entityId,normal:[x,y,z]}]}",
|
||
velocity:
|
||
"api.velocity({x?,y?,z?,gravityScale?}); persistent m/s, requires 3D character or any body2d; 2D uses x/y. Gravity runs at 60 Hz",
|
||
teleport:
|
||
"api.teleport([x,y,z],yaw?); clears velocity and contacts for character respawn",
|
||
emit: "api.emit(name,data?); delivers a presentation event to runtime callbacks",
|
||
rotate: "api.rotate(yRadians)",
|
||
rotate2D: "api.rotate2D(zRadians)",
|
||
impulse2D: "api.impulse2D([x,y]); applies impulse to dynamic body2d",
|
||
collision2d:
|
||
"Optional behavior hook collision2d(api,event): {entityId,otherId,started,sensor}. Runs before update.",
|
||
patch:
|
||
"api.patch(id,patch); updates state/transform/enabled. Structural mesh/collider edits take effect next Play.",
|
||
animate:
|
||
"api.animate(clipOrState,loop=true); use false for a one-shot animation",
|
||
effect: 'api.effect("swing"|"hit",id?)',
|
||
spawn:
|
||
"api.spawn(prefabAssetId,position); behaviors start on spawned instances",
|
||
destroy: "api.destroy(id?); disables entity+collider",
|
||
scene: "api.scene(sceneId); starts another scene",
|
||
log: "api.log(text)",
|
||
},
|
||
example:
|
||
"({ update(api, dt) { const n = api.get(); api.rotate(n.transform.rotation[1] + api.params.speed * dt); } })",
|
||
};
|
||
const json = z.record(z.string(), z.unknown()),
|
||
vector = z.tuple([z.number(), z.number(), z.number()]),
|
||
revision = {
|
||
expectedRevision: z.number().int().nonnegative(),
|
||
requestId: z.string().max(100).optional(),
|
||
};
|
||
export function createMcp(s: EngineService) {
|
||
const server = new McpServer(
|
||
{ name: "forma-engine", version: "0.3.0" },
|
||
{
|
||
instructions:
|
||
"Read project_read and forma://reference/commands first. Use expectedRevision, atomic transactions and verify runtime evidence. This is a real local engine. Do not claim testing without observed runtime tool results.",
|
||
},
|
||
);
|
||
function tool(
|
||
name: string,
|
||
description: string,
|
||
inputSchema: any,
|
||
fn: (a: any) => any,
|
||
readOnlyHint = false,
|
||
) {
|
||
server.registerTool(
|
||
name,
|
||
{
|
||
description,
|
||
inputSchema,
|
||
annotations: {
|
||
readOnlyHint,
|
||
destructiveHint: !readOnlyHint,
|
||
idempotentHint: readOnlyHint,
|
||
openWorldHint: false,
|
||
},
|
||
},
|
||
async (a: any): Promise<CallToolResult> => {
|
||
try {
|
||
const r = await fn(a);
|
||
if (
|
||
typeof r?.image === "string" &&
|
||
r.image.startsWith("data:image/png;base64,")
|
||
) {
|
||
const { image, ...meta } = r;
|
||
return {
|
||
content: [
|
||
{
|
||
type: "image",
|
||
mimeType: "image/png",
|
||
data: image.split(",")[1],
|
||
},
|
||
{ type: "text", text: JSON.stringify(meta) },
|
||
],
|
||
structuredContent: meta,
|
||
};
|
||
}
|
||
return {
|
||
content: [{ type: "text", text: JSON.stringify(r) }],
|
||
structuredContent: r,
|
||
};
|
||
} catch (e) {
|
||
return {
|
||
isError: true,
|
||
content: [{ type: "text", text: String(e) }],
|
||
};
|
||
}
|
||
},
|
||
);
|
||
}
|
||
const builds = () => {
|
||
if (!s.builds) throw Error("BUILD_SERVICE_UNAVAILABLE");
|
||
return s.builds;
|
||
};
|
||
tool(
|
||
"build_targets",
|
||
"Check local app build tools and Android release signing availability.",
|
||
{},
|
||
() => builds().capabilities(),
|
||
true,
|
||
);
|
||
tool(
|
||
"build_start",
|
||
"Build an immutable project snapshot into Linux x64 AppImage, Windows x64 portable EXE or Android APK. Returns a job; poll build_status until terminal. First-time tool downloads need network. Never claim device testing from package success.",
|
||
{
|
||
expectedRevision: z.number().int().nonnegative(),
|
||
options: z.object({
|
||
target: z.enum(["linux", "windows", "android"]),
|
||
name: z.string().optional(),
|
||
appId: z.string().optional(),
|
||
version: z.string().optional(),
|
||
versionCode: z.number().int().optional(),
|
||
mode: z.enum(["debug", "release"]).optional(),
|
||
width: z.number().int().optional(),
|
||
height: z.number().int().optional(),
|
||
fullscreen: z.boolean().optional(),
|
||
orientation: z.enum(["landscape", "portrait", "sensor"]).optional(),
|
||
}),
|
||
},
|
||
(a: any) => builds().start(s.store.project, a.options, a.expectedRevision),
|
||
);
|
||
tool(
|
||
"build_status",
|
||
"Read build status, bounded log and artifact URLs with SHA-256.",
|
||
{ id: z.string() },
|
||
(a: any) => builds().get(a.id),
|
||
true,
|
||
);
|
||
tool(
|
||
"build_list",
|
||
"List recent local build jobs.",
|
||
{},
|
||
() => ({ jobs: builds().list() }),
|
||
true,
|
||
);
|
||
tool(
|
||
"build_cancel",
|
||
"Cancel a queued or running build and stop its child processes.",
|
||
{ id: z.string() },
|
||
(a: any) => builds().cancel(a.id),
|
||
);
|
||
const tx = (a: any, commands: any[], label: string) =>
|
||
s.store.transaction({
|
||
commands,
|
||
expectedRevision: a.expectedRevision,
|
||
requestId: a.requestId,
|
||
label,
|
||
source: "mcp",
|
||
});
|
||
tool(
|
||
"project_read",
|
||
"Project revision, scenes, asset summaries, scripts, settings and editor status. Binary model data omitted.",
|
||
{},
|
||
() => {
|
||
const p = s.store.project;
|
||
return {
|
||
...s.status(),
|
||
id: p.id,
|
||
name: p.name,
|
||
revision: p.revision,
|
||
activeSceneId: p.activeSceneId,
|
||
scenes: p.scenes.map((s) => ({
|
||
id: s.id,
|
||
name: s.name,
|
||
objects: s.entities.length,
|
||
})),
|
||
assets: p.assets.map(({ uri, geometry, entities, ...rest }) => rest),
|
||
scripts: p.scripts,
|
||
settings: p.settings,
|
||
};
|
||
},
|
||
true,
|
||
);
|
||
tool(
|
||
"project_new",
|
||
"Replace active project with an empty scene. Undoable. Save your current project first.",
|
||
{
|
||
...revision,
|
||
name: z.string().max(200),
|
||
template: z.literal("empty").default("empty"),
|
||
},
|
||
(a) => {
|
||
const p = defaultProject();
|
||
p.name = a.name;
|
||
return tx(
|
||
a,
|
||
[{ op: "project.replace", args: { project: p } }],
|
||
"Новый проект",
|
||
);
|
||
},
|
||
);
|
||
tool(
|
||
"scene_read",
|
||
"Read entity hierarchy and components. Geometry arrays omitted unless requested.",
|
||
{
|
||
sceneId: z.string().optional(),
|
||
includeGeometry: z.boolean().default(false),
|
||
},
|
||
(a) => {
|
||
const scene = s.store.project.scenes.find(
|
||
(p) => p.id === (a.sceneId || s.store.project.activeSceneId),
|
||
);
|
||
if (!scene) throw Error("Scene not found");
|
||
const result = structuredClone(scene);
|
||
if (!a.includeGeometry)
|
||
for (const n of result.entities) {
|
||
const g = n.components.mesh?.geometry;
|
||
if (g)
|
||
n.components.mesh.geometry = {
|
||
vertexCount: g.positions.length / 3,
|
||
triangles: g.indices.length / 3,
|
||
};
|
||
}
|
||
return { revision: s.store.project.revision, scene: result };
|
||
},
|
||
true,
|
||
);
|
||
tool(
|
||
"scene_create",
|
||
"Create and activate a scene.",
|
||
{
|
||
...revision,
|
||
name: z.string(),
|
||
id: z.string().optional(),
|
||
mode: z.enum(["2d", "3d"]).optional(),
|
||
},
|
||
(a) =>
|
||
tx(
|
||
a,
|
||
[
|
||
{
|
||
op: "scene.create",
|
||
args: {
|
||
name: a.name,
|
||
...(a.id ? { id: a.id } : {}),
|
||
...(a.mode ? { mode: a.mode } : {}),
|
||
},
|
||
},
|
||
],
|
||
"Создать сцену",
|
||
),
|
||
);
|
||
tool(
|
||
"commands_apply",
|
||
"Apply one atomic command batch. Read forma://reference/commands for schemas.",
|
||
{
|
||
...revision,
|
||
label: z.string().max(200),
|
||
commands: z
|
||
.array(z.object({ op: z.string(), args: json }))
|
||
.min(1)
|
||
.max(1000),
|
||
},
|
||
(a) => tx(a, a.commands, a.label),
|
||
);
|
||
tool(
|
||
"node_create",
|
||
"Create a 3D entity with arbitrary components.",
|
||
{
|
||
...revision,
|
||
name: z.string(),
|
||
id: z.string().optional(),
|
||
parentId: z.string().optional(),
|
||
position: vector.default([0, 0, 0]),
|
||
components: json.default({}),
|
||
},
|
||
(a) =>
|
||
tx(
|
||
a,
|
||
[
|
||
{
|
||
op: "node.create",
|
||
args: {
|
||
name: a.name,
|
||
position: a.position,
|
||
components: a.components,
|
||
...(a.id ? { id: a.id } : {}),
|
||
...(a.parentId ? { parentId: a.parentId } : {}),
|
||
},
|
||
},
|
||
],
|
||
"Создать " + a.name,
|
||
),
|
||
);
|
||
tool(
|
||
"node_update",
|
||
"Deep merge object properties. Use node.reparent command to change hierarchy.",
|
||
{ ...revision, id: z.string(), patch: json },
|
||
(a) =>
|
||
tx(
|
||
a,
|
||
[{ op: "node.patch", args: { id: a.id, patch: a.patch } }],
|
||
"Изменить объект",
|
||
),
|
||
);
|
||
for (const op of ["delete", "duplicate"])
|
||
tool(
|
||
"node_" + op,
|
||
op + " object subtree.",
|
||
{ ...revision, id: z.string() },
|
||
(a) => tx(a, [{ op: "node." + op, args: { id: a.id } }], op),
|
||
);
|
||
tool(
|
||
"model_generate",
|
||
"Generate editable geometry WITHOUT Blender. arena: level; character: static stylized figure; extrude: polygon [x,z]+depth; lathe: profile [radius,y]+segments.",
|
||
{
|
||
...revision,
|
||
kind: z.enum(["arena", "character", "extrude", "lathe"]),
|
||
name: z.string().optional(),
|
||
width: z.number().min(8).max(80).optional(),
|
||
depth: z.number().min(0.05).max(80).optional(),
|
||
height: z.number().min(0.1).max(20).optional(),
|
||
seed: z.number().int().optional(),
|
||
obstacles: z.number().int().min(0).max(80).optional(),
|
||
segments: z.number().int().min(3).max(128).optional(),
|
||
color: z
|
||
.string()
|
||
.regex(/^#[\da-fA-F]{6}$/)
|
||
.optional(),
|
||
profile: z
|
||
.array(z.tuple([z.number(), z.number()]))
|
||
.max(256)
|
||
.optional(),
|
||
},
|
||
(a) => {
|
||
let nodes;
|
||
if (a.kind === "arena") nodes = arena(a);
|
||
else if (a.kind === "character") nodes = character(a);
|
||
else {
|
||
if (!a.profile) throw Error("profile required");
|
||
const geometry =
|
||
a.kind === "extrude"
|
||
? extrude(a.profile, a.depth || 1)
|
||
: lathe(a.profile, a.segments || 24);
|
||
nodes = [
|
||
entity(a.name || a.kind, {
|
||
mesh: { type: "custom", geometry },
|
||
material: { color: a.color || "#91a697", roughness: 0.8 },
|
||
}),
|
||
];
|
||
}
|
||
if (a.name) nodes[0].name = a.name;
|
||
return tx(
|
||
a,
|
||
nodes.map((n) => ({ op: "node.create", args: { entity: n } })),
|
||
"Генерация " + a.kind,
|
||
);
|
||
},
|
||
);
|
||
tool(
|
||
"mesh_create",
|
||
"Create custom triangle mesh directly from vertex arrays.",
|
||
{
|
||
...revision,
|
||
name: z.string(),
|
||
positions: z.array(z.number()).max(900000),
|
||
indices: z.array(z.number().int()).max(1800000),
|
||
normals: z.array(z.number()).optional(),
|
||
uvs: z.array(z.number()).optional(),
|
||
color: z.string().default("#91a697"),
|
||
},
|
||
(a) => {
|
||
const geometry = {
|
||
positions: a.positions,
|
||
indices: a.indices,
|
||
...(a.normals ? { normals: a.normals } : {}),
|
||
...(a.uvs ? { uvs: a.uvs } : {}),
|
||
};
|
||
validateGeometry(geometry);
|
||
return tx(
|
||
a,
|
||
[
|
||
{
|
||
op: "node.create",
|
||
args: {
|
||
entity: entity(a.name, {
|
||
mesh: { type: "custom", geometry },
|
||
material: { color: a.color, roughness: 0.8 },
|
||
}),
|
||
},
|
||
},
|
||
],
|
||
"Создать сетку",
|
||
);
|
||
},
|
||
);
|
||
tool(
|
||
"asset_import_image",
|
||
"Import PNG, JPEG or WebP as an image asset and optional XY sprite. Configure asset.image with asset.upsert for slicing, PPU, filter and pivots.",
|
||
{
|
||
...revision,
|
||
name: z.string().regex(/\.(png|jpe?g|webp)$/i),
|
||
base64: z.string().max(36_000_000).optional(),
|
||
path: z.string().optional(),
|
||
instantiate: z.boolean().default(true),
|
||
},
|
||
(a) => {
|
||
if (!s.importImage) throw Error("Image import unavailable");
|
||
return s.importImage(a);
|
||
},
|
||
);
|
||
tool(
|
||
"asset_import_glb",
|
||
"Import GLB, glTF with companion files, or a ZIP containing one model and its textures. Use base64 bytes OR a path inside the project folder. External files resolve only within that folder; network URLs are not fetched.",
|
||
{
|
||
...revision,
|
||
name: z.string().regex(/\.(glb|gltf|zip)$/i),
|
||
base64: z.string().max(36_000_000).optional(),
|
||
path: z.string().optional(),
|
||
instantiate: z.boolean().default(true),
|
||
asScene: z
|
||
.boolean()
|
||
.default(false)
|
||
.describe(
|
||
"Replace the project with the complete imported scene, using its active/first camera and lights. Undoable.",
|
||
),
|
||
},
|
||
(a) => s.importModel(a),
|
||
);
|
||
tool(
|
||
"script_upsert",
|
||
"Create or edit trusted JavaScript behavior and Inspector fields.",
|
||
{
|
||
...revision,
|
||
id: z.string(),
|
||
name: z.string(),
|
||
source: z.string().max(250000),
|
||
fields: json,
|
||
},
|
||
(a) => {
|
||
new Function("return (" + a.source + ");");
|
||
return tx(
|
||
a,
|
||
[
|
||
{
|
||
op: "script.upsert",
|
||
args: {
|
||
script: {
|
||
id: a.id,
|
||
name: a.name,
|
||
source: a.source,
|
||
fields: a.fields,
|
||
},
|
||
},
|
||
},
|
||
],
|
||
"Изменить скрипт " + a.name,
|
||
);
|
||
},
|
||
);
|
||
tool(
|
||
"prefab_create",
|
||
"Capture object subtree as prefab asset.",
|
||
{ ...revision, id: z.string(), name: z.string().optional() },
|
||
(a) =>
|
||
tx(
|
||
a,
|
||
[
|
||
{
|
||
op: "prefab.create",
|
||
args: { id: a.id, ...(a.name ? { name: a.name } : {}) },
|
||
},
|
||
],
|
||
"Создать префаб",
|
||
),
|
||
);
|
||
tool(
|
||
"prefab_instantiate",
|
||
"Instantiate prefab and remap internal object references.",
|
||
{ ...revision, assetId: z.string(), position: vector.default([0, 0, 0]) },
|
||
(a) =>
|
||
tx(
|
||
a,
|
||
[
|
||
{
|
||
op: "prefab.instantiate",
|
||
args: { assetId: a.assetId, position: a.position },
|
||
},
|
||
],
|
||
"Добавить префаб",
|
||
),
|
||
);
|
||
for (const action of ["undo", "redo"] as const)
|
||
tool(
|
||
"history_" + action,
|
||
action + " last transaction.",
|
||
{ expectedRevision: revision.expectedRevision },
|
||
(a) => {
|
||
if (a.expectedRevision !== s.store.project.revision)
|
||
throw Error("REVISION_CONFLICT");
|
||
s.store[action]();
|
||
return { revision: s.store.project.revision };
|
||
},
|
||
);
|
||
tool(
|
||
"project_save",
|
||
"Persist JSON and portable .forma archive to project directory.",
|
||
{},
|
||
() => s.save(),
|
||
);
|
||
tool(
|
||
"project_export_web",
|
||
"Export standalone HTML+runtime+assets as ZIP in exports/. Does not publish.",
|
||
{},
|
||
() => s.exportWeb(),
|
||
);
|
||
for (const action of ["play", "stop", "snapshot", "capture"])
|
||
tool(
|
||
"runtime_" + action,
|
||
action +
|
||
" in the connected editor. Requires a live browser editor. Capture returns actual PNG.",
|
||
{},
|
||
() => s.runtime(action),
|
||
["snapshot", "capture"].includes(action),
|
||
);
|
||
tool(
|
||
"runtime_input",
|
||
"Send timed movement, first-person view, jump, dash or attack to the running game and return observed state.",
|
||
{
|
||
x: z.number().min(-1).max(1).default(0),
|
||
y: z
|
||
.number()
|
||
.min(-1)
|
||
.max(1)
|
||
.optional()
|
||
.describe("Vertical XY input for 2D"),
|
||
z: z.number().min(-1).max(1).default(0),
|
||
attack: z.boolean().default(false),
|
||
jump: z.boolean().default(false),
|
||
dash: z.boolean().default(false),
|
||
sprint: z.boolean().default(false),
|
||
reset: z.boolean().default(false),
|
||
yaw: z.number().optional(),
|
||
pitch: z.number().min(-1.3).max(1.3).optional(),
|
||
pointer: z.boolean().default(false),
|
||
aim: vector.optional(),
|
||
durationMs: z.number().int().min(50).max(10000).default(500),
|
||
},
|
||
(a) => s.runtime("input", a),
|
||
);
|
||
tool(
|
||
"editor_focus",
|
||
"Focus editor camera on an entity.",
|
||
{ id: z.string() },
|
||
(a) => s.runtime("focus", a),
|
||
);
|
||
const resource = (name: string, uri: string, data: () => any) =>
|
||
server.registerResource(
|
||
name,
|
||
uri,
|
||
{ mimeType: "application/json" },
|
||
async () => ({
|
||
contents: [
|
||
{
|
||
uri,
|
||
mimeType: "application/json",
|
||
text: JSON.stringify(data(), null, 2),
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
resource("Current project", "forma://project/current", () => s.store.project);
|
||
resource("Commands", "forma://reference/commands", () => commandReference);
|
||
resource("Scripts", "forma://reference/scripts", () => scriptReference);
|
||
server.registerPrompt(
|
||
"create_scene",
|
||
{
|
||
description: "Create and verify a 3D scene from an empty project.",
|
||
argsSchema: { theme: z.string().optional() },
|
||
},
|
||
({ theme }) => ({
|
||
messages: [
|
||
{
|
||
role: "user",
|
||
content: {
|
||
type: "text",
|
||
text:
|
||
"Build a 3D scene" +
|
||
(theme ? " themed " + theme : "") +
|
||
". Read project metadata and command/script resources. Save the current project before replacing. Start empty, create or import geometry, configure materials and lighting, and add a camera. Add behaviors only when required by the scene. Use atomic revision-checked changes. Verify the scene and any scripted behavior with runtime tools; capture PNG when an editor is connected, save and export. Do not claim a check passed without evidence.",
|
||
},
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
return server;
|
||
}
|