station/crates/game_core/src/map.rs

276 lines
9.9 KiB
Rust
Raw Normal View History

//! Tiled (`.tmx`) map support.
//!
//! Conventions understood by the game:
//! - Tile layers are rendered in order. A tile is solid when its tile (in the tileset)
//! or its layer has the boolean custom property `solid = true`.
//! - Object layers spawn gameplay entities based on the object *class*:
//! - `spawn`: player spawn point.
//! - `building`: building with the custom property `proto` (a building id).
//! - `item`: ground item with the property `proto` (an item id) and optional `count`.
//!
//! State overrides (`state.*`, `machine.*`) and legacy integer script variables
//! are decoded by the server for both new worlds and imports.
use std::{
collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf},
};
use crate::components::GridPos;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TilesetInfo {
/// Image path relative to the assets directory.
pub image: String,
pub image_width: u32,
pub image_height: u32,
pub tile_width: u32,
pub tile_height: u32,
pub columns: u32,
pub tile_count: u32,
pub spacing: u32,
pub margin: u32,
pub solid_tiles: BTreeSet<u32>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TileCell {
pub tileset: usize,
pub id: u32,
pub flip_h: bool,
pub flip_v: bool,
pub flip_d: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TileLayerData {
pub name: String,
pub solid: bool,
/// Row-major cells, `width * height` entries.
pub cells: Vec<Option<TileCell>>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct MapObject {
pub id: u32,
pub class: String,
pub name: String,
pub pos: GridPos,
pub properties: BTreeMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct MapLayout {
pub width: u32,
pub height: u32,
pub tile_width: u32,
pub tile_height: u32,
pub tilesets: Vec<TilesetInfo>,
pub layers: Vec<TileLayerData>,
pub objects: Vec<MapObject>,
solid: Vec<bool>,
}
#[derive(Debug, thiserror::Error)]
pub enum MapError {
#[error("failed to load map `{0}`: {1}")]
Tiled(PathBuf, tiled::Error),
#[error("map `{0}`: {1}")]
Unsupported(PathBuf, String),
}
impl MapLayout {
/// Loads an asset-relative or absolute map path.
pub fn load(assets_dir: &Path, map_path: &str) -> Result<Self, MapError> {
let full = assets_dir.join(map_path);
let unsupported = |msg: String| MapError::Unsupported(full.clone(), msg);
let map = tiled::Loader::new()
.load_tmx_map(&full)
.map_err(|e| MapError::Tiled(full.clone(), e))?;
if map.orientation != tiled::Orientation::Orthogonal {
return Err(unsupported("only orthogonal maps are supported".into()));
}
let assets_canon = assets_dir
.canonicalize()
.unwrap_or_else(|_| assets_dir.into());
let mut tilesets = Vec::new();
for ts in map.tilesets() {
let image = ts.image.as_ref().ok_or_else(|| {
unsupported(format!(
"tileset `{}` must be based on a single image (image collections are not supported)",
ts.name
))
})?;
let image_path = image.source.canonicalize().unwrap_or(image.source.clone());
let rel = image_path.strip_prefix(&assets_canon).map_err(|_| {
unsupported(format!(
"tileset image `{}` must be inside the assets directory",
image.source.display()
))
})?;
tilesets.push(TilesetInfo {
image: rel.to_string_lossy().replace('\\', "/"),
image_width: image.width as u32,
image_height: image.height as u32,
tile_width: ts.tile_width,
tile_height: ts.tile_height,
columns: ts.columns,
tile_count: ts.tilecount,
spacing: ts.spacing,
margin: ts.margin,
solid_tiles: ts
.tiles()
.filter(|(_, tile)| bool_prop(&tile.properties, "solid"))
.map(|(id, _)| id)
.collect(),
});
}
let (width, height) = (map.width, map.height);
if width == 0 || height == 0 || map.tile_width == 0 || map.tile_height == 0 {
return Err(unsupported(
"map and tile dimensions must be nonzero".into(),
));
}
if width > i32::MAX as u32
|| height > i32::MAX as u32
|| u64::from(width) * u64::from(height) > 1_000_000
{
return Err(unsupported(
"maps may contain at most 1,000,000 cells".into(),
));
}
let mut layers = Vec::new();
let mut solid = vec![false; (width * height) as usize];
let mut objects = Vec::new();
let mut stack: Vec<tiled::Layer> = map.layers().collect();
stack.reverse();
while let Some(layer) = stack.pop() {
let layer_solid = bool_prop(&layer.properties, "solid");
match layer.layer_type() {
tiled::LayerType::Tiles(tiles) => {
if matches!(tiles, tiled::TileLayer::Infinite(_)) {
return Err(unsupported("infinite maps are not supported".into()));
}
let mut cells = Vec::with_capacity(solid.len());
for y in 0..height as i32 {
for x in 0..width as i32 {
let cell = tiles.get_tile(x, y).map(|t| {
let tile_solid = t
.get_tile()
.is_some_and(|d| bool_prop(&d.properties, "solid"));
if tile_solid || layer_solid {
solid[(y as u32 * width + x as u32) as usize] = true;
}
TileCell {
tileset: t.tileset_index(),
id: t.id(),
flip_h: t.flip_h,
flip_v: t.flip_v,
flip_d: t.flip_d,
}
});
cells.push(cell);
}
}
layers.push(TileLayerData {
name: layer.name.clone(),
solid: layer_solid,
cells,
});
}
tiled::LayerType::Objects(group) => {
for obj in group.objects() {
let tw = map.tile_width as f32;
let th = map.tile_height as f32;
// Tile objects are anchored at their bottom-left corner.
let y = if obj.get_tile().is_some() {
obj.y - 1.0
} else {
obj.y
};
objects.push(MapObject {
id: obj.id(),
class: obj.user_type.clone(),
name: obj.name.clone(),
pos: GridPos::new((obj.x / tw).floor() as i32, (y / th).floor() as i32),
properties: obj
.properties
.iter()
.map(|(k, v)| (k.clone(), prop_to_string(v)))
.collect(),
});
}
}
tiled::LayerType::Group(group) => {
let children: Vec<_> = group.layers().collect();
stack.extend(children.into_iter().rev());
}
tiled::LayerType::Image(_) => {}
}
}
Ok(Self {
width,
height,
tile_width: map.tile_width,
tile_height: map.tile_height,
tilesets,
layers,
objects,
solid,
})
}
pub fn in_bounds(&self, pos: GridPos) -> bool {
pos.x >= 0 && pos.y >= 0 && (pos.x as u32) < self.width && (pos.y as u32) < self.height
}
/// Tiles outside of the map are always solid.
pub fn is_solid(&self, pos: GridPos) -> bool {
!self.in_bounds(pos) || self.solid[(pos.y as u32 * self.width + pos.x as u32) as usize]
}
pub fn objects_of_class<'a>(&'a self, class: &'a str) -> impl Iterator<Item = &'a MapObject> {
self.objects.iter().filter(move |o| o.class == class)
}
}
fn bool_prop(props: &tiled::Properties, key: &str) -> bool {
matches!(props.get(key), Some(tiled::PropertyValue::BoolValue(true)))
}
fn prop_to_string(value: &tiled::PropertyValue) -> String {
use tiled::PropertyValue::*;
match value {
BoolValue(v) => v.to_string(),
FloatValue(v) => v.to_string(),
IntValue(v) => v.to_string(),
StringValue(v) | FileValue(v) => v.clone(),
ObjectValue(v) => v.to_string(),
other => format!("{other:?}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loads_shipped_map() {
let map = MapLayout::load(&crate::assets_dir(), crate::DEFAULT_MAP).unwrap();
assert!(map.width > 0 && map.height > 0);
assert!(!map.layers.is_empty());
assert!(map.objects_of_class("spawn").next().is_some());
assert!(map.is_solid(GridPos::new(-1, 0)));
assert!(
map.is_solid(GridPos::new(0, 0)),
"map border should be walls"
);
let spawn = map.objects_of_class("spawn").next().unwrap().pos;
assert!(!map.is_solid(spawn));
}
}