1e51ef7def
- 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>
1527 lines
46 KiB
HTML
1527 lines
46 KiB
HTML
<!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 4–5.5 m/s). Jetzt 2.5–4 m/s — Spieler hat mehr Reaktionszeit.
|
||
ship.userData.speed = 2.5 + Math.random() * 1.5; // 2.5–4 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: 7–11 s zwischen Schiffen (war 4.2–5.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>
|