universesandbox eingebaut

This commit is contained in:
Eskimue
2026-04-24 16:30:59 +02:00
parent 09dc007b0c
commit 0c6f24ac4a
3 changed files with 826 additions and 0 deletions
+1
View File
@@ -135,5 +135,6 @@ function manageablePublicPages(): array
'wochenvorhersage.php' => 'Wochenvorhersage',
'satellitenhimmel.php' => 'Satellitenhimmel',
'asteroid_temp.php' => 'Asteroid-Temperatur',
'universesandbox.php' => 'Universe Sandbox',
];
}
+1
View File
@@ -157,6 +157,7 @@ $menuGroups = [
['href' => 'geocron/index.php', 'label' => 'Geocron'],
['href' => 'geocron3d.php', 'label' => 'Geocron 3D'],
['href' => 'geocron3d_C.php', 'label' => 'Geocron 3D Cesium'],
['href' => 'universesandbox.php', 'label' => 'Universe Sandbox'],
],
],
[
+824
View File
@@ -0,0 +1,824 @@
<?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'; ?>