feat: BodyTemplate system — data-driven entity editor for AI

BodyTemplate: JSON-serializable entity body definition.
- parts: Vec<BodyPart> with x, y, color, label
- constraints: Vec<(usize, usize)> connecting parts
- half_w/half_h/radius: collider and physics params

Built-in templates:
- humanoid_player: purple wizard (15 parts)
- humanoid_goblin: green (15 parts)
- boulder: 4x4 rock (16 parts)

API for AI agents:
- BodyTemplate::to_json() / from_json() — serialize/deserialize
- BodyTemplate::preview_ascii() — text preview of silhouette
- BodyTemplate::apply_to(&mut entity, cx, cy) — build entity from template
- template_for_kind(EntityKind) — get template by entity type
- Custom templates: create any shape (snake, dragon, boss) from code or JSON

Entity::build_humanoid() now delegates to template_for_kind().apply_to()
— single source of truth for body layout.

7 new tests for template system (116 total, 0 failures)
This commit is contained in:
Emil
2026-06-21 10:27:37 +03:00
parent 6504a619f6
commit 61bf21bda0
4 changed files with 612 additions and 78 deletions
+492
View File
@@ -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<BodyPart>,
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<Self, String> {
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::<String>());
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(),
}
}
+2 -78
View File
@@ -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) {
+2
View File
@@ -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};
+116
View File
@@ -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());
}