dbfb5be5a0
- Schulbuch-Quellenverzeichnis: interner Dateipfad (QUELLEN.md) entfernt - gehoert nicht in ein Schuelerdokument. - Tagesbericht: Malpunkt (Mittelpunkt) statt x als Multiplikationszeichen - oesterreichische Schulschreibweise (neue Dauerregel: +, -, Malpunkt, : fuer Division in allen schueler-sichtbaren Rechnungen). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
801 lines
30 KiB
JavaScript
801 lines
30 KiB
JavaScript
// 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, BUILD_INFO, KNOWLEDGE } 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 = [];
|
||
// Vorlese-Modus: kann von der Lehrperson pro Klasse gesperrt werden
|
||
this.audioAllowed = window.TT_FEATURES?.audioInfo !== false;
|
||
this.infoMode = this.audioAllowed
|
||
? (localStorage.getItem('tt_infomode') || 'read')
|
||
: 'read';
|
||
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 = `
|
||
<div class="bi-head">
|
||
<span class="bi-title"><b>${name}</b><button class="bi-menu-info" title="Alles über: ${name}">ℹ️</button></span>
|
||
<span class="cost">${cost}</span>
|
||
</div>
|
||
<div class="bi-body">
|
||
<span class="emoji">${emoji}</span>
|
||
<small class="desc">${desc}</small>
|
||
</div>`;
|
||
el.addEventListener('click', () => this._toggleBuild(type, el));
|
||
el.querySelector('.bi-menu-info').addEventListener('click', e => {
|
||
e.stopPropagation(); // nicht in den Baumodus wechseln
|
||
audio.click();
|
||
this.showBuildInfo(type);
|
||
});
|
||
return el;
|
||
}
|
||
|
||
// ℹ im Baumenü: erklärt ein Bauwerk VOR dem Bau – Wirkung, Kosten,
|
||
// Lage-Regeln und das Lehrplan-Wissen dazu.
|
||
showBuildInfo(type) {
|
||
const def = BUILDINGS[type];
|
||
const info = BUILD_INFO[type];
|
||
const know = (KNOWLEDGE[type] || [])
|
||
.map(k => `📚 ${k.term}: ${k.text}`).join('\n\n');
|
||
const staffLine = def.staff ? ` + ${def.staff} Angestellte` : '';
|
||
this.showCard({
|
||
id: `menuinfo-${type}`, transient: true, forceModal: true,
|
||
icon: def.emoji, sound: 'none',
|
||
title: def.name,
|
||
text: `${def.desc}\n\nBaukosten: ${def.cost.toLocaleString('de-AT')} € · `
|
||
+ `laufend: ${def.upkeep} €/Tag${staffLine}.`
|
||
+ (info ? `\n\n${info.text}` : '')
|
||
+ (know ? `\n\n${know}` : ''),
|
||
});
|
||
telemetry.log('menu_info', { type });
|
||
}
|
||
|
||
_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') });
|
||
});
|
||
if (!this.audioAllowed) $('btnInfoMode').classList.add('hidden');
|
||
$('btnInfoMode').addEventListener('click', e => {
|
||
audio.click();
|
||
this.infoMode = this.infoMode === 'audio' ? 'read' : 'audio';
|
||
localStorage.setItem('tt_infomode', this.infoMode);
|
||
this._syncInfoModeBtn(e.currentTarget);
|
||
this._stopVoice();
|
||
this.toast(this.infoMode === 'audio'
|
||
? '🔊 Audio-Modus: Meldungen werden vorgelesen, das Spiel läuft weiter. Entscheidungen pausieren weiterhin.'
|
||
: '📖 Lese-Modus: Meldungen öffnen sich als Karte, das Spiel pausiert.', 5000);
|
||
telemetry.log('infomode', { mode: this.infoMode });
|
||
});
|
||
this._syncInfoModeBtn($('btnInfoMode'));
|
||
$('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.09;
|
||
player.onChange?.();
|
||
}
|
||
|
||
_syncInfoModeBtn(btn) {
|
||
if (!btn) return;
|
||
btn.textContent = this.infoMode === 'audio' ? '🔊' : '📖';
|
||
btn.title = this.infoMode === 'audio'
|
||
? 'Audio-Modus: Meldungen werden vorgelesen (umschalten auf Lese-Modus)'
|
||
: 'Lese-Modus: Meldungen pausieren das Spiel (umschalten auf Audio-Modus)';
|
||
}
|
||
|
||
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 = '') => `<tr class="${cls}"><td>${l}</td><td>${r}</td></tr>`;
|
||
|
||
let html = `<div class="rp-head"><b>${d.date}</b>`
|
||
+ `<span>${d.weather}${d.snow != null ? ` · ❄️ ${d.snow} cm${d.warmWinter ? ' (warm!)' : ''}` : ''}</span></div>`;
|
||
|
||
// Nachfrage: Basiswert × Faktoren = wollten kommen
|
||
html += `<div class="rp-sec">Wer wollte kommen?</div><table class="rp-tbl">`;
|
||
html += row(`Grund-Nachfrage (${d.season})`, d.demandBase);
|
||
for (const f of d.demandFactors) {
|
||
const bad = f.mult < 0.9;
|
||
html += `<tr><td class="rp-factor ${bad ? 'bad' : ''}">· ${f.name}</td>`
|
||
+ `<td class="rp-factor ${bad ? 'bad' : ''}">· ${f.mult.toFixed(2)}</td></tr>`;
|
||
}
|
||
html += row('= wollten kommen', `${d.demandResult}`, 'sum');
|
||
html += `</table>`;
|
||
|
||
// Kapazität / Engpass
|
||
html += `<div class="rp-sec">Wie viele konnten?</div><table class="rp-tbl">`;
|
||
html += row('Anreise möglich (Bus & Straße)', d.access);
|
||
html += row(d.capLabel, d.terrainCap);
|
||
html += row('→ Gäste gekommen', `${d.guests}${d.overnight ? ` (${d.overnight} ü.N.)` : ''}`, 'sum');
|
||
html += `</table>`;
|
||
if (d.bottleneck) {
|
||
html += `<div class="rp-bottleneck">⚠️ Engpass: <b>${d.bottleneck}</b> – `
|
||
+ `${d.bottleneckLost} Gäste kamen deshalb nicht. Hier lohnt sich Ausbau!</div>`;
|
||
}
|
||
|
||
// Einnahmen / Ausgaben
|
||
html += `<div class="rp-sec">Einnahmen</div><table class="rp-tbl">`;
|
||
for (const e of d.einnahmen) html += row(e.label, eur(e.amount));
|
||
html += row('Summe', eur(d.income), 'sum');
|
||
html += `</table>`;
|
||
html += `<div class="rp-sec">Ausgaben</div><table class="rp-tbl">`;
|
||
for (const e of d.ausgaben) html += row(e.label, `− ${eur(e.amount)}`);
|
||
html += row('Summe', `− ${eur(d.expenses)}`, 'sum');
|
||
html += `</table>`;
|
||
|
||
html += `<div class="rp-net ${d.net >= 0 ? 'pos' : 'neg'}">`
|
||
+ `<span>Tagesgewinn</span><span>${d.net >= 0 ? '+' : ''}${eur(d.net)}</span></div>`;
|
||
|
||
for (const h of d.hinweise) html += `<div class="rp-hint">💡 ${h}</div>`;
|
||
|
||
$('rpBody').innerHTML = html;
|
||
this.api.setSpeed(0);
|
||
this.markSpeed(0);
|
||
$('reportOverlay').classList.remove('hidden');
|
||
}
|
||
|
||
// ---------- Event-Karten ----------
|
||
// Zwei Info-Modi (Umschalter in der Topbar):
|
||
// 'read' – wie gehabt: Karte öffnet sich, Spiel pausiert.
|
||
// 'audio' – Spielfluss läuft weiter: Meldung wird vorgelesen und
|
||
// erscheint als Info-Zeile oben; Klick darauf (oder 🔔-Log)
|
||
// öffnet die volle Karte. Entscheidungen (A/B) und wichtige
|
||
// Meldungen (Sieg, Bank …) bleiben IMMER modal.
|
||
showCard(card) {
|
||
if (this.infoMode === 'audio' && !card.choices && !card.important && !card.forceModal) {
|
||
this._banner(card);
|
||
return;
|
||
}
|
||
this.cardQueue.push(card);
|
||
if (!this.cardOpen) this._nextCard();
|
||
}
|
||
|
||
_banner(card) {
|
||
if (!card.transient) {
|
||
this.collectedCards.push(card);
|
||
const badge = $('logBadge');
|
||
badge.textContent = this.collectedCards.length;
|
||
badge.classList.remove('hidden');
|
||
telemetry.log('event_card', { id: card.id || card.title, banner: true });
|
||
}
|
||
const el = $('infoBanner');
|
||
el.innerHTML = `<span class="ib-icon">${card.icon}</span><b>${card.title}</b>`
|
||
+ '<span class="ib-hint">antippen für Details</span>';
|
||
el.classList.remove('hidden');
|
||
el.onclick = () => {
|
||
el.classList.add('hidden');
|
||
this._stopVoice();
|
||
this.showCard({ ...card, transient: true, forceModal: true });
|
||
};
|
||
clearTimeout(this._bannerTimer);
|
||
this._bannerTimer = setTimeout(() => el.classList.add('hidden'), 9000);
|
||
this._speakCard(card);
|
||
}
|
||
|
||
// Vorproduzierte Sprach-Audios (assets/audio-info/<id>.mp3, OpenAI-TTS).
|
||
// Browser-TTS nur als Not-Fallback, falls eine Datei fehlt.
|
||
_stopVoice() {
|
||
if (this._voice) { this._voice.pause(); this._voice = null; }
|
||
if ('speechSynthesis' in window) speechSynthesis.cancel();
|
||
}
|
||
|
||
_speakCard(card) {
|
||
this._stopVoice();
|
||
const id = card.audio || card.id;
|
||
if (!id) { this._speak(`${card.title}. ${card.text}`); return; }
|
||
const a = new Audio(`assets/audio-info/${id}.mp3`);
|
||
a.volume = 0.95;
|
||
this._voice = a;
|
||
const fallback = () => { if (this._voice === a) this._speak(`${card.title}. ${card.text}`); };
|
||
a.onerror = fallback;
|
||
a.play().catch(fallback);
|
||
}
|
||
|
||
// Sprachausgabe (Browser-TTS, deutsche Stimme). null = stoppen.
|
||
_speak(text) {
|
||
if (!('speechSynthesis' in window)) return;
|
||
speechSynthesis.cancel();
|
||
if (!text) return;
|
||
const clean = text
|
||
.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}]/gu, '') // Emojis raus
|
||
.replace(/[„"«»]/g, '').replace(/\s+/g, ' ').trim();
|
||
const u = new SpeechSynthesisUtterance(clean);
|
||
u.lang = 'de-DE';
|
||
u.rate = 1.02;
|
||
const voices = speechSynthesis.getVoices().filter(v => v.lang?.startsWith('de'));
|
||
const nice = voices.find(v => /anna|petra|vicki|katja|helena/i.test(v.name)) || voices[0];
|
||
if (nice) u.voice = nice;
|
||
speechSynthesis.speak(u);
|
||
}
|
||
|
||
_nextCard() {
|
||
const card = this.cardQueue.shift();
|
||
if (!card) return;
|
||
this.currentCard = card;
|
||
this.cardOpen = true;
|
||
const cur = this.api.getSpeed();
|
||
if (cur > 0) this.speedBeforeCard = cur;
|
||
this.api.setSpeed(0); // Auto-Pause, solange die Karte offen ist
|
||
this.markSpeed(0);
|
||
$('cardIcon').textContent = card.icon;
|
||
$('cardTitle').textContent = card.title;
|
||
$('cardText').textContent = card.text;
|
||
// Entscheidungs-Karte: zwei Buttons, sonst nur „Weiter"
|
||
const alt = $('cardAlt');
|
||
if (card.choices) {
|
||
$('cardOk').textContent = card.choices[0].label;
|
||
$('cardOk').disabled = card.choices[0].disabled || false;
|
||
alt.textContent = card.choices[1].label;
|
||
alt.classList.remove('hidden');
|
||
} else {
|
||
$('cardOk').textContent = 'Weiter';
|
||
$('cardOk').disabled = false;
|
||
alt.classList.add('hidden');
|
||
}
|
||
$('cardOverlay').classList.remove('hidden');
|
||
if (!card.transient) {
|
||
const fn = audio[card.sound || 'chime'];
|
||
if (card.sound !== 'none') (typeof fn === 'function' ? fn : audio.chime).call(audio);
|
||
this.collectedCards.push(card);
|
||
const badge = $('logBadge');
|
||
badge.textContent = this.collectedCards.length;
|
||
badge.classList.remove('hidden');
|
||
telemetry.log('event_card', { id: card.id || card.title });
|
||
}
|
||
}
|
||
|
||
_closeCard(choiceIdx = null) {
|
||
const card = this.currentCard;
|
||
if (card?.choices && choiceIdx !== null) {
|
||
card.choices[choiceIdx].onPick?.();
|
||
}
|
||
$('cardOverlay').classList.add('hidden');
|
||
this.cardOpen = false;
|
||
this.currentCard = null;
|
||
if (this.cardQueue.length) {
|
||
this._nextCard();
|
||
} else {
|
||
this.api.setSpeed(this.speedBeforeCard || 1);
|
||
this.markSpeed(this.speedBeforeCard || 1);
|
||
}
|
||
}
|
||
|
||
// ---------- Gebäude-Inspektion (Klick aufs Gebäude) ----------
|
||
showBuildingCard(report, handlers) {
|
||
this._biHandlers = handlers;
|
||
this._biReport = report;
|
||
const roman = ['', 'I', 'II', 'III', 'IV'];
|
||
$('biIcon').textContent = report.emoji;
|
||
$('biTitle').textContent = report.name;
|
||
$('biLevel').textContent = `Ausbaustufe ${roman[report.level]}`
|
||
+ (report.alt !== undefined ? ` · Höhenlage ${report.alt}` : '');
|
||
const list = $('biStats');
|
||
list.innerHTML = '';
|
||
for (const s of report.stats) {
|
||
const li = document.createElement('li');
|
||
li.textContent = s;
|
||
list.appendChild(li);
|
||
}
|
||
$('biCost').innerHTML = `Laufende Kosten: <b>${report.dailyCost.toLocaleString('de-AT')} €/Tag</b> `
|
||
+ `(${report.dailyUpkeep} € Instandhaltung + ${report.dailyWage} € Löhne)`
|
||
+ (report.sparflamme ? '<br>💤 Läuft in dieser Saison auf Sparflamme – reduzierte Kosten.' : '');
|
||
|
||
// Aktuelle Probleme dieses Gebäudes
|
||
const probs = $('biProblems');
|
||
if (report.problems && report.problems.length) {
|
||
probs.innerHTML = '<div class="bi-sec">⚠️ Das hakt gerade</div>'
|
||
+ report.problems.map(p => `<div class="bi-prob">${p}</div>`).join('');
|
||
probs.classList.remove('hidden');
|
||
} else { probs.innerHTML = ''; probs.classList.add('hidden'); }
|
||
|
||
// Lehrplan-Wissen (Fachbegriffe im Kontext)
|
||
const know = $('biKnowledge');
|
||
if (report.knowledge && report.knowledge.length) {
|
||
know.innerHTML = '<div class="bi-sec">📚 Gut zu wissen</div>'
|
||
+ report.knowledge.map(k => `<div class="bi-know"><b>${k.term}:</b> ${k.text}</div>`).join('');
|
||
know.classList.remove('hidden');
|
||
} else { know.innerHTML = ''; know.classList.add('hidden'); }
|
||
|
||
const up = $('biUpgrade');
|
||
if (report.canUpgrade) {
|
||
const afford = handlers.money >= report.nextCost;
|
||
up.textContent = `⬆️ Ausbauen auf Stufe ${roman[report.level + 1]} (${report.nextCost.toLocaleString('de-AT')} €)`;
|
||
up.disabled = !afford;
|
||
up.classList.remove('hidden');
|
||
} else {
|
||
up.textContent = '★ Maximale Ausbaustufe erreicht';
|
||
up.disabled = true;
|
||
up.classList.remove('hidden');
|
||
}
|
||
// Öko-Umbau (Solar & Co.): einmalig, −20 % Betriebskosten, +Natur
|
||
const eco = $('biEco');
|
||
if (report.eco) {
|
||
eco.textContent = '🌱 Öko-Betrieb ✓';
|
||
eco.disabled = true;
|
||
} else {
|
||
eco.textContent = `🌱 Öko-Umbau (${report.ecoCost.toLocaleString('de-AT')} €)`;
|
||
eco.disabled = handlers.money < report.ecoCost;
|
||
}
|
||
this.api.setSpeed(0);
|
||
this.markSpeed(0);
|
||
$('buildingOverlay').classList.remove('hidden');
|
||
telemetry.log('inspect', { type: report.type, level: report.level });
|
||
}
|
||
|
||
_closeBuilding(resume = true) {
|
||
$('buildingOverlay').classList.add('hidden');
|
||
if (resume) { this.api.setSpeed(this.speedBeforeCard || 1); this.markSpeed(this.speedBeforeCard || 1); }
|
||
}
|
||
|
||
_bindOverlays() {
|
||
$('biClose').addEventListener('click', () => { audio.click(); this._closeBuilding(); });
|
||
$('biUpgrade').addEventListener('click', () => {
|
||
audio.click();
|
||
this._biHandlers?.onUpgrade?.(this._biReport);
|
||
});
|
||
$('biEco').addEventListener('click', () => {
|
||
audio.click();
|
||
this._biHandlers?.onEco?.(this._biReport);
|
||
});
|
||
$('biDemolish').addEventListener('click', () => {
|
||
audio.click();
|
||
this._biHandlers?.onDemolish?.(this._biReport);
|
||
this._closeBuilding();
|
||
});
|
||
$('cardOk').addEventListener('click', () => {
|
||
audio.click();
|
||
this._closeCard(this.currentCard?.choices ? 0 : null);
|
||
});
|
||
$('cardAlt').addEventListener('click', () => {
|
||
audio.click();
|
||
this._closeCard(1);
|
||
});
|
||
$('logClose').addEventListener('click', () => {
|
||
audio.click();
|
||
$('logOverlay').classList.add('hidden');
|
||
});
|
||
$('rpClose').addEventListener('click', () => {
|
||
audio.click();
|
||
$('reportOverlay').classList.add('hidden');
|
||
this.api.setSpeed(this.speedBeforeCard || 1);
|
||
this.markSpeed(this.speedBeforeCard || 1);
|
||
});
|
||
}
|
||
|
||
// ---------- Auftrag (Quest) ----------
|
||
updateQuest(quest) {
|
||
if (!quest) {
|
||
$('quest').classList.add('hidden');
|
||
this.questDone = true;
|
||
return;
|
||
}
|
||
this.currentQuest = quest;
|
||
$('questIcon').textContent = quest.icon;
|
||
$('questTitle').textContent = quest.title;
|
||
$('questDesc').textContent = quest.desc;
|
||
$('questReward').textContent = `Belohnung: ${quest.reward.toLocaleString('de-AT')} €`;
|
||
}
|
||
|
||
_openLog() {
|
||
const list = $('logList');
|
||
list.innerHTML = this.collectedCards.length
|
||
? '' : '<li>Noch keine Meldungen.</li>';
|
||
for (const c of [...this.collectedCards].reverse()) {
|
||
const li = document.createElement('li');
|
||
li.innerHTML = `${c.icon} <b>${c.title}</b><small>${c.text.split('\n')[0]}</small>`;
|
||
li.addEventListener('click', () => {
|
||
$('logOverlay').classList.add('hidden');
|
||
this.showCard({ ...c, transient: true, forceModal: true });
|
||
});
|
||
list.appendChild(li);
|
||
}
|
||
$('logOverlay').classList.remove('hidden');
|
||
telemetry.log('log_open', { count: this.collectedCards.length });
|
||
}
|
||
|
||
// ---------- Onboarding ----------
|
||
showOnboarding() {
|
||
this.showCard({
|
||
icon: '🏔️', transient: true, forceModal: true,
|
||
title: 'Willkommen! (1/3) – So spielst du',
|
||
text: 'Rechts ein Gebäude wählen, dann auf die Karte tippen – fertig. '
|
||
+ 'Nach dem Bau legst du das Werkzeug automatisch ab.\n\n'
|
||
+ 'Oben läuft die Zeit (⏸ bis 4×). Karte bewegen: ziehen · '
|
||
+ 'Zoomen: zwei Finger oder Mausrad.\n\n'
|
||
+ 'Am Anfang gibt es nur das Gasthaus – alles Weitere schaltet sich '
|
||
+ 'Schritt für Schritt frei.',
|
||
});
|
||
this.showCard({
|
||
icon: '🚶', transient: true, forceModal: true,
|
||
title: 'Die Gäste (2/3) – dein Kreislauf',
|
||
text: 'Jede Figur steht für etwa 10 Gäste. Sie kommen mit dem Bus, '
|
||
+ 'wandern, kehren ein und reisen wieder ab.\n\n'
|
||
+ 'Die Symbole überm Kopf zeigen, was sie vorhaben:\n'
|
||
+ '🍽️ einkehren · 🎿 Ski fahren · 🥾 wandern · 🏖️ baden · 🛷 rodeln\n'
|
||
+ '😋 konsumiert gerade (grüne +€!) · ❄️ „kein Schnee!" · '
|
||
+ '👋 reist ab · 😞 reist unzufrieden ab\n\n'
|
||
+ 'Wer konsumiert, bringt Geld – wer nicht kommen kann, keins.',
|
||
});
|
||
this.showCard({
|
||
icon: '🔍', transient: true, forceModal: true,
|
||
title: 'Durchblick (3/3) – warum läuft es (nicht)?',
|
||
text: 'Links stehen deine Kennzahlen. Kommen weniger Gäste als möglich, '
|
||
+ 'erscheint dort der GRUND (z.B. „Engpass: Anreise").\n\n'
|
||
+ 'Der 📋 Tagesbericht rechnet dir jeden Tag im Detail vor. '
|
||
+ 'Beim ersten Bau jedes Gebäudes erklärt dir eine Karte, was es '
|
||
+ 'bringt und was es kostet.\n\n'
|
||
+ 'Der Graph unten zeigt die Jahreszeiten farbig – das blaue Band ist '
|
||
+ 'die Skisaison (Oktober bis Ostern).\n\n'
|
||
+ 'Ab und zu taucht ein Ereignis oder eine Entscheidung auf – in Ruhe, '
|
||
+ 'das Spiel pausiert dabei. Diese Anleitung öffnest du jederzeit wieder '
|
||
+ 'über ℹ oben rechts.',
|
||
});
|
||
telemetry.log('onboarding_open');
|
||
}
|
||
|
||
toast(msg, dur = 2600) {
|
||
const t = $('toast');
|
||
t.textContent = msg;
|
||
t.classList.remove('hidden');
|
||
clearTimeout(this.toastTimer);
|
||
this.toastTimer = setTimeout(() => t.classList.add('hidden'), dur);
|
||
}
|
||
}
|