// 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) { // 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); 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' }); } } // ===== 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', 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; // 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); }