// UI-Logik: Kennzahlen, Bau-Menü (inkl. Abriss), Event-Karten (mit // Auto-Pause), Meldungs-Log, Graphen, Skipass-Regler, Ziele, Toasts, // Onboarding, Szenenwahl und Topbar-Buttons. import { BUILDINGS } from './data.js'; import { seasonOfDay, DAYS_PER_YEAR } from './model.js'; import { telemetry } from './telemetry.js'; import { audio } from './audio.js'; import { player, TRACKS } from './player.js'; const $ = id => document.getElementById(id); export class UI { constructor(state, api) { this.state = state; this.api = api; // {setSpeed, getSpeed, setBuildType} this.cardQueue = []; this.cardOpen = false; this.speedBeforeCard = 1; this.collectedCards = []; this.activeBuild = null; this.toastTimer = null; $('sceneName').textContent = state.scenario.name; this._buildMenu(); this._bindTopbar(); this._initPlayer(); this._bindOverlays(); this._bindPrice(); this._buildGoals(); this.updateKPIs(); } // ---------- Bau-Menü (zeigt nur Freigeschaltetes – kleine Schritte) ---------- _buildMenu() { this.rebuildMenu(); } rebuildMenu() { const wrap = $('buildItems'); wrap.innerHTML = ''; for (const [type, def] of Object.entries(BUILDINGS)) { if (!this.state.unlocked.has(type)) continue; wrap.appendChild(this._buildItem(type, def.emoji, def.name, def.desc, `${def.cost.toLocaleString('de-AT')} €`)); } // Hinweis: Ausbauen & Abreißen laufen jetzt über Klick aufs Gebäude // aktive Auswahl wiederherstellen if (this.activeBuild) { const el = wrap.querySelector(`[data-type="${this.activeBuild}"]`); if (el) el.classList.add('active'); else { this.activeBuild = null; this.api.setBuildType(null); } } this.refreshBuildMenu(); } // Kennzahlen & Panels erscheinen erst, wenn sie relevant werden applyProgress() { const s = this.state; const hasLift = s.unlocked.has('lift') && s.buildings.some(b => b.type === 'lift'); $('kpiRowSnow').classList.toggle('hidden', !s.unlocked.has('lift')); $('priceRow').classList.toggle('hidden', !hasLift); $('priceSlider').classList.toggle('hidden', !hasLift); $('goals').classList.toggle('hidden', !hasLift); $('graphs').classList.toggle('hidden', s.buildings.length < 2); $('kpiRowNature').classList.toggle('hidden', s.buildings.length < 2); $('kpiRowStaff').classList.toggle('hidden', !s.unlocked.has('staffhouse') && s.staffNeeded <= s.staffAvailable); $('quest').classList.toggle('hidden', s.buildings.length < 1 || this.questDone === true); } _buildItem(type, emoji, name, desc, cost) { const el = document.createElement('div'); el.className = 'build-item'; el.dataset.type = type; el.innerHTML = ` ${emoji} ${name}${desc} ${cost}`; el.addEventListener('click', () => this._toggleBuild(type, el)); return el; } _toggleBuild(type, el) { audio.click(); document.querySelectorAll('.build-item').forEach(i => i.classList.remove('active')); if (this.activeBuild === type) { this.activeBuild = null; } else { this.activeBuild = type; el.classList.add('active'); this.toast(type === 'demolish' ? '🧨 Abriss gewählt – tippe auf ein Gebäude.' : type === 'upgrade' ? '⬆️ Ausbau gewählt – tippe auf ein Gebäude.' : `${BUILDINGS[type].emoji} ${BUILDINGS[type].name} gewählt – tippe auf die Karte zum Bauen.`); } this.api.setBuildType(this.activeBuild); telemetry.log('build_select', { type: this.activeBuild }); } clearBuildSelection() { this.activeBuild = null; this.api.setBuildType(null); document.querySelectorAll('.build-item').forEach(i => i.classList.remove('active')); } refreshBuildMenu() { document.querySelectorAll('.build-item').forEach(el => { const def = BUILDINGS[el.dataset.type]; if (def) el.classList.toggle('disabled', this.state.money < def.cost); }); } // ---------- Topbar ---------- _bindTopbar() { document.querySelectorAll('.speed-btn').forEach(btn => { btn.addEventListener('click', () => { audio.click(); const sp = Number(btn.dataset.speed); this.api.setSpeed(sp); this.markSpeed(sp); telemetry.log('speed', { speed: sp }); }); }); $('btnHome').addEventListener('click', () => { audio.click(); telemetry.log('home_click'); // Der Plattform-Wrapper biegt dieses Ziel auf das gültige Cockpit um // (Schüler → /schueler, Lehrperson → /teacher). Standalone: Fallback. window.location.href='../../schueler.html'; }); $('btnSound').addEventListener('click', e => { const on = audio.toggleSound(); e.currentTarget.classList.toggle('muted', !on); telemetry.log('audio_toggle', { kind: 'sound', off: !on }); }); $('btnMusic').addEventListener('click', () => { audio.click(); $('playerPanel').classList.toggle('hidden'); telemetry.log('player_panel', { open: !$('playerPanel').classList.contains('hidden') }); }); $('btnHelp').addEventListener('click', () => { audio.click(); this.showOnboarding(); }); $('btnLog').addEventListener('click', () => { audio.click(); this._openLog(); }); $('btnReport').addEventListener('click', () => { audio.click(); const cur = this.api.getSpeed(); if (cur > 0) this.speedBeforeCard = cur; this.showReport(); telemetry.log('report_open'); }); } // ---------- Musik-Player ---------- _initPlayer() { const sel = $('trackSelect'); TRACKS.forEach((t, i) => { const o = document.createElement('option'); o.value = i; o.textContent = `♪ ${t.title}`; sel.appendChild(o); }); sel.addEventListener('change', () => { player.select(Number(sel.value)); player.play(); telemetry.log('music', { action: 'select', track: player.current().title }); }); $('plPlay').addEventListener('click', () => { player.toggle(); }); $('plNext').addEventListener('click', () => { player.next(); audio.click(); }); $('plPrev').addEventListener('click', () => { player.prev(); audio.click(); }); const vol = $('plVol'); vol.addEventListener('input', () => player.setVolume(Number(vol.value))); player.onChange = () => { $('plPlay').textContent = player.playing ? '⏸' : '▶'; sel.value = player.idx; $('btnMusic').classList.toggle('muted', !player.playing); }; } syncPlayerUI() { $('plVol').value = player.el ? player.el.volume : 0.35; player.onChange?.(); } markSpeed(sp) { document.querySelectorAll('.speed-btn').forEach(b => b.classList.toggle('active', Number(b.dataset.speed) === sp)); } // ---------- Skipass-Preis ---------- _bindPrice() { const slider = $('priceSlider'); slider.addEventListener('input', () => { this.state.price = Number(slider.value); $('kPrice').textContent = `${this.state.price} €`; }); slider.addEventListener('change', () => { telemetry.log('price_change', { price: this.state.price }); this.toast(this.state.price >= 35 ? '🎟️ Hoher Preis: mehr Geld pro Gast, aber weniger Gäste.' : this.state.price <= 18 ? '🎟️ Niedriger Preis: viele Gäste, aber wenig Einnahmen.' : '🎟️ Preis angepasst.'); }); } // ---------- Ziele ---------- _buildGoals() { document.querySelector('#goals h3').textContent = this.state.scenario.goalsTitle; const list = $('goalList'); for (const g of this.state.scenario.goals) { const li = document.createElement('li'); li.id = `goal-${g.id}`; li.textContent = g.label; list.appendChild(li); } } updateGoals() { for (const g of this.state.scenario.goals) { $(`goal-${g.id}`)?.classList.toggle('done', g.check(this.state)); } } // ---------- Kennzahlen ---------- updateKPIs() { const s = this.state; $('seasonBadge').textContent = `${s.season} · ${s.year}`; // Jahreskreis-Marker + Wetter mit Vorhersage const dayOfYear = ((s.day - 1) % DAYS_PER_YEAR) / DAYS_PER_YEAR; $('yearMarker').style.left = `${Math.round(dayOfYear * 100)}%`; if (s.weatherToday) { $('weatherNow').textContent = `${s.weatherToday} ${s.temp}°`; $('forecast').textContent = (s.forecast || []) .map(f => `${f.kind}${f.temp}°`).join(' '); } $('kMoney').textContent = `${s.money.toLocaleString('de-AT')} €`; $('kMoney').classList.toggle('warn', s.money < 3000); $('kGuests').textContent = s.guestsToday; // Transparenz: WARUM kamen nicht alle? (Engpass-Diagnose aus dem Modell) const gap = $('kGap'); if (s.bottleneck && s.buildings.length) { gap.textContent = `⚠️ ${s.demandToday} wollten kommen – Engpass: ${s.bottleneck}`; gap.classList.remove('hidden'); } else { gap.classList.add('hidden'); } $('kSnow').textContent = `${Math.round(s.snow)} cm`; $('kSnow').classList.toggle('warn', s.season === 'Winter' && s.snow < 15); $('kSat').textContent = `${s.satisfaction} %`; $('kNature').textContent = `${Math.round(s.nature)} %`; $('kNature').classList.toggle('warn', s.nature < 45); $('kStaff').textContent = `${Math.min(s.staffNeeded, s.staffAvailable)} / ${s.staffNeeded}`; $('kStaff').classList.toggle('warn', s.staffNeeded > s.staffAvailable); this.refreshBuildMenu(); this.updateGoals(); this.applyProgress(); } updateClock() { $('clock').textContent = this.state.dateStr || ''; } // ---------- Graphen (Geld links, Gäste rechts) ---------- drawGraph() { this._plot('graphMoney', this.state.history.money, '#4f7f5e', v => `${v.toLocaleString('de-AT')} €`, true); this._plot('graphCanvas', this.state.history.guests, '#e8892b', v => `${v}`, false); } _plot(id, arr, color, fmt, fill) { const cv = $(id); if (!cv) return; const dpr = window.devicePixelRatio || 1; const w = cv.clientWidth, h = cv.clientHeight; if (!w || !h) return; if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); } const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); if (arr.length < 2) return; // Alles auf die Zeichenfläche begrenzen – nichts kann mehr „ausrauschen" ctx.save(); ctx.beginPath(); ctx.rect(0, 0, w, h); ctx.clip(); const PAD = 8; // fester Rand oben/unten // Jahreszeiten als blasse Bänder + Jahresgrenzen als Linien const SEASON_BG = { Sommer: 'rgba(168,198,95,0.16)', Herbst: 'rgba(216,160,78,0.16)', Winter: 'rgba(169,198,216,0.22)', 'Frühling': 'rgba(181,216,160,0.16)', }; const n = arr.length; const firstDay = this.state.day - n + 1; let runStart = 0; let runSeason = seasonOfDay(firstDay - 1); for (let i = 1; i <= n; i++) { const sn = i < n ? seasonOfDay(firstDay + i - 1) : null; if (sn !== runSeason) { ctx.fillStyle = SEASON_BG[runSeason]; const x0 = (runStart / (n - 1)) * w; const x1 = (Math.min(i, n - 1) / (n - 1)) * w; ctx.fillRect(x0, 0, x1 - x0, h); runStart = i; runSeason = sn; } const d = firstDay + i - 1; if (d > firstDay && (d - 1) % DAYS_PER_YEAR === 0) { const x = ((i - 1) / (n - 1)) * w; ctx.strokeStyle = 'rgba(47,62,70,0.35)'; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } } ctx.strokeStyle = 'rgba(47,62,70,0.08)'; for (let i = 1; i <= 3; i++) { const y = (h / 4) * i; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } // Eigene Skala mit etwas Kopf-/Fußraum; 0 bleibt sichtbar, wenn nötig. let max = Math.max(...arr); let min = Math.min(...arr, 0); const pad = (max - min) * 0.12 || 1; max += pad; min -= (min < 0 ? pad : 0); const span = max - min || 1; const yOf = v => { const y = (h - PAD) - ((v - min) / span) * (h - 2 * PAD); return Math.max(PAD, Math.min(h - PAD, y)); }; if (min < 0) { const y0 = yOf(0); ctx.strokeStyle = 'rgba(163,50,39,0.45)'; ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(0, y0); ctx.lineTo(w, y0); ctx.stroke(); ctx.setLineDash([]); } const pts = arr.map((v, i) => [(i / (n - 1)) * w, yOf(v)]); if (fill) { ctx.beginPath(); ctx.moveTo(pts[0][0], h); pts.forEach(p => ctx.lineTo(p[0], p[1])); ctx.lineTo(pts[n - 1][0], h); ctx.closePath(); ctx.fillStyle = 'rgba(79,127,94,0.12)'; ctx.fill(); } ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath(); pts.forEach((p, i) => (i === 0 ? ctx.moveTo(p[0], p[1]) : ctx.lineTo(p[0], p[1]))); ctx.stroke(); ctx.lineWidth = 1; ctx.restore(); // Clip aufheben // aktueller Wert oben rechts ctx.font = 'bold 11px sans-serif'; ctx.textAlign = 'right'; ctx.fillStyle = color; ctx.fillText(fmt(arr[n - 1]), w - 4, 12); // dynamische Skala: Höchst- und Tiefstwert (und 0-Linie) links beschriften const dMax = Math.max(...arr), dMin = Math.min(...arr); ctx.font = '10px sans-serif'; ctx.textAlign = 'left'; ctx.fillStyle = 'rgba(47,62,70,0.5)'; if (dMax !== arr[n - 1]) { // nicht doppelt mit dem Wert oben rechts ctx.textBaseline = 'top'; ctx.fillText(fmt(dMax), 3, Math.max(1, yOf(dMax))); } ctx.textBaseline = 'bottom'; ctx.fillText(fmt(dMin), 3, Math.min(h - 1, yOf(dMin))); if (min < 0) { ctx.fillStyle = 'rgba(163,50,39,0.7)'; ctx.textBaseline = 'middle'; ctx.fillText(fmt(0), 3, yOf(0)); } ctx.textBaseline = 'alphabetic'; } // ---------- Tagesbericht als Tabelle ---------- showReport() { const d = this.state.reportData; if (!d) return; const eur = v => `${Math.round(v).toLocaleString('de-AT')} €`; const row = (l, r, cls = '') => `
| × ${f.name} | ` + `×${f.mult.toFixed(2)} |