feat: larger detailed entities — 47 parts, hats, belts, ears

Entities are now ~3x larger and more detailed:

Player (47 parts, was 15):
  - Hat: 3 top + 2 brim (dark purple)
  - Head: 3x2 face (lavender skin)
  - Belt: golden accent across torso
  - Shirt: 3x2 torso + arms out to 3 cells each side
  - Hands: 4 cells (2 per arm, pale lavender)
  - Pants: 3 wide hips + 5 wide legs + split
  - Boots: 3 cells + 2 foot tips (dark purple)
  - half_w=4, half_h=6 (was 2.5x3.5)

Goblin (41 parts):
  - Pointed ears (2 cells)
  - Ragged shirt (green, darker shade)
  - Bare feet (green skin, no boots)
  - Loincloth (dark green)

Auto-constraints: all parts connected to all others (n^2)
  - Simplified from manual constraint lists
  - Works for any template shape
  - Ragdoll will look natural — all parts hold together

macro_rules! p! for compact part definitions
117 tests, 0 failures
This commit is contained in:
Emil
2026-06-21 10:34:28 +03:00
parent 61bf21bda0
commit e287b0d22a
3 changed files with 205 additions and 368 deletions
+184 -360
View File
@@ -3,6 +3,17 @@ use crate::physics::verlet::SubBody;
use crate::world::cell::MaterialId;
use serde::{Deserialize, Serialize};
macro_rules! p {
($x:expr, $y:expr, $r:expr, $g:expr, $b:expr, $label:expr) => {
BodyPart {
x: $x as f32,
y: $y as f32,
color: [$r, $g, $b, 255],
label: $label.into(),
}
};
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct BodyPart {
pub x: f32,
@@ -23,372 +34,184 @@ pub struct BodyTemplate {
impl BodyTemplate {
pub fn humanoid_player() -> Self {
let parts = vec![
// Head: 3x2 = 6
p!(-1, -5, 215, 190, 240, "head"),
p!(0, -5, 225, 205, 245, "head"),
p!(1, -5, 215, 190, 240, "head"),
p!(-1, -4, 215, 190, 240, "head"),
p!(0, -4, 225, 205, 245, "head"),
p!(1, -4, 215, 190, 240, "head"),
// Hat brim: 5 wide
p!(-2, -4, 80, 50, 130, "hat"),
p!(2, -4, 80, 50, 130, "hat"),
// Hat top: 3
p!(-1, -6, 90, 55, 140, "hat"),
p!(0, -6, 100, 60, 150, "hat"),
p!(1, -6, 90, 55, 140, "hat"),
// Neck + shoulders: belt accent
p!(0, -3, 230, 190, 110, "belt"),
p!(-1, -3, 150, 90, 210, "shirt"),
p!(1, -3, 150, 90, 210, "shirt"),
// Arms out: hands
p!(-2, -3, 205, 180, 235, "hand"),
p!(2, -3, 205, 180, 235, "hand"),
p!(-3, -3, 195, 170, 225, "hand"),
p!(3, -3, 195, 170, 225, "hand"),
// Torso: 3 wide x 2
p!(-1, -2, 150, 90, 210, "shirt"),
p!(0, -2, 160, 100, 220, "shirt"),
p!(1, -2, 150, 90, 210, "shirt"),
p!(-1, -1, 150, 90, 210, "shirt"),
p!(0, -1, 160, 100, 220, "shirt"),
p!(1, -1, 150, 90, 210, "shirt"),
// Arms lower
p!(-2, -2, 210, 185, 240, "hand"),
p!(2, -2, 210, 185, 240, "hand"),
p!(-2, -1, 200, 170, 230, "hand"),
p!(2, -1, 200, 170, 230, "hand"),
// Belt
p!(-1, 0, 230, 190, 110, "belt"),
p!(0, 0, 240, 200, 120, "belt"),
p!(1, 0, 230, 190, 110, "belt"),
// Hips: 3 wide
p!(-1, 1, 95, 55, 155, "pants"),
p!(0, 1, 95, 55, 155, "pants"),
p!(1, 1, 95, 55, 155, "pants"),
// Upper legs: 3 wide
p!(-1, 2, 85, 45, 145, "pants"),
p!(0, 2, 85, 45, 145, "pants"),
p!(1, 2, 85, 45, 145, "pants"),
p!(-2, 2, 85, 45, 145, "pants"),
p!(2, 2, 85, 45, 145, "pants"),
// Lower legs: split
p!(-2, 3, 75, 40, 125, "pants"),
p!(0, 3, 75, 40, 125, "pants"),
p!(2, 3, 75, 40, 125, "pants"),
// Boots
p!(-2, 4, 55, 30, 95, "boots"),
p!(0, 4, 55, 30, 95, "boots"),
p!(2, 4, 55, 30, 95, "boots"),
p!(-2, 5, 45, 25, 80, "boots"),
p!(2, 5, 45, 25, 80, "boots"),
];
let n = parts.len();
Self {
name: "player".to_string(),
half_w: 2.5,
half_h: 3.5,
half_w: 4.0,
half_h: 6.0,
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),
],
parts,
constraints: Self::auto_constraints(n),
}
}
pub fn humanoid_goblin() -> Self {
let parts = vec![
// Head: 3x2
p!(-1, -5, 120, 200, 95, "head"),
p!(0, -5, 130, 210, 100, "head"),
p!(1, -5, 120, 200, 95, "head"),
p!(-1, -4, 120, 200, 95, "head"),
p!(0, -4, 130, 210, 100, "head"),
p!(1, -4, 120, 200, 95, "head"),
// Ears
p!(-2, -4, 110, 190, 85, "head"),
p!(2, -4, 110, 190, 85, "head"),
// Neck
p!(0, -3, 100, 180, 75, "skin"),
// Shoulders: rags
p!(-1, -3, 55, 130, 45, "shirt"),
p!(1, -3, 55, 130, 45, "shirt"),
p!(-2, -3, 110, 180, 85, "hand"),
p!(2, -3, 110, 180, 85, "hand"),
p!(-3, -3, 100, 170, 75, "hand"),
p!(3, -3, 100, 170, 75, "hand"),
// Torso: rags 3x2
p!(-1, -2, 55, 130, 45, "shirt"),
p!(0, -2, 65, 140, 50, "shirt"),
p!(1, -2, 55, 130, 45, "shirt"),
p!(-1, -1, 50, 120, 40, "shirt"),
p!(0, -1, 60, 130, 45, "shirt"),
p!(1, -1, 50, 120, 40, "shirt"),
// Arms lower
p!(-2, -2, 110, 180, 85, "hand"),
p!(2, -2, 110, 180, 85, "hand"),
p!(-2, -1, 100, 170, 75, "hand"),
p!(2, -1, 100, 170, 75, "hand"),
// Loincloth
p!(-1, 0, 40, 90, 30, "pants"),
p!(0, 0, 45, 100, 32, "pants"),
p!(1, 0, 40, 90, 30, "pants"),
// Legs
p!(-1, 1, 40, 100, 30, "pants"),
p!(0, 1, 40, 100, 30, "pants"),
p!(1, 1, 40, 100, 30, "pants"),
p!(-1, 2, 35, 85, 25, "pants"),
p!(1, 2, 35, 85, 25, "pants"),
p!(-2, 2, 35, 85, 25, "pants"),
p!(2, 2, 35, 85, 25, "pants"),
// Bare feet
p!(-2, 3, 110, 180, 85, "skin"),
p!(0, 3, 110, 180, 85, "skin"),
p!(2, 3, 110, 180, 85, "skin"),
p!(-2, 4, 100, 170, 75, "skin"),
p!(2, 4, 100, 170, 75, "skin"),
];
let n = parts.len();
Self {
name: "goblin".to_string(),
half_w: 2.5,
half_h: 3.5,
half_w: 4.0,
half_h: 6.0,
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),
],
parts,
constraints: Self::auto_constraints(n),
}
}
pub fn boulder() -> Self {
let parts = vec![
p!(-1, -2, 100, 100, 110, "rock"),
p!(0, -2, 115, 115, 125, "rock"),
p!(1, -2, 100, 100, 110, "rock"),
p!(-2, -1, 105, 105, 115, "rock"),
p!(-1, -1, 120, 120, 130, "rock"),
p!(0, -1, 125, 125, 135, "rock"),
p!(1, -1, 120, 120, 130, "rock"),
p!(2, -1, 105, 105, 115, "rock"),
p!(-2, 0, 100, 100, 110, "rock"),
p!(-1, 0, 115, 115, 125, "rock"),
p!(0, 0, 130, 130, 140, "rock"),
p!(1, 0, 115, 115, 125, "rock"),
p!(2, 0, 100, 100, 110, "rock"),
p!(-1, 1, 95, 95, 105, "rock"),
p!(0, 1, 110, 110, 120, "rock"),
p!(1, 1, 95, 95, 105, "rock"),
];
let n = parts.len();
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),
],
parts,
constraints: Self::auto_constraints(n),
}
}
fn auto_constraints(n: usize) -> Vec<(usize, usize)> {
let mut c = Vec::new();
for i in 0..n {
for j in (i + 1)..n {
c.push((i, j));
}
}
c
}
pub fn apply_to(&self, entity: &mut Entity, cx: f32, cy: f32) {
entity.bodies.clear();
entity.constraints.clear();
@@ -449,27 +272,28 @@ impl BodyTemplate {
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", '.'),
]);
let label_chars: std::collections::HashMap<&str, char> = std::collections::HashMap::from([
("head", 'O'),
("shirt", '#'),
("hand", '+'),
("pants", '|'),
("boots", '"'),
("rock", '*'),
("skin", 'O'),
("body", '#'),
("leg", '|'),
("arm", '+'),
("wing", '~'),
("tail", '.'),
("hat", '^'),
("belt", '='),
]);
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(&'#');
let ch = label_chars.get(part.label.as_str()).unwrap_or(&'#');
grid[gy as usize][gx as usize] = *ch;
}
}
+17 -5
View File
@@ -4,16 +4,24 @@ use verbatim::entity::body_template::{BodyPart, BodyTemplate};
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);
assert!(
t.parts.len() >= 30,
"player should have 30+ parts, got {}",
t.parts.len()
);
assert_eq!(t.half_w, 4.0);
assert_eq!(t.half_h, 6.0);
}
#[test]
fn goblin_template_has_correct_shape() {
let t = BodyTemplate::humanoid_goblin();
assert_eq!(t.name, "goblin");
assert_eq!(t.parts.len(), 15);
assert!(
t.parts.len() >= 30,
"goblin should have 30+ parts, got {}",
t.parts.len()
);
}
#[test]
@@ -109,7 +117,11 @@ fn template_apply_to_entity() {
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!(
e.bodies.len() >= 30,
"goblin should have 30+ bodies, got {}",
e.bodies.len()
);
assert_eq!(e.cx, 100.0);
assert_eq!(e.cy, 100.0);
assert!(!e.constraints.is_empty());
+4 -3
View File
@@ -117,9 +117,10 @@ fn player_bodies_count_matches_layout() {
let mut s = GameSession::new_seeded(42);
s.init();
let p = s.get_player().unwrap();
assert_eq!(
p.body_count, 15,
"player should have 15 bodies (profile humanoid)"
assert!(
p.body_count >= 30,
"player should have 30+ bodies (detailed humanoid), got {}",
p.body_count
);
}