Add browser-only editor export and 2.5D runtime presentation

This commit is contained in:
emil28092005
2026-09-12 00:05:19 +03:00
parent a25ed40805
commit 1a0bf725fa
16 changed files with 883 additions and 53 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ export function BuildPanel({
project: Project;
connected: boolean;
}) {
const [target, setTarget] = useState("linux"),
const [target, setTarget] = useState(connected ? "linux" : "web"),
[options, setOptions] = useState(() => {
try {
return normalizeOptions(
+84 -39
View File
@@ -69,7 +69,8 @@ import {
mimeFor,
} from "../engine/archive.ts";
import { FormaRuntime } from "../engine/runtime.ts";
import { loadDraft, saveDraft } from "./persistence.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";
@@ -277,7 +278,7 @@ function Modal({
</div>
);
}
export default function Editor() {
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),
@@ -405,26 +406,10 @@ export default function Editor() {
let alive = true;
let unsubscribe: (() => void) | undefined;
(async () => {
let p: Project | null = null,
status: any = null;
try {
const r = await fetch("/api/status", {
signal: AbortSignal.timeout(1800),
});
if (r.ok) {
const d: any = await r.json();
if (d.forma) {
status = d;
p = (await call("/api/project")).project;
}
}
} catch {}
if (!p)
try {
p = await loadDraft();
} catch {}
const { project: startingProject, status, restored } = await loadEditorProject({ initialProject, browserOnly });
if (!alive) return;
store.current = new ProjectStore(p || defaultProject());
store.current = new ProjectStore(startingProject);
setCached(restored);
connected.current = status;
setConnection(status);
setProject(store.current.project);
@@ -507,7 +492,7 @@ export default function Editor() {
events.current?.close();
clearTimeout(saveTimer.current);
};
}, [accept, addLog, call]);
}, [accept, addLog, call, initialProject, browserOnly]);
useEffect(() => {
if (!ready || !canvas.current) return;
const r = new FormaRuntime(canvas.current, {
@@ -1162,13 +1147,13 @@ export default function Editor() {
</div>
)}
</div>
<span className="save-status">
<span className="save-status" title={browserOnly ? "Правки сохраняются только в этом браузере. Скачай .forma через меню проекта, чтобы перенести их." : undefined}>
<i />
{connection
? "На диске"
: cached
? "Автокопия сохранена"
: "Локальный проект"}
: browserOnly ? "Проект в браузере" : "Локальный проект"}
</span>
<button className="command-search" onClick={() => setModal("search")}>
<Search size={14} />
@@ -1451,7 +1436,7 @@ export default function Editor() {
</button>
</>
)}
{playing && (
{playing && !project.settings.presentation && (
<>
<div className="game-hud">
<div>
@@ -1651,20 +1636,39 @@ export default function Editor() {
<span>{c.color}</span>
</div>
</div>
{["roughness", "metallic", "emissive"].map((k, i) => (
<div className="property" key={k}>
<label>
{["Шероховатость", "Металличность", "Свечение"][i]}
</label>
<Num
label={k}
value={c[k] || 0}
min={0}
max={k === "emissive" ? 5 : 1}
onChange={(v) => void componentField(type, k, v)}
/>
</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
@@ -1801,9 +1805,50 @@ export default function Editor() {
}
>
<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(
+20
View File
@@ -0,0 +1,20 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import Editor from './Editor.tsx';
import { validateProject } from '../engine/schema.ts';
const root = document.getElementById('root')!;
async function start() {
const response = await fetch('./project.forma.json');
if (!response.ok) throw Error('Не удалось загрузить проект Forma');
const project = await response.json();
validateProject(project);
createRoot(root).render(<Editor initialProject={project} browserOnly />);
}
void start().catch((error) => {
root.replaceChildren();
const message = document.createElement('p');
message.style.cssText = 'padding:32px;font:16px/1.6 system-ui';
message.textContent = 'Не удалось открыть проект. Обнови страницу. ' + String(error.message || error);
root.append(message);
});
+35
View File
@@ -0,0 +1,35 @@
import { type Project, clone, validateProject } from '../engine/schema.ts';
import { defaultProject } from '../engine/templates.ts';
import { loadDraft } from './persistence.ts';
export interface EditorStartupOptions { initialProject?: Project; browserOnly?: boolean; }
export async function loadEditorProject(
options: EditorStartupOptions = {},
dependencies: { request: typeof fetch; draft: () => Promise<Project | null> } = { request: fetch, draft: loadDraft },
) {
if (!options.browserOnly) {
try {
const response = await dependencies.request('/api/status', { signal: AbortSignal.timeout(1800) });
if (response.ok) {
const status = await response.json();
if (status.forma) {
const response = await dependencies.request('/api/project');
if (!response.ok) throw Error('Project unavailable');
const { project } = await response.json();
validateProject(project);
return { project: project as Project, status, restored: false };
}
}
} catch {}
}
try {
const project = await dependencies.draft();
if (project) {
validateProject(project);
return { project, status: null, restored: true };
}
} catch {}
const project = clone(options.initialProject || defaultProject());
validateProject(project);
return { project, status: null, restored: false };
}