2798 lines
96 KiB
TypeScript
2798 lines
96 KiB
TypeScript
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<string, string> = {
|
||
mesh: "Геометрия",
|
||
material: "Материал",
|
||
collider: "Коллайдер",
|
||
rigidbody: "Физическое тело",
|
||
script: "Поведение",
|
||
camera: "Камера",
|
||
character: "Контроллер персонажа",
|
||
light: "Свет",
|
||
animator: "Анимации",
|
||
data: "Игровые данные",
|
||
};
|
||
const shapes: Record<string, string> = {
|
||
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 }: EditorStartupOptions = {}) {
|
||
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),
|
||
modelFile = 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 });
|
||
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,
|
||
) => (
|
||
<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={modelFile}
|
||
type="file"
|
||
hidden
|
||
multiple
|
||
accept=".glb,.gltf"
|
||
onChange={(e) => {
|
||
if (e.target.files) void importFiles(e.target.files);
|
||
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) },
|
||
"Создать сцену",
|
||
)
|
||
}
|
||
/>
|
||
</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">
|
||
<span>{playing ? "LIVE" : "Перспектива"}</span>
|
||
<Icon
|
||
icon={Settings}
|
||
label="Настройки сцены"
|
||
onClick={() => setModal("settings")}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="canvas-wrap">
|
||
<canvas
|
||
ref={canvas}
|
||
aria-label="3D-сцена"
|
||
tabIndex={0}
|
||
onPointerDown={() => {
|
||
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 && (
|
||
<>
|
||
<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);
|
||
}}
|
||
/>
|
||
))}
|
||
</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">
|
||
ЛКМ · вращение <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 · отпустить мышь"
|
||
: "Ввод передаётся скриптам проекта"}
|
||
</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 },
|
||
"Удалить компонент",
|
||
)
|
||
}
|
||
/>
|
||
}
|
||
>
|
||
{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" ? (
|
||
<>
|
||
{["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="follow">Следование</option>
|
||
<option value="fixed">Фиксированная</option>
|
||
<option value="firstPerson">От первого лица</option>
|
||
</select>
|
||
</div>
|
||
<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])
|
||
.map(([k, v]) => (
|
||
<button
|
||
key={k}
|
||
onClick={() => {
|
||
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={() => modelFile.current?.click()}>
|
||
<Upload size={13} />
|
||
Импорт GLB
|
||
</button>
|
||
<Icon
|
||
icon={HelpCircle}
|
||
label="Справка"
|
||
onClick={() => setModal("help")}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{bottom === "assets" ? (
|
||
<div className="asset-browser">
|
||
<nav>
|
||
{[
|
||
["all", "Все ресурсы"],
|
||
["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)
|
||
}
|
||
onDoubleClick={() => void instantiate(a.id)}
|
||
onClick={() =>
|
||
notify("Двойной клик или перетащи ресурс в сцену")
|
||
}
|
||
>
|
||
<div className={"asset-art " + a.kind}>
|
||
{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 === "prefab"
|
||
? "PREFAB"
|
||
: a.kind === "geometry"
|
||
? "MESH"
|
||
: "GLB"}
|
||
</span>
|
||
</div>
|
||
<strong>{a.name}</strong>
|
||
<small>
|
||
{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>
|
||
)}
|
||
{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">
|
||
<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">
|
||
<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.6–0.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} />;
|
||
}
|