825 lines
30 KiB
PHP
825 lines
30 KiB
PHP
<?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> |
|
||
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'; ?>
|