feat: Noita-style humanoid entities with per-part colors
Redesigned entity from solid 5x5 block to humanoid silhouette:
Shape (23 bodies, was 27):
H H H (head, 3 wide)
H H H
A T T T A (shoulders + arms, 5 wide)
A T T T A
T T T (torso, 3 wide)
L R (legs, split with gap)
L R
Per-part colors (Noita-inspired):
- Player: head=bright yellow, torso=golden, arms=amber, legs=dark gold
- Goblin: head=bright green, torso=green, arms=olive, legs=dark green
- Corpse: desaturated browns
SubBody now has color: [u8; 4] field, set by build_humanoid.
All renderers (terminal, ascii, graphics) use b.color directly.
Fire effect: flickering orange overlay on burning entities.
half_w adjusted from 3.5 to 3.0, half_h from 2.5 to 3.5
(taller shape, narrower than old block).
109 tests, 0 failures
This commit is contained in:
+148
-29
@@ -1,34 +1,60 @@
|
||||
use verbatim::ai::GameSession;
|
||||
use verbatim::ai::AiAction;
|
||||
use verbatim::ai::GameSession;
|
||||
|
||||
fn setup() -> GameSession {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
s.init_empty();
|
||||
s.clear_area(90, 90, 50, 50);
|
||||
s.perform_action(&AiAction::FillRect { x: 80, y: 130, w: 80, h: 15, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 80,
|
||||
y: 130,
|
||||
w: 80,
|
||||
h: 15,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_at_world_boundary_stays_in_bounds() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 5.0, y: 120.0 });
|
||||
s.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x: 5.0,
|
||||
y: 120.0,
|
||||
});
|
||||
s.step(60);
|
||||
let entities = s.get_entities();
|
||||
if let Some(g) = entities.into_iter().find(|e| e.kind == "Goblin" && e.alive) {
|
||||
assert!(g.pos[0] >= 0.0 && g.pos[0] <= 250.0, "goblin should stay in bounds: x={}", g.pos[0]);
|
||||
assert!(g.pos[1] >= 0.0 && g.pos[1] <= 250.0, "goblin should stay in bounds: y={}", g.pos[1]);
|
||||
assert!(
|
||||
g.pos[0] >= 0.0 && g.pos[0] <= 250.0,
|
||||
"goblin should stay in bounds: x={}",
|
||||
g.pos[0]
|
||||
);
|
||||
assert!(
|
||||
g.pos[1] >= 0.0 && g.pos[1] <= 250.0,
|
||||
"goblin should stay in bounds: y={}",
|
||||
g.pos[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_at_right_boundary() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 245.0, y: 120.0 });
|
||||
s.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x: 245.0,
|
||||
y: 120.0,
|
||||
});
|
||||
s.step(60);
|
||||
let entities = s.get_entities();
|
||||
if let Some(g) = entities.into_iter().find(|e| e.kind == "Goblin" && e.alive) {
|
||||
assert!(g.pos[0] <= 248.0, "goblin should not exit right boundary: x={}", g.pos[0]);
|
||||
assert!(
|
||||
g.pos[0] <= 248.0,
|
||||
"goblin should not exit right boundary: x={}",
|
||||
g.pos[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,11 +63,18 @@ fn many_entities_simulate_without_crash() {
|
||||
let mut s = setup();
|
||||
for i in 0..10 {
|
||||
let x = 100.0 + (i as f32 * 5.0);
|
||||
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x, y: 110.0 });
|
||||
s.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x,
|
||||
y: 110.0,
|
||||
});
|
||||
}
|
||||
s.step(60);
|
||||
let alive = s.get_entities().into_iter().filter(|e| e.alive).count();
|
||||
assert!(alive > 0, "at least some entities should survive 60 ticks with 10 goblins");
|
||||
assert!(
|
||||
alive > 0,
|
||||
"at least some entities should survive 60 ticks with 10 goblins"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -49,10 +82,20 @@ fn spawn_many_goblins_stress() {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
s.init_empty();
|
||||
s.clear_area(0, 0, 250, 250);
|
||||
s.perform_action(&AiAction::FillRect { x: 0, y: 200, w: 250, h: 50, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 0,
|
||||
y: 200,
|
||||
w: 250,
|
||||
h: 50,
|
||||
material: "stone".into(),
|
||||
});
|
||||
for i in 0..20 {
|
||||
let x = 20.0 + (i as f32 * 10.0);
|
||||
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x, y: 180.0 });
|
||||
s.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x,
|
||||
y: 180.0,
|
||||
});
|
||||
}
|
||||
s.step(30);
|
||||
let entities = s.get_entities();
|
||||
@@ -74,13 +117,20 @@ 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, 27, "player should have 27 bodies (5x5 + 2 arms)");
|
||||
assert_eq!(
|
||||
p.body_count, 23,
|
||||
"player should have 23 bodies (humanoid shape)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goblin_has_correct_max_health() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 130.0, y: 120.0 });
|
||||
s.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x: 130.0,
|
||||
y: 120.0,
|
||||
});
|
||||
let entities = s.get_entities();
|
||||
let g = entities.into_iter().find(|e| e.kind == "Goblin").unwrap();
|
||||
assert_eq!(g.max_health, 40.0, "goblin max health should be 40");
|
||||
@@ -97,16 +147,37 @@ fn player_has_correct_max_health() {
|
||||
#[test]
|
||||
fn fill_rect_creates_correct_material_count() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 100, w: 5, h: 3, material: "wood".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 100,
|
||||
w: 5,
|
||||
h: 3,
|
||||
material: "wood".into(),
|
||||
});
|
||||
let count = s.count_material_in_region(100, 100, 5, 3, "wood");
|
||||
assert_eq!(count, 15, "5x3 fill_rect should create 15 wood cells, got {}", count);
|
||||
assert_eq!(
|
||||
count, 15,
|
||||
"5x3 fill_rect should create 15 wood cells, got {}",
|
||||
count
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_region_removes_all_materials() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 100, w: 5, h: 5, material: "stone".into() });
|
||||
s.perform_action(&AiAction::ClearRegion { x: 100, y: 100, w: 5, h: 5 });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 100,
|
||||
w: 5,
|
||||
h: 5,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::ClearRegion {
|
||||
x: 100,
|
||||
y: 100,
|
||||
w: 5,
|
||||
h: 5,
|
||||
});
|
||||
let count = s.count_material_in_region(100, 100, 5, 5, "stone");
|
||||
assert_eq!(count, 0, "clear_region should remove all materials");
|
||||
}
|
||||
@@ -114,17 +185,32 @@ fn clear_region_removes_all_materials() {
|
||||
#[test]
|
||||
fn set_cell_overwrites_existing() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 110,
|
||||
material: "stone".into(),
|
||||
});
|
||||
assert_eq!(s.get_cell(105, 110).material, "stone");
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "water".into() });
|
||||
assert_eq!(s.get_cell(105, 110).material, "water", "set_cell should overwrite");
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 110,
|
||||
material: "water".into(),
|
||||
});
|
||||
assert_eq!(
|
||||
s.get_cell(105, 110).material,
|
||||
"water",
|
||||
"set_cell should overwrite"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_bounds_cell_returns_error() {
|
||||
let s = setup();
|
||||
let cell = s.get_cell(-1, -1);
|
||||
assert_eq!(cell.material, "out_of_bounds", "out of bounds cell should report correctly");
|
||||
assert_eq!(
|
||||
cell.material, "out_of_bounds",
|
||||
"out of bounds cell should report correctly"
|
||||
);
|
||||
let cell2 = s.get_cell(999, 999);
|
||||
assert_eq!(cell2.material, "out_of_bounds");
|
||||
}
|
||||
@@ -132,10 +218,19 @@ fn out_of_bounds_cell_returns_error() {
|
||||
#[test]
|
||||
fn paint_creates_material_in_radius() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::Paint { x: 120, y: 110, material: "sand".into(), radius: 3 });
|
||||
s.perform_action(&AiAction::Paint {
|
||||
x: 120,
|
||||
y: 110,
|
||||
material: "sand".into(),
|
||||
radius: 3,
|
||||
});
|
||||
let count = s.count_material_in_region(116, 106, 8, 8, "sand");
|
||||
assert!(count > 0, "paint should create sand cells in radius");
|
||||
assert!(count < 50, "paint should not create too many cells: {}", count);
|
||||
assert!(
|
||||
count < 50,
|
||||
"paint should not create too many cells: {}",
|
||||
count
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -149,7 +244,12 @@ fn set_gravity_affects_player() {
|
||||
let y_up = p1.pos[1];
|
||||
s.step(30);
|
||||
let p2 = s.get_player().unwrap();
|
||||
assert!(p2.pos[1] <= y_up + 2.0, "with zero gravity, player should not fall: y_up={} y_after={}", y_up, p2.pos[1]);
|
||||
assert!(
|
||||
p2.pos[1] <= y_up + 2.0,
|
||||
"with zero gravity, player should not fall: y_up={} y_after={}",
|
||||
y_up,
|
||||
p2.pos[1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -166,14 +266,26 @@ fn center_camera_on_player() {
|
||||
s.perform_action(&AiAction::SetCamera { x: 0, y: 0 });
|
||||
s.step(30);
|
||||
s.perform_action(&AiAction::CenterCamera);
|
||||
assert!(s.game.cam_x > 0, "camera should move from 0 toward player: got {}", s.game.cam_x);
|
||||
assert!(s.game.cam_y > 0, "camera should move from 0 toward player: got {}", s.game.cam_y);
|
||||
assert!(
|
||||
s.game.cam_x > 0,
|
||||
"camera should move from 0 toward player: got {}",
|
||||
s.game.cam_x
|
||||
);
|
||||
assert!(
|
||||
s.game.cam_y > 0,
|
||||
"camera should move from 0 toward player: got {}",
|
||||
s.game.cam_y
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kill_entity_removes_alive_status() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 130.0, y: 120.0 });
|
||||
s.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x: 130.0,
|
||||
y: 120.0,
|
||||
});
|
||||
s.step(10);
|
||||
s.perform_action(&AiAction::KillEntity { id: 1 });
|
||||
s.step(1);
|
||||
@@ -185,7 +297,11 @@ fn kill_entity_removes_alive_status() {
|
||||
#[test]
|
||||
fn find_material_locates_existing() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::SetCell { x: 123, y: 115, material: "lava".into() });
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 123,
|
||||
y: 115,
|
||||
material: "lava".into(),
|
||||
});
|
||||
let found = s.find_material("lava");
|
||||
assert!(found.is_some(), "find_material should locate lava");
|
||||
let (fx, fy) = found.unwrap();
|
||||
@@ -197,5 +313,8 @@ fn find_material_locates_existing() {
|
||||
fn find_material_returns_none_for_absent() {
|
||||
let s = setup();
|
||||
let found = s.find_material("lava");
|
||||
assert!(found.is_none(), "find_material should return None for absent material");
|
||||
assert!(
|
||||
found.is_none(),
|
||||
"find_material should return None for absent material"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user