commit 502131f07cc0f99c55870e313b2e1e0ca556099b Author: Stefan Franke Date: Thu Jul 30 13:35:11 2026 +0000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d61bce --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5cbb3d9 --- /dev/null +++ b/README.md @@ -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. diff --git a/package.json b/package.json new file mode 100644 index 0000000..7bd5f94 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..3123fdc --- /dev/null +++ b/public/css/style.css @@ -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; } diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..70b84f6 --- /dev/null +++ b/public/index.html @@ -0,0 +1,93 @@ + + + + + +VR ProgrammierHub + + + +
+ +
+
VR ProgrammierHub
+
Bewegen: W A S D · Rennen Shift
+
Umschauen: Maus (Klick fängt, Esc frei) · Pfeiltasten
+
Netz: · Online: 0
+
FPS: · Init: lädt…
+
+ +
+

VR ProgrammierHub

+

Gib dir einen Namen und betritt den Raum. Andere im selben Raum siehst du live als Avatar. + W A S D laufen; zum Umschauen ins Bild klicken — die Maus wird gefangen und dreht die Blickrichtung frei, auch beim Laufen (Esc gibt sie frei). Ohne Maus: ← → zum Drehen.

+
+ + +
+ +
+ +
+ + +
+ +
🪑 An deinen Platz setzen — E
+ +
+
+ 💻 meine_uebung.py + + + + +
+ +

+
+ +
+ + + + + + + + diff --git a/public/js/avatars.js b/public/js/avatars.js new file mode 100644 index 0000000..970bfdb --- /dev/null +++ b/public/js/avatars.js @@ -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(); +} diff --git a/public/js/codescreen.js b/public/js/codescreen.js new file mode 100644 index 0000000..85ee901 --- /dev/null +++ b/public/js/codescreen.js @@ -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(); } +} diff --git a/public/js/controls.js b/public/js/controls.js new file mode 100644 index 0000000..3f18f19 --- /dev/null +++ b/public/js/controls.js @@ -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; + } +} diff --git a/public/js/ide.js b/public/js/ide.js new file mode 100644 index 0000000..c3ccc06 --- /dev/null +++ b/public/js/ide.js @@ -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; +} diff --git a/public/js/main.js b/public/js/main.js new file mode 100644 index 0000000..7884014 --- /dev/null +++ b/public/js/main.js @@ -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); +} diff --git a/public/js/net.js b/public/js/net.js new file mode 100644 index 0000000..26f54b2 --- /dev/null +++ b/public/js/net.js @@ -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); } +} diff --git a/public/js/scene.js b/public/js/scene.js new file mode 100644 index 0000000..e3bcfb3 --- /dev/null +++ b/public/js/scene.js @@ -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) }; +} diff --git a/public/js/ui.js b/public/js/ui.js new file mode 100644 index 0000000..6320a73 --- /dev/null +++ b/public/js/ui.js @@ -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); + } +} diff --git a/public/vendor/.gitkeep b/public/vendor/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/setup-vendor.sh b/scripts/setup-vendor.sh new file mode 100755 index 0000000..764a7de --- /dev/null +++ b/scripts/setup-vendor.sh @@ -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" diff --git a/server.js b/server.js new file mode 100644 index 0000000..f882bd8 --- /dev/null +++ b/server.js @@ -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)`));