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>
1829 lines
63 KiB
JavaScript
1829 lines
63 KiB
JavaScript
/*
|
|
* lgAutobahnGame
|
|
*
|
|
* Autobahn-Abfahrt-Minispiel als eigenständiges Modul.
|
|
* Eintritt: window.lgAutobahnGame.start({ container, scenario, onEnd, ... })
|
|
*
|
|
* Erwartet, dass window.THREE bereits geladen ist (lokal aus
|
|
* .../assets/vendor/three/three.min.js, NICHT aus dem CDN).
|
|
*
|
|
* Drei-Szenarien-Ergebnis: outcome ∈ { 'success', 'late', 'crash' }.
|
|
* onEnd(result) wird genau einmal aufgerufen.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
if (window.lgAutobahnGame) return;
|
|
|
|
// ============================================================
|
|
// Knotenpunkt-Geokoordinaten (Logistik prüft hier Routen-Nähe)
|
|
// ============================================================
|
|
const NODES = {
|
|
bregenz: { lat: 47.503, lon: 9.748, label: 'Bregenz' },
|
|
salzburg: { lat: 47.812, lon: 13.054, label: 'Salzburg-West' },
|
|
innsbruck: { lat: 47.270, lon: 11.401, label: 'Innsbruck' },
|
|
muenchen: { lat: 48.235, lon: 11.626, label: 'München-Nord' },
|
|
leipzig: { lat: 51.347, lon: 12.290, label: 'Leipzig-West' },
|
|
verona: { lat: 45.486, lon: 10.967, label: 'Verona Nord' }
|
|
};
|
|
|
|
// ============================================================
|
|
// Konstanten der Spielwelt
|
|
// ============================================================
|
|
const NUM_LANES = 3;
|
|
const LANE_W = 3.75;
|
|
const ROAD_W = NUM_LANES * LANE_W;
|
|
const SHOULDER_W = 2.0;
|
|
const TRUCK_SPEED_NORMAL = 20;
|
|
const TRUCK_SPEED_EASY = 16;
|
|
const EXIT_LEAD = 30;
|
|
const LANE_CHANGE_DURATION = 3.5;
|
|
const BLOCKER_CHANCE_TRUCK = 0.40;
|
|
const BLOCKER_CHANCE_CAR = 0.10;
|
|
|
|
function laneCenter(i) {
|
|
return ROAD_W / 2 - LANE_W / 2 - i * LANE_W;
|
|
}
|
|
|
|
// ============================================================
|
|
// Szenarien: Beschilderung, Hindernis, Ausfahrt
|
|
// ============================================================
|
|
const SCENARIOS = {
|
|
bregenz: {
|
|
target: 'Bregenz',
|
|
decisions: [
|
|
{ distance: 80, lanes: [
|
|
{ dest: ['Bregenz', 'Innsbruck'], type: 'straight' },
|
|
{ dest: ['Salzburg', 'Wien'], type: 'straight' },
|
|
{ dest: ['München'], type: 'straight' }
|
|
]},
|
|
{ distance: 280, lanes: [
|
|
{ dest: ['Innsbruck'], type: 'straight' },
|
|
{ dest: ['Lindau', 'St. Anton'], type: 'straight' },
|
|
{ dest: ['Bregenz'], type: 'exit' }
|
|
]}
|
|
],
|
|
hazard: { z: 200, lane: 0, kind: 'baustelle' },
|
|
exitDistance: 400, exitLane: 2
|
|
},
|
|
salzburg: {
|
|
target: 'Salzburg',
|
|
decisions: [
|
|
{ distance: 80, lanes: [
|
|
{ dest: ['Linz', 'Wien'], type: 'straight' },
|
|
{ dest: ['Krems'], type: 'straight' },
|
|
{ dest: ['Salzburg', 'München'], type: 'straight' }
|
|
]},
|
|
{ distance: 280, lanes: [
|
|
{ dest: ['Linz'], type: 'straight' },
|
|
{ dest: ['München'], type: 'straight' },
|
|
{ dest: ['Salzburg'], type: 'exit' }
|
|
]}
|
|
],
|
|
hazard: { z: 200, lane: 2, kind: 'unfall' },
|
|
exitDistance: 400, exitLane: 2
|
|
},
|
|
innsbruck: {
|
|
target: 'Innsbruck',
|
|
decisions: [
|
|
{ distance: 80, lanes: [
|
|
{ dest: ['Innsbruck', 'Bregenz'], type: 'straight' },
|
|
{ dest: ['Salzburg', 'Wien'], type: 'straight' },
|
|
{ dest: ['Brenner', 'Italien'], type: 'straight' }
|
|
]},
|
|
{ distance: 280, lanes: [
|
|
{ dest: ['Reutte', 'Imst'], type: 'straight' },
|
|
{ dest: ['Telfs'], type: 'straight' },
|
|
{ dest: ['Innsbruck'], type: 'exit' }
|
|
]}
|
|
],
|
|
hazard: { z: 200, lane: 0, kind: 'unfall' },
|
|
exitDistance: 400, exitLane: 2
|
|
},
|
|
muenchen: {
|
|
target: 'München',
|
|
decisions: [
|
|
{ distance: 80, lanes: [
|
|
{ dest: ['Stuttgart'], type: 'straight' },
|
|
{ dest: ['Wien'], type: 'straight' },
|
|
{ dest: ['München', 'Salzburg'], type: 'straight' }
|
|
]},
|
|
{ distance: 280, lanes: [
|
|
{ dest: ['Stuttgart'], type: 'straight' },
|
|
{ dest: ['Salzburg'], type: 'straight' },
|
|
{ dest: ['München'], type: 'exit' }
|
|
]}
|
|
],
|
|
hazard: { z: 200, lane: 2, kind: 'baustelle' },
|
|
exitDistance: 400, exitLane: 2
|
|
},
|
|
leipzig: {
|
|
target: 'Leipzig',
|
|
decisions: [
|
|
{ distance: 80, lanes: [
|
|
{ dest: ['Leipzig', 'Halle'], type: 'straight' },
|
|
{ dest: ['Berlin'], type: 'straight' },
|
|
{ dest: ['Dresden', 'Chemnitz'], type: 'straight' }
|
|
]},
|
|
{ distance: 280, lanes: [
|
|
{ dest: ['Halle'], type: 'straight' },
|
|
{ dest: ['Magdeburg'], type: 'straight' },
|
|
{ dest: ['Leipzig'], type: 'exit' }
|
|
]}
|
|
],
|
|
hazard: { z: 200, lane: 0, kind: 'baustelle' },
|
|
exitDistance: 400, exitLane: 2
|
|
},
|
|
verona: {
|
|
target: 'Verona',
|
|
decisions: [
|
|
{ distance: 80, lanes: [
|
|
{ dest: ['Brenner', 'Italien'], type: 'straight' },
|
|
{ dest: ['Bozen'], type: 'straight' },
|
|
{ dest: ['Verona', 'Trient'], type: 'straight' }
|
|
]},
|
|
{ distance: 280, lanes: [
|
|
{ dest: ['Brixen'], type: 'straight' },
|
|
{ dest: ['Trento'], type: 'straight' },
|
|
{ dest: ['Verona'], type: 'exit' }
|
|
]}
|
|
],
|
|
hazard: { z: 200, lane: 2, kind: 'unfall' },
|
|
exitDistance: 400, exitLane: 2
|
|
}
|
|
};
|
|
|
|
const CAR_COLORS = [
|
|
0xc1432e, 0x2e6cc1, 0xd6d6d6, 0x2a2a2a,
|
|
0xc1a02e, 0xc12e8c, 0x3f8c3f, 0xe2901c
|
|
];
|
|
|
|
// ============================================================
|
|
// CSS — alles unter .lg-aut-root, lg-aut- Klassen, --lg-aut-* Variablen
|
|
// ============================================================
|
|
const STYLE_CSS = `
|
|
.lg-aut-root {
|
|
--lg-aut-primary: #2d6d4d;
|
|
--lg-aut-primary-hover: #245840;
|
|
--lg-aut-accent: #e8c547;
|
|
--lg-aut-bg: #f5f3ea;
|
|
--lg-aut-card: #efe9d8;
|
|
--lg-aut-border: #c8c1a8;
|
|
--lg-aut-text: #2a2a2a;
|
|
--lg-aut-muted: #6a6258;
|
|
--lg-aut-warn: #b03a3a;
|
|
--lg-aut-radius: 12px;
|
|
--lg-aut-radius-sm: 8px;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
color: var(--lg-aut-text);
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-sizing: border-box;
|
|
padding: 12px;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
.lg-aut-root *, .lg-aut-root *::before, .lg-aut-root *::after { box-sizing: border-box; }
|
|
.lg-aut-card {
|
|
background: var(--lg-aut-card);
|
|
border-radius: var(--lg-aut-radius);
|
|
padding: 18px;
|
|
width: 100%;
|
|
max-width: 1100px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
.lg-aut-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.lg-aut-title {
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
color: var(--lg-aut-primary);
|
|
margin: 0;
|
|
line-height: 1.2;
|
|
}
|
|
.lg-aut-target {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
background: var(--lg-aut-bg);
|
|
border: 1px solid var(--lg-aut-border);
|
|
border-radius: var(--lg-aut-radius-sm);
|
|
padding: 8px 14px;
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
color: var(--lg-aut-primary);
|
|
}
|
|
.lg-aut-target-label {
|
|
font-size: 11px;
|
|
color: var(--lg-aut-muted);
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.6px;
|
|
}
|
|
.lg-aut-stage {
|
|
position: relative;
|
|
width: 100%;
|
|
aspect-ratio: 16 / 10;
|
|
max-height: 70vh;
|
|
background: linear-gradient(#a8d3ec 0%, #e1eaf2 60%, #6fa14d 60%, #6fa14d 100%);
|
|
border-radius: var(--lg-aut-radius-sm);
|
|
overflow: hidden;
|
|
border: 2px solid var(--lg-aut-primary);
|
|
}
|
|
.lg-aut-canvas {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
cursor: pointer;
|
|
touch-action: none;
|
|
}
|
|
.lg-aut-hud {
|
|
position: absolute;
|
|
top: 10px;
|
|
left: 12px;
|
|
right: 12px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
pointer-events: none;
|
|
gap: 8px;
|
|
}
|
|
.lg-aut-pill {
|
|
background: rgba(45, 109, 77, 0.92);
|
|
color: #f5f3ea;
|
|
padding: 6px 12px;
|
|
border-radius: 16px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
transition: background 0.15s;
|
|
}
|
|
.lg-aut-pill b { color: #ffffff; font-weight: 700; }
|
|
.lg-aut-pill.lg-aut-warn { background: rgba(176, 58, 58, 0.94); color: #ffffff; }
|
|
.lg-aut-pill.lg-aut-go {
|
|
background: rgba(232, 197, 71, 0.96);
|
|
color: #2a2a2a;
|
|
animation: lgAutPulse 0.45s infinite alternate;
|
|
}
|
|
.lg-aut-pill.lg-aut-go b { color: #2a2a2a; }
|
|
@keyframes lgAutPulse {
|
|
from { transform: scale(1); }
|
|
to { transform: scale(1.12); }
|
|
}
|
|
.lg-aut-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: rgba(245, 243, 234, 0.97);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 20px;
|
|
text-align: center;
|
|
gap: 10px;
|
|
transition: opacity 0.3s;
|
|
}
|
|
.lg-aut-overlay-hidden { opacity: 0; pointer-events: none; }
|
|
.lg-aut-overlay-h {
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
color: var(--lg-aut-primary);
|
|
margin: 0;
|
|
}
|
|
.lg-aut-overlay-p {
|
|
font-size: 14px;
|
|
color: var(--lg-aut-muted);
|
|
margin: 0;
|
|
line-height: 1.55;
|
|
max-width: 420px;
|
|
}
|
|
.lg-aut-overlay-p b { color: var(--lg-aut-text); }
|
|
.lg-aut-overlay.lg-aut-win .lg-aut-overlay-h { color: var(--lg-aut-primary); }
|
|
.lg-aut-overlay.lg-aut-lose .lg-aut-overlay-h { color: var(--lg-aut-warn); }
|
|
.lg-aut-controls {
|
|
display: grid;
|
|
grid-template-columns: 1fr 0.95fr 1fr;
|
|
gap: 8px;
|
|
}
|
|
.lg-aut-btn {
|
|
padding: 14px 12px;
|
|
min-height: 44px;
|
|
border-radius: var(--lg-aut-radius-sm);
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
border: 1.5px solid var(--lg-aut-border);
|
|
background: var(--lg-aut-bg);
|
|
color: var(--lg-aut-primary);
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
touch-action: manipulation;
|
|
transition: background 0.12s, transform 0.06s;
|
|
}
|
|
.lg-aut-btn:hover { background: var(--lg-aut-card); }
|
|
.lg-aut-btn:active, .lg-aut-btn-active {
|
|
transform: scale(0.98);
|
|
background: var(--lg-aut-warn);
|
|
color: #ffffff;
|
|
border-color: var(--lg-aut-warn);
|
|
}
|
|
.lg-aut-btn-pri {
|
|
background: var(--lg-aut-primary);
|
|
color: var(--lg-aut-bg);
|
|
border-color: var(--lg-aut-primary);
|
|
padding: 12px 24px;
|
|
font-size: 15px;
|
|
}
|
|
.lg-aut-btn-pri:hover { background: var(--lg-aut-primary-hover); border-color: var(--lg-aut-primary-hover); }
|
|
.lg-aut-hint {
|
|
font-style: italic;
|
|
font-size: 12px;
|
|
color: var(--lg-aut-muted);
|
|
text-align: center;
|
|
margin: 0;
|
|
}
|
|
.lg-aut-kbd {
|
|
display: inline-block;
|
|
background: var(--lg-aut-bg);
|
|
border: 1px solid var(--lg-aut-border);
|
|
border-radius: 4px;
|
|
padding: 0 6px;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
font-size: 12px;
|
|
color: var(--lg-aut-text);
|
|
min-width: 12px;
|
|
text-align: center;
|
|
}
|
|
`;
|
|
|
|
// ============================================================
|
|
// Three.js-Helfer (zeichnen Schilder, bauen Szene)
|
|
// ============================================================
|
|
|
|
function makeRoadTexture(THREE) {
|
|
const c = document.createElement('canvas');
|
|
c.width = 256;
|
|
c.height = 1024;
|
|
const x = c.getContext('2d');
|
|
x.fillStyle = '#3e3e3e';
|
|
x.fillRect(0, 0, 256, 1024);
|
|
x.fillStyle = '#ffffff';
|
|
x.fillRect(2, 0, 4, 1024);
|
|
x.fillRect(250, 0, 4, 1024);
|
|
const x1 = Math.round(256 / 3) - 2;
|
|
const x2 = Math.round(2 * 256 / 3) - 2;
|
|
for (let y = 0; y < 1024; y += 128) {
|
|
x.fillRect(x1, y, 4, 64);
|
|
x.fillRect(x2, y, 4, 64);
|
|
}
|
|
const t = new THREE.CanvasTexture(c);
|
|
t.wrapS = THREE.ClampToEdgeWrapping;
|
|
t.wrapT = THREE.RepeatWrapping;
|
|
t.repeat.set(1, 80);
|
|
t.anisotropy = 8;
|
|
return t;
|
|
}
|
|
|
|
function createTruck(THREE, cabColor, trailerColor) {
|
|
const g = new THREE.Group();
|
|
const cabMat = new THREE.MeshLambertMaterial({ color: cabColor });
|
|
const trailerMat = new THREE.MeshLambertMaterial({ color: trailerColor });
|
|
const blackMat = new THREE.MeshLambertMaterial({ color: 0x222222 });
|
|
const glassMat = new THREE.MeshBasicMaterial({ color: 0x1a2838 });
|
|
|
|
const cab = new THREE.Mesh(new THREE.BoxGeometry(2.4, 2.4, 2.3), cabMat);
|
|
cab.position.set(0, 1.7, 4.0);
|
|
g.add(cab);
|
|
const cabRoof = new THREE.Mesh(new THREE.BoxGeometry(2.3, 0.7, 2.2), cabMat);
|
|
cabRoof.position.set(0, 3.25, 4.0);
|
|
g.add(cabRoof);
|
|
const grille = new THREE.Mesh(new THREE.BoxGeometry(2.3, 1.2, 0.2), blackMat);
|
|
grille.position.set(0, 1.3, 5.16);
|
|
g.add(grille);
|
|
const ws = new THREE.Mesh(new THREE.PlaneGeometry(2.0, 1.0), glassMat);
|
|
ws.position.set(0, 2.5, 5.16);
|
|
g.add(ws);
|
|
[-0.85, 0.85].forEach(dx => {
|
|
const hl = new THREE.Mesh(
|
|
new THREE.BoxGeometry(0.5, 0.3, 0.1),
|
|
new THREE.MeshBasicMaterial({ color: 0xfff8d0 })
|
|
);
|
|
hl.position.set(dx, 1.0, 5.18);
|
|
g.add(hl);
|
|
});
|
|
const trailer = new THREE.Mesh(new THREE.BoxGeometry(2.5, 2.85, 9), trailerMat);
|
|
trailer.position.set(0, 1.95, -1.5);
|
|
g.add(trailer);
|
|
const trailerEdge = new THREE.Mesh(new THREE.BoxGeometry(2.55, 0.1, 9.05), blackMat);
|
|
trailerEdge.position.set(0, 0.55, -1.5);
|
|
g.add(trailerEdge);
|
|
const brakeLight = new THREE.Mesh(
|
|
new THREE.BoxGeometry(2.6, 0.3, 0.05),
|
|
new THREE.MeshBasicMaterial({ color: 0x550000 })
|
|
);
|
|
brakeLight.position.set(0, 1.0, -6.05);
|
|
g.add(brakeLight);
|
|
g.userData.brakeLight = brakeLight;
|
|
|
|
const wg = new THREE.CylinderGeometry(0.55, 0.55, 0.4, 12);
|
|
const wpos = [
|
|
[-1.25, 0.55, 4.7], [ 1.25, 0.55, 4.7],
|
|
[-1.30, 0.55, 1.5], [ 1.30, 0.55, 1.5],
|
|
[-1.30, 0.55, -3.5], [ 1.30, 0.55, -3.5],
|
|
[-1.30, 0.55, -4.8], [ 1.30, 0.55, -4.8]
|
|
];
|
|
wpos.forEach(p => {
|
|
const w = new THREE.Mesh(wg, blackMat);
|
|
w.position.set(p[0], p[1], p[2]);
|
|
w.rotation.z = Math.PI / 2;
|
|
g.add(w);
|
|
});
|
|
return g;
|
|
}
|
|
|
|
function createCar(THREE, color) {
|
|
const g = new THREE.Group();
|
|
const mat = new THREE.MeshLambertMaterial({ color });
|
|
const blackMat = new THREE.MeshLambertMaterial({ color: 0x111111 });
|
|
const glassMat = new THREE.MeshBasicMaterial({ color: 0x1a2838 });
|
|
const body = new THREE.Mesh(new THREE.BoxGeometry(1.85, 0.85, 4.4), mat);
|
|
body.position.y = 0.65;
|
|
g.add(body);
|
|
const cabin = new THREE.Mesh(new THREE.BoxGeometry(1.78, 0.8, 2.2), mat);
|
|
cabin.position.set(0, 1.45, -0.2);
|
|
g.add(cabin);
|
|
const winF = new THREE.Mesh(new THREE.PlaneGeometry(1.7, 0.7), glassMat);
|
|
winF.position.set(0, 1.45, 0.92);
|
|
g.add(winF);
|
|
const winB = new THREE.Mesh(new THREE.PlaneGeometry(1.7, 0.7), glassMat);
|
|
winB.position.set(0, 1.45, -1.32);
|
|
winB.rotation.y = Math.PI;
|
|
g.add(winB);
|
|
const wg = new THREE.CylinderGeometry(0.32, 0.32, 0.3, 10);
|
|
[[-1, 0.32, 1.4], [1, 0.32, 1.4], [-1, 0.32, -1.4], [1, 0.32, -1.4]].forEach(p => {
|
|
const w = new THREE.Mesh(wg, blackMat);
|
|
w.position.set(p[0], p[1], p[2]);
|
|
w.rotation.z = Math.PI / 2;
|
|
g.add(w);
|
|
});
|
|
return g;
|
|
}
|
|
|
|
function createTree(THREE) {
|
|
const g = new THREE.Group();
|
|
const trunk = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(0.25, 0.35, 1.2, 6),
|
|
new THREE.MeshLambertMaterial({ color: 0x6b4423 })
|
|
);
|
|
trunk.position.y = 0.6;
|
|
g.add(trunk);
|
|
const top = new THREE.Mesh(
|
|
new THREE.ConeGeometry(1.4, 3.6, 7),
|
|
new THREE.MeshLambertMaterial({ color: 0x2e6e2a })
|
|
);
|
|
top.position.y = 3.0;
|
|
g.add(top);
|
|
return g;
|
|
}
|
|
|
|
function createWindTurbine(THREE) {
|
|
const g = new THREE.Group();
|
|
const mat = new THREE.MeshLambertMaterial({ color: 0xfafafa });
|
|
const tower = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 1.1, 18, 8), mat);
|
|
tower.position.y = 9;
|
|
g.add(tower);
|
|
const nacelle = new THREE.Mesh(new THREE.BoxGeometry(1.4, 1.5, 3.2), mat);
|
|
nacelle.position.set(0, 18.7, 0);
|
|
g.add(nacelle);
|
|
const hub = new THREE.Mesh(new THREE.SphereGeometry(0.6, 8, 6), mat);
|
|
hub.position.set(0, 18.7, 1.7);
|
|
g.add(hub);
|
|
const blades = new THREE.Group();
|
|
blades.position.set(0, 18.7, 1.85);
|
|
for (let i = 0; i < 3; i++) {
|
|
const blade = new THREE.Mesh(new THREE.BoxGeometry(0.3, 9, 0.5), mat);
|
|
blade.position.y = 4.5;
|
|
const bg = new THREE.Group();
|
|
bg.add(blade);
|
|
bg.rotation.z = (i * 2 * Math.PI) / 3;
|
|
blades.add(bg);
|
|
}
|
|
g.add(blades);
|
|
g.userData.blades = blades;
|
|
return g;
|
|
}
|
|
|
|
function createMountains(THREE) {
|
|
const g = new THREE.Group();
|
|
const mat = new THREE.MeshLambertMaterial({ color: 0x6f8aa8 });
|
|
for (let i = 0; i < 12; i++) {
|
|
const h = 25 + Math.random() * 22;
|
|
const r = 28 + Math.random() * 20;
|
|
const cone = new THREE.Mesh(new THREE.ConeGeometry(r, h, 5), mat);
|
|
const sx = Math.random() < 0.5 ? -1 : 1;
|
|
cone.position.set(sx * (70 + Math.random() * 90), h / 2 - 5, 130 + Math.random() * 280);
|
|
cone.rotation.y = Math.random() * Math.PI;
|
|
g.add(cone);
|
|
}
|
|
return g;
|
|
}
|
|
|
|
function createTunnel(THREE, zPos) {
|
|
const g = new THREE.Group();
|
|
const rockMat = new THREE.MeshLambertMaterial({ color: 0x6b7d92 });
|
|
const main = new THREE.Mesh(new THREE.ConeGeometry(42, 42, 8), rockMat);
|
|
main.position.set(0, 18, zPos + 38);
|
|
g.add(main);
|
|
const left = new THREE.Mesh(new THREE.ConeGeometry(28, 32, 6), rockMat);
|
|
left.position.set(-32, 13, zPos + 28);
|
|
g.add(left);
|
|
const right = new THREE.Mesh(new THREE.ConeGeometry(30, 30, 6), rockMat);
|
|
right.position.set(34, 12, zPos + 32);
|
|
g.add(right);
|
|
const ps = new THREE.Shape();
|
|
ps.moveTo(-7.5, 0);
|
|
ps.lineTo(-7.5, 4.8);
|
|
ps.bezierCurveTo(-7.5, 8.5, 7.5, 8.5, 7.5, 4.8);
|
|
ps.lineTo(7.5, 0);
|
|
ps.lineTo(-7.5, 0);
|
|
const portal = new THREE.Mesh(
|
|
new THREE.ShapeGeometry(ps),
|
|
new THREE.MeshBasicMaterial({ color: 0x0a0a14, side: THREE.DoubleSide })
|
|
);
|
|
portal.position.set(0, 0, zPos);
|
|
portal.rotation.y = Math.PI;
|
|
g.add(portal);
|
|
return g;
|
|
}
|
|
|
|
function drawSignTexture(canvas, lanes) {
|
|
const ctx = canvas.getContext('2d');
|
|
const W = canvas.width;
|
|
const H = canvas.height;
|
|
ctx.fillStyle = '#0d4dab';
|
|
ctx.fillRect(0, 0, W, H);
|
|
ctx.strokeStyle = '#ffffff';
|
|
ctx.lineWidth = 6;
|
|
ctx.strokeRect(8, 8, W - 16, H - 16);
|
|
const laneW = W / lanes.length;
|
|
lanes.forEach((lane, i) => {
|
|
const x0 = i * laneW;
|
|
if (i > 0) {
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(x0 - 2.5, 16, 5, H - 32);
|
|
}
|
|
const cx = x0 + laneW / 2;
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.strokeStyle = '#ffffff';
|
|
if (lane.type === 'exit') {
|
|
ctx.lineWidth = 18;
|
|
ctx.lineCap = 'round';
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, 35);
|
|
ctx.lineTo(cx, 90);
|
|
ctx.quadraticCurveTo(cx, 130, cx + 80, 130);
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx + 110, 130);
|
|
ctx.lineTo(cx + 70, 100);
|
|
ctx.lineTo(cx + 70, 160);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
} else {
|
|
ctx.fillRect(cx - 11, 35, 22, 80);
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, 165);
|
|
ctx.lineTo(cx - 42, 115);
|
|
ctx.lineTo(cx + 42, 115);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
const dests = lane.dest;
|
|
const fs = dests.length > 1 ? 54 : 64;
|
|
ctx.font = '700 ' + fs + 'px Helvetica, Arial, sans-serif';
|
|
const lineH = fs + 14;
|
|
const baseY = H - 50 - (dests.length - 1) * lineH;
|
|
dests.forEach((d, di) => ctx.fillText(d, cx, baseY + di * lineH));
|
|
});
|
|
}
|
|
|
|
function createGantry(THREE, decision) {
|
|
const g = new THREE.Group();
|
|
const PILLAR_H = 7.5;
|
|
const PILLAR_X = ROAD_W / 2 + 1.5;
|
|
const SIGN_W = ROAD_W;
|
|
const SIGN_H = 3.6;
|
|
const metalMat = new THREE.MeshLambertMaterial({ color: 0xa8a8a8 });
|
|
[-1, 1].forEach(s => {
|
|
const p = new THREE.Mesh(new THREE.BoxGeometry(0.4, PILLAR_H, 0.4), metalMat);
|
|
p.position.set(s * PILLAR_X, PILLAR_H / 2, 0);
|
|
g.add(p);
|
|
});
|
|
const cb = new THREE.Mesh(new THREE.BoxGeometry(2 * PILLAR_X + 0.4, 0.5, 0.4), metalMat);
|
|
cb.position.set(0, PILLAR_H, 0);
|
|
g.add(cb);
|
|
const cnv = document.createElement('canvas');
|
|
cnv.width = 1200;
|
|
cnv.height = 384;
|
|
drawSignTexture(cnv, decision.lanes);
|
|
const tex = new THREE.CanvasTexture(cnv);
|
|
tex.anisotropy = 8;
|
|
const panelY = PILLAR_H - SIGN_H / 2 - 0.4;
|
|
const front = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(SIGN_W, SIGN_H),
|
|
new THREE.MeshBasicMaterial({ map: tex })
|
|
);
|
|
front.position.set(0, panelY, -0.05);
|
|
front.rotation.y = Math.PI;
|
|
g.add(front);
|
|
const back = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(SIGN_W, SIGN_H),
|
|
new THREE.MeshBasicMaterial({ color: 0x6e6e6e })
|
|
);
|
|
back.position.set(0, panelY, 0.05);
|
|
g.add(back);
|
|
return g;
|
|
}
|
|
|
|
function drawBaustelleSign(canvas) {
|
|
const ctx = canvas.getContext('2d');
|
|
const W = canvas.width;
|
|
const H = canvas.height;
|
|
ctx.clearRect(0, 0, W, H);
|
|
const cx = W / 2;
|
|
const top = 18;
|
|
const bot = H - 18;
|
|
const halfBase = (bot - top) / Math.sqrt(3);
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, top);
|
|
ctx.lineTo(cx + halfBase, bot);
|
|
ctx.lineTo(cx - halfBase, bot);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#c30c20';
|
|
ctx.lineWidth = 18;
|
|
ctx.lineJoin = 'round';
|
|
ctx.stroke();
|
|
ctx.fillStyle = '#000000';
|
|
ctx.beginPath();
|
|
ctx.arc(95, 132, 13, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.moveTo(82, 142);
|
|
ctx.lineTo(118, 162);
|
|
ctx.lineTo(126, 178);
|
|
ctx.lineTo(110, 182);
|
|
ctx.lineTo(76, 156);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.moveTo(102, 178);
|
|
ctx.lineTo(100, 210);
|
|
ctx.lineTo(86, 210);
|
|
ctx.lineTo(92, 178);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.moveTo(118, 178);
|
|
ctx.lineTo(122, 210);
|
|
ctx.lineTo(108, 210);
|
|
ctx.lineTo(108, 178);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#000000';
|
|
ctx.lineWidth = 5;
|
|
ctx.lineCap = 'round';
|
|
ctx.beginPath();
|
|
ctx.moveTo(124, 168);
|
|
ctx.lineTo(168, 200);
|
|
ctx.stroke();
|
|
ctx.save();
|
|
ctx.translate(168, 200);
|
|
ctx.rotate(0.6);
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(22, -4);
|
|
ctx.lineTo(28, 14);
|
|
ctx.lineTo(6, 18);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.restore();
|
|
ctx.beginPath();
|
|
ctx.moveTo(155, 215);
|
|
ctx.quadraticCurveTo(190, 188, 220, 215);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
|
|
function drawAchtungSign(canvas) {
|
|
const ctx = canvas.getContext('2d');
|
|
const W = canvas.width;
|
|
const H = canvas.height;
|
|
ctx.clearRect(0, 0, W, H);
|
|
const cx = W / 2;
|
|
const top = 18;
|
|
const bot = H - 18;
|
|
const halfBase = (bot - top) / Math.sqrt(3);
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, top);
|
|
ctx.lineTo(cx + halfBase, bot);
|
|
ctx.lineTo(cx - halfBase, bot);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#c30c20';
|
|
ctx.lineWidth = 18;
|
|
ctx.lineJoin = 'round';
|
|
ctx.stroke();
|
|
ctx.fillStyle = '#000000';
|
|
ctx.fillRect(cx - 9, 95, 18, 78);
|
|
ctx.beginPath();
|
|
ctx.arc(cx, 198, 12, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
function createWarningSign(THREE, kind, x, z) {
|
|
const g = new THREE.Group();
|
|
const cnv = document.createElement('canvas');
|
|
cnv.width = 256;
|
|
cnv.height = 256;
|
|
if (kind === 'baustelle') drawBaustelleSign(cnv);
|
|
else drawAchtungSign(cnv);
|
|
const tex = new THREE.CanvasTexture(cnv);
|
|
tex.anisotropy = 8;
|
|
const sign = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(2.4, 2.4),
|
|
new THREE.MeshBasicMaterial({ map: tex, transparent: true, alphaTest: 0.5, side: THREE.DoubleSide })
|
|
);
|
|
sign.position.set(x, 3.0, z);
|
|
sign.rotation.y = Math.PI;
|
|
g.add(sign);
|
|
const post = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(0.07, 0.07, 3.5, 6),
|
|
new THREE.MeshLambertMaterial({ color: 0x9a9a9a })
|
|
);
|
|
post.position.set(x, 1.75, z);
|
|
g.add(post);
|
|
return g;
|
|
}
|
|
|
|
function drawPfeilTafel(canvas, direction) {
|
|
const ctx = canvas.getContext('2d');
|
|
const W = canvas.width;
|
|
const H = canvas.height;
|
|
ctx.fillStyle = '#ffd000';
|
|
ctx.fillRect(0, 0, W, H);
|
|
ctx.strokeStyle = '#000000';
|
|
ctx.lineWidth = 14;
|
|
ctx.strokeRect(7, 7, W - 14, H - 14);
|
|
ctx.fillStyle = '#000000';
|
|
const cy = H / 2;
|
|
const aw = 100;
|
|
const ah = 130;
|
|
const positions = [-220, -75, 75, 220];
|
|
for (const dxOff of positions) {
|
|
const cx = W / 2 + dxOff;
|
|
ctx.beginPath();
|
|
if (direction === 'right') {
|
|
ctx.moveTo(cx - aw / 2, cy - ah / 2);
|
|
ctx.lineTo(cx + aw / 2, cy);
|
|
ctx.lineTo(cx - aw / 2, cy + ah / 2);
|
|
ctx.lineTo(cx - aw / 2 + 32, cy + ah / 2);
|
|
ctx.lineTo(cx + aw / 2 + 32, cy);
|
|
ctx.lineTo(cx - aw / 2 + 32, cy - ah / 2);
|
|
} else {
|
|
ctx.moveTo(cx + aw / 2, cy - ah / 2);
|
|
ctx.lineTo(cx - aw / 2, cy);
|
|
ctx.lineTo(cx + aw / 2, cy + ah / 2);
|
|
ctx.lineTo(cx + aw / 2 - 32, cy + ah / 2);
|
|
ctx.lineTo(cx - aw / 2 - 32, cy);
|
|
ctx.lineTo(cx + aw / 2 - 32, cy - ah / 2);
|
|
}
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
function createPfeilTafel(THREE, lane, zPos) {
|
|
const g = new THREE.Group();
|
|
const laneX = laneCenter(lane);
|
|
const dir = lane === 0 ? 'right' : 'left';
|
|
const cnv = document.createElement('canvas');
|
|
cnv.width = 1024;
|
|
cnv.height = 256;
|
|
drawPfeilTafel(cnv, dir);
|
|
const tex = new THREE.CanvasTexture(cnv);
|
|
tex.anisotropy = 8;
|
|
const sign = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(3.4, 0.85),
|
|
new THREE.MeshBasicMaterial({ map: tex, side: THREE.DoubleSide })
|
|
);
|
|
sign.position.set(laneX, 1.6, zPos);
|
|
sign.rotation.y = Math.PI;
|
|
g.add(sign);
|
|
const postMat = new THREE.MeshLambertMaterial({ color: 0x666666 });
|
|
[-1.5, 1.5].forEach(dx => {
|
|
const p = new THREE.Mesh(new THREE.BoxGeometry(0.1, 1.6, 0.1), postMat);
|
|
p.position.set(laneX + dx, 0.8, zPos);
|
|
g.add(p);
|
|
});
|
|
return g;
|
|
}
|
|
|
|
function createHazard(THREE, lane, zPos, kind) {
|
|
const g = new THREE.Group();
|
|
const laneX = laneCenter(lane);
|
|
g.add(createWarningSign(THREE, kind, -(ROAD_W / 2 + SHOULDER_W + 0.6), zPos - 70));
|
|
g.add(createPfeilTafel(THREE, lane, zPos - 14));
|
|
const coneMat = new THREE.MeshLambertMaterial({ color: 0xff5b1f });
|
|
const stripeMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
|
for (let i = 0; i < 10; i++) {
|
|
const cone = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.85, 8), coneMat);
|
|
const dx = (i % 2 === 0) ? -LANE_W * 0.32 : LANE_W * 0.32;
|
|
cone.position.set(laneX + dx, 0.42, zPos - 8 + i * 2.2);
|
|
g.add(cone);
|
|
const band = new THREE.Mesh(new THREE.CylinderGeometry(0.21, 0.24, 0.12, 8), stripeMat);
|
|
band.position.set(laneX + dx, 0.55, zPos - 8 + i * 2.2);
|
|
g.add(band);
|
|
}
|
|
if (kind === 'unfall') {
|
|
const carBody = new THREE.Mesh(
|
|
new THREE.BoxGeometry(1.85, 0.85, 4.4),
|
|
new THREE.MeshLambertMaterial({ color: 0x333333 })
|
|
);
|
|
carBody.position.set(laneX + 0.4, 0.55, zPos + 8);
|
|
carBody.rotation.y = 0.4;
|
|
g.add(carBody);
|
|
const cabin = new THREE.Mesh(
|
|
new THREE.BoxGeometry(1.78, 0.7, 2.2),
|
|
new THREE.MeshLambertMaterial({ color: 0x333333 })
|
|
);
|
|
cabin.position.set(laneX + 0.4, 1.3, zPos + 8);
|
|
cabin.rotation.y = 0.4;
|
|
g.add(cabin);
|
|
}
|
|
return g;
|
|
}
|
|
|
|
function buildExitRamp(THREE, exitZ, exitLaneIdx) {
|
|
const SEG_CURVE = 14;
|
|
const SEG_STRAIGHT = 8;
|
|
const STRAIGHT_LEN = 230;
|
|
const TRUCK_STOP = 100;
|
|
const startX = laneCenter(exitLaneIdx);
|
|
const startZ = exitZ - EXIT_LEAD;
|
|
const turnX = startX - 22;
|
|
const turnZ = exitZ + 70;
|
|
const cpX = startX;
|
|
const cpZ = startZ + 80;
|
|
const points = [];
|
|
for (let i = 0; i <= SEG_CURVE; i++) {
|
|
const t = i / SEG_CURVE;
|
|
const u = 1 - t;
|
|
points.push({
|
|
x: u * u * startX + 2 * u * t * cpX + t * t * turnX,
|
|
z: u * u * startZ + 2 * u * t * cpZ + t * t * turnZ
|
|
});
|
|
}
|
|
const tdx = turnX - cpX;
|
|
const tdz = turnZ - cpZ;
|
|
const tlen = Math.sqrt(tdx * tdx + tdz * tdz);
|
|
const utx = tdx / tlen;
|
|
const utz = tdz / tlen;
|
|
for (let i = 1; i <= SEG_STRAIGHT; i++) {
|
|
const t = i / SEG_STRAIGHT;
|
|
points.push({
|
|
x: turnX + utx * STRAIGHT_LEN * t,
|
|
z: turnZ + utz * STRAIGHT_LEN * t
|
|
});
|
|
}
|
|
const verts = [];
|
|
const indices = [];
|
|
const tans = [];
|
|
const halfW = LANE_W / 2;
|
|
for (let i = 0; i < points.length; i++) {
|
|
let dx, dz;
|
|
if (i === 0) {
|
|
dx = points[1].x - points[0].x;
|
|
dz = points[1].z - points[0].z;
|
|
} else if (i === points.length - 1) {
|
|
dx = points[i].x - points[i - 1].x;
|
|
dz = points[i].z - points[i - 1].z;
|
|
} else {
|
|
dx = points[i + 1].x - points[i - 1].x;
|
|
dz = points[i + 1].z - points[i - 1].z;
|
|
}
|
|
const len = Math.sqrt(dx * dx + dz * dz);
|
|
tans.push({ dx: dx / len, dz: dz / len });
|
|
const nx = -dz / len;
|
|
const nz = dx / len;
|
|
verts.push(points[i].x + nx * halfW, 0.04, points[i].z + nz * halfW);
|
|
verts.push(points[i].x - nx * halfW, 0.04, points[i].z - nz * halfW);
|
|
}
|
|
for (let i = 0; i < points.length - 1; i++) {
|
|
const a = i * 2;
|
|
indices.push(a, a + 1, a + 2);
|
|
indices.push(a + 1, a + 3, a + 2);
|
|
}
|
|
const geom = new THREE.BufferGeometry();
|
|
geom.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
|
|
geom.setIndex(indices);
|
|
geom.computeVertexNormals();
|
|
const mesh = new THREE.Mesh(
|
|
geom,
|
|
new THREE.MeshLambertMaterial({ color: 0x404040, side: THREE.DoubleSide })
|
|
);
|
|
const centerline = points.map((p, i) => ({
|
|
x: p.x,
|
|
z: p.z,
|
|
angle: Math.atan2(tans[i].dx, tans[i].dz)
|
|
}));
|
|
const cum = [0];
|
|
for (let i = 1; i < centerline.length; i++) {
|
|
const dx = centerline[i].x - centerline[i - 1].x;
|
|
const dz = centerline[i].z - centerline[i - 1].z;
|
|
cum.push(cum[i - 1] + Math.sqrt(dx * dx + dz * dz));
|
|
}
|
|
return {
|
|
mesh,
|
|
centerline,
|
|
cum,
|
|
totalLength: cum[cum.length - 1],
|
|
truckStop: TRUCK_STOP
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// Game-Klasse
|
|
// ============================================================
|
|
class Game {
|
|
constructor(opts) {
|
|
this.THREE = window.THREE;
|
|
this.container = opts.container;
|
|
this.scenarioKey = opts.scenario;
|
|
this.scenarioData = SCENARIOS[opts.scenario];
|
|
this.onEnd = opts.onEnd;
|
|
this.driverName = opts.driverName || '';
|
|
this.truckLabel = opts.truckLabel || '';
|
|
this.easyMode = !!opts.easyMode;
|
|
this.truckSpeed = this.easyMode ? TRUCK_SPEED_EASY : TRUCK_SPEED_NORMAL;
|
|
|
|
this.ended = false;
|
|
this.listeners = [];
|
|
this.disposables = [];
|
|
this.startedAt = 0;
|
|
this.laneChangeCount = 0;
|
|
this.brakeUsed = false;
|
|
this.rafId = null;
|
|
this.lastFrameTime = 0;
|
|
|
|
this.state = {
|
|
phase: 'idle',
|
|
position: 0,
|
|
targetLane: 1,
|
|
signs: [],
|
|
vehicles: [],
|
|
exitChecked: false,
|
|
speedFactor: 1,
|
|
exitProgress: 0,
|
|
hazardPassed: false,
|
|
failReason: null,
|
|
braking: false,
|
|
laneChange: null,
|
|
debris: [],
|
|
crashElapsed: 0
|
|
};
|
|
|
|
this.buildDom();
|
|
this.initThree();
|
|
this.setupInput();
|
|
}
|
|
|
|
addListener(target, event, handler, options) {
|
|
target.addEventListener(event, handler, options);
|
|
this.listeners.push({ target, event, handler, options });
|
|
}
|
|
|
|
removeAllListeners() {
|
|
this.listeners.forEach(l => {
|
|
try { l.target.removeEventListener(l.event, l.handler, l.options); }
|
|
catch (e) { /* ignore */ }
|
|
});
|
|
this.listeners = [];
|
|
}
|
|
|
|
buildDom() {
|
|
const c = this.container;
|
|
// Style-Element als erstes Kind in den Container — verschwindet, wenn Logistik den Container entfernt
|
|
const style = document.createElement('style');
|
|
style.textContent = STYLE_CSS;
|
|
c.appendChild(style);
|
|
|
|
const root = document.createElement('div');
|
|
root.className = 'lg-aut-root';
|
|
this.root = root;
|
|
c.appendChild(root);
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'lg-aut-card';
|
|
root.appendChild(card);
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'lg-aut-header';
|
|
const title = document.createElement('h3');
|
|
title.className = 'lg-aut-title';
|
|
title.textContent = 'Autobahn-Navigator';
|
|
header.appendChild(title);
|
|
const target = document.createElement('div');
|
|
target.className = 'lg-aut-target';
|
|
const tLabel = document.createElement('span');
|
|
tLabel.className = 'lg-aut-target-label';
|
|
tLabel.textContent = 'Ihr Ziel';
|
|
const tName = document.createElement('span');
|
|
tName.textContent = this.scenarioData.target;
|
|
target.appendChild(tLabel);
|
|
target.appendChild(tName);
|
|
header.appendChild(target);
|
|
card.appendChild(header);
|
|
|
|
const stage = document.createElement('div');
|
|
stage.className = 'lg-aut-stage';
|
|
this.stage = stage;
|
|
const canvas = document.createElement('canvas');
|
|
canvas.className = 'lg-aut-canvas';
|
|
this.canvas = canvas;
|
|
stage.appendChild(canvas);
|
|
|
|
const hud = document.createElement('div');
|
|
hud.className = 'lg-aut-hud';
|
|
const lanePill = document.createElement('div');
|
|
lanePill.className = 'lg-aut-pill';
|
|
lanePill.innerHTML = 'Spur <b>2</b>/3';
|
|
this.lanePill = lanePill;
|
|
this.laneNum = lanePill.querySelector('b');
|
|
const statusPill = document.createElement('div');
|
|
statusPill.className = 'lg-aut-pill';
|
|
this.statusPill = statusPill;
|
|
this.statusText = document.createElement('span');
|
|
this.statusText.textContent = '—';
|
|
statusPill.appendChild(this.statusText);
|
|
hud.appendChild(lanePill);
|
|
hud.appendChild(statusPill);
|
|
stage.appendChild(hud);
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'lg-aut-overlay';
|
|
this.overlay = overlay;
|
|
const oh = document.createElement('h4');
|
|
oh.className = 'lg-aut-overlay-h';
|
|
oh.textContent = 'Bereit für die Tour?';
|
|
const op = document.createElement('p');
|
|
op.className = 'lg-aut-overlay-p';
|
|
op.innerHTML = 'Schild → Spur → Vorwarnung → ausweichen → bremsen wenn nötig → Ausfahrt aktiv nach rechts.';
|
|
const startBtn = document.createElement('button');
|
|
startBtn.className = 'lg-aut-btn lg-aut-btn-pri';
|
|
startBtn.textContent = 'Los geht\'s';
|
|
this.overlayH = oh;
|
|
this.overlayP = op;
|
|
this.startBtn = startBtn;
|
|
overlay.appendChild(oh);
|
|
overlay.appendChild(op);
|
|
overlay.appendChild(startBtn);
|
|
stage.appendChild(overlay);
|
|
|
|
card.appendChild(stage);
|
|
|
|
const controls = document.createElement('div');
|
|
controls.className = 'lg-aut-controls';
|
|
const leftBtn = document.createElement('button');
|
|
leftBtn.className = 'lg-aut-btn';
|
|
leftBtn.textContent = '← Spur links';
|
|
const brakeBtn = document.createElement('button');
|
|
brakeBtn.className = 'lg-aut-btn';
|
|
brakeBtn.textContent = '⊝ Bremsen';
|
|
const rightBtn = document.createElement('button');
|
|
rightBtn.className = 'lg-aut-btn';
|
|
rightBtn.textContent = 'Spur rechts →';
|
|
this.leftBtn = leftBtn;
|
|
this.brakeBtn = brakeBtn;
|
|
this.rightBtn = rightBtn;
|
|
controls.appendChild(leftBtn);
|
|
controls.appendChild(brakeBtn);
|
|
controls.appendChild(rightBtn);
|
|
card.appendChild(controls);
|
|
|
|
const hint = document.createElement('p');
|
|
hint.className = 'lg-aut-hint';
|
|
hint.innerHTML = 'Auch per <span class="lg-aut-kbd">←</span> <span class="lg-aut-kbd">↓</span> <span class="lg-aut-kbd">→</span> oder Tippen.';
|
|
card.appendChild(hint);
|
|
}
|
|
|
|
initThree() {
|
|
const THREE = this.THREE;
|
|
const renderer = new THREE.WebGLRenderer({ canvas: this.canvas, antialias: true });
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
renderer.setClearColor(0xa8d3ec);
|
|
this.renderer = renderer;
|
|
this.disposables.push(renderer);
|
|
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0xa8d3ec);
|
|
scene.fog = new THREE.Fog(0xc8dde9, 240, 600);
|
|
this.scene = scene;
|
|
|
|
const camera = new THREE.PerspectiveCamera(58, 16 / 10, 0.5, 720);
|
|
camera.position.set(0, 5.8, -13.5);
|
|
camera.lookAt(0, 2.8, 28);
|
|
this.camera = camera;
|
|
|
|
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
|
|
const sun = new THREE.DirectionalLight(0xfff5d6, 0.9);
|
|
sun.position.set(40, 80, 30);
|
|
scene.add(sun);
|
|
|
|
const ground = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(900, 4000),
|
|
new THREE.MeshLambertMaterial({ color: 0x6fa14d })
|
|
);
|
|
ground.rotation.x = -Math.PI / 2;
|
|
ground.position.set(0, 0, 1500);
|
|
scene.add(ground);
|
|
|
|
const road = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(ROAD_W, 4000),
|
|
new THREE.MeshLambertMaterial({ map: makeRoadTexture(THREE) })
|
|
);
|
|
road.rotation.x = -Math.PI / 2;
|
|
road.position.set(0, 0.02, 1500);
|
|
scene.add(road);
|
|
|
|
const shoulderMat = new THREE.MeshLambertMaterial({ color: 0x80766a });
|
|
[-1, 1].forEach(side => {
|
|
const m = new THREE.Mesh(new THREE.PlaneGeometry(SHOULDER_W, 4000), shoulderMat);
|
|
m.rotation.x = -Math.PI / 2;
|
|
m.position.set(side * (ROAD_W / 2 + SHOULDER_W / 2), 0.01, 1500);
|
|
scene.add(m);
|
|
});
|
|
|
|
const postMat = new THREE.MeshLambertMaterial({ color: 0xeeeeee });
|
|
const postGeom = new THREE.BoxGeometry(0.14, 1.0, 0.14);
|
|
for (let z = 30; z < 1700; z += 60) {
|
|
const inExit = z > 340 && z < 520;
|
|
const inHaz = z > 175 && z < 235;
|
|
if (!inHaz) {
|
|
const left = new THREE.Mesh(postGeom, postMat);
|
|
left.position.set(8.5, 0.5, z);
|
|
scene.add(left);
|
|
}
|
|
if (!inExit && !inHaz) {
|
|
const right = new THREE.Mesh(postGeom, postMat);
|
|
right.position.set(-8.5, 0.5, z);
|
|
scene.add(right);
|
|
}
|
|
}
|
|
|
|
scene.add(createMountains(THREE));
|
|
scene.add(createTunnel(THREE, 580));
|
|
|
|
this.turbines = [];
|
|
[{ x: -115, z: 300 }, { x: -160, z: 420 }, { x: 140, z: 360 }, { x: 175, z: 480 }].forEach(p => {
|
|
const t = createWindTurbine(THREE);
|
|
t.position.set(p.x, 0, p.z);
|
|
t.userData.blades.rotation.z = Math.random() * Math.PI * 2;
|
|
t.userData.spinRate = 0.5 + Math.random() * 0.4;
|
|
scene.add(t);
|
|
this.turbines.push(t);
|
|
});
|
|
|
|
for (let i = 0; i < 40; i++) {
|
|
const t = createTree(THREE);
|
|
const side = Math.random() < 0.5 ? -1 : 1;
|
|
t.position.set(
|
|
side * (ROAD_W / 2 + SHOULDER_W + 6 + Math.random() * 35),
|
|
0,
|
|
-50 + Math.random() * 1700
|
|
);
|
|
t.scale.setScalar(0.7 + Math.random() * 0.7);
|
|
t.rotation.y = Math.random() * Math.PI * 2;
|
|
scene.add(t);
|
|
}
|
|
|
|
this.exitRamp = buildExitRamp(THREE, this.scenarioData.exitDistance, this.scenarioData.exitLane);
|
|
scene.add(this.exitRamp.mesh);
|
|
for (let i = 0; i < 10; i++) {
|
|
const t = createTree(THREE);
|
|
const idx = 12 + Math.floor(Math.random() * (this.exitRamp.centerline.length - 12));
|
|
const cp = this.exitRamp.centerline[idx];
|
|
const sideOff = (Math.random() < 0.5 ? -1 : 1) * (4 + Math.random() * 6);
|
|
t.position.set(cp.x + Math.cos(cp.angle) * sideOff, 0, cp.z - Math.sin(cp.angle) * sideOff);
|
|
t.scale.setScalar(0.6 + Math.random() * 0.5);
|
|
scene.add(t);
|
|
}
|
|
|
|
this.scenarioData.decisions.forEach(d => {
|
|
const m = createGantry(THREE, d);
|
|
m.position.set(0, 0, d.distance);
|
|
scene.add(m);
|
|
this.state.signs.push({ mesh: m, distance: d.distance });
|
|
});
|
|
|
|
const hzd = this.scenarioData.hazard;
|
|
this.hazardMesh = createHazard(THREE, hzd.lane, hzd.z, hzd.kind);
|
|
scene.add(this.hazardMesh);
|
|
|
|
this.truck = createTruck(THREE, 0xc1432e, 0xf2efe6);
|
|
this.truck.position.set(laneCenter(1), 0, 0);
|
|
scene.add(this.truck);
|
|
|
|
this.spawnVehicles();
|
|
}
|
|
|
|
spawnVehicles() {
|
|
const THREE = this.THREE;
|
|
const hzd = this.scenarioData.hazard;
|
|
for (let i = 0; i < 9; i++) {
|
|
let lane;
|
|
let attempts = 0;
|
|
const initialZ = 50 + i * 50 + Math.random() * 25;
|
|
do {
|
|
lane = Math.floor(Math.random() * NUM_LANES);
|
|
attempts++;
|
|
} while (
|
|
attempts < 6 &&
|
|
hzd && lane === hzd.lane &&
|
|
initialZ > hzd.z - 90 && initialZ < hzd.z + 30
|
|
);
|
|
const color = CAR_COLORS[Math.floor(Math.random() * CAR_COLORS.length)];
|
|
const isTruck = Math.random() < 0.3;
|
|
const m = isTruck ? createTruck(THREE, color, 0xeeeeee) : createCar(THREE, color);
|
|
m.position.set(laneCenter(lane), 0, initialZ);
|
|
this.scene.add(m);
|
|
const speed = isTruck ? 14 + Math.random() * 4 : 19 + Math.random() * 5;
|
|
this.state.vehicles.push({ mesh: m, lane, speed, isTruck });
|
|
}
|
|
}
|
|
|
|
pickSpawnLane(isTruck, newZ) {
|
|
const hzd = this.scenarioData.hazard;
|
|
const inHazardZone = hzd && !this.state.hazardPassed &&
|
|
newZ > hzd.z - 60 && newZ < hzd.z + 30;
|
|
const hazardLane = inHazardZone ? hzd.lane : -1;
|
|
const blockerChance = isTruck ? BLOCKER_CHANCE_TRUCK : BLOCKER_CHANCE_CAR;
|
|
const tryBlocker = Math.random() < blockerChance && this.state.targetLane !== hazardLane;
|
|
if (tryBlocker) return this.state.targetLane;
|
|
const valid = [];
|
|
for (let i = 0; i < NUM_LANES; i++) {
|
|
if (i === hazardLane) continue;
|
|
if (i === this.state.targetLane) continue;
|
|
valid.push(i);
|
|
}
|
|
if (valid.length === 0) {
|
|
for (let i = 0; i < NUM_LANES; i++) {
|
|
if (i !== hazardLane) valid.push(i);
|
|
}
|
|
}
|
|
return valid[Math.floor(Math.random() * valid.length)];
|
|
}
|
|
|
|
setupInput() {
|
|
const onKeyDown = (e) => {
|
|
if (e.key === 'ArrowLeft') { e.preventDefault(); this.changeLane(-1); }
|
|
else if (e.key === 'ArrowRight') { e.preventDefault(); this.changeLane(1); }
|
|
else if (e.key === 'ArrowDown') { e.preventDefault(); this.brakeOn(); }
|
|
};
|
|
const onKeyUp = (e) => {
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); this.brakeOff(); }
|
|
};
|
|
this.addListener(document, 'keydown', onKeyDown);
|
|
this.addListener(document, 'keyup', onKeyUp);
|
|
|
|
this.addListener(this.leftBtn, 'click', () => this.changeLane(-1));
|
|
this.addListener(this.rightBtn, 'click', () => this.changeLane(1));
|
|
|
|
const brakeOn = (e) => { if (e) e.preventDefault(); this.brakeOn(); };
|
|
const brakeOff = () => this.brakeOff();
|
|
this.addListener(this.brakeBtn, 'mousedown', brakeOn);
|
|
this.addListener(this.brakeBtn, 'mouseup', brakeOff);
|
|
this.addListener(this.brakeBtn, 'mouseleave', brakeOff);
|
|
this.addListener(this.brakeBtn, 'touchstart', brakeOn, { passive: false });
|
|
this.addListener(this.brakeBtn, 'touchend', brakeOff);
|
|
this.addListener(this.brakeBtn, 'touchcancel', brakeOff);
|
|
|
|
this.addListener(this.startBtn, 'click', () => this.startScenario());
|
|
|
|
this.addListener(this.canvas, 'click', (e) => {
|
|
if (this.state.phase !== 'driving') return;
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const x = (e.clientX - rect.left) / rect.width;
|
|
if (x < 0.5) this.changeLane(-1);
|
|
else this.changeLane(1);
|
|
});
|
|
|
|
const onResize = () => this.resize();
|
|
this.addListener(window, 'resize', onResize);
|
|
|
|
const onVis = () => {
|
|
if (document.hidden) {
|
|
if (this.rafId) {
|
|
cancelAnimationFrame(this.rafId);
|
|
this.rafId = null;
|
|
}
|
|
} else if (!this.ended && !this.rafId) {
|
|
this.lastFrameTime = performance.now();
|
|
this.rafId = requestAnimationFrame((t) => this.tick(t));
|
|
}
|
|
};
|
|
this.addListener(document, 'visibilitychange', onVis);
|
|
|
|
setTimeout(() => this.resize(), 30);
|
|
}
|
|
|
|
resize() {
|
|
if (!this.stage) return;
|
|
const w = this.stage.clientWidth;
|
|
const h = this.stage.clientHeight;
|
|
if (w === 0 || h === 0) return;
|
|
this.renderer.setSize(w, h, false);
|
|
this.camera.aspect = w / h;
|
|
this.camera.updateProjectionMatrix();
|
|
}
|
|
|
|
brakeOn() {
|
|
if (this.state.phase !== 'driving') return;
|
|
this.state.braking = true;
|
|
this.brakeUsed = true;
|
|
this.brakeBtn.classList.add('lg-aut-btn-active');
|
|
}
|
|
|
|
brakeOff() {
|
|
this.state.braking = false;
|
|
this.brakeBtn.classList.remove('lg-aut-btn-active');
|
|
}
|
|
|
|
changeLane(dir) {
|
|
if (this.state.phase !== 'driving') return;
|
|
const exitWindowStart = this.scenarioData.exitDistance - 30;
|
|
const exitWindowEnd = this.scenarioData.exitDistance + 35;
|
|
const inWindow = this.state.position >= exitWindowStart && this.state.position <= exitWindowEnd;
|
|
if (dir > 0 && this.state.targetLane === this.scenarioData.exitLane && inWindow) {
|
|
this.endRound(true);
|
|
return;
|
|
}
|
|
const newLane = Math.max(0, Math.min(NUM_LANES - 1, this.state.targetLane + dir));
|
|
if (newLane === this.state.targetLane) return;
|
|
this.state.targetLane = newLane;
|
|
this.laneChangeCount++;
|
|
this.state.laneChange = {
|
|
startX: this.truck.position.x,
|
|
targetX: laneCenter(newLane),
|
|
duration: LANE_CHANGE_DURATION,
|
|
elapsed: 0
|
|
};
|
|
}
|
|
|
|
startScenario() {
|
|
this.startedAt = performance.now();
|
|
this.state.phase = 'driving';
|
|
this.state.position = 0;
|
|
this.state.targetLane = 1;
|
|
this.state.exitChecked = false;
|
|
this.state.exitProgress = 0;
|
|
this.state.speedFactor = 1;
|
|
this.state.hazardPassed = false;
|
|
this.state.failReason = null;
|
|
this.state.braking = false;
|
|
this.state.laneChange = null;
|
|
this.state.crashElapsed = 0;
|
|
this.brakeBtn.classList.remove('lg-aut-btn-active');
|
|
this.truck.position.set(laneCenter(1), 0, 0);
|
|
this.truck.rotation.set(0, 0, 0);
|
|
const hzd = this.scenarioData.hazard;
|
|
this.state.vehicles.forEach((v, i) => {
|
|
let lane = Math.floor(Math.random() * NUM_LANES);
|
|
const initialZ = 50 + i * 50 + Math.random() * 25;
|
|
let attempts = 0;
|
|
while (
|
|
attempts < 6 &&
|
|
hzd && lane === hzd.lane &&
|
|
initialZ > hzd.z - 90 && initialZ < hzd.z + 30
|
|
) {
|
|
lane = Math.floor(Math.random() * NUM_LANES);
|
|
attempts++;
|
|
}
|
|
v.lane = lane;
|
|
v.mesh.position.set(laneCenter(v.lane), 0, initialZ);
|
|
});
|
|
this.overlay.classList.add('lg-aut-overlay-hidden');
|
|
if (!this.rafId) {
|
|
this.lastFrameTime = performance.now();
|
|
this.rafId = requestAnimationFrame((t) => this.tick(t));
|
|
}
|
|
}
|
|
|
|
showResultOverlay(success) {
|
|
this.overlay.classList.remove('lg-aut-overlay-hidden', 'lg-aut-win', 'lg-aut-lose');
|
|
if (success) {
|
|
this.overlay.classList.add('lg-aut-win');
|
|
this.overlayH.textContent = 'Ziel erreicht!';
|
|
this.overlayP.innerHTML = 'Saubere Tour — Sie sind genau bei der Ausfahrt <b>' + this.scenarioData.target + '</b> abgefahren.';
|
|
} else {
|
|
this.overlay.classList.add('lg-aut-lose');
|
|
const r = this.state.failReason;
|
|
const hzd = this.scenarioData.hazard;
|
|
if (r === 'hazard') {
|
|
this.overlayH.textContent = hzd.kind === 'unfall' ? 'In Unfallstelle gefahren!' : 'In Baustelle gefahren!';
|
|
this.overlayP.innerHTML = 'Spur ' + (hzd.lane + 1) + ' war gesperrt — die Pfeiltafel wies aus.';
|
|
} else if (r === 'rear_end') {
|
|
this.overlayH.textContent = 'Auffahrunfall!';
|
|
this.overlayP.innerHTML = 'Sie sind auf ein langsameres Fahrzeug aufgefahren. Spur wechseln oder rechtzeitig <b>bremsen</b>.';
|
|
} else if (r === 'no_exit') {
|
|
this.overlayH.textContent = 'Ausfahrt verpasst!';
|
|
this.overlayP.innerHTML = 'Im Ausfahrtsfenster nicht aktiv nach rechts gelenkt.';
|
|
} else {
|
|
this.overlayH.textContent = 'Ausfahrt verpasst!';
|
|
this.overlayP.innerHTML = 'Sie waren nicht auf Spur ' + (this.scenarioData.exitLane + 1) + ' (rechts).';
|
|
}
|
|
}
|
|
}
|
|
|
|
startExit() {
|
|
const rampStartZ = this.scenarioData.exitDistance - EXIT_LEAD;
|
|
const initialDist = Math.max(0, Math.min(this.exitRamp.truckStop * 0.85, this.truck.position.z - rampStartZ));
|
|
this.state.phase = 'exiting';
|
|
this.state.exitProgress = initialDist / (this.truckSpeed * 0.85);
|
|
}
|
|
|
|
endRound(success, reason) {
|
|
this.state.failReason = reason || null;
|
|
this.state.braking = false;
|
|
this.brakeBtn.classList.remove('lg-aut-btn-active');
|
|
if (success) {
|
|
this.startExit();
|
|
} else {
|
|
this.state.phase = 'losing';
|
|
this.state.speedFactor = 1;
|
|
this.showResultOverlay(false);
|
|
// Verzögert das Ende, damit das Overlay einen Moment sichtbar ist
|
|
setTimeout(() => this.finish(reason === 'hazard' || reason === 'rear_end' ? 'crash' : 'late'), 1800);
|
|
}
|
|
}
|
|
|
|
triggerCrash() {
|
|
const THREE = this.THREE;
|
|
this.state.phase = 'crashing';
|
|
this.state.crashElapsed = 0;
|
|
this.state.failReason = 'hazard';
|
|
this.state.braking = false;
|
|
this.brakeBtn.classList.remove('lg-aut-btn-active');
|
|
|
|
const impactX = this.truck.position.x;
|
|
const impactZ = this.truck.position.z + 5;
|
|
|
|
if (this.hazardMesh) {
|
|
const childrenCopy = [...this.hazardMesh.children];
|
|
childrenCopy.forEach(child => {
|
|
if (!child.isMesh || !child.geometry) return;
|
|
const dx = child.position.x - impactX;
|
|
const dz = child.position.z - impactZ;
|
|
const dist = Math.sqrt(dx * dx + dz * dz);
|
|
if (dist > 22) return;
|
|
this.hazardMesh.remove(child);
|
|
this.scene.add(child);
|
|
const sideBoost = (Math.abs(dx) < 0.01 ? (Math.random() - 0.5) * 2 : Math.sign(dx)) * (3 + Math.random() * 7);
|
|
this.state.debris.push({
|
|
mesh: child,
|
|
vx: sideBoost,
|
|
vy: 5 + Math.random() * 9,
|
|
vz: 4 + Math.random() * 14,
|
|
avx: (Math.random() - 0.5) * 14,
|
|
avy: (Math.random() - 0.5) * 14,
|
|
avz: (Math.random() - 0.5) * 14
|
|
});
|
|
});
|
|
}
|
|
|
|
const isBaustelle = this.scenarioData.hazard.kind === 'baustelle';
|
|
for (let i = 0; i < 14; i++) {
|
|
let mesh;
|
|
if (isBaustelle) {
|
|
mesh = new THREE.Mesh(
|
|
new THREE.BoxGeometry(0.16 + Math.random() * 0.1, 0.08 + Math.random() * 0.06, 1.0 + Math.random() * 0.8),
|
|
new THREE.MeshLambertMaterial({ color: i % 2 === 0 ? 0xa97f4a : 0x8b6438 })
|
|
);
|
|
} else {
|
|
mesh = new THREE.Mesh(
|
|
new THREE.BoxGeometry(0.4 + Math.random() * 0.4, 0.18 + Math.random() * 0.3, 0.4 + Math.random() * 0.4),
|
|
new THREE.MeshLambertMaterial({ color: 0x3a3a3a })
|
|
);
|
|
}
|
|
mesh.position.set(
|
|
impactX + (Math.random() - 0.5) * 3,
|
|
0.8 + Math.random() * 1.5,
|
|
impactZ + (Math.random() - 0.5) * 4
|
|
);
|
|
mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
|
|
this.scene.add(mesh);
|
|
this.state.debris.push({
|
|
mesh,
|
|
vx: (Math.random() - 0.5) * 14,
|
|
vy: 4 + Math.random() * 10,
|
|
vz: 3 + Math.random() * 14,
|
|
avx: (Math.random() - 0.5) * 16,
|
|
avy: (Math.random() - 0.5) * 16,
|
|
avz: (Math.random() - 0.5) * 16
|
|
});
|
|
}
|
|
}
|
|
|
|
updateHud() {
|
|
this.laneNum.textContent = this.state.targetLane + 1;
|
|
this.statusPill.classList.remove('lg-aut-warn', 'lg-aut-go');
|
|
const ed = this.scenarioData.exitDistance;
|
|
const exitWindowStart = ed - 30;
|
|
const exitWindowEnd = ed + 35;
|
|
const inWindow = this.state.position >= exitWindowStart && this.state.position <= exitWindowEnd;
|
|
const hzd = this.scenarioData.hazard;
|
|
if (hzd && !this.state.hazardPassed && this.state.position > hzd.z - 90 && this.state.position < hzd.z + 5) {
|
|
const k = hzd.kind === 'unfall' ? 'Unfall' : 'Baustelle';
|
|
this.statusText.textContent = '⚠ ' + k + ' Spur ' + (hzd.lane + 1);
|
|
this.statusPill.classList.add('lg-aut-warn');
|
|
} else if (inWindow) {
|
|
if (this.state.targetLane === this.scenarioData.exitLane) {
|
|
this.statusText.textContent = '→ Jetzt rechts!';
|
|
this.statusPill.classList.add('lg-aut-go');
|
|
} else {
|
|
this.statusText.textContent = '⚠ Ausfahrt: Spur ' + (this.scenarioData.exitLane + 1);
|
|
this.statusPill.classList.add('lg-aut-warn');
|
|
}
|
|
} else {
|
|
const d = Math.max(0, Math.round(ed - this.state.position));
|
|
this.statusText.textContent = 'noch ' + d + ' m';
|
|
}
|
|
}
|
|
|
|
tick(now) {
|
|
this.rafId = null;
|
|
if (this.ended) return;
|
|
if (!this.container.isConnected) {
|
|
this.finish('crash');
|
|
return;
|
|
}
|
|
|
|
// Container-Sichtbarkeit prüfen
|
|
const cs = window.getComputedStyle(this.container);
|
|
const hidden = cs.visibility === 'hidden' || cs.display === 'none' || document.hidden;
|
|
|
|
this.rafId = requestAnimationFrame((t) => this.tick(t));
|
|
|
|
if (hidden) {
|
|
this.lastFrameTime = now;
|
|
return;
|
|
}
|
|
|
|
const dt = Math.min(0.05, (now - this.lastFrameTime) / 1000);
|
|
this.lastFrameTime = now;
|
|
|
|
this.turbines.forEach(t => { t.userData.blades.rotation.z += dt * t.userData.spinRate; });
|
|
|
|
const phase = this.state.phase;
|
|
|
|
if (phase === 'driving') {
|
|
const target = this.state.braking ? 0.42 : 1.0;
|
|
const rate = this.state.braking ? 3.0 : 1.4;
|
|
this.state.speedFactor += (target - this.state.speedFactor) * Math.min(1, dt * rate);
|
|
if (this.truck.userData.brakeLight) {
|
|
this.truck.userData.brakeLight.material.color.setHex(this.state.braking ? 0xff2020 : 0x550000);
|
|
}
|
|
|
|
this.state.position += this.truckSpeed * this.state.speedFactor * dt;
|
|
this.truck.position.z = this.state.position;
|
|
|
|
if (this.state.laneChange) {
|
|
const lc = this.state.laneChange;
|
|
lc.elapsed += dt;
|
|
const t = Math.min(1, lc.elapsed / lc.duration);
|
|
const easeT = t * t * (3 - 2 * t);
|
|
const dEase = 6 * t * (1 - t);
|
|
this.truck.position.x = lc.startX + (lc.targetX - lc.startX) * easeT;
|
|
const lateralVel = (lc.targetX - lc.startX) * dEase / lc.duration;
|
|
this.truck.rotation.y = Math.atan2(lateralVel, this.truckSpeed);
|
|
if (t >= 1) {
|
|
this.truck.position.x = lc.targetX;
|
|
this.truck.rotation.y = 0;
|
|
this.state.laneChange = null;
|
|
}
|
|
} else {
|
|
this.truck.position.x = laneCenter(this.state.targetLane);
|
|
this.truck.rotation.y = 0;
|
|
}
|
|
|
|
const tx = this.truck.position.x;
|
|
const tz = this.truck.position.z;
|
|
this.camera.position.set(tx * 0.5, 5.8, tz - 13.5);
|
|
this.camera.lookAt(tx * 0.4, 2.8, tz + 28);
|
|
|
|
const hzd = this.scenarioData.hazard;
|
|
if (hzd && !this.state.hazardPassed) {
|
|
if (this.state.position > hzd.z - 6 && this.state.position < hzd.z + 18) {
|
|
const dx = Math.abs(this.truck.position.x - laneCenter(hzd.lane));
|
|
if (dx < LANE_W * 0.45) {
|
|
this.triggerCrash();
|
|
this.renderer.render(this.scene, this.camera);
|
|
return;
|
|
}
|
|
}
|
|
if (this.state.position > hzd.z + 25) this.state.hazardPassed = true;
|
|
}
|
|
|
|
let crashed = false;
|
|
this.state.vehicles.forEach(v => {
|
|
if (crashed) return;
|
|
v.mesh.position.z += v.speed * dt;
|
|
if (v.mesh.position.z < this.state.position - 30 || v.mesh.position.z > this.state.position + 350) {
|
|
const newZ = this.state.position + 200 + Math.random() * 80;
|
|
v.lane = this.pickSpawnLane(v.isTruck, newZ);
|
|
v.mesh.position.x = laneCenter(v.lane);
|
|
v.mesh.position.z = newZ;
|
|
v.speed = v.isTruck ? 14 + Math.random() * 4 : 19 + Math.random() * 5;
|
|
return;
|
|
}
|
|
const aiDx = Math.abs(v.mesh.position.x - this.truck.position.x);
|
|
if (aiDx < LANE_W * 0.55) {
|
|
const aiBackZ = v.mesh.position.z - (v.isTruck ? 6 : 2.2);
|
|
const truckFront = this.truck.position.z + 5;
|
|
if (truckFront >= aiBackZ - 0.2 && this.truck.position.z < v.mesh.position.z) crashed = true;
|
|
}
|
|
});
|
|
if (crashed) {
|
|
this.endRound(false, 'rear_end');
|
|
this.renderer.render(this.scene, this.camera);
|
|
return;
|
|
}
|
|
|
|
if (!this.state.exitChecked && this.state.position > this.scenarioData.exitDistance + 35) {
|
|
this.state.exitChecked = true;
|
|
const reason = this.state.targetLane === this.scenarioData.exitLane ? 'no_exit' : 'wrong_lane';
|
|
this.endRound(false, reason);
|
|
}
|
|
|
|
this.updateHud();
|
|
} else if (phase === 'crashing') {
|
|
this.state.crashElapsed += dt;
|
|
this.state.speedFactor = Math.max(0, this.state.speedFactor - dt * 2.5);
|
|
this.state.position += this.truckSpeed * this.state.speedFactor * dt;
|
|
this.truck.position.z = this.state.position;
|
|
this.truck.rotation.x = Math.min(0.07, this.state.crashElapsed * 0.09);
|
|
|
|
const GRAVITY = 22;
|
|
this.state.debris.forEach(d => {
|
|
d.mesh.position.x += d.vx * dt;
|
|
d.mesh.position.y += d.vy * dt;
|
|
d.mesh.position.z += d.vz * dt;
|
|
d.vy -= GRAVITY * dt;
|
|
d.mesh.rotation.x += d.avx * dt;
|
|
d.mesh.rotation.y += d.avy * dt;
|
|
d.mesh.rotation.z += d.avz * dt;
|
|
if (d.mesh.position.y < 0.25 && d.vy < 0) {
|
|
d.mesh.position.y = 0.25;
|
|
d.vy *= -0.32;
|
|
d.vx *= 0.55;
|
|
d.vz *= 0.55;
|
|
d.avx *= 0.6;
|
|
d.avz *= 0.6;
|
|
}
|
|
});
|
|
|
|
const shakeAmt = Math.max(0, 1 - this.state.crashElapsed * 1.2) * 0.45;
|
|
const tx = this.truck.position.x;
|
|
const tz = this.truck.position.z;
|
|
this.camera.position.set(
|
|
tx * 0.5 + (Math.random() - 0.5) * shakeAmt,
|
|
5.8 + (Math.random() - 0.5) * shakeAmt * 0.7,
|
|
tz - 13.5
|
|
);
|
|
this.camera.lookAt(tx * 0.4, 2.8, tz + 28);
|
|
|
|
if (this.state.crashElapsed > 1.8) {
|
|
this.showResultOverlay(false);
|
|
this.state.phase = 'finished';
|
|
setTimeout(() => this.finish('crash'), 1500);
|
|
}
|
|
} else if (phase === 'exiting') {
|
|
this.state.exitProgress += dt;
|
|
const distAlong = this.truckSpeed * 0.85 * this.state.exitProgress;
|
|
if (distAlong >= this.exitRamp.truckStop) {
|
|
this.state.phase = 'finished';
|
|
this.showResultOverlay(true);
|
|
setTimeout(() => this.finish('success'), 1500);
|
|
} else {
|
|
const cum = this.exitRamp.cum;
|
|
let i = 0;
|
|
while (i < cum.length - 1 && cum[i + 1] < distAlong) i++;
|
|
const localT = (distAlong - cum[i]) / (cum[i + 1] - cum[i]);
|
|
const p1 = this.exitRamp.centerline[i];
|
|
const p2 = this.exitRamp.centerline[i + 1];
|
|
this.truck.position.x = p1.x + (p2.x - p1.x) * localT;
|
|
this.truck.position.z = p1.z + (p2.z - p1.z) * localT;
|
|
let da = p2.angle - p1.angle;
|
|
if (da > Math.PI) da -= 2 * Math.PI;
|
|
if (da < -Math.PI) da += 2 * Math.PI;
|
|
this.truck.rotation.y = p1.angle + da * localT;
|
|
const angle = this.truck.rotation.y;
|
|
const fx = Math.sin(angle);
|
|
const fz = Math.cos(angle);
|
|
this.camera.position.set(this.truck.position.x - fx * 13.5, 5.8, this.truck.position.z - fz * 13.5);
|
|
this.camera.lookAt(this.truck.position.x + fx * 14, 2.8, this.truck.position.z + fz * 14);
|
|
this.state.vehicles.forEach(v => { v.mesh.position.z += v.speed * dt; });
|
|
}
|
|
} else if (phase === 'losing') {
|
|
this.state.speedFactor = Math.max(0, this.state.speedFactor - dt * 0.4);
|
|
this.state.position += this.truckSpeed * this.state.speedFactor * dt;
|
|
this.truck.position.z = this.state.position;
|
|
const tx = this.truck.position.x;
|
|
const tz = this.truck.position.z;
|
|
this.camera.position.set(tx * 0.5, 5.8, tz - 13.5);
|
|
this.camera.lookAt(tx * 0.4, 2.8, tz + 28);
|
|
this.state.vehicles.forEach(v => { v.mesh.position.z += v.speed * dt; });
|
|
} else if (phase === 'idle') {
|
|
const t = now * 0.0002;
|
|
this.camera.position.set(Math.sin(t) * 2, 6.0, -14);
|
|
this.camera.lookAt(0, 2.8, 30);
|
|
}
|
|
|
|
this.renderer.render(this.scene, this.camera);
|
|
}
|
|
|
|
finish(outcome) {
|
|
if (this.ended) return;
|
|
const decisionTimeMs = this.startedAt > 0 ? Math.round(performance.now() - this.startedAt) : 0;
|
|
const result = {
|
|
outcome,
|
|
scenario: this.scenarioKey,
|
|
decisionTimeMs,
|
|
laneChanges: this.laneChangeCount,
|
|
brakeUsed: this.brakeUsed
|
|
};
|
|
this.cleanup();
|
|
this.ended = true;
|
|
try { this.onEnd(result); } catch (e) { /* swallow per spec */ }
|
|
}
|
|
|
|
cleanup() {
|
|
if (this.rafId) {
|
|
cancelAnimationFrame(this.rafId);
|
|
this.rafId = null;
|
|
}
|
|
this.removeAllListeners();
|
|
try {
|
|
if (this.scene) {
|
|
this.scene.traverse(obj => {
|
|
if (obj.geometry && obj.geometry.dispose) obj.geometry.dispose();
|
|
if (obj.material) {
|
|
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
|
mats.forEach(m => {
|
|
if (m.map && m.map.dispose) m.map.dispose();
|
|
if (m.dispose) m.dispose();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
if (this.renderer && this.renderer.dispose) this.renderer.dispose();
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Public API
|
|
// ============================================================
|
|
function start(opts) {
|
|
if (!opts) throw new Error('lgAutobahnGame.start: options object required');
|
|
if (!opts.container || !(opts.container instanceof HTMLElement)) {
|
|
throw new Error('lgAutobahnGame.start: opts.container must be an HTMLElement');
|
|
}
|
|
if (typeof opts.onEnd !== 'function') {
|
|
throw new Error('lgAutobahnGame.start: opts.onEnd callback required');
|
|
}
|
|
if (!opts.scenario || !SCENARIOS[opts.scenario]) {
|
|
throw new Error('lgAutobahnGame.start: unknown scenario "' + opts.scenario + '". Valid: ' + Object.keys(SCENARIOS).join(', '));
|
|
}
|
|
if (!window.THREE) {
|
|
throw new Error('lgAutobahnGame.start: window.THREE not loaded. Bitte three.min.js zuerst einbinden.');
|
|
}
|
|
return new Game(opts);
|
|
}
|
|
|
|
window.lgAutobahnGame = { start, NODES };
|
|
})();
|