diff --git a/src/entity/body_template.rs b/src/entity/body_template.rs new file mode 100644 index 0000000..5f292fc --- /dev/null +++ b/src/entity/body_template.rs @@ -0,0 +1,492 @@ +use crate::entity::entity::{Entity, EntityKind}; +use crate::physics::verlet::SubBody; +use crate::world::cell::MaterialId; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct BodyPart { + pub x: f32, + pub y: f32, + pub color: [u8; 4], + pub label: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct BodyTemplate { + pub name: String, + pub half_w: f32, + pub half_h: f32, + pub radius: f32, + pub parts: Vec, + pub constraints: Vec<(usize, usize)>, +} + +impl BodyTemplate { + pub fn humanoid_player() -> Self { + Self { + name: "player".to_string(), + half_w: 2.5, + half_h: 3.5, + radius: 0.5, + parts: vec![ + BodyPart { + x: 0.0, + y: -3.0, + color: [220, 200, 240, 255], + label: "head".into(), + }, + BodyPart { + x: 0.0, + y: -2.0, + color: [220, 200, 240, 255], + label: "head".into(), + }, + BodyPart { + x: 0.0, + y: -1.0, + color: [140, 80, 200, 255], + label: "shirt".into(), + }, + BodyPart { + x: -1.0, + y: -1.0, + color: [210, 185, 235, 255], + label: "hand".into(), + }, + BodyPart { + x: 1.0, + y: -1.0, + color: [210, 185, 235, 255], + label: "hand".into(), + }, + BodyPart { + x: 0.0, + y: 0.0, + color: [140, 80, 200, 255], + label: "shirt".into(), + }, + BodyPart { + x: -1.0, + y: 0.0, + color: [210, 185, 235, 255], + label: "hand".into(), + }, + BodyPart { + x: 1.0, + y: 0.0, + color: [210, 185, 235, 255], + label: "hand".into(), + }, + BodyPart { + x: 0.0, + y: 1.0, + color: [90, 50, 150, 255], + label: "pants".into(), + }, + BodyPart { + x: -1.0, + y: 2.0, + color: [90, 50, 150, 255], + label: "pants".into(), + }, + BodyPart { + x: 1.0, + y: 2.0, + color: [90, 50, 150, 255], + label: "pants".into(), + }, + BodyPart { + x: 0.0, + y: 2.0, + color: [90, 50, 150, 255], + label: "pants".into(), + }, + BodyPart { + x: -1.0, + y: 3.0, + color: [60, 35, 100, 255], + label: "boots".into(), + }, + BodyPart { + x: 0.0, + y: 3.0, + color: [60, 35, 100, 255], + label: "boots".into(), + }, + BodyPart { + x: 1.0, + y: 3.0, + color: [60, 35, 100, 255], + label: "boots".into(), + }, + ], + constraints: vec![ + (0, 1), + (1, 2), + (2, 5), + (5, 8), + (2, 3), + (2, 4), + (5, 6), + (5, 7), + (8, 11), + (11, 9), + (11, 10), + (9, 12), + (10, 14), + (11, 13), + ], + } + } + + pub fn humanoid_goblin() -> Self { + Self { + name: "goblin".to_string(), + half_w: 2.5, + half_h: 3.5, + radius: 0.5, + parts: vec![ + BodyPart { + x: 0.0, + y: -3.0, + color: [130, 210, 100, 255], + label: "head".into(), + }, + BodyPart { + x: 0.0, + y: -2.0, + color: [130, 210, 100, 255], + label: "head".into(), + }, + BodyPart { + x: 0.0, + y: -1.0, + color: [55, 130, 45, 255], + label: "shirt".into(), + }, + BodyPart { + x: -1.0, + y: -1.0, + color: [110, 180, 85, 255], + label: "hand".into(), + }, + BodyPart { + x: 1.0, + y: -1.0, + color: [110, 180, 85, 255], + label: "hand".into(), + }, + BodyPart { + x: 0.0, + y: 0.0, + color: [55, 130, 45, 255], + label: "shirt".into(), + }, + BodyPart { + x: -1.0, + y: 0.0, + color: [110, 180, 85, 255], + label: "hand".into(), + }, + BodyPart { + x: 1.0, + y: 0.0, + color: [110, 180, 85, 255], + label: "hand".into(), + }, + BodyPart { + x: 0.0, + y: 1.0, + color: [40, 100, 30, 255], + label: "pants".into(), + }, + BodyPart { + x: -1.0, + y: 2.0, + color: [40, 100, 30, 255], + label: "pants".into(), + }, + BodyPart { + x: 1.0, + y: 2.0, + color: [40, 100, 30, 255], + label: "pants".into(), + }, + BodyPart { + x: 0.0, + y: 2.0, + color: [40, 100, 30, 255], + label: "pants".into(), + }, + BodyPart { + x: -1.0, + y: 3.0, + color: [25, 70, 18, 255], + label: "boots".into(), + }, + BodyPart { + x: 0.0, + y: 3.0, + color: [25, 70, 18, 255], + label: "boots".into(), + }, + BodyPart { + x: 1.0, + y: 3.0, + color: [25, 70, 18, 255], + label: "boots".into(), + }, + ], + constraints: vec![ + (0, 1), + (1, 2), + (2, 5), + (5, 8), + (2, 3), + (2, 4), + (5, 6), + (5, 7), + (8, 11), + (11, 9), + (11, 10), + (9, 12), + (10, 14), + (11, 13), + ], + } + } + + pub fn boulder() -> Self { + Self { + name: "boulder".to_string(), + half_w: 2.5, + half_h: 2.5, + radius: 0.5, + parts: vec![ + BodyPart { + x: -1.0, + y: -2.0, + color: [100, 100, 110, 255], + label: "rock".into(), + }, + BodyPart { + x: 0.0, + y: -2.0, + color: [110, 110, 120, 255], + label: "rock".into(), + }, + BodyPart { + x: 1.0, + y: -2.0, + color: [100, 100, 110, 255], + label: "rock".into(), + }, + BodyPart { + x: -2.0, + y: -1.0, + color: [105, 105, 115, 255], + label: "rock".into(), + }, + BodyPart { + x: -1.0, + y: -1.0, + color: [115, 115, 125, 255], + label: "rock".into(), + }, + BodyPart { + x: 0.0, + y: -1.0, + color: [115, 115, 125, 255], + label: "rock".into(), + }, + BodyPart { + x: 1.0, + y: -1.0, + color: [115, 115, 125, 255], + label: "rock".into(), + }, + BodyPart { + x: 2.0, + y: -1.0, + color: [105, 105, 115, 255], + label: "rock".into(), + }, + BodyPart { + x: -2.0, + y: 0.0, + color: [100, 100, 110, 255], + label: "rock".into(), + }, + BodyPart { + x: -1.0, + y: 0.0, + color: [110, 110, 120, 255], + label: "rock".into(), + }, + BodyPart { + x: 0.0, + y: 0.0, + color: [120, 120, 130, 255], + label: "rock".into(), + }, + BodyPart { + x: 1.0, + y: 0.0, + color: [110, 110, 120, 255], + label: "rock".into(), + }, + BodyPart { + x: 2.0, + y: 0.0, + color: [100, 100, 110, 255], + label: "rock".into(), + }, + BodyPart { + x: -1.0, + y: 1.0, + color: [95, 95, 105, 255], + label: "rock".into(), + }, + BodyPart { + x: 0.0, + y: 1.0, + color: [105, 105, 115, 255], + label: "rock".into(), + }, + BodyPart { + x: 1.0, + y: 1.0, + color: [95, 95, 105, 255], + label: "rock".into(), + }, + ], + constraints: vec![ + (0, 1), + (1, 2), + (0, 3), + (1, 4), + (2, 5), + (3, 4), + (4, 5), + (5, 6), + (3, 7), + (6, 7), + (3, 8), + (4, 9), + (5, 10), + (6, 11), + (7, 12), + (8, 9), + (9, 10), + (10, 11), + (11, 12), + (8, 13), + (9, 14), + (10, 15), + (14, 15), + (13, 14), + ], + } + } + + pub fn apply_to(&self, entity: &mut Entity, cx: f32, cy: f32) { + entity.bodies.clear(); + entity.constraints.clear(); + entity.rest_offsets.clear(); + entity.cx = cx; + entity.cy = cy; + entity.cvx = 0.0; + entity.cvy = 0.0; + entity.half_w = self.half_w; + entity.half_h = self.half_h; + + let mat = MaterialId::Flesh; + for part in &self.parts { + entity.rest_offsets.push((part.x, part.y)); + let mut b = SubBody::new(cx + part.x, cy + part.y, self.radius, mat); + b.color = part.color; + entity.bodies.push(b); + } + + for &(a, b) in &self.constraints { + let dx = entity.bodies[a].x - entity.bodies[b].x; + let dy = entity.bodies[a].y - entity.bodies[b].y; + let len = (dx * dx + dy * dy).sqrt().max(0.5); + entity + .constraints + .push(crate::physics::verlet::Constraint::new(a, b, len, 1.0)); + } + } + + pub fn to_json(&self) -> String { + serde_json::to_string_pretty(self).unwrap_or_default() + } + + pub fn from_json(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| e.to_string()) + } + + pub fn preview_ascii(&self) -> String { + if self.parts.is_empty() { + return "(empty template)".to_string(); + } + + let min_x = self.parts.iter().map(|p| p.x).fold(f32::INFINITY, f32::min); + let max_x = self + .parts + .iter() + .map(|p| p.x) + .fold(f32::NEG_INFINITY, f32::max); + let min_y = self.parts.iter().map(|p| p.y).fold(f32::INFINITY, f32::min); + let max_y = self + .parts + .iter() + .map(|p| p.y) + .fold(f32::NEG_INFINITY, f32::max); + + let w = ((max_x - min_x) as i32 + 3) as usize; + let h = ((max_y - min_y) as i32 + 3) as usize; + + let mut grid = vec![vec![' '; w]; h]; + + let label_colors: std::collections::HashMap<&str, char> = + std::collections::HashMap::from([ + ("head", 'O'), + ("shirt", '#'), + ("hand", '+'), + ("pants", '|'), + ("boots", '"'), + ("rock", '*'), + ("skin", 'O'), + ("body", '#'), + ("leg", '|'), + ("arm", '+'), + ("wing", '~'), + ("tail", '.'), + ]); + + for part in &self.parts { + let gx = (part.x - min_x) as i32 + 1; + let gy = (part.y - min_y) as i32 + 1; + if gx >= 0 && gx < w as i32 && gy >= 0 && gy < h as i32 { + let ch = label_colors.get(part.label.as_str()).unwrap_or(&'#'); + grid[gy as usize][gx as usize] = *ch; + } + } + + let mut out = String::new(); + for row in grid { + out.push_str(&row.iter().collect::()); + out.push('\n'); + } + out + } +} + +pub fn template_for_kind(kind: EntityKind) -> BodyTemplate { + match kind { + EntityKind::Player => BodyTemplate::humanoid_player(), + EntityKind::Goblin => BodyTemplate::humanoid_goblin(), + EntityKind::Corpse => BodyTemplate::humanoid_player(), + } +} diff --git a/src/entity/entity.rs b/src/entity/entity.rs index 5366497..ce47be7 100644 --- a/src/entity/entity.rs +++ b/src/entity/entity.rs @@ -103,84 +103,8 @@ impl Entity { } pub fn build_humanoid(&mut self, cx: f32, cy: f32) { - self.bodies.clear(); - self.constraints.clear(); - self.rest_offsets.clear(); - self.cx = cx; - self.cy = cy; - self.cvx = 0.0; - self.cvy = 0.0; - self.half_w = 2.5; - self.half_h = 3.5; - - let r = 0.5; - let mat = MaterialId::Flesh; - - let (skin, shirt, pants, boots, hand) = match self.kind { - EntityKind::Player => ( - [220, 200, 240, 255], - [140, 80, 200, 255], - [90, 50, 150, 255], - [60, 35, 100, 255], - [210, 185, 235, 255], - ), - EntityKind::Goblin => ( - [130, 210, 100, 255], - [55, 130, 45, 255], - [40, 100, 30, 255], - [25, 70, 18, 255], - [110, 180, 85, 255], - ), - EntityKind::Corpse => ( - [150, 130, 120, 255], - [120, 100, 90, 255], - [100, 85, 75, 255], - [80, 65, 55, 255], - [135, 115, 105, 255], - ), - }; - - let layout: [(f32, f32, [u8; 4]); 15] = [ - (0.0, -3.0, skin), - (0.0, -2.0, skin), - (0.0, -1.0, shirt), - (-1.0, -1.0, hand), - (1.0, -1.0, hand), - (0.0, 0.0, shirt), - (-1.0, 0.0, hand), - (1.0, 0.0, hand), - (0.0, 1.0, pants), - (-1.0, 2.0, pants), - (1.0, 2.0, pants), - (0.0, 2.0, pants), - (-1.0, 3.0, boots), - (0.0, 3.0, boots), - (1.0, 3.0, boots), - ]; - - for &(ox, oy, color) in &layout { - self.rest_offsets.push((ox, oy)); - let mut b = SubBody::new(cx + ox, cy + oy, r, mat); - b.color = color; - self.bodies.push(b); - } - - let mk = |a: usize, b: usize, len: f32| Constraint::new(a, b, len, 1.0); - - self.constraints.push(mk(0, 1, 1.0)); - self.constraints.push(mk(1, 2, 1.0)); - self.constraints.push(mk(2, 5, 1.0)); - self.constraints.push(mk(5, 8, 1.0)); - self.constraints.push(mk(2, 3, 1.0)); - self.constraints.push(mk(2, 4, 1.0)); - self.constraints.push(mk(5, 6, 1.0)); - self.constraints.push(mk(5, 7, 1.0)); - self.constraints.push(mk(8, 11, 1.0)); - self.constraints.push(mk(11, 9, 1.0)); - self.constraints.push(mk(11, 10, 1.0)); - self.constraints.push(mk(9, 12, 1.0)); - self.constraints.push(mk(10, 14, 1.0)); - self.constraints.push(mk(11, 13, 1.0)); + let template = crate::entity::body_template::template_for_kind(self.kind); + template.apply_to(self, cx, cy); } pub fn sync_bodies_to_center(&mut self) { diff --git a/src/entity/mod.rs b/src/entity/mod.rs index 1b74493..64eb7ca 100644 --- a/src/entity/mod.rs +++ b/src/entity/mod.rs @@ -1,5 +1,7 @@ pub mod entity; pub mod player; +pub mod body_template; pub use entity::{EntityManager, EntityKind}; pub use player::Player; +pub use body_template::{BodyTemplate, BodyPart, template_for_kind}; diff --git a/tests/body_template.rs b/tests/body_template.rs new file mode 100644 index 0000000..8c1f93c --- /dev/null +++ b/tests/body_template.rs @@ -0,0 +1,116 @@ +use verbatim::entity::body_template::{BodyPart, BodyTemplate}; + +#[test] +fn player_template_has_correct_shape() { + let t = BodyTemplate::humanoid_player(); + assert_eq!(t.name, "player"); + assert_eq!(t.parts.len(), 15); + assert_eq!(t.half_w, 2.5); + assert_eq!(t.half_h, 3.5); +} + +#[test] +fn goblin_template_has_correct_shape() { + let t = BodyTemplate::humanoid_goblin(); + assert_eq!(t.name, "goblin"); + assert_eq!(t.parts.len(), 15); +} + +#[test] +fn boulder_template_has_correct_shape() { + let t = BodyTemplate::boulder(); + assert_eq!(t.name, "boulder"); + assert_eq!(t.parts.len(), 16); + assert!(t.parts.iter().all(|p| p.label == "rock")); +} + +#[test] +fn template_json_roundtrip() { + let t = BodyTemplate::humanoid_player(); + let json = t.to_json(); + let t2 = BodyTemplate::from_json(&json).expect("parse"); + assert_eq!(t2.name, t.name); + assert_eq!(t2.parts.len(), t.parts.len()); + assert_eq!(t2.half_w, t.half_w); + assert_eq!(t2.half_h, t.half_h); +} + +#[test] +fn preview_ascii_not_empty() { + let t = BodyTemplate::humanoid_player(); + let preview = t.preview_ascii(); + assert!(!preview.is_empty()); + assert!(preview.contains("O")); +} + +#[test] +fn custom_template_works() { + let t = BodyTemplate { + name: "snake".to_string(), + half_w: 3.0, + half_h: 0.5, + radius: 0.5, + parts: vec![ + BodyPart { + x: -3.0, + y: 0.0, + color: [100, 200, 50, 255], + label: "body".into(), + }, + BodyPart { + x: -2.0, + y: 0.0, + color: [100, 200, 50, 255], + label: "body".into(), + }, + BodyPart { + x: -1.0, + y: 0.0, + color: [100, 200, 50, 255], + label: "body".into(), + }, + BodyPart { + x: 0.0, + y: 0.0, + color: [100, 200, 50, 255], + label: "body".into(), + }, + BodyPart { + x: 1.0, + y: 0.0, + color: [100, 200, 50, 255], + label: "body".into(), + }, + BodyPart { + x: 2.0, + y: 0.0, + color: [100, 200, 50, 255], + label: "body".into(), + }, + BodyPart { + x: 3.0, + y: 0.0, + color: [80, 180, 40, 255], + label: "head".into(), + }, + ], + constraints: vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], + }; + assert_eq!(t.parts.len(), 7); + let json = t.to_json(); + let t2 = BodyTemplate::from_json(&json).expect("parse"); + assert_eq!(t2.parts.len(), 7); + assert_eq!(t2.name, "snake"); +} + +#[test] +fn template_apply_to_entity() { + use verbatim::entity::entity::{Entity, EntityKind}; + let mut e = Entity::new(0, EntityKind::Goblin); + let t = BodyTemplate::humanoid_goblin(); + t.apply_to(&mut e, 100.0, 100.0); + assert_eq!(e.bodies.len(), 15); + assert_eq!(e.cx, 100.0); + assert_eq!(e.cy, 100.0); + assert!(!e.constraints.is_empty()); +}