Fix editor regressions and add Blender scene and animated material import
This commit is contained in:
+69
-63
@@ -70,8 +70,8 @@ import {
|
||||
} 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 { loadEditorProject, editorDraftKey, type EditorStartupOptions } from "./startup.ts";
|
||||
import { portableModel, modelComponents, modelDataUri, modelProject } from "../engine/model-import.ts";
|
||||
import "./editor.css";
|
||||
import { BuildPanel } from "./BuildPanel.tsx";
|
||||
const names: Record<string, string> = {
|
||||
@@ -278,7 +278,7 @@ function Modal({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default function Editor({ initialProject, browserOnly = false }: EditorStartupOptions = {}) {
|
||||
export default function Editor({ initialProject, browserOnly = false, draftScope }: EditorStartupOptions = {}) {
|
||||
const [project, setProject] = useState<Project | null>(null),
|
||||
[selected, setSelected] = useState<string | null>(null),
|
||||
[connection, setConnection] = useState<any>(null),
|
||||
@@ -328,6 +328,7 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
|
||||
toastTimer = useRef<any>(null),
|
||||
file = 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);
|
||||
@@ -406,7 +407,7 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
|
||||
let alive = true;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
(async () => {
|
||||
const { project: startingProject, status, restored } = await loadEditorProject({ initialProject, browserOnly });
|
||||
const { project: startingProject, status, restored } = await loadEditorProject({ initialProject, browserOnly, draftScope });
|
||||
if (!alive) return;
|
||||
store.current = new ProjectStore(startingProject);
|
||||
setCached(restored);
|
||||
@@ -427,7 +428,7 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(
|
||||
() =>
|
||||
saveDraft(store.current!.project)
|
||||
saveDraft(store.current!.project, editorDraftKey({ initialProject, browserOnly, draftScope }))
|
||||
.then(() => setCached(true))
|
||||
.catch((e) => addLog("error", String(e))),
|
||||
500,
|
||||
@@ -492,7 +493,7 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
|
||||
events.current?.close();
|
||||
clearTimeout(saveTimer.current);
|
||||
};
|
||||
}, [accept, addLog, call, initialProject, browserOnly]);
|
||||
}, [accept, addLog, call, initialProject, browserOnly, draftScope]);
|
||||
useEffect(() => {
|
||||
if (!ready || !canvas.current) return;
|
||||
const r = new FormaRuntime(canvas.current, {
|
||||
@@ -691,53 +692,51 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
|
||||
setSelected(n.id);
|
||||
}
|
||||
};
|
||||
const importFiles = async (files: FileList | File[]) => {
|
||||
for (const f of Array.from(files)) {
|
||||
const importFiles = async (files: FileList | File[], asScene = false) => {
|
||||
const selectedFiles = Array.from(files);
|
||||
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)) {
|
||||
const p = unpackProject(new Uint8Array(await f.arrayBuffer()));
|
||||
if (await cmd("project.replace", { project: p }, "Открыть проект")) {
|
||||
setSelected(null);
|
||||
notify("Проект открыт");
|
||||
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;
|
||||
}
|
||||
} 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("");
|
||||
}
|
||||
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 () => {
|
||||
@@ -1086,12 +1085,16 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
|
||||
type="file"
|
||||
hidden
|
||||
multiple
|
||||
accept=".glb,.gltf"
|
||||
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">
|
||||
@@ -1359,18 +1362,6 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
|
||||
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 && (
|
||||
<>
|
||||
@@ -1769,6 +1760,14 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
|
||||
</>
|
||||
) : 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>
|
||||
@@ -1806,9 +1805,12 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
|
||||
>
|
||||
<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 !== "imported" && <>
|
||||
<div className="property">
|
||||
<label>Проекция</label>
|
||||
<select
|
||||
@@ -1873,6 +1875,7 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
|
||||
onChange={(v) => void componentField(type, "fov", v)}
|
||||
/>
|
||||
</div>
|
||||
</>}
|
||||
</>
|
||||
) : type === "character" ? (
|
||||
<>
|
||||
@@ -2082,9 +2085,12 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
|
||||
))}
|
||||
</div>
|
||||
<div className="bottom-actions">
|
||||
<button onClick={() => sceneFile.current?.click()} title="Открыть GLB или ZIP Blender как новый проект с камерами, светом и анимацией">
|
||||
<Upload size={13} /> Открыть сцену Blender
|
||||
</button>
|
||||
<button onClick={() => modelFile.current?.click()}>
|
||||
<Upload size={13} />
|
||||
Импорт GLB
|
||||
Импорт модели
|
||||
</button>
|
||||
<Icon
|
||||
icon={HelpCircle}
|
||||
|
||||
@@ -7,10 +7,10 @@ function db(): Promise<IDBDatabase> {
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
export async function loadDraft() {
|
||||
export async function loadDraft(key = "active") {
|
||||
const d = await db();
|
||||
return new Promise<Project | null>((resolve, reject) => {
|
||||
const r = d.transaction("projects").objectStore("projects").get("active");
|
||||
const r = d.transaction("projects").objectStore("projects").get(key);
|
||||
r.onsuccess = () => {
|
||||
d.close();
|
||||
if (r.result) {
|
||||
@@ -25,11 +25,11 @@ export async function loadDraft() {
|
||||
r.onerror = () => reject(r.error);
|
||||
});
|
||||
}
|
||||
export async function saveDraft(p: Project) {
|
||||
export async function saveDraft(p: Project, key = "active") {
|
||||
const d = await db();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const tx = d.transaction("projects", "readwrite");
|
||||
tx.objectStore("projects").put(p, "active");
|
||||
tx.objectStore("projects").put(p, key);
|
||||
tx.oncomplete = () => {
|
||||
d.close();
|
||||
resolve();
|
||||
|
||||
+8
-3
@@ -2,10 +2,15 @@ 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 interface EditorStartupOptions { initialProject?: Project; browserOnly?: boolean; draftScope?: string; }
|
||||
export function editorDraftKey(options: EditorStartupOptions, base = typeof document === "undefined" ? "http://localhost/" : document.baseURI) {
|
||||
if (!options.browserOnly) return "active";
|
||||
const scope = options.draftScope ?? new URL(".", base).pathname;
|
||||
return `hosted:${scope}:${options.initialProject?.id ?? "blank"}`;
|
||||
}
|
||||
export async function loadEditorProject(
|
||||
options: EditorStartupOptions = {},
|
||||
dependencies: { request: typeof fetch; draft: () => Promise<Project | null> } = { request: fetch, draft: loadDraft },
|
||||
dependencies: { request: typeof fetch; draft: (key: string) => Promise<Project | null> } = { request: fetch, draft: loadDraft },
|
||||
) {
|
||||
if (!options.browserOnly) {
|
||||
try {
|
||||
@@ -23,7 +28,7 @@ export async function loadEditorProject(
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
const project = await dependencies.draft();
|
||||
const project = await dependencies.draft(editorDraftKey(options));
|
||||
if (project) {
|
||||
validateProject(project);
|
||||
return { project, status: null, restored: true };
|
||||
|
||||
Reference in New Issue
Block a user