programmierhub/public/js/scene.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

129 lines
5.6 KiB
JavaScript

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