88e3c09e5c
Jede Sim liefert jetzt selbst leistung_pct in GGS_LIVE_STATE UND im Submit (Fundament fuer das einheitliche %-System) — kein Rueckrechnen aus Sternen mehr, keine 0-3-Sonderfaelle. Formeln je Sim wie vereinbart: - klima/fluss/kofferdetektiv/heli: = vorhandener 0-100-score - weltkueche: richtige/(richtige+falsche)*100 (Fehler in %) - busfahrt/fluggesellschaft: topoPunkte/(Auftraege*3)*100 - farmer: gewichteter totalScore; energiemanager: ausgegl./gesamt -15/Blackout - eu-werkstatt: firstTry/Schritte -5/Muelleimer; sonnensystem: Erstversuch-Quote - tourismustal: Ziele+Zufriedenheit+Natur gewichtet; tourismusregion: gewichteter score - logistik: Kontostand->% (sendet erstmals eine Kennzahl, auch an /api/assessment) Teacher _leistungPct + live.php extractScore bevorzugen bereits diesen Wert. Additive Aenderungen, bestehende Logik/Sterne unveraendert; alle 15 Dateien syntaktisch geprueft. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
410 lines
15 KiB
JavaScript
410 lines
15 KiB
JavaScript
// 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;
|
||
// Einheitliche Leistungskennzahl: gewichteter Score live (gleiche Formel wie finish()),
|
||
// Fallback avgStars/5*100, falls Teil-Stats fehlen.
|
||
const _avg = currentAvg(state);
|
||
let _leistung_pct;
|
||
try {
|
||
const _st = state.stats;
|
||
const _nonCar = _st.transports.foot + _st.transports.bike + _st.transports.bus;
|
||
const _totalSp = Math.max(1, _st.spawned);
|
||
let _lenkung = (_nonCar / _totalSp) * 100;
|
||
if (state.buildings.some(b => b.type === 'bushaltestelle')) _lenkung = Math.min(100, _lenkung + 10);
|
||
const _scoreLive = (_avg / 5) * 100 * 0.35
|
||
+ Math.min(100, (state.revenueTotal / 2500) * 100) * 0.25
|
||
+ state.env * 0.20
|
||
+ _lenkung * 0.10
|
||
+ (state.budget >= 0 ? 100 : Math.max(0, 100 * (1 + state.budget / 1000))) * 0.10;
|
||
_leistung_pct = Math.round(_scoreLive);
|
||
} catch (_) {
|
||
_leistung_pct = Math.round((_avg / 5) * 100);
|
||
}
|
||
_leistung_pct = Math.max(0, Math.min(100, _leistung_pct));
|
||
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,
|
||
leistung_pct: _leistung_pct,
|
||
};
|
||
};
|
||
|
||
// ===== 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,
|
||
leistung_pct: Math.max(0, Math.min(100, Math.round(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);
|
||
}
|