Tourismusregion: Komplett-Integration (zweite neue Sim, beta)
Wellenbasierte Routen-Sim (DefensegeograSimMac) nach dem Tourismustal- Playbook integriert: Sim nach App/sims/tourismusregion (game.html, Sprites 47MB->9.5MB, ohne Eigen-Backend/Editor/Dashboard/Keys), Plattform-Hooks (GGS_LIVE_STATE mit Welle/Budget/Umwelt/Sterne/Lenkung, Submit bei Partie-Ende mit fertigem 0-100-Score, GGS_TUTORIAL, Home- Button, Musik leise). Wrapper + Modul-Detailseite. SQL: module_info (beta, Karte), Glossar-Kernbegriff Besucherlenkung (+Bild, Leichte Sprache) + 12 Zuordnungen, Lehrplan-Mapping auf tourismus- raumentwicklung (primaer) + verkehrserziehung/systemisches-denken. LEHRPLAN.md mit Schwerpunkt Besucherlenkung & Mobilitaet. Lehrer- Backend: Benchmark (Sim-Score direkt), Live-Cockpit-Spalten, needsHelp, Highlights. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
// 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';
|
||||
const mapList = document.getElementById('mapList');
|
||||
for (const id of MAP_ORDER) {
|
||||
const mdef = MAPS[id];
|
||||
const el = document.createElement('button');
|
||||
el.className = 'map-btn' + (id === chosenMap ? ' active' : '');
|
||||
el.dataset.map = id;
|
||||
el.innerHTML = `<img src="${mdef.image}" alt=""><span><b>${mdef.icon} ${mdef.name}</b><small>${mdef.desc}</small></span>`;
|
||||
el.addEventListener('click', () => {
|
||||
chosenMap = id;
|
||||
document.querySelectorAll('.map-btn').forEach(b => b.classList.toggle('active', b === el));
|
||||
});
|
||||
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(() => {});
|
||||
|
||||
document.getElementById('btnStart').addEventListener('click', async () => {
|
||||
await overridesReady;
|
||||
document.getElementById('startOverlay').classList.add('hidden');
|
||||
startGame();
|
||||
});
|
||||
|
||||
function startGame() {
|
||||
const name = document.getElementById('playerName').value.trim().slice(0, 40);
|
||||
const code = document.getElementById('classCode').value.trim().slice(0, 20);
|
||||
state = createState({ playerName: name, classCode: code, 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: name, classCode: code });
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user