Files
geograsim/App/sims/tourismusregion/js/main.js
T
Adminator db4b75c5e6 Tourismusregion Quick-Fixes: Startflow, Auto-Save, Lift-Talstation, iPad-Responsive
- Startbildschirm: Name/Klasscode-Felder entfernt (Kinder sind über die
  Plattform-Session identifiziert). Klick auf eine Wegkarte startet die
  Partie sofort — separater „Partie starten"-Button entfernt.
- Abschluss: „Ergebnis speichern"-Button entfernt; Speichern passiert schon
  automatisch am Partie-Ende (submitAssessment). Hinweis „automatisch
  gespeichert" ergänzt. btnSave-Listener null-sicher gemacht.
- Seilbahn: Talstation (Baukörper + Satteldach + Umlenkrad) ergänzt — der
  Lift-Anfang wirkte vorher „transparent".
- Responsive/iPad: Panels bekommen Höhen-Deckel + internes Scrollen (decken
  nie den ganzen Schirm zu). Neue Media-Query für iPad-Landscape (901–1200px
  / niedrige Höhe): kompaktere Panels, Graphen nach rechts-unten aus dem
  Info-Panel-Bereich. Sehr flache Screens blenden die Graphen aus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 14:15:41 +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';
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 = `<img src="${mdef.image}" alt=""><span><b>${mdef.icon} ${mdef.name}</b><small>${mdef.desc}</small></span>`;
// 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);
}