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:
2026-03-24 20:07:17 +09:00
parent 2d64d226a2
commit 59753b2264
3 changed files with 352 additions and 0 deletions

View File

@@ -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;

View 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);
}
}

View 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);
}
}