.webp),
// gecacht. Emoji-Canvas nur noch als Fallback.
fundTexture(fund) {
this._fundTex = this._fundTex || {};
if (this._fundTex[fund.id]) return this._fundTex[fund.id];
const loader = (this._texLoader = this._texLoader || new THREE.TextureLoader());
const tex = loader.load(BASE + 'assets/img/fundstuecke/' + fund.id + '.webp');
tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4;
this._fundTex[fund.id] = tex;
return tex;
},
emojiTexture(emoji) {
const cv = document.createElement('canvas'); cv.width = cv.height = 128;
const g = cv.getContext('2d');
g.font = '96px "Segoe UI Emoji", "Apple Color Emoji", system-ui, sans-serif';
g.textAlign = 'center'; g.textBaseline = 'middle';
g.fillText(emoji, 64, 72);
const t = new THREE.CanvasTexture(cv);
t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 4;
return t;
},
/* Wählt die 3 Fundstücke einer Runde aus: IDs bevorzugt noch nicht gesammelt
* (zufällig), Plätze zufällig frei (keine Koffer-Plätze) und mit Mindestabstand,
* damit nie zwei Fundstücke nebeneinander liegen. Resume-stabil (in S.funds). */
FUND_COUNT: 3,
FUND_MIN_DIST: 13,
chooseFunds() {
if (typeof KD_FUNDSACHEN === 'undefined' || !S) return [];
Badges.load();
const all = KD_FUNDSACHEN.map(f => f.id);
const unc = all.filter(id => !Badges.has(id)).sort(() => Math.random() - 0.5);
const col = all.filter(id => Badges.has(id)).sort(() => Math.random() - 0.5);
const ids = unc.concat(col).slice(0, this.FUND_COUNT); // ungesammelte zuerst
const caseSpots = new Set(S.cases.map(c => c.spawn % this.SPAWNS.length));
const free = this.SPAWNS.map((_, i) => i).filter(i => !caseSpots.has(i)).sort(() => Math.random() - 0.5);
const chosen = [];
for (const i of free) { // mit Mindestabstand
const sp = this.SPAWNS[i];
if (chosen.every(j => Math.hypot(this.SPAWNS[j][0] - sp[0], this.SPAWNS[j][1] - sp[1]) >= this.FUND_MIN_DIST)) {
chosen.push(i); if (chosen.length >= ids.length) break;
}
}
for (const i of free) { // notfalls ohne Abstand auffüllen
if (chosen.length >= ids.length) break;
if (chosen.indexOf(i) < 0) chosen.push(i);
}
return ids.map((id, k) => ({ id, spawn: chosen[k] }));
},
_placeFund(key, fund, x, y, z, phase) {
const grp = new THREE.Group();
const spr = new THREE.Mesh(new THREE.PlaneGeometry(0.95, 0.95),
new THREE.MeshBasicMaterial({ map: this.fundTexture(fund), transparent: true, toneMapped: false, depthWrite: false }));
spr.position.y = 0.62; grp.add(spr);
// leuchtender Ring am Boden (voll hell, kein echtes Licht — sonst würden
// ~24 Fundsachen die Shader-Lichtgrenze sprengen)
const ring = new THREE.Mesh(new THREE.RingGeometry(0.26, 0.44, 22),
new THREE.MeshBasicMaterial({ color: 0x9fe0c0, transparent: true, opacity: 0.55, toneMapped: false, depthWrite: false }));
ring.rotation.x = -Math.PI / 2; ring.position.y = 0.04; grp.add(ring);
grp.position.set(x, y, z);
grp.userData = { key, fund, spr, baseY: 0.62, phase };
this.scene.add(grp);
this.fundMeshes[key] = grp;
const anim = { mesh: grp, update: (dt, t) => {
spr.position.y = grp.userData.baseY + Math.sin(t * 1.6 + grp.userData.phase) * 0.08;
spr.quaternion.copy(this.camera.quaternion);
} };
grp.userData.anim = anim; this.animated.push(anim);
},
spawnFundsachen() {
if (!S || typeof KD_FUNDSACHEN === 'undefined') return;
// Pro Runde nur 3 Fundstücke (in S.funds gespeichert → resume-stabil)
if (!S.funds) { S.funds = this.chooseFunds(); save(); }
// gewünschter Soll-Zustand: noch nicht gesammelte der 3
const wanted = {};
(S.funds || []).forEach((f, k) => {
const fund = KD_FUNDSACHEN.find(x => x.id === f.id);
if (fund && !Badges.has(fund.id)) wanted['fd' + k] = { fund, spawn: f.spawn };
});
// bereits eingesammelte / nicht mehr gewollte Meshes entfernen
Object.keys(this.fundMeshes).forEach(key => { if (!wanted[key]) this.removeFund(key); });
// fehlende auslegen
Object.keys(wanted).forEach(key => {
if (this.fundMeshes[key]) return;
const { fund, spawn } = wanted[key];
const sp = this.SPAWNS[spawn % this.SPAWNS.length];
const y0 = this.mode === 'glb' ? this.liveFloorY(sp[0], sp[1]) : 0;
this._placeFund(key, fund, sp[0], y0, sp[1], (spawn || 0) * 1.3);
});
},
removeFund(key) {
const g = this.fundMeshes[key]; if (!g) return;
const a = g.userData && g.userData.anim;
if (a) { const k = this.animated.indexOf(a); if (k >= 0) this.animated.splice(k, 1); }
this.scene.remove(g); delete this.fundMeshes[key];
},
clearFunds() { Object.keys(this.fundMeshes).forEach(key => this.removeFund(key)); },
nearestFund(maxD) {
let best = null, bd = maxD || 2.0;
Object.values(this.fundMeshes).forEach(g => {
const d = Math.hypot(g.position.x - this.player.x, g.position.z - this.player.z);
if (d < bd) { bd = d; best = g; }
});
return best;
},
collectFund(g) {
if (!g || !g.userData) return;
const fund = g.userData.fund;
const isNew = Badges.add(fund.id);
this.removeFund(g.userData.key);
Sound.play('fund', 0.55);
UI.showBadge(fund, isNew);
},
/* --- Eingabe --- */
bindInput(canvas) {
addEventListener('keydown', e => {
if (e.code === 'Escape') {
if (UI.overlayOpen()) {
const reopen = $('ov-country').classList.contains('visible') && UI._reopenAtlas;
if ($('ov-analysis').classList.contains('visible') && S) { S.phase = 'suche'; save(); }
UI.closeOverlays(true); // Esc = Maus freigeben, KEIN Auto-Re-Lock
if (reopen) { UI._reopenAtlas = false; UI.openAtlas(); }
}
return;
}
if (e.code === 'KeyM' || e.code === 'Tab') {
if (UI.bigMap || !UI.overlayOpen()) { e.preventDefault(); UI.toggleBigMap(); }
return;
}
if (e.code === 'F9') { e.preventDefault(); Kalib.toggle(); return; }
// Overlay offen → Tastenkürzel für die Buttons (Leertaste/E = primär, F/G/V = weitere)
if (UI.overlayOpen()) {
const ae = document.activeElement, tag = ae ? ae.tagName : '';
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') {
const k = { KeyE: 'e', Space: 'e', KeyF: 'f', KeyG: 'g', KeyV: 'v' }[e.code];
if (k) {
const ov = document.querySelector('.kd-overlay.visible');
const btn = ov && ov.querySelector('[data-key="' + k + '"]');
if (btn) { e.preventDefault(); btn.click(); }
}
}
return;
}
// Freie Bewegung: Header-Kürzel (I/L/H) + Spielsteuerung
if (S && S.phase === 'suche') {
if (e.code === 'KeyI') { e.preventDefault(); UI.openBadges(); return; }
if (e.code === 'KeyL') { e.preventDefault(); UI.openAtlas(); return; }
if (e.code === 'KeyH') { e.preventDefault(); UI.openTutorial(); return; }
}
this.keys[e.code] = true;
if (e.code === 'KeyE') Game.tryInteract();
if (e.code === 'Space') {
e.preventDefault(); // verhindert Button-Auslösung/Scrollen
if (this.jumpY <= 0) { this.vy = 4.8; Sound.play('steps', 0.05); }
}
});
addEventListener('keyup', e => { this.keys[e.code] = false; });
// Maus: Pointer-Lock nur für echte Maus, Overlays lösen ihn automatisch (UI.show)
canvas.addEventListener('pointerup', e => {
if (e.pointerType === 'mouse' && !UI.overlayOpen() && !this.locked) {
canvas.requestPointerLock && canvas.requestPointerLock();
}
});
// Klick im Blickmodus schließt ein „leichtes“ Overlay (Bestätigung) — wie Leertaste
canvas.addEventListener('mousedown', () => {
if (UI.uiPause) {
const ov = document.querySelector('.kd-overlay.visible');
const b = ov && ov.querySelector('[data-key="e"]');
if (b) b.click();
}
});
document.addEventListener('pointerlockchange', () => {
const wasLocked = this.locked;
this.locked = document.pointerLockElement === canvas;
$('crosshair').classList.toggle('visible', this.locked);
if (this.locked && !wasLocked) {
this.pitch = 0; // beim (Wieder-)Einrasten Blick nach vorne nivellieren
this._skipLook = 2; // den ersten Maus-Delta-Sprung nach dem Lock verwerfen
}
// Esc während eines Light-Overlays löst den Lock → Overlay sauber schließen
if (!this.locked && UI.uiPause) UI.closeOverlays(true);
UI.updateHud();
});
this._canvas = canvas;
addEventListener('mousemove', e => {
if (this.locked && !UI.uiPause) { // bei Light-Overlay Blick pausieren
if (this._skipLook > 0) { this._skipLook--; return; } // Lock-Sprung ignorieren
if (Math.abs(e.movementX) > 180 || Math.abs(e.movementY) > 180) return; // Ausreißer-Delta verwerfen
this.player.yaw -= e.movementX * 0.0024;
this.pitch = Math.max(-0.9, Math.min(0.9, this.pitch - e.movementY * 0.0022));
}
});
// Touch: linke Hälfte Joystick, rechte Hälfte Umschauen
const joyEl = $('joystick'), knob = $('joy-knob');
const joyPos = t => {
const r = joyEl.getBoundingClientRect();
const dx = (t.clientX - (r.left + r.width / 2)) / (r.width / 2);
const dy = (t.clientY - (r.top + r.height / 2)) / (r.height / 2);
const len = Math.hypot(dx, dy) || 1, cl = Math.min(1, len);
return { dx: dx / len * cl, dy: dy / len * cl };
};
let joyTouch = null, lookTouch = null;
canvas.parentElement.addEventListener('touchstart', e => {
if (UI.overlayOpen()) return;
if (e.target.closest && e.target.closest('#btn-touch-act, #btn-map, #minimap-wrap')) return;
for (const t of e.changedTouches) {
if (t.clientX < innerWidth / 2 && joyTouch === null) {
joyTouch = t.identifier; this.joy.active = true;
joyEl.style.left = (t.clientX - 60) + 'px'; joyEl.style.top = (t.clientY - 60) + 'px';
joyEl.classList.add('visible');
const p = joyPos(t); this.joy.dx = p.dx; this.joy.dy = p.dy;
} else if (lookTouch === null) {
lookTouch = t.identifier; this.look.lx = t.clientX; this.look.ly = t.clientY;
}
}
e.preventDefault();
}, { passive: false });
canvas.parentElement.addEventListener('touchmove', e => {
for (const t of e.changedTouches) {
if (t.identifier === joyTouch) {
const p = joyPos(t); this.joy.dx = p.dx; this.joy.dy = p.dy;
knob.style.transform = 'translate(' + p.dx * 34 + 'px,' + p.dy * 34 + 'px)';
} else if (t.identifier === lookTouch) {
this.player.yaw -= (t.clientX - this.look.lx) * 0.005;
this.pitch = Math.max(-0.9, Math.min(0.9, this.pitch - (t.clientY - this.look.ly) * 0.004));
this.look.lx = t.clientX; this.look.ly = t.clientY;
}
}
e.preventDefault();
}, { passive: false });
const endTouch = e => {
for (const t of e.changedTouches) {
if (t.identifier === joyTouch) {
joyTouch = null; this.joy.active = false; this.joy.dx = this.joy.dy = 0;
joyEl.classList.remove('visible'); knob.style.transform = '';
}
if (t.identifier === lookTouch) lookTouch = null;
}
};
canvas.parentElement.addEventListener('touchend', endTouch);
canvas.parentElement.addEventListener('touchcancel', endTouch);
},
blocked(x, z) {
if (this.mode === 'glb') {
// Spieler-Radius über 4 Probepunkte
const r = 0.3;
return !this.gridWalkable(x + r, z) || !this.gridWalkable(x - r, z) ||
!this.gridWalkable(x, z + r) || !this.gridWalkable(x, z - r);
}
for (const w of this.walls) if (Math.abs(x - w.x) < w.w && Math.abs(z - w.z) < w.h) return true;
// Galerie-/Treppengeländer nur blockierend, wenn man erhöht ist (auf Treppe
// oder Galerie) — ebenerdig läuft man frei darunter hindurch
if (this.floorY > 0.3)
for (const w of this.railWalls) if (Math.abs(x - w.x) < w.w && Math.abs(z - w.z) < w.h) return true;
return false;
},
resize() {
const c = this.renderer.domElement;
const w = c.parentElement.clientWidth, h = c.parentElement.clientHeight;
this.camera.aspect = w / h; this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h, false);
},
/* Beim Spawn 1 m über dem Boden absetzen — die Sprung-/Schwerkraft-Physik
* in update() lässt die Spielfigur dann sanft herunterfallen. */
spawnDrop() { this.jumpY = 1; this.vy = 0; },
/* Kamera ohne laufendes update() korrekt platzieren (vor Spielstart /
* während des Ladebildschirms), damit nichts „von unten“ gerendert wird. */
previewCamera() {
const p = this.startPos || { x: 0, z: 0, yaw: 0 };
this.player.x = p.x; this.player.z = p.z; this.player.yaw = p.yaw;
this.floorY = 0; this.eyeY = 1.62; this.jumpY = 0; this.vy = 0; this.pitch = 0;
if (!this.camera) return;
this.camera.position.set(p.x, 1.62, p.z);
this.camera.rotation.order = 'YXZ';
this.camera.rotation.y = p.yaw; this.camera.rotation.x = 0; this.camera.rotation.z = 0;
},
/* Maus direkt fangen (für „losgeht ohne herumklicken") — nur sinnvoll aus
* einer Nutzergeste heraus (Start-Button), sonst lehnt der Browser ab. */
requestLock() {
const c = this._canvas;
if (!IS_TOUCH && c && c.requestPointerLock && !this.locked) {
try { c.requestPointerLock(); } catch (_) {}
}
},
update(dt) {
const p = this.player;
let mx = 0, mz = 0;
if (this.keys['KeyW'] || this.keys['ArrowUp']) mz -= 1;
if (this.keys['KeyS'] || this.keys['ArrowDown']) mz += 1;
if (this.keys['KeyA'] || this.keys['ArrowLeft']) mx -= 1;
if (this.keys['KeyD'] || this.keys['ArrowRight']) mx += 1;
if (this.joy.active) { mx += this.joy.dx; mz += this.joy.dy; }
const len = Math.hypot(mx, mz);
this.moving = len > 0.1;
if (this.moving) {
// Sprint: Shift-Taste (Desktop) ODER Joystick-Vollanschlag (Touch).
// Joystick-Schwelle 0.85 — leichte Bewegung = laufen, voller Auslag = sprinten.
const shiftSprint = this.keys['ShiftLeft'] || this.keys['ShiftRight'];
const joySprint = this.joy.active && Math.hypot(this.joy.dx, this.joy.dy) > 0.85;
const sprinting = shiftSprint || joySprint;
const sp = p.speed * (sprinting ? 1.7 : 1);
const sin = Math.sin(p.yaw), cos = Math.cos(p.yaw);
const vx = (mx * cos + mz * sin) / Math.max(1, len) * sp * dt;
const vz = (-mx * sin + mz * cos) / Math.max(1, len) * sp * dt;
if (!this.blocked(p.x + vx, p.z)) p.x += vx;
if (!this.blocked(p.x, p.z + vz)) p.z += vz;
}
if (this.mode === 'glb') this.checkPortals();
Sound.setWalking(this.moving && this.jumpY <= 0 && !UI.overlayOpen(),
this.keys['ShiftLeft'] || this.keys['ShiftRight']
|| (this.joy.active && Math.hypot(this.joy.dx, this.joy.dy) > 0.85));
// Sprung (Leertaste): einfache Ballistik
if (this.jumpY > 0 || this.vy !== 0) {
this.jumpY += this.vy * dt;
this.vy -= 13 * dt;
if (this.jumpY <= 0) { this.jumpY = 0; this.vy = 0; }
}
// Bodenhöhe folgt dem Untergrund (GLB: Live-Raycast; Klassik: Treppe/Galerie
// mit Hysterese). floorY ist der geglättete Zustand und steuert die Hysterese.
const groundT = this.mode === 'glb' ? this.liveFloorY(p.x, p.z) : this.classicFloorTarget(p.x, p.z);
this.floorY = this.floorY == null ? groundT : this.floorY + (groundT - this.floorY) * Math.min(1, dt * 10);
this.eyeY = this.floorY + 1.62;
this.camera.position.set(p.x, this.eyeY + this.jumpY, p.z);
this.camera.rotation.order = 'YXZ';
this.camera.rotation.y = p.yaw;
this.camera.rotation.x = this.pitch;
const t = performance.now() / 1000;
this.animated.forEach(a => a.update(dt, t));
Object.values(this.caseMeshes).forEach(g => {
g.userData.glow.intensity = 3.5 + Math.sin(t * 2.4) * 2;
if (g.userData.idTag) {
const wp = g.userData.idTag.getWorldPosition(this._tagWp || (this._tagWp = new THREE.Vector3()));
g.userData.idTag.lookAt(this.camera.position.x, wp.y, this.camera.position.z);
}
});
},
/* ============ Info-Punkte: Schilder & Einrichtungen sind lesbar ============
* Recherchierte Sachtexte (Sek I): Was passiert hier am Flughafen wirklich? */
infoPoints: [],
addInfo(x, z, icon, title, text, fact, source, sourceUrl) {
this.infoPoints.push({ x, z, icon, title, text, fact, source, sourceUrl });
},
registerInfoPoints() {
this.infoPoints = [];
this.addInfo(-32, -20, '🛄', 'Check-in',
'Hier geben Reisende ihr Gepäck auf. Jedes Stück bekommt einen Anhänger mit Barcode und einem Code aus drei Buchstaben für den Zielflughafen (z. B. VIE für Wien). Sortieranlagen lesen diesen Code automatisch. Ist der Anhänger beschädigt — wie bei unseren Fundstücken —, weiß niemand mehr, wohin der Koffer gehört.');
this.addInfo(-10, -18, '🛂', 'Sicherheitskontrolle',
'Vor dem Abflug wird jedes Handgepäck durchleuchtet. Der Röntgenscanner zeigt Gegenstände in Farben: Metall dunkel, organisches Material orange. Flüssigkeiten sind nur bis 100 ml pro Behälter erlaubt. Deshalb wissen wir: Alle liegen gebliebenen Gepäckstücke wurden bereits geprüft — sie sind ungefährlich.',
'Die „100-ml-Regel“ für Flüssigkeiten im Handgepäck gilt EU-weit seit dem 6. November 2006 — eingeführt, nachdem die Polizei in London einen geplanten Anschlag mit flüssigem Sprengstoff vereitelt hatte.',
'Europäische Kommission, Mobility & Transport',
'https://transport.ec.europa.eu/transport-modes/air/aviation-security/aviation-security-policy/liquids-aerosols-and-gels_en');
this.addInfo(12, -19.5, '🛍️', 'Duty-free',
'„Duty-free“ heißt zollfrei: Wer in ein Land außerhalb der EU fliegt, kann hier ohne Steuern und Zollabgaben einkaufen. Bei der Rückkehr gelten aber Freimengen — z. B. dürfen Erwachsene nur begrenzt Tabak, Alkohol oder Waren im Wert von etwa 430 € abgabenfrei einführen. Darüber wacht der Zoll.',
'Der weltweit erste Duty-free-Laden eröffnete 1947 am Flughafen Shannon in Irland. Die Idee dahinter: Reisende im Transitbereich sind rechtlich „zwischen den Ländern“ und zahlen dort keine Steuern.',
'Duty Free World Council (dutyfreefacts.com)',
'https://dutyfreefacts.com/our-industrys-history/');
this.addInfo(-35, 8, '🛅', 'Gepäckausgabe',
'Nach der Landung bringen Förderbänder die Koffer vom Flugzeug zu diesen Karussellen. Weltweit gehen pro Jahr Millionen Gepäckstücke verloren — die meisten tauchen wieder auf. Dafür gibt es ein internationales Suchsystem (World Tracer), mit dem Flughäfen verlorene Stücke über Ländergrenzen hinweg finden.',
'2024 gingen weltweit nur noch 6,3 von 1.000 aufgegebenen Gepäckstücken verloren oder kamen verspätet an — ein Rückgang um 67 % gegenüber 2007.',
'SITA Baggage IT Insights 2024',
'https://www.sita.aero/resources/surveys-reports/sita-baggage-it-insights-2024/');
this.addInfo(-64, -6, '⚙️', 'Gepäcksortierung',
'Hinter den Kulissen laufen kilometerlange Förderanlagen. Scanner lesen die Barcodes der Anhänger und Weichen lenken jeden Koffer zum richtigen Flug — oft in unter 10 Minuten. Große Flughäfen sortieren so über 100.000 Gepäckstücke pro Tag.');
this.addInfo(46, -2, '🛫', 'Gates & Boarding',
'Am Gate wird die Bordkarte gescannt, dann geht es über die Fluggastbrücke ins Flugzeug. Die Gate-Nummer steht auf der Anzeigetafel — sie kann sich kurzfristig ändern, darum lohnt der Blick auf die Tafeln. „Boarding“ beginnt meist 30 – 45 Minuten vor dem Abflug.');
this.addInfo(49, 24, '🗃️', 'Fundbüro',
'Was im Flughafen liegen bleibt — vom Schal bis zum Laptop — landet hier. Fundstücke werden katalogisiert und mehrere Monate aufbewahrt. Gepäck von Flügen läuft getrennt über die Gepäckermittlung: genau dort arbeitest du heute.',
'Allein an den Sicherheitskontrollen der USA bleiben jeden Monat rund 90.000 – 100.000 Gegenstände liegen. Nur etwa 15 – 20 % finden zu ihren Besitzern zurück; der Rest wird gespendet, versteigert oder entsorgt.',
'US-Transportation Security Administration (TSA)',
'https://www.tsa.gov/contact/lost-and-found');
this.addInfo(0, 0, '🖥️', 'Abflugtafel lesen',
'Jede Zeile zeigt: geplante Zeit, Ziel, Flugnummer und Status. Die Flugnummer beginnt mit dem Kürzel der Fluggesellschaft. „Gestartet“ heißt: Die Maschine ist in der Luft. Tipp für die Reise: Tafeln zeigen Städtenamen manchmal in der Landessprache — Erdkunde hilft!');
this.addInfo(6.8, 26.5, '💶', 'Geldautomat & Währungen',
'Wer verreist, braucht oft fremdes Geld: In rund 160 Ländern gibt es eigene Währungen. Der Wechselkurs sagt, wie viel ein Euro dort wert ist. Genau deshalb ist eine Münze im Koffer ein guter Hinweis aufs Herkunftsland!');
this.addInfo(-14, 24.5, '☕', 'Flughafen-Bar',
'Essen und Trinken kosten am Flughafen meist mehr als in der Stadt — die Mieten in Terminals sind hoch, und viele Reisende haben wenig Zeit zum Vergleichen. Nach Dienstschluss bleibt die Bar dunkel, nur die Theke wird für morgen vorbereitet.');
this.addInfo(-8.7, 8, '🪜', 'Treppe zur Galerie',
'Flughäfen haben viele Ebenen: Ankunft und Abflug sind oft getrennt, dazu kommen Personalbereiche, Technikgeschosse und Lüftungszentralen. Über die Treppe geht es hinauf zur Galerie.');
this.addInfo(0, -27, '🗼', 'Blick aufs Vorfeld',
'Draußen koordiniert der Tower jeden Schritt: Kein Flugzeug rollt ohne Freigabe. Das gelbe „Follow-me“-Auto führt Maschinen zu ihrer Parkposition. Die blauen Lichter markieren die Rollwege, die Befeuerung der Startbahn ist weiß. Nachts arbeiten draußen Tank-, Fracht- und Reinigungsteams.');
this.addInfo(-48, -23.5, '🚪', 'Servicebereich',
'Türen wie diese trennen die öffentlichen Bereiche vom „Bauch“ des Flughafens. Mitarbeitende brauchen einen Flughafenausweis mit Sicherheitsüberprüfung. Hinter den Kulissen arbeiten oft mehr Menschen als davor: Technik, Logistik, Reinigung, Zoll und Polizei.');
this.addInfo(20.3, 9.2, '🥨', 'Snackautomaten',
'Automaten versorgen Reisende rund um die Uhr — auch nachts, wenn die Geschäfte zu sind. International spannend: Welche Snacks es gibt, unterscheidet sich von Land zu Land deutlich.');
this.addInfo(-3, 27.5, '📞', 'Telefon & Notruf',
'Mit der 112 erreichst du in der ganzen EU kostenlos den Notruf — aus jedem Netz, sogar ohne Guthaben. Flughäfen haben zusätzlich eigene Leitstellen, die rund um die Uhr besetzt sind.');
this.addInfo(0, 14.5, '🧭', 'Wegeleitsystem',
'Flughafen-Schilder nutzen weltweit fast dieselben Piktogramme — Koffer, Flugzeug, Pfeile — damit sich Menschen aller Sprachen zurechtfinden. Gelb/Schwarz oder Weiß/Blau, immer kontrastreich: Gutes Design kann Leben einfacher machen.');
this.addInfo(0, 27.4, '🛃', 'Zoll',
'Der Zoll prüft, was über die Grenze gebracht wird. Manche Waren sind verboten oder müssen angemeldet werden — etwa größere Mengen Bargeld, bestimmte Lebensmittel, Pflanzen oder geschützte Tierprodukte.',
'Wer aus einem Nicht-EU-Land zurückkommt, darf nur begrenzte Mengen abgabenfrei mitbringen — im Flugverkehr z. B. Waren bis 430 € sowie 1 Liter Spirituosen. Darüber wird Zoll fällig.',
'EU-Richtlinie 2007/74/EG (Reisefreimengen)',
'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32007L0074');
this.addInfo(this.passageX != null ? this.passageX : -44, 27.6, '🛂', 'Passkontrolle',
'An der Passkontrolle prüft die Grenzpolizei Pass und — falls nötig — das Visum. Bei der Einreise wird oft ein Stempel ins Passdokument gesetzt; moderne Flughäfen nutzen zunehmend automatische E-Gates mit Gesichtserkennung.',
'Im Schengen-Raum — 2024 rund 29 europäische Länder — gibt es zwischen den Mitgliedstaaten keine Passkontrollen mehr. Erst bei Reisen darüber hinaus wird der Pass kontrolliert.',
'EU — Migration and Home Affairs (Schengen-Raum)',
'https://home-affairs.ec.europa.eu/policies/schengen-borders-and-visa/schengen-area_en');
this.addInfo(14, 27.4, 'ℹ️', 'Information',
'Am Info-Schalter helfen Mitarbeitende bei Fragen zu Flügen, Wegen und Anschlüssen — oft in mehreren Sprachen. Große Flughäfen sind kleine Städte mit eigener Feuerwehr, Polizei, Ärzten und Geschäften.',
'2024 nutzten weltweit rund 9,5 Milliarden Fluggäste die Flughäfen. Der größte einzelne — Atlanta in den USA — zählte allein etwa 108 Millionen.',
'Airports Council International (ACI), 2024',
'https://aci.aero/2025/04/16/the-top-10-busiest-airports-in-the-world-revealed/');
},
nearestInfo(maxD) {
let best = null, bd = maxD || 2.2;
this.infoPoints.forEach(ip => {
const d = Math.hypot(ip.x - this.player.x, ip.z - this.player.z);
if (d < bd) { bd = d; best = ip; }
});
return best;
},
nearestPoster(maxD) {
let best = null, bd = maxD || 2.4;
(this.posterInfos || []).forEach(pi => {
const d = Math.hypot(pi.x - this.player.x, pi.z - this.player.z);
if (d < bd) { bd = d; best = pi; }
});
return best;
},
nearestCase() {
let best = null, bd = 2.8;
Object.values(this.caseMeshes).forEach(g => {
const d = Math.hypot(g.position.x - this.player.x, g.position.z - this.player.z);
if (d < bd) { bd = d; best = g.userData.idx; }
});
return best;
},
caseDistance() {
const g = this.caseMeshes[S.caseIndex];
if (!g) return null;
return Math.hypot(g.position.x - this.player.x, g.position.z - this.player.z);
},
render() { if (this.scene) this.renderer.render(this.scene, this.camera); },
};
/* ============================================================
* LÄNDERATLAS — persistenter Lernfortschritt über Runden hinaus.
* gelb = Visitenkarte irgendwann gelesen · grün = Fall korrekt gelöst
* (localStorage kd.atlas, geräteweit)
* ============================================================ */
const Atlas = {
data: null,
load() {
if (this.data) return;
try { this.data = JSON.parse(localStorage.getItem('kd.atlas') || '{}'); } catch (_) { this.data = {}; }
this.data.read = this.data.read || {};
this.data.solved = this.data.solved || {};
},
save() { try { localStorage.setItem('kd.atlas', JSON.stringify(this.data)); } catch (_) {} },
markRead(iso) { this.load(); if (!this.data.read[iso]) { this.data.read[iso] = 1; this.save(); } },
markSolved(iso) { this.load(); if (!this.data.solved[iso]) { this.data.solved[iso] = 1; this.save(); } },
status(iso) {
this.load();
return this.data.solved[iso] ? 'solved' : this.data.read[iso] ? 'read' : null;
},
solvedCount() { this.load(); return Object.keys(this.data.solved).length; },
// Haupt-Welt = alle Länder außer Platin-Liga (Klasse D); Platin = wirklich alle
mainCountries() { return KD_COUNTRIES.filter(c => c.cls !== 'D'); },
mainSolved() { this.load(); return this.mainCountries().filter(c => this.data.solved[c.iso2]).length; },
complete() { return this.mainSolved() >= this.mainCountries().length; }, // → goldene Weltkugel
platinumComplete() { return this.solvedCount() >= KD_COUNTRIES.length; }, // → Platin-Weltkugel
reset() { this.data = { read: {}, solved: {} }; this.save(); },
};
/* ============================================================
* BADGE-SAMMLUNG — gefundene Reise-Utensilien (Fundsachen), persistent
* über Runden hinweg (localStorage kd.badges).
* ============================================================ */
const Badges = {
data: null,
load() { if (this.data) return; try { this.data = JSON.parse(localStorage.getItem('kd.badges') || '{}'); } catch (_) { this.data = {}; } },
save() { try { localStorage.setItem('kd.badges', JSON.stringify(this.data)); } catch (_) {} },
has(id) { this.load(); return !!this.data[id]; },
add(id) { this.load(); if (this.data[id]) return false; this.data[id] = 1; this.save(); return true; },
count() { this.load(); return Object.keys(this.data).length; },
reset() { this.data = {}; this.save(); },
complete() { return typeof KD_FUNDSACHEN !== 'undefined' && this.count() >= KD_FUNDSACHEN.length; },
};
/* ============================================================
* ACHIEVEMENTS — Sprach-, Kontinent- und Klassen-Sammlungen
* Berechnet aus Atlas.data.solved. Achievement freigeschaltet,
* sobald ALLE Länder einer Gruppe gelöst sind.
* Persistente Anzeige der erst-Freischaltung in localStorage,
* damit das „Neu!"-Glühen einmal pro Achievement sichtbar wird.
* ============================================================ */
const Achievements = {
loadSeen() { try { return JSON.parse(localStorage.getItem('kd.achievements.seen') || '{}'); } catch (_) { return {}; } },
saveSeen(seen) { try { localStorage.setItem('kd.achievements.seen', JSON.stringify(seen)); } catch (_) {} },
/* Liefert pro Achievement {a, solvedCount, total, completed, isNew} */
all() {
if (typeof KD_ACHIEVEMENTS === 'undefined') return [];
Atlas.load();
const seen = this.loadSeen();
return KD_ACHIEVEMENTS.map(a => {
const iso = a.iso || [];
const total = iso.length;
const solvedCount = iso.filter(i => Atlas.data.solved[i]).length;
const completed = total > 0 && solvedCount >= total;
const isNew = completed && !seen[a.id];
return { a, solvedCount, total, completed, isNew };
});
},
/* Markiert ein Achievement als „gesehen" (kein „Neu!" mehr). */
markSeen(id) {
const seen = this.loadSeen();
if (!seen[id]) { seen[id] = 1; this.saveSeen(seen); }
},
/* Beim ersten Lösen eines neuen Landes: prüft ob neue Achievements freigeschaltet wurden. */
checkNew() {
return this.all().filter(x => x.isNew).map(x => x.a);
},
countCompleted() { return this.all().filter(x => x.completed).length; },
countTotal() { return typeof KD_ACHIEVEMENTS !== 'undefined' ? KD_ACHIEVEMENTS.length : 0; },
};
/* ============================================================
* GOLD-PRESTIGE — wer alle Fundstücke/Länder schafft und zurücksetzt,
* sammelt goldene Trolleys bzw. goldene Weltkugeln (beliebig viele).
* ============================================================ */
const Gold = {
data: null,
load() { if (this.data) return; try { this.data = JSON.parse(localStorage.getItem('kd.gold') || '{}'); } catch (_) { this.data = {}; }
this.data.trolleys = this.data.trolleys || 0; this.data.globes = this.data.globes || 0; this.data.platinum = this.data.platinum || 0; },
save() { try { localStorage.setItem('kd.gold', JSON.stringify(this.data)); } catch (_) {} },
trolleys() { this.load(); return this.data.trolleys; },
globes() { this.load(); return this.data.globes; },
platinum() { this.load(); return this.data.platinum; },
addTrolley() { this.load(); this.data.trolleys++; this.save(); },
addGlobe() { this.load(); this.data.globes++; this.save(); },
addPlatinum() { this.load(); this.data.platinum++; this.save(); },
};
/* ============================================================
* KOFFER-KALIBRIERUNG (F9) — Größen-Regler je Pool-Modell,
* Vorschau vor dem Spieler, Werte in localStorage (kd.kofferH)
* ============================================================ */
const Kalib = {
open: false, preview: null, previewIdx: -1,
label(i) {
const n = i === 0 ? 'Lederkoffer' : i === 1 ? 'Tasche A' : i === 2 ? 'Tasche B (schwarz)' : 'Set-Tasche ' + (i - 2);
return 'M' + i + ' · ' + n; // M-Nummer = ID am Fall-Gepäck (F# · M#)
},
toggle() {
this.open = !this.open;
$('kalib-panel').classList.toggle('visible', this.open);
if (this.open) this.render(); else this.clearPreview();
},
render() {
const list = $('kalib-list');
list.innerHTML = '';
World.kofferPool.forEach((_, i) => {
const row = document.createElement('div');
row.className = 'kalib-row' + (i === this.previewIdx ? ' active' : '');
const lab = document.createElement('label'); lab.textContent = this.label(i);
const slider = document.createElement('input');
slider.type = 'range'; slider.min = '0.2'; slider.max = '2'; slider.step = '0.05';
slider.value = World.kofferH(i);
const val = document.createElement('span'); val.className = 'kv';
val.textContent = (+slider.value).toFixed(2) + ' m';
slider.oninput = () => {
World.setKofferH(i, +slider.value);
val.textContent = (+slider.value).toFixed(2) + ' m';
if (this.previewIdx === i) this.showPreview(i);
World.refreshCaseMeshes(i);
};
row.onclick = e => { if (e.target !== slider) this.showPreview(i); };
row.append(lab, slider, val);
list.appendChild(row);
});
},
showPreview(i) {
this.clearPreview();
this.previewIdx = i;
if (!World.kofferPool[i]) return;
const m = World.normalizeKoffer(World.kofferPool[i].clone(true), World.kofferH(i));
const p = World.player;
m.position.set(p.x - Math.sin(p.yaw) * 2.6, World.mode === 'glb' ? World.liveFloorY(p.x, p.z) : 0, p.z - Math.cos(p.yaw) * 2.6);
m.rotation.y = p.yaw + 0.5;
World.scene.add(m);
this.preview = m;
document.querySelectorAll('.kalib-row').forEach((r, k) => r.classList.toggle('active', k === i));
},
clearPreview() {
if (this.preview) { World.scene.remove(this.preview); this.preview = null; }
this.previewIdx = -1;
},
reset() {
try { localStorage.removeItem('kd.kofferH'); } catch (_) {}
World._kofferH = null;
this.render();
if (this.previewIdx >= 0) this.showPreview(this.previewIdx);
World.refreshCaseMeshes();
},
};
/* ============================================================
* MINIKARTE / RADAR
* ============================================================ */
const Radar = {
draw(cv, big) {
const g = cv.getContext('2d');
const W = cv.width, H = cv.height;
const M = World.MAP;
const sx = W / (M.maxx - M.minx), sz = H / (M.maxz - M.minz);
const X = x => (x - M.minx) * sx, Z = z => (z - M.minz) * sz;
g.fillStyle = '#0d1520'; g.fillRect(0, 0, W, H);
if (World.minimapCanvas) {
// GLB-Szene: begehbare Fläche aus dem Raster
g.imageSmoothingEnabled = true;
g.drawImage(World.minimapCanvas, 0, 0, W, H);
} else {
g.fillStyle = '#16202c';
World.ZONES.forEach(z => g.fillRect(X(z.x - z.w / 2), Z(z.z - z.h / 2), z.w * sx, z.h * sz));
g.fillStyle = '#3d4f63';
World.walls.forEach(w => g.fillRect(X(w.x - w.w), Z(w.z - w.h), w.w * 2 * sx, w.h * 2 * sz));
}
if (big) {
g.fillStyle = '#7d92a8'; g.font = '11px system-ui'; g.textAlign = 'center';
World.ZONES.forEach(z => g.fillText(z.name, X(z.x), Z(z.z)));
}
// Tür-Portale (GLB-Szene)
(World.doors || []).forEach(d => {
g.fillStyle = '#5ad8ef';
g.fillRect(X(d.ax) - 2, Z(d.az) - 2, 4, 4);
g.fillRect(X(d.bx) - 2, Z(d.bz) - 2, 4, 4);
});
S.cases.forEach((cs, i) => {
const sp = spawnOf(cs);
let col = null;
if (cs.solved) col = '#5a8a5e';
else if (i === S.caseIndex) col = cs.found ? '#e8c547' : '#c85c4a';
if (!col) return;
g.beginPath(); g.arc(X(sp[0]), Z(sp[1]), big ? 7 : 4.5, 0, 7);
g.fillStyle = col; g.fill();
if (i === S.caseIndex && !cs.solved) {
const t = performance.now() / 600;
g.beginPath(); g.arc(X(sp[0]), Z(sp[1]), (big ? 8 : 5) + (t % 1) * 8, 0, 7);
g.strokeStyle = col + '88'; g.stroke();
}
});
const p = World.player;
g.save(); g.translate(X(p.x), Z(p.z)); g.rotate(-p.yaw);
g.fillStyle = '#9fd8ef';
g.beginPath(); g.moveTo(0, -7); g.lineTo(5, 6); g.lineTo(-5, 6); g.closePath(); g.fill();
g.restore();
},
};
/* ============================================================
* SPIELLOGIK
* ============================================================ */
const Game = {
async newRound(cfg) {
cfg.airport = KD_AIRPORTS[Math.floor(Math.random() * KD_AIRPORTS.length)].name;
const wantMode = cfg.scene === 'glb' ? 'glb' : 'klassisch';
// Theme (Reskin) je Flughafen — andere Stimmung erfordert Neuaufbau der Halle
const wantTheme = World.themeForAirport(cfg.airport);
const themeChange = wantMode === 'klassisch' && World.themeId !== wantTheme;
if (World.mode !== wantMode || themeChange || !World.scene) {
World.pendingTheme = wantTheme;
UI.toast('✈️ ' + (KD_AIRPORT_THEMES[wantTheme] ? KD_AIRPORT_THEMES[wantTheme].name + ' — ' : '') + 'Szene wird geladen …');
await World.build(wantMode);
}
cfg.scene = World.mode === 'glb' ? 'glb' : 'klassisch'; // ggf. Fallback festhalten
S = freshState(cfg);
S.cases = buildCases(cfg);
if (World.mode === 'glb') S.glbSpawns = World.SPAWNS; // generierte Spawns persistieren (Resume!)
S.phase = 'suche';
save();
World.caseMeshes = {};
World.clearFunds();
World.player.x = World.startPos.x; World.player.z = World.startPos.z; World.player.yaw = World.startPos.yaw;
World.spawnDrop();
World.syncCases();
World.setAirport(cfg.airport);
World.buildPosters(S.cases.map(x => x.iso2));
UI.showGame();
UI.toast('🛬 Spätdienst am ' + cfg.airport + ' — neue Meldung: ' + spawnOf(curCase())[2]);
Sound.play('radar-ping', 0.5);
Sound.playPA('pa-willkommen', 0.5);
Sound.startAmbience();
Music.maybeStart();
PA.start();
},
resume() {
if (S.glbSpawns && World.mode === 'glb') World.SPAWNS = S.glbSpawns;
World.player.x = World.startPos.x; World.player.z = World.startPos.z; World.player.yaw = World.startPos.yaw;
World.spawnDrop();
World.syncCases();
World.setAirport(S.cfg.airport || 'Flughafen');
World.buildPosters(S.cases.map(x => x.iso2));
UI.showGame();
if (S.phase === 'analyse') UI.openAnalysis();
else if (S.phase === 'reflexion') UI.showRoundResult();
UI.toast('Willkommen zurück — dein Durchgang wurde fortgesetzt.');
Sound.startAmbience();
PA.start();
},
tryInteract() {
if (UI.overlayOpen() || !S || S.phase !== 'suche') return;
const idx = World.nearestCase();
if (idx === null || idx !== S.caseIndex) {
// kein Koffer in Reichweite → erst Fundsache aufheben, dann Plakat/Info
const fund = World.nearestFund(2.0);
if (fund) { World.collectFund(fund); return; }
const pi = World.nearestPoster(2.4);
if (pi) { Sound.play('paper', 0.4); UI.showCountryCard(pi.iso2, false); return; }
const ip = World.nearestInfo(2.2);
if (ip) { Sound.play('paper', 0.4); UI.showInfoPoint(ip); }
return;
}
const cs = curCase();
if (!cs.found) { cs.found = true; Sound.play('case-open', 0.6); }
S.phase = 'analyse';
save();
UI.openAnalysis();
},
/* Gegenstand untersuchen. Sichtbar sind immer nur 3 Objekte gleichzeitig
* (Pflichtenheft §18: die Reihenfolge nach Landesklasse muss greifen —
* freie Auswahl aus allen 10 würde sie aushebeln). */
revealHint(key, fromEl) {
const cs = curCase();
const isNew = !cs.revealed.includes(key);
if (isNew) {
// Sicherheitsnetz: nur Objekte des aktuellen 3er-Blocks sind aufdeckbar
const upto = UI.visibleUpto(cs);
if (cs.hintKeys.indexOf(key) >= upto) return;
cs.revealed.push(key);
Sound.play('paper', 0.5);
save();
}
UI.startInspect(key, fromEl, !isNew);
},
guess(iso2) {
const cs = curCase();
if (iso2 === cs.iso2) {
cs.solved = true;
Atlas.markSolved(cs.iso2); // Länderatlas: grün
cs.points = Math.max(10, CASE_BASE
- Math.max(0, cs.revealed.length - 1) * HINT_COST
- cs.wrongGuesses * WRONG_COST
- (cs.timeoutHit ? TIMEOUT_COST : 0));
S.totalPoints += cs.points;
S.phase = 'ergebnis';
Sound.play('correct', 0.6);
save();
// Achievement-Check: ein neues Achievement freigeschaltet?
if (typeof Achievements !== 'undefined') {
const newAch = Achievements.checkNew();
if (newAch.length && typeof UI.toastAchievement === 'function') {
newAch.forEach(a => UI.toastAchievement(a));
}
}
UI.showCaseResult(true);
} else {
cs.wrongGuesses++;
Sound.play('wrong', 0.5);
save();
const w = KD_WORLD.find(x => x[0] === iso2);
UI.guessFeedback((w ? w[1] : 'Dieses Land') + ' ist es nicht. Vergleiche die Hinweise noch einmal — du kannst weitere Gegenstände untersuchen.');
UI.renderAnalysis();
}
},
giveUp() {
const cs = curCase();
cs.givenUp = true; cs.points = 0;
S.phase = 'ergebnis';
save();
UI.showCaseResult(false);
},
nextCase() {
if (S.caseIndex < S.cases.length - 1) {
S.caseIndex++;
S.phase = 'suche';
save();
World.syncCases();
UI.closeOverlays();
UI.toast('Neue Meldung: ' + spawnOf(curCase())[2]);
Sound.play('radar-ping', 0.5);
} else {
S.phase = 'reflexion';
save();
Sound.play('complete', 0.6);
UI.showRoundResult();
}
},
tick(dt) {
if (!S) return;
const cs = curCase();
if (!cs || S.phase === 'ergebnis' || S.phase === 'reflexion' || S.phase === 'fertig') return;
const limit = TIME_LIMITS[S.cfg.timeLimit];
if ((S.phase === 'suche' || S.phase === 'analyse') && !cs.solved) {
cs.elapsed += dt;
if (limit && !cs.timeoutHit && cs.elapsed > limit) {
cs.timeoutHit = true;
UI.toast('⏱️ Zeit abgelaufen — du kannst in Ruhe weiterarbeiten, der Fall bringt etwas weniger Punkte.');
save();
}
}
if (Date.now() - lastSaveAt > 5000) save();
},
results() {
const cases = S.cases;
const solved = cases.filter(c => c.solved);
return {
cases_total: cases.length,
cases_solved: solved.length,
correct_first_try: solved.filter(c => c.wrongGuesses === 0).length,
wrong_guesses: cases.reduce((a, c) => a + c.wrongGuesses, 0),
hints_used_total: cases.reduce((a, c) => a + c.revealed.length, 0),
hints_used_avg: cases.length ? Math.round(cases.reduce((a, c) => a + c.revealed.length, 0) / cases.length * 10) / 10 : 0,
timeouts: cases.filter(c => c.timeoutHit).length,
points_total: S.totalPoints,
level: S.cfg.level,
continent_filter: S.cfg.continent,
time_limit: S.cfg.timeLimit,
airport: S.cfg.airport,
countries: cases.map(c => ({ iso2: c.iso2, solved: c.solved, points: c.points, hints: c.revealed.length, wrong: c.wrongGuesses })),
};
},
score100() {
if (!S || !S.cases.length) return 0;
return Math.round(S.totalPoints / (S.cases.length * CASE_BASE) * 100);
},
stars() {
const q = this.score100() / 100;
return q >= 0.7 ? 3 : q >= 0.45 ? 2 : q >= 0.2 ? 1 : 0;
},
};
/* ============================================================
* PLATTFORM (GeoGraSim)
* ============================================================ */
const Platform = {
submit() {
if (!S || S.submitted) return;
S.submitted = true; save();
if (!GGS || !GGS.apiUrl) return;
fetch(GGS.apiUrl + '/progress.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
body: JSON.stringify({
sim_id: SIM_ID, action: 'submit_assessment',
data: {
level: S.cfg.level,
stars: Game.stars(),
score: Game.score100(),
duration_ms: (window.__GGS_LIVE__ && window.__GGS_LIVE__.activeMs) || 0,
completed: true,
results: Game.results(),
},
}),
}).catch(() => {});
},
reflect(question, answer) {
if (!GGS || !GGS.apiUrl || !answer.trim()) return;
fetch(GGS.apiUrl + '/progress.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
body: JSON.stringify({
sim_id: SIM_ID, action: 'reflection',
data: { level: S.cfg.level, question, answer: answer.trim().slice(0, 2000) },
}),
}).catch(() => {});
},
};
window.GGS_LIVE_STATE = function () {
if (!gameReady || !S) return null;
const cs = curCase();
const limit = TIME_LIMITS[S.cfg.timeLimit];
const solved = S.cases.filter(c => c.solved).length;
const phaseLabel = S.phase === 'suche' ? 'Fall ' + (S.caseIndex + 1) + ' · Suche'
: S.phase === 'analyse' ? 'Fall ' + (S.caseIndex + 1) + ' · Analyse'
: S.phase === 'reflexion' ? 'Auswertung' : S.phase;
const wrongHere = cs ? cs.wrongGuesses : 0;
return {
simId: SIM_ID,
phase: phaseLabel,
progressPct: Math.round((solved + (cs && cs.found && !cs.solved ? 0.5 : 0)) / S.cases.length * 100),
score: Game.score100(),
health: wrongHere >= 3 || (cs && cs.timeoutHit && !cs.solved) ? 'struggle' : wrongHere >= 1 ? 'ok' : 'good',
cases_solved: solved,
cases_total: S.cases.length,
case_index: S.caseIndex + 1,
case_location: cs ? spawnOf(cs)[2] : null,
// case_country wird NICHT im Live-State geliefert (Datenschutz: Schüler:in
// könnte über DevTools die Antwort lesen). Lehrer-Cockpit liest case_iso2
// und übersetzt server-seitig zu Ländername. Sobald Lehrer-Backend
// den Lookup hat, kann hier case_country komplett raus.
case_iso2: cs ? cs.iso2 : null,
case_country: cs && cs.solved ? country(cs.iso2).name : null, // nur nach Lösung
hints_revealed: cs ? cs.revealed.length : 0,
wrong_guesses_total: S.cases.reduce((a, c) => a + c.wrongGuesses, 0),
time_left_s: limit && cs && !cs.solved ? Math.max(0, Math.round(limit - cs.elapsed)) : null,
level: S.cfg.level,
airport: S.cfg.airport,
};
};
window.GGS_TUTORIAL = {
simId: SIM_ID,
title: '🕵️ Kofferdetektiv',
replay: function () { UI.openTutorial(); },
};
/* ============================================================
* UI
* ============================================================ */
const UI = {
tutorialStep: 0,
pickerContinent: 'alle',
bigMap: false,
overlayOpen() {
return ['ov-tutorial', 'ov-analysis', 'ov-case-result', 'ov-round-result', 'ov-start', 'ov-confirm',
'ov-bigmap', 'ov-country', 'ov-atlas', 'ov-giveup', 'ov-info', 'ov-badge', 'ov-badges']
.some(id => { const e = $(id); return e && e.classList.contains('visible'); });
},
/* ---------- Länderatlas-Weltkarte ---------- */
openAtlas() {
this._reopenAtlas = false;
if (!this._atlasBuilt && worldAll.length) {
const NS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', '-180 -84 360 144'); // y = −lat (Antarktis abgeschnitten)
worldAll.forEach(entry => {
// Antimeridian-Behandlung: springt die Länge zwischen zwei Punkten um
// mehr als 180° (z. B. Russland/Alaska über die 180°-Linie), Teilpfad
// neu beginnen — sonst zieht die Füll-Linie einen Streifen quer übers Bild.
const d = entry.rings.map(r => {
// Kreuzt der Ring die 180°-Linie (Russland, Fidschi …), springt die
// Länge um >180° und zieht sonst einen Streifen quer übers Bild.
// Lösung: negative Längen um +360 verschieben → der Ring wird
// zusammenhängend, der Überhang jenseits 180° wird am Rand abgeschnitten.
const crosses = r.some((p, i) => i > 0 && Math.abs(p[0] - r[i - 1][0]) > 180);
const pts = crosses ? r.map(p => [p[0] < 0 ? p[0] + 360 : p[0], p[1]]) : r;
return 'M' + pts.map(p => p[0].toFixed(1) + ',' + (-p[1]).toFixed(1)).join('L') + 'Z';
}).join('');
const path = document.createElementNS(NS, 'path');
path.setAttribute('d', d);
path.setAttribute('class', 'ac');
path.dataset.iso = entry.iso || '';
path.dataset.name = entry.name;
const title = document.createElementNS(NS, 'title');
title.textContent = entry.iso ? (country(entry.iso) || {}).name || entry.name : entry.name;
path.appendChild(title);
path.addEventListener('click', () => {
if (path.dataset.iso) this.showCountryCard(path.dataset.iso, true);
else this.toast(entry.name + ' — zu diesem Land gibt es (noch) keine Fall-Daten.');
});
svg.appendChild(path);
});
$('atlas-svg-wrap').appendChild(svg);
this._atlasBuilt = true;
}
// Status-Farben aktualisieren
document.querySelectorAll('#atlas-svg-wrap .ac').forEach(p => {
p.classList.remove('pool', 'read', 'solved');
const iso = p.dataset.iso;
if (!iso) return;
const st = Atlas.status(iso);
p.classList.add(st || 'pool');
});
Atlas.load();
const read = Object.keys(Atlas.data.read).length;
const mainTot = Atlas.mainCountries().length, mainSolved = Atlas.mainSolved();
const platTot = KD_COUNTRIES.length, platSolved = Atlas.solvedCount();
$('atlas-stats').innerHTML =
'🌍 Haupt-Welt: ' + mainSolved + ' / ' + mainTot + ' gelöst' +
' · 💎 Platin-Liga: ' + (platSolved - mainSolved) + ' / ' + (platTot - mainTot) + ' ' +
' · ' + read + ' Länder gelesen (gelb).';
// Prestige-Anzeige: goldene + Platin-Weltkugeln
const gg = Gold.globes(), pg = Gold.platinum();
let goldHtml = '';
if (gg > 0) goldHtml += ' Gold: ' + gg + ' ';
if (pg > 0) goldHtml += ' Platin: ' + pg + ' ';
$('atlas-gold').innerHTML = goldHtml;
$('atlas-gold').style.display = goldHtml ? '' : 'none';
// Reset-Button: Platin schlägt Gold (das höhere Ziel zuerst anbieten)
const ar = $('atlas-reset'), plat = Atlas.platinumComplete(), main = Atlas.complete();
ar.style.display = (plat || main) ? '' : 'none';
ar.dataset.tier = plat ? 'platinum' : 'gold';
ar.innerHTML = plat
? '💎 Platin-Weltkugel holen & Atlas zurücksetzen'
: '🌍✨ Goldene Weltkugel holen & Atlas zurücksetzen';
ar.classList.toggle('kd-btn-platinum', plat);
this.show('ov-atlas');
},
// „Leichte“ Overlays brauchen keine Auswahl → wir bleiben im Blickmodus
// (Pointer-Lock aktiv), pausieren nur kurz den Maus-Blick und schließen per
// Leertaste/E/Klick. Alles andere ist Cursor-Modus.
LIGHT_OVERLAYS: ['ov-badge', 'ov-case-result', 'ov-country', 'ov-bigmap', 'ov-info'],
isLight(id) { return this.LIGHT_OVERLAYS.indexOf(id) >= 0; },
uiPause: false,
closeOverlays(noRelock) {
document.querySelectorAll('.kd-overlay.visible').forEach(e => e.classList.remove('visible'));
this.bigMap = false;
this.uiPause = false;
this.updateHud();
// Auto-Re-Lock: zurück ins Spiel → Maus wieder fangen, ohne erneut zu klicken.
// Nicht beim Overlay-Wechsel (show ruft closeOverlays zuerst) und nicht nach Esc.
if (!this._switching && !noRelock && !IS_TOUCH &&
S && S.phase === 'suche' && !World.locked) World.requestLock();
},
show(id) {
this._switching = true;
this.closeOverlays();
this._switching = false;
const light = this.isLight(id) && World.locked && !IS_TOUCH;
// Cursor-Overlays geben die Maus frei; Light-Overlays behalten den Lock
if (!light && document.exitPointerLock) document.exitPointerLock();
this.uiPause = light; // bei Light: Maus-Blick pausieren, Lock behalten
$(id).classList.add('visible');
},
toast(msg) {
const t = $('toast');
t.textContent = msg; t.classList.add('visible');
clearTimeout(this._tt);
this._tt = setTimeout(() => t.classList.remove('visible'), 4800);
},
/* ---------- Start ---------- */
showStart() {
this.show('ov-start');
const forced = GGS && GGS.mode === 'teacher_started' && GGS.forcedLevel;
if (forced) {
document.querySelectorAll('input[name="cfg-level"]').forEach(r => {
r.checked = +r.value === +GGS.forcedLevel; r.disabled = true;
});
$('cfg-level-note').textContent = 'Deine Lehrperson hat Stufe ' + GGS.forcedLevel + ' festgelegt.';
}
},
startFromForm() {
const cfg = {
level: +(document.querySelector('input[name="cfg-level"]:checked') || {}).value || 1,
caseCount: +(document.querySelector('input[name="cfg-cases"]:checked') || {}).value || 4,
timeLimit: (document.querySelector('input[name="cfg-time"]:checked') || {}).value || 'mittel',
continent: $('cfg-continent').value,
scene: (document.querySelector('input[name="cfg-scene"]:checked') || {}).value || 'klassisch',
};
Game.newRound(cfg);
World.requestLock(); // Maus sofort fangen — kein „erst herumklicken"
},
showGame() {
this.closeOverlays();
$('hud').style.display = '';
$('hdr-level').textContent = 'Stufe ' + S.cfg.level;
World.resize();
this.updateHud();
},
/* ---------- HUD ---------- */
updateHud() {
if (!S) return;
const cs = curCase();
$('hud-case').textContent = 'Fall ' + (S.caseIndex + 1) + ' / ' + S.cases.length;
$('hud-points').textContent = S.totalPoints + ' P';
$('hud-loc').textContent = cs && !cs.solved ? '📍 ' + spawnOf(cs)[2] : '';
const limit = TIME_LIMITS[S.cfg.timeLimit];
const tEl = $('hud-timer');
if (limit && cs && !cs.solved && S.phase !== 'reflexion') {
const left = Math.max(0, limit - cs.elapsed);
const m = Math.floor(left / 60), s = Math.floor(left % 60);
tEl.textContent = '⏱️ ' + (cs.timeoutHit ? 'Zeit vorbei' : m + ':' + String(s).padStart(2, '0'));
tEl.classList.toggle('warn', !cs.timeoutHit && left < 60);
} else tEl.textContent = '';
const d = S.phase === 'suche' ? World.caseDistance() : null;
const near = d !== null && d < 2.8;
const free = !near && S.phase === 'suche' && !this.overlayOpen();
const fundNear = free ? World.nearestFund(2.0) : null;
const posterNear = free && !fundNear ? World.nearestPoster(2.4) : null;
const infoNear = free && !fundNear && !posterNear ? World.nearestInfo(2.2) : null;
$('prompt').textContent = near ? 'E — Gepäckstück untersuchen'
: fundNear ? 'E — ' + fundNear.userData.fund.emoji + ' ' + fundNear.userData.fund.name + ' aufheben'
: posterNear ? 'E — Informationen lesen'
: infoNear ? 'E — ' + infoNear.icon + ' ' + infoNear.title : '';
const anyPrompt = near || !!fundNear || !!posterNear || !!infoNear;
$('prompt').classList.toggle('visible', anyPrompt);
$('btn-touch-act').classList.toggle('visible', anyPrompt && IS_TOUCH);
$('hud-dist').textContent = (d !== null && !near) ? Math.round(d) + ' m' : '';
// Raumkoordinaten + Blickrichtung (zum genauen Mitteilen von Positionen).
// Konvention: −z = Nord ↑, +x = Ost →, +z = Süd ↓, −x = West ←
const p = World.player;
const dx = -Math.sin(p.yaw), dz = -Math.cos(p.yaw);
let head = Math.atan2(dx, -dz) * 180 / Math.PI; if (head < 0) head += 360;
const COMPASS = ['↑ N', '↗ NO', '→ O', '↘ SO', '↓ S', '↙ SW', '← W', '↖ NW'];
const dir = COMPASS[Math.round(head / 45) % 8];
$('hud-coords').textContent = 'x ' + p.x.toFixed(1) + ' z ' + p.z.toFixed(1) +
' ' + dir + ' ' + Math.round(head) + '°';
// Maus-Hinweis unten: im Wechsel je nach Modus (Blickmodus ↔ Cursor)
const lh = $('lock-hint');
const showHint = !IS_TOUCH && S.phase === 'suche' && !this.overlayOpen();
lh.classList.toggle('visible', showHint);
if (showHint) lh.textContent = World.locked
? '⎋ Esc gibt die Maus frei'
: '🖱️ Klicken, um dich umzusehen';
this.updateModeBadge();
},
/* Modus-Anzeige rechts oben: zeigt, ob die Maus den Blick steuert (Blickmodus)
* oder frei ist (Cursor-Modus) — inkl. Kurzhinweis zum Umschalten. */
updateModeBadge() {
const el = $('mode-badge'); if (!el) return;
if (IS_TOUCH || !S || S.phase !== 'suche' || this.overlayOpen()) { el.style.display = 'none'; return; }
el.style.display = '';
if (World.locked) {
el.className = 'look';
el.innerHTML = '🎯 Blickmodus
' +
'Maus dreht den Blick · Esc = Maus frei
';
} else {
el.className = 'cursor';
el.innerHTML = '🖱️ Maus frei
' +
'Menüs bedienen · Klick ins Bild = umsehen
';
}
},
toggleBigMap() {
if (this.bigMap) { $('ov-bigmap').classList.remove('visible'); this.bigMap = false; this.updateHud(); }
else if (!this.overlayOpen()) { this.show('ov-bigmap'); this.bigMap = true; }
},
/* ---------- Tutorial (mit Story-Logik & Sicherheits-Didaktik) ---------- */
TUT: [
{ img: 'intro-terminal', h: 'Willkommen im Spätdienst', t: 'Der letzte Flug ist gelandet, der Flughafen schließt. Du arbeitest in der Gepäckermittlung: Einige Gepäckstücke konnten heute keinem Flug mehr zugeordnet werden. Deine Aufgabe: Finde sie und bestimme ihr Herkunftsland — nur so können sie ihren Weg nach Hause finden.' },
{ img: 'intro-radar', h: 'Warum wir die Koffer suchen dürfen', t: 'Reisende haben den Verlust gemeldet und dabei den ungefähren Ort angegeben — genau diese Meldungen siehst du als rote Punkte auf deinem Radar. Wichtig: Alle Gepäckstücke wurden bereits von der Sicherheitskontrolle durchleuchtet. Es besteht keine Gefahr. (Echte Regel am Flughafen: Lass dein Gepäck nie unbeaufsichtigt — und melde verlorene Stücke sofort!)' },
{ img: 'intro-radar', h: 'Bewegung & die zwei Modi', t: 'Bewege dich mit W A S D oder den Pfeiltasten — Shift = schneller, Leertaste = springen. Es gibt zwei Modi: Klick ins Bild → BLICKMODUS (die Maus dreht den Blick). Esc → MAUS FREI (für Menüs und Links). Rechts oben unter der Karte siehst du jederzeit, in welchem Modus du bist. In Menüs musst du nicht klicken: Leertaste oder E = weiter/OK, F/G/V = weitere Auswahl (steht auf den Knöpfen). M öffnet die große Karte; I = Sammlung, L = Atlas, H = diese Anleitung. Am Tablet: linker Daumen bewegt, rechter dreht den Blick.' },
{ img: 'intro-koffer', h: 'Gepäck untersuchen', t: 'Beim Gepäckstück angekommen, öffnest du es mit E (oder Antippen). Du siehst den Koffer von oben: Tippe die Gegenstände darin an, um sie zu untersuchen. Jeder enthält einen geografischen Hinweis — manche abstrakt (Einwohnerzahl), manche sehr direkt (Flagge, Foto eines Wahrzeichens).' },
{ img: 'intro-welt', h: 'Das Land bestimmen', t: 'Du kannst jederzeit eine Vermutung abgeben — über das Suchfeld mit Kontinentfilter. Je weniger Gegenstände du brauchst, desto mehr Punkte erhältst du. Eine falsche Vermutung kostet Punkte, beendet den Fall aber nicht.' },
{ img: 'intro-terminal', h: 'Dein Ziel', t: 'Jeder Dienst findet an einem anderen Flughafen der Welt statt. Löse alle gemeldeten Fälle — am Ende siehst du deine Auswertung. Und hör ruhig auf die Durchsagen: Der Feierabend-Betrieb läuft weiter. Viel Erfolg, Detektiv:in!' },
],
openTutorial() {
this.tutorialStep = 0; this.renderTutorial(); this.show('ov-tutorial');
},
renderTutorial() {
const s = this.TUT[this.tutorialStep];
$('tut-img').src = BASE + 'assets/img/intro/' + s.img + '.webp';
$('tut-h').textContent = s.h;
$('tut-t').textContent = s.t;
$('tut-dots').innerHTML = this.TUT.map((_, i) =>
' ').join('');
$('tut-back').style.visibility = this.tutorialStep ? 'visible' : 'hidden';
$('tut-next').textContent = this.tutorialStep === this.TUT.length - 1 ? 'Los geht’s!' : 'Weiter ▸';
},
tutNext() {
if (this.tutorialStep < this.TUT.length - 1) { this.tutorialStep++; this.renderTutorial(); }
else {
try { localStorage.setItem('ggs.intro.' + SIM_ID, '1'); } catch (_) {}
this.closeOverlays();
if (!S) this.showStart();
}
},
/* ---------- Analyse: Koffer-Grafik, 3er-Staffelung, Inspektions-Bühne ---------- */
// Slot-Positionen (in % der Kofferfläche)
SLOTS: [[24, 26], [52, 22], [76, 30], [30, 46], [62, 44], [80, 56], [22, 64], [48, 62], [70, 76], [34, 80]],
/* Wie viele Sequenz-Positionen sind freigeschaltet? 3er-Blöcke:
* erst wenn ein Block komplett untersucht ist, erscheint der nächste. */
visibleUpto(cs) {
return Math.min(cs.hintKeys.length, (Math.floor(cs.revealed.length / 3) + 1) * 3);
},
openAnalysis() {
this.pickerContinent = 'alle';
$('picker-search').value = '';
$('picker-panel').classList.remove('visible');
const cs = curCase();
const lt = KD_LUGGAGE_TYPES.find(t => t.id === cs.luggage) || KD_LUGGAGE_TYPES[0];
$('ana-case').style.backgroundImage = 'url("' + BASE + 'assets/img/koffer/' + lt.img + '.webp")';
this.closeInspectNow();
this.renderAnalysis();
this.show('ov-analysis');
},
renderAnalysis() {
const cs = curCase();
$('ana-title').textContent = 'Fall ' + (S.caseIndex + 1) + ': ' +
(KD_LUGGAGE_TYPES.find(t => t.id === cs.luggage) || {}).label + ' · ' + spawnOf(cs)[2];
const upto = this.visibleUpto(cs);
$('ana-status').textContent = cs.revealed.length + ' von ' + cs.hintKeys.length + ' Gegenständen untersucht' +
(upto < cs.hintKeys.length ? ' · weitere kommen beim Weitersuchen zum Vorschein' : '') +
(cs.wrongGuesses ? ' · ' + cs.wrongGuesses + ' falsche Vermutung' + (cs.wrongGuesses > 1 ? 'en' : '') : '');
// Koffer: nur die UNERLEDIGTEN Objekte des aktuellen Blocks (max 3)
const wrap = $('ana-objects'); wrap.innerHTML = '';
cs.hintKeys.slice(0, upto).forEach((key, i) => {
if (cs.revealed.includes(key)) return; // liegt schon im Protokoll
const cat = KD_CATEGORIES.find(x => x.key === key);
const chip = document.createElement('button');
chip.className = 'obj-chip';
chip.dataset.key = key;
chip.style.left = this.SLOTS[i % this.SLOTS.length][0] + '%';
chip.style.top = this.SLOTS[i % this.SLOTS.length][1] + '%';
const img = document.createElement('img');
img.className = 'oc-img';
img.alt = cat.object;
img.src = BASE + 'assets/img/objekte-frei/' + key + '.webp';
img.onerror = () => {
img.onerror = () => { img.outerHTML = '' + cat.icon + ' '; };
img.src = BASE + 'assets/img/objekte/' + key + '.webp';
};
chip.appendChild(img);
const lab = document.createElement('span');
lab.className = 'oc-label';
lab.textContent = cat.object;
chip.appendChild(lab);
chip.onclick = () => Game.revealHint(key, chip);
wrap.appendChild(chip);
});
// Hinweisprotokoll als Karten
const cards = $('ana-cards'); cards.innerHTML = '';
if (!cs.revealed.length) {
cards.innerHTML = 'Untersuchte Gegenstände landen hier im Protokoll.
';
}
const c = country(cs.iso2);
cs.revealed.forEach(k => {
const cat = KD_CATEGORIES.find(x => x.key === k);
const card = document.createElement('button');
card.className = 'log-card';
card.dataset.key = k;
card.innerHTML =
' ' +
'' + cat.title + ' ' +
(cat.special === 'flag' ? 'Flaggen-Aufnäher (ansehen)' :
cat.special === 'outline' ? 'Umriss-Sticker (ansehen)' : cat.text(c)) + ' ';
card.onclick = () => Game.revealHint(k, card);
cards.appendChild(card);
});
this.renderPicker();
this.updateHud();
},
/* ---------- Inspektions-Bühne: Objekt fliegt aus dem Koffer, zeigt seinen
* Hinweis „aufgedruckt“, und wandert danach ins Protokoll ---------- */
inspectKey: null, inspectViewOnly: false,
startInspect(key, fromEl, viewOnly) {
this.inspectKey = key; this.inspectViewOnly = !!viewOnly;
const cs = curCase(), c = country(cs.iso2);
const cat = KD_CATEGORIES.find(x => x.key === key);
// Bühne befüllen
$('os-cat').innerHTML = cat.icon + ' ' + cat.title +
' ' + ['', 'abstrakt', 'mittel', 'direkt'][cat.level] + ' ' +
' ? ';
const big = $('os-img');
big.src = BASE + 'assets/img/objekte-frei/' + key + '.webp';
big.onerror = () => { big.onerror = null; big.src = BASE + 'assets/img/objekte/' + key + '.webp'; };
let paper = '' + cat.text(c) + '
';
if (cat.special === 'flag') paper += ' ';
if (cat.special === 'outline') paper += ' ';
if (key === 'landmark') paper = ' ' + paper;
$('os-paper').innerHTML = paper;
const og = $('os-outline'); if (og) this.drawOutline(og, c.iso2);
const gb = $('os-gloss'); if (gb) gb.onclick = e => { e.stopPropagation(); this.toast('💡 ' + KD_GLOSSARY[key]); };
// Flug: vom Chip/von der Karte zur Bühne
this.fly(fromEl, $('os-img'), () => {
$('obj-stage').classList.add('visible');
});
},
/* FLIP-Flug eines Bild-Klons von A nach B */
fly(fromEl, toEl, done) {
const flyer = $('flyer');
const fi = fromEl && (fromEl.querySelector('img') || fromEl);
const src = fi && fi.tagName === 'IMG' ? fi.src : ($('os-img') || {}).src;
if (!fromEl || !src) { done(); return; }
const a = fromEl.getBoundingClientRect();
flyer.src = src;
flyer.style.cssText = 'display:block;left:' + a.left + 'px;top:' + a.top + 'px;width:' + a.width + 'px;height:' + a.height + 'px;';
requestAnimationFrame(() => {
const stage = $('ana-case').getBoundingClientRect();
const b = toEl === $('os-img')
? { left: stage.left + stage.width * 0.5 - 130, top: stage.top + stage.height * 0.5 - 150, width: 260, height: 260 }
: toEl.getBoundingClientRect();
flyer.style.transition = 'all .45s cubic-bezier(.22,1,.36,1)';
flyer.style.left = b.left + 'px'; flyer.style.top = b.top + 'px';
flyer.style.width = b.width + 'px'; flyer.style.height = (b.height || b.width) + 'px';
setTimeout(() => { flyer.style.cssText = 'display:none'; done(); }, 460);
});
},
/* Bühne schließen: Objekt fliegt (bei Neufund) in seine Protokoll-Karte */
closeInspect() {
const key = this.inspectKey;
if (!key) return;
$('obj-stage').classList.remove('visible');
this.renderAnalysis();
const card = document.querySelector('.log-card[data-key="' + key + '"]');
const stageImg = $('os-img');
if (card && stageImg) {
// Flug von der Bühnen-Position zur Karte
const fakeFrom = document.createElement('div');
const stage = $('ana-case').getBoundingClientRect();
fakeFrom.style.cssText = 'position:fixed;left:' + (stage.left + stage.width * 0.5 - 130) + 'px;top:' +
(stage.top + stage.height * 0.5 - 150) + 'px;width:260px;height:260px;pointer-events:none';
const im = document.createElement('img'); im.src = stageImg.src; fakeFrom.appendChild(im);
document.body.appendChild(fakeFrom);
this.fly(fakeFrom, card, () => {
fakeFrom.remove();
card.classList.add('flash');
setTimeout(() => card.classList.remove('flash'), 700);
});
}
this.inspectKey = null;
},
closeInspectNow() {
$('obj-stage').classList.remove('visible');
this.inspectKey = null;
},
drawOutline(cv, iso) {
const entry = outlines[iso]; if (!entry) return;
const rings = entry.rings || entry; // altes Format: nacktes Ring-Array
const cap = entry.cap || null;
const g = cv.getContext('2d');
let minLon = 1e9, maxLon = -1e9, minY = 1e9, maxY = -1e9;
rings.forEach(r => r.forEach(p => {
minLon = Math.min(minLon, p[0]); maxLon = Math.max(maxLon, p[0]);
minY = Math.min(minY, p[1]); maxY = Math.max(maxY, p[1]);
}));
// Aspektkorrektur: 1° Länge ist um cos(Breite) kürzer als 1° Breite.
// Ohne sie wirken die Umrisse vertikal gestaucht / zu breit.
const k = Math.abs(Math.cos((minY + maxY) / 2 * Math.PI / 180)) || 1;
const minX = minLon * k, maxX = maxLon * k;
const pad = 10, sc = Math.min((cv.width - pad * 2) / (maxX - minX), (cv.height - pad * 2) / (maxY - minY));
const ox = (cv.width - (maxX - minX) * sc) / 2, oy = (cv.height - (maxY - minY) * sc) / 2;
const px = lon => ox + (lon * k - minX) * sc, py = lat => oy + (maxY - lat) * sc;
g.clearRect(0, 0, cv.width, cv.height);
g.fillStyle = '#dceada'; g.strokeStyle = '#1f4b37'; g.lineWidth = 1.4;
rings.forEach(r => {
g.beginPath();
r.forEach((p, i) => { i ? g.lineTo(px(p[0]), py(p[1])) : g.moveTo(px(p[0]), py(p[1])); });
g.closePath(); g.fill(); g.stroke();
});
// Hauptstadt als Punkt mit Ring markieren
if (cap) {
const cx = px(cap[0]), cy = py(cap[1]);
g.beginPath(); g.arc(cx, cy, 5.5, 0, 7);
g.strokeStyle = '#b04a3e'; g.lineWidth = 1.6; g.stroke();
g.beginPath(); g.arc(cx, cy, 2.6, 0, 7);
g.fillStyle = '#b04a3e'; g.fill();
}
},
/* ---------- Länderwahl ---------- */
togglePicker(open) {
$('picker-panel').classList.toggle('visible', open);
if (open) { $('picker-search').focus(); this.renderPicker(); }
},
renderPicker() {
const q = ($('picker-search').value || '').toLowerCase().trim();
const cont = this.pickerContinent;
document.querySelectorAll('.cont-chip').forEach(ch => ch.classList.toggle('on', ch.dataset.c === cont));
const list = $('picker-list'); list.innerHTML = '';
let shown = 0;
KD_WORLD.forEach(([iso, name, cc]) => {
if (cont !== 'alle' && cc !== cont) return;
if (q && !name.toLowerCase().includes(q)) return;
if (shown >= 60) return;
shown++;
const b = document.createElement('button');
b.className = 'picker-item';
b.innerHTML = '' + name + ' ' + KD_CONTINENTS[cc] + ' ';
b.onclick = () => { this.togglePicker(false); Game.guess(iso); };
list.appendChild(b);
});
if (!shown) list.innerHTML = 'Kein Land gefunden — Suchbegriff oder Filter prüfen.
';
},
guessFeedback(msg) {
const f = $('ana-feedback');
f.textContent = msg; f.classList.add('visible');
clearTimeout(this._ft);
this._ft = setTimeout(() => f.classList.remove('visible'), 5000);
},
/* ---------- Fall-Ergebnis ---------- */
/* Die Visitenkarte (#vk) ist EIN DOM-Knoten, der je nach Kontext umzieht:
* Fall-Auflösung, Poster-Info oder Länderatlas-Klick. */
moveVk(slotId) {
const vk = $('vk');
const slot = $(slotId);
if (vk && slot && vk.parentElement !== slot) slot.appendChild(vk);
},
fillCountryCard(c) {
$('cr-flag').src = BASE + 'assets/img/flags/' + c.iso2 + '.svg';
$('cr-country').textContent = c.name;
$('vk-sub').textContent = c.continent + ' · ' + c.region;
const wp = $('cr-wappen');
wp.style.display = '';
wp.onerror = () => { wp.style.display = 'none'; };
wp.src = BASE + 'assets/img/wappen/' + c.iso2 + '.svg';
const lm = $('cr-landmark');
lm.parentElement.style.display = '';
lm.onerror = () => { lm.parentElement.style.display = 'none'; };
lm.src = BASE + 'assets/img/landmarks/' + c.iso2 + '.webp';
$('cr-landmark-cap').textContent = 'Wahrzeichen — ' +
((c.landmarkNote || '').replace('Auf der Rückseite steht: ', '').replace(/[„“.]/g, '') || '');
const oc = $('cr-outline');
oc.parentElement.style.display = outlines[c.iso2] ? '' : 'none';
if (outlines[c.iso2]) this.drawOutline(oc, c.iso2);
$('cr-facts').innerHTML =
'Hauptstadt: ' + c.capital + ' ' +
'Amtssprache: ' + c.language + ' ' +
'Einwohner: rund ' + c.population + ' ' +
'Währung: ' + c.currency + ' ' +
'Fläche: rund ' + c.area + ' ' +
'Internet-Domain: ' + c.domain + ' ' +
'Bevölkerungsdichte: ~' + c.density + ' Einw./km² ' +
'Zeitzone: ' + c.timezone + ' ' +
'Staatsform: ' + c.gov + ' ' +
'UN-Mitglied seit: ' + c.unSince + ' ' +
'Nachbarstaaten: ' + (c.neighbours.length ? c.neighbours.join(', ') : 'keine — Inselstaat') + ' ' +
'Wichtige Exporte: ' + c.exports + ' ';
},
/* Info-Punkt anzeigen (Sachtext zu einer Flughafen-Einrichtung) */
showInfoPoint(ip) {
$('ip-icon').textContent = ip.icon;
$('ip-name').textContent = ip.title;
$('ip-text').textContent = ip.text;
// Interessanter Fakt mit Quelle (nur wo vorhanden)
const factWrap = $('ip-fact'), srcEl = $('ip-source');
if (ip.fact) {
$('ip-fact-text').textContent = ip.fact;
if (ip.source) {
const label = ip.source.replace(/' +
label + ' ↗ F '
: 'Quelle: ' + label;
srcEl.style.display = '';
} else { srcEl.style.display = 'none'; }
factWrap.style.display = '';
} else {
factWrap.style.display = 'none';
srcEl.style.display = 'none';
}
this.show('ov-info');
},
/* Fundsache aufgehoben → Badge-Karte mit didaktischer Info */
showBadge(fund, isNew) {
$('bdg-emoji').innerHTML = ' ';
$('bdg-name').textContent = fund.name;
// info enthält Glossar-Tooltip-Spans (siehe data.js _g-Helper) → innerHTML.
// Plattform-glossar-tooltip.js delegiert Clicks; kein Re-Init nötig.
$('bdg-info').innerHTML = fund.info;
$('bdg-note').textContent = isNew
? '🎉 Neue Fundsache! ' + Badges.count() + ' von ' + KD_FUNDSACHEN.length + ' Badges gesammelt.'
: '✓ War schon in deiner Sammlung.';
this.show('ov-badge');
},
/* Sammlung anzeigen */
/* Toast für freigeschaltetes Achievement — kurz oben rechts einblenden */
toastAchievement(a) {
if (!a) return;
Achievements.markSeen(a.id);
const t = document.createElement('div');
t.style.cssText =
'position:fixed;top:74px;right:18px;z-index:99999;' +
'background:linear-gradient(135deg,#e8c547,#d4a02e);color:#1f4b37;' +
'padding:14px 22px;border-radius:12px;font-family:inherit;' +
'box-shadow:0 8px 22px rgba(0,0,0,.28);' +
'max-width:340px;animation:kdAchSlide .35s ease-out';
t.innerHTML =
'Achievement freigeschaltet
' +
'' + a.emoji + ' ' + a.titel + '
' +
'' + a.beschreibung + '
';
document.body.appendChild(t);
setTimeout(() => { t.style.transition = 'opacity .35s'; t.style.opacity = '0'; }, 5500);
setTimeout(() => { t.remove(); }, 6000);
// CSS-Animation einmalig einfügen
if (!document.getElementById('kdAchKeyframes')) {
const s = document.createElement('style');
s.id = 'kdAchKeyframes';
s.textContent = '@keyframes kdAchSlide{from{transform:translateX(20px);opacity:0}to{transform:none;opacity:1}}';
document.head.appendChild(s);
}
},
openBadges() {
const complete = Badges.complete();
$('badges-count').textContent = Badges.count() + ' / ' + KD_FUNDSACHEN.length;
// Goldene Trolleys (Prestige)
const gt = Gold.trolleys();
$('badges-gold').innerHTML = gt > 0
? ' Goldene Trolleys: ' + gt + ' '
: '';
$('badges-gold').style.display = gt > 0 ? '' : 'none';
// Reset-Button: bei Vollständigkeit goldener Trolley
const rb = $('badges-reset');
rb.innerHTML = complete
? '🛒✨ Goldenen Trolley holen & neu starten'
: '🔄 Sammlung zurücksetzen G ';
rb.classList.toggle('kd-btn-gold', complete);
const grid = $('badges-grid'); grid.innerHTML = '';
KD_FUNDSACHEN.forEach(f => {
const has = Badges.has(f.id);
const el = document.createElement('div');
el.className = 'badge-tile' + (has ? ' got' : '');
el.innerHTML = has
? '' +
'' + f.name + '
' + f.info + '
'
: '❔
Noch nicht gefunden
';
grid.appendChild(el);
});
// ─── Achievements (Welt-Erkundungen) ──────────────────────────
const achGrid = $('achievements-grid');
if (achGrid && typeof Achievements !== 'undefined') {
const all = Achievements.all();
const completed = all.filter(x => x.completed).length;
$('achievements-count').textContent = completed + ' / ' + all.length;
achGrid.innerHTML = '';
// Nach Kategorie gruppieren: sprache, kontinent, klasse, sonder
const groups = [
['sprache', '🗣️ Sprach-Sammlungen'],
['kontinent', '🌍 Kontinent-Sammlungen'],
['klasse', '🎯 Schwierigkeits-Klassen'],
['sonder', '🌟 Welt-Vollständigkeit'],
];
groups.forEach(([cat, gTitle]) => {
const items = all.filter(x => x.a.cat === cat);
if (!items.length) return;
const head = document.createElement('h4');
head.style.cssText = 'margin:14px 0 8px;font-size:.86rem;letter-spacing:.04em;color:var(--ggs-fjord-dark,#1f4b37);grid-column:1/-1;border-bottom:1px solid rgba(0,0,0,.08);padding-bottom:4px';
head.textContent = gTitle;
achGrid.appendChild(head);
items.forEach(x => {
const tile = document.createElement('div');
tile.className = 'badge-tile' + (x.completed ? ' got' : '');
if (x.isNew) tile.style.boxShadow = '0 0 16px rgba(232,197,71,.7), 0 0 4px rgba(232,197,71,1)';
const pct = x.total ? Math.round(x.solvedCount / x.total * 100) : 0;
tile.innerHTML =
'' + (x.completed ? x.a.emoji : '🔒') + '
' +
'' + x.a.titel + '
' +
'' +
'
' +
'
' + x.solvedCount + ' / ' + x.total + ' Länder · ' + pct + ' %
' +
(x.completed
? '
' + x.a.beschreibung + '
'
: '
Löse alle Länder dieser Gruppe
') +
'
';
if (x.isNew) Achievements.markSeen(x.a.id);
achGrid.appendChild(tile);
});
});
}
this.show('ov-badges');
},
/* Visitenkarte außerhalb der Fall-Auflösung (Poster / Länderatlas) */
showCountryCard(iso2, fromAtlas) {
const c = country(iso2);
if (!c) return;
Atlas.markRead(iso2); // Länderatlas: gelb
this.fillCountryCard(c);
this.moveVk('vk-slot-info');
$('cc-title').textContent = fromAtlas ? '🌍 Länderatlas' : '📰 Reise-Information';
this._countryCardFromAtlas = !!fromAtlas;
this.show('ov-country');
if (fromAtlas) this._reopenAtlas = true;
},
showCaseResult(solved) {
const cs = curCase(), c = country(cs.iso2);
if (!solved) Atlas.markRead(c.iso2); // Auflösung gesehen → gelesen
$('cr-icon').textContent = solved ? '✅' : '📋';
const h = $('cr-h');
h.childNodes[h.childNodes.length - 1].textContent =
' ' + (solved ? 'Fall gelöst!' : 'Fall abgeschlossen — Auflösung');
this.fillCountryCard(c);
this.moveVk('vk-slot-case');
$('cr-points').textContent = solved
? '+' + cs.points + ' Punkte (' + cs.revealed.length + ' Hinweis' + (cs.revealed.length === 1 ? '' : 'e') + ', ' +
cs.wrongGuesses + ' Fehlversuch' + (cs.wrongGuesses === 1 ? '' : 'e') + (cs.timeoutHit ? ', Zeit überschritten' : '') + ')'
: 'Keine Punkte für diesen Fall — beim nächsten klappt es!';
$('cr-next').textContent = S.caseIndex < S.cases.length - 1 ? 'Nächster Fall ▸' : 'Zur Auswertung ▸';
this.show('ov-case-result');
},
/* ---------- Auswertung ---------- */
REFLECT_Q: 'Welcher Hinweis hat dir am meisten geholfen — und welcher Fall war am schwierigsten? Begründe kurz.',
showRoundResult() {
const r = Game.results();
$('rr-stars').textContent = '★★★'.slice(0, Game.stars()) + '☆☆☆'.slice(0, 3 - Game.stars());
$('rr-score').textContent = Game.score100() + ' / 100';
$('rr-stats').innerHTML =
'Einsatzort: ' + (S.cfg.airport || '—') + ' ' +
'Gelöste Fälle: ' + r.cases_solved + ' von ' + r.cases_total + ' ' +
'Im ersten Versuch richtig: ' + r.correct_first_try + ' ' +
'Untersuchte Gegenstände gesamt: ' + r.hints_used_total + ' (Ø ' + r.hints_used_avg + ' pro Fall) ' +
'Falsche Vermutungen: ' + r.wrong_guesses + ' ' +
(r.timeouts ? 'Fälle mit Zeitüberschreitung: ' + r.timeouts + ' ' : '');
$('rr-cases').innerHTML = S.cases.map((cs, i) => {
const c = country(cs.iso2);
return '' + (cs.solved ? '✅' : '✖️') + ' Fall ' + (i + 1) + ': ' + c.name + ' — ' +
(cs.solved ? cs.points + ' P, ' + cs.revealed.length + ' Hinweise' : 'nicht gelöst') + ' ';
}).join('');
$('rr-question').textContent = this.REFLECT_Q;
$('rr-answer').value = '';
const wrap = $('rr-reflect-wrap');
const finish = $('rr-finish');
const count = $('rr-answer-count');
if (wrap) wrap.style.display = S.reflectionDone ? 'none' : '';
// Submit-Button erst freigeben wenn mindestens 40 Zeichen — oder Reflexion bereits abgegeben.
const MIN_CHARS = 40;
if (finish) finish.disabled = !S.reflectionDone;
if (!S.reflectionDone && $('rr-answer')) {
const ta = $('rr-answer');
ta.oninput = () => {
const n = (ta.value || '').trim().length;
if (count) count.textContent = n + ' Zeichen' + (n < MIN_CHARS ? ' (mindestens ' + MIN_CHARS + ')' : ' ✓');
if (finish) {
finish.disabled = n < MIN_CHARS;
finish.title = n < MIN_CHARS ? ('Bitte mindestens ' + MIN_CHARS + ' Zeichen.') : '';
}
};
ta.oninput();
}
this.show('ov-round-result');
},
finishRound() {
if (!S.reflectionDone) {
const a = $('rr-answer').value;
if (a.trim().length < 40) return; // Doppel-Schutz, button ist eigentlich disabled
Platform.reflect(this.REFLECT_Q, a); S.reflectionDone = true;
}
Platform.submit();
S.phase = 'fertig';
clearSave();
$('rr-reflect-wrap').style.display = 'none';
$('rr-done-note').style.display = '';
const finish = $('rr-finish');
if (finish) finish.disabled = true;
},
newRoundFromResult() {
this.closeOverlays();
S = null;
PA.stop();
Object.values(World.caseMeshes).forEach(m => World.scene.remove(m));
World.caseMeshes = {};
World.clearFunds();
this.showStart();
},
confirmReset() { this.show('ov-confirm'); },
doReset() {
clearSave(); S = null;
PA.stop();
Object.values(World.caseMeshes).forEach(m => World.scene.remove(m));
World.caseMeshes = {};
World.clearFunds();
this.closeOverlays();
this.showStart();
this.toast('Fortschritt zurückgesetzt.');
},
};
window.KD_UI = UI;
window.KD_DEBUG = { get S() { return S; }, Game, World, UI, Sound, Music, Kalib, Badges, Atlas, Gold, get ready() { return gameReady; } };
/* ============================================================
* BOOT & LOOP
* ============================================================ */
/* Ladebildschirm steuern: Balken läuft, währenddessen werden GLB-Props fertig
* geladen und Shader/Texturen vorgewärmt. Mind. ~3 s sichtbar, dann ausblenden. */
function runLoader() {
return new Promise(resolve => {
const el = $('kd-loader'), fill = $('kd-loader-fill');
if (!el || !fill) { resolve(); return; }
const MIN_MS = 3000; // Mindest-Anzeigedauer
const HARD_MS = 8000; // Notausstieg, falls Assets hängen
const start = performance.now();
let propsDone = false;
Promise.resolve(World.propsReady).catch(() => {}).then(() => { propsDone = true; });
function warmup() { // Shader vorkompilieren + Texturen hochladen
try { if (World.renderer && World.scene && World.camera) World.renderer.compile(World.scene, World.camera); } catch (_) {}
try { World.render(); } catch (_) {}
}
warmup();
function step(now) {
const t = now - start;
const settled = propsDone || t > HARD_MS;
fill.style.width = (settled ? 100 : Math.min(92, t / MIN_MS * 100)).toFixed(1) + '%';
if (settled && t >= MIN_MS) {
warmup(); // letzter Warmup-Frame vor dem Aufdecken
el.classList.add('done');
setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el); resolve(); }, 600);
return;
}
if (Math.floor(t / 300) % 2 === 0) warmup(); // zwischendurch nachwärmen
requestAnimationFrame(step);
}
requestAnimationFrame(step);
});
}
async function boot() {
Sound.init();
Music.init();
document.addEventListener('pointerdown', () => { if (S) { Sound.startAmbience(); Music.maybeStart(); } });
if (window.__kdThreePromise) await window.__kdThreePromise; // Modul-Three (für GLB-Szene)
if (!window.__kdModules) {
const row = $('cfg-scene-row');
if (row) row.style.display = 'none'; // alte Browser: nur klassische Halle
}
try {
outlines = await fetch(BASE + 'assets/data/outlines.json').then(r => r.json());
} catch (_) { outlines = {}; }
fetch(BASE + 'assets/data/world-all.json').then(r => r.json())
.then(j => { worldAll = j; }).catch(() => {});
World.init($('gl'));
const savedPeek = load();
// Bei Fortsetzung gleich das passende Flughafen-Theme bauen (kein Nachladen)
if (savedPeek && savedPeek.cfg && savedPeek.cfg.airport) World.pendingTheme = World.themeForAirport(savedPeek.cfg.airport);
await World.build(savedPeek && savedPeek.cfg && savedPeek.cfg.scene === 'glb' ? 'glb' : 'klassisch');
// Kamera schon auf Augenhöhe am Spawn setzen — sonst rendert die Szene bis
// zum Spielstart aus der Default-Position (0,0,0 = Bodenhöhe) und man sieht
// kurz einen verzerrten Blick von ganz unten („kurz gefangen“).
World.previewCamera();
// Ladebildschirm: echtes Warten auf GLB-Props + Shader/Texture-Warmup,
// damit der anfängliche Ruckler hinter dem Balken liegt. Mind. ~3 s sichtbar.
await runLoader();
$('btn-info').onclick = () => UI.openTutorial();
$('btn-mute').onclick = () => Sound.toggle();
$('btn-reset').onclick = () => UI.confirmReset();
$('btn-map').onclick = () => UI.toggleBigMap();
$('btn-touch-act').addEventListener('click', () => Game.tryInteract());
$('tut-next').onclick = () => UI.tutNext();
$('tut-back').onclick = () => { if (UI.tutorialStep) { UI.tutorialStep--; UI.renderTutorial(); } };
$('btn-start').onclick = () => UI.startFromForm();
$('ana-close').onclick = () => { S.phase = 'suche'; save(); UI.closeOverlays(); };
$('os-done').onclick = () => UI.closeInspect();
$('obj-stage').addEventListener('click', e => { if (e.target.id === 'obj-stage') UI.closeInspect(); });
$('ana-guess-btn').onclick = () => UI.togglePicker(!$('picker-panel').classList.contains('visible'));
$('ana-giveup').onclick = () => UI.show('ov-giveup');
$('giveup-no').onclick = () => UI.openAnalysis();
$('giveup-yes').onclick = () => Game.giveUp();
$('ip-close').onclick = () => UI.closeOverlays();
$('picker-search').addEventListener('input', () => UI.renderPicker());
document.querySelectorAll('.cont-chip').forEach(ch =>
ch.onclick = () => { UI.pickerContinent = ch.dataset.c; UI.renderPicker(); });
$('cr-next').onclick = () => Game.nextCase();
$('rr-finish').onclick = () => UI.finishRound();
$('rr-new').onclick = () => UI.newRoundFromResult();
$('confirm-yes').onclick = () => UI.doReset();
$('confirm-no').onclick = () => UI.closeOverlays();
$('bigmap-close').onclick = () => UI.toggleBigMap();
$('btn-atlas').onclick = () => UI.openAtlas();
$('atlas-close').onclick = () => UI.closeOverlays();
$('atlas-reset').onclick = () => {
const plat = Atlas.platinumComplete(), main = Atlas.complete();
if (!plat && !main) return;
if (plat) Gold.addPlatinum(); else Gold.addGlobe();
Atlas.reset();
// Atlas-Färbung neu (alles wieder „mögliches Fall-Land“)
document.querySelectorAll('#atlas-svg-wrap .ac').forEach(p => { p.classList.remove('read', 'solved'); if (p.dataset.iso) p.classList.add('pool'); });
UI.openAtlas();
UI.toast(plat
? '💎 Platin-Weltkugel erhalten! (insgesamt ' + Gold.platinum() + ') — du hast WIRKLICH alle Staaten gelöst!'
: '🌍✨ Goldene Weltkugel erhalten! (insgesamt ' + Gold.globes() + ') — der Atlas beginnt von vorne.');
};
$('btn-badges').onclick = () => UI.openBadges();
$('badges-close').onclick = () => UI.closeOverlays();
$('badges-reset').onclick = () => {
const complete = Badges.complete();
if (complete) Gold.addTrolley(); // alle 30 gefunden → goldener Trolley
Badges.reset();
if (S) { S.funds = World.chooseFunds(); save(); World.spawnFundsachen(); } // 3 frische auslegen
UI.openBadges(); // Grid neu zeichnen (alles „nicht gefunden“)
UI.toast(complete
? '🛒✨ Goldener Trolley erhalten! (insgesamt ' + Gold.trolleys() + ') — die Sammlung beginnt von vorne.'
: 'Sammlung zurückgesetzt — pro Runde erscheinen wieder 3 neue Fundstücke.');
};
$('bdg-close').onclick = () => UI.closeOverlays();
$('bdg-collection').onclick = () => UI.openBadges();
$('cc-close').onclick = () => {
UI.closeOverlays();
if (UI._reopenAtlas) { UI._reopenAtlas = false; UI.openAtlas(); }
};
$('kalib-close').onclick = () => Kalib.toggle();
$('kalib-reset').onclick = () => Kalib.reset();
const lp = $('ggs-lehrplan-link');
if (lp && GGS && GGS.basePath) lp.href = GGS.basePath + '/lehrplan?modul=' + SIM_ID;
const saved = load();
let seenIntro = false;
try { seenIntro = !!localStorage.getItem('ggs.intro.' + SIM_ID); } catch (_) {}
if (saved) { S = saved; Game.resume(); }
else if (!seenIntro) UI.openTutorial();
else UI.showStart();
gameReady = true;
let last = performance.now();
const mini = $('minimap'), big = $('bigmap-canvas');
let hudTick = 0;
function frame(now) {
const dt = Math.min(0.1, (now - last) / 1000); last = now;
if (S && !UI.overlayOpen()) World.update(dt);
else {
Sound.setWalking(false);
// Animationen laufen auch hinter Overlays weiter (Leben in der Szene)
const t = performance.now() / 1000;
World.animated.forEach(a => a.update(dt, t));
}
if (S) Game.tick(dt);
World.render();
if (S) {
Radar.draw(mini, false);
if (UI.bigMap) Radar.draw(big, true);
if ((hudTick++ & 3) === 0) UI.updateHud();
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
window.addEventListener('DOMContentLoaded', boot);
})();