// 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'
+ '🌲 Die Umwelt ist deine Landschaft: Wanderer und Naturfreunde lieben sie intakt. '
+ 'Zu viel Bauen und Verkehr lässt die Umwelt sinken – fällt sie zu tief, vergraulst '
+ 'du genau diese Gäste. Ein 🌿 Naturschutzgebiet hebt die Umwelt wieder und zieht '
+ 'Naturgäste an, sperrt aber Bauplätze in der Nähe. Abwägen ist alles!\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);
}