/* ============================================================ * Kofferdetektiv — Engine (v2) * 3D-Nachtflughafen (Three.js, lokal gebündelt), Fall-Logik, * Radar, Scoring, Persistenz, Plattform-Anbindung (GeoGraSim). * v2: Gepäcksortierung mit animierten Bändern, CC0-Texturen, * 3D-Koffermodelle, Flughafen-Rotation, PA-Scheduler, Musik, * Pointer-Lock-Fixes, Koffer-Grafik-Analyse, Bild-Hinweise. * Vanilla JS, kein Build-Step. * ============================================================ */ (function () { 'use strict'; /* ---------- Basis & Plattform ---------- */ const BASE = window.KD_BASE || './'; const GGS = window.__GGS__ || null; const SIM_ID = 'kofferdetektiv'; const STORE_KEY = 'kofferdetektiv.v1'; const $ = id => document.getElementById(id); const IS_TOUCH = matchMedia('(pointer: coarse)').matches; // iPad-Performance: dpr 2 = 4× Pixel und der teuerste Posten. Auf Touch die // Render-Auflösung auf 1.3 deckeln (Retina-Dichte kaschiert es), Desktop bleibt // bei seinem jeweiligen Cap. const TOUCH_DPR_CAP = 1.3; const dprCap = hi => Math.min(window.devicePixelRatio || 1, IS_TOUCH ? TOUCH_DPR_CAP : hi); /* ============================================================ * AUDIO — SFX, Ambience, PA-Durchsagen, Musik * ============================================================ */ const Sound = { cache: {}, clones: [], muted: false, ambience: null, steps: null, init() { try { this.muted = localStorage.getItem('kd.muted') === '1'; } catch (_) {} this.updateBtn(); }, el(name, loop, vol) { let a = this.cache[name]; if (!a) { a = new Audio(BASE + 'assets/audio/' + name + '.mp3'); // Lazy-load: erst beim ersten Play() laden, nicht im Voraus. // Spart Boot-Bandbreite (8 SFX + 5 PA-Durchsagen × ~50 KB). a.preload = 'metadata'; this.cache[name] = a; } a.loop = !!loop; a.volume = vol == null ? 0.5 : vol; return a; }, play(name, vol) { if (this.muted) return; try { const a = this.el(name, false, vol); if (!a.paused && a.currentTime > 0.05) { const c = a.cloneNode(); c.volume = a.volume; this.clones.push(c); c.addEventListener('ended', () => { this.clones = this.clones.filter(x => x !== c); }); c.play().catch(()=>{}); return; } a.currentTime = 0; a.play().catch(()=>{}); } catch (_) {} }, /* PA-Durchsage: spielt und duckt die Musik solange auf 50 % */ playPA(name, vol) { if (this.muted) return; try { const a = this.el(name, false, vol); Music.setDuck(0.5); let restored = false; const restore = () => { if (!restored) { restored = true; Music.setDuck(1); } }; a.onended = restore; setTimeout(restore, 30000); // Sicherheitsnetz a.currentTime = 0; a.play().catch(restore); } catch (_) { Music.setDuck(1); } }, startAmbience() { if (this.muted) return; try { if (!this.ambience) this.ambience = this.el('ambience', true, 0.18); this.ambience.play().catch(()=>{}); } catch (_) {} }, setWalking(on, running) { try { if (!this.steps) this.steps = this.el('steps', true, 0.1); // beim schnellen Gehen (Shift) schnellere, etwas lautere Schritte this.steps.playbackRate = running ? 1.55 : 1.0; this.steps.volume = running ? 0.14 : 0.1; if (on && !this.muted) { if (this.steps.paused) this.steps.play().catch(()=>{}); } else if (!this.steps.paused) this.steps.pause(); } catch (_) {} }, /* 🔊 schaltet NUR Geräusche (Ambience, Effekte, Durchsagen) — Musik hat den eigenen Player. Stoppt auch GERADE LAUFENDE Sounds (z. B. eine lange Durchsage). */ toggle() { this.muted = !this.muted; try { localStorage.setItem('kd.muted', this.muted ? '1' : '0'); } catch (_) {} if (this.muted) { Object.values(this.cache).forEach(a => { try { a.pause(); a.onended = null; } catch (_) {} }); this.clones.forEach(c => { try { c.pause(); } catch (_) {} }); this.clones = []; Music.setDuck(1); // Ducking aufheben, Musik läuft normal weiter } else this.startAmbience(); this.updateBtn(); }, updateBtn() { const b = $('btn-mute'); if (b) { b.textContent = this.muted ? '🔇' : '🔊'; b.title = this.muted ? 'Geräusche einschalten' : 'Geräusche ausschalten'; } }, }; /* PA-Durchsagen: freundlicher Feierabend-Betrieb, alle paar Minuten */ const PA = { POOL: ['pa-abschied', 'pa-fund', 'pa-reinigung', 'pa-sicherheit'], timer: null, idx: 0, start() { this.stop(); this.idx = Math.floor(Math.random() * this.POOL.length); const next = () => { this.timer = setTimeout(() => { if (S && !Sound.muted) Sound.playPA(this.POOL[this.idx++ % this.POOL.length], 0.32); next(); }, 90000 + Math.random() * 120000); // alle 1,5 – 3,5 min }; next(); }, stop() { clearTimeout(this.timer); this.timer = null; }, }; /* Hintergrundmusik-Player: Playlist aus assets/audio/music/playlist.json. Unabhängig vom 🔊-Geräusche-Schalter. Dropdown, ⏯, ⏭, 🔀, Lautstärke. Wird bei PA-Durchsagen automatisch auf 50 % geduckt. */ const Music = { tracks: [], audio: null, on: false, started: false, shuffle: false, order: [], pos: 0, baseVol: 0.16, duck: 1, async init() { try { this.on = localStorage.getItem('kd.music') !== '0'; this.shuffle = localStorage.getItem('kd.shuffle') === '1'; // Standard: Reihenfolge, Random opt-in const v = parseFloat(localStorage.getItem('kd.musicvol')); if (!isNaN(v)) this.baseVol = Math.max(0, Math.min(0.5, v)); } catch (_) {} try { const j = await fetch(BASE + 'assets/audio/music/playlist.json').then(r => r.json()); this.tracks = (j.tracks || []).filter(Boolean); } catch (_) { this.tracks = []; } this.buildOrder(); this.bindUI(); }, buildOrder() { this.order = this.tracks.map((_, i) => i); if (this.shuffle) this.order.sort(() => Math.random() - 0.5); this.pos = 0; }, ensureAudio() { if (this.audio) return; this.audio = new Audio(); this.audio.addEventListener('ended', () => this.next()); this.applyVol(); }, // 0.8 = global −20 %, damit Schritte/Geräusche besser durchkommen applyVol() { if (this.audio) this.audio.volume = this.baseVol * this.duck * 0.8; }, setDuck(d) { this.duck = d; this.applyVol(); }, current() { return this.tracks[this.order[this.pos % this.order.length]] || null; }, playCurrent() { if (!this.tracks.length) return; this.ensureAudio(); this.started = true; this.audio.src = BASE + 'assets/audio/music/' + encodeURIComponent(this.current()); this.applyVol(); this.audio.play().catch(()=>{}); this.updateUI(); }, maybeStart() { if (!this.tracks.length || !this.on || this.started) return; this.playCurrent(); }, next() { if (!this.tracks.length) return; this.pos = (this.pos + 1) % this.order.length; if (this.on) this.playCurrent(); else this.updateUI(); }, playTrackByName(name) { const ti = this.tracks.indexOf(name); if (ti < 0) return; this.pos = this.order.indexOf(ti); if (this.pos < 0) this.pos = 0; this.setOn(true); this.playCurrent(); }, setOn(on) { this.on = on; try { localStorage.setItem('kd.music', on ? '1' : '0'); } catch (_) {} if (on) { if (this.audio && this.audio.src) this.audio.play().catch(()=>{}); else this.playCurrent(); } else if (this.audio) this.audio.pause(); this.updateUI(); }, toggleShuffle() { const cur = this.current(); this.shuffle = !this.shuffle; try { localStorage.setItem('kd.shuffle', this.shuffle ? '1' : '0'); } catch (_) {} this.buildOrder(); // aktuellen Titel in der neuen Reihenfolge weiterführen const ti = this.tracks.indexOf(cur); if (ti >= 0) this.pos = Math.max(0, this.order.indexOf(ti)); this.updateUI(); }, pause() { if (this.audio) this.audio.pause(); }, /* --- Player-UI --- */ bindUI() { const b = $('btn-music'); if (!b) return; b.style.display = this.tracks.length ? '' : 'none'; b.onclick = () => { const p = $('music-panel'); p.classList.toggle('visible'); this.updateUI(); }; document.addEventListener('pointerdown', e => { const p = $('music-panel'); if (p.classList.contains('visible') && !e.target.closest('#music-panel, #btn-music')) p.classList.remove('visible'); }); const sel = $('mp-select'); sel.innerHTML = this.tracks.map(t => '').join(''); sel.onchange = () => this.playTrackByName(sel.value); $('mp-play').onclick = () => this.setOn(!(this.on && this.started)); $('mp-next').onclick = () => { this.setOn(true); this.next(); }; $('mp-shuffle').onclick = () => this.toggleShuffle(); const vol = $('mp-vol'); vol.value = Math.round(this.baseVol / 0.4 * 100); vol.oninput = () => { this.baseVol = vol.value / 100 * 0.4; try { localStorage.setItem('kd.musicvol', String(this.baseVol)); } catch (_) {} this.applyVol(); }; this.updateUI(); }, updateUI() { const b = $('btn-music'); if (!b) return; const playing = this.on && this.audio && !this.audio.paused; b.textContent = '🎵'; b.style.opacity = this.on ? '1' : '.45'; b.title = 'Musik-Player'; $('mp-now').textContent = this.started && this.current() ? this.current().replace(/\.mp3$/i, '') : '—'; $('mp-play').textContent = playing ? '⏸' : '▶'; $('mp-shuffle').classList.toggle('on', this.shuffle); const sel = $('mp-select'); if (this.current()) sel.value = this.current(); }, }; /* ============================================================ * ZUSTAND * ============================================================ */ const TIME_LIMITS = { none: 0, leicht: 10 * 60, mittel: 8 * 60, anspruchsvoll: 5 * 60 }; const HINT_COST = 7, WRONG_COST = 15, TIMEOUT_COST = 20, CASE_BASE = 100; let S = null; let gameReady = false; let outlines = {}; let worldAll = []; let lastSaveAt = 0; function freshState(cfg) { return { v: 1, cfg, createdAt: Date.now(), caseIndex: 0, cases: [], phase: 'suche', totalPoints: 0, reflectionDone: false, submitted: false, }; } function buildCases(cfg) { let pool = KD_COUNTRIES.filter(c => { if (cfg.level === 1) return c.cls === 'A'; if (cfg.level === 2) return c.cls === 'B' || c.cls === 'A'; if (cfg.level === 4) return c.cls === 'D' || c.cls === 'C'; // Welt-Experten / Platin return c.cls === 'C' || c.cls === 'B'; }); if (cfg.continent !== 'alle') pool = pool.filter(c => c.continent === cfg.continent); const prefer = cfg.level === 2 ? 'B' : cfg.level === 3 ? 'C' : cfg.level === 4 ? 'D' : 'A'; pool.sort(() => Math.random() - 0.5); pool.sort((a, b) => (a.cls === prefer ? -1 : 0) - (b.cls === prefer ? -1 : 0)); const picked = pool.slice(0, cfg.caseCount); picked.sort(() => Math.random() - 0.5); const spawnIds = World.SPAWNS.map((_, i) => i).sort(() => Math.random() - 0.5); return picked.map((c, i) => ({ iso2: c.iso2, spawn: spawnIds[i % World.SPAWNS.length], luggage: KD_LUGGAGE_TYPES[Math.floor(Math.random() * KD_LUGGAGE_TYPES.length)].id, hintKeys: buildHintSequence(c), revealed: [], wrongGuesses: 0, found: false, solved: false, givenUp: false, timeoutHit: false, elapsed: 0, points: 0, })); } function buildHintSequence(c) { const plan = KD_HINT_PLAN[c.cls]; const byLevel = { 1: [], 2: [], 3: [] }; KD_CATEGORIES.forEach(cat => { if (cat.key === 'outline' && !outlines[c.iso2]) return; byLevel[cat.level].push(cat.key); }); const seq = []; [1, 2, 3].forEach(lv => { let arr = byLevel[lv].sort(() => Math.random() - 0.5); if (lv === 3) { // Landkarte (Umriss) + Postkarte (Wahrzeichen) immer einplanen — sonst // ist mancher Fall ohne diese starken Bildhinweise zu schwer. const forced = ['outline', 'landmark'].filter(k => arr.includes(k)); arr = forced.concat(arr.filter(k => !forced.includes(k))); } seq.push(...arr.slice(0, plan[lv])); }); if (seq.length < 10) { const rest = KD_CATEGORIES.map(x => x.key).filter(k => !seq.includes(k) && !(k === 'outline' && !outlines[c.iso2])); rest.sort(() => Math.random() - 0.5); seq.push(...rest.slice(0, 10 - seq.length)); } return seq; } function country(iso2) { return KD_COUNTRIES.find(c => c.iso2 === iso2); } function curCase() { return S && S.cases[S.caseIndex] || null; } function spawnOf(cs) { const a = World.SPAWNS; return a[cs.spawn % a.length]; } function save() { if (!S) return; try { localStorage.setItem(STORE_KEY, JSON.stringify(S)); } catch (_) {} lastSaveAt = Date.now(); } function load() { try { const raw = localStorage.getItem(STORE_KEY); if (!raw) return null; const s = JSON.parse(raw); if (s && s.v === 1 && s.phase !== 'fertig') return s; } catch (_) {} return null; } function clearSave() { try { localStorage.removeItem(STORE_KEY); } catch (_) {} } /* ============================================================ * 3D-WELT * Grundriss: x von −76 (Sortierung) bis 52 (Gates), z von −30 bis 30. * ============================================================ */ const World = { MINX: -76, MAXX: 52, MINZ: -30, MAXZ: 30, mode: 'klassisch', // 'klassisch' (prozedural) | 'glb' (Sketchfab-Terminal) SPAWNS: [], ZONES: [], MAP: { minx: -78, maxx: 54, minz: -32, maxz: 32 }, startPos: { x: 0, z: 24, yaw: 0 }, colliders: [], grid: null, glbKoffer: null, minimapCanvas: null, railWalls: [], floorY: 0, // Galerie-Geländer (nur oben aktiv) + aktuelle Bodenhöhe CLASSIC_SPAWNS: [ [-38, 19.5, 'Gepäckausgabe, Band 1'], [-45.5, -2, 'Gepäckausgabe, Band 2'], [-38, -20, 'Check-in-Bereich, Schalter 4'], [-10, -22, 'Sicherheitsbereich'], [12, -22, 'Duty-free-Zone'], [40, -18, 'Abflughalle, Gate 12'], [44, 6, 'Abflughalle, Wartebereich'], [38, 22, 'Fundbüro'], [-4, 8, 'Eingangshalle, Infoschalter'], [14, 20, 'Servicegang Süd'], [-70, -16, 'Gepäcksortierung, Band A'], [-58, 12, 'Gepäcksortierung, Verladung'], [-30, -20, 'Check-in-Bereich, Schalter 6'], [44, -3, 'Abflughalle, Gate 13'], [10, -18.5, 'Duty-free, Regalgasse'], [0, 16, 'Eingangshalle, Sitzgruppe'], [24, 24, 'Gang Süd-Ost'], [-12, 13, 'Eingangshalle West'], [-64, 4, 'Gepäcksortierung, Mitte'], [30, 2, 'Übergang zur Abflughalle'], [-52, 18, 'Gepäckausgabe, Band 3'], [-22, -22, 'Check-in-Bereich, Schalter 8'], [2, -22, 'Sicherheitsbereich, Spur 3'], [22, -19, 'Duty-free, Parfümerie'], [40, 12, 'Abflughalle, Gate 14'], [47, 16, 'Abflughalle, Wartebereich Nord'], [-72, -4, 'Gepäcksortierung, Band B'], [-20, 6, 'Eingangshalle, Mitte'], [8, 10, 'Eingangshalle, Sitzgruppe Ost'], [28, -19, 'Duty-free, Ausgang'], ], CLASSIC_ZONES: [ { x: -64, z: 0, w: 22, h: 56, name: 'Gepäcksortierung' }, { x: -35, z: 8, w: 28, h: 38, name: 'Gepäckausgabe' }, { x: -35, z: -20, w: 28, h: 14, name: 'Check-in' }, { x: 0, z: -22, w: 38, h: 11, name: 'Sicherheit · Duty-free' }, { x: 41, z: 0, w: 17, h: 54, name: 'Abflughalle' }, { x: 0, z: 10, w: 38, h: 33, name: 'Eingangshalle' }, ], walls: [], scene: null, camera: null, renderer: null, caseMeshes: {}, fundMeshes: {}, // Fundsachen am Boden (Badge-Sammeln), je Spawn-Index player: { x: 0, z: 24, yaw: 0, speed: 4.2 }, keys: {}, look: { lx: 0, ly: 0 }, pitch: 0, joy: { active: false, dx: 0, dy: 0 }, // rechter Joystick: Bewegen (Fuß) lookJoy: { dx: 0, dy: 0 }, // linker Joystick: Umsehen (Auge) moving: false, locked: false, jumpY: 0, vy: 0, animated: [], // {update(dt,t)} — Bänder, Roboter, Blinklichter boardCtx: null, boardTex: null, tex: {}, addBox(x, y, z, sx, sy, sz, matOrColor, opts) { const mat = matOrColor && matOrColor.isMaterial ? matOrColor : new THREE.MeshLambertMaterial(Object.assign({ color: matOrColor }, opts || {})); const m = new THREE.Mesh(new THREE.BoxGeometry(sx, sy, sz), mat); m.position.set(x, y, z); m.castShadow = true; m.receiveShadow = true; this.scene.add(m); return m; }, addCollider(x, z, sx, sz) { this.walls.push({ x, z, w: sx / 2 + 0.45, h: sz / 2 + 0.45 }); }, wall(x, z, sx, sz, h, mat) { this.addBox(x, (h || 5) / 2, z, sx, h || 5, sz, mat || this.matWall); this.addCollider(x, z, sx, sz); }, /* Dachhöhe an Position z (Bogen-Interpolation) */ roofY(z) { const a = this.ROOF || [[-30, 7.5], [30, 7.5]]; if (z <= a[0][0]) return a[0][1]; for (let i = 0; i < a.length - 1; i++) { if (z <= a[i + 1][0]) { const t = (z - a[i][0]) / (a[i + 1][0] - a[i][0]); return a[i][1] + (a[i + 1][1] - a[i][1]) * t; } } return a[a.length - 1][1]; }, /* Abhängung: dünner Stab vom Objekt (topY) bis zur Decke */ hangRod(x, topY, z, offX) { const ry = this.roofY(z); const len = Math.max(0.1, ry - topY); const rod = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, len, 6), new THREE.MeshLambertMaterial({ color: 0x222a34 })); rod.position.set(x + (offX || 0), topY + len / 2, z); this.scene.add(rod); }, textTexture(text, w, h, opts) { const o = opts || {}; const cv = document.createElement('canvas'); cv.width = w; cv.height = h; const g = cv.getContext('2d'); g.fillStyle = o.bg || '#15202c'; g.fillRect(0, 0, w, h); if (o.border) { // Rahmen, damit Schilder nicht „rahmenlos“ wirken const bw = o.borderW || 7; g.strokeStyle = o.border; g.lineWidth = bw; g.strokeRect(bw, bw, w - bw * 2, h - bw * 2); } g.fillStyle = o.fg || '#dceaf2'; g.font = '600 ' + (o.size || 52) + 'px "Segoe UI", system-ui, sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillText(text, w / 2, h / 2 + 2); const t = new THREE.CanvasTexture(cv); t.anisotropy = 4; return t; }, /* Schild mit korrektem Seitenverhältnis (keine verzerrte Schrift): Canvas-Verhältnis = Plane-Verhältnis */ sign(text, x, y, z, sx, sy, rotY, glow, gap) { const cw = 128 * Math.max(1, Math.round(sx / sy)); const t = this.textTexture(text, cw, 128, { bg: glow ? '#103246' : '#1a232e', fg: glow ? '#bfe6f5' : '#d6e2ea', border: glow ? '#3a6f88' : '#3a4654', borderW: 7, size: Math.min(64, cw / (text.length * 0.62)), }); // Beidseitig lesbar: zwei Planes Rücken an Rücken (statt DoubleSide, // das die Schrift spiegeln würde). gap > 0 rückt die Planes von einem // Trägerobjekt ab (z. B. Stelen-Säule), das sonst die Mitte verdeckt. const off = gap || 0.045; const mat = new THREE.MeshBasicMaterial({ map: t }); const grp = new THREE.Group(); const front = new THREE.Mesh(new THREE.PlaneGeometry(sx, sy), mat); front.position.z = off; const back = new THREE.Mesh(new THREE.PlaneGeometry(sx, sy), mat); back.rotation.y = Math.PI; back.position.z = -off; grp.add(front, back); if (!gap) { // schmaler Quader als Körper — Schilder wirken sonst papierdünn const core = new THREE.Mesh(new THREE.BoxGeometry(sx + 0.08, sy + 0.08, 0.07), new THREE.MeshLambertMaterial({ color: 0x222a34 })); core.castShadow = true; grp.add(core); } grp.position.set(x, y, z); grp.rotation.y = rotY || 0; this.scene.add(grp); }, loadTextures() { const L = new THREE.TextureLoader(); const mk = (file, rx, ry) => { const t = L.load(BASE + 'assets/textures/' + file); t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(rx, ry); t.anisotropy = 4; t.colorSpace = THREE.SRGBColorSpace; return t; }; this.tex.floor = mk('large_floor_tiles_02.jpg', 26, 13); this.tex.wall = mk('painted_plaster_wall.jpg', 8, 1); this.tex.beton = mk('concrete_wall_004.jpg', 2, 2); this.tex.metall = mk('metal_plate.jpg', 2, 1); // Materialien (color tönt die Textur in die Nachtstimmung) this.matWall = new THREE.MeshLambertMaterial({ map: this.tex.wall, color: 0x8e99a8 }); this.matBeton = new THREE.MeshLambertMaterial({ map: this.tex.beton, color: 0x9aa2ae }); this.matMetall = new THREE.MeshLambertMaterial({ map: this.tex.metall, color: 0xaab2bc }); }, /* ============ Flughafen-Themes (Reskin) ============ * Gleiche Halle, andere Stimmung: prozedurale Boden-/Wandtexturen, Palette, * Licht, Dach-Himmel. Nur Optik — Geometrie/Kollision bleiben unberührt. */ themeForAirport(name) { const a = (typeof KD_AIRPORTS !== 'undefined') && KD_AIRPORTS.find(x => x.name === name); return (a && a.theme) || 'nacht'; }, activeTheme() { const T = (typeof KD_AIRPORT_THEMES !== 'undefined') && KD_AIRPORT_THEMES[this.themeId]; return T || (typeof KD_AIRPORT_THEMES !== 'undefined' ? KD_AIRPORT_THEMES.nacht : null); }, _canvasTex(size, draw, rep) { const cv = document.createElement('canvas'); cv.width = cv.height = size; draw(cv.getContext('2d'), size); const t = new THREE.CanvasTexture(cv); t.wrapS = t.wrapT = THREE.RepeatWrapping; if (rep) t.repeat.set(rep[0], rep[1]); t.anisotropy = 4; t.colorSpace = THREE.SRGBColorSpace; return t; }, // streut Flecken/Körnung randübergreifend (für nahtlose Kachelung) _speckle(g, S, n, cols, rmin, rmax, alpha) { for (let i = 0; i < n; i++) { const x = Math.random() * S, y = Math.random() * S, r = rmin + Math.random() * (rmax - rmin); g.globalAlpha = alpha * (0.5 + Math.random() * 0.5); g.fillStyle = cols[(Math.random() * cols.length) | 0]; for (const dx of [0, -S, S]) for (const dy of [0, -S, S]) g.fillRect(x + dx - r / 2, y + dy - r / 2, r, r); } g.globalAlpha = 1; }, _grid(g, S, cell, col, w) { g.strokeStyle = col; g.lineWidth = w; g.globalAlpha = 0.5; for (let p = 0; p <= S; p += cell) { g.beginPath(); g.moveTo(p, 0); g.lineTo(p, S); g.moveTo(0, p); g.lineTo(S, p); g.stroke(); } g.globalAlpha = 1; }, /* Boden-Textur je Theme (nahtlos, 512²). 'img' → das vorhandene Foto. */ themeFloorTex(T) { const f = T.floor; if (f.style === 'img') return this.tex.floor; return this._canvasTex(512, (g, S) => { g.fillStyle = f.base; g.fillRect(0, 0, S, S); if (f.style === 'marble') { this._speckle(g, S, 240, [f.accent, f.base, '#ffffff'], 2, 7, 0.12); // weiche Adern g.globalAlpha = 0.10; g.strokeStyle = f.grout; g.lineWidth = 2; for (let i = 0; i < 7; i++) { g.beginPath(); let y = Math.random() * S; g.moveTo(0, y); for (let x = 0; x <= S; x += 64) { y += (Math.random() - 0.5) * 36; g.lineTo(x, y); } g.stroke(); } g.globalAlpha = 1; this._grid(g, S, S / 2, f.grout, 3); } else if (f.style === 'sandstone') { this._speckle(g, S, 900, [f.accent, f.grout, '#e8d8bc'], 2, 5, 0.18); this._grid(g, S, S / 3, f.grout, 3); } else if (f.style === 'wood') { const planks = 4, ph = S / planks; for (let p = 0; p < planks; p++) { g.fillStyle = p % 2 ? f.accent : f.base; g.fillRect(0, p * ph, S, ph); g.globalAlpha = 0.10; g.strokeStyle = f.grout; g.lineWidth = 1; for (let k = 0; k < 7; k++) { const y = p * ph + Math.random() * ph; g.beginPath(); g.moveTo(0, y); g.lineTo(S, y); g.stroke(); } g.globalAlpha = 1; } g.strokeStyle = f.grout; g.lineWidth = 2.5; g.globalAlpha = 0.7; for (let p = 0; p <= planks; p++) { const y = (p % planks) * ph; g.beginPath(); g.moveTo(0, y + 0.5); g.lineTo(S, y + 0.5); g.stroke(); } g.globalAlpha = 1; } else { // darkstone this._speckle(g, S, 300, [f.accent, f.grout, '#3c454f'], 2, 6, 0.14); this._grid(g, S, S / 2, f.grout, 3); } }, f.rep); }, /* Wand-Textur je Theme. 'img' → das vorhandene Foto (nur getönt). */ themeWallTex(T) { const w = T.wall; if (w.style === 'img') return this.tex.wall; return this._canvasTex(512, (g, S) => { g.fillStyle = w.base; g.fillRect(0, 0, S, S); if (w.style === 'wood') { // senkrechte Latten const slats = 6, sw = S / slats; for (let s = 0; s < slats; s++) { g.fillStyle = s % 2 ? w.accent : w.base; g.fillRect(s * sw, 0, sw, S); g.globalAlpha = 0.08; g.strokeStyle = '#000'; g.lineWidth = 1; for (let k = 0; k < 6; k++) { const x = s * sw + Math.random() * sw; g.beginPath(); g.moveTo(x, 0); g.lineTo(x, S); g.stroke(); } g.globalAlpha = 1; } g.strokeStyle = w.accent; g.lineWidth = 2; g.globalAlpha = 0.6; for (let s = 0; s <= slats; s++) { const x = (s % slats) * sw; g.beginPath(); g.moveTo(x + 0.5, 0); g.lineTo(x + 0.5, S); g.stroke(); } g.globalAlpha = 1; } else if (w.style === 'stone') { // versetzte Steinblöcke const rows = 6, rh = S / rows, bw = S / 4; g.strokeStyle = w.grout; g.lineWidth = 3; g.globalAlpha = 0.8; for (let r = 0; r < rows; r++) { const y = r * rh, off = (r % 2) * bw / 2; g.beginPath(); g.moveTo(0, y + 0.5); g.lineTo(S, y + 0.5); g.stroke(); for (let x = -bw; x <= S; x += bw) { const bx = x + off; g.beginPath(); g.moveTo(bx + 0.5, y); g.lineTo(bx + 0.5, y + rh); g.stroke(); } } g.globalAlpha = 1; this._speckle(g, S, 400, [w.accent, w.grout], 2, 5, 0.12); } else { // 'tile' — glänzende Wandfliesen this._grid(g, S, S / 5, w.grout, 4); this._speckle(g, S, 120, [w.accent, '#ffffff'], 2, 6, 0.08); } }, w.style === 'tile' ? [4, 1] : [3, 1]); }, /* Vorfeld-/Fenster-Panorama je Theme: Nacht-Foto oder Tag-/Dämmerungs-Himmel * mit ferner Skyline-Silhouette (prozedural, nahtlos horizontal). */ themePanoTex(spec) { return this._canvasTex(1024, (g, S) => { const grad = g.createLinearGradient(0, 0, 0, S); grad.addColorStop(0, spec.top); grad.addColorStop(1, spec.bottom); g.fillStyle = grad; g.fillRect(0, 0, S, S); // ferne Skyline im unteren Drittel g.fillStyle = spec.sil; g.globalAlpha = 0.85; let x = 0; const base = S * 0.66; while (x < S) { const bw = 24 + Math.random() * 70, bh = 30 + Math.random() * 150; g.fillRect(x, base - bh, bw, bh); x += bw + 6; } g.globalAlpha = 0.5; for (let i = 0; i < 60; i++) g.fillRect(Math.random() * S, base - Math.random() * 130, 2, 2); // Fensterlichter g.globalAlpha = 1; }, [2, 1]); }, init(canvas) { this.camera = new THREE.PerspectiveCamera(72, canvas.clientWidth / canvas.clientHeight, 0.1, 320); this.renderer = new THREE.WebGLRenderer({ canvas, antialias: !IS_TOUCH, powerPreference: 'high-performance' }); this.renderer.setPixelRatio(dprCap(1.6)); this.renderer.setSize(canvas.clientWidth, canvas.clientHeight, false); this.bindInput(canvas); window.addEventListener('resize', () => this.resize()); }, /* Szene (neu) aufbauen — 'klassisch' oder 'glb'. Alte Szene wird entsorgt. */ async build(mode) { this.disposeScene(); this.walls = []; this.animated = []; this.caseMeshes = {}; this.colliders = []; this.grid = null; this.minimapCanvas = null; this.railWalls = []; this.floorY = 0; this.galleryWalk = null; this.fundMeshes = {}; this.boardCtx = null; this.boardTex = null; this.glbKoffer = null; this.eyeY = null; this.doors = []; this._tpArmed = true; // Renderer-Qualität auf Klassik-Standard zurücksetzen (GLB überschreibt) this.renderer.setPixelRatio(dprCap(1.6)); this.renderer.toneMapping = THREE.NoToneMapping; this.renderer.toneMappingExposure = 1; this.renderer.shadowMap.enabled = false; this.mode = (mode === 'glb' && window.__kdModules) ? 'glb' : 'klassisch'; if (this.mode === 'glb') { try { await this.buildGLB(); } catch (e) { console.warn('[kofferdetektiv] GLB-Szene fehlgeschlagen — klassische Halle:', e); this.mode = 'klassisch'; this.buildClassic(); } } else this.buildClassic(); this.resize(); }, disposeScene() { if (!this.scene) return; this.scene.traverse(o => { if (o.geometry) { if (o.geometry.disposeBoundsTree) try { o.geometry.disposeBoundsTree(); } catch (_) {} o.geometry.dispose(); } if (o.material) (Array.isArray(o.material) ? o.material : [o.material]).forEach(m => { if (m.map) m.map.dispose(); m.dispose(); }); }); this.scene = null; }, buildClassic() { this.themeId = this.pendingTheme || 'nacht'; const T = this.theme = this.activeTheme() || { bg:0x0c1119, fogNear:36, fogFar:110, amb:[0x8b929e,1.9], warm:0xffe0b3, cool:0xcfe2f0, pInt:30, panel:[0xd8cfc0,0xcdbfa4], moon:[0xbfd4ea,1.2], floor:{style:'img',tint:0x97a8be,rough:0.16,metal:0.52,rep:[26,13]}, wall:{style:'img',tint:0x8e99a8}, band:0x6e7888, beton:0x9aa2ae, metall:0xaab2bc, sky:{type:'night',top:'#0b1530',mid:'#142450',glow:'#eef2ff'}, apron:0x0a1422, pano:{type:'night'}, }; this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(T.bg); this.scene.fog = new THREE.Fog(T.bg, T.fogNear, T.fogFar); this.SPAWNS = this.CLASSIC_SPAWNS; this.ZONES = this.CLASSIC_ZONES; this.MAP = { minx: -78, maxx: 54, minz: -32, maxz: 32 }; this.startPos = { x: 0, z: 21, yaw: 0 }; this.ROOF = [[-30, 11], [-20, 12.5], [-10, 13.5], [0, 13.8], [10, 13.5], [20, 12.5], [30, 11]]; // Qualität „all in“ (Schul-iPads ab 2022): ACES-Tonemapping, Retina, // statisch gebackene Schatten, Environment-Reflexionen für den Boden this.renderer.setPixelRatio(dprCap(2)); this.renderer.toneMapping = THREE.ACESFilmicToneMapping; this.renderer.toneMappingExposure = 1.12; this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; this.renderer.shadowMap.autoUpdate = false; if (window.KD_RoomEnvironment && !this._envTex) { try { const pmrem = new THREE.PMREMGenerator(this.renderer); this._envTex = pmrem.fromScene(new window.KD_RoomEnvironment(), 0.04).texture; pmrem.dispose(); } catch (_) { this._envTex = null; } } if (this._envTex) this.scene.environment = this._envTex; this.loadTextures(); // Theme-Tönung auf die geteilten Materialien (Wand/Beton/Metall) anwenden const themeWallTex = this.themeWallTex(T); this.matWall.map = themeWallTex; this.matWall.color.setHex(T.wall.tint); this.matWall.needsUpdate = true; this.matBeton.color.setHex(T.beton); this.matMetall.color.setHex(T.metall); const W = this.MAXX - this.MINX, D = this.MAXZ - this.MINZ; const CX = (this.MAXX + this.MINX) / 2, CZ = 0; // Licht: Stimmung je Theme (warm/kühl, Tag/Nacht) this.scene.add(new THREE.AmbientLight(T.amb[0], T.amb[1])); [[-30, 0], [0, -14], [0, 14], [30, 0], [-30, -20], [40, -14], [40, 14], [-44, 10], [-66, -12], [-66, 10], [-58, 0]].forEach((p, i) => { const warm = i % 3 !== 0; const l = new THREE.PointLight(warm ? T.warm : T.cool, T.pInt, 40, 1.5); l.position.set(p[0], 6.5, p[1]); this.scene.add(l); this.addBox(p[0], 7.4, p[1], 4, 0.15, 1.4, T.panel[0], { emissive: T.panel[1], emissiveIntensity: 0.45 }); // Lichtpaneele hängen an Stäben von der (gewölbten) Decke this.hangRod(p[0], 7.5, p[1], -1.5); this.hangRod(p[0], 7.5, p[1], 1.5); }); // Polierter Flughafenboden: Textur je Theme + dezente Spiegelung (Env-Map) const floor = new THREE.Mesh(new THREE.PlaneGeometry(W, D), new THREE.MeshStandardMaterial({ map: this.themeFloorTex(T), color: T.floor.tint, roughness: T.floor.rough, metalness: T.floor.metal, envMapIntensity: 0.85, })); floor.rotation.x = -Math.PI / 2; floor.position.set(CX, 0, CZ); floor.receiveShadow = true; this.scene.add(floor); // Leitlicht durch die Fensterfront (Mond/Sonne je Theme) → weiche, gebackene Schatten const moon = new THREE.DirectionalLight(T.moon[0], T.moon[1]); moon.position.set(14, 42, -55); moon.castShadow = true; moon.shadow.mapSize.set(2048, 2048); moon.shadow.camera.left = -85; moon.shadow.camera.right = 85; moon.shadow.camera.top = 75; moon.shadow.camera.bottom = -75; moon.shadow.camera.far = 200; moon.shadow.bias = -0.0004; this.scene.add(moon); // Gewölbte Decke (facettierter Bogen, 11 m an den Rändern → 13,8 m Apex) // mit zwei Sternenhimmel-Glasstreifen zwischen den Segmenten { const arch = this.ROOF; // GLASDACH: alle Segmente zeigen den hellen Sternenhimmel mit Mond — // gelegentlich fliegt eine Maschine darüber hinweg (in die Textur animiert) const ribMat = new THREE.MeshLambertMaterial({ color: 0x2c3540 }); const sky = T.sky, isNight = sky.type === 'night'; const skies = []; for (let i = 0; i < arch.length - 1; i++) { const [z1, y1] = arch[i], [z2, y2] = arch[i + 1]; const len = Math.hypot(z2 - z1, y2 - y1); const sc = document.createElement('canvas'); sc.width = 1280; sc.height = 196; const sg = sc.getContext('2d'); const stars = []; const nStars = isNight ? 150 : sky.type === 'dusk' ? 40 : 0; for (let s = 0; s < nStars; s++) stars.push([Math.random() * 1280, Math.random() * 196, s % 8 === 0 ? 2.6 : 1.5, ['#ffffff', '#dcebfa', '#e8c547'][s % 3], 0.5 + Math.random() * 0.5]); // Tages-/Dämmerungshimmel: weiche Wolkenbänder statt Sterne const clouds = []; const nClouds = sky.type === 'day' ? 7 : sky.type === 'dusk' ? 4 : 0; for (let c = 0; c < nClouds; c++) clouds.push([Math.random() * 1280, 30 + Math.random() * 120, 50 + Math.random() * 90, 12 + Math.random() * 16, 0.12 + Math.random() * 0.16]); const hasMoon = i === 3; const drawSky = (planeX) => { const grad = sg.createLinearGradient(0, 0, 1280, 0); grad.addColorStop(0, sky.top); grad.addColorStop(0.55, sky.mid); grad.addColorStop(1, sky.top); sg.fillStyle = grad; sg.fillRect(0, 0, 1280, 196); stars.forEach(([x, y, r, col, a]) => { sg.globalAlpha = a; sg.fillStyle = col; sg.fillRect(x, y, r, r); }); clouds.forEach(([x, y, w, h, a]) => { sg.globalAlpha = a; sg.fillStyle = '#ffffff'; sg.beginPath(); sg.ellipse(x, y, w, h, 0, 0, 7); sg.fill(); }); sg.globalAlpha = 1; if (hasMoon) { // Mond (Nacht) bzw. Sonne (Tag/Dämmerung) const mg = sg.createRadialGradient(980, 95, 8, 980, 95, isNight ? 90 : 130); mg.addColorStop(0, sky.glow); mg.addColorStop(0.25, isNight ? 'rgba(220,230,250,0.5)' : 'rgba(255,240,200,0.55)'); mg.addColorStop(1, 'rgba(255,250,235,0)'); sg.fillStyle = mg; sg.fillRect(820, 0, 320, 196); sg.fillStyle = sky.glow; sg.beginPath(); sg.arc(980, 95, isNight ? 26 : 30, 0, 7); sg.fill(); if (isNight) { sg.fillStyle = '#d6ddf2'; sg.beginPath(); sg.arc(971, 88, 6, 0, 7); sg.fill(); sg.beginPath(); sg.arc(990, 104, 4, 0, 7); sg.fill(); } } if (planeX != null) { sg.fillStyle = '#0c1322'; sg.save(); sg.translate(planeX, 60); sg.beginPath(); sg.ellipse(0, 0, 16, 3.2, 0, 0, 7); sg.fill(); // Rumpf sg.beginPath(); sg.moveTo(-2, 0); sg.lineTo(-9, 12); sg.lineTo(-5, 12); sg.lineTo(2, 0); sg.fill(); sg.beginPath(); sg.moveTo(-2, 0); sg.lineTo(-9, -12); sg.lineTo(-5, -12); sg.lineTo(2, 0); sg.fill(); sg.fillStyle = Math.random() > 0.5 ? '#ff6666' : '#3a1a1a'; sg.fillRect(-16, -1.5, 3, 3); // Blinklicht sg.restore(); } }; drawSky(null); const tex = new THREE.CanvasTexture(sc); tex.colorSpace = THREE.SRGBColorSpace; tex.wrapS = THREE.RepeatWrapping; // für die Sternen-Parallaxe const seg = new THREE.Mesh(new THREE.PlaneGeometry(W, len + 0.05), new THREE.MeshBasicMaterial({ map: tex, toneMapped: false })); seg.position.set(CX, (y1 + y2) / 2, (z1 + z2) / 2); seg.rotation.x = Math.PI / 2 + Math.atan2(y2 - y1, z2 - z1); this.scene.add(seg); skies.push({ tex, drawSky }); // schmale Rahmenrippe an der Segmentkante const rib = new THREE.Mesh(new THREE.BoxGeometry(W, 0.18, 0.3), ribMat); rib.position.set(CX, y1, z1); this.scene.add(rib); } // Sternen-Parallaxe: der Himmel ist „unendlich weit“ — beim Gehen // verschiebt er sich gegenläufig zum Dach (plus ganz langsame Drift) this.animated.push({ update: (dt, t) => { const ox = -this.player.x * 0.0045 + t * 0.0006; skies.forEach(s => { s.tex.offset.x = ox; }); }}); // Überflug-Animation: alle 15 – 45 s quert eine Maschine ein Dachsegment let flyIdx = -1, flyX = 0, flyWait = 8; this.animated.push({ update: (dt) => { if (flyIdx < 0) { flyWait -= dt; if (flyWait <= 0) { flyIdx = 1 + Math.floor(Math.random() * 4); flyX = -40; } } else { flyX += dt * 170; skies[flyIdx].drawSky(flyX); skies[flyIdx].tex.needsUpdate = true; if (flyX > 1330) { skies[flyIdx].drawSky(null); skies[flyIdx].tex.needsUpdate = true; flyIdx = -1; flyWait = 15 + Math.random() * 30; } } }}); // Querbinder (Dachträger) alle 16 m for (let x = this.MINX + 8; x < this.MAXX; x += 16) { const beam = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.8, D), new THREE.MeshLambertMaterial({ color: 0x2c3540 })); beam.position.set(x, 10.6, CZ); beam.castShadow = true; this.scene.add(beam); } // Obere Wandbänder (7,5 m → 11 m) + Wandsäulen/Pilaster const bandMat = new THREE.MeshLambertMaterial({ map: themeWallTex, color: T.band }); this.addBox(CX, 9.25, this.MINZ + 0.3, W, 3.5, 0.6, bandMat); this.addBox(CX, 9.25, this.MAXZ - 0.3, W, 3.5, 0.6, bandMat); this.addBox(this.MINX + 0.3, 9.25, CZ, 0.6, 3.5, D, bandMat); this.addBox(this.MAXX - 0.3, 9.25, CZ, 0.6, 3.5, D, bandMat); for (let x = this.MINX + 8; x < this.MAXX; x += 16) { this.addBox(x, 5.5, this.MAXZ - 0.45, 0.8, 11, 0.5, this.matBeton); // Südwand-Pilaster this.addBox(x, 9.4, this.MINZ + 0.45, 0.8, 3.6, 0.5, this.matBeton); // Nord oberhalb Glas } } // Nordwand: ECHTE Glasfront — dahinter das große Nacht-Vorfeld als Bild // (Parallaxe: das Panorama steht 20 m hinter dem Glas) this.addCollider(CX, this.MINZ, W, 1); const glass = new THREE.Mesh(new THREE.PlaneGeometry(W, 7.5), new THREE.MeshLambertMaterial({ color: 0x9fc8e0, transparent: true, opacity: 0.1, depthWrite: false })); glass.position.set(CX, 3.75, this.MINZ + 0.12); this.scene.add(glass); // Streben + Brüstung + Querriegel for (let x = this.MINX; x <= this.MAXX; x += 8) { this.addBox(x, 3.75, this.MINZ + 0.1, 0.22, 7.5, 0.3, 0x222a34); } this.addBox(CX, 0.45, this.MINZ + 0.1, W, 0.9, 0.34, this.matMetall); this.addBox(CX, 5.1, this.MINZ + 0.1, W, 0.16, 0.26, 0x222a34); // Panorama-Rückwand hinterm Glas: Nacht-Foto oder prozeduraler Tag-/Dämmerungshimmel let panoTex; if (T.pano.type === 'sky') { panoTex = this.themePanoTex(T.pano); } else { panoTex = new THREE.TextureLoader().load(BASE + 'assets/img/panorama-nacht.webp'); panoTex.colorSpace = THREE.SRGBColorSpace; panoTex.wrapS = THREE.MirroredRepeatWrapping; // Bildzone so legen, dass Jet + Tower durchs Fensterband (y ≈ 1 – 7,5 m) sichtbar sind panoTex.repeat.set(2, 0.7); panoTex.offset.y = 0.15; } const pano = new THREE.Mesh(new THREE.PlaneGeometry(240, 16), new THREE.MeshBasicMaterial({ map: panoTex, fog: false })); pano.position.set(CX, 6.5, this.MINZ - 20); this.scene.add(pano); // Vorfeld-Boden zwischen Glas und Panorama (Asphalt je Theme) const apron = new THREE.Mesh(new THREE.PlaneGeometry(240, 21), new THREE.MeshBasicMaterial({ color: T.apron, fog: false })); apron.rotation.x = -Math.PI / 2; apron.position.set(CX, -0.05, this.MINZ - 10.5); this.scene.add(apron); // Blinkendes Vorfeld-Licht (Atmosphäre hinter dem Glas) const beacon = new THREE.Mesh(new THREE.SphereGeometry(0.18, 8, 8), new THREE.MeshBasicMaterial({ color: 0xffd75e })); beacon.position.set(CX + 28, 6.5, this.MINZ - 16); this.scene.add(beacon); this.animated.push({ update: (dt, t) => { beacon.material.color.setHex(Math.sin(t * 2.2) > 0.3 ? 0xffd75e : 0x4a3c14); } }); // Außenwände Süd/Ost/West // Südwand: durchgehender Kollider, aber Mesh-Lücke für den Passkontrolle- // Durchbruch bei x≈−44 (man sieht den Gang, kommt aber nicht durch) const pgx = this.passageX = -44, pgh = 1.9; this.addBox((this.MINX + pgx - pgh) / 2, 3.75, this.MAXZ, (pgx - pgh) - this.MINX, 7.5, 1, this.matWall); this.addBox((pgx + pgh + this.MAXX) / 2, 3.75, this.MAXZ, this.MAXX - (pgx + pgh), 7.5, 1, this.matWall); this.addCollider(CX, this.MAXZ, W, 1); // Ostwand: durchgehender Kollider, Mesh-Lücke für den Fundbüro-Durchbruch (z≈24) const egz = this.fundGapZ = 24, egh = 1.9; this.addBox(this.MAXX, 3.75, (this.MINZ + egz - egh) / 2, 1, 7.5, (egz - egh) - this.MINZ, this.matWall); this.addBox(this.MAXX, 3.75, (egz + egh + this.MAXZ) / 2, 1, 7.5, this.MAXZ - (egz + egh), this.matWall); this.addCollider(this.MAXX, 0, 1, D); this.wall(this.MINX, 0, 1, D, 7.5); // Innenwände (mit Durchgängen) // Sortierung | West-Trakt: Haupttür bei z −4…8, zweiter Durchbruch bei // z −26…−21, damit der Bereich HINTER der Band-Schleife erreichbar ist this.wall(-52, -28, 1.2, 4, 5); this.wall(-52, -12.5, 1.2, 17, 5); this.sign('Service', -50.8, 3.4, -23.5, 3.4, 1.1, Math.PI / 2, true); this.wall(-52, 19, 1.2, 22, 5); this.wall(-21, -19, 1.2, 22, 5); this.wall(-21, 21, 1.2, 14, 5); this.wall(21, -21, 1.2, 18, 5); this.wall(21, 14, 1.2, 16, 5); this.wall(-35, -8, 26, 1.2, 5); this.wall(34, 17, 24, 1.2, 4); this.buildSorting(); this.buildWestTract(); this.buildCenter(); this.buildEast(); this.buildArt(); this.buildBoard(); this.buildRobot(); this.buildEscalators(); this.buildSteles(); this.buildVending(); this.buildApronLife(); this.buildExitSigns(); this.buildAirportSigns(); this.registerInfoPoints(); this.renderer.shadowMap.needsUpdate = true; // Schatten einmalig backen this.validateSpawns(); // kein Spawn in Möbeln/Wänden this.propsReady = this.loadClassicProps(); // GLB-Koffer asynchron dazu (abwartbar für Ladebildschirm) }, /* Sicherheitsnetz: liegt ein Spawn-Punkt in einem Hindernis (z. B. nach * Umbauten der Halle), wird er spiralförmig auf den nächsten freien, * erreichbaren Platz verschoben. */ validateSpawns() { this.SPAWNS = this.SPAWNS.map(sp => { const free = (x, z) => !this.blocked(x, z) && // großzügiger geprüft: auch der Anlauf-Radius fürs Untersuchen muss frei sein !this.blocked(x + 0.9, z) && !this.blocked(x - 0.9, z) && !this.blocked(x, z + 0.9) && !this.blocked(x, z - 0.9); if (free(sp[0], sp[1])) return sp; for (let r = 0.8; r <= 9; r += 0.8) { for (let a = 0; a < 12; a++) { const ang = a / 12 * Math.PI * 2; const x = sp[0] + Math.cos(ang) * r, z = sp[1] + Math.sin(ang) * r; if (free(x, z)) { console.warn('[kofferdetektiv] Spawn „' + sp[2] + '“ verschoben:', sp[0], sp[1], '→', Math.round(x * 10) / 10, Math.round(z * 10) / 10); return [x, z, sp[2]]; } } } return sp; }); }, /* Echte Koffermodelle (Sketchfab, optimiert): 7er-Pool für Fall-Koffer + Deko. * koffer-pool.glb enthält 6 benannte Rollkoffer (XI_LU…, XI_LY…), dazu koffer1. */ kofferPool: [], async loadClassicProps() { if (!window.__kdModules || !window.KD_GLTFLoader) return; try { const loader = new window.KD_GLTFLoader(); if (window.KD_Meshopt) loader.setMeshoptDecoder(window.KD_Meshopt); if (!this._koffer1) { this._koffer1 = (await loader.loadAsync(BASE + 'assets/models/koffer1.glb')).scene; this._koffer1.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } }); } this.glbKoffer = this._koffer1; if (!this._poolSrc) { this._poolSrc = []; // Quelle B: zwei freistehende Taschen aus koffer-pool.glb // (die geskinnten Rollkoffer dort sind ohne Skelett nicht nutzbar) try { const pool = (await loader.loadAsync(BASE + 'assets/models/koffer-pool.glb')).scene; pool.updateMatrixWorld(true); pool.traverse(o => { if (o.isMesh && !o.isSkinnedMesh) { const m = new THREE.Mesh(o.geometry, o.material); m.applyMatrix4(o.matrixWorld); m.castShadow = m.receiveShadow = true; this._poolSrc.push(m); } }); } catch (_) {} // (Das wahanas-Set fliegt bewusst raus: Low-Poly-Würfel sahen wie die // alten prozeduralen Klötze aus — nur die „guten“ Modelle bleiben.) } this.kofferPool = [this._koffer1, ...this._poolSrc]; // BEWUSST keine Deko-Koffer: Herrenloses Gepäck wäre am Flughafen ein // Sicherheitsfall — sichtbar ist nur das gemeldete Fall-Gepäck. // Ausnahme: Gepäck IN BEARBEITUNG auf der Sortier-Schleife. if (this._beltRiderInit) this._beltRiderInit(); this.renderer.shadowMap.needsUpdate = true; // Fall-Koffer ggf. neu aufbauen, falls der Pool nach dem Spawn fertig wurde if (S) { Object.keys(this.caseMeshes).forEach(i => { this.scene.remove(this.caseMeshes[i]); delete this.caseMeshes[i]; }); this.syncCases(); } await this.loadHallProps(loader); } catch (_) {} }, /* ============ Flughafen-Objekte (Sketchfab-Props, je 10 – 80 KB) ============ * Ersetzen die wichtigsten prozeduralen Möbel; Positionen handgesetzt. * mode: 'h' = auf Höhe normieren, 'long' = auf längste Seite normieren */ PROP_DEFS: [ { file: 'airport_check_in_desk', size: 5.0, mode: 'h', collide: true, at: [[-38, -23, -Math.PI / 2], [-26, -23, -Math.PI / 2]] }, { file: 'airport_security_check', size: 3.6, mode: 'h', collide: true, at: [[-13, -22, -Math.PI / 2], [-6, -22, -Math.PI / 2]] }, { file: 'airport_boarding_area_desk', size: 3.5, mode: 'h', collide: true, at: [[50.0, -12.2, Math.PI], [50.0, -0.2, Math.PI], [50.0, 11.8, Math.PI]] }, { file: 'airport_chair', size: 0.85, mode: 'h', collide: true, at: [[40.2, -16, 0], [42.3, -16, 0], [44.4, -16, 0], [40.2, -7, 0], [42.3, -7, 0], [44.4, -7, 0], [40.2, 2, 0], [42.3, 2, 0], [44.4, 2, 0], [40.2, 11, 0], [42.3, 11, 0], [44.4, 11, 0], [-9.4, 18, 0], [-7.3, 18, 0], [-5.2, 18, 0], [5.2, 18, 0], [7.3, 18, 0], [9.4, 18, 0], [-9.4, 24, 0], [-7.3, 24, 0], [-5.2, 24, 0], [5.2, 24, 0], [7.3, 24, 0], [9.4, 24, 0]] }, { file: 'airport_advertising', size: 2.1, mode: 'h', collide: true, at: [[-16, -6, 0.5], [16, 8, -0.4], [-44, 8, 1.2], [-30, 10, 2.4], [34, 6, -1.1], [12, -4, 0.9]] }, { file: 'airport_atm', size: 2.0, mode: 'h', collide: true, at: [[5.5, 29.0, Math.PI / 2], [8.2, 29.0, Math.PI / 2]] }, { file: 'airport_trashcan', size: 0.85, mode: 'h', collide: true, at: [[-12.6, 5, 0], [13, 19, 0], [38, 17, 0], [-30, -16, 0], [-55, 16, 0], [25, -18, 0], [38.6, -16, 0], [38.6, 2, 0], [46, -7, 0], [46, 11, 0], [-11, 18, 0], [11, 24, 0]] }, { file: 'bar_counter', size: 6.6, mode: 'long', collide: true, at: [[-14, 27.2, 0]] }, { file: 'airport_extinguiser', size: 0.75, mode: 'h', collide: false, y: 0.55 - 0.75, mount: true, rotAdd: -Math.PI / 2, // alle 90° nach rechts gedreht + um eigene Höhe abgesenkt at: [[-20.3, -14, Math.PI / 2], [-20.3, 16, Math.PI / 2], [20.3, 18, -Math.PI / 2], [-51.3, -8, Math.PI / 2], [-75.4, 6, Math.PI / 2], [51.4, -8, -Math.PI / 2], [33, 29.3, Math.PI], [-40, 29.3, Math.PI]] }, { file: 'airport_telephone', size: 0.8, mode: 'h', collide: false, y: 0.85, at: [[-3, 29.0, Math.PI / 2]] }, { file: 'airport_security_cam', size: 0.45, mode: 'h', collide: false, y: 4.6, mount: true, at: [[-20.35, 2, Math.PI / 2], [21.65, -16, -Math.PI / 2], [-51.35, 10, Math.PI / 2], [-40, 29.35, Math.PI], [-10, 29.35, Math.PI], [20, 29.35, Math.PI], [46, 29.35, Math.PI], [-30, -29.5, 0], [10, -29.5, 0], [40, -29.5, 0], [-75.45, -10, Math.PI / 2], [51.45, 18, -Math.PI / 2]] }, ], async loadHallProps(loader) { for (const def of this.PROP_DEFS) { try { const src = (await loader.loadAsync(BASE + 'assets/models/props/' + def.file + '.glb')).scene; src.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } }); // Normieren const b = new THREE.Box3().setFromObject(src); const s = b.getSize(new THREE.Vector3()); const ref = def.mode === 'long' ? Math.max(s.x, s.z) : s.y; src.scale.multiplyScalar(def.size / Math.max(0.001, ref)); const b2 = new THREE.Box3().setFromObject(src); const foot = b2.getSize(new THREE.Vector3()); def.at.forEach(([x, z, rotY]) => { const rot = (rotY || 0) + (def.rotAdd || 0); const inst = src.clone(true); const wrap = new THREE.Group(); inst.position.set(-(b2.min.x + b2.max.x) / 2, -b2.min.y, -(b2.min.z + b2.max.z) / 2); wrap.add(inst); wrap.position.set(x, def.y || 0, z); wrap.rotation.y = rot; this.scene.add(wrap); if (def.mount) { // Wandhalterung: kleiner Block zwischen Wand und Objekt (an der Wandseite, // die sich aus der ursprünglichen Standort-Ausrichtung ergibt — nicht aus rotAdd) const mnt = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.16, 0.22), new THREE.MeshLambertMaterial({ color: 0x222a34 })); mnt.position.set(x - Math.sin(rotY || 0) * 0.12, (def.y || 0) + foot.y * 0.4, z - Math.cos(rotY || 0) * 0.12); mnt.rotation.y = rotY || 0; this.scene.add(mnt); } if (def.collide) { // Footprint je nach Rotation (90°-Schritte reichen hier) const quarter = Math.abs(Math.round(rot / (Math.PI / 2))) % 2 === 1; this.addCollider(x, z, quarter ? foot.z : foot.x, quarter ? foot.x : foot.z); } }); } catch (e) { console.warn('[kofferdetektiv] Prop fehlgeschlagen:', def.file, e && e.message); } } this.validateSpawns(); // Spawns gegen neue Möbel prüfen this.renderer.shadowMap.needsUpdate = true; if (S) { // Koffer + Fundsachen an den (ggf. korrigierten) Plätzen neu setzen Object.keys(this.caseMeshes).forEach(i => { this.scene.remove(this.caseMeshes[i]); delete this.caseMeshes[i]; }); Object.keys(this.fundMeshes).forEach(i => this.removeFund(i)); this.syncCases(); } }, /* Subtree (ohne Skinning) → statische Mesh-Gruppe, Transforms relativ zur Wurzel */ extractStatic(src) { const grp = new THREE.Group(); const inv = new THREE.Matrix4().copy(src.matrixWorld).invert(); src.traverse(o => { if (o.isMesh && !o.isSkinnedMesh) { const m = new THREE.Mesh(o.geometry, o.material); m.castShadow = m.receiveShadow = true; m.applyMatrix4(new THREE.Matrix4().multiplyMatrices(inv, o.matrixWorld)); grp.add(m); } }); return grp; }, /* Zielhöhe je Pool-Modell: Defaults nach Taschentyp, per F9-Kalibrierung * übersteuerbar (localStorage kd.kofferH) */ KOFFER_LABELS: null, kofferDefaults() { return this.kofferPool.map((_, i) => i === 0 ? 1.3 : i === 1 ? 0.55 : i === 2 ? 0.35 : 0.8); }, kofferH(i) { if (!this._kofferH) { this._kofferH = this.kofferDefaults(); try { const saved = JSON.parse(localStorage.getItem('kd.kofferH') || '[]'); saved.forEach((v, k) => { if (typeof v === 'number' && v > 0.1) this._kofferH[k] = v; }); } catch (_) {} } return this._kofferH[i] || 0.8; }, setKofferH(i, h) { this.kofferH(0); // sicherstellen, dass _kofferH initialisiert ist this._kofferH[i] = h; try { localStorage.setItem('kd.kofferH', JSON.stringify(this._kofferH)); } catch (_) {} }, /* Gruppe auf Zielhöhe normieren, am Boden zentrieren */ normalizeKoffer(grp, targetH) { const wrap = new THREE.Group(); const b = new THREE.Box3().setFromObject(grp); const s = b.getSize(new THREE.Vector3()); // multiplizieren, nicht überschreiben — Objekte können eigene Skalierung mitbringen grp.scale.multiplyScalar((targetH || 1.1) / Math.max(0.001, s.y)); const b2 = new THREE.Box3().setFromObject(grp); // Position KORRIGIEREN statt setzen — gebackene Meshes bringen eigene // Versätze mit (sonst landen sie unterm Boden → „unsichtbare“ Koffer) grp.position.x -= (b2.min.x + b2.max.x) / 2; grp.position.y -= b2.min.y; grp.position.z -= (b2.min.z + b2.max.z) / 2; wrap.add(grp); return wrap; }, tintedKoffer(tint) { const grp = new THREE.Group(); const inst = this._koffer1.clone(true); inst.traverse(o => { if (o.isMesh) { o.material = o.material.clone(); o.material.color = o.material.color.clone().multiply(new THREE.Color(tint)); } }); const b = new THREE.Box3().setFromObject(inst); const s = b.getSize(new THREE.Vector3()); inst.scale.setScalar(1.1 / Math.max(0.001, s.y)); const b2 = new THREE.Box3().setFromObject(inst); inst.position.set(-(b2.min.x + b2.max.x) / 2, -b2.min.y, -(b2.min.z + b2.max.z) / 2); grp.add(inst); return grp; }, /* ============ Sketchfab-Terminal (GLB) ============ * Modell: „Airport — Final Big Scene“, sketchfab.com (Standard-Lizenz), * optimiert via gltf-transform (simplify + meshopt, 82 MB → 1,8 MB). * Begehbarkeit: Raster aus Raycasts (Boden + Dach = „innerhalb der Halle“), * dann Flood-Fill — nur die größte zusammenhängende Innenfläche ist begehbar. */ async buildGLB() { const scene = this.scene = new THREE.Scene(); scene.background = new THREE.Color(0x141a24); scene.fog = new THREE.Fog(0x141a24, 80, 320); // Qualität „all in“ (Schulgeräte ab 2022): volle Auflösung, ACES-Tonemapping, // statische Schatten (einmal gerendert, danach kostenlos) this.renderer.setPixelRatio(dprCap(2)); this.renderer.toneMapping = THREE.ACESFilmicToneMapping; this.renderer.toneMappingExposure = 1.15; this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; this.renderer.shadowMap.autoUpdate = false; scene.add(new THREE.HemisphereLight(0xd6dee8, 0x4a5260, 2.2)); scene.add(new THREE.AmbientLight(0xc6ccd6, 2.0)); const sun = new THREE.DirectionalLight(0xffe8c8, 2.0); sun.position.set(60, 110, 40); sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048); sun.shadow.camera.left = -130; sun.shadow.camera.right = 130; sun.shadow.camera.top = 130; sun.shadow.camera.bottom = -130; sun.shadow.camera.far = 320; sun.shadow.bias = -0.0004; scene.add(sun); const fill = new THREE.DirectionalLight(0x9fb8d0, 0.7); fill.position.set(-60, 70, -40); scene.add(fill); // BVH für schnelle Raycasts einhängen if (window.KD_BVH) { THREE.BufferGeometry.prototype.computeBoundsTree = window.KD_BVH.computeBoundsTree; THREE.BufferGeometry.prototype.disposeBoundsTree = window.KD_BVH.disposeBoundsTree; THREE.Mesh.prototype.raycast = window.KD_BVH.acceleratedRaycast; } const loader = new window.KD_GLTFLoader(); if (window.KD_Meshopt) loader.setMeshoptDecoder(window.KD_Meshopt); const gltf = await loader.loadAsync(BASE + 'assets/models/airport.glb'); const root = gltf.scene; // Normalisieren: zentriert, Boden auf y=0. Skaliert wird nur, wenn das // Modell offensichtlich nicht in Metern modelliert ist (zu winzig/riesig). let box = new THREE.Box3().setFromObject(root); const size = box.getSize(new THREE.Vector3()); const longSide = Math.max(size.x, size.z); if (longSide < 50 || longSide > 350) root.scale.setScalar(160 / longSide); box = new THREE.Box3().setFromObject(root); const center = box.getCenter(new THREE.Vector3()); root.position.set(root.position.x - center.x, root.position.y - box.min.y, root.position.z - center.z); scene.add(root); root.updateMatrixWorld(true); this.colliders = []; root.traverse(o => { if (o.isMesh) { if (o.geometry.computeBoundsTree) o.geometry.computeBoundsTree(); // Wände/Böden sind nach außen orientiert — von innen wären sie ohne // DoubleSide unsichtbar (Löcher beim Blick aus der Halle) (Array.isArray(o.material) ? o.material : [o.material]).forEach(m => { m.side = THREE.DoubleSide; }); o.castShadow = true; o.receiveShadow = true; this.colliders.push(o); } }); box = new THREE.Box3().setFromObject(root); this.MAP = { minx: box.min.x, maxx: box.max.x, minz: box.min.z, maxz: box.max.z }; this.buildGrid(); // Lichter über der begehbaren Fläche verteilen this.sampleWalkable(16, 10).forEach(p => { const l = new THREE.PointLight(0xffe0b3, 30, 26, 1.4); l.position.set(p[0], 3.4, p[1]); scene.add(l); }); // Echtes Koffermodell für die Fälle (Sketchfab, optimiert) try { const k = await loader.loadAsync(BASE + 'assets/models/koffer1.glb'); this.glbKoffer = k.scene; } catch (_) { this.glbKoffer = null; } // Statische Schatten einmalig backen (Szene bewegt sich nicht) this.renderer.shadowMap.needsUpdate = true; }, /* Begehbarkeits-Raster: ebener Boden auf Geländeniveau nötig. * Gebäude (Dächer in Kopfhöhe und höher) werden automatisch zu Hindernissen, * jenseits der Geländekante gibt es keinen Boden → Spieler bleibt im Areal. * (Das Sketchfab-Modell ist ein Außengelände-Diorama — eine strikte * „nur unter Dach“-Regel ließe nur ~90 m² übrig; siehe INTEGRATION.md.) */ buildGrid() { const res = 0.8, M = this.MAP; const nx = Math.ceil((M.maxx - M.minx) / res), nz = Math.ceil((M.maxz - M.minz) / res); const data = new Uint8Array(nx * nz); const fy = new Float32Array(nx * nz); const ray = new THREE.Raycaster(); ray.firstHitOnly = true; const DOWN = new THREE.Vector3(0, -1, 0); const org = new THREE.Vector3(); const UP = new THREE.Vector3(0, 1, 0); const roof = new Uint8Array(nx * nz); for (let iz = 0; iz < nz; iz++) for (let ix = 0; ix < nx; ix++) { const x = M.minx + (ix + 0.5) * res, z = M.minz + (iz + 0.5) * res; ray.far = 6; ray.set(org.set(x, 2.4, z), DOWN); const hit = ray.intersectObjects(this.colliders, false)[0]; if (!hit) continue; const floorY = 2.4 - hit.distance; if (floorY < -1.2 || floorY > 0.7) continue; // kein ebener Boden / Dach im Weg data[iz * nx + ix] = 1; fy[iz * nx + ix] = floorY; ray.far = 120; ray.set(org.set(x, 2.5, z), UP); if (ray.intersectObjects(this.colliders, false).length) roof[iz * nx + ix] = 1; } // Flood-Fill: Komponenten labeln. Größte Fläche = Hauptbereich (Vorfeld). // Überdachte Innenräume ab ~40 m² bleiben begehbar und werden über // Tür-Portale angebunden (das Modell hat keine echten Türöffnungen). const label = new Int32Array(nx * nz).fill(-1); let best = -1, bestSize = 0, nLabels = 0; const sizes = [], roofs = []; const stack = []; for (let i = 0; i < nx * nz; i++) { if (data[i] !== 1 || label[i] !== -1) continue; let count = 0, roofed = 0; stack.push(i); label[i] = nLabels; while (stack.length) { const c = stack.pop(); count++; roofed += roof[c]; const cx = c % nx, cz = (c / nx) | 0; [[1, 0], [-1, 0], [0, 1], [0, -1]].forEach(d => { const ax = cx + d[0], az = cz + d[1]; if (ax < 0 || az < 0 || ax >= nx || az >= nz) return; const ai = az * nx + ax; if (data[ai] === 1 && label[ai] === -1) { label[ai] = nLabels; stack.push(ai); } }); } sizes.push(count); roofs.push(roofed); if (count > bestSize) { bestSize = count; best = nLabels; } nLabels++; } const keepInterior = []; for (let l = 0; l < nLabels; l++) { if (l !== best && sizes[l] >= 60 && roofs[l] / sizes[l] >= 0.5) keepInterior.push(l); } for (let i = 0; i < nx * nz; i++) { if (label[i] !== best && !keepInterior.includes(label[i])) data[i] = 0; } // Türen: pro Innenraum das nächstgelegene Zellenpaar (drinnen ↔ Vorfeld) const cellXZ = i => [M.minx + ((i % nx) + 0.5) * res, M.minz + (((i / nx) | 0) + 0.5) * res]; const mainCells = []; for (let i = 0; i < nx * nz; i++) if (data[i] === 1 && label[i] === best && (i % 3 === 0)) mainCells.push(i); this.doors = []; keepInterior.forEach(l => { let bp = null, bd = 1e9; for (let i = 0; i < nx * nz; i++) { if (data[i] !== 1 || label[i] !== l) continue; const [x1, z1] = cellXZ(i); for (const m of mainCells) { const [x2, z2] = cellXZ(m); const d = (x1 - x2) * (x1 - x2) + (z1 - z2) * (z1 - z2); if (d < bd) { bd = d; bp = { bx: x1, bz: z1, ax: x2, az: z2 }; } } } if (bp && bd < 30 * 30) this.doors.push(bp); }); // Portal-Enden von der Wand weg auf „freie“ Zellen rücken (Spieler-Radius!) const snapOpen = (x, z, wantLabel) => { let bq = null, bqd = 1e9; for (let iz = 1; iz < nz - 1; iz++) for (let ix = 1; ix < nx - 1; ix++) { const i = iz * nx + ix; if (data[i] !== 1 || (wantLabel != null && label[i] !== wantLabel)) continue; let openOK = true; for (let dz = -1; dz <= 1 && openOK; dz++) for (let dx = -1; dx <= 1; dx++) if (data[(iz + dz) * nx + ix + dx] !== 1) { openOK = false; break; } if (!openOK) continue; const [cx2, cz2] = cellXZ(i); const dd = (cx2 - x) * (cx2 - x) + (cz2 - z) * (cz2 - z); if (dd < bqd) { bqd = dd; bq = [cx2, cz2]; } } return bqd < 8 * 8 ? bq : [x, z]; }; this.doors.forEach(d => { const labA = (() => { const ix = Math.floor((d.ax - M.minx) / res), iz = Math.floor((d.az - M.minz) / res); return label[iz * nx + ix]; })(); const labB = (() => { const ix = Math.floor((d.bx - M.minx) / res), iz = Math.floor((d.bz - M.minz) / res); return label[iz * nx + ix]; })(); const a2 = snapOpen(d.ax, d.az, labA); d.ax = a2[0]; d.az = a2[1]; const b2 = snapOpen(d.bx, d.bz, labB); d.bx = b2[0]; d.bz = b2[1]; }); this.doors.forEach(d => this.addPortal(d)); // „Freie“ Zellen: alle 8 Nachbarn ebenfalls begehbar (genug Abstand zu Wänden) const open = new Uint8Array(nx * nz); for (let iz = 1; iz < nz - 1; iz++) for (let ix = 1; ix < nx - 1; ix++) { if (data[iz * nx + ix] !== 1) continue; let ok = true; for (let dz = -1; dz <= 1 && ok; dz++) for (let dx = -1; dx <= 1; dx++) { if (data[(iz + dz) * nx + ix + dx] !== 1) { ok = false; break; } } if (ok) open[iz * nx + ix] = 1; } this.grid = { data, open, fy, nx, nz, res, minx: M.minx, minz: M.minz }; // Start: Zelle im Herzen der GRÖSSTEN freien Fläche (iterative Erosion — // überlebt eine Zelle viele Erosionsrunden, liegt sie mitten in einer // großen Halle, nicht in einer Abstellkammer) let cur = data.slice(); let survivors = null; for (let pass = 0; pass < 10; pass++) { const next = new Uint8Array(nx * nz); let any = 0; for (let iz = 1; iz < nz - 1; iz++) for (let ix = 1; ix < nx - 1; ix++) { const i = iz * nx + ix; if (cur[i] !== 1) continue; if (cur[i - 1] && cur[i + 1] && cur[i - nx] && cur[i + nx]) { next[i] = 1; any++; } } if (!any) break; cur = next; survivors = next; } const cx = (M.minx + M.maxx) / 2, cz = (M.minz + M.maxz) / 2; let bd = 1e9, bp = null; const pickFrom = (mask) => { if (!mask) return; for (let iz = 0; iz < nz; iz++) for (let ix = 0; ix < nx; ix++) { if (mask[iz * nx + ix] !== 1) continue; const x = M.minx + (ix + 0.5) * res, z = M.minz + (iz + 0.5) * res; const d = (x - cx) * (x - cx) + (z - cz) * (z - cz); if (d < bd) { bd = d; bp = [x, z]; } } }; pickFrom(survivors); if (!bp) pickFrom(open); if (!bp) pickFrom(data); // Blick in die offenste Richtung let yaw = 0; if (bp) { let bestD = -1; for (let a = 0; a < 16; a++) { const ang = a / 16 * Math.PI * 2; let d = 0; while (d < 30 && this.gridWalkableRaw(bp[0] - Math.sin(ang) * (d + 1), bp[1] - Math.cos(ang) * (d + 1), data, M, res, nx, nz)) d++; if (d > bestD) { bestD = d; yaw = ang; } } } this.startPos = bp ? { x: bp[0], z: bp[1], yaw } : { x: cx, z: cz, yaw: 0 }; // Spawns: 12 weit verteilte Punkte, benannt nach Sektoren const pts = this.sampleWalkable(12, 12); const letter = x => 'ABCDEF'[Math.min(5, Math.floor((x - M.minx) / (M.maxx - M.minx) * 6))]; const num = z => 1 + Math.min(3, Math.floor((z - M.minz) / (M.maxz - M.minz) * 4)); this.SPAWNS = pts.map(p => [p[0], p[1], 'Terminal, Sektor ' + letter(p[0]) + num(p[1])]); this.ZONES = []; // Minimap aus dem Raster const cv = document.createElement('canvas'); cv.width = nx; cv.height = nz; const g = cv.getContext('2d'); g.fillStyle = '#0d1520'; g.fillRect(0, 0, nx, nz); const img = g.createImageData(nx, nz); for (let i = 0; i < nx * nz; i++) { const on = data[i] === 1; img.data[i * 4] = on ? 0x2e : 0x0d; img.data[i * 4 + 1] = on ? 0x3c : 0x15; img.data[i * 4 + 2] = on ? 0x4e : 0x20; img.data[i * 4 + 3] = 255; } g.putImageData(img, 0, 0); this.minimapCanvas = cv; }, /* Leuchtendes Tür-Portal (außen + innen je ein Ring) */ addPortal(d) { [[d.ax, d.az, 'Eingang'], [d.bx, d.bz, 'Ausgang']].forEach(([x, z, txt]) => { const ring = new THREE.Mesh(new THREE.TorusGeometry(0.95, 0.09, 8, 28), new THREE.MeshBasicMaterial({ color: 0x5ad8ef })); ring.position.set(x, 1.25, z); this.scene.add(ring); const l = new THREE.PointLight(0x5ad8ef, 8, 9, 1.8); l.position.set(x, 1.6, z); this.scene.add(l); this.sign('🚪 ' + txt, x, 2.7, z, 2.4, 0.7, 0, true); this.animated.push({ update: (dt, t) => { ring.rotation.y = t * 0.8; ring.scale.setScalar(1 + Math.sin(t * 2.5) * 0.05); }}); }); }, /* Portal-Teleport: Hineinlaufen wechselt zwischen Vorfeld und Innenraum */ checkPortals() { if (!this.doors || !this.doors.length) return; const p = this.player; let near = 1e9; this.doors.forEach(d => { near = Math.min(near, Math.hypot(p.x - d.ax, p.z - d.az), Math.hypot(p.x - d.bx, p.z - d.bz)); }); if (near > 1.6) { this._tpArmed = true; return; } if (!this._tpArmed) return; for (const d of this.doors) { const da = Math.hypot(p.x - d.ax, p.z - d.az), db = Math.hypot(p.x - d.bx, p.z - d.bz); if (da < 1.0) { p.x = d.bx; p.z = d.bz; this._tpArmed = false; Sound.play('case-open', 0.35); UI.toast('🚪 Du betrittst das Gebäude.'); return; } if (db < 1.0) { p.x = d.ax; p.z = d.az; this._tpArmed = false; Sound.play('case-open', 0.35); UI.toast('🚪 Zurück auf dem Vorfeld.'); return; } } }, eachWalkable(fn, onlyOpen) { const g = this.grid; if (!g) return; const src = (onlyOpen && g.open) ? g.open : g.data; for (let iz = 0; iz < g.nz; iz++) for (let ix = 0; ix < g.nx; ix++) { if (src[iz * g.nx + ix] === 1) fn(g.minx + (ix + 0.5) * g.res, g.minz + (iz + 0.5) * g.res); } }, /* k Punkte mit Mindestabstand, möglichst weit gestreut (greedy farthest point) */ sampleWalkable(k, minDist) { const cells = []; this.eachWalkable((x, z) => { if (Math.random() < 0.4) cells.push([x, z]); }, true); if (!cells.length) this.eachWalkable((x, z) => { if (Math.random() < 0.25) cells.push([x, z]); }); if (!cells.length) return []; const picked = [cells[(Math.random() * cells.length) | 0]]; while (picked.length < k) { let best = null, bd = -1; for (const c of cells) { let d = 1e9; for (const p of picked) d = Math.min(d, Math.hypot(c[0] - p[0], c[1] - p[1])); if (d > bd) { bd = d; best = c; } } if (!best || bd < minDist * 0.4) break; picked.push(best); } return picked; }, gridWalkableRaw(x, z, data, M, res, nx, nz) { const ix = Math.floor((x - M.minx) / res), iz = Math.floor((z - M.minz) / res); if (ix < 0 || iz < 0 || ix >= nx || iz >= nz) return false; return data[iz * nx + ix] === 1; }, gridWalkable(x, z) { const g = this.grid; if (!g) return true; const ix = Math.floor((x - g.minx) / g.res), iz = Math.floor((z - g.minz) / g.res); if (ix < 0 || iz < 0 || ix >= g.nx || iz >= g.nz) return false; return g.data[iz * g.nx + ix] === 1; }, floorYAt(x, z) { const g = this.grid; if (!g) return 0; const ix = Math.floor((x - g.minx) / g.res), iz = Math.floor((z - g.minz) / g.res); if (ix < 0 || iz < 0 || ix >= g.nx || iz >= g.nz) return 0; return g.fy[iz * g.nx + ix] || 0; }, /* Exakte Bodenhöhe per Raycast direkt unter der Position (BVH → billig). * Das Raster ist nur Fallback — es mittelt 80-cm-Zellen und lag in den * mehrstöckigen Hallen daneben („Schweben“). */ liveFloorY(x, z) { if (this.mode !== 'glb' || !this.colliders.length) return 0; const ref = this.floorYAt(x, z); if (!this._gRay) { this._gRay = new THREE.Raycaster(); this._gRay.firstHitOnly = true; this._gDown = new THREE.Vector3(0, -1, 0); this._gOrg = new THREE.Vector3(); } this._gRay.far = 4; this._gRay.set(this._gOrg.set(x, ref + 1.9, z), this._gDown); const hit = this._gRay.intersectObjects(this.colliders, false)[0]; if (!hit) return ref; const y = ref + 1.9 - hit.distance; // absurde Treffer (Tischplatte exakt unterm Kopf etc.) verwerfen return (y > ref + 1.2 || y < ref - 2) ? ref : y; }, /* --- Gepäcksortierung: animierte Förderbänder --- */ beltTexture() { const cv = document.createElement('canvas'); cv.width = 128; cv.height = 128; const g = cv.getContext('2d'); g.fillStyle = '#23282e'; g.fillRect(0, 0, 128, 128); g.fillStyle = '#2f353d'; for (let i = 0; i < 128; i += 16) g.fillRect(i, 0, 7, 128); g.fillStyle = '#444b55'; g.fillRect(0, 0, 2, 128); g.fillRect(126, 0, 2, 128); const t = new THREE.CanvasTexture(cv); t.wrapS = t.wrapT = THREE.RepeatWrapping; return t; }, buildSorting() { this.sign('Gepäcksortierung', -64, 4.4, -28.4, 13, 1.6, 0, true); // Förderband als GESCHLOSSENE SCHLEIFE (Stadion-Form): zwei Geraden, // an den Enden Halbkreise — die Stücke fahren im Kreis statt „in die Wand“ this.buildBeltLoop(-64, -13, 6, 4); // Querband Richtung Verladung (steht still) + Rutsche this.addBox(-58, 0.5, 8, 2.2, 1, 10, this.matMetall); this.addCollider(-58, 8, 2.2, 10); // Gepäckwagen + Kistenstapel this.addBox(-70, 0.7, 12, 3, 1.4, 1.8, 0x3f5a72); this.addCollider(-70, 12, 3, 1.8); [[-73, 22], [-70.5, 22], [-73, 19.5]].forEach((p, i) => { this.addBox(p[0], 0.5 + (i === 2 ? 1 : 0) * 0, p[1], 2, 1, 2, this.matBeton); }); this.addBox(-73, 1.5, 22, 1.8, 1, 1.8, this.matBeton); this.addCollider(-72, 21, 5, 4); // Warnlicht (rotierend) const warn = this.addBox(-53, 5.2, 2, 0.35, 0.35, 0.35, 0xe8833a, { emissive: 0xe8833a, emissiveIntensity: 1 }); const warnL = new THREE.PointLight(0xe8833a, 8, 12, 1.6); warnL.position.set(-53, 5.2, 2); this.scene.add(warnL); this.animated.push({ update: (dt, t) => { const on = Math.sin(t * 4) > 0; warn.material.emissiveIntensity = on ? 1 : 0.15; warnL.intensity = on ? 8 : 0.5; }}); }, /* Stadion-Förderband: Mittelpunkt (cx,cz), Geraden-Halblänge L, Bogenradius R. * In Bearbeitung befindliches Gepäck fährt die Schleife entlang. */ buildBeltLoop(cx, cz, L, R) { const bw = 2.2; // Bandbreite const beltMat = new THREE.MeshLambertMaterial({ color: 0x23282e }); const railMat = this.matMetall; // Geraden (oben/unten) [-R, R].forEach(off => { const surf = this.addBox(cx, 0.8, cz + off, L * 2, 0.22, bw, beltMat); this.addBox(cx, 0.42, cz + off, L * 2, 0.55, bw - 0.5, railMat); }); // Halbkreis-Enden (flacher Ring, halbe Drehung) + Stützwand [[-L, Math.PI / 2], [L, -Math.PI / 2]].forEach(([ex, rot]) => { const ring = new THREE.Mesh( new THREE.RingGeometry(R - bw / 2, R + bw / 2, 24, 1, rot, Math.PI), beltMat); ring.rotation.x = -Math.PI / 2; ring.position.set(cx + ex, 0.91, cz); ring.receiveShadow = true; this.scene.add(ring); const wall = new THREE.Mesh( new THREE.CylinderGeometry(R + bw / 2, R + bw / 2, 0.85, 24, 1, true, rot, Math.PI), railMat); wall.position.set(cx + ex, 0.45, cz); wall.castShadow = true; this.scene.add(wall); }); this.addCollider(cx, cz, L * 2 + (R + bw / 2) * 2, (R + bw / 2) * 2); // Gepäck „in Bearbeitung“ fährt die Schleife (Pool-Modelle, sobald geladen) const path = t => { // t ∈ [0,1) → Position + Tangente const straight = 2 * L, arc = Math.PI * R, total = 2 * straight + 2 * arc; let d = t * total; if (d < straight) return { x: cx - L + d, z: cz - R, rot: Math.PI / 2 }; d -= straight; if (d < arc) { const a = d / R; return { x: cx + L + Math.sin(a) * R, z: cz - Math.cos(a) * R, rot: Math.PI / 2 - a }; } d -= arc; if (d < straight) return { x: cx + L - d, z: cz + R, rot: -Math.PI / 2 }; d -= straight; const a = d / R; return { x: cx - L - Math.sin(a) * R, z: cz + Math.cos(a) * R, rot: -Math.PI / 2 - a }; }; const riders = []; this._beltRiderInit = () => { if (!this.kofferPool.length || riders.length) return; for (let i = 0; i < 4; i++) { const m = this.normalizeKoffer(this.kofferPool[i % this.kofferPool.length].clone(true), this.kofferH(i % this.kofferPool.length) * 0.85); m.userData.beltT = i / 4; this.scene.add(m); riders.push(m); } }; this.animated.push({ update: (dt) => { riders.forEach(m => { m.userData.beltT = (m.userData.beltT + dt * 0.012) % 1; const p = path(m.userData.beltT); m.position.set(p.x, 0.95, p.z); m.rotation.y = p.rot; }); }}); }, buildWestTract() { // Check-in-Schalter: GLB-Props (loadHallProps); prozedural nur als Fallback if (!window.__kdModules) { for (let i = 0; i < 5; i++) { const x = -46 + i * 7; this.addBox(x, 0.6, -24, 4.5, 1.2, 2.2, this.matMetall); this.addCollider(x, -24, 4.5, 2.2); this.addBox(x, 2.6, -25, 1.6, 1.0, 0.1, 0x0e1d2a, { emissive: 0x2a6a8a, emissiveIntensity: 0.8 }); } } this.sign('Check-in 1 – 10', -35, 4.2, -29.4, 14, 1.8, 0, true); // Gepäckkarusselle (stehen nach Dienstschluss still): flaches Band // mit Metall-Seitenwänden und Mittelinsel statt des alten „Donuts“ [[-38, 12], [-38, -2]].forEach((p, bi) => { const grp = new THREE.Group(); const wallOut = new THREE.Mesh(new THREE.CylinderGeometry(5.2, 5.45, 0.78, 28, 1, true), this.matMetall); wallOut.position.y = 0.39; grp.add(wallOut); const belt = new THREE.Mesh(new THREE.RingGeometry(3.1, 5.1, 28), new THREE.MeshLambertMaterial({ color: 0x23282e })); belt.rotation.x = -Math.PI / 2; belt.position.y = 0.8; grp.add(belt); // Segment-Fugen auf dem Band for (let s = 0; s < 14; s++) { const seg = new THREE.Mesh(new THREE.BoxGeometry(2.0, 0.02, 0.06), new THREE.MeshLambertMaterial({ color: 0x3a424c })); const a = s / 14 * Math.PI * 2; seg.position.set(Math.cos(a) * 4.1, 0.82, Math.sin(a) * 4.1); seg.rotation.y = -a + Math.PI / 2; grp.add(seg); } const island = new THREE.Mesh(new THREE.CylinderGeometry(2.9, 3.1, 1.25, 22), this.matMetall); island.position.y = 0.62; grp.add(island); const cap = new THREE.Mesh(new THREE.CylinderGeometry(1.1, 2.0, 0.7, 16), new THREE.MeshLambertMaterial({ color: 0x3a4654 })); cap.position.y = 1.6; grp.add(cap); grp.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } }); grp.position.set(p[0], 0, p[1]); this.scene.add(grp); this.addCollider(p[0], p[1], 10.6, 10.6); this.sign('Band ' + (bi + 1), p[0], 3.4, p[1], 3, 1.2, 0.6, true); this.hangRod(p[0], 4.0, p[1]); }); this.sign('Gepäckausgabe', -35, 4.4, 5.5, 12, 1.6, Math.PI, true); this.hangRod(-35, 5.2, 5.5, -4); this.hangRod(-35, 5.2, 5.5, 4); }, buildCenter() { // Sicherheitsbereich: GLB-Scanner via Props; prozedural nur als Fallback if (!window.__kdModules) { [-12, -7].forEach(x => { this.addBox(x, 1.5, -22, 0.4, 3, 0.4, 0x4a5666); this.addBox(x, 3.1, -22, 0.4, 0.25, 2.4, 0x4a5666); this.addCollider(x, -21.4, 1, 2.8); }); } this.sign('Sicherheitskontrolle', -10, 4.2, -28.4, 13, 1.5, 0, true); // Absperrungen: Warteschlangen-Führung vor den Scannern + seitliche Sperren — // ohne sie ergäbe die Kontrolle keinen Sinn (man könnte vorbeilaufen) this.buildBarrier([[-17.5, -16], [-17.5, -19.5], [-10, -19.5], [-10, -16.5]]); this.buildBarrier([[-2, -16], [-2, -19.5], [-9.2, -19.5]]); this.buildBarrier([[-17.5, -22], [-16, -22]]); // Flanke West this.buildBarrier([[-3.2, -22], [-2, -22]]); // Flanke Ost // Duty-free mit bunten, beleuchteten Regalen (Schaufenster-Glühen) for (let i = 0; i < 3; i++) { const x = 8 + i * 5; this.addBox(x, 1.1, -23, 1.2, 2.2, 6, 0x4a3f50); this.addCollider(x, -23, 1.2, 6); this.addBox(x, 2.0, -23, 1.4, 0.12, 6.2, 0x6a5a72); for (let s = 0; s < 5; s++) this.addBox(x, 1.3 + (s % 2) * 0.55, -25.4 + s * 1.2, 1.3, 0.35, 0.35, [0xe8c547, 0xb04a3e, 0x4a7c4e, 0x7ea6c4, 0xc9a24a][s], { emissive: 0x333333 }); const shopL = new THREE.PointLight(0xffd9a0, 9, 11, 1.6); shopL.position.set(x, 2.9, -21.5); this.scene.add(shopL); this.animated.push({ update: (dt, t) => { shopL.intensity = 8.5 + Math.sin(t * 1.3 + i * 2) * 1.5; } }); } this.sign('Duty-free', 12, 4.2, -28.4, 8, 1.5, 0, true); // Eingangshalle: Infoschalter, Säulen, Bänke, Pflanzen, Putzwagen this.addBox(0, 0.7, 6, 5.5, 1.4, 2.4, this.matMetall); this.addCollider(0, 6, 5.5, 2.4); this.sign('Information', 0, 2.9, 6, 5, 1, 0, true); [[-14, 0], [14, 0], [-14, 16], [14, 16]].forEach(p => { const col = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 0.7, 7.5, 10), this.matBeton); col.position.set(p[0], 3.75, p[1]); this.scene.add(col); this.addCollider(p[0], p[1], 1.4, 1.4); }); if (!window.__kdModules) { [[-7, 18], [7, 18], [-7, 24], [7, 24]].forEach(p => { this.addBox(p[0], 0.45, p[1], 4.5, 0.5, 1.2, 0x4a6a8a); this.addCollider(p[0], p[1], 4.5, 1.2); this.addBox(p[0], 1.05, p[1] + 0.45, 4.5, 1.1, 0.18, 0x3a5a78); }); } this.plants([[-17, 6], [17, 6], [-7, 27.5], [5, 27.5], [-24, -4]]); this.addBox(17, 0.6, 12, 1, 1.2, 1.6, 0xc9b23a); this.addCollider(17, 12, 1, 1.6); }, buildEast() { if (!window.__kdModules) { for (let r = 0; r < 4; r++) { const z = -16 + r * 9; this.addBox(42, 0.5, z, 8, 1, 1.1, 0x4a6a8a); this.addCollider(42, z, 8, 1.1); this.addBox(42, 1.1, z + 0.45, 8, 1.1, 0.18, 0x3a5a78); } } for (let g = 0; g < 3; g++) this.sign('Gate 1' + (g + 1), 50.8, 3.6, -14 + g * 12, 4, 1.3, -Math.PI / 2, true); this.sign('Abflug', 26, 4.6, -10, 6, 1.6, -Math.PI / 2, true); this.hangRod(26, 5.4, -10, 0); this.addBox(40, 0.6, 19.4, 5, 1.2, 2, this.matMetall); this.addCollider(40, 19.4, 5, 2); this.sign('Fundbüro', 40, 3.6, 16.2, 5.5, 1.3, Math.PI, true); this.plants([[36, -24], [47, 24]]); }, /* ============ Treppe (probehalber begehbar) + Galerie ============ */ buildEscalators() { const glass = new THREE.MeshLambertMaterial({ color: 0x9fc8e0, transparent: true, opacity: 0.25 }); // Stufenband von (z=5, y=0) hinauf zu (z=−8, y=6.4) — bis knapp unter die Galerie [[-9.8], [-7.6]].forEach(([x], dir) => { for (let s = 0; s < 17; s++) { const t = s / 16; this.addBox(x, 0.18 + t * 6.2, 5 - t * 13, 1.6, 0.36, 0.95, dir ? 0x39434f : 0x424d5a); } [[-0.85], [0.85]].forEach(([off]) => { // Glas-Balustrade entlang der Treppe (etwas höher) const side = new THREE.Mesh(new THREE.PlaneGeometry(14.6, 1.75), glass); side.position.set(x + off, 3.95, -1.5); side.rotation.y = Math.PI / 2; side.rotation.z = Math.atan2(6.4, 13); this.scene.add(side); // Handlauf OBEN auf der Balustrade — folgt der Treppe (vorher falsch herum // gekippt: −atan2 → die Strebe lief entgegen der Treppe) const rail = this.addBox(x + off, 4.78, -1.5, 0.09, 0.11, 14.4, 0x1b222b); rail.rotation.x = Math.atan2(6.4, 13); // dünne Stützpfosten von der Stufe bis zum Handlauf for (let s = 1; s < 16; s += 3) { const t = s / 16, py = 0.18 + t * 6.2, pz = 5 - t * 13; this.addBox(x + off, py + 1.15, pz, 0.05, 2.3, 0.05, 0x1b222b); } }); }); // (probehalber begehbar — kein Sperr-Kollider auf der Treppe) // Galerie-Plattform oben — deutlich breiter (60 m, läuft längs durch die // Halle), mit umlaufendem Glasgeländer und Treppen-Durchlass vorne mittig const railMat = new THREE.MeshLambertMaterial({ color: 0x9fc8e0, transparent: true, opacity: 0.3 }); const CX = -8.7, CZ = -10.3, W = 60, D = 4.5, topY = 6.825, railY = 7.35; // läuft längs durch die Halle const x0 = CX - W / 2, x1 = CX + W / 2, zFront = CZ + D / 2, zBack = CZ - D / 2; this.addBox(CX, 6.65, CZ, W, 0.35, D, this.matBeton); // Plattform this.addBox(CX, railY, zBack, W, 1.0, 0.12, railMat); // Geländer hinten this.addBox(x0, railY, CZ, 0.12, 1.0, D, railMat); // Geländer links this.addBox(x1, railY, CZ, 0.12, 1.0, D, railMat); // Geländer rechts // Geländer vorne in zwei Teilen, ~3,4 m Lücke mittig über der Treppe const gap = 3.4, segW = (W - gap) / 2; const flx = CX - (gap + segW) / 2, frx = CX + (gap + segW) / 2; this.addBox(flx, railY, zFront, segW, 1.0, 0.12, railMat); this.addBox(frx, railY, zFront, segW, 1.0, 0.12, railMat); // Geländer als Kollider (nur aktiv, wenn man oben ist — siehe blocked()). // Die Lücke vorne über der Treppe bleibt frei zum Ein-/Aussteigen. [[CX, zBack, W, 0.12], [x0, CZ, 0.12, D], [x1, CZ, 0.12, D], [flx, zFront, segW, 0.12], [frx, zFront, segW, 0.12], // Treppen-Seitengeländer (äußere Handläufe x≈−10.65 / −6.75) — man kann // nicht seitlich von der Stiege herunter [-10.65, -1.5, 0.12, 13], [-6.75, -1.5, 0.12, 13]].forEach(([rx, rz, sx, sz]) => this.railWalls.push({ x: rx, z: rz, w: sx / 2 + 0.4, h: sz / 2 + 0.4 })); // Pflanzen längs über die ganze Galerie verteilt (ohne Boden-Kollider, da // nicht begehbar); die Mitte über der Rolltreppe bleibt frei this.plants([[-34, -11.3], [-29, -9.3], [-24, -11.3], [-19, -9.3], [-14, -11.3], [-3.5, -11.3], [1, -9.3], [6, -11.3], [11, -9.3], [16, -11.3]], { y: topY, collide: false }); this.sign('Galerie', -8.7, 3.1, 5.9, 4.2, 0.8, 0, true); // Begeh-Geometrie (Test): Rampe steigt von z=5 (y=0) auf z=−8 (y=topY), // darüber die ebene Plattform. classicFloorH() hebt die Augenhöhe an. this.galleryWalk = { rampX: [-10.9, -6.5], rampZ: [-8.5, 5.6], rampZ0: 5, rampLen: 13, platX: [x0, x1], platZ: [zBack - 0.2, zFront + 0.05], topY: topY, }; }, /* Ziel-Bodenhöhe in der klassischen Halle (nur Treppe + Galerie erhöht). * Hysterese: die Plattform hebt nur an, wenn man bereits oben ist (über die * Treppe gekommen) — sonst läuft man ebenerdig UNTER der Galerie hindurch. */ classicFloorTarget(x, z) { const g = this.galleryWalk; if (!g) return 0; // Rampe = einzige Verbindung Boden <-> Galerie if (x > g.rampX[0] && x < g.rampX[1] && z > g.rampZ[0] && z < g.rampZ[1]) { const rampH = Math.min(1, Math.max(0, (g.rampZ0 - z) / g.rampLen)) * g.topY; // nur anheben, wenn man unten einsteigt (rampH klein) oder schon auf der // Treppe ist — sonst läuft man ebenerdig UNTER der erhöhten Stiege durch if (rampH < 0.6 || Math.abs(this.floorY - rampH) < 1.4) return rampH; return 0; } // Plattform nur erhöht, wenn man schon oben ist if (x > g.platX[0] && x < g.platX[1] && z > g.platZ[0] && z < g.platZ[1] && this.floorY > g.topY * 0.5) return g.topY; return 0; }, onGallery() { const g = this.galleryWalk; return !!g && this.floorY > g.topY * 0.5; }, /* ============ Info-Stelen mit Richtungspfeilen ============ */ buildSteles() { const mk = (x, z, rotY, lines) => { this.addBox(x, 1.4, z, 0.45, 2.8, 0.45, 0x2a313b); this.addCollider(x, z, 0.6, 0.6); // gap 0.27 rückt die Schilder vor/hinter die Säule (sonst verdeckt sie die Mitte) lines.forEach((txt, i) => this.sign(txt, x, 2.45 - i * 0.55, z, 2.6, 0.48, rotY, true, 0.27)); }; mk(4, 14, 0, ['→ Abflug · Gates 11 – 13', '← Gepäckausgabe']); mk(-26, 2, Math.PI / 2, ['→ Check-in 1 – 10', '← Eingangshalle']); mk(26, -8, -Math.PI / 2, ['→ Duty-free · Sicherheit', '← Gates 11 – 13']); mk(-48, 4, Math.PI / 2, ['→ Gepäcksortierung', '← Gepäckausgabe']); }, /* ============ Snack-/Getränkeautomaten (leuchtende Front) ============ */ buildVending() { const front = (label, cols) => { const cv = document.createElement('canvas'); cv.width = 128; cv.height = 256; const g = cv.getContext('2d'); g.fillStyle = '#0e1822'; g.fillRect(0, 0, 128, 256); for (let r = 0; r < 5; r++) for (let c = 0; c < 4; c++) { g.fillStyle = cols[(r * 4 + c) % cols.length]; g.fillRect(12 + c * 28, 26 + r * 36, 22, 26); } g.fillStyle = '#1a2634'; g.fillRect(0, 212, 128, 44); g.fillStyle = '#9fd8ef'; g.fillRect(14, 224, 100, 8); return new THREE.CanvasTexture(cv); }; [[20.25, 8, ['#e8c547', '#b04a3e', '#4a7c4e', '#c9a24a']], [20.25, 10.4, ['#7ea6c4', '#5a8a5e', '#e8833a', '#9fd8ef']]].forEach(([x, z, cols]) => { this.addBox(x, 0.95, z, 0.75, 1.9, 1.05, 0x252d38); this.addCollider(x, z, 0.9, 1.2); const f = new THREE.Mesh(new THREE.PlaneGeometry(0.95, 1.7), new THREE.MeshBasicMaterial({ map: front('', cols) })); f.position.set(x - 0.39, 1.0, z); f.rotation.y = -Math.PI / 2; this.scene.add(f); const gl = new THREE.PointLight(0xbfe2f0, 4, 5, 1.8); gl.position.set(x - 0.8, 1.2, z); this.scene.add(gl); }); }, /* ============ Leben auf dem Vorfeld (hinter der Glasfront) ============ */ buildApronLife() { const zCar = this.MINZ - 11, zPlane = this.MINZ - 16; // Follow-me-Auto const car = new THREE.Group(); const body = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.9, 1.1), new THREE.MeshBasicMaterial({ color: 0xc9b23a })); body.position.y = 0.75; car.add(body); const cab = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.6, 1.0), new THREE.MeshBasicMaterial({ color: 0x2a313b })); cab.position.set(-0.4, 1.4, 0); car.add(cab); const beacon = new THREE.Mesh(new THREE.SphereGeometry(0.14, 6, 6), new THREE.MeshBasicMaterial({ color: 0xffd75e })); beacon.position.set(-0.4, 1.85, 0); car.add(beacon); car.position.set(this.MINX + 14, 0, zCar); this.scene.add(car); // Rollende Maschinen: freigestellte Jet-Illustrationen (zwei Varianten) const planeLoader = new THREE.TextureLoader(); const mkJet = (file, w) => { const t = planeLoader.load(BASE + 'assets/img/' + file); t.colorSpace = THREE.SRGBColorSpace; const m = new THREE.Mesh(new THREE.PlaneGeometry(w, w * 1024 / 1536), new THREE.MeshBasicMaterial({ map: t, transparent: true, toneMapped: false })); // tiefer ansetzen: Maschinen sollen auf dem Vorfeld stehen, nicht schweben // (der Vorfeld-Vordergrund verdeckt den unteren Rand natürlich) m.position.set(this.MAXX + 40, (w * 1024 / 1536) / 2 - 4.6, zPlane); this.scene.add(m); return m; }; const jets = [mkJet('jet1.webp', 24), mkJet('jet2.webp', 19)]; let jetIdx = 0; const plane = { get position() { return jets[jetIdx].position; } }; // Radarmast mit drehender Schüssel const mast = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.3, 9, 8), new THREE.MeshBasicMaterial({ color: 0x222c38 })); mast.position.set((this.MINX + this.MAXX) / 2 + 46, 4.5, this.MINZ - 14); this.scene.add(mast); const dish = new THREE.Mesh(new THREE.BoxGeometry(2.6, 0.7, 0.18), new THREE.MeshBasicMaterial({ color: 0x39434f })); dish.position.set(mast.position.x, 9.2, mast.position.z); this.scene.add(dish); let carDir = 1, carWait = 0, planeT = -20; this.animated.push({ update: (dt, t) => { dish.rotation.y = t * 0.9; beacon.material.color.setHex(Math.sin(t * 5) > 0 ? 0xffd75e : 0x6b5a22); if (carWait > 0) { carWait -= dt; } else { car.position.x += dt * 5.5 * carDir; if (car.position.x > this.MAXX - 12 || car.position.x < this.MINX + 12) { carDir *= -1; carWait = 4 + Math.random() * 6; car.rotation.y = carDir > 0 ? 0 : Math.PI; } } planeT += dt; if (planeT > 0) { plane.position.x = this.MAXX + 30 - planeT * 4.2; if (plane.position.x < this.MINX - 40) { plane.position.x = this.MAXX + 40; // aktuellen Jet parken jetIdx = (jetIdx + 1) % jets.length; // nächstes Mal der andere Jet planeT = -25 - Math.random() * 30; } } }}); }, /* ============ Pulsierende Notausgang-Schilder ============ */ buildExitSigns() { const spots = [[-51.35, 4.6, 2, Math.PI / 2], [-51.35, 4.6, -23.5, Math.PI / 2], [-20.35, 4.6, 3, Math.PI / 2], [20.35, 4.6, 0, -Math.PI / 2]]; const mats = []; spots.forEach(([x, y, z, rotY]) => { const m = new THREE.Mesh(new THREE.PlaneGeometry(0.95, 0.4), new THREE.MeshBasicMaterial({ map: this.textTexture('🏃 EXIT', 192, 80, { bg: '#0e3320', fg: '#7df0a8', size: 40 }), transparent: true, })); m.position.set(x, y, z); m.rotation.y = rotY; this.scene.add(m); mats.push(m.material); }); this.animated.push({ update: (dt, t) => { const o = 0.75 + Math.sin(t * 2.2) * 0.25; mats.forEach(mt => { mt.opacity = o; }); }}); }, /* ============ Flughafen-Piktogramme + Türen / Ein- & Ausgänge ============ */ // Piktogramm-Plakette: weißes Symbol/Label auf farbiger Tafel (Flughafen-Standard) pictoTexture(emoji, label, bg, arrow) { const w = 132, h = label ? 168 : 132; const cv = document.createElement('canvas'); cv.width = w; cv.height = h; const g = cv.getContext('2d'); const r = 16; g.fillStyle = bg || '#10548f'; g.beginPath(); g.moveTo(r, 0); g.arcTo(w, 0, w, h, r); g.arcTo(w, h, 0, h, r); g.arcTo(0, h, 0, 0, r); g.arcTo(0, 0, w, 0, r); g.closePath(); g.fill(); g.textAlign = 'center'; g.textBaseline = 'middle'; const cy = label ? 62 : h / 2 + 2; if (arrow) { // sauberes weißes Dreieck (kein Emoji) g.fillStyle = '#fff'; const s = 32; g.beginPath(); if (arrow === 'left') { g.moveTo(w / 2 - s, cy); g.lineTo(w / 2 + s * 0.7, cy - s); g.lineTo(w / 2 + s * 0.7, cy + s); } else { g.moveTo(w / 2 + s, cy); g.lineTo(w / 2 - s * 0.7, cy - s); g.lineTo(w / 2 - s * 0.7, cy + s); } g.closePath(); g.fill(); } else if (emoji) { g.font = '86px "Segoe UI Emoji", "Apple Color Emoji", system-ui, sans-serif'; g.fillText(emoji, w / 2, cy); } if (label) { g.fillStyle = '#fff'; let fs = 24; // lange Labels (z. B. „Rauchverbot“) schrumpfen g.font = '700 ' + fs + 'px "Segoe UI", system-ui, sans-serif'; while (g.measureText(label).width > w - 16 && fs > 12) { fs -= 1; g.font = '700 ' + fs + 'px "Segoe UI", system-ui, sans-serif'; } g.fillText(label, w / 2, h - 28); } const t = new THREE.CanvasTexture(cv); t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 4; return t; }, picto(emoji, label, x, y, z, rotY, size, bg, arrow) { size = size || 1.1; const h = label ? size * (168 / 132) : size; const m = new THREE.Mesh(new THREE.PlaneGeometry(size, h), new THREE.MeshBasicMaterial({ map: this.pictoTexture(emoji, label, bg, arrow), transparent: true, toneMapped: false })); m.position.set(x, y, z); m.rotation.y = rotY || 0; this.scene.add(m); return m; }, // Tür an einer Wand (Rahmen + Türblätter + Griffe + Schild darüber) buildDoor(x, z, rotY, opts) { opts = opts || {}; const w = opts.w || 2.4, h = opts.h || 3.2, fr = 0.2; const grp = new THREE.Group(); const mk = (px, py, pz, sx, sy, sz, col) => { const m = new THREE.Mesh(new THREE.BoxGeometry(sx, sy, sz), new THREE.MeshLambertMaterial({ color: col })); m.position.set(px, py, pz); m.castShadow = m.receiveShadow = true; grp.add(m); return m; }; mk(-(w / 2 + fr / 2), h / 2, 0, fr, h + fr, 0.18, 0x3d4651); // Pfosten links mk((w / 2 + fr / 2), h / 2, 0, fr, h + fr, 0.18, 0x3d4651); // Pfosten rechts mk(0, h + fr / 2, 0, w + fr * 2, fr, 0.18, 0x3d4651); // Sturz if (opts.glass) { const gl = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.07), new THREE.MeshLambertMaterial({ color: 0x9fc8e0, transparent: true, opacity: 0.4 })); gl.position.set(0, h / 2, -0.04); grp.add(gl); mk(0, h / 2, -0.02, 0.06, h, 0.09, 0x2a3138); // Mittelfuge } else { mk(-w / 4, h / 2, -0.03, w / 2 - 0.06, h - 0.1, 0.09, opts.color || 0x2a3138); // Türblatt links mk(w / 4, h / 2, -0.03, w / 2 - 0.06, h - 0.1, 0.09, opts.color || 0x2a3138); // Türblatt rechts } mk(-0.18, h / 2, 0.04, 0.07, 0.5, 0.07, 0xcfd6dd); // Griff links mk(0.18, h / 2, 0.04, 0.07, 0.5, 0.07, 0xcfd6dd); // Griff rechts // Schild ÜBER der Tür (sitzt vollständig über dem Rahmen, ragt nicht hinein) if (opts.emoji || opts.label || opts.arrow) { const sw = opts.signW || 1.6; const sh = (opts.label ? sw * (168 / 132) : sw); const sy = h + fr + sh / 2 + 0.1; // knapp über dem Türsturz const s = this.picto(opts.emoji || '', opts.label, 0, sy, 0.02, 0, sw, opts.bg, opts.arrow); s.position.set(0, sy, 0.02); grp.add(s); // ins Gruppen-Koordinatensystem } grp.position.set(x, 0, z); grp.rotation.y = rotY || 0; this.scene.add(grp); return grp; }, // Wanddurchbruch + kurzer Gang nach draußen (z. B. Passkontrolle), mit // Absperrung davor und Schild darüber. Wand bei Südseite (z = MAXZ). buildWallPassage(x, z, opts) { opts = opts || {}; const W = opts.w || 3.2, H = opts.h || 3.4, L = opts.len || 8, dir = opts.dir || 1; // dir=1 → nach +z const grp = new THREE.Group(); const lamb = c => new THREE.MeshLambertMaterial({ color: c }); const mk = (px, py, pz, sx, sy, sz, mat) => { const m = new THREE.Mesh(new THREE.BoxGeometry(sx, sy, sz), mat); m.position.set(px, py, pz); m.castShadow = m.receiveShadow = true; grp.add(m); return m; }; // dunkler Rahmen um die Öffnung const fr = 0.25, wall = lamb(0x3a4654); mk(-(W / 2 + fr / 2), H / 2, 0, fr, H + fr, 0.6, wall); mk((W / 2 + fr / 2), H / 2, 0, fr, H + fr, 0.6, wall); mk(0, H + fr / 2, 0, W + fr * 2, fr, 0.6, wall); // Wand ÜBER der Öffnung schließen (volle Wandhöhe 7,5 m) — sonst klafft // oberhalb des Gangs eine Lücke bis zur Decke und man sieht den Himmel const above = new THREE.Mesh(new THREE.BoxGeometry(W + fr * 2 + 1, 7.5 - (H + fr), 0.6), this.matWall || new THREE.MeshLambertMaterial({ color: 0x9aa2ae })); above.position.set(0, (H + fr + 7.5) / 2, 0); above.castShadow = above.receiveShadow = true; grp.add(above); // Gang nach außen const zc = dir * (L / 2 + 0.3), gwall = lamb(0x44505e), gfloor = lamb(0x2b333c); mk(0, 0.02, zc, W, 0.08, L, gfloor); // Boden mk(0, H, zc, W, 0.12, L, lamb(0x2f3741)); // Decke mk(-W / 2, H / 2, zc, 0.12, H, L, gwall); // linke Wand mk(W / 2, H / 2, zc, 0.12, H, L, gwall); // rechte Wand // hell erleuchtete Endwand (kein echtes Licht — Performance) const end = new THREE.Mesh(new THREE.PlaneGeometry(W, H), new THREE.MeshBasicMaterial({ color: 0xbcd2e6, toneMapped: false })); end.position.set(0, H / 2, dir * (L + 0.3)); end.rotation.y = dir > 0 ? Math.PI : 0; grp.add(end); grp.position.set(x, 0, z); grp.rotation.y = opts.rotY || 0; this.scene.add(grp); // Theke in der Öffnung (z. B. Fundbüro / Schalter) if (opts.counter) { mk(0, 0.52, dir * 0.45, W - 0.2, 1.04, 0.7, lamb(0x3a4654)); // Korpus mk(0, 1.08, dir * 0.18, W - 0.2, 0.08, 0.95, lamb(0xb7c0cc)); // Ablageplatte (ragt zur Halle) } // Regale mit Fundstücken hinten im Gang if (opts.shelves) { const itemCols = [0xb04a3e, 0xc9a24a, 0x4a7c4e, 0x2563a8, 0x8a5a3b, 0x6b7a5a, 0xc4a97a]; [-1, 1].forEach(side => { const sx = side * (W / 2 - 0.08); [1.0, 1.9, 2.8].forEach((sy, r) => { mk(sx, sy, dir * (L * 0.62), 0.22, 0.06, L * 0.62, lamb(0x55606e)); // Regalbrett for (let i = 0; i < 4; i++) { // kleine Gepäckstücke const iz = dir * (L * 0.4 + i * (L * 0.45) / 4); mk(sx - side * 0.02, sy + 0.18, iz, 0.2, 0.26, 0.2, lamb(itemCols[(r * 4 + i) % itemCols.length])); } }); }); } grp.position.set(x, 0, z); grp.rotation.y = opts.rotY || 0; this.scene.add(grp); // Absperrung davor (im Halleninneren) + Schild über der Öffnung if (opts.barrier) { const bz = z - dir * 1.6 * Math.cos(opts.rotY || 0); // 1,6 m vor der Öffnung const bx = x - dir * 1.6 * Math.sin(opts.rotY || 0); const ax = Math.abs(Math.sin(opts.rotY || 0)) > 0.5; // Absperrung quer zur Öffnung this.buildBarrier(ax ? [[bx, bz - 1.3], [bx, bz + 1.3]] : [[bx - 1.3, bz], [bx + 1.3, bz]]); } if (opts.emoji || opts.label) { // Schild über der Öffnung, zur Halle gewandt (lokales −z) und deutlich VOR // der Wand-über-Öffnung (sonst steckt es darin) const sy = H + (opts.signYOff || 0.7); const s = this.picto(opts.emoji || '', opts.label, 0, sy, -dir * 0.45, Math.PI, opts.signW || 1.9, opts.bg); s.position.set(0, sy, -dir * 0.45); s.rotation.y = Math.PI; grp.add(s); } return grp; }, // Service-Theke an einer Wand (Zoll/Information): Korpus + Trennwand + Monitor buildCounter(x, z, rotY, opts) { opts = opts || {}; const grp = new THREE.Group(); const lamb = c => new THREE.MeshLambertMaterial({ color: c }); const mk = (px, py, pz, sx, sy, sz, c) => { const m = new THREE.Mesh(new THREE.BoxGeometry(sx, sy, sz), lamb(c)); m.position.set(px, py, pz); m.castShadow = m.receiveShadow = true; grp.add(m); return m; }; const W = opts.w || 2.6; mk(0, 0.52, 0, W, 1.04, 0.72, 0x3a4654); // Theken-Korpus mk(0, 1.08, -0.06, W, 0.08, 0.92, 0xb7c0cc); // Ablageplatte (ragt zur Halle) mk(0, 1.85, 0.5, W, 3.7, 0.12, 0x2b333c); // Rückwand/Trennwand (zur Hallenwand) mk(0.55, 1.33, -0.14, 0.5, 0.34, 0.05, 0x0d1a24); // Monitor mk(-0.6, 1.18, -0.12, 0.34, 0.06, 0.18, 0x202833); // Tastatur grp.position.set(x, 0, z); grp.rotation.y = rotY || 0; this.scene.add(grp); this.addCollider(x, z, W, 0.9); return grp; }, buildAirportSigns() { // Schilder leicht VOR die Wandflächen (Innenflächen: Süd 29.5, Ost 51.5, // West −75.5) — sonst Z-Fighting/Flackern. const Z = this.MAXZ - 0.62, XE = this.MAXX - 0.62, XW = this.MINX + 0.62; const WZ = this.MAXZ, PI = Math.PI; // Piktogramme bewusst OHNE Text (nur Symbol) — Schilder mit Text nur dort, // wo es eine Beschriftung braucht (Ausgang-Pfeil, Passkontrolle). const DZ = Z, P = 4.4; // P = Höhe der Wandbeschilderung // ---- WC mit Tür (nur Piktogramm) ---- this.buildDoor(20, DZ, PI, { emoji: '🚻', w: 2.6, signW: 1.2, bg: '#10548f' }); this.picto('🚹', null, 17.6, 2.0, Z, PI, 0.8, '#10548f'); this.picto('🚺', null, 22.4, 2.0, Z, PI, 0.8, '#10548f'); this.picto('♿', null, 24.2, 2.0, Z, PI, 0.7, '#10548f'); // ---- Aufzug als Doppeltür (nur Piktogramm) ---- this.buildDoor(-26, DZ, PI, { emoji: '🛗', w: 2.4, color: 0x8a939c, signW: 1.2, bg: '#10548f' }); // ---- Passkontrolle: Durchbruch + Gang + Absperrung (Schild höher, kleinere Schrift) ---- this.buildWallPassage(this.passageX != null ? this.passageX : -44, WZ, { rotY: 0, dir: 1, emoji: '🛂', label: 'Passkontrolle', barrier: true, bg: '#10548f', signW: 1.5, signYOff: 1.15 }); // ---- Ausgang (Südwand) — Standard-Schild mit Pfeil ---- this.buildDoor(-64, DZ, PI, { glass: true, label: 'Ausgang', arrow: 'left', w: 3.0, signW: 2.0, bg: '#0e6b3a' }); // ---- Zoll: Schalter (Theke) dort, wo früher der Eingang war + Piktogramm ---- this.buildCounter(0, 28.35, 0, { w: 2.6 }); this.picto('🛃', null, 0, P, Z, PI, 1.0, '#10548f'); // ---- Information: Schalter (Theke) + Piktogramm ---- this.buildCounter(14, 28.35, 0, { w: 2.6 }); this.picto('ℹ️', null, 14, P, Z, PI, 1.0); // ---- Wand-Piktogramme (nur Symbol) ---- this.picto('🚭', null, 34, P, Z, PI, 1.0, '#a8322a'); // Rauchverbot // Ostwand (rotY = −PI/2) — über den Boarding-Tresen gehoben this.picto('✈️', null, XE, 5.4, -6, -PI / 2, 1.1, '#10548f'); // Abflug this.picto('🛫', null, XE, 5.4, 6, -PI / 2, 1.0, '#10548f'); // Gates this.picto('🍴', null, XE, 5.4, 14, -PI / 2, 1.0); // Restaurant // ---- Fundbüro: Durchbruch in der Ostwand + Theke + Regale mit Fundstücken ---- this.buildWallPassage(this.MAXX, this.fundGapZ != null ? this.fundGapZ : 24, { rotY: PI / 2, dir: 1, len: 6, emoji: '🧳', label: 'Fundbüro', counter: true, shelves: true, bg: '#10548f', signW: 1.6, signYOff: 0.9 }); // Westwand (rotY = PI/2) this.picto('🛄', null, XW, 5.4, -8, PI / 2, 1.0, '#c79a1e'); // Gepäck this.picto('🛬', null, XW, 5.4, 6, PI / 2, 1.0, '#10548f'); // Ankunft }, /* Länder-Werbeplakate („Travel to …“) mit den Wahrzeichen-Illustrationen. * Wird pro Durchgang neu bestückt — NIE mit Ländern der aktuellen Fälle * (sonst würden die Poster die Lösung spoilern). */ POSTER_SPOTS: [ [8, 3.1, 29.25, Math.PI], [-34, 3.1, 29.25, Math.PI], [-21.65, 3.1, -12, -Math.PI / 2], [21.65, 3.1, 2, Math.PI / 2], [30, 3.1, 17.7, 0], [-51.35, 3.1, 14, Math.PI / 2], ], // große hängende Werbetafeln (beidseitig, unter dem Dach) OVERHEAD_SPOTS: [[-30, 8.4, 4, 0], [30, 8.4, 4, 0], [0, 8.4, 18, 0]], async buildPosters(excludeIsos) { if (this.mode !== 'klassisch') return; if (this.posterGroup) { this.posterGroup.traverse(o => { if (o.material && o.material.map) o.material.map.dispose(); if (o.material) o.material.dispose(); if (o.geometry) o.geometry.dispose(); }); this.scene.remove(this.posterGroup); } const grp = this.posterGroup = new THREE.Group(); this.scene.add(grp); this.posterInfos = []; const SLOGANS = ['Travel to', 'Entdecke', 'Erlebe', 'Reise nach', 'Visit', 'Traumziel']; const nWall = this.POSTER_SPOTS.length, nOver = this.OVERHEAD_SPOTS.length; const picks = KD_COUNTRIES.filter(c => !excludeIsos.includes(c.iso2)) .sort(() => Math.random() - 0.5).slice(0, nWall + nOver); picks.slice(0, nWall).forEach((c, i) => { const [x, , z] = this.POSTER_SPOTS[i]; this.posterInfos.push({ x, z, iso2: c.iso2 }); }); // Überkopf-Tafeln: beidseitig bebildert, an Stangen vom Dach picks.slice(nWall).forEach((c, i) => { const [x, y, z] = this.OVERHEAD_SPOTS[i]; const img = new Image(); img.onload = () => { try { const cv = document.createElement('canvas'); cv.width = 768; cv.height = 432; const g = cv.getContext('2d'); g.fillStyle = '#10202c'; g.fillRect(0, 0, 768, 432); g.drawImage(img, 12, 12, 408, 408); g.fillStyle = '#e8c547'; g.font = '700 56px "Segoe UI", system-ui, sans-serif'; g.textAlign = 'center'; g.fillText('Travel to', 590, 170); g.fillStyle = '#fff'; g.font = '800 60px "Segoe UI", system-ui, sans-serif'; const name = c.short || c.name; g.fillText(name.length > 11 ? name.slice(0, 10) + '…' : name, 590, 250); g.strokeStyle = '#2c3a4a'; g.lineWidth = 10; g.strokeRect(5, 5, 758, 422); const tex = new THREE.CanvasTexture(cv); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; const mat = new THREE.MeshBasicMaterial({ map: tex }); const board = new THREE.Group(); const f = new THREE.Mesh(new THREE.PlaneGeometry(6.4, 3.6), mat); f.position.z = 0.04; const bk = new THREE.Mesh(new THREE.PlaneGeometry(6.4, 3.6), mat); bk.rotation.y = Math.PI; bk.position.z = -0.04; board.add(f, bk); const core = new THREE.Mesh(new THREE.BoxGeometry(6.6, 3.8, 0.07), new THREE.MeshLambertMaterial({ color: 0x1a232e })); board.add(core); // Abhängung bis zur tatsächlichen Dachhöhe an dieser Position const rodLen = Math.max(0.3, World.roofY(z) - (y + 1.8)); [-2.6, 2.6].forEach(ox => { const rod = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, rodLen, 6), new THREE.MeshLambertMaterial({ color: 0x222a34 })); rod.position.set(ox, 1.8 + rodLen / 2, 0); board.add(rod); }); board.position.set(x, y, z); grp.add(board); } catch (_) {} }; img.src = BASE + 'assets/img/landmarks/' + c.iso2 + '.webp'; }); await Promise.all(picks.map((c, i) => new Promise(resolve => { const img = new Image(); img.onload = () => { try { const cv = document.createElement('canvas'); cv.width = 512; cv.height = 760; const g = cv.getContext('2d'); g.fillStyle = '#efe9da'; g.fillRect(0, 0, 512, 760); g.fillStyle = '#1f4b37'; g.font = '700 46px "Segoe UI", system-ui, sans-serif'; g.textAlign = 'center'; g.fillText(SLOGANS[i % SLOGANS.length], 256, 78); g.drawImage(img, 16, 110, 480, 480); g.font = '800 52px "Segoe UI", system-ui, sans-serif'; g.fillStyle = '#b04a3e'; const name = c.short || c.name; g.fillText(name.length > 14 ? name.slice(0, 13) + '…' : name, 256, 680); g.strokeStyle = '#c4a97a'; g.lineWidth = 10; g.strokeRect(5, 5, 502, 750); const tex = new THREE.CanvasTexture(cv); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4; const [x, y, z, rotY] = this.POSTER_SPOTS[i]; const frame = new THREE.Mesh(new THREE.BoxGeometry( Math.abs(rotY) === Math.PI / 2 ? 0.16 : 2.5, 3.6, Math.abs(rotY) === Math.PI / 2 ? 2.5 : 0.16), new THREE.MeshLambertMaterial({ color: 0x2a313b })); frame.position.set(x - Math.sin(rotY) * 0.06, y, z - Math.cos(rotY) * 0.06); grp.add(frame); const m = new THREE.Mesh(new THREE.PlaneGeometry(2.3, 3.42), new THREE.MeshBasicMaterial({ map: tex })); m.position.set(x + Math.sin(rotY) * 0.04, y, z + Math.cos(rotY) * 0.04); m.rotation.y = rotY; grp.add(m); } catch (_) {} resolve(); }; img.onerror = resolve; img.src = BASE + 'assets/img/landmarks/' + c.iso2 + '.webp'; }))); this.renderer.shadowMap.needsUpdate = true; }, /* Absperrung: Pfosten mit rotem Gurtband entlang einer Punktfolge */ buildBarrier(points) { const postMat = new THREE.MeshLambertMaterial({ color: 0x2a313b }); const beltMat = new THREE.MeshLambertMaterial({ color: 0xb04a3e }); points.forEach((pt, i) => { const post = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.1, 1.0, 8), postMat); post.position.set(pt[0], 0.5, pt[1]); post.castShadow = true; this.scene.add(post); if (i === 0) return; const a = points[i - 1], dx = pt[0] - a[0], dz = pt[1] - a[1]; const len = Math.hypot(dx, dz); const belt = new THREE.Mesh(new THREE.BoxGeometry(len, 0.09, 0.03), beltMat); belt.position.set((a[0] + pt[0]) / 2, 0.88, (a[1] + pt[1]) / 2); belt.rotation.y = -Math.atan2(dz, dx); this.scene.add(belt); // Kollision je Segment (schmal) this.addCollider((a[0] + pt[0]) / 2, (a[1] + pt[1]) / 2, Math.abs(dx) + 0.25, Math.abs(dz) + 0.25); }); }, plants(list, opts) { const baseY = (opts && opts.y) || 0; const collide = !opts || opts.collide !== false; // auf erhöhten Flächen (Galerie) ohne Boden-Kollider const M = c => new THREE.MeshLambertMaterial({ color: c }); const GREENS = [0x4a7c4e, 0x5a8a5e, 0x3f6b43, 0x6a9a5e]; const POTS = [0xb04a3e, 0xc4a97a, 0x39434f, 0x8a5a3b]; list.forEach((p, pi) => { const grp = new THREE.Group(); const type = pi % 4; const pot = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.32, 0.62, 9), M(POTS[pi % POTS.length])); pot.position.y = 0.31; grp.add(pot); if (type === 0) { // Nadelbaum (Klassiker) const a = new THREE.Mesh(new THREE.ConeGeometry(0.8, 1.8, 7), M(GREENS[0])); a.position.y = 1.55; grp.add(a); const b = new THREE.Mesh(new THREE.ConeGeometry(0.55, 1.2, 7), M(GREENS[1])); b.position.y = 2.4; grp.add(b); } else if (type === 1) { // Palme: Stamm + gebogene Wedel const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.14, 1.9, 7), M(0x8a6a4b)); trunk.position.y = 1.55; grp.add(trunk); for (let f = 0; f < 6; f++) { const frond = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.05, 0.3), M(GREENS[f % 3])); frond.position.set(Math.cos(f * 1.05) * 0.62, 2.55 + (f % 2) * 0.1, Math.sin(f * 1.05) * 0.62); frond.rotation.y = -f * 1.05; frond.rotation.z = 0.45; grp.add(frond); } } else if (type === 2) { // Monstera-Art: große Blattscheiben auf schrägen Stielen for (let f = 0; f < 5; f++) { const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.035, 1.1 + (f % 3) * 0.3, 5), M(0x3f6b43)); stem.position.set(Math.cos(f * 1.3) * 0.16, 1.05, Math.sin(f * 1.3) * 0.16); stem.rotation.z = Math.cos(f * 1.3) * 0.35; stem.rotation.x = -Math.sin(f * 1.3) * 0.35; grp.add(stem); const leaf = new THREE.Mesh(new THREE.SphereGeometry(0.34, 8, 6), M(GREENS[(f + 1) % 4])); leaf.scale.set(1, 0.18, 0.75); leaf.position.set(Math.cos(f * 1.3) * 0.55, 1.55 + (f % 3) * 0.28, Math.sin(f * 1.3) * 0.55); leaf.rotation.z = Math.cos(f * 1.3) * 0.4; grp.add(leaf); } } else { // runder Strauch aus Kugeln [[0, 1.0, 0, 0.55], [0.3, 1.35, 0.1, 0.4], [-0.28, 1.3, -0.12, 0.38], [0.05, 1.7, -0.05, 0.3]].forEach(([ox, oy, oz, r], k) => { const ball = new THREE.Mesh(new THREE.SphereGeometry(r, 8, 7), M(GREENS[k % 4])); ball.position.set(ox, oy, oz); grp.add(ball); }); } grp.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } }); grp.position.set(p[0], baseY, p[1]); grp.rotation.y = pi * 1.7; this.scene.add(grp); if (collide) this.addCollider(p[0], p[1], 1, 1); }); }, /* Abstrakte Kunstwerke an den Wänden */ buildArt() { const L = new THREE.TextureLoader(); const place = (file, x, y, z, rotY, w, h) => { const t = L.load(BASE + 'assets/img/art/' + file); t.colorSpace = THREE.SRGBColorSpace; const sideways = Math.abs(Math.abs(rotY) - Math.PI / 2) < 0.01; this.addBox(x, y, z, sideways ? 0.18 : w + 0.4, h + 0.4, sideways ? w + 0.4 : 0.18, 0x2a313b); const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ map: t })); // Versatz entlang der Blickrichtung der Plane (Normale nach Y-Rotation) m.position.set(x + Math.sin(rotY) * 0.12, y, z + Math.cos(rotY) * 0.12); m.rotation.y = rotY; this.scene.add(m); }; place('art1.webp', -8, 3.2, 29.3, Math.PI, 4.5, 4.5); // Südwand Eingang, zeigt nach Norden place('art2.webp', 51.3, 3.2, 6, -Math.PI / 2, 4.5, 4.5); // Ostwand, zeigt nach Westen place('art3.webp', -51.3, 3.2, -10, Math.PI / 2, 4, 4); // Trennwand Gepäckausgabe, zeigt nach Osten }, /* Anzeigetafel: sauberes Layout, unverzerrte Schrift (Canvas 2:1 ↔ Plane 2:1) */ buildBoard() { const cv = document.createElement('canvas'); cv.width = 1280; cv.height = 640; this.boardCtx = cv.getContext('2d'); this.boardTex = new THREE.CanvasTexture(cv); this.boardTex.anisotropy = 4; const board = new THREE.Mesh(new THREE.PlaneGeometry(11, 5.5), new THREE.MeshBasicMaterial({ map: this.boardTex })); board.position.set(0, 4.4, -4); this.scene.add(board); const back = this.addBox(0, 4.4, -4.15, 11.4, 5.9, 0.25, 0x10161e); this.addBox(0, 1.2, -4.15, 0.5, 2.5, 0.25, 0x2a313b); // Steher this.drawBoard('Flughafen'); }, drawBoard(airport) { const g = this.boardCtx; if (!g) return; const W = 1280, H = 640; g.fillStyle = '#0c1218'; g.fillRect(0, 0, W, H); g.fillStyle = '#13202c'; g.fillRect(0, 0, W, 92); g.fillStyle = '#bfe6f5'; g.font = '600 44px "Segoe UI", system-ui, sans-serif'; g.textAlign = 'left'; g.textBaseline = 'middle'; g.fillText('✈ ' + airport, 36, 48); g.textAlign = 'right'; g.font = '400 36px "Segoe UI", system-ui, sans-serif'; g.fillStyle = '#7d92a8'; g.fillText('Abflüge · Departures', W - 36, 48); // Spaltenköpfe g.textAlign = 'left'; g.font = '400 30px "Segoe UI", system-ui, sans-serif'; g.fillStyle = '#55687c'; g.fillText('ZEIT', 36, 132); g.fillText('ZIEL', 230, 132); g.fillText('FLUG', 760, 132); g.fillText('STATUS', 980, 132); g.strokeStyle = '#1d2a38'; g.beginPath(); g.moveTo(24, 156); g.lineTo(W - 24, 156); g.stroke(); // Zeilen const cities = KD_BOARD_CITIES.slice().sort(() => Math.random() - 0.5).slice(0, 7); g.font = '500 38px "Consolas", "Courier New", monospace'; cities.forEach((city, i) => { const y = 206 + i * 62; const hh = 17 + Math.floor(i / 2), mm = (i * 17 + 5) % 60; g.fillStyle = '#e8c547'; g.fillText(hh + ':' + String(mm).padStart(2, '0'), 36, y); g.fillText(city.toUpperCase(), 230, y); g.fillText('GS ' + (140 + i * 37), 760, y); g.fillStyle = i < 5 ? '#5a8a5e' : '#7d92a8'; g.fillText(i < 5 ? 'gestartet' : 'beendet', 980, y); }); this.boardTex.needsUpdate = true; }, setAirport(name) { this.drawBoard(name); }, /* Putzroboter: fährt eine Runde durch die Eingangshalle */ buildRobot() { const grp = new THREE.Group(); const body = new THREE.Mesh(new THREE.CylinderGeometry(0.55, 0.65, 0.4, 12), new THREE.MeshLambertMaterial({ color: 0xe8c547 })); body.position.y = 0.25; grp.add(body); const dome = new THREE.Mesh(new THREE.SphereGeometry(0.3, 10, 8), new THREE.MeshLambertMaterial({ color: 0x2a313b })); dome.position.y = 0.5; grp.add(dome); const lamp = new THREE.Mesh(new THREE.SphereGeometry(0.07, 6, 6), new THREE.MeshLambertMaterial({ color: 0x5af0a0, emissive: 0x5af0a0, emissiveIntensity: 1 })); lamp.position.set(0, 0.68, 0); grp.add(lamp); this.scene.add(grp); const PATH = [[-10, 22], [10, 22], [10, 11], [22, 11], [22, -4], [-10, -4], [-10, 11]]; let seg = 0, tpos = 0; this.animated.push({ update: (dt, t) => { const a = PATH[seg], b = PATH[(seg + 1) % PATH.length]; const len = Math.hypot(b[0] - a[0], b[1] - a[1]); tpos += dt * 1.0 / len; if (tpos >= 1) { tpos = 0; seg = (seg + 1) % PATH.length; return; } grp.position.set(a[0] + (b[0] - a[0]) * tpos, 0, a[1] + (b[1] - a[1]) * tpos); grp.rotation.y = -Math.atan2(b[1] - a[1], b[0] - a[0]) + Math.PI / 2; lamp.material.emissiveIntensity = Math.sin(t * 5) > 0 ? 1 : 0.3; }}); }, /* --- 3D-Gepäckmodelle (prozedural, 6 Bauformen) --- */ buildLuggage(lt, scale) { const grp = new THREE.Group(); const col = lt.color, dark = 0x222a33; const M = c => new THREE.MeshLambertMaterial({ color: c }); const box = (x, y, z, sx, sy, sz, c) => { const m = new THREE.Mesh(new THREE.BoxGeometry(sx, sy, sz), M(c)); m.position.set(x, y, z); grp.add(m); return m; }; const cyl = (x, y, z, r, h, c, rotZ) => { const m = new THREE.Mesh(new THREE.CylinderGeometry(r, r, h, 10), M(c)); m.position.set(x, y, z); if (rotZ != null) m.rotation.z = rotZ; grp.add(m); return m; }; const fam = lt.img; if (fam === 'hartschale' || fam === 'business') { const w = fam === 'business' ? 0.72 : 0.92; box(0, 0.75, 0, w, 1.2, 0.42, col); for (let i = -1; i <= 1; i++) box(i * w * 0.3, 0.75, 0.215, 0.07, 1.18, 0.02, col === 0x3b3f46 ? 0x4a505a : col + 0x101010); // Rollen + Teleskopgriff cyl(-w / 2 + 0.1, 0.08, 0.12, 0.08, 0.08, dark, Math.PI / 2); cyl(w / 2 - 0.1, 0.08, 0.12, 0.08, 0.08, dark, Math.PI / 2); box(-0.18, 1.65, -0.1, 0.05, 0.7, 0.05, dark); box(0.18, 1.65, -0.1, 0.05, 0.7, 0.05, dark); box(0, 2.0, -0.1, 0.45, 0.08, 0.07, dark); box(0.3, 0.95, 0.22, 0.22, 0.14, 0.02, 0xe8c547); // Adress-Anhänger } else if (fam === 'stoff') { box(0, 0.7, 0, 0.9, 1.15, 0.45, col); box(0, 0.5, 0.25, 0.7, 0.55, 0.08, col + 0x0a0604); // Fronttasche box(0, 1.33, 0, 0.4, 0.1, 0.12, dark); // Griff box(0, 1.27, 0, 0.92, 0.03, 0.47, 0xd8d2c4); // Reißverschluss cyl(-0.32, 0.07, 0.15, 0.07, 0.07, dark, Math.PI / 2); cyl(0.32, 0.07, 0.15, 0.07, 0.07, dark, Math.PI / 2); } else if (fam === 'rucksack') { box(0, 0.65, 0, 0.7, 1.1, 0.5, col); box(0, 1.28, 0, 0.55, 0.35, 0.4, col + 0x0a0a06); // Deckel box(0, 0.45, 0.3, 0.45, 0.5, 0.12, col + 0x101008); // Fronttasche box(-0.2, 0.7, -0.3, 0.1, 0.9, 0.05, dark); box(0.2, 0.7, -0.3, 0.1, 0.9, 0.05, dark); // Gurte box(0, 1.5, 0, 0.25, 0.08, 0.1, dark); } else { // sport / seesack const m = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.4, 1.3, 12), M(col)); m.rotation.z = Math.PI / 2; m.position.y = 0.45; grp.add(m); box(0, 0.95, 0, 0.5, 0.08, 0.1, dark); box(-0.3, 0.45, 0, 0.04, 0.85, 0.85, col + 0x0a0a0a).rotation.x = Math.PI / 4; box(0.55, 0.45, 0, 0.12, 0.5, 0.5, col + 0x141210); // Endkappe box(-0.55, 0.45, 0, 0.12, 0.5, 0.5, col + 0x141210); } if (scale) grp.scale.setScalar(scale); return grp; }, spawnCaseMesh(idx) { const cs = S.cases[idx]; const sp = this.SPAWNS[cs.spawn % this.SPAWNS.length]; const lt = KD_LUGGAGE_TYPES.find(t => t.id === cs.luggage) || KD_LUGGAGE_TYPES[0]; // Fall-Koffer aus dem Modell-Pool (deterministisch je Fall → Resume-stabil); // prozedurales Modell nur als Fallback ohne Modul-Support const poolIdx = this.kofferPool.length ? (idx * 3 + cs.iso2.charCodeAt(0)) % this.kofferPool.length : -1; // Mindestgröße fürs Fall-Gepäck: eine 35-cm-Handtasche wäre im Nachtlicht unsichtbar const caseH = poolIdx >= 0 ? Math.max(this.kofferH(poolIdx), 0.75) : 1.1; const grp = poolIdx >= 0 ? this.normalizeKoffer(this.kofferPool[poolIdx].clone(true), caseH) : this.buildLuggage(lt); grp.userData = grp.userData || {}; grp.userData.poolIdx = poolIdx; // Anhänger „?“ + Glühen const tag = new THREE.Mesh(new THREE.PlaneGeometry(0.34, 0.2), new THREE.MeshBasicMaterial({ map: this.textTexture('?', 64, 40, { bg: '#e8c547', fg: '#1f4b37', size: 30 }) })); tag.position.set(0.3, caseH * 0.75, 0.28); grp.add(tag); const glow = new THREE.PointLight(0xe8c547, 5, 7, 1.8); glow.position.y = caseH + 0.7; grp.add(glow); // Debug-/Kommunikations-ID über jedem Fall-Gepäck: Fall-Nr + Modell-Nr const idTag = new THREE.Mesh(new THREE.PlaneGeometry(0.7, 0.22), new THREE.MeshBasicMaterial({ map: this.textTexture('F' + (idx + 1) + ' · M' + poolIdx, 128, 40, { bg: '#103246', fg: '#bfe6f5', size: 24 }), transparent: true, })); idTag.position.y = caseH + 0.55; grp.add(idTag); grp.userData.idTag = idTag; // Billboard im update() // Leuchtring am Boden: gemeldete Stücke sind immer auffindbar const ring = new THREE.Mesh(new THREE.RingGeometry(0.7, 0.95, 24), new THREE.MeshBasicMaterial({ color: 0xe8c547, transparent: true, opacity: 0.5 })); ring.rotation.x = -Math.PI / 2; ring.position.y = 0.03; grp.add(ring); grp.position.set(sp[0], this.mode === 'glb' ? this.liveFloorY(sp[0], sp[1]) : 0, sp[1]); grp.rotation.y = (idx * 1.7) % 6.28; grp.userData = { idx, glow, poolIdx }; this.scene.add(grp); this.caseMeshes[idx] = grp; }, /* Fall-Koffer neu aufbauen (z. B. nach Kalibrierung); idx optional filtert aufs Modell */ refreshCaseMeshes(poolIdx) { if (!S) return; Object.keys(this.caseMeshes).forEach(k => { const m = this.caseMeshes[k]; if (poolIdx == null || (m.userData && m.userData.poolIdx === poolIdx)) { this.scene.remove(m); delete this.caseMeshes[k]; } }); this.syncCases(); }, syncCases() { S.cases.forEach((cs, i) => { const done = cs.solved || cs.givenUp; const active = i === S.caseIndex && !done; if (active && !this.caseMeshes[i]) this.spawnCaseMesh(i); if (this.caseMeshes[i] && done) { this.scene.remove(this.caseMeshes[i]); delete this.caseMeshes[i]; } }); this.spawnFundsachen(); }, /* ---------- Fundsachen / Badges am Boden ---------- * An jedem Spawn-Platz, der in dieser Runde KEINEN Koffer trägt und noch * nicht eingesammelt wurde, liegt eine Reise-Fundsache. Deterministisch je * Platz-Index → Resume-stabil. */ // Freigestellte Fundstück-Illustration (assets/img/fundstuecke/.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: zwei feste, immer sichtbare Joysticks. // links = Umsehen (Auge 👁) → this.lookJoy (ratenbasiertes Drehen in update()) // rechts = Bewegen (Fuß 🦶) → this.joy if (IS_TOUCH) document.body.classList.add('kd-touch'); const moveEl = $('joystick'), moveKnob = $('joy-knob'); const lookEl = $('lookstick'), lookKnob = $('look-knob'); const KNOB_TRAVEL = 34; // px, wie weit der Knopf maximal auswandert const CATCH_R = 160; // px Fangradius um den Joystick-Mittelpunkt const centerOf = el => { const r = el.getBoundingClientRect(); return { cx: r.left + r.width / 2, cy: r.top + r.height / 2, hw: r.width / 2, hh: r.height / 2 }; }; // Normalisierter Vektor (−1..1) eines Touches relativ zum festen Joystick-Zentrum. const stickVec = (el, t) => { const c = centerOf(el); const dx = (t.clientX - c.cx) / c.hw, dy = (t.clientY - c.cy) / c.hh; const len = Math.hypot(dx, dy) || 1, cl = Math.min(1, len); return { dx: dx / len * cl, dy: dy / len * cl }; }; // Weist einen neuen Touch dem näheren freien Joystick zu (nur im Fangradius). const assign = t => { const dist = el => { const c = centerOf(el); return Math.hypot(t.clientX - c.cx, t.clientY - c.cy); }; const dM = dist(moveEl), dL = dist(lookEl); if (dM <= dL) return dM < CATCH_R ? 'move' : null; return dL < CATCH_R ? 'look' : null; }; let joyTouch = null, lookTouch = null; const applyMove = t => { const v = stickVec(moveEl, t); this.joy.dx = v.dx; this.joy.dy = v.dy; moveKnob.style.transform = 'translate(' + v.dx * KNOB_TRAVEL + 'px,' + v.dy * KNOB_TRAVEL + 'px)'; }; const applyLook = t => { const v = stickVec(lookEl, t); this.lookJoy.dx = v.dx; this.lookJoy.dy = v.dy; lookKnob.style.transform = 'translate(' + v.dx * KNOB_TRAVEL + 'px,' + v.dy * KNOB_TRAVEL + 'px)'; }; 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) { const which = assign(t); if (which === 'move' && joyTouch === null) { joyTouch = t.identifier; this.joy.active = true; applyMove(t); } else if (which === 'look' && lookTouch === null) { lookTouch = t.identifier; applyLook(t); } } e.preventDefault(); }, { passive: false }); canvas.parentElement.addEventListener('touchmove', e => { for (const t of e.changedTouches) { if (t.identifier === joyTouch) applyMove(t); else if (t.identifier === lookTouch) applyLook(t); } 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; moveKnob.style.transform = ''; } if (t.identifier === lookTouch) { lookTouch = null; this.lookJoy.dx = this.lookJoy.dy = 0; lookKnob.style.transform = ''; } } }; 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; } // Linker Joystick: ratenbasiertes Umsehen (Auslenkung = Dreh-/Neige-Tempo). if (this.lookJoy.dx || this.lookJoy.dy) { const LOOK_RATE = 2.4; // rad/s bei Vollausschlag p.yaw -= this.lookJoy.dx * LOOK_RATE * dt; this.pitch = Math.max(-0.9, Math.min(0.9, this.pitch - this.lookJoy.dy * LOOK_RATE * dt)); } 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 += 'Flagge'; if (cat.special === 'outline') paper += ''; if (key === 'landmark') paper = 'Foto' + 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); })();