From 1bce9da0c6182326a6a7133038e3be14f9f56dc9 Mon Sep 17 00:00:00 2001 From: nico Date: Thu, 9 Jul 2026 11:13:39 +0200 Subject: [PATCH] Initial commit: AtomicEngine v0.2 - Rust/WASM physics engine (grid, material, physics, render_buffer) - 16 material types (stone, sand, water, wood, fire, lava, glass, ice, oil, acid, steam, spark, smoke + player placeholder) - Fire system (fuel-based, flammability-scaled consumption, spread, smoke/sparks) - Lighting system (emission, flood-fill propagation, glow, reflection) - Per-material pixel texturing (wood grain, dirt specks, stone noise, flame variation) - WebGL2 renderer with camera (WASD move, mouse wheel zoom) - Native x86 debug binary (identical dimensions, FPS counter) - Large world 1024x768 with active-region simulation (camera + 200px margin) - UI material palette + FPS display --- AGENTS.md | 224 ++++++++ engine/.gitignore | 2 + engine/Cargo.lock | 178 ++++++ engine/Cargo.toml | 20 + engine/src/grid.rs | 112 ++++ engine/src/lib.rs | 117 ++++ engine/src/main.rs | 118 ++++ engine/src/material.rs | 141 +++++ engine/src/physics.rs | 528 ++++++++++++++++++ engine/src/render_buffer.rs | 248 +++++++++ web/.gitignore | 3 + web/index.html | 94 ++++ web/package-lock.json | 1041 +++++++++++++++++++++++++++++++++++ web/package.json | 15 + web/src/camera.ts | 15 + web/src/engine.ts | 97 ++++ web/src/input.ts | 25 + web/src/main.ts | 153 +++++ web/src/renderer.ts | 102 ++++ web/tsconfig.json | 15 + web/vite.config.ts | 13 + 21 files changed, 3261 insertions(+) create mode 100644 AGENTS.md create mode 100644 engine/.gitignore create mode 100644 engine/Cargo.lock create mode 100644 engine/Cargo.toml create mode 100644 engine/src/grid.rs create mode 100644 engine/src/lib.rs create mode 100644 engine/src/main.rs create mode 100644 engine/src/material.rs create mode 100644 engine/src/physics.rs create mode 100644 engine/src/render_buffer.rs create mode 100644 web/.gitignore create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/camera.ts create mode 100644 web/src/engine.ts create mode 100644 web/src/input.ts create mode 100644 web/src/main.ts create mode 100644 web/src/renderer.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..851cb19 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,224 @@ +# AtomicEngine + +> Eine 2D-Platformer-Gameengine bei der jeder Pixel wie ein Atom mit eigenen +> Eigenschaften und Anziehungskräften zu seinen Nachbarn agiert. +> Browser-basiert mit Rust/WASM für die Physik und WebGL2 fürs Rendering. + +## Architektur-Übersicht + +``` +engine/ (Rust → WASM) web/ (TypeScript + Vite) +┌─────────────────────┐ ┌──────────────────────┐ +│ grid.rs │ │ main.ts │ +│ material.rs │ │ renderer.ts (WebGL2) │ +│ physics.rs │ │ input.ts │ +│ render_buffer.rs │ │ camera.ts │ +│ lib.rs (wasm API) │ │ │ +└─────────────────────┘ └──────────────────────┘ + │ │ + └── SharedArrayBuffer ──────┘ +``` + +## Tech-Stack + +| Schicht | Technologie | Warum | +|---------------|--------------------------|------------------------------------| +| Physik | Rust → WASM | Performance, Sicherheit | +| WASM Bridge | wasm-bindgen, wasm-pack | Auto JS-Bindings | +| Renderer | WebGL2 (Canvas) | GPU-beschleunigt, 60fps | +| Web Shell | TypeScript, Vite | Build, Dev-Server, HMR | +| Speicher | SharedArrayBuffer | Zero-copy WASM ↔ JS | + +## Welt-Modell + +- **Große Welt**: 1024×768 Pixel (4×3 Chunks à 256×256) +- Nur Zellen im Sichtfeld + 80px Margin werden simuliert +- Rest eingefroren — keine CPU-Kosten für unsichtbare Bereiche +- Grid wächst auf 1024×768 = 786K Zellen (~3 MB Speicher) + +### Datenstruktur Cell (pro Pixel) + +```rust +struct Cell { + material: u8, // Material-ID (0 = Luft) + health: u8, // Lebenspunkte/Dichte + temp: u8, // Temperatur + flags: u8, // Bitflags (aktiv, Player, Flüssig, ...) +} +``` + +Grid ist SoA (Structure of Arrays): +- `Vec` für materials, healths, temps, flags +- Breite × Höhe = Chunk-Größe + +## Material-System + +16 Materialien (0=Luft, 1=Stein, 2=Erde, 3=Sand, 4=Wasser, 5=Holz, 6=Lava, +7=Player, 8=Dampf, 9=Feuer, 10=Glas, 11=Eis, 12=Öl, 13=Säure, 14=Funken, 15=Rauch). + +Properties pro Material: `MaterialProps` in `engine/src/material.rs`: +- `color`, `density`, Bewegung-Typ (`is_powder/is_liquid/is_gas/is_solid/is_static`) +- `flammability`, `melt_temp`, `boil_temp`, `heat_conduct`, `acid_resist` +- `light_emit` (0–255, wie viel Licht das Material abstrahlt) +- `light_block` (0–255, wie stark das Material Licht blockiert) + +### Interaktions-System + +**Heat-Transfer**: Temperatur-Diffusion über Moore-Nachbarn (`heat_conduct`-abhängig). + +**Phase-Transformationen** (`material::transform`): +- Eis (11) → Wasser (4) bei temp ≥ 5°C +- Wasser (4) → Dampf (8) bei temp ≥ 100°C +- Dampf (8) → Wasser (4) bei temp ≤ 80°C +- Lava (6) → Stein (1) bei temp ≤ 5°C +- Holz (5) → Feuer (9) bei temp ≥ 240°C +- Öl (12) → Feuer (9) bei temp ≥ 120°C + +**Reaktionen** (`physics::react`): +- Wasser + Lava → Dampf + Stein +- Wasser + Feuer → Dampf + Luft (löscht Feuer) +- Lava + Holz/Öl → entzündet Holz/Öl sofort zu Feuer +- Säure + Holz/Sand → zerstört Holz/Sand +- Säure + Stein/Erde → zerstört Stein/Erde (langsamer) + +**Feuer-System**: +- Braucht Brennstoff (Holz, Öl) um zu überleben — prüft direkt unter sich + Moore-Nachbarn +- Feuer-Säule: Hat eine Zelle Feuer unter sich, gilt sie als versorgt (Ketten-Propagation) +- Mit Brennstoff: keine Health-Decay +- Ohne Brennstoff: −6hp/Frame → erlischt in ~7 Frames (~0.1s) +- Brenn-Rate `flammability`-abhängig: `300/flam` Frames zwischen −1hp-Konsum + - Holz (30): ~10 Frames/Konsum → ~42s pro Block + - Öl (80): ~3 Frames/Konsum → ~13s pro Block +- Ausbreitung: 1/250 Chance auf brennbare Nachbarn +- Wasser/Eis in Nachbarschaft → Feuer erlischt sofort +- Erzeugt Funken (14) und Rauch (15) beim Brennen +- **Flammen-Züngeln**: 1/5 Chance pro Zelle, Funken-Partikel nach oben zu feuern (4–12 Frames Lebensdauer) + +**Ephemere Partikel** (Funken + Rauch): +- `update_ephemeral`: dekrementiert Health, bei 0 → Luft +- Flammen-Funken: 4–12 Frames, 1/5 Emission, kurzlebig für Flammenspitzen +- Funken: 30–80 Frames Lebensdauer, orange, 1/16 Emission/Frame +- Rauch: 140–220 Frames, hellgrau transparent, 1/6 Emission/Frame +- Gas-Pass separat (alle 2 Frames, top→bottom = max 1px/Frame Aufstieg) + +## Physik-Loop (pro Frame) + +Reihenfolge in `physics::update(cam_x, cam_y, rw, rh, zoom)`: + +Nur Zellen innerhalb **Kamera-Sichtfeld + 80px Margin** werden simuliert. +Rest der Welt ist eingefroren (keine CPU-Kosten). + +1. **update_fire** — Feuer-Update: Brennstoff-Verbrauch, Ausbreitung, Funken/Rauch-Emission +2. **Haupt-Loop** (bottom→top, alternierende Spalten, nur aktive Region): + - Heat-Transfer über Moore-Nachbarn + - Material-Reaktionen (Water+Lava, Säure+Holz, ...) + - Phasen-Transformation (Temp-basiert) + - Bewegung: Powders ↓, Liquids ↓↔, Solids ↓ (kein Gas!) +3. **update_gases** (top→bottom, nur jedes 2. Frame, nur aktive Region): Gas steigt 1px/Frame +4. **update_ephemeral** (nur aktive Region): Spark/Smoke Health dekrementieren, bei 0 → Luft + +## Level-Format + +RGBA-PNG, jeder Pixel = 1 Atom: +- R = Material-ID (0=Luft) +- G = Dichte/Gesundheit +- B = Temperatur +- A = Flags + +Levels in Aseprite/Photoshop malbar. Große Welten = Raster von PNG-Dateien. + +## Rendering + +1. WASM schreibt sichtbaren Bereich als RGBA-Buffer +2. Buffer als Uint8Array aus WASM-Speicher gelesen +3. WebGL2 lädt als Textur → Fullscreen-Quad +4. Kamera-Matrix für Scroll/Zoom + +### Textur-System + +Jedes Material bekommt deterministische Pixel-Variation basierend auf Grid-Koordinaten: +- **Holz**: Vertikale Maserung (dünne Fasern alle ~5px, ±1px Welle) +- **Erde**: Grobkörnige Flecken (niederfrequentes Rauschen, ±20 RGB) +- **Stein/Glas**: Subtiles Rauschen (±10 RGB) +- **Sand**: Feines Granulat (±6 RGB) +- **Eis**: Minimales Rauschen (±5 RGB) +- **Flüssigkeiten/Gase**: Keine Textur +- **Feuer/Lava**: Kanal-getrennte Varianz (R ±6, G ±12, B ±8) — natürliche Gelb-/Orange-Mischung + +### Beleuchtungs-System + +**Light-Propagation** in `render_buffer.rs`: +1. Emissions-Pass: Jedes Pixel mit `light_emit > 0` strahlt Licht +2. Flood-Fill (max 12 Passes, early-termination): 8-Richtungs-Propagation + - Falloff: 2 (kardinal) / 3 (diagonal) + `light_block` (gecapped bei 200) + - Opaque Pixel blocken Weitergabe, werden aber selbst beleuchtet +3. Abwechselnde Scan-Richtung für gleichmäßige Verteilung + +**Lichtquellen**: Feuer (255), Lava (200), Funken (180) + +**Glow-Effekt**: Luft-Pixel mit Licht blenden von dunklem Hintergrund zu warmem Orange — quadratische Kurve (`t = lvl²/255`) für natürlichen Abfall. Nur direkt an der Quelle stark sichtbar. + +**Reflexion**: Semi-transparente Materialien (Glas, Wasser, Eis — block 1–179) werfen 50% des empfangenen Lichts an Nachbar-Pixel zurück → indirekte Beleuchtung. + +**Beleuchtungs-Formel** (Material-Pixel): Additiv: `r = min(255, r × 150/256 + light)` — Ambient-Basis 59% + Licht obendrauf. + +### UI +- **Material-Palette**: Leiste am unteren Bildschirmrand, Farb-Swatch + Name + Taste +- **FPS-Anzeige**: Oben rechts, grün, alle 500ms aktualisiert + +## Build & Entwicklung + +**WICHTIG**: Native Debug-Binary und WASM/Web-Build müssen immer identisch laufen. +Der Debug-Build nutzt dieselben Module (`grid`, `material`, `physics`, `render_buffer`) +und dieselben Dimensionen (256×192 Grid, 320×180 Render-Buffer). Performance-Unterschiede +sind WASM-Overhead, nicht Logik-Abweichungen. Bei Änderungen an der Engine immer beide +Builds testen. + +```bash +# Rust → WASM bauen +cd engine && wasm-pack build --target web --out-dir ../web/pkg + +# Native Debug-Binary (x86, ohne WASM) +cd engine && cargo run --bin atomic-debug --release + +# Web Dev-Server +cd web && npm run dev + +# Produktion +cd web && npm run build +``` + +## Datei-Index (was findet man wo) + +| Was | Datei | +|----------------------------------|-----------------------------------| +| WASM öffentliche API | `engine/src/lib.rs` | +| Native Debug Binary | `engine/src/main.rs` | +| Cell + Chunk + Grid DS | `engine/src/grid.rs` | +| Material-Definitionen | `engine/src/material.rs` | +| Physik: Kräfte, Sand, Fluide | `engine/src/physics.rs` | +| RGBA-Buffer Export | `engine/src/render_buffer.rs` | +| Rust-Abhängigkeiten | `engine/Cargo.toml` | +| WebGL2 Renderer | `web/src/renderer.ts` | +| WASM-Bridge + Material-Konstanten| `web/src/engine.ts` | +| Game-Loop, Wiring | `web/src/main.ts` | +| Input (Keyboard, Maus) | `web/src/input.ts` | +| Kamera (Scroll, Zoom) | `web/src/camera.ts` | +| HTML Einstieg | `web/index.html` | +| Vite Konfiguration | `web/vite.config.ts` | +| TypeScript Konfiguration | `web/tsconfig.json` | + +## Aktueller Status + +- [x] Phase 0: Projekt-Struktur + WASM/WebGL End-to-End ← **fertig** +- [x] Phase 1: Sand-Physik (Pixel fällt nach unten) ← **fertig** (Sand + Flüssigkeiten) +- [x] Phase 2: Mehrere Materialien + Kräfte-Tabelle ← **fertig** +- [x] Phase 3: Chunk-System + große Welt + Kamera ← **fertig** +- [ ] Phase 4: Spieler als Atom-Cluster + Input +- [ ] Phase 5: Erweiterungen (Items, Player-Interaktion, Editor, ...) + +## Namenskonventionen + +- Rust: snake_case, English +- TypeScript: camelCase, English +- Keine Kommentare im Code (nur wenn explizit gewünscht) diff --git a/engine/.gitignore b/engine/.gitignore new file mode 100644 index 0000000..34aa146 --- /dev/null +++ b/engine/.gitignore @@ -0,0 +1,2 @@ +target/ +pkg/ diff --git a/engine/Cargo.lock b/engine/Cargo.lock new file mode 100644 index 0000000..db92934 --- /dev/null +++ b/engine/Cargo.lock @@ -0,0 +1,178 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-engine" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "wasm-bindgen", + "wee_alloc", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if 1.0.4", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "memory_units" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if 1.0.4", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wee_alloc" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb3b5a6b2bb17cb6ad44a2e68a43e8d2722c997da10e928665c72ec6c0a0b8e" +dependencies = [ + "cfg-if 0.1.10", + "libc", + "memory_units", + "winapi", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/engine/Cargo.toml b/engine/Cargo.toml new file mode 100644 index 0000000..b649a3b --- /dev/null +++ b/engine/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "atomic-engine" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "atomic-debug" +path = "src/main.rs" + +[dependencies] +wasm-bindgen = "0.2" +console_error_panic_hook = "0.1" +wee_alloc = "0.4" + +[profile.release] +opt-level = "s" +lto = true diff --git a/engine/src/grid.rs b/engine/src/grid.rs new file mode 100644 index 0000000..5847cf7 --- /dev/null +++ b/engine/src/grid.rs @@ -0,0 +1,112 @@ +pub struct Grid { + pub materials: Vec, + pub healths: Vec, + pub temps: Vec, + pub flags: Vec, + pub width: u32, + pub height: u32, +} + +pub const FLAG_UPDATED: u8 = 0b0000_0001; +pub const FLAG_PLAYER: u8 = 0b0000_0010; + +impl Grid { + pub fn new(width: u32, height: u32) -> Self { + let size = (width * height) as usize; + Self { + materials: vec![0; size], + healths: vec![255; size], + temps: vec![0; size], + flags: vec![0; size], + width, + height, + } + } + + #[inline] + pub fn index(&self, x: u32, y: u32) -> usize { + (y * self.width + x) as usize + } + + pub fn in_bounds(&self, x: i32, y: i32) -> bool { + x >= 0 && x < self.width as i32 && y >= 0 && y < self.height as i32 + } + + #[inline] + pub fn get(&self, x: u32, y: u32) -> u8 { + self.materials[self.index(x, y)] + } + + #[inline] + pub fn get_signed(&self, x: i32, y: i32) -> u8 { + if self.in_bounds(x, y) { + self.materials[self.index(x as u32, y as u32)] + } else { + 0 + } + } + + #[inline] + pub fn set(&mut self, x: u32, y: u32, material: u8) { + let idx = self.index(x, y); + self.materials[idx] = material; + self.flags[idx] |= FLAG_UPDATED; + if material == 6 { + self.temps[idx] = 200; + self.healths[idx] = 255; + } else if material == 9 { + self.temps[idx] = 200; + self.healths[idx] = 200; + } else if material == 11 { + self.temps[idx] = 0; + } + } + + #[inline] + pub fn swap(&mut self, x1: u32, y1: u32, x2: u32, y2: u32) { + let i1 = self.index(x1, y1); + let i2 = self.index(x2, y2); + self.materials.swap(i1, i2); + self.healths.swap(i1, i2); + self.temps.swap(i1, i2); + self.flags[i1] |= FLAG_UPDATED; + self.flags[i2] |= FLAG_UPDATED; + } + + pub fn is_empty(&self, x: u32, y: u32) -> bool { + self.get(x, y) == 0 + } + + pub fn clear_flags(&mut self) { + for f in &mut self.flags { + *f &= !FLAG_UPDATED; + } + } + + pub fn fill_rect(&mut self, x: u32, y: u32, w: u32, h: u32, material: u8) { + for dy in 0..h { + for dx in 0..w { + let px = x + dx; + let py = y + dy; + if px < self.width && py < self.height { + self.set(px, py, material); + } + } + } + } + + pub fn fill_circle(&mut self, cx: u32, cy: u32, radius: u32, material: u8) { + let r = radius as i32; + for dy in -r..=r { + for dx in -r..=r { + if dx * dx + dy * dy <= r * r { + let px = (cx as i32 + dx) as u32; + let py = (cy as i32 + dy) as u32; + if px < self.width && py < self.height { + self.set(px, py, material); + } + } + } + } + } +} diff --git a/engine/src/lib.rs b/engine/src/lib.rs new file mode 100644 index 0000000..463a94d --- /dev/null +++ b/engine/src/lib.rs @@ -0,0 +1,117 @@ +pub mod grid; +pub mod material; +pub mod physics; +pub mod render_buffer; + +use std::cell::RefCell; +use grid::Grid; +use physics::Physics; +use render_buffer::RenderBuffer; +use wasm_bindgen::prelude::*; + +thread_local! { + static ENGINE: RefCell> = RefCell::new(None); +} + +struct Engine { + grid: Grid, + physics: Physics, + render_buffer: RenderBuffer, +} + +#[wasm_bindgen(start)] +pub fn start() { + console_error_panic_hook::set_once(); +} + +fn with_engine(f: F) -> R +where + F: FnOnce(&mut Engine) -> R, +{ + ENGINE.with(|cell| { + let mut opt = cell.borrow_mut(); + let engine = opt.as_mut().expect("Engine not initialized. Call init() first."); + f(engine) + }) +} + +#[wasm_bindgen] +pub fn init(width: u32, height: u32) { + let engine = Engine { + grid: Grid::new(width, height), + physics: Physics::new(), + render_buffer: RenderBuffer::new(320, 180), + }; + ENGINE.with(|cell| { + *cell.borrow_mut() = Some(engine); + }); +} + +#[wasm_bindgen] +pub fn get_width() -> u32 { + with_engine(|e| e.grid.width) +} + +#[wasm_bindgen] +pub fn get_height() -> u32 { + with_engine(|e| e.grid.height) +} + +#[wasm_bindgen] +pub fn fill_rect(x: u32, y: u32, w: u32, h: u32, material: u8) { + with_engine(|e| e.grid.fill_rect(x, y, w, h, material)); +} + +#[wasm_bindgen] +pub fn fill_circle(x: u32, y: u32, radius: u32, material: u8) { + with_engine(|e| e.grid.fill_circle(x, y, radius, material)); +} + +#[wasm_bindgen] +pub fn simulate(cam_x: i32, cam_y: i32, zoom: f32) { + with_engine(|e| { + let rw = e.render_buffer.width; + let rh = e.render_buffer.height; + e.physics.update(&mut e.grid, cam_x, cam_y, rw, rh, zoom); + }); +} + +#[wasm_bindgen] +pub fn render_buffer_ptr() -> *const u8 { + ENGINE.with(|cell| { + let opt = cell.borrow(); + let engine = opt.as_ref().expect("Engine not initialized."); + engine.render_buffer.pixels.as_ptr() + }) +} + +#[wasm_bindgen] +pub fn render_buffer_len() -> usize { + ENGINE.with(|cell| { + let opt = cell.borrow(); + let engine = opt.as_ref().expect("Engine not initialized."); + engine.render_buffer.pixels.len() + }) +} + +#[wasm_bindgen] +pub fn render_width() -> u32 { + with_engine(|e| e.render_buffer.width) +} + +#[wasm_bindgen] +pub fn render_height() -> u32 { + with_engine(|e| e.render_buffer.height) +} + +#[wasm_bindgen] +pub fn update_render(cam_x: i32, cam_y: i32, zoom: f32) { + with_engine(|e| e.render_buffer.render(&e.grid, cam_x, cam_y, zoom)); +} + +#[wasm_bindgen] +pub fn reset() { + with_engine(|e| { + e.grid = Grid::new(e.grid.width, e.grid.height); + }); +} diff --git a/engine/src/main.rs b/engine/src/main.rs new file mode 100644 index 0000000..68e8d89 --- /dev/null +++ b/engine/src/main.rs @@ -0,0 +1,118 @@ +mod grid; +mod material; +mod physics; +mod render_buffer; + +use grid::Grid; +use physics::Physics; +use render_buffer::RenderBuffer; + +fn main() { + let width = 1024u32; + let height = 768u32; + let rw = 320u32; + let rh = 180u32; + let mut grid = Grid::new(width, height); + let mut physics = Physics::new(); + let mut rb = RenderBuffer::new(rw, rh); + + grid.fill_rect(440, 720, 144, 48, 1); + grid.fill_rect(460, 700, 104, 20, 2); + grid.fill_rect(470, 600, 84, 12, 5); + grid.fill_circle(490, 560, 20, 3); + grid.fill_rect(430, 650, 50, 30, 4); + grid.fill_rect(540, 650, 50, 30, 6); + grid.fill_rect(500, 640, 10, 18, 11); + grid.fill_rect(480, 630, 64, 24, 5); + grid.fill_rect(480, 618, 64, 12, 9); + + let cam_x = 512i32; + let cam_y = 600i32; + let zoom = 2.0f32; + + let mut frame = 0; + let start = std::time::Instant::now(); + + loop { + physics.update(&mut grid, cam_x, cam_y, rw, rh, zoom); + rb.render(&grid, cam_x, cam_y, zoom); + frame += 1; + + if frame % 60 == 0 { + let elapsed = start.elapsed(); + let fps = frame as f64 / elapsed.as_secs_f64(); + let (fire, wood, spark, smoke) = count_mats(&grid); + + println!("--- {}s (frame {}) fps: {:.0} ---", frame / 60, frame, fps); + println!("fire: {}, wood: {}, spark: {}, smoke: {}", fire, wood, spark, smoke); + + if fire == 0 { + let elapsed = start.elapsed(); + let fps = frame as f64 / elapsed.as_secs_f64(); + println!("Fire died at frame {}, avg fps: {:.0}", frame, fps); + break; + } + if frame >= 3600 { + println!("Timeout at 60s"); + break; + } + } + } +} + +fn count_mats(grid: &Grid) -> (u32, u32, u32, u32) { + let mut fire = 0u32; + let mut wood = 0u32; + let mut spark = 0u32; + let mut smoke = 0u32; + for y in 0..grid.height { + for x in 0..grid.width { + match grid.get(x, y) { + 5 => wood += 1, + 9 => fire += 1, + 14 => spark += 1, + 15 => smoke += 1, + _ => {} + } + } + } + (fire, wood, spark, smoke) +} + +fn temp_range(grid: &Grid) -> (u8, u8) { + let mut min = 255u8; + let mut max = 0u8; + for y in 0..grid.height { + for x in 0..grid.width { + let idx = grid.index(x, y); + let t = grid.temps[idx]; + if t > 0 { + min = min.min(t); + max = max.max(t); + } + } + } + if min == 255 { min = 0; } + (min, max) +} + +fn print_grid(grid: &Grid) { + for y in 0..grid.height { + for x in 0..grid.width { + let c = match grid.get(x, y) { + 5 => 'W', + 9 => 'F', + 14 => '*', + 15 => '%', + 0 => '.', + 1 => '#', + 4 => '~', + 6 => 'L', + 8 => '\'', + _ => '?', + }; + print!("{}", c); + } + println!(); + } +} diff --git a/engine/src/material.rs b/engine/src/material.rs new file mode 100644 index 0000000..4b89a63 --- /dev/null +++ b/engine/src/material.rs @@ -0,0 +1,141 @@ +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Material { + Air = 0, + Stone = 1, + Dirt = 2, + Sand = 3, + Water = 4, + Wood = 5, + Lava = 6, + Player = 7, + Steam = 8, + Fire = 9, + Glass = 10, + Ice = 11, + Oil = 12, + Acid = 13, + Spark = 14, + Smoke = 15, +} + +impl Material { + pub fn from_u8(val: u8) -> Material { + match val { + 0 => Material::Air, + 1 => Material::Stone, + 2 => Material::Dirt, + 3 => Material::Sand, + 4 => Material::Water, + 5 => Material::Wood, + 6 => Material::Lava, + 7 => Material::Player, + 8 => Material::Steam, + 9 => Material::Fire, + 10 => Material::Glass, + 11 => Material::Ice, + 12 => Material::Oil, + 13 => Material::Acid, + 14 => Material::Spark, + 15 => Material::Smoke, + _ => Material::Air, + } + } +} + +#[derive(Clone, Copy)] +pub struct MaterialProps { + pub color: [u8; 4], + pub density: u8, + pub is_powder: bool, + pub is_liquid: bool, + pub is_gas: bool, + pub is_solid: bool, + pub is_static: bool, + pub flammability: u8, + pub melt_temp: u8, + pub boil_temp: u8, + pub heat_conduct: u8, + pub acid_resist: u8, + pub light_emit: u8, + pub light_block: u8, +} + +pub const M: usize = 16; +pub static PROPS: [MaterialProps; M] = [ + MaterialProps { color: [0, 0, 0, 0], density: 0, is_powder: false, is_liquid: false, is_gas: false, is_solid: false, is_static: false, flammability: 0, melt_temp: 255, boil_temp: 255, heat_conduct: 0, acid_resist: 255, light_emit: 0, light_block: 0 }, + MaterialProps { color: [120, 120, 130, 255], density: 100, is_powder: false, is_liquid: false, is_gas: false, is_solid: true, is_static: true, flammability: 0, melt_temp: 220, boil_temp: 255, heat_conduct: 3, acid_resist: 200, light_emit: 0, light_block: 255 }, + MaterialProps { color: [101, 67, 33, 255], density: 60, is_powder: true, is_liquid: false, is_gas: false, is_solid: false, is_static: false, flammability: 0, melt_temp: 200, boil_temp: 255, heat_conduct: 2, acid_resist: 100, light_emit: 0, light_block: 255 }, + MaterialProps { color: [194, 178, 128, 255], density: 40, is_powder: true, is_liquid: false, is_gas: false, is_solid: false, is_static: false, flammability: 0, melt_temp: 200, boil_temp: 255, heat_conduct: 5, acid_resist: 150, light_emit: 0, light_block: 200 }, + MaterialProps { color: [30, 60, 200, 180], density: 10, is_powder: false, is_liquid: true, is_gas: false, is_solid: false, is_static: false, flammability: 0, melt_temp: 0, boil_temp: 100, heat_conduct: 10, acid_resist: 200, light_emit: 0, light_block: 80 }, + MaterialProps { color: [90, 60, 30, 255], density: 80, is_powder: false, is_liquid: false, is_gas: false, is_solid: true, is_static: false, flammability: 30, melt_temp: 240, boil_temp: 255, heat_conduct: 1, acid_resist: 80, light_emit: 0, light_block: 255 }, + MaterialProps { color: [255, 100, 0, 255], density: 50, is_powder: false, is_liquid: true, is_gas: false, is_solid: false, is_static: false, flammability: 0, melt_temp: 200, boil_temp: 255, heat_conduct: 20, acid_resist: 255, light_emit: 200, light_block: 60 }, + MaterialProps { color: [50, 200, 50, 255], density: 90, is_powder: false, is_liquid: false, is_gas: false, is_solid: false, is_static: false, flammability: 0, melt_temp: 255, boil_temp: 255, heat_conduct: 5, acid_resist: 255, light_emit: 0, light_block: 100 }, + MaterialProps { color: [200, 200, 220, 140], density: 1, is_powder: false, is_liquid: false, is_gas: true, is_solid: false, is_static: false, flammability: 0, melt_temp: 0, boil_temp: 0, heat_conduct: 5, acid_resist: 255, light_emit: 0, light_block: 20 }, + MaterialProps { color: [255, 180, 30, 255], density: 2, is_powder: false, is_liquid: false, is_gas: false, is_solid: false, is_static: false, flammability: 255, melt_temp: 255, boil_temp: 255, heat_conduct: 50, acid_resist: 255, light_emit: 255, light_block: 30 }, + MaterialProps { color: [180, 210, 220, 255], density: 95, is_powder: false, is_liquid: false, is_gas: false, is_solid: true, is_static: false, flammability: 0, melt_temp: 160, boil_temp: 255, heat_conduct: 1, acid_resist: 250, light_emit: 0, light_block: 40 }, + MaterialProps { color: [180, 220, 255, 255], density: 85, is_powder: false, is_liquid: false, is_gas: false, is_solid: true, is_static: false, flammability: 0, melt_temp: 5, boil_temp: 100, heat_conduct: 8, acid_resist: 200, light_emit: 0, light_block: 100 }, + MaterialProps { color: [180, 140, 40, 200], density: 15, is_powder: false, is_liquid: true, is_gas: false, is_solid: false, is_static: false, flammability: 80, melt_temp: 0, boil_temp: 120, heat_conduct: 3, acid_resist: 200, light_emit: 0, light_block: 60 }, + MaterialProps { color: [100, 255, 60, 200], density: 30, is_powder: false, is_liquid: true, is_gas: false, is_solid: false, is_static: false, flammability: 0, melt_temp: 0, boil_temp: 150, heat_conduct: 15, acid_resist: 255, light_emit: 0, light_block: 80 }, + MaterialProps { color: [255, 180, 40, 255], density: 1, is_powder: false, is_liquid: false, is_gas: true, is_solid: false, is_static: false, flammability: 0, melt_temp: 255, boil_temp: 255, heat_conduct: 30, acid_resist: 255, light_emit: 180, light_block: 30 }, + MaterialProps { color: [160, 155, 150, 200], density: 1, is_powder: false, is_liquid: false, is_gas: true, is_solid: false, is_static: false, flammability: 0, melt_temp: 255, boil_temp: 255, heat_conduct: 5, acid_resist: 255, light_emit: 0, light_block: 80 }, +]; + +#[derive(Clone, Copy, Debug)] +pub struct Interaction { + pub attraction: i8, + pub can_merge: bool, + pub bond_strength: u8, +} + +impl Interaction { + const fn none() -> Self { + Interaction { attraction: 0, can_merge: false, bond_strength: 0 } + } +} + +pub fn interact(a: u8, b: u8) -> Interaction { + match (a, b) { + (1, 4) | (4, 1) => Interaction { attraction: 5, can_merge: false, bond_strength: 3 }, + (1, 6) | (6, 1) => Interaction { attraction: 0, can_merge: false, bond_strength: 0 }, + (3, 4) | (4, 3) => Interaction { attraction: 8, can_merge: false, bond_strength: 0 }, + (4, 6) | (6, 4) => Interaction { attraction: -20, can_merge: false, bond_strength: 0 }, + (5, 9) | (9, 5) => Interaction { attraction: 0, can_merge: false, bond_strength: 0 }, + (5, 4) | (4, 5) => Interaction { attraction: 10, can_merge: false, bond_strength: 2 }, + (11, 4) | (4, 11) => Interaction { attraction: 15, can_merge: false, bond_strength: 5 }, + (11, 6) | (6, 11) => Interaction { attraction: -30, can_merge: false, bond_strength: 0 }, + (9, 4) | (4, 9) => Interaction { attraction: -10, can_merge: false, bond_strength: 0 }, + (12, 9) | (9, 12) => Interaction { attraction: 0, can_merge: false, bond_strength: 0 }, + (13, 1) | (1, 13) => Interaction { attraction: 0, can_merge: false, bond_strength: 0 }, + (13, 3) | (3, 13) | (13, 5) | (5, 13) => Interaction { attraction: 0, can_merge: false, bond_strength: 0 }, + _ => { + if a == b { + if a == 4 || a == 6 || a == 12 || a == 13 { + Interaction { attraction: 20, can_merge: true, bond_strength: 1 } + } else if a == 3 || a == 2 { + Interaction { attraction: 5, can_merge: false, bond_strength: 0 } + } else if a == 1 || a == 10 || a == 11 { + Interaction { attraction: 30, can_merge: false, bond_strength: 10 } + } else if a == 5 { + Interaction { attraction: 15, can_merge: false, bond_strength: 8 } + } else { + Interaction::none() + } + } else { + Interaction::none() + } + } + } +} + +pub fn transform(temp: u8, material: u8) -> u8 { + match material { + 4 => if temp >= 100 { 8 } else { 4 }, + 11 => if temp >= 5 { 4 } else { 11 }, + 6 => if temp <= 5 { 1 } else { 6 }, + 8 => if temp <= 80 { 4 } else { 8 }, + 5 => if temp >= 240 { 9 } else { 5 }, + 12 => if temp >= 120 { 9 } else { 12 }, + _ => material, + } +} diff --git a/engine/src/physics.rs b/engine/src/physics.rs new file mode 100644 index 0000000..08a0061 --- /dev/null +++ b/engine/src/physics.rs @@ -0,0 +1,528 @@ +use crate::grid::Grid; +use crate::material::{self, PROPS}; + +const MOORE_X: [i32; 8] = [-1, 0, 1, -1, 1, -1, 0, 1]; +const MOORE_Y: [i32; 8] = [-1, -1, -1, 0, 0, 1, 1, 1]; + +pub struct Physics { + rand_state: u32, + frame: u32, +} + +impl Physics { + pub fn new() -> Self { + Self { rand_state: 12345, frame: 0 } + } + + fn rand(&mut self) -> u32 { + self.rand_state = self.rand_state.wrapping_mul(1103515245).wrapping_add(12345); + self.rand_state >> 16 + } + + fn rand_dir(&mut self) -> i32 { + if self.rand() & 1 == 0 { -1 } else { 1 } + } + + pub fn update(&mut self, grid: &mut Grid, cam_x: i32, cam_y: i32, rw: u32, rh: u32, zoom: f32) { + self.frame = self.frame.wrapping_add(1); + let w = grid.width as i32; + let h = grid.height as i32; + + let margin = 200; + let vw = (rw as f32 / zoom) as i32; + let vh = (rh as f32 / zoom) as i32; + let x0 = (cam_x - vw / 2 - margin).max(0) as u32; + let y0 = (cam_y - vh / 2 - margin).max(0) as u32; + let x1 = (cam_x + vw / 2 + margin).min(w) as u32; + let y1 = (cam_y + vh / 2 + margin).min(h) as u32; + + self.update_fire(grid, x0, y0, x1, y1); + + for y in (y0..y1).rev() { + let parity = (self.frame & 1) as u32; + let xs = if x0 & 1 == parity { x0 } else { x0 + 1 }; + for x in (xs..x1).step_by(2) { + let idx = grid.index(x, y); + let mat = grid.materials[idx]; + if mat == 0 { + continue; + } + let temp = grid.temps[idx]; + + self.transfer_heat(grid, x, y, mat, temp); + self.react(grid, x, y, mat); + + let mat = grid.materials[grid.index(x, y)]; + let temp = grid.temps[grid.index(x, y)]; + self.transform(grid, x, y, mat, temp); + + let mat = grid.materials[grid.index(x, y)]; + if mat == 0 { + continue; + } + + let props = &PROPS[mat as usize]; + + if props.is_powder { + self.update_powder(grid, x, y, mat); + } else if props.is_liquid { + self.update_liquid(grid, x, y, mat); + } else if props.is_solid && !props.is_static { + self.update_solid(grid, x, y, mat); + } + } + } + + if self.frame % 2 == 0 { + self.update_gases(grid, x0, y0, x1, y1); + } + self.update_ephemeral(grid, x0, y0, x1, y1); + } + + fn transfer_heat(&mut self, grid: &mut Grid, x: u32, y: u32, mat: u8, temp: u8) { + let props = &PROPS[mat as usize]; + if props.heat_conduct == 0 { + return; + } + + let idx = grid.index(x, y); + for i in 0..8 { + let nx = x as i32 + MOORE_X[i]; + let ny = y as i32 + MOORE_Y[i]; + if !grid.in_bounds(nx, ny) { + continue; + } + let ni = grid.index(nx as u32, ny as u32); + let nmat = grid.materials[ni]; + if nmat == 0 { + continue; + } + let ntemp = grid.temps[ni]; + if temp == ntemp { + continue; + } + + let diff = if temp > ntemp { + let d = temp - ntemp; + let rate = (props.heat_conduct as u32 * d as u32) / 255; + ((rate + 1) / 2) as u8 + } else { + let d = ntemp - temp; + let nprops = &PROPS[nmat as usize]; + let rate = (nprops.heat_conduct as u32 * d as u32) / 255; + ((rate + 1) / 2) as u8 + }; + + if diff == 0 { + continue; + } + + if temp > ntemp { + grid.temps[idx] = temp.saturating_sub(diff); + grid.temps[ni] = ntemp.saturating_add(diff); + } else { + grid.temps[idx] = temp.saturating_add(diff); + grid.temps[ni] = ntemp.saturating_sub(diff); + } + } + } + + fn react(&mut self, grid: &mut Grid, x: u32, y: u32, mat: u8) { + let idx = grid.index(x, y); + + for i in 0..8 { + let nx = x as i32 + MOORE_X[i]; + let ny = y as i32 + MOORE_Y[i]; + if !grid.in_bounds(nx, ny) { + continue; + } + let ni = grid.index(nx as u32, ny as u32); + let nmat = grid.materials[ni]; + if nmat == 0 { + continue; + } + + match (mat, nmat) { + (4, 6) | (6, 4) => { + if self.rand() % 4 == 0 { + if mat == 4 { + grid.materials[idx] = 8; + grid.materials[ni] = 1; + grid.temps[idx] = 100; + grid.temps[ni] = 10; + } else { + grid.materials[idx] = 1; + grid.materials[ni] = 8; + grid.temps[idx] = 10; + grid.temps[ni] = 100; + } + grid.flags[idx] |= crate::grid::FLAG_UPDATED; + grid.flags[ni] |= crate::grid::FLAG_UPDATED; + } + } + (6, 5) | (5, 6) | (6, 12) | (12, 6) => { + if self.rand() % 2 == 0 { + if mat == 6 { + grid.materials[ni] = 9; + grid.temps[ni] = 200; + grid.healths[ni] = 200; + } else { + grid.materials[idx] = 9; + grid.temps[idx] = 200; + grid.healths[idx] = 200; + } + grid.flags[idx] |= crate::grid::FLAG_UPDATED; + grid.flags[ni] |= crate::grid::FLAG_UPDATED; + } + } + (4, 9) => { + if self.rand() % 2 == 0 { + grid.materials[idx] = 8; + grid.materials[ni] = 0; + grid.temps[idx] = 90; + grid.temps[ni] = 0; + grid.flags[idx] |= crate::grid::FLAG_UPDATED; + grid.flags[ni] |= crate::grid::FLAG_UPDATED; + } + } + (13, 5) | (13, 3) => { + if self.rand() % 4 == 0 { + grid.materials[ni] = 0; + grid.healths[idx] = grid.healths[idx].saturating_sub(5); + grid.flags[ni] |= crate::grid::FLAG_UPDATED; + } + } + (13, 1) | (13, 2) => { + if self.rand() % 8 == 0 { + grid.materials[ni] = 0; + grid.healths[idx] = grid.healths[idx].saturating_sub(2); + grid.flags[ni] |= crate::grid::FLAG_UPDATED; + } + } + _ => {} + } + } + } + + fn transform(&mut self, grid: &mut Grid, x: u32, y: u32, mat: u8, temp: u8) { + let idx = grid.index(x, y); + let new_mat = material::transform(temp, mat); + if new_mat != mat { + grid.materials[idx] = new_mat; + grid.flags[idx] |= crate::grid::FLAG_UPDATED; + } + } + + fn update_fire(&mut self, grid: &mut Grid, x0: u32, y0: u32, x1: u32, y1: u32) { + for y in (y0..y1).rev() { + for x in x0..x1 { + let idx = grid.index(x, y); + if grid.materials[idx] != 9 { + continue; + } + + let mut extinguish = false; + for i in 0..8 { + let nx = x as i32 + MOORE_X[i]; + let ny = y as i32 + MOORE_Y[i]; + if !grid.in_bounds(nx, ny) { continue; } + let ni = grid.index(nx as u32, ny as u32); + let nmat = grid.materials[ni]; + if nmat == 4 || nmat == 11 { + grid.materials[idx] = 0; + grid.temps[idx] = 0; + grid.flags[idx] |= crate::grid::FLAG_UPDATED; + if nmat == 4 { + grid.materials[ni] = 8; + grid.temps[ni] = 90; + } else { + grid.materials[ni] = 4; + grid.temps[ni] = 5; + } + grid.flags[ni] |= crate::grid::FLAG_UPDATED; + extinguish = true; + break; + } + } + if extinguish { + continue; + } + + let mut on_fuel = false; + if y + 1 < grid.height { + let below_idx = grid.index(x, y + 1); + let below_mat = grid.materials[below_idx]; + if PROPS[below_mat as usize].flammability > 0 && below_mat != 9 { + on_fuel = true; + let flam = PROPS[below_mat as usize].flammability as u32; + let threshold = (300 / flam).max(3); + if self.rand() % threshold == 0 { + let bh = grid.healths[below_idx]; + if bh <= 1 { + grid.materials[below_idx] = 0; + grid.flags[below_idx] |= crate::grid::FLAG_UPDATED; + } else { + grid.healths[below_idx] = bh - 1; + } + } + if y > 0 && self.rand() % 6 == 0 { + for &sdx in &[0, -1, 1] { + let sx = x as i32 + sdx; + if grid.in_bounds(sx, y as i32 - 1) { + let si = grid.index(sx as u32, y - 1); + if grid.materials[si] == 0 { + grid.materials[si] = 15; + grid.temps[si] = 50; + grid.healths[si] = 140 + (self.rand() % 80) as u8; + grid.flags[si] |= crate::grid::FLAG_UPDATED; + break; + } + } + } + } + } + } + if !on_fuel && y + 1 < grid.height && grid.get(x, y + 1) == 9 { + on_fuel = true; + } + if !on_fuel { + for i in 0..8 { + let nx = x as i32 + MOORE_X[i]; + let ny = y as i32 + MOORE_Y[i]; + if !grid.in_bounds(nx, ny) { continue; } + let ni = grid.index(nx as u32, ny as u32); + let nmat = grid.materials[ni]; + if nmat == 9 { continue; } + if PROPS[nmat as usize].flammability > 0 { + on_fuel = true; + break; + } + } + } + + let fh = grid.healths[idx]; + if on_fuel { + grid.healths[idx] = fh; + } else { + grid.healths[idx] = fh.saturating_sub(6); + } + + if grid.healths[idx] == 0 { + grid.materials[idx] = 0; + grid.temps[idx] = 0; + grid.flags[idx] |= crate::grid::FLAG_UPDATED; + continue; + } + + grid.temps[idx] = 200; + + for i in 0..8 { + let nx = x as i32 + MOORE_X[i]; + let ny = y as i32 + MOORE_Y[i]; + if !grid.in_bounds(nx, ny) { continue; } + let ni = grid.index(nx as u32, ny as u32); + let nmat = grid.materials[ni]; + if nmat == 0 { continue; } + + if PROPS[nmat as usize].flammability > 0 && nmat != 9 && self.rand() % 250 == 0 { + grid.materials[ni] = 9; + grid.temps[ni] = 200; + grid.healths[ni] = 40; + grid.flags[ni] |= crate::grid::FLAG_UPDATED; + } + } + + if y > 0 && self.rand() % 5 == 0 { + let above = grid.get(x, y - 1); + if above == 0 { + let ai = grid.index(x, y - 1); + grid.materials[ai] = 14; + grid.temps[ai] = 180; + grid.healths[ai] = 4 + (self.rand() % 8) as u8; + grid.flags[ai] |= crate::grid::FLAG_UPDATED; + } else { + let sdir = self.rand_dir(); + let sx = x as i32 + sdir; + if grid.in_bounds(sx, y as i32 - 1) && grid.get(sx as u32, y - 1) == 0 { + let si = grid.index(sx as u32, y - 1); + grid.materials[si] = 14; + grid.temps[si] = 180; + grid.healths[si] = 4 + (self.rand() % 8) as u8; + grid.flags[si] |= crate::grid::FLAG_UPDATED; + } + } + } + + if y > 0 && self.rand() % 16 == 0 { + let above = grid.get(x, y - 1); + if above == 0 { + let ai = grid.index(x, y - 1); + grid.materials[ai] = 14; + grid.temps[ai] = 150; + grid.healths[ai] = 30 + (self.rand() % 50) as u8; + grid.flags[ai] |= crate::grid::FLAG_UPDATED; + } else { + let sdir = self.rand_dir(); + let sx = x as i32 + sdir; + if grid.in_bounds(sx, y as i32 - 1) && grid.get(sx as u32, y - 1) == 0 { + let si = grid.index(sx as u32, y - 1); + grid.materials[si] = 14; + grid.temps[si] = 150; + grid.healths[si] = 30 + (self.rand() % 50) as u8; + grid.flags[si] |= crate::grid::FLAG_UPDATED; + } + } + } + } + } + } + + fn update_powder(&mut self, grid: &mut Grid, x: u32, y: u32, _mat: u8) { + if y + 1 >= grid.height { + return; + } + + let below = grid.get(x, y + 1); + let bprops = &PROPS[below as usize]; + if below == 0 || bprops.is_liquid || bprops.is_gas { + grid.swap(x, y, x, y + 1); + return; + } + + let dir = self.rand_dir(); + let lx = x as i32 + dir; + let rx = x as i32 - dir; + + if grid.in_bounds(lx, y as i32 + 1) && grid.get(lx as u32, y + 1) == 0 { + grid.swap(x, y, lx as u32, y + 1); + } else if grid.in_bounds(rx, y as i32 + 1) && grid.get(rx as u32, y + 1) == 0 { + grid.swap(x, y, rx as u32, y + 1); + } + } + + fn update_liquid(&mut self, grid: &mut Grid, x: u32, y: u32, mat: u8) { + if y + 1 >= grid.height { + return; + } + + let mprops = &PROPS[mat as usize]; + + let below = grid.get(x, y + 1); + let bprops = &PROPS[below as usize]; + let can_fall = below == 0 || bprops.is_gas + || (bprops.is_liquid && mprops.density > bprops.density); + + if can_fall { + let was_liquid = bprops.is_liquid; + grid.swap(x, y, x, y + 1); + if was_liquid { + let pdir = self.rand_dir(); + for dist in 1..=4i32 { + let sx = x as i32 + pdir * dist; + if !grid.in_bounds(sx, y as i32 + 1) { break; } + let sxu = sx as u32; + if grid.get(sxu, y + 1) == 0 { + grid.swap(x, y + 1, sxu, y + 1); + break; + } + } + } + return; + } + + let dir = self.rand_dir(); + for &d in &[dir, -dir] { + let dx = x as i32 + d; + if grid.in_bounds(dx, y as i32 + 1) { + let db = grid.get(dx as u32, y + 1); + let dp = &PROPS[db as usize]; + if db == 0 || dp.is_gas || (dp.is_liquid && mprops.density > dp.density) { + grid.swap(x, y, dx as u32, y + 1); + return; + } + } + } + + let max_spread = 10 + (self.rand() % 40) as i32; + for &d in &[dir, -dir] { + for dist in 1..=max_spread { + let sx = x as i32 + d * dist; + if !grid.in_bounds(sx, y as i32) { break; } + let sxu = sx as u32; + let cell = grid.get(sxu, y); + if cell != 0 && !PROPS[cell as usize].is_liquid { break; } + let below_cell = grid.get_signed(sx, y as i32 + 1); + if cell == 0 && below_cell != 0 { + grid.swap(x, y, sxu, y); + return; + } + } + } + } + + fn update_solid(&mut self, grid: &mut Grid, x: u32, y: u32, _mat: u8) { + if y + 1 >= grid.height { + return; + } + + let below = grid.get(x, y + 1); + let bprops = &PROPS[below as usize]; + if below == 0 || bprops.is_liquid || bprops.is_gas { + grid.swap(x, y, x, y + 1); + } + } + + fn update_gases(&mut self, grid: &mut Grid, x0: u32, y0: u32, x1: u32, y1: u32) { + for y in y0..y1 { + for x in x0..x1 { + let idx = grid.index(x, y); + let mat = grid.materials[idx]; + if mat == 0 { continue; } + let props = &PROPS[mat as usize]; + if !props.is_gas { continue; } + if y == 0 { continue; } + + let above = grid.get(x, y - 1); + if above == 0 { + grid.swap(x, y, x, y - 1); + continue; + } + + let dir = self.rand_dir(); + let dx = x as i32 + dir; + if grid.in_bounds(dx, y as i32 - 1) { + let dau = dx as u32; + if grid.get(dau, y - 1) == 0 { + grid.swap(x, y, dau, y - 1); + continue; + } + } + + let odx = x as i32 - dir; + if grid.in_bounds(odx, y as i32) && grid.get(odx as u32, y) == 0 { + grid.swap(x, y, odx as u32, y); + } + } + } + } + + fn update_ephemeral(&mut self, grid: &mut Grid, x0: u32, y0: u32, x1: u32, y1: u32) { + for y in y0..y1 { + for x in x0..x1 { + let idx = grid.index(x, y); + let mat = grid.materials[idx]; + if mat != 14 && mat != 15 { + continue; + } + let hh = grid.healths[idx]; + if hh <= 1 { + grid.materials[idx] = 0; + grid.temps[idx] = 0; + grid.flags[idx] |= crate::grid::FLAG_UPDATED; + } else { + grid.healths[idx] = hh - 1; + } + } + } + } +} diff --git a/engine/src/render_buffer.rs b/engine/src/render_buffer.rs new file mode 100644 index 0000000..f1db1d4 --- /dev/null +++ b/engine/src/render_buffer.rs @@ -0,0 +1,248 @@ +use crate::grid::Grid; +use crate::material::PROPS; + +pub struct RenderBuffer { + pub pixels: Vec, + pub width: u32, + pub height: u32, + light: Vec, + block: Vec, +} + +impl RenderBuffer { + pub fn new(width: u32, height: u32) -> Self { + let size = (width * height * 4) as usize; + let lsize = (width * height) as usize; + Self { + pixels: vec![0; size], + width, + height, + light: vec![0; lsize], + block: vec![0; lsize], + } + } + + pub fn render(&mut self, grid: &Grid, cam_x: i32, cam_y: i32, zoom: f32) { + let rw = self.width as i32; + let rh = self.height as i32; + let total = (rw * rh) as usize; + + for i in 0..total { + self.light[i] = 0; + self.block[i] = 0; + } + + for py in 0..rh { + let gy = cam_y + ((py as f32 - rh as f32 / 2.0) / zoom) as i32; + for px in 0..rw { + let gx = cam_x + ((px as f32 - rw as f32 / 2.0) / zoom) as i32; + let idx = (py * rw + px) as usize; + if grid.in_bounds(gx, gy) { + let gi = grid.index(gx as u32, gy as u32); + let mat = grid.materials[gi]; + if mat > 0 { + let props = &PROPS[mat as usize]; + self.light[idx] = props.light_emit; + self.block[idx] = props.light_block; + } + } + } + } + + for _pass in 0..12 { + let dir = _pass & 1; + let mut changed = false; + if dir == 0 { + for py in 0..rh { + for px in 0..rw { + if self.spread_light(py, px, rw, rh) { + changed = true; + } + } + } + } else { + for py in (0..rh).rev() { + for px in (0..rw).rev() { + if self.spread_light(py, px, rw, rh) { + changed = true; + } + } + } + } + if !changed { + break; + } + } + + for py in 0..rh { + for px in 0..rw { + let gx = cam_x + ((px as f32 - rw as f32 / 2.0) / zoom) as i32; + let gy = cam_y + ((py as f32 - rh as f32 / 2.0) / zoom) as i32; + + let color = if grid.in_bounds(gx, gy) { + let gi = grid.index(gx as u32, gy as u32); + let mat = grid.materials[gi]; + let li = (py * rw + px) as usize; + let lvl = self.light[li] as u32; + + if mat == 0 { + let bg = [20u32, 20, 30]; + let glow_r = 255u32; + let glow_g = 160u32; + let glow_b = 40u32; + let t = ((lvl * lvl) / 255).min(255); + let r = (bg[0] * (255 - t) + glow_r * t) / 255; + let g = (bg[1] * (255 - t) + glow_g * t) / 255; + let b = (bg[2] * (255 - t) + glow_b * t) / 255; + [r as u8, g as u8, b as u8, 255] + } else { + let props = &PROPS[mat as usize]; + let base = props.color; + let temp = grid.temps[gi]; + let t = (temp as f32 / 200.0).min(1.0); + + let mut r = (base[0] as f32 + t * 60.0) as u8; + let mut g = (base[1] as f32 * (1.0 - t * 0.4)) as u8; + let mut b = (base[2] as f32 * (1.0 - t * 0.6)) as u8; + + if mat == 9 || mat == 6 { + let (fr, fg, fb) = flame_variation(gx as u32, gy as u32, mat); + r = (r as i32 + fr).clamp(0, 255) as u8; + g = (g as i32 + fg).clamp(0, 255) as u8; + b = (b as i32 + fb).clamp(0, 255) as u8; + } else { + let var = texture_offset(gx as u32, gy as u32, mat); + r = (r as i32 + var).clamp(0, 255) as u8; + g = (g as i32 + var).clamp(0, 255) as u8; + b = (b as i32 + var).clamp(0, 255) as u8; + } + + if props.light_emit == 0 { + let ambient = 150u32; + let add = lvl; + r = ((r as u32 * ambient >> 8) + add).min(255) as u8; + g = ((g as u32 * ambient >> 8) + add).min(255) as u8; + b = ((b as u32 * ambient >> 8) + add).min(255) as u8; + } + + [r, g, b, base[3]] + } + } else { + [20, 20, 30, 255] + }; + + let pi = ((py * rw + px) * 4) as usize; + self.pixels[pi] = color[0]; + self.pixels[pi + 1] = color[1]; + self.pixels[pi + 2] = color[2]; + self.pixels[pi + 3] = color[3]; + } + } + } + + fn spread_light(&mut self, py: i32, px: i32, rw: i32, rh: i32) -> bool { + let idx = (py * rw + px) as usize; + let cur = self.light[idx] as u32; + let blk = (self.block[idx] as u32).min(200); + + let neighbors: [(i32, i32); 8] = [ + (px - 1, py - 1), (px, py - 1), (px + 1, py - 1), + (px - 1, py), (px + 1, py), + (px - 1, py + 1), (px, py + 1), (px + 1, py + 1), + ]; + + let mut best = cur; + for &(nx, ny) in &neighbors { + if nx >= 0 && nx < rw && ny >= 0 && ny < rh { + let ni = (ny * rw + nx) as usize; + let nlight = self.light[ni] as u32; + if nlight <= 1 { continue; } + let falloff = (if nx == px || ny == py { 2 } else { 3 }) + blk; + if nlight > falloff { + let incoming = nlight - falloff; + if incoming > best { + best = incoming; + } + } + } + } + + if blk > 0 && blk < 180 && best > cur && best > 4 { + for &(nx, ny) in &neighbors { + if nx >= 0 && nx < rw && ny >= 0 && ny < rh { + let ni = (ny * rw + nx) as usize; + let nlight = self.light[ni] as u32; + if nlight >= best { continue; } + let reflect = best >> 1; + if reflect > nlight { + self.light[ni] = reflect as u8; + } + } + } + } + + if best != cur { + self.light[idx] = best as u8; + true + } else { + false + } + } +} + +fn texture_offset(x: u32, y: u32, mat: u8) -> i32 { + match mat { + 5 => { + let wobble = ((y.wrapping_mul(5) ^ y.wrapping_shr(2)) & 3) as i32 - 1; + let pos = (x as i32).wrapping_add(wobble); + let grain = pos % 5; + let line_id = (pos / 5) as u32; + let darkness = hash(line_id, 0) & 15; + if grain == 0 { + -12 - darkness as i32 + } else if grain == 1 { + -5 + } else { + let n = hash(x.wrapping_add(y >> 1), y); + ((n as i32 - 128) * 7) >> 7 + } + } + 2 => { + let n = hash(x.wrapping_mul(3) >> 2, y.wrapping_mul(3) >> 2); + ((n as i32 - 128) * 20) >> 7 + } + 1 | 10 => { + let n = hash(x, y); + ((n as i32 - 128) * 10) >> 7 + } + 3 => { + let n = hash(x.wrapping_add(y), y); + ((n as i32 - 128) * 6) >> 7 + } + 11 => { + let n = hash(x, y.wrapping_mul(y)); + ((n as i32 - 128) * 5) >> 7 + } + _ => 0, + } +} + +fn hash(x: u32, y: u32) -> u8 { + let h = x.wrapping_mul(374761393) + .wrapping_add(y.wrapping_mul(668265263)); + (h ^ h.wrapping_shr(13)).wrapping_mul(1274126177) as u8 +} + +fn flame_variation(x: u32, y: u32, mat: u8) -> (i32, i32, i32) { + let h = hash(x, y) as i32; + let r_var = ((h - 128) * 6) >> 7; + if mat == 9 { + let g_var = ((h.wrapping_sub(40) as i32 - 128) * 12) >> 7; + let b_var = ((h.wrapping_add(60) as i32 - 128) * 8) >> 7; + (-r_var, g_var, b_var) + } else { + let g_var = ((h - 128) * 8) >> 7; + let b_var = ((h - 128) * 5) >> 7; + (r_var, g_var, b_var) + } +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..3a1b597 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,3 @@ +pkg/ +dist/ +node_modules/ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..2df41dc --- /dev/null +++ b/web/index.html @@ -0,0 +1,94 @@ + + + + + + AtomicEngine + + + + +
AtomicEngine v0.2
+
60 FPS
+
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..3dace54 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1041 @@ +{ + "name": "atomic-engine-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "atomic-engine-web", + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.5", + "vite": "^5.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..b027c6f --- /dev/null +++ b/web/package.json @@ -0,0 +1,15 @@ +{ + "name": "atomic-engine-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "^5.5", + "vite": "^5.4" + } +} diff --git a/web/src/camera.ts b/web/src/camera.ts new file mode 100644 index 0000000..f2d8d9b --- /dev/null +++ b/web/src/camera.ts @@ -0,0 +1,15 @@ +export class Camera { + x = 0; + y = 0; + zoom = 1; + targetZoom = 1; + + update(dt: number) { + const speed = 10; + this.zoom += (this.targetZoom - this.zoom) * speed * dt; + } + + setZoom(z: number) { + this.targetZoom = Math.max(0.1, Math.min(10, z)); + } +} diff --git a/web/src/engine.ts b/web/src/engine.ts new file mode 100644 index 0000000..db4fac7 --- /dev/null +++ b/web/src/engine.ts @@ -0,0 +1,97 @@ +import __wbg_init, { + init as wasm_init, + fill_rect, + fill_circle, + simulate, + update_render, + render_buffer_ptr, + render_buffer_len, + render_width, + render_height, +} from "../pkg/atomic_engine.js"; + +export const M = { + AIR: 0, + STONE: 1, + DIRT: 2, + SAND: 3, + WATER: 4, + WOOD: 5, + LAVA: 6, + PLAYER: 7, + STEAM: 8, + FIRE: 9, + GLASS: 10, + ICE: 11, + OIL: 12, + ACID: 13, + SPARK: 14, + SMOKE: 15, +} as const; + +export const MATERIAL_NAMES: Record = { + 0: "Air", + 1: "Stone", + 2: "Dirt", + 3: "Sand", + 4: "Water", + 5: "Wood", + 6: "Lava", + 7: "Player", + 8: "Steam", + 9: "Fire", + 10: "Glass", + 11: "Ice", + 12: "Oil", + 13: "Acid", + 14: "Spark", + 15: "Smoke", +}; + +export const MATERIAL_COLORS: Record = { + 0: "transparent", + 1: "#787882", + 2: "#654321", + 3: "#c2b280", + 4: "#1e3cc8", + 5: "#5a3c1e", + 6: "#ff6400", + 7: "#32c832", + 8: "#c8c8dc", + 9: "#ffb41e", + 10: "#b4d2dc", + 11: "#b4dcff", + 12: "#b48c28", + 13: "#64ff3c", + 14: "#ffb428", + 15: "#a09b96", +}; + +export interface Engine { + fillRect: (x: number, y: number, w: number, h: number, material: number) => void; + fillCircle: (x: number, y: number, radius: number, material: number) => void; + simulate: (camX: number, camY: number, zoom: number) => void; + updateRender: (camX: number, camY: number, zoom: number) => void; + renderBufferPtr: () => number; + renderBufferLen: () => number; + renderWidth: () => number; + renderHeight: () => number; + memory: WebAssembly.Memory; +} + +export async function createEngine(gridWidth: number, gridHeight: number): Promise { + const wasm = await __wbg_init(); + wasm_init(gridWidth, gridHeight); + + return { + fillRect: (x, y, w, h, m) => fill_rect(x, y, w, h, m), + fillCircle: (x, y, r, m) => fill_circle(x, y, r, m), + simulate: (cx: number, cy: number, z: number) => simulate(cx, cy, z), + updateRender: (cx, cy, z) => update_render(cx, cy, z), + renderBufferPtr: () => render_buffer_ptr(), + renderBufferLen: () => render_buffer_len(), + renderWidth: () => render_width(), + renderHeight: () => render_height(), + memory: wasm.memory, + }; +} diff --git a/web/src/input.ts b/web/src/input.ts new file mode 100644 index 0000000..69db687 --- /dev/null +++ b/web/src/input.ts @@ -0,0 +1,25 @@ +export class Input { + keys: Set = new Set(); + mouseX = 0; + mouseY = 0; + mouseDown = false; + wheel = 0; + + constructor() { + window.addEventListener("keydown", (e) => this.keys.add(e.code)); + window.addEventListener("keyup", (e) => this.keys.delete(e.code)); + window.addEventListener("mousemove", (e) => { + this.mouseX = e.clientX; + this.mouseY = e.clientY; + }); + window.addEventListener("mousedown", () => (this.mouseDown = true)); + window.addEventListener("mouseup", () => (this.mouseDown = false)); + window.addEventListener("wheel", (e) => { + this.wheel += e.deltaY > 0 ? -1 : 1; + }); + } + + isDown(key: string): boolean { + return this.keys.has(key); + } +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..5e22514 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,153 @@ +import { createEngine, M, MATERIAL_NAMES, MATERIAL_COLORS } from "./engine"; +import { Renderer } from "./renderer"; +import { Input } from "./input"; +import { Camera } from "./camera"; + +const GRID_WIDTH = 1024; +const GRID_HEIGHT = 768; + +const PAINT_MATERIALS = [M.SAND, M.WATER, M.LAVA, M.WOOD, M.FIRE, M.ICE, M.OIL, M.ACID, M.STONE, M.DIRT, M.GLASS]; + +async function main() { + const canvas = document.getElementById("canvas") as HTMLCanvasElement; + const renderer = new Renderer(canvas); + const input = new Input(); + const camera = new Camera(); + camera.x = GRID_WIDTH / 2; + camera.y = GRID_HEIGHT / 2; + camera.zoom = 3; + + const engine = await createEngine(GRID_WIDTH, GRID_HEIGHT); + + engine.fillRect(0, 740, 1024, 28, M.STONE); + engine.fillRect(440, 720, 144, 20, M.WOOD); + engine.fillRect(460, 700, 104, 20, M.DIRT); + engine.fillCircle(490, 560, 25, M.SAND); + engine.fillCircle(520, 550, 20, M.SAND); + engine.fillRect(430, 650, 40, 30, M.WATER); + engine.fillRect(550, 650, 40, 25, M.LAVA); + engine.fillRect(505, 640, 10, 16, M.ICE); + engine.fillCircle(620, 580, 20, M.OIL); + engine.fillRect(480, 620, 64, 30, M.WOOD); + engine.fillRect(480, 600, 64, 20, M.FIRE); + engine.fillRect(300, 720, 40, 20, M.GLASS); + engine.fillCircle(200, 680, 18, M.SAND); + engine.fillRect(100, 700, 30, 40, M.WOOD); + engine.fillRect(750, 680, 60, 60, M.STONE); + engine.fillCircle(780, 660, 15, M.LAVA); + engine.fillRect(700, 720, 30, 20, M.ICE); + + const rw = engine.renderWidth(); + const rh = engine.renderHeight(); + + camera.x = 512; + camera.y = 600; + + function resize() { + const dpr = window.devicePixelRatio || 1; + renderer.resize(window.innerWidth, window.innerHeight, dpr); + } + window.addEventListener("resize", resize); + resize(); + + let paintMat: number = M.SAND; + + function screenToGrid(sx: number, sy: number) { + const mx = (sx / window.innerWidth - 0.5) * rw / camera.zoom + camera.x; + const my = (sy / window.innerHeight - 0.5) * rh / camera.zoom + camera.y; + return { x: Math.floor(mx), y: Math.floor(my) }; + } + + const panel = document.getElementById("panel")!; + function buildPanel() { + panel.innerHTML = ""; + PAINT_MATERIALS.forEach((mat, idx) => { + const div = document.createElement("div"); + div.className = "mat" + (mat === paintMat ? " active" : ""); + div.dataset.mat = String(mat); + div.innerHTML = `
${idx} ${MATERIAL_NAMES[mat]}
`; + div.addEventListener("click", () => selectMat(mat)); + panel.appendChild(div); + }); + } + function selectMat(mat: number) { + paintMat = mat; + document.querySelectorAll(".mat").forEach((el) => { + el.classList.toggle("active", (el as HTMLElement).dataset.mat === String(mat)); + }); + } + buildPanel(); + + window.addEventListener("keydown", (e) => { + const idx = parseInt(e.key); + if (idx >= 0 && idx < PAINT_MATERIALS.length) { + selectMat(PAINT_MATERIALS[idx]); + } + }); + + let lastTime = performance.now(); + let simAccum = 0; + const simStep = 1 / 60; + let fpsFrames = 0; + let fpsLast = performance.now(); + const fpsEl = document.getElementById("fps")!; + + function loop(now: number) { + const dt = Math.min((now - lastTime) / 1000, 0.1); + lastTime = now; + + fpsFrames++; + if (now - fpsLast >= 500) { + const fps = Math.round(fpsFrames / ((now - fpsLast) / 1000)); + fpsEl.textContent = `${fps} FPS`; + fpsFrames = 0; + fpsLast = now; + } + + simAccum += dt; + while (simAccum >= simStep) { + simAccum -= simStep; + engine.simulate( + Math.round(camera.x), + Math.round(camera.y), + camera.zoom, + ); + } + + const speed = 200; + if (input.isDown("KeyA") || input.isDown("ArrowLeft")) camera.x -= speed * dt; + if (input.isDown("KeyD") || input.isDown("ArrowRight")) camera.x += speed * dt; + if (input.isDown("KeyW") || input.isDown("ArrowUp")) camera.y -= speed * dt; + if (input.isDown("KeyS") || input.isDown("ArrowDown")) camera.y += speed * dt; + if (input.isDown("Equal")) camera.setZoom(camera.zoom + 2 * dt); + if (input.isDown("Minus")) camera.setZoom(camera.zoom - 2 * dt); + if (input.wheel !== 0) { + camera.setZoom(camera.zoom + input.wheel * 0.5); + input.wheel = 0; + } + + if (input.mouseDown) { + const g = screenToGrid(input.mouseX, input.mouseY); + engine.fillCircle(g.x, g.y, 6, paintMat); + } + + camera.update(dt); + engine.updateRender( + Math.round(camera.x), + Math.round(camera.y), + camera.zoom, + ); + + const ptr = engine.renderBufferPtr(); + const len = engine.renderBufferLen(); + const buf = new Uint8Array(engine.memory.buffer, ptr, len).slice(0); + renderer.uploadTexture(buf, rw, rh); + renderer.render(); + + requestAnimationFrame(loop); + } + + requestAnimationFrame(loop); +} + +main(); diff --git a/web/src/renderer.ts b/web/src/renderer.ts new file mode 100644 index 0000000..4ff81f9 --- /dev/null +++ b/web/src/renderer.ts @@ -0,0 +1,102 @@ +export class Renderer { + private gl: WebGL2RenderingContext; + private texture: WebGLTexture | null = null; + private program: WebGLProgram | null = null; + private vao: WebGLVertexArrayObject | null = null; + + constructor(canvas: HTMLCanvasElement) { + const gl = canvas.getContext("webgl2", { + antialias: false, + alpha: false, + premultipliedAlpha: false, + }); + if (!gl) throw new Error("WebGL2 not supported"); + this.gl = gl; + this.init(); + } + + private init() { + const gl = this.gl; + + const vs = gl.createShader(gl.VERTEX_SHADER)!; + gl.shaderSource(vs, `#version 300 es + in vec2 a_pos; + in vec2 a_uv; + out vec2 v_uv; + void main() { + gl_Position = vec4(a_pos, 0.0, 1.0); + v_uv = a_uv; + } + `); + gl.compileShader(vs); + + const fs = gl.createShader(gl.FRAGMENT_SHADER)!; + gl.shaderSource(fs, `#version 300 es + precision mediump float; + in vec2 v_uv; + out vec4 fragColor; + uniform sampler2D u_tex; + void main() { + fragColor = texture(u_tex, v_uv); + } + `); + gl.compileShader(fs); + + this.program = gl.createProgram()!; + gl.attachShader(this.program, vs); + gl.attachShader(this.program, fs); + gl.linkProgram(this.program); + + const vertices = new Float32Array([ + -1, -1, 0, 1, + 1, -1, 1, 1, + 1, 1, 1, 0, + -1, -1, 0, 1, + 1, 1, 1, 0, + -1, 1, 0, 0, + ]); + + this.vao = gl.createVertexArray(); + gl.bindVertexArray(this.vao); + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); + + const stride = 4 * 4; + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, stride, 0); + gl.enableVertexAttribArray(1); + gl.vertexAttribPointer(1, 2, gl.FLOAT, false, stride, 2 * 4); + + this.texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, this.texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + } + + uploadTexture(data: Uint8Array, width: number, height: number) { + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); + } + + render() { + const gl = this.gl; + gl.clearColor(0.0, 0.0, 0.0, 1.0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.useProgram(this.program); + gl.bindVertexArray(this.vao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + resize(cssWidth: number, cssHeight: number, dpr: number) { + const canvas = this.gl.canvas as HTMLCanvasElement; + canvas.width = cssWidth * dpr; + canvas.height = cssHeight * dpr; + canvas.style.width = cssWidth + "px"; + canvas.style.height = cssHeight + "px"; + this.gl.viewport(0, 0, canvas.width, canvas.height); + } +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..c2062f1 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src", + "sourceMap": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..cf3818c --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + server: { + headers: { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + }, + }, + build: { + target: "es2020", + }, +});