programmierhub/public/js/main.js
Stefan Franke 502131f07c VR ProgrammierHub — begehbarer 3D-Lernraum für hybride Programmier-Lehre
Erste lauffähige Fassung: 3D-Raum, Echtzeit-Multiplayer (WebSocket),
Python-IDE via Pyodide im Browser, eigener Arbeitsplatz mit Live-Code-Monitor.
Modularer, self-hostbarer Aufbau (Three.js + Node/Express + ws), ohne CDN-
Abhängigkeit zur Laufzeit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 13:35:11 +00:00

133 lines
5.2 KiB
JavaScript

// 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);
}