Files
2026-09-12 04:58:59 +03:00

143 lines
5.2 KiB
TypeScript

import { zipSync, unzipSync, strToU8, strFromU8 } from "fflate";
import { type Project, clone, validateProject } from "./schema.ts";
export const assetExtension = (name: string) =>
/\.(gltf|png|jpg|jpeg|webp)$/i.exec(name)?.[0].toLowerCase() || ".glb";
export const mimeFor = (n: string) =>
(
({
".gltf": "model/gltf+json",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
}) as Record<string, string>
)[assetExtension(n)] || "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 function engineResource(
path: string,
base = typeof document === "undefined" ? undefined : document.baseURI,
) {
return base ? new URL(path.replace(/^\//, ""), base).href : path;
}
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.kind === "image"
? a.uri.startsWith("data:")
? "." +
(
/^data:image\/(png|jpeg|webp);/i.exec(a.uri)?.[1] || "png"
).replace("jpeg", "jpg")
: assetExtension(a.uri)
: assetExtension(a.name));
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(engineResource("/engine/player.js"));
files["player.css"] = await read(engineResource("/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);
}