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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import './styles/base.css'
|
||||
import { ScenicBackground } from './ui/animations/scenic-bg'
|
||||
|
||||
// Scenic Background starten
|
||||
const heroSection = document.getElementById('hero')
|
||||
if (heroSection) {
|
||||
const scenic = new ScenicBackground(heroSection)
|
||||
scenic.start()
|
||||
}
|
||||
|
||||
console.log('🌍 GeoGraSim geladen')
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,806 @@
|
||||
/**
|
||||
* Klimawächter — Canvas Renderer (V3)
|
||||
*
|
||||
* Designprinzipien (nach User-Feedback):
|
||||
* - RUHE: Kein Tag-Nacht-Wechsel, immer Tagslicht
|
||||
* - DEZENT: Jahreszeiten als sehr leichte Farbverschiebung, keine Vollbild-Effekte
|
||||
* - KLIMASTRESS sichtbar: Mit steigender Temperatur wird Himmel gelblicher,
|
||||
* Land trockener — das ist die einzige langfristige Farbveränderung
|
||||
* - DETERMINISTISCHE BAUTEN: Position fest beim Bauen
|
||||
* - ZEITLEISTE am unteren Rand: 2025 ━━●━━━ 2100
|
||||
* - LEBEN am Rand: Möwen, Hintergrundschiffe, springender Fisch
|
||||
*
|
||||
* Zeitraum: 2025–2100 (75 Jahre, 75 Ticks)
|
||||
*/
|
||||
|
||||
import { KlimawaechterGame } from './game'
|
||||
|
||||
interface PlacedObject {
|
||||
type: 'tree' | 'solar' | 'wind' | 'green-roof' | 'dike' | 'sea-wall'
|
||||
x: number // 0..1
|
||||
scale: number
|
||||
ownerId: string
|
||||
builtTick: number
|
||||
}
|
||||
|
||||
interface Bird {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
wingPhase: number
|
||||
size: number
|
||||
}
|
||||
|
||||
interface BgShip {
|
||||
x: number
|
||||
speed: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export class KlimawaechterRenderer {
|
||||
private canvas: HTMLCanvasElement
|
||||
private ctx: CanvasRenderingContext2D
|
||||
private game: KlimawaechterGame
|
||||
private W = 0
|
||||
private H = 0
|
||||
private t = 0
|
||||
private animId = 0
|
||||
private placed: PlacedObject[] = []
|
||||
private knownIds = new Set<string>()
|
||||
private lastTick = -1
|
||||
private tickStartT = 0
|
||||
|
||||
private birds: Bird[] = []
|
||||
private bgShips: BgShip[] = []
|
||||
private fishTimer = 0
|
||||
private fishX = 0
|
||||
private fishPhase = -1
|
||||
|
||||
private houseSeeds: number[] = []
|
||||
private landSurfaceFn: (x: number) => number = () => 0
|
||||
|
||||
constructor(container: HTMLElement, game: KlimawaechterGame) {
|
||||
this.game = game
|
||||
this.canvas = document.createElement('canvas')
|
||||
this.canvas.style.cssText = 'width:100%;display:block;border-radius:12px;background:#dde3da;'
|
||||
container.appendChild(this.canvas)
|
||||
const ctx = this.canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Canvas not supported')
|
||||
this.ctx = ctx
|
||||
|
||||
this.resize()
|
||||
this.initFauna()
|
||||
window.addEventListener('resize', () => this.resize())
|
||||
}
|
||||
|
||||
private resize(): void {
|
||||
const rect = this.canvas.parentElement!.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
this.W = rect.width
|
||||
this.H = Math.min(rect.width * 0.55, 420)
|
||||
this.canvas.width = this.W * dpr
|
||||
this.canvas.height = this.H * dpr
|
||||
this.canvas.style.height = this.H + 'px'
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
}
|
||||
|
||||
private initFauna(): void {
|
||||
if (this.houseSeeds.length === 0) {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
this.houseSeeds.push(this.seededRand(i * 7919) * 0.04 - 0.02)
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 4; i++) {
|
||||
this.birds.push({
|
||||
x: Math.random() * this.W,
|
||||
y: this.H * (0.05 + Math.random() * 0.18),
|
||||
vx: 0.15 + Math.random() * 0.2,
|
||||
wingPhase: Math.random() * Math.PI * 2,
|
||||
size: 3 + Math.random() * 3,
|
||||
})
|
||||
}
|
||||
this.bgShips = [
|
||||
{ x: this.W * 0.1, speed: 0.05, size: 0.7 },
|
||||
{ x: this.W * 0.6, speed: 0.03, size: 0.5 },
|
||||
]
|
||||
}
|
||||
|
||||
private seededRand(seed: number): number {
|
||||
const x = Math.sin(seed * 12.9898) * 43758.5453
|
||||
return x - Math.floor(x)
|
||||
}
|
||||
|
||||
start(): void {
|
||||
let lastFrame = performance.now()
|
||||
const loop = (now: number) => {
|
||||
const dt = (now - lastFrame) / 1000
|
||||
lastFrame = now
|
||||
this.t += dt
|
||||
this.updateScene()
|
||||
this.draw()
|
||||
this.animId = requestAnimationFrame(loop)
|
||||
}
|
||||
this.animId = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
cancelAnimationFrame(this.animId)
|
||||
}
|
||||
|
||||
private updateScene(): void {
|
||||
const snap = this.game.getSnapshot()
|
||||
|
||||
if (snap.tick !== this.lastTick) {
|
||||
this.lastTick = snap.tick
|
||||
this.tickStartT = this.t
|
||||
}
|
||||
|
||||
const owned = this.game.getOwnedMeasures()
|
||||
for (const m of owned) {
|
||||
for (let i = 0; i < m.count; i++) {
|
||||
const id = `${m.measureId}-${i}`
|
||||
if (!this.knownIds.has(id)) {
|
||||
this.knownIds.add(id)
|
||||
this.placed.push(this.placeMeasure(m.measureId, i, snap.tick))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const b of this.birds) {
|
||||
b.x += b.vx
|
||||
b.wingPhase += 0.18
|
||||
b.y += Math.sin(this.t * 0.6 + b.wingPhase * 0.3) * 0.1
|
||||
if (b.x > this.W + 30) {
|
||||
b.x = -30
|
||||
b.y = this.H * (0.05 + Math.random() * 0.18)
|
||||
}
|
||||
}
|
||||
for (const s of this.bgShips) {
|
||||
s.x += s.speed
|
||||
if (s.x > this.W + 80) s.x = -80
|
||||
}
|
||||
}
|
||||
|
||||
private placeMeasure(id: string, index: number, tick: number): PlacedObject {
|
||||
const baseScale = 0.85 + this.seededRand(index * 991 + 31) * 0.3
|
||||
const stableId = `${id}-${index}`
|
||||
|
||||
if (id === 'forest') {
|
||||
const slot = index % 6
|
||||
const x = 0.04 + slot * 0.025 + this.seededRand(index * 13 + 7) * 0.015
|
||||
return { type: 'tree', x, scale: baseScale, ownerId: stableId, builtTick: tick }
|
||||
}
|
||||
if (id === 'solar') {
|
||||
const slot = index % 5
|
||||
const x = 0.78 - slot * 0.035 + this.seededRand(index * 17 + 3) * 0.01
|
||||
return { type: 'solar', x, scale: 0.95, ownerId: stableId, builtTick: tick }
|
||||
}
|
||||
if (id === 'wind') {
|
||||
const slot = index % 4
|
||||
const x = 0.02 + slot * 0.04 + this.seededRand(index * 23 + 11) * 0.01
|
||||
return { type: 'wind', x, scale: 1, ownerId: stableId, builtTick: tick }
|
||||
}
|
||||
if (id === 'green-roof') {
|
||||
return { type: 'green-roof', x: 0, scale: 1, ownerId: stableId, builtTick: tick }
|
||||
}
|
||||
if (id === 'dike') {
|
||||
const slot = index % 4
|
||||
const x = 0.83 + slot * 0.035
|
||||
return { type: 'dike', x, scale: 1, ownerId: stableId, builtTick: tick }
|
||||
}
|
||||
if (id === 'sea-wall') {
|
||||
const x = 0.95
|
||||
return { type: 'sea-wall', x, scale: 1 + index * 0.08, ownerId: stableId, builtTick: tick }
|
||||
}
|
||||
return { type: 'tree', x: 0.5, scale: 1, ownerId: stableId, builtTick: tick }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// RENDERING
|
||||
// ============================================================
|
||||
|
||||
private draw(): void {
|
||||
const { ctx, W, H } = this
|
||||
ctx.clearRect(0, 0, W, H)
|
||||
|
||||
const snap = this.game.getSnapshot()
|
||||
const co2 = snap.resources.co2 ?? 425
|
||||
const temp = snap.resources.temperature ?? 15
|
||||
const seaCm = snap.resources.sealevel ?? 0
|
||||
const flooded = snap.resources.flooded ?? 0
|
||||
const speed = snap.speed || 1
|
||||
|
||||
// Klimastress: subtiler Farbwandel über die Zeit
|
||||
// Temperatur 15 → 19+ → Himmel wird gelblicher, Land trockener
|
||||
const tempStress = Math.max(0, Math.min(1, (temp - 15) / 4))
|
||||
const co2Stress = Math.max(0, Math.min(1, (co2 - 400) / 400))
|
||||
|
||||
// Saison-Fortschritt innerhalb des aktuellen Jahres (für sehr dezente Akzente)
|
||||
const msPerTick = 4000 / Math.max(0.001, speed)
|
||||
const elapsedInTick = (this.t - this.tickStartT) * 1000
|
||||
const tickProgress = Math.min(1, elapsedInTick / msPerTick)
|
||||
const seasonF = tickProgress * 4
|
||||
const seasonIdx = Math.floor(seasonF) % 4
|
||||
const seasonBlend = seasonF - Math.floor(seasonF)
|
||||
|
||||
// Reservierter Bereich für die Zeitleiste am unteren Rand
|
||||
const timelineH = 28
|
||||
const sceneH = H - timelineH
|
||||
const groundY = sceneH * 0.7
|
||||
const baseSeaY = sceneH * 0.78
|
||||
const seaY = baseSeaY - Math.min(baseSeaY * 0.2, seaCm * 0.4)
|
||||
|
||||
// ===== HIMMEL — basiert auf Klimastress, NICHT auf Tageszeit =====
|
||||
const skyTopBase = [200, 215, 210] // hell-bläulich
|
||||
const skyMidBase = [215, 225, 215]
|
||||
const skyBottomBase = [225, 230, 215]
|
||||
|
||||
// Klimastress: Himmel wird gelb-bräunlicher
|
||||
const stressedSky = (base: number[]) => {
|
||||
const r = Math.round(base[0] + tempStress * 25 + co2Stress * 10)
|
||||
const g = Math.round(base[1] + tempStress * 5 - co2Stress * 5)
|
||||
const b = Math.round(base[2] - tempStress * 30 - co2Stress * 25)
|
||||
return `rgb(${r},${g},${b})`
|
||||
}
|
||||
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, groundY)
|
||||
sky.addColorStop(0, stressedSky(skyTopBase))
|
||||
sky.addColorStop(0.6, stressedSky(skyMidBase))
|
||||
sky.addColorStop(1, stressedSky(skyBottomBase))
|
||||
ctx.fillStyle = sky
|
||||
ctx.fillRect(0, 0, W, groundY)
|
||||
|
||||
// ===== SONNE — fix oben rechts, dezent =====
|
||||
const sunX = W * 0.85
|
||||
const sunY = sceneH * 0.13
|
||||
const sunGlow = ctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, 60)
|
||||
sunGlow.addColorStop(0, 'rgba(255,235,180,0.35)')
|
||||
sunGlow.addColorStop(1, 'rgba(255,235,180,0)')
|
||||
ctx.fillStyle = sunGlow
|
||||
ctx.fillRect(sunX - 60, sunY - 60, 120, 120)
|
||||
// Sonnen-Farbe wird leicht orange-rot bei Klimastress
|
||||
const sunR = 240 + tempStress * 15
|
||||
const sunG = 200 - tempStress * 30
|
||||
const sunB = 100 - tempStress * 30
|
||||
ctx.fillStyle = `rgb(${sunR},${sunG},${sunB})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(sunX, sunY, 18, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// ===== WOLKEN — dezent driftend =====
|
||||
const cloudOffset = this.t * 4
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const baseX = (i / 4) * W * 1.4 - W * 0.1
|
||||
const cx = ((baseX + cloudOffset * (1 + i * 0.1)) % (W + 100)) - 50
|
||||
const cy = sceneH * (0.08 + i * 0.04)
|
||||
const cloudOpacity = 0.7 - co2Stress * 0.2
|
||||
this.drawCloud(ctx, cx, cy, 0.7 + (i % 3) * 0.15, `rgba(255,255,255,${cloudOpacity})`)
|
||||
}
|
||||
|
||||
// ===== KEIL-INSEL — schräg ansteigend von rechts (Meer) nach links (Vulkan) =====
|
||||
// landSurface(x) = y-Koordinate der Landoberfläche an horizontaler Position x
|
||||
// Hinten/links hoch, vorne/rechts niedrig (sinkt knapp unter baseSeaY)
|
||||
const landHighY = groundY - 40 // linke Seite (hinter Vulkan)
|
||||
const landLowY = baseSeaY - 6 // rechte Seite — knapp über Wasser
|
||||
const landSurface = (x: number): number => {
|
||||
const t = x / W // 0 links → 1 rechts
|
||||
// Leicht quadratisch abfallend für natürlicheren Keil
|
||||
const ease = t * t * 0.4 + t * 0.6
|
||||
const base = landHighY * (1 - ease) + landLowY * ease
|
||||
// Kleine Welligkeit
|
||||
return base + Math.sin(x * 0.02) * 3 + Math.sin(x * 0.006 + 1.2) * 4
|
||||
}
|
||||
// Speicher als Render-State für Maßnahmen-Platzierung
|
||||
this.landSurfaceFn = landSurface
|
||||
|
||||
// Hintergrund-Berge (weit links, entsprechen weiterem Hinterland)
|
||||
const mountainR = 150 + tempStress * 25
|
||||
const mountainG = 165 - tempStress * 30
|
||||
const mountainB = 150 - tempStress * 35
|
||||
ctx.fillStyle = `rgb(${mountainR},${mountainG},${mountainB})`
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, landSurface(0) - 20)
|
||||
for (let x = 0; x <= W * 0.6; x += 25) {
|
||||
const my = landSurface(x) - 30 - Math.sin(x * 0.007 + 1) * 18 - Math.sin(x * 0.014 + 0.3) * 10
|
||||
ctx.lineTo(x, my)
|
||||
}
|
||||
ctx.lineTo(W * 0.6, landSurface(W * 0.6))
|
||||
ctx.lineTo(0, landSurface(0))
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
// === VULKAN mit Gletscher-Deckel (links, ragt aus der Landschaft) ===
|
||||
const volcanoBaseX = W * 0.16
|
||||
const volcanoBaseY = landSurface(volcanoBaseX) - 2
|
||||
const volcanoHeight = 120
|
||||
const volcanoHalfBase = 55
|
||||
const volcanoTopHalf = 14
|
||||
// Fels
|
||||
ctx.fillStyle = `rgb(${106 + tempStress * 20},${90 + tempStress * 10},${74})`
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(volcanoBaseX - volcanoHalfBase, volcanoBaseY)
|
||||
ctx.lineTo(volcanoBaseX - volcanoTopHalf, volcanoBaseY - volcanoHeight)
|
||||
ctx.lineTo(volcanoBaseX + volcanoTopHalf, volcanoBaseY - volcanoHeight)
|
||||
ctx.lineTo(volcanoBaseX + volcanoHalfBase, volcanoBaseY)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
// Krater-Delle
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.25)'
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(volcanoBaseX, volcanoBaseY - volcanoHeight + 1, volcanoTopHalf - 2, 2, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
// Gletscher-Deckel (1/3 der Höhe, opak, schrumpft mit Temperatur)
|
||||
const glacierFraction = Math.max(0, Math.min(1, 1 - (temp - 15) / 3))
|
||||
if (glacierFraction > 0.02) {
|
||||
const glacierFullH = volcanoHeight / 3
|
||||
const glacierH = glacierFullH * glacierFraction
|
||||
const glacierBaseY = volcanoBaseY - (volcanoHeight - glacierFullH + glacierFullH - glacierH)
|
||||
const grime = (1 - glacierFraction) * 40
|
||||
// Die Deckel-Form ist ein Trapez, breiter als der Vulkan-Top
|
||||
const capBottomHalf = (volcanoTopHalf + 6) * (0.5 + glacierFraction * 0.5)
|
||||
const capTopHalf = volcanoTopHalf * (0.6 + glacierFraction * 0.4)
|
||||
ctx.fillStyle = `rgb(${240 - grime},${246 - grime},${250 - grime})`
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(volcanoBaseX - capBottomHalf, glacierBaseY)
|
||||
ctx.lineTo(volcanoBaseX - capTopHalf, glacierBaseY - glacierH)
|
||||
ctx.lineTo(volcanoBaseX + capTopHalf, glacierBaseY - glacierH)
|
||||
ctx.lineTo(volcanoBaseX + capBottomHalf, glacierBaseY)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
// Schatten-Linie unten am Deckel
|
||||
ctx.strokeStyle = `rgba(140,150,160,${0.3 * glacierFraction})`
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(volcanoBaseX - capBottomHalf, glacierBaseY)
|
||||
ctx.lineTo(volcanoBaseX + capBottomHalf, glacierBaseY)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// ===== LAND (Keil) — Grasfarbe verändert sich mit Klimastress =====
|
||||
const grassR = 140 + tempStress * 30
|
||||
const grassG = 165 - tempStress * 35
|
||||
const grassB = 110 - tempStress * 25
|
||||
ctx.fillStyle = `rgb(${grassR},${grassG},${grassB})`
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, landSurface(0))
|
||||
for (let x = 0; x <= W; x += 10) {
|
||||
ctx.lineTo(x, landSurface(x))
|
||||
}
|
||||
// Rechte untere Ecke: in den Boden / unter die Wasserlinie
|
||||
ctx.lineTo(W, sceneH)
|
||||
ctx.lineTo(0, sceneH)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
// Dünner Bodenstreifen direkt unter dem Gras
|
||||
ctx.fillStyle = `rgb(${110 + tempStress * 20},${100 - tempStress * 15},${80 - tempStress * 20})`
|
||||
for (let x = 0; x <= W; x += 4) {
|
||||
ctx.fillRect(x, landSurface(x) + 4, 4, 6)
|
||||
}
|
||||
|
||||
// ===== BÄUME =====
|
||||
for (const obj of this.placed.filter(p => p.type === 'tree')) {
|
||||
const px = obj.x * W
|
||||
this.drawTree(ctx, px, landSurface(px) - 1, obj.scale, tempStress)
|
||||
}
|
||||
|
||||
// ===== WINDRÄDER (stehen auf dem Keil — weiter oben links ist windiger) =====
|
||||
for (const obj of this.placed.filter(p => p.type === 'wind')) {
|
||||
const px = obj.x * W
|
||||
this.drawWind(ctx, px, landSurface(px) - 5, this.t)
|
||||
}
|
||||
|
||||
// ===== HÄUSER — bevorzugt auf der höheren (linken) Seite =====
|
||||
const greenRoofs = this.placed.filter(s => s.type === 'green-roof').length
|
||||
const totalHouses = 12
|
||||
const floodedCount = Math.round((flooded / 100) * totalHouses)
|
||||
for (let i = 0; i < totalHouses; i++) {
|
||||
// Häuser eher im Mittelteil/links (0.28..0.72) — Tiefland rechts bleibt leer
|
||||
const x = (0.28 + (i / totalHouses) * 0.44 + this.houseSeeds[i] * 0.8) * W
|
||||
const isFlooded = i >= (totalHouses - floodedCount)
|
||||
const hasGreenRoof = i < greenRoofs
|
||||
this.drawHouse(ctx, x, landSurface(x) - 1, 0.95 + this.houseSeeds[i] * 4, isFlooded, hasGreenRoof)
|
||||
}
|
||||
|
||||
// ===== SOLARANLAGEN =====
|
||||
for (const obj of this.placed.filter(p => p.type === 'solar')) {
|
||||
const px = obj.x * W
|
||||
this.drawSolar(ctx, px, landSurface(px) - 4)
|
||||
}
|
||||
|
||||
// ===== MEER =====
|
||||
const seaR = 145 - tempStress * 10
|
||||
const seaG = 175 - tempStress * 15
|
||||
const seaB = 175 - tempStress * 5
|
||||
const seaGrad = ctx.createLinearGradient(0, seaY, 0, sceneH)
|
||||
seaGrad.addColorStop(0, `rgb(${seaR},${seaG},${seaB})`)
|
||||
seaGrad.addColorStop(1, `rgb(${seaR - 25},${seaG - 25},${seaB - 15})`)
|
||||
ctx.fillStyle = seaGrad
|
||||
ctx.fillRect(0, seaY, W, sceneH - seaY)
|
||||
|
||||
// Hintergrundschiffe
|
||||
for (const ship of this.bgShips) {
|
||||
this.drawBgShip(ctx, ship.x, seaY - 4, ship.size)
|
||||
}
|
||||
|
||||
// Wellen
|
||||
for (let i = 0; i < 4; i++) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, seaY)
|
||||
for (let x = 0; x <= W; x += 4) {
|
||||
ctx.lineTo(x, seaY + Math.sin(x * 0.02 + this.t * (1 + i * 0.5)) * (1.2 + i))
|
||||
}
|
||||
ctx.lineTo(W, sceneH); ctx.lineTo(0, sceneH); ctx.closePath()
|
||||
ctx.fillStyle = `rgba(255,255,255,${0.04 - i * 0.008})`
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// Sonnenreflexion auf dem Wasser (immer, da Sonne fix oben)
|
||||
ctx.globalAlpha = 0.25
|
||||
ctx.fillStyle = `rgb(${sunR},${sunG},${sunB})`
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const ry = seaY + 4 + i * 4
|
||||
const rw = 24 - i * 3 + Math.sin(this.t * 2 + i) * 3
|
||||
ctx.fillRect(sunX - rw / 2, ry, rw, 1)
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
|
||||
// ===== DEICHE & MAUERN =====
|
||||
for (const obj of this.placed.filter(p => p.type === 'dike')) {
|
||||
this.drawDike(ctx, obj.x * W, groundY)
|
||||
}
|
||||
for (const obj of this.placed.filter(p => p.type === 'sea-wall')) {
|
||||
this.drawSeaWall(ctx, obj.x * W, groundY - 18, baseSeaY)
|
||||
}
|
||||
|
||||
// ===== BEWOHNER =====
|
||||
const popLossPct = Math.max(0, 10000 - snap.resources.population) / 10000
|
||||
const peopleCount = Math.round(8 * (1 - popLossPct))
|
||||
for (let i = 0; i < peopleCount; i++) {
|
||||
const px = W * (0.22 + (i / 8) * 0.58 + Math.sin(this.t * 0.5 + i) * 0.003)
|
||||
const py = groundY - 1
|
||||
ctx.fillStyle = '#3a3a3a'
|
||||
ctx.fillRect(px - 1, py - 5, 2, 5)
|
||||
ctx.beginPath()
|
||||
ctx.arc(px, py - 6, 1.4, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// ===== VÖGEL =====
|
||||
for (const b of this.birds) {
|
||||
this.drawBird(ctx, b)
|
||||
}
|
||||
|
||||
// ===== SPRINGENDER FISCH =====
|
||||
this.fishTimer -= 0.016
|
||||
if (this.fishTimer <= 0) {
|
||||
this.fishTimer = 8 + Math.random() * 12
|
||||
this.fishX = W * (0.55 + Math.random() * 0.35)
|
||||
this.fishPhase = 0
|
||||
}
|
||||
if (this.fishPhase >= 0 && this.fishPhase < 1) {
|
||||
this.fishPhase += 0.018
|
||||
const fy = seaY - Math.sin(this.fishPhase * Math.PI) * 18
|
||||
const rot = -Math.PI * 0.3 + this.fishPhase * Math.PI * 0.6
|
||||
ctx.save()
|
||||
ctx.translate(this.fishX, fy)
|
||||
ctx.rotate(rot)
|
||||
ctx.globalAlpha = 0.5
|
||||
ctx.fillStyle = '#5a7a7e'
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(0, 0, 5, 2, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-5, 0); ctx.lineTo(-8, -2); ctx.lineTo(-8, 2); ctx.closePath()
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
// ===== DEZENTE SAISON-AKZENTE (sehr klein, nicht aufdringlich) =====
|
||||
if (seasonIdx === 2) this.drawLeaves(ctx, seasonBlend, sceneH)
|
||||
if (seasonIdx === 3) this.drawSnow(ctx, seasonBlend, sceneH)
|
||||
|
||||
// ===== ZEITLEISTE am unteren Rand =====
|
||||
this.drawTimeline(ctx, timelineH, snap.tick, snap.events)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ZEITLEISTE
|
||||
// ============================================================
|
||||
|
||||
private drawTimeline(ctx: CanvasRenderingContext2D, h: number, currentTick: number, events: any[]): void {
|
||||
const { W, H } = this
|
||||
const y = H - h
|
||||
const marginX = 40
|
||||
|
||||
// Hintergrund
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.92)'
|
||||
ctx.fillRect(0, y, W, h)
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.06)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, y); ctx.lineTo(W, y)
|
||||
ctx.stroke()
|
||||
|
||||
// Zeitleiste
|
||||
const lineY = y + h / 2 + 2
|
||||
const lineX0 = marginX
|
||||
const lineX1 = W - marginX
|
||||
|
||||
// Linie
|
||||
ctx.strokeStyle = '#c8c4b8'
|
||||
ctx.lineWidth = 2
|
||||
ctx.lineCap = 'round'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(lineX0, lineY); ctx.lineTo(lineX1, lineY)
|
||||
ctx.stroke()
|
||||
|
||||
// Dezimal-Markierungen (alle 10 Jahre)
|
||||
const totalTicks = 75
|
||||
for (let i = 0; i <= 7; i++) {
|
||||
const decade = i * 10
|
||||
const x = lineX0 + (decade / totalTicks) * (lineX1 - lineX0)
|
||||
ctx.strokeStyle = '#a8a497'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, lineY - 3); ctx.lineTo(x, lineY + 3)
|
||||
ctx.stroke()
|
||||
// Jahreszahl
|
||||
if (i === 0 || i === 7 || i % 2 === 0) {
|
||||
ctx.fillStyle = '#7a7468'
|
||||
ctx.font = '9px Inter, system-ui, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(`${2025 + decade}`, x, lineY + 14)
|
||||
}
|
||||
}
|
||||
|
||||
// Aktueller Stand — runder Marker
|
||||
const progress = Math.min(1, currentTick / totalTicks)
|
||||
const markerX = lineX0 + progress * (lineX1 - lineX0)
|
||||
|
||||
// Track bis hierher leicht hervorheben
|
||||
ctx.strokeStyle = '#4a7c8a'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(lineX0, lineY); ctx.lineTo(markerX, lineY)
|
||||
ctx.stroke()
|
||||
|
||||
// Marker-Kreis
|
||||
ctx.fillStyle = '#4a7c8a'
|
||||
ctx.beginPath()
|
||||
ctx.arc(markerX, lineY, 5, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.beginPath()
|
||||
ctx.arc(markerX, lineY, 2, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Aktuelles Jahr über dem Marker
|
||||
const currentYear = 2025 + currentTick
|
||||
ctx.fillStyle = '#4a7c8a'
|
||||
ctx.font = 'bold 10px Inter, system-ui, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(`${currentYear}`, markerX, lineY - 8)
|
||||
|
||||
// Event-Marker auf der Linie (kleine Punkte)
|
||||
for (const ev of events) {
|
||||
if (ev.tick > currentTick) continue
|
||||
const ex = lineX0 + (ev.tick / totalTicks) * (lineX1 - lineX0)
|
||||
ctx.fillStyle = ev.severity === 'danger' ? '#c0503c' : ev.severity === 'warning' ? '#c4a35a' : ev.severity === 'success' ? '#5a8a5e' : '#8a8a8a'
|
||||
ctx.beginPath()
|
||||
ctx.arc(ex, lineY - 8, 2, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DRAW HELPERS
|
||||
// ============================================================
|
||||
|
||||
private drawCloud(ctx: CanvasRenderingContext2D, x: number, y: number, s: number, color: string): void {
|
||||
ctx.fillStyle = color
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(x, y, 24*s, 9*s, 0, 0, Math.PI*2); ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(x-13*s, y+2*s, 17*s, 7*s, 0, 0, Math.PI*2); ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(x+14*s, y+2*s, 18*s, 7*s, 0, 0, Math.PI*2); ctx.fill()
|
||||
}
|
||||
|
||||
private drawTree(ctx: CanvasRenderingContext2D, x: number, y: number, s: number, stress: number): void {
|
||||
// Bei Klimastress: Bäume werden bräunlicher
|
||||
const leafG = 130 - stress * 40
|
||||
const leafR = 90 + stress * 60
|
||||
const leafB = 80 - stress * 30
|
||||
const shadeG = 100 - stress * 35
|
||||
const shadeR = 70 + stress * 55
|
||||
const shadeB = 60 - stress * 25
|
||||
|
||||
ctx.fillStyle = '#5a4a3a'
|
||||
ctx.fillRect(x - 1.5*s, y - 9*s, 3*s, 9*s)
|
||||
ctx.fillStyle = `rgb(${shadeR},${shadeG},${shadeB})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(x - 4*s, y - 10*s, 6*s, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.arc(x + 4*s, y - 10*s, 6*s, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.fillStyle = `rgb(${leafR},${leafG},${leafB})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y - 14*s, 7*s, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.arc(x - 3*s, y - 11*s, 5*s, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.arc(x + 3*s, y - 11*s, 5*s, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
private drawHouse(ctx: CanvasRenderingContext2D, x: number, y: number, s: number, flooded: boolean, greenRoof: boolean): void {
|
||||
if (flooded) {
|
||||
ctx.globalAlpha = 0.6
|
||||
ctx.fillStyle = '#a08878'
|
||||
ctx.fillRect(x - 7*s, y - 8*s, 14*s, 8*s)
|
||||
ctx.fillStyle = '#5a4a3a'
|
||||
ctx.fillRect(x - 7*s, y - 4*s, 14*s, 4*s)
|
||||
ctx.globalAlpha = 1
|
||||
return
|
||||
}
|
||||
|
||||
// Wand
|
||||
ctx.fillStyle = '#e8d8b8'
|
||||
ctx.fillRect(x - 8*s, y - 14*s, 16*s, 14*s)
|
||||
|
||||
// Dach
|
||||
if (greenRoof) {
|
||||
ctx.fillStyle = '#6aa86e'
|
||||
} else {
|
||||
ctx.fillStyle = '#b06a5a'
|
||||
}
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - 10*s, y - 14*s)
|
||||
ctx.lineTo(x, y - 22*s)
|
||||
ctx.lineTo(x + 10*s, y - 14*s)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
// Fenster
|
||||
ctx.fillStyle = '#a8c8d0'
|
||||
ctx.fillRect(x - 3*s, y - 11*s, 6*s, 5*s)
|
||||
// Tür
|
||||
ctx.fillStyle = '#6a4a3a'
|
||||
ctx.fillRect(x - 2*s, y - 5*s, 4*s, 5*s)
|
||||
}
|
||||
|
||||
private drawSolar(ctx: CanvasRenderingContext2D, x: number, y: number): void {
|
||||
const tilt = -0.3
|
||||
ctx.save()
|
||||
ctx.translate(x, y)
|
||||
ctx.rotate(tilt)
|
||||
ctx.fillStyle = 'rgba(255,220,150,0.3)'
|
||||
ctx.fillRect(-10, -12, 20, 12)
|
||||
ctx.fillStyle = '#3a4a6a'
|
||||
ctx.fillRect(-8, -10, 16, 8)
|
||||
ctx.strokeStyle = '#5a6a8a'
|
||||
ctx.lineWidth = 0.5
|
||||
for (let i = -6; i <= 6; i += 4) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(i, -10); ctx.lineTo(i, -2)
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-8, -6); ctx.lineTo(8, -6)
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
ctx.fillStyle = '#5a5a5a'
|
||||
ctx.fillRect(x - 1, y - 8, 2, 8)
|
||||
}
|
||||
|
||||
private drawWind(ctx: CanvasRenderingContext2D, x: number, y: number, t: number): void {
|
||||
ctx.fillStyle = '#e0dcd0'
|
||||
ctx.fillRect(x - 1.5, y - 30, 3, 30)
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y - 30, 2.5, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
const angle = t * 1.8
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const a = angle + (i / 3) * Math.PI * 2
|
||||
ctx.save()
|
||||
ctx.translate(x, y - 30)
|
||||
ctx.rotate(a)
|
||||
ctx.fillStyle = '#f0ece0'
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(0, -8, 1.2, 10, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
private drawDike(ctx: CanvasRenderingContext2D, x: number, groundY: number): void {
|
||||
ctx.fillStyle = '#8a7a6a'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - 12, groundY)
|
||||
ctx.lineTo(x - 6, groundY - 14)
|
||||
ctx.lineTo(x + 6, groundY - 14)
|
||||
ctx.lineTo(x + 12, groundY)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
ctx.fillStyle = '#6a5a4a'
|
||||
ctx.fillRect(x - 7, groundY - 16, 14, 2)
|
||||
}
|
||||
|
||||
private drawSeaWall(ctx: CanvasRenderingContext2D, x: number, topY: number, baseY: number): void {
|
||||
ctx.fillStyle = '#a8a8a8'
|
||||
ctx.fillRect(x - 4, topY, 8, baseY - topY + 10)
|
||||
ctx.fillStyle = '#888'
|
||||
ctx.fillRect(x - 5, topY, 10, 3)
|
||||
}
|
||||
|
||||
private drawBird(ctx: CanvasRenderingContext2D, b: Bird): void {
|
||||
const wing = Math.sin(b.wingPhase) * 0.5
|
||||
ctx.globalAlpha = 0.5
|
||||
ctx.strokeStyle = '#3a3a3a'
|
||||
ctx.lineWidth = 1.3
|
||||
ctx.lineCap = 'round'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(b.x - b.size, b.y - wing * b.size)
|
||||
ctx.quadraticCurveTo(b.x, b.y + 1.5, b.x + b.size, b.y - wing * b.size)
|
||||
ctx.stroke()
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
private drawBgShip(ctx: CanvasRenderingContext2D, x: number, y: number, s: number): void {
|
||||
ctx.globalAlpha = 0.4
|
||||
ctx.fillStyle = '#6a5a5a'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - 14 * s, y)
|
||||
ctx.lineTo(x - 11 * s, y + 5 * s)
|
||||
ctx.lineTo(x + 11 * s, y + 5 * s)
|
||||
ctx.lineTo(x + 14 * s, y)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = '#4a4a4a'
|
||||
ctx.lineWidth = 0.8
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, y); ctx.lineTo(x, y - 14 * s)
|
||||
ctx.stroke()
|
||||
ctx.fillStyle = '#e8e3d8'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + 1, y - 13 * s)
|
||||
ctx.lineTo(x + 9 * s, y - 2 * s)
|
||||
ctx.lineTo(x + 1, y - 1 * s)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
private drawLeaves(ctx: CanvasRenderingContext2D, blend: number, sceneH: number): void {
|
||||
// Sehr dezent — nur 4 Blätter
|
||||
ctx.globalAlpha = blend * 0.3
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const lx = (i * 173 + this.t * 6) % this.W
|
||||
const ly = ((this.t * 8 + i * 89) % (sceneH * 0.7))
|
||||
ctx.fillStyle = i % 2 ? '#c87a3a' : '#a85a2a'
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(lx, ly, 1.5, 0.8, this.t + i, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
private drawSnow(ctx: CanvasRenderingContext2D, blend: number, sceneH: number): void {
|
||||
// Sehr dezent — nur 8 Flocken
|
||||
ctx.globalAlpha = Math.min(1, blend * 1.5) * 0.3
|
||||
ctx.fillStyle = '#fff'
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const sx = (i * 119 + this.t * 4) % this.W
|
||||
const sy = ((this.t * 10 + i * 89) % (sceneH * 0.85))
|
||||
ctx.beginPath()
|
||||
ctx.arc(sx + Math.sin(this.t + i) * 3, sy, 1, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* SIM-05: Treibhauseffekt-Simulator — LOGIK
|
||||
*
|
||||
* Vereinfachtes Klimamodell:
|
||||
* - Sonneneinstrahlung (konstant ~1361 W/m²)
|
||||
* - Albedo (Reflexion, ~0.3)
|
||||
* - CO₂-Konzentration beeinflusst Treibhauseffekt
|
||||
* - Ergebnis: Gleichgewichtstemperatur der Erde
|
||||
*
|
||||
* Gezielt gegen Fehlkonzept: "Ozonloch = Klimawandel"
|
||||
* (Schuler 2011, Reinfried et al. 2010)
|
||||
*
|
||||
* Didaktischer Ablauf: Predict → Observe → Explain
|
||||
*/
|
||||
|
||||
import { Simulation, SimulationMeta } from '@core/simulation'
|
||||
|
||||
const META: SimulationMeta = {
|
||||
id: 'sim-05',
|
||||
name: 'Treibhauseffekt-Simulator',
|
||||
educationLevels: [5, 6, 7, 8], // AT: 1.–4. Kl. MS, DE: 5.–8., CH: Zyklus 3
|
||||
primaryLevel: 5, // Primär für AT 1. Klasse MS (= 5. Schulstufe)
|
||||
kompetenzbereich: 'Leben und Wirtschaften im Hinblick auf nachhaltige Ernährung',
|
||||
lernziele: [
|
||||
'Grundprinzip des Treibhauseffekts erklären können',
|
||||
'Zusammenhang zwischen CO₂-Konzentration und Temperatur verstehen',
|
||||
'Treibhauseffekt vom Ozonloch unterscheiden können',
|
||||
],
|
||||
basiskonzepte: ['Veränderung und Wandel', 'Maßstabsebenen und Raum'],
|
||||
requiresReading: true, // Text-basierte Reflexionsfragen
|
||||
dpiMinuten: 20,
|
||||
typ: 'sachsimulation',
|
||||
tier: 1,
|
||||
}
|
||||
|
||||
/** Physikalische Konstanten (vereinfacht für Schulniveau) */
|
||||
const SOLAR_CONSTANT = 1361 // W/m², Solarkonstante
|
||||
const STEFAN_BOLTZMANN = 5.67e-8 // W/(m²·K⁴)
|
||||
const PRE_INDUSTRIAL_CO2 = 280 // ppm
|
||||
const CURRENT_CO2 = 425 // ppm (ca. 2026)
|
||||
|
||||
/**
|
||||
* Berechnet die Gleichgewichtstemperatur der Erde
|
||||
* basierend auf einem vereinfachten Strahlungsmodell.
|
||||
*
|
||||
* Ohne Treibhauseffekt: ~-18°C
|
||||
* Mit natürlichem Treibhauseffekt (280 ppm): ~15°C
|
||||
* Aktuell (425 ppm): ~16.1°C
|
||||
*/
|
||||
export function computeTemperature(co2ppm: number, albedo: number): number {
|
||||
// Absorbierte Sonnenstrahlung pro m²
|
||||
const absorbed = (SOLAR_CONSTANT / 4) * (1 - albedo)
|
||||
|
||||
// Treibhauseffekt als logarithmische Funktion der CO₂-Konzentration
|
||||
// ΔT ≈ λ * ln(CO₂/CO₂_ref) — vereinfacht nach Arrhenius
|
||||
const climateSensitivity = 3.0 // °C pro Verdoppelung CO₂
|
||||
const deltaT = climateSensitivity * Math.log2(co2ppm / PRE_INDUSTRIAL_CO2)
|
||||
|
||||
// Basistemperatur ohne Treibhauseffekt
|
||||
const tempNoGreenhouse = Math.pow(absorbed / STEFAN_BOLTZMANN, 0.25) - 273.15 // ~-18°C
|
||||
|
||||
// Natürlicher Treibhauseffekt ~33°C
|
||||
const naturalGreenhouse = 33
|
||||
|
||||
return tempNoGreenhouse + naturalGreenhouse + deltaT
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Folgen der Temperaturänderung (vereinfacht)
|
||||
*/
|
||||
export function computeEffects(tempC: number): {
|
||||
seaLevelRise: number // cm über vorindustriellem Niveau
|
||||
arcticIce: number // % verbleibend (100% = vorindustriell)
|
||||
extremeEvents: number // Faktor (1 = normal, 2 = doppelt so häufig)
|
||||
} {
|
||||
const deltaT = tempC - 15 // Differenz zum vorindustriellen Mittel
|
||||
|
||||
return {
|
||||
seaLevelRise: Math.max(0, deltaT * 15), // ~15cm pro °C (vereinfacht)
|
||||
arcticIce: Math.max(0, Math.min(100, 100 - deltaT * 12)),
|
||||
extremeEvents: Math.max(1, 1 + deltaT * 0.3),
|
||||
}
|
||||
}
|
||||
|
||||
export class TreibhausSimulation extends Simulation {
|
||||
constructor() {
|
||||
super(META)
|
||||
|
||||
// Startwerte setzen
|
||||
const ranges = this.getVariableRanges()
|
||||
for (const [key, range] of Object.entries(ranges)) {
|
||||
this.state.variables[key] = range.default
|
||||
}
|
||||
}
|
||||
|
||||
getVariableRanges() {
|
||||
return {
|
||||
co2: {
|
||||
min: 200,
|
||||
max: 1000,
|
||||
default: CURRENT_CO2,
|
||||
unit: 'ppm',
|
||||
label: 'CO₂-Konzentration',
|
||||
},
|
||||
albedo: {
|
||||
min: 0.1,
|
||||
max: 0.6,
|
||||
default: 0.3,
|
||||
unit: '',
|
||||
label: 'Albedo (Reflexion)',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
compute() {
|
||||
const co2 = this.getVariable('co2')
|
||||
const albedo = this.getVariable('albedo')
|
||||
|
||||
const temp = computeTemperature(co2, albedo)
|
||||
const effects = computeEffects(temp)
|
||||
|
||||
const results = {
|
||||
temperature: Math.round(temp * 10) / 10,
|
||||
seaLevelRise: Math.round(effects.seaLevelRise),
|
||||
arcticIce: Math.round(effects.arcticIce),
|
||||
extremeEvents: Math.round(effects.extremeEvents * 10) / 10,
|
||||
tempWithoutGreenhouse: Math.round((Math.pow((SOLAR_CONSTANT / 4) * (1 - albedo) / STEFAN_BOLTZMANN, 0.25) - 273.15) * 10) / 10,
|
||||
}
|
||||
|
||||
this.state.results = results
|
||||
return results
|
||||
}
|
||||
|
||||
protected onVariableChange(_name: string, _value: number): void {
|
||||
this.compute()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* SIM-05: Treibhauseffekt — Canvas Renderer
|
||||
*
|
||||
* Visualisiert:
|
||||
* - Sonne → Sonnenstrahlen → Erdoberfläche
|
||||
* - Wärmestrahlung von der Erde nach oben
|
||||
* - CO₂-Schicht fängt Wärmestrahlung ab (je dicker, desto mehr)
|
||||
* - Temperaturanzeige
|
||||
* - Auswirkungen (Meeresspiegel, Eis, Extremereignisse)
|
||||
*
|
||||
* Stil: Skandinavisch minimal — gedeckte Farben, sanfte Animationen
|
||||
*/
|
||||
|
||||
import { TreibhausSimulation, computeTemperature, computeEffects } from './logic'
|
||||
|
||||
interface Particle {
|
||||
x: number; y: number; vx: number; vy: number
|
||||
type: 'solar' | 'heat' | 'reflected'
|
||||
life: number; maxLife: number
|
||||
absorbed: boolean
|
||||
}
|
||||
|
||||
export class TreibhausRenderer {
|
||||
private canvas: HTMLCanvasElement
|
||||
private ctx: CanvasRenderingContext2D
|
||||
private sim: TreibhausSimulation
|
||||
private W = 0
|
||||
private H = 0
|
||||
private t = 0
|
||||
private particles: Particle[] = []
|
||||
private animId = 0
|
||||
|
||||
// Layout zones (ratios of height)
|
||||
private sunY = 0
|
||||
private atmoTop = 0
|
||||
private atmoBot = 0
|
||||
private groundY = 0
|
||||
private seaY = 0
|
||||
|
||||
// Colors — skandinavisch
|
||||
private col = {
|
||||
sky: '#dde3da',
|
||||
space: '#c5cdc2',
|
||||
sun: '#e8c84a',
|
||||
sunGlow: 'rgba(232,200,74,0.15)',
|
||||
solar: '#e8c84a',
|
||||
heat: '#c07a6b',
|
||||
reflected:'#8ab0b8',
|
||||
co2: 'rgba(180,160,130,VAR)', // opacity varies
|
||||
ground: '#8a9a82',
|
||||
groundDark:'#6a7a62',
|
||||
sea: '#9ab5b8',
|
||||
ice: '#d8e0dc',
|
||||
text: '#1a1a1a',
|
||||
muted: '#6a6a6a',
|
||||
}
|
||||
|
||||
constructor(container: HTMLElement, sim: TreibhausSimulation) {
|
||||
this.sim = sim
|
||||
|
||||
this.canvas = document.createElement('canvas')
|
||||
this.canvas.style.cssText = 'width:100%;height:100%;display:block;border-radius:12px;'
|
||||
container.appendChild(this.canvas)
|
||||
|
||||
const ctx = this.canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Canvas not supported')
|
||||
this.ctx = ctx
|
||||
|
||||
this.resize()
|
||||
window.addEventListener('resize', () => this.resize())
|
||||
}
|
||||
|
||||
private resize(): void {
|
||||
const rect = this.canvas.parentElement!.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
this.W = rect.width
|
||||
this.H = Math.min(rect.width * 0.65, 500)
|
||||
this.canvas.width = this.W * dpr
|
||||
this.canvas.height = this.H * dpr
|
||||
this.canvas.style.height = this.H + 'px'
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
|
||||
// Layout zones
|
||||
this.sunY = this.H * 0.08
|
||||
this.atmoTop = this.H * 0.25
|
||||
this.atmoBot = this.H * 0.45
|
||||
this.groundY = this.H * 0.7
|
||||
this.seaY = this.H * 0.75
|
||||
}
|
||||
|
||||
start(): void {
|
||||
const loop = () => {
|
||||
this.t += 0.016
|
||||
this.update()
|
||||
this.draw()
|
||||
this.animId = requestAnimationFrame(loop)
|
||||
}
|
||||
loop()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
cancelAnimationFrame(this.animId)
|
||||
}
|
||||
|
||||
private update(): void {
|
||||
const co2 = this.sim.getVariable('co2')
|
||||
const absorptionRate = Math.min(0.9, (co2 - 200) / 800 * 0.85)
|
||||
|
||||
// Spawn solar particles
|
||||
if (Math.random() < 0.15) {
|
||||
this.particles.push({
|
||||
x: this.W * 0.3 + Math.random() * this.W * 0.4,
|
||||
y: 0,
|
||||
vx: (Math.random() - 0.5) * 0.3,
|
||||
vy: 1.5 + Math.random() * 0.5,
|
||||
type: 'solar',
|
||||
life: 0, maxLife: 300,
|
||||
absorbed: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Update particles
|
||||
for (let i = this.particles.length - 1; i >= 0; i--) {
|
||||
const p = this.particles[i]
|
||||
p.x += p.vx
|
||||
p.y += p.vy
|
||||
p.life++
|
||||
|
||||
if (p.type === 'solar' && p.y >= this.groundY) {
|
||||
// Solar hits ground → becomes heat radiation going up
|
||||
p.type = 'heat'
|
||||
p.vy = -(1.0 + Math.random() * 0.5)
|
||||
p.vx = (Math.random() - 0.5) * 0.8
|
||||
p.y = this.groundY - 2
|
||||
}
|
||||
|
||||
if (p.type === 'heat' && !p.absorbed && p.y <= this.atmoBot && p.y >= this.atmoTop) {
|
||||
// Heat in CO₂ layer — chance of absorption
|
||||
if (Math.random() < absorptionRate * 0.03) {
|
||||
p.absorbed = true
|
||||
p.vy = 0.8 + Math.random() * 0.5 // reflected back down
|
||||
p.vx = (Math.random() - 0.5) * 1.2
|
||||
p.type = 'reflected'
|
||||
}
|
||||
}
|
||||
|
||||
// Remove particles that leave the canvas
|
||||
if (p.y < -10 || p.y > this.H + 10 || p.x < -20 || p.x > this.W + 20 || p.life > p.maxLife) {
|
||||
this.particles.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Cap particles
|
||||
if (this.particles.length > 120) {
|
||||
this.particles.splice(0, this.particles.length - 120)
|
||||
}
|
||||
}
|
||||
|
||||
private draw(): void {
|
||||
const { ctx, W, H } = this
|
||||
const co2 = this.sim.getVariable('co2')
|
||||
const albedo = this.sim.getVariable('albedo')
|
||||
const temp = computeTemperature(co2, albedo)
|
||||
const effects = computeEffects(temp)
|
||||
const co2Opacity = Math.min(0.4, (co2 - 200) / 800 * 0.35)
|
||||
|
||||
ctx.clearRect(0, 0, W, H)
|
||||
|
||||
// Background — space/sky gradient
|
||||
const skyGrad = ctx.createLinearGradient(0, 0, 0, this.groundY)
|
||||
skyGrad.addColorStop(0, this.col.space)
|
||||
skyGrad.addColorStop(0.3, this.col.sky)
|
||||
skyGrad.addColorStop(1, '#c8d4c6')
|
||||
ctx.fillStyle = skyGrad
|
||||
ctx.fillRect(0, 0, W, this.groundY)
|
||||
|
||||
// Sun
|
||||
const sunX = W * 0.8
|
||||
const sunR = 28
|
||||
// Glow
|
||||
const glow = ctx.createRadialGradient(sunX, this.sunY, sunR * 0.5, sunX, this.sunY, sunR * 3)
|
||||
glow.addColorStop(0, 'rgba(232,200,74,0.3)')
|
||||
glow.addColorStop(1, 'rgba(232,200,74,0)')
|
||||
ctx.fillStyle = glow
|
||||
ctx.fillRect(sunX - sunR * 3, this.sunY - sunR * 3, sunR * 6, sunR * 6)
|
||||
// Sun disc
|
||||
ctx.fillStyle = this.col.sun
|
||||
ctx.beginPath()
|
||||
ctx.arc(sunX, this.sunY, sunR, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// CO₂ layer
|
||||
ctx.fillStyle = `rgba(180,160,130,${co2Opacity})`
|
||||
ctx.fillRect(0, this.atmoTop, W, this.atmoBot - this.atmoTop)
|
||||
// CO₂ label
|
||||
ctx.fillStyle = `rgba(100,80,60,${Math.min(0.6, co2Opacity + 0.15)})`
|
||||
ctx.font = '11px Inter, sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(`CO₂: ${co2} ppm`, 12, this.atmoTop + 16)
|
||||
|
||||
// Atmosphere borders (subtle)
|
||||
ctx.strokeStyle = `rgba(150,130,100,${co2Opacity * 0.5})`
|
||||
ctx.lineWidth = 0.5
|
||||
ctx.setLineDash([4, 4])
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, this.atmoTop); ctx.lineTo(W, this.atmoTop)
|
||||
ctx.moveTo(0, this.atmoBot); ctx.lineTo(W, this.atmoBot)
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
|
||||
// Ground
|
||||
ctx.fillStyle = this.col.ground
|
||||
ctx.fillRect(0, this.groundY, W, H - this.groundY)
|
||||
// Ground detail — hills
|
||||
ctx.fillStyle = this.col.groundDark
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, this.groundY)
|
||||
for (let x = 0; x <= W; x += 5) {
|
||||
ctx.lineTo(x, this.groundY - Math.sin(x * 0.02 + 1) * 6 - Math.sin(x * 0.007) * 10)
|
||||
}
|
||||
ctx.lineTo(W, H); ctx.lineTo(0, H); ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
// Sea (rises with temperature)
|
||||
const seaRise = effects.seaLevelRise * 0.15
|
||||
const seaLevel = this.seaY - seaRise
|
||||
ctx.fillStyle = this.col.sea
|
||||
ctx.globalAlpha = 0.7
|
||||
ctx.fillRect(W * 0.55, seaLevel, W * 0.45, H - seaLevel)
|
||||
ctx.globalAlpha = 1
|
||||
|
||||
// Ice cap (shrinks with temperature)
|
||||
const iceWidth = W * 0.12 * (effects.arcticIce / 100)
|
||||
if (iceWidth > 2) {
|
||||
ctx.fillStyle = this.col.ice
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(W * 0.15, this.groundY - 8, iceWidth, 8, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// Small trees
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const tx = W * 0.05 + i * W * 0.09
|
||||
this.drawTree(ctx, tx, this.groundY - 12, 0.5 + Math.sin(i) * 0.15)
|
||||
}
|
||||
|
||||
// Small houses
|
||||
this.drawHouse(ctx, W * 0.35, this.groundY - 10, 0.7)
|
||||
this.drawHouse(ctx, W * 0.42, this.groundY - 8, 0.5)
|
||||
|
||||
// Particles
|
||||
for (const p of this.particles) {
|
||||
ctx.globalAlpha = Math.max(0, 1 - p.life / p.maxLife) * 0.7
|
||||
if (p.type === 'solar') {
|
||||
ctx.fillStyle = this.col.solar
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
} else if (p.type === 'heat') {
|
||||
ctx.fillStyle = this.col.heat
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, 2, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
} else if (p.type === 'reflected') {
|
||||
ctx.fillStyle = this.col.heat
|
||||
ctx.globalAlpha *= 0.8
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
// Temperature display
|
||||
this.drawThermometer(ctx, W - 55, this.groundY * 0.5, temp)
|
||||
|
||||
// Info panel bottom
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.75)'
|
||||
ctx.fillRect(0, H - 50, W, 50)
|
||||
ctx.fillStyle = this.col.text
|
||||
ctx.font = 'bold 13px Inter, sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(`🌡️ ${temp.toFixed(1)}°C`, 15, H - 20)
|
||||
ctx.font = '11px Inter, sans-serif'
|
||||
ctx.fillStyle = this.col.muted
|
||||
ctx.fillText(`Meeresspiegel: +${effects.seaLevelRise.toFixed(0)} cm`, W * 0.25, H - 20)
|
||||
ctx.fillText(`Arktis-Eis: ${effects.arcticIce.toFixed(0)}%`, W * 0.52, H - 20)
|
||||
ctx.fillText(`Extremereignisse: ×${effects.extremeEvents.toFixed(1)}`, W * 0.75, H - 20)
|
||||
}
|
||||
|
||||
private drawThermometer(ctx: CanvasRenderingContext2D, x: number, y: number, temp: number): void {
|
||||
const h = 80
|
||||
const w = 14
|
||||
const fill = Math.max(0, Math.min(1, (temp + 20) / 50)) // -20..+30°C range
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x - w/2, y - h/2, w, h, 7)
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.1)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.stroke()
|
||||
|
||||
// Fill
|
||||
const fillH = h * fill * 0.85
|
||||
const fillColor = temp > 17 ? '#c07a6b' : temp > 15 ? '#c4a35a' : '#4a7c8a'
|
||||
ctx.fillStyle = fillColor
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x - w/2 + 2, y + h/2 - fillH - 2, w - 4, fillH, 4)
|
||||
ctx.fill()
|
||||
|
||||
// Temperature text
|
||||
ctx.fillStyle = this.col.text
|
||||
ctx.font = 'bold 11px Inter, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(`${temp.toFixed(1)}°`, x, y - h/2 - 6)
|
||||
}
|
||||
|
||||
private drawTree(ctx: CanvasRenderingContext2D, x: number, y: number, s: number): void {
|
||||
ctx.fillStyle = '#5a5a4a'
|
||||
ctx.fillRect(x - 1.5 * s, y, 3 * s, 10 * s)
|
||||
ctx.fillStyle = '#6a8a5e'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y - 2 * s, 8 * s, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
private drawHouse(ctx: CanvasRenderingContext2D, x: number, y: number, s: number): void {
|
||||
// Wall
|
||||
ctx.fillStyle = '#d8c8b0'
|
||||
ctx.fillRect(x - 8 * s, y - 10 * s, 16 * s, 12 * s)
|
||||
// Roof
|
||||
ctx.fillStyle = '#c07a6b'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - 10 * s, y - 10 * s)
|
||||
ctx.lineTo(x, y - 18 * s)
|
||||
ctx.lineTo(x + 10 * s, y - 10 * s)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
// Window
|
||||
ctx.fillStyle = '#a8c8d0'
|
||||
ctx.fillRect(x - 3 * s, y - 7 * s, 6 * s, 5 * s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* Erdbeben-Spiel — Canvas Renderer
|
||||
*
|
||||
* Ansicht: Stadt von der Seite, im Vordergrund Häuser verschiedener Bauart,
|
||||
* im Hintergrund Berge und ein Verwerfungs-Hinweis (rot pulsierende Linie zeigt Stress).
|
||||
*
|
||||
* Bei Beben: kurzer Shake, einstürzende Häuser werden zu Trümmern.
|
||||
* Zeitleiste am Rand mit Markierungen für vergangene Beben.
|
||||
*/
|
||||
|
||||
import { ErdbebenGame, BUILDING_TYPES } from './game'
|
||||
|
||||
interface PlacedBuilding {
|
||||
typeId: string
|
||||
x: number // 0..1
|
||||
scale: number
|
||||
ownerId: string
|
||||
intact: boolean
|
||||
builtTick: number
|
||||
}
|
||||
|
||||
export class ErdbebenRenderer {
|
||||
private canvas: HTMLCanvasElement
|
||||
private ctx: CanvasRenderingContext2D
|
||||
private game: ErdbebenGame
|
||||
private W = 0
|
||||
private H = 0
|
||||
private t = 0
|
||||
private animId = 0
|
||||
private placed: PlacedBuilding[] = []
|
||||
private knownIds = new Set<string>()
|
||||
private shake = 0
|
||||
private shakeUntil = 0
|
||||
private lastDeaths = 0
|
||||
private lastQuakeShown = 0
|
||||
|
||||
constructor(container: HTMLElement, game: ErdbebenGame) {
|
||||
this.game = game
|
||||
this.canvas = document.createElement('canvas')
|
||||
this.canvas.style.cssText = 'width:100%;display:block;border-radius:12px;background:#dde3da;'
|
||||
container.appendChild(this.canvas)
|
||||
const ctx = this.canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Canvas not supported')
|
||||
this.ctx = ctx
|
||||
|
||||
this.resize()
|
||||
window.addEventListener('resize', () => this.resize())
|
||||
}
|
||||
|
||||
private resize(): void {
|
||||
const rect = this.canvas.parentElement!.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
this.W = rect.width
|
||||
this.H = Math.min(rect.width * 0.55, 420)
|
||||
this.canvas.width = this.W * dpr
|
||||
this.canvas.height = this.H * dpr
|
||||
this.canvas.style.height = this.H + 'px'
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
}
|
||||
|
||||
private seededRand(seed: number): number {
|
||||
const x = Math.sin(seed * 12.9898) * 43758.5453
|
||||
return x - Math.floor(x)
|
||||
}
|
||||
|
||||
start(): void {
|
||||
let lastFrame = performance.now()
|
||||
const loop = (now: number) => {
|
||||
const dt = (now - lastFrame) / 1000
|
||||
lastFrame = now
|
||||
this.t += dt
|
||||
this.updateScene()
|
||||
this.draw()
|
||||
this.animId = requestAnimationFrame(loop)
|
||||
}
|
||||
this.animId = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
cancelAnimationFrame(this.animId)
|
||||
}
|
||||
|
||||
private updateScene(): void {
|
||||
// Add new buildings to scene
|
||||
const owned = this.game.getOwnedBuildings()
|
||||
let counterByType: Record<string, number> = {}
|
||||
|
||||
for (const b of owned) {
|
||||
counterByType[b.typeId] = 0
|
||||
for (let i = 0; i < b.count; i++) {
|
||||
const id = `${b.typeId}-${i}`
|
||||
const intact = i >= b.damaged
|
||||
if (!this.knownIds.has(id)) {
|
||||
this.knownIds.add(id)
|
||||
this.placed.push(this.placeBuilding(b.typeId, i, this.game.getSnapshot().tick))
|
||||
}
|
||||
// Update intact state
|
||||
const obj = this.placed.find(p => p.ownerId === id)
|
||||
if (obj) obj.intact = intact
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger shake on new earthquake
|
||||
const snap = this.game.getSnapshot()
|
||||
const lastMag = snap.resources.lastQuake
|
||||
const deaths = snap.resources.deaths
|
||||
|
||||
if (deaths > this.lastDeaths) {
|
||||
this.shake = lastMag * 1.5
|
||||
this.shakeUntil = this.t + 1.2
|
||||
this.lastDeaths = deaths
|
||||
}
|
||||
|
||||
if (this.t > this.shakeUntil) {
|
||||
this.shake *= 0.85
|
||||
if (this.shake < 0.05) this.shake = 0
|
||||
}
|
||||
}
|
||||
|
||||
private placeBuilding(typeId: string, index: number, tick: number): PlacedBuilding {
|
||||
const id = `${typeId}-${index}`
|
||||
// Position depends on type (different "districts")
|
||||
let x = 0.1
|
||||
let scale = 1
|
||||
if (typeId === 'slum') {
|
||||
// Slums on the far edges
|
||||
const slot = index % 8
|
||||
x = 0.04 + slot * 0.025 + this.seededRand(index * 13 + 7) * 0.015
|
||||
scale = 0.7 + this.seededRand(index * 19) * 0.2
|
||||
} else if (typeId === 'simple') {
|
||||
// Simple houses in the middle-left area
|
||||
const slot = index % 8
|
||||
x = 0.25 + slot * 0.04 + this.seededRand(index * 17 + 3) * 0.02
|
||||
scale = 0.85 + this.seededRand(index * 23) * 0.2
|
||||
} else if (typeId === 'reinforced') {
|
||||
// Reinforced in middle-right
|
||||
const slot = index % 6
|
||||
x = 0.55 + slot * 0.05 + this.seededRand(index * 29 + 5) * 0.02
|
||||
scale = 0.95 + this.seededRand(index * 31) * 0.15
|
||||
} else if (typeId === 'quake-proof') {
|
||||
// Quake-proof on the right
|
||||
const slot = index % 5
|
||||
x = 0.83 + slot * 0.025 + this.seededRand(index * 37 + 11) * 0.01
|
||||
scale = 1.05
|
||||
} else if (typeId === 'school') {
|
||||
// School at the center, slightly bigger
|
||||
x = 0.48
|
||||
scale = 1.4
|
||||
}
|
||||
return { typeId, x, scale, ownerId: id, intact: true, builtTick: tick }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// RENDERING
|
||||
// ============================================================
|
||||
|
||||
private draw(): void {
|
||||
const { ctx, W, H } = this
|
||||
const snap = this.game.getSnapshot()
|
||||
const tick = snap.tick
|
||||
|
||||
ctx.save()
|
||||
if (this.shake > 0.01) {
|
||||
const sx = (Math.random() - 0.5) * this.shake
|
||||
const sy = (Math.random() - 0.5) * this.shake
|
||||
ctx.translate(sx, sy)
|
||||
}
|
||||
|
||||
ctx.clearRect(-20, -20, W + 40, H + 40)
|
||||
|
||||
const timelineH = 28
|
||||
const sceneH = H - timelineH
|
||||
const groundY = sceneH * 0.78
|
||||
|
||||
// Sky
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, groundY)
|
||||
sky.addColorStop(0, '#c8d4d8')
|
||||
sky.addColorStop(0.6, '#d8e0d8')
|
||||
sky.addColorStop(1, '#e0e4d8')
|
||||
ctx.fillStyle = sky
|
||||
ctx.fillRect(0, 0, W, groundY)
|
||||
|
||||
// Sun
|
||||
ctx.fillStyle = '#e8c84a'
|
||||
ctx.beginPath()
|
||||
ctx.arc(W * 0.85, sceneH * 0.13, 16, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Background mountains
|
||||
ctx.fillStyle = '#9aa494'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, groundY)
|
||||
for (let x = 0; x <= W; x += 30) {
|
||||
const my = groundY - 35 - Math.sin(x * 0.005 + 1) * 18 - Math.sin(x * 0.013 + 0.3) * 10
|
||||
ctx.lineTo(x, my)
|
||||
}
|
||||
ctx.lineTo(W, groundY)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
// Closer mountain layer (visualizing the fault zone)
|
||||
ctx.fillStyle = '#7a8474'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, groundY)
|
||||
for (let x = 0; x <= W; x += 25) {
|
||||
const my = groundY - 18 - Math.sin(x * 0.008 + 0.5) * 10
|
||||
ctx.lineTo(x, my)
|
||||
}
|
||||
ctx.lineTo(W, groundY)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
// === Fault zone visualization ===
|
||||
// A zigzag red-pulsing line in the mountain that shows tectonic stress
|
||||
const nextIn = this.game.getNextQuakeIn ? this.game.getNextQuakeIn() : 5
|
||||
const stressLevel = Math.max(0, Math.min(1, (12 - nextIn) / 12))
|
||||
const pulseAlpha = (Math.sin(this.t * 2) * 0.3 + 0.5) * stressLevel
|
||||
ctx.strokeStyle = `rgba(180,80,60,${pulseAlpha * 0.5})`
|
||||
ctx.lineWidth = 1.5 + stressLevel * 1.5
|
||||
ctx.setLineDash([3, 4])
|
||||
ctx.beginPath()
|
||||
let lx = 0
|
||||
let ly = groundY - 25
|
||||
ctx.moveTo(lx, ly)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
lx += W / 20
|
||||
ly = groundY - 22 + (Math.sin(i * 1.7) * 5) + (Math.cos(i * 0.9) * 3)
|
||||
ctx.lineTo(lx, ly)
|
||||
}
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
|
||||
// Ground
|
||||
ctx.fillStyle = '#9a9a82'
|
||||
ctx.fillRect(0, groundY, W, sceneH - groundY)
|
||||
ctx.fillStyle = '#7a7a62'
|
||||
ctx.fillRect(0, groundY + 3, W, 5)
|
||||
|
||||
// ===== BUILDINGS =====
|
||||
// Sort by x for correct depth
|
||||
const sorted = [...this.placed].sort((a, b) => a.x - b.x)
|
||||
for (const obj of sorted) {
|
||||
const x = obj.x * W
|
||||
this.drawBuilding(ctx, x, groundY, obj)
|
||||
}
|
||||
|
||||
// ===== PEOPLE WAITING (left edge, in front) =====
|
||||
const waiting = snap.resources.waiting || 0
|
||||
const waitingDots = Math.min(20, Math.floor(waiting / 30))
|
||||
if (waitingDots > 0) {
|
||||
// Draw a small "tent camp" with stick figures
|
||||
for (let i = 0; i < waitingDots; i++) {
|
||||
const px = 8 + (i % 4) * 7 + Math.floor(i / 4) * 0.5
|
||||
const py = groundY - 1
|
||||
// Tent
|
||||
if (i % 4 === 0) {
|
||||
ctx.fillStyle = '#c4a880'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(px - 5, py)
|
||||
ctx.lineTo(px, py - 7)
|
||||
ctx.lineTo(px + 5, py)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
// Person
|
||||
ctx.fillStyle = '#3a3a3a'
|
||||
ctx.fillRect(px - 0.5, py - 4, 1, 4)
|
||||
ctx.beginPath()
|
||||
ctx.arc(px, py - 5, 1, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
// Label
|
||||
ctx.fillStyle = 'rgba(180,100,80,0.9)'
|
||||
ctx.font = 'bold 9px Inter, sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(`${Math.round(waiting)} ohne Wohnung`, 8, groundY - 30)
|
||||
}
|
||||
|
||||
// ===== HUD =====
|
||||
const year = 1975 + tick
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.9)'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(12, 12, 230, 28, 8)
|
||||
ctx.fill()
|
||||
ctx.fillStyle = '#1a1a1a'
|
||||
ctx.font = 'bold 12px Inter, sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(`Jahr ${year}`, 22, 30)
|
||||
ctx.fillStyle = '#5a5a5a'
|
||||
ctx.font = '10px Inter, sans-serif'
|
||||
ctx.fillText(`Bevölkerung: ${Math.round(snap.resources.population)} · Tote: ${Math.round(snap.resources.deaths)}`, 75, 30)
|
||||
|
||||
// Stress indicator
|
||||
if (stressLevel > 0.5) {
|
||||
ctx.fillStyle = `rgba(180,80,60,${pulseAlpha})`
|
||||
ctx.font = 'bold 10px Inter, sans-serif'
|
||||
ctx.textAlign = 'right'
|
||||
ctx.fillText('⚠ Tektonische Spannung baut sich auf', W - 14, 28)
|
||||
}
|
||||
|
||||
ctx.restore() // shake
|
||||
|
||||
// ===== ZEITLEISTE =====
|
||||
this.drawTimeline(ctx, timelineH, tick, snap.events)
|
||||
}
|
||||
|
||||
private drawTimeline(ctx: CanvasRenderingContext2D, h: number, currentTick: number, events: any[]): void {
|
||||
const { W, H } = this
|
||||
const y = H - h
|
||||
const marginX = 40
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.92)'
|
||||
ctx.fillRect(0, y, W, h)
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.06)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, y); ctx.lineTo(W, y)
|
||||
ctx.stroke()
|
||||
|
||||
const lineY = y + h / 2 + 2
|
||||
const lineX0 = marginX
|
||||
const lineX1 = W - marginX
|
||||
|
||||
ctx.strokeStyle = '#c8c4b8'
|
||||
ctx.lineWidth = 2
|
||||
ctx.lineCap = 'round'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(lineX0, lineY); ctx.lineTo(lineX1, lineY)
|
||||
ctx.stroke()
|
||||
|
||||
const totalTicks = 50
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const decade = i * 10
|
||||
const x = lineX0 + (decade / totalTicks) * (lineX1 - lineX0)
|
||||
ctx.strokeStyle = '#a8a497'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, lineY - 3); ctx.lineTo(x, lineY + 3)
|
||||
ctx.stroke()
|
||||
ctx.fillStyle = '#7a7468'
|
||||
ctx.font = '9px Inter, system-ui, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(`${1975 + decade}`, x, lineY + 14)
|
||||
}
|
||||
|
||||
const progress = Math.min(1, currentTick / totalTicks)
|
||||
const markerX = lineX0 + progress * (lineX1 - lineX0)
|
||||
|
||||
ctx.strokeStyle = '#4a7c8a'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(lineX0, lineY); ctx.lineTo(markerX, lineY)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = '#4a7c8a'
|
||||
ctx.beginPath()
|
||||
ctx.arc(markerX, lineY, 5, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.beginPath()
|
||||
ctx.arc(markerX, lineY, 2, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = '#4a7c8a'
|
||||
ctx.font = 'bold 10px Inter, system-ui, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(`${1975 + currentTick}`, markerX, lineY - 8)
|
||||
|
||||
// Event markers — focus on quakes
|
||||
for (const ev of events) {
|
||||
if (ev.tick > currentTick) continue
|
||||
const ex = lineX0 + (ev.tick / totalTicks) * (lineX1 - lineX0)
|
||||
let color = '#8a8a8a'
|
||||
if (ev.severity === 'danger') color = '#c0503c'
|
||||
else if (ev.severity === 'warning') color = '#c4a35a'
|
||||
else if (ev.severity === 'success') color = '#5a8a5e'
|
||||
ctx.fillStyle = color
|
||||
ctx.beginPath()
|
||||
ctx.arc(ex, lineY - 8, 2.5, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BUILDING DRAWING
|
||||
// ============================================================
|
||||
|
||||
private drawBuilding(ctx: CanvasRenderingContext2D, x: number, baseY: number, obj: PlacedBuilding): void {
|
||||
const t = BUILDING_TYPES.find(b => b.id === obj.typeId)
|
||||
if (!t) return
|
||||
|
||||
if (!obj.intact) {
|
||||
// Rubble
|
||||
ctx.fillStyle = '#7a6a5a'
|
||||
ctx.fillRect(x - 8 * obj.scale, baseY - 4 * obj.scale, 16 * obj.scale, 4 * obj.scale)
|
||||
ctx.fillStyle = '#5a4a3a'
|
||||
ctx.fillRect(x - 6 * obj.scale, baseY - 6 * obj.scale, 4 * obj.scale, 2 * obj.scale)
|
||||
ctx.fillRect(x + 1 * obj.scale, baseY - 6 * obj.scale, 5 * obj.scale, 2 * obj.scale)
|
||||
return
|
||||
}
|
||||
|
||||
const s = obj.scale
|
||||
|
||||
if (obj.typeId === 'slum') {
|
||||
// Wonky shack
|
||||
ctx.fillStyle = '#a89878'
|
||||
ctx.fillRect(x - 6 * s, baseY - 8 * s, 12 * s, 8 * s)
|
||||
ctx.fillStyle = '#8a7858'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - 7 * s, baseY - 8 * s)
|
||||
ctx.lineTo(x - 1, baseY - 12 * s)
|
||||
ctx.lineTo(x + 7 * s, baseY - 7 * s)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
ctx.fillStyle = '#5a4a3a'
|
||||
ctx.fillRect(x - 1 * s, baseY - 4 * s, 2 * s, 4 * s)
|
||||
} else if (obj.typeId === 'simple') {
|
||||
// Simple house
|
||||
ctx.fillStyle = '#e8d8b8'
|
||||
ctx.fillRect(x - 8 * s, baseY - 12 * s, 16 * s, 12 * s)
|
||||
ctx.fillStyle = '#b06a5a'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - 10 * s, baseY - 12 * s)
|
||||
ctx.lineTo(x, baseY - 19 * s)
|
||||
ctx.lineTo(x + 10 * s, baseY - 12 * s)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
ctx.fillStyle = '#a8c8d0'
|
||||
ctx.fillRect(x - 3 * s, baseY - 9 * s, 6 * s, 4 * s)
|
||||
ctx.fillStyle = '#6a4a3a'
|
||||
ctx.fillRect(x - 1.5 * s, baseY - 4 * s, 3 * s, 4 * s)
|
||||
} else if (obj.typeId === 'reinforced') {
|
||||
// Reinforced — slightly larger, concrete look
|
||||
ctx.fillStyle = '#c8c4b4'
|
||||
ctx.fillRect(x - 9 * s, baseY - 16 * s, 18 * s, 16 * s)
|
||||
// Visible reinforcement bands
|
||||
ctx.fillStyle = '#8a8478'
|
||||
ctx.fillRect(x - 9 * s, baseY - 13 * s, 18 * s, 1)
|
||||
ctx.fillRect(x - 9 * s, baseY - 7 * s, 18 * s, 1)
|
||||
// Roof
|
||||
ctx.fillStyle = '#7a6a5a'
|
||||
ctx.fillRect(x - 10 * s, baseY - 17 * s, 20 * s, 2 * s)
|
||||
// Windows
|
||||
ctx.fillStyle = '#a8c8d0'
|
||||
ctx.fillRect(x - 6 * s, baseY - 11 * s, 4 * s, 3 * s)
|
||||
ctx.fillRect(x + 2 * s, baseY - 11 * s, 4 * s, 3 * s)
|
||||
ctx.fillStyle = '#6a4a3a'
|
||||
ctx.fillRect(x - 1.5 * s, baseY - 5 * s, 3 * s, 5 * s)
|
||||
} else if (obj.typeId === 'quake-proof') {
|
||||
// Modern building — taller, gray, with steel framework
|
||||
ctx.fillStyle = '#b8c0c4'
|
||||
ctx.fillRect(x - 9 * s, baseY - 22 * s, 18 * s, 22 * s)
|
||||
// Steel frame visible
|
||||
ctx.strokeStyle = '#5a6a74'
|
||||
ctx.lineWidth = 0.8
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - 9 * s, baseY - 22 * s); ctx.lineTo(x - 9 * s, baseY)
|
||||
ctx.moveTo(x + 9 * s, baseY - 22 * s); ctx.lineTo(x + 9 * s, baseY)
|
||||
ctx.moveTo(x - 9 * s, baseY - 18 * s); ctx.lineTo(x + 9 * s, baseY - 18 * s)
|
||||
ctx.moveTo(x - 9 * s, baseY - 11 * s); ctx.lineTo(x + 9 * s, baseY - 11 * s)
|
||||
ctx.stroke()
|
||||
// Many windows
|
||||
ctx.fillStyle = '#a8c8d0'
|
||||
for (let row = 0; row < 4; row++) {
|
||||
for (let col = 0; col < 3; col++) {
|
||||
ctx.fillRect(x - 7 * s + col * 5 * s, baseY - 20 * s + row * 5 * s, 3 * s, 3 * s)
|
||||
}
|
||||
}
|
||||
// Flat roof
|
||||
ctx.fillStyle = '#6a747c'
|
||||
ctx.fillRect(x - 10 * s, baseY - 23 * s, 20 * s, 2 * s)
|
||||
} else if (obj.typeId === 'school') {
|
||||
// School — wider, low building, with flag
|
||||
ctx.fillStyle = '#e8e0c8'
|
||||
ctx.fillRect(x - 14 * s, baseY - 14 * s, 28 * s, 14 * s)
|
||||
// Roof
|
||||
ctx.fillStyle = '#5a8a5e'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - 16 * s, baseY - 14 * s)
|
||||
ctx.lineTo(x, baseY - 22 * s)
|
||||
ctx.lineTo(x + 16 * s, baseY - 14 * s)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
// Door
|
||||
ctx.fillStyle = '#5a4a3a'
|
||||
ctx.fillRect(x - 2 * s, baseY - 7 * s, 4 * s, 7 * s)
|
||||
// Windows
|
||||
ctx.fillStyle = '#a8c8d0'
|
||||
ctx.fillRect(x - 11 * s, baseY - 11 * s, 5 * s, 4 * s)
|
||||
ctx.fillRect(x + 6 * s, baseY - 11 * s, 5 * s, 4 * s)
|
||||
// Flagpole
|
||||
ctx.strokeStyle = '#5a5a5a'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, baseY - 22 * s); ctx.lineTo(x, baseY - 30 * s)
|
||||
ctx.stroke()
|
||||
ctx.fillStyle = '#c0503c'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, baseY - 30 * s); ctx.lineTo(x + 6 * s, baseY - 28 * s); ctx.lineTo(x, baseY - 26 * s)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* SIM-07: Stadtplaner*in in Erdbebenregion — Spielbare Erdbeben-Simulation
|
||||
*
|
||||
* Du übernimmst 1975 als Bürgermeister*in eine Kleinstadt in einer Erdbebenregion.
|
||||
* Über 50 Jahre (bis 2025) wachsen die Einwohnerzahlen — du musst Wohnraum bauen.
|
||||
*
|
||||
* Du hast die Wahl:
|
||||
* - Slum-Häuser (5 €): schnell und billig, aber bei Beben tödlich
|
||||
* - Standard-Häuser (15 €): solide gebaut, halten kleinere Beben aus
|
||||
* - Erdbebensichere Häuser (40 €): teuer, aber sicher
|
||||
*
|
||||
* Erdbeben kommen mehrmals während des Spiels — manchmal stark, manchmal schwach.
|
||||
* Du gewinnst, wenn 2025 alle Familien wohnen UND weniger als 200 Menschen
|
||||
* durch Erdbeben gestorben sind UND du nicht pleite bist.
|
||||
*
|
||||
* Fachlich korrekt:
|
||||
* - Magnitude-Verteilung folgt Gutenberg-Richter (häufig schwach, selten stark)
|
||||
* - Schäden hängen exponentiell von Magnitude UND Bauqualität ab
|
||||
* - Die Botschaft: "Naturgefahr ≠ Naturkatastrophe" (Vulnerabilität entscheidet)
|
||||
*/
|
||||
|
||||
import { GameEngine, type GameMeta } from '@core/game-engine'
|
||||
|
||||
const META: GameMeta = {
|
||||
id: 'sim-07',
|
||||
title: 'Stadtplaner*in in Erdbebenregion',
|
||||
description: '50 Jahre lang baust du eine Stadt in einer Erdbebenregion auf. Wieviele Menschenleben kannst du retten?',
|
||||
msPerTick: 4000,
|
||||
tickUnit: 'Jahr',
|
||||
maxTicks: 50,
|
||||
tutorialSteps: 4,
|
||||
}
|
||||
|
||||
const START_YEAR = 1975
|
||||
|
||||
export interface BuildingType {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
description: string
|
||||
cost: number
|
||||
capacity: number // Wieviele Menschen wohnen drin
|
||||
quality: number // 0..1 (Bruchresistenz)
|
||||
upkeep: number
|
||||
}
|
||||
|
||||
export const BUILDING_TYPES: BuildingType[] = [
|
||||
{
|
||||
id: 'slum',
|
||||
name: 'Slum-Häuser',
|
||||
emoji: '🏚️',
|
||||
description: 'Sehr billig. Bewohner haben keine Wahl. Stürzt bei Beben ab Magnitude 5 ein.',
|
||||
cost: 30,
|
||||
capacity: 200,
|
||||
quality: 0.05,
|
||||
upkeep: 1,
|
||||
},
|
||||
{
|
||||
id: 'simple',
|
||||
name: 'Einfache Häuser',
|
||||
emoji: '🏘️',
|
||||
description: 'Solide Bauweise. Übersteht schwache Beben.',
|
||||
cost: 80,
|
||||
capacity: 150,
|
||||
quality: 0.35,
|
||||
upkeep: 3,
|
||||
},
|
||||
{
|
||||
id: 'reinforced',
|
||||
name: 'Verstärkte Häuser',
|
||||
emoji: '🏠',
|
||||
description: 'Mit Stahl verstärkt. Übersteht mittlere Beben gut.',
|
||||
cost: 180,
|
||||
capacity: 120,
|
||||
quality: 0.65,
|
||||
upkeep: 6,
|
||||
},
|
||||
{
|
||||
id: 'quake-proof',
|
||||
name: 'Erdbebensichere Häuser',
|
||||
emoji: '🏛️',
|
||||
description: 'Modernster Standard. Übersteht selbst starke Beben fast unbeschadet.',
|
||||
cost: 400,
|
||||
capacity: 100,
|
||||
quality: 0.92,
|
||||
upkeep: 12,
|
||||
},
|
||||
{
|
||||
id: 'school',
|
||||
name: 'Schule (sicher)',
|
||||
emoji: '🏫',
|
||||
description: 'Erdbebensicher. Senkt Opferzahlen bei Beben durch Aufklärung & Übungen.',
|
||||
cost: 250,
|
||||
capacity: 0,
|
||||
quality: 0.95,
|
||||
upkeep: 8,
|
||||
},
|
||||
]
|
||||
|
||||
interface OwnedBuilding {
|
||||
typeId: string
|
||||
count: number
|
||||
damaged: number // wieviele beschädigt nach letztem Beben
|
||||
}
|
||||
|
||||
interface PastQuake {
|
||||
year: number
|
||||
magnitude: number
|
||||
deaths: number
|
||||
homeless: number
|
||||
}
|
||||
|
||||
export class ErdbebenGame extends GameEngine {
|
||||
private buildings: OwnedBuilding[] = []
|
||||
private pastQuakes: PastQuake[] = []
|
||||
|
||||
// Nicht untergebrachte Menschen (Wartende auf Wohnraum)
|
||||
private waiting = 0
|
||||
private hasSchool = false
|
||||
private nextQuakeIn = 0 // Ticks bis zum nächsten Beben (zufällig 6-12)
|
||||
private firedEvents = new Set<string>()
|
||||
|
||||
constructor() {
|
||||
super(META)
|
||||
this.setupResources()
|
||||
this.setupGoals()
|
||||
this.setupTutorial()
|
||||
this.setupVariables()
|
||||
|
||||
// Erstes Beben kommt nach 8-14 Jahren
|
||||
this.nextQuakeIn = 8 + Math.floor(Math.random() * 6)
|
||||
}
|
||||
|
||||
private setupResources(): void {
|
||||
this.addResource({
|
||||
id: 'budget',
|
||||
name: 'Budget',
|
||||
icon: '💰',
|
||||
initial: 250,
|
||||
unit: '€',
|
||||
format: (v) => `${Math.round(v)} €`,
|
||||
})
|
||||
this.addResource({
|
||||
id: 'population',
|
||||
name: 'Bevölkerung',
|
||||
icon: '👥',
|
||||
initial: 500,
|
||||
unit: '',
|
||||
format: (v) => `${Math.round(v).toLocaleString('de-AT')}`,
|
||||
})
|
||||
this.addResource({
|
||||
id: 'housed',
|
||||
name: 'Wohnraum',
|
||||
icon: '🏠',
|
||||
initial: 0,
|
||||
unit: 'Plätze',
|
||||
format: (v) => `${Math.round(v).toLocaleString('de-AT')}`,
|
||||
})
|
||||
this.addResource({
|
||||
id: 'waiting',
|
||||
name: 'Ohne Wohnung',
|
||||
icon: '⛺',
|
||||
initial: 0,
|
||||
unit: 'Menschen',
|
||||
format: (v) => `${Math.round(v).toLocaleString('de-AT')}`,
|
||||
})
|
||||
this.addResource({
|
||||
id: 'deaths',
|
||||
name: 'Opfer (gesamt)',
|
||||
icon: '🕯️',
|
||||
initial: 0,
|
||||
unit: '',
|
||||
format: (v) => `${Math.round(v).toLocaleString('de-AT')}`,
|
||||
})
|
||||
this.addResource({
|
||||
id: 'lastQuake',
|
||||
name: 'Letzte Magnitude',
|
||||
icon: '📊',
|
||||
initial: 0,
|
||||
unit: 'M',
|
||||
format: (v) => v > 0 ? `M ${v.toFixed(1)}` : '—',
|
||||
})
|
||||
}
|
||||
|
||||
private setupGoals(): void {
|
||||
this.addGoal({
|
||||
id: 'survive',
|
||||
title: 'Bis 2025 regieren',
|
||||
description: '50 Jahre Stadtplanung — von 1975 bis 2025.',
|
||||
check: (g) => (g as ErdbebenGame).tick >= 50,
|
||||
progress: (g) => Math.min(100, ((g as ErdbebenGame).tick / 50) * 100),
|
||||
required: true,
|
||||
})
|
||||
this.addGoal({
|
||||
id: 'housing',
|
||||
title: 'Alle Bewohner unterbringen',
|
||||
description: 'Maximal 50 Menschen ohne Wohnung am Ende.',
|
||||
check: (g) => (g as ErdbebenGame).waiting <= 50,
|
||||
progress: (g) => {
|
||||
const w = (g as ErdbebenGame).waiting
|
||||
return Math.max(0, Math.min(100, 100 - w / 5))
|
||||
},
|
||||
required: true,
|
||||
})
|
||||
this.addGoal({
|
||||
id: 'safe',
|
||||
title: 'Weniger als 200 Tote',
|
||||
description: 'Halte die Opferzahl durch Erdbeben unter 200.',
|
||||
check: (g) => g.getResource('deaths') < 200,
|
||||
progress: (g) => Math.max(0, Math.min(100, 100 - g.getResource('deaths') / 2)),
|
||||
required: true,
|
||||
})
|
||||
this.addGoal({
|
||||
id: 'budget',
|
||||
title: 'Nicht pleite gehen',
|
||||
description: 'Halte ein positives Budget.',
|
||||
check: (g) => g.getResource('budget') > 0,
|
||||
required: true,
|
||||
})
|
||||
}
|
||||
|
||||
private setupTutorial(): void {
|
||||
this.setTutorial([
|
||||
{
|
||||
triggerTick: 0,
|
||||
title: 'Willkommen, Bürgermeister*in!',
|
||||
text: 'Es ist 1975. Du übernimmst eine Kleinstadt mit 500 Einwohnern in einer Erdbebenregion.\n\nIn den nächsten 50 Jahren werden viele neue Familien zuziehen. Du musst entscheiden: Welche Häuser baust du?\n\nVorsicht: Erdbeben kommen unangekündigt. Manche schwach, manche stark.',
|
||||
unlocks: ['budget'],
|
||||
},
|
||||
{
|
||||
triggerTick: 0,
|
||||
title: 'Wachstum & Wohnraum',
|
||||
text: 'Jedes Jahr wächst die Bevölkerung um etwa 80 Personen. Du musst rechtzeitig Wohnraum schaffen.\n\nMenschen ohne Wohnung warten in Notunterkünften — und sie werden unzufrieden. Bei Beben sterben sie überproportional oft.\n\nBaue klug voraus, nicht erst wenn es zu spät ist.',
|
||||
unlocks: ['population'],
|
||||
},
|
||||
{
|
||||
triggerTick: 0,
|
||||
title: 'Bauqualität entscheidet',
|
||||
text: 'Die wichtigste Frage: Wie stabil baust du?\n\n🏚️ Slum-Häuser: 30 € — wirken verlockend billig, aber bei Beben sterben viele Menschen darin.\n🏘️ Einfach: 80 € — übersteht schwache Beben.\n🏠 Verstärkt: 180 € — gute Wahl für mittlere Beben.\n🏛️ Erdbebensicher: 400 € — sicher, aber teuer.\n\n🏫 Eine Schule senkt zusätzlich die Opferzahlen durch Aufklärung.',
|
||||
unlocks: ['shop'],
|
||||
},
|
||||
{
|
||||
triggerTick: 0,
|
||||
title: 'Die zentrale Erkenntnis',
|
||||
text: 'Naturgefahren werden zu Naturkatastrophen — durch unsere Entscheidungen.\n\nGleiche Magnitude, andere Bauqualität: 5 Tote oder 500.\n\nDu hast 250 € Startbudget. Pro Jahr bekommst du Steuern (~20 € pro 1000 Einwohner). Wartung deiner Gebäude bezahlt sich davon.\n\nDeine Aufgabe: alle bis 2025 sicher unterbringen und unter 200 Opfer bleiben.\n\nLos geht\'s! 🏗️',
|
||||
unlocks: ['controls'],
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
private setupVariables(): void {
|
||||
this.setVariable('totalCapacity', 0)
|
||||
this.setVariable('avgQuality', 0)
|
||||
this.setVariable('upkeepTotal', 0)
|
||||
}
|
||||
|
||||
private recalc(): void {
|
||||
let cap = 0
|
||||
let qSum = 0
|
||||
let qCount = 0
|
||||
let upkeep = 0
|
||||
for (const b of this.buildings) {
|
||||
const t = BUILDING_TYPES.find(x => x.id === b.typeId)
|
||||
if (!t) continue
|
||||
const aliveCount = b.count - b.damaged
|
||||
cap += t.capacity * aliveCount
|
||||
qSum += t.quality * aliveCount
|
||||
qCount += aliveCount
|
||||
upkeep += t.upkeep * aliveCount
|
||||
}
|
||||
this.setVariable('totalCapacity', cap)
|
||||
this.setVariable('avgQuality', qCount > 0 ? qSum / qCount : 0)
|
||||
this.setVariable('upkeepTotal', upkeep)
|
||||
}
|
||||
|
||||
buyBuilding(typeId: string): boolean {
|
||||
const t = BUILDING_TYPES.find(x => x.id === typeId)
|
||||
if (!t) return false
|
||||
const budget = this.getResource('budget')
|
||||
if (budget < t.cost) {
|
||||
this.addEvent('error', `Nicht genug Budget für ${t.name}`, 'warning')
|
||||
return false
|
||||
}
|
||||
this.changeResource('budget', -t.cost)
|
||||
|
||||
const existing = this.buildings.find(x => x.typeId === typeId)
|
||||
if (existing) {
|
||||
existing.count++
|
||||
} else {
|
||||
this.buildings.push({ typeId, count: 1, damaged: 0 })
|
||||
}
|
||||
|
||||
if (typeId === 'school') this.hasSchool = true
|
||||
this.recalc()
|
||||
this.addEvent('build', `${t.emoji} ${t.name} gebaut (-${t.cost} €)`, 'success')
|
||||
this.notify()
|
||||
return true
|
||||
}
|
||||
|
||||
getOwnedBuildings(): OwnedBuilding[] {
|
||||
return this.buildings
|
||||
}
|
||||
|
||||
getBuildingCount(id: string): number {
|
||||
return this.buildings.find(b => b.typeId === id)?.count ?? 0
|
||||
}
|
||||
|
||||
getStartYear(): number { return START_YEAR }
|
||||
|
||||
protected simulateTick(): void {
|
||||
// 1. Bevölkerung wächst
|
||||
const growth = 80 + Math.floor(Math.random() * 30) - 15
|
||||
this.changeResource('population', growth)
|
||||
|
||||
// 2. Wohnraum berechnen
|
||||
const totalPop = this.getResource('population')
|
||||
const totalCap = this.getVariable('totalCapacity')
|
||||
const housed = Math.min(totalPop, totalCap)
|
||||
this.waiting = Math.max(0, totalPop - totalCap)
|
||||
this.setResource('housed', housed)
|
||||
this.setResource('waiting', this.waiting)
|
||||
|
||||
// 3. Steuern (proportional zur untergebrachten Bevölkerung)
|
||||
const income = Math.round((housed / 1000) * 25 + 5)
|
||||
this.changeResource('budget', income)
|
||||
|
||||
// 4. Wartungskosten
|
||||
const upkeep = this.getVariable('upkeepTotal')
|
||||
this.changeResource('budget', -upkeep)
|
||||
|
||||
// 5. Erdbeben?
|
||||
this.nextQuakeIn--
|
||||
if (this.nextQuakeIn <= 0) {
|
||||
this.triggerEarthquake()
|
||||
// Nächstes Beben in 5-12 Jahren
|
||||
this.nextQuakeIn = 5 + Math.floor(Math.random() * 8)
|
||||
}
|
||||
|
||||
// 6. Schäden über die Zeit reparieren (langsam)
|
||||
for (const b of this.buildings) {
|
||||
if (b.damaged > 0 && Math.random() < 0.3) {
|
||||
b.damaged--
|
||||
}
|
||||
}
|
||||
this.recalc()
|
||||
|
||||
// 7. Events
|
||||
if (this.tick === 5 && this.waiting > 100 && !this.firedEvent('housing-1')) {
|
||||
this.addEvent('housing-1', '⚠️ Über 100 Menschen leben in Notunterkünften!', 'warning')
|
||||
}
|
||||
if (this.tick === 20 && this.getResource('deaths') === 0 && !this.firedEvent('praise-1')) {
|
||||
this.addEvent('praise-1', '👏 20 Jahre ohne Opfer — die Bürger danken dir!', 'success')
|
||||
}
|
||||
if (this.hasSchool && !this.firedEvent('school-built')) {
|
||||
this.addEvent('school-built', '🏫 Die neue Schule informiert die Bürger über Erdbebenschutz.', 'success')
|
||||
this.firedEvents.add('school-built')
|
||||
}
|
||||
}
|
||||
|
||||
private triggerEarthquake(): void {
|
||||
// Magnitude folgt grob Gutenberg-Richter — kleinere Beben häufiger
|
||||
// 60% schwach (4-5), 30% mittel (5-6.5), 10% stark (6.5-8)
|
||||
const r = Math.random()
|
||||
let magnitude: number
|
||||
if (r < 0.6) magnitude = 4 + Math.random()
|
||||
else if (r < 0.9) magnitude = 5 + Math.random() * 1.5
|
||||
else magnitude = 6.5 + Math.random() * 1.5
|
||||
|
||||
this.setResource('lastQuake', magnitude)
|
||||
|
||||
// Schäden berechnen
|
||||
let totalDeaths = 0
|
||||
let totalHomeless = 0
|
||||
let totalDamaged = 0
|
||||
|
||||
for (const b of this.buildings) {
|
||||
const t = BUILDING_TYPES.find(x => x.id === b.typeId)
|
||||
if (!t || t.capacity === 0) continue
|
||||
|
||||
// Wahrscheinlichkeit, dass ein Gebäude einstürzt:
|
||||
// f(magnitude, quality)
|
||||
// Bei Magnitude 4 + quality 0.05 → ~30% collapse
|
||||
// Bei Magnitude 7 + quality 0.05 → ~95% collapse
|
||||
// Bei Magnitude 7 + quality 0.92 → ~10% collapse
|
||||
const stress = Math.max(0, (magnitude - 3) / 5) // 0..1
|
||||
const collapseProbability = Math.max(0, Math.min(0.95, stress - t.quality * 0.9))
|
||||
|
||||
const aliveCount = b.count - b.damaged
|
||||
let collapsedThisQuake = 0
|
||||
for (let i = 0; i < aliveCount; i++) {
|
||||
if (Math.random() < collapseProbability) {
|
||||
collapsedThisQuake++
|
||||
}
|
||||
}
|
||||
b.damaged += collapsedThisQuake
|
||||
totalDamaged += collapsedThisQuake
|
||||
|
||||
// Tote pro eingestürztem Gebäude
|
||||
// Schule senkt um 50%
|
||||
const schoolFactor = this.hasSchool ? 0.5 : 1
|
||||
const deathsPerCollapse = Math.round(t.capacity * 0.15 * schoolFactor * (1 - t.quality * 0.5))
|
||||
totalDeaths += collapsedThisQuake * deathsPerCollapse
|
||||
totalHomeless += collapsedThisQuake * t.capacity
|
||||
}
|
||||
|
||||
// Nicht-untergebrachte Menschen sterben überproportional
|
||||
if (this.waiting > 0) {
|
||||
const stress = Math.max(0, (magnitude - 3) / 5)
|
||||
const waitingDeaths = Math.round(this.waiting * stress * 0.15)
|
||||
totalDeaths += waitingDeaths
|
||||
}
|
||||
|
||||
if (totalDeaths > 0) {
|
||||
this.changeResource('population', -totalDeaths)
|
||||
this.changeResource('deaths', totalDeaths)
|
||||
}
|
||||
|
||||
this.pastQuakes.push({
|
||||
year: this.tick,
|
||||
magnitude,
|
||||
deaths: totalDeaths,
|
||||
homeless: totalHomeless,
|
||||
})
|
||||
|
||||
// Event-Meldung
|
||||
if (magnitude < 5) {
|
||||
this.addEvent('quake', `📊 Schwaches Beben (M ${magnitude.toFixed(1)}). ${totalDeaths > 0 ? `${totalDeaths} Opfer.` : 'Keine Opfer.'}`, totalDeaths > 0 ? 'warning' : 'info')
|
||||
} else if (magnitude < 6.5) {
|
||||
this.addEvent('quake', `⚠️ Mittleres Beben (M ${magnitude.toFixed(1)}). ${totalDeaths} Opfer, ${totalDamaged} Häuser beschädigt.`, 'warning')
|
||||
} else {
|
||||
this.addEvent('quake', `🆘 STARKES BEBEN (M ${magnitude.toFixed(1)})! ${totalDeaths} Opfer, ${totalDamaged} Häuser zerstört.`, 'danger')
|
||||
}
|
||||
|
||||
this.notify()
|
||||
}
|
||||
|
||||
private firedEvent(id: string): boolean {
|
||||
if (this.firedEvents.has(id)) return true
|
||||
this.firedEvents.add(id)
|
||||
return false
|
||||
}
|
||||
|
||||
getPastQuakes(): PastQuake[] {
|
||||
return this.pastQuakes
|
||||
}
|
||||
|
||||
getNextQuakeIn(): number {
|
||||
return this.nextQuakeIn
|
||||
}
|
||||
|
||||
protected checkLossCondition(): boolean {
|
||||
if (this.getResource('budget') < -300) return true
|
||||
if (this.getResource('deaths') > 1000) return true
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* SIM-07: Erdbeben-Simulator — LOGIK
|
||||
*
|
||||
* Modell:
|
||||
* - Zwei tektonische Platten bewegen sich gegeneinander
|
||||
* - Spannung baut sich auf (abhängig von Geschwindigkeit und Gesteinstyp)
|
||||
* - Bei Überschreitung der Bruchspannung → Erdbeben
|
||||
* - Magnitude abhängig von akkumulierter Spannung
|
||||
* - Auswirkungen auf zwei Städte (arm vs. reich) berechnet
|
||||
*
|
||||
* Didaktik:
|
||||
* - Fehlkonzepte: "Erdbeben sind zufällig", "Stärke = Schaden"
|
||||
* - Kernaussage: Gleiche Magnitude, unterschiedliche Auswirkungen
|
||||
*/
|
||||
|
||||
import { Simulation, SimulationMeta } from '@core/simulation'
|
||||
|
||||
const META: SimulationMeta = {
|
||||
id: 'sim-07',
|
||||
name: 'Erdbeben-Simulator',
|
||||
educationLevels: [5, 6, 7, 8],
|
||||
primaryLevel: 5,
|
||||
kompetenzbereich: 'Leben und Wirtschaften unter Beachtung der natürlichen Prozesse',
|
||||
lernziele: [
|
||||
'Zusammenhang zwischen Plattenbewegung und Erdbeben verstehen',
|
||||
'Unterschied zwischen Magnitude und Schadensausmaß erkennen',
|
||||
'Ungleiche Betroffenheit durch Naturgefahren analysieren',
|
||||
],
|
||||
basiskonzepte: ['Veränderung und Wandel', 'Gemeinsamkeiten und Unterschiede'],
|
||||
dpiMinuten: 25,
|
||||
typ: 'sachsimulation',
|
||||
tier: 1,
|
||||
requiresReading: true,
|
||||
}
|
||||
|
||||
export interface QuakeEvent {
|
||||
time: number
|
||||
magnitude: number
|
||||
epicenterX: number
|
||||
depth: number
|
||||
}
|
||||
|
||||
export interface CityImpact {
|
||||
name: string
|
||||
type: 'rich' | 'poor'
|
||||
distance: number
|
||||
damage: number // 0-100%
|
||||
casualties: number // estimated
|
||||
buildingCollapse: number // %
|
||||
recovery: string // "Monate" | "Jahre" | "Jahrzehnte"
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Magnitude basierend auf akkumulierter Spannung
|
||||
* Vereinfachtes Gutenberg-Richter-artiges Modell
|
||||
*/
|
||||
export function computeMagnitude(stress: number, rockHardness: number): number {
|
||||
// log-Beziehung: mehr Spannung → exponentiell stärkeres Beben
|
||||
const base = Math.log10(Math.max(1, stress * rockHardness)) + 2
|
||||
return Math.min(9.5, Math.max(1, base))
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Auswirkungen auf eine Stadt
|
||||
*/
|
||||
export function computeCityImpact(
|
||||
magnitude: number,
|
||||
distance: number,
|
||||
buildingQuality: number // 0-1 (0=schlecht, 1=erdbebensicher)
|
||||
): CityImpact {
|
||||
// Intensität nimmt mit Entfernung ab (vereinfacht)
|
||||
const distanceFactor = Math.max(0.1, 1 - distance / 500)
|
||||
const intensity = magnitude * distanceFactor
|
||||
|
||||
// Schaden abhängig von Bauqualität
|
||||
const rawDamage = Math.pow(intensity / 9, 2.5) * 100
|
||||
const damage = Math.min(100, rawDamage * (1 - buildingQuality * 0.8))
|
||||
|
||||
// Opfer proportional zu Schaden und inverser Bauqualität
|
||||
const casualties = Math.round(damage * (1 - buildingQuality) * 5)
|
||||
|
||||
// Gebäudekollaps
|
||||
const buildingCollapse = Math.min(100, rawDamage * (1 - buildingQuality * 0.9))
|
||||
|
||||
// Erholungszeit
|
||||
let recovery = 'Wochen'
|
||||
if (damage > 70) recovery = 'Jahrzehnte'
|
||||
else if (damage > 40) recovery = 'Jahre'
|
||||
else if (damage > 15) recovery = 'Monate'
|
||||
|
||||
return {
|
||||
name: '',
|
||||
type: buildingQuality > 0.6 ? 'rich' : 'poor',
|
||||
distance,
|
||||
damage: Math.round(damage),
|
||||
casualties,
|
||||
buildingCollapse: Math.round(buildingCollapse),
|
||||
recovery,
|
||||
}
|
||||
}
|
||||
|
||||
export class ErdbebenSimulation extends Simulation {
|
||||
private stress = 0
|
||||
private quakeHistory: QuakeEvent[] = []
|
||||
private tickCount = 0
|
||||
|
||||
constructor() {
|
||||
super(META)
|
||||
const ranges = this.getVariableRanges()
|
||||
for (const [key, range] of Object.entries(ranges)) {
|
||||
this.state.variables[key] = range.default
|
||||
}
|
||||
}
|
||||
|
||||
getVariableRanges() {
|
||||
return {
|
||||
plateSpeed: {
|
||||
min: 1, max: 15, default: 5,
|
||||
unit: 'cm/Jahr', label: 'Plattengeschwindigkeit',
|
||||
},
|
||||
rockHardness: {
|
||||
min: 0.3, max: 1.5, default: 0.8,
|
||||
unit: '', label: 'Gesteinshärte',
|
||||
},
|
||||
buildingQualityA: {
|
||||
min: 0, max: 1, default: 0.8,
|
||||
unit: '', label: 'Bauqualität Stadt A (reich)',
|
||||
},
|
||||
buildingQualityB: {
|
||||
min: 0, max: 1, default: 0.2,
|
||||
unit: '', label: 'Bauqualität Stadt B (arm)',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Simuliert einen Tick (= 1 Jahr) */
|
||||
tick(): QuakeEvent | null {
|
||||
this.tickCount++
|
||||
const speed = this.getVariable('plateSpeed')
|
||||
const hardness = this.getVariable('rockHardness')
|
||||
|
||||
// Spannung baut sich auf
|
||||
this.stress += speed * 0.1 * hardness
|
||||
|
||||
// Bruchspannung (mit Zufallskomponente)
|
||||
const breakThreshold = 3 + Math.random() * 2
|
||||
|
||||
if (this.stress >= breakThreshold) {
|
||||
const magnitude = computeMagnitude(this.stress, hardness)
|
||||
const quake: QuakeEvent = {
|
||||
time: this.tickCount,
|
||||
magnitude,
|
||||
epicenterX: 0.5 + (Math.random() - 0.5) * 0.2,
|
||||
depth: 5 + Math.random() * 50,
|
||||
}
|
||||
this.quakeHistory.push(quake)
|
||||
this.stress = this.stress * 0.1 // Spannungsabbau (nicht komplett)
|
||||
return quake
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
getStress(): number { return this.stress; }
|
||||
getHistory(): QuakeEvent[] { return [...this.quakeHistory]; }
|
||||
|
||||
/** Vergleicht Auswirkungen auf beide Städte */
|
||||
compareImpact(magnitude: number): { cityA: CityImpact; cityB: CityImpact } {
|
||||
const qualA = this.getVariable('buildingQualityA')
|
||||
const qualB = this.getVariable('buildingQualityB')
|
||||
|
||||
const cityA = computeCityImpact(magnitude, 30, qualA)
|
||||
cityA.name = 'Stadt A (wohlhabend)'
|
||||
cityA.type = 'rich'
|
||||
|
||||
const cityB = computeCityImpact(magnitude, 30, qualB)
|
||||
cityB.name = 'Stadt B (einkommensschwach)'
|
||||
cityB.type = 'poor'
|
||||
|
||||
this.state.results = { cityA, cityB, magnitude }
|
||||
return { cityA, cityB }
|
||||
}
|
||||
|
||||
compute() {
|
||||
return {
|
||||
stress: this.stress,
|
||||
quakeCount: this.quakeHistory.length,
|
||||
lastMagnitude: this.quakeHistory.length > 0
|
||||
? this.quakeHistory[this.quakeHistory.length - 1].magnitude
|
||||
: 0,
|
||||
}
|
||||
}
|
||||
|
||||
protected onVariableChange(): void {}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* SIM-07: Erdbeben-Simulator — Canvas Renderer
|
||||
*
|
||||
* Visualisiert:
|
||||
* - Querschnitt der Erdkruste mit zwei tektonischen Platten
|
||||
* - Spannungsaufbau (visuell durch Risse/Verformung)
|
||||
* - Erdbeben-Welle bei Bruch
|
||||
* - Zwei Städte auf der Oberfläche (links arm, rechts reich)
|
||||
* - Schadensanzeige bei Beben
|
||||
*
|
||||
* Stil: Skandinavisch — gedeckte Erdfarben, klare Schichten
|
||||
*/
|
||||
|
||||
import { ErdbebenSimulation, computeCityImpact, type QuakeEvent } from './logic'
|
||||
|
||||
interface SeismicWave {
|
||||
x: number
|
||||
y: number
|
||||
radius: number
|
||||
maxRadius: number
|
||||
intensity: number
|
||||
}
|
||||
|
||||
interface DamageMarker {
|
||||
x: number
|
||||
y: number
|
||||
damage: number
|
||||
age: number
|
||||
}
|
||||
|
||||
export class ErdbebenRenderer {
|
||||
private canvas: HTMLCanvasElement
|
||||
private ctx: CanvasRenderingContext2D
|
||||
private sim: ErdbebenSimulation
|
||||
private W = 0
|
||||
private H = 0
|
||||
private t = 0
|
||||
private animId = 0
|
||||
private waves: SeismicWave[] = []
|
||||
private damageA: DamageMarker[] = []
|
||||
private damageB: DamageMarker[] = []
|
||||
private shake = 0
|
||||
private autoTick = true
|
||||
private tickAccum = 0
|
||||
private lastQuake: QuakeEvent | null = null
|
||||
private lastImpact: ReturnType<ErdbebenSimulation['compareImpact']> | null = null
|
||||
|
||||
// Layout (relative to H)
|
||||
private surfaceY = 0
|
||||
private mantleY = 0
|
||||
private cityAX = 0
|
||||
private cityBX = 0
|
||||
|
||||
// Plate offsets (visual deformation)
|
||||
private plateLeftX = 0
|
||||
private plateRightX = 0
|
||||
|
||||
private col = {
|
||||
sky: '#dde3da',
|
||||
skyTop: '#c8d4c6',
|
||||
crust: '#a89878',
|
||||
crustDark: '#8a7858',
|
||||
mantle: '#c07a6b',
|
||||
mantleHot: '#d08a7b',
|
||||
line: '#5a5a5a',
|
||||
cityRich: '#5a8a5e',
|
||||
cityPoor: '#c4a35a',
|
||||
wave: '#c07a6b',
|
||||
text: '#1a1a1a',
|
||||
muted: '#6a6a6a',
|
||||
}
|
||||
|
||||
constructor(container: HTMLElement, sim: ErdbebenSimulation) {
|
||||
this.sim = sim
|
||||
this.canvas = document.createElement('canvas')
|
||||
this.canvas.style.cssText = 'width:100%;height:100%;display:block;border-radius:12px;'
|
||||
container.appendChild(this.canvas)
|
||||
const ctx = this.canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Canvas not supported')
|
||||
this.ctx = ctx
|
||||
|
||||
this.resize()
|
||||
window.addEventListener('resize', () => this.resize())
|
||||
}
|
||||
|
||||
private resize(): void {
|
||||
const rect = this.canvas.parentElement!.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
this.W = rect.width
|
||||
this.H = Math.min(rect.width * 0.65, 500)
|
||||
this.canvas.width = this.W * dpr
|
||||
this.canvas.height = this.H * dpr
|
||||
this.canvas.style.height = this.H + 'px'
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
|
||||
this.surfaceY = this.H * 0.45
|
||||
this.mantleY = this.H * 0.85
|
||||
this.cityAX = this.W * 0.25
|
||||
this.cityBX = this.W * 0.75
|
||||
}
|
||||
|
||||
start(): void {
|
||||
const loop = () => {
|
||||
this.t += 0.016
|
||||
this.update()
|
||||
this.draw()
|
||||
this.animId = requestAnimationFrame(loop)
|
||||
}
|
||||
loop()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
cancelAnimationFrame(this.animId)
|
||||
}
|
||||
|
||||
triggerManualQuake(): void {
|
||||
// Force-tick until quake
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const q = this.sim.tick()
|
||||
if (q) {
|
||||
this.spawnQuake(q)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private update(): void {
|
||||
// Auto-tick
|
||||
if (this.autoTick) {
|
||||
this.tickAccum += 0.016
|
||||
if (this.tickAccum > 0.3) { // Tick every 0.3s
|
||||
this.tickAccum = 0
|
||||
const q = this.sim.tick()
|
||||
if (q) this.spawnQuake(q)
|
||||
}
|
||||
}
|
||||
|
||||
// Plate visual deformation based on stress
|
||||
const stress = this.sim.getStress()
|
||||
const targetOffset = Math.min(8, stress * 1.5)
|
||||
this.plateLeftX += (-targetOffset - this.plateLeftX) * 0.05
|
||||
this.plateRightX += (targetOffset - this.plateRightX) * 0.05
|
||||
|
||||
// Update waves
|
||||
for (let i = this.waves.length - 1; i >= 0; i--) {
|
||||
const w = this.waves[i]
|
||||
w.radius += 4
|
||||
w.intensity *= 0.97
|
||||
if (w.radius > w.maxRadius) this.waves.splice(i, 1)
|
||||
}
|
||||
|
||||
// Shake decay
|
||||
this.shake *= 0.92
|
||||
}
|
||||
|
||||
private spawnQuake(q: QuakeEvent): void {
|
||||
this.lastQuake = q
|
||||
const epicenterX = q.epicenterX * this.W
|
||||
|
||||
this.waves.push({
|
||||
x: epicenterX,
|
||||
y: this.surfaceY + 20,
|
||||
radius: 5,
|
||||
maxRadius: this.W * 0.8,
|
||||
intensity: 1,
|
||||
})
|
||||
|
||||
this.shake = q.magnitude * 0.5
|
||||
|
||||
// Reset plate visual deformation
|
||||
this.plateLeftX = 0
|
||||
this.plateRightX = 0
|
||||
|
||||
// Compute city impacts
|
||||
this.lastImpact = this.sim.compareImpact(q.magnitude)
|
||||
|
||||
// Add damage markers
|
||||
this.damageA.push({
|
||||
x: this.cityAX,
|
||||
y: this.surfaceY,
|
||||
damage: this.lastImpact.cityA.damage,
|
||||
age: 0,
|
||||
})
|
||||
this.damageB.push({
|
||||
x: this.cityBX,
|
||||
y: this.surfaceY,
|
||||
damage: this.lastImpact.cityB.damage,
|
||||
age: 0,
|
||||
})
|
||||
|
||||
// Keep last 3 markers
|
||||
if (this.damageA.length > 3) this.damageA.shift()
|
||||
if (this.damageB.length > 3) this.damageB.shift()
|
||||
}
|
||||
|
||||
private draw(): void {
|
||||
const { ctx, W, H } = this
|
||||
|
||||
// Apply shake
|
||||
ctx.save()
|
||||
if (this.shake > 0.01) {
|
||||
ctx.translate((Math.random() - 0.5) * this.shake, (Math.random() - 0.5) * this.shake)
|
||||
}
|
||||
|
||||
ctx.clearRect(-10, -10, W + 20, H + 20)
|
||||
|
||||
// Sky
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, this.surfaceY)
|
||||
sky.addColorStop(0, this.col.skyTop)
|
||||
sky.addColorStop(1, this.col.sky)
|
||||
ctx.fillStyle = sky
|
||||
ctx.fillRect(0, 0, W, this.surfaceY)
|
||||
|
||||
// Sun
|
||||
ctx.fillStyle = '#e8c84a'
|
||||
ctx.beginPath()
|
||||
ctx.arc(W * 0.85, this.H * 0.12, 18, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Earth crust (left plate)
|
||||
ctx.save()
|
||||
ctx.translate(this.plateLeftX, 0)
|
||||
ctx.fillStyle = this.col.crust
|
||||
ctx.fillRect(-20, this.surfaceY, W * 0.5 + 20, this.mantleY - this.surfaceY)
|
||||
ctx.fillStyle = this.col.crustDark
|
||||
ctx.fillRect(-20, this.mantleY - 8, W * 0.5 + 20, 8)
|
||||
// Surface texture
|
||||
ctx.fillStyle = this.col.crustDark
|
||||
for (let x = 0; x < W * 0.5; x += 25) {
|
||||
ctx.fillRect(x + 2, this.surfaceY, 1, 6 + Math.sin(x * 0.1) * 3)
|
||||
}
|
||||
ctx.restore()
|
||||
|
||||
// Right plate
|
||||
ctx.save()
|
||||
ctx.translate(this.plateRightX, 0)
|
||||
ctx.fillStyle = this.col.crust
|
||||
ctx.fillRect(W * 0.5 - 5, this.surfaceY, W * 0.5 + 20, this.mantleY - this.surfaceY)
|
||||
ctx.fillStyle = this.col.crustDark
|
||||
ctx.fillRect(W * 0.5 - 5, this.mantleY - 8, W * 0.5 + 20, 8)
|
||||
for (let x = W * 0.5; x < W; x += 25) {
|
||||
ctx.fillRect(x + 2, this.surfaceY, 1, 6 + Math.sin(x * 0.1) * 3)
|
||||
}
|
||||
ctx.restore()
|
||||
|
||||
// Plate boundary line (red, intensity = stress)
|
||||
const stress = this.sim.getStress()
|
||||
const boundaryAlpha = Math.min(0.8, stress * 0.15)
|
||||
ctx.strokeStyle = `rgba(192,80,60,${boundaryAlpha})`
|
||||
ctx.lineWidth = 2 + stress * 0.5
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(W * 0.5, this.surfaceY)
|
||||
ctx.lineTo(W * 0.5, this.mantleY)
|
||||
ctx.stroke()
|
||||
|
||||
// Mantle
|
||||
const mantleGrad = ctx.createLinearGradient(0, this.mantleY, 0, H)
|
||||
mantleGrad.addColorStop(0, this.col.mantle)
|
||||
mantleGrad.addColorStop(1, this.col.mantleHot)
|
||||
ctx.fillStyle = mantleGrad
|
||||
ctx.fillRect(0, this.mantleY, W, H - this.mantleY)
|
||||
|
||||
// Magma blobs (animated)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const mx = (i / 5) * W + Math.sin(this.t + i) * 20
|
||||
const my = this.mantleY + 15 + Math.sin(this.t * 0.5 + i * 2) * 5
|
||||
ctx.fillStyle = `rgba(232,140,120,${0.3 + Math.sin(this.t + i) * 0.2})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(mx, my, 8 + Math.sin(this.t * 2 + i) * 2, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// Plate movement arrows
|
||||
if (stress > 0.5) {
|
||||
this.drawArrow(ctx, W * 0.15, this.mantleY - 25, 'right', stress * 0.3)
|
||||
this.drawArrow(ctx, W * 0.85, this.mantleY - 25, 'left', stress * 0.3)
|
||||
}
|
||||
|
||||
// Cities
|
||||
this.drawCity(ctx, this.cityAX, this.surfaceY, 'rich', this.damageA[this.damageA.length - 1])
|
||||
this.drawCity(ctx, this.cityBX, this.surfaceY, 'poor', this.damageB[this.damageB.length - 1])
|
||||
|
||||
// City labels
|
||||
ctx.fillStyle = this.col.text
|
||||
ctx.font = 'bold 11px Inter, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('Stadt A', this.cityAX, this.surfaceY - 35)
|
||||
ctx.font = '9px Inter, sans-serif'
|
||||
ctx.fillStyle = this.col.muted
|
||||
ctx.fillText('(wohlhabend)', this.cityAX, this.surfaceY - 24)
|
||||
|
||||
ctx.fillStyle = this.col.text
|
||||
ctx.font = 'bold 11px Inter, sans-serif'
|
||||
ctx.fillText('Stadt B', this.cityBX, this.surfaceY - 35)
|
||||
ctx.font = '9px Inter, sans-serif'
|
||||
ctx.fillStyle = this.col.muted
|
||||
ctx.fillText('(einkommensschwach)', this.cityBX, this.surfaceY - 24)
|
||||
|
||||
// Seismic waves
|
||||
for (const w of this.waves) {
|
||||
ctx.strokeStyle = `rgba(192,122,107,${w.intensity * 0.7})`
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.arc(w.x, w.y, w.radius, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
|
||||
// Inner waves
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeStyle = `rgba(192,122,107,${w.intensity * 0.4})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(w.x, w.y, w.radius * 0.7, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
ctx.restore() // shake
|
||||
|
||||
// ── Top info bar ──
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.85)'
|
||||
ctx.fillRect(0, 0, W, 38)
|
||||
ctx.fillStyle = this.col.text
|
||||
ctx.font = 'bold 12px Inter, sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(`Spannung: ${stress.toFixed(1)}`, 12, 17)
|
||||
|
||||
// Stress bar
|
||||
const barX = 105, barY = 8, barW = 80, barH = 8
|
||||
ctx.fillStyle = '#e0ddd6'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(barX, barY, barW, barH, 4)
|
||||
ctx.fill()
|
||||
const fillW = Math.min(barW, (stress / 5) * barW)
|
||||
ctx.fillStyle = stress > 4 ? '#c0503c' : stress > 2.5 ? '#c4a35a' : '#5a8a5e'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(barX, barY, fillW, barH, 4)
|
||||
ctx.fill()
|
||||
|
||||
ctx.font = '11px Inter, sans-serif'
|
||||
ctx.fillStyle = this.col.muted
|
||||
ctx.fillText(`Beben: ${this.sim.getHistory().length}`, 200, 17)
|
||||
|
||||
if (this.lastQuake) {
|
||||
ctx.fillStyle = this.col.text
|
||||
ctx.font = 'bold 11px Inter, sans-serif'
|
||||
ctx.fillText(`Letztes Beben: ${this.lastQuake.magnitude.toFixed(1)} M`, 280, 17)
|
||||
}
|
||||
|
||||
// Bottom comparison panel (when impact computed)
|
||||
if (this.lastImpact) {
|
||||
const panelY = H - 60
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.92)'
|
||||
ctx.fillRect(0, panelY, W, 60)
|
||||
|
||||
// City A
|
||||
ctx.fillStyle = this.col.cityRich
|
||||
ctx.font = 'bold 11px Inter, sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(`Stadt A: ${this.lastImpact.cityA.damage}% Schaden`, 15, panelY + 20)
|
||||
ctx.fillStyle = this.col.muted
|
||||
ctx.font = '10px Inter, sans-serif'
|
||||
ctx.fillText(`${this.lastImpact.cityA.casualties} Opfer · Wiederaufbau: ${this.lastImpact.cityA.recovery}`, 15, panelY + 38)
|
||||
|
||||
// City B
|
||||
ctx.fillStyle = this.col.cityPoor
|
||||
ctx.font = 'bold 11px Inter, sans-serif'
|
||||
ctx.textAlign = 'right'
|
||||
ctx.fillText(`Stadt B: ${this.lastImpact.cityB.damage}% Schaden`, W - 15, panelY + 20)
|
||||
ctx.fillStyle = this.col.muted
|
||||
ctx.font = '10px Inter, sans-serif'
|
||||
ctx.fillText(`${this.lastImpact.cityB.casualties} Opfer · Wiederaufbau: ${this.lastImpact.cityB.recovery}`, W - 15, panelY + 38)
|
||||
}
|
||||
}
|
||||
|
||||
private drawArrow(ctx: CanvasRenderingContext2D, x: number, y: number, dir: 'left' | 'right', intensity: number): void {
|
||||
ctx.save()
|
||||
ctx.globalAlpha = Math.min(1, intensity)
|
||||
ctx.fillStyle = '#c0503c'
|
||||
ctx.strokeStyle = '#c0503c'
|
||||
ctx.lineWidth = 2
|
||||
|
||||
const len = 25
|
||||
const sign = dir === 'right' ? 1 : -1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, y)
|
||||
ctx.lineTo(x + len * sign, y)
|
||||
ctx.stroke()
|
||||
// Arrow head
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + len * sign, y)
|
||||
ctx.lineTo(x + (len - 6) * sign, y - 5)
|
||||
ctx.lineTo(x + (len - 6) * sign, y + 5)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
private drawCity(ctx: CanvasRenderingContext2D, x: number, y: number, type: 'rich' | 'poor', damage?: DamageMarker): void {
|
||||
const dmg = damage ? damage.damage / 100 : 0
|
||||
const isRich = type === 'rich'
|
||||
|
||||
if (isRich) {
|
||||
// Wohlhabende Stadt — moderne Hochhäuser
|
||||
const buildings = [
|
||||
{ offX: -20, w: 8, h: 22 },
|
||||
{ offX: -10, w: 10, h: 30 },
|
||||
{ offX: 2, w: 12, h: 35 },
|
||||
{ offX: 16, w: 8, h: 25 },
|
||||
]
|
||||
for (const b of buildings) {
|
||||
const collapseRatio = Math.max(0, 1 - dmg * 0.6)
|
||||
const actualH = b.h * collapseRatio
|
||||
// Tilt if damaged
|
||||
const tilt = dmg * (Math.random() - 0.5) * 0.15
|
||||
ctx.save()
|
||||
ctx.translate(x + b.offX, y)
|
||||
ctx.rotate(tilt)
|
||||
ctx.fillStyle = dmg > 0.3 ? '#9a8a7a' : '#c0d0c8'
|
||||
ctx.fillRect(0, -actualH, b.w, actualH)
|
||||
// Windows
|
||||
if (dmg < 0.5) {
|
||||
ctx.fillStyle = '#5a8a8e'
|
||||
for (let wy = 4; wy < actualH - 2; wy += 6) {
|
||||
for (let wx = 1; wx < b.w - 2; wx += 4) {
|
||||
ctx.fillRect(wx, -actualH + wy, 2, 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
} else {
|
||||
// Einkommensschwache Stadt — kleine, einfache Häuser
|
||||
const houses = [
|
||||
{ offX: -22, w: 10, h: 12 },
|
||||
{ offX: -10, w: 11, h: 14 },
|
||||
{ offX: 2, w: 12, h: 13 },
|
||||
{ offX: 15, w: 9, h: 10 },
|
||||
{ offX: 25, w: 8, h: 11 },
|
||||
]
|
||||
for (const h of houses) {
|
||||
const collapseRatio = Math.max(0.1, 1 - dmg * 0.95)
|
||||
const actualH = h.h * collapseRatio
|
||||
const tilt = dmg * (Math.random() - 0.5) * 0.4
|
||||
ctx.save()
|
||||
ctx.translate(x + h.offX, y)
|
||||
ctx.rotate(tilt)
|
||||
ctx.fillStyle = dmg > 0.4 ? '#8a7a6a' : '#d8c8a8'
|
||||
ctx.fillRect(0, -actualH, h.w, actualH)
|
||||
// Roof (only if not too damaged)
|
||||
if (dmg < 0.5) {
|
||||
ctx.fillStyle = '#a06050'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-1, -actualH)
|
||||
ctx.lineTo(h.w / 2, -actualH - 4)
|
||||
ctx.lineTo(h.w + 1, -actualH)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.restore()
|
||||
|
||||
// Rubble
|
||||
if (dmg > 0.3) {
|
||||
ctx.fillStyle = '#7a6a5a'
|
||||
ctx.fillRect(x + h.offX - 2, y - 2, h.w + 4, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* Energiemix-Spiel — Canvas Renderer
|
||||
*
|
||||
* Ansicht: Weite Landschaft mit Stadt am rechten Rand, dazwischen
|
||||
* verschiedene Kraftwerke in deterministischen Positionen. Im Hintergrund
|
||||
* Berge. Über der Landschaft zieht Wetter (Wolken, Sonne).
|
||||
*
|
||||
* Animationen:
|
||||
* - Windräder drehen sich (Geschwindigkeit abhängig von Wind-Faktor)
|
||||
* - Solar-Panels glitzern
|
||||
* - Rauch aus Kohle/Gas-Schornsteinen (pulsiert mit Auslastung)
|
||||
* - Kühltürme mit Dampf
|
||||
* - Batteriespeicher zeigen Pulsieren
|
||||
* - Bei Blackout: Stadt wird dunkel
|
||||
*
|
||||
* Zeitleiste am unteren Rand mit Jahren und Blackout-Markern.
|
||||
*/
|
||||
|
||||
import { EnergiemixGame, PLANT_TYPES } from './game'
|
||||
|
||||
interface PlacedPlant {
|
||||
typeId: string
|
||||
x: number // 0..1 in Welt
|
||||
y: number // 0..1 vertikal
|
||||
scale: number
|
||||
ownerId: string
|
||||
builtTick: number
|
||||
}
|
||||
|
||||
export class EnergiemixRenderer {
|
||||
private canvas: HTMLCanvasElement
|
||||
private ctx: CanvasRenderingContext2D
|
||||
private game: EnergiemixGame
|
||||
private W = 0
|
||||
private H = 0
|
||||
private t = 0
|
||||
private animId = 0
|
||||
private placed: PlacedPlant[] = []
|
||||
private knownIds = new Set<string>()
|
||||
private lastBlackouts = 0
|
||||
private blackoutFlash = 0
|
||||
|
||||
constructor(container: HTMLElement, game: EnergiemixGame) {
|
||||
this.game = game
|
||||
this.canvas = document.createElement('canvas')
|
||||
this.canvas.style.cssText = 'width:100%;display:block;border-radius:12px;background:#dce6ea;'
|
||||
container.appendChild(this.canvas)
|
||||
const ctx = this.canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Canvas not supported')
|
||||
this.ctx = ctx
|
||||
this.resize()
|
||||
window.addEventListener('resize', () => this.resize())
|
||||
}
|
||||
|
||||
private resize(): void {
|
||||
const rect = this.canvas.parentElement!.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
this.W = rect.width
|
||||
this.H = Math.min(rect.width * 0.55, 440)
|
||||
this.canvas.width = this.W * dpr
|
||||
this.canvas.height = this.H * dpr
|
||||
this.canvas.style.height = this.H + 'px'
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
}
|
||||
|
||||
private seededRand(seed: number): number {
|
||||
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453
|
||||
return x - Math.floor(x)
|
||||
}
|
||||
|
||||
start(): void {
|
||||
let lastFrame = performance.now()
|
||||
const loop = (now: number) => {
|
||||
const dt = (now - lastFrame) / 1000
|
||||
lastFrame = now
|
||||
this.t += dt
|
||||
this.updateScene()
|
||||
this.draw()
|
||||
this.animId = requestAnimationFrame(loop)
|
||||
}
|
||||
this.animId = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
cancelAnimationFrame(this.animId)
|
||||
}
|
||||
|
||||
private updateScene(): void {
|
||||
const owned = this.game.getOwnedPlants()
|
||||
for (const p of owned) {
|
||||
for (let i = 0; i < p.count; i++) {
|
||||
const id = `${p.typeId}-${i}`
|
||||
if (!this.knownIds.has(id)) {
|
||||
this.knownIds.add(id)
|
||||
this.placed.push(this.placePlant(p.typeId, i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Blackout-Flash
|
||||
const blackouts = this.game.getResource('blackouts')
|
||||
if (blackouts > this.lastBlackouts) {
|
||||
this.blackoutFlash = 1.0
|
||||
this.lastBlackouts = blackouts
|
||||
}
|
||||
this.blackoutFlash = Math.max(0, this.blackoutFlash - 0.012)
|
||||
}
|
||||
|
||||
private placePlant(typeId: string, index: number): PlacedPlant {
|
||||
// Stable deterministic placement by type+index.
|
||||
// Different plant types get different "zones":
|
||||
const seed = typeId.charCodeAt(0) * 97 + typeId.charCodeAt(1) * 13 + index * 41
|
||||
let xZone: [number, number] = [0.08, 0.68]
|
||||
let yZone: [number, number] = [0.48, 0.62]
|
||||
|
||||
switch (typeId) {
|
||||
case 'wind':
|
||||
xZone = [0.05, 0.58]
|
||||
yZone = [0.25, 0.45]
|
||||
break
|
||||
case 'solar':
|
||||
xZone = [0.12, 0.60]
|
||||
yZone = [0.55, 0.68]
|
||||
break
|
||||
case 'hydro':
|
||||
xZone = [0.02, 0.18]
|
||||
yZone = [0.50, 0.62]
|
||||
break
|
||||
case 'coal':
|
||||
case 'gas':
|
||||
xZone = [0.18, 0.52]
|
||||
yZone = [0.45, 0.58]
|
||||
break
|
||||
case 'biomass':
|
||||
xZone = [0.25, 0.58]
|
||||
yZone = [0.52, 0.62]
|
||||
break
|
||||
case 'nuclear':
|
||||
xZone = [0.32, 0.50]
|
||||
yZone = [0.44, 0.56]
|
||||
break
|
||||
case 'battery':
|
||||
xZone = [0.55, 0.72]
|
||||
yZone = [0.60, 0.70]
|
||||
break
|
||||
}
|
||||
|
||||
const rx = this.seededRand(seed)
|
||||
const ry = this.seededRand(seed + 7)
|
||||
return {
|
||||
typeId,
|
||||
x: xZone[0] + rx * (xZone[1] - xZone[0]),
|
||||
y: yZone[0] + ry * (yZone[1] - yZone[0]),
|
||||
scale: 0.9 + this.seededRand(seed + 13) * 0.25,
|
||||
ownerId: `${typeId}-${index}`,
|
||||
builtTick: this.game.getSnapshot().tick,
|
||||
}
|
||||
}
|
||||
|
||||
private draw(): void {
|
||||
const ctx = this.ctx
|
||||
const W = this.W
|
||||
const H = this.H
|
||||
const tick = this.game.getSnapshot().tick
|
||||
const year = this.game.getStartYear() + tick
|
||||
const weather = this.game.getWeatherFactors()
|
||||
|
||||
// Fortschritt 0..1 über die Spielzeit
|
||||
const progress = Math.min(1, tick / 25)
|
||||
// Himmel wird mit zunehmender Erneuerbar-Quote klarer
|
||||
const renewable = this.game.getResource('renewable') / 100
|
||||
|
||||
// === HIMMEL ===
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, H * 0.7)
|
||||
const smog = 0.3 * (1 - renewable)
|
||||
sky.addColorStop(0, `rgb(${180 + smog * 20}, ${205 + smog * 10}, ${225 - smog * 20})`)
|
||||
sky.addColorStop(1, `rgb(${220 + smog * 10}, ${230}, ${225 - smog * 10})`)
|
||||
ctx.fillStyle = sky
|
||||
ctx.fillRect(0, 0, W, H * 0.72)
|
||||
|
||||
// === SONNE ===
|
||||
const sunX = W * 0.82
|
||||
const sunY = H * 0.15
|
||||
const sunR = 22 * (0.8 + weather.solar * 0.3)
|
||||
const sunAlpha = Math.max(0.3, weather.solar)
|
||||
ctx.fillStyle = `rgba(250, 220, 140, ${sunAlpha})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(sunX, sunY, sunR, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.fillStyle = `rgba(250, 240, 200, ${sunAlpha * 0.3})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(sunX, sunY, sunR * 1.6, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// === WOLKEN (verstärkt bei dunkler Solar-Faktor) ===
|
||||
const cloudCount = Math.round(3 + (1 - weather.solar) * 4)
|
||||
for (let i = 0; i < cloudCount; i++) {
|
||||
const cx = ((this.t * 6 + i * W / cloudCount) % (W + 80)) - 40
|
||||
const cy = H * 0.10 + i * 12
|
||||
this.drawCloud(cx, cy, 30 + i * 6, 0.75)
|
||||
}
|
||||
|
||||
// === BERGE HINTEN ===
|
||||
ctx.fillStyle = '#8aa0a8'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, H * 0.52)
|
||||
for (let x = 0; x <= W; x += 40) {
|
||||
const s = Math.sin(x * 0.013 + 2) * 22 + Math.sin(x * 0.031) * 10
|
||||
ctx.lineTo(x, H * 0.52 + s)
|
||||
}
|
||||
ctx.lineTo(W, H * 0.72)
|
||||
ctx.lineTo(0, H * 0.72)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = '#9fb2ba'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, H * 0.58)
|
||||
for (let x = 0; x <= W; x += 30) {
|
||||
const s = Math.sin(x * 0.019 + 4) * 18
|
||||
ctx.lineTo(x, H * 0.58 + s)
|
||||
}
|
||||
ctx.lineTo(W, H * 0.72)
|
||||
ctx.lineTo(0, H * 0.72)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
// === BODEN ===
|
||||
const ground = ctx.createLinearGradient(0, H * 0.68, 0, H * 0.92)
|
||||
// Farbe wird mit Erneuerbar-Quote grüner
|
||||
const green = 140 + renewable * 30
|
||||
ground.addColorStop(0, `rgb(140, ${green}, 110)`)
|
||||
ground.addColorStop(1, `rgb(110, ${green - 20}, 90)`)
|
||||
ctx.fillStyle = ground
|
||||
ctx.fillRect(0, H * 0.68, W, H * 0.24)
|
||||
|
||||
// === FLUSS am linken Rand (für Wasserkraft) ===
|
||||
ctx.fillStyle = `rgba(74, 124, 138, ${0.75 + weather.hydro * 0.2})`
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, H * 0.70)
|
||||
ctx.quadraticCurveTo(W * 0.05, H * 0.80, W * 0.12, H * 0.85)
|
||||
ctx.lineTo(W * 0.14, H * 0.89)
|
||||
ctx.lineTo(0, H * 0.89)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
// === STADT RECHTS ===
|
||||
this.drawCity(W * 0.75, H * 0.70, W * 0.22, H * 0.16, tick, this.blackoutFlash)
|
||||
|
||||
// === KRAFTWERKE ===
|
||||
// Sortiere nach x für korrekte Tiefenordnung
|
||||
const sorted = [...this.placed].sort((a, b) => a.y - b.y)
|
||||
for (const p of sorted) {
|
||||
const px = p.x * W
|
||||
const py = p.y * H
|
||||
this.drawPlant(p, px, py, weather)
|
||||
}
|
||||
|
||||
// === HUD ===
|
||||
this.drawHUD(year, tick)
|
||||
|
||||
// === BLACKOUT-FLASH OVERLAY ===
|
||||
if (this.blackoutFlash > 0) {
|
||||
ctx.fillStyle = `rgba(40, 10, 10, ${this.blackoutFlash * 0.5})`
|
||||
ctx.fillRect(0, 0, W, H * 0.88)
|
||||
}
|
||||
|
||||
// === ZEITLEISTE UNTEN ===
|
||||
this.drawTimeline()
|
||||
}
|
||||
|
||||
private drawCloud(cx: number, cy: number, r: number, alpha: number): void {
|
||||
const ctx = this.ctx
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, r * 0.55, 0, Math.PI * 2)
|
||||
ctx.arc(cx + r * 0.5, cy - 4, r * 0.45, 0, Math.PI * 2)
|
||||
ctx.arc(cx + r * 0.9, cy, r * 0.5, 0, Math.PI * 2)
|
||||
ctx.arc(cx + r * 0.4, cy + 5, r * 0.45, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
private drawCity(x: number, baseY: number, w: number, h: number, _tick: number, blackout: number): void {
|
||||
const ctx = this.ctx
|
||||
// Gebäude-Silhouetten (6 Türme)
|
||||
const towers = 6
|
||||
const litProbability = Math.max(0.1, 1 - blackout)
|
||||
for (let i = 0; i < towers; i++) {
|
||||
const seed = i * 11 + 3
|
||||
const r = this.seededRand(seed)
|
||||
const tw = (w / towers) * 0.8
|
||||
const th = h * (0.6 + r * 0.5)
|
||||
const tx = x + i * (w / towers)
|
||||
const ty = baseY - th
|
||||
ctx.fillStyle = '#4a4a55'
|
||||
ctx.fillRect(tx, ty, tw, th)
|
||||
// Fenster
|
||||
for (let wy = ty + 4; wy < baseY - 3; wy += 6) {
|
||||
for (let wx = tx + 2; wx < tx + tw - 2; wx += 5) {
|
||||
const on = this.seededRand(seed * 77 + wy * 5 + wx) < litProbability * 0.55
|
||||
ctx.fillStyle = on ? 'rgba(255, 220, 140, .9)' : 'rgba(80, 80, 95, .6)'
|
||||
ctx.fillRect(wx, wy, 2, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawPlant(p: PlacedPlant, x: number, y: number, weather: { wind: number; solar: number; hydro: number }): void {
|
||||
const ctx = this.ctx
|
||||
const s = p.scale
|
||||
|
||||
switch (p.typeId) {
|
||||
case 'coal':
|
||||
this.drawCoalPlant(x, y, s)
|
||||
break
|
||||
case 'gas':
|
||||
this.drawGasPlant(x, y, s)
|
||||
break
|
||||
case 'hydro':
|
||||
this.drawHydroPlant(x, y, s)
|
||||
break
|
||||
case 'wind':
|
||||
this.drawWindTurbine(x, y, s, weather.wind)
|
||||
break
|
||||
case 'solar':
|
||||
this.drawSolarPanel(x, y, s)
|
||||
break
|
||||
case 'biomass':
|
||||
this.drawBiomassPlant(x, y, s)
|
||||
break
|
||||
case 'nuclear':
|
||||
this.drawNuclearPlant(x, y, s)
|
||||
break
|
||||
case 'battery':
|
||||
this.drawBattery(x, y, s)
|
||||
break
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
private drawCoalPlant(x: number, y: number, s: number): void {
|
||||
const ctx = this.ctx
|
||||
// Haupthalle
|
||||
ctx.fillStyle = '#6a6055'
|
||||
ctx.fillRect(x - 18 * s, y - 14 * s, 36 * s, 18 * s)
|
||||
ctx.fillStyle = '#4a4038'
|
||||
ctx.fillRect(x - 18 * s, y - 14 * s, 36 * s, 2 * s)
|
||||
// Schornsteine
|
||||
ctx.fillStyle = '#8a7868'
|
||||
ctx.fillRect(x - 12 * s, y - 30 * s, 5 * s, 18 * s)
|
||||
ctx.fillRect(x + 6 * s, y - 30 * s, 5 * s, 18 * s)
|
||||
// Rauch
|
||||
this.drawSmoke(x - 9 * s, y - 30 * s, 1.0, '#7a6a58')
|
||||
this.drawSmoke(x + 9 * s, y - 30 * s, 0.9, '#7a6a58')
|
||||
}
|
||||
|
||||
private drawGasPlant(x: number, y: number, s: number): void {
|
||||
const ctx = this.ctx
|
||||
ctx.fillStyle = '#5a7a8a'
|
||||
ctx.fillRect(x - 14 * s, y - 10 * s, 28 * s, 14 * s)
|
||||
// Tank
|
||||
ctx.fillStyle = '#aabdc5'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x - 8 * s, y - 2 * s, 6 * s, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
// Schornstein
|
||||
ctx.fillStyle = '#7a8a95'
|
||||
ctx.fillRect(x + 6 * s, y - 22 * s, 3 * s, 14 * s)
|
||||
this.drawSmoke(x + 7 * s, y - 22 * s, 0.55, '#dddacc')
|
||||
}
|
||||
|
||||
private drawHydroPlant(x: number, y: number, s: number): void {
|
||||
const ctx = this.ctx
|
||||
// Staumauer
|
||||
ctx.fillStyle = '#999a9c'
|
||||
ctx.fillRect(x - 12 * s, y - 18 * s, 24 * s, 22 * s)
|
||||
// Abflussöffnung
|
||||
ctx.fillStyle = '#3a5a6a'
|
||||
ctx.fillRect(x - 3 * s, y - 4 * s, 6 * s, 8 * s)
|
||||
// Wasserstrahl
|
||||
ctx.fillStyle = 'rgba(200, 230, 240, 0.7)'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - 3 * s, y + 4 * s)
|
||||
ctx.lineTo(x + 3 * s, y + 4 * s)
|
||||
ctx.lineTo(x + 8 * s, y + 12 * s)
|
||||
ctx.lineTo(x - 8 * s, y + 12 * s)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
private drawWindTurbine(x: number, y: number, s: number, windFactor: number): void {
|
||||
const ctx = this.ctx
|
||||
// Mast
|
||||
ctx.fillStyle = '#f0f0f0'
|
||||
ctx.fillRect(x - 1.2 * s, y - 30 * s, 2.4 * s, 44 * s)
|
||||
// Nabe
|
||||
ctx.fillStyle = '#e0e0e0'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y - 30 * s, 2.5 * s, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
// Rotor — dreht sich mit Windgeschwindigkeit
|
||||
const rot = this.t * windFactor * 1.8
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const a = rot + (i * Math.PI * 2) / 3
|
||||
ctx.save()
|
||||
ctx.translate(x, y - 30 * s)
|
||||
ctx.rotate(a)
|
||||
ctx.fillStyle = '#fafafa'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, 0)
|
||||
ctx.lineTo(1.2 * s, -14 * s)
|
||||
ctx.lineTo(-1.2 * s, -14 * s)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
private drawSolarPanel(x: number, y: number, s: number): void {
|
||||
const ctx = this.ctx
|
||||
// 3 Panels in Reihe
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const px = x + (i - 1) * 9 * s
|
||||
ctx.save()
|
||||
ctx.translate(px, y)
|
||||
// leichte Neigung
|
||||
ctx.transform(1, 0, -0.35, 0.82, 0, 0)
|
||||
ctx.fillStyle = '#1e3a5a'
|
||||
ctx.fillRect(-6 * s, -3 * s, 12 * s, 6 * s)
|
||||
// Gitter
|
||||
ctx.strokeStyle = '#3a5a7a'
|
||||
ctx.lineWidth = 0.5
|
||||
ctx.beginPath()
|
||||
for (let g = -5; g <= 5; g += 2) {
|
||||
ctx.moveTo(g * s, -3 * s)
|
||||
ctx.lineTo(g * s, 3 * s)
|
||||
}
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
// Ständer
|
||||
ctx.fillStyle = '#666'
|
||||
ctx.fillRect(px - 0.5, y + 2 * s, 1, 4 * s)
|
||||
}
|
||||
}
|
||||
|
||||
private drawBiomassPlant(x: number, y: number, s: number): void {
|
||||
const ctx = this.ctx
|
||||
ctx.fillStyle = '#7a5a3a'
|
||||
ctx.fillRect(x - 13 * s, y - 10 * s, 26 * s, 14 * s)
|
||||
// Holzstapel
|
||||
ctx.fillStyle = '#8a6a4a'
|
||||
ctx.fillRect(x - 16 * s, y + 1 * s, 6 * s, 3 * s)
|
||||
ctx.fillRect(x - 16 * s, y - 2 * s, 6 * s, 3 * s)
|
||||
// Grüner Schornstein
|
||||
ctx.fillStyle = '#6a7a5a'
|
||||
ctx.fillRect(x + 5 * s, y - 22 * s, 3 * s, 14 * s)
|
||||
this.drawSmoke(x + 6.5 * s, y - 22 * s, 0.45, '#e0e0d0')
|
||||
}
|
||||
|
||||
private drawNuclearPlant(x: number, y: number, s: number): void {
|
||||
const ctx = this.ctx
|
||||
// Reaktorkuppel
|
||||
ctx.fillStyle = '#c0c5c8'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x - 10 * s, y - 4 * s, 8 * s, Math.PI, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.fillRect(x - 18 * s, y - 4 * s, 16 * s, 8 * s)
|
||||
// Kühlturm
|
||||
ctx.fillStyle = '#b0b5b8'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + 3 * s, y - 28 * s)
|
||||
ctx.lineTo(x + 14 * s, y - 28 * s)
|
||||
ctx.lineTo(x + 18 * s, y + 4 * s)
|
||||
ctx.lineTo(x - 1 * s, y + 4 * s)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
// Dampf
|
||||
this.drawSmoke(x + 8.5 * s, y - 28 * s, 1.3, '#ffffff')
|
||||
}
|
||||
|
||||
private drawBattery(x: number, y: number, s: number): void {
|
||||
const ctx = this.ctx
|
||||
// Container
|
||||
ctx.fillStyle = '#e8e8e8'
|
||||
ctx.fillRect(x - 12 * s, y - 8 * s, 24 * s, 14 * s)
|
||||
ctx.strokeStyle = '#888'
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeRect(x - 12 * s, y - 8 * s, 24 * s, 14 * s)
|
||||
// LEDs pulsieren
|
||||
const pulse = (Math.sin(this.t * 2) + 1) / 2
|
||||
ctx.fillStyle = `rgba(90, 200, 120, ${0.5 + pulse * 0.5})`
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ctx.fillRect(x - 10 * s + i * 5 * s, y - 5 * s, 3 * s, 2 * s)
|
||||
}
|
||||
// Blitzsymbol
|
||||
ctx.fillStyle = '#d4a050'
|
||||
ctx.font = `bold ${7 * s}px sans-serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('⚡', x, y + 4 * s)
|
||||
}
|
||||
|
||||
private drawSmoke(x: number, y: number, intensity: number, color: string): void {
|
||||
const ctx = this.ctx
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const off = (this.t * 8 + i * 6) % 18
|
||||
const alpha = Math.max(0, (1 - off / 18) * intensity * 0.7)
|
||||
ctx.fillStyle = color.startsWith('#') ? this.hexWithAlpha(color, alpha) : color
|
||||
ctx.globalAlpha = alpha
|
||||
ctx.beginPath()
|
||||
ctx.arc(x + Math.sin(off * 0.3) * 3, y - off, 3 + off * 0.25, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
private hexWithAlpha(hex: string, _a: number): string {
|
||||
return hex
|
||||
}
|
||||
|
||||
private drawHUD(year: number, _tick: number): void {
|
||||
const ctx = this.ctx
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.85)'
|
||||
ctx.fillRect(10, 10, 108, 36)
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.1)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeRect(10, 10, 108, 36)
|
||||
ctx.fillStyle = '#2a2a2a'
|
||||
ctx.font = 'bold 15px Inter, sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(`${year}`, 18, 28)
|
||||
ctx.fillStyle = '#6a6a6a'
|
||||
ctx.font = '9px Inter, sans-serif'
|
||||
const renewable = Math.round(this.game.getResource('renewable'))
|
||||
ctx.fillText(`🌱 ${renewable} % erneuerbar`, 18, 40)
|
||||
}
|
||||
|
||||
private drawTimeline(): void {
|
||||
const ctx = this.ctx
|
||||
const W = this.W
|
||||
const H = this.H
|
||||
const tlY = H - 28
|
||||
const tlH = 22
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.95)'
|
||||
ctx.fillRect(0, tlY, W, tlH)
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.06)'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, tlY + 0.5)
|
||||
ctx.lineTo(W, tlY + 0.5)
|
||||
ctx.stroke()
|
||||
|
||||
const maxTicks = 25
|
||||
const tick = this.game.getSnapshot().tick
|
||||
const startYear = this.game.getStartYear()
|
||||
|
||||
// Jahre-Marker alle 5 Jahre
|
||||
ctx.fillStyle = '#8a8a8a'
|
||||
ctx.font = '9px Inter, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
for (let y = 0; y <= maxTicks; y += 5) {
|
||||
const x = (y / maxTicks) * W
|
||||
ctx.fillRect(x - 0.5, tlY + 2, 1, 5)
|
||||
ctx.fillText(`${startYear + y}`, x, tlY + 18)
|
||||
}
|
||||
|
||||
// Aktueller Tick als Punkt
|
||||
const curX = (tick / maxTicks) * W
|
||||
ctx.fillStyle = '#4a7c8a'
|
||||
ctx.beginPath()
|
||||
ctx.arc(curX, tlY + 10, 4, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Blackout-Marker
|
||||
const events = this.game.getSnapshot().events
|
||||
for (const e of events) {
|
||||
if (e.type === 'blackout' || e.type === 'peak-blackout') {
|
||||
const ex = (e.tick / maxTicks) * W
|
||||
ctx.fillStyle = '#b04a3a'
|
||||
ctx.beginPath()
|
||||
ctx.arc(ex, tlY + 10, 2.5, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export für Typ-Konsumierung durch UI
|
||||
export type { PlantType } from './game'
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* SIM-08: Energiewende-Planer*in — Spielbare Energiemix-Simulation
|
||||
*
|
||||
* Du übernimmst 2025 als Energieplaner*in eine Region mit ca. 200.000
|
||||
* Einwohnern. Aktuell kommt der Strom größtenteils aus fossilen Quellen.
|
||||
* Bis 2050 (25 Jahre) musst du den Energiemix umbauen:
|
||||
*
|
||||
* - Die Nachfrage steigt (E-Autos, Wärmepumpen, Digitalisierung).
|
||||
* - CO₂ muss runter — Klimaziel.
|
||||
* - Blackouts dürfen nicht passieren — Versorgungssicherheit.
|
||||
* - Du hast ein knappes Budget.
|
||||
*
|
||||
* Fachlich korrekt:
|
||||
* - Kapazitätsfaktoren echt (Wind ~25%, Solar ~12%, Wasser ~45%, Kernkraft ~90%)
|
||||
* - CO₂-Emissionen in gCO₂/kWh basieren auf IPCC-Median-Werten
|
||||
* - Erneuerbare brauchen Speicher oder backup-fähige Partner (Gas)
|
||||
*
|
||||
* Kernbotschaft: Es gibt keinen Königsweg. Jede Technologie hat Stärken und
|
||||
* Schwächen. Die Transformation ist ein Balance-Akt zwischen drei Zielen:
|
||||
* CO₂, Kosten und Versorgungssicherheit.
|
||||
*/
|
||||
|
||||
import { GameEngine, type GameMeta } from '@core/game-engine'
|
||||
|
||||
const META: GameMeta = {
|
||||
id: 'sim-08',
|
||||
title: 'Energiewende-Planer*in',
|
||||
description: 'Führe deine Region bis 2050 in eine CO₂-neutrale, sichere Stromversorgung.',
|
||||
msPerTick: 4000,
|
||||
tickUnit: 'Jahr',
|
||||
maxTicks: 25,
|
||||
tutorialSteps: 4,
|
||||
}
|
||||
|
||||
const START_YEAR = 2025
|
||||
|
||||
export interface PlantType {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
description: string
|
||||
cost: number // Baukosten in Mio. €
|
||||
capacityMW: number // Nennleistung
|
||||
capacityFactor: number // Anteil der Nennleistung im Jahresmittel (0..1)
|
||||
co2PerKWh: number // gCO₂/kWh (IPCC Median)
|
||||
upkeep: number // Mio. €/Jahr
|
||||
renewable: boolean
|
||||
flexible: boolean // Kann bei Bedarf hochgefahren werden (für Backup)
|
||||
storage?: number // MW Speicherkapazität (reduziert Blackout-Risiko)
|
||||
}
|
||||
|
||||
export const PLANT_TYPES: PlantType[] = [
|
||||
{
|
||||
id: 'coal',
|
||||
name: 'Kohlekraftwerk',
|
||||
emoji: '🏭',
|
||||
description: 'Billig, zuverlässig, aber höchste CO₂-Emission. Gesellschaftlich umstritten.',
|
||||
cost: 80,
|
||||
capacityMW: 300,
|
||||
capacityFactor: 0.75,
|
||||
co2PerKWh: 820,
|
||||
upkeep: 8,
|
||||
renewable: false,
|
||||
flexible: true,
|
||||
},
|
||||
{
|
||||
id: 'gas',
|
||||
name: 'Gaskraftwerk',
|
||||
emoji: '⛽',
|
||||
description: 'Flexibler Backup — schnell regelbar. Halbe CO₂-Emission wie Kohle.',
|
||||
cost: 110,
|
||||
capacityMW: 250,
|
||||
capacityFactor: 0.55,
|
||||
co2PerKWh: 490,
|
||||
upkeep: 9,
|
||||
renewable: false,
|
||||
flexible: true,
|
||||
},
|
||||
{
|
||||
id: 'hydro',
|
||||
name: 'Wasserkraftwerk',
|
||||
emoji: '💧',
|
||||
description: 'CO₂-frei und zuverlässig. Standortgebunden, bei Dürre weniger Leistung.',
|
||||
cost: 280,
|
||||
capacityMW: 180,
|
||||
capacityFactor: 0.45,
|
||||
co2PerKWh: 24,
|
||||
upkeep: 4,
|
||||
renewable: true,
|
||||
flexible: true,
|
||||
},
|
||||
{
|
||||
id: 'wind',
|
||||
name: 'Windpark',
|
||||
emoji: '💨',
|
||||
description: 'Günstig, CO₂-arm. Wetterabhängig: liefert nur bei Wind (~25% der Zeit).',
|
||||
cost: 120,
|
||||
capacityMW: 200,
|
||||
capacityFactor: 0.28,
|
||||
co2PerKWh: 11,
|
||||
upkeep: 5,
|
||||
renewable: true,
|
||||
flexible: false,
|
||||
},
|
||||
{
|
||||
id: 'solar',
|
||||
name: 'Solarpark',
|
||||
emoji: '☀️',
|
||||
description: 'Sehr günstig, CO₂-arm. Nur tagsüber, bei Bewölkung weniger.',
|
||||
cost: 70,
|
||||
capacityMW: 150,
|
||||
capacityFactor: 0.13,
|
||||
co2PerKWh: 48,
|
||||
upkeep: 3,
|
||||
renewable: true,
|
||||
flexible: false,
|
||||
},
|
||||
{
|
||||
id: 'biomass',
|
||||
name: 'Biomasse-Heizkraftwerk',
|
||||
emoji: '🌿',
|
||||
description: 'Aus Holz und Agrarresten. Flexibel, nahezu CO₂-neutral.',
|
||||
cost: 140,
|
||||
capacityMW: 80,
|
||||
capacityFactor: 0.65,
|
||||
co2PerKWh: 230,
|
||||
upkeep: 7,
|
||||
renewable: true,
|
||||
flexible: true,
|
||||
},
|
||||
{
|
||||
id: 'nuclear',
|
||||
name: 'Kernkraftwerk',
|
||||
emoji: '☢️',
|
||||
description: 'CO₂-frei, extrem hohe Grundlast-Leistung. Hohe Baukosten, politisch umstritten.',
|
||||
cost: 600,
|
||||
capacityMW: 1000,
|
||||
capacityFactor: 0.90,
|
||||
co2PerKWh: 12,
|
||||
upkeep: 18,
|
||||
renewable: false,
|
||||
flexible: false,
|
||||
},
|
||||
{
|
||||
id: 'battery',
|
||||
name: 'Batteriespeicher',
|
||||
emoji: '🔋',
|
||||
description: 'Erzeugt keinen Strom, glättet aber Schwankungen und verhindert Blackouts.',
|
||||
cost: 150,
|
||||
capacityMW: 0,
|
||||
capacityFactor: 0,
|
||||
co2PerKWh: 0,
|
||||
upkeep: 4,
|
||||
renewable: true,
|
||||
flexible: true,
|
||||
storage: 100,
|
||||
},
|
||||
]
|
||||
|
||||
interface OwnedPlant {
|
||||
typeId: string
|
||||
count: number
|
||||
builtTick: number
|
||||
}
|
||||
|
||||
export class EnergiemixGame extends GameEngine {
|
||||
private plants: OwnedPlant[] = []
|
||||
private firedEvents = new Set<string>()
|
||||
|
||||
// Wetter-Modifikator dieses Jahres (ca. 0.7..1.2)
|
||||
private weatherWindFactor = 1.0
|
||||
private weatherSolarFactor = 1.0
|
||||
private weatherHydroFactor = 1.0
|
||||
|
||||
constructor() {
|
||||
super(META)
|
||||
this.setupResources()
|
||||
this.setupGoals()
|
||||
this.setupTutorial()
|
||||
this.setupStartingPlants()
|
||||
this.recalc()
|
||||
}
|
||||
|
||||
private setupResources(): void {
|
||||
this.addResource({
|
||||
id: 'budget',
|
||||
name: 'Budget',
|
||||
icon: '💰',
|
||||
initial: 300,
|
||||
unit: 'Mio €',
|
||||
format: (v) => `${Math.round(v)} Mio €`,
|
||||
})
|
||||
this.addResource({
|
||||
id: 'demand',
|
||||
name: 'Strombedarf',
|
||||
icon: '🔌',
|
||||
initial: 700,
|
||||
unit: 'MW',
|
||||
format: (v) => `${Math.round(v)} MW`,
|
||||
})
|
||||
this.addResource({
|
||||
id: 'supply',
|
||||
name: 'Erzeugung (Ø)',
|
||||
icon: '⚡',
|
||||
initial: 0,
|
||||
unit: 'MW',
|
||||
format: (v) => `${Math.round(v)} MW`,
|
||||
})
|
||||
this.addResource({
|
||||
id: 'co2',
|
||||
name: 'CO₂-Ausstoß',
|
||||
icon: '🌫',
|
||||
initial: 0,
|
||||
unit: 'kt/J',
|
||||
format: (v) => `${Math.round(v)} kt/J`,
|
||||
})
|
||||
this.addResource({
|
||||
id: 'renewable',
|
||||
name: 'Erneuerbar',
|
||||
icon: '🌱',
|
||||
initial: 0,
|
||||
unit: '%',
|
||||
format: (v) => `${Math.round(v)} %`,
|
||||
})
|
||||
this.addResource({
|
||||
id: 'blackouts',
|
||||
name: 'Blackouts',
|
||||
icon: '🕯',
|
||||
initial: 0,
|
||||
unit: '',
|
||||
format: (v) => `${Math.round(v)}`,
|
||||
})
|
||||
}
|
||||
|
||||
private setupGoals(): void {
|
||||
this.addGoal({
|
||||
id: 'survive',
|
||||
title: 'Bis 2050 planen',
|
||||
description: '25 Jahre Energiewende begleiten.',
|
||||
check: (g) => (g as EnergiemixGame).tick >= 25,
|
||||
progress: (g) => Math.min(100, ((g as EnergiemixGame).tick / 25) * 100),
|
||||
required: true,
|
||||
})
|
||||
this.addGoal({
|
||||
id: 'renewable',
|
||||
title: '80 % Erneuerbare Energie',
|
||||
description: 'Mindestens 80 % des Strombedarfs aus erneuerbaren Quellen.',
|
||||
check: (g) => g.getResource('renewable') >= 80,
|
||||
progress: (g) => Math.min(100, (g.getResource('renewable') / 80) * 100),
|
||||
required: true,
|
||||
})
|
||||
this.addGoal({
|
||||
id: 'co2',
|
||||
title: 'CO₂ unter 400 kt/Jahr',
|
||||
description: 'Die Emissionen müssen deutlich sinken.',
|
||||
check: (g) => g.getResource('co2') < 400,
|
||||
progress: (g) => {
|
||||
const co2 = g.getResource('co2')
|
||||
return Math.max(0, Math.min(100, 100 - ((co2 - 400) / 10)))
|
||||
},
|
||||
required: true,
|
||||
})
|
||||
this.addGoal({
|
||||
id: 'reliable',
|
||||
title: 'Maximal 3 Blackouts',
|
||||
description: 'Die Versorgung muss sicher bleiben.',
|
||||
check: (g) => g.getResource('blackouts') <= 3,
|
||||
progress: (g) => Math.max(0, Math.min(100, 100 - g.getResource('blackouts') * 25)),
|
||||
required: true,
|
||||
})
|
||||
this.addGoal({
|
||||
id: 'budget',
|
||||
title: 'Nicht pleite gehen',
|
||||
description: 'Budget muss positiv bleiben.',
|
||||
check: (g) => g.getResource('budget') > 0,
|
||||
required: true,
|
||||
})
|
||||
}
|
||||
|
||||
private setupTutorial(): void {
|
||||
this.setTutorial([
|
||||
{
|
||||
triggerTick: 0,
|
||||
title: 'Willkommen, Energieplaner*in!',
|
||||
text: '2025. Du übernimmst die Energieplanung für eine Region mit ca. 200.000 Einwohner*innen.\n\nDein Auftrag: Bis 2050 (in 25 Jahren) muss der Strom CO₂-neutral, bezahlbar und zuverlässig sein.\n\nAktuell dominiert noch fossiler Strom. Der Umbau kostet Geld — aber nichts zu tun kostet das Klima.',
|
||||
unlocks: ['budget'],
|
||||
},
|
||||
{
|
||||
triggerTick: 0,
|
||||
title: 'Der Energie-Mix',
|
||||
text: 'Es gibt keine Lösung, die alles kann:\n\n🏭 Kohle: billig, aber hoher CO₂-Ausstoß\n⛽ Gas: flexibel, mittleres CO₂\n💧 Wasser: sauber, aber abhängig vom Niederschlag\n💨 Wind: günstig, nur bei Wind\n☀️ Solar: sehr günstig, nur bei Sonne\n🌿 Biomasse: klimaneutral, begrenzte Verfügbarkeit\n☢️ Kernkraft: CO₂-frei, teuer, umstritten\n🔋 Speicher: glättet Schwankungen',
|
||||
unlocks: ['shop'],
|
||||
},
|
||||
{
|
||||
triggerTick: 0,
|
||||
title: 'Versorgungssicherheit',
|
||||
text: 'Wind und Sonne liefern nicht immer.\n\nDer sogenannte Kapazitätsfaktor beschreibt, wieviel % der Nennleistung im Jahresmittel wirklich anfällt:\n- Wind ≈ 28 %\n- Solar ≈ 13 %\n- Wasser ≈ 45 %\n- Kernkraft ≈ 90 %\n\nFehlt Strom, drohen Blackouts. Backup-Kraftwerke (Gas, Biomasse) oder Speicher helfen, Lücken zu überbrücken.',
|
||||
unlocks: ['supply'],
|
||||
},
|
||||
{
|
||||
triggerTick: 0,
|
||||
title: 'Der Zielkonflikt',
|
||||
text: 'Du musst drei Ziele unter einen Hut bringen:\n\n🌱 CO₂: auf unter 400 kt/Jahr senken\n⚡ Versorgung: maximal 3 Blackouts bis 2050\n💰 Budget: nicht pleite gehen\n\nPro Jahr bekommst du Einnahmen aus Stromverkauf. Bau klug aus — Kraftwerke brauchen mehrere Jahre, bis sie sich rechnen.\n\nViel Erfolg! ⚡',
|
||||
unlocks: ['controls'],
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
private setupStartingPlants(): void {
|
||||
// Startzustand: fossil dominiert
|
||||
this.plants = [
|
||||
{ typeId: 'coal', count: 2, builtTick: -10 },
|
||||
{ typeId: 'gas', count: 1, builtTick: -5 },
|
||||
{ typeId: 'hydro', count: 1, builtTick: -20 },
|
||||
]
|
||||
}
|
||||
|
||||
private recalc(): void {
|
||||
let totalMW = 0
|
||||
let renewableMW = 0
|
||||
let co2 = 0
|
||||
let upkeep = 0
|
||||
let storageMW = 0
|
||||
let flexibleMW = 0
|
||||
|
||||
for (const p of this.plants) {
|
||||
const t = PLANT_TYPES.find(x => x.id === p.typeId)
|
||||
if (!t) continue
|
||||
|
||||
// Wetter-Modifikator anwenden
|
||||
let cf = t.capacityFactor
|
||||
if (t.id === 'wind') cf *= this.weatherWindFactor
|
||||
else if (t.id === 'solar') cf *= this.weatherSolarFactor
|
||||
else if (t.id === 'hydro') cf *= this.weatherHydroFactor
|
||||
|
||||
const avgMW = t.capacityMW * cf * p.count
|
||||
totalMW += avgMW
|
||||
if (t.renewable) renewableMW += avgMW
|
||||
// CO₂: gCO₂/kWh × MW × 8760h/Jahr = gCO₂/J → kt/J
|
||||
co2 += (avgMW * 8760 * t.co2PerKWh) / 1e9
|
||||
upkeep += t.upkeep * p.count
|
||||
if (t.storage) storageMW += t.storage * p.count
|
||||
if (t.flexible) flexibleMW += t.capacityMW * p.count
|
||||
}
|
||||
|
||||
this.setResource('supply', totalMW)
|
||||
this.setResource('co2', co2)
|
||||
const demand = this.getResource('demand')
|
||||
this.setResource('renewable', demand > 0 ? Math.min(100, (renewableMW / demand) * 100) : 0)
|
||||
this.setVariable('upkeepTotal', upkeep)
|
||||
this.setVariable('storageMW', storageMW)
|
||||
this.setVariable('flexibleMW', flexibleMW)
|
||||
}
|
||||
|
||||
buyPlant(typeId: string): boolean {
|
||||
const t = PLANT_TYPES.find(x => x.id === typeId)
|
||||
if (!t) return false
|
||||
const budget = this.getResource('budget')
|
||||
if (budget < t.cost) {
|
||||
this.addEvent('error', `Nicht genug Budget für ${t.name}`, 'warning')
|
||||
return false
|
||||
}
|
||||
this.changeResource('budget', -t.cost)
|
||||
const existing = this.plants.find(p => p.typeId === typeId)
|
||||
if (existing) existing.count++
|
||||
else this.plants.push({ typeId, count: 1, builtTick: this.tick })
|
||||
|
||||
this.recalc()
|
||||
this.addEvent('build', `${t.emoji} ${t.name} gebaut (-${t.cost} Mio €)`, 'success')
|
||||
this.notify()
|
||||
return true
|
||||
}
|
||||
|
||||
getOwnedPlants(): OwnedPlant[] { return this.plants }
|
||||
getPlantCount(id: string): number {
|
||||
return this.plants.find(p => p.typeId === id)?.count ?? 0
|
||||
}
|
||||
getStartYear(): number { return START_YEAR }
|
||||
getWeatherFactors() {
|
||||
return {
|
||||
wind: this.weatherWindFactor,
|
||||
solar: this.weatherSolarFactor,
|
||||
hydro: this.weatherHydroFactor,
|
||||
}
|
||||
}
|
||||
|
||||
protected simulateTick(): void {
|
||||
// 1. Nachfrage wächst (E-Mobilität, Wärmepumpen, Digitalisierung)
|
||||
// Ca. 1.8 % pro Jahr
|
||||
const demand = this.getResource('demand')
|
||||
this.setResource('demand', demand * 1.018)
|
||||
|
||||
// 2. Wetter des Jahres ziehen
|
||||
this.weatherWindFactor = 0.75 + Math.random() * 0.5 // 0.75..1.25
|
||||
this.weatherSolarFactor = 0.85 + Math.random() * 0.3 // 0.85..1.15
|
||||
this.weatherHydroFactor = 0.70 + Math.random() * 0.55 // 0.70..1.25
|
||||
|
||||
// 3. Neu berechnen mit aktuellem Wetter
|
||||
this.recalc()
|
||||
|
||||
// 4. Versorgungs-Check
|
||||
const supply = this.getResource('supply')
|
||||
const newDemand = this.getResource('demand')
|
||||
const storageMW = this.getVariable('storageMW')
|
||||
const flexibleMW = this.getVariable('flexibleMW')
|
||||
|
||||
// Spitzenlast ist höher als Durchschnitt (Faktor ~1.35)
|
||||
const peakDemand = newDemand * 1.35
|
||||
// Gesicherte Leistung = flexible Kraftwerke + Speicher
|
||||
const firmCapacity = flexibleMW + storageMW
|
||||
|
||||
if (supply < newDemand * 0.9) {
|
||||
// Unterversorgung im Jahresmittel
|
||||
this.changeResource('blackouts', 1)
|
||||
this.addEvent('blackout', `🕯 Blackout! Stromangebot (${Math.round(supply)} MW) deckt die Nachfrage nicht.`, 'danger')
|
||||
} else if (firmCapacity < peakDemand * 0.7) {
|
||||
// Nicht genug gesicherte Leistung für Spitzenlast
|
||||
if (Math.random() < 0.35) {
|
||||
this.changeResource('blackouts', 1)
|
||||
this.addEvent('peak-blackout', `⚠️ Spitzenlast-Blackout: zu wenig steuerbare Leistung im Netz.`, 'warning')
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Einnahmen aus Stromverkauf (nur was wirklich verkauft wird)
|
||||
const soldMW = Math.min(supply, newDemand)
|
||||
const revenue = Math.round(soldMW * 0.08) // ~0.08 Mio €/MW/Jahr
|
||||
this.changeResource('budget', revenue)
|
||||
|
||||
// 6. Wartungskosten
|
||||
const upkeep = this.getVariable('upkeepTotal')
|
||||
this.changeResource('budget', -upkeep)
|
||||
|
||||
// 7. CO₂-Strafe ab 2030 (EU-ETS-Preise steigen)
|
||||
if (this.tick >= 5) {
|
||||
const co2 = this.getResource('co2')
|
||||
const penalty = Math.round(co2 * 0.03 * Math.min(3, (this.tick - 4) / 5))
|
||||
this.changeResource('budget', -penalty)
|
||||
if (penalty > 0 && this.tick === 5 && !this.firedEvent('ets-start')) {
|
||||
this.addEvent('ets', `📜 EU-CO₂-Bepreisung greift: Emissionen kosten jetzt Geld.`, 'warning')
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Events
|
||||
if (this.weatherWindFactor < 0.85 && this.weatherSolarFactor < 0.95 && this.tick > 2) {
|
||||
this.addEvent('dunkelflaute', `🌫 Dunkelflaute: wenig Wind, wenig Sonne. Versorgung knapp.`, 'warning')
|
||||
}
|
||||
if (this.weatherHydroFactor < 0.8) {
|
||||
this.addEvent('drought', `☀️ Trockenes Jahr: Wasserkraft liefert weniger.`, 'info')
|
||||
}
|
||||
if (this.tick === 3 && !this.firedEvent('ev-boom')) {
|
||||
this.addEvent('ev-boom', `🔋 E-Mobilitäts-Boom: Strombedarf steigt schneller als erwartet.`, 'info')
|
||||
}
|
||||
if (this.tick === 10 && !this.firedEvent('heat-pumps')) {
|
||||
this.addEvent('heat-pumps', `🔥 Wärmepumpen-Förderung: Heizen wird elektrisch.`, 'info')
|
||||
}
|
||||
if (this.tick === 15 && !this.firedEvent('coal-exit')) {
|
||||
const coalCount = this.getPlantCount('coal')
|
||||
if (coalCount > 0) {
|
||||
this.addEvent('coal-exit', `📢 Kohleausstieg beschlossen — alte Kohlekraftwerke werden unrentabel.`, 'warning')
|
||||
}
|
||||
}
|
||||
if (this.getResource('renewable') >= 50 && !this.firedEvent('milestone-50')) {
|
||||
this.addEvent('milestone-50', `🌱 Meilenstein: 50 % erneuerbare Energie erreicht!`, 'success')
|
||||
}
|
||||
if (this.getResource('renewable') >= 80 && !this.firedEvent('milestone-80')) {
|
||||
this.addEvent('milestone-80', `🎉 80 % erneuerbar — Klimaziel erreicht!`, 'success')
|
||||
}
|
||||
}
|
||||
|
||||
private firedEvent(id: string): boolean {
|
||||
if (this.firedEvents.has(id)) return true
|
||||
this.firedEvents.add(id)
|
||||
return false
|
||||
}
|
||||
|
||||
protected checkLossCondition(): boolean {
|
||||
if (this.getResource('budget') < -200) return true
|
||||
if (this.getResource('blackouts') > 8) return true
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* SIM-09: Energiemix-Simulator — LOGIK
|
||||
*
|
||||
* Modell:
|
||||
* - Schüler*innen stellen einen Energiemix zusammen
|
||||
* - Drei Zielgrößen: CO₂-Ausstoß, Kosten, Versorgungssicherheit
|
||||
* - Jede Energiequelle hat spezifische Werte für alle drei Dimensionen
|
||||
* - Zielkonflikt sichtbar machen: billiger = dreckiger, sauber = teurer/unsicherer
|
||||
*
|
||||
* Didaktik:
|
||||
* - Keine "richtige" Antwort — es geht um das Abwägen
|
||||
* - Urteilskompetenz: verschiedene Perspektiven einnehmen
|
||||
* - Gamification: Highscore für besten Kompromiss
|
||||
*/
|
||||
|
||||
import { Simulation, SimulationMeta } from '@core/simulation'
|
||||
|
||||
const META: SimulationMeta = {
|
||||
id: 'sim-09',
|
||||
name: 'Energiemix-Simulator',
|
||||
educationLevels: [5, 6, 7, 8, 9, 10],
|
||||
primaryLevel: 6,
|
||||
kompetenzbereich: 'Nachhaltiger Umgang mit Energie und Ressourcen',
|
||||
lernziele: [
|
||||
'Erneuerbare und nicht erneuerbare Energieträger vergleichen',
|
||||
'Zielkonflikte zwischen Kosten, Umwelt und Versorgungssicherheit erkennen',
|
||||
'Eigene Position zu Energiepolitik bilden und begründen',
|
||||
],
|
||||
basiskonzepte: ['Leistungserstellung und Nachhaltigkeit', 'Ökonomische Prinzipien und Entscheidungsfindung'],
|
||||
dpiMinuten: 25,
|
||||
typ: 'sachsimulation',
|
||||
tier: 1,
|
||||
requiresReading: true,
|
||||
}
|
||||
|
||||
export interface EnergySource {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
color: string
|
||||
co2PerGWh: number // Tonnen CO₂ pro GWh (Lifecycle)
|
||||
costPerMWh: number // EUR pro MWh
|
||||
reliability: number // 0-1 (1 = immer verfügbar)
|
||||
maxShare: number // maximaler realistischer Anteil (0-1)
|
||||
renewable: boolean
|
||||
description: string
|
||||
}
|
||||
|
||||
export const ENERGY_SOURCES: EnergySource[] = [
|
||||
{
|
||||
id: 'coal', name: 'Kohle', emoji: '🪨', color: '#4a4a4a',
|
||||
co2PerGWh: 820, costPerMWh: 65, reliability: 0.85, maxShare: 1,
|
||||
renewable: false, description: 'Billig aber sehr CO₂-intensiv'
|
||||
},
|
||||
{
|
||||
id: 'gas', name: 'Erdgas', emoji: '🔥', color: '#c4a35a',
|
||||
co2PerGWh: 490, costPerMWh: 55, reliability: 0.87, maxShare: 0.8,
|
||||
renewable: false, description: 'Hälfte des CO₂ von Kohle, flexibel'
|
||||
},
|
||||
{
|
||||
id: 'nuclear', name: 'Atomkraft', emoji: '⚛️', color: '#8a5caa',
|
||||
co2PerGWh: 12, costPerMWh: 90, reliability: 0.92, maxShare: 0.6,
|
||||
renewable: false, description: 'Kaum CO₂, aber teuer und Atommüll'
|
||||
},
|
||||
{
|
||||
id: 'wind', name: 'Windkraft', emoji: '🌬️', color: '#5a9aaa',
|
||||
co2PerGWh: 11, costPerMWh: 45, reliability: 0.35, maxShare: 0.5,
|
||||
renewable: true, description: 'Günstig und sauber, aber wetterabhängig'
|
||||
},
|
||||
{
|
||||
id: 'solar', name: 'Solarenergie', emoji: '☀️', color: '#e8c84a',
|
||||
co2PerGWh: 45, costPerMWh: 40, reliability: 0.25, maxShare: 0.4,
|
||||
renewable: true, description: 'Billigste Quelle, aber nur tagsüber'
|
||||
},
|
||||
{
|
||||
id: 'hydro', name: 'Wasserkraft', emoji: '💧', color: '#4a7c8a',
|
||||
co2PerGWh: 24, costPerMWh: 50, reliability: 0.55, maxShare: 0.3,
|
||||
renewable: true, description: 'Zuverlässig, aber Standort-begrenzt'
|
||||
},
|
||||
]
|
||||
|
||||
export interface MixResult {
|
||||
totalCO2: number // Tonnen CO₂ pro GWh (gewichteter Durchschnitt)
|
||||
totalCost: number // EUR pro MWh
|
||||
totalReliability: number // 0-1
|
||||
renewableShare: number // 0-100%
|
||||
score: number // Gesamtbewertung 0-100
|
||||
rating: string // z.B. "Gut balanciert"
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Ergebnisse eines Energiemixes
|
||||
* @param mix Map von SourceID → Anteil (0-100, Summe sollte 100 sein)
|
||||
*/
|
||||
export function computeMixResult(mix: Record<string, number>): MixResult {
|
||||
let totalCO2 = 0
|
||||
let totalCost = 0
|
||||
let totalReliability = 0
|
||||
let renewableShare = 0
|
||||
let totalShare = 0
|
||||
|
||||
for (const source of ENERGY_SOURCES) {
|
||||
const share = (mix[source.id] || 0) / 100
|
||||
totalShare += share
|
||||
totalCO2 += source.co2PerGWh * share
|
||||
totalCost += source.costPerMWh * share
|
||||
totalReliability += source.reliability * share
|
||||
if (source.renewable) renewableShare += share * 100
|
||||
}
|
||||
|
||||
// Normalisieren falls Summe ≠ 100
|
||||
if (totalShare > 0 && Math.abs(totalShare - 1) > 0.01) {
|
||||
totalCO2 /= totalShare
|
||||
totalCost /= totalShare
|
||||
totalReliability /= totalShare
|
||||
renewableShare /= totalShare
|
||||
}
|
||||
|
||||
// Score: Multi-Kriterien-Bewertung
|
||||
const co2Score = Math.max(0, 100 - totalCO2 / 8) // 0 CO₂ = 100, 800 = 0
|
||||
const costScore = Math.max(0, 100 - (totalCost - 30) / 0.7) // 30€ = 100, 100€ = 0
|
||||
const reliScore = totalReliability * 100
|
||||
const score = Math.round((co2Score * 0.4 + costScore * 0.3 + reliScore * 0.3))
|
||||
|
||||
let rating = 'Experimentell'
|
||||
if (score >= 80) rating = 'Exzellent! 🌟'
|
||||
else if (score >= 65) rating = 'Gut balanciert 👍'
|
||||
else if (score >= 50) rating = 'Solide Basis'
|
||||
else if (score >= 35) rating = 'Verbesserungswürdig'
|
||||
|
||||
return {
|
||||
totalCO2: Math.round(totalCO2),
|
||||
totalCost: Math.round(totalCost),
|
||||
totalReliability: Math.round(totalReliability * 100) / 100,
|
||||
renewableShare: Math.round(renewableShare),
|
||||
score,
|
||||
rating,
|
||||
}
|
||||
}
|
||||
|
||||
export class EnergiemixSimulation extends Simulation {
|
||||
constructor() {
|
||||
super(META)
|
||||
// Startwerte: aktueller europäischer Mix (circa)
|
||||
this.state.variables = {
|
||||
coal: 15, gas: 20, nuclear: 20,
|
||||
wind: 18, solar: 12, hydro: 15,
|
||||
}
|
||||
}
|
||||
|
||||
getVariableRanges() {
|
||||
const ranges: Record<string, { min: number; max: number; default: number; unit: string; label: string }> = {}
|
||||
for (const source of ENERGY_SOURCES) {
|
||||
ranges[source.id] = {
|
||||
min: 0,
|
||||
max: Math.round(source.maxShare * 100),
|
||||
default: this.state.variables[source.id] || 0,
|
||||
unit: '%',
|
||||
label: `${source.emoji} ${source.name}`,
|
||||
}
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
/** Normalisiert den Mix auf 100% */
|
||||
normalizeMix(): void {
|
||||
const total = ENERGY_SOURCES.reduce((s, src) => s + this.getVariable(src.id), 0)
|
||||
if (total > 0) {
|
||||
for (const src of ENERGY_SOURCES) {
|
||||
this.state.variables[src.id] = Math.round(this.getVariable(src.id) / total * 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compute() {
|
||||
const mix: Record<string, number> = {}
|
||||
for (const src of ENERGY_SOURCES) {
|
||||
mix[src.id] = this.getVariable(src.id)
|
||||
}
|
||||
const result = computeMixResult(mix)
|
||||
this.state.results = result
|
||||
return result as unknown as Record<string, number>
|
||||
}
|
||||
|
||||
protected onVariableChange(): void {
|
||||
this.compute()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* SIM-09: Energiemix-Simulator — Canvas Renderer
|
||||
*
|
||||
* Visualisiert:
|
||||
* - Donut-Diagramm des aktuellen Mixes
|
||||
* - Drei Tachos: CO₂, Kosten, Versorgungssicherheit
|
||||
* - Score-Anzeige in der Mitte
|
||||
* - Animierte Energie-Icons (Wind dreht, Sonne strahlt)
|
||||
*
|
||||
* Stil: Skandinavisch mit subtiler Bewegung
|
||||
*/
|
||||
|
||||
import { EnergiemixSimulation, ENERGY_SOURCES, computeMixResult } from './logic'
|
||||
|
||||
export class EnergiemixRenderer {
|
||||
private canvas: HTMLCanvasElement
|
||||
private ctx: CanvasRenderingContext2D
|
||||
private sim: EnergiemixSimulation
|
||||
private W = 0
|
||||
private H = 0
|
||||
private t = 0
|
||||
private animId = 0
|
||||
|
||||
private col = {
|
||||
bg: '#fafaf8',
|
||||
text: '#1a1a1a',
|
||||
muted: '#6a6a6a',
|
||||
line: '#e0ddd6',
|
||||
}
|
||||
|
||||
constructor(container: HTMLElement, sim: EnergiemixSimulation) {
|
||||
this.sim = sim
|
||||
this.canvas = document.createElement('canvas')
|
||||
this.canvas.style.cssText = 'width:100%;height:100%;display:block;border-radius:12px;background:#fafaf8;'
|
||||
container.appendChild(this.canvas)
|
||||
const ctx = this.canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Canvas not supported')
|
||||
this.ctx = ctx
|
||||
this.resize()
|
||||
window.addEventListener('resize', () => this.resize())
|
||||
}
|
||||
|
||||
private resize(): void {
|
||||
const rect = this.canvas.parentElement!.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
this.W = rect.width
|
||||
this.H = Math.min(rect.width * 0.7, 540)
|
||||
this.canvas.width = this.W * dpr
|
||||
this.canvas.height = this.H * dpr
|
||||
this.canvas.style.height = this.H + 'px'
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
}
|
||||
|
||||
start(): void {
|
||||
const loop = () => {
|
||||
this.t += 0.016
|
||||
this.draw()
|
||||
this.animId = requestAnimationFrame(loop)
|
||||
}
|
||||
loop()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
cancelAnimationFrame(this.animId)
|
||||
}
|
||||
|
||||
private draw(): void {
|
||||
const { ctx, W, H } = this
|
||||
ctx.clearRect(0, 0, W, H)
|
||||
|
||||
const result = this.sim.compute()
|
||||
const mix: Record<string, number> = {}
|
||||
for (const src of ENERGY_SOURCES) mix[src.id] = this.sim.getVariable(src.id)
|
||||
|
||||
// ===== LEFT: Donut Chart =====
|
||||
const donutCx = W * 0.28
|
||||
const donutCy = H * 0.45
|
||||
const donutR = Math.min(W * 0.18, 95)
|
||||
const innerR = donutR * 0.6
|
||||
|
||||
let startAngle = -Math.PI / 2
|
||||
const total = Object.values(mix).reduce((a, b) => a + b, 0) || 1
|
||||
|
||||
for (const src of ENERGY_SOURCES) {
|
||||
const share = (mix[src.id] || 0) / total
|
||||
if (share <= 0) continue
|
||||
const angle = share * Math.PI * 2
|
||||
|
||||
ctx.fillStyle = src.color
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(donutCx, donutCy)
|
||||
ctx.arc(donutCx, donutCy, donutR, startAngle, startAngle + angle)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
startAngle += angle
|
||||
}
|
||||
|
||||
// Inner hole (donut)
|
||||
ctx.fillStyle = this.col.bg
|
||||
ctx.beginPath()
|
||||
ctx.arc(donutCx, donutCy, innerR, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Score in center
|
||||
ctx.fillStyle = this.col.text
|
||||
ctx.font = 'bold 28px Inter, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(`${result.score}`, donutCx, donutCy - 4)
|
||||
ctx.font = '10px Inter, sans-serif'
|
||||
ctx.fillStyle = this.col.muted
|
||||
ctx.fillText('Score', donutCx, donutCy + 14)
|
||||
|
||||
// Donut title
|
||||
ctx.font = 'bold 13px Inter, sans-serif'
|
||||
ctx.fillStyle = this.col.text
|
||||
ctx.fillText('Energiemix', donutCx, donutCy - donutR - 18)
|
||||
|
||||
// Rating below donut
|
||||
ctx.font = 'bold 11px Inter, sans-serif'
|
||||
ctx.fillStyle = result.score >= 65 ? '#5a8a5e' : result.score >= 50 ? '#c4a35a' : '#c0503c'
|
||||
ctx.fillText(result.rating, donutCx, donutCy + donutR + 18)
|
||||
|
||||
// ===== RIGHT: Three Gauges =====
|
||||
const gx = W * 0.62
|
||||
const gw = W * 0.32
|
||||
const gaugeH = H * 0.22
|
||||
|
||||
// CO₂ gauge
|
||||
this.drawGauge(ctx, gx, H * 0.12, gw, gaugeH,
|
||||
'CO₂-Ausstoß', `${result.totalCO2} t/GWh`,
|
||||
result.totalCO2, 0, 800, 'reverse', '#c0503c'
|
||||
)
|
||||
|
||||
// Cost gauge
|
||||
this.drawGauge(ctx, gx, H * 0.4, gw, gaugeH,
|
||||
'Kosten', `${result.totalCost} €/MWh`,
|
||||
result.totalCost, 30, 100, 'reverse', '#c4a35a'
|
||||
)
|
||||
|
||||
// Reliability gauge
|
||||
this.drawGauge(ctx, gx, H * 0.68, gw, gaugeH,
|
||||
'Versorgungssicherheit', `${(result.totalReliability * 100).toFixed(0)}%`,
|
||||
result.totalReliability * 100, 0, 100, 'normal', '#5a8a5e'
|
||||
)
|
||||
|
||||
// Renewable share at bottom
|
||||
ctx.font = 'bold 11px Inter, sans-serif'
|
||||
ctx.fillStyle = this.col.text
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(`Erneuerbare: ${result.renewableShare}%`, donutCx, H - 18)
|
||||
|
||||
// Animated icons around the donut
|
||||
this.drawAnimatedIcons(ctx, donutCx, donutCy, donutR + 30, mix)
|
||||
}
|
||||
|
||||
private drawGauge(
|
||||
ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number,
|
||||
label: string, valueText: string,
|
||||
value: number, min: number, max: number,
|
||||
direction: 'normal' | 'reverse',
|
||||
color: string
|
||||
): void {
|
||||
// Label
|
||||
ctx.fillStyle = this.col.muted
|
||||
ctx.font = 'bold 10px Inter, sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(label.toUpperCase(), x, y)
|
||||
|
||||
// Value
|
||||
ctx.fillStyle = this.col.text
|
||||
ctx.font = 'bold 18px Inter, sans-serif'
|
||||
ctx.fillText(valueText, x, y + 22)
|
||||
|
||||
// Bar background
|
||||
const barY = y + 32
|
||||
const barH = 8
|
||||
ctx.fillStyle = '#e8e5dc'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, barY, w, barH, 4)
|
||||
ctx.fill()
|
||||
|
||||
// Bar fill
|
||||
const ratio = Math.max(0, Math.min(1, (value - min) / (max - min)))
|
||||
const fillRatio = direction === 'reverse' ? 1 - ratio : ratio
|
||||
const fillW = w * Math.max(0, Math.min(1, fillRatio))
|
||||
|
||||
ctx.fillStyle = color
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, barY, fillW, barH, 4)
|
||||
ctx.fill()
|
||||
|
||||
// Min/max labels
|
||||
ctx.font = '9px Inter, sans-serif'
|
||||
ctx.fillStyle = this.col.muted
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(direction === 'reverse' ? 'Schlecht' : `${min}`, x, barY + 22)
|
||||
ctx.textAlign = 'right'
|
||||
ctx.fillText(direction === 'reverse' ? 'Gut' : `${max}`, x + w, barY + 22)
|
||||
}
|
||||
|
||||
private drawAnimatedIcons(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, mix: Record<string, number>): void {
|
||||
let i = 0
|
||||
for (const src of ENERGY_SOURCES) {
|
||||
const share = mix[src.id] || 0
|
||||
if (share <= 0) { i++; continue }
|
||||
|
||||
const angle = (i / ENERGY_SOURCES.length) * Math.PI * 2 - Math.PI / 2
|
||||
const x = cx + Math.cos(angle) * r
|
||||
const y = cy + Math.sin(angle) * r
|
||||
|
||||
// Background circle
|
||||
ctx.fillStyle = src.color
|
||||
ctx.globalAlpha = 0.15
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 14, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.globalAlpha = 1
|
||||
|
||||
// Icon (emoji)
|
||||
ctx.font = '14px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(src.emoji, x, y + 1)
|
||||
|
||||
// Share label
|
||||
ctx.fillStyle = this.col.text
|
||||
ctx.font = 'bold 9px Inter, sans-serif'
|
||||
ctx.fillText(`${share}%`, x, y + 22)
|
||||
|
||||
i++
|
||||
}
|
||||
ctx.textBaseline = 'alphabetic'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
/**
|
||||
* SIM-10: Lieferketten-Planer*in — Was kostet dein T-Shirt?
|
||||
*
|
||||
* Die Anwender*in ist Einkaufsleiter*in einer Modemarke in Wien. Alle 3
|
||||
* Monate (1 Tick) wird eine Charge T-Shirts (5.000 Stück) bestellt. Pro
|
||||
* Bestellung wählst du:
|
||||
*
|
||||
* 1. Wo die BAUMWOLLE herkommt
|
||||
* 2. Wo die T-Shirts GENÄHT werden
|
||||
* 3. Wie sie nach WIEN transportiert werden
|
||||
*
|
||||
* Die Wahl beeinflusst drei Werte, die alle gleichzeitig stimmen müssen:
|
||||
*
|
||||
* 💰 Budget — Verkauf − Einkauf − Transport (12 €/Stück Marktpreis)
|
||||
* 🌫 CO₂-Fußabdruck — Durchschnitt pro Stück, langfristig sichtbar
|
||||
* ⚖️ Ethik-Score — Arbeitsbedingungen am Standort (0-10)
|
||||
*
|
||||
* Kernbotschaft: Es gibt KEINEN Königsweg. Billigster Stoff = oft schlechte
|
||||
* Arbeitsbedingungen. Schnellster Weg = höchster CO₂-Ausstoß. Du musst
|
||||
* abwägen — und Lehrer*innen können die Konfiguration für ihre Klasse anpassen.
|
||||
*
|
||||
* Konfiguration durch die Lehrperson (3 Parameter):
|
||||
*
|
||||
* - startMoney Startbudget (Default: 800 Mio €)
|
||||
* - enabledOptions Welche Standorte/Transporte sind verfügbar?
|
||||
* - enabledEvents Welche Welt-Ereignisse können auftreten?
|
||||
*
|
||||
* Die Defaults entsprechen dem mittleren Schwierigkeitsgrad.
|
||||
*/
|
||||
|
||||
import { GameEngine, type GameMeta } from '@core/game-engine'
|
||||
|
||||
const META: GameMeta = {
|
||||
id: 'sim-10',
|
||||
title: 'Lieferketten-Planer*in',
|
||||
description: 'Bestelle T-Shirts aus aller Welt — und finde heraus, was sie wirklich kosten.',
|
||||
msPerTick: 6000, // 6 Sekunden = 3 Monate (1 Tick) — gibt Zeit zum Nachdenken
|
||||
tickUnit: 'Quartal',
|
||||
maxTicks: 20, // 20 Quartale = 5 Jahre
|
||||
tutorialSteps: 4,
|
||||
}
|
||||
|
||||
const START_YEAR = 2025
|
||||
|
||||
// === STANDORTE ===
|
||||
|
||||
export interface CottonSource {
|
||||
id: string
|
||||
name: string
|
||||
country: string // Land
|
||||
emoji: string
|
||||
flag: string // Flag-Emoji
|
||||
/** Welt-Karte: x/y in Prozent (0-100) */
|
||||
pos: { x: number; y: number }
|
||||
/** Preis pro T-Shirt (€) — Rohstoff-Anteil */
|
||||
pricePerShirt: number
|
||||
/** CO₂-Footprint Baumwoll-Anbau (kg/Stück) */
|
||||
co2PerShirt: number
|
||||
/** Wasser-Bedarf in L/Stück (zur Anzeige, nicht spielmechanisch) */
|
||||
waterPerShirt: number
|
||||
/** Arbeits-Score 0-10 (Bauern-Einkommen, Pestizid-Schutz, Kinderarbeit) */
|
||||
ethicsScore: number
|
||||
description: string
|
||||
}
|
||||
|
||||
export const COTTON_SOURCES: CottonSource[] = [
|
||||
{
|
||||
id: 'india',
|
||||
name: 'Bauer-Kollektiv Indien',
|
||||
country: 'Indien',
|
||||
emoji: '🌾', flag: '🇮🇳',
|
||||
pos: { x: 67, y: 50 },
|
||||
pricePerShirt: 2.20,
|
||||
co2PerShirt: 1.8,
|
||||
waterPerShirt: 2700,
|
||||
ethicsScore: 4,
|
||||
description: 'Größter Baumwoll-Produzent der Welt. Sehr günstig, aber viel Wasser, oft niedrige Löhne.',
|
||||
},
|
||||
{
|
||||
id: 'usa',
|
||||
name: 'Großfarm Texas',
|
||||
country: 'USA',
|
||||
emoji: '🌾', flag: '🇺🇸',
|
||||
pos: { x: 18, y: 42 },
|
||||
pricePerShirt: 3.40,
|
||||
co2PerShirt: 2.6,
|
||||
waterPerShirt: 1900,
|
||||
ethicsScore: 7,
|
||||
description: 'Industrielle Produktion. Maschinen und viel Pestizid, dafür gute Löhne und Sicherheit.',
|
||||
},
|
||||
{
|
||||
id: 'turkey',
|
||||
name: 'Genossenschaft Türkei',
|
||||
country: 'Türkei',
|
||||
emoji: '🌾', flag: '🇹🇷',
|
||||
pos: { x: 56, y: 41 },
|
||||
pricePerShirt: 3.10,
|
||||
co2PerShirt: 1.5,
|
||||
waterPerShirt: 2200,
|
||||
ethicsScore: 6,
|
||||
description: 'Mittlere Größe, näher an Europa. Solides Gleichgewicht.',
|
||||
},
|
||||
{
|
||||
id: 'egypt',
|
||||
name: 'Bio-Baumwolle Ägypten',
|
||||
country: 'Ägypten',
|
||||
emoji: '🌿', flag: '🇪🇬',
|
||||
pos: { x: 55, y: 47 },
|
||||
pricePerShirt: 4.80,
|
||||
co2PerShirt: 0.9,
|
||||
waterPerShirt: 2500,
|
||||
ethicsScore: 9,
|
||||
description: 'Hochwertige Bio-Baumwolle. Faire Löhne, weniger Pestizid — aber teuer.',
|
||||
},
|
||||
]
|
||||
|
||||
export interface Factory {
|
||||
id: string
|
||||
name: string
|
||||
country: string
|
||||
emoji: string
|
||||
flag: string
|
||||
pos: { x: number; y: number }
|
||||
/** Verarbeitungskosten pro T-Shirt (€) */
|
||||
pricePerShirt: number
|
||||
/** CO₂ in der Produktion (Strom + Färben) */
|
||||
co2PerShirt: number
|
||||
/** Arbeits-Score 0-10 (Sicherheit, Lohn, Stunden) */
|
||||
ethicsScore: number
|
||||
description: string
|
||||
}
|
||||
|
||||
export const FACTORIES: Factory[] = [
|
||||
{
|
||||
id: 'bangladesh',
|
||||
name: 'Mega-Näherei Dhaka',
|
||||
country: 'Bangladesch',
|
||||
emoji: '🏭', flag: '🇧🇩',
|
||||
pos: { x: 70, y: 50 },
|
||||
pricePerShirt: 0.80,
|
||||
co2PerShirt: 1.4,
|
||||
ethicsScore: 3,
|
||||
description: 'Sehr billig. Schwache Arbeitsbedingungen, lange Stunden, niedrige Löhne.',
|
||||
},
|
||||
{
|
||||
id: 'vietnam',
|
||||
name: 'Industrie-Park Vietnam',
|
||||
country: 'Vietnam',
|
||||
emoji: '🏭', flag: '🇻🇳',
|
||||
pos: { x: 78, y: 54 },
|
||||
pricePerShirt: 1.10,
|
||||
co2PerShirt: 1.6,
|
||||
ethicsScore: 5,
|
||||
description: 'Mittlere Preise, mittlere Bedingungen. Solider Standard.',
|
||||
},
|
||||
{
|
||||
id: 'turkey-fab',
|
||||
name: 'Familien-Manufaktur Türkei',
|
||||
country: 'Türkei',
|
||||
emoji: '🏭', flag: '🇹🇷',
|
||||
pos: { x: 56, y: 41 },
|
||||
pricePerShirt: 1.80,
|
||||
co2PerShirt: 1.0,
|
||||
ethicsScore: 7,
|
||||
description: 'Kleinere Betriebe, kürzere Wege nach Europa, bessere Bedingungen.',
|
||||
},
|
||||
{
|
||||
id: 'portugal',
|
||||
name: 'Öko-Manufaktur Portugal',
|
||||
country: 'Portugal',
|
||||
emoji: '🏭', flag: '🇵🇹',
|
||||
pos: { x: 42, y: 39 },
|
||||
pricePerShirt: 3.20,
|
||||
co2PerShirt: 0.5,
|
||||
ethicsScore: 9,
|
||||
description: 'EU-Standards: Mindestlohn, Sicherheit, grüner Strom. Aber deutlich teurer.',
|
||||
},
|
||||
]
|
||||
|
||||
// === ZIEL: WIEN ===
|
||||
export const VIENNA = { x: 49, y: 38 }
|
||||
|
||||
// === TRANSPORT ===
|
||||
|
||||
export interface TransportMode {
|
||||
id: 'ship' | 'truck' | 'plane'
|
||||
name: string
|
||||
emoji: string
|
||||
/** Kosten pro 1000 km pro Stück (€) */
|
||||
costPer1000km: number
|
||||
/** CO₂ pro 1000 km pro Stück (kg) */
|
||||
co2Per1000km: number
|
||||
/** Lieferzeit in Tagen pro 1000 km */
|
||||
daysPer1000km: number
|
||||
description: string
|
||||
}
|
||||
|
||||
export const TRANSPORT_MODES: TransportMode[] = [
|
||||
{
|
||||
id: 'ship',
|
||||
name: 'Containerschiff',
|
||||
emoji: '🚢',
|
||||
costPer1000km: 0.06,
|
||||
co2Per1000km: 0.10,
|
||||
daysPer1000km: 4.0,
|
||||
description: 'Sehr günstig und sehr klimafreundlich pro Stück. Aber langsam — und nicht überall hin.',
|
||||
},
|
||||
{
|
||||
id: 'truck',
|
||||
name: 'LKW',
|
||||
emoji: '🚛',
|
||||
costPer1000km: 0.18,
|
||||
co2Per1000km: 0.65,
|
||||
daysPer1000km: 1.5,
|
||||
description: 'Schneller als Schiff. Mittlere Kosten, hohe CO₂-Last.',
|
||||
},
|
||||
{
|
||||
id: 'plane',
|
||||
name: 'Frachtflugzeug',
|
||||
emoji: '✈️',
|
||||
costPer1000km: 1.20,
|
||||
co2Per1000km: 2.40,
|
||||
daysPer1000km: 0.3,
|
||||
description: 'Über Nacht da. Teuer und mit Abstand der größte CO₂-Ausstoß.',
|
||||
},
|
||||
]
|
||||
|
||||
// Vereinfachte Distanztabelle (km, Luftlinie / Schiff je nach Ausrichtung)
|
||||
// Wir verwenden für ALLE Transportmodi denselben Wert — vereinfacht didaktisch.
|
||||
const DIST_KM: Record<string, Record<string, number>> = {
|
||||
india: { bangladesh: 1700, vietnam: 2700, 'turkey-fab': 4400, portugal: 8000 },
|
||||
usa: { bangladesh: 13000, vietnam: 13500, 'turkey-fab': 9500, portugal: 7800 },
|
||||
turkey: { bangladesh: 4500, vietnam: 7800, 'turkey-fab': 0, portugal: 3200 },
|
||||
egypt: { bangladesh: 5800, vietnam: 8200, 'turkey-fab': 1100, portugal: 3700 },
|
||||
}
|
||||
const DIST_TO_VIENNA: Record<string, number> = {
|
||||
bangladesh: 7000,
|
||||
vietnam: 9000,
|
||||
'turkey-fab': 1600,
|
||||
portugal: 2200,
|
||||
}
|
||||
|
||||
/** Eine konkret bestellte Charge */
|
||||
export interface Order {
|
||||
cottonId: string
|
||||
factoryId: string
|
||||
transport1: TransportMode['id'] // Baumwolle → Näherei
|
||||
transport2: TransportMode['id'] // Näherei → Wien
|
||||
}
|
||||
|
||||
// === KONFIGURATION ===
|
||||
|
||||
/**
|
||||
* Lehrer-Konfiguration. Drei Parameter, die ein*e Lehrperson später
|
||||
* über ein Dashboard setzen kann (URL-Parameter heute, UI später).
|
||||
*
|
||||
* Defaults entsprechen dem mittleren Schwierigkeitsgrad.
|
||||
*/
|
||||
export interface SimConfig {
|
||||
/** Startbudget in Mio € */
|
||||
startMoney: number
|
||||
/** IDs der zugelassenen Cotton-Sources / Factories / Transport-Modi */
|
||||
enabledOptions: {
|
||||
cotton: string[]
|
||||
factory: string[]
|
||||
transport: TransportMode['id'][]
|
||||
}
|
||||
/** IDs der zugelassenen Welt-Ereignisse */
|
||||
enabledEvents: string[]
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: SimConfig = {
|
||||
startMoney: 800,
|
||||
enabledOptions: {
|
||||
cotton: ['india', 'usa', 'turkey', 'egypt'],
|
||||
factory: ['bangladesh', 'vietnam', 'turkey-fab', 'portugal'],
|
||||
transport: ['ship', 'truck', 'plane'],
|
||||
},
|
||||
enabledEvents: ['drought-india', 'wage-bangladesh', 'suez', 'eu-co2-tax', 'consumer-pressure'],
|
||||
}
|
||||
|
||||
/** Spiel-Mechanik */
|
||||
const SHIRTS_PER_ORDER = 5000
|
||||
const MARKET_PRICE = 12.0 // €/Stück, was du für ein T-Shirt bekommst
|
||||
const CO2_GOAL_KG = 6.0 // unter 6 kg CO₂/Stück = klimaziel
|
||||
const ETHICS_GOAL = 5.0 // ≥ 5 = ethik-ziel
|
||||
|
||||
interface OrderHistory {
|
||||
tick: number
|
||||
cottonId: string
|
||||
factoryId: string
|
||||
transport1: TransportMode['id']
|
||||
transport2: TransportMode['id']
|
||||
shirts: number
|
||||
costPerShirt: number
|
||||
co2PerShirt: number
|
||||
ethicsScore: number
|
||||
profit: number
|
||||
}
|
||||
|
||||
export class LieferkettenGame extends GameEngine {
|
||||
private config: SimConfig
|
||||
private history: OrderHistory[] = []
|
||||
/** Zeitlich verschobene Welt-Ereignisse — können Werte mutieren */
|
||||
private activeEffects: Record<string, number> = {}
|
||||
/** Bisher gefeuerte Events */
|
||||
private firedEvents = new Set<string>()
|
||||
|
||||
constructor(config: Partial<SimConfig> = {}) {
|
||||
super(META)
|
||||
this.config = {
|
||||
...DEFAULT_CONFIG,
|
||||
...config,
|
||||
enabledOptions: { ...DEFAULT_CONFIG.enabledOptions, ...(config.enabledOptions || {}) },
|
||||
}
|
||||
this.setupResources()
|
||||
this.setupGoals()
|
||||
this.setupTutorial()
|
||||
}
|
||||
|
||||
getConfig(): SimConfig {
|
||||
return this.config
|
||||
}
|
||||
|
||||
getHistory(): OrderHistory[] {
|
||||
return this.history
|
||||
}
|
||||
|
||||
getCurrentYear(): number {
|
||||
return START_YEAR + Math.floor(this.tick / 4)
|
||||
}
|
||||
|
||||
private setupResources(): void {
|
||||
this.addResource({
|
||||
id: 'budget',
|
||||
name: 'Budget',
|
||||
icon: '💰',
|
||||
initial: this.config.startMoney,
|
||||
unit: 'Mio €',
|
||||
format: (v) => `${Math.round(v)} Mio €`,
|
||||
})
|
||||
this.addResource({
|
||||
id: 'co2_avg',
|
||||
name: 'Ø CO₂ pro Shirt',
|
||||
icon: '🌫',
|
||||
initial: 0,
|
||||
unit: 'kg',
|
||||
format: (v) => `${v.toFixed(1)} kg`,
|
||||
})
|
||||
this.addResource({
|
||||
id: 'ethics_avg',
|
||||
name: 'Ø Ethik-Score',
|
||||
icon: '⚖️',
|
||||
initial: 0,
|
||||
unit: 'von 10',
|
||||
format: (v) => `${v.toFixed(1)} / 10`,
|
||||
})
|
||||
this.addResource({
|
||||
id: 'shirts_total',
|
||||
name: 'Verkaufte Shirts',
|
||||
icon: '👕',
|
||||
initial: 0,
|
||||
unit: 'Stück',
|
||||
format: (v) => `${Math.round(v).toLocaleString('de-AT')}`,
|
||||
})
|
||||
}
|
||||
|
||||
private setupGoals(): void {
|
||||
this.addGoal({
|
||||
id: 'survive',
|
||||
title: 'Bis Quartal 20 überleben',
|
||||
description: 'Halte dein Budget über 0 Mio € durchgehend.',
|
||||
check: (g) => (g as LieferkettenGame).tick >= 20,
|
||||
progress: (g) => Math.min(100, ((g as LieferkettenGame).tick / 20) * 100),
|
||||
required: true,
|
||||
})
|
||||
this.addGoal({
|
||||
id: 'co2',
|
||||
title: `Ø CO₂ unter ${CO2_GOAL_KG} kg/Shirt`,
|
||||
description: 'Nachhaltige Lieferketten — der Klima-Fußabdruck deiner Modemarke.',
|
||||
check: (g) => {
|
||||
const v = g.getResource('co2_avg')
|
||||
return v > 0 && v < CO2_GOAL_KG
|
||||
},
|
||||
progress: (g) => {
|
||||
const v = g.getResource('co2_avg')
|
||||
if (v === 0) return 0
|
||||
return Math.max(0, Math.min(100, (1 - (v - 4) / 6) * 100))
|
||||
},
|
||||
required: true,
|
||||
})
|
||||
this.addGoal({
|
||||
id: 'ethics',
|
||||
title: `Ø Ethik-Score über ${ETHICS_GOAL}`,
|
||||
description: 'Faire Arbeitsbedingungen entlang der ganzen Lieferkette.',
|
||||
check: (g) => g.getResource('ethics_avg') >= ETHICS_GOAL,
|
||||
progress: (g) => Math.max(0, Math.min(100, (g.getResource('ethics_avg') / 10) * 100)),
|
||||
required: true,
|
||||
})
|
||||
this.addGoal({
|
||||
id: 'budget',
|
||||
title: 'Nicht pleite gehen',
|
||||
description: 'Halte ein positives Budget bis zum Spielende.',
|
||||
check: (g) => g.getResource('budget') > 0,
|
||||
required: true,
|
||||
})
|
||||
}
|
||||
|
||||
private setupTutorial(): void {
|
||||
this.setTutorial([
|
||||
{
|
||||
triggerTick: 0,
|
||||
title: 'Willkommen, Einkaufsleiter*in!',
|
||||
text: 'Du leitest den Einkauf einer kleinen Modemarke in Wien. Alle 3 Monate (=1 Quartal) musst du eine neue Charge T-Shirts bestellen.\n\nEine Charge sind 5.000 Stück. Du verkaufst sie für 12 € pro Stück — das macht 60.000 € Umsatz pro Charge.\n\nAber: Dein Gewinn hängt davon ab, was du für die Produktion bezahlst.',
|
||||
unlocks: ['budget-display'],
|
||||
},
|
||||
{
|
||||
triggerTick: 0,
|
||||
title: 'Drei Stufen der Lieferkette',
|
||||
text: 'Jede Bestellung hat 3 Entscheidungen:\n\n🌾 BAUMWOLLE — Wo wird sie angebaut? (4 Länder)\n🏭 NÄHEREI — Wo wird das Shirt genäht? (4 Länder)\n🚢 TRANSPORT — Wie kommt es nach Wien? (Schiff/LKW/Flugzeug)\n\nEs gibt also 4 × 4 × 3 × 3 = 144 mögliche Lieferketten. Welche ist die beste? Es kommt darauf an, was DU willst.',
|
||||
unlocks: ['order-form'],
|
||||
},
|
||||
{
|
||||
triggerTick: 0,
|
||||
title: 'Drei Werte zählen',
|
||||
text: 'Du hast 3 Hauptziele — alle gleichzeitig:\n\n💰 BUDGET — bleibe profitabel\n🌫 CO₂ — der durchschnittliche Klimaabdruck pro Shirt soll unter 6 kg bleiben\n⚖️ ETHIK — der Ø-Wert für Arbeitsbedingungen soll über 5 von 10 liegen\n\nDie drei stehen oft im Widerspruch. Du musst abwägen — wie im echten Leben.',
|
||||
unlocks: ['goals'],
|
||||
},
|
||||
{
|
||||
triggerTick: 0,
|
||||
title: 'Bereit?',
|
||||
text: 'Im Verlauf der Simulation passieren Welt-Ereignisse: Dürren, Lohnerhöhungen, Suezkanal-Stau, Klimazölle. Sie verändern die Werte einzelner Standorte.\n\nDu hast 20 Quartale (= 5 Jahre) Zeit. Klick eine Bestellung zusammen und drücke „Bestellen".\n\nLos geht\'s! 🚢',
|
||||
unlocks: ['controls'],
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Simuliere eine Bestellung (vom Spieler ausgelöst). Wendet sofort die
|
||||
* Effekte an und schreibt einen History-Eintrag.
|
||||
*/
|
||||
placeOrder(order: Order): boolean {
|
||||
const cotton = COTTON_SOURCES.find(c => c.id === order.cottonId)
|
||||
const factory = FACTORIES.find(f => f.id === order.factoryId)
|
||||
const t1 = TRANSPORT_MODES.find(t => t.id === order.transport1)
|
||||
const t2 = TRANSPORT_MODES.find(t => t.id === order.transport2)
|
||||
if (!cotton || !factory || !t1 || !t2) return false
|
||||
|
||||
// Welt-Effekte anwenden
|
||||
const cottonPrice = cotton.pricePerShirt * (1 + (this.activeEffects[`cotton-${cotton.id}-price`] || 0))
|
||||
const factoryPrice = factory.pricePerShirt * (1 + (this.activeEffects[`factory-${factory.id}-price`] || 0))
|
||||
const cottonCO2 = cotton.co2PerShirt
|
||||
const factoryCO2 = factory.co2PerShirt
|
||||
const cottonEthics = cotton.ethicsScore + (this.activeEffects[`cotton-${cotton.id}-ethics`] || 0)
|
||||
const factoryEthics = factory.ethicsScore + (this.activeEffects[`factory-${factory.id}-ethics`] || 0)
|
||||
|
||||
// Distanzen
|
||||
const dist1 = (DIST_KM[cotton.id]?.[factory.id] ?? 5000) / 1000
|
||||
const dist2 = (DIST_TO_VIENNA[factory.id] ?? 3000) / 1000
|
||||
|
||||
// Transportkosten und CO₂
|
||||
const t1Cost = t1.costPer1000km * dist1 * (1 + (this.activeEffects[`transport-${t1.id}-cost`] || 0))
|
||||
const t2Cost = t2.costPer1000km * dist2 * (1 + (this.activeEffects[`transport-${t2.id}-cost`] || 0))
|
||||
const t1CO2 = t1.co2Per1000km * dist1
|
||||
const t2CO2 = t2.co2Per1000km * dist2
|
||||
|
||||
// Pro-Stück-Werte
|
||||
const costPerShirt = cottonPrice + factoryPrice + t1Cost + t2Cost
|
||||
const co2PerShirt = cottonCO2 + factoryCO2 + t1CO2 + t2CO2
|
||||
// Gewichteter Ethik-Mittelwert (Cotton 50 %, Factory 50 %)
|
||||
const ethicsScore = (cottonEthics + factoryEthics) / 2
|
||||
|
||||
// Wirtschaft: Verkauf − Einkauf − Transport (bezogen auf 5000 Stück)
|
||||
// Wir rechnen in Tausend €, dann zu Mio € → /1000
|
||||
const totalRevenue = SHIRTS_PER_ORDER * MARKET_PRICE / 1000 // k€
|
||||
const totalCost = SHIRTS_PER_ORDER * costPerShirt / 1000 // k€
|
||||
const profitKEur = totalRevenue - totalCost
|
||||
const profitMio = profitKEur / 1000 // Mio €
|
||||
this.changeResource('budget', profitMio)
|
||||
|
||||
// Durchschnittliche CO₂- und Ethik-Werte aktualisieren (gewichteter Mittelwert)
|
||||
const oldShirts = this.getResource('shirts_total')
|
||||
const newShirts = oldShirts + SHIRTS_PER_ORDER
|
||||
const oldCO2avg = this.getResource('co2_avg')
|
||||
const newCO2avg = oldShirts === 0
|
||||
? co2PerShirt
|
||||
: (oldCO2avg * oldShirts + co2PerShirt * SHIRTS_PER_ORDER) / newShirts
|
||||
const oldEthAvg = this.getResource('ethics_avg')
|
||||
const newEthAvg = oldShirts === 0
|
||||
? ethicsScore
|
||||
: (oldEthAvg * oldShirts + ethicsScore * SHIRTS_PER_ORDER) / newShirts
|
||||
|
||||
this.setResource('co2_avg', newCO2avg)
|
||||
this.setResource('ethics_avg', newEthAvg)
|
||||
this.setResource('shirts_total', newShirts)
|
||||
|
||||
// History
|
||||
this.history.push({
|
||||
tick: this.tick,
|
||||
cottonId: cotton.id,
|
||||
factoryId: factory.id,
|
||||
transport1: t1.id,
|
||||
transport2: t2.id,
|
||||
shirts: SHIRTS_PER_ORDER,
|
||||
costPerShirt,
|
||||
co2PerShirt,
|
||||
ethicsScore,
|
||||
profit: profitMio,
|
||||
})
|
||||
|
||||
// Event für die Anzeige
|
||||
const profitStr = profitMio >= 0 ? `+${profitMio.toFixed(1)}` : profitMio.toFixed(1)
|
||||
this.addEvent(
|
||||
'order-' + this.tick + '-' + this.history.length,
|
||||
`${cotton.flag}→${factory.flag} ${t1.emoji}${t2.emoji} · ${profitStr} Mio € · ${co2PerShirt.toFixed(1)} kg CO₂ · Ethik ${ethicsScore.toFixed(0)}/10`,
|
||||
profitMio >= 0 ? 'success' : 'warning',
|
||||
)
|
||||
|
||||
this.notify()
|
||||
return true
|
||||
}
|
||||
|
||||
protected simulateTick(): void {
|
||||
// Fixkosten pro Quartal (Marketing, Mieten, Lohn): 8 Mio €
|
||||
this.changeResource('budget', -8)
|
||||
|
||||
// Welt-Ereignisse (Tick-basiert, einmalig)
|
||||
this.maybeFireEvent()
|
||||
}
|
||||
|
||||
/** Welt-Ereignisse, die Standort-Werte verändern */
|
||||
private maybeFireEvent(): void {
|
||||
const enabled = new Set(this.config.enabledEvents)
|
||||
const fire = (id: string, body: () => void) => {
|
||||
if (!enabled.has(id) || this.firedEvents.has(id)) return
|
||||
this.firedEvents.add(id)
|
||||
body()
|
||||
}
|
||||
|
||||
if (this.tick === 5) {
|
||||
fire('drought-india', () => {
|
||||
this.activeEffects['cotton-india-price'] = 0.40
|
||||
this.addEvent(
|
||||
'drought-india',
|
||||
'🌵 Trockenheit in Indien — Baumwoll-Preis +40 %.',
|
||||
'warning',
|
||||
)
|
||||
})
|
||||
}
|
||||
if (this.tick === 8) {
|
||||
fire('wage-bangladesh', () => {
|
||||
this.activeEffects['factory-bangladesh-price'] = 0.30
|
||||
this.activeEffects['factory-bangladesh-ethics'] = 2
|
||||
this.addEvent(
|
||||
'wage-bangladesh',
|
||||
'✊ Bangladesch erhöht den Mindestlohn — +30 % Preis, aber +2 Ethik.',
|
||||
'info',
|
||||
)
|
||||
})
|
||||
}
|
||||
if (this.tick === 11) {
|
||||
fire('suez', () => {
|
||||
this.activeEffects['transport-ship-cost'] = 0.50
|
||||
this.addEvent(
|
||||
'suez',
|
||||
'🚧 Schiffs-Stau im Suezkanal — Containerschiff +50 % Kosten.',
|
||||
'warning',
|
||||
)
|
||||
})
|
||||
}
|
||||
if (this.tick === 14) {
|
||||
fire('eu-co2-tax', () => {
|
||||
this.activeEffects['transport-plane-cost'] = 0.60
|
||||
this.activeEffects['transport-truck-cost'] = 0.30
|
||||
this.addEvent(
|
||||
'eu-co2-tax',
|
||||
'🇪🇺 EU-Klimazoll — Flug +60 %, LKW +30 %.',
|
||||
'warning',
|
||||
)
|
||||
})
|
||||
}
|
||||
if (this.tick === 17) {
|
||||
fire('consumer-pressure', () => {
|
||||
// Konsumenten verlangen Nachhaltigkeit — Bonus für Bio/EU
|
||||
this.activeEffects['cotton-egypt-price'] = -0.15
|
||||
this.activeEffects['factory-portugal-price'] = -0.15
|
||||
this.addEvent(
|
||||
'consumer-pressure',
|
||||
'📰 Kund*innen verlangen Nachhaltigkeit — Bio-Baumwolle und EU-Manufaktur −15 %.',
|
||||
'success',
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
protected checkLossCondition(): boolean {
|
||||
return this.getResource('budget') < -100
|
||||
}
|
||||
|
||||
protected serializeSubclass(): Record<string, unknown> {
|
||||
return {
|
||||
config: this.config,
|
||||
history: this.history,
|
||||
activeEffects: this.activeEffects,
|
||||
firedEvents: Array.from(this.firedEvents),
|
||||
}
|
||||
}
|
||||
|
||||
protected deserializeSubclass(data: Record<string, unknown>): void {
|
||||
if (data.config) this.config = data.config as SimConfig
|
||||
if (Array.isArray(data.history)) this.history = data.history as OrderHistory[]
|
||||
if (data.activeEffects) this.activeEffects = data.activeEffects as Record<string, number>
|
||||
if (Array.isArray(data.firedEvents)) this.firedEvents = new Set(data.firedEvents as string[])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* SIM-10: Lieferketten-Renderer (SVG, 2D)
|
||||
*
|
||||
* Zeichnet eine vereinfachte Weltkarte als SVG, mit Markern für die
|
||||
* Baumwoll-Quellen, Nähereien und Wien. Wenn die Anwender*in eine Route
|
||||
* im Bestell-Formular zusammenstellt, wird sie hier als gestrichelte Linie
|
||||
* angezeigt — und beim Bestellen ein kleines Transport-Sprite (🚢/🚛/✈️)
|
||||
* über die Linie animiert.
|
||||
*
|
||||
* Das ist BEWUSST viel einfacher als der Klimawächter-3D-Renderer:
|
||||
* - Keine Three.js, kein WebGL — nur SVG
|
||||
* - Keine 60fps-Loop, nur Update bei Bedarf
|
||||
* - Komplett pixel-flach, keine Schatten, kein Performance-Risiko
|
||||
*/
|
||||
|
||||
import {
|
||||
COTTON_SOURCES,
|
||||
FACTORIES,
|
||||
TRANSPORT_MODES,
|
||||
VIENNA,
|
||||
type LieferkettenGame,
|
||||
type Order,
|
||||
} from './game'
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg'
|
||||
|
||||
export class LieferkettenRenderer {
|
||||
private container: HTMLElement
|
||||
private game: LieferkettenGame
|
||||
private svg!: SVGSVGElement
|
||||
/** Aktuell im Bestell-Formular gewählte Route (für die Vorschau-Linie) */
|
||||
private previewOrder: Partial<Order> = {}
|
||||
private animationStart = 0
|
||||
private animatedSprites: Array<{ el: SVGTextElement; from: { x: number; y: number }; to: { x: number; y: number }; start: number; duration: number }> = []
|
||||
private rafId = 0
|
||||
|
||||
constructor(container: HTMLElement, game: LieferkettenGame) {
|
||||
this.container = container
|
||||
this.game = game
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.buildSVG()
|
||||
this.draw()
|
||||
this.tick()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.rafId) cancelAnimationFrame(this.rafId)
|
||||
this.rafId = 0
|
||||
}
|
||||
|
||||
/** Wird vom HTML aufgerufen, wenn der Spieler eine Bestell-Auswahl ändert */
|
||||
setPreviewOrder(order: Partial<Order>): void {
|
||||
this.previewOrder = { ...order }
|
||||
this.draw()
|
||||
}
|
||||
|
||||
/** Wird beim Bestellen aufgerufen — animiert ein Sprite über die Route */
|
||||
animateOrder(order: Order): void {
|
||||
const cotton = COTTON_SOURCES.find(c => c.id === order.cottonId)
|
||||
const factory = FACTORIES.find(f => f.id === order.factoryId)
|
||||
const t1 = TRANSPORT_MODES.find(t => t.id === order.transport1)
|
||||
const t2 = TRANSPORT_MODES.find(t => t.id === order.transport2)
|
||||
if (!cotton || !factory || !t1 || !t2) return
|
||||
|
||||
// Sprite 1: Baumwolle → Näherei
|
||||
const s1 = this.makeSprite(t1.emoji)
|
||||
this.animatedSprites.push({
|
||||
el: s1,
|
||||
from: cotton.pos,
|
||||
to: factory.pos,
|
||||
start: performance.now(),
|
||||
duration: 1500,
|
||||
})
|
||||
|
||||
// Sprite 2: Näherei → Wien (startet etwas später)
|
||||
const s2 = this.makeSprite(t2.emoji)
|
||||
this.animatedSprites.push({
|
||||
el: s2,
|
||||
from: factory.pos,
|
||||
to: VIENNA,
|
||||
start: performance.now() + 800,
|
||||
duration: 1500,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Interna
|
||||
// ============================================================
|
||||
|
||||
private buildSVG(): void {
|
||||
this.container.innerHTML = ''
|
||||
this.container.style.cssText = 'position:fixed;inset:0;background:linear-gradient(180deg,#dceaf0 0%,#bcd5e6 100%);'
|
||||
this.svg = document.createElementNS(SVG_NS, 'svg') as SVGSVGElement
|
||||
this.svg.setAttribute('viewBox', '0 0 100 60')
|
||||
this.svg.setAttribute('preserveAspectRatio', 'xMidYMid meet')
|
||||
this.svg.style.cssText = 'width:100%;height:100%;display:block;'
|
||||
this.container.appendChild(this.svg)
|
||||
}
|
||||
|
||||
private draw(): void {
|
||||
if (!this.svg) return
|
||||
// Layers: Karte, Linien, Marker, Animation
|
||||
this.svg.innerHTML = `
|
||||
${this.drawWorldShape()}
|
||||
${this.drawRouteLines()}
|
||||
${this.drawMarkers()}
|
||||
`
|
||||
// Animations-Sprites werden separat als DOM-Knoten gehalten
|
||||
for (const s of this.animatedSprites) {
|
||||
this.svg.appendChild(s.el)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sehr stark vereinfachte Welt-Silhouette als <path>.
|
||||
* Wir zeichnen 4 grobe Kontinent-Blobs — keine Geo-Genauigkeit, nur damit
|
||||
* die Marker im richtigen Bereich sitzen.
|
||||
*/
|
||||
private drawWorldShape(): string {
|
||||
const oceanGrad = `
|
||||
<defs>
|
||||
<linearGradient id="ocean" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#dceaf0" />
|
||||
<stop offset="100%" stop-color="#a0c0d0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
`
|
||||
// Kontinent-Pfade — handgemalt, didaktische Vereinfachung
|
||||
const continents = `
|
||||
<!-- Nordamerika -->
|
||||
<path d="M 5,25 Q 8,20 14,22 Q 20,21 24,28 Q 26,38 22,46 Q 16,50 10,46 Q 4,40 5,25 Z"
|
||||
fill="#e8e3d4" stroke="#b0a890" stroke-width="0.2" />
|
||||
<!-- Südamerika -->
|
||||
<path d="M 20,48 Q 24,46 26,50 Q 28,55 24,58 Q 20,58 19,53 Z"
|
||||
fill="#e8e3d4" stroke="#b0a890" stroke-width="0.2" />
|
||||
<!-- Europa -->
|
||||
<path d="M 42,30 Q 48,28 52,32 Q 53,38 50,42 Q 44,42 42,38 Q 41,33 42,30 Z"
|
||||
fill="#e8e3d4" stroke="#b0a890" stroke-width="0.2" />
|
||||
<!-- Afrika -->
|
||||
<path d="M 46,42 Q 52,42 55,48 Q 56,55 50,57 Q 45,55 44,49 Q 44,44 46,42 Z"
|
||||
fill="#e8e3d4" stroke="#b0a890" stroke-width="0.2" />
|
||||
<!-- Asien (groß, diffus) -->
|
||||
<path d="M 53,28 Q 65,24 78,28 Q 86,32 84,42 Q 78,48 72,50 Q 60,48 56,42 Q 54,36 53,28 Z"
|
||||
fill="#e8e3d4" stroke="#b0a890" stroke-width="0.2" />
|
||||
<!-- Indien-Spitze -->
|
||||
<path d="M 65,46 Q 68,45 70,50 Q 67,53 64,51 Z"
|
||||
fill="#e8e3d4" stroke="#b0a890" stroke-width="0.2" />
|
||||
<!-- Australien -->
|
||||
<path d="M 80,52 Q 86,50 88,54 Q 86,57 81,56 Z"
|
||||
fill="#e8e3d4" stroke="#b0a890" stroke-width="0.2" />
|
||||
`
|
||||
return `${oceanGrad}<rect width="100" height="60" fill="url(#ocean)" />${continents}`
|
||||
}
|
||||
|
||||
/** Zeichnet Routen-Linien:
|
||||
* - dünne graue Linien für ALLE möglichen Verbindungen (zur Orientierung)
|
||||
* - dicke gestrichelte Linie für die aktuell im Formular gewählte Route */
|
||||
private drawRouteLines(): string {
|
||||
let lines = ''
|
||||
// Vorschau-Route (wenn etwas gewählt ist)
|
||||
if (this.previewOrder.cottonId && this.previewOrder.factoryId) {
|
||||
const c = COTTON_SOURCES.find(s => s.id === this.previewOrder.cottonId)
|
||||
const f = FACTORIES.find(x => x.id === this.previewOrder.factoryId)
|
||||
if (c && f) {
|
||||
lines += this.line(c.pos, f.pos, '#c07a6b', 0.45)
|
||||
}
|
||||
}
|
||||
if (this.previewOrder.factoryId) {
|
||||
const f = FACTORIES.find(x => x.id === this.previewOrder.factoryId)
|
||||
if (f) {
|
||||
lines += this.line(f.pos, VIENNA, '#c07a6b', 0.45)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private line(a: { x: number; y: number }, b: { x: number; y: number }, color: string, w: number): string {
|
||||
return `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}"
|
||||
stroke="${color}" stroke-width="${w}" stroke-dasharray="1.2 0.8"
|
||||
stroke-linecap="round" />`
|
||||
}
|
||||
|
||||
private drawMarkers(): string {
|
||||
let out = ''
|
||||
// Wien als rotes Ziel
|
||||
out += this.markerWithLabel(VIENNA.x, VIENNA.y, '🏛', 'Wien', '#b04a3a', 2.6)
|
||||
// Baumwoll-Quellen
|
||||
for (const c of COTTON_SOURCES) {
|
||||
const isSelected = this.previewOrder.cottonId === c.id
|
||||
out += this.markerWithLabel(
|
||||
c.pos.x, c.pos.y,
|
||||
c.flag, c.country,
|
||||
isSelected ? '#5a8a5e' : '#5a8a8a',
|
||||
isSelected ? 2.4 : 2.0,
|
||||
)
|
||||
}
|
||||
// Nähereien
|
||||
for (const f of FACTORIES) {
|
||||
const isSelected = this.previewOrder.factoryId === f.id
|
||||
// Position leicht versetzen, falls Factory am gleichen Ort wie Cotton
|
||||
const dx = (f.id === 'turkey-fab' && COTTON_SOURCES.some(c => c.id === 'turkey')) ? 1.5 : 0
|
||||
const dy = (f.id === 'turkey-fab' && COTTON_SOURCES.some(c => c.id === 'turkey')) ? 1.5 : 0
|
||||
out += this.markerWithLabel(
|
||||
f.pos.x + dx, f.pos.y + dy,
|
||||
f.emoji, f.country,
|
||||
isSelected ? '#5a8a5e' : '#7a6a5a',
|
||||
isSelected ? 2.4 : 2.0,
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private markerWithLabel(x: number, y: number, emoji: string, label: string, color: string, size: number): string {
|
||||
return `
|
||||
<g>
|
||||
<circle cx="${x}" cy="${y}" r="${size}" fill="${color}" opacity="0.85" />
|
||||
<circle cx="${x}" cy="${y}" r="${size + 0.4}" fill="none" stroke="#fff" stroke-width="0.3" />
|
||||
<text x="${x}" y="${y + size * 0.45}" text-anchor="middle" font-size="${size * 1.4}">${emoji}</text>
|
||||
<text x="${x}" y="${y + size + 1.6}" text-anchor="middle" font-size="1.4"
|
||||
font-weight="700" fill="#1a1a1a"
|
||||
style="paint-order: stroke; stroke: #fff; stroke-width: 0.5;">${this.escape(label)}</text>
|
||||
</g>
|
||||
`
|
||||
}
|
||||
|
||||
private makeSprite(emoji: string): SVGTextElement {
|
||||
const el = document.createElementNS(SVG_NS, 'text') as SVGTextElement
|
||||
el.setAttribute('font-size', '2.6')
|
||||
el.setAttribute('text-anchor', 'middle')
|
||||
el.textContent = emoji
|
||||
return el
|
||||
}
|
||||
|
||||
private tick(): void {
|
||||
const now = performance.now()
|
||||
let stillRunning = false
|
||||
for (let i = this.animatedSprites.length - 1; i >= 0; i--) {
|
||||
const s = this.animatedSprites[i]
|
||||
const t = (now - s.start) / s.duration
|
||||
if (t < 0) {
|
||||
// noch nicht gestartet — verstecken
|
||||
s.el.setAttribute('x', '-100')
|
||||
s.el.setAttribute('y', '-100')
|
||||
stillRunning = true
|
||||
continue
|
||||
}
|
||||
if (t >= 1) {
|
||||
// Fertig — entfernen
|
||||
if (s.el.parentNode) s.el.parentNode.removeChild(s.el)
|
||||
this.animatedSprites.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
// Linear interpolieren mit kleiner Hubbel
|
||||
const x = s.from.x + (s.to.x - s.from.x) * t
|
||||
const y = s.from.y + (s.to.y - s.from.y) * t - Math.sin(t * Math.PI) * 1.5
|
||||
s.el.setAttribute('x', String(x))
|
||||
s.el.setAttribute('y', String(y))
|
||||
stillRunning = true
|
||||
}
|
||||
// rAF-Loop
|
||||
this.rafId = requestAnimationFrame(() => this.tick())
|
||||
}
|
||||
|
||||
private escape(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* SIM-11: Forscher*in im Regenwald — Daten
|
||||
*
|
||||
* Eine scrollende Flussreise durch drei Klima-Zonen des Regenwalds.
|
||||
* An jedem Halt gibt es ein Tier/Pflanze/Phänomen zu entdecken.
|
||||
*
|
||||
* KEIN GameEngine-Erbe — das ist kein Tick-basiertes Spiel, sondern
|
||||
* ein Explorations-Tool mit Sammelheft.
|
||||
*/
|
||||
|
||||
export interface Discoverable {
|
||||
id: string
|
||||
emoji: string
|
||||
name: string
|
||||
/** Position auf der Karte: 0 = Start (Flussmündung), 100 = Ende (Bergregenwald) */
|
||||
position: number
|
||||
/** In welcher Zone liegt das Tier? */
|
||||
zone: 'delta' | 'tiefland' | 'berg'
|
||||
/** 2-3 kindgerechte Sätze */
|
||||
fact: string
|
||||
/** Optionaler Funfact / Staunen-Satz */
|
||||
wow?: string
|
||||
/** Ist es ein Tier, eine Pflanze, ein Mensch oder ein Phänomen? */
|
||||
type: 'tier' | 'pflanze' | 'mensch' | 'phaenomen'
|
||||
}
|
||||
|
||||
/**
|
||||
* 15 entdeckbare Stationen entlang des Flusses.
|
||||
* Verteilt über 3 Zonen: Delta (0-33), Tiefland (34-66), Berg (67-100).
|
||||
*/
|
||||
export const DISCOVERIES: Discoverable[] = [
|
||||
// === DELTA (Flussmündung, flach, warm, feucht) ===
|
||||
{
|
||||
id: 'krokodil',
|
||||
emoji: '🐊',
|
||||
name: 'Krokodil',
|
||||
position: 5,
|
||||
zone: 'delta',
|
||||
type: 'tier',
|
||||
fact: 'Krokodile leben seit über 200 Millionen Jahren auf der Erde — sie waren schon da, als die Dinosaurier lebten! Sie lauern im flachen Wasser und sind blitzschnell.',
|
||||
wow: 'Ein Krokodil kann über eine Stunde die Luft anhalten.',
|
||||
},
|
||||
{
|
||||
id: 'papagei',
|
||||
emoji: '🦜',
|
||||
name: 'Ara-Papagei',
|
||||
position: 12,
|
||||
zone: 'delta',
|
||||
type: 'tier',
|
||||
fact: 'Aras sind die buntesten Vögel im Regenwald. Sie leben in Paaren und bleiben ihr ganzes Leben zusammen. Ihr lauter Ruf ist kilometerweit zu hören.',
|
||||
wow: 'Aras fressen Lehm von Flussufern — das hilft gegen Gifte in unreifen Früchten!',
|
||||
},
|
||||
{
|
||||
id: 'mangrove',
|
||||
emoji: '🌿',
|
||||
name: 'Mangroven-Baum',
|
||||
position: 18,
|
||||
zone: 'delta',
|
||||
type: 'pflanze',
|
||||
fact: 'Mangroven sind die einzigen Bäume, die im Salzwasser wachsen können. Ihre Wurzeln ragen wie Stelzen aus dem Wasser und schützen die Küste vor Stürmen und Wellen.',
|
||||
wow: 'In den Mangrovenwurzeln leben hunderte kleine Fische, Krebse und Schnecken — ein ganzes Unterwasser-Dorf!',
|
||||
},
|
||||
{
|
||||
id: 'flussdelfin',
|
||||
emoji: '🐬',
|
||||
name: 'Rosa Flussdelfin',
|
||||
position: 25,
|
||||
zone: 'delta',
|
||||
type: 'tier',
|
||||
fact: 'Im Amazonas schwimmen rosa Delfine! Sie sind tatsächlich pink — niemand weiß genau warum. Sie sind scheu und schwer zu beobachten.',
|
||||
wow: 'Flussdelfine können ihren Kopf in alle Richtungen drehen — das können Meerdelfine nicht.',
|
||||
},
|
||||
{
|
||||
id: 'fischer',
|
||||
emoji: '🧑',
|
||||
name: 'Fischer-Familie',
|
||||
position: 30,
|
||||
zone: 'delta',
|
||||
type: 'mensch',
|
||||
fact: 'Am Flussufer leben Familien, die vom Fischfang leben. Sie kennen den Fluss besser als jede Landkarte. Ihr Wissen wird seit Generationen weitergegeben.',
|
||||
},
|
||||
|
||||
// === TIEFLAND (dichter Dschungel, wenig Licht am Boden) ===
|
||||
{
|
||||
id: 'jaguar',
|
||||
emoji: '🐆',
|
||||
name: 'Jaguar',
|
||||
position: 38,
|
||||
zone: 'tiefland',
|
||||
type: 'tier',
|
||||
fact: 'Der Jaguar ist die größte Raubkatze Südamerikas. Er kann klettern, schwimmen und sogar unter Wasser jagen. Am liebsten frisst er Pekaris und Kaimane.',
|
||||
wow: 'Jaguare haben den stärksten Biss aller Großkatzen — sie knacken sogar Schildkrötenpanzer.',
|
||||
},
|
||||
{
|
||||
id: 'faultier',
|
||||
emoji: '🦥',
|
||||
name: 'Dreifinger-Faultier',
|
||||
position: 45,
|
||||
zone: 'tiefland',
|
||||
type: 'tier',
|
||||
fact: 'Faultiere bewegen sich so langsam, dass auf ihrem Fell Algen wachsen — das macht sie grünlich und tarnt sie zwischen den Blättern. Sie schlafen bis zu 20 Stunden am Tag.',
|
||||
wow: 'Ein Faultier braucht einen ganzen Monat, um ein einziges Blatt zu verdauen!',
|
||||
},
|
||||
{
|
||||
id: 'ameisen',
|
||||
emoji: '🐜',
|
||||
name: 'Blattschneider-Ameisen',
|
||||
position: 50,
|
||||
zone: 'tiefland',
|
||||
type: 'tier',
|
||||
fact: 'Diese Ameisen schneiden Blattstücke ab und tragen sie in ihren Bau. Aber sie essen die Blätter nicht — sie züchten damit einen Pilz, der ihr eigentliches Essen ist! Sie sind also Pilz-Bauern.',
|
||||
wow: 'Eine Blattschneider-Kolonie kann bis zu 8 Millionen Ameisen haben.',
|
||||
},
|
||||
{
|
||||
id: 'orchidee',
|
||||
emoji: '🌺',
|
||||
name: 'Riesen-Orchidee',
|
||||
position: 55,
|
||||
zone: 'tiefland',
|
||||
type: 'pflanze',
|
||||
fact: 'Im Regenwald wachsen über 25.000 Orchideen-Arten. Manche blühen nur eine einzige Nacht lang. Viele wachsen hoch oben auf Baumstämmen, weil dort mehr Licht hinkommt.',
|
||||
},
|
||||
{
|
||||
id: 'indigene',
|
||||
emoji: '🧑',
|
||||
name: 'Indigene Gemeinschaft',
|
||||
position: 62,
|
||||
zone: 'tiefland',
|
||||
type: 'mensch',
|
||||
fact: 'Im Regenwald leben Menschen, deren Familien dort seit Tausenden von Jahren zuhause sind. Sie kennen jede Pflanze, wissen welche heilen und welche giftig sind. Ihr Wissen ist ein Schatz.',
|
||||
wow: 'Über 80 % aller Medikamente haben ihren Ursprung in Pflanzen aus dem Regenwald!',
|
||||
},
|
||||
|
||||
// === BERGREGENWALD (kühler, neblig, moosig) ===
|
||||
{
|
||||
id: 'affe',
|
||||
emoji: '🐒',
|
||||
name: 'Brüllaffe',
|
||||
position: 70,
|
||||
zone: 'berg',
|
||||
type: 'tier',
|
||||
fact: 'Brüllaffen sind die lautesten Landtiere der Welt. Ihren Ruf hört man bis zu 5 Kilometer weit! Sie brüllen morgens, um ihr Revier zu markieren — ohne zu kämpfen.',
|
||||
wow: 'Sie brüllen so laut wie ein Düsenflugzeug beim Start — 140 Dezibel!',
|
||||
},
|
||||
{
|
||||
id: 'kolibri',
|
||||
emoji: '🐦',
|
||||
name: 'Kolibri',
|
||||
position: 78,
|
||||
zone: 'berg',
|
||||
type: 'tier',
|
||||
fact: 'Kolibris sind die kleinsten Vögel der Welt. Sie können in der Luft stehen bleiben und sogar rückwärts fliegen! Ihr Herz schlägt bis zu 1.200 Mal pro Minute.',
|
||||
wow: 'Ein Kolibri trinkt am Tag doppelt so viel Nektar wie sein Körpergewicht.',
|
||||
},
|
||||
{
|
||||
id: 'nebel',
|
||||
emoji: '🌫',
|
||||
name: 'Nebelwald',
|
||||
position: 85,
|
||||
zone: 'berg',
|
||||
type: 'phaenomen',
|
||||
fact: 'Im Bergregenwald hängt fast immer dichter Nebel zwischen den Bäumen. Die Feuchtigkeit sammelt sich an den Blättern und tropft herunter — wie ein unsichtbarer Regen, der den Wald von innen gießt.',
|
||||
wow: 'Im Nebelwald wächst mehr Moos als irgendwo sonst auf der Erde.',
|
||||
},
|
||||
{
|
||||
id: 'schlange',
|
||||
emoji: '🐍',
|
||||
name: 'Smaragd-Baumboa',
|
||||
position: 90,
|
||||
zone: 'berg',
|
||||
type: 'tier',
|
||||
fact: 'Die Smaragd-Baumboa ist leuchtend grün und hängt zusammengerollt auf Ästen. Sie jagt Vögel und Eidechsen — und ist völlig harmlos für Menschen, auch wenn sie gefährlich aussieht.',
|
||||
},
|
||||
{
|
||||
id: 'rodung',
|
||||
emoji: '🪓',
|
||||
name: 'Gerodete Fläche',
|
||||
position: 96,
|
||||
zone: 'berg',
|
||||
type: 'phaenomen',
|
||||
fact: 'Hier standen früher riesige Bäume. Jetzt ist der Boden kahl — gerodet für Rinderweiden oder Soja-Felder. Jedes Jahr verschwindet eine Regenwald-Fläche so groß wie die Schweiz.',
|
||||
wow: 'Wenn der Regenwald ganz verschwindet, verlieren wir Millionen von Tier- und Pflanzenarten — für immer. Viele davon kennen wir noch gar nicht.',
|
||||
},
|
||||
]
|
||||
|
||||
/** Die 3 Klimazonen des Flusslaufs */
|
||||
export interface Zone {
|
||||
id: 'delta' | 'tiefland' | 'berg'
|
||||
name: string
|
||||
emoji: string
|
||||
/** Hintergrundfarbe (CSS-Gradient) */
|
||||
bgFrom: string
|
||||
bgTo: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const ZONES: Zone[] = [
|
||||
{
|
||||
id: 'delta',
|
||||
name: 'Flussmündung',
|
||||
emoji: '🏝',
|
||||
bgFrom: '#a8d8c8',
|
||||
bgTo: '#7ab8a0',
|
||||
description: 'Flach, warm, feucht. Wo der Fluss ins Meer mündet, leben Krokodile und Flussdelfine.',
|
||||
},
|
||||
{
|
||||
id: 'tiefland',
|
||||
name: 'Tiefland-Regenwald',
|
||||
emoji: '🌴',
|
||||
bgFrom: '#5a9a6a',
|
||||
bgTo: '#3a7a4a',
|
||||
description: 'Dichter Dschungel, fast kein Licht am Boden. Hier leben die meisten Tiere.',
|
||||
},
|
||||
{
|
||||
id: 'berg',
|
||||
name: 'Bergregenwald',
|
||||
emoji: '⛰',
|
||||
bgFrom: '#4a8a6a',
|
||||
bgTo: '#2a5a3a',
|
||||
description: 'Kühl und neblig. Moosbedeckte Bäume, seltene Orchideen, Brüllaffen und Kolibris.',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* SIM-12: Flussmanagement — Game Controller
|
||||
*
|
||||
* Rundenbasiertes Spiel. Verwaltet Zustand, Runden, Events,
|
||||
* Scoring und die gesamte Spielschleife.
|
||||
*/
|
||||
|
||||
import {
|
||||
type Controls, type State, type Conditions, type ScoreBreakdown,
|
||||
type LevelDefinition, type LevelEvent,
|
||||
simulateRound, computeScore, computeTotalCost, checkWinLose, checkFinalWin,
|
||||
LEVELS,
|
||||
} from './logic'
|
||||
|
||||
export type GamePhase = 'level-select' | 'intro' | 'playing' | 'event' | 'round-result' | 'won' | 'lost'
|
||||
|
||||
export interface RoundHistory {
|
||||
round: number
|
||||
state: State
|
||||
controls: Controls
|
||||
score: ScoreBreakdown
|
||||
cost: number
|
||||
event?: LevelEvent
|
||||
}
|
||||
|
||||
export class FlussGame {
|
||||
// Zustand
|
||||
level!: LevelDefinition
|
||||
phase: GamePhase = 'level-select'
|
||||
round = 0
|
||||
state!: State
|
||||
controls!: Controls
|
||||
budgetRemaining = 0
|
||||
score!: ScoreBreakdown
|
||||
history: RoundHistory[] = []
|
||||
currentEvent: LevelEvent | null = null
|
||||
|
||||
// Callbacks
|
||||
private onChange: (() => void) | null = null
|
||||
|
||||
constructor() {
|
||||
this.reset()
|
||||
}
|
||||
|
||||
subscribe(fn: () => void): void {
|
||||
this.onChange = fn
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.onChange?.()
|
||||
}
|
||||
|
||||
/** Level auswaehlen und Spiel starten */
|
||||
selectLevel(levelId: string): void {
|
||||
const lvl = LEVELS.find(l => l.id === levelId)
|
||||
if (!lvl) return
|
||||
this.level = lvl
|
||||
this.phase = 'intro'
|
||||
this.round = 0
|
||||
this.state = { ...lvl.initialState }
|
||||
this.controls = this.createEmptyControls()
|
||||
this.budgetRemaining = lvl.conditions.budget
|
||||
this.score = computeScore(this.state, lvl.scoreWeights)
|
||||
this.history = []
|
||||
this.currentEvent = null
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/** Intro bestaetigt → Spielphase */
|
||||
startPlaying(): void {
|
||||
this.phase = 'playing'
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/** Leere Controls erstellen (nur erlaubte Massnahmen) */
|
||||
private createEmptyControls(): Controls {
|
||||
return {
|
||||
straightening: 0,
|
||||
levees: 0,
|
||||
dredging: 0,
|
||||
floodplainRelease: 0,
|
||||
renaturation: 0,
|
||||
irrigation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Massnahme aendern (Slider) */
|
||||
setControl(key: keyof Controls, value: number): void {
|
||||
if (!this.level.allowedControls.includes(key)) return
|
||||
|
||||
// Limit pruefen
|
||||
const limit = this.level.controlLimits?.[key] ?? 100
|
||||
value = Math.max(0, Math.min(limit, value))
|
||||
|
||||
// Budget pruefen: Differenz berechnen
|
||||
const oldControls = { ...this.controls }
|
||||
const testControls = { ...this.controls, [key]: value }
|
||||
const oldCost = computeTotalCost(oldControls)
|
||||
const newCost = computeTotalCost(testControls)
|
||||
const costDiff = newCost - oldCost
|
||||
|
||||
if (costDiff > this.budgetRemaining + computeTotalCost(this.controls)) {
|
||||
// Nicht genug Budget — Maximum berechnen
|
||||
return
|
||||
}
|
||||
|
||||
this.controls[key] = value
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/** Runde ausfuehren */
|
||||
executeRound(): void {
|
||||
if (this.phase !== 'playing') return
|
||||
if (this.round >= this.level.rounds) return
|
||||
|
||||
this.round++
|
||||
const cost = computeTotalCost(this.controls)
|
||||
|
||||
// Budget abziehen
|
||||
this.budgetRemaining = Math.max(0, this.budgetRemaining - cost)
|
||||
|
||||
// Event fuer diese Runde?
|
||||
const event = this.level.events?.find(e => e.round === this.round) ?? null
|
||||
this.currentEvent = event
|
||||
|
||||
// Simulation ausfuehren
|
||||
this.state = simulateRound(this.state, this.controls, this.level.conditions, event ?? undefined)
|
||||
this.score = computeScore(this.state, this.level.scoreWeights)
|
||||
|
||||
// History speichern
|
||||
this.history.push({
|
||||
round: this.round,
|
||||
state: { ...this.state },
|
||||
controls: { ...this.controls },
|
||||
score: { ...this.score },
|
||||
cost,
|
||||
event: event ?? undefined,
|
||||
})
|
||||
|
||||
// Win/Lose pruefen
|
||||
const result = checkWinLose(this.state, this.score, this.level)
|
||||
if (result === 'lose') {
|
||||
this.phase = 'lost'
|
||||
this.notify()
|
||||
return
|
||||
}
|
||||
|
||||
// Event anzeigen?
|
||||
if (event) {
|
||||
this.phase = 'event'
|
||||
this.notify()
|
||||
return
|
||||
}
|
||||
|
||||
// Letzte Runde?
|
||||
if (this.round >= this.level.rounds) {
|
||||
this.phase = checkFinalWin(this.score, this.level) ? 'won' : 'lost'
|
||||
this.notify()
|
||||
return
|
||||
}
|
||||
|
||||
this.phase = 'round-result'
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/** Event bestaetigen → weiter spielen */
|
||||
acknowledgeEvent(): void {
|
||||
if (this.round >= this.level.rounds) {
|
||||
this.phase = checkFinalWin(this.score, this.level) ? 'won' : 'lost'
|
||||
} else {
|
||||
this.phase = 'playing'
|
||||
}
|
||||
this.currentEvent = null
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/** Rundenresultat bestaetigen → naechste Runde */
|
||||
continueAfterResult(): void {
|
||||
this.phase = 'playing'
|
||||
this.notify()
|
||||
}
|
||||
|
||||
/** Alles zuruecksetzen */
|
||||
reset(): void {
|
||||
this.phase = 'level-select'
|
||||
this.round = 0
|
||||
this.history = []
|
||||
this.currentEvent = null
|
||||
}
|
||||
|
||||
/** Serialisieren fuer Save */
|
||||
serialize(): string {
|
||||
return JSON.stringify({
|
||||
v: 1,
|
||||
levelId: this.level?.id,
|
||||
phase: this.phase,
|
||||
round: this.round,
|
||||
state: this.state,
|
||||
controls: this.controls,
|
||||
budgetRemaining: this.budgetRemaining,
|
||||
history: this.history,
|
||||
})
|
||||
}
|
||||
|
||||
/** Deserialisieren */
|
||||
deserialize(json: string): boolean {
|
||||
try {
|
||||
const d = JSON.parse(json)
|
||||
if (d.v !== 1) return false
|
||||
const lvl = LEVELS.find(l => l.id === d.levelId)
|
||||
if (!lvl) return false
|
||||
this.level = lvl
|
||||
this.phase = d.phase
|
||||
this.round = d.round
|
||||
this.state = d.state
|
||||
this.controls = d.controls
|
||||
this.budgetRemaining = d.budgetRemaining
|
||||
this.history = d.history || []
|
||||
this.score = computeScore(this.state, lvl.scoreWeights)
|
||||
this.notify()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* SIM-12: Flussmanagement — Simulationslogik
|
||||
*
|
||||
* Nichtlineares Simulationsmodell fuer Flussmanagement.
|
||||
* 6 Steuermassnahmen → 8 Zustandsparameter → 4 Zielbereiche.
|
||||
*
|
||||
* Alle Werte liegen im Bereich 0–100.
|
||||
* Abnehmender Grenznutzen, ueberproportionale Nebenwirkungen bei Extremen.
|
||||
*/
|
||||
|
||||
// === Typen ===
|
||||
|
||||
export interface Controls {
|
||||
straightening: number // Flussbegradigung (0–100)
|
||||
levees: number // Daemme/Deiche (0–100)
|
||||
dredging: number // Ausbaggern (0–100)
|
||||
floodplainRelease: number // Auen freigeben (0–100)
|
||||
renaturation: number // Renaturierung (0–100)
|
||||
irrigation: number // Bewaesserung (0–100)
|
||||
}
|
||||
|
||||
export interface State {
|
||||
floodLocal: number // Lokales Hochwasserrisiko
|
||||
floodDownstream: number // Hochwasser flussabwaerts
|
||||
erosion: number // Erosionsrisiko
|
||||
soilFertility: number // Bodenfruchtbarkeit
|
||||
biodiversity: number // Biodiversitaet
|
||||
groundwater: number // Grundwasserspiegel
|
||||
usableLand: number // Nutzbare Flaeche
|
||||
economy: number // Wirtschaftsleistung
|
||||
}
|
||||
|
||||
export interface Conditions {
|
||||
rainfall: number // Niederschlag
|
||||
extremeWeather: number // Extremwetter-Wahrscheinlichkeit
|
||||
slope: number // Gefaelle
|
||||
populationPressure: number // Bevoelkerungsdruck
|
||||
budget: number // Budget (Punkte, nicht 0–100)
|
||||
}
|
||||
|
||||
export interface ScoreBreakdown {
|
||||
safety: number
|
||||
ecology: number
|
||||
agriculture: number
|
||||
economy: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface LevelEvent {
|
||||
round: number
|
||||
type: 'flood_event' | 'drought' | 'economic_boost'
|
||||
intensity: number // 0–100
|
||||
}
|
||||
|
||||
export interface LevelDefinition {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
durationTargetMinutes: number
|
||||
rounds: number
|
||||
initialState: State
|
||||
conditions: Conditions
|
||||
allowedControls: (keyof Controls)[]
|
||||
controlLimits?: Partial<Controls>
|
||||
scoreWeights: { safety: number; ecology: number; agriculture: number; economy: number }
|
||||
events?: LevelEvent[]
|
||||
winConditions: { minScore?: number; targetScores?: Partial<ScoreBreakdown> }
|
||||
loseConditions?: { maxFloodLocal?: number; maxFloodDownstream?: number; minGroundwater?: number }
|
||||
}
|
||||
|
||||
// === Hilfsfunktionen ===
|
||||
|
||||
/** Wert auf 0–100 begrenzen */
|
||||
function clamp(v: number): number {
|
||||
return Math.max(0, Math.min(100, v))
|
||||
}
|
||||
|
||||
/** Abnehmender Grenznutzen: hohe Intensitaet bringt weniger */
|
||||
function diminishing(intensity: number): number {
|
||||
// f(x) = 1 - (1 - x/100)^2 → schneller Anstieg am Anfang, flacher am Ende
|
||||
const x = intensity / 100
|
||||
return (1 - Math.pow(1 - x, 2)) * 100
|
||||
}
|
||||
|
||||
/** Ueberproportionale Nebenwirkung bei hoher Intensitaet */
|
||||
function sideEffect(intensity: number): number {
|
||||
// f(x) = x^1.8 / 100^0.8 → bei 50: ~35, bei 100: 100
|
||||
return Math.pow(intensity, 1.8) / Math.pow(100, 0.8)
|
||||
}
|
||||
|
||||
/** Moderater Effekt (linear mit leichtem Bogen) */
|
||||
function moderate(intensity: number): number {
|
||||
const x = intensity / 100
|
||||
return x * 0.7 + x * x * 0.3
|
||||
}
|
||||
|
||||
// === Simulation ===
|
||||
|
||||
/**
|
||||
* Berechnet den neuen Zustand nach einer Runde.
|
||||
* Kern der nichtlinearen Simulation.
|
||||
*/
|
||||
export function simulateRound(
|
||||
state: State,
|
||||
controls: Controls,
|
||||
conditions: Conditions,
|
||||
event?: LevelEvent
|
||||
): State {
|
||||
const s = { ...state }
|
||||
|
||||
// --- Basis-Effekte der Rahmenbedingungen ---
|
||||
const rainFactor = conditions.rainfall / 60 // 1.0 bei normalem Regen
|
||||
const slopeFactor = conditions.slope / 50 // 1.0 bei normalem Gefaelle
|
||||
const popFactor = conditions.populationPressure / 50
|
||||
|
||||
// --- FLUSSBEGRADIGUNG ---
|
||||
// + Mehr nutzbare Flaeche, + Wirtschaft
|
||||
// - Mehr Hochwasser flussabwaerts, - Biodiversitaet, - Grundwasser
|
||||
if (controls.straightening > 0) {
|
||||
const eff = diminishing(controls.straightening)
|
||||
const side = sideEffect(controls.straightening)
|
||||
s.usableLand = clamp(s.usableLand + eff * 0.15)
|
||||
s.economy = clamp(s.economy + eff * 0.08)
|
||||
s.floodLocal = clamp(s.floodLocal - eff * 0.08)
|
||||
s.floodDownstream = clamp(s.floodDownstream + side * 0.25 * rainFactor)
|
||||
s.biodiversity = clamp(s.biodiversity - side * 0.18)
|
||||
s.groundwater = clamp(s.groundwater - moderate(controls.straightening) * 12)
|
||||
s.erosion = clamp(s.erosion + side * 0.12 * slopeFactor)
|
||||
}
|
||||
|
||||
// --- DAEMME/DEICHE ---
|
||||
// + Lokaler Hochwasserschutz
|
||||
// - Flussabwaerts schlimmer, - Grundwasser (Fluss vom Umland getrennt)
|
||||
if (controls.levees > 0) {
|
||||
const eff = diminishing(controls.levees)
|
||||
const side = sideEffect(controls.levees)
|
||||
s.floodLocal = clamp(s.floodLocal - eff * 0.3)
|
||||
s.floodDownstream = clamp(s.floodDownstream + side * 0.15)
|
||||
s.groundwater = clamp(s.groundwater - moderate(controls.levees) * 8)
|
||||
s.soilFertility = clamp(s.soilFertility - side * 0.06)
|
||||
s.biodiversity = clamp(s.biodiversity - side * 0.05)
|
||||
}
|
||||
|
||||
// --- AUSBAGGERN ---
|
||||
// + Tieferer Fluss = weniger lokales Hochwasser, + Schifffahrt/Wirtschaft
|
||||
// - Erosion, - Biodiversitaet, temporaerer Effekt
|
||||
if (controls.dredging > 0) {
|
||||
const eff = diminishing(controls.dredging)
|
||||
const side = sideEffect(controls.dredging)
|
||||
s.floodLocal = clamp(s.floodLocal - eff * 0.15)
|
||||
s.economy = clamp(s.economy + eff * 0.1)
|
||||
s.erosion = clamp(s.erosion + side * 0.3 * slopeFactor)
|
||||
s.biodiversity = clamp(s.biodiversity - side * 0.15)
|
||||
s.groundwater = clamp(s.groundwater - moderate(controls.dredging) * 6)
|
||||
}
|
||||
|
||||
// --- AUEN FREIGEBEN ---
|
||||
// + Reduziert Hochwasser (Retentionsflaeche), + Grundwasser, + Biodiversitaet
|
||||
// - Weniger nutzbare Flaeche, - Wirtschaft
|
||||
if (controls.floodplainRelease > 0) {
|
||||
const eff = diminishing(controls.floodplainRelease)
|
||||
const side = sideEffect(controls.floodplainRelease)
|
||||
s.floodLocal = clamp(s.floodLocal - eff * 0.2)
|
||||
s.floodDownstream = clamp(s.floodDownstream - eff * 0.15)
|
||||
s.groundwater = clamp(s.groundwater + eff * 0.15)
|
||||
s.biodiversity = clamp(s.biodiversity + eff * 0.12)
|
||||
s.soilFertility = clamp(s.soilFertility + eff * 0.05)
|
||||
s.usableLand = clamp(s.usableLand - side * 0.2)
|
||||
s.economy = clamp(s.economy - side * 0.08)
|
||||
}
|
||||
|
||||
// --- RENATURIERUNG ---
|
||||
// + Biodiversitaet, + Grundwasser, + Bodenfruchtbarkeit, + Erosionsschutz
|
||||
// - Nutzbare Flaeche, - Wirtschaft, hohe Kosten
|
||||
if (controls.renaturation > 0) {
|
||||
const eff = diminishing(controls.renaturation)
|
||||
const side = sideEffect(controls.renaturation)
|
||||
s.biodiversity = clamp(s.biodiversity + eff * 0.25)
|
||||
s.groundwater = clamp(s.groundwater + eff * 0.12)
|
||||
s.soilFertility = clamp(s.soilFertility + eff * 0.1)
|
||||
s.erosion = clamp(s.erosion - eff * 0.15)
|
||||
s.floodLocal = clamp(s.floodLocal - eff * 0.08)
|
||||
s.floodDownstream = clamp(s.floodDownstream - eff * 0.08)
|
||||
s.usableLand = clamp(s.usableLand - side * 0.15)
|
||||
s.economy = clamp(s.economy - side * 0.1)
|
||||
}
|
||||
|
||||
// --- BEWAESSERUNG ---
|
||||
// + Bodenfruchtbarkeit, + Wirtschaft (Landwirtschaftsertrag)
|
||||
// - Grundwasser (Entnahme), - bei Extreme: Versalzung
|
||||
if (controls.irrigation > 0) {
|
||||
const eff = diminishing(controls.irrigation)
|
||||
const side = sideEffect(controls.irrigation)
|
||||
s.soilFertility = clamp(s.soilFertility + eff * 0.18)
|
||||
s.economy = clamp(s.economy + eff * 0.08)
|
||||
s.groundwater = clamp(s.groundwater - side * 0.2)
|
||||
// Versalzung bei extremer Bewaesserung in trockenem Klima
|
||||
if (controls.irrigation > 70 && conditions.rainfall < 40) {
|
||||
s.soilFertility = clamp(s.soilFertility - side * 0.12)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Natuerliche Dynamik ---
|
||||
// Regen hebt Grundwasser, Erosion verschlechtert Bodenqualitaet
|
||||
s.groundwater = clamp(s.groundwater + (rainFactor - 1) * 3)
|
||||
s.soilFertility = clamp(s.soilFertility - s.erosion * 0.02)
|
||||
|
||||
// Bevoelkerungsdruck erhoeht Wirtschaftsbedarf, reduziert Biodiversitaet
|
||||
s.economy = clamp(s.economy + (popFactor - 1) * 2)
|
||||
s.biodiversity = clamp(s.biodiversity - (popFactor - 1) * 1.5)
|
||||
|
||||
// --- Events ---
|
||||
if (event) {
|
||||
if (event.type === 'flood_event') {
|
||||
const floodForce = event.intensity / 100
|
||||
s.floodLocal = clamp(s.floodLocal + 25 * floodForce * rainFactor)
|
||||
s.floodDownstream = clamp(s.floodDownstream + 20 * floodForce)
|
||||
s.erosion = clamp(s.erosion + 15 * floodForce * slopeFactor)
|
||||
s.usableLand = clamp(s.usableLand - 10 * floodForce)
|
||||
s.economy = clamp(s.economy - 8 * floodForce)
|
||||
}
|
||||
if (event.type === 'drought') {
|
||||
const droughtForce = event.intensity / 100
|
||||
s.groundwater = clamp(s.groundwater - 20 * droughtForce)
|
||||
s.soilFertility = clamp(s.soilFertility - 12 * droughtForce)
|
||||
s.biodiversity = clamp(s.biodiversity - 8 * droughtForce)
|
||||
s.floodLocal = clamp(s.floodLocal - 10 * droughtForce) // weniger Hochwasser
|
||||
}
|
||||
if (event.type === 'economic_boost') {
|
||||
const boostForce = event.intensity / 100
|
||||
s.economy = clamp(s.economy + 15 * boostForce)
|
||||
s.populationPressure = clamp((conditions.populationPressure || 50) + 10 * boostForce)
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* Kosten einer Massnahme berechnen (abhaengig von Intensitaet).
|
||||
* Hoehere Intensitaet = ueberproportional teurer.
|
||||
*/
|
||||
export function computeCost(control: keyof Controls, intensity: number): number {
|
||||
const baseCosts: Record<keyof Controls, number> = {
|
||||
straightening: 25,
|
||||
levees: 20,
|
||||
dredging: 15,
|
||||
floodplainRelease: 10,
|
||||
renaturation: 30,
|
||||
irrigation: 18,
|
||||
}
|
||||
const base = baseCosts[control]
|
||||
// Kosten steigen quadratisch: cost(50) = ~50% des Maximums, cost(100) = 100%
|
||||
return Math.round(base * Math.pow(intensity / 100, 1.5))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gesamtkosten aller aktiven Massnahmen berechnen.
|
||||
*/
|
||||
export function computeTotalCost(controls: Controls): number {
|
||||
let total = 0
|
||||
for (const key of Object.keys(controls) as (keyof Controls)[]) {
|
||||
if (controls[key] > 0) {
|
||||
total += computeCost(key, controls[key])
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Score berechnen (0–100 pro Bereich + Gesamtscore).
|
||||
*/
|
||||
export function computeScore(
|
||||
state: State,
|
||||
weights: { safety: number; ecology: number; agriculture: number; economy: number }
|
||||
): ScoreBreakdown {
|
||||
// Sicherheit: niedrige Hochwasser + niedrige Erosion
|
||||
const safety = clamp(100 - (state.floodLocal * 0.4 + state.floodDownstream * 0.35 + state.erosion * 0.25))
|
||||
|
||||
// Oekologie: hohe Biodiversitaet + hoher Grundwasserspiegel
|
||||
const ecology = clamp(state.biodiversity * 0.6 + state.groundwater * 0.4)
|
||||
|
||||
// Landwirtschaft: hohe Bodenfruchtbarkeit + genug Flaeche + genug Wasser
|
||||
const agriculture = clamp(state.soilFertility * 0.5 + state.usableLand * 0.3 + state.groundwater * 0.2)
|
||||
|
||||
// Wirtschaft: direkt
|
||||
const economy = state.economy
|
||||
|
||||
const total = clamp(
|
||||
safety * weights.safety +
|
||||
ecology * weights.ecology +
|
||||
agriculture * weights.agriculture +
|
||||
economy * weights.economy
|
||||
)
|
||||
|
||||
return { safety, ecology, agriculture, economy, total }
|
||||
}
|
||||
|
||||
/**
|
||||
* Win/Lose-Bedingungen pruefen.
|
||||
*/
|
||||
export function checkWinLose(
|
||||
state: State,
|
||||
score: ScoreBreakdown,
|
||||
level: LevelDefinition
|
||||
): 'win' | 'lose' | 'playing' {
|
||||
// Lose-Bedingungen
|
||||
if (level.loseConditions) {
|
||||
if (level.loseConditions.maxFloodLocal !== undefined && state.floodLocal > level.loseConditions.maxFloodLocal) return 'lose'
|
||||
if (level.loseConditions.maxFloodDownstream !== undefined && state.floodDownstream > level.loseConditions.maxFloodDownstream) return 'lose'
|
||||
if (level.loseConditions.minGroundwater !== undefined && state.groundwater < level.loseConditions.minGroundwater) return 'lose'
|
||||
}
|
||||
|
||||
// Win-Bedingungen (nur am Ende relevant, nicht pro Runde)
|
||||
return 'playing'
|
||||
}
|
||||
|
||||
/**
|
||||
* Am Ende des Spiels pruefen ob Win-Bedingungen erfuellt sind.
|
||||
*/
|
||||
export function checkFinalWin(
|
||||
score: ScoreBreakdown,
|
||||
level: LevelDefinition
|
||||
): boolean {
|
||||
const wc = level.winConditions
|
||||
if (wc.minScore !== undefined && score.total < wc.minScore) return false
|
||||
if (wc.targetScores) {
|
||||
for (const [key, target] of Object.entries(wc.targetScores)) {
|
||||
if (score[key as keyof ScoreBreakdown] < (target as number)) return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// === Level-Definitionen ===
|
||||
|
||||
export const LEVELS: LevelDefinition[] = [
|
||||
{
|
||||
id: 'L1',
|
||||
title: 'Fluss und Siedlung',
|
||||
description: 'Eine kleine Siedlung liegt an einem Fluss und ist regelmäßig von Hochwasser betroffen. Finde einen Weg, die Bewohner zu schützen!',
|
||||
durationTargetMinutes: 10,
|
||||
rounds: 5,
|
||||
initialState: {
|
||||
floodLocal: 60, floodDownstream: 40, erosion: 30,
|
||||
soilFertility: 60, biodiversity: 70, groundwater: 55,
|
||||
usableLand: 40, economy: 40
|
||||
},
|
||||
conditions: { rainfall: 60, extremeWeather: 30, slope: 40, populationPressure: 30, budget: 120 },
|
||||
allowedControls: ['levees', 'floodplainRelease'],
|
||||
controlLimits: { levees: 60, floodplainRelease: 60 },
|
||||
scoreWeights: { safety: 0.5, ecology: 0.2, agriculture: 0.15, economy: 0.15 },
|
||||
winConditions: { minScore: 60 },
|
||||
loseConditions: { maxFloodLocal: 85 },
|
||||
},
|
||||
{
|
||||
id: 'L2',
|
||||
title: 'Fruchtbares Tal',
|
||||
description: 'Ein Tal lebt von fruchtbaren Böden durch regelmäßige Überschwemmungen. Wie nutzt du das Wasser, ohne alles zu riskieren?',
|
||||
durationTargetMinutes: 20,
|
||||
rounds: 7,
|
||||
initialState: {
|
||||
floodLocal: 55, floodDownstream: 35, erosion: 35,
|
||||
soilFertility: 75, biodiversity: 65, groundwater: 60,
|
||||
usableLand: 45, economy: 50
|
||||
},
|
||||
conditions: { rainfall: 65, extremeWeather: 40, slope: 35, populationPressure: 40, budget: 150 },
|
||||
allowedControls: ['levees', 'floodplainRelease', 'irrigation'],
|
||||
scoreWeights: { safety: 0.25, ecology: 0.2, agriculture: 0.4, economy: 0.15 },
|
||||
winConditions: { targetScores: { agriculture: 65, safety: 50 } },
|
||||
},
|
||||
{
|
||||
id: 'L3',
|
||||
title: 'Der gezähmte Fluss',
|
||||
description: 'Der Fluss soll kontrolliert werden, um Städte und Industrie zu schützen. Doch die Natur schlägt zurück!',
|
||||
durationTargetMinutes: 25,
|
||||
rounds: 8,
|
||||
initialState: {
|
||||
floodLocal: 50, floodDownstream: 45, erosion: 40,
|
||||
soilFertility: 55, biodiversity: 50, groundwater: 50,
|
||||
usableLand: 55, economy: 60
|
||||
},
|
||||
conditions: { rainfall: 60, extremeWeather: 45, slope: 50, populationPressure: 70, budget: 180 },
|
||||
allowedControls: ['levees', 'straightening', 'dredging'],
|
||||
scoreWeights: { safety: 0.4, ecology: 0.1, agriculture: 0.2, economy: 0.3 },
|
||||
events: [{ round: 4, type: 'flood_event', intensity: 70 }],
|
||||
winConditions: { minScore: 65 },
|
||||
},
|
||||
{
|
||||
id: 'L4',
|
||||
title: 'Fluss im Gleichgewicht',
|
||||
description: 'Finde eine Balance zwischen Sicherheit, Natur und Nutzung. Alle Ziele zählen gleich!',
|
||||
durationTargetMinutes: 30,
|
||||
rounds: 10,
|
||||
initialState: {
|
||||
floodLocal: 55, floodDownstream: 50, erosion: 45,
|
||||
soilFertility: 60, biodiversity: 60, groundwater: 50,
|
||||
usableLand: 50, economy: 55
|
||||
},
|
||||
conditions: { rainfall: 60, extremeWeather: 50, slope: 45, populationPressure: 60, budget: 180 },
|
||||
allowedControls: ['levees', 'straightening', 'floodplainRelease', 'renaturation', 'irrigation'],
|
||||
scoreWeights: { safety: 0.25, ecology: 0.25, agriculture: 0.25, economy: 0.25 },
|
||||
winConditions: { minScore: 70 },
|
||||
},
|
||||
{
|
||||
id: 'L5',
|
||||
title: 'Nildelta',
|
||||
description: 'Die jährlichen Überschwemmungen bringen fruchtbare Böden — aber auch Risiken. Schützt du die Ernte oder die Natur?',
|
||||
durationTargetMinutes: 35,
|
||||
rounds: 10,
|
||||
initialState: {
|
||||
floodLocal: 65, floodDownstream: 40, erosion: 30,
|
||||
soilFertility: 85, biodiversity: 70, groundwater: 65,
|
||||
usableLand: 50, economy: 60
|
||||
},
|
||||
conditions: { rainfall: 40, extremeWeather: 20, slope: 20, populationPressure: 70, budget: 160 },
|
||||
allowedControls: ['levees', 'irrigation', 'floodplainRelease'],
|
||||
scoreWeights: { safety: 0.2, ecology: 0.2, agriculture: 0.45, economy: 0.15 },
|
||||
winConditions: { targetScores: { agriculture: 75 } },
|
||||
loseConditions: { minGroundwater: 25 },
|
||||
},
|
||||
]
|
||||
|
||||
/** Massnahmen-Metadaten fuer UI */
|
||||
export const CONTROL_META: Record<keyof Controls, { name: string; emoji: string; description: string }> = {
|
||||
straightening: { name: 'Flussbegradigung', emoji: '📏', description: 'Den Fluss gerade ziehen — mehr Fläche, aber die Natur leidet.' },
|
||||
levees: { name: 'Deiche bauen', emoji: '🧱', description: 'Schutz vor Hochwasser — aber das Wasser muss irgendwohin.' },
|
||||
dredging: { name: 'Ausbaggern', emoji: '⛏️', description: 'Den Fluss tiefer machen — gut für Schiffe, schlecht für Ökosysteme.' },
|
||||
floodplainRelease: { name: 'Auen freigeben', emoji: '🌊', description: 'Dem Fluss Raum geben — weniger Hochwasser, aber weniger Fläche.' },
|
||||
renaturation: { name: 'Renaturierung', emoji: '🌿', description: 'Die Natur zurückbringen — gut für alles außer die Wirtschaft.' },
|
||||
irrigation: { name: 'Bewässerung', emoji: '💧', description: 'Felder bewässern — mehr Ertrag, aber das Grundwasser sinkt.' },
|
||||
}
|
||||
|
||||
/** Zustandsparameter-Metadaten fuer UI */
|
||||
export const STATE_META: Record<keyof State, { name: string; emoji: string; goodDirection: 'low' | 'high' }> = {
|
||||
floodLocal: { name: 'Hochwasser (lokal)', emoji: '🌊', goodDirection: 'low' },
|
||||
floodDownstream: { name: 'Hochwasser (flussab.)', emoji: '🌊', goodDirection: 'low' },
|
||||
erosion: { name: 'Erosion', emoji: '🏜️', goodDirection: 'low' },
|
||||
soilFertility: { name: 'Bodenfruchtbarkeit', emoji: '🌱', goodDirection: 'high' },
|
||||
biodiversity: { name: 'Biodiversität', emoji: '🦎', goodDirection: 'high' },
|
||||
groundwater: { name: 'Grundwasser', emoji: '💧', goodDirection: 'high' },
|
||||
usableLand: { name: 'Nutzbare Fläche', emoji: '🏘️', goodDirection: 'high' },
|
||||
economy: { name: 'Wirtschaft', emoji: '💰', goodDirection: 'high' },
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* SIM-12: Flussmanagement — Canvas Renderer
|
||||
*
|
||||
* Zeichnet eine Landschaft mit Fluss, Vegetation, Siedlung, Auen.
|
||||
* Alle visuellen Elemente reagieren auf den Simulationszustand.
|
||||
*/
|
||||
|
||||
import type { State, Controls } from './logic'
|
||||
|
||||
export class FlussRenderer {
|
||||
private ctx: CanvasRenderingContext2D
|
||||
private w: number
|
||||
private h: number
|
||||
private time = 0
|
||||
|
||||
constructor(private canvas: HTMLCanvasElement) {
|
||||
this.ctx = canvas.getContext('2d')!
|
||||
this.w = canvas.width
|
||||
this.h = canvas.height
|
||||
}
|
||||
|
||||
resize(): void {
|
||||
const rect = this.canvas.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
this.canvas.width = rect.width * dpr
|
||||
this.canvas.height = rect.height * dpr
|
||||
this.ctx.scale(dpr, dpr)
|
||||
this.w = rect.width
|
||||
this.h = rect.height
|
||||
}
|
||||
|
||||
render(state: State, controls: Controls): void {
|
||||
this.time += 0.02
|
||||
const ctx = this.ctx
|
||||
const w = this.w
|
||||
const h = this.h
|
||||
|
||||
// Hintergrund — Himmel
|
||||
const skyGrad = ctx.createLinearGradient(0, 0, 0, h * 0.4)
|
||||
skyGrad.addColorStop(0, '#87CEEB')
|
||||
skyGrad.addColorStop(1, '#B0E0E6')
|
||||
ctx.fillStyle = skyGrad
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
|
||||
// Grund — Erde/Gras
|
||||
const groundY = h * 0.35
|
||||
const groundGrad = ctx.createLinearGradient(0, groundY, 0, h)
|
||||
const greenIntensity = Math.round(80 + state.biodiversity * 0.8)
|
||||
groundGrad.addColorStop(0, `rgb(${120 - state.soilFertility * 0.3}, ${greenIntensity}, ${60 - state.erosion * 0.3})`)
|
||||
groundGrad.addColorStop(1, `rgb(${140 - state.soilFertility * 0.2}, ${100 + state.biodiversity * 0.4}, ${70})`)
|
||||
ctx.fillStyle = groundGrad
|
||||
ctx.fillRect(0, groundY, w, h - groundY)
|
||||
|
||||
// Berge im Hintergrund
|
||||
this.drawMountains(ctx, w, h)
|
||||
|
||||
// Auen-Bereich (wenn freigegeben)
|
||||
if (controls.floodplainRelease > 10) {
|
||||
this.drawFloodplains(ctx, w, h, controls.floodplainRelease, state)
|
||||
}
|
||||
|
||||
// Fluss zeichnen
|
||||
this.drawRiver(ctx, w, h, state, controls)
|
||||
|
||||
// Deiche
|
||||
if (controls.levees > 10) {
|
||||
this.drawLevees(ctx, w, h, controls.levees)
|
||||
}
|
||||
|
||||
// Hochwasser-Overlay
|
||||
if (state.floodLocal > 50) {
|
||||
this.drawFlood(ctx, w, h, state.floodLocal)
|
||||
}
|
||||
|
||||
// Vegetation / Baeume (Biodiversitaet)
|
||||
this.drawVegetation(ctx, w, h, state.biodiversity, state.soilFertility)
|
||||
|
||||
// Siedlung
|
||||
this.drawSettlement(ctx, w, h, state.economy, state.usableLand)
|
||||
|
||||
// Felder (Landwirtschaft)
|
||||
this.drawFarms(ctx, w, h, state.soilFertility, controls.irrigation)
|
||||
|
||||
// Erosionsspuren
|
||||
if (state.erosion > 40) {
|
||||
this.drawErosion(ctx, w, h, state.erosion)
|
||||
}
|
||||
|
||||
// Wolken
|
||||
this.drawClouds(ctx, w, h)
|
||||
}
|
||||
|
||||
private drawMountains(ctx: CanvasRenderingContext2D, w: number, h: number): void {
|
||||
const baseY = h * 0.35
|
||||
ctx.fillStyle = '#8BA89A'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, baseY)
|
||||
ctx.lineTo(w * 0.1, baseY - h * 0.15)
|
||||
ctx.lineTo(w * 0.2, baseY - h * 0.08)
|
||||
ctx.lineTo(w * 0.35, baseY - h * 0.2)
|
||||
ctx.lineTo(w * 0.5, baseY - h * 0.05)
|
||||
ctx.lineTo(w * 0.65, baseY - h * 0.18)
|
||||
ctx.lineTo(w * 0.8, baseY - h * 0.1)
|
||||
ctx.lineTo(w * 0.9, baseY - h * 0.14)
|
||||
ctx.lineTo(w, baseY)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
// Schneedecke
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.5)'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(w * 0.33, baseY - h * 0.2)
|
||||
ctx.lineTo(w * 0.35, baseY - h * 0.2)
|
||||
ctx.lineTo(w * 0.37, baseY - h * 0.17)
|
||||
ctx.lineTo(w * 0.31, baseY - h * 0.17)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
private drawRiver(ctx: CanvasRenderingContext2D, w: number, h: number, state: State, controls: Controls): void {
|
||||
const riverWidth = 20 + (100 - controls.straightening) * 0.15
|
||||
const meander = (100 - controls.straightening) * 0.4 // Maeander-Amplitude
|
||||
const riverY = h * 0.55
|
||||
|
||||
// Flussverlauf (von links nach rechts)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, riverY)
|
||||
|
||||
const steps = 50
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const x = (i / steps) * w
|
||||
const progress = i / steps
|
||||
const wave = Math.sin(progress * Math.PI * 3 + this.time) * meander
|
||||
const y = riverY + wave
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
// Untere Kante (Flussbreite)
|
||||
for (let i = steps; i >= 0; i--) {
|
||||
const x = (i / steps) * w
|
||||
const progress = i / steps
|
||||
const wave = Math.sin(progress * Math.PI * 3 + this.time) * meander
|
||||
const y = riverY + wave + riverWidth
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.closePath()
|
||||
|
||||
// Flussfarbe: klar (biodiversitaet hoch) bis trueb (erosion hoch)
|
||||
const clarity = Math.max(0, Math.min(1, (state.biodiversity - state.erosion * 0.5) / 80))
|
||||
const r = Math.round(30 + (1 - clarity) * 60)
|
||||
const g = Math.round(100 + clarity * 50)
|
||||
const b = Math.round(160 + clarity * 40)
|
||||
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, 0.85)`
|
||||
ctx.fill()
|
||||
|
||||
// Wasseroberflaeche Glanz
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'
|
||||
ctx.lineWidth = 1
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const sx = Math.random() * w
|
||||
const sy = riverY + Math.sin(sx / w * Math.PI * 3 + this.time) * meander + riverWidth * 0.3
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(sx, sy)
|
||||
ctx.lineTo(sx + 15 + Math.random() * 20, sy - 1)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
private drawLevees(ctx: CanvasRenderingContext2D, w: number, h: number, intensity: number): void {
|
||||
const leveeH = 3 + intensity * 0.08
|
||||
const riverY = h * 0.55
|
||||
ctx.fillStyle = '#8B7355'
|
||||
|
||||
// Oberer Deich
|
||||
ctx.fillRect(0, riverY - leveeH - 5, w, leveeH)
|
||||
// Unterer Deich
|
||||
ctx.fillRect(0, riverY + 30, w, leveeH)
|
||||
}
|
||||
|
||||
private drawFloodplains(ctx: CanvasRenderingContext2D, w: number, h: number, intensity: number, state: State): void {
|
||||
const riverY = h * 0.55
|
||||
const extent = intensity * 0.3
|
||||
const alpha = 0.15 + intensity * 0.002
|
||||
|
||||
ctx.fillStyle = `rgba(100, 180, 140, ${alpha})`
|
||||
// Aue oben
|
||||
ctx.fillRect(0, riverY - extent - 20, w, extent)
|
||||
// Aue unten
|
||||
ctx.fillRect(0, riverY + 35, w, extent)
|
||||
|
||||
// Schilf in den Auen
|
||||
if (intensity > 30) {
|
||||
ctx.fillStyle = '#5A8A3E'
|
||||
for (let i = 0; i < intensity * 0.3; i++) {
|
||||
const x = (i * 47 + 20) % w
|
||||
const y = riverY - 25 - Math.random() * extent * 0.5
|
||||
this.drawReeds(ctx, x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawReeds(ctx: CanvasRenderingContext2D, x: number, y: number): void {
|
||||
ctx.save()
|
||||
ctx.strokeStyle = '#4A7A2E'
|
||||
ctx.lineWidth = 1.5
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const lean = Math.sin(this.time * 2 + x * 0.1) * 3
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + i * 3, y)
|
||||
ctx.quadraticCurveTo(x + i * 3 + lean, y - 8, x + i * 3 + lean * 1.5, y - 15)
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
private drawFlood(ctx: CanvasRenderingContext2D, w: number, h: number, intensity: number): void {
|
||||
const alpha = Math.min(0.4, (intensity - 50) / 100)
|
||||
const extent = (intensity - 50) * 0.8
|
||||
const riverY = h * 0.55
|
||||
|
||||
ctx.fillStyle = `rgba(70, 130, 180, ${alpha})`
|
||||
// Ueberflutung breitet sich vom Fluss aus
|
||||
ctx.fillRect(0, riverY - extent, w, extent * 2 + 30)
|
||||
|
||||
// Wellenlinien
|
||||
ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.5})`
|
||||
ctx.lineWidth = 1
|
||||
for (let row = 0; row < 3; row++) {
|
||||
ctx.beginPath()
|
||||
const baseY = riverY - extent + row * extent * 0.6
|
||||
for (let x = 0; x < w; x += 5) {
|
||||
const y = baseY + Math.sin(x * 0.05 + this.time * 3 + row) * 3
|
||||
x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
private drawVegetation(ctx: CanvasRenderingContext2D, w: number, h: number, biodiversity: number, fertility: number): void {
|
||||
const treeCount = Math.floor(biodiversity * 0.15)
|
||||
const groundY = h * 0.35
|
||||
|
||||
for (let i = 0; i < treeCount; i++) {
|
||||
const seed = i * 137.5 // goldener Winkel fuer Verteilung
|
||||
const x = (seed % w)
|
||||
const yBase = groundY + 10 + (seed * 7 % (h * 0.15))
|
||||
|
||||
// Nur Baeume die nicht im Flussbereich sind
|
||||
if (yBase > h * 0.5 && yBase < h * 0.65) continue
|
||||
|
||||
const treeH = 12 + (fertility * 0.1) + (i % 5) * 2
|
||||
const green = Math.round(60 + biodiversity * 0.8 + (i % 3) * 20)
|
||||
|
||||
// Stamm
|
||||
ctx.fillStyle = '#6B4423'
|
||||
ctx.fillRect(x - 1.5, yBase - treeH * 0.4, 3, treeH * 0.4)
|
||||
|
||||
// Krone
|
||||
ctx.fillStyle = `rgb(${40 + (i % 20)}, ${green}, ${30 + (i % 15)})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, yBase - treeH * 0.5, treeH * 0.35, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
private drawSettlement(ctx: CanvasRenderingContext2D, w: number, h: number, economy: number, usableLand: number): void {
|
||||
const houseCount = Math.floor(3 + economy * 0.08)
|
||||
const startX = w * 0.6
|
||||
const baseY = h * 0.42
|
||||
|
||||
for (let i = 0; i < houseCount; i++) {
|
||||
const x = startX + (i % 4) * 30 + Math.floor(i / 4) * 15
|
||||
const y = baseY + Math.floor(i / 4) * 20
|
||||
const houseH = 12 + (economy * 0.05)
|
||||
|
||||
if (x > w - 20) continue
|
||||
|
||||
// Haus
|
||||
ctx.fillStyle = i < 3 ? '#D4A574' : '#C4956A'
|
||||
ctx.fillRect(x, y - houseH, 16, houseH)
|
||||
|
||||
// Dach
|
||||
ctx.fillStyle = '#8B4513'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - 3, y - houseH)
|
||||
ctx.lineTo(x + 8, y - houseH - 8)
|
||||
ctx.lineTo(x + 19, y - houseH)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
// Fenster
|
||||
ctx.fillStyle = '#FFF8DC'
|
||||
ctx.fillRect(x + 3, y - houseH + 3, 4, 4)
|
||||
ctx.fillRect(x + 9, y - houseH + 3, 4, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private drawFarms(ctx: CanvasRenderingContext2D, w: number, h: number, fertility: number, irrigation: number): void {
|
||||
const farmArea = h * 0.75
|
||||
const rows = Math.floor(3 + fertility * 0.04)
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const y = farmArea + r * 12
|
||||
const x = w * 0.05 + r * 20
|
||||
|
||||
// Feldstreifen
|
||||
const green = Math.round(100 + fertility * 0.8)
|
||||
const brown = Math.round(180 - fertility * 0.5)
|
||||
ctx.fillStyle = r % 2 === 0
|
||||
? `rgb(${brown}, ${green}, 60)`
|
||||
: `rgb(${brown - 20}, ${Math.min(180, green + 20)}, 40)`
|
||||
ctx.fillRect(x, y, w * 0.25, 8)
|
||||
|
||||
// Bewaesserungskanaele
|
||||
if (irrigation > 20) {
|
||||
ctx.strokeStyle = 'rgba(70, 130, 200, 0.4)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.setLineDash([3, 3])
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, y + 4)
|
||||
ctx.lineTo(x + w * 0.25, y + 4)
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawErosion(ctx: CanvasRenderingContext2D, w: number, h: number, erosion: number): void {
|
||||
const count = Math.floor((erosion - 40) * 0.2)
|
||||
ctx.fillStyle = 'rgba(139, 115, 85, 0.3)'
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x = (i * 89 + 30) % w
|
||||
const y = h * 0.6 + (i * 43 % (h * 0.2))
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(x, y, 8 + erosion * 0.05, 3, 0.3, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
private drawClouds(ctx: CanvasRenderingContext2D, w: number, h: number): void {
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const cx = (i * 250 + this.time * 15) % (w + 100) - 50
|
||||
const cy = 30 + i * 25
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, 20, 0, Math.PI * 2)
|
||||
ctx.arc(cx + 15, cy - 5, 15, 0, Math.PI * 2)
|
||||
ctx.arc(cx + 30, cy, 18, 0, Math.PI * 2)
|
||||
ctx.arc(cx - 12, cy + 2, 14, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/* ========================================
|
||||
GeoGraSim Base Styles
|
||||
======================================== */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap');
|
||||
@import './tokens.css';
|
||||
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html { font-size: 16px; scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height);
|
||||
letter-spacing: var(--letter-spacing);
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* --- Typografie --- */
|
||||
h1, h2, h3, h4 { font-weight: var(--font-weight-black); line-height: 1.2; }
|
||||
h1 { font-size: var(--font-size-xxl); }
|
||||
h2 { font-size: var(--font-size-xl); }
|
||||
h3 { font-size: var(--font-size-lg); }
|
||||
h4 { font-size: var(--font-size-md); }
|
||||
p { margin-bottom: var(--space-md); }
|
||||
a { color: var(--color-fjord); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* --- Container --- */
|
||||
.container {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-lg);
|
||||
}
|
||||
|
||||
/* --- Card --- */
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: var(--space-lg);
|
||||
transition: transform var(--duration-normal) var(--ease-out),
|
||||
box-shadow var(--duration-normal) var(--ease-out);
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* --- Button --- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-sm);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-out);
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--color-fjord);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #3d6b78;
|
||||
transform: translateY(-1px);
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--color-surface-alt);
|
||||
color: var(--color-text);
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-bg-warm);
|
||||
}
|
||||
|
||||
/* --- Badge / Tag --- */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
.tag-k1 { background: var(--color-fjord-light); color: var(--color-fjord); }
|
||||
.tag-k2 { background: var(--color-moss-light); color: var(--color-moss); }
|
||||
.tag-k3 { background: var(--color-sand-light); color: var(--color-sand); }
|
||||
.tag-k4 { background: var(--color-coral-light); color: var(--color-coral); }
|
||||
|
||||
/* --- Section spacing --- */
|
||||
section { padding: var(--space-xxl) 0; }
|
||||
.section-label {
|
||||
display: inline-block;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-bold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-fjord);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/* ========================================
|
||||
GeoGraSim Design Tokens
|
||||
Stil: Scandinavian Minimal
|
||||
======================================== */
|
||||
|
||||
:root {
|
||||
/* --- Farben: Gedeckte Naturpalette --- */
|
||||
--color-bg: #fafaf8;
|
||||
--color-bg-warm: #f5f3ef;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-alt: #f0eeea;
|
||||
|
||||
--color-text: #1a1a1a;
|
||||
--color-text-secondary: #5a5a5a;
|
||||
--color-text-muted: #8a8a8a;
|
||||
|
||||
/* Akzentfarben — gedeckt, natürlich */
|
||||
--color-fjord: #4a7c8a; /* Petrol/Fjord-Blau */
|
||||
--color-fjord-light: #dae8ec;
|
||||
--color-moss: #5a8a5e; /* Moos-Grün */
|
||||
--color-moss-light: #dceadd;
|
||||
--color-sand: #c4a35a; /* Sandstein */
|
||||
--color-sand-light: #f2eacc;
|
||||
--color-coral: #c07a6b; /* Gedecktes Korall */
|
||||
--color-coral-light: #f2ddd8;
|
||||
--color-slate: #6a6a7a; /* Schiefer */
|
||||
--color-slate-light: #e2e2e8;
|
||||
|
||||
/* Klassen-Farben */
|
||||
--color-k1: #4a7c8a; /* 1. Klasse: Fjord */
|
||||
--color-k2: #5a8a5e; /* 2. Klasse: Moos */
|
||||
--color-k3: #c4a35a; /* 3. Klasse: Sand */
|
||||
--color-k4: #c07a6b; /* 4. Klasse: Korall */
|
||||
|
||||
/* Feedback */
|
||||
--color-success: #5a8a5e;
|
||||
--color-warning: #c4a35a;
|
||||
--color-error: #b55a4a;
|
||||
--color-info: #4a7c8a;
|
||||
|
||||
/* --- Typografie --- */
|
||||
--font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.85rem;
|
||||
--font-size-base: 0.95rem;
|
||||
--font-size-md: 1.1rem;
|
||||
--font-size-lg: 1.4rem;
|
||||
--font-size-xl: 2rem;
|
||||
--font-size-xxl: 3rem;
|
||||
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-bold: 600;
|
||||
--font-weight-black: 800;
|
||||
|
||||
--line-height: 1.65;
|
||||
--letter-spacing: -0.01em;
|
||||
|
||||
/* --- Spacing --- */
|
||||
--space-xs: 0.25rem;
|
||||
--space-sm: 0.5rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 1.5rem;
|
||||
--space-xl: 2.5rem;
|
||||
--space-xxl: 4rem;
|
||||
|
||||
/* --- Radien --- */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 20px;
|
||||
--radius-full: 999px;
|
||||
|
||||
/* --- Schatten --- */
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.06);
|
||||
--shadow-lg: 0 8px 30px rgba(0,0,0,0.08);
|
||||
--shadow-card: 0 2px 8px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.03);
|
||||
|
||||
/* --- Animation --- */
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 300ms;
|
||||
--duration-slow: 600ms;
|
||||
--duration-scenic: 20s; /* Für Deko-Animationen */
|
||||
|
||||
/* --- Layout --- */
|
||||
--max-width: 1140px;
|
||||
--nav-height: 60px;
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Scenic Background — Skandinavische Landschaftsanimation
|
||||
*
|
||||
* Erzeugt eine sanfte, minimalistische Hintergrundszene mit:
|
||||
* - Sanften Hügeln / Fjord-Silhouette
|
||||
* - Segelboot das langsam vorbeifährt
|
||||
* - Wolken die ziehen
|
||||
* - Vögel die fliegen
|
||||
* - Subtile Wellenbewegung
|
||||
*
|
||||
* Alles in gedeckten Naturfarben, leicht und beruhigend.
|
||||
*/
|
||||
|
||||
interface ScenicElement {
|
||||
x: number
|
||||
y: number
|
||||
speed: number
|
||||
size: number
|
||||
opacity: number
|
||||
}
|
||||
|
||||
interface Cloud extends ScenicElement {
|
||||
width: number
|
||||
}
|
||||
|
||||
interface Bird extends ScenicElement {
|
||||
wingPhase: number
|
||||
wingSpeed: number
|
||||
}
|
||||
|
||||
interface Boat extends ScenicElement {
|
||||
bobPhase: number
|
||||
}
|
||||
|
||||
interface Wave {
|
||||
offset: number
|
||||
amplitude: number
|
||||
frequency: number
|
||||
speed: number
|
||||
}
|
||||
|
||||
export class ScenicBackground {
|
||||
private canvas: HTMLCanvasElement
|
||||
private ctx: CanvasRenderingContext2D
|
||||
private width = 0
|
||||
private height = 0
|
||||
private time = 0
|
||||
private animId = 0
|
||||
|
||||
private clouds: Cloud[] = []
|
||||
private birds: Bird[] = []
|
||||
private boat: Boat
|
||||
private waves: Wave[] = []
|
||||
|
||||
// Farben — skandinavisch gedeckt
|
||||
private colors = {
|
||||
sky: '#e8ebe6',
|
||||
skyBottom: '#d4ddd6',
|
||||
water: '#9ab5b8',
|
||||
waterDeep: '#7a9ea2',
|
||||
mountain: '#8a9a8c',
|
||||
mountainFar:'#b0bab2',
|
||||
land: '#a4b09a',
|
||||
cloud: '#ffffff',
|
||||
boat: '#c07a6b',
|
||||
boatSail: '#f0eeea',
|
||||
bird: '#5a5a5a',
|
||||
}
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.canvas = document.createElement('canvas')
|
||||
this.canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;'
|
||||
container.style.position = 'relative'
|
||||
container.prepend(this.canvas)
|
||||
|
||||
const ctx = this.canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Canvas 2D not supported')
|
||||
this.ctx = ctx
|
||||
|
||||
this.boat = { x: -100, y: 0, speed: 0.3, size: 1, opacity: 0.7, bobPhase: 0 }
|
||||
|
||||
this.resize()
|
||||
this.initElements()
|
||||
window.addEventListener('resize', () => this.resize())
|
||||
}
|
||||
|
||||
private resize(): void {
|
||||
const rect = this.canvas.parentElement!.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
this.width = rect.width
|
||||
this.height = rect.height
|
||||
this.canvas.width = this.width * dpr
|
||||
this.canvas.height = this.height * dpr
|
||||
this.ctx.scale(dpr, dpr)
|
||||
}
|
||||
|
||||
private initElements(): void {
|
||||
// Wolken
|
||||
this.clouds = Array.from({ length: 5 }, () => ({
|
||||
x: Math.random() * this.width * 1.5,
|
||||
y: this.height * (0.05 + Math.random() * 0.15),
|
||||
speed: 0.1 + Math.random() * 0.2,
|
||||
size: 0.6 + Math.random() * 0.6,
|
||||
opacity: 0.3 + Math.random() * 0.4,
|
||||
width: 60 + Math.random() * 80,
|
||||
}))
|
||||
|
||||
// Vögel
|
||||
this.birds = Array.from({ length: 3 }, () => ({
|
||||
x: Math.random() * this.width,
|
||||
y: this.height * (0.1 + Math.random() * 0.2),
|
||||
speed: 0.4 + Math.random() * 0.3,
|
||||
size: 4 + Math.random() * 4,
|
||||
opacity: 0.3 + Math.random() * 0.2,
|
||||
wingPhase: Math.random() * Math.PI * 2,
|
||||
wingSpeed: 3 + Math.random() * 2,
|
||||
}))
|
||||
|
||||
// Wellen
|
||||
this.waves = Array.from({ length: 3 }, (_, i) => ({
|
||||
offset: 0,
|
||||
amplitude: 2 + i * 1.5,
|
||||
frequency: 0.008 + i * 0.003,
|
||||
speed: 0.3 + i * 0.15,
|
||||
}))
|
||||
}
|
||||
|
||||
start(): void {
|
||||
const animate = () => {
|
||||
this.time += 0.016
|
||||
this.update()
|
||||
this.draw()
|
||||
this.animId = requestAnimationFrame(animate)
|
||||
}
|
||||
animate()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
cancelAnimationFrame(this.animId)
|
||||
}
|
||||
|
||||
private update(): void {
|
||||
// Wolken bewegen
|
||||
for (const c of this.clouds) {
|
||||
c.x += c.speed
|
||||
if (c.x > this.width + c.width) {
|
||||
c.x = -c.width * 2
|
||||
c.y = this.height * (0.05 + Math.random() * 0.15)
|
||||
}
|
||||
}
|
||||
|
||||
// Vögel bewegen
|
||||
for (const b of this.birds) {
|
||||
b.x += b.speed
|
||||
b.wingPhase += b.wingSpeed * 0.016
|
||||
b.y += Math.sin(this.time * 0.5 + b.wingPhase) * 0.15
|
||||
if (b.x > this.width + 50) {
|
||||
b.x = -50
|
||||
b.y = this.height * (0.1 + Math.random() * 0.2)
|
||||
}
|
||||
}
|
||||
|
||||
// Boot bewegen
|
||||
this.boat.x += this.boat.speed
|
||||
this.boat.bobPhase += 0.02
|
||||
if (this.boat.x > this.width + 100) {
|
||||
this.boat.x = -100
|
||||
}
|
||||
|
||||
// Wellen
|
||||
for (const w of this.waves) {
|
||||
w.offset += w.speed
|
||||
}
|
||||
}
|
||||
|
||||
private draw(): void {
|
||||
const { ctx, width: w, height: h } = this
|
||||
const waterLine = h * 0.55
|
||||
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
// Himmel — sanfter Gradient
|
||||
const skyGrad = ctx.createLinearGradient(0, 0, 0, waterLine)
|
||||
skyGrad.addColorStop(0, this.colors.sky)
|
||||
skyGrad.addColorStop(1, this.colors.skyBottom)
|
||||
ctx.fillStyle = skyGrad
|
||||
ctx.fillRect(0, 0, w, waterLine)
|
||||
|
||||
// Ferne Berge
|
||||
this.drawMountains(ctx, w, waterLine, this.colors.mountainFar, 0.35, 0.08)
|
||||
this.drawMountains(ctx, w, waterLine, this.colors.mountain, 0.45, 0.12)
|
||||
|
||||
// Wolken
|
||||
for (const c of this.clouds) {
|
||||
this.drawCloud(ctx, c)
|
||||
}
|
||||
|
||||
// Vögel
|
||||
for (const b of this.birds) {
|
||||
this.drawBird(ctx, b)
|
||||
}
|
||||
|
||||
// Wasser
|
||||
const waterGrad = ctx.createLinearGradient(0, waterLine, 0, h)
|
||||
waterGrad.addColorStop(0, this.colors.water)
|
||||
waterGrad.addColorStop(1, this.colors.waterDeep)
|
||||
ctx.fillStyle = waterGrad
|
||||
ctx.fillRect(0, waterLine, w, h - waterLine)
|
||||
|
||||
// Wellen auf dem Wasser
|
||||
for (const wave of this.waves) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, waterLine)
|
||||
for (let x = 0; x <= w; x += 3) {
|
||||
const y = waterLine + Math.sin(x * wave.frequency + wave.offset * 0.01) * wave.amplitude
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.lineTo(w, h)
|
||||
ctx.lineTo(0, h)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = `rgba(255,255,255,0.04)`
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// Boot
|
||||
const boatY = waterLine - 8 + Math.sin(this.boat.bobPhase) * 2.5
|
||||
this.drawBoat(ctx, this.boat.x, boatY)
|
||||
|
||||
// Sanfter Fade nach unten (damit Text darüber lesbar bleibt)
|
||||
const fadeGrad = ctx.createLinearGradient(0, h * 0.7, 0, h)
|
||||
fadeGrad.addColorStop(0, 'rgba(250,250,248,0)')
|
||||
fadeGrad.addColorStop(1, 'rgba(250,250,248,1)')
|
||||
ctx.fillStyle = fadeGrad
|
||||
ctx.fillRect(0, h * 0.7, w, h * 0.3)
|
||||
}
|
||||
|
||||
private drawMountains(ctx: CanvasRenderingContext2D, w: number, baseline: number, color: string, heightFactor: number, roughness: number): void {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, baseline)
|
||||
|
||||
const segments = 8
|
||||
const segW = w / segments
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const x = i * segW
|
||||
const peakH = baseline * heightFactor * (0.5 + Math.sin(i * 1.3 + 0.5) * 0.5)
|
||||
const y = baseline - peakH + Math.sin(i * 2.7) * baseline * roughness
|
||||
if (i === 0) {
|
||||
ctx.lineTo(x, y)
|
||||
} else {
|
||||
const cpx = x - segW * 0.5
|
||||
const cpy = y - baseline * roughness * 0.3
|
||||
ctx.quadraticCurveTo(cpx, cpy, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.lineTo(w, baseline)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = color
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
private drawCloud(ctx: CanvasRenderingContext2D, c: Cloud): void {
|
||||
ctx.globalAlpha = c.opacity
|
||||
ctx.fillStyle = this.colors.cloud
|
||||
|
||||
const cx = c.x
|
||||
const cy = c.y
|
||||
const s = c.size
|
||||
|
||||
// Wolke aus überlappenden Ellipsen
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(cx, cy, 30 * s, 12 * s, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(cx - 18 * s, cy + 2 * s, 20 * s, 10 * s, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(cx + 20 * s, cy + 3 * s, 22 * s, 9 * s, 0, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
private drawBird(ctx: CanvasRenderingContext2D, b: Bird): void {
|
||||
const wingAngle = Math.sin(b.wingPhase) * 0.4
|
||||
ctx.globalAlpha = b.opacity
|
||||
ctx.strokeStyle = this.colors.bird
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.lineCap = 'round'
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(b.x - b.size, b.y - wingAngle * b.size)
|
||||
ctx.quadraticCurveTo(b.x, b.y + 1, b.x + b.size, b.y - wingAngle * b.size)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
private drawBoat(ctx: CanvasRenderingContext2D, x: number, y: number): void {
|
||||
ctx.globalAlpha = this.boat.opacity
|
||||
const s = 0.8
|
||||
|
||||
// Rumpf
|
||||
ctx.fillStyle = this.colors.boat
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - 20 * s, y)
|
||||
ctx.lineTo(x - 16 * s, y + 8 * s)
|
||||
ctx.lineTo(x + 16 * s, y + 8 * s)
|
||||
ctx.lineTo(x + 20 * s, y)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
// Mast
|
||||
ctx.strokeStyle = '#5a5a5a'
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, y)
|
||||
ctx.lineTo(x, y - 22 * s)
|
||||
ctx.stroke()
|
||||
|
||||
// Segel
|
||||
ctx.fillStyle = this.colors.boatSail
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + 1, y - 20 * s)
|
||||
ctx.lineTo(x + 14 * s, y - 4 * s)
|
||||
ctx.lineTo(x + 1, y - 2 * s)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Simulation Shell — Der Rahmen um jede Simulation
|
||||
*
|
||||
* Enthält:
|
||||
* - Header mit Titel und Metadaten
|
||||
* - POE-Phase-Indikator (Predict → Observe → Explain)
|
||||
* - Variablen-Regler
|
||||
* - Canvas-Container für die Visualisierung
|
||||
* - Reflexions-Panel
|
||||
*/
|
||||
|
||||
import { Simulation } from '@core/simulation'
|
||||
import { AUSTRIA, getLocalName } from '@core/education-levels'
|
||||
|
||||
export class SimShell {
|
||||
private container: HTMLElement
|
||||
private sim: Simulation
|
||||
private canvasContainer: HTMLElement
|
||||
private controlsContainer: HTMLElement
|
||||
|
||||
constructor(hostElement: HTMLElement, sim: Simulation) {
|
||||
this.sim = sim
|
||||
this.container = document.createElement('div')
|
||||
this.container.className = 'sim-shell'
|
||||
this.container.innerHTML = this.buildHTML()
|
||||
hostElement.appendChild(this.container)
|
||||
|
||||
this.canvasContainer = this.container.querySelector('.sim-canvas-area')!
|
||||
this.controlsContainer = this.container.querySelector('.sim-controls')!
|
||||
|
||||
this.buildControls()
|
||||
this.updatePhaseUI()
|
||||
}
|
||||
|
||||
getCanvasContainer(): HTMLElement {
|
||||
return this.canvasContainer
|
||||
}
|
||||
|
||||
private buildHTML(): string {
|
||||
const m = this.sim.meta
|
||||
const localName = getLocalName(AUSTRIA, m.primaryLevel) || `Stufe ${m.primaryLevel}`
|
||||
|
||||
return `
|
||||
<style>
|
||||
.sim-shell { max-width: 900px; margin: 0 auto; padding: 1rem; }
|
||||
.sim-header { margin-bottom: 1rem; }
|
||||
.sim-header h2 { font-size: 1.4rem; font-weight: 800; letter-spacing: -.02em; margin-bottom: .2rem; }
|
||||
.sim-header-meta { display: flex; gap: .4rem; flex-wrap: wrap; }
|
||||
.sim-meta-tag { font-size: .68rem; font-weight: 600; padding: 2px 8px; border-radius: 6px; }
|
||||
.sim-meta-klasse { background: #dae8ec; color: #4a7c8a; }
|
||||
.sim-meta-zeit { background: #f0eeea; color: #6a6a6a; }
|
||||
.sim-meta-typ { background: #dceadd; color: #5a8a5e; }
|
||||
|
||||
.sim-poe { display: flex; gap: .3rem; margin-bottom: 1rem; }
|
||||
.sim-poe-step { flex: 1; padding: .5rem; border-radius: 8px; text-align: center; font-size: .72rem; font-weight: 600; background: #f0eeea; color: #8a8a8a; transition: all .3s; }
|
||||
.sim-poe-step.active { background: #4a7c8a; color: #fff; }
|
||||
.sim-poe-step.done { background: #dceadd; color: #5a8a5e; }
|
||||
|
||||
.sim-body { display: grid; grid-template-columns: 1fr 240px; gap: 1rem; }
|
||||
.sim-canvas-area { background: #fff; border-radius: 12px; border: 1px solid rgba(0,0,0,.06); overflow: hidden; min-height: 300px; box-shadow: 0 2px 10px rgba(0,0,0,.04); }
|
||||
.sim-sidebar { display: flex; flex-direction: column; gap: .8rem; }
|
||||
|
||||
.sim-controls { background: #fff; border-radius: 12px; border: 1px solid rgba(0,0,0,.06); padding: 1rem; box-shadow: 0 2px 10px rgba(0,0,0,.04); }
|
||||
.sim-controls h4 { font-size: .78rem; font-weight: 700; color: #4a7c8a; margin-bottom: .6rem; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.sim-control { margin-bottom: .8rem; }
|
||||
.sim-control label { display: flex; justify-content: space-between; font-size: .78rem; font-weight: 500; margin-bottom: .2rem; }
|
||||
.sim-control label span { color: #8a8a8a; font-weight: 600; }
|
||||
.sim-control input[type=range] { width: 100%; -webkit-appearance: none; height: 6px; border-radius: 3px; background: #e8e8e4; outline: none; }
|
||||
.sim-control input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #4a7c8a; cursor: pointer; box-shadow: 0 1px 4px rgba(0,0,0,.15); }
|
||||
|
||||
.sim-lernziele { background: #fff; border-radius: 12px; border: 1px solid rgba(0,0,0,.06); padding: 1rem; box-shadow: 0 2px 10px rgba(0,0,0,.04); }
|
||||
.sim-lernziele h4 { font-size: .78rem; font-weight: 700; color: #5a8a5e; margin-bottom: .4rem; }
|
||||
.sim-lernziele ul { font-size: .75rem; color: #4a4a4a; padding-left: 1rem; }
|
||||
.sim-lernziele li { margin-bottom: .2rem; }
|
||||
|
||||
.sim-phase-btn { width: 100%; padding: .6rem; border: none; border-radius: 8px; font-weight: 600; font-size: .82rem; cursor: pointer; background: #4a7c8a; color: #fff; transition: all .2s; }
|
||||
.sim-phase-btn:hover { background: #3a6470; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.sim-body { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="sim-header">
|
||||
<h2>${m.name}</h2>
|
||||
<div class="sim-header-meta">
|
||||
<span class="sim-meta-tag sim-meta-klasse">${localName}</span>
|
||||
<span class="sim-meta-tag sim-meta-zeit">⏱ ${m.dpiMinuten} min</span>
|
||||
<span class="sim-meta-tag sim-meta-typ">${m.typ}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sim-poe">
|
||||
<div class="sim-poe-step" data-phase="intro">📖 Intro</div>
|
||||
<div class="sim-poe-step" data-phase="predict">🤔 Predict</div>
|
||||
<div class="sim-poe-step" data-phase="simulate">🔬 Simulate</div>
|
||||
<div class="sim-poe-step" data-phase="observe">👁️ Observe</div>
|
||||
<div class="sim-poe-step" data-phase="reflect">💭 Reflect</div>
|
||||
</div>
|
||||
|
||||
<div class="sim-body">
|
||||
<div class="sim-canvas-area"></div>
|
||||
<div class="sim-sidebar">
|
||||
<div class="sim-controls">
|
||||
<h4>Parameter</h4>
|
||||
</div>
|
||||
<div class="sim-lernziele">
|
||||
<h4>🎯 Lernziele</h4>
|
||||
<ul>${m.lernziele.map(l => `<li>${l}</li>`).join('')}</ul>
|
||||
</div>
|
||||
<button class="sim-phase-btn" onclick="this.closest('.sim-shell').__nextPhase()">
|
||||
Weiter →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Attach next phase handler
|
||||
setTimeout(() => {
|
||||
const btn = this.container.querySelector('.sim-phase-btn') as HTMLButtonElement
|
||||
if (btn) {
|
||||
btn.addEventListener('click', () => {
|
||||
this.sim.nextPhase()
|
||||
this.updatePhaseUI()
|
||||
})
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
private buildControls(): void {
|
||||
const ranges = this.sim.getVariableRanges()
|
||||
const ctrl = this.controlsContainer
|
||||
|
||||
for (const [key, range] of Object.entries(ranges)) {
|
||||
const div = document.createElement('div')
|
||||
div.className = 'sim-control'
|
||||
div.innerHTML = `
|
||||
<label>${range.label} <span class="val">${range.default}${range.unit ? ' ' + range.unit : ''}</span></label>
|
||||
<input type="range" min="${range.min}" max="${range.max}" value="${range.default}" step="${range.max > 10 ? 1 : 0.01}">
|
||||
`
|
||||
|
||||
const input = div.querySelector('input')!
|
||||
const valSpan = div.querySelector('.val')!
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
const v = parseFloat(input.value)
|
||||
this.sim.setVariable(key, v)
|
||||
valSpan.textContent = `${v}${range.unit ? ' ' + range.unit : ''}`
|
||||
})
|
||||
|
||||
ctrl.appendChild(div)
|
||||
}
|
||||
}
|
||||
|
||||
private updatePhaseUI(): void {
|
||||
const phases = ['intro', 'predict', 'simulate', 'observe', 'reflect']
|
||||
const current = this.sim['state'].phase // accessing protected state
|
||||
const currentIdx = phases.indexOf(current === 'complete' ? 'reflect' : current)
|
||||
|
||||
this.container.querySelectorAll('.sim-poe-step').forEach((el, i) => {
|
||||
el.classList.remove('active', 'done')
|
||||
if (i === currentIdx) el.classList.add('active')
|
||||
else if (i < currentIdx) el.classList.add('done')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,965 @@
|
||||
/**
|
||||
* Game UI — Wiederverwendbare Game-Page-Komponente
|
||||
*
|
||||
* Bekommt eine Game-Instance, einen Renderer und eine Konfiguration für
|
||||
* den Maßnahmen-Shop, und baut die komplette UI auf:
|
||||
* - Ressourcen-Panel
|
||||
* - Zielepanel
|
||||
* - Speed-Controls
|
||||
* - Tutorial-Overlay
|
||||
* - Maßnahmen-Shop
|
||||
* - Events
|
||||
* - Graphen
|
||||
* - Save / Load / Finish
|
||||
* - End-Screen
|
||||
*/
|
||||
|
||||
import type { GameEngine } from '@core/game-engine'
|
||||
import { INFO_TOPICS, openInfoOverlay } from './info-overlay'
|
||||
import { persistence } from '@core/persistence'
|
||||
|
||||
export interface ShopItem {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
description: string
|
||||
cost: number
|
||||
upkeep?: number
|
||||
badges?: Array<{ label: string; type: 'cost' | 'reduction' | 'protection' | 'capacity' | 'quality' | 'neutral' }>
|
||||
}
|
||||
|
||||
export interface GraphZone {
|
||||
/** y-Wert ab dem diese Zone beginnt */
|
||||
from: number
|
||||
/** y-Wert bis wo sie geht */
|
||||
to: number
|
||||
/** Farbe im RGBA-Format, bevorzugt mit Alpha ~0.15 */
|
||||
color: string
|
||||
/** Beschriftung die links in der Zone angezeigt wird */
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface GraphLine {
|
||||
/** y-Wert der Linie */
|
||||
at: number
|
||||
/** Farbe */
|
||||
color: string
|
||||
/** Text-Label rechts neben der Linie */
|
||||
label?: string
|
||||
/** Strichlinie? */
|
||||
dashed?: boolean
|
||||
}
|
||||
|
||||
export interface GraphConfig {
|
||||
id: string
|
||||
title: string
|
||||
field: string // resource id
|
||||
yMin: number
|
||||
yMax: number
|
||||
color: string
|
||||
format?: (v: number) => string
|
||||
/** Horizontale Farbbänder im Hintergrund (z.B. Temperaturzonen) */
|
||||
zones?: GraphZone[]
|
||||
/** Horizontale Referenzlinien (z.B. "Klimaziel 17°C") */
|
||||
lines?: GraphLine[]
|
||||
/** Einheit für die Anzeige ("°C", "cm", "Mio €", "ppm") */
|
||||
unit?: string
|
||||
/**
|
||||
* Dynamische Skalierung: yMax und optional yMin wandern mit den Daten mit,
|
||||
* sodass die Kurve immer den Graph füllt. yMin/yMax aus der Config bleiben
|
||||
* als UNTERGRENZEN (yMax wird bei Bedarf nach oben erweitert).
|
||||
*/
|
||||
autoScale?: boolean
|
||||
/** Kurzer Achsen-Label für die Tick-Zahl (z.B. "°C" leer lassen weil im Titel) */
|
||||
yTickSuffix?: string
|
||||
}
|
||||
|
||||
export type PlayMode = 'free' | 'guided'
|
||||
|
||||
export interface GameUIConfig {
|
||||
game: GameEngine
|
||||
Renderer: new (container: HTMLElement, game: any) => { start(): void; stop(): void }
|
||||
shopItems: ShopItem[]
|
||||
onBuy: (id: string) => boolean
|
||||
/** Optional: Maßnahme abreißen (Rückerstattung). Wird im Shop als 🗑 angezeigt. */
|
||||
onDemolish?: (id: string) => boolean
|
||||
/** Optional: aktuelle Stückzahl einer Maßnahme abfragen (für "× N"-Badge im Shop) */
|
||||
getOwnedCount?: (id: string) => number
|
||||
/**
|
||||
* Optional: Baueditor-Hook. Wenn gesetzt, wird beim Klick auf eine
|
||||
* Maßnahme NICHT direkt onBuy aufgerufen, sondern dieser Callback —
|
||||
* der Renderer startet dann den Placement-Mode (Ghost-Mesh, Raycast).
|
||||
* Sobald die Anwender*in eine Stelle wählt, ruft der Callback intern
|
||||
* `game.buyMeasure(id, {x, z})` auf.
|
||||
*/
|
||||
onStartPlacement?: (id: string) => void
|
||||
graphs: GraphConfig[]
|
||||
saveKey: string
|
||||
finishOnTick?: number // optional: auto-finish nach diesem Tick
|
||||
yearOffset?: number // z.B. 2025 oder 1975
|
||||
|
||||
/**
|
||||
* Spielmodus:
|
||||
* - 'free' (Default): Schüler kann Geschwindigkeit frei wählen
|
||||
* - 'guided': Lehrperson hat fixe Spieldauer gesetzt; Speed-Buttons ausgeblendet,
|
||||
* Tick-Dauer wird auf totalDurationSec/maxTicks gerechnet, nur Pause erlaubt
|
||||
*/
|
||||
mode?: PlayMode
|
||||
/** Im 'guided' Mode: Gesamt-Spieldauer in Sekunden, die das Spiel laufen soll */
|
||||
totalDurationSec?: number
|
||||
/**
|
||||
* Optional: Whitelist welche Resource-IDs in der Status-Box angezeigt werden.
|
||||
* Wenn nicht gesetzt, werden alle Resources angezeigt.
|
||||
* Werte die in einem Graph stehen, kann man hier weglassen, um Doppelung zu vermeiden.
|
||||
*/
|
||||
statusResources?: string[]
|
||||
}
|
||||
|
||||
export class GameUI {
|
||||
private cfg: GameUIConfig
|
||||
private game: GameEngine
|
||||
private renderer: { start(): void; stop(): void; [key: string]: any } | null = null
|
||||
private endShown = false
|
||||
private autoSaveTimer = 0
|
||||
private autoSavePending = false
|
||||
/**
|
||||
* Pro Resource ein gedämpft animierter Anzeige-Wert — springt nicht hart
|
||||
* beim Tick-Wechsel, sondern zählt weich zum neuen Ziel.
|
||||
*/
|
||||
private displayedValues: Record<string, number> = {}
|
||||
private displayAnimRaf = 0
|
||||
/** True wenn wir das Spiel wegen einer offenen Bürger-Beschwerde pausiert haben */
|
||||
private pausedForCitizen = false
|
||||
/** ID der aktuell angezeigten Bürger-Beschwerde — verhindert Re-Render bei Ticks */
|
||||
private currentCitizenId: string | null = null
|
||||
/** Wird auf true gesetzt wenn Reset läuft — verhindert, dass beforeunload den Save wiederherstellt */
|
||||
private resetting = false
|
||||
|
||||
constructor(cfg: GameUIConfig) {
|
||||
this.cfg = cfg
|
||||
this.game = cfg.game
|
||||
|
||||
// Im 'guided' Mode die Tick-Dauer fix setzen, sodass das Spiel genau totalDurationSec dauert.
|
||||
if (cfg.mode === 'guided' && cfg.totalDurationSec && this.game.meta.maxTicks > 0) {
|
||||
const msPerTick = (cfg.totalDurationSec * 1000) / this.game.meta.maxTicks
|
||||
// Direktes Patchen, weil msPerTick im meta sonst readonly wäre
|
||||
;(this.game.meta as any).msPerTick = msPerTick
|
||||
}
|
||||
|
||||
// Vorhandenen Stand automatisch laden, falls vorhanden
|
||||
this.tryAutoLoad()
|
||||
|
||||
this.bindControls()
|
||||
this.game.subscribe(() => {
|
||||
this.renderAll()
|
||||
this.scheduleAutoSave()
|
||||
})
|
||||
this.renderAll()
|
||||
|
||||
// Beim Verlassen der Page sofort speichern
|
||||
window.addEventListener('beforeunload', () => this.flushAutoSave())
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') this.flushAutoSave()
|
||||
})
|
||||
}
|
||||
|
||||
/** Renderer-Instanz für externe Steuerung (z.B. Kamera-Buttons) */
|
||||
getRenderer(): { start(): void; stop(): void; [key: string]: any } | null {
|
||||
return this.renderer
|
||||
}
|
||||
|
||||
// === Auto-Save ===
|
||||
private tryAutoLoad(): void {
|
||||
// Synchron aus localStorage laden (schnell), dann async aus API nachladen
|
||||
const localSave = localStorage.getItem(this.cfg.saveKey)
|
||||
if (localSave && this.game.deserialize(localSave)) {
|
||||
// Stand aus localStorage wiederhergestellt
|
||||
}
|
||||
// Async: Server-Stand pruefen (ueberschreibt ggf. localStorage-Stand)
|
||||
if (persistence.hasSession()) {
|
||||
persistence.load(this.cfg.saveKey).then(serverSave => {
|
||||
if (serverSave && serverSave !== localSave) {
|
||||
this.game.deserialize(serverSave)
|
||||
this.renderAll()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleAutoSave(): void {
|
||||
// Debounce: spätestens nach 1.5 s schreiben
|
||||
if (this.autoSavePending) return
|
||||
this.autoSavePending = true
|
||||
this.autoSaveTimer = window.setTimeout(() => {
|
||||
this.flushAutoSave()
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
private flushAutoSave(): void {
|
||||
if (this.autoSaveTimer) {
|
||||
clearTimeout(this.autoSaveTimer)
|
||||
this.autoSaveTimer = 0
|
||||
}
|
||||
this.autoSavePending = false
|
||||
// Während eines Resets NICHT wieder speichern, sonst bleibt der alte Zustand erhalten
|
||||
if (this.resetting) return
|
||||
const data = this.game.serialize()
|
||||
persistence.save(this.cfg.saveKey, data)
|
||||
}
|
||||
|
||||
private bindControls(): void {
|
||||
// Speed-Modus durchsetzen: im 'guided' nur Pause/Play (Speed 0 und 1) erlaubt.
|
||||
// Selector geht auf das ATTRIBUT data-speed (nicht auf eine Klasse), weil
|
||||
// verschiedene HTML-Seiten verschiedene Button-Klassen verwenden
|
||||
// (game-3d: .icon-btn, game.html: .speed-btn).
|
||||
const isGuided = this.cfg.mode === 'guided'
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-speed]').forEach(btn => {
|
||||
const speed = parseInt(btn.dataset.speed || '1')
|
||||
if (isGuided && speed > 1) {
|
||||
btn.style.display = 'none'
|
||||
return
|
||||
}
|
||||
btn.addEventListener('click', () => {
|
||||
const s = speed as 0 | 1 | 2 | 4
|
||||
this.game.setSpeed(s)
|
||||
document.querySelectorAll('[data-speed]').forEach(b => b.classList.remove('active'))
|
||||
btn.classList.add('active')
|
||||
})
|
||||
})
|
||||
|
||||
// Save / Load / Finish
|
||||
document.getElementById('btn-save')?.addEventListener('click', () => {
|
||||
persistence.save(this.cfg.saveKey, this.game.serialize())
|
||||
this.toast('💾 Stand gespeichert')
|
||||
})
|
||||
document.getElementById('btn-load')?.addEventListener('click', async () => {
|
||||
const save = await persistence.load(this.cfg.saveKey)
|
||||
if (!save) { this.toast('Kein Stand vorhanden'); return }
|
||||
if (this.game.deserialize(save)) {
|
||||
this.renderAll()
|
||||
this.toast('📂 Stand geladen')
|
||||
}
|
||||
})
|
||||
document.getElementById('btn-finish')?.addEventListener('click', () => {
|
||||
this.game.finish()
|
||||
this.showEndScreen()
|
||||
})
|
||||
document.getElementById('btn-reset')?.addEventListener('click', () => {
|
||||
if (confirm('Stand wirklich löschen und neu starten?')) {
|
||||
// Reset-Flag setzt beforeunload-Handler außer Kraft
|
||||
this.resetting = true
|
||||
localStorage.removeItem(this.cfg.saveKey)
|
||||
location.reload()
|
||||
}
|
||||
})
|
||||
|
||||
// Tutorial next button
|
||||
document.getElementById('tut-next')?.addEventListener('click', () => {
|
||||
this.game.nextTutorialStep()
|
||||
this.renderAll()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzwingt ein sofortiges Neuzeichnen aller Graphen — wird z. B. nach
|
||||
* dem Zoom-Animation-End aufgerufen, damit sich die SVGs an die neue
|
||||
* Card-Größe anpassen.
|
||||
*/
|
||||
redrawGraphs(): void {
|
||||
this.renderGraphs()
|
||||
}
|
||||
|
||||
private renderAll(): void {
|
||||
this.renderResources()
|
||||
this.renderGoals()
|
||||
this.renderShop()
|
||||
this.renderEvents()
|
||||
this.renderGraphs()
|
||||
this.renderTutorial()
|
||||
this.renderCitizenEvent()
|
||||
|
||||
const snap = this.game.getSnapshot()
|
||||
if ((snap.state === 'won' || snap.state === 'lost') && !this.endShown) {
|
||||
setTimeout(() => this.showEndScreen(), 800)
|
||||
}
|
||||
}
|
||||
|
||||
private renderCitizenEvent(): void {
|
||||
const host = document.getElementById('citizen-event-host')
|
||||
if (!host) return
|
||||
const ev = this.game.getPendingCitizenEvent()
|
||||
|
||||
// Kein Event → Modal ausblenden, Spiel fortsetzen
|
||||
if (!ev) {
|
||||
if (this.currentCitizenId !== null) {
|
||||
host.innerHTML = ''
|
||||
this.currentCitizenId = null
|
||||
}
|
||||
if (this.pausedForCitizen) {
|
||||
this.pausedForCitizen = false
|
||||
this.game.setSpeed(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Gleiches Event wie zuletzt → Modal NICHT neu rendern,
|
||||
// sonst flackert es bei jedem Tick-Render
|
||||
if (this.currentCitizenId === ev.id) {
|
||||
return
|
||||
}
|
||||
this.currentCitizenId = ev.id
|
||||
|
||||
// Neues Event: Spiel pausieren, Modal einmal aufbauen
|
||||
if (!this.pausedForCitizen) {
|
||||
this.pausedForCitizen = true
|
||||
this.game.setSpeed(0)
|
||||
}
|
||||
const choicesHtml = ev.choices.map((c, i) => `
|
||||
<button class="citizen-choice" data-idx="${i}">
|
||||
<div class="citizen-choice-label">${c.label}</div>
|
||||
${c.description ? `<div class="citizen-choice-desc">${c.description}</div>` : ''}
|
||||
</button>
|
||||
`).join('')
|
||||
host.innerHTML = `
|
||||
<div class="citizen-overlay">
|
||||
<div class="citizen-card">
|
||||
<div class="citizen-head">
|
||||
<div class="citizen-avatar">${ev.character}</div>
|
||||
<div>
|
||||
<div class="citizen-title">${ev.title}</div>
|
||||
<div class="citizen-sub">Eine Bürgerin meldet sich</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="citizen-message">„${ev.message}"</div>
|
||||
<div class="citizen-choices">${choicesHtml}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
host.querySelectorAll<HTMLButtonElement>('.citizen-choice').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const idx = parseInt(btn.dataset.idx || '0')
|
||||
this.game.resolveCitizenEvent(idx)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private renderResources(): void {
|
||||
const el = document.getElementById('resources')
|
||||
if (!el) return
|
||||
let resources = this.game.getResourcesArray()
|
||||
if (this.cfg.statusResources && this.cfg.statusResources.length > 0) {
|
||||
const allow = new Set(this.cfg.statusResources)
|
||||
resources = resources.filter(r => allow.has(r.id))
|
||||
}
|
||||
// HTML-Gerüst einmal bauen (wenn noch nicht da oder Struktur geändert)
|
||||
const existing = el.querySelectorAll<HTMLElement>('.resource')
|
||||
const needsRebuild = existing.length !== resources.length
|
||||
if (needsRebuild) {
|
||||
el.innerHTML = resources.map(r => {
|
||||
// Spezialfall STROM: zwei kleine Balken (Erzeugung / Bedarf)
|
||||
// visualisieren das Strom-Netz didaktisch.
|
||||
let extra = ''
|
||||
if (r.id === 'power') {
|
||||
extra = `
|
||||
<div class="power-grid">
|
||||
<div class="pg-row">
|
||||
<span class="pg-lbl">⚡ Strom</span>
|
||||
<div class="pg-track"><div class="pg-fill pg-cap"></div></div>
|
||||
<span class="pg-num pg-cap-num">0</span>
|
||||
</div>
|
||||
<div class="pg-row">
|
||||
<span class="pg-lbl">🏠 Bedarf</span>
|
||||
<div class="pg-track"><div class="pg-fill pg-dem"></div></div>
|
||||
<span class="pg-num pg-dem-num">0</span>
|
||||
</div>
|
||||
<div class="pg-status pg-status-ok">Versorgung gesichert</div>
|
||||
</div>
|
||||
`
|
||||
} else if (r.id === 'budget' && typeof (this.game as any).getYearlyBalance === 'function') {
|
||||
// Spezialfall BUDGET: Jahres-Bilanz (Einnahmen / Wartung / Netto)
|
||||
extra = `
|
||||
<div class="budget-balance">
|
||||
<div class="bb-row">
|
||||
<span class="bb-lbl">+ Steuern</span>
|
||||
<span class="bb-num bb-pos bb-income">0</span>
|
||||
</div>
|
||||
<div class="bb-row bb-tourism" style="display:none">
|
||||
<span class="bb-lbl">+ Tourismus</span>
|
||||
<span class="bb-num bb-pos bb-tourism-num">0</span>
|
||||
</div>
|
||||
<div class="bb-row">
|
||||
<span class="bb-lbl">− Wartung</span>
|
||||
<span class="bb-num bb-neg bb-upkeep">0</span>
|
||||
</div>
|
||||
<div class="bb-row bb-climate" style="display:none">
|
||||
<span class="bb-lbl">− Klima-Schaden</span>
|
||||
<span class="bb-num bb-neg bb-climate-num">0</span>
|
||||
</div>
|
||||
<div class="bb-row bb-net-row">
|
||||
<span class="bb-lbl">= pro Jahr</span>
|
||||
<span class="bb-num bb-net">0</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
return `
|
||||
<div class="resource" data-tip="${r.id}" data-res-id="${r.id}">
|
||||
<div class="resource-icon">${r.icon}</div>
|
||||
<div class="resource-info">
|
||||
<div class="resource-name">${r.name}</div>
|
||||
<div class="resource-val"></div>
|
||||
${extra}
|
||||
</div>
|
||||
</div>
|
||||
`}).join('')
|
||||
}
|
||||
// Werte schreiben (aus displayedValues, nicht aus r.current)
|
||||
for (const r of resources) {
|
||||
if (this.displayedValues[r.id] === undefined) {
|
||||
this.displayedValues[r.id] = r.current
|
||||
}
|
||||
const row = el.querySelector<HTMLElement>(`[data-res-id="${r.id}"] .resource-val`)
|
||||
if (row) {
|
||||
const v = this.displayedValues[r.id]
|
||||
row.textContent = r.format ? r.format(v) : String(Math.round(v))
|
||||
}
|
||||
}
|
||||
this.updatePowerGrid()
|
||||
this.updateBudgetBalance()
|
||||
// Animations-Loop starten (einmal)
|
||||
this.ensureDisplayAnim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert die Jahres-Bilanz unter dem Budget-Wert (sim-05-spezifisch).
|
||||
* Liest game.getYearlyBalance() — wenn die Methode nicht existiert, NoOp.
|
||||
*/
|
||||
private updateBudgetBalance(): void {
|
||||
const host = document.querySelector<HTMLElement>('[data-res-id="budget"] .budget-balance')
|
||||
if (!host) return
|
||||
const fn = (this.game as any).getYearlyBalance as (() => { income: number; tourism: number; upkeep: number; climate: number; net: number }) | undefined
|
||||
if (typeof fn !== 'function') return
|
||||
const b = fn.call(this.game)
|
||||
const sel = (q: string) => host.querySelector<HTMLElement>(q)
|
||||
const incomeEl = sel('.bb-income')
|
||||
if (incomeEl) incomeEl.textContent = `${b.income} Mio €`
|
||||
const tourismRow = sel('.bb-tourism') as HTMLElement | null
|
||||
const tourismEl = sel('.bb-tourism-num')
|
||||
if (tourismRow) tourismRow.style.display = b.tourism > 0 ? '' : 'none'
|
||||
if (tourismEl) tourismEl.textContent = `${b.tourism} Mio €`
|
||||
const upkeepEl = sel('.bb-upkeep')
|
||||
if (upkeepEl) upkeepEl.textContent = `${b.upkeep} Mio €`
|
||||
const climateRow = sel('.bb-climate') as HTMLElement | null
|
||||
const climateEl = sel('.bb-climate-num')
|
||||
if (climateRow) climateRow.style.display = b.climate > 0 ? '' : 'none'
|
||||
if (climateEl) climateEl.textContent = `${b.climate} Mio €`
|
||||
const netEl = sel('.bb-net')
|
||||
if (netEl) {
|
||||
const sign = b.net >= 0 ? '+' : ''
|
||||
netEl.textContent = `${sign}${b.net} Mio €`
|
||||
netEl.classList.toggle('bb-pos', b.net >= 0)
|
||||
netEl.classList.toggle('bb-neg', b.net < 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert die Strom-Netz-Visualisierung in der Status-Box.
|
||||
* Liest die displayed-power-Kapazität und die aktuelle Bevölkerung,
|
||||
* berechnet Bedarf = pop/1000 und stellt zwei Balken auf gemeinsame Skala.
|
||||
*/
|
||||
private updatePowerGrid(): void {
|
||||
const host = document.querySelector<HTMLElement>('[data-res-id="power"] .power-grid')
|
||||
if (!host) return
|
||||
const cap = Math.round(this.displayedValues['power'] ?? 0)
|
||||
const pop = this.displayedValues['population'] ?? this.game.getResource('population')
|
||||
const dem = Math.max(0, Math.round(pop / 1000))
|
||||
// Skala: höchster Wert + 2 Puffer, mindestens 8
|
||||
const scale = Math.max(8, Math.max(cap, dem) + 2)
|
||||
const capPct = Math.min(100, (cap / scale) * 100)
|
||||
const demPct = Math.min(100, (dem / scale) * 100)
|
||||
const capFill = host.querySelector<HTMLElement>('.pg-cap')
|
||||
const demFill = host.querySelector<HTMLElement>('.pg-dem')
|
||||
const capNum = host.querySelector<HTMLElement>('.pg-cap-num')
|
||||
const demNum = host.querySelector<HTMLElement>('.pg-dem-num')
|
||||
const status = host.querySelector<HTMLElement>('.pg-status')
|
||||
if (capFill) capFill.style.width = `${capPct}%`
|
||||
if (demFill) demFill.style.width = `${demPct}%`
|
||||
if (capNum) capNum.textContent = `${cap} MW`
|
||||
if (demNum) demNum.textContent = `${dem} MW`
|
||||
const ok = cap >= dem
|
||||
if (capFill) capFill.classList.toggle('pg-cap-low', !ok)
|
||||
if (status) {
|
||||
status.textContent = ok ? 'Versorgung gesichert' : 'Stromausfall — zu wenig!'
|
||||
status.classList.toggle('pg-status-ok', ok)
|
||||
status.classList.toggle('pg-status-bad', !ok)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hält einen rAF-Loop am Laufen, der displayedValues langsam zu den echten
|
||||
* Resource-Werten zieht. Pro Sekunde werden ~30% der Differenz überbrückt,
|
||||
* bei sehr kleinen Änderungen wird direkt gesetzt.
|
||||
*/
|
||||
private ensureDisplayAnim(): void {
|
||||
if (this.displayAnimRaf !== 0) return
|
||||
const loop = () => {
|
||||
let anyMoving = false
|
||||
for (const r of this.game.getResourcesArray()) {
|
||||
const target = r.current
|
||||
const cur = this.displayedValues[r.id]
|
||||
if (cur === undefined) {
|
||||
this.displayedValues[r.id] = target
|
||||
continue
|
||||
}
|
||||
const delta = target - cur
|
||||
const absDelta = Math.abs(delta)
|
||||
if (absDelta < 0.5 && absDelta < Math.abs(target) * 0.001 + 0.5) {
|
||||
// Snap wenn nah genug
|
||||
if (cur !== target) {
|
||||
this.displayedValues[r.id] = target
|
||||
anyMoving = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Gedämpftes Hinzählen: 12% der Distanz pro Frame (~60fps)
|
||||
this.displayedValues[r.id] = cur + delta * 0.12
|
||||
anyMoving = true
|
||||
}
|
||||
if (anyMoving) {
|
||||
// Kurz UI updaten (nur die Werte-Zellen, nicht das ganze DOM)
|
||||
const el = document.getElementById('resources')
|
||||
if (el) {
|
||||
for (const r of this.game.getResourcesArray()) {
|
||||
const row = el.querySelector<HTMLElement>(`[data-res-id="${r.id}"] .resource-val`)
|
||||
if (row) {
|
||||
const v = this.displayedValues[r.id]
|
||||
row.textContent = r.format ? r.format(v) : String(Math.round(v))
|
||||
}
|
||||
}
|
||||
// Strom-Netz-Visualisierung und Bilanz mitziehen
|
||||
this.updatePowerGrid()
|
||||
this.updateBudgetBalance()
|
||||
}
|
||||
}
|
||||
this.displayAnimRaf = requestAnimationFrame(loop)
|
||||
}
|
||||
this.displayAnimRaf = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
private renderGoals(): void {
|
||||
// Ziele werden entweder in ein separates #goals-Element gerendert,
|
||||
// oder — falls kein separates Element existiert — in das gemeinsame
|
||||
// #status-goals Element (dann unter den Resources in der Status-Box).
|
||||
const goalsEl = document.getElementById('goals') || document.getElementById('status-goals')
|
||||
if (!goalsEl) return
|
||||
const snap = this.game.getSnapshot()
|
||||
const goalsList = (this.game as any).goals as Array<{ title: string; description: string }>
|
||||
goalsEl.innerHTML = snap.goals.map((g, i) => {
|
||||
const goal = goalsList?.[i] || { title: g.id, description: '' }
|
||||
return `
|
||||
<div class="goal">
|
||||
<div class="goal-row">
|
||||
<div class="goal-check ${g.achieved ? 'done' : ''}">${g.achieved ? '✓' : ''}</div>
|
||||
<div>${goal.title || g.id}</div>
|
||||
</div>
|
||||
<div class="goal-bar"><div class="goal-bar-fill" style="width:${g.progress}%"></div></div>
|
||||
</div>
|
||||
`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
private renderShop(): void {
|
||||
const el = document.getElementById('measures')
|
||||
if (!el) return
|
||||
const budget = this.game.getResource('budget')
|
||||
const getCount = this.cfg.getOwnedCount
|
||||
const canDemolish = !!this.cfg.onDemolish
|
||||
el.innerHTML = this.cfg.shopItems.map(item => {
|
||||
const canAfford = budget >= item.cost
|
||||
const owned = getCount ? getCount(item.id) : 0
|
||||
const badgesHtml = (item.badges || []).map(b => `<span class="measure-stat measure-${b.type}">${b.label}</span>`).join('')
|
||||
const ownedBadge = owned > 0
|
||||
? `<span class="measure-owned">×${owned}</span>`
|
||||
: ''
|
||||
const demolishBtn = (owned > 0 && canDemolish)
|
||||
? `<button class="measure-demolish" data-demolish="${item.id}" title="Abreißen (50 % zurück)">🗑</button>`
|
||||
: ''
|
||||
return `
|
||||
<div class="measure-card ${canAfford ? '' : 'disabled'}" data-id="${item.id}">
|
||||
<div class="measure-icon">${item.emoji}</div>
|
||||
<div class="measure-info">
|
||||
<div class="measure-name">${item.name} ${ownedBadge}</div>
|
||||
<div class="measure-desc">${item.description}</div>
|
||||
<div class="measure-stats">
|
||||
<span class="measure-stat measure-cost">💰 ${item.cost} Mio €</span>
|
||||
${badgesHtml}
|
||||
${item.upkeep ? `<span class="measure-stat">⚙ ${item.upkeep} Mio €/J</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${demolishBtn}
|
||||
</div>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
el.querySelectorAll<HTMLDivElement>('.measure-card').forEach(card => {
|
||||
card.addEventListener('click', (ev) => {
|
||||
// Klick auf den Abreißen-Button NICHT als Kauf werten
|
||||
const target = ev.target as HTMLElement
|
||||
if (target.closest('.measure-demolish')) return
|
||||
if (card.classList.contains('disabled')) return
|
||||
const id = card.dataset.id!
|
||||
// Wenn der Baueditor-Hook gesetzt ist, statt sofort zu kaufen den
|
||||
// Placement-Mode starten. Sonst direkt kaufen (Auto-Platzierung).
|
||||
if (this.cfg.onStartPlacement) {
|
||||
this.cfg.onStartPlacement(id)
|
||||
} else {
|
||||
this.cfg.onBuy(id)
|
||||
}
|
||||
})
|
||||
})
|
||||
el.querySelectorAll<HTMLButtonElement>('.measure-demolish').forEach(btn => {
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
const id = btn.getAttribute('data-demolish')
|
||||
if (id && this.cfg.onDemolish) this.cfg.onDemolish(id)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private renderEvents(): void {
|
||||
const el = document.getElementById('events')
|
||||
if (!el) return
|
||||
const events = this.game.getEvents(20)
|
||||
if (events.length === 0) {
|
||||
el.innerHTML = '<div style="font-size:.65rem;color:var(--text3);text-align:center;padding:.4rem">Noch keine Ereignisse</div>'
|
||||
return
|
||||
}
|
||||
const yearOffset = this.cfg.yearOffset || 0
|
||||
el.innerHTML = events.map(e => {
|
||||
const prefix = yearOffset > 0 ? yearOffset + e.tick : 'Jahr ' + e.tick
|
||||
const infoBtn = e.infoKey && INFO_TOPICS[e.infoKey]
|
||||
? `<button class="event-info-btn" data-event-info="${e.infoKey}" title="Mehr erfahren">i</button>`
|
||||
: ''
|
||||
return `
|
||||
<div class="event event-${e.severity}">
|
||||
<div class="event-text"><strong>${prefix}:</strong> ${e.text}</div>
|
||||
${infoBtn}
|
||||
</div>
|
||||
`
|
||||
}).join('')
|
||||
el.querySelectorAll<HTMLButtonElement>('.event-info-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const key = btn.getAttribute('data-event-info')
|
||||
if (key && INFO_TOPICS[key]) openInfoOverlay(INFO_TOPICS[key])
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private renderGraphs(): void {
|
||||
const snap = this.game.getSnapshot()
|
||||
const tl = snap.timeline
|
||||
const currentTick = snap.tick
|
||||
|
||||
for (const g of this.cfg.graphs) {
|
||||
const points: Array<{ x: number; y: number }> = tl.map(e => ({
|
||||
x: e.tick,
|
||||
y: e.values[g.field] ?? 0,
|
||||
}))
|
||||
// Aktuellen Live-Wert anhängen, damit Käufe sofort sichtbar sind —
|
||||
// auch wenn noch kein Tick darüber gelaufen ist.
|
||||
const liveValue = this.game.getResource(g.field)
|
||||
const lastPt = points[points.length - 1]
|
||||
if (!lastPt || lastPt.x < currentTick || lastPt.y !== liveValue) {
|
||||
points.push({ x: currentTick, y: liveValue })
|
||||
}
|
||||
this.drawGraph(g, points)
|
||||
}
|
||||
}
|
||||
|
||||
private drawGraph(cfg: GraphConfig, points: { x: number; y: number }[]): void {
|
||||
const svg = document.getElementById(cfg.id) as unknown as SVGElement | null
|
||||
if (!svg) return
|
||||
const w = (svg as any).clientWidth || 280
|
||||
const h = (svg as any).clientHeight || 110
|
||||
svg.setAttribute('viewBox', `0 0 ${w} ${h}`)
|
||||
const maxTicks = (this.game as any).meta?.maxTicks || 75
|
||||
|
||||
// Platz links für die Y-Achse
|
||||
const axisW = 36
|
||||
const plotX = axisW
|
||||
const plotW = w - axisW - 4
|
||||
const plotY0 = 4
|
||||
const plotY1 = h - 4
|
||||
const plotH = plotY1 - plotY0
|
||||
|
||||
// === Auto-Skalierung ===
|
||||
let yMin = cfg.yMin
|
||||
let yMax = cfg.yMax
|
||||
if (cfg.autoScale && points.length > 0) {
|
||||
let maxV = -Infinity, minV = Infinity
|
||||
for (const p of points) {
|
||||
if (p.y > maxV) maxV = p.y
|
||||
if (p.y < minV) minV = p.y
|
||||
}
|
||||
// yMax dynamisch: mindestens Config-Wert, erweitert wenn Daten darüber
|
||||
if (maxV > yMax) {
|
||||
yMax = this.niceCeiling(maxV * 1.10)
|
||||
}
|
||||
// yMin darf nach unten gehen, wenn Daten darunter
|
||||
if (minV < yMin) {
|
||||
yMin = this.niceFloor(minV * 1.10)
|
||||
}
|
||||
}
|
||||
const yRange = yMax - yMin || 1
|
||||
|
||||
// Helper: y-Wert → Pixel im Plot-Bereich
|
||||
const yToPx = (y: number) =>
|
||||
plotY0 + plotH - ((y - yMin) / yRange) * plotH
|
||||
|
||||
// === Hintergrund-Zonen (werden nur im Plot-Bereich gezeichnet) ===
|
||||
let bgRects = ''
|
||||
if (cfg.zones) {
|
||||
for (const z of cfg.zones) {
|
||||
const y1 = Math.max(plotY0, yToPx(z.to))
|
||||
const y2 = Math.min(plotY1, yToPx(z.from))
|
||||
const zoneH = Math.max(0, y2 - y1)
|
||||
if (zoneH <= 0) continue
|
||||
bgRects += `<rect x="${plotX}" y="${y1.toFixed(1)}" width="${plotW}" height="${zoneH.toFixed(1)}" fill="${z.color}" />`
|
||||
if (z.label) {
|
||||
bgRects += `<text x="${plotX + 4}" y="${(y1 + 10).toFixed(1)}" font-size="9" fill="rgba(40,40,40,.55)" font-weight="600">${this.escapeXml(z.label)}</text>`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Y-Achse mit Tick-Labels (links) ===
|
||||
let yAxis = ''
|
||||
// 4 Ticks: yMin, 1/3, 2/3, yMax
|
||||
const ticks = [yMin, yMin + yRange / 3, yMin + (yRange * 2) / 3, yMax]
|
||||
for (const t of ticks) {
|
||||
const y = yToPx(t)
|
||||
// Tick-Text
|
||||
const labelText = this.formatTick(t, cfg)
|
||||
yAxis += `<text x="${(axisW - 4).toFixed(1)}" y="${(y + 3).toFixed(1)}" text-anchor="end" font-size="9" fill="rgba(40,40,40,.55)" font-weight="600">${this.escapeXml(labelText)}</text>`
|
||||
// Dezenter Gitterstrich
|
||||
yAxis += `<line x1="${plotX}" y1="${y.toFixed(1)}" x2="${plotX + plotW}" y2="${y.toFixed(1)}" stroke="rgba(0,0,0,0.06)" stroke-width="0.8" />`
|
||||
}
|
||||
|
||||
// === Referenzlinien ===
|
||||
let refLines = ''
|
||||
if (cfg.lines) {
|
||||
for (const l of cfg.lines) {
|
||||
const y = yToPx(l.at)
|
||||
if (y < plotY0 || y > plotY1) continue
|
||||
const dash = l.dashed ? 'stroke-dasharray="3 3"' : ''
|
||||
refLines += `<line x1="${plotX}" y1="${y.toFixed(1)}" x2="${plotX + plotW}" y2="${y.toFixed(1)}" stroke="${l.color}" stroke-width="1.3" ${dash} />`
|
||||
if (l.label) {
|
||||
refLines += `<text x="${(plotX + plotW - 4).toFixed(1)}" y="${(y - 2).toFixed(1)}" text-anchor="end" font-size="9" font-weight="700" fill="${l.color}">${this.escapeXml(l.label)}</text>`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Linie + Punkt + aktueller Wert ===
|
||||
let linePath = ''
|
||||
let valueLabel = ''
|
||||
if (points.length >= 1) {
|
||||
const path = points.map((p, i) => {
|
||||
const x = plotX + (p.x / maxTicks) * plotW
|
||||
const y = Math.max(plotY0 + 2, Math.min(plotY1 - 2, yToPx(p.y)))
|
||||
return `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`
|
||||
}).join(' ')
|
||||
if (points.length >= 2) {
|
||||
linePath = `<path d="${path}" fill="none" stroke="${cfg.color}" stroke-width="2.3" stroke-linejoin="round" stroke-linecap="round" />`
|
||||
}
|
||||
const lastPt = points[points.length - 1]
|
||||
const xLast = plotX + (lastPt.x / maxTicks) * plotW
|
||||
const yLast = Math.max(plotY0 + 6, Math.min(plotY1 - 6, yToPx(lastPt.y)))
|
||||
// Pulsierender Halo (SMIL-Animation, läuft browserseitig völlig autonom)
|
||||
linePath += `
|
||||
<circle cx="${xLast.toFixed(1)}" cy="${yLast.toFixed(1)}" r="5" fill="${cfg.color}" opacity="0.45">
|
||||
<animate attributeName="r" values="4;10;4" dur="1.8s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.55;0.05;0.55" dur="1.8s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
`
|
||||
linePath += `<circle cx="${xLast.toFixed(1)}" cy="${yLast.toFixed(1)}" r="3.5" fill="${cfg.color}" stroke="#fff" stroke-width="1.2" />`
|
||||
// Live-Wert direkt aus den Resources holen, damit auch Format mit Einheit funktioniert
|
||||
const liveResource = this.game.getResourcesArray().find(r => r.id === cfg.field)
|
||||
let valText: string
|
||||
if (liveResource?.format) {
|
||||
valText = liveResource.format(lastPt.y)
|
||||
} else if (cfg.format) {
|
||||
valText = cfg.format(lastPt.y)
|
||||
} else {
|
||||
const num = lastPt.y.toFixed(Math.abs(lastPt.y) > 50 ? 0 : 1)
|
||||
valText = cfg.unit ? `${num} ${cfg.unit}` : num
|
||||
}
|
||||
const textW = Math.max(40, Math.min(108, valText.length * 6.2 + 14))
|
||||
const badgeX = plotX + plotW - textW
|
||||
valueLabel = `
|
||||
<rect x="${badgeX.toFixed(1)}" y="4" width="${textW}" height="16" rx="4" fill="${cfg.color}" />
|
||||
<text x="${(plotX + plotW - 6).toFixed(1)}" y="15" text-anchor="end" font-size="10" font-weight="800" fill="#fff">${this.escapeXml(valText)}</text>
|
||||
`
|
||||
}
|
||||
|
||||
svg.innerHTML = `
|
||||
${bgRects}
|
||||
${yAxis}
|
||||
${refLines}
|
||||
${linePath}
|
||||
${valueLabel}
|
||||
`
|
||||
}
|
||||
|
||||
/** Kurz-Format für Y-Achsen-Ticks */
|
||||
private formatTick(v: number, cfg: GraphConfig): string {
|
||||
// Sehr kompakt halten — Platz ist limitiert
|
||||
if (Math.abs(v) >= 1000) return (v / 1000).toFixed(1).replace('.0', '') + 'k'
|
||||
if (Math.abs(v) >= 100) return Math.round(v).toString()
|
||||
if (Number.isInteger(v)) return v.toString()
|
||||
return v.toFixed(1)
|
||||
}
|
||||
|
||||
/** Rundet nach oben auf schöne Werte: 10, 20, 50, 100, 200, 500, 1000, ... */
|
||||
private niceCeiling(v: number): number {
|
||||
if (v <= 0) return 10
|
||||
const exp = Math.floor(Math.log10(v))
|
||||
const base = Math.pow(10, exp)
|
||||
const n = v / base
|
||||
let nice: number
|
||||
if (n <= 1) nice = 1
|
||||
else if (n <= 2) nice = 2
|
||||
else if (n <= 5) nice = 5
|
||||
else nice = 10
|
||||
return nice * base
|
||||
}
|
||||
|
||||
private niceFloor(v: number): number {
|
||||
if (v >= 0) return 0
|
||||
return -this.niceCeiling(-v)
|
||||
}
|
||||
|
||||
private escapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
private renderTutorial(): void {
|
||||
const el = document.getElementById('tutorial')
|
||||
if (!el) return
|
||||
const step = this.game.getCurrentTutorialStep()
|
||||
const snap = this.game.getSnapshot()
|
||||
|
||||
if (!step || snap.state !== 'tutorial') {
|
||||
el.style.display = 'none'
|
||||
if (!this.renderer) {
|
||||
const wrap = document.getElementById('canvas-wrap')!
|
||||
this.renderer = new this.cfg.Renderer(wrap, this.game)
|
||||
this.renderer.start()
|
||||
}
|
||||
return
|
||||
}
|
||||
el.style.display = 'flex'
|
||||
document.getElementById('tut-title')!.textContent = step.title
|
||||
document.getElementById('tut-text')!.textContent = step.text
|
||||
|
||||
const totalSteps = (this.game as any).meta?.tutorialSteps || 4
|
||||
const dotsEl = document.getElementById('tut-dots')!
|
||||
dotsEl.innerHTML = ''
|
||||
for (let i = 0; i < totalSteps; i++) {
|
||||
const d = document.createElement('div')
|
||||
d.className = 'dot' + (i === snap.tutorialStep ? ' active' : '')
|
||||
dotsEl.appendChild(d)
|
||||
}
|
||||
const nextBtn = document.getElementById('tut-next')!
|
||||
nextBtn.textContent = snap.tutorialStep === totalSteps - 1 ? 'Simulation starten 🚀' : 'Weiter →'
|
||||
}
|
||||
|
||||
private showEndScreen(): void {
|
||||
if (this.endShown) return
|
||||
this.endShown = true
|
||||
const snap = this.game.getSnapshot()
|
||||
const meta = (this.game as any).meta
|
||||
const maxTicks = meta?.maxTicks || 0
|
||||
const tickUnit = meta?.tickUnit || 'Ticks'
|
||||
|
||||
const won = snap.state === 'won'
|
||||
const lost = snap.state === 'lost'
|
||||
const finishedEarly = !won && !lost && maxTicks > 0 && snap.tick < maxTicks
|
||||
|
||||
// Verpflichtende Ziele auswerten
|
||||
const goalsList = (this.game as any).goals as Array<{ title: string; required?: boolean }>
|
||||
const requiredGoals = (goalsList || []).map((g, i) => ({
|
||||
title: g.title,
|
||||
required: g.required !== false,
|
||||
achieved: snap.goals[i]?.achieved ?? false,
|
||||
})).filter(g => g.required)
|
||||
const requiredAchieved = requiredGoals.filter(g => g.achieved).length
|
||||
const requiredTotal = requiredGoals.length
|
||||
|
||||
// Icon + Title sind ehrlich
|
||||
let icon: string
|
||||
let title: string
|
||||
let summary: string
|
||||
if (won) {
|
||||
icon = '🏆'
|
||||
title = 'Mission erfüllt!'
|
||||
summary = `Du hast bis ${this.cfg.yearOffset ? this.cfg.yearOffset + snap.tick : snap.tick} alle ${requiredTotal} Hauptziele erreicht. Glückwunsch!`
|
||||
} else if (lost) {
|
||||
icon = '💔'
|
||||
title = 'Simulation gescheitert'
|
||||
summary = `Im Jahr ${this.cfg.yearOffset ? this.cfg.yearOffset + snap.tick : snap.tick} ist deine Mission gescheitert. ${requiredAchieved} von ${requiredTotal} Zielen waren erreicht.`
|
||||
} else if (finishedEarly) {
|
||||
icon = '⏹'
|
||||
title = 'Vorzeitig beendet'
|
||||
const remaining = maxTicks - snap.tick
|
||||
summary = `Du hast nach ${snap.tick} von ${maxTicks} ${tickUnit} abgebrochen — noch ${remaining} ${tickUnit} wären übrig gewesen. ${requiredAchieved} von ${requiredTotal} Zielen waren zu diesem Zeitpunkt erreicht. <strong>Das ist kein Sieg.</strong>`
|
||||
} else {
|
||||
icon = '🏁'
|
||||
title = 'Simulationsdauer zu Ende'
|
||||
summary = `Du hast ${snap.tick} ${tickUnit} überstanden, aber nicht alle Hauptziele erreicht (${requiredAchieved}/${requiredTotal}).`
|
||||
}
|
||||
|
||||
// Goal-Liste mit ✓ / ✗
|
||||
const goalsHtml = requiredGoals.map(g => `
|
||||
<div class="end-goal ${g.achieved ? 'done' : 'fail'}">
|
||||
<span class="end-goal-icon">${g.achieved ? '✓' : '✗'}</span>
|
||||
<span>${g.title}</span>
|
||||
</div>
|
||||
`).join('')
|
||||
|
||||
// Resourcen
|
||||
const statsHtml = this.game.getResourcesArray().slice(0, 4).map(r => `
|
||||
<div class="end-stat">
|
||||
<div class="end-stat-val">${r.format ? r.format(r.current) : r.current}</div>
|
||||
<div class="end-stat-lbl">${r.name}</div>
|
||||
</div>
|
||||
`).join('')
|
||||
|
||||
const html = `
|
||||
<div class="end-screen">
|
||||
<div class="end-card">
|
||||
<div class="end-icon">${icon}</div>
|
||||
<h2>${title}</h2>
|
||||
<p class="end-summary">${summary}</p>
|
||||
<div class="end-goals">${goalsHtml}</div>
|
||||
<div class="end-stats">${statsHtml}</div>
|
||||
<button class="tut-btn" id="end-replay">🔄 Nochmal spielen</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
document.getElementById('end-screen-host')!.innerHTML = html
|
||||
document.getElementById('end-replay')?.addEventListener('click', () => {
|
||||
this.resetting = true
|
||||
localStorage.removeItem(this.cfg.saveKey)
|
||||
location.reload()
|
||||
})
|
||||
}
|
||||
|
||||
private toast(msg: string): void {
|
||||
// Simple alert for now
|
||||
alert(msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Info-Overlay — Erklärende Begriffs-Popups für Kinder (10–14 Jahre).
|
||||
*
|
||||
* Nutzung:
|
||||
* import { INFO_TOPICS, openInfoOverlay } from '@ui/info-overlay'
|
||||
* openInfoOverlay(INFO_TOPICS.co2)
|
||||
*
|
||||
* Zeigt ein einfaches, kinderverständliches Erklär-Panel über der Simulation.
|
||||
* Braucht im HTML nur einen leeren <div id="info-overlay-host"></div>.
|
||||
* Die passenden CSS-Klassen (.info-overlay, .info-card, .info-hint) werden
|
||||
* in der jeweiligen Seite definiert.
|
||||
*/
|
||||
|
||||
export interface InfoTopic {
|
||||
title: string
|
||||
/** Lange Erklärung in einfachen Worten. Darf \n\n für Absätze enthalten. */
|
||||
text: string
|
||||
/** Optionaler "Merksatz" am Ende */
|
||||
hint?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Kurzdefinition für Mouseover-Tooltips.
|
||||
* 1–2 Sätze, die eine Einheit oder ein Fachwort knapp erklären.
|
||||
*/
|
||||
export interface TipDef {
|
||||
title: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export const TIPS: Record<string, TipDef> = {
|
||||
ppm: {
|
||||
title: 'ppm',
|
||||
text: '„parts per million" – wie viele CO₂-Teilchen unter 1 Million Luft-Teilchen sind. 425 ppm = 425 CO₂-Teilchen unter einer Million. Früher waren es 280, ab 500 wird es gefährlich.',
|
||||
},
|
||||
celsius: {
|
||||
title: '°C',
|
||||
text: 'Grad Celsius. Die globale Durchschnittstemperatur der Erde. Vorindustriell: 15 °C. Pariser Klimaziel: unter 17 °C halten.',
|
||||
},
|
||||
paris_goal: {
|
||||
title: 'Pariser Klimaziel',
|
||||
text: '2015 haben fast alle Länder der Welt in Paris versprochen, die Erderwärmung auf höchstens 2 °C über dem vorindustriellen Niveau zu begrenzen — besser sogar auf 1,5 °C. In unserer Simulation heißt das: Die globale Temperatur darf nicht über 17 °C steigen.',
|
||||
},
|
||||
cm: {
|
||||
title: 'cm Meeresspiegel',
|
||||
text: 'Zentimeter, um die das Meer seit Start angestiegen ist. 30 cm = Strand weg. 60 cm = erste Häuser betroffen.',
|
||||
},
|
||||
mio_euro: {
|
||||
title: 'Mio €',
|
||||
text: 'Millionen Euro. So viel Geld hat der ganze Inselstaat zur Verfügung. 1 Mio € = 1.000.000 €.',
|
||||
},
|
||||
population: {
|
||||
title: 'Bevölkerung',
|
||||
text: 'Wie viele Menschen auf der Insel leben. Mehr Leute = mehr Steuern, aber auch mehr zu schützen.',
|
||||
},
|
||||
budget: {
|
||||
title: 'Budget',
|
||||
text: 'Dein Geld. Steigt durch Steuern (Bevölkerung), sinkt durch Baukosten und Wartung. Unter 0 = Pleite.',
|
||||
},
|
||||
flooded: {
|
||||
title: 'Überflutete Gebiete',
|
||||
text: 'Wie viel Prozent deiner Stadt schon unter Wasser stehen. Ab 30% drohst du zu verlieren.',
|
||||
},
|
||||
upkeep: {
|
||||
title: 'Wartung',
|
||||
text: 'Jedes Jahr abgezogene Kosten, um eine Maßnahme (Solar, Wind, Deich …) am Laufen zu halten.',
|
||||
},
|
||||
power: {
|
||||
title: 'Strom (MW)',
|
||||
text: 'Megawatt – die Einheit für elektrische Leistung. Eine Insel mit 6.000 Einwohnern braucht etwa 6 MW. Die Anzeige „6/8 MW" heißt: 6 MW werden gebraucht, 8 MW sind da. Wenn der Bedarf größer wird als die Kapazität, hast du einen Stromausfall.',
|
||||
},
|
||||
blackout: {
|
||||
title: 'Stromausfall',
|
||||
text: 'Wenn deine Kraftwerke nicht genug Strom liefern, wird es kalt. Die Leute fällen Bäume und verheizen das Holz, um warm zu bleiben. Das setzt zusätzliches CO₂ frei — und tote Bäume können kein CO₂ mehr binden. Ein doppelter Schaden! Baue rechtzeitig genug Kraftwerke (Wind oder Solar — Kohle ist billiger, aber schlecht für das Klima).',
|
||||
},
|
||||
}
|
||||
|
||||
let tipEl: HTMLDivElement | null = null
|
||||
let tipInstalled = false
|
||||
|
||||
function ensureTipElement(): HTMLDivElement {
|
||||
if (tipEl) return tipEl
|
||||
tipEl = document.createElement('div')
|
||||
tipEl.className = 'info-tip'
|
||||
tipEl.style.cssText = `
|
||||
position: fixed; pointer-events: none; z-index: 95;
|
||||
max-width: 260px; padding: .55rem .75rem;
|
||||
background: #1a2b33; color: #f5f6f4;
|
||||
border-radius: 8px; font-size: .78rem; line-height: 1.45;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,.35);
|
||||
opacity: 0; transition: opacity .12s ease-out;
|
||||
font-family: inherit;
|
||||
`
|
||||
document.body.appendChild(tipEl)
|
||||
return tipEl
|
||||
}
|
||||
|
||||
function showTip(key: string, x: number, y: number): void {
|
||||
const tip = TIPS[key]
|
||||
if (!tip) return
|
||||
const el = ensureTipElement()
|
||||
el.innerHTML = `
|
||||
<div style="font-weight:800; color:#8ecbd8; margin-bottom:.15rem; font-size:.72rem; letter-spacing:.03em; text-transform:uppercase;">${escapeHtml(tip.title)}</div>
|
||||
<div>${escapeHtml(tip.text)}</div>
|
||||
`
|
||||
// Positionierung: rechts unterhalb des Cursors, außer wenn am Rand
|
||||
const pad = 12
|
||||
const rect = { w: 280, h: 80 } // grobe Schätzung
|
||||
let left = x + pad
|
||||
let top = y + pad
|
||||
if (left + rect.w > window.innerWidth) left = x - rect.w - pad
|
||||
if (top + rect.h > window.innerHeight) top = y - rect.h - pad
|
||||
el.style.left = `${Math.max(6, left)}px`
|
||||
el.style.top = `${Math.max(6, top)}px`
|
||||
el.style.opacity = '1'
|
||||
}
|
||||
|
||||
function hideTip(): void {
|
||||
if (tipEl) tipEl.style.opacity = '0'
|
||||
}
|
||||
|
||||
/**
|
||||
* Installiert einen globalen Delegated-Listener für `data-tip="key"`-Elemente.
|
||||
* Muss nur einmal pro Seite aufgerufen werden.
|
||||
*/
|
||||
export function installTooltips(): void {
|
||||
if (tipInstalled) return
|
||||
tipInstalled = true
|
||||
document.addEventListener('mouseover', (e) => {
|
||||
const target = (e.target as HTMLElement)?.closest('[data-tip]') as HTMLElement | null
|
||||
if (!target) return
|
||||
const key = target.getAttribute('data-tip')
|
||||
if (!key) return
|
||||
showTip(key, e.clientX, e.clientY)
|
||||
})
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!tipEl || tipEl.style.opacity === '0') return
|
||||
const target = (e.target as HTMLElement)?.closest('[data-tip]') as HTMLElement | null
|
||||
if (!target) { hideTip(); return }
|
||||
const key = target.getAttribute('data-tip')
|
||||
if (!key || !TIPS[key]) { hideTip(); return }
|
||||
// Tooltip mitbewegen
|
||||
const pad = 12
|
||||
let left = e.clientX + pad
|
||||
let top = e.clientY + pad
|
||||
if (left + 280 > window.innerWidth) left = e.clientX - 290
|
||||
if (top + 80 > window.innerHeight) top = e.clientY - 90
|
||||
tipEl.style.left = `${Math.max(6, left)}px`
|
||||
tipEl.style.top = `${Math.max(6, top)}px`
|
||||
})
|
||||
document.addEventListener('mouseout', (e) => {
|
||||
const target = (e.target as HTMLElement)?.closest('[data-tip]')
|
||||
if (!target) return
|
||||
hideTip()
|
||||
})
|
||||
}
|
||||
|
||||
export const INFO_TOPICS: Record<string, InfoTopic> = {
|
||||
co2: {
|
||||
title: '🌫 CO₂ — das Treibhaus-Gas',
|
||||
text:
|
||||
'CO₂ (Kohlenstoffdioxid) ist ein unsichtbares Gas in der Luft. Du atmest es auch aus. Pflanzen brauchen es zum Wachsen.\n\n' +
|
||||
'Das Problem: Wenn Menschen Benzin, Kohle oder Gas verbrennen, entsteht sehr viel CO₂. Das bleibt über hunderte Jahre in der Luft und wirkt wie ein dickes Dach über der Erde — die Wärme der Sonne kommt rein, aber nur schwer wieder raus.\n\n' +
|
||||
'Vor 200 Jahren hatte unsere Luft etwa 280 ppm CO₂. Heute sind es über 420 ppm. Je mehr CO₂, desto wärmer wird es.',
|
||||
hint: 'ppm bedeutet: wieviele CO₂-Teilchen in einer Million Luft-Teilchen drinstecken.',
|
||||
},
|
||||
|
||||
temperature: {
|
||||
title: '🌡 Globale Temperatur',
|
||||
text:
|
||||
'Die Erde hat eine Durchschnittstemperatur. Das ist die mittlere Temperatur von allen Orten und allen Jahreszeiten zusammen.\n\n' +
|
||||
'Vor der Industrialisierung (etwa 1850) lag sie bei rund 15°C. Heute ist sie schon um mehr als 1°C gestiegen.\n\n' +
|
||||
'Das klingt wenig — aber 2°C mehr würden bedeuten: Gletscher schmelzen, Meere steigen, Hitzewellen werden häufiger, viele Tiere verlieren ihr Zuhause.',
|
||||
hint: 'Im Pariser Klimaabkommen (2015) haben sich die Länder geeinigt, die Erwärmung auf unter 2°C zu halten — möglichst sogar unter 1,5°C.',
|
||||
},
|
||||
|
||||
sealevel: {
|
||||
title: '🌊 Meeresspiegelanstieg',
|
||||
text:
|
||||
'Wenn es wärmer wird, passieren zwei Dinge mit den Meeren:\n\n' +
|
||||
'1) Das Wasser dehnt sich aus. Wie ein Metallstab, der im Feuer länger wird.\n' +
|
||||
'2) Die Gletscher und das Eis am Nord- und Südpol schmelzen. Das viele Schmelzwasser fließt ins Meer.\n\n' +
|
||||
'Beides zusammen lässt den Meeresspiegel langsam, aber unaufhaltsam steigen. Schon 50 cm mehr reichen, um Strände, Felder und ganze Küstenstädte unter Wasser zu setzen.\n\n' +
|
||||
'Inselstaaten wie Tuvalu oder die Malediven sind besonders gefährdet. Dort leben Menschen, die ihr Zuhause verlieren könnten.',
|
||||
hint: 'Wenn das gesamte Eis der Antarktis schmelzen würde, stiege das Meer um etwa 60 Meter.',
|
||||
},
|
||||
|
||||
budget: {
|
||||
title: '💰 Budget',
|
||||
text:
|
||||
'Dein Budget ist das Geld, das der ganzen Insel zur Verfügung steht — angegeben in Millionen Euro (Mio €). Jedes Jahr bekommst du Steuern von der Bevölkerung. Je mehr Menschen auf der Insel leben, desto mehr kannst du einnehmen.\n\n' +
|
||||
'Aber: Alles was du gebaut hast, kostet auch jedes Jahr Wartung. Ein Windpark kostet z. B. 18 Mio € pro Jahr, damit er gewartet wird und funktioniert.\n\n' +
|
||||
'Wenn dein Budget unter 0 Mio € fällt, bist du pleite — dann hast du verloren.',
|
||||
hint: 'Überlege vor jedem Bau: Kann ich mir auch die Wartung in den nächsten Jahren leisten?',
|
||||
},
|
||||
|
||||
glacier: {
|
||||
title: '🏔 Gletscher',
|
||||
text:
|
||||
'Gletscher sind riesige, uralte Eismassen. Sie entstehen in den Bergen, wo über Jahrtausende Schnee fällt und sich zu Eis verdichtet.\n\n' +
|
||||
'Gletscher sind wichtig: Im Sommer schmilzt etwas Eis und liefert Trinkwasser für Menschen, Tiere und Felder weiter unten im Tal.\n\n' +
|
||||
'Wenn es zu warm wird, schmelzen die Gletscher schneller als neuer Schnee nachkommt. Am Ende sind sie ganz verschwunden — und das Trinkwasser im Sommer fehlt.',
|
||||
hint: 'Der Vulkan auf unserer Insel hat einen Gletscher als Kappe. Wenn die Temperatur steigt, kannst du zusehen, wie er schrumpft.',
|
||||
},
|
||||
|
||||
wedge: {
|
||||
title: '⛰ Warum ist die Insel schräg?',
|
||||
text:
|
||||
'Echte Inseln sind fast nie flach. Sie haben meistens eine höhere Seite (zum Beispiel mit einem Berg) und eine niedrigere, die sanft ins Meer abfällt.\n\n' +
|
||||
'Das Tiefland ist besonders in Gefahr, wenn der Meeresspiegel steigt. Genau das passiert auch auf unserer Insel: Die Vorderseite wird zuerst nass, weil sie fast auf Meereshöhe liegt.\n\n' +
|
||||
'Das ist kein Programmfehler — das soll zeigen, dass man mit Deichen zwar Häuser schützen kann, aber nicht die ganze Landschaft.',
|
||||
},
|
||||
|
||||
deich: {
|
||||
title: '🌊 Deich',
|
||||
text:
|
||||
'Ein Deich ist ein aufgeschütteter Wall aus Erde, Sand und Gras, der das Meer von einer Stadt oder einem Feld fernhält.\n\n' +
|
||||
'In den Niederlanden werden Deiche seit über 1000 Jahren gebaut. Ohne sie wäre ein großer Teil des Landes Meer.\n\n' +
|
||||
'Ein Deich im Spiel schützt deine Häuser davor, dass sie vom steigenden Meer überflutet werden — aber er hilft nicht gegen die Klimaerwärmung selbst. Du musst trotzdem das CO₂ reduzieren.',
|
||||
hint: 'Deiche sind eine "Anpassung" — man bekämpft nicht die Ursache, sondern die Folge.',
|
||||
},
|
||||
|
||||
solar: {
|
||||
title: '☀️ Solaranlage',
|
||||
text:
|
||||
'Eine Solaranlage macht aus Sonnenlicht Strom. Auf ihren dunkelblauen Panels sitzen viele kleine Zellen, die Licht in elektrischen Strom umwandeln.\n\n' +
|
||||
'Der große Vorteil: Es entsteht kein CO₂. Das ist der wichtigste Unterschied zu einem Kraftwerk, das Kohle oder Gas verbrennt.\n\n' +
|
||||
'Deshalb ersetzt jede Solaranlage ein Stück "schmutzigen" Strom. In der Simulation siehst du das so: Bei jedem Haus, das eine Solaranlage hat, verschwindet die Rauchwolke vom Schornstein.',
|
||||
},
|
||||
|
||||
wind: {
|
||||
title: '💨 Windpark',
|
||||
text:
|
||||
'Ein Windpark sind mehrere große Windräder. Der Wind dreht die Flügel, und ein Generator im Turm macht daraus Strom.\n\n' +
|
||||
'Wind ist kostenlos und erzeugt keinen CO₂. Ein einziges großes Windrad kann den Strom für hunderte Haushalte liefern.\n\n' +
|
||||
'In der Simulation ist ein Windpark teurer als eine Solaranlage, reduziert aber deutlich mehr CO₂. Er ist die stärkste Einzel-Maßnahme.',
|
||||
},
|
||||
|
||||
forest: {
|
||||
title: '🌲 Wald aufforsten',
|
||||
text:
|
||||
'Bäume holen CO₂ aus der Luft und speichern es in ihrem Holz. Ein ausgewachsener Baum bindet pro Jahr etwa 10 kg CO₂.\n\n' +
|
||||
'Wenn du einen Wald pflanzt, hilfst du also direkt gegen den Klimawandel. Der Effekt ist aber klein — du brauchst sehr viele Bäume, um wirklich etwas zu bewirken.\n\n' +
|
||||
'Wälder sind günstig, aber allein reichen sie nicht aus. Am besten kombinierst du sie mit Solaranlagen oder Windrädern.',
|
||||
},
|
||||
|
||||
vegetation: {
|
||||
title: '🌿 Warum sterben Pflanzen bei Meeresanstieg?',
|
||||
text:
|
||||
'Die meisten Pflanzen können kein salziges Wasser vertragen. Wenn das Meer steigt, sickert Salzwasser durch den Boden — auch dort, wo du es gar nicht siehst.\n\n' +
|
||||
'Das Salz zerstört die feinen Wurzeln. Die Pflanzen können kein Wasser mehr aufnehmen und vertrocknen, obwohl der Boden nass ist. Ganze Wälder und Felder werden so kaputt.\n\n' +
|
||||
'In Kenia, an der Küste, sind in den letzten Jahren schon viele Mangroven und Bauernfelder durch Salzwasser gestorben. Das ist nicht Theorie — das passiert gerade.',
|
||||
hint: 'Fachbegriff: Versalzung. Erst gehen die Pflanzen kaputt, dann die Böden.',
|
||||
},
|
||||
|
||||
drinking_water: {
|
||||
title: '💧 Trinkwasser in Gefahr',
|
||||
text:
|
||||
'Auf Inseln gibt es meistens keine Flüsse oder Seen. Die Menschen bekommen ihr Trinkwasser aus Brunnen — also aus Süßwasser, das unter der Erde liegt.\n\n' +
|
||||
'Wenn das Meer steigt, drückt das salzige Meerwasser unter der Insel durch und vermischt sich mit dem Süßwasser. Der Brunnen gibt dann salziges Wasser — und das kann man nicht trinken.\n\n' +
|
||||
'Viele Inseln haben deshalb heute schon Probleme, obwohl die Häuser noch gar nicht unter Wasser stehen.',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffnet das Info-Overlay. Braucht einen <div id="info-overlay-host"></div>
|
||||
* im Seiten-HTML.
|
||||
*/
|
||||
export function openInfoOverlay(topic: InfoTopic | null | undefined): void {
|
||||
const host = document.getElementById('info-overlay-host')
|
||||
if (!host) return
|
||||
// Defensive: niemals ein leeres Modal zeigen, wenn topic fehlt oder kein Inhalt da ist.
|
||||
if (!topic || !topic.title || !topic.text) {
|
||||
host.innerHTML = ''
|
||||
return
|
||||
}
|
||||
const paragraphs = topic.text.split('\n\n').map(p => `<p>${escapeHtml(p)}</p>`).join('')
|
||||
const hintHtml = topic.hint
|
||||
? `<div class="info-hint">💡 ${escapeHtml(topic.hint)}</div>`
|
||||
: ''
|
||||
host.innerHTML = `
|
||||
<div class="info-overlay" id="info-overlay-bg">
|
||||
<div class="info-card">
|
||||
<h3>${escapeHtml(topic.title)}</h3>
|
||||
${paragraphs}
|
||||
${hintHtml}
|
||||
<button class="tut-btn" id="info-close">Verstanden ✓</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
const close = () => { host.innerHTML = '' }
|
||||
document.getElementById('info-close')?.addEventListener('click', close)
|
||||
document.getElementById('info-overlay-bg')?.addEventListener('click', (e) => {
|
||||
if ((e.target as HTMLElement).id === 'info-overlay-bg') close()
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
Reference in New Issue
Block a user