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

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

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 };
})();