/* * 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 2/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 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 ' + this.scenarioData.target + ' 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 bremsen.'; } 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 }; })();