Files
geograsim/App/sims/tourismusregion/js/ui.js
T
Adminator 8047778a5b Tourismusregion: Komplett-Integration (zweite neue Sim, beta)
Wellenbasierte Routen-Sim (DefensegeograSimMac) nach dem Tourismustal-
Playbook integriert: Sim nach App/sims/tourismusregion (game.html,
Sprites 47MB->9.5MB, ohne Eigen-Backend/Editor/Dashboard/Keys),
Plattform-Hooks (GGS_LIVE_STATE mit Welle/Budget/Umwelt/Sterne/Lenkung,
Submit bei Partie-Ende mit fertigem 0-100-Score, GGS_TUTORIAL, Home-
Button, Musik leise). Wrapper + Modul-Detailseite. SQL: module_info
(beta, Karte), Glossar-Kernbegriff Besucherlenkung (+Bild, Leichte
Sprache) + 12 Zuordnungen, Lehrplan-Mapping auf tourismus-
raumentwicklung (primaer) + verkehrserziehung/systemisches-denken.
LEHRPLAN.md mit Schwerpunkt Besucherlenkung & Mobilitaet. Lehrer-
Backend: Benchmark (Sim-Score direkt), Live-Cockpit-Spalten,
needsHelp, Highlights.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:14:12 +02:00

716 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DOM-UI: HUD-Kennzahlen, Baumenü, Info-Panels (Gebäude/Besucher),
// Ereignis-Karten mit Auto-Pause (inkl. Entscheidungen und Social-Media-
// Feed), Meldungs-Log, Zeit-Graph, Ziele, Toasts, Onboarding, Endauswertung.
import { BUILDINGS, GROUPS, GOALS, currentAvg, GAME_LEN } from './data.js';
import { telemetry } from './telemetry.js';
import { audio } from './audio.js';
import { music, TRACKS } from './music.js';
const $ = id => document.getElementById(id);
const euro = n => Math.round(n).toLocaleString('de-AT');
export class UI {
constructor(state, api) {
this.state = state;
this.api = api; // {setSpeed, getSpeed, setBuildType, selectBuilding, upgradeSelected, demolishSelected, restart, save}
this.cardQueue = [];
this.cardOpen = false;
this.speedBeforeCard = 1;
this.collectedCards = [];
this.activeBuild = null;
this.selectedBuilding = null;
this.selectedVisitor = null;
this.toastTimer = null;
this.gMoney = $('graphMoneyCanvas').getContext('2d');
this.gSat = $('graphSatCanvas').getContext('2d');
this._buildGoals();
this.rebuildMenu();
this._bindTopbar();
this._bindOverlays();
this.updateKPIs();
}
// ---------- Baumenü ----------
rebuildMenu() {
const wrap = $('buildItems');
wrap.innerHTML = '';
for (const [type, def] of Object.entries(BUILDINGS)) {
if (!this.state.unlocked.has(type)) continue;
const el = document.createElement('div');
el.className = 'build-item';
el.dataset.type = type;
el.innerHTML = `
<span class="emoji">${def.emoji}</span>
<span class="info"><b>${def.name}</b><small>${def.desc}</small></span>
<span class="cost">${euro(def.cost[0])} €</span>`;
el.addEventListener('click', () => this._toggleBuild(type, el));
wrap.appendChild(el);
}
const locked = Object.values(BUILDINGS).filter(d => d.unlockPhase > this.state.phase).length;
if (locked > 0) {
const hint = document.createElement('div');
hint.className = 'locked-hint';
hint.textContent = `🔒 ${locked} weitere werden im Spielverlauf freigeschaltet.`;
wrap.appendChild(hint);
}
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();
}
_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');
const def = BUILDINGS[type];
this.toast(`${def.emoji} ${def.name} gewählt tippe auf die Karte. Der Kreis zeigt die Reichweite.`);
this.hideInfoPanel();
}
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.budget < def.cost[0]);
});
}
// ---------- 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 Cockpit um
window.location.href='../../schueler.html';
});
$('btnSound').addEventListener('click', e => {
// Master-Mute: Effekte, Musik-Player UND Saison-Ambience
const on = audio.toggleSound();
music.setMuted(!on);
if (!on) audio._stopAmbience?.();
else if (audio.musicOn && !music.playing) audio.startMusic();
e.currentTarget.classList.toggle('muted', !on);
telemetry.log('audio_toggle', { kind: 'sound', off: !on });
});
$('btnMusic').addEventListener('click', () => {
audio.click();
$('musicPanel').classList.toggle('hidden');
telemetry.log('music_panel', { open: !$('musicPanel').classList.contains('hidden') });
});
this._bindMusic();
$('btnAnim').addEventListener('click', e => {
const on = this.api.toggleReduceAnim();
e.currentTarget.classList.toggle('active', on);
this.toast(on ? '🐢 Reduzierte Animationen aktiviert.' : '🐢 Animationen wieder aktiviert.');
telemetry.log('reduce_anim', { on });
});
$('btnCamera').addEventListener('click', () => { audio.click(); this.api.resetCamera(); });
$('btnNextWave').addEventListener('click', () => this.api.nextWave());
$('btnHelp').addEventListener('click', () => { audio.click(); this.showOnboarding(); });
$('btnLog').addEventListener('click', () => { audio.click(); this._openLog(); });
}
// ---------- Musik-Player ----------
_bindMusic() {
const sel = $('muSelect');
sel.innerHTML = TRACKS.map((t, i) => `<option value="${i}">🎵 ${t.title}</option>`).join('');
music.onChange = () => {
$('muPlay').textContent = music.playing ? '⏸' : '▶';
sel.value = String(music.idx);
};
$('muPlay').addEventListener('click', () => { music.toggle(); telemetry.log('music', { action: 'toggle', track: music.current().title }); });
$('muNext').addEventListener('click', () => { music.next(); telemetry.log('music', { action: 'next', track: music.current().title }); });
$('muPrev').addEventListener('click', () => { music.prev(); telemetry.log('music', { action: 'prev', track: music.current().title }); });
$('muVol').addEventListener('input', e => music.setVolume(Number(e.target.value)));
sel.addEventListener('change', () => { music.play(Number(sel.value)); telemetry.log('music', { action: 'select', track: music.current().title }); });
}
markSpeed(sp) {
document.querySelectorAll('.speed-btn').forEach(b =>
b.classList.toggle('active', Number(b.dataset.speed) === sp));
}
// ---------- Ziele ----------
_buildGoals() {
const list = $('goalList');
list.innerHTML = '';
for (const g of GOALS) {
const li = document.createElement('li');
li.id = `goal-${g.id}`;
li.textContent = g.label;
list.appendChild(li);
}
}
// ---------- Kennzahlen + Ruf-Leiste + Wellen-Status ----------
updateKPIs() {
const s = this.state;
$('clock').textContent = `Welle ${s.phase}/8`;
$('seasonBadge').textContent = `${{ spring: '🌸', summer: '☀️', autumn: '🍂', winter: '❄️' }[s.seasonKey]} ${s.season}`;
$('kMoney').textContent = `${euro(s.budget)} €`;
$('kMoney').classList.toggle('warn', s.budget < 100);
$('kEnv').textContent = Math.round(s.env);
$('kEnv').classList.toggle('warn', s.env < 45);
$('kVisitors').textContent = s.visitors.length;
$('kTraffic').textContent = s.traffic;
$('kTraffic').classList.toggle('warn', s.traffic > 30);
$('kRevenue').textContent = `${euro(s.revenueTotal)} €`;
// Ruf: Ø der letzten Abreisen (TD-„Basis-HP"); pulst bei schlechter Abreise
const rep = s.recentStars.length
? s.recentStars.reduce((a, b) => a + b, 0) / s.recentStars.length : null;
const pct = rep === null ? 60 : Math.round((rep / 5) * 100);
$('repVal').textContent = rep === null ? '' : `${rep.toFixed(1)} ★`;
const fill = $('repFill');
fill.style.width = `${pct}%`;
fill.classList.toggle('low', rep !== null && rep < 2.5);
fill.classList.toggle('mid', rep !== null && rep >= 2.5 && rep < 3.5);
if (s.lastBadExit && s.lastBadExit !== this._lastBadShown) {
this._lastBadShown = s.lastBadExit;
const row = $('repRow');
row.classList.remove('pulse'); void row.offsetWidth; row.classList.add('pulse');
}
// Wellen-Status-Banner
const ws = $('waveStatus');
if (s.waveStatus === 'pause') {
ws.className = 'wave-status pause';
ws.textContent = `🔨 Baupause Welle ${s.phase} startet in ${Math.ceil(Math.max(0, s.pauseLeft))} s`;
} else {
const left = s.visitors.filter(v => v.wave === s.phase).length + s.spawnQueue.filter(e => e.type).length;
ws.className = 'wave-status running';
ws.textContent = `🌊 Welle ${s.phase} läuft noch ${left} Gäste`;
}
$('btnNextWave').disabled = s.ended || (s.phase >= 8 && s.waveStatus === 'running');
$('btnNextWave').textContent = s.waveStatus === 'pause' ? '⏩ Welle jetzt starten' : '⏩ Nächste Welle rufen';
for (const g of GOALS) {
$(`goal-${g.id}`)?.classList.toggle('done', g.check(s));
}
this.refreshBuildMenu();
this.refreshInfoPanel();
}
// ---------- Statistik-Overlay ----------
showStats() {
const s = this.state;
const rows = (title, items) => {
if (!items.length) return '';
const max = Math.max(...items.map(i => i.val), 1);
return `<div class="stat-group"><h3>${title}</h3>` + items.map(i =>
`<div class="stat-row"><span>${i.label}</span>`
+ `<div class="stat-bar"><div style="width:${Math.round(i.val / max * 100)}%;background:${i.color}"></div></div>`
+ `<b>${i.disp}</b></div>`).join('') + '</div>';
};
// Zufriedenheit je Gruppe
const groups = Object.entries(s.stats.groups)
.filter(([, g]) => g.count > 0)
.map(([id, g]) => ({ label: `${GROUPS[id].emoji} ${GROUPS[id].name}`, val: g.avg,
disp: `${g.avg.toFixed(1)}★`, color: g.avg >= 3.5 ? '#3f8f5e' : g.avg >= 2.5 ? '#e8892b' : '#c0392b' }))
.sort((a, b) => b.val - a.val);
// Umsatz je Gebäudekategorie
const cats = {};
for (const [type, rev] of Object.entries(s.stats.buildingRevenue)) {
const c = BUILDINGS[type].cat;
cats[c] = (cats[c] || 0) + rev;
}
const catNames = { gastronomy: '🍽️ Gastronomie', shopping: '🛍️ Handel', nature: '🌿 Natur',
play: '🛝 Freizeit', service: '🛎️ Service', culture: '🏛️ Kultur', sport: '⛷️ Sport' };
const revItems = Object.entries(cats).map(([c, v]) => ({ label: catNames[c] || c, val: v,
disp: `${euro(v)}€`, color: '#4f7f5e' })).sort((a, b) => b.val - a.val);
// Anreise-Mix
const tr = s.stats.transports;
const trItems = [['foot', '🚶 zu Fuß'], ['bike', '🚴 Rad'], ['bus', '🚌 Bus'], ['car', '🚗 Auto']]
.map(([k, l]) => ({ label: l, val: tr[k], disp: `${tr[k]}`, color: k === 'car' ? '#c9531f' : '#2f6ea0' }))
.filter(i => i.val > 0);
$('statsBody').innerHTML =
`<div class="stat-group"><h3>Heatmap der Besucherströme</h3>`
+ `<canvas id="heatCanvas" class="heat-canvas"></canvas></div>`
+ rows('Zufriedenheit je Gruppe (Ø Sterne)', groups)
+ rows('Wertschöpfung je Kategorie', revItems)
+ rows('Anreise der Gäste', trItems)
+ `<div class="stat-group"><h3>Bilanz</h3>`
+ `<div class="info-row"><span>Umwelt</span><b>${Math.round(s.env)}</b></div>`
+ `<div class="info-row"><span>Verkehr</span><b>${s.traffic}</b></div>`
+ `<div class="info-row"><span>Busgruppen ohne Parkplatz</span><b>${s.stats.skippedNoParking}</b></div>`
+ `<div class="info-row"><span>Gäste gesamt</span><b>${s.stats.spawned}</b></div></div>`;
this._drawHeatmap();
$('statsOverlay').classList.remove('hidden');
telemetry.log('stats_open', {});
}
// Heatmap: Kartenbild verkleinert + Besucherstrom-Dichte als Farbflecken
_drawHeatmap() {
const s = this.state;
const cv = $('heatCanvas');
if (!cv) return;
const W = 420, H = Math.round(W * s.map.h / s.map.w);
const dpr = window.devicePixelRatio || 1;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = W + 'px'; cv.style.height = H + 'px';
const ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (!this._heatImg) { this._heatImg = new Image(); }
const paint = () => {
ctx.clearRect(0, 0, W, H);
if (this._heatImg.complete && this._heatImg.naturalWidth) {
ctx.globalAlpha = 0.55; ctx.drawImage(this._heatImg, 0, 0, W, H); ctx.globalAlpha = 1;
} else { ctx.fillStyle = '#cfe0cf'; ctx.fillRect(0, 0, W, H); }
const HC = s.heatCols, HR = s.heatRows;
const max = Math.max(...s.heat, 1);
const cw = W / HC, ch = H / HR;
ctx.globalCompositeOperation = 'source-over';
for (let r = 0; r < HR; r++) {
for (let c = 0; c < HC; c++) {
const v = s.heat[r * HC + c] / max;
if (v < 0.04) continue;
const x = (c + 0.5) * cw, y = (r + 0.5) * ch;
const rad = ch * 1.6;
const g = ctx.createRadialGradient(x, y, 0, x, y, rad);
const a = Math.min(0.8, v * 1.1);
// grün → gelb → rot je nach Dichte
const col = v > 0.66 ? '220,60,40' : v > 0.33 ? '232,150,40' : '90,170,90';
g.addColorStop(0, `rgba(${col},${a})`);
g.addColorStop(1, `rgba(${col},0)`);
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(x, y, rad, 0, 7); ctx.fill();
}
}
// Legende
ctx.font = '10px sans-serif'; ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(47,62,70,0.7)';
ctx.fillText('wenig', 6, H - 6);
ctx.textAlign = 'right';
ctx.fillText('viel Verkehr →', W - 6, H - 6);
};
if (this._heatImg.src.endsWith(s.map.image)) paint();
else { this._heatImg.onload = paint; this._heatImg.src = s.map.image; paint(); }
}
// ---------- Info-Panels ----------
showBuildingPanel(b) {
this.selectedBuilding = b;
this.selectedVisitor = null;
this.refreshInfoPanel();
$('infoPanel').classList.remove('hidden');
}
showVisitorPanel(v) {
this.selectedVisitor = v;
this.selectedBuilding = null;
this.refreshInfoPanel();
$('infoPanel').classList.remove('hidden');
}
hideInfoPanel() {
this.selectedBuilding = null;
this.selectedVisitor = null;
this.api.selectBuilding(null);
$('infoPanel').classList.add('hidden');
}
refreshInfoPanel() {
const s = this.state;
if (this.selectedBuilding) {
const b = this.selectedBuilding;
if (!s.buildings.includes(b)) { this.hideInfoPanel(); return; }
const def = BUILDINGS[b.type];
const lvl = b.level - 1;
const name = def.levelNames ? def.levelNames[lvl] : def.name;
const stars = '★'.repeat(b.level) + '☆'.repeat(def.maxLevel - b.level);
const goodFor = Object.entries(def.target)
.filter(([, f]) => f >= 1).map(([gid]) => GROUPS[gid]?.emoji).filter(Boolean).join(' ');
const rev = s.stats.buildingRevenue[b.type];
const busy = b.construction > 0 ? '🏗️ Baustelle …'
: b.upgrading ? '🏗️ Ausbau läuft …' : '';
$('infoTitle').textContent = `${def.emoji} ${name} ${stars}`;
$('infoBody').innerHTML = `
${busy ? `<div class="info-row"><span>Status</span><b class="warn">${busy}</b></div>` : ''}
<div class="info-row"><span>Wirkung</span><b>+${def.sat[lvl]} Zufriedenheit</b></div>
${def.slow[lvl] ? `<div class="info-row"><span>Verweilen</span><b>${Math.round(def.slow[lvl] * 100)} % Tempo</b></div>` : ''}
<div class="info-row"><span>Reichweite</span><b>${def.range[lvl]} Felder</b></div>
<div class="info-row"><span>Kapazität</span><b>${def.capacity[lvl] >= 999 ? 'unbegrenzt' : `${def.capacity[lvl]} Gäste`}</b></div>
${def.capacity[lvl] < 999 ? `<div class="info-row"><span>Auslastung</span><b class="${(b.inRange || 0) > def.capacity[lvl] ? 'warn' : ''}">${b.inRange || 0} / ${def.capacity[lvl]}${(b.inRange || 0) > def.capacity[lvl] ? ' voll!' : ''}</b></div>` : ''}
<div class="info-row"><span>Betriebskosten</span><b>${def.opCost[lvl]} € / min</b></div>
${goodFor ? `<div class="info-row"><span>Passt für</span><b>${goodFor}</b></div>` : ''}
${rev ? `<div class="info-row"><span>Umsatz bisher</span><b>${euro(rev)} €</b></div>` : ''}
${b.infoBoost > 1 ? `<div class="info-row"><span>Info-Bonus</span><b>+${Math.round((b.infoBoost - 1) * 100)} %</b></div>` : ''}
${b.synergy > 1 ? `<div class="info-row"><span>🔗 Synergie</span><b class="syn">+${Math.round((b.synergy - 1) * 100)} %</b></div>`
+ (b.synergyList || []).map(w => `<div class="syn-why">✓ ${w}</div>`).join('') : ''}`;
const up = $('btnUpgrade');
if (b.level < def.maxLevel && !busy) {
up.classList.remove('hidden');
up.textContent = `⬆️ Ausbauen ${euro(def.cost[b.level])} €`;
up.disabled = s.budget < def.cost[b.level];
} else {
up.classList.add('hidden');
}
$('btnDemolish').classList.toggle('hidden', b.type === 'naturschutz');
} else if (this.selectedVisitor) {
const v = this.selectedVisitor;
if (v.exited || !s.visitors.includes(v)) { this.hideInfoPanel(); return; }
const g = GROUPS[v.type];
const ratio = Math.max(0, v.satisfaction) / g.satTarget;
const stars = Math.max(1, Math.min(5, Math.ceil(ratio * 5)));
$('infoTitle').textContent = `${g.emoji} ${g.name}`;
$('infoBody').innerHTML = `
<div class="info-row"><span>Zufriedenheit</span><b>${Math.round(Math.max(0, v.satisfaction))} / ${g.satTarget}</b></div>
<div class="info-row"><span>Budget übrig</span><b>${euro(g.budget - v.spent)} €</b></div>
<div class="info-row"><span>Interessen</span><b>${g.likes}</b></div>
<div class="info-row"><span>Bewertung derzeit</span><b>${'⭐'.repeat(stars)}</b></div>`;
$('btnUpgrade').classList.add('hidden');
$('btnDemolish').classList.add('hidden');
}
}
// ---------- Zwei Verlaufs-Graphen in eigenen Feldern ----------
// Feste 020-min-Zeitachse, sichtbare Bodenlinie, alle Daten bleiben.
// Links: Budget (€). Rechts: Zufriedenheits-Erfüllung in % (Ziel 100 %,
// wie „zerstört" im Tower Defense) + Umweltwert auf derselben 0100-Skala.
drawGraph() {
const hist = this.state.history;
if (hist.budget.length < 2) return;
const bMax = Math.max(600, ...hist.budget);
const bMin = Math.min(0, ...hist.budget);
this._plotPanel(this.gMoney, {
title: '💰 Budget',
series: [{ data: hist.budget, color: 'rgb(79,127,94)', fill: true }],
vMin: bMin, vMax: bMax, unit: '€',
current: `${euro(this.state.budget)} €`,
goals: [],
t: hist.t,
});
this._plotPanel(this.gSat, {
title: '😊 Zufriedenheit · 🌲 Umwelt',
series: [
{ data: hist.satPct, color: 'rgb(232,137,43)', fill: true },
{ data: hist.env, color: 'rgb(47,110,160)', fill: false },
],
vMin: 0, vMax: 110, unit: '%',
current: `${hist.satPct[hist.satPct.length - 1]} % · ${Math.round(this.state.env)}`,
goals: [
{ v: 100, label: 'Ziel 100 %' },
{ v: 45, label: 'Umwelt-Ziel 45' },
],
t: hist.t,
});
}
_plotPanel(ctx, o) {
const cv = ctx.canvas;
const dpr = window.devicePixelRatio || 1;
const w = cv.clientWidth, h = cv.clientHeight;
if (cv.width !== w * dpr) { cv.width = w * dpr; cv.height = h * dpr; }
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const padL = 36, padR = 6, padT = 16, padB = 15;
const pw = w - padL - padR, ph = h - padT - padB;
const X = i => padL + (o.t[i] / 1200) * pw;
const Y = v => padT + ph - ((Math.max(o.vMin, Math.min(v, o.vMax)) - o.vMin) / (o.vMax - o.vMin || 1)) * ph;
// Titel + aktueller Wert
ctx.font = 'bold 10.5px sans-serif';
ctx.textAlign = 'left';
ctx.fillStyle = '#2f3e46';
ctx.fillText(o.title, 2, 10);
ctx.textAlign = 'right';
ctx.fillStyle = o.series[0].color;
ctx.fillText(o.current, w - 2, 10);
// Skala links (oben/unten)
ctx.font = '8.5px sans-serif';
ctx.fillStyle = 'rgba(47,62,70,0.6)';
ctx.textAlign = 'right';
ctx.fillText(`${o.vMax}${o.unit}`, padL - 3, padT + 7);
ctx.fillText(`${o.vMin}${o.unit}`, padL - 3, padT + ph);
// Ziellinien
for (const g of o.goals) {
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(47,62,70,0.35)';
ctx.beginPath(); ctx.moveTo(padL, Y(g.v)); ctx.lineTo(padL + pw, Y(g.v)); ctx.stroke();
ctx.setLineDash([]);
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(47,62,70,0.55)';
ctx.fillText(g.label, padL + 2, Y(g.v) - 2);
}
// Datenlinien (+ Füllung der ersten Serie)
for (const s of o.series) {
if (s.fill) {
ctx.beginPath();
ctx.moveTo(X(0), padT + ph);
s.data.forEach((v, i) => ctx.lineTo(X(i), Y(v)));
ctx.lineTo(X(s.data.length - 1), padT + ph);
ctx.closePath();
ctx.fillStyle = s.color.replace('rgb', 'rgba').replace(')', ',0.10)');
ctx.fill();
}
ctx.strokeStyle = s.color;
ctx.lineWidth = 1.8;
ctx.beginPath();
s.data.forEach((v, i) => (i ? ctx.lineTo(X(i), Y(v)) : ctx.moveTo(X(i), Y(v))));
ctx.stroke();
ctx.lineWidth = 1;
}
// BODEN: kräftige Grundlinie + Zeitachse
ctx.strokeStyle = 'rgba(47,62,70,0.45)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(padL, padT + ph);
ctx.lineTo(padL + pw, padT + ph);
ctx.stroke();
ctx.lineWidth = 1;
ctx.fillStyle = 'rgba(47,62,70,0.55)';
ctx.textAlign = 'center';
for (const min of [0, 5, 10, 15, 20]) {
const x = padL + (min / 20) * pw;
ctx.beginPath();
ctx.moveTo(x, padT + ph);
ctx.lineTo(x, padT + ph + 3);
ctx.stroke();
ctx.fillText(`${min}'`, x, h - 2);
}
}
// ---------- Karten ----------
showCard(card) {
// Tempo VOR der ersten Karte merken auch eine manuelle Pause (0)
// wird nach dem Schließen wiederhergestellt.
if (!this.cardOpen) this.speedBeforeCard = this.api.getSpeed();
this.cardQueue.push(card);
if (!this.cardOpen) this._nextCard();
}
_nextCard() {
const card = this.cardQueue.shift();
if (!card) return;
this.currentCard = card;
this.cardOpen = true;
this.api.setSpeed(0);
this.markSpeed(0);
$('cardIcon').textContent = card.icon || '️';
$('cardTitle').textContent = card.title || '';
$('cardText').textContent = card.text || '';
const list = $('cardList');
list.innerHTML = '';
list.classList.toggle('hidden', !card.items);
if (card.items) {
for (const f of card.items) {
const li = document.createElement('li');
li.innerHTML = `<span class="feed-stars">${'⭐'.repeat(f.stars)}</span>`
+ `<span class="feed-text">${f.emoji}${f.text}"</span>`;
list.appendChild(li);
}
$('cardText').textContent = ${card.avg.toFixed(1)} Sterne → Besucherfaktor `
+ `×${card.fame.toFixed(2)} für die nächste Phase.`;
}
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 if (!this.state.ended) {
this.api.setSpeed(this.speedBeforeCard);
this.markSpeed(this.speedBeforeCard);
}
}
_bindOverlays() {
$('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');
});
$('infoClose').addEventListener('click', () => { audio.click(); this.hideInfoPanel(); });
$('btnUpgrade').addEventListener('click', () => this.api.upgradeSelected());
$('btnDemolish').addEventListener('click', () => this.api.demolishSelected());
$('btnSave').addEventListener('click', () => this.api.save());
$('btnRestart').addEventListener('click', () => this.api.restart());
$('btnStats').addEventListener('click', () => { audio.click(); this.showStats(); });
$('statsClose').addEventListener('click', () => { audio.click(); $('statsOverlay').classList.add('hidden'); });
}
_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');
const first = (c.text || (c.items ? 'Bewertungen der Gäste' : '')).split('\n')[0];
li.innerHTML = `${c.icon || '️'} <b>${c.title}</b><small>${first}</small>`;
li.addEventListener('click', () => {
$('logOverlay').classList.add('hidden');
this.showCard({ ...c, transient: true, choices: null });
});
list.appendChild(li);
}
$('logOverlay').classList.remove('hidden');
telemetry.log('log_open', { count: this.collectedCards.length });
}
// ---------- Endauswertung ----------
showEnd() {
const s = this.state;
const r = s.result;
$('endProfileIcon').textContent = r.profile.icon;
$('endProfileName').textContent = r.profile.name;
$('endProfileText').textContent = r.profile.text;
$('endStars').textContent = `${'⭐'.repeat(Math.round(r.avgStars))} Ø ${r.avgStars.toFixed(1)} Sterne · Gesamtscore ${r.score}/100`;
const bars = [
['Bewertung (35 %)', r.scores.rating, '#e8892b'],
['Wertschöpfung (25 %)', r.scores.revenue, '#4f7f5e'],
['Nachhaltigkeit (20 %)', r.scores.sustain, '#2f6ea0'],
['Besucherlenkung (10 %)', r.scores.lenkung, '#a34a8e'],
['Finanzstabilität (10 %)', r.scores.finance, '#8c6239'],
];
$('endBars').innerHTML = bars.map(([label, v, color]) => `
<div class="end-bar-row">
<span>${label}</span>
<div class="end-bar"><div style="width:${Math.round(v)}%;background:${color}"></div></div>
<b>${Math.round(v)}</b>
</div>`).join('');
$('endGoals').innerHTML = r.goals.map(g =>
`<li class="${g.done ? 'done' : ''}">${g.done ? '✓' : '✗'} ${g.label}</li>`).join('');
$('endFacts').textContent = `${r.visitors} Besuchergruppen · ${r.rated} Bewertungen · `
+ `${euro(r.revenue)} € Wertschöpfung · Umwelt ${r.env} · Budget ${euro(r.budget)} €`;
$('endFeedback').innerHTML = r.feedback.map(l => `<p>${l}</p>`).join('');
$('endReflect').innerHTML = '<h3>🧠 Denk mal nach …</h3><ul>'
+ this._reflectionQuestions(s).map(q => `<li>${q}</li>`).join('') + '</ul>';
$('endOverlay').classList.remove('hidden');
audio.win();
telemetry.log('game_end', {
score: r.score, stars: r.avgStars, revenue: r.revenue,
env: r.env, budget: r.budget, profile: r.profile.id,
});
}
// Reflexionsfragen (§24): teils an das Ergebnis angepasst, für die
// Nachbesprechung im Unterricht (offene Fragen, keine „richtige" Antwort).
_reflectionQuestions(s) {
const q = [];
const st = s.stats;
// stärkste/schwächste Gruppe
let best = null, worst = null;
for (const [id, g] of Object.entries(st.groups)) {
if (g.count < 3) continue;
if (!best || g.avg > best.avg) best = { id, ...g };
if (!worst || g.avg < worst.avg) worst = { id, ...g };
}
if (worst && worst.avg < 3.2) {
q.push(`${GROUPS[worst.id].name} waren am unzufriedensten. Welche Infrastruktur `
+ `hätte ihnen geholfen und warum hast du sie nicht (rechtzeitig) gebaut?`);
}
if (s.env < 50) {
q.push('Der Umweltwert ist gesunken. Welche Entscheidungen haben der Natur '
+ 'geschadet? Wie ließe sich Tourismus nachhaltiger gestalten?');
} else {
q.push('Du hast die Umwelt gut geschützt. Wo musstest du dafür auf Einnahmen '
+ 'verzichten und war das die richtige Abwägung?');
}
if (s.traffic > 25 || st.skippedNoParking > 0) {
q.push('Verkehr und Erreichbarkeit waren ein Thema. Wie verändern Parkplatz, '
+ 'Bushaltestelle und Radweg, wer überhaupt zu Gast kommt und mit welchen Folgen?');
}
q.push('Warum lohnt sich dieselbe Infrastruktur an einem Standort mehr als an '
+ 'einem anderen? Nenne ein Beispiel aus deiner Partie.');
q.push('Wie hat die Saison (Frühling → Winter) verändert, was sich gelohnt hat?');
if (best) {
q.push(`Deine Region wurde zur „${s.result.profile.name}". Wolltest du das `
+ 'oder ist es „passiert"? Was würdest du beim nächsten Mal anders planen?');
}
return q.slice(0, 5);
}
// ---------- Onboarding ----------
showOnboarding() {
this.showCard({
icon: '🗺️',
title: 'So entwickelst du deine Tourismusregion',
transient: true,
text: '1. Besuchergruppen ziehen über die Wege durch die Region jede hat '
+ 'eigene Interessen (antippen zeigt sie!).\n\n'
+ '2. Wähle rechts ein Gebäude und baue es NAH AN DEN WEGEN. Der Kreis '
+ 'zeigt, wen es erreicht. Antippen: Info + Ausbau.\n\n'
+ '3. Zufriedene Gäste geben Geld aus und bewerten die Region gute '
+ 'Bewertungen bringen in der nächsten Welle mehr Gäste.\n\n'
+ '4. Achte auf Saison, Umweltwert und Budget: Betriebskosten laufen '
+ 'immer weiter!\n\n'
+ '5. Gäste kommen in 8 Wellen. Zwischen den Wellen ist Baupause '
+ 'baue und rüste dann auf (Bauen kostet Zeit!). „Nächste Welle rufen" '
+ 'startet früher, mit Bonus. Karte bewegen: '
+ 'ziehen · Zoom: zwei Finger / Mausrad · ⏸ pausiert.',
});
telemetry.log('onboarding_open');
}
toast(msg) {
const t = $('toast');
t.textContent = msg;
t.classList.remove('hidden');
clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => t.classList.add('hidden'), 3000);
}
}