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>
This commit is contained in:
Stefan Franke 2026-07-30 13:35:11 +00:00
commit 502131f07c
16 changed files with 1191 additions and 0 deletions

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
# Abhängigkeiten
node_modules/
# Self-gehostete Vendor-Assets (per scripts/setup-vendor.sh laden)
public/vendor/*
!public/vendor/.gitkeep
# Umgebung / Secrets — niemals einchecken
.env
.env.*
*.pem
*.key
id_*
*.log
# System
.DS_Store

109
README.md Normal file
View file

@ -0,0 +1,109 @@
# VR ProgrammierHub
Ein browserbasierter, begehbarer **3D-Lernraum** für die hybride Programmier-Lehre.
Studierende betreten als Avatar einen virtuellen Seminarraum, setzen sich an ihren
Arbeitsplatz und programmieren dort **Python direkt im Browser** während sie sehen,
woran die anderen gerade arbeiten.
Entstanden für den Grundlagenkurs *Objektorientierte Programmierung (Python)* an der
PH Weingarten, der teils in Präsenz, teils per Zoom stattfindet.
---
## Didaktischer Wert
Das Projekt adressiert drei Kernprobleme hybrider Programmier-Lehre und leitet daraus
seinen didaktischen Anspruch ab:
**1. Sichtbarkeit statt stiller Einzelarbeit (Peer-Learning).**
In klassischen Online-Settings arbeitet jede:r isoliert vor sich hin. Der Hub macht die
Arbeit *sichtbar*: Man sieht die Avatare der anderen, ihren Aufgaben-Status und kann ihnen
buchstäblich „über die Schulter schauen“. Das ermöglicht beiläufiges Lernen am Modell der
Peers (sozial-konstruktivistisch, Vygotskys *Zone der nächsten Entwicklung*), das in
Videokonferenzen komplett wegfällt.
**2. Soziale Eingebundenheit für Remote-Teilnehmende.**
Wer zuhause sitzt, ist in reinen Zoom-Formaten „abgehängt“. Ein gemeinsamer Raum mit
verkörperten Avataren erzeugt Präsenz- und Zugehörigkeitsgefühl (*relatedness* im Sinne der
Selbstbestimmungstheorie) ein bekannter Faktor gegen Abbruch und für Lernmotivation.
**3. Niedrigste Einstiegshürde Fokus aufs Programmieren, nicht auf Setup.**
Kein Download, keine lokale Python-Installation, keine IDE-Konfiguration: Seite öffnen,
einloggen, loslegen. Python läuft via **Pyodide (WebAssembly) im Browser** der Studierenden.
Das entfernt die klassische Anfänger-Hürde („bei mir läuft es nicht“) und verlagert die
kognitive Last dorthin, wo sie hingehört: auf das Erlernen von Konzepten.
**Für die Lehrperson** entsteht *formative* Transparenz: Man sieht in Echtzeit, wer woran
arbeitet und wer hängt auch remote und kann gezielt und rechtzeitig unterstützen, statt
auf Zuruf zu reagieren. Ein sichtbares **Hilfe-Handzeichen** senkt zusätzlich die Hemmschwelle,
um Hilfe zu bitten.
**Nicht zuletzt** ist der Hub selbst Anschauungsobjekt: Er verbindet Web-, 3D- und
Netzwerk-Programmierung in einem realen, motivierenden Kontext und eignet sich als
Gegenstand studentischer Projekt- und Abschlussarbeiten.
---
## Was schon funktioniert
- Begehbarer 3D-Raum (WASD + Maus/Pointer-Lock, Pfeiltasten als Tastatur-Fallback)
- Echtzeit-**Multiplayer**: alle sehen alle Avatare live, mit Namensschild, Blickrichtung
und Hilfe-Marker; framerate-unabhängige Interpolation
- **Python-IDE** als 2D-Fenster: Code ausführen via Pyodide, Ausgabe direkt sichtbar
- Eigener **Arbeitsplatz** mit Monitor; **Hinsetzen** (Taste `E`) öffnet die IDE
- Der eigene Code wird **live auf den 3D-Monitor** gerendert (Grundlage für die
„über-die-Schulter“-Einsicht der anderen)
## Roadmap (Auszug)
- Broadcast des Codes → andere sehen ihn auf dem jeweiligen Monitor
- Login/Auth (Rollen `student` / `teacher`) und Persistenz der Workspaces
- Klick auf fremden Monitor → Code-Snapshot; Lehrenden-Dashboard; Hilfe-Warteschlange
- CodeMirror statt Textarea; optionaler VR-Zugang (WebXR)
---
## Architektur
- **Frontend:** [Three.js](https://threejs.org) (WebGL), modulare ES-Module, kein Build-Schritt
- **Python:** [Pyodide](https://pyodide.org) (CPython als WebAssembly, läuft im Browser)
- **Backend:** Node.js + Express + `ws` (ein Prozess: liefert Assets aus **und** hält den
Echtzeit-Raumzustand im WebSocket-Hub)
- **Prinzip:** komplett self-hostbar, keine CDN-Abhängigkeit zur Laufzeit, kein Vendor-Lock-in
```
public/
index.html schlankes Markup + Import-Map
css/style.css
js/
main.js Orchestrator + Render-Schleife
scene.js Weltaufbau (Raum, Licht, Arbeitsplätze)
controls.js WASD + Maus-Blick (Pointer Lock)
avatars.js fremde Avatare, Namensschilder, Interpolation
net.js WebSocket-Client + Protokoll
ide.js Python-IDE-Overlay (Pyodide)
codescreen.js Code als Live-Textur auf dem 3D-Monitor
ui.js Overlay, Toolbar, HUD
vendor/ Three.js + Pyodide (per Setup-Skript, nicht eingecheckt)
server.js Express + WebSocket-Hub
```
---
## Installation & Start
```bash
npm install
./scripts/setup-vendor.sh # lädt Three.js + Pyodide nach public/vendor/
npm start # startet den Server auf http://localhost:3000
```
Dann `http://localhost:3000` im Browser öffnen (Chrome/Edge empfohlen).
Für den Betrieb hinter einer Domain zusätzlich einen Reverse-Proxy mit TLS davorschalten
(WebXR/VR benötigt HTTPS).
---
## Lizenz
Noch nicht festgelegt. © Stefan Franke, PH Weingarten.

15
package.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "programmierhub",
"version": "0.3.0",
"private": true,
"description": "VR ProgrammierHub — 3D-Lernraum, Backend-Grundgeruest (Express + ws)",
"type": "commonjs",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.21.2",
"ws": "^8.18.0"
}
}

88
public/css/style.css Normal file
View file

@ -0,0 +1,88 @@
/* VR ProgrammierHub — UI-Styles */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; background: #0b1020; font-family: system-ui, sans-serif; }
#app { position: fixed; inset: 0; }
canvas { display: block; }
#hud {
position: fixed; top: 12px; left: 12px; z-index: 10;
color: #cdd6f4; font-size: 13px; line-height: 1.5;
background: rgba(17, 23, 41, .72); padding: 10px 14px; border-radius: 10px;
backdrop-filter: blur(6px); border: 1px solid rgba(255, 255, 255, .08);
pointer-events: none; max-width: 280px;
}
#hud b { color: #89b4fa; }
#fps { color: #a6e3a1; }
#net { color: #f9e2af; }
#dbg { color: #94e2d5; }
#overlay {
position: fixed; inset: 0; z-index: 20; display: flex;
align-items: center; justify-content: center; flex-direction: column;
background: radial-gradient(circle at 50% 40%, #1b2444, #0b1020);
color: #cdd6f4; text-align: center; gap: 16px; padding: 20px;
}
#overlay h1 { font-size: 30px; font-weight: 700; letter-spacing: .5px; }
#overlay h1 span { color: #89b4fa; }
#overlay p { font-size: 14px; opacity: .8; max-width: 440px; line-height: 1.5; }
#overlay .key { background: #313455; padding: 2px 8px; border-radius: 5px; font-family: monospace; }
.row { display: flex; gap: 10px; align-items: center; }
#name { padding: 11px 14px; font-size: 15px; border-radius: 10px; border: 1px solid #45496b; background: #11172a; color: #cdd6f4; width: 220px; }
#role { padding: 11px; font-size: 15px; border-radius: 10px; border: 1px solid #45496b; background: #11172a; color: #cdd6f4; }
#startbtn { padding: 12px 26px; font-size: 16px; font-weight: 600; border: none; border-radius: 10px; background: #89b4fa; color: #0b1020; cursor: pointer; }
#toolbar { position: fixed; bottom: 16px; left: 16px; z-index: 12; display: none; gap: 8px; }
#toolbar button { padding: 9px 14px; border-radius: 9px; border: 1px solid #45496b; background: rgba(17, 23, 41, .85); color: #cdd6f4; cursor: pointer; font-size: 13px; }
#toolbar button.on { background: #f38ba8; color: #0b1020; border-color: #f38ba8; }
#err {
position: fixed; inset: auto 12px 12px 12px; z-index: 100; display: none;
background: #3a1420; color: #ffd7de; border: 1px solid #ff6b81; border-radius: 10px;
padding: 12px 14px; font-family: monospace; font-size: 12px; white-space: pre-wrap; max-height: 40vh; overflow: auto;
}
/* ── IDE-Fenster (mittig, Bildschirmgröße; 3D-Umgebung bleibt drumherum sichtbar) ── */
#ide {
position: fixed; z-index: 30; display: none;
top: 50%; left: 50%; transform: translate(-50%, -50%);
width: min(900px, 88vw); height: min(600px, 84vh);
flex-direction: column; background: #0d1220; color: #cdd6f4;
border: 1px solid rgba(137, 180, 250, .35); border-radius: 12px; overflow: hidden;
box-shadow: 0 24px 70px rgba(0, 0, 0, .6);
}
#ide-bar {
display: flex; align-items: center; gap: 12px;
padding: 10px 14px; background: #11172a; border-bottom: 1px solid rgba(255, 255, 255, .08);
font-size: 14px;
}
#ide-title { font-weight: 600; color: #89b4fa; }
#ide-status { font-size: 12px; color: #94e2d5; font-family: monospace; }
#ide-status.err { color: #f38ba8; }
#ide-bar .spacer { flex: 1; }
#ide-bar button {
padding: 8px 14px; border-radius: 8px; border: 1px solid #45496b;
background: rgba(137, 180, 250, .12); color: #cdd6f4; cursor: pointer; font-size: 13px;
}
#ide-run { background: #a6e3a1 !important; color: #0b1020 !important; border-color: #a6e3a1 !important; font-weight: 600; }
#ide-run:disabled { opacity: .5; cursor: default; }
#ide-editor {
flex: 1; min-height: 0; resize: none; border: none; outline: none;
padding: 16px; background: #0d1220; color: #e6e9f5;
font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
font-size: 15px; line-height: 1.5; tab-size: 4;
}
#ide-output {
height: 34%; margin: 0; padding: 12px 16px; overflow: auto;
background: #080c17; border-top: 1px solid rgba(255, 255, 255, .08);
font-family: ui-monospace, monospace; font-size: 13px; line-height: 1.45; white-space: pre-wrap;
}
#ide-output .err { color: #f38ba8; }
/* ── "Setzen"-Hinweis in Sitznähe ── */
#sithint {
position: fixed; left: 50%; bottom: 72px; transform: translateX(-50%); z-index: 12; display: none;
background: rgba(17, 23, 41, .88); color: #cdd6f4; padding: 10px 18px; border-radius: 10px;
border: 1px solid rgba(137, 180, 250, .45); font-size: 14px; backdrop-filter: blur(6px);
}
#sithint .key { background: #313455; padding: 2px 8px; border-radius: 5px; font-family: monospace; }

93
public/index.html Normal file
View file

@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>VR ProgrammierHub</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div id="app"></div>
<div id="hud">
<div><b>VR ProgrammierHub</b></div>
<div>Bewegen: <b>W A S D</b> · Rennen <b>Shift</b></div>
<div>Umschauen: <b>Maus</b> (Klick fängt, Esc frei) · Pfeiltasten</div>
<div>Netz: <span id="net"></span> · Online: <b id="count">0</b></div>
<div>FPS: <span id="fps"></span> · Init: <span id="dbg">lädt…</span></div>
</div>
<div id="overlay">
<h1>VR <span>ProgrammierHub</span></h1>
<p>Gib dir einen Namen und betritt den Raum. Andere im selben Raum siehst du live als Avatar.
<span class="key">W A S D</span> laufen; zum Umschauen ins Bild klicken — die Maus wird gefangen und dreht die Blickrichtung frei, auch beim Laufen (<span class="key">Esc</span> gibt sie frei). Ohne Maus: <span class="key">← →</span> zum Drehen.</p>
<div class="row">
<input id="name" placeholder="Dein Name" maxlength="24" autocomplete="off">
<select id="role"><option value="student">Studierende:r</option><option value="teacher">Lehrperson</option></select>
</div>
<button id="startbtn">Raum betreten</button>
</div>
<div id="toolbar">
<button id="ideBtn">💻 Code</button>
<button id="helpBtn">✋ Hilfe anfragen</button>
</div>
<div id="sithint">🪑 An deinen Platz setzen — <span class="key">E</span></div>
<div id="ide">
<div id="ide-bar">
<span id="ide-title">💻 meine_uebung.py</span>
<span id="ide-status"></span>
<span class="spacer"></span>
<button id="ide-run">▶ Ausführen (Strg+Enter)</button>
<button id="ide-close">✕ Schließen (Esc)</button>
</div>
<textarea id="ide-editor" spellcheck="false"># Willkommen im ProgrammierHub 🐍
# Schreib Python und klick auf „Ausführen" (oder Strg+Enter).
class Punkt:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Punkt({self.x}, {self.y})"
for i in range(4):
print(i, "->", Punkt(i, i * i))
</textarea>
<pre id="ide-output"></pre>
</div>
<div id="err"></div>
<!-- Sicherheitsnetz: fängt Ladefehler global ab, unabhängig vom Modul. -->
<script>
window.__hubReady = false;
window.addEventListener('error', function (e) {
var b = document.getElementById('err'); if (!b) return;
b.style.display = 'block';
b.textContent = '⚠ Fehler (bitte an Claude weitergeben):\n' + (e.message || e.error || 'Ladefehler (Script/Modul)') + '\n' + (e.filename || '') + (e.lineno ? ':' + e.lineno : '');
});
window.addEventListener('unhandledrejection', function (e) {
var b = document.getElementById('err'); if (!b) return;
b.style.display = 'block';
b.textContent = '⚠ Promise-Fehler:\n' + (e.reason && (e.reason.stack || e.reason.message || e.reason));
});
// Watchdog: falls das Modul nicht startet (z. B. Netzwerk), klare Meldung statt stummem Blank.
setTimeout(function () {
if (window.__hubReady) return;
var b = document.getElementById('err'); if (!b) return;
b.style.display = 'block';
b.textContent = '⚠ Das 3D-Modul wurde nicht geladen (Netzwerk/Script-Fehler).\nBitte hart neu laden: Strg+Shift+R';
}, 4000);
</script>
<script type="importmap">
{ "imports": { "three": "/vendor/three.module.js" } }
</script>
<script type="module" src="/js/main.js"></script>
</body>
</html>

110
public/js/avatars.js Normal file
View file

@ -0,0 +1,110 @@
// avatars.js — Verwaltung der fremden Avatare: Erzeugen, Namensschild, Interpolation.
import * as THREE from 'three';
const BODY_GEO = new THREE.CapsuleGeometry(0.35, 0.9, 4, 8);
const NOSE_GEO = new THREE.ConeGeometry(0.12, 0.25, 8); // zeigt Blickrichtung
export class Avatars {
constructor(scene) {
this.scene = scene;
this.map = new Map(); // id -> Eintrag
}
get count() { return this.map.size; }
add(user) {
if (this.map.has(user.id)) return;
const group = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({ color: colorFor(user.id), roughness: 0.6 });
const body = new THREE.Mesh(BODY_GEO, mat);
body.position.y = 1.05; body.castShadow = true; group.add(body);
const nose = new THREE.Mesh(NOSE_GEO, mat);
nose.rotation.x = Math.PI / 2; nose.position.set(0, 1.3, -0.4); group.add(nose);
const entry = {
group, mat,
name: user.name,
statusLabel: user.label || '',
help: !!user.help,
label: null,
target: { p: [...user.p], rot: user.rot || 0 },
cur: { p: [...user.p], rot: user.rot || 0 },
};
entry.label = makeLabel(labelText(entry), entry.help);
group.add(entry.label);
group.position.set(user.p[0], user.p[1], user.p[2]);
group.rotation.y = user.rot || 0;
this.scene.add(group);
this.map.set(user.id, entry);
}
remove(id) {
const e = this.map.get(id);
if (!e) return;
this.scene.remove(e.group);
e.label.material.map.dispose();
this.map.delete(id);
}
setTarget(id, p, rot) { const e = this.map.get(id); if (e) { e.target.p = p; e.target.rot = rot; } }
setStatus(id, label) { const e = this.map.get(id); if (e) { e.statusLabel = label; this._refresh(e); } }
setHelp(id, on) { const e = this.map.get(id); if (e) { e.help = on; this._refresh(e); } }
/** Pro Frame: fremde Avatare weich zur letzten bekannten Zielposition interpolieren. */
update(dt) {
const a = 1 - Math.pow(0.001, dt); // framerate-unabhängiges exponentielles Glätten
for (const e of this.map.values()) {
e.cur.p[0] += (e.target.p[0] - e.cur.p[0]) * a;
e.cur.p[1] += (e.target.p[1] - e.cur.p[1]) * a;
e.cur.p[2] += (e.target.p[2] - e.cur.p[2]) * a;
let d = e.target.rot - e.cur.rot;
while (d > Math.PI) d -= 2 * Math.PI;
while (d < -Math.PI) d += 2 * Math.PI;
e.cur.rot += d * a;
e.group.position.set(e.cur.p[0], e.cur.p[1], e.cur.p[2]);
e.group.rotation.y = e.cur.rot;
}
}
_refresh(e) {
e.group.remove(e.label);
e.label.material.map.dispose();
e.label = makeLabel(labelText(e), e.help);
e.group.add(e.label);
}
}
// ── Hilfsfunktionen ──
function labelText(e) { return e.name + (e.statusLabel ? ' · ' + e.statusLabel : ''); }
function colorFor(id) { const c = new THREE.Color(); c.setHSL((id * 0.13) % 1, 0.55, 0.62); return c; }
function makeLabel(text, help) {
const cv = document.createElement('canvas');
cv.width = 256; cv.height = 72;
const g = cv.getContext('2d');
g.fillStyle = help ? 'rgba(243,139,168,.92)' : 'rgba(17,23,41,.82)';
roundRect(g, 4, 4, 248, 64, 12); g.fill();
g.strokeStyle = help ? '#ffd7de' : 'rgba(137,180,250,.6)'; g.lineWidth = 2; g.stroke();
g.fillStyle = help ? '#3a1420' : '#cdd6f4';
g.font = 'bold 30px system-ui, sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
g.fillText((help ? '✋ ' : '') + text, 128, 38);
const tex = new THREE.CanvasTexture(cv); tex.anisotropy = 4;
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
spr.scale.set(1.8, 0.5, 1);
spr.position.y = 2.35;
return spr;
}
function roundRect(g, x, y, w, h, r) {
g.beginPath();
g.moveTo(x + r, y);
g.arcTo(x + w, y, x + w, y + h, r);
g.arcTo(x + w, y + h, x, y + h, r);
g.arcTo(x, y + h, x, y, r);
g.arcTo(x, y, x + w, y, r);
g.closePath();
}

58
public/js/codescreen.js Normal file
View file

@ -0,0 +1,58 @@
// codescreen.js — zeigt Code als Live-Textur auf einer Bildschirmfläche in der 3D-Welt.
// Dient (a) dem eigenen Monitor und (b) später den Monitoren anderer ("über die Schulter schauen").
// Bewusst als Textur (nicht CSS3D): echte 3D-Geometrie -> korrekte Verdeckung, scharf, performant.
import * as THREE from 'three';
const W = 1024, H = 640; // Textur-Auflösung
const PAD = 22, LINE = 27; // Innenabstand / Zeilenhöhe in px
const FONT_PX = 22;
const MAX_COLS = 54, MAX_LINES = 20;
export class CodeScreen {
/** @param {number} width - Breite der Fläche in Metern (Höhe folgt dem Seitenverhältnis). */
constructor(width = 2.0) {
this.canvas = document.createElement('canvas');
this.canvas.width = W; this.canvas.height = H;
this.ctx = this.canvas.getContext('2d');
this.tex = new THREE.CanvasTexture(this.canvas);
this.tex.colorSpace = THREE.SRGBColorSpace;
// MeshBasicMaterial = unbeleuchtet -> Bildschirm leuchtet gleichmäßig, immer lesbar.
const geo = new THREE.PlaneGeometry(width, width * H / W);
this.mesh = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ map: this.tex }));
this._title = 'Monitor';
this.setCode('');
}
setTitle(t) { this._title = t; }
/** Zeichnet den Code neu auf die Textur (nur bei Änderung aufrufen, nicht pro Frame). */
setCode(text) {
const g = this.ctx;
g.fillStyle = '#0b1020'; g.fillRect(0, 0, W, H);
g.strokeStyle = 'rgba(137,180,250,.45)'; g.lineWidth = 3; g.strokeRect(4, 4, W - 8, H - 8);
// Kopfzeile
g.fillStyle = '#11172a'; g.fillRect(6, 6, W - 12, 40);
g.fillStyle = '#89b4fa'; g.font = `bold 22px ui-monospace, monospace`;
g.fillText('💻 ' + this._title, PAD, 34);
// Code
g.fillStyle = '#dfe4f5'; g.font = `${FONT_PX}px ui-monospace, "JetBrains Mono", monospace`;
const lines = String(text).replace(/\t/g, ' ').split('\n');
const shown = lines.slice(0, MAX_LINES);
let y = 46 + LINE;
for (const ln of shown) {
g.fillText(ln.slice(0, MAX_COLS), PAD, y);
y += LINE;
}
if (lines.length > MAX_LINES) {
g.fillStyle = '#6c7086';
g.fillText(`… +${lines.length - MAX_LINES} Zeilen`, PAD, y);
}
this.tex.needsUpdate = true;
}
dispose() { this.tex.dispose(); this.mesh.geometry.dispose(); this.mesh.material.dispose(); }
}

90
public/js/controls.js vendored Normal file
View file

@ -0,0 +1,90 @@
// controls.js — Desktop-Steuerung: WASD-Bewegung des Player-Rigs + Maus-Blick.
//
// Umsehen läuft über Pointer Lock — das Web-Äquivalent zum "relative mouse mode",
// den native Spiele (SDL) nutzen. Klick fängt die Maus, danach dreht die Maus die
// Blickrichtung frei, auch während WASD gehalten wird. Esc gibt die Maus frei.
//
// Bewusst KEIN Drag-Look (Taste halten): das erzeugte auf Touchpads ständig neue
// Berührungen und lief so in libinputs "disable-while-typing" (nur *neu startende*
// Berührungen werden beim Tippen unterdrückt). Bei gefangener Maus gleitet der Finger
// durchgehend — die Berührung ist bereits aktiv und bleibt erlaubt, exakt wie im Spiel.
//
// Reine Tastatur-Alternative zum Drehen: Pfeiltasten links/rechts (ohne Maus/Touchpad).
import * as THREE from 'three';
const BOUND = 28; // Raumgrenzen (halbe Bodengröße minus Rand)
const WALK = 4.5, RUN = 9; // m/s
const LOOK = 0.0022; // Maus-Empfindlichkeit
const TURN = 1.6; // Pfeiltasten-Drehung rad/s
export class Controls {
constructor(camera, player, domElement) {
this.camera = camera;
this.player = player;
this.dom = domElement;
this.keys = {};
this.enabled = true; // pausierbar, z. B. während die IDE offen ist
this.locked = false;
this._euler = new THREE.Euler(0, 0, 0, 'YXZ');
this._fwd = new THREE.Vector3();
this._right = new THREE.Vector3();
this._up = new THREE.Vector3(0, 1, 0);
this._world = new THREE.Vector3();
addEventListener('keydown', (e) => { this.keys[e.code] = true; });
addEventListener('keyup', (e) => { this.keys[e.code] = false; });
// Klick fängt die Maus (auch erneut, nachdem Esc sie freigegeben hat) — außer die Steuerung ist pausiert (IDE offen).
domElement.addEventListener('click', () => { if (!this.locked && this.enabled) this.lock(); });
document.addEventListener('pointerlockchange', () => { this.locked = document.pointerLockElement === domElement; });
document.addEventListener('mousemove', (e) => this._onMouseMove(e));
}
lock() { if (this.dom.requestPointerLock) { try { this.dom.requestPointerLock(); } catch { /* Browser lehnt ab; Pfeiltasten bleiben als Fallback */ } } }
/** Steuerung an-/ausschalten (z. B. während die IDE offen ist). */
setEnabled(v) { this.enabled = v; if (!v) this.keys = {}; }
_onMouseMove(e) {
if (!this.locked || !this.enabled) return;
const eu = this._euler;
eu.setFromQuaternion(this.camera.quaternion);
eu.y -= e.movementX * LOOK;
eu.x -= e.movementY * LOOK;
eu.x = Math.max(-Math.PI / 2 + 0.05, Math.min(Math.PI / 2 - 0.05, eu.x));
this.camera.quaternion.setFromEuler(eu);
}
/** Aktuelle horizontale Blickrichtung als Yaw-Winkel (für Netzwerk-Sync). */
get yaw() {
this.camera.getWorldDirection(this._world);
return Math.atan2(-this._world.x, -this._world.z);
}
/** Pro Frame aufrufen. @returns {boolean} ob sich der Player gerade bewegt. */
update(dt) {
if (!this.enabled) return false;
const k = this.keys;
if (k['ArrowLeft']) this.player.rotation.y += TURN * dt;
if (k['ArrowRight']) this.player.rotation.y -= TURN * dt;
const moving = k['KeyW'] || k['KeyS'] || k['KeyA'] || k['KeyD'];
if (moving) {
const speed = (k['ShiftLeft'] || k['ShiftRight']) ? RUN : WALK;
this.camera.getWorldDirection(this._fwd);
this._fwd.y = 0;
if (this._fwd.lengthSq() < 1e-6) this._fwd.set(0, 0, -1);
this._fwd.normalize();
this._right.crossVectors(this._fwd, this._up).normalize();
const step = speed * dt;
const p = this.player.position;
if (k['KeyW']) p.addScaledVector(this._fwd, step);
if (k['KeyS']) p.addScaledVector(this._fwd, -step);
if (k['KeyD']) p.addScaledVector(this._right, step);
if (k['KeyA']) p.addScaledVector(this._right, -step);
p.x = Math.max(-BOUND, Math.min(BOUND, p.x));
p.z = Math.max(-BOUND, Math.min(BOUND, p.z));
}
return moving;
}
}

110
public/js/ide.js Normal file
View file

@ -0,0 +1,110 @@
// ide.js — 2D-IDE-Overlay über der 3D-Szene: Python-Editor + Ausführung via Pyodide.
//
// Bewusst als DOM-Overlay (nicht auf eine 3D-Textur gerendert): scharfer Text, freie Maus,
// echtes Copy/Paste/Scrollen. Pyodide (CPython als WASM) läuft komplett im Browser —
// null Server-Last, keine Sandbox nötig (siehe Spec Abschnitt 7, Variante A).
// Editor ist zunächst ein Textarea; Upgrade auf CodeMirror (lokal gehostet) später.
import { loadPyodide } from '/vendor/pyodide/pyodide.mjs';
export class IDE {
constructor() {
this.root = document.getElementById('ide');
this.editor = document.getElementById('ide-editor');
this.output = document.getElementById('ide-output');
this.runBtn = document.getElementById('ide-run');
this.statusEl = document.getElementById('ide-status');
this.isOpen = false;
this.pyodide = null;
this._loading = null; // Promise, solange Pyodide lädt
this._openCb = null;
this._closeCb = null;
this._changeCb = null;
this._changeTimer = null;
document.getElementById('ide-close').addEventListener('click', () => this.close());
this.runBtn.addEventListener('click', () => this.run());
this.editor.addEventListener('keydown', (e) => {
if (e.key === 'Tab') { e.preventDefault(); insertText(this.editor, ' '); }
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); this.run(); }
else if (e.key === 'Escape') this.close();
});
// Live-Änderungen (entprellt) an den Monitor melden.
this.editor.addEventListener('input', () => {
clearTimeout(this._changeTimer);
this._changeTimer = setTimeout(() => { if (this._changeCb) this._changeCb(this.editor.value); }, 250);
});
}
onOpen(cb) { this._openCb = cb; }
onClose(cb) { this._closeCb = cb; }
onChange(cb) { this._changeCb = cb; }
getCode() { return this.editor.value; }
open() {
if (this.isOpen) return;
this.isOpen = true;
this.root.style.display = 'flex';
this.editor.focus();
if (this._openCb) this._openCb();
}
close() {
if (!this.isOpen) return;
this.isOpen = false;
this.root.style.display = 'none';
if (this._closeCb) this._closeCb();
}
async run() {
let py;
try {
py = await this._ensurePyodide();
} catch (e) {
this._status('Python konnte nicht geladen werden', true);
this._append('\n[Laden fehlgeschlagen] ' + (e && e.message || e), true);
return;
}
this._clear();
py.setStdout({ batched: (s) => this._append(s) });
py.setStderr({ batched: (s) => this._append(s) });
this._status('läuft …');
try {
await py.runPythonAsync(this.editor.value);
this._status('fertig ✓');
} catch (e) {
this._append('\n' + (e && e.message ? e.message : e), true);
this._status('Fehler', true);
}
}
_ensurePyodide() {
if (this.pyodide) return Promise.resolve(this.pyodide);
if (!this._loading) {
this.runBtn.disabled = true;
this._status('Python wird geladen … (einmalig, ~10 MB)');
this._loading = loadPyodide({ indexURL: '/vendor/pyodide/' })
.then((py) => { this.pyodide = py; this.runBtn.disabled = false; this._status('bereit'); return py; })
.catch((e) => { this.runBtn.disabled = false; this._loading = null; throw e; });
}
return this._loading;
}
_clear() { this.output.textContent = ''; }
_append(text, isErr) {
const span = document.createElement('span');
if (isErr) span.className = 'err';
span.textContent = text;
this.output.appendChild(span);
this.output.scrollTop = this.output.scrollHeight;
}
_status(text, isErr) { this.statusEl.textContent = text; this.statusEl.classList.toggle('err', !!isErr); }
}
/** Fügt Text an der Cursorposition eines Textarea ein (für Tab-Einrückung). */
function insertText(ta, text) {
const start = ta.selectionStart, end = ta.selectionEnd;
ta.value = ta.value.slice(0, start) + text + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = start + text.length;
}

133
public/js/main.js Normal file
View file

@ -0,0 +1,133 @@
// main.js — Einstiegspunkt: verdrahtet Welt, Steuerung, Avatare, Netzwerk und UI
// und treibt die Render-Schleife. Hält selbst keine Logik-Details.
import * as THREE from 'three';
import { createWorld, createWorkstation } from './scene.js';
import { Controls } from './controls.js';
import { Avatars } from './avatars.js';
import { Net } from './net.js';
import { UI } from './ui.js';
import { IDE } from './ide.js';
import { CodeScreen } from './codescreen.js';
const SEND_HZ = 15; // Positions-Updates pro Sekunde
const SEND_DT = 1 / SEND_HZ;
const ui = new UI();
try {
ui.setDbg('renderer');
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.getElementById('app').appendChild(renderer.domElement);
ui.setDbg('welt');
const { scene, camera, player, monMat } = createWorld();
// Eigener Arbeitsplatz: echter Monitor auf dem Tisch, zeigt den IDE-Code live in 3D.
const myScreen = new CodeScreen(1.4);
myScreen.setTitle('mein Monitor');
const station = createWorkstation(myScreen.mesh, 1.4);
station.group.position.set(0, 0, 2.0); // vor dem Spawn (Blickrichtung -z)
scene.add(station.group);
const seatPos = station.seat.clone().add(station.group.position); // Sitzplatz in Weltkoordinaten
ui.setDbg('steuerung');
const controls = new Controls(camera, player, renderer.domElement);
const avatars = new Avatars(scene);
ui.setDbg('netzwerk');
let myId = null;
const refreshCount = () => ui.setCount(avatars.count + 1);
const net = new Net({
onWelcome: (id, users) => { myId = id; users.forEach((u) => avatars.add(u)); refreshCount(); },
onJoin: (u) => { if (u.id !== myId) { avatars.add(u); refreshCount(); } },
onLeave: (id) => { avatars.remove(id); refreshCount(); },
onUpdate: (id, p, rot) => avatars.setTarget(id, p, rot),
onStatus: (id, label) => avatars.setStatus(id, label),
onHelp: (id, on) => avatars.setHelp(id, on),
onNet: (text, color) => ui.setNet(text, color),
});
ui.onStart((name, role) => { controls.lock(); net.connect(name, role, player.position); });
ui.onHelpToggle((on) => net.sendHelp(on));
// IDE: pausiert die Steuerung und gibt die Maus frei, solange sie offen ist.
ui.setDbg('ide');
const ide = new IDE();
ide.onOpen(() => { controls.setEnabled(false); if (document.exitPointerLock) document.exitPointerLock(); });
ide.onClose(() => { controls.setEnabled(true); });
ide.onChange((text) => myScreen.setCode(text)); // Tippen -> Monitor läuft live mit
myScreen.setCode(ide.getCode()); // Startcode gleich anzeigen
ui.onOpenIde(() => ide.open());
// An den Platz setzen: in Sitznähe erscheint ein Hinweis, [E] setzt hin + öffnet die IDE.
const sithint = document.getElementById('sithint');
const SIT_RADIUS = 1.8;
let nearSeat = false;
function sitDown() {
if (ide.isOpen) return;
player.position.copy(seatPos);
player.rotation.set(0, 0, 0);
camera.quaternion.identity(); // Blick geradeaus nach -z auf den Monitor
ide.open();
}
addEventListener('keydown', (e) => { if (e.code === 'KeyE' && nearSeat) sitDown(); });
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
});
// ── Render-Schleife ──
const clock = new THREE.Clock();
let frames = 0, fpsAcc = 0, sendAcc = 0, loopBroken = false;
let last = { p: [0, 0, 0], rot: 0, anim: 'idle' };
const round2 = (n) => Math.round(n * 100) / 100;
renderer.setAnimationLoop(() => {
const dt = Math.min(clock.getDelta(), 0.05);
let moving = false;
if (!loopBroken) {
try {
moving = controls.update(dt);
avatars.update(dt);
} catch (e) { loopBroken = true; ui.showError('update', e); }
}
monMat.emissiveIntensity = 0.5 + 0.15 * Math.sin(performance.now() * 0.0015);
// Sitzplatz-Nähe -> Hinweis ein/ausblenden
const near = !ide.isOpen && player.position.distanceTo(seatPos) < SIT_RADIUS;
if (near !== nearSeat) { nearSeat = near; sithint.style.display = near ? 'block' : 'none'; }
// Position senden — gedrosselt und nur bei tatsächlicher Änderung
sendAcc += dt;
if (net.connected && sendAcc >= SEND_DT) {
sendAcc = 0;
const p = [round2(player.position.x), round2(player.position.y), round2(player.position.z)];
const rot = round2(controls.yaw);
const anim = moving ? 'walk' : 'idle';
if (anim !== last.anim || Math.abs(rot - last.rot) > 0.02 ||
Math.abs(p[0] - last.p[0]) > 0.01 || Math.abs(p[2] - last.p[2]) > 0.01) {
net.sendMove(p, rot, anim);
last = { p, rot, anim };
}
}
frames++; fpsAcc += dt;
if (fpsAcc >= 0.5) { ui.setFps(Math.round(frames / fpsAcc)); frames = 0; fpsAcc = 0; }
renderer.render(scene, camera);
});
ui.setDbg('bereit ✓');
window.__hubReady = true;
} catch (e) {
ui.showError('Init', e);
}

66
public/js/net.js Normal file
View file

@ -0,0 +1,66 @@
// net.js — WebSocket-Client: Verbindung, Reconnect, Protokoll (siehe Spec 9.2).
// Kennt die 3D-Welt nicht — reicht Ereignisse per Callbacks nach oben.
export class Net {
/**
* @param {object} handlers - onWelcome(id,users), onJoin(user), onLeave(id),
* onUpdate(id,p,rot), onStatus(id,label), onHelp(id,on), onNet(text,color)
*/
constructor(handlers) {
this.h = handlers;
this.ws = null;
this.myId = null;
this.started = false;
this.name = 'Gast';
this.role = 'student';
this.startPos = [0, 0, 8];
}
connect(name, role, startPos) {
this.name = name;
this.role = role;
if (startPos) this.startPos = [startPos.x, startPos.y, startPos.z];
this.started = true;
this._open();
}
_open() {
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws';
this._net('verbinde …');
const ws = new WebSocket(url);
this.ws = ws;
ws.onopen = () => {
this._net('verbunden', '#a6e3a1');
ws.send(JSON.stringify({ t: 'hello', name: this.name, role: this.role, p: this.startPos }));
};
ws.onclose = () => {
this._net('getrennt — reconnect …', '#f38ba8');
this.myId = null;
setTimeout(() => { if (this.started) this._open(); }, 1500);
};
ws.onerror = () => this._net('Fehler', '#f38ba8');
ws.onmessage = (ev) => this._recv(ev);
}
_recv(ev) {
let m;
try { m = JSON.parse(ev.data); } catch { return; }
const h = this.h;
switch (m.t) {
case 'welcome': this.myId = m.id; h.onWelcome(m.id, m.users); break;
case 'join': h.onJoin(m.user); break;
case 'leave': h.onLeave(m.id); break;
case 'update': h.onUpdate(m.id, m.p, m.rot); break;
case 'status': h.onStatus(m.id, m.label); break;
case 'help': h.onHelp(m.id, m.on); break;
}
}
get connected() { return this.ws && this.ws.readyState === 1 && this.myId != null; }
sendMove(p, rot, anim) { if (this.connected) this.ws.send(JSON.stringify({ t: 'move', p, rot, anim })); }
sendHelp(on) { if (this.connected) this.ws.send(JSON.stringify({ t: 'help', on })); }
sendStatus(label) { if (this.connected) this.ws.send(JSON.stringify({ t: 'status', label })); }
_net(text, color) { if (this.h.onNet) this.h.onNet(text, color); }
}

129
public/js/scene.js Normal file
View file

@ -0,0 +1,129 @@
// scene.js — Aufbau der statischen 3D-Welt (Raum, Licht, Arbeitsplätze).
import * as THREE from 'three';
/**
* Erstellt Szene, Kamera und Player-Rig samt Raum-Geometrie.
* @returns {{scene: THREE.Scene, camera: THREE.PerspectiveCamera, player: THREE.Group, monMat: THREE.Material}}
*/
export function createWorld() {
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1020);
scene.fog = new THREE.Fog(0x0b1020, 22, 65);
const camera = new THREE.PerspectiveCamera(72, innerWidth / innerHeight, 0.1, 200);
// Player-Rig: dieses bewegen wir (VR-tauglich). Kamera sitzt drin auf Augenhöhe.
const player = new THREE.Group();
player.position.set(0, 0, 8);
camera.position.set(0, 1.7, 0);
player.add(camera);
scene.add(player);
addLights(scene);
addRoom(scene);
const monMat = addWorkstations(scene);
return { scene, camera, player, monMat };
}
function addLights(scene) {
scene.add(new THREE.HemisphereLight(0x9fb4ff, 0x20263f, 1.0));
const key = new THREE.DirectionalLight(0xffffff, 1.1);
key.position.set(8, 14, 6);
key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
key.shadow.camera.near = 1; key.shadow.camera.far = 60;
key.shadow.camera.left = -25; key.shadow.camera.right = 25;
key.shadow.camera.top = 25; key.shadow.camera.bottom = -25;
scene.add(key);
}
function addRoom(scene) {
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(60, 60),
new THREE.MeshStandardMaterial({ color: 0x141a30, roughness: 0.95 })
);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
const grid = new THREE.GridHelper(60, 60, 0x3a4470, 0x232a47);
grid.position.y = 0.01;
scene.add(grid);
const wallMat = new THREE.MeshStandardMaterial({ color: 0x1a2140, roughness: 1, side: THREE.DoubleSide });
const wall = (w, h, x, y, z, ry) => {
const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), wallMat);
m.position.set(x, y, z); m.rotation.y = ry; scene.add(m);
};
wall(60, 8, 0, 4, -30, 0);
wall(60, 8, 0, 4, 30, Math.PI);
wall(60, 8, -30, 4, 0, Math.PI / 2);
wall(60, 8, 30, 4, 0, -Math.PI / 2);
}
/** Arbeitsplätze (Tisch + Monitor) als InstancedMesh = wenige Draw-Calls. */
function addWorkstations(scene) {
const COLS = 6, ROWS = 4, GAP = 6, N = COLS * ROWS;
const desks = new THREE.InstancedMesh(
new THREE.BoxGeometry(2.2, 0.15, 1.2),
new THREE.MeshStandardMaterial({ color: 0x2a3358, roughness: 0.8 }), N);
const monMat = new THREE.MeshStandardMaterial({ color: 0x0e1428, emissive: 0x2b6cff, emissiveIntensity: 0.6, roughness: 0.4 });
const monitors = new THREE.InstancedMesh(new THREE.BoxGeometry(1.4, 0.85, 0.06), monMat, N);
desks.castShadow = desks.receiveShadow = monitors.castShadow = true;
const m = new THREE.Object3D();
const startX = -((COLS - 1) * GAP) / 2, startZ = -((ROWS - 1) * GAP) / 2;
let i = 0;
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const x = startX + c * GAP, z = startZ + r * GAP;
m.position.set(x, 0.9, z); m.rotation.set(0, 0, 0); m.updateMatrix(); desks.setMatrixAt(i, m.matrix);
m.position.set(x, 1.5, z - 0.5); m.updateMatrix(); monitors.setMatrixAt(i, m.matrix);
i++;
}
scene.add(desks, monitors);
return monMat;
}
/**
* Baut einen konkreten Arbeitsplatz (Tisch, Monitor mit Rahmen/Ständer, Stuhl) und
* bettet die übergebene Bildschirmfläche als echten Monitor ein.
* Lokales Koordinatensystem: Bildschirm zeigt nach +z, der Sitzplatz liegt bei +z davor.
* @param {THREE.Mesh} screenMesh - die CodeScreen-Fläche (wird als Kind eingehängt)
* @param {number} screenWidth - Breite der Fläche in Metern (16:10-Verhältnis)
* @returns {{ group: THREE.Group, seat: THREE.Vector3 }} seat = Sitzposition (lokal)
*/
export function createWorkstation(screenMesh, screenWidth) {
const g = new THREE.Group();
const wood = new THREE.MeshStandardMaterial({ color: 0x2a3358, roughness: 0.85 });
const dark = new THREE.MeshStandardMaterial({ color: 0x0a0e1a, roughness: 0.5 });
const metal = new THREE.MeshStandardMaterial({ color: 0x1a2140, roughness: 0.6, metalness: 0.3 });
const chairMat = new THREE.MeshStandardMaterial({ color: 0x394260, roughness: 0.7 });
// Tischplatte + Beine
const desk = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.08, 0.8), wood);
desk.position.set(0, 0.74, 0.55); desk.castShadow = desk.receiveShadow = true; g.add(desk);
const legGeo = new THREE.BoxGeometry(0.07, 0.74, 0.07);
for (const [x, z] of [[-0.82, 0.25], [0.82, 0.25], [-0.82, 0.85], [0.82, 0.85]]) {
const leg = new THREE.Mesh(legGeo, metal); leg.position.set(x, 0.37, z); g.add(leg);
}
// Monitorfuß + Hals
const base = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.03, 0.22), dark); base.position.set(0, 0.80, 0.35); g.add(base);
const neck = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.42, 0.07), dark); neck.position.set(0, 1.0, 0.35); g.add(neck);
// Rahmen (Bezel) hinter dem Bildschirm
const sh = screenWidth * 640 / 1024;
const bezel = new THREE.Mesh(new THREE.BoxGeometry(screenWidth + 0.1, sh + 0.1, 0.06), dark);
bezel.position.set(0, 1.35, 0.30); bezel.castShadow = true; g.add(bezel);
// Bildschirm: knapp vor dem Bezel, zeigt nach +z (zum Sitzplatz)
screenMesh.position.set(0, 1.35, 0.335);
g.add(screenMesh);
// Einfacher Stuhl am Sitzplatz
const seatPad = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.08, 0.5), chairMat); seatPad.position.set(0, 0.5, 1.5); seatPad.castShadow = true; g.add(seatPad);
const backRest = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.6, 0.08), chairMat); backRest.position.set(0, 0.8, 1.75); backRest.castShadow = true; g.add(backRest);
return { group: g, seat: new THREE.Vector3(0, 0, 1.5) };
}

58
public/js/ui.js Normal file
View file

@ -0,0 +1,58 @@
// ui.js — DOM-Oberfläche: Start-Overlay, Toolbar, HUD-Anzeigen, Fehleranzeige.
// Kennt Three.js/Netzwerk nicht — kommuniziert per Callbacks.
export class UI {
constructor() {
this.overlay = document.getElementById('overlay');
this.toolbar = document.getElementById('toolbar');
this.errBox = document.getElementById('err');
this.el = {
dbg: document.getElementById('dbg'),
net: document.getElementById('net'),
fps: document.getElementById('fps'),
count: document.getElementById('count'),
help: document.getElementById('helpBtn'),
ide: document.getElementById('ideBtn'),
};
this._startCb = null;
this._helpCb = null;
this._ideCb = null;
this._helpOn = false;
document.getElementById('startbtn').addEventListener('click', () => this._start());
document.getElementById('name').addEventListener('keydown', (e) => { if (e.key === 'Enter') this._start(); });
this.el.help.addEventListener('click', () => this._toggleHelp());
this.el.ide.addEventListener('click', () => { if (this._ideCb) this._ideCb(); });
}
_start() {
const name = (document.getElementById('name').value || 'Gast').trim().slice(0, 24) || 'Gast';
const role = document.getElementById('role').value;
this.overlay.style.display = 'none';
this.toolbar.style.display = 'flex';
if (this._startCb) this._startCb(name, role);
}
_toggleHelp() {
this._helpOn = !this._helpOn;
this.el.help.classList.toggle('on', this._helpOn);
this.el.help.textContent = this._helpOn ? '✋ Hilfe angefragt (klick = zurück)' : '✋ Hilfe anfragen';
if (this._helpCb) this._helpCb(this._helpOn);
}
onStart(cb) { this._startCb = cb; }
onHelpToggle(cb) { this._helpCb = cb; }
onOpenIde(cb) { this._ideCb = cb; }
setDbg(s) { if (this.el.dbg) this.el.dbg.textContent = s; }
setNet(text, color) { if (this.el.net) { this.el.net.textContent = text; this.el.net.style.color = color || '#f9e2af'; } }
setFps(n) { if (this.el.fps) this.el.fps.textContent = n; }
setCount(n) { if (this.el.count) this.el.count.textContent = n; }
showError(where, e) {
if (!this.errBox) return;
this.errBox.style.display = 'block';
this.errBox.textContent = '⚠ Fehler in ' + where + ':\n' + (e && (e.stack || e.message) || e);
this.setDbg('FEHLER @ ' + where);
}
}

0
public/vendor/.gitkeep vendored Normal file
View file

24
scripts/setup-vendor.sh Executable file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Lädt die self-gehosteten Frontend-Abhängigkeiten (Three.js + Pyodide) nach public/vendor/.
# Bewusst nicht im Repo eingecheckt: große Binär-/Drittanbieter-Assets.
set -euo pipefail
THREE_VERSION=0.169.0
PYODIDE_VERSION=0.26.4
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VENDOR="$ROOT/public/vendor"
mkdir -p "$VENDOR/pyodide"
echo "→ Three.js $THREE_VERSION"
curl -fsSL "https://cdn.jsdelivr.net/npm/three@${THREE_VERSION}/build/three.module.js" \
-o "$VENDOR/three.module.js"
echo "→ Pyodide $PYODIDE_VERSION"
for f in pyodide.mjs pyodide.asm.js pyodide.asm.wasm python_stdlib.zip pyodide-lock.json; do
echo " $f"
curl -fsSL "https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/$f" \
-o "$VENDOR/pyodide/$f"
done
echo "✓ Vendor-Assets liegen in $VENDOR"

91
server.js Normal file
View file

@ -0,0 +1,91 @@
'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)`));