feat(ecs): add World with type-erased storage, queries, and Transform component
Implements Task 3 (World: spawn/despawn, add/get/remove components, query/query2 with type-erased HashMap<TypeId, Box<dyn ComponentStorage>>) and Task 4 (Transform: position/rotation/scale with matrix() building T*RotY*RotX*RotZ*S). 25 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
pub mod entity;
|
||||
pub mod sparse_set;
|
||||
pub mod world;
|
||||
pub mod transform;
|
||||
|
||||
pub use entity::{Entity, EntityAllocator};
|
||||
pub use sparse_set::SparseSet;
|
||||
pub use world::World;
|
||||
pub use transform::Transform;
|
||||
|
||||
111
crates/voltex_ecs/src/transform.rs
Normal file
111
crates/voltex_ecs/src/transform.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use voltex_math::{Vec3, Mat4};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Transform {
|
||||
pub position: Vec3,
|
||||
pub rotation: Vec3, // euler angles (radians): pitch(x), yaw(y), roll(z)
|
||||
pub scale: Vec3,
|
||||
}
|
||||
|
||||
impl Transform {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
position: Vec3::ZERO,
|
||||
rotation: Vec3::ZERO,
|
||||
scale: Vec3::ONE,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_position(position: Vec3) -> Self {
|
||||
Self {
|
||||
position,
|
||||
rotation: Vec3::ZERO,
|
||||
scale: Vec3::ONE,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_position_scale(position: Vec3, scale: Vec3) -> Self {
|
||||
Self {
|
||||
position,
|
||||
rotation: Vec3::ZERO,
|
||||
scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the model matrix: Translation * RotY * RotX * RotZ * Scale
|
||||
pub fn matrix(&self) -> Mat4 {
|
||||
let t = Mat4::translation(self.position.x, self.position.y, self.position.z);
|
||||
let ry = Mat4::rotation_y(self.rotation.y);
|
||||
let rx = Mat4::rotation_x(self.rotation.x);
|
||||
let rz = Mat4::rotation_z(self.rotation.z);
|
||||
let s = Mat4::scale(self.scale.x, self.scale.y, self.scale.z);
|
||||
t * ry * rx * rz * s
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Transform {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use voltex_math::Vec4;
|
||||
use std::f32::consts::FRAC_PI_2;
|
||||
|
||||
fn approx_eq(a: f32, b: f32) -> bool {
|
||||
(a - b).abs() < 1e-5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identity_transform() {
|
||||
let t = Transform::new();
|
||||
let m = t.matrix();
|
||||
// Transform point (1, 2, 3) — should be unchanged
|
||||
let p = Vec4::new(1.0, 2.0, 3.0, 1.0);
|
||||
let result = m * p;
|
||||
assert!(approx_eq(result.x, 1.0), "x: {}", result.x);
|
||||
assert!(approx_eq(result.y, 2.0), "y: {}", result.y);
|
||||
assert!(approx_eq(result.z, 3.0), "z: {}", result.z);
|
||||
assert!(approx_eq(result.w, 1.0), "w: {}", result.w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translation() {
|
||||
let t = Transform::from_position(Vec3::new(10.0, 20.0, 30.0));
|
||||
let m = t.matrix();
|
||||
// Transform origin — should move to (10,20,30)
|
||||
let p = Vec4::new(0.0, 0.0, 0.0, 1.0);
|
||||
let result = m * p;
|
||||
assert!(approx_eq(result.x, 10.0), "x: {}", result.x);
|
||||
assert!(approx_eq(result.y, 20.0), "y: {}", result.y);
|
||||
assert!(approx_eq(result.z, 30.0), "z: {}", result.z);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scale() {
|
||||
let t = Transform::from_position_scale(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0));
|
||||
let m = t.matrix();
|
||||
// Scale (1,1,1) to (2,3,4)
|
||||
let p = Vec4::new(1.0, 1.0, 1.0, 1.0);
|
||||
let result = m * p;
|
||||
assert!(approx_eq(result.x, 2.0), "x: {}", result.x);
|
||||
assert!(approx_eq(result.y, 3.0), "y: {}", result.y);
|
||||
assert!(approx_eq(result.z, 4.0), "z: {}", result.z);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rotation_y() {
|
||||
let mut t = Transform::new();
|
||||
// 90° Y rotation on (1,0,0) -> approx (0,0,-1)
|
||||
t.rotation.y = FRAC_PI_2;
|
||||
let m = t.matrix();
|
||||
let p = Vec4::new(1.0, 0.0, 0.0, 1.0);
|
||||
let result = m * p;
|
||||
assert!(approx_eq(result.x, 0.0), "x: {}", result.x);
|
||||
assert!(approx_eq(result.y, 0.0), "y: {}", result.y);
|
||||
assert!(approx_eq(result.z, -1.0), "z: {}", result.z);
|
||||
}
|
||||
}
|
||||
237
crates/voltex_ecs/src/world.rs
Normal file
237
crates/voltex_ecs/src/world.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
use std::any::TypeId;
|
||||
use std::collections::HashMap;
|
||||
use crate::entity::{Entity, EntityAllocator};
|
||||
use crate::sparse_set::{SparseSet, ComponentStorage};
|
||||
|
||||
pub struct World {
|
||||
allocator: EntityAllocator,
|
||||
storages: HashMap<TypeId, Box<dyn ComponentStorage>>,
|
||||
}
|
||||
|
||||
impl World {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
allocator: EntityAllocator::new(),
|
||||
storages: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn(&mut self) -> Entity {
|
||||
self.allocator.allocate()
|
||||
}
|
||||
|
||||
pub fn despawn(&mut self, entity: Entity) -> bool {
|
||||
if !self.allocator.deallocate(entity) {
|
||||
return false;
|
||||
}
|
||||
for storage in self.storages.values_mut() {
|
||||
storage.remove_entity(entity);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn is_alive(&self, entity: Entity) -> bool {
|
||||
self.allocator.is_alive(entity)
|
||||
}
|
||||
|
||||
pub fn entity_count(&self) -> usize {
|
||||
self.allocator.alive_count()
|
||||
}
|
||||
|
||||
pub fn add<T: 'static>(&mut self, entity: Entity, component: T) {
|
||||
let type_id = TypeId::of::<T>();
|
||||
let storage = self.storages
|
||||
.entry(type_id)
|
||||
.or_insert_with(|| Box::new(SparseSet::<T>::new()));
|
||||
let set = storage.as_any_mut().downcast_mut::<SparseSet<T>>().unwrap();
|
||||
set.insert(entity, component);
|
||||
}
|
||||
|
||||
pub fn get<T: 'static>(&self, entity: Entity) -> Option<&T> {
|
||||
let type_id = TypeId::of::<T>();
|
||||
let storage = self.storages.get(&type_id)?;
|
||||
let set = storage.as_any().downcast_ref::<SparseSet<T>>()?;
|
||||
set.get(entity)
|
||||
}
|
||||
|
||||
pub fn get_mut<T: 'static>(&mut self, entity: Entity) -> Option<&mut T> {
|
||||
let type_id = TypeId::of::<T>();
|
||||
let storage = self.storages.get_mut(&type_id)?;
|
||||
let set = storage.as_any_mut().downcast_mut::<SparseSet<T>>()?;
|
||||
set.get_mut(entity)
|
||||
}
|
||||
|
||||
pub fn remove<T: 'static>(&mut self, entity: Entity) -> Option<T> {
|
||||
let type_id = TypeId::of::<T>();
|
||||
let storage = self.storages.get_mut(&type_id)?;
|
||||
let set = storage.as_any_mut().downcast_mut::<SparseSet<T>>()?;
|
||||
set.remove(entity)
|
||||
}
|
||||
|
||||
pub fn storage<T: 'static>(&self) -> Option<&SparseSet<T>> {
|
||||
let type_id = TypeId::of::<T>();
|
||||
let storage = self.storages.get(&type_id)?;
|
||||
storage.as_any().downcast_ref::<SparseSet<T>>()
|
||||
}
|
||||
|
||||
pub fn storage_mut<T: 'static>(&mut self) -> Option<&mut SparseSet<T>> {
|
||||
let type_id = TypeId::of::<T>();
|
||||
let storage = self.storages.get_mut(&type_id)?;
|
||||
storage.as_any_mut().downcast_mut::<SparseSet<T>>()
|
||||
}
|
||||
|
||||
pub fn query<T: 'static>(&self) -> impl Iterator<Item = (Entity, &T)> {
|
||||
self.storage::<T>()
|
||||
.map(|s| s.iter())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub fn query2<A: 'static, B: 'static>(&self) -> Vec<(Entity, &A, &B)> {
|
||||
let a_storage = match self.storage::<A>() {
|
||||
Some(s) => s,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
let b_storage = match self.storage::<B>() {
|
||||
Some(s) => s,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
// Iterate the smaller set, look up in the larger
|
||||
let mut result = Vec::new();
|
||||
if a_storage.len() <= b_storage.len() {
|
||||
for (entity, a) in a_storage.iter() {
|
||||
if let Some(b) = b_storage.get(entity) {
|
||||
result.push((entity, a, b));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (entity, b) in b_storage.iter() {
|
||||
if let Some(a) = a_storage.get(entity) {
|
||||
result.push((entity, a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for World {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Position { x: f32, y: f32 }
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Velocity { dx: f32, dy: f32 }
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Name(String);
|
||||
|
||||
#[test]
|
||||
fn test_spawn_and_add() {
|
||||
let mut world = World::new();
|
||||
let e = world.spawn();
|
||||
world.add(e, Position { x: 1.0, y: 2.0 });
|
||||
let pos = world.get::<Position>(e).unwrap();
|
||||
assert_eq!(pos.x, 1.0);
|
||||
assert_eq!(pos.y, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_missing() {
|
||||
let world = World::new();
|
||||
let e = Entity { id: 0, generation: 0 };
|
||||
assert!(world.get::<Position>(e).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_mut() {
|
||||
let mut world = World::new();
|
||||
let e = world.spawn();
|
||||
world.add(e, Position { x: 0.0, y: 0.0 });
|
||||
{
|
||||
let pos = world.get_mut::<Position>(e).unwrap();
|
||||
pos.x = 42.0;
|
||||
}
|
||||
assert_eq!(world.get::<Position>(e).unwrap().x, 42.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_component() {
|
||||
let mut world = World::new();
|
||||
let e = world.spawn();
|
||||
world.add(e, Position { x: 5.0, y: 6.0 });
|
||||
let removed = world.remove::<Position>(e);
|
||||
assert_eq!(removed, Some(Position { x: 5.0, y: 6.0 }));
|
||||
assert!(world.get::<Position>(e).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_despawn() {
|
||||
let mut world = World::new();
|
||||
let e = world.spawn();
|
||||
world.add(e, Position { x: 1.0, y: 2.0 });
|
||||
world.add(e, Velocity { dx: 3.0, dy: 4.0 });
|
||||
assert!(world.despawn(e));
|
||||
assert!(!world.is_alive(e));
|
||||
assert!(world.get::<Position>(e).is_none());
|
||||
assert!(world.get::<Velocity>(e).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_single() {
|
||||
let mut world = World::new();
|
||||
let e0 = world.spawn();
|
||||
let e1 = world.spawn();
|
||||
let _e2 = world.spawn(); // no Position
|
||||
world.add(e0, Position { x: 1.0, y: 0.0 });
|
||||
world.add(e1, Position { x: 2.0, y: 0.0 });
|
||||
|
||||
let results: Vec<(Entity, &Position)> = world.query::<Position>().collect();
|
||||
assert_eq!(results.len(), 2);
|
||||
let entities: Vec<Entity> = results.iter().map(|(e, _)| *e).collect();
|
||||
assert!(entities.contains(&e0));
|
||||
assert!(entities.contains(&e1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query2() {
|
||||
let mut world = World::new();
|
||||
let e0 = world.spawn();
|
||||
let e1 = world.spawn();
|
||||
let e2 = world.spawn(); // only Position, no Velocity
|
||||
world.add(e0, Position { x: 1.0, y: 0.0 });
|
||||
world.add(e0, Velocity { dx: 1.0, dy: 0.0 });
|
||||
world.add(e1, Position { x: 2.0, y: 0.0 });
|
||||
world.add(e1, Velocity { dx: 2.0, dy: 0.0 });
|
||||
world.add(e2, Position { x: 3.0, y: 0.0 });
|
||||
|
||||
let results = world.query2::<Position, Velocity>();
|
||||
assert_eq!(results.len(), 2);
|
||||
let entities: Vec<Entity> = results.iter().map(|(e, _, _)| *e).collect();
|
||||
assert!(entities.contains(&e0));
|
||||
assert!(entities.contains(&e1));
|
||||
assert!(!entities.contains(&e2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entity_count() {
|
||||
let mut world = World::new();
|
||||
assert_eq!(world.entity_count(), 0);
|
||||
let e0 = world.spawn();
|
||||
let e1 = world.spawn();
|
||||
assert_eq!(world.entity_count(), 2);
|
||||
world.despawn(e0);
|
||||
assert_eq!(world.entity_count(), 1);
|
||||
world.despawn(e1);
|
||||
assert_eq!(world.entity_count(), 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user