Files
2026-09-12 04:58:59 +03:00

3351 lines
116 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
editorDraftKey,
type EditorStartupOptions,
} from "./startup.ts";
import {
portableModel,
modelComponents,
modelDataUri,
modelProject,
} from "../engine/model-import.ts";
import "./editor.css";
import { project2D } from "../engine/template2d.ts";
import { imageAsset } from "../engine/image-import.ts";
import { Inspector2D, Field2D } from "./Inspector2D.tsx";
import { SpriteEditor } from "./SpriteEditor.tsx";
import { BuildPanel } from "./BuildPanel.tsx";
const names: Record<string, string> = {
sprite: "Спрайт",
tilemap: "Карта тайлов",
spriteAnimator: "Анимация спрайта",
collider2d: "Коллайдер 2D",
rigidbody2d: "Тело 2D",
character2d: "Персонаж 2D",
joint2d: "Соединение 2D",
mesh: "Геометрия",
material: "Материал",
collider: "Коллайдер",
rigidbody: "Физическое тело",
script: "Поведение",
camera: "Камера",
character: "Контроллер персонажа",
light: "Свет",
animator: "Анимации",
data: "Игровые данные",
};
const shapes: Record<string, string> = {
sprite: "Спрайт",
tilemap: "Карта тайлов",
character2d: "Персонаж 2D",
camera2d: "Камера 2D",
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 (
<button
className={"icon " + (active ? "active" : "")}
title={label}
aria-label={label}
onClick={onClick}
disabled={disabled}
>
<I size={16} />
</button>
);
}
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 (
<input
type="number"
aria-label={label}
value={d}
step={step}
min={min}
max={max}
onChange={(e) => 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 (
<input
aria-label={label}
value={d}
onChange={(e) => 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 (
<div className="vector">
{["X", "Y", "Z"].map((a, i) => (
<label key={a}>
<span className={"axis" + i}>{a}</span>
<Num
value={value[i] * (angle ? 180 / Math.PI : 1)}
label={label + " " + a}
onChange={(v) => {
const n = [...value] as Vec3;
n[i] = v / (angle ? 180 / Math.PI : 1);
onChange(n);
}}
/>
</label>
))}
</div>
);
}
function Section({
title,
children,
action,
}: {
title: string;
children: ReactNode;
action?: ReactNode;
}) {
const [open, setOpen] = useState(true);
return (
<section className="section">
<div className="section-title">
<button onClick={() => setOpen(!open)}>
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<strong>{title}</strong>
</button>
{action}
</div>
{open && <div className="section-body">{children}</div>}
</section>
);
}
function Modal({
title,
subtitle,
children,
onClose,
wide = false,
}: {
title: string;
subtitle?: string;
children: ReactNode;
onClose: () => void;
wide?: boolean;
}) {
return (
<div
className="backdrop"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<section
className={"modal " + (wide ? "wide" : "")}
role="dialog"
aria-label={title}
aria-modal="true"
>
<header>
<div>
<h2>{title}</h2>
{subtitle && <p>{subtitle}</p>}
</div>
<Icon icon={X} label="Закрыть" onClick={onClose} />
</header>
{children}
</section>
</div>
);
}
export default function Editor({
initialProject,
browserOnly = false,
draftScope,
}: EditorStartupOptions = {}) {
const [view2D, setView2D] = useState(false),
[imageId, setImageId] = useState<string | null>(null);
const [project, setProject] = useState<Project | null>(null),
[selected, setSelected] = useState<string | null>(null),
[connection, setConnection] = useState<any>(null),
[ready, setReady] = useState(false),
[playing, setPlaying] = useState(false),
[paused, setPaused] = useState(false),
[stats, setStats] = useState<any>({ fps: 0, triangles: 0 }),
[logs, setLogs] = useState<any[]>([]),
[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<string | null>(null),
[modal, setModal] = useState<string | null>(null),
[search, setSearch] = useState(""),
[tool, setTool] = useState("move"),
[snap, setSnap] = useState(false),
[grid, setGrid] = useState(true),
[collapsed, setCollapsed] = useState(new Set<string>());
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<ProjectStore | null>(null),
runtime = useRef<FormaRuntime | null>(null),
canvas = useRef<HTMLCanvasElement | null>(null),
connected = useRef<any>(null),
events = useRef<EventSource | null>(null),
saveTimer = useRef<any>(null),
toastTimer = useRef<any>(null),
file = useRef<HTMLInputElement | null>(null),
imageFile = useRef<HTMLInputElement | null>(null),
modelFile = useRef<HTMLInputElement | null>(null),
sceneFile = useRef<HTMLInputElement | null>(null),
viewport = useRef<HTMLDivElement | null>(null),
stickId = useRef<number | null>(null),
knob = useRef<HTMLDivElement | null>(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, draftScope });
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,
editorDraftKey({ initialProject, browserOnly, draftScope }),
)
.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, draftScope]);
useEffect(() => {
if (!ready || !canvas.current) return;
const r = new FormaRuntime(canvas.current, {
select: setSelected,
transform: (id, t) => void patch(id, { transform: t }),
paintTiles: (id, cells) =>
void patch(id, { components: { tilemap: { cells } } }),
log: addLog,
stats: setStats,
mode: (p) => {
setPlaying(p);
setPaused(false);
},
});
runtime.current = r;
r.load(store.current!.project)
.then(() => {
r.select(selected);
r.setView2D(activeScene(store.current!.project).mode === "2d");
})
.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);
if (runtime.current) runtime.current.view2d.brush = null;
}, [selected]);
useEffect(() => {
if (project) {
const enabled = activeScene(project).mode === "2d";
setView2D(enabled);
runtime.current?.setView2D(enabled);
}
}, [project?.activeSceneId, project && activeScene(project).mode, ready]);
const switch2D = (enabled: boolean) => {
setView2D(enabled);
runtime.current?.setView2D(enabled);
};
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 (imageId) {
setImageId(null);
return;
}
if (modal === "script" && dirty) setUnsavedClose(true);
else setModal(null);
setMenu(null);
return;
}
if (typing || modal || imageId || 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);
if (runtime.current) runtime.current.view2d.brush = null;
}
};
window.addEventListener("keydown", key);
return () => window.removeEventListener("keydown", key);
}, [save, modal, imageId, 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;
if (["sprite", "tilemap", "character2d", "camera2d"].includes(type)) {
const components: any =
type === "camera2d"
? {
camera: {
mode: "2d",
orthoWidth: 20,
pixelsPerUnit: 100,
targetId: selected || "",
offset: [0, 0, 0],
},
}
: type === "tilemap"
? { tilemap: clone(componentDefaults.tilemap) }
: { sprite: clone(componentDefaults.sprite) };
if (type === "character2d")
Object.assign(components, {
collider2d: clone(componentDefaults.collider2d),
rigidbody2d: {
...clone(componentDefaults.rigidbody2d),
type: "kinematic",
},
character2d: clone(componentDefaults.character2d),
});
const n = entity(
shapes[type],
components,
type === "camera2d" ? [0, 0, 20] : [0, 0, 0],
);
if (await cmd("node.create", { entity: n }, "Добавить " + n.name)) {
setSelected(n.id);
switch2D(true);
}
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 === "image") {
const n = entity(a.name, {
sprite: { ...clone(componentDefaults.sprite), assetId: id },
});
if (await cmd("node.create", { entity: n }, "Добавить спрайт")) {
setSelected(n.id);
switch2D(true);
}
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[], asScene = false) => {
const selectedFiles = Array.from(files);
const imageOnly = !selectedFiles.some((f) =>
/\.(gltf|glb|zip|forma|json)$/i.test(f.name),
);
if (imageOnly)
for (const f of selectedFiles.filter((f) =>
/\.(png|jpe?g|webp)$/i.test(f.name),
)) {
setBusy("Импорт " + f.name);
try {
const a = imageAsset(new Uint8Array(await f.arrayBuffer()), f.name);
const bitmap = await createImageBitmap(f);
bitmap.close();
const n = entity(f.name, {
sprite: { ...clone(componentDefaults.sprite), assetId: a.id },
});
if (
await execute(
[
{ op: "asset.upsert", args: { asset: a } },
{ op: "node.create", args: { entity: n } },
],
"Импорт спрайта",
)
) {
setSelected(n.id);
setImageId(a.id);
switch2D(true);
}
} catch (e) {
notify(String(e));
} finally {
setBusy("");
}
}
const entries = new Map(
selectedFiles.map((file) => [file.webkitRelativePath || file.name, file]),
);
for (const f of selectedFiles.filter((file) =>
/\.(forma|json|zip|glb|gltf)$/i.test(file.name),
)) {
setBusy("Импорт " + f.name);
try {
if (f.size > 80 * 1024 * 1024)
throw Error("Лимит файла импорта: 80 МБ");
const bytes = new Uint8Array(await f.arrayBuffer());
if (/\.(forma|json|zip)$/i.test(f.name)) {
let imported: Project | undefined;
try {
imported = unpackProject(bytes);
} catch (error) {
if (
!/\.zip$/i.test(f.name) ||
!(error instanceof Error) ||
error.message !== "Нет project.forma.json"
)
throw error;
}
if (imported) {
if (
await cmd(
"project.replace",
{ project: imported },
"Открыть проект",
)
) {
setSelected(null);
notify("Проект открыт");
}
continue;
}
}
const model = await portableModel(
bytes,
f.webkitRelativePath || f.name,
async (path) => {
const file = entries.get(path);
if (!file)
throw Error(
"Отсутствует " + path + ". Упакуй glTF с ресурсами в ZIP.",
);
if (file.size > 25 * 1024 * 1024)
throw Error("Лимит ресурса: 25 МБ");
return new Uint8Array(await file.arrayBuffer());
},
);
if (asScene) {
if (
await cmd(
"project.replace",
{ project: modelProject(model) },
"Открыть сцену Blender",
)
) {
setSelected(null);
notify(
"Сцена Blender открыта; исходную сцену можно вернуть через Undo",
);
for (const warning of model.metadata.warnings)
addLog("warning", warning);
}
continue;
}
const id = uid("asset");
const n = entity(
model.name.replace(/\.(glb|gltf)$/i, ""),
modelComponents(id, model.metadata),
);
const result = await execute(
[
{
op: "asset.upsert",
args: {
asset: {
id,
name: model.name,
kind: "model",
metadata: model.metadata,
uri: modelDataUri(model),
},
},
},
{ op: "node.create", args: { entity: n } },
],
"Импорт " + model.name,
);
if (result) {
setSelected(n.id);
for (const warning of model.metadata.warnings)
addLog("warning", warning, n.id);
notify(
`Импортировано: ${model.metadata.nodes} узлов, ${model.metadata.clips.length} клипов, ${model.metadata.animatedProperties.length} анимированных свойств`,
);
}
} catch (error) {
notify(String(error));
addLog("error", String(error));
} 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,
) => (
<select
aria-label={label}
value={value || ""}
onChange={(e) => change(e.target.value)}
>
<option value="">Не назначено</option>
{scene!.entities.map((n) => (
<option key={n.id} value={n.id}>
{n.name}
</option>
))}
</select>
);
const fieldsFor = (type: string, c: any) =>
Object.entries(c).map(([key, value]) => {
if (["geometry", "assetId"].includes(key)) return null;
return (
<div className="property" key={key}>
<label>{key}</label>
{typeof value === "boolean" ? (
<input
aria-label={key}
type="checkbox"
checked={value}
onChange={(e) => componentField(type, key, e.target.checked)}
/>
) : typeof value === "number" ? (
<Num
value={value}
label={key}
onChange={(v) => componentField(type, key, v)}
/>
) : typeof value === "string" ? (
<TextInput
label={key}
value={value}
onCommit={(v) => void componentField(type, key, v)}
/>
) : (
<span className="muted">JSON</span>
)}
</div>
);
});
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 (
<React.Fragment key={n.id}>
<div
className={
"tree-row " +
(selected === n.id ? "selected" : "") +
(!n.enabled ? " disabled" : "")
}
style={{ paddingLeft: 12 + depth * 15 }}
draggable={!playing}
onDragStart={(e) => 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)}
>
<button
className="tree-toggle"
aria-label={(open ? "Свернуть " : "Развернуть ") + n.name}
onClick={(e) => {
e.stopPropagation();
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(n.id)) next.delete(n.id);
else next.add(n.id);
return next;
});
}}
>
{children.length ? (
open ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)
) : null}
</button>
<I size={14} />
<span>{n.name}</span>
<button
className="tree-eye"
aria-label={(n.enabled ? "Скрыть " : "Показать ") + n.name}
onClick={(e) => {
e.stopPropagation();
void patch(n.id, { enabled: !n.enabled });
}}
>
{n.enabled ? <Eye size={12} /> : <EyeOff size={12} />}
</button>
</div>
{open && children.map((c) => row(c, depth + 1))}
</React.Fragment>
);
};
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 (
<div className="boot">
<div className="brand-mark">
<Box size={27} />
</div>
<h1>Forma</h1>
<p>Готовим пространство для твоей игры…</p>
</div>
);
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 (
<div className={"editor " + (playing ? "is-playing" : "")}>
<input
ref={file}
type="file"
hidden
accept=".forma,.json,.zip"
onChange={(e) => {
if (e.target.files) void importFiles(e.target.files);
e.target.value = "";
}}
/>
<input
ref={imageFile}
type="file"
hidden
multiple
accept=".png,.jpg,.jpeg,.webp"
onChange={(e) => {
if (e.target.files) void importFiles(e.target.files);
e.target.value = "";
}}
/>
<input
ref={modelFile}
type="file"
hidden
multiple
accept=".glb,.gltf,.zip,.bin,.png,.jpg,.jpeg,.webp"
onChange={(e) => {
if (e.target.files) void importFiles(e.target.files);
e.target.value = "";
}}
/>
<input
ref={sceneFile}
type="file"
hidden
accept=".glb,.gltf,.zip"
onChange={(e) => {
if (e.target.files) void importFiles(e.target.files, true);
e.target.value = "";
}}
/>
<header className="topbar">
<div className="brand">
<div className="brand-mark">
<Box size={21} />
</div>
<strong>
Forma<span>ENGINE</span>
</strong>
</div>
<div className="project-menu">
<button
className="project-name"
onClick={() => setMenu(menu === "project" ? null : "project")}
>
<Folder size={14} />
{project.name}
<ChevronDown size={13} />
</button>
{menu === "project" && (
<div className="dropdown">
<button
onClick={() => {
setModal("new");
setMenu(null);
}}
>
<Plus size={14} />
Новый проект
</button>
<button
onClick={() => {
file.current?.click();
setMenu(null);
}}
>
<Folder size={14} />
Открыть .forma
</button>
<button
onClick={() => {
setRename(project.name);
setModal("rename");
setMenu(null);
}}
>
<Code2 size={14} />
Переименовать
</button>
<button onClick={() => void save()}>
<Save size={14} />
Сохранить <kbd>Ctrl S</kbd>
</button>
</div>
)}
</div>
<span
className="save-status"
title={
browserOnly
? "Правки сохраняются только в этом браузере. Скачай .forma через меню проекта, чтобы перенести их."
: undefined
}
>
<i />
{connection
? "На диске"
: cached
? "Автокопия сохранена"
: browserOnly
? "Проект в браузере"
: "Локальный проект"}
</span>
<button className="command-search" onClick={() => setModal("search")}>
<Search size={14} />
<span>Найти действие…</span>
<kbd> K</kbd>
</button>
<div className="header-actions">
<button className="quiet" onClick={() => setModal("mcp")}>
<Link size={15} />
Подключить ИИ
</button>
<button
className="primary"
onClick={() => setModal("build")}
disabled={!!busy}
>
<Download size={15} />
Сборка игры
</button>
</div>
</header>
<aside className="hierarchy">
<div className="panel-title">
<span>
<Layers size={14} />
Иерархия
</span>
<Icon
icon={Plus}
label="Добавить объект"
onClick={() => setMenu(menu === "add" ? null : "add")}
/>
</div>
{menu === "add" && (
<div className="dropdown add-menu">
{Object.entries(shapes).map(([t, n]) => (
<button key={t} onClick={() => void addObject(t)}>
<Box size={13} />
{n}
</button>
))}
</div>
)}
<label className="filter">
<Search size={13} />
<input
placeholder="Найти объект"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</label>
<div className="scene-name">
<ChevronDown size={12} />
<select
aria-label="Активная сцена"
value={project.activeSceneId}
onChange={(e) =>
void cmd(
"scene.activate",
{ id: e.target.value },
"Открыть сцену",
)
}
>
{project.scenes.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<Icon
icon={Plus}
label="Создать сцену"
onClick={() =>
void cmd(
"scene.create",
{ name: "Сцена " + (project.scenes.length + 1) },
"Создать сцену",
)
}
/>
<button
title="Создать пустую 2D-сцену"
onClick={() =>
void cmd(
"scene.create",
{ name: "2D Сцена " + (project.scenes.length + 1), mode: "2d" },
"Создать 2D-сцену",
)
}
>
+ 2D
</button>
</div>
<div
className="tree"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
const id = e.dataTransfer.getData("forma/entity");
if (id)
void cmd(
"node.reparent",
{ id, parentId: null },
"В корень сцены",
);
}}
>
{scene!.entities.filter((n) => !n.parentId).map((n) => row(n))}
{!scene!.entities.length && (
<div className="empty-tree">
<Layers size={28} />
<strong>Начни с идеи</strong>
<p>Добавь объект или создай уровень.</p>
<button
onClick={() => {
setKind("arena");
setModal("model");
}}
>
Создать уровень
</button>
</div>
)}
</div>
<div className="hierarchy-footer">
<span>{scene!.entities.length} объектов</span>
<div>
<Icon
icon={Undo2}
label="Отменить · Ctrl Z"
disabled={!canUndo || playing}
onClick={() => void historyAction("undo")}
/>
<Icon
icon={Redo2}
label="Повторить · Ctrl Shift Z"
disabled={!canRedo || playing}
onClick={() => void historyAction("redo")}
/>
</div>
</div>
<div
className="resize left"
onPointerDown={(e) => startResize(e, "left")}
/>
</aside>
<main
className="viewport"
ref={viewport}
onDragOver={(e) => 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);
}
}}
>
<div className="viewbar">
<div className="view-tabs">
<button
className={!playing ? "active" : ""}
onClick={() => {
if (playing) void togglePlay();
}}
>
<Box size={13} />
Сцена
</button>
<button
className={playing ? "active" : ""}
onClick={() => {
if (!playing) void togglePlay();
}}
>
<Play size={12} />
Игра
</button>
</div>
<div className="play-controls">
<Icon
icon={playing ? Square : Play}
label={playing ? "Остановить игру" : "Запустить игру"}
active={playing}
disabled={!!busy}
onClick={() => void togglePlay()}
/>
<Icon
icon={Pause}
label="Пауза"
disabled={!playing}
active={paused}
onClick={() => {
if (runtime.current) {
runtime.current.releaseInput();
runtime.current.paused = !paused;
setPaused(!paused);
}
}}
/>
</div>
<div className="view-options">
<button
disabled={playing}
className={view2D ? "active" : ""}
onClick={() => switch2D(!view2D)}
title="Переключить редактор XY / 3D"
>
{view2D ? "2D · XY" : "3D"}
</button>
<span>
{playing ? "LIVE" : view2D ? "Ортография" : "Перспектива"}
</span>
<Icon
icon={Settings}
label="Настройки сцены"
onClick={() => setModal("settings")}
/>
</div>
</div>
<div className="canvas-wrap">
<canvas ref={canvas} aria-label="Сцена Forma" tabIndex={0} />
{!playing && (
<>
<div className="transform-tools">
{[
[MousePointer2, "select", "Выбрать · Q"],
[Move, "move", "Перемещение · W"],
[RotateCw, "rotate", "Вращение · E"],
[Maximize2, "scale", "Масштаб · R"],
].map(([I, t, label]) => (
<Icon
key={t as string}
icon={I as ElementType}
label={label as string}
active={tool === t}
onClick={() => {
setTool(t as string);
runtime.current?.setTool(t as string);
if (runtime.current) runtime.current.view2d.brush = null;
}}
/>
))}
</div>
<div className="viewport-top-right">
<button
className={snap ? "active" : ""}
onClick={() => {
setSnap(!snap);
runtime.current?.setSnap(!snap);
}}
>
Шаг 0.5
</button>
<Icon
icon={Grid3X3}
label="Сетка"
active={grid}
onClick={() => {
setGrid(!grid);
runtime.current?.grid.setEnabled(!grid);
}}
/>
<button onClick={() => runtime.current?.topView()}>
Вид сверху
</button>
</div>
<div className="world-label">
<span>{scene!.name}</span>
<small>{node?.name || "Рабочее пространство"}</small>
</div>
<div className="view-hint">
{view2D ? "ЛКМ · выбрать / переместить" : "ЛКМ · вращение"}{" "}
<span>Колесо · масштаб</span> <span>ПКМ · панорама</span>{" "}
<kbd>F</kbd> фокус
</div>
<button
className="model-shortcut"
onClick={() => {
setKind("arena");
setModal("model");
}}
>
<WandSparkles size={15} />
Создать модель
</button>
</>
)}
{playing && !project.settings.presentation && (
<>
<div className="game-hud">
<div>
<span>{project.name}</span>
<small>
{stats.firstPerson
? "Клик · захват мыши / Esc · отпустить мышь"
: stats.twoD
? "WASD / стрелки · движение, пробел · прыжок"
: "Ввод передаётся скриптам проекта"}
</small>
</div>
<button
onClick={() => {
void runtime.current
?.stop(project)
.then(() => runtime.current?.play(project));
}}
>
Начать заново
</button>
</div>
<div className="touch-controls">
<div
className="stick"
aria-label="Джойстик"
onPointerDown={(e) => {
stickId.current = e.pointerId;
e.currentTarget.setPointerCapture(e.pointerId);
stickMove(e);
}}
onPointerMove={stickMove}
onPointerUp={releaseStick}
onPointerCancel={releaseStick}
>
<div ref={knob} />
</div>
<button
className="attack"
aria-label="Действие"
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
if (runtime.current) runtime.current.touch.attack = true;
}}
onPointerUp={() => {
if (runtime.current) runtime.current.touch.attack = false;
}}
onPointerCancel={() => {
if (runtime.current) runtime.current.touch.attack = false;
}}
>
A
</button>
</div>
</>
)}
{busy && (
<div className="busy">
<span className="spinner" />
{busy}
</div>
)}
</div>
<div className="view-status">
<span>
<i className={playing ? "live-dot" : ""} />
{playing ? "Игровой режим" : "Режим редактирования"}
</span>
<div>
<span>{stats.fps} FPS</span>
<span>
{Math.round(stats.triangles).toLocaleString("ru")} треуг.
</span>
<button onClick={() => void viewport.current?.requestFullscreen()}>
На весь экран <Maximize2 size={11} />
</button>
</div>
</div>
</main>
<aside className="inspector">
<div
className="resize right"
onPointerDown={(e) => startResize(e, "right")}
/>
<div className="panel-title">
<span>
<Settings size={14} />
Инспектор
</span>
<span className="tiny">СВОЙСТВА</span>
</div>
{node ? (
<>
<div className="object-heading">
<div className="object-icon">
{node.components.script ? (
<PersonStanding size={24} />
) : (
<Box size={24} />
)}
</div>
<div>
<TextInput
label="Имя объекта"
value={node.name}
onCommit={(name) => void patch(node.id, { name })}
/>
<small>
{node.components.script
? "Игровой объект"
: node.components.mesh
? "3D-объект"
: "Объект сцены"}
</small>
</div>
<input
aria-label="Объект включён"
type="checkbox"
checked={node.enabled}
onChange={(e) =>
void patch(node.id, { enabled: e.target.checked })
}
/>
</div>
<div className="inspector-scroll">
<Section title="Трансформация">
{(["position", "rotation", "scale"] as const).map((key, i) => (
<div className="transform-property" key={key}>
<label>{["Позиция", "Вращение", "Масштаб"][i]}</label>
<Vector
label={["Позиция", "Вращение", "Масштаб"][i]}
value={node.transform[key]}
angle={key === "rotation"}
onChange={(v) =>
void patch(node.id, { transform: { [key]: v } })
}
/>
</div>
))}
</Section>
{Object.entries(node.components).map(([type, c]) => (
<Section
key={node.id + type}
title={names[type] || type}
action={
<Icon
icon={X}
label={"Удалить компонент " + type}
disabled={playing}
onClick={() =>
void cmd(
"component.remove",
{ id: node.id, type },
"Удалить компонент",
)
}
/>
}
>
{[
"sprite",
"tilemap",
"spriteAnimator",
"collider2d",
"rigidbody2d",
"character2d",
"joint2d",
].includes(type) ? (
<Inspector2D
type={type}
c={c}
node={node}
project={project}
change={(v) => void setComponent(type, v)}
editImage={setImageId}
preview={(name) =>
runtime.current?.previewAnimation(node.id, name)
}
brush={(frame, fill) => {
switch2D(true);
if (runtime.current)
runtime.current.view2d.brush = {
id: node.id,
frame,
fill,
};
}}
/>
) : type === "mesh" ? (
<>
<div className="property">
<label>Тип</label>
<span>
{c.type === "model"
? "Импортированная модель"
: c.type === "custom"
? "Своя геометрия"
: shapes[c.type] || c.type}
</span>
</div>
{c.assetId && (
<div className="asset-ref">
<Package size={13} />
{project.assets.find((a) => a.id === c.assetId)?.name}
</div>
)}
{c.size && (
<Vector
label="Размер"
value={c.size}
onChange={(v) => void componentField(type, "size", v)}
/>
)}
</>
) : type === "material" ? (
<>
<div className="property">
<label>Цвет</label>
<div className="color-input">
<input
aria-label="Цвет материала"
type="color"
value={c.color || "#91a697"}
onChange={(e) =>
void componentField(type, "color", e.target.value)
}
/>
<span>{c.color}</span>
</div>
</div>
{["roughness", "metallic", "emissive", "alpha"].map(
(k, i) => (
<div className="property" key={k}>
<label>
{
[
"Шероховатость",
"Металличность",
"Свечение",
"Непрозрачность",
][i]
}
</label>
<Num
label={k}
value={c[k] ?? (k === "alpha" ? 1 : 0)}
min={0}
max={k === "emissive" ? 5 : 1}
onChange={(v) => void componentField(type, k, v)}
/>
</div>
),
)}
<label className="check-row">
<input
type="checkbox"
checked={c.unlit || false}
onChange={(e) =>
void componentField(type, "unlit", e.target.checked)
}
/>
Без освещения (2D / VFX)
</label>
{node.components.mesh?.type === "model" && (
<label className="check-row">
<input
type="checkbox"
checked={c.override || false}
onChange={(e) =>
void componentField(
type,
"override",
e.target.checked,
)
}
/>
Заменить материалы модели
</label>
)}
</>
) : type === "script" ? (
<>
<select
aria-label="Скрипт поведения"
value={c.scriptId}
onChange={(e) =>
void setComponent(type, {
scriptId: e.target.value,
params: {},
})
}
>
{project.scripts.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
{Object.entries(
project.scripts.find((s) => s.id === c.scriptId)
?.fields || {},
).map(([k, f]) => (
<div className="property" key={k}>
<label>{f.label || k}</label>
{f.type === "number" ? (
<Num
label={f.label || k}
value={c.params?.[k] ?? f.default}
min={f.min}
max={f.max}
onChange={(v) =>
void componentField(type, "params", {
...c.params,
[k]: v,
})
}
/>
) : f.type === "entity" ? (
entitySelect(
c.params?.[k] ?? f.default,
(v) =>
void componentField(type, "params", {
...c.params,
[k]: v,
}),
f.label || k,
)
) : f.type === "boolean" ? (
<input
aria-label={k}
type="checkbox"
checked={c.params?.[k] ?? f.default}
onChange={(e) =>
void componentField(type, "params", {
...c.params,
[k]: e.target.checked,
})
}
/>
) : (
<TextInput
label={k}
value={c.params?.[k] ?? f.default}
onCommit={(v) =>
void componentField(type, "params", {
...c.params,
[k]: v,
})
}
/>
)}
</div>
))}
<button
className="wide-button"
onClick={() => openScript(c.scriptId)}
>
<Code2 size={13} />
Открыть скрипт
</button>
</>
) : type === "animator" ? (
<>
<div className="property">
<label>Автозапуск при Play</label>
<select
aria-label="Клип автозапуска"
value={c.autoplay || ""}
onChange={(e) =>
void componentField(
type,
"autoplay",
e.target.value,
)
}
>
<option value="">Не запускать</option>
{(
runtime.current?.importInfo.get(
node.components.mesh?.assetId,
)?.clips || []
).map((clip: string) => (
<option key={clip} value={clip}>
{clip}
</option>
))}
</select>
</div>
<button
className="wide-button"
onClick={() =>
runtime.current?.previewAnimation(
node.id,
c.autoplay ||
runtime.current?.importInfo.get(
node.components.mesh?.assetId,
)?.clips?.[0],
)
}
>
Предпросмотр клипа
</button>
{["idle", "run", "attack", "death"].map((k) => (
<div className="property" key={k}>
<label>{k}</label>
<TextInput
label={"Анимация " + k}
value={c[k] || ""}
onCommit={(v) => void componentField(type, k, v)}
/>
<Icon
icon={Play}
label={"Проверить " + k}
onClick={() =>
runtime.current?.previewAnimation(node.id, c[k])
}
/>
</div>
))}
<p className="small-note">
Клипы:{" "}
{runtime.current?.importInfo
.get(node.components.mesh?.assetId)
?.clips.join(", ") || "из GLB"}
</p>
</>
) : type === "camera" ? (
<>
<div className="property">
<label>Режим</label>
<select
aria-label="Режим камеры"
value={c.mode || "follow"}
onChange={(e) =>
void componentField(type, "mode", e.target.value)
}
>
<option value="2d">2D · XY</option>
<option value="follow">Следование</option>
<option value="fixed">Фиксированная</option>
{node.components.mesh?.type === "model" && (
<option value="imported">Из Blender / glTF</option>
)}
<option value="firstPerson">От первого лица</option>
</select>
</div>
{c.mode === "imported" && (
<div className="property">
<label>Камера модели</label>
<select
aria-label="Камера модели"
value={c.cameraName || ""}
onChange={(e) =>
void componentField(
type,
"cameraName",
e.target.value,
)
}
>
<option value="">Первая камера</option>
{(
runtime.current?.importInfo.get(
node.components.mesh?.assetId,
)?.cameraNames || []
).map((name: string) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
)}
{c.mode === "2d" && (
<>
<Inspector2D
type="camera2d"
c={c}
node={node}
project={project}
change={(v) => void setComponent(type, v)}
editImage={setImageId}
preview={() => {}}
brush={() => {}}
/>
<div className="property">
<label>Цель</label>
{entitySelect(
c.targetId,
(v) => void componentField(type, "targetId", v),
"Цель камеры 2D",
)}
</div>
</>
)}
{c.mode !== "imported" && c.mode !== "2d" && (
<>
<div className="property">
<label>Проекция</label>
<select
aria-label="Проекция камеры"
value={c.projection || "perspective"}
onChange={(e) =>
void setComponent(type, {
...c,
projection: e.target.value,
orthoWidth: c.orthoWidth || 20,
})
}
>
<option value="perspective">Перспектива</option>
<option value="orthographic">
Ортографическая
</option>
</select>
</div>
{c.projection === "orthographic" && (
<div className="property">
<label>Ширина кадра</label>
<Num
label="Ширина ортокамеры"
value={c.orthoWidth || 20}
min={0.1}
max={200}
onChange={(v) =>
void componentField(type, "orthoWidth", v)
}
/>
</div>
)}
{c.mode === "fixed" && (
<Vector
label="Точка взгляда"
value={c.lookAt || [0, 0, 0]}
onChange={(v) =>
void componentField(type, "lookAt", v)
}
/>
)}
<div className="property">
<label>Цель</label>
{entitySelect(
c.targetId,
(v) => void componentField(type, "targetId", v),
"Цель камеры",
)}
</div>
<label className="field-label">Смещение</label>
<Vector
label="Смещение камеры"
value={c.offset || [0, 13, -10]}
onChange={(v) =>
void componentField(type, "offset", v)
}
/>
<div className="property">
<label>FOV, радиан</label>
<Num
label="FOV"
min={0.1}
max={2}
value={c.fov || 0.72}
onChange={(v) =>
void componentField(type, "fov", v)
}
/>
</div>
</>
)}
</>
) : type === "character" ? (
<>
{[
["gravity", "Гравитация", 24],
["autostep", "Высота ступени", 0.25],
].map(([key, label, value]) => (
<div className="property" key={String(key)}>
<label>{String(label)}</label>
<Num
label={String(label)}
min={0}
max={50}
value={c[String(key)] ?? Number(value)}
onChange={(v) =>
void componentField(type, String(key), v)
}
/>
</div>
))}
</>
) : type === "collider" ? (
<>
<div className="property">
<label>Форма</label>
<select
aria-label="Форма коллайдера"
value={c.shape}
onChange={(e) =>
void componentField(type, "shape", e.target.value)
}
>
<option value="box">Коробка</option>
<option value="ball">Сфера</option>
<option value="capsule">Капсула</option>
</select>
</div>
{c.shape === "box" ? (
<Vector
label="Коллайдер"
value={c.size || [1, 1, 1]}
onChange={(v) => void componentField(type, "size", v)}
/>
) : (
<>
<div className="property">
<label>Радиус</label>
<Num
label="Радиус"
min={0.01}
value={c.radius || 0.3}
onChange={(v) =>
void componentField(type, "radius", v)
}
/>
</div>
{c.shape === "capsule" && (
<div className="property">
<label>Высота</label>
<Num
label="Высота коллайдера"
min={0.1}
value={c.height || 1.8}
onChange={(v) =>
void componentField(type, "height", v)
}
/>
</div>
)}
</>
)}
<label className="field-label">Смещение</label>
<Vector
label="Смещение коллайдера"
value={c.offset || [0, 0, 0]}
onChange={(v) => void componentField(type, "offset", v)}
/>
</>
) : type === "rigidbody" ? (
<>
<select
aria-label="Тип физического тела"
value={c.type}
onChange={(e) =>
void componentField(type, "type", e.target.value)
}
>
<option value="fixed">Неподвижное</option>
<option value="kinematic">Управляемое</option>
<option value="dynamic">Динамическое</option>
</select>
<div className="property">
<label>Масса</label>
<Num
label="Масса"
min={0.01}
value={c.mass || 1}
onChange={(v) => void componentField(type, "mass", v)}
/>
</div>
</>
) : (
fieldsFor(type, c)
)}
</Section>
))}
<button
className="add-component"
onClick={() =>
setMenu(menu === "component" ? null : "component")
}
disabled={playing}
>
<Plus size={14} />
Добавить компонент
</button>
{menu === "component" && (
<div className="component-options">
{Object.entries(names)
.filter(
([k]) =>
!node.components[k] &&
(k !== "spriteAnimator" || node.components.sprite) &&
(!["sprite", "tilemap"].includes(k) ||
(!node.components.mesh &&
!node.components.sprite &&
!node.components.tilemap)),
)
.map(([k, v]) => (
<button
key={k}
onClick={() => {
if (
k === "rigidbody2d" &&
!node.components.collider2d
) {
void patch(node.id, {
components: {
collider2d: clone(componentDefaults.collider2d),
rigidbody2d: clone(
componentDefaults.rigidbody2d,
),
},
});
setMenu(null);
return;
}
if (k === "character2d") {
void patch(node.id, {
components: {
collider2d:
node.components.collider2d ||
clone(componentDefaults.collider2d),
rigidbody2d: {
...clone(componentDefaults.rigidbody2d),
type: "kinematic",
},
character2d: clone(
componentDefaults.character2d,
),
},
});
setMenu(null);
return;
}
if (k === "joint2d") {
const target = scene!.entities.find(
(n) =>
n.id !== node.id &&
n.components.rigidbody2d &&
n.components.collider2d,
);
if (!target) {
notify(
"Сначала создайте второе физическое тело 2D",
);
return;
}
void patch(node.id, {
components: {
collider2d:
node.components.collider2d ||
clone(componentDefaults.collider2d),
rigidbody2d:
node.components.rigidbody2d ||
clone(componentDefaults.rigidbody2d),
joint2d: {
...clone(componentDefaults.joint2d),
targetId: target.id,
},
},
});
setMenu(null);
return;
}
void setComponent(
k,
k === "script"
? {
scriptId: project.scripts[0]?.id || "",
params: {},
}
: clone(componentDefaults[k] || {}),
);
setMenu(null);
}}
>
{v}
</button>
))}
</div>
)}
<div className="object-actions">
<button
onClick={() =>
void cmd("prefab.create", { id: node.id }, "Создать префаб")
}
>
<Package size={13} />В префаб
</button>
<Icon
icon={Copy}
label="Дублировать объект"
onClick={() =>
void cmd("node.duplicate", { id: node.id }, "Дублировать")
}
/>
<Icon
icon={Trash2}
label="Удалить объект"
onClick={() => {
void cmd("node.delete", { id: node.id }, "Удалить объект");
setSelected(null);
}}
/>
</div>
<code className="object-id">{node.id}</code>
</div>
</>
) : (
<div className="empty-inspector">
<MousePointer2 size={27} />
<h3>Всё под рукой</h3>
<p>Выбери объект в сцене или иерархии, чтобы настроить его.</p>
<button onClick={() => setModal("help")}>
Краткое руководство <ChevronRight size={13} />
</button>
</div>
)}
</aside>
<section className="bottom-panel">
<div
className="resize bottom"
onPointerDown={(e) => startResize(e, "bottom")}
/>
<div className="bottom-bar">
<div className="bottom-tabs">
{[
[Package, "assets", "Ресурсы"],
[Code2, "scripts", "Скрипты"],
[Terminal, "console", "Консоль"],
[Undo2, "history", "История"],
].map(([I, id, label]) => (
<button
key={id as string}
className={bottom === id ? "active" : ""}
onClick={() => setBottom(id as string)}
>
{React.createElement(I as ElementType, { size: 13 })}
{label as string}
{id === "console" &&
logs.filter((l) => l.level === "error").length > 0 && (
<b className="error-count">
{logs.filter((l) => l.level === "error").length}
</b>
)}
</button>
))}
</div>
<div className="bottom-actions">
<button
onClick={() => sceneFile.current?.click()}
title="Открыть GLB или ZIP Blender как новый проект с камерами, светом и анимацией"
>
<Upload size={13} /> Открыть сцену Blender
</button>
<button onClick={() => imageFile.current?.click()}>
<Upload size={13} /> Импорт спрайтов
</button>
<button onClick={() => modelFile.current?.click()}>
<Upload size={13} />
Импорт модели
</button>
<Icon
icon={HelpCircle}
label="Справка"
onClick={() => setModal("help")}
/>
</div>
</div>
{bottom === "assets" ? (
<div className="asset-browser">
<nav>
{[
["all", "Все ресурсы"],
["image", "Спрайты"],
["model", "Модели"],
["geometry", "Геометрия"],
["prefab", "Префабы"],
].map(([k, n]) => (
<button
key={k}
className={resourceFilter === k ? "active" : ""}
onClick={() => setResourceFilter(k)}
>
{n}
</button>
))}
</nav>
<div className="asset-content">
<div className="asset-breadcrumb">
<Folder size={12} />
Проект <ChevronRight size={11} /> Assets{" "}
<span>
{
project.assets.filter(
(a) =>
resourceFilter === "all" || a.kind === resourceFilter,
).length
}{" "}
ресурсов
</span>
</div>
<div className="asset-grid">
{project.assets
.filter(
(a) =>
resourceFilter === "all" || a.kind === resourceFilter,
)
.map((a) => (
<button
className="asset-card"
key={a.id}
draggable
onDragStart={(e) =>
e.dataTransfer.setData("forma/asset", a.id)
}
onContextMenu={(e) => {
if (a.kind === "image") {
e.preventDefault();
setImageId(a.id);
}
}}
onDoubleClick={() => void instantiate(a.id)}
onClick={() =>
notify("Двойной клик или перетащи ресурс в сцену")
}
>
<div className={"asset-art " + a.kind}>
{a.kind === "image" ? (
<img
src={a.uri}
alt={a.name}
style={{
maxWidth: 70,
maxHeight: 50,
imageRendering: "pixelated",
}}
/>
) : a.name.toLowerCase().includes("sentinel") ? (
<PersonStanding size={38} strokeWidth={1.2} />
) : a.kind === "prefab" ? (
<Package size={35} strokeWidth={1.2} />
) : (
<Box size={36} strokeWidth={1.2} />
)}
<span>
{a.kind === "image"
? "SPRITE"
: a.kind === "prefab"
? "PREFAB"
: a.kind === "geometry"
? "MESH"
: "GLB"}
</span>
</div>
<strong>{a.name}</strong>
<small>
{a.kind === "image"
? a.image!.frames.length + " кадров · ПКМ: нарезка"
: a.kind === "prefab"
? (a.entities?.length || 0) + " объектов"
: a.metadata?.clips
? a.metadata.clips.length + " анимаций"
: "3D-ресурс"}
</small>
</button>
))}
<button
className="asset-card add-asset"
onClick={() => {
setKind("character");
setModal("model");
}}
>
<div>
<Plus size={24} />
</div>
<strong>Создать модель</strong>
<small>Без Blender</small>
</button>
</div>
</div>
</div>
) : bottom === "scripts" ? (
<div className="script-list">
{project.scripts.map((s) => (
<button key={s.id} onClick={() => openScript(s.id)}>
<Code2 size={26} />
<strong>{s.name}</strong>
<small>{Object.keys(s.fields).length} параметров</small>
</button>
))}
<button onClick={() => void createScript()}>
<Plus size={26} />
<strong>Новый скрипт</strong>
</button>
</div>
) : bottom === "console" ? (
<div className="log-list">
{logs.length ? (
logs.map((l, i) => (
<button
className={"log " + l.level}
key={i}
onClick={() => {
if (l.id) setSelected(l.id);
}}
>
<time>{new Date(l.time).toLocaleTimeString("ru")}</time>
<span>{l.level === "error" ? "!" : "·"}</span>
{l.message}
</button>
))
) : (
<p>Здесь появятся сообщения игры и ошибки скриптов.</p>
)}
</div>
) : (
<div className="history-list">
{(store.current?.history || []).map((h, i) => (
<div key={i}>
<span>r{h.revision}</span>
<strong>{h.label}</strong>
<small>{h.source === "mcp" ? "ИИ / MCP" : "Редактор"}</small>
<time>{new Date(h.time).toLocaleTimeString("ru")}</time>
</div>
))}
</div>
)}
</section>
<footer className="footer">
<span>
<span className="green-dot" />
Forma Engine <b>0.3</b>
</span>
<span>Проект · r{project.revision}</span>
<button onClick={() => setModal("help")}>
Твоё пространство для создания миров <ArrowIcon />
</button>
</footer>
{toast && (
<div role="status" className="toast">
{toast}
</div>
)}
{imageId && project.assets.some((a) => a.id === imageId) && (
<Modal
title="Редактор спрайтов"
subtitle="Нарезка, размер в сцене и точки опоры"
wide
onClose={() => setImageId(null)}
>
<SpriteEditor
key={imageId}
asset={project.assets.find((a) => a.id === imageId)!}
save={async (asset) => {
const ok = !!(await cmd(
"asset.upsert",
{ asset },
"Настроить спрайт",
));
if (ok) setImageId(null);
return ok;
}}
/>
</Modal>
)}
{modal === "build" && project && (
<Modal
title="Сборка игры"
subtitle="Самостоятельное приложение с вашей игрой"
wide
onClose={closeModal}
>
<BuildPanel project={project} connected={!!connection} />
</Modal>
)}
{modal === "model" && (
<Modal
title="Создать модель"
subtitle="Редактируемая геометрия прямо в движке."
onClose={closeModal}
>
<div className="modal-body">
<div className="model-kinds">
{[
["arena", "Уровень"],
["character", "Персонаж"],
["extrude", "Выдавливание"],
["lathe", "Вращение"],
["custom", "Своя сетка"],
].map(([k, n]) => (
<button
className={kind === k ? "active" : ""}
key={k}
onClick={() => {
setKind(k);
if (k === "lathe") {
setProfile(
"[[0,0],[0.8,0],[0.6,0.2],[0.4,1.3],[0.6,1.5],[0,1.5]]",
);
setCount(24);
}
if (k === "extrude")
setProfile("[[-1,-1],[1,-1],[1,0],[0,0],[0,1],[-1,1]]");
}}
>
{n}
</button>
))}
</div>
<div className="model-illustration">
{kind === "arena" ? (
<Mountain size={60} strokeWidth={1} />
) : kind === "character" ? (
<PersonStanding size={60} strokeWidth={1} />
) : (
<Box size={60} strokeWidth={1} />
)}
<div>
<strong>
{kind === "arena"
? "Процедурный уровень"
: kind === "character"
? "Составная модель"
: "От профиля к объёму"}
</strong>
<p>
{kind === "character"
? "Составная статичная модель. Для скелетных анимаций импортируй GLB."
: "Результат появится в иерархии. Любую часть можно изменить."}
</p>
</div>
</div>
{kind === "arena" ? (
<div className="form-grid">
<label>
Ширина
<Num
label="Ширина уровня"
value={modelWidth}
min={8}
max={80}
onChange={setWidth}
/>
</label>
<label>
Длина
<Num
label="Длина уровня"
value={modelDepth}
min={8}
max={80}
onChange={setDepth}
/>
</label>
<label>
Seed
<Num label="Seed" value={seed} step={1} onChange={setSeed} />
</label>
<label>
Препятствия
<Num
label="Препятствия"
value={count}
min={0}
max={80}
step={1}
onChange={setCount}
/>
</label>
</div>
) : (
<>
<div className="form-grid">
<label>
{kind === "lathe" ? "Сегменты" : "Высота / глубина"}
<Num
label="Высота модели"
value={kind === "lathe" ? count : height}
min={kind === "lathe" ? 3 : 0.1}
onChange={kind === "lathe" ? setCount : setHeight}
/>
</label>
<label>
Цвет
<input
type="color"
aria-label="Цвет модели"
value={color}
onChange={(e) => setColor(e.target.value)}
/>
</label>
</div>
{(kind === "extrude" || kind === "lathe") && (
<label className="textarea-label">
Профиль · JSON
<textarea
aria-label="Профиль"
value={profile}
onChange={(e) => setProfile(e.target.value)}
/>
</label>
)}
{kind === "custom" && (
<label className="textarea-label">
positions, indices, normals?, uvs?
<textarea
aria-label="Геометрия JSON"
value={meshJson}
onChange={(e) => setMeshJson(e.target.value)}
/>
</label>
)}
</>
)}
</div>
<footer>
<span>Одно действие в истории отмены</span>
<button className="primary" onClick={() => void generate()}>
<WandSparkles size={15} />
Создать в сцене
</button>
</footer>
</Modal>
)}
{modal === "new" && (
<Modal
title="Новый проект"
subtitle="Текущие изменения можно вернуть через отмену."
onClose={closeModal}
>
<div className="project-templates">
{[false, true].map((example) => (
<button
key={String(example)}
onClick={() => {
void cmd(
"project.replace",
{ project: project2D(example) },
"Новый 2D-проект",
);
setSelected(null);
setModal(null);
}}
>
<Layers size={33} />
<strong>
{example ? "2D Платформер" : "Пустой 2D-проект"}
</strong>
<p>
{example
? "Рабочая сцена: анимированный персонаж, Tilemap, прыжки, ящик и односторонняя платформа."
: "Общая сцена в XY, ортографическая камера и Rapier2D."}
</p>
<span>Создать </span>
</button>
))}
<button
onClick={() => {
void cmd(
"project.replace",
{ project: defaultProject(true) },
"Пустой проект",
);
setSelected(null);
setModal(null);
}}
>
<Plus size={33} />
<strong>Пустой проект</strong>
<p>
Чистая сцена. Добавляй объекты, импортируй модели и создавай
свои скрипты.
</p>
<span>Начать с нуля </span>
</button>
</div>
</Modal>
)}
{modal === "rename" && (
<Modal title="Имя проекта" onClose={closeModal}>
<div className="modal-body">
<input
aria-label="Имя проекта"
value={rename}
onChange={(e) => setRename(e.target.value)}
/>
</div>
<footer>
<button
className="primary"
onClick={() => {
void cmd("project.rename", { name: rename }, "Имя проекта");
setModal(null);
}}
>
Сохранить
</button>
</footer>
</Modal>
)}
{modal === "script" && (
<Modal
title={
project.scripts.find((s) => s.id === scriptId)?.name || "Скрипт"
}
subtitle="JavaScript · start(api), update(api, dt) · исполняется в worker"
wide
onClose={closeModal}
>
<div className="script-editor">
<div className="code-area">
<div className="code-lines">
{code.split("\n").map((_, i) => (
<span key={i}>{i + 1}</span>
))}
</div>
<textarea
aria-label="Код скрипта"
spellCheck={false}
value={code}
onChange={(e) => {
setCode(e.target.value);
setDirty(true);
}}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
void saveScript();
}
if (e.key === "Tab") {
e.preventDefault();
const el = e.currentTarget,
a = el.selectionStart,
b = el.selectionEnd;
setCode(code.slice(0, a) + " " + code.slice(b));
setDirty(true);
}
}}
/>
</div>
<div className="script-fields">
<h4>Параметры инспектора</h4>
<textarea
aria-label="Поля скрипта JSON"
spellCheck={false}
value={fieldCode}
onChange={(e) => {
setFieldCode(e.target.value);
setDirty(true);
}}
/>
<p>
Типы: number, boolean, string, entity.
<br />
Обязательное поле: default.
</p>
<h4>API</h4>
<code>
api.state · api.params
<br />
api.input · api.get()
<br />
api.move([x,y,z])
<br />
api.patch(id, patch)
<br />
api.animate(name, loop)
<br />
api.spawn(prefab, position)
<br />
api.scene(id) · api.log(text)
</code>
</div>
</div>
<footer>
<span>
{dirty ? "Есть несохранённые изменения" : "Сохранено"} · изменения
после перезапуска
</span>
{unsavedClose && (
<button
className="danger"
onClick={() => {
setDirty(false);
setModal(null);
setUnsavedClose(false);
}}
>
Закрыть без сохранения
</button>
)}
<button className="primary" onClick={() => void saveScript()}>
<Save size={14} />
Сохранить скрипт
</button>
</footer>
</Modal>
)}
{modal === "settings" && (
<Modal
title="Настройки сцены"
subtitle="Общие параметры изображения и производительности."
onClose={closeModal}
>
<div className="modal-body">
<label className="property">
Режим сцены
<select
value={scene!.mode || "3d"}
onChange={(e) =>
void cmd(
"scene.configure",
{ mode: e.target.value },
"Режим сцены",
)
}
>
<option value="3d">3D</option>
<option value="2d">2D · XY</option>
</select>
</label>
{[0, 1].map((i) => (
<Field2D
key={i}
label={"Гравитация 2D " + (i ? "Y" : "X")}
value={(project.settings.physics2d?.gravity || [0, -9.81])[i]}
onChange={(v) =>
void cmd("project.settings", {
physics2d: {
gravity: (
project.settings.physics2d?.gravity || [0, -9.81]
).map((x, j) => (j === i ? v : x)),
},
})
}
/>
))}
<div className="property">
<label>Фон</label>
<input
aria-label="Фон"
type="color"
value={project.settings.background}
onChange={(e) =>
void cmd("project.settings", { background: e.target.value })
}
/>
</div>
<div className="property">
<label>Окружающий свет</label>
<Num
label="Окружающий свет"
value={project.settings.ambient}
min={0}
max={5}
onChange={(v) => void cmd("project.settings", { ambient: v })}
/>
</div>
<div className="property">
<label>Качество рендера</label>
<Num
label="Качество рендера"
value={project.settings.renderScale}
min={0.4}
max={1.5}
onChange={(v) =>
void cmd("project.settings", { renderScale: v })
}
/>
</div>
<label className="check-row">
<input
type="checkbox"
checked={project.settings.shadows}
onChange={(e) =>
void cmd("project.settings", { shadows: e.target.checked })
}
/>
Мягкие тени
</label>
<p className="small-note">
На Android можно снизить качество до 0.60.8.
</p>
</div>
</Modal>
)}
{modal === "mcp" && (
<Modal
title="Подключение ИИ"
subtitle="Один проект для тебя и твоего агента."
onClose={closeModal}
>
<div className="modal-body">
<div className="connection-card">
<Link size={25} />
<div>
<strong>
{connection
? "Локальный сервис подключён"
: "Сейчас открыт режим файлов"}
</strong>
<p>
{connection
? "Команды MCP сразу появляются в редакторе и истории."
: "Запусти полную версию на Linux, чтобы подключить MCP."}
</p>
</div>
</div>
{connection ? (
<>
<label className="textarea-label">
Endpoint
<input readOnly value={connection.mcpUrl} />
</label>
<button
className="wide-button"
onClick={() =>
void call("/api/mcp", {
enabled: connection.mcpEnabled === false,
})
.then(accept)
.catch((e) => notify(String(e)))
}
>
{connection.mcpEnabled === false
? "Включить MCP"
: "Приостановить MCP"}
</button>
{connection.mcpEnabled === false && (
<p className="small-note">
MCP отключён до включения или перезапуска сервера.
</p>
)}
</>
) : (
<p>
Полная версия:{" "}
<code>npm ci npm run local:build npm run local</code>.
Подключение описано в docs/MCP.md.
</p>
)}
<div className="capabilities">
{[
"Сцены и объекты",
"Скрипты и параметры",
"Геометрия без Blender",
"Модели и анимации",
"Запуск и управление",
"Снимки и диагностика",
"История и ревизии",
"Сохранение и экспорт",
].map((s) => (
<span key={s}>
<Check size={13} />
{s}
</span>
))}
</div>
<p className="small-note">
Встроенной модели ИИ нет. Используй свой MCP-клиент. Подключение
именно твоего ChatGPT / Work проверяется отдельно.
</p>
</div>
</Modal>
)}
{modal === "search" && (
<Modal title="Найти действие" onClose={closeModal}>
<div className="modal-body">
<label className="large-search">
<Search size={18} />
<input
autoFocus
placeholder="Действие или объект…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</label>
<div className="search-results">
{actions
.filter((a) =>
a.name.toLowerCase().includes(search.toLowerCase()),
)
.map((a) => (
<button
key={a.name}
onClick={() => {
setModal(null);
a.run();
}}
>
{a.name}
<ChevronRight size={13} />
</button>
))}
{scene!.entities
.filter(
(n) =>
search &&
n.name.toLowerCase().includes(search.toLowerCase()),
)
.slice(0, 15)
.map((n) => (
<button
key={n.id}
onClick={() => {
setSelected(n.id);
runtime.current?.focus(n.id);
setModal(null);
}}
>
<Box size={13} />
{n.name}
</button>
))}
</div>
</div>
</Modal>
)}
{modal === "help" && (
<Modal
title="Первый мир в Forma"
subtitle="От пустой сцены до своей игры."
onClose={closeModal}
>
<div className="modal-body help">
<ol>
<li>
<strong>Создай пустой проект.</strong> Меню проекта Новый
проект.
</li>
<li>
<strong>Создай уровень.</strong> «Создать модель» Уровень.
</li>
<li>
<strong>Добавь персонажа, противника и камеру.</strong> Кнопка +
в иерархии. У противника и камеры назначь цель персонажа.
</li>
<li>
<strong>Настрой бой.</strong> Скорость, урон и здоровье
находятся в инспекторе. Исходный скрипт доступен там же.
</li>
<li>
<strong>Запусти сцену.</strong> WASD движение, мышь или пробел
удар. Stop восстанавливает сцену.
</li>
<li>
<strong>Сохрани и экспортируй.</strong> .forma включает ресурсы.
ZIP игры запускается на обычном HTTP-хостинге.
</li>
</ol>
<p>
Blender Export glTF 2.0 GLB, включи skinning и animation
actions. Клипам дай имена Idle, Run, Attack, Death или назначь
свои в инспекторе.
</p>
<p className="small-note">
Скрипты импортированных проектов доверенный JavaScript. Worker
защищает отзывчивость редактора, но не является изоляцией
недоверенного кода.
</p>
</div>
</Modal>
)}
</div>
);
}
function ArrowIcon() {
return <ChevronRight size={12} />;
}