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

110 lines
3.8 KiB
JavaScript

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