/** * 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 } 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 variables: Record 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 = new Map() protected variables: Record = {} protected events: GameEvent[] = [] protected timeline: TimelineEntry[] = [] protected goals: GoalDef[] = [] protected unlockedFeatures: Set = 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 & { 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)) { 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 { return {} } /** Override in Subklasse, um zusätzliche interne Felder wiederherzustellen. */ protected deserializeSubclass(_data: Record): 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 = {} 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 } }