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,22 @@
|
||||
{
|
||||
"name": "@psx/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./skeleton": "./src/skeleton/canonical.ts",
|
||||
"./types": "./src/types/index.ts",
|
||||
"./schemas": "./src/schemas/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./types/index.js";
|
||||
export * from "./schemas/index.js";
|
||||
export * from "./skeleton/canonical.js";
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Minimal validation primitives.
|
||||
*
|
||||
* `shared` stays dependency-free so the browser bundle and the Hono server can
|
||||
* both import it without agreeing on a validation library — worth the hand
|
||||
* written predicates, since this code sits on two trust boundaries (cloud saves
|
||||
* and peer messages).
|
||||
*/
|
||||
|
||||
export type ValidationResult<T> = { ok: true; value: T } | { ok: false; errors: string[] };
|
||||
|
||||
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
export const isVec3 = (value: unknown): boolean =>
|
||||
isRecord(value) &&
|
||||
Number.isFinite(value["x"]) &&
|
||||
Number.isFinite(value["y"]) &&
|
||||
Number.isFinite(value["z"]);
|
||||
|
||||
export const isStringArray = (value: unknown): value is string[] =>
|
||||
Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
||||
|
||||
/** Rejects NaN and Infinity, which JSON.parse will happily produce from input. */
|
||||
export const isFiniteNumber = (value: unknown): value is number => Number.isFinite(value);
|
||||
|
||||
export const ok = <T>(value: T): ValidationResult<T> => ({ ok: true, value });
|
||||
export const fail = <T>(...errors: string[]): ValidationResult<T> => ({ ok: false, errors });
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./common.js";
|
||||
export * from "./save.js";
|
||||
export * from "./net.js";
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PROTOCOL_VERSION } from "../types/net.js";
|
||||
import { validateClientMessage, validateHostMessage, validateSignalMessage } from "./net.js";
|
||||
|
||||
/**
|
||||
* These validators sit on the boundary between the host and an untrusted peer.
|
||||
* They are expected to *sanitise*, not merely accept or reject, so the tests
|
||||
* assert on the values that come out rather than only on the ok/fail verdict.
|
||||
*/
|
||||
|
||||
describe("validateClientMessage", () => {
|
||||
it("accepts well-formed input", () => {
|
||||
const result = validateClientMessage({
|
||||
t: "input",
|
||||
tick: 12,
|
||||
move: { x: 0.5, z: -1 },
|
||||
run: true,
|
||||
yaw: 1.57,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.ok && result.value).toMatchObject({
|
||||
t: "input",
|
||||
tick: 12,
|
||||
move: { x: 0.5, z: -1 },
|
||||
run: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps an out-of-range movement vector instead of rejecting it", () => {
|
||||
const result = validateClientMessage({
|
||||
t: "input",
|
||||
tick: 1,
|
||||
move: { x: 1e9, z: -1e9 },
|
||||
run: true,
|
||||
yaw: 0,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.ok && result.value).toMatchObject({ move: { x: 1, z: -1 } });
|
||||
});
|
||||
|
||||
it("rejects NaN and Infinity, which survive JSON round trips", () => {
|
||||
expect(
|
||||
validateClientMessage({
|
||||
t: "input",
|
||||
tick: 1,
|
||||
move: { x: Number.NaN, z: 0 },
|
||||
run: false,
|
||||
yaw: 0,
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
validateClientMessage({
|
||||
t: "input",
|
||||
tick: 1,
|
||||
move: { x: 0, z: 0 },
|
||||
run: false,
|
||||
yaw: Number.POSITIVE_INFINITY,
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("coerces a non-boolean run flag rather than trusting it", () => {
|
||||
const result = validateClientMessage({
|
||||
t: "input",
|
||||
tick: 1,
|
||||
move: { x: 0, z: 0 },
|
||||
run: "yes",
|
||||
yaw: 0,
|
||||
});
|
||||
expect(result.ok && result.value).toMatchObject({ run: false });
|
||||
});
|
||||
|
||||
it("floors a fractional tick", () => {
|
||||
const result = validateClientMessage({
|
||||
t: "input",
|
||||
tick: 7.9,
|
||||
move: { x: 0, z: 0 },
|
||||
run: false,
|
||||
yaw: 0,
|
||||
});
|
||||
expect(result.ok && result.value).toMatchObject({ tick: 7 });
|
||||
});
|
||||
|
||||
it("caps chat length so one frame cannot exhaust host memory", () => {
|
||||
const result = validateClientMessage({ t: "chat", text: "x".repeat(10_000) });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.ok && (result.value as { text: string }).text.length).toBe(280);
|
||||
});
|
||||
|
||||
it("rejects an over-long interactable id", () => {
|
||||
expect(validateClientMessage({ t: "interact", interactableId: "x".repeat(500) }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown message types", () => {
|
||||
expect(validateClientMessage({ t: "shutdown" }).ok).toBe(false);
|
||||
expect(validateClientMessage(null).ok).toBe(false);
|
||||
expect(validateClientMessage([]).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateSignalMessage", () => {
|
||||
it("overwrites a claimed sender id", () => {
|
||||
const result = validateSignalMessage({
|
||||
t: "signal",
|
||||
from: 99,
|
||||
to: 1,
|
||||
payload: { sdp: { type: "offer", sdp: "v=0" } },
|
||||
});
|
||||
|
||||
// The relay stamps the true sender; a claimed `from` must not survive.
|
||||
expect(result.ok && result.value).toMatchObject({ from: -1, to: 1 });
|
||||
});
|
||||
|
||||
it("rejects a protocol version mismatch", () => {
|
||||
const result = validateSignalMessage({
|
||||
t: "hello",
|
||||
token: "abc",
|
||||
lobbyCode: "ABCDE",
|
||||
protocol: PROTOCOL_VERSION + 1,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(!result.ok && result.errors[0]).toContain("protocol mismatch");
|
||||
});
|
||||
|
||||
it("accepts a valid hello", () => {
|
||||
const result = validateSignalMessage({
|
||||
t: "hello",
|
||||
token: "abc",
|
||||
lobbyCode: "ABCDE",
|
||||
protocol: PROTOCOL_VERSION,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateHostMessage", () => {
|
||||
it("rejects a snapshot carrying more players than the lobby allows", () => {
|
||||
const players = Array.from({ length: 9 }, (_, i) => ({
|
||||
peerId: i,
|
||||
p: { x: 0, y: 0, z: 0 },
|
||||
yaw: 0,
|
||||
spd: 0,
|
||||
grounded: true,
|
||||
}));
|
||||
|
||||
expect(validateHostMessage({ t: "snapshot", tick: 1, ack: {}, players }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a snapshot with a non-finite position", () => {
|
||||
const result = validateHostMessage({
|
||||
t: "snapshot",
|
||||
tick: 1,
|
||||
ack: {},
|
||||
players: [
|
||||
{ peerId: 1, p: { x: Number.NaN, y: 0, z: 0 }, yaw: 0, spd: 0, grounded: true },
|
||||
],
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a well-formed snapshot", () => {
|
||||
const result = validateHostMessage({
|
||||
t: "snapshot",
|
||||
tick: 4,
|
||||
ack: { 1: 3 },
|
||||
players: [{ peerId: 1, p: { x: 1, y: 0, z: 2 }, yaw: 0.4, spd: 1.2, grounded: true }],
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects malformed flag payloads", () => {
|
||||
expect(validateHostMessage({ t: "flags", set: "everything", unset: [] }).ok).toBe(false);
|
||||
expect(validateHostMessage({ t: "flags", set: [1, 2], unset: [] }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { ClientMessage, HostMessage, SignalMessage, SignalPayload } from "../types/net.js";
|
||||
import { MAX_PLAYERS, PROTOCOL_VERSION } from "../types/net.js";
|
||||
import { fail, isFiniteNumber, isRecord, isStringArray, ok, type ValidationResult } from "./common.js";
|
||||
|
||||
/**
|
||||
* Peer message validation.
|
||||
*
|
||||
* Guests are untrusted. Every `ClientMessage` the host acts on passes through
|
||||
* here first, and the validator *sanitises* rather than merely accepting:
|
||||
* movement is clamped to the unit square so a peer cannot claim a 1e9 input
|
||||
* vector and teleport, and identifiers are length-capped so a peer cannot
|
||||
* exhaust host memory with a single frame.
|
||||
*/
|
||||
|
||||
/** Nothing legitimate approaches these; they exist to bound hostile input. */
|
||||
const MAX_ID_LENGTH = 128;
|
||||
const MAX_CHAT_LENGTH = 280;
|
||||
|
||||
const clampUnit = (value: number): number => (value < -1 ? -1 : value > 1 ? 1 : value);
|
||||
|
||||
const isSaneId = (value: unknown): value is string =>
|
||||
typeof value === "string" && value.length > 0 && value.length <= MAX_ID_LENGTH;
|
||||
|
||||
export function validateClientMessage(input: unknown): ValidationResult<ClientMessage> {
|
||||
if (!isRecord(input)) return fail("message must be an object");
|
||||
|
||||
switch (input["t"]) {
|
||||
case "input": {
|
||||
const move = input["move"];
|
||||
if (!isRecord(move) || !isFiniteNumber(move["x"]) || !isFiniteNumber(move["z"])) {
|
||||
return fail("input.move must be {x, z} finite numbers");
|
||||
}
|
||||
if (!isFiniteNumber(input["tick"]) || input["tick"] < 0) {
|
||||
return fail("input.tick must be a non-negative number");
|
||||
}
|
||||
if (!isFiniteNumber(input["yaw"])) return fail("input.yaw must be finite");
|
||||
|
||||
return ok({
|
||||
t: "input",
|
||||
tick: Math.floor(input["tick"]),
|
||||
move: { x: clampUnit(move["x"]), z: clampUnit(move["z"]) },
|
||||
run: input["run"] === true,
|
||||
yaw: input["yaw"],
|
||||
});
|
||||
}
|
||||
|
||||
case "interact":
|
||||
if (!isSaneId(input["interactableId"])) return fail("interact.interactableId invalid");
|
||||
return ok({ t: "interact", interactableId: input["interactableId"] });
|
||||
|
||||
case "combine":
|
||||
if (!isSaneId(input["itemA"]) || !isSaneId(input["itemB"])) {
|
||||
return fail("combine requires two item ids");
|
||||
}
|
||||
return ok({ t: "combine", itemA: input["itemA"], itemB: input["itemB"] });
|
||||
|
||||
case "chat": {
|
||||
const text = input["text"];
|
||||
if (typeof text !== "string") return fail("chat.text must be a string");
|
||||
return ok({ t: "chat", text: text.slice(0, MAX_CHAT_LENGTH) });
|
||||
}
|
||||
|
||||
default:
|
||||
return fail(`unknown client message type: ${String(input["t"])}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guests validate host messages too. The host is authoritative over *game*
|
||||
* state, not over the guest's memory safety, and a compromised host should not
|
||||
* be able to crash the room.
|
||||
*/
|
||||
export function validateHostMessage(input: unknown): ValidationResult<HostMessage> {
|
||||
if (!isRecord(input)) return fail("message must be an object");
|
||||
|
||||
switch (input["t"]) {
|
||||
case "snapshot": {
|
||||
if (!isFiniteNumber(input["tick"])) return fail("snapshot.tick must be finite");
|
||||
if (!Array.isArray(input["players"])) return fail("snapshot.players must be an array");
|
||||
if (input["players"].length > MAX_PLAYERS) return fail("snapshot.players exceeds MAX_PLAYERS");
|
||||
|
||||
for (const player of input["players"]) {
|
||||
if (!isRecord(player) || !isFiniteNumber(player["peerId"])) {
|
||||
return fail("snapshot.players entry missing peerId");
|
||||
}
|
||||
const position = player["p"];
|
||||
if (
|
||||
!isRecord(position) ||
|
||||
!isFiniteNumber(position["x"]) ||
|
||||
!isFiniteNumber(position["y"]) ||
|
||||
!isFiniteNumber(position["z"])
|
||||
) {
|
||||
return fail("snapshot.players entry has an invalid position");
|
||||
}
|
||||
if (!isFiniteNumber(player["yaw"])) return fail("snapshot player yaw must be finite");
|
||||
}
|
||||
return ok(input as unknown as HostMessage);
|
||||
}
|
||||
|
||||
case "flags":
|
||||
if (!isStringArray(input["set"]) || !isStringArray(input["unset"])) {
|
||||
return fail("flags.set and flags.unset must be string arrays");
|
||||
}
|
||||
return ok(input as unknown as HostMessage);
|
||||
|
||||
case "despawn":
|
||||
if (!isStringArray(input["interactableIds"])) {
|
||||
return fail("despawn.interactableIds must be a string array");
|
||||
}
|
||||
return ok(input as unknown as HostMessage);
|
||||
|
||||
case "inventory":
|
||||
if (!isFiniteNumber(input["peerId"]) || !Array.isArray(input["items"])) {
|
||||
return fail("inventory requires peerId and items[]");
|
||||
}
|
||||
return ok(input as unknown as HostMessage);
|
||||
|
||||
case "notice":
|
||||
if (typeof input["text"] !== "string") return fail("notice.text must be a string");
|
||||
return ok(input as unknown as HostMessage);
|
||||
|
||||
case "chat":
|
||||
if (!isFiniteNumber(input["from"]) || typeof input["text"] !== "string") {
|
||||
return fail("chat requires from and text");
|
||||
}
|
||||
return ok({ t: "chat", from: input["from"], text: input["text"].slice(0, MAX_CHAT_LENGTH) });
|
||||
|
||||
case "sync":
|
||||
if (typeof input["roomId"] !== "string") return fail("sync.roomId must be a string");
|
||||
if (!isStringArray(input["flags"]) || !isStringArray(input["despawned"])) {
|
||||
return fail("sync.flags and sync.despawned must be string arrays");
|
||||
}
|
||||
return ok(input as unknown as HostMessage);
|
||||
|
||||
default:
|
||||
return fail(`unknown host message type: ${String(input["t"])}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSignalMessage(input: unknown): ValidationResult<SignalMessage> {
|
||||
if (!isRecord(input)) return fail("message must be an object");
|
||||
|
||||
switch (input["t"]) {
|
||||
case "hello":
|
||||
if (typeof input["token"] !== "string") return fail("hello.token must be a string");
|
||||
if (!isSaneId(input["lobbyCode"])) return fail("hello.lobbyCode invalid");
|
||||
if (input["protocol"] !== PROTOCOL_VERSION) {
|
||||
return fail(
|
||||
`protocol mismatch: peer speaks ${String(input["protocol"])}, server speaks ${PROTOCOL_VERSION}`,
|
||||
);
|
||||
}
|
||||
return ok({
|
||||
t: "hello",
|
||||
token: input["token"],
|
||||
lobbyCode: input["lobbyCode"],
|
||||
protocol: PROTOCOL_VERSION,
|
||||
});
|
||||
|
||||
case "signal": {
|
||||
if (!isFiniteNumber(input["to"])) return fail("signal.to must be a peer id");
|
||||
if (!isRecord(input["payload"])) return fail("signal.payload must be an object");
|
||||
// `from` is assigned by the server from the authenticated socket, never
|
||||
// taken from the sender — otherwise a peer could impersonate another.
|
||||
return ok({
|
||||
t: "signal",
|
||||
from: -1,
|
||||
to: input["to"],
|
||||
payload: input["payload"] as SignalPayload,
|
||||
});
|
||||
}
|
||||
|
||||
case "ready":
|
||||
return ok({ t: "ready", isReady: input["isReady"] === true });
|
||||
|
||||
default:
|
||||
return fail(`unknown signal message type: ${String(input["t"])}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { SaveData } from "../types/save.js";
|
||||
import { SAVE_FORMAT_VERSION } from "../types/save.js";
|
||||
import { isRecord, isVec3, type ValidationResult } from "./common.js";
|
||||
|
||||
export function validateSaveData(input: unknown): ValidationResult<SaveData> {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!isRecord(input)) return { ok: false, errors: ["save must be an object"] };
|
||||
|
||||
if (input["version"] !== SAVE_FORMAT_VERSION) {
|
||||
errors.push(
|
||||
`unsupported save version ${String(input["version"])}, expected ${SAVE_FORMAT_VERSION}`,
|
||||
);
|
||||
}
|
||||
if (typeof input["campaign"] !== "string") errors.push("campaign must be a string");
|
||||
if (typeof input["playtimeSeconds"] !== "number") errors.push("playtimeSeconds must be a number");
|
||||
if (!Array.isArray(input["flags"])) errors.push("flags must be an array");
|
||||
if (!Array.isArray(input["consumedInteractables"])) {
|
||||
errors.push("consumedInteractables must be an array");
|
||||
}
|
||||
|
||||
const player = input["player"];
|
||||
if (!isRecord(player)) {
|
||||
errors.push("player must be an object");
|
||||
} else {
|
||||
if (typeof player["roomId"] !== "string") errors.push("player.roomId must be a string");
|
||||
if (!isVec3(player["position"])) errors.push("player.position must be a Vec3");
|
||||
if (typeof player["yaw"] !== "number") errors.push("player.yaw must be a number");
|
||||
if (typeof player["health"] !== "number") errors.push("player.health must be a number");
|
||||
const inventory = player["inventory"];
|
||||
if (
|
||||
!isRecord(inventory) ||
|
||||
!Array.isArray(inventory["slots"]) ||
|
||||
typeof inventory["capacity"] !== "number"
|
||||
) {
|
||||
errors.push("player.inventory must have slots[] and capacity");
|
||||
}
|
||||
}
|
||||
|
||||
return errors.length === 0
|
||||
? { ok: true, value: input as unknown as SaveData }
|
||||
: { ok: false, errors };
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Canonical skeleton — GDD §10.2.
|
||||
*
|
||||
* Mixamo-compatible core set. The harness builds joints under these names and
|
||||
* binds skin weights to them, so every generated character plays the same
|
||||
* procedural clips (and any future shared animation library).
|
||||
*/
|
||||
|
||||
export const CANONICAL_BONES = [
|
||||
"Hips",
|
||||
"Spine",
|
||||
"Spine1",
|
||||
"Spine2",
|
||||
"Neck",
|
||||
"Head",
|
||||
"LeftShoulder",
|
||||
"LeftArm",
|
||||
"LeftForeArm",
|
||||
"LeftHand",
|
||||
"RightShoulder",
|
||||
"RightArm",
|
||||
"RightForeArm",
|
||||
"RightHand",
|
||||
"LeftUpLeg",
|
||||
"LeftLeg",
|
||||
"LeftFoot",
|
||||
"LeftToeBase",
|
||||
"RightUpLeg",
|
||||
"RightLeg",
|
||||
"RightFoot",
|
||||
"RightToeBase",
|
||||
] as const;
|
||||
|
||||
export type CanonicalBone = (typeof CANONICAL_BONES)[number];
|
||||
|
||||
/** Parent of each bone; `null` marks the skeleton root. */
|
||||
export const CANONICAL_HIERARCHY: Record<CanonicalBone, CanonicalBone | null> = {
|
||||
Hips: null,
|
||||
Spine: "Hips",
|
||||
Spine1: "Spine",
|
||||
Spine2: "Spine1",
|
||||
Neck: "Spine2",
|
||||
Head: "Neck",
|
||||
LeftShoulder: "Spine2",
|
||||
LeftArm: "LeftShoulder",
|
||||
LeftForeArm: "LeftArm",
|
||||
LeftHand: "LeftForeArm",
|
||||
RightShoulder: "Spine2",
|
||||
RightArm: "RightShoulder",
|
||||
RightForeArm: "RightArm",
|
||||
RightHand: "RightForeArm",
|
||||
LeftUpLeg: "Hips",
|
||||
LeftLeg: "LeftUpLeg",
|
||||
LeftFoot: "LeftLeg",
|
||||
LeftToeBase: "LeftFoot",
|
||||
RightUpLeg: "Hips",
|
||||
RightLeg: "RightUpLeg",
|
||||
RightFoot: "RightLeg",
|
||||
RightToeBase: "RightFoot",
|
||||
};
|
||||
|
||||
/** Bones a rigger must produce for a clip to be playable at all. */
|
||||
export const REQUIRED_BONES: readonly CanonicalBone[] = [
|
||||
"Hips",
|
||||
"Spine",
|
||||
"Head",
|
||||
"LeftArm",
|
||||
"RightArm",
|
||||
"LeftUpLeg",
|
||||
"LeftLeg",
|
||||
"LeftFoot",
|
||||
"RightUpLeg",
|
||||
"RightLeg",
|
||||
"RightFoot",
|
||||
];
|
||||
|
||||
const CANONICAL_SET: ReadonlySet<string> = new Set(CANONICAL_BONES);
|
||||
|
||||
export function isCanonicalBone(name: string): name is CanonicalBone {
|
||||
return CANONICAL_SET.has(name);
|
||||
}
|
||||
|
||||
/** Depth-first walk order, parents always before children. */
|
||||
export function canonicalWalkOrder(): CanonicalBone[] {
|
||||
const out: CanonicalBone[] = [];
|
||||
const visit = (bone: CanonicalBone) => {
|
||||
out.push(bone);
|
||||
for (const child of CANONICAL_BONES) {
|
||||
if (CANONICAL_HIERARCHY[child] === bone) visit(child);
|
||||
}
|
||||
};
|
||||
visit("Hips");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common aliases emitted by third-party riggers, normalised to canonical names.
|
||||
* Mixamo's `mixamorig:` prefix is stripped before lookup.
|
||||
*/
|
||||
export const BONE_ALIASES: Record<string, CanonicalBone> = {
|
||||
hip: "Hips",
|
||||
pelvis: "Hips",
|
||||
root: "Hips",
|
||||
spine_01: "Spine",
|
||||
spine_02: "Spine1",
|
||||
spine_03: "Spine2",
|
||||
chest: "Spine2",
|
||||
upperchest: "Spine2",
|
||||
head_end: "Head",
|
||||
clavicle_l: "LeftShoulder",
|
||||
clavicle_r: "RightShoulder",
|
||||
upperarm_l: "LeftArm",
|
||||
upperarm_r: "RightArm",
|
||||
lowerarm_l: "LeftForeArm",
|
||||
lowerarm_r: "RightForeArm",
|
||||
hand_l: "LeftHand",
|
||||
hand_r: "RightHand",
|
||||
thigh_l: "LeftUpLeg",
|
||||
thigh_r: "RightUpLeg",
|
||||
calf_l: "LeftLeg",
|
||||
calf_r: "RightLeg",
|
||||
foot_l: "LeftFoot",
|
||||
foot_r: "RightFoot",
|
||||
ball_l: "LeftToeBase",
|
||||
ball_r: "RightToeBase",
|
||||
};
|
||||
|
||||
/** Best-effort mapping of an arbitrary rigger bone name onto the canonical set. */
|
||||
export function normaliseBoneName(raw: string): CanonicalBone | null {
|
||||
const stripped = raw.replace(/^mixamorig[:_]?/i, "").trim();
|
||||
if (isCanonicalBone(stripped)) return stripped;
|
||||
|
||||
const key = stripped.toLowerCase().replace(/[\s.-]+/g, "_");
|
||||
const alias = BONE_ALIASES[key];
|
||||
if (alias) return alias;
|
||||
|
||||
// `LeftArm` vs `leftarm` vs `Left_Arm`
|
||||
const squashed = key.replace(/_/g, "");
|
||||
for (const bone of CANONICAL_BONES) {
|
||||
if (bone.toLowerCase() === squashed) return bone;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Quat, Vec3 } from "./math.js";
|
||||
|
||||
/**
|
||||
* Collision filter categories.
|
||||
*
|
||||
* box3d-wasm exposes `{categoryBits, maskBits}` filters on shapes and raycasts.
|
||||
* The character mover raycasts against WORLD|PROP only, so it never snags on
|
||||
* sensors or on its own capsule.
|
||||
*/
|
||||
export const CollisionGroup = {
|
||||
WORLD: 0x0001,
|
||||
PLAYER: 0x0002,
|
||||
ENEMY: 0x0004,
|
||||
PROP: 0x0008,
|
||||
TRIGGER: 0x0010,
|
||||
CAMERA_ZONE: 0x0020,
|
||||
} as const;
|
||||
|
||||
export type CollisionGroupName = keyof typeof CollisionGroup;
|
||||
|
||||
export interface CollisionFilter {
|
||||
categoryBits: number;
|
||||
maskBits: number;
|
||||
}
|
||||
|
||||
/** Everything the character mover is allowed to stand on or bump into. */
|
||||
export const MOVER_SOLID_MASK = CollisionGroup.WORLD | CollisionGroup.PROP;
|
||||
|
||||
/**
|
||||
* Collider primitives.
|
||||
*
|
||||
* Deliberately limited to what box3d-wasm@0.2.0 binds: box, sphere, capsule and
|
||||
* convex hull. Upstream Box3D has no triangle-mesh shape in this build, so room
|
||||
* geometry is authored as a compound of these rather than as a trimesh (GDD §7
|
||||
* assumes trimesh support that does not exist yet).
|
||||
*/
|
||||
export type ColliderDef =
|
||||
| { kind: "box"; halfExtents: Vec3; offset?: Vec3; rotation?: Quat }
|
||||
| { kind: "sphere"; radius: number; offset?: Vec3 }
|
||||
| { kind: "capsule"; radius: number; height: number; offset?: Vec3 }
|
||||
| { kind: "hull"; points: Vec3[]; offset?: Vec3 };
|
||||
|
||||
export interface ColliderMaterial {
|
||||
friction?: number;
|
||||
restitution?: number;
|
||||
density?: number;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./math.js";
|
||||
export * from "./collision.js";
|
||||
export * from "./room.js";
|
||||
export * from "./inventory.js";
|
||||
export * from "./save.js";
|
||||
export * from "./net.js";
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Inventory + item definitions — GDD §6.3. */
|
||||
|
||||
export type ItemCategory = "key" | "consumable" | "weapon" | "ammo" | "document";
|
||||
|
||||
export interface ItemDef {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: ItemCategory;
|
||||
/** Key items cannot be discarded and do not consume a normal slot. */
|
||||
isKeyItem?: boolean;
|
||||
stackable?: boolean;
|
||||
maxStack?: number;
|
||||
/** Icon colour, standing in for a sprite until the item pipeline lands. */
|
||||
iconColor?: number;
|
||||
}
|
||||
|
||||
export interface ItemStack {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
/** Recipe for GDD §6.3 item combination. Order-independent. */
|
||||
export interface CombinationDef {
|
||||
inputs: [string, string];
|
||||
output: ItemStack;
|
||||
/** Consume the inputs, or keep them (e.g. a tool used on a component). */
|
||||
consumes?: [boolean, boolean];
|
||||
}
|
||||
|
||||
export interface InventoryState {
|
||||
slots: (ItemStack | null)[];
|
||||
capacity: number;
|
||||
/**
|
||||
* Key items are held outside the slot array — they must not compete for
|
||||
* capacity, or a full inventory could soft-lock a puzzle.
|
||||
*/
|
||||
keyItems: string[];
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Plain-object math types.
|
||||
*
|
||||
* box3d-wasm passes vectors as `{x,y,z}` and quaternions as `{x,y,z,w}`, which
|
||||
* is also what three.js `.set()` / `.copy()` accept, so these cross the physics
|
||||
* boundary with no marshalling.
|
||||
*/
|
||||
|
||||
export interface Vec3 {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface Quat {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
w: number;
|
||||
}
|
||||
|
||||
export interface Transform {
|
||||
position: Vec3;
|
||||
rotation: Quat;
|
||||
}
|
||||
|
||||
export const VEC3_ZERO: Readonly<Vec3> = Object.freeze({ x: 0, y: 0, z: 0 });
|
||||
export const VEC3_UP: Readonly<Vec3> = Object.freeze({ x: 0, y: 1, z: 0 });
|
||||
export const QUAT_IDENTITY: Readonly<Quat> = Object.freeze({ x: 0, y: 0, z: 0, w: 1 });
|
||||
|
||||
export const vec3 = (x = 0, y = 0, z = 0): Vec3 => ({ x, y, z });
|
||||
|
||||
export const addVec3 = (a: Vec3, b: Vec3): Vec3 => ({
|
||||
x: a.x + b.x,
|
||||
y: a.y + b.y,
|
||||
z: a.z + b.z,
|
||||
});
|
||||
|
||||
export const subVec3 = (a: Vec3, b: Vec3): Vec3 => ({
|
||||
x: a.x - b.x,
|
||||
y: a.y - b.y,
|
||||
z: a.z - b.z,
|
||||
});
|
||||
|
||||
export const scaleVec3 = (v: Vec3, s: number): Vec3 => ({
|
||||
x: v.x * s,
|
||||
y: v.y * s,
|
||||
z: v.z * s,
|
||||
});
|
||||
|
||||
export const dotVec3 = (a: Vec3, b: Vec3): number => a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
|
||||
export const lengthVec3 = (v: Vec3): number => Math.sqrt(dotVec3(v, v));
|
||||
|
||||
export function normaliseVec3(v: Vec3): Vec3 {
|
||||
const len = lengthVec3(v);
|
||||
return len > 1e-8 ? scaleVec3(v, 1 / len) : { x: 0, y: 0, z: 0 };
|
||||
}
|
||||
|
||||
/** Yaw-only quaternion — the only rotation a tank-controlled character needs. */
|
||||
export function quatFromYaw(yaw: number): Quat {
|
||||
const half = yaw * 0.5;
|
||||
return { x: 0, y: Math.sin(half), z: 0, w: Math.cos(half) };
|
||||
}
|
||||
|
||||
export function yawFromQuat(q: Quat): number {
|
||||
return Math.atan2(2 * (q.w * q.y + q.x * q.z), 1 - 2 * (q.y * q.y + q.z * q.z));
|
||||
}
|
||||
|
||||
/** Shortest signed angular delta from `a` to `b`, in (-PI, PI]. */
|
||||
export function angleDelta(a: number, b: number): number {
|
||||
let d = (b - a) % (Math.PI * 2);
|
||||
if (d > Math.PI) d -= Math.PI * 2;
|
||||
if (d < -Math.PI) d += Math.PI * 2;
|
||||
return d;
|
||||
}
|
||||
|
||||
export const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
|
||||
|
||||
export const clamp = (v: number, min: number, max: number): number =>
|
||||
v < min ? min : v > max ? max : v;
|
||||
|
||||
/**
|
||||
* Frame-rate independent exponential smoothing.
|
||||
* `smoothing` is the fraction of remaining distance left after one second.
|
||||
*/
|
||||
export const damp = (a: number, b: number, smoothing: number, dt: number): number =>
|
||||
lerp(a, b, 1 - Math.pow(smoothing, dt));
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { Vec3 } from "./math.js";
|
||||
|
||||
/**
|
||||
* Network protocol — GDD §8.
|
||||
*
|
||||
* Two distinct channels:
|
||||
*
|
||||
* - **Signalling** (`SignalMessage`) rides the WebSocket to the Hono server.
|
||||
* It exists only to exchange SDP and ICE candidates and to announce lobby
|
||||
* membership. No gameplay ever crosses it.
|
||||
*
|
||||
* - **Game** (`ClientMessage` / `HostMessage`) rides WebRTC data channels
|
||||
* directly between peers. Topology is a star centred on the host, not a
|
||||
* mesh: the host is authoritative, so guest-to-guest links would carry
|
||||
* nothing anyone is allowed to trust.
|
||||
*
|
||||
* Field names are short because these are serialised on every snapshot at
|
||||
* `SNAPSHOT_HZ`, and JSON key overhead dominates a payload this small.
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
|
||||
/** GDD §8: "10–20 Hz (adventure pacing is forgiving)". */
|
||||
export const SNAPSHOT_HZ = 15;
|
||||
export const SNAPSHOT_INTERVAL = 1 / SNAPSHOT_HZ;
|
||||
|
||||
export const MAX_PLAYERS = 4;
|
||||
|
||||
export type PeerId = number;
|
||||
|
||||
export interface PeerInfo {
|
||||
peerId: PeerId;
|
||||
displayName: string;
|
||||
isHost: boolean;
|
||||
isReady: boolean;
|
||||
}
|
||||
|
||||
// --- signalling -------------------------------------------------------------
|
||||
|
||||
/** Opaque WebRTC descriptors. Kept `unknown`-ish so we never re-type the spec. */
|
||||
export interface SignalPayload {
|
||||
sdp?: { type: "offer" | "answer"; sdp: string };
|
||||
ice?: { candidate: string; sdpMid: string | null; sdpMLineIndex: number | null };
|
||||
}
|
||||
|
||||
export type SignalMessage =
|
||||
/** client → server, first frame after the socket opens */
|
||||
| { t: "hello"; token: string; lobbyCode: string; protocol: number }
|
||||
/** server → client, accepted */
|
||||
| { t: "welcome"; peerId: PeerId; isHost: boolean; peers: PeerInfo[] }
|
||||
/** server → all, membership changes */
|
||||
| { t: "peer-joined"; peer: PeerInfo }
|
||||
| { t: "peer-left"; peerId: PeerId }
|
||||
| { t: "peer-updated"; peer: PeerInfo }
|
||||
/** client → server → target client, relayed verbatim */
|
||||
| { t: "signal"; from: PeerId; to: PeerId; payload: SignalPayload }
|
||||
/** client → server */
|
||||
| { t: "ready"; isReady: boolean }
|
||||
/** server → all, host pressed start */
|
||||
| { t: "start"; roomId: string; spawnPointId: string }
|
||||
| { t: "error"; message: string };
|
||||
|
||||
// --- guest → host -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Guests send intent, never position. The host is free to reject any of it,
|
||||
* which is the whole point of host-authoritative play (GDD §8).
|
||||
*/
|
||||
export type ClientMessage =
|
||||
| {
|
||||
t: "input";
|
||||
/** Guest's local tick, echoed back in snapshots so it can reconcile. */
|
||||
tick: number;
|
||||
/** Desired movement on the XZ plane, each component in -1..1. */
|
||||
move: { x: number; z: number };
|
||||
run: boolean;
|
||||
/** Facing the guest believes it has; the host treats it as advisory. */
|
||||
yaw: number;
|
||||
}
|
||||
| { t: "interact"; interactableId: string }
|
||||
| { t: "combine"; itemA: string; itemB: string }
|
||||
| { t: "chat"; text: string };
|
||||
|
||||
// --- host → guests ----------------------------------------------------------
|
||||
|
||||
export interface PlayerSnapshot {
|
||||
peerId: PeerId;
|
||||
p: Vec3;
|
||||
yaw: number;
|
||||
/** Horizontal speed, so remote avatars animate without a second message. */
|
||||
spd: number;
|
||||
grounded: boolean;
|
||||
}
|
||||
|
||||
export type HostMessage =
|
||||
| {
|
||||
t: "snapshot";
|
||||
tick: number;
|
||||
/** Last input tick the host consumed, per peer, for reconciliation. */
|
||||
ack: Record<PeerId, number>;
|
||||
players: PlayerSnapshot[];
|
||||
}
|
||||
/** Authoritative world state. Sent on change, not per tick. */
|
||||
| { t: "flags"; set: string[]; unset: string[] }
|
||||
| { t: "despawn"; interactableIds: string[] }
|
||||
| { t: "inventory"; peerId: PeerId; items: Array<{ itemId: string; quantity: number }> }
|
||||
| { t: "notice"; text: string; to?: PeerId }
|
||||
| { t: "chat"; from: PeerId; text: string }
|
||||
/** Full state for a peer that just joined mid-session. */
|
||||
| {
|
||||
t: "sync";
|
||||
roomId: string;
|
||||
flags: string[];
|
||||
despawned: string[];
|
||||
players: PlayerSnapshot[];
|
||||
};
|
||||
|
||||
export type GameMessage = ClientMessage | HostMessage;
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ColliderDef, ColliderMaterial } from "./collision.js";
|
||||
import type { Vec3 } from "./math.js";
|
||||
|
||||
/** A static piece of level geometry: one visual mesh + its collider compound. */
|
||||
export interface StaticGeometryDef {
|
||||
id: string;
|
||||
/** Which builder in `client/src/world/geometry.ts` renders this piece. */
|
||||
visual:
|
||||
| { kind: "box"; size: Vec3; color: number; textureId?: string }
|
||||
| { kind: "plane"; size: [number, number]; color: number; textureId?: string }
|
||||
/** Rendered via ConvexGeometry so the mesh matches a hull collider exactly. */
|
||||
| { kind: "hull"; points: Vec3[]; color: number }
|
||||
| { kind: "none" };
|
||||
position: Vec3;
|
||||
/** Yaw in radians. Rooms are authored axis-aligned plus yaw; that is enough for the genre. */
|
||||
yaw?: number;
|
||||
colliders: ColliderDef[];
|
||||
material?: ColliderMaterial;
|
||||
/** Despawned once this flag is set — barred doors, collapsed debris, shutters. */
|
||||
removeOnFlag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fixed camera zone — GDD §6.1.
|
||||
*
|
||||
* The trigger is a box sensor. While the player is inside, the camera snaps to
|
||||
* `camera` and looks at `lookAt` (or tracks the player if `trackPlayer` is set).
|
||||
* Overlapping zones resolve by highest `priority`.
|
||||
*/
|
||||
export interface CameraZoneDef {
|
||||
id: string;
|
||||
trigger: { center: Vec3; halfExtents: Vec3 };
|
||||
camera: {
|
||||
position: Vec3;
|
||||
lookAt: Vec3;
|
||||
fov?: number;
|
||||
/** Follow the player with a damped look-at instead of a fixed target. */
|
||||
trackPlayer?: boolean;
|
||||
/** How far the camera may pan while tracking, in world units. */
|
||||
trackRadius?: number;
|
||||
};
|
||||
/** Hard cut (classic RE) or a timed blend (Silent Hill style). Seconds. */
|
||||
blend?: { mode: "cut" } | { mode: "blend"; duration: number };
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export type InteractionKind =
|
||||
| { kind: "examine"; text: string }
|
||||
| { kind: "pickup"; itemId: string; quantity?: number }
|
||||
| { kind: "door"; targetRoomId: string; spawnPointId: string; requiresItemId?: string }
|
||||
| { kind: "useItem"; acceptsItemId: string; onUseFlag: string; text: string };
|
||||
|
||||
/** Something the player can walk up to and press Interact on. */
|
||||
export interface InteractableDef {
|
||||
id: string;
|
||||
label: string;
|
||||
position: Vec3;
|
||||
/** Sensor half-extents defining the "you can reach this" volume. */
|
||||
halfExtents: Vec3;
|
||||
action: InteractionKind;
|
||||
/** Only active while every listed flag is set (or unset, when prefixed with `!`). */
|
||||
requiresFlags?: string[];
|
||||
/** Set to false once consumed; persisted in the save. */
|
||||
repeatable?: boolean;
|
||||
visual?: { kind: "box"; size: Vec3; color: number } | { kind: "none" };
|
||||
}
|
||||
|
||||
export interface SpawnPointDef {
|
||||
id: string;
|
||||
position: Vec3;
|
||||
yaw: number;
|
||||
}
|
||||
|
||||
export interface RoomDef {
|
||||
id: string;
|
||||
displayName: string;
|
||||
/** Linear fog, the primary atmosphere tool (GDD §14). */
|
||||
fog: { color: number; near: number; far: number };
|
||||
ambientLight: { color: number; intensity: number };
|
||||
lights: Array<{
|
||||
kind: "point" | "directional";
|
||||
color: number;
|
||||
intensity: number;
|
||||
position: Vec3;
|
||||
distance?: number;
|
||||
}>;
|
||||
geometry: StaticGeometryDef[];
|
||||
cameraZones: CameraZoneDef[];
|
||||
interactables: InteractableDef[];
|
||||
spawnPoints: SpawnPointDef[];
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { InventoryState } from "./inventory.js";
|
||||
import type { Vec3 } from "./math.js";
|
||||
|
||||
/**
|
||||
* Save payload — serialised straight into the `saves.data` JSONB column
|
||||
* (GDD §9). Versioned so migrations stay possible once campaigns ship.
|
||||
*/
|
||||
export const SAVE_FORMAT_VERSION = 1;
|
||||
|
||||
export interface PlayerSaveState {
|
||||
roomId: string;
|
||||
position: Vec3;
|
||||
yaw: number;
|
||||
health: number;
|
||||
inventory: InventoryState;
|
||||
}
|
||||
|
||||
export interface SaveData {
|
||||
version: number;
|
||||
campaign: string;
|
||||
player: PlayerSaveState;
|
||||
/** Puzzle/door/interaction flags. Presence in the set means "set". */
|
||||
flags: string[];
|
||||
/** Interactables already consumed (non-repeatable ones that fired). */
|
||||
consumedInteractables: string[];
|
||||
playtimeSeconds: number;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
export function emptySave(campaign: string, spawnRoomId: string): SaveData {
|
||||
return {
|
||||
version: SAVE_FORMAT_VERSION,
|
||||
campaign,
|
||||
player: {
|
||||
roomId: spawnRoomId,
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
yaw: 0,
|
||||
health: 100,
|
||||
inventory: { slots: Array(8).fill(null), capacity: 8, keyItems: [] },
|
||||
},
|
||||
flags: [],
|
||||
consumedInteractables: [],
|
||||
playtimeSeconds: 0,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user