Files
geograsim/App/src/core/game-engine.ts
T
Adminator f22c5ebbfe 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>
2026-04-13 16:43:42 +02:00

515 lines
15 KiB
TypeScript
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.
/**
* 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
/** 23 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
}
}