Add browser-only editor export and 2.5D runtime presentation
This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user