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