import React, { useEffect, useRef, useState, useCallback, type ReactNode, type ElementType, } from "react"; import { Box, Layers, Package, Play, Pause, Square, Undo2, Redo2, Download, Upload, Plus, Search, ChevronDown, ChevronRight, X, Settings, Terminal, Folder, Code2, Link, Eye, EyeOff, Grid3X3, Camera, Sun, Trash2, Copy, Check, PersonStanding, Mountain, Save, HelpCircle, MousePointer2, Move, RotateCw, Maximize2, WandSparkles, } from "lucide-react"; import { type Project, type Entity, type Vec3, type Command, clone, entity, uid, activeScene, componentDefaults, validateGeometry, } from "../engine/schema.ts"; import { ProjectStore } from "../engine/store.ts"; import { defaultProject } from "../engine/templates.ts"; import { arena, character, extrude, lathe } from "../engine/geometry.ts"; import { gameArchive, projectArchive, unpackProject, download, base64, mimeFor, } from "../engine/archive.ts"; import { FormaRuntime } from "../engine/runtime.ts"; import { saveDraft } from "./persistence.ts"; import { loadEditorProject, type EditorStartupOptions } from "./startup.ts"; import { inspectModel } from "../engine/model.ts"; import "./editor.css"; import { BuildPanel } from "./BuildPanel.tsx"; const names: Record = { mesh: "Геометрия", material: "Материал", collider: "Коллайдер", rigidbody: "Физическое тело", script: "Поведение", camera: "Камера", character: "Контроллер персонажа", light: "Свет", animator: "Анимации", data: "Игровые данные", }; const shapes: Record = { empty: "Группа", box: "Куб", sphere: "Сфера", cylinder: "Цилиндр", icosphere: "Икосфера", torus: "Кольцо", character: "Контроллер персонажа", camera: "Камера", light: "Свет", }; function Icon({ icon: I, label, onClick, active = false, disabled = false, }: { icon: ElementType; label: string; onClick?: () => void; active?: boolean; disabled?: boolean; }) { return ( ); } function Num({ value, onChange, label, min, max, step = 0.1, }: { value: number; onChange: (v: number) => void; label: string; min?: number; max?: number; step?: number; }) { const [d, setD] = useState(String(Math.round(value * 1000) / 1000)); useEffect(() => setD(String(Math.round(value * 1000) / 1000)), [value]); return ( setD(e.target.value)} onBlur={() => { const n = Number(d); if (d !== "" && Number.isFinite(n) && n !== value) onChange(Math.max(min ?? -Infinity, Math.min(max ?? Infinity, n))); else setD(String(value)); }} onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); e.stopPropagation(); }} /> ); } function TextInput({ value, onCommit, label, }: { value: string; onCommit: (s: string) => void; label: string; }) { const [d, setD] = useState(value); useEffect(() => setD(value), [value]); return ( setD(e.target.value)} onBlur={() => { if (d !== value) onCommit(d); }} onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); e.stopPropagation(); }} /> ); } function Vector({ value, onChange, label, angle = false, }: { value: Vec3; onChange: (v: Vec3) => void; label: string; angle?: boolean; }) { return (
{["X", "Y", "Z"].map((a, i) => ( ))}
); } function Section({ title, children, action, }: { title: string; children: ReactNode; action?: ReactNode; }) { const [open, setOpen] = useState(true); return (
{action}
{open &&
{children}
}
); } function Modal({ title, subtitle, children, onClose, wide = false, }: { title: string; subtitle?: string; children: ReactNode; onClose: () => void; wide?: boolean; }) { return (
{ if (e.target === e.currentTarget) onClose(); }} >

{title}

{subtitle &&

{subtitle}

}
{children}
); } export default function Editor({ initialProject, browserOnly = false }: EditorStartupOptions = {}) { const [project, setProject] = useState(null), [selected, setSelected] = useState(null), [connection, setConnection] = useState(null), [ready, setReady] = useState(false), [playing, setPlaying] = useState(false), [paused, setPaused] = useState(false), [stats, setStats] = useState({ fps: 0, triangles: 0 }), [logs, setLogs] = useState([]), [busy, setBusy] = useState(""), [toast, setToast] = useState(""), [cached, setCached] = useState(false); const [bottom, setBottom] = useState("assets"), [resourceFilter, setResourceFilter] = useState("all"), [filter, setFilter] = useState(""), [menu, setMenu] = useState(null), [modal, setModal] = useState(null), [search, setSearch] = useState(""), [tool, setTool] = useState("move"), [snap, setSnap] = useState(false), [grid, setGrid] = useState(true), [collapsed, setCollapsed] = useState(new Set()); const [kind, setKind] = useState("arena"), [modelWidth, setWidth] = useState(20), [modelDepth, setDepth] = useState(16), [seed, setSeed] = useState(42), [count, setCount] = useState(6), [height, setHeight] = useState(1.8), [color, setColor] = useState("#7e998a"), [profile, setProfile] = useState( "[[-1,-1],[1,-1],[1,0],[0,0],[0,1],[-1,1]]", ), [meshJson, setMeshJson] = useState( '{"positions":[-1,0,-1,1,0,-1,0,1,0],"indices":[0,2,1]}', ); const [scriptId, setScriptId] = useState(""), [code, setCode] = useState(""), [fieldCode, setFieldCode] = useState("{}"), [dirty, setDirty] = useState(false), [unsavedClose, setUnsavedClose] = useState(false), [rename, setRename] = useState(""); const store = useRef(null), runtime = useRef(null), canvas = useRef(null), connected = useRef(null), events = useRef(null), saveTimer = useRef(null), toastTimer = useRef(null), file = useRef(null), modelFile = useRef(null), viewport = useRef(null), stickId = useRef(null), knob = useRef(null); const notify = useCallback((s: string) => { setToast(s); clearTimeout(toastTimer.current); toastTimer.current = setTimeout(() => setToast(""), 5000); }, []); const addLog = useCallback( (level: string, message: string, id?: string) => setLogs((l) => [ ...l.slice(-149), { level, message, id, time: Date.now() }, ]), [], ); const accept = useCallback((d: any) => { if (d.project) { store.current?.synchronize(d.project); if (d.history) store.current!.history = d.history; } if (d.status) { connected.current = d.status; setConnection(d.status); } }, []); const call = useCallback( async (url: string, body?: any) => { const r = await fetch(url, { method: body ? "POST" : "GET", headers: body ? { "Content-Type": "application/json" } : {}, ...(body ? { body: JSON.stringify(body) } : {}), }); const d: any = await r.json(); if (!r.ok) { if (d.project) accept(d); throw Error(d.error || "Ошибка сервиса"); } return d; }, [accept], ); const execute = useCallback( async (commands: Command[], label: string) => { try { if (!store.current) return; if (connected.current) { const d = await call("/api/transaction", { commands, label, expectedRevision: store.current.project.revision, requestId: uid("req"), source: "editor", }); accept(d); return d.result; } return store.current.transaction({ commands, label, source: "editor" }); } catch (e) { notify(String(e)); addLog("error", String(e)); } }, [accept, call, notify, addLog], ); const cmd = useCallback( (op: string, args: any, label = op) => execute([{ op, args }], label), [execute], ); const patch = useCallback( (id: string, p: any) => cmd("node.patch", { id, patch: p }, "Изменить объект"), [cmd], ); useEffect(() => { let alive = true; let unsubscribe: (() => void) | undefined; (async () => { const { project: startingProject, status, restored } = await loadEditorProject({ initialProject, browserOnly }); if (!alive) return; store.current = new ProjectStore(startingProject); setCached(restored); connected.current = status; setConnection(status); setProject(store.current.project); setCollapsed( new Set( activeScene(store.current.project) .entities.filter((n) => !n.parentId && !n.components.mesh) .map((n) => n.id), ), ); setSelected(activeScene(store.current.project).entities[0]?.id || null); unsubscribe = store.current.subscribe(() => { setProject(store.current!.project); setCached(false); clearTimeout(saveTimer.current); saveTimer.current = setTimeout( () => saveDraft(store.current!.project) .then(() => setCached(true)) .catch((e) => addLog("error", String(e))), 500, ); }); setReady(true); if (status) { const s = new EventSource("/api/events"); events.current = s; s.addEventListener("project", (e) => accept(JSON.parse((e as MessageEvent).data)), ); s.addEventListener("runtime", async (e) => { const m = JSON.parse((e as MessageEvent).data); try { const r = runtime.current; if (!r) throw Error("Редактор ещё загружается"); let result; switch (m.action) { case "cancel": r.setInput({ x: 0, z: 0, attack: false, durationMs: 0 }); result = { cancelled: true }; break; case "play": await r.play(store.current!.project); result = r.snapshot(); break; case "stop": await r.stop(store.current!.project); result = r.snapshot(); break; case "capture": result = { image: r.screenshot(), runId: r.runId }; break; case "input": if (!r.playing) throw Error("Сначала запустите сцену"); r.setInput(m.args); await new Promise((resolve) => setTimeout(resolve, (m.args.durationMs || 500) + 80), ); result = r.snapshot(); break; case "focus": r.focus(m.args.id); result = { focused: m.args.id }; break; default: result = r.snapshot(); } await call("/api/runtime-result", { id: m.id, result }); } catch (e) { await call("/api/runtime-result", { id: m.id, error: String(e) }); } }); s.onerror = () => addLog("warning", "Связь прервана; повторное подключение…"); } })(); return () => { alive = false; unsubscribe?.(); events.current?.close(); clearTimeout(saveTimer.current); }; }, [accept, addLog, call, initialProject, browserOnly]); useEffect(() => { if (!ready || !canvas.current) return; const r = new FormaRuntime(canvas.current, { select: setSelected, transform: (id, t) => void patch(id, { transform: t }), log: addLog, stats: setStats, mode: (p) => { setPlaying(p); setPaused(false); }, }); runtime.current = r; r.load(store.current!.project) .then(() => r.select(selected)) .catch((e) => { notify(String(e)); addLog("error", String(e)); }); return () => { r.dispose(); runtime.current = null; }; }, [ready]); useEffect(() => { if (project && runtime.current && !runtime.current.playing) void runtime.current .load(project) .catch((e) => addLog("error", String(e))); }, [project, addLog]); useEffect(() => runtime.current?.select(selected), [selected]); const save = useCallback(async () => { if (!store.current) return; setBusy("Сохранение проекта"); try { if (connected.current) { await call("/api/save", {}); notify("Проект сохранён на диске"); } else { download( await projectArchive(store.current.project), store.current.project.name + ".forma", ); notify("Проект скачан вместе с ресурсами"); } } catch (e) { notify(String(e)); } finally { setBusy(""); } }, [call, notify]); const historyAction = useCallback( async (action: "undo" | "redo") => { if (playing) return; try { if (connected.current) accept( await call("/api/history", { action, expectedRevision: store.current!.project.revision, }), ); else store.current?.[action](); } catch (e) { notify(String(e)); } }, [playing, accept, call, notify], ); const togglePlay = useCallback(async () => { const r = runtime.current, p = store.current?.project; if (!r || !p) return; setBusy(r.playing ? "Остановка" : "Подготовка игры"); try { if (r.playing) await r.stop(p); else await r.play(p); } catch (e) { notify(String(e)); addLog("error", String(e)); } finally { setBusy(""); } }, [notify, addLog]); const closeModal = () => { if (modal === "script" && dirty) { setUnsavedClose(true); return; } setModal(null); }; useEffect(() => { const key = (e: KeyboardEvent) => { const typing = /INPUT|TEXTAREA|SELECT/.test( (e.target as HTMLElement)?.tagName, ); if ((e.ctrlKey || e.metaKey) && e.code === "KeyS") { e.preventDefault(); if (modal !== "script") void save(); return; } if ((e.ctrlKey || e.metaKey) && e.code === "KeyK") { e.preventDefault(); setModal("search"); return; } if (e.code === "Escape") { if (modal === "script" && dirty) setUnsavedClose(true); else setModal(null); setMenu(null); return; } if (typing || modal || playing) return; if ((e.ctrlKey || e.metaKey) && e.code === "KeyZ") { e.preventDefault(); void historyAction(e.shiftKey ? "redo" : "undo"); } else if ((e.ctrlKey || e.metaKey) && e.code === "KeyD" && selected) { e.preventDefault(); void cmd("node.duplicate", { id: selected }, "Дублировать"); } else if (e.code === "Delete" && selected) { void cmd("node.delete", { id: selected }, "Удалить объект"); setSelected(null); } else if (e.code === "KeyF") runtime.current?.focus(); else if (["KeyW", "KeyE", "KeyR", "KeyQ"].includes(e.code)) { const t = ( { KeyW: "move", KeyE: "rotate", KeyR: "scale", KeyQ: "select" } as any )[e.code]; setTool(t); runtime.current?.setTool(t); } }; window.addEventListener("keydown", key); return () => window.removeEventListener("keydown", key); }, [save, modal, dirty, playing, selected, historyAction, cmd]); const scene = project ? activeScene(project) : null, node = scene?.entities.find((n) => n.id === selected) || null; const addObject = async (type: string) => { setMenu(null); if (!project) return; let n: Entity; n = entity( shapes[type], type === "empty" ? {} : type === "character" ? { mesh: { type: "cylinder", size: [0.7, 1.8, 0.7] }, material: clone(componentDefaults.material), collider: { shape: "capsule", radius: 0.35, height: 1.8 }, rigidbody: { type: "kinematic" }, character: clone(componentDefaults.character), } : type === "camera" ? { camera: { ...clone(componentDefaults.camera), targetId: selected || "", }, } : type === "light" ? { light: clone(componentDefaults.light) } : { mesh: { type, size: [1, 1, 1] }, material: clone(componentDefaults.material), }, type === "camera" ? [0, 13, -10] : type === "character" ? [0, 1, 0] : [0, 0.5, 0], ); const r = await execute( [{ op: "node.create", args: { entity: n } }], "Добавить " + n.name, ); if (r) setSelected(n.id); }; const instantiate = async (id: string) => { const a = project?.assets.find((a) => a.id === id); if (!a) return; if (a.kind === "prefab") { const r = await cmd( "prefab.instantiate", { assetId: id, position: [0, 0, 0] }, "Добавить префаб", ); if (r) setSelected(r.results[0].id); } else { const n = entity(a.name.replace(/\.(glb|gltf)$/i, ""), { mesh: { type: a.kind === "model" ? "model" : "geometry", assetId: id }, ...(a.kind === "geometry" ? { material: clone(componentDefaults.material) } : {}), }); if (await cmd("node.create", { entity: n }, "Добавить ресурс")) setSelected(n.id); } }; const importFiles = async (files: FileList | File[]) => { for (const f of Array.from(files)) { setBusy("Импорт " + f.name); try { if (/\.(forma|json|zip)$/i.test(f.name)) { const p = unpackProject(new Uint8Array(await f.arrayBuffer())); if (await cmd("project.replace", { project: p }, "Открыть проект")) { setSelected(null); notify("Проект открыт"); } } else if (/\.(glb|gltf)$/i.test(f.name)) { if (f.size > 25 * 1024 * 1024) throw Error("Лимит модели: 25 МБ"); const bytes = new Uint8Array(await f.arrayBuffer()); const metadata = inspectModel(bytes, f.name); const id = uid("asset"), n = entity(f.name.replace(/\.(glb|gltf)$/i, ""), { mesh: { type: "model", assetId: id }, }); if ( await execute( [ { op: "asset.upsert", args: { asset: { id, name: f.name, kind: "model", metadata, uri: "data:" + mimeFor(f.name) + ";base64," + base64(bytes), }, }, }, { op: "node.create", args: { entity: n } }, ], "Импорт " + f.name, ) ) setSelected(n.id); } else throw Error("Поддерживаются .forma, GLB, встроенный glTF"); } catch (e) { notify(String(e)); addLog("error", String(e)); } finally { setBusy(""); } } }; const exportGame = async () => { if (!project) return; setBusy("Сборка игры"); try { download( await gameArchive(project), project.name + "-web.zip", "application/zip", ); notify("Игра экспортирована. Распакуйте и запустите HTTP-сервер."); } catch (e) { notify(String(e)); } finally { setBusy(""); } }; const generate = async () => { try { let nodes: Entity[]; if (kind === "arena") nodes = arena({ width: modelWidth, depth: modelDepth, seed, obstacles: count, }); else if (kind === "character") nodes = character({ height, color }); else { const geometry = kind === "custom" ? JSON.parse(meshJson) : kind === "extrude" ? extrude(JSON.parse(profile), height) : lathe(JSON.parse(profile), Math.round(count)); validateGeometry(geometry); nodes = [ entity( kind === "extrude" ? "Выдавленный профиль" : kind === "lathe" ? "Тело вращения" : "Своя геометрия", { mesh: { type: "custom", geometry }, material: { color, roughness: 0.8 }, }, ), ]; } if ( await execute( nodes.map((n) => ({ op: "node.create", args: { entity: n } })), "Создать " + kind, ) ) { setSelected(nodes[0].id); setModal(null); notify("Создано объектов: " + nodes.length); } } catch (e) { notify(String(e)); } }; const openScript = (id: string) => { const s = project?.scripts.find((s) => s.id === id); if (!s) return; setScriptId(id); setCode(s.source); setFieldCode(JSON.stringify(s.fields, null, 2)); setDirty(false); setUnsavedClose(false); setModal("script"); }; const saveScript = async () => { try { new Function("return (" + code + ");"); const s = project!.scripts.find((s) => s.id === scriptId)!; if ( await cmd( "script.upsert", { script: { ...s, source: code, fields: JSON.parse(fieldCode) } }, "Изменить " + s.name, ) ) { setDirty(false); notify("Скрипт сохранён. Перезапустите сцену."); } } catch (e) { notify("Ошибка скрипта: " + String(e)); } }; const createScript = async () => { const s = { id: uid("script"), name: "NewBehavior", source: "({\n update(api, dt) {\n const n = api.get();\n api.rotate(n.transform.rotation[1] + dt * api.params.speed);\n }\n})", fields: { speed: { type: "number", default: 1, label: "Скорость", min: -10, max: 10, }, }, }; await cmd("script.upsert", { script: s }, "Создать скрипт"); setBottom("scripts"); }; const setComponent = (type: string, value: any) => node && cmd( "component.set", { id: node.id, type, value }, "Изменить " + (names[type] || type), ); const componentField = (type: string, key: string, value: any) => node && patch(node.id, { components: { [type]: { [key]: value } } }); const entitySelect = ( value: string, change: (s: string) => void, label: string, ) => ( ); const fieldsFor = (type: string, c: any) => Object.entries(c).map(([key, value]) => { if (["geometry", "assetId"].includes(key)) return null; return (
{typeof value === "boolean" ? ( componentField(type, key, e.target.checked)} /> ) : typeof value === "number" ? ( componentField(type, key, v)} /> ) : typeof value === "string" ? ( void componentField(type, key, v)} /> ) : ( JSON )}
); }); const row = (n: Entity, depth = 0): ReactNode => { const children = scene!.entities.filter((x) => x.parentId === n.id), open = !collapsed.has(n.id); if (filter && !n.name.toLowerCase().includes(filter.toLowerCase())) return children.map((c) => row(c, depth)); const I = n.components.camera ? Camera : n.components.light ? Sun : n.components.script ? PersonStanding : n.components.mesh ? Box : Layers; return (
e.dataTransfer.setData("forma/entity", n.id)} onDragOver={(e) => e.preventDefault()} onDrop={(e) => { e.preventDefault(); e.stopPropagation(); const id = e.dataTransfer.getData("forma/entity"); if (id && id !== n.id) void cmd( "node.reparent", { id, parentId: n.id }, "Изменить иерархию", ); }} onClick={() => setSelected(n.id)} onDoubleClick={() => runtime.current?.focus(n.id)} > {n.name}
{open && children.map((c) => row(c, depth + 1))}
); }; const startResize = (e: React.PointerEvent, side: string) => { e.preventDefault(); const start = e.clientX, startY = e.clientY, root = document.querySelector(".editor") as HTMLElement, styles = getComputedStyle(root), initial = parseFloat(styles.getPropertyValue("--" + side)); const move = (ev: PointerEvent) => root.style.setProperty( "--" + side, Math.max( side === "bottom" ? 130 : 190, Math.min( side === "bottom" ? 450 : 430, initial + (side === "right" ? start - ev.clientX : side === "bottom" ? startY - ev.clientY : ev.clientX - start), ), ) + "px", ); const up = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); }; const stickMove = (e: React.PointerEvent) => { if (stickId.current !== e.pointerId || !runtime.current) return; const b = e.currentTarget.getBoundingClientRect(), x = (e.clientX - b.left - b.width / 2) / 35, y = (e.clientY - b.top - b.height / 2) / 35, l = Math.max(1, Math.hypot(x, y)); runtime.current.touch.x = x / l; runtime.current.touch.z = -y / l; if (knob.current) knob.current.style.transform = "translate(" + (x / l) * 28 + "px," + (y / l) * 28 + "px)"; }; const releaseStick = () => { stickId.current = null; if (runtime.current) runtime.current.touch.x = runtime.current.touch.z = 0; if (knob.current) knob.current.style.transform = ""; }; if (!project) return (

Forma

Готовим пространство для твоей игры…

); const canUndo = connection ? connection.canUndo : store.current?.canUndo, canRedo = connection ? connection.canRedo : store.current?.canRedo; const actions = [ { name: "Пустой проект", run: () => { setModal("new"); }, }, { name: "Создать уровень", run: () => { setKind("arena"); setModal("model"); }, }, { name: "Сохранить проект", run: () => void save() }, { name: "Сборка игры", run: () => setModal("build") }, { name: "Скачать веб-сборку", run: () => void exportGame() }, { name: "Подключение MCP", run: () => setModal("mcp") }, { name: "Настройки сцены", run: () => setModal("settings") }, ...Object.entries(shapes).map(([type, name]) => ({ name: "Добавить " + name, run: () => void addObject(type), })), ]; return (
{ if (e.target.files) void importFiles(e.target.files); e.target.value = ""; }} /> { if (e.target.files) void importFiles(e.target.files); e.target.value = ""; }} />
FormaENGINE
{menu === "project" && (
)}
{connection ? "На диске" : cached ? "Автокопия сохранена" : browserOnly ? "Проект в браузере" : "Локальный проект"}
e.preventDefault()} onDrop={(e) => { e.preventDefault(); if (e.dataTransfer.files.length) void importFiles(e.dataTransfer.files); else { const id = e.dataTransfer.getData("forma/asset"); if (id) void instantiate(id); } }} >
void togglePlay()} /> { if (runtime.current) { runtime.current.releaseInput(); runtime.current.paused = !paused; setPaused(!paused); } }} />
{playing ? "LIVE" : "Перспектива"} setModal("settings")} />
{ const r = runtime.current; if ( r?.playing && !r.paused && r.state.some((n) => n.components.camera?.mode === "firstPerson") ) { try { void canvas.current?.requestPointerLock()?.catch(() => {}); } catch {} } }} /> {!playing && ( <>
{[ [MousePointer2, "select", "Выбрать · Q"], [Move, "move", "Перемещение · W"], [RotateCw, "rotate", "Вращение · E"], [Maximize2, "scale", "Масштаб · R"], ].map(([I, t, label]) => ( { setTool(t as string); runtime.current?.setTool(t as string); }} /> ))}
{ setGrid(!grid); runtime.current?.grid.setEnabled(!grid); }} />
{scene!.name} {node?.name || "Рабочее пространство"}
ЛКМ · вращение Колесо · масштаб{" "} ПКМ · панорама F фокус
)} {playing && !project.settings.presentation && ( <>
{project.name} {stats.firstPerson ? "Клик · захват мыши / Esc · отпустить мышь" : "Ввод передаётся скриптам проекта"}
{ stickId.current = e.pointerId; e.currentTarget.setPointerCapture(e.pointerId); stickMove(e); }} onPointerMove={stickMove} onPointerUp={releaseStick} onPointerCancel={releaseStick} >
)} {busy && (
{busy}…
)}
{playing ? "Игровой режим" : "Режим редактирования"}
{stats.fps} FPS {Math.round(stats.triangles).toLocaleString("ru")} треуг.
startResize(e, "bottom")} />
{[ [Package, "assets", "Ресурсы"], [Code2, "scripts", "Скрипты"], [Terminal, "console", "Консоль"], [Undo2, "history", "История"], ].map(([I, id, label]) => ( ))}
setModal("help")} />
{bottom === "assets" ? (
Проект Assets{" "} { project.assets.filter( (a) => resourceFilter === "all" || a.kind === resourceFilter, ).length }{" "} ресурсов
{project.assets .filter( (a) => resourceFilter === "all" || a.kind === resourceFilter, ) .map((a) => ( ))}
) : bottom === "scripts" ? (
{project.scripts.map((s) => ( ))}
) : bottom === "console" ? (
{logs.length ? ( logs.map((l, i) => ( )) ) : (

Здесь появятся сообщения игры и ошибки скриптов.

)}
) : (
{(store.current?.history || []).map((h, i) => (
r{h.revision} {h.label} {h.source === "mcp" ? "ИИ / MCP" : "Редактор"}
))}
)}
Forma Engine 0.3 Проект · r{project.revision}
{toast && (
{toast}
)} {modal === "build" && project && ( )} {modal === "model" && (
{[ ["arena", "Уровень"], ["character", "Персонаж"], ["extrude", "Выдавливание"], ["lathe", "Вращение"], ["custom", "Своя сетка"], ].map(([k, n]) => ( ))}
{kind === "arena" ? ( ) : kind === "character" ? ( ) : ( )}
{kind === "arena" ? "Процедурный уровень" : kind === "character" ? "Составная модель" : "От профиля к объёму"}

{kind === "character" ? "Составная статичная модель. Для скелетных анимаций импортируй GLB." : "Результат появится в иерархии. Любую часть можно изменить."}

{kind === "arena" ? (
) : ( <>
{(kind === "extrude" || kind === "lathe") && (