1e51ef7def
- Konzept/, didaktik_geografie/, didaktik_simulation/, v2-modules/, v2-platform/ - 12 code-workspace-Files - STATUS-*.md - viele M/D/R-Änderungen an bereits getrackten Files - .gitignore verstärkt: **/.humaninput/, **/secret_keys.txt Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
793 lines
33 KiB
JavaScript
793 lines
33 KiB
JavaScript
/**
|
|
* Flussmanagement — Engine (Single Source of Truth)
|
|
* ------------------------------------------------------------------
|
|
* Headless Spiellogik: kein DOM, keine Audio-Aufrufe, kein Zeichnen.
|
|
* Der Konsument (game.html) ruft die Engine-Methoden auf und zeichnet
|
|
* den zurueckgegebenen Zustand. Events werden in einem internen Puffer
|
|
* `game._pendingEvents` gesammelt und bei der naechsten mutierenden
|
|
* Aktion zurueckgegeben.
|
|
*
|
|
* Portiert aus App/fluss.html (V1). Logik bleibt weitgehend erhalten,
|
|
* wird aber in eine serialisierbare State-Struktur gehoben, damit
|
|
* Autosave und Resume sauber funktionieren.
|
|
*
|
|
* Exportiert als globales `window.FlussEngine` (kein ES-Module).
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
/* ============================================================
|
|
KONSTANTEN — Grid, Werkzeuge, Wetter, Schwierigkeit
|
|
============================================================ */
|
|
|
|
const COLS = 20;
|
|
const ROWS = 16;
|
|
const RIVER_SEGMENTS = ROWS; // ein Fluss-Punkt pro Zeile
|
|
const MAX_YEARS_DEFAULT = 100;
|
|
|
|
const TOOLS = [
|
|
{ id: 'inspect', name: 'Ansehen', icon: '🔍', cost: 0, desc: 'Klicken, um ein Feld zu untersuchen.' },
|
|
{ id: 'deich', name: 'Deich bauen', icon: '🛡️', cost: 8, desc: 'Schützt angrenzende Felder vor Hochwasser.' },
|
|
{ id: 'feld', name: 'Feld anlegen', icon: '🌾', cost: 4, desc: 'Mehr Nahrung, aber weniger Biodiversität.' },
|
|
{ id: 'siedlung', name: 'Siedlung bauen', icon: '🏘️', cost: 12, desc: 'Mehr Bevölkerung und Wirtschaft.' },
|
|
{ id: 'wald', name: 'Wald pflanzen', icon: '🌳', cost: 6, desc: 'Stärkt Biodiversität und Retention.' },
|
|
{ id: 'abriss', name: 'Abriss', icon: '🧨', cost: 2, desc: 'Entfernt ein gebautes Feld.' },
|
|
{ id: 'begradigen', name: 'Fluss begradigen', icon: '📏', cost: 15, desc: 'Gewinnt Bauland, schadet Biodiversität.' },
|
|
];
|
|
|
|
const WEATHER = [
|
|
{ key: 'drought', name: 'Dürre', icon: '☀️', factor: 0.2, prob: 0.12 },
|
|
{ key: 'dry', name: 'Trocken', icon: '🌤️', factor: 0.4, prob: 0.25 },
|
|
{ key: 'normal', name: 'Normal', icon: '⛅', factor: 0.8, prob: 0.33 },
|
|
{ key: 'rain', name: 'Regen', icon: '🌧️', factor: 1.5, prob: 0.20 },
|
|
{ key: 'heavy_rain', name: 'Starkregen', icon: '⛈️', factor: 2.8, prob: 0.10 },
|
|
];
|
|
|
|
// Achievements — werden in tick/applyTool gesetzt und bei Level-Ende
|
|
// ausgewertet.
|
|
// Tierarten als Biodiversitaets-Belohnung. Je hoeher der Biodiv-Wert,
|
|
// desto seltenere Arten siedeln sich an. Hysterese: Arten verschwinden
|
|
// erst, wenn Biodiv 5 Punkte unter die Schwelle faellt — verhindert
|
|
// Flapping an der Grenze.
|
|
const SPECIES = [
|
|
{ key: 'hase', icon: '🐇', name: 'Feldhase', threshold: 40 },
|
|
{ key: 'frosch', icon: '🐸', name: 'Laubfrosch', threshold: 50 },
|
|
{ key: 'ente', icon: '🦆', name: 'Stockente', threshold: 58 },
|
|
{ key: 'biene', icon: '🐝', name: 'Wildbiene', threshold: 62 },
|
|
{ key: 'falter', icon: '🦋', name: 'Schmetterling', threshold: 68 },
|
|
{ key: 'reh', icon: '🦌', name: 'Reh', threshold: 74 },
|
|
{ key: 'eichhorn', icon: '🐿️', name: 'Eichhörnchen', threshold: 80 },
|
|
{ key: 'eule', icon: '🦉', name: 'Waldkauz', threshold: 86 },
|
|
{ key: 'fuchs', icon: '🦊', name: 'Fuchs', threshold: 91 },
|
|
{ key: 'storch', icon: '🕊️', name: 'Weißstorch', threshold: 96 },
|
|
];
|
|
const SPECIES_HYSTERESIS = 5;
|
|
|
|
const ACHIEVEMENTS = [
|
|
{ key: 'first_deich', icon: '🛡️', title: 'Sicher ist sicher', desc: 'Ersten Deich gebaut.' },
|
|
{ key: 'first_wald', icon: '🌳', title: 'Grüner Daumen', desc: 'Ersten Wald gepflanzt.' },
|
|
{ key: 'decade_safe', icon: '🏆', title: 'Zehn Jahre stabil', desc: '10 Jahre ohne zerstörte Siedlung.' },
|
|
{ key: 'balance_found', icon: '⚖️', title: 'Ausgleich gefunden', desc: 'Biodiv. > 60 UND Wirtschaft > 50 über 50 Jahre.' },
|
|
{ key: 'renaturierung', icon: '🦋', title: 'Renaturiert', desc: 'Begradigung wieder aufgelöst.' },
|
|
{ key: 'survivor', icon: '🎖️', title: 'Century-Hüter', desc: '100 Jahre am Fluss überlebt.' },
|
|
];
|
|
|
|
// Hilfs-PRNG — wir verlassen uns auf Math.random(), aber der Seed-Block
|
|
// dient als Platzhalter, falls spaeter deterministische Replays noetig werden.
|
|
|
|
function rollWeather() {
|
|
let r = Math.random(), cum = 0;
|
|
for (const w of WEATHER) { cum += w.prob; if (r < cum) return w; }
|
|
return WEATHER[2];
|
|
}
|
|
|
|
/* ============================================================
|
|
GAME STATE FABRIK
|
|
============================================================ */
|
|
|
|
function createGame(opts) {
|
|
opts = opts || {};
|
|
const level = opts.level || 1;
|
|
const cfg = opts.levelConfig || {};
|
|
const maxYears = cfg.timeLimit || MAX_YEARS_DEFAULT;
|
|
const startBudget = cfg.startBudget != null ? cfg.startBudget : 60;
|
|
const eventFreq = cfg.eventFrequency != null ? cfg.eventFrequency : 0.3;
|
|
const toolsAvailable = cfg.toolsAvailable || TOOLS.map(t => t.id);
|
|
|
|
const game = {
|
|
level: level,
|
|
levelConfig: cfg,
|
|
maxYears: maxYears,
|
|
eventFreq: eventFreq,
|
|
toolsAvailable: toolsAvailable,
|
|
|
|
// Fluss: 16 Spaltenpositionen + Engineering-Stufe
|
|
riverCol: [8,9,10,11,10,9,8,7,8,9,10,11,10,9,8,7],
|
|
riverColSmooth: [8,9,10,11,10,9,8,7,8,9,10,11,10,9,8,7],
|
|
riverEng: new Array(RIVER_SEGMENTS).fill(0),
|
|
riverRadius: [],
|
|
|
|
// Land-Grid (ROWS x COLS)
|
|
land: [],
|
|
tilePop: [],
|
|
tileYield: [],
|
|
tileFishYield: [], // Fischfang pro Siedlung, fuer Rendering
|
|
floodIntensity: [],
|
|
tileFloodYears: [],
|
|
|
|
// Wetter & Zeit
|
|
weather: WEATHER[2],
|
|
wxTimer: 0,
|
|
year: 0,
|
|
budget: startBudget,
|
|
gameOver: false,
|
|
|
|
// Verlaufsgeschichte fuer Graphen (pro Jahr ein Eintrag)
|
|
history: [],
|
|
|
|
// Didaktik-Tracking
|
|
achievements: {}, // key -> true
|
|
species: {}, // key -> true (vorhandene Tierarten)
|
|
stats: {
|
|
settlementsLost: 0,
|
|
fieldsLost: 0,
|
|
yearsWithoutLoss: 0,
|
|
balancedYears: 0,
|
|
straightenCount: 0,
|
|
denaturalizeCount: 0,
|
|
builtTools: {}, // tool -> count
|
|
},
|
|
|
|
_pendingEvents: [],
|
|
_nextEventId: 1,
|
|
};
|
|
|
|
// Fluss-Radien initialisieren (variieren)
|
|
for (let i = 0; i < RIVER_SEGMENTS; i++) {
|
|
game.riverRadius[i] = 0.8 + Math.sin(i * 0.9) * 0.3 + Math.random() * 0.2;
|
|
}
|
|
|
|
// Grid initialisieren
|
|
for (let r = 0; r < ROWS; r++) {
|
|
game.land[r] = []; game.tilePop[r] = []; game.tileYield[r] = [];
|
|
game.tileFishYield[r] = [];
|
|
game.floodIntensity[r] = []; game.tileFloodYears[r] = [];
|
|
for (let c = 0; c < COLS; c++) {
|
|
const dist = Math.abs(c - game.riverCol[r]);
|
|
if (dist <= 1) game.land[r][c] = 'meadow';
|
|
else if (dist > 4 && Math.random() < 0.5) game.land[r][c] = 'forest';
|
|
else game.land[r][c] = 'meadow';
|
|
game.tilePop[r][c] = 0;
|
|
game.tileYield[r][c] = 0;
|
|
game.tileFishYield[r][c] = 0;
|
|
game.floodIntensity[r][c] = 0;
|
|
game.tileFloodYears[r][c] = 0;
|
|
}
|
|
}
|
|
|
|
// Startgebaeude (eine Siedlung + zwei Felder)
|
|
game.land[4][13] = 'settlement';
|
|
game.tilePop[4][13] = 3;
|
|
game.land[5][13] = 'field';
|
|
game.land[5][14] = 'field';
|
|
|
|
// Startzustand hat biodiv ~100: alle Arten, deren Schwelle erfuellt ist,
|
|
// sind bereits da (ohne Toast-Flut).
|
|
const startBiodiv = estimateStartBiodiv(game);
|
|
SPECIES.forEach(sp => {
|
|
if (startBiodiv >= sp.threshold) game.species[sp.key] = true;
|
|
});
|
|
|
|
pushEvent(game, '🏞️', 'Willkommen am Fluss! Er wandert — plane voraus.', 'neutral');
|
|
return game;
|
|
}
|
|
|
|
function estimateStartBiodiv(game) {
|
|
let forests = 0, settlements = 0, fields = 0;
|
|
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
|
|
const t = game.land[r][c];
|
|
if (t === 'forest') forests++;
|
|
else if (t === 'settlement') settlements++;
|
|
else if (t === 'field') fields++;
|
|
}
|
|
// Selbe Formel wie im Tick-Snapshot.
|
|
return clamp100(forests * 2 - (settlements + fields) * 2.5);
|
|
}
|
|
|
|
/* ============================================================
|
|
FLUSS-GEOMETRIE
|
|
============================================================ */
|
|
|
|
function distToRiver(game, r, c) {
|
|
let minD = 999;
|
|
for (let i = 0; i < ROWS; i++) {
|
|
const rc = game.riverColSmooth[i];
|
|
const d = Math.sqrt((r - i) * (r - i) + (c - rc) * (c - rc));
|
|
if (d < minD) minD = d;
|
|
if (i < ROWS - 1) {
|
|
const rc2 = game.riverColSmooth[i + 1];
|
|
const d2 = Math.sqrt((r - (i + 1)) * (r - (i + 1)) + (c - rc2) * (c - rc2));
|
|
if (d2 < minD) minD = d2;
|
|
}
|
|
}
|
|
return minD;
|
|
}
|
|
|
|
function isRiverTile(game, r, c) { return distToRiver(game, r, c) < 1.3; }
|
|
|
|
function hasLeveeNear(game, r, c) {
|
|
for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) {
|
|
const nr = r + dr, nc = c + dc;
|
|
if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS && game.land[nr][nc] === 'levee') return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/* ============================================================
|
|
TICK — ein Jahr weiter
|
|
============================================================ */
|
|
|
|
function tick(game) {
|
|
if (game.gameOver) return [];
|
|
game.year++;
|
|
|
|
// 1. Einkommen / Nahrung / Bevoelkerungsdynamik
|
|
// Globale Einnahmen-Daempfung: Werkzeuge sollen sich nicht zu schnell
|
|
// refinanzieren, sonst hat die Lernende keinen echten Trade-off.
|
|
const INCOME_FACTOR = 0.5;
|
|
let income = 0, totalPop = 0, totalFood = 0;
|
|
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
|
|
const dist = distToRiver(game, r, c);
|
|
const moisture = Math.max(0, 1 - Math.abs(dist - 2.5) / 3);
|
|
const fi = game.floodIntensity[r][c];
|
|
const tile = game.land[r][c];
|
|
|
|
if (tile === 'field') {
|
|
let dryPenalty = 0;
|
|
for (let i = Math.max(0, r-1); i <= Math.min(ROWS-1, r+1); i++) {
|
|
if (game.riverEng[i] > 0) dryPenalty = Math.max(dryPenalty, game.riverEng[i] * 0.25);
|
|
}
|
|
const effMoisture = Math.max(0, moisture - dryPenalty);
|
|
let workerBonus = 0;
|
|
for (let dr = -2; dr <= 2; dr++) for (let dc = -2; dc <= 2; dc++) {
|
|
const nr = r+dr, nc = c+dc;
|
|
if (nr>=0&&nr<ROWS&&nc>=0&&nc<COLS && game.land[nr][nc] === 'settlement')
|
|
workerBonus += game.tilePop[nr][nc] * 0.15;
|
|
}
|
|
workerBonus = Math.min(1, workerBonus);
|
|
const baseYield = (1 + effMoisture * 5) * (1 - fi * 0.9);
|
|
const y = Math.round(Math.max(0, Math.min(8, baseYield * (0.3 + workerBonus * 0.7))));
|
|
game.tileYield[r][c] = y;
|
|
income += y * 0.5;
|
|
totalFood += y;
|
|
}
|
|
if (tile === 'settlement') {
|
|
const cur = game.tilePop[r][c];
|
|
const hasFood = totalFood > totalPop * 0.4;
|
|
if (cur < 8 && hasFood && fi < 0.15 && Math.random() < 0.35) {
|
|
game.tilePop[r][c] = Math.min(8, cur + 1);
|
|
}
|
|
if (fi > 0.2 && cur > 0 && Math.random() < fi * 0.2) {
|
|
game.tilePop[r][c] = Math.max(0, cur - 1);
|
|
}
|
|
totalPop += game.tilePop[r][c];
|
|
income += game.tilePop[r][c] * 0.8;
|
|
// Fischfang: je naeher am Fluss, umso mehr Fische. Hochwasser
|
|
// truebt das Wasser und halbiert den Fang.
|
|
if (dist <= 3 && game.tilePop[r][c] > 0) {
|
|
const floodFactor = fi > 0.3 ? 0.5 : 1;
|
|
const fishCaught = Math.round((3 - dist) * game.tilePop[r][c] * 0.75 * floodFactor);
|
|
totalFood += fishCaught;
|
|
game.tileFishYield[r][c] = fishCaught;
|
|
} else if (game.tileFishYield && game.tileFishYield[r]) {
|
|
game.tileFishYield[r][c] = 0;
|
|
}
|
|
}
|
|
}
|
|
game.budget += Math.round(income * INCOME_FACTOR);
|
|
|
|
// 2. Wetter wechselt alle 3 Jahre
|
|
game.wxTimer++;
|
|
if (game.wxTimer >= 3) { game.wxTimer = 0; game.weather = rollWeather(); }
|
|
|
|
// 3. Fluss wandert — bis zu ZWEI Punkte pro Jahr, wenn nicht befestigt
|
|
// (vorher nur einer; jetzt deutlich lebendiger).
|
|
const moves = 2;
|
|
for (let m = 0; m < moves; m++) {
|
|
const idx = Math.floor(Math.random() * ROWS);
|
|
if (game.riverEng[idx] !== 0) continue;
|
|
if (Math.random() >= 0.95 + game.eventFreq * 0.05) continue; // fast immer
|
|
const dir = Math.random() < 0.5 ? -1 : 1;
|
|
const newCol = game.riverCol[idx] + dir;
|
|
const prevOk = idx === 0 || Math.abs(newCol - game.riverCol[idx - 1]) <= 3;
|
|
const nextOk = idx === ROWS - 1 || Math.abs(newCol - game.riverCol[idx + 1]) <= 3;
|
|
if (!(newCol >= 1 && newCol < COLS - 1 && prevOk && nextOk)) continue;
|
|
{
|
|
game.riverCol[idx] = newCol;
|
|
const prevTile = game.land[idx][newCol];
|
|
if (prevTile === 'settlement') {
|
|
game.land[idx][newCol] = 'ruin';
|
|
game.stats.settlementsLost++;
|
|
pushEvent(game, '🌊', 'Fluss verschlingt Siedlung — die Bewohner fliehen.', 'bad', 'flood-settlement');
|
|
} else if (prevTile === 'field') {
|
|
game.land[idx][newCol] = 'meadow';
|
|
game.stats.fieldsLost++;
|
|
pushEvent(game, '🌊', 'Fluss überflutet ein Feld.', 'bad', 'flood-field');
|
|
} else if (prevTile === 'levee') {
|
|
// Deich haelt — Fluss wird zurueckgedraengt
|
|
game.riverCol[idx] -= dir;
|
|
pushEvent(game, '🛡️', 'Ein Deich hält dem Wasser stand!', 'good', 'levee-hold');
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Hochwasser / Feuchtigkeit smooth updaten
|
|
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
|
|
const dist = distToRiver(game, r, c);
|
|
let target = 0;
|
|
if (game.weather.factor >= 1.0 && dist < 4) {
|
|
const base = Math.max(0, 1 - dist / 4);
|
|
const tile = game.land[r][c];
|
|
const leveeProt = tile === 'levee' ? 0.9 : (hasLeveeNear(game, r, c) ? 0.5 : 0);
|
|
target = base * game.weather.factor * 0.4 * (1 - leveeProt);
|
|
}
|
|
game.floodIntensity[r][c] += (target - game.floodIntensity[r][c]) * 0.2;
|
|
|
|
if (game.floodIntensity[r][c] > 0.35) {
|
|
game.tileFloodYears[r][c] = (game.tileFloodYears[r][c] || 0) + 1;
|
|
} else {
|
|
game.tileFloodYears[r][c] = 0;
|
|
}
|
|
|
|
const tile = game.land[r][c];
|
|
// Schadens-Schwellen: deutlich haerter als V1, damit die Lernende
|
|
// Hochwasser ernst nimmt.
|
|
if (tile === 'field' && game.tileFloodYears[r][c] >= 1) {
|
|
game.land[r][c] = 'meadow';
|
|
game.stats.fieldsLost++;
|
|
pushEvent(game, '🌾', 'Ein Feld ist ertrunken — zu lange unter Wasser.', 'bad', 'flood-field');
|
|
}
|
|
if (tile === 'settlement' && game.floodIntensity[r][c] > 0.4 && Math.random() < 0.12) {
|
|
game.land[r][c] = 'ruin';
|
|
game.stats.settlementsLost++;
|
|
pushEvent(game, '🏚️', 'Eine Siedlung ist durch das Hochwasser zerstört worden!', 'bad', 'flood-settlement');
|
|
}
|
|
if (tile === 'forest' && game.tileFloodYears[r][c] >= 2) {
|
|
game.land[r][c] = 'dead_tree';
|
|
pushEvent(game, '🌳', 'Ein Wald ist im Dauerhochwasser abgestorben.', 'bad', 'tree-flood-die');
|
|
}
|
|
if (tile === 'levee' && game.floodIntensity[r][c] > 0.35 && Math.random() < 0.15) {
|
|
game.land[r][c] = 'meadow';
|
|
pushEvent(game, '🧱', 'Ein Deich wurde vom Hochwasser weggespült!', 'bad', 'levee-break');
|
|
}
|
|
}
|
|
|
|
// 4b. Begradigung trocknet Umgebung aus → Baeume sterben
|
|
for (let i = 0; i < ROWS; i++) {
|
|
if (game.riverEng[i] > 0) {
|
|
const rc = game.riverCol[i];
|
|
for (let dc = -3; dc <= 3; dc++) {
|
|
const cc = rc + dc;
|
|
if (cc < 0 || cc >= COLS) continue;
|
|
if (Math.abs(dc) <= 1) continue;
|
|
if (game.land[i][cc] === 'forest' && Math.random() < 0.03 * game.riverEng[i]) {
|
|
game.land[i][cc] = 'dead_tree';
|
|
pushEvent(game, '🌳', 'Ein Baum vertrocknet neben der Begradigung.', 'bad', 'tree-straighten-die');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. Flussbreite passt sich an (Hochwasser → breiter)
|
|
for (let i = 0; i < ROWS; i++) {
|
|
const target = game.weather.factor > 1.2 ? 1.8 : 1.2;
|
|
game.riverRadius[i] += (target - game.riverRadius[i]) * 0.1;
|
|
}
|
|
|
|
// 6. Ruinen und tote Baeume verfallen
|
|
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
|
|
if (game.land[r][c] === 'ruin' && Math.random() < 0.03) game.land[r][c] = 'meadow';
|
|
if (game.land[r][c] === 'dead_tree' && Math.random() < 0.03) game.land[r][c] = 'meadow';
|
|
}
|
|
|
|
// 7. Smooth-Fluss-Animation (auch im Headless: wir brauchen die Werte
|
|
// fuer Distanz-Berechnungen im naechsten Tick)
|
|
for (let i = 0; i < ROWS; i++) {
|
|
game.riverColSmooth[i] += (game.riverCol[i] - game.riverColSmooth[i]) * 0.3;
|
|
}
|
|
|
|
// 8. History eintragen
|
|
const counts = countTiles(game);
|
|
const avgEng = game.riverEng.reduce((s,v) => s+v, 0) / ROWS;
|
|
const floodTotal = sum2d(game.floodIntensity) / (ROWS * COLS);
|
|
const snapshot = {
|
|
year: game.year,
|
|
pop: clamp100(totalPop * 5 - counts.ruin * 8),
|
|
// Nahrungs-Skala: 100 wird erst erreicht, wenn ungefaehr die Haelfte
|
|
// des Talbodens unter Weizen steht (totalFood ~333). Vorher 1.5
|
|
// war zu locker — Anzeige war fast immer voll.
|
|
food: clamp100(totalFood * 0.3),
|
|
// Biodiv: Wald-Gewicht halbiert (4 -> 2), Bau-Strafe erhoeht (1.5 -> 2.5),
|
|
// damit der Startwert nahe 100 liegt und jeder Bau sofort spuerbar ist.
|
|
biodiv: clamp100(counts.forest * 2 - avgEng * 30 - (counts.settlement + counts.field) * 2.5),
|
|
flood: clamp100(15 + counts.levee * 6 + avgEng * 8 - floodTotal * 80),
|
|
economy: clamp100(totalPop * 3 + counts.field * 2 - counts.ruin * 5),
|
|
budget: game.budget,
|
|
};
|
|
game.history.push(snapshot);
|
|
|
|
// Didaktik-Tracking
|
|
if (game.stats.settlementsLost === 0) game.stats.yearsWithoutLoss++;
|
|
if (snapshot.biodiv > 60 && snapshot.economy > 50) game.stats.balancedYears++;
|
|
else game.stats.balancedYears = 0;
|
|
|
|
// Arten-Check: Schwelle nach oben → neue Art; Schwelle + Hysterese
|
|
// nach unten → Art verschwindet. Die Events landen im Stapel und als
|
|
// Toast, damit der Biodiv-Wandel spuerbar wird.
|
|
SPECIES.forEach(sp => {
|
|
const has = !!game.species[sp.key];
|
|
if (!has && snapshot.biodiv >= sp.threshold) {
|
|
game.species[sp.key] = true;
|
|
pushEvent(game, sp.icon, 'Neue Art im Gebiet: ' + sp.name, 'good', 'species-arrived');
|
|
} else if (has && snapshot.biodiv < sp.threshold - SPECIES_HYSTERESIS) {
|
|
delete game.species[sp.key];
|
|
pushEvent(game, sp.icon, sp.name + ' verschwindet aus dem Gebiet.', 'bad', 'species-left');
|
|
}
|
|
});
|
|
|
|
checkAchievements(game, snapshot);
|
|
|
|
if (game.year >= game.maxYears) {
|
|
game.gameOver = true;
|
|
unlockAchievement(game, 'survivor');
|
|
}
|
|
|
|
return drainEvents(game);
|
|
}
|
|
|
|
/* ============================================================
|
|
TOOLS — Interaktion mit dem Grid
|
|
============================================================ */
|
|
|
|
function applyTool(game, toolId, r, c) {
|
|
if (game.gameOver) return { ok: false, reason: 'game_over', events: drainEvents(game) };
|
|
if (!game.toolsAvailable.includes(toolId)) return { ok: false, reason: 'locked', events: [] };
|
|
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return { ok: false, reason: 'out_of_bounds', events: [] };
|
|
const tool = TOOLS.find(t => t.id === toolId);
|
|
if (!tool) return { ok: false, reason: 'unknown', events: [] };
|
|
const onRiver = isRiverTile(game, r, c);
|
|
const cur = game.land[r][c];
|
|
|
|
if (toolId === 'inspect') {
|
|
const msg = inspectTile(game, r, c);
|
|
pushEvent(game, msg.icon, msg.text, msg.type);
|
|
return { ok: true, events: drainEvents(game) };
|
|
}
|
|
|
|
if (toolId === 'begradigen') {
|
|
if (!onRiver) return fail(game, 'Nur auf dem Fluss anwendbar.');
|
|
if (game.budget < tool.cost) return fail(game, 'Nicht genug Budget.');
|
|
game.riverEng[r] = Math.min(3, game.riverEng[r] + 1);
|
|
game.riverCol[r] = Math.floor(COLS / 2);
|
|
game.budget -= tool.cost;
|
|
game.stats.straightenCount++;
|
|
pushEvent(game, '📏', 'Fluss begradigt — Bauland, aber Ufer trocknet aus.', 'bad', 'begradigen');
|
|
track(game, toolId);
|
|
return { ok: true, events: drainEvents(game) };
|
|
}
|
|
|
|
if (toolId === 'deich') {
|
|
if (onRiver) return fail(game, 'Deich nicht direkt im Fluss möglich.');
|
|
const d = distToRiver(game, r, c);
|
|
if (d > 4) return fail(game, 'Zu weit vom Fluss — Deich muss am Ufer stehen.');
|
|
if (cur !== 'meadow' && cur !== 'forest') return fail(game, 'Nur auf Wiese oder Wald möglich.');
|
|
if (game.budget < tool.cost) return fail(game, 'Nicht genug Budget.');
|
|
game.land[r][c] = 'levee';
|
|
game.budget -= tool.cost;
|
|
unlockAchievement(game, 'first_deich');
|
|
track(game, toolId);
|
|
pushEvent(game, '🛡️', 'Deich gebaut — Schutz für die Nachbarschaft.', 'good');
|
|
return { ok: true, events: drainEvents(game) };
|
|
}
|
|
|
|
if (toolId === 'feld') {
|
|
if (onRiver) return fail(game, 'Feld nicht im Fluss möglich.');
|
|
if (cur !== 'meadow') return fail(game, 'Nur auf freier Wiese möglich.');
|
|
if (game.budget < tool.cost) return fail(game, 'Nicht genug Budget.');
|
|
game.land[r][c] = 'field';
|
|
game.budget -= tool.cost;
|
|
track(game, toolId);
|
|
const d = distToRiver(game, r, c);
|
|
if (d < 2.5) pushEvent(game, '🌾', 'Feld nah am Fluss — guter Ertrag, aber Hochwassergefahr.', 'good');
|
|
else if (d < 4) pushEvent(game, '🌾', 'Feld in guter Lage.', 'good');
|
|
else pushEvent(game, '🌾', 'Feld weit vom Fluss — wird trocken, geringer Ertrag.', 'bad');
|
|
return { ok: true, events: drainEvents(game) };
|
|
}
|
|
|
|
if (toolId === 'siedlung') {
|
|
if (onRiver) return fail(game, 'Siedlung nicht im Fluss möglich.');
|
|
if (cur !== 'meadow' && cur !== 'field') return fail(game, 'Nur auf Wiese oder Feld möglich.');
|
|
if (game.budget < tool.cost) return fail(game, 'Nicht genug Budget.');
|
|
game.land[r][c] = 'settlement';
|
|
game.tilePop[r][c] = 0;
|
|
game.budget -= tool.cost;
|
|
track(game, toolId);
|
|
const d = distToRiver(game, r, c);
|
|
if (d < 2) pushEvent(game, '🏘️', 'Siedlung sehr nah am Fluss — Deich dringend empfohlen!', 'bad');
|
|
else if (d < 4) pushEvent(game, '🏘️', 'Siedlung gebaut — gute Lage.', 'good');
|
|
else pushEvent(game, '🏘️', 'Siedlung sicher, aber weit weg vom Wasser.', 'neutral');
|
|
return { ok: true, events: drainEvents(game) };
|
|
}
|
|
|
|
if (toolId === 'wald') {
|
|
if (onRiver) return fail(game, 'Wald nicht im Fluss möglich.');
|
|
if (cur !== 'meadow') return fail(game, 'Nur auf Wiese möglich.');
|
|
if (game.budget < tool.cost) return fail(game, 'Nicht genug Budget.');
|
|
game.land[r][c] = 'forest';
|
|
game.budget -= tool.cost;
|
|
unlockAchievement(game, 'first_wald');
|
|
track(game, toolId);
|
|
pushEvent(game, '🌳', 'Wald gepflanzt — stärkt Biodiversität und Retention.', 'good');
|
|
return { ok: true, events: drainEvents(game) };
|
|
}
|
|
|
|
if (toolId === 'abriss') {
|
|
if (onRiver && game.riverEng[r] > 0) {
|
|
if (game.budget < tool.cost) return fail(game, 'Nicht genug Budget.');
|
|
game.riverEng[r] = 0;
|
|
game.budget -= tool.cost;
|
|
game.stats.denaturalizeCount++;
|
|
unlockAchievement(game, 'renaturierung');
|
|
pushEvent(game, '🦋', 'Begradigung aufgelöst — der Fluss darf wieder wandern.', 'good', 'renaturierung');
|
|
return { ok: true, events: drainEvents(game) };
|
|
}
|
|
if (!['field','settlement','levee','ruin','dead_tree','forest'].includes(cur))
|
|
return fail(game, 'Hier gibt es nichts zum Abreißen.');
|
|
if (game.budget < tool.cost) return fail(game, 'Nicht genug Budget.');
|
|
game.land[r][c] = 'meadow';
|
|
game.budget -= tool.cost;
|
|
track(game, toolId);
|
|
pushEvent(game, '🧨', 'Gebäude abgerissen.', 'neutral');
|
|
return { ok: true, events: drainEvents(game) };
|
|
}
|
|
|
|
return { ok: false, reason: 'no_match', events: [] };
|
|
}
|
|
|
|
function inspectTile(game, r, c) {
|
|
const dist = distToRiver(game, r, c);
|
|
const fi = game.floodIntensity[r]?.[c] || 0;
|
|
const fy = game.tileFloodYears[r]?.[c] || 0;
|
|
const onRiver = isRiverTile(game, r, c);
|
|
const cur = game.land[r][c];
|
|
|
|
if (onRiver) {
|
|
const eng = game.riverEng[r];
|
|
const engNames = ['natürlich','Holzverbau','Bruchstein','Beton'];
|
|
let t = 'Fluss — Befestigung: ' + (engNames[eng] || 'natürlich') + '. ';
|
|
t += eng > 0 ? 'Fluss ist hier fixiert. Umgebung trocknet aus.'
|
|
: 'Fließt frei — kann jedes Jahr wandern!';
|
|
return { icon: '🌊', text: t, type: eng > 0 ? 'bad' : 'neutral' };
|
|
}
|
|
if (cur === 'field') {
|
|
const yi = game.tileYield[r]?.[c] || 0;
|
|
let t = 'Feld — Ertrag: ' + yi + '/8. ';
|
|
if (fi > 0.35) t += 'Abgesoffen! Noch ' + Math.max(0, 2 - fy) + ' Jahr(e) bis es stirbt.';
|
|
else if (fi > 0.15) t += 'Zu feucht — Ertrag reduziert.';
|
|
else if (dist > 5) t += 'Sehr trocken — weit vom Fluss.';
|
|
else if (dist > 3.5) t += 'Etwas trocken.';
|
|
else t += 'Gute Feuchtigkeit!';
|
|
return { icon: '🌾', text: t, type: yi > 4 ? 'good' : yi > 1 ? 'neutral' : 'bad' };
|
|
}
|
|
if (cur === 'settlement') {
|
|
const pop = game.tilePop[r]?.[c] || 0;
|
|
let t = 'Siedlung — ' + pop + '/8 Bewohner. ';
|
|
if (fi > 0.3) t += 'Feucht! Bewohner werden krank und fliehen.';
|
|
else if (fi > 0.1) t += 'Etwas feucht — manche Bewohner sind unwohl.';
|
|
else t += 'Trocken und sicher.';
|
|
return { icon: '🏘️', text: t, type: fi > 0.2 ? 'bad' : 'good' };
|
|
}
|
|
if (cur === 'levee') {
|
|
let t = 'Deich. ';
|
|
t += fi > 0.3 ? 'Unter Druck! Kann bei Dauerflut brechen.' : 'Intakt. Schützt die Nachbarschaft.';
|
|
return { icon: '🛡️', text: t, type: fi > 0.3 ? 'bad' : 'good' };
|
|
}
|
|
if (cur === 'forest') {
|
|
let t = 'Wald — schützt vor Erosion, speichert Grundwasser. ';
|
|
let nearStraight = false;
|
|
for (let i = Math.max(0,r-1); i <= Math.min(ROWS-1,r+1); i++)
|
|
if (game.riverEng[i] > 0) nearStraight = true;
|
|
if (nearStraight) t += 'Neben Begradigung — Baum in Gefahr!';
|
|
else if (fi > 0.2) t += 'Feucht. Bei Dauerflut stirbt der Baum.';
|
|
return { icon: '🌳', text: t, type: nearStraight ? 'bad' : 'good' };
|
|
}
|
|
if (cur === 'ruin') return { icon: '🏚️', text: 'Ruine. Kann abgerissen werden. Verfällt auch von selbst.', type: 'bad' };
|
|
if (cur === 'dead_tree') return { icon: '🪵', text: 'Toter Baum — vertrocknet oder ertrunken.', type: 'bad' };
|
|
// meadow
|
|
let t = 'Wiese — freie Fläche. ';
|
|
if (dist < 2.5) t += 'Nah am Fluss: fruchtbar, aber hochwassergefährdet.';
|
|
else if (dist < 4) t += 'Gute Lage für Feld oder Siedlung.';
|
|
else t += 'Weit vom Fluss. Trocken.';
|
|
return { icon: '🌱', text: t, type: 'neutral' };
|
|
}
|
|
|
|
/* ============================================================
|
|
SUMMARY / SERIALIZE
|
|
============================================================ */
|
|
|
|
function getStatus(game) {
|
|
const counts = countTiles(game);
|
|
const avgEng = game.riverEng.reduce((s,v) => s+v, 0) / ROWS;
|
|
const floodTotal = sum2d(game.floodIntensity) / (ROWS * COLS);
|
|
let pop = 0, food = 0;
|
|
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
|
|
if (game.land[r][c] === 'settlement') pop += game.tilePop[r][c];
|
|
if (game.land[r][c] === 'field') food += game.tileYield[r][c];
|
|
}
|
|
return {
|
|
year: game.year,
|
|
maxYears: game.maxYears,
|
|
budget: game.budget,
|
|
weather: game.weather,
|
|
gameOver: game.gameOver,
|
|
params: {
|
|
population: pop,
|
|
populationMax: 200,
|
|
// Identische Formeln wie im Tick-Snapshot oben — Single Source of Truth
|
|
// sollte langfristig einer extrahierten Formel-Funktion folgen.
|
|
food: clamp100(food * 0.3),
|
|
biodiv: clamp100(counts.forest * 2 - avgEng * 30 - (counts.settlement + counts.field) * 2.5),
|
|
flood: clamp100(15 + counts.levee * 6 + avgEng * 8 - floodTotal * 80),
|
|
economy: clamp100(pop * 3 + counts.field * 2 - counts.ruin * 5),
|
|
},
|
|
counts: counts,
|
|
achievements: Object.keys(game.achievements),
|
|
};
|
|
}
|
|
|
|
function getSummary(game) {
|
|
const st = getStatus(game);
|
|
let score = Math.round((st.params.biodiv + st.params.flood + st.params.economy) / 3);
|
|
score = Math.max(0, Math.min(100, score - game.stats.settlementsLost * 5));
|
|
const stars = score >= 85 ? 5 : score >= 70 ? 4 : score >= 55 ? 3 : score >= 35 ? 2 : 1;
|
|
return {
|
|
simId: 'fluss',
|
|
level: game.level,
|
|
stars: stars,
|
|
score: score,
|
|
completed: game.year >= game.maxYears,
|
|
params: st.params,
|
|
stats: game.stats,
|
|
badges_earned: Object.keys(game.achievements),
|
|
durationYears: game.year,
|
|
};
|
|
}
|
|
|
|
function serialize(game) {
|
|
return JSON.stringify({
|
|
v: 1,
|
|
level: game.level,
|
|
levelConfig: game.levelConfig,
|
|
maxYears: game.maxYears,
|
|
eventFreq: game.eventFreq,
|
|
toolsAvailable: game.toolsAvailable,
|
|
riverCol: game.riverCol,
|
|
riverColSmooth: game.riverColSmooth,
|
|
riverEng: game.riverEng,
|
|
riverRadius: game.riverRadius,
|
|
land: game.land,
|
|
tilePop: game.tilePop,
|
|
tileYield: game.tileYield,
|
|
tileFishYield: game.tileFishYield,
|
|
floodIntensity: game.floodIntensity,
|
|
tileFloodYears: game.tileFloodYears,
|
|
weather: game.weather,
|
|
wxTimer: game.wxTimer,
|
|
year: game.year,
|
|
budget: game.budget,
|
|
gameOver: game.gameOver,
|
|
history: game.history,
|
|
achievements: game.achievements,
|
|
species: game.species,
|
|
stats: game.stats,
|
|
});
|
|
}
|
|
|
|
function deserialize(json) {
|
|
let s; try { s = JSON.parse(json); } catch { return null; }
|
|
if (!s || s.v !== 1) return null;
|
|
const game = createGame({ level: s.level, levelConfig: s.levelConfig });
|
|
Object.assign(game, {
|
|
maxYears: s.maxYears, eventFreq: s.eventFreq, toolsAvailable: s.toolsAvailable,
|
|
riverCol: s.riverCol, riverColSmooth: s.riverColSmooth, riverEng: s.riverEng,
|
|
riverRadius: s.riverRadius, land: s.land, tilePop: s.tilePop, tileYield: s.tileYield,
|
|
tileFishYield: s.tileFishYield || [],
|
|
floodIntensity: s.floodIntensity, tileFloodYears: s.tileFloodYears,
|
|
weather: s.weather, wxTimer: s.wxTimer, year: s.year, budget: s.budget,
|
|
gameOver: s.gameOver, history: s.history, achievements: s.achievements,
|
|
species: s.species || {},
|
|
stats: s.stats,
|
|
_pendingEvents: [],
|
|
});
|
|
return game;
|
|
}
|
|
|
|
/* ============================================================
|
|
HELPERS
|
|
============================================================ */
|
|
|
|
function countTiles(game) {
|
|
const out = { meadow:0, forest:0, field:0, settlement:0, levee:0, ruin:0, dead_tree:0 };
|
|
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
|
|
const t = game.land[r][c];
|
|
if (out[t] !== undefined) out[t]++;
|
|
}
|
|
return out;
|
|
}
|
|
function sum2d(arr) {
|
|
let s = 0;
|
|
for (let r = 0; r < arr.length; r++) for (let c = 0; c < arr[r].length; c++) s += arr[r][c];
|
|
return s;
|
|
}
|
|
function clamp100(v) { return Math.max(0, Math.min(100, Math.round(v))); }
|
|
|
|
function pushEvent(game, icon, text, type, topic) {
|
|
game._pendingEvents.push({
|
|
id: game._nextEventId++, icon: icon, text: text,
|
|
type: type || 'neutral', year: game.year,
|
|
topic: topic || null,
|
|
});
|
|
}
|
|
function drainEvents(game) {
|
|
const out = game._pendingEvents;
|
|
game._pendingEvents = [];
|
|
return out;
|
|
}
|
|
function fail(game, msg) {
|
|
pushEvent(game, '⚠️', msg, 'bad');
|
|
return { ok: false, reason: 'constraint', events: drainEvents(game) };
|
|
}
|
|
function track(game, toolId) {
|
|
game.stats.builtTools[toolId] = (game.stats.builtTools[toolId] || 0) + 1;
|
|
}
|
|
|
|
function unlockAchievement(game, key) {
|
|
if (game.achievements[key]) return false;
|
|
const def = ACHIEVEMENTS.find(a => a.key === key);
|
|
if (!def) return false;
|
|
game.achievements[key] = true;
|
|
pushEvent(game, def.icon, 'Erfolg freigeschaltet: ' + def.title, 'good');
|
|
return true;
|
|
}
|
|
function checkAchievements(game, snap) {
|
|
if (game.stats.yearsWithoutLoss >= 10) unlockAchievement(game, 'decade_safe');
|
|
if (game.stats.balancedYears >= 50) unlockAchievement(game, 'balance_found');
|
|
}
|
|
|
|
/* ============================================================
|
|
EXPORT
|
|
============================================================ */
|
|
|
|
window.FlussEngine = {
|
|
TOOLS: TOOLS,
|
|
WEATHER: WEATHER,
|
|
ACHIEVEMENTS: ACHIEVEMENTS,
|
|
SPECIES: SPECIES,
|
|
COLS: COLS,
|
|
ROWS: ROWS,
|
|
MAX_YEARS_DEFAULT: MAX_YEARS_DEFAULT,
|
|
|
|
createGame: createGame,
|
|
tick: tick,
|
|
applyTool: applyTool,
|
|
distToRiver: distToRiver,
|
|
isRiverTile: isRiverTile,
|
|
inspectTile: inspectTile,
|
|
countTiles: countTiles,
|
|
getStatus: getStatus,
|
|
getSummary: getSummary,
|
|
serialize: serialize,
|
|
deserialize: deserialize,
|
|
};
|
|
})();
|