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,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user