programmierhub/server.js
Stefan Franke 502131f07c VR ProgrammierHub — begehbarer 3D-Lernraum für hybride Programmier-Lehre
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>
2026-07-30 13:35:11 +00:00

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)`));