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>
This commit is contained in:
@@ -0,0 +1,715 @@
|
||||
// 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 0–20-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 0–100-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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user