From d4f5541142a13fe5be40cff77dc4e00008f487d7 Mon Sep 17 00:00:00 2001 From: Louis Capitanchik <contact@louiscap.co> Date: Sat, 16 Nov 2024 23:38:50 +0000 Subject: [PATCH] Convert layout rules to work against 7x7 grids; add a number of conversion methods for converting into the correct format; add custom alternate debug renders for grid based items --- README.md | 4 +- src/autotile.rs | 18 ++- src/layout.rs | 337 +++++++++++++++++++++++++++++++++++++++--------- src/lib.rs | 34 ++--- src/utils.rs | 17 +++ 5 files changed, 327 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 0b4eb69..8c86350 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@ index in some theoretical sprite sheet. chance for that rule to be chosen. ```rust use micro_autotile::AutoTileRule; - const GROUND: usize = 0; - const WALL: usize = 1; + const GROUND: usize = 1; + const WALL: usize = 2; let alt_ground_rule = AutoTileRule::single_any_chance(GROUND, vec![123, 124, 125], 0.2); let fallback_ground_rule = AutoTileRule::exact(GROUND, 126); diff --git a/src/autotile.rs b/src/autotile.rs index 9ecbdb0..14d6c24 100644 --- a/src/autotile.rs +++ b/src/autotile.rs @@ -1,9 +1,10 @@ +use std::fmt::{Debug, Formatter}; use std::ops::Add; use crate::{TileLayout, TileMatcher}; use crate::output::TileOutput; /// Checks tile layouts against a matcher instance, and uses the output to produce a value -#[derive(Clone, Debug, Default)] +#[derive(Clone, Default)] pub struct AutoTileRule { /// The pattern that this rule will use for matching pub matcher: TileMatcher, @@ -14,6 +15,17 @@ pub struct AutoTileRule { pub chance: f32, } +impl Debug for AutoTileRule { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if f.alternate() { + // Perform grid style formatting for matcher value + write!(f, "AutoTileRule \n{:#?}output: {:?}, chance: {:.2}", self.matcher, self.output, self.chance) + } else { + write!(f, "AutoTileRule {{ matcher: {:?}, output: {:?}, chance: {:.2} }}", self.matcher, self.output, self.chance) + } + } +} + impl AutoTileRule { /// Create a rule that will always produce `output_value` when the target tile matches /// `input_value` @@ -25,7 +37,7 @@ impl AutoTileRule { /// `input_value` and the selection chance is rolled under the value of `chance` (0.0 to 1.0) pub const fn exact_chance(input_value: i32, output_value: i32, chance: f32) -> Self { AutoTileRule { - matcher: TileMatcher::single(input_value), + matcher: TileMatcher::single_match(input_value), output: TileOutput::single(output_value), chance, } @@ -56,7 +68,7 @@ impl AutoTileRule { chance: f32, ) -> Self { AutoTileRule { - matcher: TileMatcher::single(input_value), + matcher: TileMatcher::single_match(input_value), output: TileOutput::any(output_value), chance, } diff --git a/src/layout.rs b/src/layout.rs index afef824..03686d2 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -1,5 +1,24 @@ +use std::fmt::{write, Debug, Formatter}; +use crate::utils::IntoTile; + +/// The size of the grid that can be matched; equal to the length of one side of the square grid +const RULE_MAGNITUDE: usize = 7; +/// Number of array entries required to store all the data for a grid +const TILE_GRID_SIZE: usize = RULE_MAGNITUDE.pow(2); +/// The index of the center tile in the grid +const GRID_CENTER: usize = (TILE_GRID_SIZE - 1) / 2; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct NotSquareError; +impl std::fmt::Display for NotSquareError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Input is not a square grid") + } +} +impl std::error::Error for NotSquareError {} + /// Represents how a single tile location should be matched when evaluating a rule -#[derive(Ord, PartialOrd, Eq, PartialEq, Hash, Debug, Default, Copy, Clone)] +#[derive(Ord, PartialOrd, Eq, PartialEq, Hash, Default, Copy, Clone)] pub enum TileStatus { /// This tile will always match, regardless of the input tile #[default] @@ -14,15 +33,29 @@ pub enum TileStatus { IsNot(i32), } +impl Debug for TileStatus { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Ignore => write!(f, "Any Or No Tile"), + Self::Nothing => write!(f, "No Tile"), + Self::Anything => write!(f, "Any Tile"), + Self::Is(value) => write!(f, "Must Match [{}]", value), + Self::IsNot(value) => write!(f, "Must Not Match [{}]", value), + } + } +} + impl PartialEq<Option<i32>> for TileStatus { fn eq(&self, other: &Option<i32>) -> bool { - match self { + let matched = match self { Self::Ignore => true, Self::Nothing => other.is_none(), Self::Anything => other.is_some(), Self::Is(value) => &Some(*value) == other, Self::IsNot(value) => &Some(*value) != other, - } + }; + + matched } } @@ -41,22 +74,24 @@ impl TileStatus { /// Holds a grid of raw input data, as a more ideal format for interop and storage #[repr(transparent)] -pub struct TileLayout(pub [Option<i32>; 9]); +pub struct TileLayout(pub [Option<i32>; TILE_GRID_SIZE]); impl TileLayout { /// Create a 1x1 grid of tile data pub fn single(value: i32) -> Self { - TileLayout([None, None, None, None, Some(value), None, None, None, None]) + let mut grid = [None; TILE_GRID_SIZE]; + grid[GRID_CENTER] = Some(value); + TileLayout(grid) } - /// Construct a filled 3x3 grid of tile data - pub fn filled(values: [i32; 9]) -> Self { + /// Construct a filled grid of tile data + pub fn filled(values: [i32; TILE_GRID_SIZE]) -> Self { TileLayout(values.map(Some)) } - /// Construct a filled 3x3 grid of identical tile data + /// Construct a filled grid of identical tile data pub fn spread(value: i32) -> Self { - TileLayout([Some(value); 9]) + TileLayout([Some(value); TILE_GRID_SIZE]) } /// Filter the layout data so that it only contains the tiles surrounding the target tile. This @@ -83,53 +118,88 @@ impl TileLayout { } } -/// Holds the evaluation rules for a 3x3 grid of tiles. A 1x1 grid of tile matchers -/// can be created by providing an array of `TileStatus` structs that are all `TileStatus::Ignore`, -/// except for the value in the fifth position -/// -/// e.g. -/// -/// ``` -/// # use micro_autotile::{TileMatcher, TileStatus}; -/// let matcher = TileMatcher([ -/// TileStatus::Ignore, TileStatus::Ignore, TileStatus::Ignore, -/// TileStatus::Ignore, TileStatus::Anything, TileStatus::Ignore, -/// TileStatus::Ignore, TileStatus::Ignore, TileStatus::Ignore, -/// ]); -/// ``` -#[derive(Clone, Debug, Default)] +impl Debug for TileLayout { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if f.alternate() { + let lines = as_lines(&self.0); + let mut max_width = 1; + + for line in lines.iter() { + for value in line.iter() { + if let Some(value) = value { + max_width = max_width.max(value.to_string().len()); + } + } + } + + for line in lines { + for value in line { + if let Some(value) = value { + write!(f, "{:^max_width$?} ", value)?; + } else { + write!(f, "{:^max_width$} ", "#")?; + } + } + write!(f, "\n")?; + } + write!(f, "\n") + } else { + writeln!(f, "{:?}", self.0) + } + } +} + +impl <T> TryFrom<&[T]> for TileLayout where T: IntoTile + Copy + Default + Debug { + type Error = NotSquareError; + fn try_from(value: &[T]) -> Result<Self, Self::Error> { + if is_square(value.len()) { + let formatted = transpose(value); + Ok(Self(formatted.map(|t| if t.into_tile() == 0 { None } else { Some(t.into_tile()) }))) + } else { + Err(NotSquareError) + } + } +} + +/// Holds parsed tile rules that are used to evaluate a layout +#[derive(Clone, Copy)] #[repr(transparent)] -pub struct TileMatcher(pub [TileStatus; 9]); +pub struct TileMatcher(pub [TileStatus; TILE_GRID_SIZE]); +impl Debug for TileMatcher { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if f.alternate() { + let lines = as_lines(&self.0); + writeln!(f, "Tile Matcher")?; + for line in lines { + for value in line { + write!(f, "{:?} ", value)?; + } + write!(f, "\n")?; + } + writeln!(f, "\n") + } else { + write!(f, "TileMatcher({:?})", self.0) + } + } +} + +impl Default for TileMatcher { + fn default() -> Self { + TileMatcher([TileStatus::default(); TILE_GRID_SIZE]) + } +} impl TileMatcher { - /// Create a 1x1 matcher, where the target tile must be the supplied `value` - pub const fn single(value: i32) -> Self { - Self([ - TileStatus::Ignore, - TileStatus::Ignore, - TileStatus::Ignore, - TileStatus::Ignore, - TileStatus::Is(value), - TileStatus::Ignore, - TileStatus::Ignore, - TileStatus::Ignore, - TileStatus::Ignore, - ]) + pub const fn single(value: TileStatus) -> Self { + let mut rules = [TileStatus::Ignore; TILE_GRID_SIZE]; + rules[GRID_CENTER] = value; + TileMatcher(rules) } + /// Create a 1x1 matcher, with any rule for the target tile - pub const fn single_match(value: TileStatus) -> Self { - Self([ - TileStatus::Ignore, - TileStatus::Ignore, - TileStatus::Ignore, - TileStatus::Ignore, - value, - TileStatus::Ignore, - TileStatus::Ignore, - TileStatus::Ignore, - TileStatus::Ignore, - ]) + pub const fn single_match(value: i32) -> Self { + Self::single(TileStatus::Is(value)) } /// Check if the given input layout of tile data conforms to this matcher @@ -140,22 +210,165 @@ impl TileMatcher { .all(|(status, reality)| *status == *reality) } - /// Load data from an LDTK JSON file. Currently supports 1x1 and 3x3 matchers. + /// Load data from an LDTK JSON file. Supports arbitrary sized matchers for any square grid. /// Other sizes of matcher will result in `None` pub fn from_ldtk_array(value: Vec<i64>) -> Option<Self> { - if value.len() == 1 { - let tile = value[0]; - Some(Self::single_match(TileStatus::from(tile))) - } else if value.len() == 9 { - Some(TileMatcher( - [ - value[0], value[1], value[2], value[3], value[4], value[5], value[6], value[7], - value[8], - ] - .map(TileStatus::from), - )) + if is_square(value.len()) { + Some(Self(transpose(value.as_slice()).map(TileStatus::from))) } else { None } } +} + +impl <T> TryFrom<&[T]> for TileMatcher where T: IntoTile + Copy + Default { + type Error = NotSquareError; + fn try_from(value: &[T]) -> Result<Self, Self::Error> { + if is_square(value.len()) { + let formatted = transpose(value); + Ok(Self(formatted.map(TileStatus::from))) + } else { + Err(NotSquareError) + } + } +} +impl TryFrom<&[TileStatus]> for TileMatcher { + type Error = NotSquareError; + fn try_from(value: &[TileStatus]) -> Result<Self, Self::Error> { + if is_square(value.len()) { + Ok(Self(transpose(value))) + } else { + Err(NotSquareError) + } + } +} + +/// Convert a square grid of arbitrary size into one of the expected autotiler grid size (7x7). Odd +/// numbered grids will be centered in the output, while even numbered grids will be offset by 1 to +/// the top and left of the output. +/// +/// This method will panic if the provided list of values is not a square grid +/// +/// ## Example +/// +/// For a 2x2 input grid: +/// +/// ```text +/// 2 2 +/// 2 2 +/// ``` +/// +/// Applying this function will result in the following output grid: +/// +/// ```text +/// 1 1 1 1 1 1 1 +/// 1 1 1 1 1 1 1 +/// 1 1 2 2 1 1 1 +/// 1 1 2 2 1 1 1 +/// 1 1 1 1 1 1 1 +/// 1 1 1 1 1 1 1 +/// 1 1 1 1 1 1 1 +/// ``` +/// +/// ## Example +/// +/// For a 3x3 input grid: +/// +/// ```text +/// 3 3 3 +/// 3 3 3 +/// 3 3 3 +/// ``` +/// +/// Applying this function will result in the following output grid: +/// +/// ```text +/// 1 1 1 1 1 1 1 +/// 1 1 1 1 1 1 1 +/// 1 1 3 3 3 1 1 +/// 1 1 3 3 3 1 1 +/// 1 1 3 3 3 1 1 +/// 1 1 1 1 1 1 1 +/// 1 1 1 1 1 1 1 +/// ``` +/// +fn transpose<Value>(input: &[Value]) -> [Value; TILE_GRID_SIZE] where Value: Default + Copy { + if input.len() == TILE_GRID_SIZE { + match input.try_into() { + Ok(output) => return output, + Err(_) => { + // This should never happen since we've already checked the length - just in case, + // we want it to fall through and run the rest of the algorithm + } + } + } + + if !is_square(input.len()) { + // Length isn't square == it does not represent a square grid + panic!("Input must be a square grid"); + } + + let input_size = (input.len() as f64).sqrt() as usize; + let mut output = [Value::default(); TILE_GRID_SIZE]; + + let output_start = if input_size < RULE_MAGNITUDE { + // Even padding requires our initial x coord to be half the size difference from the edge. + // Even sized squares will be offset to by one to the left and top, which is preferable to + // rejecting even squares altogether + (RULE_MAGNITUDE - input_size) / 2 + } else { + 0 + }; + + let input_start = if input_size > RULE_MAGNITUDE { + (input_size - RULE_MAGNITUDE) / 2 + } else { + 0 + }; + + for x in 0..input_size { + for y in 0..input_size { + let adjusted_input_x = x + input_start; + let adjusted_input_y = y + input_start; + let input_idx = adjusted_input_y * input_size + adjusted_input_x; + + let adjusted_output_x = x + output_start; + let adjusted_output_y = y + output_start; + let output_idx = adjusted_output_y * RULE_MAGNITUDE + adjusted_output_x; + + output[output_idx] = input[input_idx]; + } + } + + output +} + +fn is_square(n: usize) -> bool { + let sqrt_n = (n as f64).sqrt() as usize; + n == sqrt_n.pow(2) +} + +fn as_lines<T>(input: &[T]) -> Vec<&[T]> { + input.chunks(RULE_MAGNITUDE).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_transposes_odd_grids() { + let input = [2, 2, 2, 2, 2, 2, 2, 2, 2]; + let expected = [ + 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, + 0, 0, 2, 2, 2, 0, 0, + 0, 0, 2, 2, 2, 0, 0, + 0, 0, 2, 2, 2, 0, 0, + 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, + ]; + + assert_eq!(expected, transpose(&input)); + } } \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index a4320ce..88c7a72 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,8 +46,8 @@ //! use micro_autotile::{AutoTileRule, AutoRuleSet}; //! //! # fn main() { -//! const WALL_TILE: i32 = 0; -//! const GROUND_TILE: i32 = 1; +//! const WALL_TILE: i32 = 1; +//! const GROUND_TILE: i32 = 2; //! //! // Match a 1x1 ground tile, output the index within the spritesheet that we'll use for rendering //! let match_1_x_1 = AutoTileRule::exact(GROUND_TILE, 57); @@ -60,11 +60,12 @@ //! // More realistically, we might have some tile data for a ground tile with other data arround it. //! // When we match against a rule, we're always trying to produce a value for the _central_ value in //! // our `TileLayout` (the fifth element) -//! let enclosed_ground = TileLayout([ -//! Some(WALL_TILE), Some(WALL_TILE), Some(WALL_TILE), -//! Some(WALL_TILE), Some(GROUND_TILE), Some(WALL_TILE), -//! Some(WALL_TILE), Some(WALL_TILE), Some(WALL_TILE), -//! ]); +//! let layout_3_x_3 = [ +//! Some(WALL_TILE), Some(WALL_TILE), Some(WALL_TILE), +//! Some(WALL_TILE), Some(GROUND_TILE), Some(WALL_TILE), +//! Some(WALL_TILE), Some(WALL_TILE), Some(WALL_TILE), +//! ]; +//! let enclosed_ground = TileLayout::try_from(layout_3_x_3.as_slice()).unwrap(); //! //! assert_eq!( //! match_1_x_1.resolve_match(&enclosed_ground), @@ -73,7 +74,7 @@ //! //! // There may also be situations in which you just want to know that a given layout matches a rule, without //! // concern for producing a value for that layout. You can directly use a `TileMatcher` for this -//! assert!(TileMatcher::single(GROUND_TILE).matches(&enclosed_ground)); +//! assert!(TileMatcher::single_match(GROUND_TILE).matches(&enclosed_ground)); //! # } //! ``` //! @@ -92,21 +93,21 @@ //! //! # fn main() { //! use micro_autotile::{TileMatcher, TileStatus}; -//! const WALL_TILE: i32 = 0; -//! const GROUND_TILE: i32 = 1; +//! const WALL_TILE: i32 = 1; +//! const GROUND_TILE: i32 = 2; //! const OTHER_TILE: i32 = 342; //! //! let wall_rules = AutoRuleSet(vec![ -//! AutoTileRule::single_when(TileMatcher([ // Top Left Corner +//! AutoTileRule::single_when(TileMatcher::try_from([ // Top Left Corner //! TileStatus::IsNot(WALL_TILE), TileStatus::IsNot(WALL_TILE), TileStatus::IsNot(WALL_TILE), //! TileStatus::IsNot(WALL_TILE), TileStatus::Is(WALL_TILE), TileStatus::Is(WALL_TILE), //! TileStatus::IsNot(WALL_TILE), TileStatus::Is(WALL_TILE), TileStatus::Is(WALL_TILE), -//! ]), 54), -//! AutoTileRule::single_when(TileMatcher([ // Top Right Corner +//! ].as_slice()).unwrap(), 54), +//! AutoTileRule::single_when(TileMatcher::try_from([ // Top Right Corner //! TileStatus::IsNot(WALL_TILE), TileStatus::IsNot(WALL_TILE), TileStatus::IsNot(WALL_TILE), //! TileStatus::Is(WALL_TILE), TileStatus::Is(WALL_TILE), TileStatus::IsNot(WALL_TILE), //! TileStatus::Is(WALL_TILE), TileStatus::Is(WALL_TILE), TileStatus::IsNot(WALL_TILE), -//! ]), 55), +//! ].as_slice()).unwrap(), 55), //! // ... Etc //! ]); //! @@ -120,15 +121,16 @@ //! // Easily merge rule sets in an ordered way //! let combined_rules = wall_rules + ground_rules; //! -//! let sublayout = TileLayout([ +//! let sublayout = TileLayout::try_from([ //! Some(OTHER_TILE), Some(GROUND_TILE), Some(GROUND_TILE), //! Some(WALL_TILE), Some(WALL_TILE), Some(OTHER_TILE), //! Some(WALL_TILE), Some(WALL_TILE), Some(GROUND_TILE), -//! ]); +//! ].as_slice()).unwrap(); //! //! // We've got a layout that represents the top right corner of a wall, the second rule in our //! // set - the value of the tiles that match "IsNot(WALL_TILE)" are irrelevant, as long as they //! // exist (Option::Some) +//! //! let output = combined_rules.resolve_match(&sublayout); //! assert_eq!(output, Some(55)); //! # } diff --git a/src/utils.rs b/src/utils.rs index 5c9c182..1c04ee7 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -103,6 +103,23 @@ impl From<TileStatus> for i64 { } } +impl <T: IntoTile> IntoTile for Option<T> { + fn into_tile(self) -> i32 { + match self { + Some(value) => value.into_tile(), + None => 0, + } + } +} + +impl <T: IntoTile, E> IntoTile for Result<T, E> { + fn into_tile(self) -> i32 { + match self { + Ok(value) => value.into_tile(), + Err(_) => 0, + } + } +} impl <I: IntoTile> From<I> for TileStatus { fn from(value: I) -> Self { -- GitLab