Files
skyview.astronomiemuseum.de/public/universesandbox.php
T
2026-04-24 16:30:59 +02:00

825 lines
30 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
session_start();
$pageTitle = 'Universe Sandbox – AstroTools';
$bodyClass = 'usb-page';
$publicBasePath = './';
require __DIR__ . '/header.php';
?>
<style>
/* ── Layout ───────────────────────────────────────────── */
.usb-wrap {
display: flex;
flex-direction: column;
gap: 0;
height: calc(100vh - 120px);
min-height: 500px;
}
/* ── Toolbar ──────────────────────────────────────────── */
.usb-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .5rem;
padding: .6rem .75rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius) var(--radius) 0 0;
flex-shrink: 0;
}
.usb-toolbar-group {
display: flex;
align-items: center;
gap: .35rem;
}
.usb-sep {
width: 1px;
height: 24px;
background: var(--border);
flex-shrink: 0;
}
.usb-label {
color: var(--text-dim);
font-size: .75rem;
text-transform: uppercase;
letter-spacing: .05em;
white-space: nowrap;
}
.usb-mode-btn {
padding: .3rem .7rem;
border-radius: var(--radius);
font-size: .8rem;
cursor: pointer;
border: 1px solid var(--border);
background: transparent;
color: var(--text-dim);
transition: background .15s, color .15s, border-color .15s;
white-space: nowrap;
}
.usb-mode-btn:hover { color: var(--gold); border-color: var(--gold-dim); }
.usb-mode-btn.is-active { background: var(--gold); color: #06080f; border-color: var(--gold); font-weight: 600; }
.usb-mode-btn.is-danger { border-color: #c0392b; color: #e74c3c; }
.usb-mode-btn.is-danger:hover{ background: #c0392b; color: #fff; }
.usb-slider-wrap {
display: flex;
align-items: center;
gap: .4rem;
}
.usb-slider {
-webkit-appearance: none;
width: 90px;
height: 4px;
border-radius: 2px;
background: var(--border);
outline: none;
cursor: pointer;
}
.usb-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
border-radius: 50%;
background: var(--gold);
cursor: pointer;
}
.usb-value {
font-size: .78rem;
color: var(--gold-pale);
min-width: 38px;
text-align: right;
}
/* ── Canvas area ──────────────────────────────────────── */
.usb-canvas-wrap {
position: relative;
flex: 1 1 0;
overflow: hidden;
background: #000;
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 var(--radius) var(--radius);
cursor: crosshair;
}
.usb-canvas-wrap.mode-orbit { cursor: default; }
.usb-canvas-wrap.mode-select { cursor: pointer; }
#usbCanvas {
display: block;
width: 100%;
height: 100%;
}
/* ── HUD / overlay ────────────────────────────────────── */
.usb-hud {
position: absolute;
bottom: .75rem;
left: .75rem;
font-size: .72rem;
color: var(--text-dim);
pointer-events: none;
line-height: 1.6;
}
.usb-hint {
position: absolute;
top: .75rem;
left: 50%;
transform: translateX(-50%);
background: rgba(6,8,15,.75);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: .35rem .75rem;
font-size: .78rem;
color: var(--gold-pale);
pointer-events: none;
white-space: nowrap;
transition: opacity .4s;
}
/* ── Object panel ─────────────────────────────────────── */
.usb-panel {
position: absolute;
top: .75rem;
right: .75rem;
width: 200px;
background: rgba(6,8,15,.88);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: .65rem .75rem;
font-size: .78rem;
color: var(--text);
display: none;
}
.usb-panel.is-visible { display: block; }
.usb-panel h3 {
font-size: .72rem;
text-transform: uppercase;
letter-spacing: .07em;
color: var(--gold);
margin: 0 0 .5rem;
border-bottom: 1px solid var(--border);
padding-bottom: .3rem;
}
.usb-panel-row {
display: flex;
justify-content: space-between;
margin-bottom: .25rem;
}
.usb-panel-key { color: var(--text-dim); }
.usb-panel-val { color: var(--gold-pale); font-family: monospace; }
/* ── Color picker swatches ─────────────────────────────── */
.usb-swatches {
display: flex;
gap: .3rem;
}
.usb-swatch {
width: 18px; height: 18px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: border-color .15s, transform .15s;
}
.usb-swatch:hover { transform: scale(1.2); }
.usb-swatch.active { border-color: #fff; }
/* ── Responsive ────────────────────────────────────────── */
@media (max-width: 600px) {
.usb-wrap { height: calc(100vh - 100px); }
.usb-panel { display: none !important; }
}
</style>
<!-- ══════════════════════════════════════════════════════
TOOLBAR
═══════════════════════════════════════════════════════ -->
<div class="usb-wrap">
<div class="usb-toolbar">
<!-- Modus-Auswahl -->
<div class="usb-toolbar-group">
<span class="usb-label">Modus:</span>
<button class="usb-mode-btn is-active" id="usbModePlace">✦ Platzieren</button>
<button class="usb-mode-btn" id="usbModeOrbit">↻ Orbit</button>
</div>
<div class="usb-sep"></div>
<!-- Körper-Typ -->
<div class="usb-toolbar-group">
<span class="usb-label">Typ:</span>
<button class="usb-mode-btn is-active" id="typeStar" data-type="star">★ Stern</button>
<button class="usb-mode-btn" id="typePlanet" data-type="planet">● Planet</button>
<button class="usb-mode-btn" id="typeMoon" data-type="moon">○ Mond</button>
</div>
<div class="usb-sep"></div>
<!-- Farbe -->
<div class="usb-toolbar-group">
<span class="usb-label">Farbe:</span>
<div class="usb-swatches" id="usbSwatches">
<div class="usb-swatch active" style="background:#fffbe6" data-color="#fffbe6"></div>
<div class="usb-swatch" style="background:#ffd966" data-color="#ffd966"></div>
<div class="usb-swatch" style="background:#ff9944" data-color="#ff9944"></div>
<div class="usb-swatch" style="background:#ff4444" data-color="#ff4444"></div>
<div class="usb-swatch" style="background:#4fc3d8" data-color="#4fc3d8"></div>
<div class="usb-swatch" style="background:#88ff88" data-color="#88ff88"></div>
<div class="usb-swatch" style="background:#aaaaff" data-color="#aaaaff"></div>
<div class="usb-swatch" style="background:#bbbbbb" data-color="#bbbbbb"></div>
</div>
</div>
<div class="usb-sep"></div>
<!-- Masse -->
<div class="usb-toolbar-group">
<span class="usb-label">Masse:</span>
<div class="usb-slider-wrap">
<input class="usb-slider" type="range" id="usbMass" min="1" max="100" value="30">
<span class="usb-value" id="usbMassVal">30</span>
</div>
</div>
<div class="usb-sep"></div>
<!-- Zeitgeschwindigkeit -->
<div class="usb-toolbar-group">
<span class="usb-label">Zeit:</span>
<button class="usb-mode-btn" id="usbPause">⏸</button>
<button class="usb-mode-btn is-active" data-speed="1">1×</button>
<button class="usb-mode-btn" data-speed="5">5×</button>
<button class="usb-mode-btn" data-speed="20">20×</button>
<button class="usb-mode-btn" data-speed="100">100×</button>
</div>
<div class="usb-sep"></div>
<!-- Aktionen -->
<div class="usb-toolbar-group">
<button class="usb-mode-btn is-danger" id="usbReset">⟳ Zurücksetzen</button>
<button class="usb-mode-btn" id="usbPreset">☀ Sonnensystem</button>
</div>
</div>
<!-- ══════════════════════════════════════════════════════
3D-CANVAS
═══════════════════════════════════════════════════════ -->
<div class="usb-canvas-wrap mode-orbit" id="usbCanvasWrap">
<canvas id="usbCanvas"></canvas>
<div class="usb-hint" id="usbHint">Im Orbit-Modus: Ziehen = Drehen · Scroll = Zoomen · Platzieren-Modus: Klick = Körper setzen, Ziehen = Startgeschwindigkeit</div>
<!-- Info-Panel für selektierten Körper -->
<div class="usb-panel" id="usbPanel">
<h3 id="usbPanelTitle">Objekt</h3>
<div class="usb-panel-row"><span class="usb-panel-key">Masse</span><span class="usb-panel-val" id="piMass">—</span></div>
<div class="usb-panel-row"><span class="usb-panel-key">Radius</span><span class="usb-panel-val" id="piRadius">—</span></div>
<div class="usb-panel-row"><span class="usb-panel-key">Geschw.</span><span class="usb-panel-val" id="piVel">—</span></div>
<div class="usb-panel-row"><span class="usb-panel-key">Pos X</span><span class="usb-panel-val" id="piX">—</span></div>
<div class="usb-panel-row"><span class="usb-panel-key">Pos Y</span><span class="usb-panel-val" id="piY">—</span></div>
<div class="usb-panel-row"><span class="usb-panel-key">Pos Z</span><span class="usb-panel-val" id="piZ">—</span></div>
</div>
<div class="usb-hud" id="usbHud">
Körper: <span id="hudBodies">0</span> &nbsp;|&nbsp;
FPS: <span id="hudFps">—</span>
</div>
</div>
</div><!-- .usb-wrap -->
<!-- Three.js + OrbitControls aus CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// OrbitControls r128 inline (minimale, selbst enthalten)
// Quelle: three.js examples/jsm/controls/OrbitControls.js — MIT-Lizenz
// Hier als kompakter, direkter Einbau ohne Modul-System.
</script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script>
(function () {
'use strict';
/* ═══════════════════════════════════════════════════════
KONFIGURATION
═══════════════════════════════════════════════════════ */
const G = 6.674e-2; // skalierte Gravitationskonstante
const TRAIL_LEN = 300; // maximale Spurpunkte pro Körper
const DT_BASE = 0.016; // Basis-Zeitschritt (Sekunden pro Frame)
/* ═══════════════════════════════════════════════════════
THREE.JS SETUP
═══════════════════════════════════════════════════════ */
const wrap = document.getElementById('usbCanvasWrap');
const canvas = document.getElementById('usbCanvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x000000, 1);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 50000);
camera.position.set(0, 80, 250);
const controls = new THREE.OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = false;
controls.minDistance = 10;
controls.maxDistance = 5000;
// Schwaches Hintergrund-Sternfeld
(function buildStarfield() {
const geo = new THREE.BufferGeometry();
const pos = [];
for (let i = 0; i < 3000; i++) {
const r = 4000;
pos.push(
(Math.random() - 0.5) * r,
(Math.random() - 0.5) * r,
(Math.random() - 0.5) * r
);
}
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
scene.add(new THREE.Points(geo, new THREE.PointsMaterial({ color: 0xffffff, size: 1.2, sizeAttenuation: false })));
})();
/* ═══════════════════════════════════════════════════════
SIMULATIONS-ZUSTAND
═══════════════════════════════════════════════════════ */
let bodies = [];
let simPaused = false;
let timeScale = 1;
let nextId = 1;
let selectedBody = null;
// Placement-Drag-State
let placing = false;
let placeStart = null; // {x,y} in NDC
let placePos3D = null; // THREE.Vector3
function hexToThree(hex) {
return new THREE.Color(hex);
}
/* ── Körper-Klasse ───────────────────────────────────── */
class Body {
constructor(pos, vel, mass, color, type) {
this.id = nextId++;
this.mass = mass;
this.type = type; // 'star' | 'planet' | 'moon'
this.color = color;
this.pos = pos.clone();
this.vel = vel.clone();
this.acc = new THREE.Vector3();
this.trail = []; // ring-buffer of THREE.Vector3
this.alive = true;
const radii = { star: 4, planet: 2.5, moon: 1.2 };
this.radius = radii[type] * Math.pow(mass / 30, 0.33);
// Three.js-Mesh
const geo = new THREE.SphereGeometry(this.radius, 24, 16);
const mat = new THREE.MeshStandardMaterial({ color: hexToThree(color), emissive: hexToThree(color), emissiveIntensity: type === 'star' ? 1.2 : 0.15 });
this.mesh = new THREE.Mesh(geo, mat);
this.mesh.position.copy(pos);
scene.add(this.mesh);
// Licht für Sterne
if (type === 'star') {
this.light = new THREE.PointLight(hexToThree(color), 2, 800);
this.light.position.copy(pos);
scene.add(this.light);
}
// Spur (LineSegments)
const trailGeo = new THREE.BufferGeometry();
const trailPos = new Float32Array(TRAIL_LEN * 3);
trailGeo.setAttribute('position', new THREE.BufferAttribute(trailPos, 3));
trailGeo.setDrawRange(0, 0);
const trailMat = new THREE.LineBasicMaterial({ color: hexToThree(color), transparent: true, opacity: 0.4 });
this.trailLine = new THREE.Line(trailGeo, trailMat);
scene.add(this.trailLine);
}
addTrailPoint() {
this.trail.push(this.pos.clone());
if (this.trail.length > TRAIL_LEN) this.trail.shift();
const attr = this.trailLine.geometry.attributes.position;
for (let i = 0; i < this.trail.length; i++) {
attr.setXYZ(i, this.trail[i].x, this.trail[i].y, this.trail[i].z);
}
attr.needsUpdate = true;
this.trailLine.geometry.setDrawRange(0, this.trail.length);
}
destroy() {
scene.remove(this.mesh);
scene.remove(this.trailLine);
if (this.light) scene.remove(this.light);
this.mesh.geometry.dispose();
this.mesh.material.dispose();
this.trailLine.geometry.dispose();
this.trailLine.material.dispose();
}
}
/* ═══════════════════════════════════════════════════════
PHYSIK – Verlet-Integration (N-Body)
═══════════════════════════════════════════════════════ */
const _diff = new THREE.Vector3();
function computeAccelerations() {
for (const b of bodies) b.acc.set(0, 0, 0);
for (let i = 0; i < bodies.length; i++) {
for (let j = i + 1; j < bodies.length; j++) {
const a = bodies[i], b = bodies[j];
_diff.subVectors(b.pos, a.pos);
const r2 = _diff.lengthSq();
const r = Math.sqrt(r2);
const soft = 4; // Softening vermeidet Singularität
const f = G * a.mass * b.mass / (r2 + soft * soft);
const dir = _diff.clone().divideScalar(r);
a.acc.addScaledVector(dir, f / a.mass);
b.acc.addScaledVector(dir, -f / b.mass);
}
}
}
function stepPhysics(dt) {
computeAccelerations();
for (const b of bodies) {
// Velocity Verlet
b.vel.addScaledVector(b.acc, dt * 0.5);
b.pos.addScaledVector(b.vel, dt);
b.vel.addScaledVector(b.acc, dt * 0.5);
b.mesh.position.copy(b.pos);
if (b.light) b.light.position.copy(b.pos);
b.addTrailPoint();
}
// Kollisionserkennung
for (let i = 0; i < bodies.length; i++) {
for (let j = i + 1; j < bodies.length; j++) {
const a = bodies[i], b = bodies[j];
if (!a.alive || !b.alive) continue;
const dist = a.pos.distanceTo(b.pos);
if (dist < (a.radius + b.radius) * 0.8) {
mergeBodies(a, b);
}
}
}
bodies = bodies.filter(b => {
if (!b.alive) { b.destroy(); return false; }
return true;
});
}
function mergeBodies(a, b) {
// Größerer Körper übernimmt — Impulserhaltung
const [big, small] = a.mass >= b.mass ? [a, b] : [b, a];
const totalMass = big.mass + small.mass;
big.vel.multiplyScalar(big.mass / totalMass).addScaledVector(small.vel, small.mass / totalMass);
big.mass = totalMass;
big.radius = big.radius * Math.pow(totalMass / (big.mass || 1), 0.2);
big.mesh.geometry.dispose();
big.mesh.geometry = new THREE.SphereGeometry(big.radius, 24, 16);
small.alive = false;
if (selectedBody === small) selectedBody = big;
}
/* ═══════════════════════════════════════════════════════
PRESET — vereinfachtes Sonnensystem
═══════════════════════════════════════════════════════ */
function loadPreset() {
clearBodies();
// Sonne
addBody(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 0), 100, '#fffbe6', 'star');
// Planeten (Kreisbahnen: v = sqrt(G*M/r))
const planets = [
{ r: 30, m: 1, color: '#aaaaff', type: 'planet' }, // Merkur-analog
{ r: 50, m: 3, color: '#ffd966', type: 'planet' }, // Venus-analog
{ r: 75, m: 4, color: '#4fc3d8', type: 'planet' }, // Erde-analog
{ r: 105, m: 2, color: '#ff9944', type: 'planet' }, // Mars-analog
{ r: 160, m: 8, color: '#ff7744', type: 'planet' }, // Jupiter-analog
{ r: 220, m: 5, color: '#c9a84c', type: 'planet' }, // Saturn-analog
];
for (const p of planets) {
const angle = Math.random() * Math.PI * 2;
const px = p.r * Math.cos(angle);
const pz = p.r * Math.sin(angle);
const v = Math.sqrt(G * 100 / p.r);
const vx = -v * Math.sin(angle);
const vz = v * Math.cos(angle);
addBody(
new THREE.Vector3(px, 0, pz),
new THREE.Vector3(vx, 0, vz),
p.m, p.color, p.type
);
}
}
function addBody(pos, vel, mass, color, type) {
bodies.push(new Body(pos, vel, mass, color, type));
updateHud();
}
function clearBodies() {
for (const b of bodies) b.destroy();
bodies = [];
selectedBody = null;
hidePanel();
updateHud();
}
/* ═══════════════════════════════════════════════════════
RESIZE
═══════════════════════════════════════════════════════ */
function onResize() {
const w = wrap.clientWidth;
const h = wrap.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
window.addEventListener('resize', onResize);
onResize();
/* ═══════════════════════════════════════════════════════
PLATZIEREN – Mausinteraktion
═══════════════════════════════════════════════════════ */
let interactionMode = 'orbit'; // 'orbit' | 'place'
let arrowHelper = null;
const raycaster = new THREE.Raycaster();
const placeplane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // xz-Ebene
function ndcFromEvent(e) {
const rect = canvas.getBoundingClientRect();
return new THREE.Vector2(
((e.clientX - rect.left) / rect.width) * 2 - 1,
-((e.clientY - rect.top) / rect.height) * 2 + 1
);
}
function worldPosOnPlane(ndc) {
raycaster.setFromCamera(ndc, camera);
const target = new THREE.Vector3();
raycaster.ray.intersectPlane(placeplane, target);
return target;
}
canvas.addEventListener('mousedown', function (e) {
if (interactionMode !== 'place') return;
e.preventDefault();
placing = true;
placeStart = ndcFromEvent(e);
placePos3D = worldPosOnPlane(placeStart);
controls.enabled = false;
});
canvas.addEventListener('mousemove', function (e) {
if (!placing) return;
const ndc = ndcFromEvent(e);
const endPos = worldPosOnPlane(ndc);
if (!endPos || !placePos3D) return;
// Pfeil für Geschwindigkeitsrichtung
if (arrowHelper) scene.remove(arrowHelper);
const dir = endPos.clone().sub(placePos3D);
if (dir.length() > 0.5) {
arrowHelper = new THREE.ArrowHelper(dir.clone().normalize(), placePos3D, dir.length(), 0xffd966, 3, 2);
scene.add(arrowHelper);
}
});
canvas.addEventListener('mouseup', function (e) {
if (!placing) return;
placing = false;
controls.enabled = true;
const ndc = ndcFromEvent(e);
const endPos = worldPosOnPlane(ndc);
if (arrowHelper) { scene.remove(arrowHelper); arrowHelper = null; }
if (!placePos3D) return;
const vel = endPos ? endPos.clone().sub(placePos3D).multiplyScalar(0.5) : new THREE.Vector3();
vel.y = 0;
const mass = parseInt(document.getElementById('usbMass').value, 10);
const color = currentColor;
const type = currentType;
addBody(placePos3D, vel, mass, color, type);
});
// Touch-Support (einfach)
canvas.addEventListener('touchstart', function (e) {
if (interactionMode !== 'place' || e.touches.length !== 1) return;
const touch = e.touches[0];
placing = true;
placeStart = ndcFromEvent(touch);
placePos3D = worldPosOnPlane(placeStart);
controls.enabled = false;
}, { passive: true });
canvas.addEventListener('touchend', function (e) {
if (!placing) return;
placing = false;
controls.enabled = true;
if (arrowHelper) { scene.remove(arrowHelper); arrowHelper = null; }
if (!placePos3D) return;
const mass = parseInt(document.getElementById('usbMass').value, 10);
addBody(placePos3D, new THREE.Vector3(), mass, currentColor, currentType);
}, { passive: true });
/* ═══════════════════════════════════════════════════════
TOOLBAR INTERAKTION
═══════════════════════════════════════════════════════ */
let currentColor = '#fffbe6';
let currentType = 'star';
// Modus-Buttons
document.getElementById('usbModePlace').addEventListener('click', function () {
interactionMode = 'place';
wrap.className = 'usb-canvas-wrap';
controls.enabled = false;
this.classList.add('is-active');
document.getElementById('usbModeOrbit').classList.remove('is-active');
});
document.getElementById('usbModeOrbit').addEventListener('click', function () {
interactionMode = 'orbit';
wrap.className = 'usb-canvas-wrap mode-orbit';
controls.enabled = true;
placing = false;
if (arrowHelper) { scene.remove(arrowHelper); arrowHelper = null; }
this.classList.add('is-active');
document.getElementById('usbModePlace').classList.remove('is-active');
});
// Typ-Buttons
document.querySelectorAll('[data-type]').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('[data-type]').forEach(b => b.classList.remove('is-active'));
this.classList.add('is-active');
currentType = this.dataset.type;
});
});
// Farb-Swatches
document.querySelectorAll('.usb-swatch').forEach(function (sw) {
sw.addEventListener('click', function () {
document.querySelectorAll('.usb-swatch').forEach(s => s.classList.remove('active'));
this.classList.add('active');
currentColor = this.dataset.color;
});
});
// Masse-Slider
const massSlider = document.getElementById('usbMass');
const massVal = document.getElementById('usbMassVal');
massSlider.addEventListener('input', function () { massVal.textContent = this.value; });
// Zeit-Buttons
document.querySelectorAll('[data-speed]').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('[data-speed]').forEach(b => b.classList.remove('is-active'));
this.classList.add('is-active');
timeScale = parseFloat(this.dataset.speed);
simPaused = false;
document.getElementById('usbPause').textContent = '⏸';
});
});
document.getElementById('usbPause').addEventListener('click', function () {
simPaused = !simPaused;
this.textContent = simPaused ? '▶' : '⏸';
});
document.getElementById('usbReset').addEventListener('click', clearBodies);
document.getElementById('usbPreset').addEventListener('click', loadPreset);
/* ═══════════════════════════════════════════════════════
HUD & PANEL
═══════════════════════════════════════════════════════ */
function updateHud() {
document.getElementById('hudBodies').textContent = bodies.length;
}
function showPanel(b) {
selectedBody = b;
const panel = document.getElementById('usbPanel');
panel.classList.add('is-visible');
document.getElementById('usbPanelTitle').textContent = b.type.charAt(0).toUpperCase() + b.type.slice(1) + ' #' + b.id;
}
function hidePanel() {
document.getElementById('usbPanel').classList.remove('is-visible');
}
function updatePanel() {
if (!selectedBody || !selectedBody.alive) { hidePanel(); return; }
const b = selectedBody;
document.getElementById('piMass').textContent = b.mass.toFixed(1);
document.getElementById('piRadius').textContent = b.radius.toFixed(2);
document.getElementById('piVel').textContent = b.vel.length().toFixed(2);
document.getElementById('piX').textContent = b.pos.x.toFixed(1);
document.getElementById('piY').textContent = b.pos.y.toFixed(1);
document.getElementById('piZ').textContent = b.pos.z.toFixed(1);
}
// Klick auf Körper für Panel-Auswahl
canvas.addEventListener('click', function (e) {
if (interactionMode !== 'orbit') return;
const ndc = ndcFromEvent(e);
raycaster.setFromCamera(ndc, camera);
const meshes = bodies.map(b => b.mesh);
const hits = raycaster.intersectObjects(meshes);
if (hits.length) {
const hit = hits[0].object;
const b = bodies.find(b => b.mesh === hit);
if (b) showPanel(b);
} else {
hidePanel();
selectedBody = null;
}
});
/* ═══════════════════════════════════════════════════════
AMBIENTES LICHT
═══════════════════════════════════════════════════════ */
scene.add(new THREE.AmbientLight(0x111122, 0.8));
/* ═══════════════════════════════════════════════════════
RENDER-LOOP
═══════════════════════════════════════════════════════ */
let lastTime = performance.now();
let fps = 0, fpsCounter = 0, fpsClock = 0;
function animate(now) {
requestAnimationFrame(animate);
const elapsed = (now - lastTime) / 1000;
lastTime = now;
// FPS
fpsCounter++;
fpsClock += elapsed;
if (fpsClock >= 0.5) {
fps = Math.round(fpsCounter / fpsClock);
document.getElementById('hudFps').textContent = fps;
fpsCounter = 0; fpsClock = 0;
}
// Physik
if (!simPaused && bodies.length > 0) {
const dt = Math.min(elapsed, 0.05) * timeScale;
const steps = Math.max(1, Math.round(timeScale / 5));
const subDt = dt / steps;
for (let i = 0; i < steps; i++) stepPhysics(subDt);
updateHud();
}
controls.update();
updatePanel();
renderer.render(scene, camera);
}
// Hint nach 5 Sekunden ausblenden
setTimeout(function () {
const h = document.getElementById('usbHint');
if (h) h.style.opacity = '0';
}, 5000);
// Standard: Orbit-Modus + Preset laden
loadPreset();
animate(performance.now());
})();
</script>
<?php require __DIR__ . '/footer.php'; ?>