feat: add python and web implementation

This commit is contained in:
teier committed 2026-10-28 15:43:41 +01:00
1 parent fabbc6d147
commit df0950ef11
30 files changed
+5458 -8

No files matched your search

+272
View File
@@ -0,0 +1,272 @@
/**
* Waldbrand als zellulärer Automat – Portierung von python/ue1.py.
*
* Gleiche Regeln wie in Python, aber ein anderer Zufallsgenerator (mulberry32):
* Läufe mit demselben Seed sind reproduzierbar, stimmen aber nicht mit den Python-Läufen überein.
*/
export const WALD = 0;
export const BRENNEND = 1;
export const ABGEBRANNT = 2;
export const GELOESCHT = 3;
export const SCHNEISE = 4;
export const ZUSTAENDE = [
{ name: "Wald", farbe: "#008300" },
{ name: "brennend", farbe: "#e34948" },
{ name: "abgebrannt", farbe: "#3a3a38" },
{ name: "gelöscht", farbe: "#86b6ef" },
{ name: "Brandschneise", farbe: "#d9d4c7" },
] as const;
// Moore-Nachbarschaft: [Zeilenversatz, Spaltenversatz, Distanzgewicht]
export const NACHBARN: ReadonlyArray<readonly [number, number, number]> = [
[-1, -1, 0.7], [-1, 0, 1.0], [-1, 1, 0.7],
[0, -1, 1.0], [0, 1, 1.0],
[1, -1, 0.7], [1, 0, 1.0], [1, 1, 0.7],
];
export type Zelle = readonly [zeile: number, spalte: number];
export interface Params {
breite: number;
hoehe: number;
/** Anteil natürlicher Brandschneisen (z.B. Wasser, Fels), zufällig verteilt */
schneiseAnteil: number;
/** Künstliche Brandschneisen als volle Zeilen bzw. Spalten */
schneisenZeilen: readonly number[];
schneisenSpalten: readonly number[];
/** Brandfaktor ("Trockenheit") je Waldzelle, gleichverteilt */
brandfaktorMin: number;
brandfaktorMax: number;
/** Restbrenndauer je Waldzelle, ganzzahlig gleichverteilt (inklusive Grenzen) */
brenndauerMin: number;
brenndauerMax: number;
/** (x, y): x zeigt nach Osten, y nach Norden; Richtung, in die der Wind weht */
windRichtung: readonly [number, number];
windStaerke: number;
loeschWahrscheinlichkeit: number;
/** Startzelle, null = zufällige Waldzelle */
start: Zelle | null;
maxSchritte: number;
seed: number;
}
export const STANDARD_PARAMS: Params = {
breite: 100,
hoehe: 100,
schneiseAnteil: 0.05,
schneisenZeilen: [],
schneisenSpalten: [],
brandfaktorMin: 0.1,
brandfaktorMax: 1.0,
brenndauerMin: 2,
brenndauerMax: 5,
windRichtung: [1, 0],
windStaerke: 0,
loeschWahrscheinlichkeit: 0.05,
start: null,
maxSchritte: 1000,
seed: 0,
};
export interface Gitter {
breite: number;
hoehe: number;
zustand: Uint8Array;
brandfaktor: Float32Array;
restbrenndauer: Uint16Array;
}
export type Zufall = () => number;
/** mulberry32: kleiner, schneller Zufallsgenerator mit Seed, liefert Zahlen in [0, 1) */
export function zufallsgenerator(seed: number): Zufall {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** 1 + Windstärke * cos(Winkel zwischen Windvektor und Vektor zur Nachbarzelle) */
export function windfaktor(dr: number, dc: number, [wx, wy]: readonly [number, number], staerke: number): number {
const normWind = Math.hypot(wx, wy);
if (staerke === 0 || normWind === 0) return 1;
// Zeilenindex wächst nach unten, y zeigt nach oben
const nx = dc;
const ny = -dr;
return 1 + (staerke * (wx * nx + wy * ny)) / (normWind * Math.hypot(nx, ny));
}
/** Erzeugt Zustand, Brandfaktor und Restbrenndauer und entzündet die Startzelle. */
export function initGitter(params: Params, zufall: Zufall): Gitter {
const { breite, hoehe } = params;
const n = breite * hoehe;
const zustand = new Uint8Array(n);
for (let i = 0; i < n; i++) zustand[i] = zufall() < params.schneiseAnteil ? SCHNEISE : WALD;
if (params.start) {
// Am vorgegebenen Brandherd wächst immer Wald, auch wenn dort zufällig eine natürliche Schneise läge
zustand[params.start[0] * breite + params.start[1]] = WALD;
}
for (const zeile of params.schneisenZeilen) zustand.fill(SCHNEISE, zeile * breite, (zeile + 1) * breite);
for (const spalte of params.schneisenSpalten) {
for (let r = 0; r < hoehe; r++) zustand[r * breite + spalte] = SCHNEISE;
}
// Rand: die äußerste Zeile bzw. Spalte auf jeder Seite ist Brandschneise
zustand.fill(SCHNEISE, 0, breite);
zustand.fill(SCHNEISE, (hoehe - 1) * breite, n);
for (let r = 0; r < hoehe; r++) {
zustand[r * breite] = SCHNEISE;
zustand[r * breite + breite - 1] = SCHNEISE;
}
let start: number;
if (params.start === null) {
// Zufällige Waldzelle; hängt nur vom Seed und den Zelltypen ab, daher bei gleichem Seed
// in allen Varianten dieselbe, solange diese die Zelltypen nicht verändern
const waldzellen: number[] = [];
for (let i = 0; i < n; i++) if (zustand[i] === WALD) waldzellen.push(i);
if (waldzellen.length === 0) throw new Error("Keine Waldzelle für den Brandherd vorhanden");
start = waldzellen[Math.floor(zufall() * waldzellen.length)];
} else {
start = params.start[0] * breite + params.start[1];
if (zustand[start] !== WALD) {
throw new Error(`Startzelle (${params.start.join(", ")}) liegt auf einer künstlichen Brandschneise oder am Rand`);
}
}
const brandfaktor = new Float32Array(n);
const restbrenndauer = new Uint16Array(n);
const spanne = params.brandfaktorMax - params.brandfaktorMin;
const dauern = params.brenndauerMax - params.brenndauerMin + 1;
for (let i = 0; i < n; i++) {
// Für jede Zelle ziehen, damit Varianten mit gleichem Seed denselben Wald erhalten
const bf = params.brandfaktorMin + zufall() * spanne;
const dauer = params.brenndauerMin + Math.floor(zufall() * dauern);
if (zustand[i] !== SCHNEISE) {
brandfaktor[i] = bf;
restbrenndauer[i] = dauer;
}
}
zustand[start] = BRENNEND;
return { breite, hoehe, zustand, brandfaktor, restbrenndauer };
}
/** Ein synchroner Zeitschritt: liest nur das alte Gitter und gibt ein neues zurück. */
export function schritt(gitter: Gitter, params: Params, zufall: Zufall): Gitter {
const { breite, hoehe, zustand, brandfaktor } = gitter;
const neu: Gitter = {
breite,
hoehe,
zustand: zustand.slice(),
brandfaktor: brandfaktor.slice(),
restbrenndauer: gitter.restbrenndauer.slice(),
};
const windfaktoren = NACHBARN.map(([dr, dc]) => windfaktor(dr, dc, params.windRichtung, params.windStaerke));
for (let r = 0; r < hoehe; r++) {
for (let c = 0; c < breite; c++) {
const i = r * breite + c;
if (zustand[i] !== BRENNEND) continue;
if (zufall() < params.loeschWahrscheinlichkeit) {
neu.zustand[i] = GELOESCHT;
neu.brandfaktor[i] = 0;
neu.restbrenndauer[i] = 0;
continue;
}
for (let k = 0; k < NACHBARN.length; k++) {
const [dr, dc, gewicht] = NACHBARN[k];
const nr = r + dr;
const nc = c + dc;
if (nr < 0 || nr >= hoehe || nc < 0 || nc >= breite) continue;
const j = nr * breite + nc;
if (zustand[j] !== WALD) continue;
if (zufall() < 0.5 * gewicht * brandfaktor[j] * windfaktoren[k]) neu.zustand[j] = BRENNEND;
}
neu.restbrenndauer[i] -= 1;
if (neu.restbrenndauer[i] === 0) {
neu.zustand[i] = ABGEBRANNT;
neu.brandfaktor[i] = 0;
}
}
}
return neu;
}
export type Anzahl = number[];
export function zaehle(zustand: Uint8Array): Anzahl {
const anzahl = new Array<number>(ZUSTAENDE.length).fill(0);
for (const z of zustand) anzahl[z]++;
return anzahl;
}
/** Zellen, die irgendwann gebrannt haben */
export function betroffen(anzahl: Anzahl): number {
return anzahl[BRENNEND] + anzahl[ABGEBRANNT] + anzahl[GELOESCHT];
}
/** Jede Zelle, die keine Brandschneise ist, war zu Beginn Wald */
export function waldflaeche(anzahl: Anzahl): number {
return anzahl.reduce((a, b) => a + b, 0) - anzahl[SCHNEISE];
}
export interface Ergebnis {
breite: number;
hoehe: number;
endzustand: Uint8Array;
/** Anzahl Zellen je Zustand und Zeitschritt, anzahl[0] = Anfangszustand */
anzahl: Anzahl[];
dauer: number;
betroffenAnteil: number;
}
/** Simuliert, bis keine Zelle mehr brennt oder maxSchritte erreicht ist. */
export function simuliere(params: Params): Ergebnis {
const zufall = zufallsgenerator(params.seed);
let gitter = initGitter(params, zufall);
const anzahl = [zaehle(gitter.zustand)];
while (anzahl[anzahl.length - 1][BRENNEND] > 0 && anzahl.length <= params.maxSchritte) {
gitter = schritt(gitter, params, zufall);
anzahl.push(zaehle(gitter.zustand));
}
const letzte = anzahl[anzahl.length - 1];
return {
breite: gitter.breite,
hoehe: gitter.hoehe,
endzustand: gitter.zustand,
anzahl,
dauer: anzahl.length - 1,
betroffenAnteil: betroffen(letzte) / waldflaeche(letzte),
};
}
function imInneren(gitter: Gitter, [r, c]: Zelle): boolean {
return r > 0 && r < gitter.hoehe - 1 && c > 0 && c < gitter.breite - 1;
}
/** Macht eine Waldzelle zur Brandschneise (interaktives Zeichnen). Verändert das Gitter direkt. */
export function setzeSchneise(gitter: Gitter, zelle: Zelle): boolean {
const i = zelle[0] * gitter.breite + zelle[1];
if (!imInneren(gitter, zelle) || gitter.zustand[i] !== WALD) return false;
gitter.zustand[i] = SCHNEISE;
gitter.brandfaktor[i] = 0;
gitter.restbrenndauer[i] = 0;
return true;
}
/** Entzündet eine Waldzelle (interaktiv). Verändert das Gitter direkt. */
export function entzuende(gitter: Gitter, zelle: Zelle): boolean {
const i = zelle[0] * gitter.breite + zelle[1];
if (!imInneren(gitter, zelle) || gitter.zustand[i] !== WALD) return false;
gitter.zustand[i] = BRENNEND;
return true;
}