From f5d16ffe64be793b2617d18a061b3c48deaf5d1d Mon Sep 17 00:00:00 2001 From: nico Date: Fri, 10 Jul 2026 16:09:58 +0200 Subject: [PATCH] v0.6: Actor system (rigidbody) replaces pixel player - New actors.rs: Rigidbody Actor with gravity, grid collision, powder displacement - Actor rendered as colored rectangle on pixel buffer (zoom-scaled) - WASM API: spawn_actor, move_actor, update_actor, actor_x/y - WASD/Arrow keys control actor, camera follows with soft lerp - displace_powders pushes sand/dirt out of actor area - Collision: stops at solids, walks through powders - Updated AGENTS.md with actor documentation --- AGENTS.md | 13 ++++- engine/src/actors.rs | 103 ++++++++++++++++++++++++++++++++++++ engine/src/lib.rs | 68 +++++++++++++++++++++++- engine/src/main.rs | 2 +- engine/src/render_buffer.rs | 31 ++++++++++- web/src/engine.ts | 15 ++++++ web/src/main.ts | 24 ++++++--- 7 files changed, 244 insertions(+), 12 deletions(-) create mode 100644 engine/src/actors.rs diff --git a/AGENTS.md b/AGENTS.md index c7c9ab0..9dbdad0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -182,7 +182,17 @@ Jedes Material bekommt deterministische Pixel-Variation basierend auf Grid-Koord ### Player-System (Phase 4 — in Arbeit) -Player-Material (7) existiert als grüner Pixel-Cluster. `move_player(dx, dy)`-API in `physics.rs` via WASM exportiert (`engine.movePlayer`). Aktueller Ansatz: Clear+Place (alle Player-Zellen löschen, an Zielposition neu setzen), all-or-nothing bei Kollision mit Solids. Gravitation: `dy=1` jede Frame. Horizontal-Input: Pfeiltasten separat. Kamera: folgt Player-Zentrum (Scan 200×200 Region um letzte Position, cached). Offen: Cluster-Form stabil halten (kreisrund), Sprung-Mechanik, Schadenssystem. +**Aktueller Ansatz**: Rigidbody-Actor-System statt Pixel-Player. +- `engine/src/actors.rs`: `Actor`-Struct (x, y, w, h, vx, vy, color) +- Gravitation (500px/s²), Geschwindigkeits-Dämpfung (0.9×/Frame) +- Grid-Kollision: `collides_at()` prüft Solids (nicht Powders), seitliches Stoppen + vertikales Snap an Oberflächen +- `displace_powders()`: Verschiebt Sand/Erde aus dem Actor-Bereich in leere Nachbarzellen +- WASM-API: `spawn_actor`, `move_actor`, `update_actor`, `actor_x/y` +- Rendering: Actor als farbiges Rechteck über dem Pixel-Buffer (zoom-skaliert) +- Input: WASD/Pfeiltasten, Kamera folgt Actor weich +- Player-Material (7) existiert weiterhin, wird aktuell nicht genutzt + +Offen: Mehrere Actors, Maus-Selektion, Pathfinding (A*), Task-System ## Build & Entwicklung @@ -215,6 +225,7 @@ cd web && npm run build | Cell + Chunk + Grid DS | `engine/src/grid.rs` | | Material-Definitionen | `engine/src/material.rs` | | Physik: Kräfte, Sand, Fluide | `engine/src/physics.rs` | +| Actor-System (Rigidbody) | `engine/src/actors.rs` | | RGBA-Buffer Export | `engine/src/render_buffer.rs` | | Rust-Abhängigkeiten | `engine/Cargo.toml` | | WebGL2 Renderer | `web/src/renderer.ts` | diff --git a/engine/src/actors.rs b/engine/src/actors.rs new file mode 100644 index 0000000..e62f5f7 --- /dev/null +++ b/engine/src/actors.rs @@ -0,0 +1,103 @@ +use crate::grid::Grid; + +pub struct Actor { + pub x: f32, + pub y: f32, + pub w: u32, + pub h: u32, + pub vx: f32, + pub vy: f32, + pub color: [u8; 3], +} + +impl Actor { + pub fn new(x: f32, y: f32, w: u32, h: u32, color: [u8; 3]) -> Self { + Self { x, y, w, h, vx: 0.0, vy: 0.0, color } + } + + pub fn update(&mut self, grid: &Grid, dt: f32) { + const GRAVITY: f32 = 500.0; + self.vy += GRAVITY * dt; + self.vy = self.vy.clamp(-600.0, 600.0); + + let new_x = (self.x + self.vx * dt).clamp(0.0, grid.width as f32 - self.w as f32); + let new_y = (self.y + self.vy * dt).clamp(0.0, grid.height as f32 - self.h as f32); + + if self.vx != 0.0 && self.collides_at(grid, new_x, self.y) { + self.vx = 0.0; + } else { + self.x = new_x; + } + + if self.collides_at(grid, self.x, new_y) { + if self.vy > 0.0 { + let mut sy = new_y; + while sy <= new_y + self.h as f32 && self.collides_at(grid, self.x, sy) { + sy -= 1.0; + } + self.y = sy; + } else { + let mut sy = new_y; + while sy >= new_y - self.h as f32 && self.collides_at(grid, self.x, sy) { + sy += 1.0; + } + self.y = sy; + } + self.vy = 0.0; + } else { + self.y = new_y; + } + } + + fn collides_at(&self, grid: &Grid, ax: f32, ay: f32) -> bool { + let x0 = ax.floor() as u32; + let y0 = ay.floor() as u32; + let x1 = ((ax + self.w as f32 - 0.01).ceil() as u32).min(grid.width); + let y1 = ((ay + self.h as f32 - 0.01).ceil() as u32).min(grid.height); + + for gy in y0..y1 { + for gx in x0..x1 { + let mat = grid.get(gx, gy); + let props = &crate::material::PROPS[mat as usize]; + if props.is_solid && !props.is_powder { + return true; + } + } + } + + false + } + + pub fn displace_powders(&self, grid: &mut Grid) { + let x0 = self.x.floor() as u32; + let y0 = self.y.floor() as u32; + let x1 = ((self.x + self.w as f32).ceil() as u32).min(grid.width); + let y1 = ((self.y + self.h as f32).ceil() as u32).min(grid.height); + + for gy in y0..y1 { + for gx in x0..x1 { + let mat = grid.get(gx, gy); + let props = &crate::material::PROPS[mat as usize]; + if !props.is_powder { continue; } + let mut placed = false; + for dy in 0i32..=4i32 { + for dx in -4i32..=4i32 { + let sx = gx as i32 + dx; + let sy = gy as i32 + dy; + if !grid.in_bounds(sx, sy) { continue; } + let su = (sx as u32, sy as u32); + if su.0 >= x0 && su.0 < x1 && su.1 >= y0 && su.1 < y1 { continue; } + if grid.get(su.0, su.1) == 0 { + grid.set(su.0, su.1, mat); + let idx = grid.index(gx, gy); + grid.materials[idx] = 0; + placed = true; + break; + } + } +} + if placed { break; } + } + } + } + } diff --git a/engine/src/lib.rs b/engine/src/lib.rs index 463a94d..a4eb02a 100644 --- a/engine/src/lib.rs +++ b/engine/src/lib.rs @@ -1,12 +1,14 @@ +pub mod actors; pub mod grid; pub mod material; pub mod physics; pub mod render_buffer; use std::cell::RefCell; +use actors::Actor; use grid::Grid; use physics::Physics; -use render_buffer::RenderBuffer; +use render_buffer::{ActorRect, RenderBuffer}; use wasm_bindgen::prelude::*; thread_local! { @@ -17,6 +19,7 @@ struct Engine { grid: Grid, physics: Physics, render_buffer: RenderBuffer, + actor: Option, } #[wasm_bindgen(start)] @@ -41,6 +44,7 @@ pub fn init(width: u32, height: u32) { grid: Grid::new(width, height), physics: Physics::new(), render_buffer: RenderBuffer::new(320, 180), + actor: None, }; ENGINE.with(|cell| { *cell.borrow_mut() = Some(engine); @@ -106,7 +110,19 @@ pub fn render_height() -> u32 { #[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)); + with_engine(|e| { + let mut actors = Vec::new(); + if let Some(ref a) = e.actor { + actors.push(ActorRect { + x: a.x, + y: a.y, + w: a.w, + h: a.h, + color: (a.color[0], a.color[1], a.color[2]), + }); + } + e.render_buffer.render(&e.grid, cam_x, cam_y, zoom, &actors); + }); } #[wasm_bindgen] @@ -115,3 +131,51 @@ pub fn reset() { e.grid = Grid::new(e.grid.width, e.grid.height); }); } + +#[wasm_bindgen] +pub fn spawn_actor(x: f32, y: f32) { + with_engine(|e| { + e.actor = Some(Actor::new(x, y, 10, 16, [255, 80, 80])); + }); +} + +#[wasm_bindgen] +pub fn actor_x() -> f32 { + ENGINE.with(|cell| { + let opt = cell.borrow(); + let engine = opt.as_ref().expect("Engine not initialized."); + engine.actor.as_ref().map(|a| a.x).unwrap_or(0.0) + }) +} + +#[wasm_bindgen] +pub fn actor_y() -> f32 { + ENGINE.with(|cell| { + let opt = cell.borrow(); + let engine = opt.as_ref().expect("Engine not initialized."); + engine.actor.as_ref().map(|a| a.y).unwrap_or(0.0) + }) +} + +#[wasm_bindgen] +pub fn move_actor(dx: f32, dy: f32) { + with_engine(|e| { + if let Some(ref mut a) = e.actor { + a.vx = dx * 150.0; + if dy < 0.0 && a.vy == 0.0 { + a.vy = -400.0; + } + } + }); +} + +#[wasm_bindgen] +pub fn update_actor(dt: f32) { + with_engine(|e| { + if let Some(ref mut a) = e.actor { + a.update(&e.grid, dt); + a.displace_powders(&mut e.grid); + a.vx = a.vx * 0.9; + } + }); +} diff --git a/engine/src/main.rs b/engine/src/main.rs index dec5b71..057ff7c 100644 --- a/engine/src/main.rs +++ b/engine/src/main.rs @@ -105,7 +105,7 @@ fn main() { loop { physics.update(&mut grid, cam_x, cam_y, rw, rh, zoom); - rb.render(&grid, cam_x, cam_y, zoom); + rb.render(&grid, cam_x, cam_y, zoom, &[]); frame += 1; if frame % 60 == 0 { diff --git a/engine/src/render_buffer.rs b/engine/src/render_buffer.rs index 31a75c1..f2fca42 100644 --- a/engine/src/render_buffer.rs +++ b/engine/src/render_buffer.rs @@ -1,6 +1,14 @@ use crate::grid::Grid; use crate::material::PROPS; +pub struct ActorRect { + pub x: f32, + pub y: f32, + pub w: u32, + pub h: u32, + pub color: (u8, u8, u8), +} + pub struct RenderBuffer { pub pixels: Vec, pub width: u32, @@ -24,7 +32,7 @@ impl RenderBuffer { } } - pub fn render(&mut self, grid: &Grid, cam_x: i32, cam_y: i32, zoom: f32) { + pub fn render(&mut self, grid: &Grid, cam_x: i32, cam_y: i32, zoom: f32, actors: &[ActorRect]) { let rw = self.width as i32; let rh = self.height as i32; let total = (rw * rh) as usize; @@ -162,6 +170,27 @@ impl RenderBuffer { self.pixels[pi + 3] = color[3]; } } + + for actor in actors { + let ax = ((actor.x - cam_x as f32) * zoom + rw as f32 / 2.0) as i32; + let ay = ((actor.y - cam_y as f32) * zoom + rh as f32 / 2.0) as i32; + let aw = (actor.w as f32 * zoom).max(1.0) as u32; + let ah = (actor.h as f32 * zoom).max(1.0) as u32; + let (cr, cg, cb) = actor.color; + for dy in 0..ah.min(self.height) { + for dx in 0..aw.min(self.width) { + let spx = ax + dx as i32; + let spy = ay + dy as i32; + if spx >= 0 && spx < rw && spy >= 0 && spy < rh { + let pi = ((spy * rw + spx) * 4) as usize; + self.pixels[pi] = cr; + self.pixels[pi + 1] = cg; + self.pixels[pi + 2] = cb; + self.pixels[pi + 3] = 255; + } + } + } + } } fn spread_light(&mut self, py: i32, px: i32, rw: i32, rh: i32) -> bool { diff --git a/web/src/engine.ts b/web/src/engine.ts index 4c3a7f6..cbe9275 100644 --- a/web/src/engine.ts +++ b/web/src/engine.ts @@ -8,6 +8,11 @@ import __wbg_init, { render_buffer_len, render_width, render_height, + spawn_actor, + actor_x, + actor_y, + move_actor, + update_actor, } from "../pkg/atomic_engine.js"; export const M = { @@ -79,6 +84,11 @@ export interface Engine { renderBufferLen: () => number; renderWidth: () => number; renderHeight: () => number; + spawnActor: (x: number, y: number) => void; + actorX: () => number; + actorY: () => number; + moveActor: (dx: number, dy: number) => void; + updateActor: (dt: number) => void; memory: WebAssembly.Memory; } @@ -96,5 +106,10 @@ export async function createEngine(gridWidth: number, gridHeight: number): Promi renderWidth: () => render_width(), renderHeight: () => render_height(), memory: wasm.memory, + spawnActor: (x, y) => spawn_actor(x, y), + actorX: () => actor_x(), + actorY: () => actor_y(), + moveActor: (dx, dy) => move_actor(dx, dy), + updateActor: (dt) => update_actor(dt), }; } diff --git a/web/src/main.ts b/web/src/main.ts index 0817ae3..4ee2b87 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -99,9 +99,9 @@ async function main() { } } - // === Spawn point: center on the castle === + engine.spawnActor(400, 550); camera.x = 400; - camera.y = 630; + camera.y = 550; const rw = engine.renderWidth(); const rh = engine.renderHeight(); @@ -184,11 +184,21 @@ async function main() { ); } - 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; + let mx = 0; + let my = 0; + if (input.isDown("KeyA") || input.isDown("ArrowLeft")) mx = -1; + if (input.isDown("KeyD") || input.isDown("ArrowRight")) mx = 1; + if (input.isDown("KeyW") || input.isDown("ArrowUp")) my = -1; + engine.moveActor(mx, my); + engine.updateActor(dt); + + const ax = engine.actorX(); + const ay = engine.actorY(); + if (ax > 0 || ay > 0) { + camera.x += (ax - camera.x) * 10 * dt; + camera.y += (ay - 60 - camera.y) * 10 * 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) {