Files
psxbase/tools/web/vite.config.ts
T
ryanfitzpatrickio 8a96ede9f2 Initial public release of PSX Adventure Engine
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.
2026-07-31 06:32:43 -05:00

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",
],
},
});