d195b085d9
Antwort auf die Design-Frage 'Naturwert zurueck ausser weniger bauen': - Oeko-Umbau pro Gebaeude (Button im Gebaeude-Dialog): einmalig, 40 % des Bauwerts, dafuer -20 % Betriebskosten, +2 Naturwert, Gebaeude belastet die taegliche Natur-Regeneration nicht mehr. Sichtbar: Solardach-Overlay (auch auf Megabauwerken, 1,6x). Didaktik: Investition vs. laufende Kosten, nachhaltiges Wirtschaften (GW 4.10). - Aufforsten als wiederholbare Aktion im Baumenue (1.500 Euro): pflanzt Wald auf freie Wiese/Alm/Feld, +3 Naturwert, Werkzeug bleibt aktiv fuer Serien-Pflanzung. Wissen: Renaturierung + Schutzwald (1/3 des oesterreichischen Walds). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
435 lines
17 KiB
JavaScript
435 lines
17 KiB
JavaScript
// Einstiegspunkt: Szenenwahl → Spielstart. Verdrahtet Modell, Renderer,
|
||
// Audio und UI und treibt die laufende Zeit an (einstellbare Zeitskala).
|
||
|
||
import { createState, simulateDay, canPlace, place, demolish, upgrade, ecoUpgrade, 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 = 4000; // 1 Tag = 4 s bei 1× – ruhiges Tempo (halbiert 2026-07-11)
|
||
|
||
// 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
|
||
},
|
||
onEco: () => {
|
||
const res = ecoUpgrade(state, c, r);
|
||
if (!res.ok) { ui.toast(`❌ ${res.reason}`); audio.error(); return; }
|
||
audio.build();
|
||
renderer.addFloat('🌱 Öko-Umbau!', '#3f8f5e', c + 0.5, r - 0.5, true);
|
||
ui.updateKPIs();
|
||
ui.toast(`🌱 ${res.name} läuft jetzt als Öko-Betrieb (−${res.cost.toLocaleString('de-AT')} €): −20 % Betriebskosten, +2 Naturwert.`, 6000);
|
||
telemetry.log('eco_upgrade', { c, r, cost: res.cost });
|
||
inspectBuilding(c, r); // Karte mit neuem Status
|
||
},
|
||
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) {
|
||
// Ablehnung erklärt IMMER auch die Bauregeln des Gebäudes
|
||
ui.toast(`❌ ${check.reason} 💡 ${BUILDINGS[type].name}: ${BUILDINGS[type].desc}`, 7000);
|
||
audio.error();
|
||
telemetry.log('build_rejected', { type, c, r, reason: check.reason });
|
||
return;
|
||
}
|
||
const res = place(state, type, c, r);
|
||
if (res.planted) {
|
||
ui.toast('🌲 Wald gepflanzt: +3 Naturwert. Weiter tippen zum Aufforsten!', 4000);
|
||
audio.build();
|
||
ui.updateKPIs();
|
||
telemetry.log('forest_planted', { c, r, moneyLeft: state.money });
|
||
return; // Werkzeug bleibt aktiv – Aufforsten in Serie
|
||
}
|
||
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 });
|
||
// 4 gleiche im Quadrat → Megabauwerk (Skaleneffekt-Lernmoment)
|
||
if (res.merged) {
|
||
audio.milestone?.();
|
||
renderer.addFloat('🏛️ Megabauwerk!', '#e8892b', res.merged.c + 1, res.merged.r - 0.5, true);
|
||
const first = !state.firedEvents.has('mega-info');
|
||
state.firedEvents.add('mega-info');
|
||
ui.showCard({
|
||
id: `mega-${res.merged.c}-${res.merged.r}`, audio: 'mega', icon: '🏛️', sound: 'milestone',
|
||
title: `Megabauwerk: ${res.merged.name}!`,
|
||
text: `Vier gleiche Gebäude im Quadrat sind zu EINEM großen ${res.merged.name} verschmolzen.`
|
||
+ '\n\nDas ist ein Skaleneffekt: Das Megabauwerk leistet so viel wie '
|
||
+ 'alle vier zusammen – braucht aber nur die Betriebskosten von DREI. '
|
||
+ 'Große Betriebe arbeiten pro Gast günstiger als kleine.'
|
||
+ (first ? '\n\nTipp: Das klappt mit vielen Gebäudetypen – probiere es aus!' : ''),
|
||
});
|
||
telemetry.log('mega_merge', { type, c: res.merged.c, r: res.merged.r });
|
||
}
|
||
// 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', important: true,
|
||
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', important: true,
|
||
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!', important: true,
|
||
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);
|
||
}
|