Files
Adminator 1e51ef7def Nachtrag: alle bisher untracked Ordner + hängende Änderungen mit-committen
- Konzept/, didaktik_geografie/, didaktik_simulation/, v2-modules/, v2-platform/
- 12 code-workspace-Files
- STATUS-*.md
- viele M/D/R-Änderungen an bereits getrackten Files
- .gitignore verstärkt: **/.humaninput/, **/secret_keys.txt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 02:27:02 +02:00

1527 lines
46 KiB
HTML
Raw Permalink 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.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no">
<title>Frachtroute — Buckelwal-Simulator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,600;12..96,800&family=Hanken+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
:root {
--bg: #e8ddc5;
--bg-light: #f4ede0;
--bg-dim: #d4c5a0;
--forest: #2d4a3e;
--forest-dark: #1f3329;
--forest-soft: #4a6f5e;
--ink: #1a2620;
--ink-soft: #4a5550;
--terracotta: #b86840;
--terracotta-dark: #8b4a2a;
--sand: #c9b794;
--line: #b8a987;
--good: #4a7c4a;
--bad: #a04030;
--warn: #b08947;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
overflow: hidden;
background: #2a4a55;
font-family: 'Hanken Grotesk', -apple-system, system-ui, sans-serif;
color: var(--ink);
-webkit-tap-highlight-color: transparent;
user-select: none;
}
#canvas {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
/* ============ HUD ============ */
#hud {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 10;
pointer-events: none;
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px;
}
.hud-row {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.hud-bars {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 180px;
}
.bar-wrap {
background: rgba(31, 51, 41, 0.85);
padding: 7px 11px 9px;
border-left: 3px solid var(--terracotta);
}
.bar-label {
display: flex;
justify-content: space-between;
font-family: 'Bricolage Grotesque', sans-serif;
font-size: 0.62rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--sand);
font-weight: 700;
margin-bottom: 4px;
}
.bar-label .v {
font-variant-numeric: tabular-nums;
color: var(--bg-light);
letter-spacing: 0.05em;
}
.bar-track {
height: 4px;
background: rgba(0,0,0,0.4);
position: relative;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: var(--good);
width: 100%;
transition: width 0.15s linear, background 0.3s;
}
.bar-fill.low { background: var(--warn); }
.bar-fill.crit { background: var(--bad); animation: pulse 0.6s ease-in-out infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.progress-box {
background: rgba(31, 51, 41, 0.85);
padding: 7px 11px 9px;
text-align: right;
min-width: 130px;
border-right: 3px solid var(--sand);
}
.progress-box .bar-label { justify-content: flex-end; gap: 8px; }
.progress-box .bar-track { width: 100%; }
.progress-box .bar-fill { background: var(--sand); }
.warning {
position: fixed;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: rgba(160, 64, 48, 0.92);
color: var(--bg-light);
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 800;
font-size: 1rem;
letter-spacing: 0.2em;
text-transform: uppercase;
padding: 10px 20px;
z-index: 20;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.warning.show { opacity: 1; }
/* ============ TOUCH CONTROLS ============ */
#touch-pad {
position: fixed;
bottom: 18px;
left: 18px;
right: 18px;
z-index: 10;
display: flex;
justify-content: space-between;
pointer-events: none;
}
.touch-cluster {
display: grid;
grid-template-columns: repeat(3, 48px);
grid-template-rows: repeat(3, 48px);
gap: 4px;
pointer-events: auto;
}
.touch-cluster.left {
/* . . . */
/* ← . → */
/* . . . */
}
.tbtn {
background: rgba(31, 51, 41, 0.7);
color: var(--bg-light);
border: none;
font-family: 'Bricolage Grotesque', sans-serif;
font-size: 1.4rem;
font-weight: 800;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
.tbtn:active { background: var(--terracotta); }
.t-up { grid-column: 2; grid-row: 1; }
.t-down { grid-column: 2; grid-row: 3; }
.t-left { grid-column: 1; grid-row: 2; }
.t-right { grid-column: 3; grid-row: 2; }
.keyboard-hint {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 9;
font-family: 'Bricolage Grotesque', sans-serif;
font-size: 0.65rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--bg-light);
background: rgba(31, 51, 41, 0.7);
padding: 8px 14px;
font-weight: 600;
}
@media (max-width: 720px) {
.keyboard-hint { display: none; }
}
@media (min-width: 721px) {
#touch-pad { display: none; }
}
/* Split-screen separator and view labels */
.view-split {
position: fixed;
left: 0;
right: 0;
top: 58%;
height: 3px;
background: var(--terracotta);
z-index: 9;
pointer-events: none;
}
.view-label {
position: fixed;
z-index: 9;
font-family: 'Bricolage Grotesque', sans-serif;
font-size: 0.6rem;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--bg-light);
background: rgba(31, 51, 41, 0.85);
padding: 5px 10px;
font-weight: 700;
pointer-events: none;
}
.view-label.top { top: calc(58% - 24px); right: 14px; }
.view-label.bottom { top: calc(58% + 8px); right: 14px; }
/* ============ MODALS ============ */
.modal {
position: fixed;
inset: 0;
background: rgba(31, 51, 41, 0.92);
z-index: 100;
display: none;
align-items: center;
justify-content: center;
padding: 16px;
}
.modal.active { display: flex; }
.modal-panel {
background: var(--bg-light);
max-width: 540px;
width: 100%;
max-height: 92vh;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.modal-head {
background: var(--forest);
color: var(--bg-light);
padding: 16px 22px 18px;
}
.modal-tag {
font-family: 'Bricolage Grotesque', sans-serif;
font-size: 0.65rem;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--sand);
font-weight: 700;
margin-bottom: 5px;
}
.modal-head h2 {
font-family: 'Bricolage Grotesque', sans-serif;
font-size: 1.5rem;
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
}
.modal-head h2 em {
font-style: normal;
font-weight: 400;
color: var(--sand);
}
.modal-body {
padding: 22px;
display: flex;
flex-direction: column;
gap: 14px;
color: var(--ink);
}
.modal-body p {
font-size: 0.95rem;
line-height: 1.6;
}
.modal-body p strong {
color: var(--forest);
font-weight: 700;
}
.intro-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px 14px;
align-items: center;
padding: 14px 16px;
background: var(--bg);
border-left: 3px solid var(--forest);
margin: 4px 0;
}
.intro-key {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 800;
font-size: 0.78rem;
letter-spacing: 0.1em;
background: var(--forest);
color: var(--bg-light);
padding: 4px 8px;
text-align: center;
min-width: 60px;
}
.intro-desc {
font-size: 0.88rem;
color: var(--ink);
}
.btn {
font-family: 'Bricolage Grotesque', sans-serif;
font-weight: 700;
font-size: 0.88rem;
padding: 14px 26px;
background: var(--terracotta);
color: var(--bg-light);
border: none;
cursor: pointer;
letter-spacing: 0.06em;
text-transform: uppercase;
transition: background 0.15s;
margin-top: 4px;
}
.btn:hover { background: var(--terracotta-dark); }
.btn:active { transform: translateY(1px); }
.btn-secondary {
background: var(--forest);
}
.btn-secondary:hover { background: var(--forest-dark); }
.btn-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.btn-row .btn { flex: 1; margin-top: 0; min-width: 140px; }
.outcome-stats {
display: flex;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
margin: 4px 0;
}
.outcome-stat {
flex: 1;
padding: 12px;
font-family: 'Bricolage Grotesque', sans-serif;
border-right: 1px solid var(--line);
}
.outcome-stat:last-child { border-right: none; }
.os-label {
font-size: 0.6rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-soft);
font-weight: 600;
margin-bottom: 4px;
}
.os-value {
font-size: 1.3rem;
font-weight: 800;
color: var(--forest);
font-variant-numeric: tabular-nums;
}
.fact-box {
padding: 12px 14px;
background: var(--sand);
border-left: 3px solid var(--forest);
font-size: 0.86rem;
line-height: 1.55;
}
.fact-tag {
font-family: 'Bricolage Grotesque', sans-serif;
font-size: 0.6rem;
letter-spacing: 0.25em;
text-transform: uppercase;
color: var(--forest);
display: block;
font-weight: 700;
margin-bottom: 4px;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div class="view-split"></div>
<div class="view-label top">Hinter dem Wal</div>
<div class="view-label bottom">Tiefenansicht · von der Seite</div>
<div id="hud">
<div class="hud-row">
<div class="hud-bars">
<div class="bar-wrap">
<div class="bar-label"><span>Mutter · Atem</span><span class="v" id="motherO2Val">100</span></div>
<div class="bar-track"><div class="bar-fill" id="motherO2Bar"></div></div>
</div>
<div class="bar-wrap" style="border-left-color: var(--sand);">
<div class="bar-label"><span>Kalb · Atem</span><span class="v" id="calfO2Val">100</span></div>
<div class="bar-track"><div class="bar-fill" id="calfO2Bar"></div></div>
</div>
</div>
<div class="progress-box">
<div class="bar-label"><span class="v" id="progressVal">0%</span><span>Querung</span></div>
<div class="bar-track"><div class="bar-fill" id="progressBar" style="width:0"></div></div>
<div class="bar-label" style="margin-top:4px"><span class="v" id="hudKrill" style="color:#4a7c4a">0 / 15</span><span>🦐 Krill</span></div>
</div>
</div>
</div>
<div class="warning" id="warning">Auftauchen!</div>
<div id="touch-pad">
<div class="touch-cluster left">
<button class="tbtn t-up" data-key="ArrowUp"></button>
<button class="tbtn t-down" data-key="ArrowDown"></button>
</div>
<div class="touch-cluster right">
<button class="tbtn t-left" data-key="ArrowLeft"></button>
<button class="tbtn t-right" data-key="ArrowRight"></button>
</div>
</div>
<div class="keyboard-hint">Pfeiltasten · ↑ Auftauchen · ↓ Tauchen · ← → Seitwärts</div>
<!-- INTRO MODAL -->
<div class="modal active" id="modal-intro">
<div class="modal-panel">
<div class="modal-head">
<div class="modal-tag">Minispiel · Etappe 2 · Begegnung 1</div>
<h2>Frachtroute<em>San Francisco Tokio</em></h2>
</div>
<div class="modal-body">
<p>Du näherst dich mit deinem neugeborenen Kalb der dichtesten Schifffahrtsroute des Nordpazifiks. Containerschiffe ziehen mit zwanzig Knoten westwärts — du musst <strong>unter ihren Rümpfen durchtauchen</strong>.</p>
<p>Dein Kalb hält weniger lang die Luft an als du. Wenn ihm der Atem ausgeht, muss es auftauchen — direkt unter einen Frachter, wenn du dich verschätzt hast.</p>
<div class="intro-grid">
<span class="intro-key"></span><span class="intro-desc">Steigen — zur Oberfläche, Atem holen</span>
<span class="intro-key"></span><span class="intro-desc">Tauchen — unter die Schiffsrümpfe</span>
<span class="intro-key">← →</span><span class="intro-desc">Seitlich ausweichen, gegen die Strömung steuern</span>
</div>
<div class="fact-box">
<span class="fact-tag">Hintergrund</span>
Schiffsschläge sind die häufigste durch Menschen verursachte Todesursache bei Großwalen. Frachter mit zwölf Metern Tiefgang reichen bis weit unter die Oberfläche — sicher ist es erst ab etwa 30 Metern Tiefe.
</div>
<button class="btn" onclick="startGame()">Beginnen</button>
</div>
</div>
</div>
<!-- END MODAL -->
<div class="modal" id="modal-end">
<div class="modal-panel">
<div class="modal-head">
<div class="modal-tag" id="endTag">— Ergebnis —</div>
<h2 id="endTitle"></h2>
</div>
<div class="modal-body">
<p id="endText"></p>
<div class="outcome-stats" id="endStats"></div>
<div class="fact-box" id="endFact" style="display:none;">
<span class="fact-tag">Wusstest du?</span>
<span id="endFactText"></span>
</div>
<div class="btn-row">
<button class="btn" onclick="resetGame()">Nochmal versuchen</button>
</div>
</div>
</div>
</div>
<script>
// ============================================================
// THREE.JS SETUP
// ============================================================
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x355c69);
scene.fog = new THREE.Fog(0x355c69, 18, 75);
// Two cameras: back-view (perspective) and side-view (orthographic, x-ray style)
const BACK_SPLIT = 0.58; // back view takes 58% of height (top), side gets 42% (bottom)
function backHeight() { return Math.floor(window.innerHeight * BACK_SPLIT); }
function sideHeight() { return window.innerHeight - backHeight(); }
const cameraBack = new THREE.PerspectiveCamera(65, window.innerWidth / backHeight(), 0.1, 200);
const SIDE_VIEW_SIZE = 6.5; // half-height of ortho frustum in world units
const cameraSide = new THREE.OrthographicCamera(
-SIDE_VIEW_SIZE * (window.innerWidth / sideHeight()),
SIDE_VIEW_SIZE * (window.innerWidth / sideHeight()),
SIDE_VIEW_SIZE,
-SIDE_VIEW_SIZE,
0.1, 250
);
const renderer = new THREE.WebGLRenderer({
canvas: document.getElementById('canvas'),
antialias: true
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = false;
// Lights
const sun = new THREE.DirectionalLight(0xfff0d4, 1.1);
sun.position.set(8, 25, -6);
scene.add(sun);
const ambient = new THREE.AmbientLight(0x456070, 0.45);
scene.add(ambient);
const hemi = new THREE.HemisphereLight(0xa8c8d4, 0x1a2620, 0.5);
scene.add(hemi);
// ============================================================
// WORLD ELEMENTS
// ============================================================
// Water surface (seen from below when whale is deep)
const surfaceGeom = new THREE.PlaneGeometry(200, 200, 40, 40);
const surfaceMat = new THREE.MeshStandardMaterial({
color: 0xa8c8d4,
transparent: true,
opacity: 0.55,
side: THREE.DoubleSide,
roughness: 0.3,
metalness: 0.15
});
const surface = new THREE.Mesh(surfaceGeom, surfaceMat);
surface.rotation.x = -Math.PI / 2;
surface.position.y = 0;
scene.add(surface);
// Original surface vertices for wave animation
const surfaceVerts = surfaceGeom.attributes.position.array.slice();
// Sun rays (semi-transparent slanted planes going down from surface)
const rayGroup = new THREE.Group();
for (let i = 0; i < 6; i++) {
const rayGeom = new THREE.PlaneGeometry(2.5, 40);
const rayMat = new THREE.MeshBasicMaterial({
color: 0xfff0d4,
transparent: true,
opacity: 0.06,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide,
depthWrite: false
});
const ray = new THREE.Mesh(rayGeom, rayMat);
ray.position.set(-30 + i * 12, -15, 0);
ray.rotation.z = 0.15 + Math.random() * 0.05;
rayGroup.add(ray);
}
scene.add(rayGroup);
// Deep floor (very dark, far below)
const floorGeom = new THREE.PlaneGeometry(400, 400);
const floorMat = new THREE.MeshStandardMaterial({
color: 0x0a1a1f,
roughness: 1
});
const floor = new THREE.Mesh(floorGeom, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -40;
scene.add(floor);
// Drifting particles (for current visualization + atmosphere)
const particles = [];
const particleGeom = new THREE.SphereGeometry(0.08, 4, 3);
const particleMat = new THREE.MeshBasicMaterial({
color: 0xc9d8dc,
transparent: true,
opacity: 0.4
});
for (let i = 0; i < 120; i++) {
const p = new THREE.Mesh(particleGeom, particleMat);
p.position.set(
(Math.random() - 0.5) * 60,
-Math.random() * 25,
(Math.random() - 0.5) * 80
);
scene.add(p);
particles.push(p);
}
// ============================================================
// WHALE BUILDER
// ============================================================
function buildWhale(scale, color) {
const g = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({
color: color,
roughness: 0.55,
metalness: 0.05
});
const matBelly = new THREE.MeshStandardMaterial({
color: 0xe8c8a0,
roughness: 0.7
});
const matFin = new THREE.MeshStandardMaterial({
color: color,
roughness: 0.5,
metalness: 0.05,
side: THREE.DoubleSide
});
// ===== BODY: streamlined, tapered, slightly flattened =====
const bodyGeom = new THREE.SphereGeometry(0.8, 32, 24);
const pos = bodyGeom.attributes.position;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const y = pos.getY(i);
const z = pos.getZ(i);
const t = z / 0.8; // -1 to 1
let profile = 1.0;
if (t > 0.3) {
profile = 1.0 - Math.pow((t - 0.3) / 0.7, 1.4) * 0.55;
}
if (t < -0.4) {
profile = 1.0 - Math.pow((-t - 0.4) / 0.6, 1.3) * 0.88;
}
const dorsoVentral = y > 0 ? 0.78 : 0.7;
pos.setX(i, x * profile * 0.82);
pos.setY(i, y * profile * dorsoVentral);
pos.setZ(i, z * 2.55);
}
bodyGeom.computeVertexNormals();
const body = new THREE.Mesh(bodyGeom, mat);
g.add(body);
// Belly (lighter) - slightly larger Y-offset so visible from below
const bellyGeom = new THREE.SphereGeometry(0.78, 24, 16);
const bp = bellyGeom.attributes.position;
for (let i = 0; i < bp.count; i++) {
const x = bp.getX(i);
const y = bp.getY(i);
const z = bp.getZ(i);
const t = z / 0.78;
let profile = 1.0;
if (t > 0.3) profile = 1.0 - Math.pow((t - 0.3) / 0.7, 1.4) * 0.55;
if (t < -0.4) profile = 1.0 - Math.pow((-t - 0.4) / 0.6, 1.3) * 0.88;
bp.setX(i, x * profile * 0.78);
// Clip top of belly to be flat below midline
let newY = y * profile * 0.65;
if (newY > -0.05) newY = -0.05;
bp.setY(i, newY - 0.05);
bp.setZ(i, z * 2.55);
}
bellyGeom.computeVertexNormals();
const belly = new THREE.Mesh(bellyGeom, matBelly);
g.add(belly);
// ===== PECTORAL FINS: long, curved, humpback-style (extruded 2D shape) =====
function makePecShape() {
const s = new THREE.Shape();
s.moveTo(0, 0);
// Leading edge — curves up to apex
s.bezierCurveTo(0.15, 0.32, 0.85, 0.55, 1.6, 0.6);
s.bezierCurveTo(2.1, 0.6, 2.5, 0.48, 2.7, 0.25);
// Tip
s.quadraticCurveTo(2.8, 0.05, 2.6, -0.12);
// Trailing edge — concave curve back to root
s.bezierCurveTo(2.0, -0.18, 1.0, -0.18, 0.4, -0.32);
s.quadraticCurveTo(0.1, -0.32, 0, 0);
return s;
}
const pecGeom = new THREE.ExtrudeGeometry(makePecShape(), {
depth: 0.14,
bevelEnabled: true,
bevelThickness: 0.04,
bevelSize: 0.05,
bevelSegments: 2,
curveSegments: 12
});
pecGeom.translate(0, 0, -0.07);
// Pivot for each fin for clean animation
const finPivotL = new THREE.Group();
finPivotL.position.set(-0.45, -0.1, 0.4);
finPivotL.rotation.set(0, Math.PI, -0.35);
const finMeshL = new THREE.Mesh(pecGeom, matFin);
finMeshL.scale.set(1, 1, 0.7);
finPivotL.add(finMeshL);
g.add(finPivotL);
g.userData.finL = finPivotL;
const finPivotR = new THREE.Group();
finPivotR.position.set(0.45, -0.1, 0.4);
finPivotR.rotation.set(0, 0, -0.35);
const finMeshR = new THREE.Mesh(pecGeom, matFin);
finMeshR.scale.set(1, 1, 0.7);
finPivotR.add(finMeshR);
g.add(finPivotR);
g.userData.finR = finPivotR;
// ===== TAIL STALK (peduncle) — narrow, flattened oval =====
const stalkGeom = new THREE.CylinderGeometry(0.28, 0.1, 0.6, 14);
stalkGeom.rotateX(Math.PI / 2);
const sp = stalkGeom.attributes.position;
for (let i = 0; i < sp.count; i++) sp.setY(i, sp.getY(i) * 0.55);
stalkGeom.computeVertexNormals();
const stalk = new THREE.Mesh(stalkGeom, mat);
stalk.position.z = -1.9;
g.add(stalk);
// ===== FLUKE (tail): notched humpback shape =====
function makeFlukeShape() {
const s = new THREE.Shape();
s.moveTo(0, 0);
// Right wing leading edge
s.bezierCurveTo(0.2, 0.04, 0.7, 0.08, 1.25, 0.04);
s.quadraticCurveTo(1.6, 0.0, 1.7, -0.08);
// Right tip
s.quadraticCurveTo(1.78, -0.18, 1.55, -0.24);
// Right wing trailing — curve back to notch
s.bezierCurveTo(0.95, -0.28, 0.4, -0.22, 0.12, -0.16);
// Center notch
s.lineTo(0, -0.06);
// Left wing trailing
s.lineTo(-0.12, -0.16);
s.bezierCurveTo(-0.4, -0.22, -0.95, -0.28, -1.55, -0.24);
// Left tip
s.quadraticCurveTo(-1.78, -0.18, -1.7, -0.08);
// Left wing leading back to start
s.quadraticCurveTo(-1.6, 0.0, -1.25, 0.04);
s.bezierCurveTo(-0.7, 0.08, -0.2, 0.04, 0, 0);
return s;
}
const flukeGeom = new THREE.ExtrudeGeometry(makeFlukeShape(), {
depth: 0.08,
bevelEnabled: true,
bevelThickness: 0.025,
bevelSize: 0.04,
bevelSegments: 2,
curveSegments: 14
});
flukeGeom.translate(0, 0, -0.04);
// Fluke pivot at the peduncle so it animates around that point
const flukePivot = new THREE.Group();
flukePivot.position.set(0, 0, -2.2);
const flukeMesh = new THREE.Mesh(flukeGeom, matFin);
flukeMesh.rotation.x = -Math.PI / 2; // lay horizontal
flukePivot.add(flukeMesh);
g.add(flukePivot);
g.userData.fluke = flukePivot;
// ===== DORSAL FIN: small, hump-shaped =====
function makeDorsalShape() {
const s = new THREE.Shape();
s.moveTo(-0.18, 0);
s.bezierCurveTo(-0.14, 0.22, 0.06, 0.32, 0.16, 0.26);
s.quadraticCurveTo(0.22, 0.18, 0.17, 0.04);
s.lineTo(-0.18, 0);
return s;
}
const dorsalGeom = new THREE.ExtrudeGeometry(makeDorsalShape(), {
depth: 0.06,
bevelEnabled: true,
bevelThickness: 0.02,
bevelSize: 0.02,
bevelSegments: 1,
curveSegments: 8
});
dorsalGeom.translate(0, 0, -0.03);
const dorsal = new THREE.Mesh(dorsalGeom, mat);
dorsal.position.set(0, 0.5, -0.85);
g.add(dorsal);
// ===== EYES (on sides, just behind mouth corner) =====
const eyeGeom = new THREE.SphereGeometry(0.05, 8, 8);
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x0a1010 });
const eyeL = new THREE.Mesh(eyeGeom, eyeMat);
eyeL.position.set(-0.55, -0.05, 0.85);
g.add(eyeL);
const eyeR = new THREE.Mesh(eyeGeom, eyeMat);
eyeR.position.set(0.55, -0.05, 0.85);
g.add(eyeR);
// ===== TUBERCLES (head bumps — humpback signature) =====
const bumpMat = new THREE.MeshStandardMaterial({
color: 0x8b4a2a,
roughness: 0.95
});
const tubercles = [
[0, 0.42, 1.55, 0.055],
[-0.22, 0.38, 1.45, 0.05],
[0.22, 0.38, 1.45, 0.05],
[-0.1, 0.46, 1.3, 0.05],
[0.1, 0.46, 1.3, 0.05],
[-0.3, 0.32, 1.2, 0.045],
[0.3, 0.32, 1.2, 0.045],
[0, 0.5, 1.15, 0.05],
[-0.18, 0.4, 1.0, 0.04],
[0.18, 0.4, 1.0, 0.04],
[0, 0.55, 0.95, 0.04],
];
for (const [tx, ty, tz, tr] of tubercles) {
const tb = new THREE.Mesh(new THREE.SphereGeometry(tr, 8, 6), bumpMat);
tb.position.set(tx, ty, tz);
g.add(tb);
}
// ===== BLOWHOLES =====
const bhGeom = new THREE.SphereGeometry(0.045, 6, 4);
const bhMat = new THREE.MeshStandardMaterial({ color: 0x1a0e08, roughness: 1 });
const bhL = new THREE.Mesh(bhGeom, bhMat);
bhL.position.set(-0.07, 0.55, 0.7);
bhL.scale.set(1, 0.35, 1.6);
g.add(bhL);
const bhR = new THREE.Mesh(bhGeom, bhMat);
bhR.position.set(0.07, 0.55, 0.7);
bhR.scale.set(1, 0.35, 1.6);
g.add(bhR);
g.scale.setScalar(scale);
return g;
}
const mother = buildWhale(1.0, 0xb86840);
mother.position.set(0, -4, 0);
scene.add(mother);
const calf = buildWhale(0.5, 0xc87850);
calf.position.set(-1.5, -3.5, -2.5);
scene.add(calf);
// ============================================================
// SHIPS
// ============================================================
function buildShip() {
const g = new THREE.Group();
const hullMat = new THREE.MeshStandardMaterial({
color: 0x1a2228,
roughness: 0.8,
flatShading: true
});
const hullBottomMat = new THREE.MeshStandardMaterial({
color: 0x8b3a2a,
roughness: 0.9
});
// Hull above waterline
const hullGeom = new THREE.BoxGeometry(22, 3.5, 4.5);
// Taper bow
const hp = hullGeom.attributes.position;
for (let i = 0; i < hp.count; i++) {
const x = hp.getX(i);
if (x > 9) {
hp.setZ(i, hp.getZ(i) * 0.5);
hp.setY(i, hp.getY(i) * 0.85);
}
if (x < -10) {
hp.setZ(i, hp.getZ(i) * 0.85);
}
}
hullGeom.computeVertexNormals();
const hull = new THREE.Mesh(hullGeom, hullMat);
hull.position.y = 1.5;
g.add(hull);
// Hull below waterline (deeper red - antifouling paint)
const hullDeepGeom = new THREE.BoxGeometry(21, 3.6, 4);
const hdp = hullDeepGeom.attributes.position;
for (let i = 0; i < hdp.count; i++) {
const x = hdp.getX(i);
if (x > 8) {
hdp.setZ(i, hdp.getZ(i) * 0.45);
hdp.setY(i, hdp.getY(i) * 0.9);
}
if (x < -9.5) {
hdp.setZ(i, hdp.getZ(i) * 0.85);
}
// Bring bottom to a slight keel
if (hdp.getY(i) < -0.5) {
hdp.setZ(i, hdp.getZ(i) * 0.7);
}
}
hullDeepGeom.computeVertexNormals();
const hullDeep = new THREE.Mesh(hullDeepGeom, hullBottomMat);
hullDeep.position.y = -1.6;
g.add(hullDeep);
// Bridge
const bridgeGeom = new THREE.BoxGeometry(2.5, 2.2, 3.5);
const bridge = new THREE.Mesh(bridgeGeom, hullMat);
bridge.position.set(-8, 4.4, 0);
g.add(bridge);
// Containers (random colors, stacked)
const contColors = [0x556b6e, 0x6b5a4a, 0x4a5852, 0x7a4a3a, 0x456060];
for (let x = -6.5; x < 8; x += 1.6) {
for (let z = -1.5; z <= 1.5; z += 1.5) {
const stack = 1 + Math.floor(Math.random() * 2);
for (let s = 0; s < stack; s++) {
const cg = new THREE.BoxGeometry(1.4, 1.0, 1.3);
const cm = new THREE.MeshStandardMaterial({
color: contColors[Math.floor(Math.random() * contColors.length)],
roughness: 0.85,
flatShading: true
});
const cont = new THREE.Mesh(cg, cm);
cont.position.set(x, 3.6 + s * 1.05, z);
g.add(cont);
}
}
}
// Subtle decorative line (waterline)
return g;
}
const ships = [];
function spawnShip(direction, zPos) {
const ship = buildShip();
ship.userData.dir = direction;
// 2026-06-02: Difficulty gesenkt (war 45.5 m/s). Jetzt 2.54 m/s — Spieler hat mehr Reaktionszeit.
ship.userData.speed = 2.5 + Math.random() * 1.5; // 2.54 m/s
ship.userData.zPos = zPos;
ship.position.set(direction > 0 ? -32 : 32, 0, zPos);
ship.rotation.y = direction > 0 ? 0 : Math.PI;
// Dark shadow on water surface
const shadowGeom = new THREE.PlaneGeometry(26, 7);
const shadowMat = new THREE.MeshBasicMaterial({
color: 0x05131a,
transparent: true,
opacity: 0.55,
depthWrite: false,
side: THREE.DoubleSide
});
const shadow = new THREE.Mesh(shadowGeom, shadowMat);
shadow.rotation.x = -Math.PI / 2;
shadow.position.y = -0.08;
ship.add(shadow);
scene.add(ship);
ships.push(ship);
return ship;
}
// ============================================================
// GAME STATE
// ============================================================
const state = {
running: false,
time: 0,
motherO2: 100,
calfO2: 100,
motherPos: new THREE.Vector3(0, -4, 0),
motherVel: new THREE.Vector3(0, 0, 0),
forwardSpeed: 2.0,
progress: 0,
goalZ: 50,
status: 'ready',
lossReason: null,
lastShipSpawn: 0,
shipInterval: 5.0, // erste Schiff-Spawn nach ~5s
currentX: 0,
currentT: 0,
// Modus-Erweiterung 2026-06-02
mode: 'fracht', // 'fracht' (Schifffahrt) | 'krill' (Nahrungssuche)
krillCollected: 0,
krillGoal: 15,
lastKrillSpawn: 0,
};
// ============================================================
// KRILL-MODUS — Nahrungssuche, keine Schiffe
// ============================================================
const krillObjects = [];
function spawnKrill(zPos) {
const krill = new THREE.Group();
// Krill-Schwarm: 6-10 kleine, lichtgrüne Kugeln dicht zusammen
const count = 6 + Math.floor(Math.random() * 5);
for (let i = 0; i < count; i++) {
const k = new THREE.Mesh(
new THREE.SphereGeometry(0.15, 8, 6),
new THREE.MeshBasicMaterial({ color: 0xc8e6a8, transparent: true, opacity: 0.85 })
);
k.position.set(
(Math.random() - 0.5) * 2.4,
(Math.random() - 0.5) * 1.2,
(Math.random() - 0.5) * 2.4
);
krill.add(k);
}
krill.position.set(
(Math.random() - 0.5) * 16,
-2 - Math.random() * 14,
zPos
);
krill.userData.collected = false;
scene.add(krill);
krillObjects.push(krill);
return krill;
}
function updateKrill(dt) {
if (state.mode !== 'krill') return;
// Krill alle 1.5-2.5s spawn vor dem Wal
if (state.time - state.lastKrillSpawn > 1.8) {
state.lastKrillSpawn = state.time;
spawnKrill(state.motherPos.z + 12 + Math.random() * 6);
}
// Kollision Wal vs. Krill
for (let i = krillObjects.length - 1; i >= 0; i--) {
const k = krillObjects[i];
if (k.userData.collected) continue;
const d = k.position.distanceTo(state.motherPos);
if (d < 3.2) {
k.userData.collected = true;
state.krillCollected++;
// Wal bekommt O2-Bonus durch Fressen
state.motherO2 = Math.min(100, state.motherO2 + 8);
state.calfO2 = Math.min(100, state.calfO2 + 8);
// Krill-Animation: schrumpfen + verschwinden
k.children.forEach(c => { c.material.opacity = 0; });
setTimeout(() => { scene.remove(k); krillObjects.splice(krillObjects.indexOf(k), 1); }, 100);
// HUD-Update
const hudKrill = document.getElementById('hudKrill');
if (hudKrill) hudKrill.textContent = state.krillCollected + ' / ' + state.krillGoal;
// Gewinn-Check
if (state.krillCollected >= state.krillGoal && state.status === 'playing') {
state.status = 'won';
winGame();
}
}
// Krill langsam nach hinten driften + entfernen wenn weg
if (k.position.z < state.motherPos.z - 10) {
scene.remove(k);
krillObjects.splice(i, 1);
}
}
}
function setMode(mode) {
state.mode = mode;
// Clean-up: Schiffe entfernen wenn Krill-Modus, Krill entfernen wenn Fracht
if (mode === 'krill') {
ships.forEach(s => scene.remove(s));
ships.length = 0;
state.goalZ = 9999; // kein Z-Ziel im Krill-Modus
state.krillCollected = 0;
state.krillGoal = 15;
var hudKrill = document.getElementById('hudKrill');
if (hudKrill) hudKrill.textContent = '0 / 15';
} else {
krillObjects.forEach(k => scene.remove(k));
krillObjects.length = 0;
state.goalZ = 50;
}
}
window.setMode = setMode; // damit task-overlay.js den Modus wechseln kann
// ============================================================
// INPUT
// ============================================================
const keys = { up: false, down: false, left: false, right: false };
function setKey(name, val) {
if (name === 'ArrowUp') keys.up = val;
if (name === 'ArrowDown') keys.down = val;
if (name === 'ArrowLeft') keys.left = val;
if (name === 'ArrowRight') keys.right = val;
}
addEventListener('keydown', e => {
if (e.key.startsWith('Arrow')) { e.preventDefault(); setKey(e.key, true); }
});
addEventListener('keyup', e => {
if (e.key.startsWith('Arrow')) { e.preventDefault(); setKey(e.key, false); }
});
// Touch
document.querySelectorAll('.tbtn').forEach(btn => {
const k = btn.dataset.key;
const press = (e) => { e.preventDefault(); setKey(k, true); };
const release = (e) => { e.preventDefault(); setKey(k, false); };
btn.addEventListener('touchstart', press, { passive: false });
btn.addEventListener('touchend', release, { passive: false });
btn.addEventListener('touchcancel', release, { passive: false });
btn.addEventListener('mousedown', press);
btn.addEventListener('mouseup', release);
btn.addEventListener('mouseleave', release);
});
// ============================================================
// GAME LOOP
// ============================================================
let lastTime = performance.now();
function tick(now) {
const dt = Math.min((now - lastTime) / 1000, 0.05);
lastTime = now;
state.time += dt;
if (state.running) updateGame(dt);
if (state.running) updateKrill(dt);
updateVisuals(dt);
updateCamera(dt);
// Split-screen rendering: back view (top), side view (bottom)
const w = window.innerWidth;
const bH = backHeight();
const sH = sideHeight();
renderer.setScissorTest(true);
// Back (top) - in WebGL coords origin is bottom-left, so top region is y = sH .. h
renderer.setViewport(0, sH, w, bH);
renderer.setScissor(0, sH, w, bH);
renderer.render(scene, cameraBack);
// Side (bottom) - y = 0 .. sH
renderer.setViewport(0, 0, w, sH);
renderer.setScissor(0, 0, w, sH);
renderer.render(scene, cameraSide);
renderer.setScissorTest(false);
requestAnimationFrame(tick);
}
function updateGame(dt) {
// Input → acceleration
// Achsen invertiert (2026-06-02): aus Spieler-Perspektive hinter dem Wal
// sind Bildschirm-Links/-Rechts spiegelverkehrt zum Wal-Eigen-Links/-Rechts.
// Auf/Ab waren ebenfalls vertauscht.
const ax = (keys.left ? 30 : 0) - (keys.right ? 30 : 0);
const ay = (keys.down ? 30 : 0) - (keys.up ? 30 : 0);
state.motherVel.x += ax * dt;
state.motherVel.y += ay * dt;
// Damping
state.motherVel.x *= 0.92;
state.motherVel.y *= 0.92;
// Speed limits
state.motherVel.x = THREE.MathUtils.clamp(state.motherVel.x, -7, 7);
state.motherVel.y = THREE.MathUtils.clamp(state.motherVel.y, -6, 6);
// Current (oscillating lateral push, makes it slightly harder)
state.currentT += dt;
state.currentX = Math.sin(state.currentT * 0.4) * 1.2;
state.motherVel.x += state.currentX * dt;
// Move mother
state.motherPos.x += state.motherVel.x * dt;
state.motherPos.y += state.motherVel.y * dt;
state.motherPos.z += state.forwardSpeed * dt;
// Clamp boundaries
state.motherPos.x = THREE.MathUtils.clamp(state.motherPos.x, -14, 14);
state.motherPos.y = THREE.MathUtils.clamp(state.motherPos.y, -22, -0.4);
mother.position.copy(state.motherPos);
// Mother orientation (face direction of motion)
const heading = Math.atan2(state.motherVel.x, state.forwardSpeed);
mother.rotation.y = heading * 0.8;
// Pitch from vertical motion
const pitch = -Math.atan2(state.motherVel.y, state.forwardSpeed);
mother.rotation.x = pitch * 0.6;
// Progress
state.progress = THREE.MathUtils.clamp(state.motherPos.z / state.goalZ, 0, 1);
// Calf follows mother (spring) — tight follow so it doesn't lag fatally
const target = new THREE.Vector3(
state.motherPos.x - 1.2,
state.motherPos.y + 0.15,
state.motherPos.z - 2.2
);
calf.position.lerp(target, dt * 3.5);
// Calf orientation
const calfDir = new THREE.Vector3().subVectors(target, calf.position);
if (calfDir.lengthSq() > 0.001) {
calf.rotation.y = mother.rotation.y * 0.7;
calf.rotation.x = mother.rotation.x * 0.7;
}
// Oxygen
const atSurfaceM = state.motherPos.y > -1.4;
const atSurfaceC = calf.position.y > -1.4;
if (atSurfaceM) {
state.motherO2 = Math.min(100, state.motherO2 + 50 * dt);
} else {
state.motherO2 = Math.max(0, state.motherO2 - 3 * dt); // 2026-06-02: -4 → -3
}
if (atSurfaceC) {
state.calfO2 = Math.min(100, state.calfO2 + 55 * dt);
} else {
state.calfO2 = Math.max(0, state.calfO2 - 5 * dt); // 2026-06-02: -7 → -5
}
// Spawn ships — z chosen so ship arrives at whale's x=0 when whale reaches ship's z
// Whale at 2.0 m/s, ship 4.75 m/s avg, ship spawn x=32 → crosses to x=0 in 6.7s
// In 6.7s whale moves 13.4m → spawn ship at z = whaleZ + 11..16
// 2026-06-02: nur Schiffe spawnen wenn nicht im Krill-Modus
if (state.mode !== 'krill' && state.time - state.lastShipSpawn > state.shipInterval) {
state.lastShipSpawn = state.time;
const aheadZ = state.motherPos.z + 11 + Math.random() * 5;
const dir = Math.random() > 0.5 ? 1 : -1;
spawnShip(dir, aheadZ);
// Difficulty: 711 s zwischen Schiffen (war 4.25.8) — ca. halbe Frequenz
state.shipInterval = 7 + Math.random() * 4;
}
// Move ships
for (let i = ships.length - 1; i >= 0; i--) {
const ship = ships[i];
ship.position.x += ship.userData.dir * ship.userData.speed * dt;
if (Math.abs(ship.position.x) > 42) {
scene.remove(ship);
ships.splice(i, 1);
}
}
// Collision — wider hull, deeper draft → can only dive past it
for (const ship of ships) {
const dxM = state.motherPos.x - ship.position.x;
const dzM = state.motherPos.z - ship.position.z;
const yM = state.motherPos.y;
if (Math.abs(dxM) < 13 && Math.abs(dzM) < 3 && yM > -3.3) {
return loseGame('collision');
}
const dxC = calf.position.x - ship.position.x;
const dzC = calf.position.z - ship.position.z;
const yC = calf.position.y;
if (Math.abs(dxC) < 13 && Math.abs(dzC) < 3 && yC > -3.3) {
return loseGame('collisionCalf');
}
}
// Oxygen lose
if (state.motherO2 <= 0) return loseGame('drownMother');
if (state.calfO2 <= 0) return loseGame('drownCalf');
// Warning
const lowO2 = Math.min(state.motherO2, state.calfO2);
document.getElementById('warning').classList.toggle('show', lowO2 < 20 && lowO2 > 0);
document.getElementById('warning').textContent = (state.calfO2 < state.motherO2)
? 'Kalb braucht Luft!'
: 'Auftauchen!';
// Win
if (state.progress >= 1) return winGame();
// Update HUD
updateHUD();
}
function updateVisuals(dt) {
// Surface wave animation
const time = state.time;
const positions = surfaceGeom.attributes.position;
for (let i = 0; i < positions.count; i++) {
const ox = surfaceVerts[i * 3];
const oy = surfaceVerts[i * 3 + 1];
positions.setZ(i, Math.sin(ox * 0.2 + time * 0.8) * 0.15 + Math.cos(oy * 0.15 + time * 0.6) * 0.12);
}
positions.needsUpdate = true;
// Whale animations (fluke + fins)
const swimT = state.time * 3.5;
if (mother.userData.fluke) {
mother.userData.fluke.rotation.x = Math.sin(swimT) * 0.4;
}
if (mother.userData.finL && mother.userData.finR) {
const finFlap = Math.sin(swimT * 0.5) * 0.18;
mother.userData.finL.rotation.z = -0.35 + finFlap;
mother.userData.finR.rotation.z = -0.35 + finFlap;
}
const swimC = state.time * 4.5;
if (calf.userData.fluke) {
calf.userData.fluke.rotation.x = Math.sin(swimC) * 0.45;
}
if (calf.userData.finL && calf.userData.finR) {
const finFlapC = Math.sin(swimC * 0.5) * 0.22;
calf.userData.finL.rotation.z = -0.35 + finFlapC;
calf.userData.finR.rotation.z = -0.35 + finFlapC;
}
// Particles drift (lateral with current, plus slight forward)
for (const p of particles) {
p.position.x += state.currentX * dt * 0.5;
p.position.z -= dt * 1.5;
// Recycle particles around whale
if (p.position.z < state.motherPos.z - 20) {
p.position.z += 80;
p.position.x = state.motherPos.x + (Math.random() - 0.5) * 50;
p.position.y = -Math.random() * 22;
}
if (p.position.x > state.motherPos.x + 25) p.position.x -= 50;
if (p.position.x < state.motherPos.x - 25) p.position.x += 50;
}
// Sun rays follow camera horizontally
rayGroup.position.z = state.motherPos.z;
rayGroup.position.x = state.motherPos.x * 0.5;
}
function updateCamera(dt) {
// ----- BACK VIEW (perspective, third-person follow) -----
const backTarget = new THREE.Vector3(
state.motherPos.x * 0.5,
Math.max(state.motherPos.y + 4, -3),
state.motherPos.z - 9
);
cameraBack.position.lerp(backTarget, dt * 2.5);
const backLook = new THREE.Vector3(
state.motherPos.x * 0.3,
state.motherPos.y + 0.5,
state.motherPos.z + 4
);
cameraBack.lookAt(backLook);
// ----- SIDE VIEW (orthographic, x-ray from the left side) -----
// Camera sits far to the -X side and looks toward +X.
// Result: world +Z (whale's forward direction) maps to image right,
// world +Y (up) maps to image up. Reads naturally.
const sideTarget = new THREE.Vector3(
state.motherPos.x - 40,
1,
state.motherPos.z + 3
);
cameraSide.position.lerp(sideTarget, dt * 3);
cameraSide.lookAt(
state.motherPos.x + 3,
-1,
state.motherPos.z + 3
);
}
function updateHUD() {
const mO2 = Math.round(state.motherO2);
const cO2 = Math.round(state.calfO2);
document.getElementById('motherO2Val').textContent = mO2;
document.getElementById('calfO2Val').textContent = cO2;
document.getElementById('motherO2Bar').style.width = mO2 + '%';
document.getElementById('calfO2Bar').style.width = cO2 + '%';
document.getElementById('motherO2Bar').className = 'bar-fill' + (mO2 < 20 ? ' crit' : mO2 < 40 ? ' low' : '');
document.getElementById('calfO2Bar').className = 'bar-fill' + (cO2 < 20 ? ' crit' : cO2 < 40 ? ' low' : '');
const p = Math.round(state.progress * 100);
document.getElementById('progressVal').textContent = p + '%';
document.getElementById('progressBar').style.width = p + '%';
}
// ============================================================
// GAME FLOW
// ============================================================
function startGame() {
document.getElementById('modal-intro').classList.remove('active');
state.running = true;
state.status = 'playing';
state.motherO2 = 100;
state.calfO2 = 100;
state.motherPos.set(0, -4, 0);
state.motherVel.set(0, 0, 0);
state.progress = 0;
state.time = 0;
state.lastShipSpawn = -1; // spawn one quickly
// Clear existing ships
for (const s of ships) scene.remove(s);
ships.length = 0;
mother.position.copy(state.motherPos);
calf.position.set(-1.5, -3.5, -2.5);
}
function loseGame(reason) {
state.running = false;
state.status = 'lost';
state.lossReason = reason;
document.getElementById('warning').classList.remove('show');
let title, text, factText;
if (reason === 'collision') {
title = 'Kollision';
text = 'Du bist mit einem Containerschiff zusammengestoßen. Du verlierst Energie und Gesundheit — deine Reise endet hier.';
factText = 'Ein modernes Containerschiff mit 20 Knoten kann einen Wal von 30 Tonnen töten. Die Tiere hören die Schiffe schon hunderte Kilometer entfernt, aber direkt vor dem Bug ist die Schallquelle paradoxerweise oft schwer zu lokalisieren.';
} else if (reason === 'collisionCalf') {
title = 'Kalb getroffen';
text = 'Dein Kalb hat den Schiffsrumpf nicht rechtzeitig unterquert. Es ist schwer verletzt — und ohne dich kommt es nicht weiter.';
factText = 'Junge Kalbtiere haben deutlich weniger Erfahrung mit Hindernissen als ihre Mütter. Sie folgen ihrer Mutter eng, können aber bei abrupten Manövern den Anschluss verlieren.';
} else if (reason === 'drownMother') {
title = 'Atem zu Ende';
text = 'Du bist zu lange unter Wasser geblieben. Buckelwale können zwar bis zu 45 Minuten tauchen — aber unter Stress sinkt die Zeit schnell auf wenige Minuten.';
factText = 'Buckelwale atmen über zwei Blaslöcher auf dem Kopf. Bei jedem Atemzug tauschen sie etwa 80% ihrer Lungenluft aus — viel mehr als Menschen (etwa 15%).';
} else if (reason === 'drownCalf') {
title = 'Kalb braucht Luft';
text = 'Dein Kalb konnte nicht rechtzeitig auftauchen. Junge Buckelwale müssen alle drei bis fünf Minuten zur Oberfläche.';
factText = 'Kalbtiere haben kleinere Lungen und mehr Sauerstoffbedarf. In ihren ersten Lebensmonaten bleiben sie selten länger als wenige Minuten unter Wasser.';
}
document.getElementById('endTag').textContent = '— Versuch gescheitert —';
document.getElementById('endTitle').textContent = title;
document.getElementById('endText').textContent = text;
document.getElementById('endFactText').textContent = factText;
document.getElementById('endFact').style.display = 'block';
document.getElementById('endStats').innerHTML = `
<div class="outcome-stat"><div class="os-label">Querung</div><div class="os-value">${Math.round(state.progress*100)}%</div></div>
<div class="outcome-stat"><div class="os-label">Mutter</div><div class="os-value">${Math.round(state.motherO2)}</div></div>
<div class="outcome-stat"><div class="os-label">Kalb</div><div class="os-value">${Math.round(state.calfO2)}</div></div>
`;
document.getElementById('modal-end').classList.add('active');
}
function winGame() {
state.running = false;
state.status = 'won';
document.getElementById('warning').classList.remove('show');
document.getElementById('endTag').textContent = '— Querung geschafft —';
document.getElementById('endTitle').textContent = 'Frachtroute überquert';
document.getElementById('endText').textContent = 'Du hast die Frachtroute mit deinem Kalb durchquert. Das Schraubgeräusch der Containerschiffe verklingt hinter euch. Vor euch liegt der subtropische Übergang.';
document.getElementById('endFactText').textContent = 'Im Nordpazifik kreuzen sich Migrationsrouten von Walen mit den Hauptschifffahrtswegen zwischen Nordamerika und Asien. Vorgeschriebene Routenverlegungen ("Verkehrstrennungsgebiete") haben in einigen Regionen die Anzahl der Schiffsschläge messbar reduziert.';
document.getElementById('endFact').style.display = 'block';
document.getElementById('endStats').innerHTML = `
<div class="outcome-stat"><div class="os-label">Querung</div><div class="os-value">100%</div></div>
<div class="outcome-stat"><div class="os-label">Mutter</div><div class="os-value">${Math.round(state.motherO2)}</div></div>
<div class="outcome-stat"><div class="os-label">Kalb</div><div class="os-value">${Math.round(state.calfO2)}</div></div>
`;
document.getElementById('modal-end').classList.add('active');
}
function resetGame() {
document.getElementById('modal-end').classList.remove('active');
document.getElementById('modal-intro').classList.add('active');
state.status = 'ready';
}
// ============================================================
// RESIZE
// ============================================================
function applyResize() {
const w = window.innerWidth;
const h = window.innerHeight;
renderer.setSize(w, h);
// Back view aspect uses the back viewport's height
cameraBack.aspect = w / backHeight();
cameraBack.updateProjectionMatrix();
// Side view ortho frustum follows side viewport aspect
const sideAspect = w / sideHeight();
cameraSide.left = -SIDE_VIEW_SIZE * sideAspect;
cameraSide.right = SIDE_VIEW_SIZE * sideAspect;
cameraSide.top = SIDE_VIEW_SIZE;
cameraSide.bottom = -SIDE_VIEW_SIZE;
cameraSide.updateProjectionMatrix();
}
addEventListener('resize', applyResize);
applyResize();
// Initial camera placement
cameraBack.position.set(0, 0, -10);
cameraBack.lookAt(0, -3, 5);
cameraSide.position.set(-40, 1, 3);
cameraSide.lookAt(3, -1, 3);
// Start loop
updateHUD();
requestAnimationFrame(tick);
</script>
<script src="task-overlay.js"></script>
</body>
</html>