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:
parent
d2ff670848
commit
af9f1ad66d
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
server/booth-state.json*
|
||||
|
||||
36
server/package-lock.json
generated
Normal file
36
server/package-lock.json
generated
Normal 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
12
server/package.json
Normal 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
183
server/relay.mjs
Normal 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})`);
|
||||
25
src/main.ts
25
src/main.ts
@ -14,6 +14,8 @@ import { AudioEngine } from './audio/AudioEngine';
|
||||
import { FxSystem } from './fx/FxSystem';
|
||||
import { Hud } from './ui/Hud';
|
||||
import { Menu, QUALITY_PIXEL_RATIO } from './ui/Menu';
|
||||
import { Avatars } from './net/Avatars';
|
||||
import { NetClient, relayUrl } from './net/NetClient';
|
||||
|
||||
const container = document.getElementById('app')!;
|
||||
container.innerHTML = '';
|
||||
@ -106,6 +108,26 @@ document.addEventListener('pointerlockchange', () => {
|
||||
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
|
||||
// like Lane B's movement keys.
|
||||
const locked = () => document.pointerLockElement === renderer.domElement;
|
||||
@ -141,6 +163,7 @@ function frame(now: number): void {
|
||||
player.update(FIXED_DT);
|
||||
interaction.update(FIXED_DT);
|
||||
fx.update(FIXED_DT);
|
||||
avatars.update(FIXED_DT);
|
||||
acc -= FIXED_DT;
|
||||
steps++;
|
||||
}
|
||||
@ -162,4 +185,4 @@ requestAnimationFrame(frame);
|
||||
declare global {
|
||||
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
116
src/net/Avatars.ts
Normal 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
204
src/net/NetClient.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -7,13 +7,20 @@ export interface Settings {
|
||||
sensitivity: number; // multiplier on the 0.0022 rad/px base
|
||||
viewBob: boolean;
|
||||
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> = {
|
||||
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_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
|
||||
switch on the mixer's back edge lights green. Climb up and press it.</td></tr>
|
||||
</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>
|
||||
<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
|
||||
@ -129,11 +140,16 @@ export class Menu {
|
||||
};
|
||||
|
||||
private load(): Settings {
|
||||
let s = { ...DEFAULTS };
|
||||
try {
|
||||
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 */ }
|
||||
return { ...DEFAULTS };
|
||||
if (!s.name) {
|
||||
s.name = randomName();
|
||||
localStorage.setItem(LS_SETTINGS, JSON.stringify(s));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
@ -187,6 +203,19 @@ export class Menu {
|
||||
st.appendChild(this.select('Render quality',
|
||||
[['sharp', 'Sharp'], ['balanced', 'Balanced'], ['fast', 'Fast']], s.quality,
|
||||
(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 {
|
||||
@ -263,7 +292,9 @@ export class Menu {
|
||||
.tcm-row input[type=range]{flex:1;accent-color:#ffb028}
|
||||
.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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
"noUnusedLocals": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client"],
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user