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.
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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",
|
||||
],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user