Files
geograsim/App/sims/tourismustal/js/main.js
T
Adminator 16c562ed1d Tourismustal: iPad-Test-Feedback Runde 1
- Bikepark: echtes 2x2-Footprint (canPlace/place/demolish/upgrade/Report
  ankerbasiert, buildingRef auf Teilflaechen), Sprite ohne Schatten neu
  generiert, zentriert gross gezeichnet. Baukriterium gelockert: Bahn-Naehe
  ersetzt die Zonen-Regel (wie Hotel im Bahn-Umkreis).
- Zeit halbiert: 1 Tag = 4 s bei 1x (Absturz-Rate entsprechend angepasst).
- Problem-Sprechblasen alle ~3 min am Gebaeude (Personal gesucht / kein
  Schnee), erster Hinweis nach 15-35 s.
- Heli: sichtbares Abheben/Landen am Pad (aus vorigem Commit) bleibt.
- Test-Harnesse (model-test, strategien) wandern in App/sims/tourismustal/tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:20:06 +02:00

400 lines
15 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, buildingReport } from './model.js';
import { BUILDINGS, EVENTS, MILESTONES, DECISIONS, HINTS, ADVISOR, BUILD_INFO } 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(`🏗️ ${res.name} wird auf Stufe ${roman} umgebaut (${res.cost.toLocaleString('de-AT')} €) schau zu!`);
telemetry.log('upgrade', { c, r, cost: res.cost, level: res.level });
ui._closeBuilding(); // Karte schließen → man sieht die Baustelle
},
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) {
// 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;
const check = canPlace(state, type, c, r);
if (!check.ok) {
ui.toast(`❌ ${check.reason}`);
audio.error();
telemetry.log('build_rejected', { type, c, r, reason: check.reason });
return;
}
place(state, type, c, r);
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 });
// 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' });
}
}
// ===== 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;
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,
};
};
// ===== 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),
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 */ }
}
// ---------- 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',
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',
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;
// 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!',
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);
}