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,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