diff --git a/.gitignore b/.gitignore index 3bdd52e..32530ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ .DS_Store +server/booth-state.json* diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..f17512a --- /dev/null +++ b/server/package-lock.json @@ -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 + } + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..a55d3d4 --- /dev/null +++ b/server/package.json @@ -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" + } +} diff --git a/server/relay.mjs b/server/relay.mjs new file mode 100644 index 0000000..6387539 --- /dev/null +++ b/server/relay.mjs @@ -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})`); diff --git a/src/main.ts b/src/main.ts index c99b930..6cff800 100644 --- a/src/main.ts +++ b/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 }; diff --git a/src/net/Avatars.ts b/src/net/Avatars.ts new file mode 100644 index 0000000..6ff577f --- /dev/null +++ b/src/net/Avatars.ts @@ -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(); + + 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; } +} diff --git a/src/net/NetClient.ts b/src/net/NetClient.ts new file mode 100644 index 0000000..de01a0c --- /dev/null +++ b/src/net/NetClient.ts @@ -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; + } +} diff --git a/src/ui/Menu.ts b/src/ui/Menu.ts index b41b517..79c7ae7 100644 --- a/src/ui/Menu.ts +++ b/src/ui/Menu.ts @@ -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 = { 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. POWERwhen the other four glow on your tracker, the master switch on the mixer's back edge lights green. Climb up and press it. +

MULTIPLAYER

+

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.

TIPS

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 }; + if (raw) s = { ...s, ...JSON.parse(raw) as Partial }; } 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); } } diff --git a/tsconfig.json b/tsconfig.json index 20d4d93..5ccdc2a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "noUnusedLocals": false, "noFallthroughCasesInSwitch": true, "skipLibCheck": true, + "types": ["vite/client"], "isolatedModules": true, "noEmit": true },