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
This commit is contained in:
2026-07-10 16:09:58 +02:00
parent a2700df292
commit f5d16ffe64
7 changed files with 244 additions and 12 deletions
+12 -1
View File
@@ -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` |
+103
View File
@@ -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; }
}
}
}
}
+66 -2
View File
@@ -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<Actor>,
}
#[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;
}
});
}
+1 -1
View File
@@ -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 {
+30 -1
View File
@@ -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<u8>,
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 {
+15
View File
@@ -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),
};
}
+17 -7
View File
@@ -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) {