Files
geograsim/App/sims/tourismusregion/js/main.js
T
Adminator 5f603cb193 Tourismusregion: Umwelt beißt jetzt + Schüler-Erklärung (Balance-Pass 1)
Diagnose per headless-Sim-Harness (.dontDeploy/tools/tr-balance-harness.mjs):
„alles zubauen" senkte die Umwelt nur 75→50 (über der 45-Schwelle) und der
Exit-Umwelt-Malus war TOTER Flag-Code → man konnte der Umwelt kaum schaden.

- model.js: (1) Exit-Umwelt-Malus wirklich angewandt — env<60 zieht bei
  umweltbewussten Gästen Zufriedenheit ab (je tiefer, desto mehr). (2) Über-
  tourismus-Drift im 10-s-Takt: dichte Bebauung (>6 Bauwerke) + Verkehr zehren
  die Umwelt aus. (3) Einmalige Lern-Warnkarte, wenn Umwelt < 50 kippt.
- data.js: Naturschutzgebiet-Beschreibung klar (rettet die Umwelt +20, Natur-
  Attraktionen +15 %, sperrt Bauplätze — Naturschutz vs. Ausbau abwägen).
- main.js: Willkommenskarte erklärt die Umwelt-Mechanik für Schüler.

Messung nachher: „alles zubauen" → Umwelt 75→37 (unter 45), Umweltziel
verfehlt, Ø-Sterne fallen. Feintuning Sterne-Kurve/Ansprüche-Eskalation
bleibt für Thomas' Playtest (Game-Feeling headless nicht bewertbar).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 18:10:42 +02:00

388 lines
14 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'
+ '🌲 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);
}