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:
commit
502131f07c
16 changed files with 1191 additions and 0 deletions
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal 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
109
README.md
Normal 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
15
package.json
Normal 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
88
public/css/style.css
Normal 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
93
public/index.html
Normal 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
110
public/js/avatars.js
Normal 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
58
public/js/codescreen.js
Normal 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
90
public/js/controls.js
vendored
Normal 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
110
public/js/ide.js
Normal 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
133
public/js/main.js
Normal 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
66
public/js/net.js
Normal 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
129
public/js/scene.js
Normal 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
58
public/js/ui.js
Normal 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
0
public/vendor/.gitkeep
vendored
Normal file
24
scripts/setup-vendor.sh
Executable file
24
scripts/setup-vendor.sh
Executable 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
91
server.js
Normal 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)`));
|
||||
Loading…
Reference in a new issue