/** * Fluggesellschaft — Engine (Phase 1) * * Schwester-Modul zu Busfahrt. Wichtigste Unterschiede: * - Großkreis-Routing (kürzeste Verbindung auf Kugel) statt OSRM * - Flughafen-Marker = Stadtmitte (vereinfacht; reale Flughäfen liegen * oft außerhalb, didaktisch nicht relevant in dieser Stufe) * - Tarif/Kosten in €/km wie Bus, aber andere Werte (Flug ist teurer * pro Sitzplatz, aber schneller) * - Quiz hat 2 Fragen-Typen (Land + Kontinent), Engine wählt * deterministisch pro Auftrag-ID * * Plattform-Hooks (vom Wrapper): * FLUG_BASE, FLUG_FORCED_LEVEL, STUDENT_EASY, * FLUG_API_BASE, FLUG_SESSION_ID */ window.FlugEngine = (function () { 'use strict'; let CITIES_INDEX = null; let ORDERS_POOL = null; /** Bewertungs-Schwellen — Flughafen-Tap (km Abweichung). Großzügiger * als Bus, weil Welt-Karte deutlich größer skaliert ist. */ const HIT_THRESHOLDS = { PERFECT: 200, OK: 800, }; /** Wirtschafts-Konstanten. Werte mit {l1,l2,l3} sind pro Level. */ const ECON = { startBudget: 8000, // höher als Bus (Flugzeug ist teurer) targetBudget: { l1: 14000, l2: 32000, l3: 60000 }, tariffPerKm: 0.18, // €/km — Ticketpreis pro Sitzplatz × 50 Pax = effektiv 9 €/km costPerKm: 0.13, // €/km — Treibstoff + Flughafengebühr + Crew tapToleranceKm: { l1: 250, l2: 200, l3: 150 }, nightFirst: { l1: 5, l2: 3, l3: 1 }, nightChanceAfter: { l1: 0.15, l2: 0.30, l3: 0.60 }, tourLength: { l1: 10, l2: 14, l3: 18 }, homeCity: 'frankfurt', quizBonusFirstTryEur: { l1: 100, l2: 150, l3: 200 }, quizPenaltyPerWrongEur: { l1: 100, l2: 150, l3: 200 }, // CO2-Daten: pro Flug-km ca. 0.18 kg CO2 pro Passagier (Kurzstrecke). // Wird in der Buchhaltung ausgewiesen — Umweltbildung. co2KgPerKmPerPax: 0.18, paxOnboard: 180, }; function econ(key, levelId) { const v = ECON[key]; if (v && typeof v === 'object' && !Array.isArray(v)) { return v[levelId] != null ? v[levelId] : v.l1; } return v; } async function loadData() { if (CITIES_INDEX && ORDERS_POOL) return; const base = (window.FLUG_BASE || './') + 'assets/data/'; const [cRes, oRes] = await Promise.all([ fetch(base + 'cities.json'), fetch(base + 'orders.json'), ]); const cJson = await cRes.json(); const oJson = await oRes.json(); CITIES_INDEX = { raw: cJson, byId: Object.fromEntries(cJson.map(c => [c.id, c])) }; ORDERS_POOL = oJson; } /** Distanz-Filterung: L1 = Nahflug (<1500 km), L2 = Mittelstrecke (<4000 km), * L3 = alles. Distanz wird gegen homeCity gerechnet. Aufträge ohne * auflösbare cityId fallen heraus. Wenn ein Level zu wenig Aufträge hätte * (Sicherheits-Floor 4), wird das Limit für dieses Level ignoriert. */ function distanceFromHomeKm(order) { if (!CITIES_INDEX) return Infinity; const home = CITIES_INDEX.byId[ECON.homeCity]; const dst = CITIES_INDEX.byId[order.cityId]; if (!home || !dst) return Infinity; return haversineKm(home.lat, home.lon, dst.lat, dst.lon); } function filterPoolByLevel(levelId) { if (!ORDERS_POOL) return []; const inLevel = ORDERS_POOL.filter(o => { if (levelId === 'l1') return o.level === 'l1'; if (levelId === 'l2') return o.level === 'l1' || o.level === 'l2'; return true; }); // Distanz-Cap pro Level const caps = { l1: 1500, l2: 4000, l3: Infinity }; const cap = caps[levelId] != null ? caps[levelId] : Infinity; if (!isFinite(cap)) return inLevel; const capped = inLevel.filter(o => distanceFromHomeKm(o) <= cap); // Sicherheits-Floor: wenn unter 4 Aufträge übrig, Cap ignorieren return capped.length >= 4 ? capped : inLevel; } function init(levelId, seed) { const lvl = levelId || 'l1'; const seedNum = (seed || Date.now()) >>> 0; const rng = mulberry32(seedNum); const pool = filterPoolByLevel(lvl); const tourLen = econ('tourLength', lvl) || 8; const nightFirst = econ('nightFirst', lvl); const nightChanceAfter = econ('nightChanceAfter', lvl); const shuffled = shuffleSeeded(pool, rng); // Tour-Auswahl analog Busfahrt: dreistufiger Filter, damit jede Stadt // nur einmal pro Tour auftaucht (auch wenn mehrere Auftragstypen // dieselbe cityId teilen). const usedCities = new Set([ECON.homeCity]); const candidates = shuffled.slice(); const tour = []; let prevCity = ECON.homeCity; for (let i = 0; i < tourLen && candidates.length; i++) { let pickIdx = candidates.findIndex(c => !usedCities.has(c.cityId)); if (pickIdx === -1) pickIdx = candidates.findIndex(c => c.cityId !== prevCity); if (pickIdx === -1) pickIdx = 0; const pick = candidates.splice(pickIdx, 1)[0]; let isNight = false; if (i === nightFirst) isNight = true; else if (i > nightFirst && rng() < nightChanceAfter) isNight = true; tour.push({ ...pick, isNight }); prevCity = pick.cityId; usedCities.add(pick.cityId); } return { levelId: lvl, seed: seedNum, tourOrders: tour, orderIndex: 0, currentOrder: null, currentQuestion: null, attempts: [], score: 0, budget: ECON.startBudget, earnings: 0, co2Kg: 0, lastCity: ECON.homeCity, reports: [], }; } /** Nächster Auftrag. Setzt auch game.currentQuestion mit Quiz-Daten. * Wechselt deterministisch zwischen Land-Frage und Kontinent-Frage * (basierend auf Auftrag-ID) — damit das Quiz nicht monoton wird. */ function nextOrder(game) { if (game.orderIndex >= game.tourOrders.length) return null; const order = game.tourOrders[game.orderIndex]; game.currentOrder = order; game.attempts = []; let q = null; if (order.type === 'B' && Array.isArray(order.choices) && order.choices.length) { const correctIdx = order.choices.indexOf(order.cityId); q = { kind: 'capital', prompt: order.prompt, choices: order.choices, choiceLabels: order.choices.map(cid => { const c = CITIES_INDEX && CITIES_INDEX.byId[cid]; return c ? c.title : cid; }), correctIndex: correctIdx >= 0 ? correctIdx : 0, attempts: [], resolved: false, wasFirstTryCorrect: false, bonusEur: 0, penaltyEur: 0, }; } else { const city = CITIES_INDEX && CITIES_INDEX.byId[order.cityId]; // Wechsel Land/Kontinent: gerade Hash → Land, ungerade → Kontinent const h = simpleHash(order.id); const cq = (h % 2 === 0) ? city && city.question : city && city.questionContinent; const kind = (h % 2 === 0) ? 'country' : 'continent'; if (cq && Array.isArray(cq.choices)) { q = { kind, prompt: cq.prompt, choices: cq.choices.slice(), choiceLabels: cq.choices.slice(), correctIndex: cq.correctIndex, attempts: [], resolved: false, wasFirstTryCorrect: false, bonusEur: 0, penaltyEur: 0, }; } } game.currentQuestion = q; return order; } function evaluateQuestion(game, pickedIndex) { const q = game.currentQuestion; if (!q || q.resolved) return null; if (typeof pickedIndex !== 'number' || pickedIndex < 0 || pickedIndex >= q.choices.length) return null; q.attempts.push(pickedIndex); const correct = pickedIndex === q.correctIndex; const bonus = econ('quizBonusFirstTryEur', game.levelId); const penalty = econ('quizPenaltyPerWrongEur', game.levelId); let delta = 0; if (correct) { if (q.attempts.length === 1) { q.wasFirstTryCorrect = true; q.bonusEur = bonus; delta = bonus; } q.resolved = true; } else { q.penaltyEur += penalty; delta = -penalty; } game.budget += delta; return { correct, resolved: q.resolved, deltaEur: delta, bonusEur: q.bonusEur, penaltyEur: q.penaltyEur, attempts: q.attempts.slice(), }; } function checkTap(game, tap) { if (!game.currentOrder || !CITIES_INDEX) return null; const target = CITIES_INDEX.byId[game.currentOrder.cityId]; if (!target) return null; const distanceKm = haversineKm(tap.lat, tap.lon, target.lat, target.lon); let score = 0; if (distanceKm < HIT_THRESHOLDS.PERFECT) score = 3; else if (distanceKm < HIT_THRESHOLDS.OK) score = 1; const hint = directionHint(tap, target, distanceKm); const attempt = { tap, distanceKm, score, hint }; game.attempts.push(attempt); const resolved = score > 0 || game.attempts.length >= 3; return { ...attempt, resolved }; } /** Auftrag abrechnen — Großkreis-Distanz vom Start-Flughafen zum Ziel-Flughafen. * Tap-Abweichung: wenn weit daneben, fliegt der Pilot einen „Umweg" über die * Tap-Position (didaktisch). Sonst Direkt-Großkreis. */ function settleOrder(game) { if (!game.currentOrder || !CITIES_INDEX) return null; if (!game.attempts.length) return null; const lastAttempt = game.attempts[game.attempts.length - 1]; const tap = lastAttempt.tap; const order = game.currentOrder; const fromCity = CITIES_INDEX.byId[game.lastCity]; const toCity = CITIES_INDEX.byId[order.cityId]; if (!fromCity || !toCity) return null; const realDistanceKm = haversineKm(fromCity.lat, fromCity.lon, toCity.lat, toCity.lon); const startToTap = haversineKm(fromCity.lat, fromCity.lon, tap.lat, tap.lon); const tapToTarget = haversineKm(tap.lat, tap.lon, toCity.lat, toCity.lon); const viaTap = startToTap + tapToTarget; const withinTolerance = lastAttempt.distanceKm < econ('tapToleranceKm', game.levelId); const actualDistanceKm = withinTolerance ? realDistanceKm : viaTap; const detourKm = Math.max(0, actualDistanceKm - realDistanceKm); const tariff = order.tariffPerKm || ECON.tariffPerKm; const playerPrice = realDistanceKm * tariff * ECON.paxOnboard; const busCost = actualDistanceKm * ECON.costPerKm * ECON.paxOnboard; const baseMargin = playerPrice - busCost; const margin = Math.round(baseMargin); const co2Kg = Math.round(actualDistanceKm * ECON.co2KgPerKmPerPax * ECON.paxOnboard); const finalScore = lastAttempt.score; game.score += finalScore; game.budget += margin; game.earnings += margin; game.co2Kg += co2Kg; const q = game.currentQuestion; const quizBonus = q ? (q.bonusEur || 0) : 0; const quizPenalty = q ? (q.penaltyEur || 0) : 0; // Zeitzonen-Differenz beim Ankunft-Land (für Sachinfo in InfoCard) const utcFrom = fromCity.utcOffset || 0; const utcTo = toCity.utcOffset || 0; const tzDiffHours = utcTo - utcFrom; const report = { orderId: order.id, cityId: order.cityId, fromCityId: game.lastCity, realDistanceKm, actualDistanceKm, detourKm, tapDistanceKm: lastAttempt.distanceKm, withinTolerance, tariff, playerPrice: Math.round(playerPrice), busCost: Math.round(busCost), baseMargin: Math.round(baseMargin), co2Kg, tzDiffHours, quizBonus, quizPenalty, quizFirstTry: !!(q && q.wasFirstTryCorrect), quizAttemptsCount: q ? q.attempts.length : 0, quizKind: q ? q.kind : null, won: margin >= 0, margin, finalScore, isNight: !!order.isNight, attempts: game.attempts.slice(), tap, }; game.reports.push(report); game.lastCity = order.cityId; game.orderIndex += 1; game.currentOrder = null; game.currentQuestion = null; game.attempts = []; return report; } function haversineKm(lat1, lon1, lat2, lon2) { const R = 6371; const toRad = d => d * Math.PI / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon/2)**2; return 2 * R * Math.asin(Math.sqrt(a)); } /** Großkreis-Linie als Polyline-Punkte (für Karten-Darstellung). 64 Segmente. */ function greatCirclePoints(lat1, lon1, lat2, lon2, segments) { segments = segments || 64; const toRad = d => d * Math.PI / 180; const toDeg = r => r * 180 / Math.PI; const φ1 = toRad(lat1), λ1 = toRad(lon1); const φ2 = toRad(lat2), λ2 = toRad(lon2); const d = 2 * Math.asin(Math.sqrt( Math.sin((φ2-φ1)/2)**2 + Math.cos(φ1)*Math.cos(φ2) * Math.sin((λ2-λ1)/2)**2 )); if (d === 0) return [[lat1, lon1]]; const pts = []; for (let i = 0; i <= segments; i++) { const f = i / segments; const A = Math.sin((1-f)*d) / Math.sin(d); const B = Math.sin(f*d) / Math.sin(d); const x = A*Math.cos(φ1)*Math.cos(λ1) + B*Math.cos(φ2)*Math.cos(λ2); const y = A*Math.cos(φ1)*Math.sin(λ1) + B*Math.cos(φ2)*Math.sin(λ2); const z = A*Math.sin(φ1) + B*Math.sin(φ2); const φ = Math.atan2(z, Math.sqrt(x*x + y*y)); const λ = Math.atan2(y, x); pts.push([toDeg(φ), toDeg(λ)]); } return pts; } function directionHint(tap, target, distanceKm) { const dLat = target.lat - tap.lat; const dLon = target.lon - tap.lon; const ns = dLat > 0 ? 'nördlich' : 'südlich'; const ew = dLon > 0 ? 'östlich' : 'westlich'; return `Du musst ${Math.round(distanceKm)} km Richtung ${ns}/${ew}.`; } function mulberry32(seed) { let t = seed >>> 0; return function() { t = (t + 0x6D2B79F5) >>> 0; let r = Math.imul(t ^ (t >>> 15), 1 | t); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } function shuffleSeeded(arr, rng) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } function simpleHash(s) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = (h * 16777619) >>> 0; } return h; } function getCity(id) { return CITIES_INDEX ? CITIES_INDEX.byId[id] : null; } function getCapitals() { return CITIES_INDEX ? CITIES_INDEX.raw.filter(c => c.isCapital) : []; } return { loadData, init, nextOrder, evaluateQuestion, checkTap, settleOrder, greatCirclePoints, getCity, getCapitals, HIT_THRESHOLDS, ECON, }; })();