Stand 2026-04-13: PHP/MySQL Infrastruktur, Flussmanagement, Stadt-Prototyp
- PHP/MySQL Backend (XAMPP + Produktionsserver) - Front-Controller, API-Endpunkte, Session-Management - Flussmanagement-Simulation (Echtzeit, Punkt-basierter Fluss) - Stadt & Raumplanung (Prototyp, Top-Down Kachelsystem) - Klimawaechter 3D: Deiche kleiner, Baeume kippen, Budget angepasst - persistence.ts: Dualer Speicher (localStorage + Server-API) - 6 Unit-Test-Dateien fuer bestehende Simulationen Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Bildungsstufen-Konfiguration
|
||||
*
|
||||
* Abstrahiert von länderspezifischen Bezeichnungen.
|
||||
* Pro App-Instanz wird ein Land/Region konfiguriert,
|
||||
* das die Zuordnung von internen Leveln zu lokalen Bezeichnungen steuert.
|
||||
*
|
||||
* Intern arbeiten wir mit:
|
||||
* - `educationLevel`: 1–13 (= Schulstufe/Schuljahr, international vergleichbar)
|
||||
* - `ageRange`: Alter der Zielgruppe
|
||||
* - `readingLevel`: Lesekompetenz (none, basic, fluent)
|
||||
*
|
||||
* In der Entwicklung entscheiden wir pro Simulation:
|
||||
* - Welche educationLevels werden unterstützt?
|
||||
* - Braucht es eine Variante ohne Lesen (Grundstufe 1)?
|
||||
* - Welche Sprachkomplexität ist angemessen?
|
||||
*/
|
||||
|
||||
export type ReadingLevel = 'none' | 'basic' | 'fluent'
|
||||
|
||||
export interface EducationLevel {
|
||||
/** Internationale Schulstufe (1 = 1. Schuljahr, 5 = 5. Schuljahr etc.) */
|
||||
level: number
|
||||
/** Typisches Alter */
|
||||
ageMin: number
|
||||
ageMax: number
|
||||
/** Erwartete Lesekompetenz */
|
||||
reading: ReadingLevel
|
||||
/** Kann komplexe Texte verarbeiten? */
|
||||
canProcessComplexText: boolean
|
||||
}
|
||||
|
||||
export interface CountryConfig {
|
||||
id: string
|
||||
name: string
|
||||
/** Zuordnung: lokale Bezeichnung → internationale Schulstufe */
|
||||
stages: CountryStage[]
|
||||
/** Welche Schulstufen deckt unser Produkt primär ab? */
|
||||
primaryRange: { from: number; to: number }
|
||||
/** Optionale Erweiterung (z.B. Volksschule) */
|
||||
extendedRange?: { from: number; to: number }
|
||||
}
|
||||
|
||||
export interface CountryStage {
|
||||
/** Internationale Schulstufe */
|
||||
level: number
|
||||
/** Lokale Bezeichnung (z.B. "1. Klasse Mittelschule") */
|
||||
localName: string
|
||||
/** Kurzform (z.B. "1. Kl. MS") */
|
||||
shortName: string
|
||||
/** Schultyp-Bezeichnung */
|
||||
schoolType: string
|
||||
/** Fachbezeichnung für Geografie in dieser Stufe */
|
||||
subjectName: string
|
||||
/** Wochenstunden GW (falls bekannt) */
|
||||
hoursPerWeek?: number
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Länderkonfigurationen
|
||||
// ============================================================
|
||||
|
||||
export const AUSTRIA: CountryConfig = {
|
||||
id: 'at',
|
||||
name: 'Österreich',
|
||||
primaryRange: { from: 5, to: 8 }, // Mittelschule / AHS-Unterstufe
|
||||
extendedRange: { from: 1, to: 4 }, // Volksschule (Sachunterricht)
|
||||
stages: [
|
||||
// Volksschule (optional, für spätere Erweiterung)
|
||||
{ level: 1, localName: '1. Klasse Volksschule', shortName: '1. VS', schoolType: 'Volksschule', subjectName: 'Sachunterricht', hoursPerWeek: undefined },
|
||||
{ level: 2, localName: '2. Klasse Volksschule', shortName: '2. VS', schoolType: 'Volksschule', subjectName: 'Sachunterricht', hoursPerWeek: undefined },
|
||||
{ level: 3, localName: '3. Klasse Volksschule', shortName: '3. VS', schoolType: 'Volksschule', subjectName: 'Sachunterricht', hoursPerWeek: undefined },
|
||||
{ level: 4, localName: '4. Klasse Volksschule', shortName: '4. VS', schoolType: 'Volksschule', subjectName: 'Sachunterricht', hoursPerWeek: undefined },
|
||||
// Mittelschule / AHS-Unterstufe (= unser Hauptfokus)
|
||||
{ level: 5, localName: '1. Klasse', shortName: '1. Kl.', schoolType: 'Mittelschule / AHS-Unterstufe', subjectName: 'Geografie und wirtschaftliche Bildung', hoursPerWeek: 2 },
|
||||
{ level: 6, localName: '2. Klasse', shortName: '2. Kl.', schoolType: 'Mittelschule / AHS-Unterstufe', subjectName: 'Geografie und wirtschaftliche Bildung', hoursPerWeek: 1 },
|
||||
{ level: 7, localName: '3. Klasse', shortName: '3. Kl.', schoolType: 'Mittelschule / AHS-Unterstufe', subjectName: 'Geografie und wirtschaftliche Bildung', hoursPerWeek: 2 },
|
||||
{ level: 8, localName: '4. Klasse', shortName: '4. Kl.', schoolType: 'Mittelschule / AHS-Unterstufe', subjectName: 'Geografie und wirtschaftliche Bildung', hoursPerWeek: 2 },
|
||||
],
|
||||
}
|
||||
|
||||
export const GERMANY_BAYERN: CountryConfig = {
|
||||
id: 'de-by',
|
||||
name: 'Deutschland (Bayern)',
|
||||
primaryRange: { from: 5, to: 10 },
|
||||
stages: [
|
||||
{ level: 5, localName: '5. Jahrgangsstufe', shortName: '5. Jgst.', schoolType: 'Gymnasium / Realschule', subjectName: 'Geographie', hoursPerWeek: 2 },
|
||||
{ level: 6, localName: '6. Jahrgangsstufe', shortName: '6. Jgst.', schoolType: 'Gymnasium / Realschule', subjectName: 'Geographie', hoursPerWeek: 0 },
|
||||
{ level: 7, localName: '7. Jahrgangsstufe', shortName: '7. Jgst.', schoolType: 'Gymnasium / Realschule', subjectName: 'Geographie', hoursPerWeek: 2 },
|
||||
{ level: 8, localName: '8. Jahrgangsstufe', shortName: '8. Jgst.', schoolType: 'Gymnasium / Realschule', subjectName: 'Geographie', hoursPerWeek: 2 },
|
||||
{ level: 9, localName: '9. Jahrgangsstufe', shortName: '9. Jgst.', schoolType: 'Gymnasium / Realschule', subjectName: 'Geographie', hoursPerWeek: 0 },
|
||||
{ level: 10, localName: '10. Jahrgangsstufe', shortName: '10. Jgst.', schoolType: 'Gymnasium / Realschule', subjectName: 'Geographie', hoursPerWeek: 2 },
|
||||
],
|
||||
}
|
||||
|
||||
export const GERMANY_NRW: CountryConfig = {
|
||||
id: 'de-nrw',
|
||||
name: 'Deutschland (NRW)',
|
||||
primaryRange: { from: 5, to: 10 },
|
||||
stages: [
|
||||
{ level: 5, localName: 'Klasse 5', shortName: 'Kl. 5', schoolType: 'Gymnasium / Gesamtschule', subjectName: 'Erdkunde', hoursPerWeek: 2 },
|
||||
{ level: 6, localName: 'Klasse 6', shortName: 'Kl. 6', schoolType: 'Gymnasium / Gesamtschule', subjectName: 'Erdkunde', hoursPerWeek: 2 },
|
||||
{ level: 7, localName: 'Klasse 7', shortName: 'Kl. 7', schoolType: 'Gymnasium / Gesamtschule', subjectName: 'Erdkunde', hoursPerWeek: 2 },
|
||||
{ level: 8, localName: 'Klasse 8', shortName: 'Kl. 8', schoolType: 'Gymnasium / Gesamtschule', subjectName: 'Erdkunde', hoursPerWeek: 1 },
|
||||
{ level: 9, localName: 'Klasse 9', shortName: 'Kl. 9', schoolType: 'Gymnasium / Gesamtschule', subjectName: 'Erdkunde', hoursPerWeek: 2 },
|
||||
{ level: 10, localName: 'Klasse 10', shortName: 'Kl. 10', schoolType: 'Gymnasium / Gesamtschule', subjectName: 'Erdkunde', hoursPerWeek: 1 },
|
||||
],
|
||||
}
|
||||
|
||||
export const SWITZERLAND: CountryConfig = {
|
||||
id: 'ch',
|
||||
name: 'Schweiz',
|
||||
primaryRange: { from: 7, to: 9 }, // Zyklus 3
|
||||
extendedRange: { from: 3, to: 6 }, // Zyklus 2 (NMG)
|
||||
stages: [
|
||||
// Zyklus 2 (optional)
|
||||
{ level: 3, localName: '3. Klasse', shortName: '3. Kl.', schoolType: 'Primarstufe', subjectName: 'Natur, Mensch, Gesellschaft', hoursPerWeek: undefined },
|
||||
{ level: 4, localName: '4. Klasse', shortName: '4. Kl.', schoolType: 'Primarstufe', subjectName: 'Natur, Mensch, Gesellschaft', hoursPerWeek: undefined },
|
||||
{ level: 5, localName: '5. Klasse', shortName: '5. Kl.', schoolType: 'Primarstufe', subjectName: 'Natur, Mensch, Gesellschaft', hoursPerWeek: undefined },
|
||||
{ level: 6, localName: '6. Klasse', shortName: '6. Kl.', schoolType: 'Primarstufe', subjectName: 'Natur, Mensch, Gesellschaft', hoursPerWeek: undefined },
|
||||
// Zyklus 3 (= unser Hauptfokus)
|
||||
{ level: 7, localName: '1. Oberstufe', shortName: '1. OS', schoolType: 'Sekundarschule', subjectName: 'Räume, Zeiten, Gesellschaften', hoursPerWeek: 2 },
|
||||
{ level: 8, localName: '2. Oberstufe', shortName: '2. OS', schoolType: 'Sekundarschule', subjectName: 'Räume, Zeiten, Gesellschaften', hoursPerWeek: 2 },
|
||||
{ level: 9, localName: '3. Oberstufe', shortName: '3. OS', schoolType: 'Sekundarschule', subjectName: 'Räume, Zeiten, Gesellschaften', hoursPerWeek: 2 },
|
||||
],
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Education-Level-Definitionen (länderunabhängig)
|
||||
// ============================================================
|
||||
|
||||
export const EDUCATION_LEVELS: EducationLevel[] = [
|
||||
{ level: 1, ageMin: 6, ageMax: 7, reading: 'none', canProcessComplexText: false },
|
||||
{ level: 2, ageMin: 7, ageMax: 8, reading: 'basic', canProcessComplexText: false },
|
||||
{ level: 3, ageMin: 8, ageMax: 9, reading: 'basic', canProcessComplexText: false },
|
||||
{ level: 4, ageMin: 9, ageMax: 10, reading: 'fluent', canProcessComplexText: false },
|
||||
{ level: 5, ageMin: 10, ageMax: 11, reading: 'fluent', canProcessComplexText: false },
|
||||
{ level: 6, ageMin: 11, ageMax: 12, reading: 'fluent', canProcessComplexText: true },
|
||||
{ level: 7, ageMin: 12, ageMax: 13, reading: 'fluent', canProcessComplexText: true },
|
||||
{ level: 8, ageMin: 13, ageMax: 14, reading: 'fluent', canProcessComplexText: true },
|
||||
{ level: 9, ageMin: 14, ageMax: 15, reading: 'fluent', canProcessComplexText: true },
|
||||
{ level: 10, ageMin: 15, ageMax: 16, reading: 'fluent', canProcessComplexText: true },
|
||||
]
|
||||
|
||||
// ============================================================
|
||||
// Alle verfügbaren Länder
|
||||
// ============================================================
|
||||
|
||||
export const ALL_COUNTRIES: CountryConfig[] = [
|
||||
AUSTRIA,
|
||||
GERMANY_BAYERN,
|
||||
GERMANY_NRW,
|
||||
SWITZERLAND,
|
||||
]
|
||||
|
||||
// ============================================================
|
||||
// Helper-Funktionen
|
||||
// ============================================================
|
||||
|
||||
/** Gibt die lokale Bezeichnung für eine Schulstufe zurück */
|
||||
export function getLocalName(country: CountryConfig, level: number): string | undefined {
|
||||
return country.stages.find(s => s.level === level)?.localName
|
||||
}
|
||||
|
||||
/** Gibt alle Stufen zurück, die im Hauptfokus des Landes liegen */
|
||||
export function getPrimaryStages(country: CountryConfig): CountryStage[] {
|
||||
return country.stages.filter(
|
||||
s => s.level >= country.primaryRange.from && s.level <= country.primaryRange.to
|
||||
)
|
||||
}
|
||||
|
||||
/** Prüft, ob eine Schulstufe Lesekompetenz erfordert */
|
||||
export function requiresReading(level: number): boolean {
|
||||
const ed = EDUCATION_LEVELS.find(e => e.level === level)
|
||||
return ed ? ed.reading !== 'none' : true
|
||||
}
|
||||
|
||||
/** Gibt die Lesekompetenz für eine Stufe zurück */
|
||||
export function getReadingLevel(level: number): ReadingLevel {
|
||||
return EDUCATION_LEVELS.find(e => e.level === level)?.reading ?? 'fluent'
|
||||
}
|
||||
|
||||
/** Gibt die Fachbezeichnung für eine Stufe in einem Land zurück */
|
||||
export function getSubjectName(country: CountryConfig, level: number): string | undefined {
|
||||
return country.stages.find(s => s.level === level)?.subjectName
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
/**
|
||||
* Simulations-Engine — Generischer Mechanik-Layer
|
||||
*
|
||||
* Basisklasse für interaktive Simulationen:
|
||||
* - Zeit-Loop (real-time mit pause/play/speed)
|
||||
* - Tick-System (jeder Tick = 1 simulierte Zeiteinheit, z.B. 1 Jahr/Monat/Sekunde)
|
||||
* - Score & Resources
|
||||
* - Erfolgs-/Misserfolgs-Zustände
|
||||
* - Tutorial-Phasen mit schrittweiser Freischaltung von Features
|
||||
* - Save/Resume
|
||||
* - Zeitreihen-Daten für Graphen
|
||||
*
|
||||
* Designprinzipien:
|
||||
* - Fachlich korrekt: alle Werte sind echt, keine Fantasiedaten
|
||||
* - Erlebbar: schmaler Grat zwischen Erfolg/Misserfolg
|
||||
* - Klassenkompatibel: jederzeit abschließbar, speicherbar
|
||||
* - Transparent: alle Werte sind sichtbar (keine versteckten Variablen)
|
||||
*
|
||||
* Hinweis: Klassen- und Variablennamen tragen weiter "Game..." als
|
||||
* technische Bezeichnung. User-facing Texte sprechen aber von
|
||||
* "Simulation" — siehe architektur.md.
|
||||
*/
|
||||
|
||||
export type GameState = 'tutorial' | 'playing' | 'paused' | 'won' | 'lost' | 'complete'
|
||||
export type GameSpeed = 0 | 1 | 2 | 4 // 0 = pause, 1 = normal, 2 = schnell, 4 = sehr schnell
|
||||
|
||||
export interface GameMeta {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
|
||||
/** Wie lange dauert ein Tick in echten Millisekunden bei Speed = 1? */
|
||||
msPerTick: number
|
||||
/** Was repräsentiert ein Tick in der Simulation? z.B. "Monat", "Jahr", "Sekunde" */
|
||||
tickUnit: string
|
||||
/** Maximale Anzahl Ticks (= Spielende durch Zeit) — 0 = unbegrenzt */
|
||||
maxTicks: number
|
||||
|
||||
/** Wieviele Tutorial-Schritte hat das Spiel? */
|
||||
tutorialSteps: number
|
||||
}
|
||||
|
||||
export interface Resource {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
current: number
|
||||
initial: number
|
||||
min?: number
|
||||
max?: number
|
||||
unit: string
|
||||
format?: (v: number) => string
|
||||
}
|
||||
|
||||
export interface GoalDef {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
/** Bedingung erfüllt? Erhält Game-Snapshot */
|
||||
check: (game: GameEngine) => boolean
|
||||
/** Optional: numerischer Fortschritt 0-100 */
|
||||
progress?: (game: GameEngine) => number
|
||||
/** Verpflichtend für Win, oder Bonus? */
|
||||
required: boolean
|
||||
}
|
||||
|
||||
export interface TimelineEntry {
|
||||
tick: number
|
||||
values: Record<string, number>
|
||||
}
|
||||
|
||||
export interface TutorialStep {
|
||||
/** Wann zeigt sich dieser Step (welcher Tick) */
|
||||
triggerTick: number
|
||||
title: string
|
||||
text: string
|
||||
/** Optional: welche UI-Elemente werden in diesem Step sichtbar */
|
||||
unlocks?: string[]
|
||||
}
|
||||
|
||||
export interface GameEvent {
|
||||
tick: number
|
||||
type: string
|
||||
text: string
|
||||
severity: 'info' | 'success' | 'warning' | 'danger'
|
||||
/** Optional: Schlüssel für ein Info-Overlay (kindgerechter Erklärtext) */
|
||||
infoKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Bürger-Beschwerde / Forderung mit interaktiver Wahl.
|
||||
* Wird vom Spiel ausgelöst und vom Spieler entschieden — die Wahl
|
||||
* hat reale Konsequenzen für Resourcen.
|
||||
*/
|
||||
export interface CitizenChoice {
|
||||
label: string
|
||||
description?: string
|
||||
/** Wird ausgeführt, wenn der Spieler diese Option wählt */
|
||||
apply: (game: GameEngine) => void
|
||||
}
|
||||
|
||||
export interface CitizenEvent {
|
||||
id: string
|
||||
/** Welcher Bürger meldet sich? Emoji für Avatar */
|
||||
character: string
|
||||
/** Kurztitel, z.B. „Forderung der Fischer" */
|
||||
title: string
|
||||
/** Sprechblase / Forderung */
|
||||
message: string
|
||||
/** 2–3 Optionen */
|
||||
choices: CitizenChoice[]
|
||||
}
|
||||
|
||||
export interface GameSnapshot {
|
||||
state: GameState
|
||||
speed: GameSpeed
|
||||
tick: number
|
||||
tutorialStep: number
|
||||
resources: Record<string, number>
|
||||
variables: Record<string, number>
|
||||
events: GameEvent[]
|
||||
timeline: TimelineEntry[]
|
||||
goals: Array<{ id: string; achieved: boolean; progress: number }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Basisklasse für ein spielbares GeoGraSim-Modul
|
||||
*/
|
||||
export abstract class GameEngine {
|
||||
readonly meta: GameMeta
|
||||
|
||||
protected state: GameState = 'tutorial'
|
||||
protected speed: GameSpeed = 1
|
||||
protected tick = 0
|
||||
protected tutorialStep = 0
|
||||
protected resources: Map<string, Resource> = new Map()
|
||||
protected variables: Record<string, number> = {}
|
||||
protected events: GameEvent[] = []
|
||||
protected timeline: TimelineEntry[] = []
|
||||
protected goals: GoalDef[] = []
|
||||
protected unlockedFeatures: Set<string> = new Set()
|
||||
protected tutorialSteps: TutorialStep[] = []
|
||||
/** Aktuell anstehende Bürger-Beschwerde, bis sie der Spieler beantwortet hat */
|
||||
protected pendingCitizenEvent: CitizenEvent | null = null
|
||||
|
||||
private animFrame = 0
|
||||
private lastTickTime = 0
|
||||
private listeners: Set<() => void> = new Set()
|
||||
|
||||
constructor(meta: GameMeta) {
|
||||
this.meta = meta
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// Setup (subclass calls these in constructor)
|
||||
// ==========================================================
|
||||
|
||||
protected addResource(r: Omit<Resource, 'current'> & { current?: number }): void {
|
||||
this.resources.set(r.id, { ...r, current: r.current ?? r.initial })
|
||||
}
|
||||
|
||||
protected addGoal(g: GoalDef): void {
|
||||
this.goals.push(g)
|
||||
}
|
||||
|
||||
protected setTutorial(steps: TutorialStep[]): void {
|
||||
this.tutorialSteps = steps
|
||||
}
|
||||
|
||||
protected setVariable(name: string, value: number): void {
|
||||
this.variables[name] = value
|
||||
this.notify()
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// Public API
|
||||
// ==========================================================
|
||||
|
||||
getResource(id: string): number {
|
||||
return this.resources.get(id)?.current ?? 0
|
||||
}
|
||||
|
||||
setResource(id: string, value: number): void {
|
||||
const r = this.resources.get(id)
|
||||
if (!r) return
|
||||
r.current = Math.max(r.min ?? -Infinity, Math.min(r.max ?? Infinity, value))
|
||||
this.notify()
|
||||
}
|
||||
|
||||
changeResource(id: string, delta: number): void {
|
||||
this.setResource(id, this.getResource(id) + delta)
|
||||
}
|
||||
|
||||
getVariable(name: string): number {
|
||||
return this.variables[name] ?? 0
|
||||
}
|
||||
|
||||
/** Spieler ändert eine Variable über UI (Slider, Button) */
|
||||
playerSetVariable(name: string, value: number): void {
|
||||
this.variables[name] = value
|
||||
this.onPlayerAction(name, value)
|
||||
this.notify()
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// Bürger-Beschwerden
|
||||
// ==========================================================
|
||||
|
||||
/** Subclass kann ein Citizen-Event in die Warteschlange legen */
|
||||
protected triggerCitizenEvent(event: CitizenEvent): void {
|
||||
if (this.pendingCitizenEvent) return // nur eines auf einmal
|
||||
this.pendingCitizenEvent = event
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/** Aktuell anstehende Bürger-Beschwerde (oder null) */
|
||||
getPendingCitizenEvent(): CitizenEvent | null {
|
||||
return this.pendingCitizenEvent
|
||||
}
|
||||
|
||||
/** Spieler hat eine Wahl getroffen */
|
||||
resolveCitizenEvent(choiceIndex: number): void {
|
||||
const ev = this.pendingCitizenEvent
|
||||
if (!ev) return
|
||||
const choice = ev.choices[choiceIndex]
|
||||
if (!choice) return
|
||||
choice.apply(this)
|
||||
this.addEvent(ev.id, `💬 ${ev.title}: „${choice.label}"`, 'info')
|
||||
this.pendingCitizenEvent = null
|
||||
this.notify()
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// Game loop
|
||||
// ==========================================================
|
||||
|
||||
start(): void {
|
||||
if (this.state === 'tutorial' && this.tutorialSteps.length > 0) {
|
||||
// Im Tutorial bleiben, manuell weiter
|
||||
this.notify()
|
||||
return
|
||||
}
|
||||
this.state = 'playing'
|
||||
this.lastTickTime = performance.now()
|
||||
this.loop()
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
if (this.state === 'playing') {
|
||||
this.state = 'paused'
|
||||
cancelAnimationFrame(this.animFrame)
|
||||
this.notify()
|
||||
}
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
if (this.state === 'paused') {
|
||||
this.state = 'playing'
|
||||
this.lastTickTime = performance.now()
|
||||
this.loop()
|
||||
}
|
||||
}
|
||||
|
||||
setSpeed(s: GameSpeed): void {
|
||||
this.speed = s
|
||||
if (s === 0) this.pause()
|
||||
else if (this.state === 'paused' || this.state === 'tutorial') {
|
||||
this.state = 'playing'
|
||||
this.lastTickTime = performance.now()
|
||||
this.loop()
|
||||
}
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/** Vorzeitig abschließen — speichert finalen Zustand */
|
||||
finish(): void {
|
||||
cancelAnimationFrame(this.animFrame)
|
||||
if (this.state !== 'won' && this.state !== 'lost') {
|
||||
this.state = 'complete'
|
||||
}
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/** Tutorial einen Schritt weiter */
|
||||
nextTutorialStep(): void {
|
||||
this.tutorialStep++
|
||||
if (this.tutorialStep >= this.tutorialSteps.length) {
|
||||
// Tutorial fertig — Spiel beginnt
|
||||
this.state = 'playing'
|
||||
this.lastTickTime = performance.now()
|
||||
this.loop()
|
||||
} else {
|
||||
const step = this.tutorialSteps[this.tutorialStep]
|
||||
if (step.unlocks) step.unlocks.forEach(u => this.unlockedFeatures.add(u))
|
||||
}
|
||||
this.notify()
|
||||
}
|
||||
|
||||
isFeatureUnlocked(name: string): boolean {
|
||||
return this.unlockedFeatures.has(name)
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// Save / Resume
|
||||
// ==========================================================
|
||||
|
||||
/** Save-Format-Version. Bei Breaking Changes erhöhen — ältere saves werden dann verworfen. */
|
||||
static readonly SAVE_VERSION = 2
|
||||
|
||||
serialize(): string {
|
||||
return JSON.stringify({
|
||||
v: GameEngine.SAVE_VERSION,
|
||||
meta: this.meta.id,
|
||||
state: this.state,
|
||||
tick: this.tick,
|
||||
tutorialStep: this.tutorialStep,
|
||||
speed: this.speed,
|
||||
variables: this.variables,
|
||||
resources: Object.fromEntries(
|
||||
Array.from(this.resources.entries()).map(([k, v]) => [k, v.current])
|
||||
),
|
||||
events: this.events,
|
||||
timeline: this.timeline,
|
||||
unlockedFeatures: Array.from(this.unlockedFeatures),
|
||||
// Subclass-spezifischer Zustand (interne private Felder)
|
||||
sub: this.serializeSubclass(),
|
||||
})
|
||||
}
|
||||
|
||||
deserialize(json: string): boolean {
|
||||
try {
|
||||
const data = JSON.parse(json)
|
||||
if (data.meta !== this.meta.id) return false
|
||||
// Alte/inkompatible saves komplett verwerfen
|
||||
if (!data.v || data.v < GameEngine.SAVE_VERSION) return false
|
||||
this.state = data.state
|
||||
this.tick = data.tick
|
||||
this.tutorialStep = data.tutorialStep
|
||||
this.speed = data.speed
|
||||
this.variables = data.variables
|
||||
for (const [k, v] of Object.entries(data.resources as Record<string, number>)) {
|
||||
const r = this.resources.get(k)
|
||||
if (r) r.current = v
|
||||
}
|
||||
this.events = data.events
|
||||
this.timeline = data.timeline
|
||||
this.unlockedFeatures = new Set(data.unlockedFeatures)
|
||||
// Subclass-spezifische Felder zurücksetzen
|
||||
if (data.sub) this.deserializeSubclass(data.sub)
|
||||
this.notify()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Override in Subklasse, um zusätzliche interne Felder zu serialisieren. */
|
||||
protected serializeSubclass(): Record<string, unknown> {
|
||||
return {}
|
||||
}
|
||||
|
||||
/** Override in Subklasse, um zusätzliche interne Felder wiederherzustellen. */
|
||||
protected deserializeSubclass(_data: Record<string, unknown>): void {
|
||||
// default: nichts
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// Snapshot for UI
|
||||
// ==========================================================
|
||||
|
||||
getSnapshot(): GameSnapshot {
|
||||
return {
|
||||
state: this.state,
|
||||
speed: this.speed,
|
||||
tick: this.tick,
|
||||
tutorialStep: this.tutorialStep,
|
||||
resources: Object.fromEntries(
|
||||
Array.from(this.resources.entries()).map(([k, r]) => [k, r.current])
|
||||
),
|
||||
variables: { ...this.variables },
|
||||
events: [...this.events],
|
||||
timeline: [...this.timeline],
|
||||
goals: this.goals.map(g => ({
|
||||
id: g.id,
|
||||
achieved: g.check(this),
|
||||
progress: g.progress?.(this) ?? (g.check(this) ? 100 : 0),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
getResourcesArray(): Resource[] {
|
||||
return Array.from(this.resources.values())
|
||||
}
|
||||
|
||||
getCurrentTutorialStep(): TutorialStep | null {
|
||||
return this.tutorialSteps[this.tutorialStep] || null
|
||||
}
|
||||
|
||||
getEvents(limit = 5): GameEvent[] {
|
||||
return this.events.slice(-limit).reverse()
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// Subscribe (UI-Updates)
|
||||
// ==========================================================
|
||||
|
||||
subscribe(fn: () => void): () => void {
|
||||
this.listeners.add(fn)
|
||||
return () => this.listeners.delete(fn)
|
||||
}
|
||||
|
||||
protected notify(): void {
|
||||
this.listeners.forEach(fn => fn())
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// Main loop (internal)
|
||||
// ==========================================================
|
||||
|
||||
private loop = (): void => {
|
||||
if (this.state !== 'playing') return
|
||||
|
||||
const now = performance.now()
|
||||
const dt = now - this.lastTickTime
|
||||
const interval = this.meta.msPerTick / this.speed
|
||||
|
||||
if (dt >= interval) {
|
||||
this.lastTickTime = now
|
||||
this.runTick()
|
||||
}
|
||||
|
||||
this.animFrame = requestAnimationFrame(this.loop)
|
||||
}
|
||||
|
||||
private runTick(): void {
|
||||
this.tick++
|
||||
|
||||
// Subclass simulates one tick
|
||||
this.simulateTick()
|
||||
|
||||
// Record timeline
|
||||
this.recordTimeline()
|
||||
|
||||
// Check tutorial triggers
|
||||
this.checkTutorialTriggers()
|
||||
|
||||
// Check win/loss
|
||||
this.checkEndConditions()
|
||||
|
||||
// Max ticks
|
||||
if (this.meta.maxTicks > 0 && this.tick >= this.meta.maxTicks) {
|
||||
if (this.state !== 'won' && this.state !== 'lost') {
|
||||
this.checkEndConditions() // Final check
|
||||
if (this.state === 'playing') this.state = 'complete'
|
||||
}
|
||||
}
|
||||
|
||||
this.notify()
|
||||
}
|
||||
|
||||
protected addEvent(
|
||||
type: string,
|
||||
text: string,
|
||||
severity: GameEvent['severity'] = 'info',
|
||||
infoKey?: string,
|
||||
): void {
|
||||
this.events.push({ tick: this.tick, type, text, severity, infoKey })
|
||||
if (this.events.length > 50) this.events.shift()
|
||||
}
|
||||
|
||||
private recordTimeline(): void {
|
||||
const values: Record<string, number> = {}
|
||||
for (const [k, r] of this.resources) values[k] = r.current
|
||||
for (const [k, v] of Object.entries(this.variables)) values[k] = v
|
||||
this.timeline.push({ tick: this.tick, values })
|
||||
// Cap timeline length
|
||||
if (this.timeline.length > 500) this.timeline.shift()
|
||||
}
|
||||
|
||||
private checkTutorialTriggers(): void {
|
||||
// Skip — tutorial is manual progression
|
||||
}
|
||||
|
||||
private checkEndConditions(): void {
|
||||
const requiredGoals = this.goals.filter(g => g.required)
|
||||
const allRequired = requiredGoals.every(g => g.check(this))
|
||||
if (allRequired && requiredGoals.length > 0) {
|
||||
this.state = 'won'
|
||||
this.addEvent('win', 'Du hast alle Hauptziele erreicht!', 'success')
|
||||
cancelAnimationFrame(this.animFrame)
|
||||
}
|
||||
|
||||
// Subclass can override loss conditions via checkLossCondition
|
||||
if (this.checkLossCondition()) {
|
||||
this.state = 'lost'
|
||||
cancelAnimationFrame(this.animFrame)
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// Subclass hooks
|
||||
// ==========================================================
|
||||
|
||||
/** Wird bei jedem Tick aufgerufen — Subklasse simuliert hier ein Zeitintervall */
|
||||
protected abstract simulateTick(): void
|
||||
|
||||
/** Reagiere auf Spieleraktion (z.B. Slider-Bewegung, Bau-Klick) */
|
||||
protected onPlayerAction(_name: string, _value: number): void {}
|
||||
|
||||
/** Soll das Spiel verloren sein? Override in Subklasse */
|
||||
protected checkLossCondition(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* GeoGraSim — Persistence Layer
|
||||
*
|
||||
* Dualer Speicher: localStorage (immer, schnell, offline) + PHP-API (wenn Session vorhanden).
|
||||
* Wird von game-ui.ts und den Simulationen verwendet.
|
||||
*/
|
||||
|
||||
interface GGSContext {
|
||||
sessionId: string | null
|
||||
teacherId: number | null
|
||||
baseUrl: string
|
||||
basePath: string
|
||||
}
|
||||
|
||||
function getContext(): GGSContext {
|
||||
return (window as any).__GGS__ ?? { sessionId: null, teacherId: null, baseUrl: '', basePath: '' }
|
||||
}
|
||||
|
||||
function hasSession(): boolean {
|
||||
return !!getContext().sessionId
|
||||
}
|
||||
|
||||
function apiUrl(path: string): string {
|
||||
return getContext().baseUrl + '/api/' + path
|
||||
}
|
||||
|
||||
export const persistence = {
|
||||
|
||||
/**
|
||||
* Spielstand speichern.
|
||||
* Schreibt IMMER in localStorage (schnell + offline).
|
||||
* Spiegelt an PHP-API wenn eine Session existiert (fire-and-forget).
|
||||
*/
|
||||
async save(key: string, data: string, version = 2): Promise<void> {
|
||||
// 1. Immer localStorage (synchron, schnell)
|
||||
try { localStorage.setItem(key, data) } catch { /* quota exceeded */ }
|
||||
|
||||
// 2. Spiegeln an Server wenn Session vorhanden
|
||||
if (!hasSession()) return
|
||||
try {
|
||||
await fetch(apiUrl('saves'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ key, data, version }),
|
||||
})
|
||||
} catch { /* Netzwerk offline — localStorage hat den Save */ }
|
||||
},
|
||||
|
||||
/**
|
||||
* Spielstand laden.
|
||||
* Bevorzugt Server (aktueller ueber Geraete hinweg), Fallback auf localStorage.
|
||||
*/
|
||||
async load(key: string): Promise<string | null> {
|
||||
if (hasSession()) {
|
||||
try {
|
||||
const res = await fetch(apiUrl('saves') + '?key=' + encodeURIComponent(key), {
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.data) return json.data
|
||||
} catch { /* Fallback auf localStorage */ }
|
||||
}
|
||||
return localStorage.getItem(key)
|
||||
},
|
||||
|
||||
/**
|
||||
* Assessment-Daten an den Server senden (fuer Lehrkraefte-Dashboard).
|
||||
*/
|
||||
async submitAssessment(simId: string, data: Record<string, unknown>): Promise<void> {
|
||||
if (!hasSession()) return
|
||||
try {
|
||||
await fetch(apiUrl('assessment'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ simId, ...data }),
|
||||
})
|
||||
} catch { /* best-effort */ }
|
||||
},
|
||||
|
||||
/** Session-Status abfragen */
|
||||
async getSessionStatus(): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const res = await fetch(apiUrl('sessions'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ action: 'status' }),
|
||||
})
|
||||
return await res.json()
|
||||
} catch { return null }
|
||||
},
|
||||
|
||||
/** Einer Klasse beitreten */
|
||||
async joinClass(joinCode: string, displayName: string): Promise<Record<string, unknown>> {
|
||||
const res = await fetch(apiUrl('sessions'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ action: 'join', joinCode, displayName }),
|
||||
})
|
||||
return await res.json()
|
||||
},
|
||||
|
||||
/** Pruefen ob Session existiert (synchron, liest nur window.__GGS__) */
|
||||
hasSession,
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Minimaler Hash-Router
|
||||
* Kein Framework nötig — einfaches Hash-basiertes Routing.
|
||||
*
|
||||
* Routen:
|
||||
* #/ → Landing Page
|
||||
* #/sim/05 → Simulation 05 (Treibhauseffekt)
|
||||
* #/sim/07 → Simulation 07 (Erdbeben)
|
||||
* #/dashboard → Lehrkräfte-Dashboard (später)
|
||||
*/
|
||||
|
||||
type RouteHandler = (params: Record<string, string>) => void
|
||||
|
||||
interface Route {
|
||||
pattern: RegExp
|
||||
handler: RouteHandler
|
||||
}
|
||||
|
||||
export class Router {
|
||||
private routes: Route[] = []
|
||||
private currentCleanup: (() => void) | null = null
|
||||
|
||||
constructor() {
|
||||
window.addEventListener('hashchange', () => this.resolve())
|
||||
window.addEventListener('load', () => this.resolve())
|
||||
}
|
||||
|
||||
on(path: string, handler: RouteHandler): this {
|
||||
// Convert path like '/sim/:id' to regex
|
||||
const pattern = new RegExp(
|
||||
'^' + path.replace(/:[a-zA-Z]+/g, '([^/]+)') + '$'
|
||||
)
|
||||
this.routes.push({ pattern, handler })
|
||||
return this
|
||||
}
|
||||
|
||||
navigate(path: string): void {
|
||||
window.location.hash = path
|
||||
}
|
||||
|
||||
resolve(): void {
|
||||
const hash = window.location.hash.slice(1) || '/'
|
||||
|
||||
for (const route of this.routes) {
|
||||
const match = hash.match(route.pattern)
|
||||
if (match) {
|
||||
// Cleanup previous view
|
||||
if (this.currentCleanup) {
|
||||
this.currentCleanup()
|
||||
this.currentCleanup = null
|
||||
}
|
||||
|
||||
// Extract params
|
||||
const params: Record<string, string> = {}
|
||||
const paramNames = route.pattern.source.match(/\([^)]+\)/g) || []
|
||||
paramNames.forEach((_, i) => {
|
||||
params[`p${i}`] = match[i + 1]
|
||||
})
|
||||
|
||||
route.handler(params)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setCleanup(fn: () => void): void {
|
||||
this.currentCleanup = fn
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Simulation Base Class
|
||||
*
|
||||
* Jede Simulation erbt von dieser Klasse.
|
||||
* Trennung: Logik (testbar ohne Canvas) ↔ Rendering (Canvas)
|
||||
*/
|
||||
|
||||
export interface SimulationMeta {
|
||||
id: string // z.B. "sim-05"
|
||||
name: string // z.B. "Treibhauseffekt-Simulator"
|
||||
|
||||
/**
|
||||
* Unterstützte Schulstufen (internationale Nummerierung, 1–13)
|
||||
* z.B. [5, 6] = 1.+2. Klasse Mittelschule in AT, Klasse 5+6 in DE
|
||||
* Die länderspezifische Bezeichnung wird über education-levels.ts aufgelöst.
|
||||
*/
|
||||
educationLevels: number[]
|
||||
|
||||
/**
|
||||
* Primäre Zielstufe (für Sortierung und Empfehlung)
|
||||
* z.B. 5 = 1. Klasse Mittelschule in AT
|
||||
*/
|
||||
primaryLevel: number
|
||||
|
||||
kompetenzbereich: string
|
||||
lernziele: string[]
|
||||
basiskonzepte: string[]
|
||||
dpiMinuten: number // erwartete Dauer in Minuten
|
||||
typ: 'sachsimulation' | 'planspiel' | 'exploration' | 'rollenspiel' | 'abstimmung'
|
||||
tier: 1 | 2 | 3
|
||||
|
||||
/**
|
||||
* Braucht die Simulation Lesekompetenz?
|
||||
* false = auch für Grundstufe 1 nutzbar (rein visuell/auditiv)
|
||||
*/
|
||||
requiresReading: boolean
|
||||
}
|
||||
|
||||
export interface SimulationState {
|
||||
phase: 'intro' | 'predict' | 'simulate' | 'observe' | 'reflect' | 'complete'
|
||||
startTime: number
|
||||
elapsedMs: number
|
||||
variables: Record<string, number>
|
||||
predictions: Record<string, unknown>
|
||||
results: Record<string, unknown>
|
||||
reflections: string[]
|
||||
}
|
||||
|
||||
export interface AssessmentData {
|
||||
processLog: Array<{
|
||||
timestamp: number
|
||||
action: string
|
||||
variable?: string
|
||||
oldValue?: number
|
||||
newValue?: number
|
||||
}>
|
||||
predictions: Record<string, unknown>
|
||||
results: Record<string, unknown>
|
||||
reflections: string[]
|
||||
duration: number
|
||||
completedPhases: string[]
|
||||
}
|
||||
|
||||
export abstract class Simulation {
|
||||
readonly meta: SimulationMeta
|
||||
protected state: SimulationState
|
||||
|
||||
constructor(meta: SimulationMeta) {
|
||||
this.meta = meta
|
||||
this.state = {
|
||||
phase: 'intro',
|
||||
startTime: Date.now(),
|
||||
elapsedMs: 0,
|
||||
variables: {},
|
||||
predictions: {},
|
||||
results: {},
|
||||
reflections: [],
|
||||
}
|
||||
this._processLog = [] // Sicherstellen, dass das Array existiert, bevor Subklassen Variablen setzen
|
||||
}
|
||||
|
||||
/** Setzt eine Variable und loggt die Änderung */
|
||||
setVariable(name: string, value: number): void {
|
||||
const old = this.state.variables[name]
|
||||
this.state.variables[name] = value
|
||||
this.logAction('set-variable', name, old, value)
|
||||
this.onVariableChange(name, value)
|
||||
}
|
||||
|
||||
/** Holt den aktuellen Wert einer Variable */
|
||||
getVariable(name: string): number {
|
||||
return this.state.variables[name] ?? 0
|
||||
}
|
||||
|
||||
/** Speichert eine Vorhersage (Predict-Phase) */
|
||||
setPrediction(key: string, value: unknown): void {
|
||||
this.state.predictions[key] = value
|
||||
this.logAction('predict', key)
|
||||
}
|
||||
|
||||
/** Speichert eine Reflexion (Reflect-Phase) */
|
||||
addReflection(text: string): void {
|
||||
this.state.reflections.push(text)
|
||||
this.logAction('reflect')
|
||||
}
|
||||
|
||||
/** Wechselt zur nächsten Phase */
|
||||
nextPhase(): void {
|
||||
const phases: SimulationState['phase'][] = ['intro', 'predict', 'simulate', 'observe', 'reflect', 'complete']
|
||||
const idx = phases.indexOf(this.state.phase)
|
||||
if (idx < phases.length - 1) {
|
||||
this.state.phase = phases[idx + 1]
|
||||
this.logAction('phase-change')
|
||||
}
|
||||
}
|
||||
|
||||
/** Gibt Assessment-Daten für das Lehrkräfte-Dashboard */
|
||||
getAssessmentData(): AssessmentData {
|
||||
return {
|
||||
processLog: [...this._processLog],
|
||||
predictions: { ...this.state.predictions },
|
||||
results: { ...this.state.results },
|
||||
reflections: [...this.state.reflections],
|
||||
duration: Date.now() - this.state.startTime,
|
||||
completedPhases: this.getCompletedPhases(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Abstrakte Methoden — jede Simulation implementiert diese ---
|
||||
|
||||
/** Berechne den aktuellen Zustand basierend auf Variablen */
|
||||
abstract compute(): Record<string, number>
|
||||
|
||||
/** Reagiere auf Variablenänderung */
|
||||
protected abstract onVariableChange(name: string, value: number): void
|
||||
|
||||
/** Gib die initialen Variablen und ihre Bereiche zurück */
|
||||
abstract getVariableRanges(): Record<string, { min: number; max: number; default: number; unit: string; label: string }>
|
||||
|
||||
// --- Internes Logging ---
|
||||
|
||||
private _processLog!: AssessmentData['processLog']
|
||||
|
||||
private logAction(action: string, variable?: string, oldValue?: number, newValue?: number): void {
|
||||
this._processLog.push({
|
||||
timestamp: Date.now() - this.state.startTime,
|
||||
action,
|
||||
variable,
|
||||
oldValue,
|
||||
newValue,
|
||||
})
|
||||
}
|
||||
|
||||
private getCompletedPhases(): string[] {
|
||||
const all: SimulationState['phase'][] = ['intro', 'predict', 'simulate', 'observe', 'reflect', 'complete']
|
||||
const idx = all.indexOf(this.state.phase)
|
||||
return all.slice(0, idx + 1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user