Files
geograsim/App/sims/tourismusregion/js/main.js
T
Adminator 8047778a5b Tourismusregion: Komplett-Integration (zweite neue Sim, beta)
Wellenbasierte Routen-Sim (DefensegeograSimMac) nach dem Tourismustal-
Playbook integriert: Sim nach App/sims/tourismusregion (game.html,
Sprites 47MB->9.5MB, ohne Eigen-Backend/Editor/Dashboard/Keys),
Plattform-Hooks (GGS_LIVE_STATE mit Welle/Budget/Umwelt/Sterne/Lenkung,
Submit bei Partie-Ende mit fertigem 0-100-Score, GGS_TUTORIAL, Home-
Button, Musik leise). Wrapper + Modul-Detailseite. SQL: module_info
(beta, Karte), Glossar-Kernbegriff Besucherlenkung (+Bild, Leichte
Sprache) + 12 Zuordnungen, Lehrplan-Mapping auf tourismus-
raumentwicklung (primaer) + verkehrserziehung/systemisches-denken.
LEHRPLAN.md mit Schwerpunkt Besucherlenkung & Mobilitaet. Lehrer-
Backend: Benchmark (Sim-Score direkt), Live-Cockpit-Spalten,
needsHelp, Highlights.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:14:12 +02:00

384 lines
13 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: 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';
const mapList = document.getElementById('mapList');
for (const id of MAP_ORDER) {
const mdef = MAPS[id];
const el = document.createElement('button');
el.className = 'map-btn' + (id === chosenMap ? ' active' : '');
el.dataset.map = id;
el.innerHTML = `<img src="${mdef.image}" alt=""><span><b>${mdef.icon} ${mdef.name}</b><small>${mdef.desc}</small></span>`;
el.addEventListener('click', () => {
chosenMap = id;
document.querySelectorAll('.map-btn').forEach(b => b.classList.toggle('active', b === el));
});
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(() => {});
document.getElementById('btnStart').addEventListener('click', async () => {
await overridesReady;
document.getElementById('startOverlay').classList.add('hidden');
startGame();
});
function startGame() {
const name = document.getElementById('playerName').value.trim().slice(0, 40);
const code = document.getElementById('classCode').value.trim().slice(0, 20);
state = createState({ playerName: name, classCode: code, 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: name, classCode: code });
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);
}