Browser reference stack for PSX-era third-person adventure: fixed cameras, inventory puzzles, Box3D physics, host-authoritative P2P co-op, and a modular character harness. Ships the Ashgrove Precinct Level 1 investigation demo with a full cast and nine linked rooms.
191 lines
6.2 KiB
TypeScript
191 lines
6.2 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { dirname, extname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import { defineConfig, type Plugin } from "vite";
|
|
|
|
const root = fileURLToPath(new URL(".", import.meta.url));
|
|
const toolsSrc = resolve(root, "../src");
|
|
const toolsDir = resolve(root, "..");
|
|
const workspaceRoot = resolve(root, "../..");
|
|
const assetsDir = resolve(workspaceRoot, "assets/characters");
|
|
const textureCli = resolve(toolsSrc, "texture/cli.ts");
|
|
|
|
function readBody(req: IncomingMessage): Promise<Buffer> {
|
|
return new Promise((resolveBody, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
req.on("end", () => resolveBody(Buffer.concat(chunks)));
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
res.statusCode = status;
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
function runTextureCli(payload: unknown): Promise<Record<string, unknown>> {
|
|
return new Promise((resolvePromise, reject) => {
|
|
const child = spawn(
|
|
process.execPath,
|
|
["--import", "tsx", textureCli],
|
|
{
|
|
cwd: toolsDir,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
env: process.env,
|
|
},
|
|
);
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (c: Buffer) => (stdout += c.toString()));
|
|
child.stderr.on("data", (c: Buffer) => (stderr += c.toString()));
|
|
child.on("error", reject);
|
|
child.on("close", (code) => {
|
|
const line = stdout.trim().split("\n").filter(Boolean).pop() ?? "";
|
|
try {
|
|
const json = JSON.parse(line) as Record<string, unknown>;
|
|
if (code !== 0 && !json["ok"]) {
|
|
reject(new Error(String(json["error"] ?? (stderr || `texture cli exited ${code}`))));
|
|
return;
|
|
}
|
|
resolvePromise(json);
|
|
} catch {
|
|
reject(new Error(stderr || stdout || `texture cli exited ${code}`));
|
|
}
|
|
});
|
|
|
|
child.stdin.write(JSON.stringify(payload));
|
|
child.stdin.end();
|
|
});
|
|
}
|
|
|
|
/** Dev-only APIs: save GLB, UV guide / Grok texture, serve character assets. */
|
|
function creatorApiPlugin(): Plugin {
|
|
return {
|
|
name: "psx-creator-api",
|
|
configureServer(server) {
|
|
server.middlewares.use(async (req, res, next) => {
|
|
// GET /assets-characters/<file>
|
|
if (req.method === "GET" && req.url?.startsWith("/assets-characters/")) {
|
|
const name = decodeURIComponent(req.url.slice("/assets-characters/".length).split("?")[0] ?? "");
|
|
if (!name || name.includes("..") || name.includes("/")) {
|
|
res.statusCode = 400;
|
|
res.end("bad path");
|
|
return;
|
|
}
|
|
try {
|
|
const bytes = await readFile(resolve(assetsDir, name));
|
|
const ext = extname(name).toLowerCase();
|
|
const mime =
|
|
ext === ".png"
|
|
? "image/png"
|
|
: ext === ".jpg" || ext === ".jpeg"
|
|
? "image/jpeg"
|
|
: ext === ".glb"
|
|
? "model/gltf-binary"
|
|
: "application/octet-stream";
|
|
res.statusCode = 200;
|
|
res.setHeader("Content-Type", mime);
|
|
res.setHeader("Cache-Control", "no-store");
|
|
res.end(bytes);
|
|
} catch {
|
|
res.statusCode = 404;
|
|
res.end("not found");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.method !== "POST") {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (req.url === "/api/save") {
|
|
const raw = await readBody(req);
|
|
const payload = JSON.parse(raw.toString("utf8")) as {
|
|
id?: string;
|
|
recipe?: unknown;
|
|
glbBase64?: string;
|
|
};
|
|
const id = payload.id?.replace(/[^a-z0-9_-]/gi, "") ?? "";
|
|
if (!id || !payload.recipe || !payload.glbBase64) {
|
|
sendJson(res, 400, { error: "Expected { id, recipe, glbBase64 }" });
|
|
return;
|
|
}
|
|
await mkdir(assetsDir, { recursive: true });
|
|
const glbPath = resolve(assetsDir, `${id}.glb`);
|
|
const recipePath = resolve(assetsDir, `${id}.recipe.json`);
|
|
const glb = Buffer.from(payload.glbBase64, "base64");
|
|
await writeFile(glbPath, glb);
|
|
await writeFile(recipePath, `${JSON.stringify(payload.recipe, null, 2)}\n`);
|
|
sendJson(res, 200, { ok: true, glb: glbPath, recipe: recipePath, bytes: glb.byteLength });
|
|
return;
|
|
}
|
|
|
|
if (req.url === "/api/texture") {
|
|
const raw = await readBody(req);
|
|
const payload = JSON.parse(raw.toString("utf8")) as {
|
|
recipe?: unknown;
|
|
mode?: "guide" | "texture";
|
|
force?: boolean;
|
|
albedoBase64?: string;
|
|
};
|
|
if (!payload.recipe) {
|
|
sendJson(res, 400, { error: "Expected { recipe, mode }" });
|
|
return;
|
|
}
|
|
const result = await runTextureCli({
|
|
recipe: payload.recipe,
|
|
mode: payload.mode === "texture" ? "texture" : "guide",
|
|
force: payload.force === true,
|
|
...(payload.albedoBase64 ? { albedoBase64: payload.albedoBase64 } : {}),
|
|
});
|
|
sendJson(res, result["ok"] ? 200 : 500, result);
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
sendJson(res, 500, { error: message });
|
|
return;
|
|
}
|
|
|
|
next();
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
export default defineConfig({
|
|
root,
|
|
plugins: [creatorApiPlugin()],
|
|
resolve: {
|
|
alias: {
|
|
"@creator": toolsSrc,
|
|
},
|
|
},
|
|
server: {
|
|
port: 5174,
|
|
open: false,
|
|
fs: {
|
|
allow: [workspaceRoot, dirname(toolsSrc)],
|
|
},
|
|
},
|
|
build: {
|
|
outDir: resolve(root, "dist"),
|
|
emptyOutDir: true,
|
|
target: "es2022",
|
|
},
|
|
optimizeDeps: {
|
|
include: [
|
|
"three",
|
|
"three/examples/jsm/loaders/GLTFLoader.js",
|
|
"three/examples/jsm/controls/OrbitControls.js",
|
|
],
|
|
},
|
|
});
|