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