Tourismustal: Komplett-Integration (Sim + Wrapper + SQL + Lehrer-Backend)

Sim nach App/sims/tourismustal umgezogen (Source of Truth), game.html mit
Injection-Marker. Neu in der Sim: 5 Sommergebaeude (Bikepark mit Flowtrails,
Hochseilgarten, Fahrradverleih als Verstaerker, Aussichtsplattform,
Erlebnisspielplatz), Rettungsstation mit Notarzthubschrauber, Ausbau-
Bauanimation, Event-Pacing, Schulden-Notbremse (Bank-Zwangsverkauf),
Berater-Tipps (Ruecklagen), Sommer-Trend-Mechanik (Serfaus-Effekt) und
Wissens-Bausteine mit echten DACH-Tourismusdaten (QUELLEN.md).

Plattform: PHP-Wrapper mit base-Tag + Session-Mode, Modul-Detailseite,
module_info (beta) + Glossar (14 Begriffe inkl. Leichter Sprache + Quellen)
+ Lehrplan-Anker AT/DE/CH an neuer Kompetenz tourismus-raumentwicklung.
Lehrer-Backend: Benchmark-Formel, Live-Cockpit-Spalten, needsHelp-Heuristik,
Modul-Highlights. Strategie-Testharness (tests/strategien.mjs) belegt:
Panzer-Rush wird liquidiert, Ruecklagen zahlen sich aus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 02:45:08 +02:00
parent eb565d4c01
commit 0a2ebf46b4
55 changed files with 6820 additions and 0 deletions
+663
View File
@@ -0,0 +1,663 @@
// 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 = `
<span class="emoji">${emoji}</span>
<span class="info"><b>${name}</b><small>${desc}</small></span>
<span class="cost">${cost}</span>`;
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 = '') => `<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 &amp; 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 ----------
showCard(card) {
this.cardQueue.push(card);
if (!this.cardOpen) this._nextCard();
}
_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');
}
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);
});
$('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 });
});
list.appendChild(li);
}
$('logOverlay').classList.remove('hidden');
telemetry.log('log_open', { count: this.collectedCards.length });
}
// ---------- Onboarding ----------
showOnboarding() {
this.showCard({
icon: '🏔️', transient: 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,
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,
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);
}
}