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:
+89
-39
@@ -1,4 +1,4 @@
|
|||||||
use crate::physics::verlet::{SubBody, Constraint};
|
use crate::physics::verlet::{Constraint, SubBody};
|
||||||
use crate::world::cell::MaterialId;
|
use crate::world::cell::MaterialId;
|
||||||
|
|
||||||
pub type EntityId = u32;
|
pub type EntityId = u32;
|
||||||
@@ -110,55 +110,105 @@ impl Entity {
|
|||||||
self.cy = cy;
|
self.cy = cy;
|
||||||
self.cvx = 0.0;
|
self.cvx = 0.0;
|
||||||
self.cvy = 0.0;
|
self.cvy = 0.0;
|
||||||
self.half_w = 3.5;
|
self.half_w = 3.0;
|
||||||
self.half_h = 2.5;
|
self.half_h = 3.5;
|
||||||
|
|
||||||
let r = 0.5;
|
let r = 0.5;
|
||||||
let mat = match self.kind {
|
let mat = MaterialId::Flesh;
|
||||||
EntityKind::Player => MaterialId::Flesh,
|
|
||||||
EntityKind::Goblin => MaterialId::Flesh,
|
let (head_c, torso_c, arm_c, leg_c) = match self.kind {
|
||||||
EntityKind::Corpse => MaterialId::Flesh,
|
EntityKind::Player => (
|
||||||
|
[255, 230, 130, 255],
|
||||||
|
[230, 180, 70, 255],
|
||||||
|
[200, 155, 50, 255],
|
||||||
|
[170, 130, 35, 255],
|
||||||
|
),
|
||||||
|
EntityKind::Goblin => (
|
||||||
|
[130, 230, 110, 255],
|
||||||
|
[80, 180, 70, 255],
|
||||||
|
[60, 150, 50, 255],
|
||||||
|
[45, 120, 35, 255],
|
||||||
|
),
|
||||||
|
EntityKind::Corpse => (
|
||||||
|
[120, 90, 80, 255],
|
||||||
|
[100, 75, 65, 255],
|
||||||
|
[90, 65, 55, 255],
|
||||||
|
[80, 55, 45, 255],
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 5x5 body + 2 arms = 27 bodies
|
let layout: [(f32, f32, [u8; 4]); 23] = [
|
||||||
let layout: [(f32, f32, MaterialId); 27] = [
|
(-1.0, -3.0, head_c),
|
||||||
// Row 0 (top): head
|
(0.0, -3.0, head_c),
|
||||||
(-2.0, -2.0, mat), (-1.0, -2.0, mat), ( 0.0, -2.0, mat), ( 1.0, -2.0, mat), ( 2.0, -2.0, mat),
|
(1.0, -3.0, head_c),
|
||||||
// Row 1: shoulders
|
(-1.0, -2.0, head_c),
|
||||||
(-2.0, -1.0, mat), (-1.0, -1.0, mat), ( 0.0, -1.0, mat), ( 1.0, -1.0, mat), ( 2.0, -1.0, mat),
|
(0.0, -2.0, head_c),
|
||||||
// Row 2: torso
|
(1.0, -2.0, head_c),
|
||||||
(-2.0, 0.0, mat), (-1.0, 0.0, mat), ( 0.0, 0.0, mat), ( 1.0, 0.0, mat), ( 2.0, 0.0, mat),
|
(-2.0, -1.0, arm_c),
|
||||||
// Row 3: hips
|
(-1.0, -1.0, torso_c),
|
||||||
(-2.0, 1.0, mat), (-1.0, 1.0, mat), ( 0.0, 1.0, mat), ( 1.0, 1.0, mat), ( 2.0, 1.0, mat),
|
(0.0, -1.0, torso_c),
|
||||||
// Row 4: legs
|
(1.0, -1.0, torso_c),
|
||||||
(-2.0, 2.0, mat), (-1.0, 2.0, mat), ( 0.0, 2.0, mat), ( 1.0, 2.0, mat), ( 2.0, 2.0, mat),
|
(2.0, -1.0, arm_c),
|
||||||
// Arms
|
(-2.0, 0.0, arm_c),
|
||||||
( 3.0, -1.0, mat), ( 3.0, 0.0, mat),
|
(-1.0, 0.0, torso_c),
|
||||||
|
(0.0, 0.0, torso_c),
|
||||||
|
(1.0, 0.0, torso_c),
|
||||||
|
(2.0, 0.0, arm_c),
|
||||||
|
(-1.0, 1.0, torso_c),
|
||||||
|
(0.0, 1.0, torso_c),
|
||||||
|
(1.0, 1.0, torso_c),
|
||||||
|
(-1.0, 2.0, leg_c),
|
||||||
|
(1.0, 2.0, leg_c),
|
||||||
|
(-1.0, 3.0, leg_c),
|
||||||
|
(1.0, 3.0, leg_c),
|
||||||
];
|
];
|
||||||
|
|
||||||
for &(ox, oy, m) in &layout {
|
for &(ox, oy, color) in &layout {
|
||||||
self.rest_offsets.push((ox, oy));
|
self.rest_offsets.push((ox, oy));
|
||||||
self.bodies.push(SubBody::new(cx + ox, cy + oy, r, m));
|
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);
|
let mk = |a: usize, b: usize, len: f32| Constraint::new(a, b, len, 1.0);
|
||||||
|
|
||||||
// Horizontal connections
|
self.constraints.push(mk(0, 1, 1.0));
|
||||||
for row in 0..5 {
|
self.constraints.push(mk(1, 2, 1.0));
|
||||||
let base = row * 5;
|
self.constraints.push(mk(3, 4, 1.0));
|
||||||
for col in 0..4 {
|
self.constraints.push(mk(4, 5, 1.0));
|
||||||
self.constraints.push(mk(base + col, base + col + 1, 1.0));
|
self.constraints.push(mk(0, 3, 1.0));
|
||||||
}
|
self.constraints.push(mk(1, 4, 1.0));
|
||||||
}
|
self.constraints.push(mk(2, 5, 1.0));
|
||||||
// Vertical connections
|
|
||||||
for col in 0..5 {
|
self.constraints.push(mk(3, 7, 1.0));
|
||||||
for row in 0..4 {
|
self.constraints.push(mk(4, 8, 1.0));
|
||||||
self.constraints.push(mk(row * 5 + col, (row + 1) * 5 + col, 1.0));
|
self.constraints.push(mk(5, 9, 1.0));
|
||||||
}
|
|
||||||
}
|
self.constraints.push(mk(7, 8, 1.0));
|
||||||
// Arm connections
|
self.constraints.push(mk(8, 9, 1.0));
|
||||||
self.constraints.push(mk(9, 25, 1.0));
|
self.constraints.push(mk(12, 13, 1.0));
|
||||||
self.constraints.push(mk(25, 26, 1.0));
|
self.constraints.push(mk(13, 14, 1.0));
|
||||||
|
self.constraints.push(mk(16, 17, 1.0));
|
||||||
|
self.constraints.push(mk(17, 18, 1.0));
|
||||||
|
|
||||||
|
self.constraints.push(mk(7, 12, 1.0));
|
||||||
|
self.constraints.push(mk(8, 13, 1.0));
|
||||||
|
self.constraints.push(mk(9, 14, 1.0));
|
||||||
|
self.constraints.push(mk(12, 16, 1.0));
|
||||||
|
self.constraints.push(mk(13, 17, 1.0));
|
||||||
|
self.constraints.push(mk(14, 18, 1.0));
|
||||||
|
|
||||||
|
self.constraints.push(mk(6, 7, 1.0));
|
||||||
|
self.constraints.push(mk(10, 9, 1.0));
|
||||||
|
self.constraints.push(mk(11, 12, 1.0));
|
||||||
|
self.constraints.push(mk(15, 14, 1.0));
|
||||||
|
self.constraints.push(mk(6, 11, 1.0));
|
||||||
|
self.constraints.push(mk(10, 15, 1.0));
|
||||||
|
|
||||||
|
self.constraints.push(mk(16, 19, 1.0));
|
||||||
|
self.constraints.push(mk(18, 20, 1.0));
|
||||||
|
self.constraints.push(mk(19, 21, 1.0));
|
||||||
|
self.constraints.push(mk(20, 22, 1.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sync_bodies_to_center(&mut self) {
|
pub fn sync_bodies_to_center(&mut self) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub struct SubBody {
|
|||||||
pub health: f32,
|
pub health: f32,
|
||||||
pub on_fire: bool,
|
pub on_fire: bool,
|
||||||
pub fire_timer: u32,
|
pub fire_timer: u32,
|
||||||
|
pub color: [u8; 4],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SubBody {
|
impl SubBody {
|
||||||
@@ -31,6 +32,7 @@ impl SubBody {
|
|||||||
health: 100.0,
|
health: 100.0,
|
||||||
on_fire: false,
|
on_fire: false,
|
||||||
fire_timer: 0,
|
fire_timer: 0,
|
||||||
|
color: [255, 255, 255, 255],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +115,12 @@ impl VerletSolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn solve_constraints(&self, bodies: &mut [SubBody], constraints: &[Constraint], iterations: u32) {
|
pub fn solve_constraints(
|
||||||
|
&self,
|
||||||
|
bodies: &mut [SubBody],
|
||||||
|
constraints: &[Constraint],
|
||||||
|
iterations: u32,
|
||||||
|
) {
|
||||||
const MAX_CORRECTION: f32 = 0.5;
|
const MAX_CORRECTION: f32 = 0.5;
|
||||||
for _ in 0..iterations {
|
for _ in 0..iterations {
|
||||||
for c in constraints {
|
for c in constraints {
|
||||||
|
|||||||
+5
-10
@@ -546,18 +546,13 @@ impl GraphicsRenderer {
|
|||||||
let sx = b.x as i32 - cam_x;
|
let sx = b.x as i32 - cam_x;
|
||||||
let sy = b.y as i32 - cam_y;
|
let sy = b.y as i32 - cam_y;
|
||||||
if sx >= 0 && sx < self.grid_w as i32 && sy >= 0 && sy < self.grid_h as i32 {
|
if sx >= 0 && sx < self.grid_w as i32 && sy >= 0 && sy < self.grid_h as i32 {
|
||||||
let fg = if e.on_fire {
|
let color = if e.on_fire {
|
||||||
[255, 160, 40, 255]
|
let flicker = b.fire_timer % 4;
|
||||||
} else if !e.alive {
|
[255, 120 + flicker as u8 * 20, 20 + flicker as u8 * 10, 255]
|
||||||
[100, 60, 60, 255]
|
|
||||||
} else {
|
} else {
|
||||||
match e.kind {
|
b.color
|
||||||
EntityKind::Player => [255, 255, 100, 255],
|
|
||||||
EntityKind::Goblin => [100, 220, 100, 255],
|
|
||||||
_ => [180, 50, 50, 255],
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
entity_map.insert((sx, sy), fg);
|
entity_map.insert((sx, sy), color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-15
@@ -1,11 +1,16 @@
|
|||||||
use std::io::{self, Write, stdout};
|
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
cursor::{Hide, MoveTo, Show},
|
cursor::{Hide, MoveTo, Show},
|
||||||
event::{DisableMouseCapture, EnableMouseCapture, KeyboardEnhancementFlags, PushKeyboardEnhancementFlags, PopKeyboardEnhancementFlags},
|
event::{
|
||||||
|
DisableMouseCapture, EnableMouseCapture, KeyboardEnhancementFlags,
|
||||||
|
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
||||||
|
},
|
||||||
execute, queue,
|
execute, queue,
|
||||||
style::{Color, SetBackgroundColor, SetForegroundColor, ResetColor, Print},
|
style::{Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor},
|
||||||
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, size as term_size},
|
terminal::{
|
||||||
|
self, size as term_size, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
use std::io::{self, stdout, Write};
|
||||||
|
|
||||||
use crate::entity::EntityManager;
|
use crate::entity::EntityManager;
|
||||||
use crate::render::Renderer;
|
use crate::render::Renderer;
|
||||||
@@ -64,7 +69,13 @@ impl Renderer for TerminalRenderer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&mut self, grid: &Grid, entities: &EntityManager, cam_x: i32, cam_y: i32) -> io::Result<()> {
|
fn render(
|
||||||
|
&mut self,
|
||||||
|
grid: &Grid,
|
||||||
|
entities: &EntityManager,
|
||||||
|
cam_x: i32,
|
||||||
|
cam_y: i32,
|
||||||
|
) -> io::Result<()> {
|
||||||
if !self.initialized {
|
if !self.initialized {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -85,7 +96,11 @@ impl Renderer for TerminalRenderer {
|
|||||||
}
|
}
|
||||||
let cell = grid.get(wx, wy);
|
let cell = grid.get(wx, wy);
|
||||||
if cell.is_empty() {
|
if cell.is_empty() {
|
||||||
frame[idx] = (' ', (cell.fg[0], cell.fg[1], cell.fg[2]), (cell.bg[0], cell.bg[1], cell.bg[2]));
|
frame[idx] = (
|
||||||
|
' ',
|
||||||
|
(cell.fg[0], cell.fg[1], cell.fg[2]),
|
||||||
|
(cell.bg[0], cell.bg[1], cell.bg[2]),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
let ch = cell.material.display_char();
|
let ch = cell.material.display_char();
|
||||||
let fg = if cell.material == MaterialId::Lava {
|
let fg = if cell.material == MaterialId::Lava {
|
||||||
@@ -115,14 +130,8 @@ impl Renderer for TerminalRenderer {
|
|||||||
};
|
};
|
||||||
let fg = if e.on_fire {
|
let fg = if e.on_fire {
|
||||||
(255, 160, 40)
|
(255, 160, 40)
|
||||||
} else if !e.alive {
|
|
||||||
(100, 60, 60)
|
|
||||||
} else {
|
} else {
|
||||||
match e.kind {
|
(b.color[0], b.color[1], b.color[2])
|
||||||
crate::entity::EntityKind::Player => (255, 255, 100),
|
|
||||||
crate::entity::EntityKind::Goblin => (100, 220, 100),
|
|
||||||
_ => (180, 50, 50),
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
frame[idx] = (ch, fg, (20, 10, 10));
|
frame[idx] = (ch, fg, (20, 10, 10));
|
||||||
}
|
}
|
||||||
@@ -140,8 +149,16 @@ impl Renderer for TerminalRenderer {
|
|||||||
|
|
||||||
queue!(out, MoveTo(dx as u16, dy as u16))?;
|
queue!(out, MoveTo(dx as u16, dy as u16))?;
|
||||||
|
|
||||||
let new_fg = Color::Rgb { r: fg.0, g: fg.1, b: fg.2 };
|
let new_fg = Color::Rgb {
|
||||||
let new_bg = Color::Rgb { r: bg.0, g: bg.1, b: bg.2 };
|
r: fg.0,
|
||||||
|
g: fg.1,
|
||||||
|
b: fg.2,
|
||||||
|
};
|
||||||
|
let new_bg = Color::Rgb {
|
||||||
|
r: bg.0,
|
||||||
|
g: bg.1,
|
||||||
|
b: bg.2,
|
||||||
|
};
|
||||||
if prev_color != Some((new_fg, new_bg)) {
|
if prev_color != Some((new_fg, new_bg)) {
|
||||||
queue!(out, SetForegroundColor(new_fg), SetBackgroundColor(new_bg))?;
|
queue!(out, SetForegroundColor(new_fg), SetBackgroundColor(new_bg))?;
|
||||||
prev_color = Some((new_fg, new_bg));
|
prev_color = Some((new_fg, new_bg));
|
||||||
|
|||||||
@@ -221,14 +221,8 @@ impl VulkanRenderer {
|
|||||||
};
|
};
|
||||||
let fg = if e.on_fire {
|
let fg = if e.on_fire {
|
||||||
[255, 160, 40, 255]
|
[255, 160, 40, 255]
|
||||||
} else if !e.alive {
|
|
||||||
[100, 60, 60, 255]
|
|
||||||
} else {
|
} else {
|
||||||
match e.kind {
|
b.color
|
||||||
EntityKind::Player => [255, 255, 100, 255],
|
|
||||||
EntityKind::Goblin => [100, 220, 100, 255],
|
|
||||||
_ => [180, 50, 50, 255],
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
entity_map.insert((sx, sy), (ch, fg));
|
entity_map.insert((sx, sy), (ch, fg));
|
||||||
}
|
}
|
||||||
|
|||||||
+148
-29
@@ -1,34 +1,60 @@
|
|||||||
use verbatim::ai::GameSession;
|
|
||||||
use verbatim::ai::AiAction;
|
use verbatim::ai::AiAction;
|
||||||
|
use verbatim::ai::GameSession;
|
||||||
|
|
||||||
fn setup() -> GameSession {
|
fn setup() -> GameSession {
|
||||||
let mut s = GameSession::new_seeded(42);
|
let mut s = GameSession::new_seeded(42);
|
||||||
s.init_empty();
|
s.init_empty();
|
||||||
s.clear_area(90, 90, 50, 50);
|
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
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn entity_at_world_boundary_stays_in_bounds() {
|
fn entity_at_world_boundary_stays_in_bounds() {
|
||||||
let mut s = setup();
|
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);
|
s.step(60);
|
||||||
let entities = s.get_entities();
|
let entities = s.get_entities();
|
||||||
if let Some(g) = entities.into_iter().find(|e| e.kind == "Goblin" && e.alive) {
|
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!(
|
||||||
assert!(g.pos[1] >= 0.0 && g.pos[1] <= 250.0, "goblin should stay in bounds: y={}", g.pos[1]);
|
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]
|
#[test]
|
||||||
fn entity_at_right_boundary() {
|
fn entity_at_right_boundary() {
|
||||||
let mut s = setup();
|
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);
|
s.step(60);
|
||||||
let entities = s.get_entities();
|
let entities = s.get_entities();
|
||||||
if let Some(g) = entities.into_iter().find(|e| e.kind == "Goblin" && e.alive) {
|
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();
|
let mut s = setup();
|
||||||
for i in 0..10 {
|
for i in 0..10 {
|
||||||
let x = 100.0 + (i as f32 * 5.0);
|
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);
|
s.step(60);
|
||||||
let alive = s.get_entities().into_iter().filter(|e| e.alive).count();
|
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]
|
#[test]
|
||||||
@@ -49,10 +82,20 @@ fn spawn_many_goblins_stress() {
|
|||||||
let mut s = GameSession::new_seeded(42);
|
let mut s = GameSession::new_seeded(42);
|
||||||
s.init_empty();
|
s.init_empty();
|
||||||
s.clear_area(0, 0, 250, 250);
|
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 {
|
for i in 0..20 {
|
||||||
let x = 20.0 + (i as f32 * 10.0);
|
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);
|
s.step(30);
|
||||||
let entities = s.get_entities();
|
let entities = s.get_entities();
|
||||||
@@ -74,13 +117,20 @@ fn player_bodies_count_matches_layout() {
|
|||||||
let mut s = GameSession::new_seeded(42);
|
let mut s = GameSession::new_seeded(42);
|
||||||
s.init();
|
s.init();
|
||||||
let p = s.get_player().unwrap();
|
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]
|
#[test]
|
||||||
fn goblin_has_correct_max_health() {
|
fn goblin_has_correct_max_health() {
|
||||||
let mut s = setup();
|
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 entities = s.get_entities();
|
||||||
let g = entities.into_iter().find(|e| e.kind == "Goblin").unwrap();
|
let g = entities.into_iter().find(|e| e.kind == "Goblin").unwrap();
|
||||||
assert_eq!(g.max_health, 40.0, "goblin max health should be 40");
|
assert_eq!(g.max_health, 40.0, "goblin max health should be 40");
|
||||||
@@ -97,16 +147,37 @@ fn player_has_correct_max_health() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn fill_rect_creates_correct_material_count() {
|
fn fill_rect_creates_correct_material_count() {
|
||||||
let mut s = setup();
|
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");
|
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]
|
#[test]
|
||||||
fn clear_region_removes_all_materials() {
|
fn clear_region_removes_all_materials() {
|
||||||
let mut s = setup();
|
let mut s = setup();
|
||||||
s.perform_action(&AiAction::FillRect { x: 100, y: 100, w: 5, h: 5, material: "stone".into() });
|
s.perform_action(&AiAction::FillRect {
|
||||||
s.perform_action(&AiAction::ClearRegion { x: 100, y: 100, w: 5, h: 5 });
|
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");
|
let count = s.count_material_in_region(100, 100, 5, 5, "stone");
|
||||||
assert_eq!(count, 0, "clear_region should remove all materials");
|
assert_eq!(count, 0, "clear_region should remove all materials");
|
||||||
}
|
}
|
||||||
@@ -114,17 +185,32 @@ fn clear_region_removes_all_materials() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn set_cell_overwrites_existing() {
|
fn set_cell_overwrites_existing() {
|
||||||
let mut s = setup();
|
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");
|
assert_eq!(s.get_cell(105, 110).material, "stone");
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "water".into() });
|
s.perform_action(&AiAction::SetCell {
|
||||||
assert_eq!(s.get_cell(105, 110).material, "water", "set_cell should overwrite");
|
x: 105,
|
||||||
|
y: 110,
|
||||||
|
material: "water".into(),
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
s.get_cell(105, 110).material,
|
||||||
|
"water",
|
||||||
|
"set_cell should overwrite"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn out_of_bounds_cell_returns_error() {
|
fn out_of_bounds_cell_returns_error() {
|
||||||
let s = setup();
|
let s = setup();
|
||||||
let cell = s.get_cell(-1, -1);
|
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);
|
let cell2 = s.get_cell(999, 999);
|
||||||
assert_eq!(cell2.material, "out_of_bounds");
|
assert_eq!(cell2.material, "out_of_bounds");
|
||||||
}
|
}
|
||||||
@@ -132,10 +218,19 @@ fn out_of_bounds_cell_returns_error() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn paint_creates_material_in_radius() {
|
fn paint_creates_material_in_radius() {
|
||||||
let mut s = setup();
|
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");
|
let count = s.count_material_in_region(116, 106, 8, 8, "sand");
|
||||||
assert!(count > 0, "paint should create sand cells in radius");
|
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]
|
#[test]
|
||||||
@@ -149,7 +244,12 @@ fn set_gravity_affects_player() {
|
|||||||
let y_up = p1.pos[1];
|
let y_up = p1.pos[1];
|
||||||
s.step(30);
|
s.step(30);
|
||||||
let p2 = s.get_player().unwrap();
|
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]
|
#[test]
|
||||||
@@ -166,14 +266,26 @@ fn center_camera_on_player() {
|
|||||||
s.perform_action(&AiAction::SetCamera { x: 0, y: 0 });
|
s.perform_action(&AiAction::SetCamera { x: 0, y: 0 });
|
||||||
s.step(30);
|
s.step(30);
|
||||||
s.perform_action(&AiAction::CenterCamera);
|
s.perform_action(&AiAction::CenterCamera);
|
||||||
assert!(s.game.cam_x > 0, "camera should move from 0 toward player: got {}", s.game.cam_x);
|
assert!(
|
||||||
assert!(s.game.cam_y > 0, "camera should move from 0 toward player: got {}", s.game.cam_y);
|
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]
|
#[test]
|
||||||
fn kill_entity_removes_alive_status() {
|
fn kill_entity_removes_alive_status() {
|
||||||
let mut s = setup();
|
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.step(10);
|
||||||
s.perform_action(&AiAction::KillEntity { id: 1 });
|
s.perform_action(&AiAction::KillEntity { id: 1 });
|
||||||
s.step(1);
|
s.step(1);
|
||||||
@@ -185,7 +297,11 @@ fn kill_entity_removes_alive_status() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn find_material_locates_existing() {
|
fn find_material_locates_existing() {
|
||||||
let mut s = setup();
|
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");
|
let found = s.find_material("lava");
|
||||||
assert!(found.is_some(), "find_material should locate lava");
|
assert!(found.is_some(), "find_material should locate lava");
|
||||||
let (fx, fy) = found.unwrap();
|
let (fx, fy) = found.unwrap();
|
||||||
@@ -197,5 +313,8 @@ fn find_material_locates_existing() {
|
|||||||
fn find_material_returns_none_for_absent() {
|
fn find_material_returns_none_for_absent() {
|
||||||
let s = setup();
|
let s = setup();
|
||||||
let found = s.find_material("lava");
|
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