120 lines
4.5 KiB
TypeScript
120 lines
4.5 KiB
TypeScript
import { zipSync, unzipSync, strToU8, strFromU8 } from "fflate";
|
|
import { type Project, clone, validateProject } from "./schema.ts";
|
|
export const mimeFor = (n: string) =>
|
|
n.toLowerCase().endsWith(".gltf") ? "model/gltf+json" : "model/gltf-binary";
|
|
export function base64(a: Uint8Array) {
|
|
let s = "";
|
|
for (let i = 0; i < a.length; i += 16384)
|
|
s += String.fromCharCode(...a.subarray(i, i + 16384));
|
|
return btoa(s);
|
|
}
|
|
export function decodeData(uri: string) {
|
|
const i = uri.indexOf(",");
|
|
if (i < 0 || !uri.slice(0, i).endsWith(";base64"))
|
|
throw Error("Ожидался data URI base64");
|
|
return Uint8Array.from(atob(uri.slice(i + 1)), (c) => c.charCodeAt(0));
|
|
}
|
|
export async function readBytes(uri: string) {
|
|
if (uri.startsWith("data:")) return decodeData(uri);
|
|
const r = await fetch(uri);
|
|
if (!r.ok) throw Error("Не удалось загрузить " + uri);
|
|
return new Uint8Array(await r.arrayBuffer());
|
|
}
|
|
async function pack(
|
|
project: Project,
|
|
read: (uri: string) => Promise<Uint8Array>,
|
|
) {
|
|
validateProject(project);
|
|
const p = clone(project),
|
|
files: Record<string, Uint8Array> = {};
|
|
for (const a of p.assets)
|
|
if (a.uri) {
|
|
const file =
|
|
"assets/" +
|
|
a.id +
|
|
(a.name.toLowerCase().endsWith(".gltf") ? ".gltf" : ".glb");
|
|
files[file] = await read(a.uri);
|
|
a.uri = file;
|
|
}
|
|
return { p, files };
|
|
}
|
|
export async function projectArchive(
|
|
project: Project,
|
|
read: (uri: string) => Promise<Uint8Array> = readBytes,
|
|
) {
|
|
const { p, files } = await pack(project, read);
|
|
files["project.forma.json"] = strToU8(JSON.stringify(p, null, 2));
|
|
for (const s of p.scripts)
|
|
files["scripts/" + s.id + ".js"] = strToU8(s.source);
|
|
files["README.txt"] = strToU8(
|
|
"Open this .forma archive in Forma. project.forma.json is authoritative. scripts/ contains readable copies.\n",
|
|
);
|
|
return zipSync(files, { level: 6 });
|
|
}
|
|
export async function gameArchive(
|
|
project: Project,
|
|
read: (uri: string) => Promise<Uint8Array> = readBytes,
|
|
) {
|
|
const { p, files } = await pack(project, read);
|
|
files["project.forma.json"] = strToU8(JSON.stringify(p));
|
|
files["player.js"] = await read("/engine/player.js");
|
|
files["player.css"] = await read("/engine/player.css");
|
|
files["index.html"] = strToU8(
|
|
'<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>' +
|
|
p.name.replace(/[<>&"]/g, "") +
|
|
'</title><link rel="stylesheet" href="./player.css"></head><body><canvas id="game"></canvas><div id="game-ui"></div><script type="module" src="./player.js"></script></body></html>',
|
|
);
|
|
files["README.txt"] = strToU8(
|
|
"Serve this directory with an HTTP server, e.g. python3 -m http.server 8080. Open http://localhost:8080. No editor or MCP required.\n",
|
|
);
|
|
return zipSync(files, { level: 6 });
|
|
}
|
|
export function unpackProject(bytes: Uint8Array): Project {
|
|
let p: Project;
|
|
if (bytes.length > 80 * 1024 * 1024) throw Error("Проект превышает 80 МБ");
|
|
if (strFromU8(bytes.subarray(0, 20)).trimStart().startsWith("{"))
|
|
p = JSON.parse(strFromU8(bytes));
|
|
else {
|
|
let total = 0;
|
|
const files = unzipSync(bytes, {
|
|
filter: (f) => {
|
|
total += f.originalSize;
|
|
if (total > 200 * 1024 * 1024 || f.originalSize > 60 * 1024 * 1024)
|
|
throw Error("Распакованный проект превышает лимит");
|
|
if (
|
|
f.name.includes("..") ||
|
|
f.name.startsWith("/") ||
|
|
f.name.includes("\\")
|
|
)
|
|
throw Error("Небезопасный путь в архиве");
|
|
return true;
|
|
},
|
|
});
|
|
if (!files["project.forma.json"]) throw Error("Нет project.forma.json");
|
|
p = JSON.parse(strFromU8(files["project.forma.json"]));
|
|
validateProject(p);
|
|
for (const a of p.assets)
|
|
if (a.uri && !a.uri.startsWith("data:")) {
|
|
const uri = a.uri.replace(/^\.\//, "").replace(/^\//, "");
|
|
if (!files[uri]) throw Error("Ресурс отсутствует: " + uri);
|
|
a.uri = "data:" + mimeFor(a.name) + ";base64," + base64(files[uri]);
|
|
}
|
|
}
|
|
validateProject(p);
|
|
return p;
|
|
}
|
|
export function download(
|
|
bytes: Uint8Array,
|
|
name: string,
|
|
mime = "application/octet-stream",
|
|
) {
|
|
const url = URL.createObjectURL(
|
|
new Blob([bytes as BlobPart], { type: mime }),
|
|
);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = name;
|
|
a.click();
|
|
setTimeout(() => URL.revokeObjectURL(url), 3000);
|
|
}
|