// Einstiegspunkt: Start-Overlay → startGame(). Verdrahtet Modell, Renderer, // Audio, Telemetrie und UI und treibt die Echtzeit-Partie an (Pause/1×; // ?speed=4 nur zum Testen). Karten aus dem Modell werden hier angezeigt, // Bauen/Auswählen läuft über Tile-Taps. import { createState, tick, place, upgrade, demolish, canPlace, callNextWave } from './model.js'; import { BUILDINGS, GROUPS, GOALS, currentAvg } from './data.js'; import { SceneRenderer } from './scene.js'; import { MAPS, MAP_ORDER, applyMapOverrides } from './maps.js'; import { UI } from './ui.js'; import { telemetry } from './telemetry.js'; import { audio } from './audio.js'; import { music } from './music.js'; window.__ttLoaded = true; // Signal an den file://-Fallback in index.html const params = new URLSearchParams(location.search); const TEST_SPEED = Math.max(1, Math.min(16, Number(params.get('speed')) || 1)); let state = null; let ui = null; let renderer = null; let speed = 0; // 0 = Pause, 1 = läuft let last = performance.now(); let uiAccum = 0; let endShown = false; // ---------- Start: Kartenwahl + Partie ---------- let chosenMap = 'talschleife'; let _starting = false; // Doppel-Start verhindern (rasche Doppelklicks) const mapList = document.getElementById('mapList'); for (const id of MAP_ORDER) { const mdef = MAPS[id]; const el = document.createElement('button'); el.className = 'map-btn'; el.dataset.map = id; el.innerHTML = `${mdef.icon} ${mdef.name}${mdef.desc}`; // Klick auf eine Wegkarte startet direkt (kein separater „Partie starten"-Button) el.addEventListener('click', async () => { if (_starting) return; _starting = true; chosenMap = id; el.classList.add('active'); await overridesReady; document.getElementById('startOverlay').classList.add('hidden'); startGame(); }); mapList.appendChild(el); } // Editor-Anpassungen (js/maps-custom.json) laden, bevor eine Partie startet const overridesReady = fetch('js/maps-custom.json', { cache: 'no-store' }) .then(r => (r.ok ? r.json() : null)) .then(o => applyMapOverrides(o)) .catch(() => {}); function startGame() { // Kinder sind über die Plattform-Session bereits identifiziert — kein Name/Code-Feld nötig. state = createState({ playerName: '', classCode: '', seed: 20260703, mapId: chosenMap }); window.__ttState = state; // für Playwright-Tests lesbar endShown = false; telemetry.init(() => Math.round(state?.time ?? 0)); telemetry.log('session_start', { player: '', classCode: '' }); ui = new UI(state, { setSpeed: v => { speed = v; }, getSpeed: () => speed, setBuildType: t => renderer.setBuildType(t), selectBuilding: b => { if (renderer) renderer.selected = b; }, upgradeSelected, demolishSelected, resetCamera: () => renderer.resetCamera(), nextWave: () => { const wasPause = state.waveStatus === 'pause'; const res = callNextWave(state); if (!res.ok) { ui.toast(`❌ ${res.reason}`); audio.error(); return; } ui.toast(wasPause ? `⏩ Baupause übersprungen – Bonus: +${res.bonus} €` : `⏩ Nächste Welle früh gerufen – Mut-Bonus: +${res.bonus} €`); audio.success(); ui.updateKPIs(); telemetry.log('next_wave', { bonus: res.bonus, phase: state.phase }); }, toggleReduceAnim: () => { renderer.reduceAnim = !renderer.reduceAnim; return renderer.reduceAnim; }, restart: () => location.reload(), save: saveResult, }); renderer = new SceneRenderer(document.getElementById('map'), state, { onTap }); window.__ttRenderer = renderer; // für Playwright-Tests (Welt → Screen) // Plattform-Standard: Onboarding jederzeit über ℹ abrufbar window.GGS_TUTORIAL = { simId: 'tourismusregion', title: 'Tourismusregion entwickeln', replay: () => ui.showOnboarding(), }; state._startedAt = Date.now(); audio.ensure(); audio.click(); audio.setSeason(state.season); music.setVolume(0.09); // Plattform-Standard: Musik leise music.play(Math.floor(Math.random() * 14)); // Hintergrundmusik ab Start speed = 1; ui.markSpeed(1); ui.updateKPIs(); ui.showCard({ icon: '🏞️', title: 'Willkommen in deiner Region!', sound: 'card', text: 'Du entwickelst eine kleine Tourismusregion: Dorf, ' + 'See, Berg – und drei Wege, auf denen Gäste ankommen.\n\n' + 'Ziel: zufriedene Gäste (Ø 3,5 ★), regionale Wertschöpfung (2500 €), ' + 'intakte Umwelt (≥ 45) und ein solides Budget.\n\n' + 'Gebaut wird auf den markierten Bauplätzen am Weg – starte mit einem Wirtshaus!', }); if (!localStorage.getItem('tourismusregion_seen')) { localStorage.setItem('tourismusregion_seen', '1'); ui.showOnboarding(); } requestAnimationFrame(frame); } // ---------- Interaktion auf der Karte ---------- function onTap(hit) { if (!state || state.ended) return; // Platzierungsmodus: Tap auf einen freien Slot if (ui.activeBuild && (hit.kind === 'slot' || hit.kind === 'building')) { const type = ui.activeBuild; const slotIdx = hit.slot.idx; const check = canPlace(state, type, slotIdx); if (!check.ok) { ui.toast(`❌ ${check.reason}`); audio.error(); telemetry.log('build_rejected', { type, slot: slotIdx, reason: check.reason }); return; } const res = place(state, type, slotIdx); const syn = res.building.synergyList || []; ui.toast(syn.length ? `✅ ${BUILDINGS[type].name} gebaut · 🔗 Synergie: ${syn[0]}!` : `✅ ${BUILDINGS[type].name} gebaut – Reichweite im Blick behalten!`); audio.build(); if (syn.length) { audio.success(); renderer.addFloat('🔗 Synergie!', '#7b3fa0', res.building.x, res.building.y - 20); } renderer.addFloat(`−${BUILDINGS[type].cost[0]} €`, '#c0392b', res.building.x, res.building.y); ui.updateKPIs(); ui.rebuildMenu(); telemetry.log('build_placed', { type, slot: slotIdx, budgetLeft: Math.round(state.budget) }); ui.clearBuildSelection(); ui.showBuildingPanel(res.building); renderer.selected = res.building; return; } if (ui.activeBuild && hit.kind !== 'none') return; if (hit.kind === 'building') { ui.showBuildingPanel(hit.building); renderer.selected = hit.building; audio.click(); telemetry.log('select_building', { type: hit.building.type }); } else if (hit.kind === 'visitor') { ui.showVisitorPanel(hit.visitor); renderer.selected = null; audio.click(); telemetry.log('select_visitor', { type: hit.visitor.type }); } else { ui.hideInfoPanel(); renderer.selected = null; } } function upgradeSelected() { const b = ui.selectedBuilding; if (!b) return; const res = upgrade(state, b); if (!res.ok) { ui.toast(`❌ ${res.reason}`); audio.error(); return; } const def = BUILDINGS[b.type]; ui.toast(`⬆️ ${def.levelNames ? def.levelNames[b.level - 1] : def.name} – Stufe ${b.level}!`); audio.build(); renderer.addFloat(`−${res.cost} €`, '#c0392b', b.x, b.y); ui.updateKPIs(); telemetry.log('upgrade', { type: b.type, level: b.level }); } function demolishSelected() { const b = ui.selectedBuilding; if (!b) return; const res = demolish(state, b); if (!res.ok) { ui.toast(`❌ ${res.reason}`); audio.error(); return; } ui.toast(`🧨 ${res.name} abgerissen – ${res.refund} € zurück.`); audio.demolish(); ui.hideInfoPanel(); renderer.selected = null; ui.updateKPIs(); telemetry.log('demolish', { type: res.name, refund: res.refund }); } // ---------- Karten aus dem Modell anzeigen ---------- function pumpCards() { while (state.pendingCards.length) { const card = state.pendingCards.shift(); switch (card.kind) { case 'decision': ui.showCard({ id: card.id, icon: card.icon, title: card.title, text: card.text, sound: 'card', choices: [ { label: card.yes.label, disabled: card.yes.can ? !card.yes.can(state) : false, onPick: () => { card.yes.apply(state); ui.updateKPIs(); telemetry.log('decision', { id: card.id, choice: 'yes' }); }, }, { label: card.no.label, onPick: () => { card.no.apply(state); ui.updateKPIs(); telemetry.log('decision', { id: card.id, choice: 'no' }); }, }, ], }); break; case 'banner': { const g = GROUPS[card.type]; const dir = { W: 'von Westen', N: 'über den Radweg', S: 'über den Fußweg' }[g.entry] || ''; ui.toast(`${g.emoji} Welle: ${card.n}× ${g.name} ${dir} im Anmarsch!`); audio.chime(); break; } case 'feed': ui.showCard({ ...card, sound: 'card' }); break; case 'built': // Baustelle fertig – nur dezenter Toast, keine Pause ui.toast(`✅ ${BUILDINGS[card.type].name} ist fertiggebaut und öffnet!`); audio.success(); break; case 'phase': ui.showCard({ ...card, sound: 'milestone' }); ui.rebuildMenu(); break; case 'season': ui.showCard({ ...card, sound: 'card' }); audio.setSeason(state.season); break; case 'end': endShown = true; submitAssessment(); // Plattform: Ergebnis ans Lehrer-Cockpit speed = 0; ui.markSpeed(0); ui.showEnd(); break; default: ui.showCard({ ...card, sound: card.sound || 'card' }); } } } // ===== Live-View für Lehrkräfte (Plattform-Hook) ===== // Wird vom live-client.js alle ~4 s an /api/live gesendet. window.GGS_LIVE_STATE = function () { if (!state) return null; return { scenarioId: state.map?.id, // Karten-Key = Lektions-Gruppierung mapId: state.map?.id, wave: state.phase, waveTotal: 8, waveStatus: state.waveStatus, season: state.season, budget: Math.round(state.budget), revenue: Math.round(state.revenueTotal), env: Math.round(state.env), avgStars: Math.round(currentAvg(state) * 100) / 100, visitors: state.stats?.spawned ?? 0, buildings: state.buildings.length, goalsDone: GOALS.filter(g => g.check(state)).length, goalsTotal: GOALS.length, ended: !!state.ended, }; }; // ===== Submit-Hook (Plattform) ===== // Schreibt am Partie-Ende einen Eintrag in `assessments` (Lehrer-Cockpit). let _submitted = false; function submitAssessment() { if (!window.TT_SESSION_ID || _submitted) return; const r = state?.result; if (!r) return; _submitted = true; const api = (window.TT_API_BASE || '../../php/api') + '/progress.php'; const goalsDone = r.goals.filter(g => g.done).length; try { fetch(api, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ sim_id: 'tourismusregion', action: 'submit_assessment', data: { level: state.map?.id, stars: Math.max(0, Math.min(5, Math.round(r.avgStars))), score: r.score, duration_ms: Date.now() - (state._startedAt || Date.now()), completed: goalsDone === r.goals.length, results: { mapId: state.map?.id, score: r.score, avgStars: Math.round(r.avgStars * 100) / 100, revenue: r.revenue, env: r.env, budget: r.budget, visitors: r.visitors, goalsDone, goalsTotal: r.goals.length, profile: r.profile?.id, scores: r.scores, }, }, }), }).catch(() => {}); } catch { /* Standalone ohne Plattform: ok */ } } // ---------- Ergebnis speichern (PHP/MySQL, Fallback localStorage) ---------- async function saveResult() { const r = state.result; if (!r) return; submitAssessment(); // Plattform-Submit (falls noch nicht geschehen) const payload = { player_name: state.playerName || 'anonym', class_code: state.classCode || '', final_score: r.score, avg_rating: Number(r.avgStars.toFixed(2)), revenue: r.revenue, environment_score: r.env, region_profile: r.profile.id, map_id: state.map.id, telemetry: telemetry.flush().slice(-200), }; let saved = false; try { const res = await fetch('api/save_score.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); saved = res.ok && (await res.json()).ok; } catch { /* kein Backend (z. B. python-Server) – localStorage reicht */ } try { const all = JSON.parse(localStorage.getItem('tourismusregion_results') || '[]'); all.push({ ...payload, telemetry: undefined, ts: Date.now() }); localStorage.setItem('tourismusregion_results', JSON.stringify(all.slice(-30))); } catch { /* Privatmodus */ } ui.toast(saved ? '💾 Ergebnis am Server gespeichert.' : '💾 Ergebnis lokal gespeichert (kein Server erreichbar).'); telemetry.log('save_result', { server: saved }); } // ---------- Game-Loop ---------- function frame(now) { const dtMs = Math.min(100, now - last); last = now; const simSpeed = speed * TEST_SPEED; tick(state, (dtMs / 1000) * simSpeed); pumpCards(); uiAccum += dtMs; if (uiAccum > 250) { uiAccum = 0; ui.updateKPIs(); ui.drawGraph(); } renderer.draw(now, dtMs, simSpeed); requestAnimationFrame(frame); }