Multiplayer: shared-booth relay + client sync + avatars

- server/relay.mjs: WebSocket room (ws, 127.0.0.1:8433 behind a proxy).
  Relays positions/edits/repairs/platter states; keeps the authoritative
  world diff + quest state for late joiners; persists to booth-state.json.
  Hardened: origin allowlist, 24-peer cap, 2KB frames, 40 msg/s budget,
  bounds+type validation on every message, ping/terminate dead sockets.
- src/net/NetClient.ts: offline-tolerant client — mirrors bus events out,
  applies remote events with an echo guard (quest.repair/platter setters
  are idempotent/deduped so replication can't loop). 10Hz delta'd positions.
- src/net/Avatars.ts: voxel-person avatars with headphones + name tags,
  lerped to network updates.
- Menu: DJ name setting (auto-generated default); help page multiplayer note.
- main.ts: wires avatars + status chip ('online - N other DJs in the booth').

Verified with two live tabs: mutual avatars, block edit + quest repair
replicate both ways, late-joiner gets full state replay, non-gameplay
setBlock correctly does NOT sync, zero console errors, relay log clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jing 2026-07-13 23:06:49 +10:00
parent d2ff670848
commit af9f1ad66d
9 changed files with 612 additions and 5 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
node_modules/ node_modules/
dist/ dist/
.DS_Store .DS_Store
server/booth-state.json*

36
server/package-lock.json generated Normal file
View File

@ -0,0 +1,36 @@
{
"name": "turncraft-relay",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "turncraft-relay",
"version": "0.1.0",
"dependencies": {
"ws": "^8.18.0"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

12
server/package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "turncraft-relay",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"start": "node relay.mjs"
},
"dependencies": {
"ws": "^8.18.0"
}
}

183
server/relay.mjs Normal file
View File

@ -0,0 +1,183 @@
// TURNCRAFT multiplayer relay — one shared booth.
//
// A deliberately small WebSocket room server: relays player positions,
// voxel edits, quest repairs and platter states; keeps the authoritative
// world-diff so late joiners see the same booth; persists it to disk.
//
// Run: node relay.mjs (PORT=8433 HOST=127.0.0.1 by default)
// Prod: bind 127.0.0.1 behind an nginx `location /turncraft/ws` proxy.
import { WebSocketServer } from 'ws';
import { readFileSync, writeFileSync, renameSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const PORT = Number(process.env.PORT ?? 8433);
const HOST = process.env.HOST ?? '127.0.0.1';
// Mirrors src/core/constants.ts (server is standalone by design).
const WORLD = { x: 448, y: 160, z: 256 };
const MAX_BLOCK_ID = 30;
const NODES = new Set(['stylus', 'rca', 'crossfader', 'fuse', 'power']);
// Limits (ship-check): bounded room, bounded frames, bounded rates.
const MAX_PEERS = 24;
const MAX_FRAME_BYTES = 2048;
const MAX_MSGS_PER_SEC = 40;
const MAX_NAME_LEN = 24;
const ALLOWED_ORIGINS = [
/^https:\/\/([a-z0-9-]+\.)?partly\.party$/,
/^https?:\/\/localhost(:\d+)?$/,
/^https?:\/\/127\.0\.0\.1(:\d+)?$/,
];
const STATE_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'booth-state.json');
// ---- room state --------------------------------------------------------
/** edits: "x,y,z" -> blockId (the diff against the generated booth) */
const edits = new Map();
const repaired = new Set();
const platters = { A: { playing: false, rpm: 33 }, B: { playing: false, rpm: 33 } };
let dirty = false;
function loadState() {
if (!existsSync(STATE_FILE)) return;
try {
const s = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
for (const [k, v] of s.edits ?? []) edits.set(k, v);
for (const n of s.repaired ?? []) if (NODES.has(n)) repaired.add(n);
Object.assign(platters.A, s.platters?.A ?? {});
Object.assign(platters.B, s.platters?.B ?? {});
console.log(`[relay] loaded state: ${edits.size} edits, ${repaired.size} repairs`);
} catch (e) {
console.error('[relay] state load failed, starting fresh:', e.message);
}
}
function saveState() {
if (!dirty) return;
dirty = false;
const tmp = STATE_FILE + '.tmp';
writeFileSync(tmp, JSON.stringify({
edits: [...edits.entries()], repaired: [...repaired], platters,
}));
renameSync(tmp, STATE_FILE);
}
// ---- helpers -----------------------------------------------------------
const isInt = (v) => Number.isInteger(v);
const inBounds = (x, y, z) =>
isInt(x) && isInt(y) && isInt(z) &&
x >= 0 && x < WORLD.x && y >= 0 && y < WORLD.y && z >= 0 && z < WORLD.z;
const finiteVec3 = (p) => Array.isArray(p) && p.length === 3 && p.every(Number.isFinite);
const cleanName = (n) =>
(typeof n === 'string' ? n : '').replace(/[^\w \-.]/g, '').slice(0, MAX_NAME_LEN) || 'lil dj';
let nextId = 1;
const peers = new Map(); // id -> { ws, name, alive, msgTimes }
function send(ws, msg) {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
}
function broadcast(msg, exceptId = null) {
const raw = JSON.stringify(msg);
for (const [id, p] of peers) {
if (id !== exceptId && p.ws.readyState === p.ws.OPEN) p.ws.send(raw);
}
}
// ---- server ------------------------------------------------------------
loadState();
const wss = new WebSocketServer({
host: HOST, port: PORT, maxPayload: MAX_FRAME_BYTES, clientTracking: false,
verifyClient: ({ origin }) => !origin || ALLOWED_ORIGINS.some((re) => re.test(origin)),
});
wss.on('connection', (ws) => {
if (peers.size >= MAX_PEERS) {
send(ws, { t: 'full' });
ws.close(1013, 'room full');
return;
}
const id = nextId++;
const peer = { ws, name: 'lil dj', alive: true, budget: MAX_MSGS_PER_SEC };
peers.set(id, peer);
send(ws, {
t: 'hello', id,
peers: [...peers.entries()].filter(([pid]) => pid !== id).map(([pid, p]) => ({ id: pid, name: p.name })),
state: { edits: [...edits.entries()], repaired: [...repaired], platters },
});
ws.on('pong', () => { peer.alive = true; });
ws.on('message', (data, isBinary) => {
if (isBinary || data.length > MAX_FRAME_BYTES) return;
if (--peer.budget < 0) return; // over rate: drop silently (budget refills each second)
let m;
try { m = JSON.parse(data.toString()); } catch { return; }
if (!m || typeof m.t !== 'string') return;
switch (m.t) {
case 'hi': {
peer.name = cleanName(m.name);
broadcast({ t: 'join', id, name: peer.name }, id);
break;
}
case 'pos': {
if (!finiteVec3(m.p) || !finiteVec3(m.look)) return;
broadcast({ t: 'pos', id, p: m.p, look: m.look }, id);
break;
}
case 'edit': {
if (!inBounds(m.x, m.y, m.z)) return;
if (!isInt(m.id) || m.id < 0 || m.id > MAX_BLOCK_ID) return;
edits.set(`${m.x},${m.y},${m.z}`, m.id);
dirty = true;
broadcast({ t: 'edit', x: m.x, y: m.y, z: m.z, id: m.id }, id);
break;
}
case 'repair': {
if (!NODES.has(m.node) || repaired.has(m.node)) return;
repaired.add(m.node);
dirty = true;
broadcast({ t: 'repair', node: m.node }, id);
break;
}
case 'platter': {
if (m.deck !== 'A' && m.deck !== 'B') return;
if (typeof m.playing !== 'boolean' || (m.rpm !== 33 && m.rpm !== 45)) return;
platters[m.deck] = { playing: m.playing, rpm: m.rpm };
dirty = true;
broadcast({ t: 'platter', deck: m.deck, playing: m.playing, rpm: m.rpm }, id);
break;
}
default: // unknown type: ignore
}
});
ws.on('close', () => {
peers.delete(id);
broadcast({ t: 'leave', id });
});
ws.on('error', () => { /* close follows */ });
});
setInterval(() => {
for (const p of peers.values()) p.budget = MAX_MSGS_PER_SEC;
}, 1000);
setInterval(() => {
for (const [id, p] of peers) {
if (!p.alive) { p.ws.terminate(); peers.delete(id); broadcast({ t: 'leave', id }); continue; }
p.alive = false;
p.ws.ping();
}
}, 30_000);
setInterval(saveState, 30_000);
for (const sig of ['SIGINT', 'SIGTERM']) {
process.on(sig, () => { saveState(); process.exit(0); });
}
console.log(`[relay] turncraft relay on ws://${HOST}:${PORT} (room cap ${MAX_PEERS})`);

View File

@ -14,6 +14,8 @@ import { AudioEngine } from './audio/AudioEngine';
import { FxSystem } from './fx/FxSystem'; import { FxSystem } from './fx/FxSystem';
import { Hud } from './ui/Hud'; import { Hud } from './ui/Hud';
import { Menu, QUALITY_PIXEL_RATIO } from './ui/Menu'; import { Menu, QUALITY_PIXEL_RATIO } from './ui/Menu';
import { Avatars } from './net/Avatars';
import { NetClient, relayUrl } from './net/NetClient';
const container = document.getElementById('app')!; const container = document.getElementById('app')!;
container.innerHTML = ''; container.innerHTML = '';
@ -106,6 +108,26 @@ document.addEventListener('pointerlockchange', () => {
else if (started && !menu.isOpen()) hud.setPaused(true); else if (started && !menu.isOpen()) hud.setPaused(true);
}); });
// Multiplayer: shared booth via the relay (server/relay.mjs). Fully optional —
// if the relay is unreachable this is a normal single-player session.
const avatars = new Avatars(scene);
const netChip = document.createElement('div');
netChip.style.cssText =
'position:fixed;top:10px;left:150px;z-index:40;font:11px ui-monospace,monospace;' +
'color:#8a8378;background:#0008;border:1px solid #3a352c;border-radius:6px;padding:4px 8px;display:none';
document.body.appendChild(netChip);
const net = new NetClient({
world, machines, player, avatars,
name: menu.getSettings().name,
url: relayUrl(import.meta.env.BASE_URL),
onStatus: (peerCount, connected) => {
netChip.style.display = connected ? 'block' : 'none';
netChip.textContent = peerCount === 0
? 'online — booth to yourself'
: `online — ${peerCount} other DJ${peerCount === 1 ? '' : 's'} in the booth`;
},
});
// Interaction input (mouse buttons + E + hotbar keys), gated on pointer lock // Interaction input (mouse buttons + E + hotbar keys), gated on pointer lock
// like Lane B's movement keys. // like Lane B's movement keys.
const locked = () => document.pointerLockElement === renderer.domElement; const locked = () => document.pointerLockElement === renderer.domElement;
@ -141,6 +163,7 @@ function frame(now: number): void {
player.update(FIXED_DT); player.update(FIXED_DT);
interaction.update(FIXED_DT); interaction.update(FIXED_DT);
fx.update(FIXED_DT); fx.update(FIXED_DT);
avatars.update(FIXED_DT);
acc -= FIXED_DT; acc -= FIXED_DT;
steps++; steps++;
} }
@ -162,4 +185,4 @@ requestAnimationFrame(frame);
declare global { declare global {
interface Window { TURNCRAFT: unknown } interface Window { TURNCRAFT: unknown }
} }
window.TURNCRAFT = { world, player, machines, quest: machines.quest, hotbar, audio, fx, hud, chunks, interaction, camera, render }; window.TURNCRAFT = { world, player, machines, quest: machines.quest, hotbar, audio, fx, hud, chunks, interaction, camera, render, net, avatars };

116
src/net/Avatars.ts Normal file
View File

@ -0,0 +1,116 @@
// Remote-player avatars: a chunky voxel-person (body + head) per peer with a
// name tag, position/heading smoothed toward the last network update.
import * as THREE from 'three';
import type { Vec3 } from '../core/types';
const LERP_RATE = 12; // 1/s — snappy but smooth at 10 Hz updates
interface Peer {
group: THREE.Group;
nameTag: THREE.Sprite;
target: THREE.Vector3;
targetYaw: number;
yaw: number;
}
function nameSprite(name: string): THREE.Sprite {
const c = document.createElement('canvas');
const ctx = c.getContext('2d')!;
ctx.font = '28px ui-monospace, monospace';
const w = Math.min(320, Math.ceil(ctx.measureText(name).width) + 24);
c.width = w; c.height = 40;
const ctx2 = c.getContext('2d')!;
ctx2.fillStyle = 'rgba(0,0,0,0.55)';
ctx2.fillRect(0, 0, w, 40);
ctx2.font = '28px ui-monospace, monospace';
ctx2.fillStyle = '#ffe9c0';
ctx2.textAlign = 'center';
ctx2.textBaseline = 'middle';
ctx2.fillText(name, w / 2, 21);
const tex = new THREE.CanvasTexture(c);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true }));
sprite.scale.set(w / 40, 1, 1);
return sprite;
}
/** Deterministic per-id avatar hue so everyone sees the same colors. */
function peerColor(id: number): THREE.Color {
return new THREE.Color().setHSL((id * 0.6180339887) % 1, 0.55, 0.55);
}
export class Avatars {
private readonly scene: THREE.Object3D;
private readonly peers = new Map<number, Peer>();
constructor(scene: THREE.Object3D) {
this.scene = scene;
}
add(id: number, name: string): void {
if (this.peers.has(id)) return;
const color = peerColor(id);
const group = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({ color, roughness: 0.8 });
const headMat = new THREE.MeshStandardMaterial({ color: color.clone().offsetHSL(0, 0, 0.15), roughness: 0.7 });
const body = new THREE.Mesh(new THREE.BoxGeometry(0.6, 1.15, 0.35), bodyMat);
body.position.y = 0.575;
const head = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.5, 0.5), headMat);
head.position.y = 1.45;
// Headphones: every tiny DJ wears them.
const bandMat = new THREE.MeshStandardMaterial({ color: 0x191919, roughness: 0.5 });
const band = new THREE.Mesh(new THREE.BoxGeometry(0.56, 0.08, 0.2), bandMat);
band.position.y = 1.72;
const cupL = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.24, 0.24), bandMat);
cupL.position.set(-0.31, 1.45, 0);
const cupR = cupL.clone();
cupR.position.x = 0.31;
group.add(body, head, band, cupL, cupR);
const tag = nameSprite(name);
tag.position.y = 2.15;
group.add(tag);
group.visible = false; // until the first pos update
this.scene.add(group);
this.peers.set(id, { group, nameTag: tag, target: new THREE.Vector3(), targetYaw: 0, yaw: 0 });
}
remove(id: number): void {
const p = this.peers.get(id);
if (!p) return;
this.scene.remove(p.group);
p.group.traverse((o) => {
const mesh = o as THREE.Mesh;
if (mesh.geometry) mesh.geometry.dispose();
const mat = (mesh as THREE.Mesh).material as THREE.Material | undefined;
if (mat) mat.dispose();
});
this.peers.delete(id);
}
updatePos(id: number, p: Vec3, look: Vec3): void {
const peer = this.peers.get(id);
if (!peer) return;
if (!peer.group.visible) {
peer.group.visible = true;
peer.group.position.set(p[0], p[1], p[2]);
}
peer.target.set(p[0], p[1], p[2]);
peer.targetYaw = Math.atan2(look[0], look[2]);
}
update(dt: number): void {
const k = Math.min(1, LERP_RATE * dt);
for (const p of this.peers.values()) {
p.group.position.lerp(p.target, k);
let d = p.targetYaw - p.yaw;
d = ((d + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
p.yaw += d * k;
p.group.rotation.y = p.yaw;
}
}
count(): number { return this.peers.size; }
}

204
src/net/NetClient.ts Normal file
View File

@ -0,0 +1,204 @@
// TURNCRAFT multiplayer client. Connects to the relay (server/relay.mjs),
// mirrors local gameplay onto the wire and remote gameplay into the world:
//
// out: position @10Hz, block edits, quest repairs, platter states
// in: peers' positions -> Avatars; edits -> world.setBlock (+ bus fx);
// repairs -> quest.repair (idempotent); platter -> machine state
//
// Offline-tolerant: if the relay is unreachable the game is simply
// single-player; reconnects with backoff in the background.
import { AIR } from '../core/blocks';
import { bus } from '../core/events';
import type { SignalNode } from '../core/constants';
import type { IPlayerView, IVoxelWorld, Vec3 } from '../core/types';
import type { MachineSet, Platter } from '../machines';
import { Avatars } from './Avatars';
const POS_HZ = 10;
const RECONNECT_MS = 15_000;
interface NetOpts {
world: IVoxelWorld;
machines: MachineSet;
player: IPlayerView;
avatars: Avatars;
name: string;
/** e.g. "wss://partly.party/turncraft/ws" or "ws://localhost:8137" */
url: string;
onStatus?(peerCount: number, connected: boolean): void;
}
export function relayUrl(basePath: string): string {
if (location.protocol === 'https:') return `wss://${location.host}${basePath}ws`;
return `ws://${location.hostname}:8433`; // local dev relay
}
export class NetClient {
private readonly o: NetOpts;
private ws: WebSocket | null = null;
private applyingRemote = false;
private peerCount = 0;
private lastSent: { p: Vec3; look: Vec3 } | null = null;
private posTimer: number | null = null;
private reconnectTimer: number | null = null;
private disposed = false;
private readonly unsubs: Array<() => void> = [];
constructor(opts: NetOpts) {
this.o = opts;
this.wireBus();
this.connect();
}
private platter(deck: 'A' | 'B'): Platter | undefined {
return this.o.machines.machines.find((m) => m.id === `platter${deck}`) as Platter | undefined;
}
private send(msg: unknown): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
}
private wireBus(): void {
this.unsubs.push(bus.on('block:break', ({ x, y, z }) => {
if (!this.applyingRemote) this.send({ t: 'edit', x, y, z, id: AIR });
}));
this.unsubs.push(bus.on('block:place', ({ x, y, z, id }) => {
if (!this.applyingRemote) this.send({ t: 'edit', x, y, z, id });
}));
this.unsubs.push(bus.on('signal:repair', ({ node }) => {
if (!this.applyingRemote) this.send({ t: 'repair', node });
}));
this.unsubs.push(bus.on('platter:state', ({ deck, playing, rpm }) => {
if (!this.applyingRemote) this.send({ t: 'platter', deck, playing, rpm });
}));
}
private connect(): void {
if (this.disposed) return;
let ws: WebSocket;
try {
ws = new WebSocket(this.o.url);
} catch {
this.scheduleReconnect();
return;
}
this.ws = ws;
ws.onopen = () => {
this.send({ t: 'hi', name: this.o.name });
this.startPosLoop();
this.o.onStatus?.(this.peerCount, true);
};
ws.onmessage = (ev) => {
let m: any;
try { m = JSON.parse(String(ev.data)); } catch { return; }
if (!m || typeof m.t !== 'string') return;
this.handle(m);
};
ws.onclose = () => {
this.stopPosLoop();
this.o.onStatus?.(0, false);
this.scheduleReconnect();
};
ws.onerror = () => { /* onclose follows */ };
}
private scheduleReconnect(): void {
if (this.disposed || this.reconnectTimer !== null) return;
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, RECONNECT_MS);
}
private handle(m: any): void {
switch (m.t) {
case 'hello': {
this.peerCount = Array.isArray(m.peers) ? m.peers.length : 0;
for (const p of m.peers ?? []) this.o.avatars.add(p.id, String(p.name ?? 'lil dj'));
this.applyRemote(() => {
for (const [key, id] of m.state?.edits ?? []) {
const [x, y, z] = String(key).split(',').map(Number);
this.o.world.setBlock(x, y, z, id);
}
for (const node of m.state?.repaired ?? []) {
this.o.machines.quest.repair(node as SignalNode);
}
for (const deck of ['A', 'B'] as const) {
const s = m.state?.platters?.[deck];
const pl = this.platter(deck);
if (s && pl) { pl.setSpeed(s.rpm); pl.setPlaying(s.playing); }
}
});
this.o.onStatus?.(this.peerCount, true);
break;
}
case 'join':
this.peerCount++;
this.o.avatars.add(m.id, String(m.name ?? 'lil dj'));
this.o.onStatus?.(this.peerCount, true);
break;
case 'leave':
this.peerCount = Math.max(0, this.peerCount - 1);
this.o.avatars.remove(m.id);
this.o.onStatus?.(this.peerCount, true);
break;
case 'pos':
this.o.avatars.updatePos(m.id, m.p, m.look);
break;
case 'edit':
this.applyRemote(() => {
this.o.world.setBlock(m.x, m.y, m.z, m.id);
if (m.id === AIR) bus.emit('block:break', { x: m.x, y: m.y, z: m.z, id: AIR });
else bus.emit('block:place', { x: m.x, y: m.y, z: m.z, id: m.id });
});
break;
case 'repair':
this.applyRemote(() => { this.o.machines.quest.repair(m.node as SignalNode); });
break;
case 'platter': {
const pl = this.platter(m.deck);
if (pl) this.applyRemote(() => { pl.setSpeed(m.rpm); pl.setPlaying(m.playing); });
break;
}
case 'full':
console.warn('[net] booth is full — playing solo');
this.dispose();
break;
}
}
private applyRemote(fn: () => void): void {
this.applyingRemote = true;
try { fn(); } finally { this.applyingRemote = false; }
}
private startPosLoop(): void {
this.stopPosLoop();
this.posTimer = window.setInterval(() => {
const p = this.o.player.position;
const look = this.o.player.lookDir;
const last = this.lastSent;
const moved = !last ||
Math.abs(p[0] - last.p[0]) + Math.abs(p[1] - last.p[1]) + Math.abs(p[2] - last.p[2]) > 0.03 ||
Math.abs(look[0] - last.look[0]) + Math.abs(look[2] - last.look[2]) > 0.02;
if (!moved) return;
this.lastSent = { p: [...p] as Vec3, look: [...look] as Vec3 };
this.send({ t: 'pos', p: this.lastSent.p, look: this.lastSent.look });
}, 1000 / POS_HZ);
}
private stopPosLoop(): void {
if (this.posTimer !== null) { clearInterval(this.posTimer); this.posTimer = null; }
}
dispose(): void {
this.disposed = true;
this.stopPosLoop();
if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer);
for (const u of this.unsubs) u();
this.ws?.close();
this.ws = null;
}
}

View File

@ -7,13 +7,20 @@ export interface Settings {
sensitivity: number; // multiplier on the 0.0022 rad/px base sensitivity: number; // multiplier on the 0.0022 rad/px base
viewBob: boolean; viewBob: boolean;
quality: 'sharp' | 'balanced' | 'fast'; // pixelRatio cap 2 / 1.5 / 1 quality: 'sharp' | 'balanced' | 'fast'; // pixelRatio cap 2 / 1.5 / 1
name: string; // DJ name shown to other players (applies on reload)
} }
export const QUALITY_PIXEL_RATIO: Record<Settings['quality'], number> = { export const QUALITY_PIXEL_RATIO: Record<Settings['quality'], number> = {
sharp: 2, balanced: 1.5, fast: 1, sharp: 2, balanced: 1.5, fast: 1,
}; };
const DEFAULTS: Settings = { volume: 0.85, sensitivity: 1, viewBob: false, quality: 'sharp' }; const DJ_NAMES = ['dusty', 'crossfade', 'stylus', 'wax', 'groove', 'needle', 'slipmat', 'hi-hat'];
const randomName = () =>
`dj ${DJ_NAMES[Math.floor(Math.random() * DJ_NAMES.length)]}${Math.floor(Math.random() * 90 + 10)}`;
const DEFAULTS: Settings = {
volume: 0.85, sensitivity: 1, viewBob: false, quality: 'sharp', name: '',
};
const LS_SETTINGS = 'turncraft.settings'; const LS_SETTINGS = 'turncraft.settings';
const LS_HELP_SEEN = 'turncraft.helpSeen'; const LS_HELP_SEEN = 'turncraft.helpSeen';
@ -59,6 +66,10 @@ empty box while holding it.</td></tr>
<tr><td>POWER</td><td>when the other four glow on your tracker, the master <tr><td>POWER</td><td>when the other four glow on your tracker, the master
switch on the mixer's back edge lights green. Climb up and press it.</td></tr> switch on the mixer's back edge lights green. Climb up and press it.</td></tr>
</table> </table>
<h3>MULTIPLAYER</h3>
<p>The booth is shared: other tiny DJs may be in here with you. You'll see
them riding the records; blocks they mine or place change for everyone, and
quest repairs count for the whole booth. Set your DJ name in SETTINGS.</p>
<h3>TIPS</h3> <h3>TIPS</h3>
<p>The records spin stand on one and ride. Walking to the middle while it <p>The records spin stand on one and ride. Walking to the middle while it
turns is a real fight (tiny legs, big physics): press the START/STOP plate to turns is a real fight (tiny legs, big physics): press the START/STOP plate to
@ -129,11 +140,16 @@ export class Menu {
}; };
private load(): Settings { private load(): Settings {
let s = { ...DEFAULTS };
try { try {
const raw = localStorage.getItem(LS_SETTINGS); const raw = localStorage.getItem(LS_SETTINGS);
if (raw) return { ...DEFAULTS, ...JSON.parse(raw) as Partial<Settings> }; if (raw) s = { ...s, ...JSON.parse(raw) as Partial<Settings> };
} catch { /* fall through to defaults */ } } catch { /* fall through to defaults */ }
return { ...DEFAULTS }; if (!s.name) {
s.name = randomName();
localStorage.setItem(LS_SETTINGS, JSON.stringify(s));
}
return s;
} }
private save(): void { private save(): void {
@ -187,6 +203,19 @@ export class Menu {
st.appendChild(this.select('Render quality', st.appendChild(this.select('Render quality',
[['sharp', 'Sharp'], ['balanced', 'Balanced'], ['fast', 'Fast']], s.quality, [['sharp', 'Sharp'], ['balanced', 'Balanced'], ['fast', 'Fast']], s.quality,
(v) => { this.settings.quality = v as Settings['quality']; this.save(); })); (v) => { this.settings.quality = v as Settings['quality']; this.save(); }));
st.appendChild(this.text('DJ name (shown to others — applies on reload)', s.name,
(v) => { this.settings.name = v.slice(0, 24) || s.name; this.save(); }));
}
private text(label: string, value: string, onChange: (v: string) => void): HTMLElement {
const row = this.row(label);
const input = document.createElement('input');
input.type = 'text';
input.maxLength = 24;
input.value = value;
input.addEventListener('change', () => onChange(input.value.trim()));
row.appendChild(input);
return row;
} }
private row(label: string): HTMLLabelElement { private row(label: string): HTMLLabelElement {
@ -263,7 +292,9 @@ export class Menu {
.tcm-row input[type=range]{flex:1;accent-color:#ffb028} .tcm-row input[type=range]{flex:1;accent-color:#ffb028}
.tcm-row input[type=checkbox]{accent-color:#ffb028;width:16px;height:16px} .tcm-row input[type=checkbox]{accent-color:#ffb028;width:16px;height:16px}
.tcm-row select{background:#0a0908;color:#d8cdb4;border:1px solid #3a352c;border-radius:4px; .tcm-row select{background:#0a0908;color:#d8cdb4;border:1px solid #3a352c;border-radius:4px;
font:12px ui-monospace,monospace;padding:4px 6px}`; font:12px ui-monospace,monospace;padding:4px 6px}
.tcm-row input[type=text]{background:#0a0908;color:#d8cdb4;border:1px solid #3a352c;
border-radius:4px;font:12px ui-monospace,monospace;padding:4px 8px;width:160px}`;
document.head.appendChild(css); document.head.appendChild(css);
} }
} }

View File

@ -8,6 +8,7 @@
"noUnusedLocals": false, "noUnusedLocals": false,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"skipLibCheck": true, "skipLibCheck": true,
"types": ["vite/client"],
"isolatedModules": true, "isolatedModules": true,
"noEmit": true "noEmit": true
}, },