Tourismustal: Komplett-Integration (Sim + Wrapper + SQL + Lehrer-Backend)
Sim nach App/sims/tourismustal umgezogen (Source of Truth), game.html mit Injection-Marker. Neu in der Sim: 5 Sommergebaeude (Bikepark mit Flowtrails, Hochseilgarten, Fahrradverleih als Verstaerker, Aussichtsplattform, Erlebnisspielplatz), Rettungsstation mit Notarzthubschrauber, Ausbau- Bauanimation, Event-Pacing, Schulden-Notbremse (Bank-Zwangsverkauf), Berater-Tipps (Ruecklagen), Sommer-Trend-Mechanik (Serfaus-Effekt) und Wissens-Bausteine mit echten DACH-Tourismusdaten (QUELLEN.md). Plattform: PHP-Wrapper mit base-Tag + Session-Mode, Modul-Detailseite, module_info (beta) + Glossar (14 Begriffe inkl. Leichter Sprache + Quellen) + Lehrplan-Anker AT/DE/CH an neuer Kompetenz tourismus-raumentwicklung. Lehrer-Backend: Benchmark-Formel, Live-Cockpit-Spalten, needsHelp-Heuristik, Modul-Highlights. Strategie-Testharness (tests/strategien.mjs) belegt: Panzer-Rush wird liquidiert, Ruecklagen zahlen sich aus. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
// 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 = 2000; // 1 Tag = 2 s bei Geschwindigkeit 1× – ruhiges Tempo
|
||||
|
||||
// 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) {
|
||||
ui.toast(`❌ ${check.reason}`);
|
||||
audio.error();
|
||||
telemetry.log('build_rejected', { type, c, r, reason: check.reason });
|
||||
return;
|
||||
}
|
||||
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 });
|
||||
// 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',
|
||||
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',
|
||||
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!',
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user