use indexmap::IndexMap;
use rapier2d::prelude::*;
use serde::{Deserialize, Serialize};
use crate::config::FieldValue;
use crate::physics::{deg_to_rad, encode_map_object, round2};
pub const DEFAULT_FRICTION: f32 = 0.2;
pub const DEFAULT_RESTITUTION: f32 = 0.0;
const REST_VELOCITY_EPSILON: f32 = 0.01;
pub const MAX_LEVELS: usize = 2;
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DynamicObjectConfig {
pub position: [f32; 2],
pub angle: f32,
pub width: f32,
pub height: f32,
pub density: f32,
#[serde(default)]
pub linear_damping: Option<f32>,
#[serde(default)]
pub angular_damping: Option<f32>,
#[serde(default)]
pub level: u8,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MapLevelConfig {
pub map: Vec<Vec<i32>>,
#[serde(default)]
pub floor: Vec<i32>,
#[serde(default)]
pub walls: Vec<i32>,
#[serde(default)]
pub layers: IndexMap<String, Vec<i32>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum RampDir {
North,
South,
West,
East,
}
impl RampDir {
pub fn axis_sign(self) -> (u8, i8) {
match self {
RampDir::North => (1, -1),
RampDir::South => (1, 1),
RampDir::West => (0, -1),
RampDir::East => (0, 1),
}
}
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RampConfig {
pub tile: i32,
pub dir: RampDir,
#[serde(default)]
pub from: u8,
#[serde(default = "default_ramp_to")]
pub to: u8,
}
fn default_ramp_to() -> u8 {
1
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MapConfig {
#[serde(default)]
pub set_id: Option<String>,
#[serde(default)]
pub scale: Option<f32>,
pub step: f32,
pub map: Vec<Vec<i32>>,
#[serde(default)]
pub physics_static: Vec<i32>,
#[serde(default)]
pub physics_dynamic: Vec<DynamicObjectConfig>,
#[serde(default)]
pub respawns: IndexMap<String, Vec<Vec<f32>>>,
#[serde(default)]
pub levels: IndexMap<String, MapLevelConfig>,
#[serde(default)]
pub ramps: Vec<RampConfig>,
}
pub fn validate_levels(
map: &[Vec<i32>],
physics_static: &[i32],
levels: &IndexMap<String, MapLevelConfig>,
ramps: &[RampConfig],
) -> Result<(), String> {
let rows = map.len();
let level_count = levels.len() + 1;
if !levels.is_empty() {
if level_count > MAX_LEVELS {
return Err(format!(
"map levels: {level_count} levels, at most {MAX_LEVELS} supported"
));
}
let mut keys: Vec<u8> = Vec::with_capacity(levels.len());
for key in levels.keys() {
let level: u8 = key
.parse()
.map_err(|_| format!("map levels: key '{key}' is not a level number"))?;
if level == 0 {
return Err(
"map levels: level 0 lives in map/physicsStatic, not in levels".to_string(),
);
}
keys.push(level);
}
keys.sort_unstable();
for (index, &level) in keys.iter().enumerate() {
if level as usize != index + 1 {
return Err(format!(
"map levels: levels must run from 1 without gaps, got {level} at position {}",
index + 1
));
}
}
}
for (key, level) in levels {
if level.map.len() != rows {
return Err(format!(
"map levels: level {key} grid has {} rows, map has {rows}",
level.map.len()
));
}
for (y, row) in level.map.iter().enumerate() {
let expected = map[y].len();
if row.len() != expected {
return Err(format!(
"map levels: level {key} row {y} has {} cells, map has {expected}",
row.len()
));
}
}
for tile in &level.walls {
if !level.floor.contains(tile) {
return Err(format!(
"map levels: level {key} wall tile {tile} is not part of floor"
));
}
}
}
for (index, ramp) in ramps.iter().enumerate() {
if ramp.from == ramp.to {
return Err(format!("map ramps: ramp {index} goes from level {} to itself", ramp.from));
}
if (ramp.from as usize) >= level_count || (ramp.to as usize) >= level_count {
return Err(format!(
"map ramps: ramp {index} references level out of range (levels: {level_count})"
));
}
let grid = if ramp.from == 0 {
Some(map)
} else {
levels.get(&ramp.from.to_string()).map(|level| level.map.as_slice())
};
let found = grid.is_some_and(|grid| {
grid.iter().any(|row| row.contains(&ramp.tile))
});
if !found {
return Err(format!(
"map ramps: ramp {index} tile {} is missing from level {} grid",
ramp.tile, ramp.from
));
}
let grid = grid.unwrap_or(map);
for (x, y) in ramp_run_exits(grid, ramp.tile, ramp.dir) {
if !walkable(map, physics_static, levels, ramp.to, x, y) {
return Err(format!(
"map ramps: ramp {index} run ends at ({x}, {y}), which is not \
walkable ground of level {}",
ramp.to
));
}
}
}
validate_level_edges(map, physics_static, levels)?;
Ok(())
}
fn validate_level_edges(
map: &[Vec<i32>],
physics_static: &[i32],
levels: &IndexMap<String, MapLevelConfig>,
) -> Result<(), String> {
for (key, level) in levels {
for (y, row) in level.map.iter().enumerate() {
for (x, tile) in row.iter().enumerate() {
if !level.floor.contains(tile) || level.walls.contains(tile) {
continue;
}
for (dx, dy) in [(1_i64, 0_i64), (-1, 0), (0, 1), (0, -1)] {
let nx = x as i64 + dx;
let ny = y as i64 + dy;
let neighbour = cell_at(&level.map, nx, ny);
if neighbour.is_some_and(|tile| level.floor.contains(&tile)) {
continue;
}
if ground_walkable(map, physics_static, nx, ny) {
continue;
}
return Err(format!(
"map levels: level {key} floor cell ({x}, {y}) has an open \
edge at ({nx}, {ny}) with no walkable ground below — close \
it with a wall tile"
));
}
}
}
}
Ok(())
}
fn cell_at(grid: &[Vec<i32>], x: i64, y: i64) -> Option<i32> {
if x < 0 || y < 0 {
return None;
}
grid.get(y as usize)
.and_then(|row| row.get(x as usize))
.copied()
}
fn ground_walkable(map: &[Vec<i32>], physics_static: &[i32], x: i64, y: i64) -> bool {
cell_at(map, x, y).is_some_and(|tile| !physics_static.contains(&tile))
}
fn walkable(
map: &[Vec<i32>],
physics_static: &[i32],
levels: &IndexMap<String, MapLevelConfig>,
level: u8,
x: i64,
y: i64,
) -> bool {
if level == 0 {
return ground_walkable(map, physics_static, x, y);
}
let Some(cfg) = levels.get(&level.to_string()) else {
return false;
};
cell_at(&cfg.map, x, y)
.is_some_and(|tile| cfg.floor.contains(&tile) && !cfg.walls.contains(&tile))
}
fn ramp_run_exits(grid: &[Vec<i32>], tile: i32, dir: RampDir) -> Vec<(i64, i64)> {
let (axis, sign) = dir.axis_sign();
let mut out = Vec::new();
if axis == 1 {
let cols = grid.iter().map(|row| row.len()).max().unwrap_or(0);
for x in 0..cols {
let mut y = 0;
while y < grid.len() {
if grid[y].get(x) != Some(&tile) {
y += 1;
continue;
}
let y0 = y;
while y < grid.len() && grid[y].get(x) == Some(&tile) {
y += 1;
}
let exit = if sign > 0 { y as i64 } else { y0 as i64 - 1 };
out.push((x as i64, exit));
}
}
} else {
for (y, row) in grid.iter().enumerate() {
let mut x = 0;
while x < row.len() {
if row[x] != tile {
x += 1;
continue;
}
let x0 = x;
while x < row.len() && row[x] == tile {
x += 1;
}
let exit = if sign > 0 { x as i64 } else { x0 as i64 - 1 };
out.push((exit, y as i64));
}
}
}
out
}
impl MapConfig {
pub fn validate(&self) -> Result<(), String> {
let level_count = self.levels.len() + 1;
validate_levels(&self.map, &self.physics_static, &self.levels, &self.ramps)?;
for (team, points) in &self.respawns {
for (index, point) in points.iter().enumerate() {
if point.len() != 3 && point.len() != 4 {
return Err(format!(
"map respawns: {team}[{index}] has {} numbers, expected 3 or 4",
point.len()
));
}
if point.len() == 4 && (point[3] as usize) >= level_count {
return Err(format!(
"map respawns: {team}[{index}] level {} is out of range (levels: {level_count})",
point[3]
));
}
}
}
for (index, object) in self.physics_dynamic.iter().enumerate() {
if (object.level as usize) >= level_count {
return Err(format!(
"map physicsDynamic: object {index} level {} is out of range (levels: {level_count})",
object.level
));
}
}
Ok(())
}
}
pub fn level_group(level: u8) -> Group {
debug_assert!(
(level as usize) < MAX_LEVELS,
"level_group: level {level} is out of range (MAX_LEVELS: {MAX_LEVELS})"
);
match level {
0 => Group::GROUP_1,
_ => Group::GROUP_2,
}
}
pub const STATIC_LEVEL_GROUP: Group = Group::GROUP_9;
pub fn level_interaction(level: u8) -> InteractionGroups {
let group = level_group(level);
InteractionGroups::new(group, group, InteractionTestMode::And)
}
pub fn static_level_interaction(level: u8) -> InteractionGroups {
let group = level_group(level) | STATIC_LEVEL_GROUP;
InteractionGroups::new(group, group, InteractionTestMode::And)
}
pub fn levels_interaction(mask: Group) -> InteractionGroups {
InteractionGroups::new(mask, mask, InteractionTestMode::And)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RampSample {
pub progress: f32,
pub from: u8,
pub to: u8,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct RampRun {
pub axis: u8,
pub sign: i8,
pub from: u8,
pub to: u8,
pub min: f32,
pub max: f32,
pub cross_min: f32,
pub cross_max: f32,
}
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct MapLevels {
grids: Vec<Vec<Vec<i32>>>,
solid: Vec<Vec<i32>>,
floor: Vec<Vec<i32>>,
runs: Vec<RampRun>,
run_cells: Vec<Vec<i16>>,
tile_size: f32,
}
#[allow(clippy::too_many_arguments)]
fn push_run(
runs: &mut Vec<RampRun>,
run_cells: &mut [Vec<i16>],
tile_size: f32,
ramp: &RampConfig,
axis: u8,
sign: i8,
from: usize,
to: usize,
cross0: usize,
cross1: usize,
) {
if runs.len() >= i16::MAX as usize {
return;
}
let index = runs.len() as i16;
let mut claimed = false;
for main in from..to {
for cross in cross0..cross1 {
let (x, y) = if axis == 1 { (cross, main) } else { (main, cross) };
let Some(cell) = run_cells.get_mut(y).and_then(|row| row.get_mut(x)) else {
continue;
};
if *cell < 0 {
*cell = index;
claimed = true;
}
}
}
if !claimed {
return;
}
let size = tile_size;
runs.push(RampRun {
axis,
sign,
from: ramp.from,
to: ramp.to,
min: from as f32 * size,
max: to as f32 * size,
cross_min: cross0 as f32 * size,
cross_max: cross1 as f32 * size,
});
}
impl MapLevels {
pub fn build(
grid0: &[Vec<i32>],
solid0: &[i32],
levels: &IndexMap<String, MapLevelConfig>,
ramps: &[RampConfig],
tile_size: f32,
) -> Self {
let mut out = Self {
grids: vec![grid0.to_vec()],
solid: vec![solid0.to_vec()],
floor: vec![Vec::new()],
runs: Vec::new(),
run_cells: grid0.iter().map(|row| vec![-1i16; row.len()]).collect(),
tile_size,
};
let mut ordered: Vec<(u8, &MapLevelConfig)> = levels
.iter()
.filter_map(|(key, level)| key.parse::<u8>().ok().map(|index| (index, level)))
.collect();
ordered.sort_by_key(|(index, _)| *index);
for (index, level) in ordered {
if index as usize != out.grids.len() {
continue;
}
out.grids.push(level.map.clone());
out.solid.push(level.walls.clone());
out.floor.push(level.floor.clone());
}
out.build_runs(ramps);
out
}
fn build_runs(&mut self, ramps: &[RampConfig]) {
let Self {
grids,
runs,
run_cells,
tile_size,
..
} = self;
for ramp in ramps {
let (axis, sign) = ramp.dir.axis_sign();
let Some(grid) = grids.get(ramp.from as usize) else {
continue;
};
let rows = grid.len();
if axis == 1 {
let cols = grid.iter().map(|row| row.len()).max().unwrap_or(0);
for x in 0..cols {
let mut y = 0;
while y < rows {
if grid[y].get(x) != Some(&ramp.tile) {
y += 1;
continue;
}
let y0 = y;
while y < rows && grid[y].get(x) == Some(&ramp.tile) {
y += 1;
}
push_run(
runs,
run_cells,
*tile_size,
ramp,
axis,
sign,
y0,
y,
x,
x + 1,
);
}
}
} else {
for (y, row) in grid.iter().enumerate() {
let mut x = 0;
while x < row.len() {
if row[x] != ramp.tile {
x += 1;
continue;
}
let x0 = x;
while x < row.len() && row[x] == ramp.tile {
x += 1;
}
push_run(
runs,
run_cells,
*tile_size,
ramp,
axis,
sign,
x0,
x,
y,
y + 1,
);
}
}
}
}
}
pub fn is_layered(&self) -> bool {
self.grids.len() > 1
}
pub fn level_count(&self) -> usize {
self.grids.len().max(1)
}
pub fn tile_size(&self) -> f32 {
self.tile_size
}
pub fn grid(&self, level: u8) -> Option<&Vec<Vec<i32>>> {
self.grids.get(level as usize)
}
pub fn solid(&self, level: u8) -> &[i32] {
self.solid.get(level as usize).map_or(&[], |list| list)
}
pub fn floor(&self, level: u8) -> &[i32] {
self.floor.get(level as usize).map_or(&[], |list| list)
}
pub fn runs(&self) -> &[RampRun] {
&self.runs
}
pub fn cell_at(&self, x: f32, y: f32) -> Option<(usize, usize)> {
if self.tile_size <= 0.0 || x < 0.0 || y < 0.0 {
return None;
}
let cx = (x / self.tile_size).floor() as usize;
let cy = (y / self.tile_size).floor() as usize;
let row = self.grids.first()?.get(cy)?;
if cx >= row.len() { None } else { Some((cx, cy)) }
}
pub fn has_floor(&self, level: u8, x: f32, y: f32) -> bool {
let Some((cx, cy)) = self.cell_at(x, y) else {
return false;
};
if level == 0 {
return true;
}
let Some(grid) = self.grid(level) else {
return false;
};
let Some(&tile) = grid.get(cy).and_then(|row| row.get(cx)) else {
return false;
};
self.floor(level).contains(&tile)
}
pub fn is_solid(&self, level: u8, x: f32, y: f32) -> bool {
let Some((cx, cy)) = self.cell_at(x, y) else {
return false;
};
let Some(grid) = self.grid(level) else {
return false;
};
let Some(&tile) = grid.get(cy).and_then(|row| row.get(cx)) else {
return false;
};
self.solid(level).contains(&tile)
}
pub fn level_at(&self, x: f32, y: f32) -> u8 {
let mut level = 0;
for candidate in (1..self.level_count() as u8).rev() {
if self.has_floor(candidate, x, y) {
level = candidate;
break;
}
}
level
}
pub fn ramp_at(&self, x: f32, y: f32) -> Option<RampSample> {
let (cx, cy) = self.cell_at(x, y)?;
let index = *self.run_cells.get(cy)?.get(cx)?;
if index < 0 {
return None;
}
let run = &self.runs[index as usize];
let value = if run.axis == 0 { x } else { y };
let span = run.max - run.min;
let raw = if span <= 0.0 { 0.0 } else { (value - run.min) / span };
let progress = if run.sign > 0 { raw } else { 1.0 - raw };
Some(RampSample {
progress: progress.clamp(0.0, 1.0),
from: run.from,
to: run.to,
})
}
}
#[derive(Serialize, Deserialize)]
pub struct GameMap {
pub set_id: String,
pub step: f32,
pub grid: Vec<Vec<i32>>,
pub physics_static: Vec<i32>,
pub respawns: IndexMap<String, Vec<Vec<f32>>>,
levels: MapLevels,
static_bodies: Vec<RigidBodyHandle>,
static_levels: Vec<u8>,
dynamic_bodies: Vec<RigidBodyHandle>,
dynamic_levels: Vec<u8>,
}
impl GameMap {
pub fn create(
world: &mut PhysicsWorld,
cfg: &MapConfig,
default_scale: f32,
default_set_id: &str,
) -> Self {
let scale = cfg.scale.unwrap_or(default_scale);
let step = cfg.step * scale;
let levels = MapLevels::build(
&cfg.map,
&cfg.physics_static,
&cfg.levels,
&cfg.ramps,
step,
);
let mut map = Self {
set_id: cfg
.set_id
.clone()
.unwrap_or_else(|| default_set_id.to_string()),
step,
grid: cfg.map.clone(),
physics_static: cfg.physics_static.clone(),
respawns: cfg
.respawns
.iter()
.map(|(team, arr)| {
(
team.clone(),
arr.iter()
.map(|point| {
let mut out = point.clone();
if out.len() >= 2 {
out[0] *= scale;
out[1] *= scale;
}
out
})
.collect(),
)
})
.collect(),
levels,
static_bodies: Vec::new(),
static_levels: Vec::new(),
dynamic_bodies: Vec::new(),
dynamic_levels: Vec::new(),
};
map.create_static(world);
map.create_dynamic(world, &cfg.physics_dynamic, scale);
map
}
fn create_static(&mut self, world: &mut PhysicsWorld) {
for level in 0..self.levels.level_count() as u8 {
let Some(grid) = self.levels.grid(level) else {
continue;
};
let solid = self.levels.solid(level).to_vec();
let mut work: Vec<Vec<Option<i32>>> = grid
.iter()
.map(|row| row.iter().map(|&tile| Some(tile)).collect())
.collect();
for y in 0..work.len() {
for x in 0..work[y].len() {
let is_static = work[y][x].is_some_and(|tile| solid.contains(&tile));
if is_static {
let (width, height) =
search_static_block(&mut work, &solid, self.step, y, x);
let pos_x = x as f32 * self.step + width / 2.0;
let pos_y = y as f32 * self.step + height / 2.0;
let body = world.insert_body(
RigidBodyBuilder::fixed().translation(Vector::new(pos_x, pos_y)),
);
world.insert_collider(
ColliderBuilder::cuboid(width / 2.0, height / 2.0)
.friction(DEFAULT_FRICTION)
.restitution(DEFAULT_RESTITUTION)
.collision_groups(static_level_interaction(level)),
Some(body),
);
self.static_bodies.push(body);
self.static_levels.push(level);
}
}
}
}
}
fn create_dynamic(
&mut self,
world: &mut PhysicsWorld,
dynamics: &[DynamicObjectConfig],
scale: f32,
) {
for data in dynamics {
let pos_x = data.position[0] * scale;
let pos_y = data.position[1] * scale;
let width = data.width * scale;
let height = data.height * scale;
let body = world.insert_body(
RigidBodyBuilder::dynamic()
.translation(Vector::new(pos_x, pos_y))
.rotation(deg_to_rad(data.angle))
.linear_damping(data.linear_damping.unwrap_or(0.0))
.angular_damping(data.angular_damping.unwrap_or(0.01))
.soft_ccd_prediction(width.min(height))
.user_data(encode_map_object()),
);
world.insert_collider(
ColliderBuilder::cuboid(width / 2.0, height / 2.0)
.translation(Vector::new(width / 2.0, height / 2.0))
.density(data.density)
.friction(DEFAULT_FRICTION)
.restitution(DEFAULT_RESTITUTION)
.collision_groups(level_interaction(data.level)),
Some(body),
);
self.dynamic_bodies.push(body);
self.dynamic_levels.push(data.level);
}
}
pub fn levels(&self) -> &MapLevels {
&self.levels
}
pub fn is_layered(&self) -> bool {
self.levels.is_layered()
}
pub fn level_count(&self) -> usize {
self.levels.level_count()
}
pub fn level_at(&self, x: f32, y: f32) -> u8 {
self.levels.level_at(x, y)
}
pub fn has_floor(&self, level: u8, x: f32, y: f32) -> bool {
self.levels.has_floor(level, x, y)
}
pub fn ramp_at(&self, x: f32, y: f32) -> Option<RampSample> {
self.levels.ramp_at(x, y)
}
pub fn dynamic_level(&self, index: usize) -> u8 {
self.dynamic_levels.get(index).copied().unwrap_or(0)
}
pub fn static_levels(&self) -> &[u8] {
&self.static_levels
}
pub fn dynamic_levels(&self) -> &[u8] {
&self.dynamic_levels
}
pub fn static_body_count(&self) -> usize {
self.static_bodies.len()
}
pub fn dynamic_body_count(&self) -> usize {
self.dynamic_bodies.len()
}
pub fn destroy(&mut self, world: &mut PhysicsWorld) {
for handle in self.static_bodies.drain(..) {
world.remove_body(handle);
}
for handle in self.dynamic_bodies.drain(..) {
world.remove_body(handle);
}
self.static_levels.clear();
self.dynamic_levels.clear();
}
pub fn dynamic_map_data(
&self,
world: &PhysicsWorld,
with_velocities: bool,
) -> Vec<(u8, Vec<FieldValue>)> {
self.dynamic_bodies
.iter()
.enumerate()
.filter_map(|(index, &handle)| {
world.bodies.get(handle).map(|body| {
let pos = body.translation();
let mut fields = vec![
FieldValue::F32(round2(pos.x)),
FieldValue::F32(round2(pos.y)),
FieldValue::F32(round2(body.rotation().angle())),
];
if with_velocities {
let linvel = body.linvel();
let angvel = body.angvel();
let resting = body.is_sleeping()
|| (linvel.x.hypot(linvel.y) < REST_VELOCITY_EPSILON
&& angvel.abs() < REST_VELOCITY_EPSILON);
if !resting {
fields.push(FieldValue::F32(round2(linvel.x)));
fields.push(FieldValue::F32(round2(linvel.y)));
fields.push(FieldValue::F32(round2(angvel)));
}
}
(index as u8, fields)
})
})
.collect()
}
}
fn search_static_block(
work: &mut [Vec<Option<i32>>],
solid: &[i32],
step: f32,
y0: usize,
x0: usize,
) -> (f32, f32) {
let mut x = x0;
let mut w_counter = 0;
let mut h_counter = 1;
while x < work[y0].len() && work[y0][x].is_some_and(|tile| solid.contains(&tile)) {
work[y0][x] = None;
x += 1;
w_counter += 1;
}
let len_x = x;
let len_y = work.len();
for y in (y0 + 1)..len_y {
let mut empty_tile = false;
let mut x = x0;
while x < len_x {
if x < work[y].len() && work[y][x].is_some_and(|tile| solid.contains(&tile)) {
x += 1;
} else {
empty_tile = true;
break;
}
}
if empty_tile {
break;
}
h_counter += 1;
for cell in work[y][x0..len_x].iter_mut() {
*cell = None;
}
}
(w_counter as f32 * step, h_counter as f32 * step)
}
#[cfg(test)]
mod tests {
use super::*;
const TIME_STEP: f32 = 1.0 / 120.0;
fn map_config() -> MapConfig {
serde_json::from_value(serde_json::json!({
"step": 20.0,
"map": [[1, 1, 1, 1, 1], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0]],
"physicsStatic": [1],
"physicsDynamic": [{
"position": [40.0, 60.0],
"angle": 0.0,
"width": 20.0,
"height": 20.0,
"density": 1.0
}]
}))
.unwrap()
}
fn make_world() -> PhysicsWorld {
let mut world = PhysicsWorld::new();
world.gravity = Vector::ZERO;
world.integration_parameters.dt = TIME_STEP;
world
}
fn max_penetration(world: &mut PhysicsWorld, body: RigidBodyHandle) -> f32 {
let mut depth: f32 = 0.0;
world.bodies[body].set_linvel(Vector::new(0.0, -2000.0), true);
for _ in 0..120 {
world.step();
for pair in world.contact_pairs() {
for manifold in &pair.manifolds {
for point in &manifold.points {
depth = depth.max(-point.dist);
}
}
}
}
depth
}
fn layered_config() -> MapConfig {
serde_json::from_value(serde_json::json!({
"step": 20.0,
"map": [[1, 0, 0], [0, 0, 0], [0, 0, 0]],
"physicsStatic": [1],
"levels": {
"1": {
"map": [[0, 0, 0], [0, 5, 6], [0, 0, 0]],
"floor": [5, 6],
"walls": [6]
}
}
}))
.unwrap()
}
fn ramp_config(dir: &str) -> MapConfig {
serde_json::from_value(serde_json::json!({
"step": 20.0,
"map": [[7, 0, 0], [7, 0, 0], [7, 0, 0]],
"physicsStatic": [],
"levels": {
"1": { "map": [[0, 0, 0], [0, 0, 0], [0, 0, 0]], "floor": [] }
},
"ramps": [{ "tile": 7, "dir": dir }]
}))
.unwrap()
}
fn collision_groups(world: &PhysicsWorld, body: RigidBodyHandle) -> InteractionGroups {
let handle = world.bodies[body].colliders()[0];
world.colliders[handle].collision_groups()
}
#[test]
fn legacy_map_has_no_levels() {
let mut world = make_world();
let map = GameMap::create(&mut world, &map_config(), 1.0, "set");
assert!(!map.is_layered());
assert_eq!(map.level_count(), 1);
assert_eq!(map.static_body_count(), 1);
assert_eq!(
world.bodies[map.static_bodies[0]].translation(),
Vector::new(50.0, 10.0)
);
}
#[test]
fn layered_map_builds_static_per_level() {
let mut world = make_world();
let map = GameMap::create(&mut world, &layered_config(), 1.0, "set");
assert!(map.is_layered());
assert_eq!(map.static_body_count(), 2);
assert_eq!(map.static_levels(), &[0, 1]);
assert_eq!(
collision_groups(&world, map.static_bodies[0]).memberships,
Group::GROUP_1 | STATIC_LEVEL_GROUP
);
assert_eq!(
collision_groups(&world, map.static_bodies[1]).memberships,
Group::GROUP_2 | STATIC_LEVEL_GROUP
);
}
#[test]
fn static_group_sees_walls_but_no_bodies() {
let falling = InteractionGroups::new(
STATIC_LEVEL_GROUP,
STATIC_LEVEL_GROUP,
InteractionTestMode::And,
);
assert!(falling.test(static_level_interaction(0)));
assert!(falling.test(static_level_interaction(1)));
assert!(!falling.test(level_interaction(0)));
assert!(!falling.test(level_interaction(1)));
}
#[test]
fn dynamic_body_carries_level_group() {
let mut cfg = layered_config();
cfg.physics_dynamic = vec![DynamicObjectConfig {
position: [20.0, 20.0],
angle: 0.0,
width: 20.0,
height: 20.0,
density: 1.0,
linear_damping: None,
angular_damping: None,
level: 1,
}];
let mut world = make_world();
let map = GameMap::create(&mut world, &cfg, 1.0, "set");
assert_eq!(map.dynamic_level(0), 1);
assert_eq!(
collision_groups(&world, map.dynamic_bodies[0]).memberships,
Group::GROUP_2
);
}
#[test]
fn has_floor_reports_slab_and_ground() {
let mut world = make_world();
let map = GameMap::create(&mut world, &layered_config(), 1.0, "set");
assert!(map.has_floor(0, 10.0, 10.0));
assert!(!map.has_floor(0, -5.0, 10.0));
assert!(!map.has_floor(0, 70.0, 10.0));
assert!(map.has_floor(1, 30.0, 30.0));
assert!(map.has_floor(1, 50.0, 30.0));
assert!(!map.has_floor(1, 10.0, 10.0));
}
#[test]
fn level_at_picks_highest_floor() {
let mut world = make_world();
let map = GameMap::create(&mut world, &layered_config(), 1.0, "set");
assert_eq!(map.level_at(30.0, 30.0), 1);
assert_eq!(map.level_at(10.0, 30.0), 0);
}
#[test]
fn ramp_progress_runs_from_bottom_to_top() {
let mut world = make_world();
let north = GameMap::create(&mut world, &ramp_config("north"), 1.0, "set");
assert!(north.ramp_at(10.0, 59.0).unwrap().progress < 0.05);
assert!(north.ramp_at(10.0, 1.0).unwrap().progress > 0.95);
assert!((north.ramp_at(10.0, 30.0).unwrap().progress - 0.5).abs() < 0.01);
assert_eq!(north.ramp_at(10.0, 30.0).unwrap().to, 1);
assert!(north.ramp_at(30.0, 30.0).is_none());
let mut world = make_world();
let south = GameMap::create(&mut world, &ramp_config("south"), 1.0, "set");
assert!(south.ramp_at(10.0, 1.0).unwrap().progress < 0.05);
assert!(south.ramp_at(10.0, 59.0).unwrap().progress > 0.95);
}
#[test]
fn ramp_runs_are_split_per_line() {
let mut cfg = ramp_config("north");
for row in &mut cfg.map {
row[1] = 7;
}
let mut world = make_world();
let map = GameMap::create(&mut world, &cfg, 1.0, "set");
let runs = map.levels().runs();
assert_eq!(runs.len(), 2);
assert_eq!((runs[0].min, runs[0].max), (0.0, 60.0));
assert_eq!((runs[1].min, runs[1].max), (0.0, 60.0));
assert_ne!(runs[0].cross_min, runs[1].cross_min);
}
#[test]
fn respawn_accepts_three_and_four_numbers() {
let cfg: MapConfig = serde_json::from_value(serde_json::json!({
"step": 20.0,
"map": [[0, 0], [0, 0]],
"levels": { "1": { "map": [[0, 0], [0, 0]], "floor": [] } },
"respawns": { "team1": [[10.0, 20.0, 0.0], [30.0, 40.0, 90.0, 1.0]] }
}))
.unwrap();
cfg.validate().unwrap();
let mut world = make_world();
let map = GameMap::create(&mut world, &cfg, 2.0, "set");
let points = &map.respawns["team1"];
assert_eq!(points[0], vec![20.0, 40.0, 0.0]);
assert_eq!(points[1], vec![60.0, 80.0, 90.0, 1.0]);
}
#[test]
fn validate_rejects_mismatched_level_grid() {
let mut cfg = layered_config();
cfg.levels["1"].map.pop();
let error = cfg.validate().unwrap_err();
assert!(error.contains("rows"), "{error}");
}
#[test]
fn validate_rejects_railing_outside_floor() {
let mut cfg = layered_config();
cfg.levels["1"].floor = vec![5];
let error = cfg.validate().unwrap_err();
assert!(error.contains("floor"), "{error}");
}
#[test]
fn validate_rejects_unknown_ramp_tile() {
let mut cfg = ramp_config("north");
cfg.ramps[0].tile = 99;
let error = cfg.validate().unwrap_err();
assert!(error.contains("missing"), "{error}");
}
#[test]
fn shared_layered_fixtures() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../contract/fixtures/layered");
let mut checked = 0;
for entry in std::fs::read_dir(&dir).expect("fixtures dir") {
let path = entry.unwrap().path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let name = path.file_stem().unwrap().to_string_lossy().to_string();
let doc: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap())
.unwrap_or_else(|e| panic!("{name}: {e}"));
let cfg: MapConfig = serde_json::from_value(doc["map"].clone())
.unwrap_or_else(|e| panic!("{name}: {e}"));
let result = cfg.validate();
match doc.get("expect").and_then(|v| v.as_str()) {
None => assert!(result.is_ok(), "{name}: {result:?}"),
Some(fragment) => {
let error = result.expect_err(&format!("{name}: expected an error"));
assert!(error.contains(fragment), "{name}: got {error}");
}
}
checked += 1;
}
assert!(checked > 1, "корпус фикстур не прочитан: {checked}");
}
#[test]
fn validate_rejects_level_out_of_range() {
let cfg: MapConfig = serde_json::from_value(serde_json::json!({
"step": 20.0,
"map": [[0, 0], [0, 0]],
"respawns": { "team1": [[10.0, 20.0, 0.0, 5.0]] }
}))
.unwrap();
let error = cfg.validate().unwrap_err();
assert!(error.contains("out of range"), "{error}");
}
#[test]
fn dynamic_body_gets_soft_ccd_prediction_of_thickness() {
let mut world = make_world();
let map = GameMap::create(&mut world, &map_config(), 1.0, "set");
let prediction = world.bodies[map.dynamic_bodies[0]].soft_ccd_prediction();
assert_eq!(prediction, 20.0);
}
#[test]
fn soft_ccd_prediction_keeps_penetration_shallow() {
let mut world = make_world();
let map = GameMap::create(&mut world, &map_config(), 1.0, "set");
let handle = map.dynamic_bodies[0];
let predicted = max_penetration(&mut world, handle);
let mut plain_world = make_world();
let plain_map = GameMap::create(&mut plain_world, &map_config(), 1.0, "set");
let plain_handle = plain_map.dynamic_bodies[0];
plain_world.bodies[plain_handle].set_soft_ccd_prediction(0.0);
let plain = max_penetration(&mut plain_world, plain_handle);
assert!(
plain > 5.0,
"без предсказания ожидалось глубокое перекрытие, получено {plain}"
);
assert!(predicted < 2.0, "с предсказанием перекрытие {predicted}");
}
}