Erste lauffähige Fassung: 3D-Raum, Echtzeit-Multiplayer (WebSocket), Python-IDE via Pyodide im Browser, eigener Arbeitsplatz mit Live-Code-Monitor. Modularer, self-hostbarer Aufbau (Three.js + Node/Express + ws), ohne CDN- Abhängigkeit zur Laufzeit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
91 lines
3.6 KiB
JavaScript
91 lines
3.6 KiB
JavaScript
'use strict';
|
|
/*
|
|
* VR ProgrammierHub — Backend-Grundgeruest (Phase 1)
|
|
* Ein einziger Node-Prozess: liefert die Frontend-Assets aus UND haelt den
|
|
* Echtzeit-Raumzustand (WebSocket-Hub, komplett im RAM).
|
|
*
|
|
* Protokoll siehe Spezifikation Abschnitt 9.2. Auth (Login/Rollen) folgt als
|
|
* naechster Schritt — hier zunaechst Name + Rolle aus dem "hello"-Frame.
|
|
*/
|
|
const express = require('express');
|
|
const http = require('http');
|
|
const path = require('path');
|
|
const { WebSocketServer } = require('ws');
|
|
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// ── HTTP: statische Assets ──────────────────────────────────────────────
|
|
const app = express();
|
|
app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));
|
|
app.get('/health', (_req, res) => res.json({ ok: true, users: clients.size, uptime: process.uptime() }));
|
|
|
|
const server = http.createServer(app);
|
|
|
|
// ── Realtime-Hub: fluechtiger Raumzustand im Arbeitsspeicher ────────────
|
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
let nextId = 1;
|
|
/** id -> { ws, alive, state } */
|
|
const clients = new Map();
|
|
|
|
function send(ws, obj) { if (ws.readyState === 1) ws.send(JSON.stringify(obj)); }
|
|
function broadcast(obj, exceptId) {
|
|
const msg = JSON.stringify(obj);
|
|
for (const [id, c] of clients) if (id !== exceptId && c.ws.readyState === 1) c.ws.send(msg);
|
|
}
|
|
|
|
wss.on('connection', (ws) => {
|
|
const id = nextId++;
|
|
const state = { id, name: '…', role: 'student', p: [0, 0, 8], rot: 0, anim: 'idle', label: '', help: false };
|
|
const client = { ws, alive: true, state, joined: false };
|
|
clients.set(id, client);
|
|
ws.on('pong', () => (client.alive = true));
|
|
|
|
ws.on('message', (data) => {
|
|
let m;
|
|
try { m = JSON.parse(data); } catch { return; }
|
|
switch (m.t) {
|
|
case 'hello': {
|
|
state.name = String(m.name || 'Gast').slice(0, 24) || 'Gast';
|
|
state.role = m.role === 'teacher' ? 'teacher' : 'student';
|
|
if (Array.isArray(m.p) && m.p.length === 3) state.p = m.p.map(Number);
|
|
// eigene id + Vollzustand aller anderen an den neuen Client
|
|
send(ws, { t: 'welcome', id, users: [...clients.values()].filter((c) => c.state.id !== id).map((c) => c.state) });
|
|
client.joined = true;
|
|
broadcast({ t: 'join', user: state }, id); // andere ueber Neuzugang informieren
|
|
break;
|
|
}
|
|
case 'move': {
|
|
if (!client.joined) break;
|
|
if (Array.isArray(m.p) && m.p.length === 3) state.p = m.p.map(Number);
|
|
if (typeof m.rot === 'number') state.rot = m.rot;
|
|
if (typeof m.anim === 'string') state.anim = m.anim;
|
|
broadcast({ t: 'update', id, p: state.p, rot: state.rot, anim: state.anim }, id);
|
|
break;
|
|
}
|
|
case 'status': {
|
|
state.label = String(m.label || '').slice(0, 40);
|
|
broadcast({ t: 'status', id, label: state.label }, id);
|
|
break;
|
|
}
|
|
case 'help': {
|
|
state.help = !!m.on;
|
|
broadcast({ t: 'help', id, on: state.help }, id);
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => { clients.delete(id); broadcast({ t: 'leave', id }); });
|
|
ws.on('error', () => {});
|
|
});
|
|
|
|
// ── Heartbeat: tote Verbindungen aufraeumen ─────────────────────────────
|
|
setInterval(() => {
|
|
for (const [id, c] of clients) {
|
|
if (!c.alive) { try { c.ws.terminate(); } catch {} clients.delete(id); broadcast({ t: 'leave', id }); continue; }
|
|
c.alive = false;
|
|
try { c.ws.ping(); } catch {}
|
|
}
|
|
}, 15000);
|
|
|
|
server.listen(PORT, () => console.log(`[ProgrammierHub] laeuft auf :${PORT} (WS-Pfad /ws)`));
|