Files
geograsim/App/sims/tourismustal/js/main.js
T
Adminator 88e3c09e5c Per-Sim leistung_pct: alle 14 Sims senden das eine Leistungs-% (0-100)
Jede Sim liefert jetzt selbst leistung_pct in GGS_LIVE_STATE UND im Submit
(Fundament fuer das einheitliche %-System) — kein Rueckrechnen aus Sternen
mehr, keine 0-3-Sonderfaelle. Formeln je Sim wie vereinbart:
- klima/fluss/kofferdetektiv/heli: = vorhandener 0-100-score
- weltkueche: richtige/(richtige+falsche)*100 (Fehler in %)
- busfahrt/fluggesellschaft: topoPunkte/(Auftraege*3)*100
- farmer: gewichteter totalScore; energiemanager: ausgegl./gesamt -15/Blackout
- eu-werkstatt: firstTry/Schritte -5/Muelleimer; sonnensystem: Erstversuch-Quote
- tourismustal: Ziele+Zufriedenheit+Natur gewichtet; tourismusregion: gewichteter score
- logistik: Kontostand->% (sendet erstmals eine Kennzahl, auch an /api/assessment)
Teacher _leistungPct + live.php extractScore bevorzugen bereits diesen Wert.
Additive Aenderungen, bestehende Logik/Sterne unveraendert; alle 15 Dateien
syntaktisch geprueft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 12:35:56 +02:00

520 lines
21 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.
// Einstiegspunkt: Szenenwahl → Spielstart. Verdrahtet Modell, Renderer,
// Audio und UI und treibt die laufende Zeit an (einstellbare Zeitskala).
import { createState, simulateDay, canPlace, place, demolish, upgrade, ecoUpgrade, buildingReport } from './model.js';
import { BUILDINGS, EVENTS, MILESTONES, DECISIONS, HINTS, ADVISOR, BUILD_INFO, KOSTEN_CHECKS } from './data.js';
import { IsoRenderer } from './iso.js';
import { UI } from './ui.js';
import { telemetry } from './telemetry.js';
import { audio } from './audio.js';
import { player } from './player.js';
window.__ttLoaded = true; // Signal an den file://-Fallback in index.html
const MS_PER_DAY = 4000; // 1 Tag = 4 s bei 1× ruhiges Tempo (halbiert 2026-07-11)
// Ereignis-Pacing: Schonfrist zum Ankommen + Mindestabstand zwischen
// Ereignis-/Entscheidungskarten, damit am Anfang nicht mehrere Karten im
// Sekundentakt aufploppen. Dringende Warnungen umgehen die Sperre.
const EVENT_GRACE_DAYS = 20; // erste ~2/3 Saison ohne Zufalls-Karten
const EVENT_COOLDOWN = 18; // Mindestabstand in Spieltagen
const URGENT_EVENTS = new Set(['low-money', 'broke', 'first-winter', 'no-snow-winter']);
let speed = 0;
let acc = 0;
let last = performance.now();
let state = null;
let ui = null;
let renderer = null;
// ---------- Szenenwahl & Spielstart ----------
document.querySelectorAll('.scene-btn:not(.locked)').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('startOverlay').classList.add('hidden');
startGame(btn.dataset.scene);
});
});
function startGame(sceneId) {
state = createState(sceneId);
const nameInput = document.getElementById('valleyName');
if (nameInput?.value.trim()) state.valleyName = nameInput.value.trim().slice(0, 24);
document.getElementById('valleyTitle').textContent = state.valleyName;
document.title = `${state.valleyName} GeoGraSim`;
window.__ttState = state; // für Playwright-Tests lesbar
window.__ttRenderer = () => renderer;
telemetry.init(() => state?.day);
telemetry.log('scene_start', { scene: sceneId, valley: state.valleyName });
ui = new UI(state, {
setSpeed: v => { speed = v; },
getSpeed: () => speed,
setBuildType: t => renderer.setBuildType(t),
});
// Plattform-Standard: Onboarding jederzeit über den -Header-Button abrufbar
window.GGS_TUTORIAL = {
simId: 'tourismustal',
title: 'Willkommen im Tourismustal',
replay: () => ui.showOnboarding(),
};
state._startedAt = Date.now();
renderer = new IsoRenderer(document.getElementById('map'), state, { onTileTap });
audio.ensure();
audio.click();
audio.startMusic(); // gelegentliche Naturlaute
player.init(); // Hintergrundmusik aus .humaninput/songs/
player.play();
ui.syncPlayerUI();
speed = 1;
advanceDay(); // Tag 1 sofort: Kennzahlen sind da
ui.updateClock();
ui.showCard({ ...state.scenario.intro, sound: 'card' });
if (!localStorage.getItem('tourismustal_seen')) {
localStorage.setItem('tourismustal_seen', '1');
ui.showOnboarding();
}
requestAnimationFrame(frame);
}
// ---------- Bauen / Abreißen ----------
// Klick auf ein bestehendes Gebäude (ohne aktives Bau-Werkzeug) → Report
function inspectBuilding(c, r) {
const report = buildingReport(state, c, r);
if (!report) return;
const cur = speed;
if (cur > 0) ui.speedBeforeCard = cur;
ui.showBuildingCard(report, {
money: state.money,
onUpgrade: (rep) => {
const res = upgrade(state, c, r);
if (!res.ok) { ui.toast(`❌ ${res.reason}`); audio.error(); return; }
const roman = ['', 'I', 'II', 'III', 'IV'][res.level];
audio.build();
renderer.addFloat('🏗️ Umbau …', '#e8892b', c + 0.5, r - 0.5, true);
ui.updateKPIs();
ui.toast(rep.type === 'staffhouse'
? `🤝 ${res.name} wird auf Stufe ${roman} ausgebaut (${res.cost.toLocaleString('de-AT')} €) dein Personal wohnt besser, der Service wird freundlicher!`
: `🏗️ ${res.name} wird auf Stufe ${roman} umgebaut (${res.cost.toLocaleString('de-AT')} €) schau zu!`, 6000);
telemetry.log('upgrade', { c, r, cost: res.cost, level: res.level });
ui._closeBuilding(); // Karte schließen → man sieht die Baustelle
},
onEco: () => {
const res = ecoUpgrade(state, c, r);
if (!res.ok) { ui.toast(`❌ ${res.reason}`); audio.error(); return; }
audio.build();
renderer.addFloat('🌱 Öko-Umbau!', '#3f8f5e', c + 0.5, r - 0.5, true);
ui.updateKPIs();
ui.toast(`🌱 ${res.name} läuft jetzt als Öko-Betrieb (${res.cost.toLocaleString('de-AT')} €): 20 % Betriebskosten, +2 Naturwert.`, 6000);
telemetry.log('eco_upgrade', { c, r, cost: res.cost });
inspectBuilding(c, r); // Karte mit neuem Status
},
onDemolish: () => {
const res = demolish(state, c, r);
if (!res.ok) { ui.toast(`❌ ${res.reason}`); audio.error(); return; }
ui.toast(`🧨 ${res.name} abgerissen ${res.refund.toLocaleString('de-AT')} € zurück.`);
audio.demolish();
ui.updateKPIs();
telemetry.log('demolish', { c, r, refund: res.refund });
},
});
}
function onTileTap(c, r, pointerType) {
// Ohne Bau-Werkzeug: Gebäude anklicken öffnet den Report
if (!ui.activeBuild) {
if (state.map[r]?.[c]?.building) inspectBuilding(c, r);
return;
}
const type = ui.activeBuild;
// Touch (iPad): erster Tipp heftet die VORSCHAU an die Kachel (grün/rot,
// Pisten-/Strecken-Korridor, Einflusskreis) erst der zweite Tipp auf
// DIESELBE Kachel baut. Mit Maus bleibt der Sofort-Bau (Hover zeigt ja).
if (pointerType !== 'mouse') {
const p = renderer.pendingTile;
if (!p || p.c !== c || p.r !== r) {
renderer.pendingTile = { c, r };
if (!state.firedEvents.has('touch-build-hint')) {
state.firedEvents.add('touch-build-hint');
ui.toast('👆 Vorschau gesetzt tippe dieselbe Stelle nochmal an, um zu bauen.', 5000);
}
return;
}
renderer.pendingTile = null;
}
const check = canPlace(state, type, c, r);
if (!check.ok) {
// Ablehnung erklärt IMMER auch die Bauregeln des Gebäudes
ui.toast(`❌ ${check.reason} 💡 ${BUILDINGS[type].name}: ${BUILDINGS[type].desc}`, 7000);
audio.error();
telemetry.log('build_rejected', { type, c, r, reason: check.reason });
return;
}
const res = place(state, type, c, r);
if (res.planted) {
ui.toast('🌲 Wald gepflanzt: +3 Naturwert. Weiter tippen zum Aufforsten!', 4000);
audio.build();
ui.updateKPIs();
telemetry.log('forest_planted', { c, r, moneyLeft: state.money });
return; // Werkzeug bleibt aktiv Aufforsten in Serie
}
ui.toast(`✅ ${BUILDINGS[type].name} gebaut.`);
ui.clearBuildSelection(); // Baumodus verlassen → sicher navigieren
audio.build();
ui.updateKPIs();
telemetry.log('build_placed', { type, c, r, moneyLeft: state.money });
// 4 gleiche im Quadrat → Megabauwerk (Skaleneffekt-Lernmoment)
if (res.merged) {
audio.milestone?.();
renderer.addFloat('🏛️ Megabauwerk!', '#e8892b', res.merged.c + 1, res.merged.r - 0.5, true);
const first = !state.firedEvents.has('mega-info');
state.firedEvents.add('mega-info');
ui.showCard({
id: `mega-${res.merged.c}-${res.merged.r}`, audio: 'mega', icon: '🏛️', sound: 'milestone',
title: `Megabauwerk: ${res.merged.name}!`,
text: `Vier gleiche Gebäude im Quadrat sind zu EINEM großen ${res.merged.name} verschmolzen.`
+ '\n\nDas ist ein Skaleneffekt: Das Megabauwerk leistet so viel wie '
+ 'alle vier zusammen braucht aber nur die Betriebskosten von DREI. '
+ 'Große Betriebe arbeiten pro Gast günstiger als kleine.'
+ (first ? '\n\nTipp: Das klappt mit vielen Gebäudetypen probiere es aus!' : ''),
});
telemetry.log('mega_merge', { type, c: res.merged.c, r: res.merged.r });
}
// Lernprogramm: beim ERSTEN Bau jedes Typs erklärt eine Karte die Wirkung
const infoId = `bi-${type}`;
if (BUILD_INFO[type] && !state.firedEvents.has(infoId)) {
state.firedEvents.add(infoId);
ui.showCard({ id: infoId, ...BUILD_INFO[type], sound: 'card' });
}
// 💶 Kosten-Check vormerken: Realitäts-Quiz zum gerade gebauten Typ
// (erscheint später mit Abstand übers Event-Pacing, nicht sofort)
const kc = KOSTEN_CHECKS.find(k => k.trigger === type);
if (kc && !state.firedEvents.has(kc.id)) {
state.firedEvents.add(kc.id);
(state.kcQueue ??= []).push(kc.id);
}
}
// ===== Live-View für Lehrkräfte (Plattform-Hook) =====
// Wird vom live-client.js alle ~4 s an /api/live gesendet. Klein halten,
// stabile Schlüssel, didaktisch sinnvoll (Cockpit-Spalten).
window.GGS_LIVE_STATE = function () {
if (!state) return null;
const _goalsDone = state.scenario.goals.filter(g => g.check(state)).length;
const _goalsTotal = state.scenario.goals.length;
const _goalPct = _goalsTotal > 0 ? (_goalsDone / _goalsTotal) * 100 : 0;
const _leistung_pct = Math.max(0, Math.min(100, Math.round(
0.5 * _goalPct + 0.3 * (state.satisfaction || 0) + 0.2 * (state.nature || 0)
)));
return {
scene: state.scenario.id,
scenarioId: state.scenario.id, // Plattform-Konvention für Lektions-Gruppierung
day: state.day,
year: state.year,
season: state.season,
money: state.money,
guestsToday: state.guestsToday,
maxGuests: state.stats.maxGuests,
maxSummerGuests: state.stats.maxSummerGuests,
satisfaction: state.satisfaction,
nature: Math.round(state.nature),
snow: Math.round(state.snow),
price: state.price,
buildings: state.buildings.length,
staffGap: Math.max(0, state.staffNeeded - state.staffAvailable),
lossStreak: state.lossStreak,
goalsDone: state.scenario.goals.filter(g => g.check(state)).length,
goalsTotal: state.scenario.goals.length,
questsDone: state.questIndex,
bottleneck: state.bottleneck,
leistung_pct: _leistung_pct,
};
};
// ===== Submit-Hook (Plattform) =====
// Schreibt bei Zielerreichung (Sieg) und bei der Zwischenbilanz (Deadline)
// einen Eintrag in `assessments`, damit das Lehrer-Cockpit Ergebnisse sieht.
function submitAssessment(completed) {
if (!window.TT_SESSION_ID) return; // nur mit Schüler-Session
const api = (window.TT_API_BASE || '../../php/api') + '/progress.php';
const goals = state.scenario.goals.filter(g => g.check(state)).length;
const total = state.scenario.goals.length;
const stars = Math.min(5, goals + (completed ? 1 : 0));
try {
fetch(api, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
sim_id: 'tourismustal',
action: 'submit_assessment',
data: {
level: state.scenario.id,
stars,
score: Math.round((goals / total) * 100),
leistung_pct: Math.max(0, Math.min(100, Math.round(
0.5 * (total > 0 ? (goals / total) * 100 : 0)
+ 0.3 * (state.satisfaction || 0) + 0.2 * (state.nature || 0)
))),
duration_ms: Date.now() - (state._startedAt || Date.now()),
completed,
results: {
scene: state.scenario.id,
day: state.day,
year: state.year,
money: state.money,
maxGuests: state.stats.maxGuests,
maxSummerGuests: state.stats.maxSummerGuests,
satisfaction: state.satisfaction,
nature: Math.round(state.nature),
goalsDone: goals,
goalsTotal: total,
questsDone: state.questIndex,
questsTotal: state.scenario.quests.length,
buildings: state.buildings.length,
valleyName: state.valleyName,
},
},
}),
}).catch(() => {});
} catch { /* offline/Standalone: still ok */ }
}
// 💶 Kosten-Check auflösen: richtig = Bonus-Budget, falsch = nur Lerninfo
function resolveKostenCheck(kc, picked) {
const richtig = picked === kc.richtig;
if (richtig) {
state.money += kc.bonus;
const b = state.buildings[0];
if (b) renderer.addFloat(`💶 +${kc.bonus.toLocaleString('de-AT')} €`, '#3f8f5e', b.c + 0.5, b.r - 0.5, true);
}
ui.showCard({
id: kc.id + '-aufloesung', transient: true, forceModal: true,
icon: richtig ? '✅' : '📚',
sound: richtig ? 'success' : 'card',
title: richtig
? `Richtig! Gut kalkuliert: +${kc.bonus.toLocaleString('de-AT')} € Bonus`
: 'Nicht ganz aber jetzt weißt du es!',
text: kc.aufloesung + (richtig
? '\n\n🏦 Die Bank ist beeindruckt von deinem Realitätswissen und gewährt bessere Konditionen.'
: ''),
});
ui.updateKPIs();
telemetry.log('kosten_check_result', { id: kc.id, richtig });
}
// ---------- Karten: Meilensteine, Sieg, Entscheidungen, Events ----------
function checkEvents() {
// Meilensteine: Gebäude Schritt für Schritt freischalten (Szene 1)
for (const m of MILESTONES) {
if (state.firedEvents.has(m.id)) continue;
const unlocks = Array.isArray(m.unlock) ? m.unlock : [m.unlock];
if (unlocks.every(u => state.unlocked.has(u))) { state.firedEvents.add(m.id); continue; }
if (m.when(state)) {
state.firedEvents.add(m.id);
unlocks.forEach(u => state.unlocked.add(u));
m.apply?.(state);
ui.rebuildMenu();
ui.applyProgress();
ui.showCard({ id: m.id, icon: m.icon, title: m.title, text: m.text, sound: 'milestone' });
state.lastInterruptDay = state.day; // nach großer Karte erst wieder Ruhe
telemetry.log('milestone', { id: m.id, unlock: m.unlock });
return; // eine Karte pro Tag reicht
}
}
if (state.goalsDone && !state.firedEvents.has('win')) {
state.firedEvents.add('win');
ui.showCard({
id: 'win', icon: '🏆', title: 'Alle Ziele erreicht!', sound: 'win', important: true,
text: `Großartig! Nach ${state.day} Tagen hast du alle Ziele dieser Szene `
+ `geschafft mit ${state.stats.maxGuests} Gästen am besten Tag, `
+ `${state.money.toLocaleString('de-AT')} € am Konto und einem Naturwert von `
+ `${Math.round(state.nature)} %.\n\nDu kannst weiterspielen und dein Tal ausbauen.`,
});
telemetry.log('goals_done', { day: state.day, money: state.money });
submitAssessment(true);
return;
}
if (state.deadlineMissed && !state.firedEvents.has('deadline')) {
state.firedEvents.add('deadline');
const done = state.scenario.goals.filter(g => g.check(state)).length;
ui.showCard({
id: 'deadline', icon: '⏰', title: 'Zwischenbilanz', important: true,
text: `Die Zeit dieser Szene ist um. Du hast ${done} von `
+ `${state.scenario.goals.length} Zielen erreicht.\n\n`
+ `Kein Problem du kannst weiterspielen und die restlichen Ziele noch schaffen!`,
});
telemetry.log('deadline', { goalsDone: done });
submitAssessment(false);
return;
}
// Pacing-Fenster: erst nach Schonfrist und mit Mindestabstand zur letzten Karte
const calmEnough = state.day >= EVENT_GRACE_DAYS
&& (state.day - state.lastInterruptDay) >= EVENT_COOLDOWN;
// 💶 Kosten-Check: Realitäts-Quiz (Info + Schätzfrage, Bonus bei Treffer).
// Läuft im selben Ruhe-Fenster wie Entscheidungen nie im Karten-Stakkato.
if (calmEnough && state.kcQueue?.length) {
const kc = KOSTEN_CHECKS.find(k => k.id === state.kcQueue[0]);
state.kcQueue.shift();
if (kc) {
state.lastInterruptDay = state.day;
const first = !state.firedEvents.has('kc-modellhinweis');
state.firedEvents.add('kc-modellhinweis');
const vorspann = first
? 'Übrigens: Die Preise in dieser Simulation sind stark verkleinert und '
+ 'vereinfacht, damit du schnell entscheiden kannst. Die VERHÄLTNISSE '
+ 'stimmen die echten Zahlen sind viel größer. Zeig, dass du sie kennst:\n\n'
: '';
ui.showCard({
id: kc.id, icon: '💶', title: kc.title,
text: vorspann + kc.info + '\n\n❓ ' + kc.frage,
choices: [
{ label: kc.optionen[0], onPick: () => resolveKostenCheck(kc, 0) },
{ label: kc.optionen[1], onPick: () => resolveKostenCheck(kc, 1) },
],
});
telemetry.log('kosten_check', { id: kc.id });
return;
}
}
// Entscheidungs-Karten: A/B-Wahl mit echten Folgen (je einmal, zeitversetzt)
if (calmEnough) for (const d of DECISIONS) {
if (state.firedEvents.has(d.id)) continue;
if (d.when(state) && Math.random() < 0.3) {
state.firedEvents.add(d.id);
state.lastInterruptDay = state.day;
const yesOk = !d.yes.can || d.yes.can(state);
ui.showCard({
id: d.id, icon: d.icon, title: d.title, text: d.text,
choices: [
{
label: d.yes.label, disabled: !yesOk,
onPick: () => {
d.yes.apply(state);
ui.toast(d.yes.toast);
ui.updateKPIs();
telemetry.log('decision', { id: d.id, choice: 'yes' });
},
},
{
label: d.no.label,
onPick: () => {
d.no.apply(state);
ui.toast(d.no.toast);
ui.updateKPIs();
telemetry.log('decision', { id: d.id, choice: 'no' });
},
},
],
});
return;
}
}
for (const ev of EVENTS) {
if (state.firedEvents.has(ev.id)) continue;
if (ev.scenes && !ev.scenes.includes(state.scenario.id)) continue;
const urgent = URGENT_EVENTS.has(ev.id);
if (!urgent && !calmEnough) continue; // nur dringende Events umgehen die Sperre
if (ev.when(state)) {
state.firedEvents.add(ev.id);
if (!urgent) state.lastInterruptDay = state.day;
ui.showCard(ev);
break; // max. eine neue Karte pro Tag
}
}
}
// Mini-Aufträge: immer genau einer aktiv, Belohnung bei Erfüllung
function checkQuest() {
const quests = state.scenario.quests;
const quest = quests[state.questIndex];
if (!quest) { ui.updateQuest(null); return; }
ui.updateQuest(quest);
if (quest.check(state)) {
state.money += quest.reward;
state.questIndex += 1;
state.lastInterruptDay = state.day; // Auftrags-Karte zählt als Unterbrechung
const next = quests[state.questIndex];
const b = state.buildings[0];
if (b) renderer.addFloat(`+${quest.reward.toLocaleString('de-AT')} €`, '#e8892b', b.c + 0.5, b.r - 0.5);
ui.showCard({
id: quest.id, icon: quest.icon, sound: 'success',
title: `Auftrag erfüllt: ${quest.title}!`,
text: `${quest.desc} geschafft!\n\nBelohnung: ${quest.reward.toLocaleString('de-AT')} € `
+ `${next ? `\n\nNeuer Auftrag: ${next.icon} ${next.title} ${next.desc}` : '\n\nDas war der letzte Auftrag stark!'}`,
});
telemetry.log('quest_done', { id: quest.id, day: state.day });
ui.updateQuest(next || null);
}
}
// ---------- Zeit ----------
function advanceDay() {
simulateDay(state);
// Bank-Zwangsverkauf (Überschuldung): als Karte erklären, was passiert ist
if (state.forcedSale && state.forcedSale.day === state.day) {
const fs = state.forcedSale;
state.forcedSale = null;
ui.showCard({
id: `forced-${state.day}`, icon: '🏦', title: 'Die Bank greift ein!', important: true,
text: `Deine Schulden sind über 15.000 € gestiegen. Die Bank hat `
+ `„${fs.name}" zwangsverkauft nur ${fs.refund.toLocaleString('de-AT')} € Erlös.\n\n`
+ 'Wer baut, muss die LAUFENDEN Kosten mitplanen: Instandhaltung und '
+ 'Löhne fallen jeden Tag an, auch ohne Gäste. Baue erst wieder, wenn '
+ 'die Einnahmen stimmen.',
sound: 'card',
});
telemetry.log('forced_sale', { name: fs.name, day: state.day });
}
ui.updateKPIs();
ui.updateClock();
ui.drawGraph();
// Tagesbilanz als aufsteigende Zahl über dem ersten Betrieb
if (state.buildings.length && state.lastNet !== 0) {
const b = state.buildings[0];
const txt = `${state.lastNet > 0 ? '+' : ''}${state.lastNet.toLocaleString('de-AT')} €`;
renderer.addFloat(txt, state.lastNet > 0 ? '#3f8f5e' : '#c0392b', b.c + 0.5, b.r);
}
audio.setSeason(state.season);
// Berater: erklärt wiederkehrend, WARUM es gerade schlecht läuft
for (const adv of ADVISOR) {
if (!adv.when(state)) continue;
if (state.day - (state.advisorLast[adv.id] ?? -999) < adv.cooldown) continue;
state.advisorLast[adv.id] = state.day;
ui.toast(adv.text, 6500);
telemetry.log('advisor', { id: adv.id });
break;
}
for (const h of HINTS) {
if (state.firedEvents.has(h.id)) continue;
if (h.scenes && !h.scenes.includes(state.scenario.id)) continue;
if (h.when(state)) {
state.firedEvents.add(h.id);
ui.toast(h.text, 5000);
telemetry.log('hint', { id: h.id });
break;
}
}
checkQuest();
checkEvents();
}
function frame(now) {
const dt = now - last;
last = now;
acc += dt * speed;
while (acc >= MS_PER_DAY) {
acc -= MS_PER_DAY;
advanceDay();
}
renderer.draw(now, dt, speed);
requestAnimationFrame(frame);
}