From 078ba9e0566bfc912948e649c0a98210dc4235c5 Mon Sep 17 00:00:00 2001 From: Louis Capitanchik <contact@louiscap.co> Date: Mon, 17 Apr 2023 15:23:39 +0100 Subject: [PATCH] Write rustdoc w/ doc tests --- .gitignore | 1 + README.md | 73 +++++++++++ rustfmt.toml | 4 + src/lib.rs | 350 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 428 insertions(+) create mode 100644 README.md create mode 100644 rustfmt.toml diff --git a/.gitignore b/.gitignore index 4fffb2f..3d84f13 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /Cargo.lock +.idea/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9927afa --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Micro Autotile + +[](https://crates.io/crates/micro_autotile) +[](https://docs.rs/micro_autotile) + +_Bring LDTK's autotile feature to your Rust project_ + +`micro_autotile` provides an implementation of the LDTK auto-tiling algorithm, for use in +programs at runtime. The representation is compatible with that saved by LDTK, meaning that +definitions can be loaded directly from LDTK JSON exports. Great for either building a +compatible editor into your project, or for using LDTK's rule format to decorate generated +content. + +## Installation + +Either add it to your `Cargo.toml` dependencies: + +```toml +[dependencies] +micro_autotile = "0.1" +``` + +Or use cargo to add it to your project: + +```sh +cargo add micro_autotile +``` + +## Usage + +_For a more thorough usage guide, check out the [docs.rs](https://docs.rs/micro_autotile) page_ + +Autotiling is, fundamentally, a method of mapping a two dimensional pattern to a single scalar value. +The context for this is usually "tile maps" in video games, and while that is not the sole use case it +is the one that will be assumed throughout the docs for `micro_autotile`. + +To start, determine what your input types and output types will be - in this example, we will use integers +to define types of terrain (walls, ground, water, etc) for our input, and the output will be a specific sprite +index in some theoretical sprite sheet. + +1. Create one or more rules that define a pattern to match, a value to output, and optionally a percentage +chance for that rule to be chosen. + * ```rust + use micro_autotile::AutoTileRule; + const GROUND: usize = 0; + const WALL: usize = 1; + + let alt_ground_rule = AutoTileRule::single_any_chance(GROUND, vec![123, 124, 125], 0.2); + let fallback_ground_rule = AutoTileRule::exact(GROUND, 126); + ``` +2. (Optional) Put together your rules in a rule set. This can be skipped and the rule structs used directly + * ```rust + use micro_autotile::{AutoRuleSet, AutoTileRule}; + let ground_rules = AutoRuleSet::new(vec![alt_ground_rule, fallback_ground_rule]); + let wall_rules = AutoTileRule::exact(WALL, 35).into(); + let combined_rules = wall_rules + ground_rules; + ``` +3. Elsewhere, generate a slice of level data wrapped in a `TileLayout` struct. This represents a single tile + (the central element) and its surrounding neighbors. The order of the neighbors is important, and is laid + out as though in a flattened grid. + * ```rust + use micro_autotile::TileLayout; + let layout = TileLayout::filled([ + GROUND, GROUND, GROUND, + GROUND, WALL, GROUND, + GROUND, GROUND, GROUND, + ]); + ``` +4. Produce an output using either the rule set or the rule directly (the same methods exist for both) + * ```rust + let output = combined_rules.resolve_match(&layout); + assert_eq!(output, Some(35)); + ``` \ No newline at end of file diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..d62aed7 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,4 @@ +hard_tabs = true +#group_imports = "StdExternalCrate" +use_field_init_shorthand = true +use_try_shorthand = true \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index b3fe406..1e04e9b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,12 +1,152 @@ +//! `micro_autotile` provides an implementation of the LDTK auto-tiling algorithm, for use in +//! programs at runtime. The representation is compatible with that saved by LDTK, meaning that +//! definitions can be loaded directly from LDTK JSON exports. +//! +//! Creating a single rule works like this: +//! +//! 1. Create a `TileMatcher` out of `TileStatus` entries. +//! * Tile Matchers are squares represented as fixed size flat arrays +//! * Currently only 1x1 and 3x3 matchers are supported, 5x5 matchers are incompatible +//! * Since a Tile Matcher is a rule, they are usually created statically or loaded as +//! an asset that will not change much / at all +//! 2. Create a `TileOutput` that represents the value produces by this rule when it matches +//! * An output value of `Skip` will cause the rule to be a noop. This has utility when combined +//! with a rule's `chance` value, as part of a set of rules +//! * A `Single` output will always produce the same value +//! * A `Random` output will produce one of the provided values at random +//! 3. Combine these into an `AutoTileRule` +//! * There are a number of convenience methods for doing this process without mistakes in a single function call +//! +//! To utilise your matcher, you'll need to provide a specifically formatted slice of your input data (typically a sub-grid +//! of a tile map). If you're matching a single tile, you can use the convenience method `TileLayout::single`, otherwise +//! you will need to provide a 9 element array that represents 3 rows and 3 columns of data, in the following format: +//! +//! ```text +//! Flat array data +//! [1, 2, 3, 4, 5, 6, 7, 8, 9] +//! ``` +//! +//! ```text +//! Formatted in the way it would appear if laid out in a tile map +//! [ +//! 1, 2, 3, # First row, all three columns +//! 4, 5, 6, # Second row, all three columns +//! 7, 8, 9, # Third row, all three columns +//! ] +//! ``` +//! +//! As we can see, the fifth element of the array is the centre tile of our matching grid. In fact, `TileLayout::single` constructs +//! a 9 element array where the fifth element is `Some(your_value)`, and the rest are simply `None`. This is possible because the +//! actual data represents each element as an `Option` (not as the simple numbers above), which allows matching up against edges of +//! data arrays, or against non-regular shapes. Putting this together to match against data from our tile map, we have the following: +//! +//! e.g. +//! ```rust +//! # use micro_autotile::{AutoTileRule, TileLayout, TileOutput}; +//! # fn main() { +//! use micro_autotile::TileMatcher; +//! // Tile maps often use unsigned integers to represent different types of tiles +//! const WALL_TILE: usize = 0; +//! const GROUND_TILE: usize = 1; +//! +//! // 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); +//! +//! assert_eq!( +//! match_1_x_1.resolve_match(&TileLayout::single(GROUND_TILE)), +//! Some(57) +//! ); +//! +//! // 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), +//! ]); +//! +//! assert_eq!( +//! match_1_x_1.resolve_match(&enclosed_ground), +//! Some(57) +//! ); +//! +//! // 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)); +//! # } +//! ``` +//! +//! There's already a lot of utility to these structures, but we still need to manually run a set of +//! rules against our maps and do some work with the `AutoTileRule::chance` property to figure out +//! what the final output should be for a given layout. +//! +//! Introducing the `AutoRuleSet` struct, that represents a sequence of rules that should be evaluated +//! to produce an output. It provides similar methods to the individual AutoTileRule, but will execute +//! against a set of rules at once. There are also convenience methods for combining AutoRuleSet +//! instances +//! +//! ```rust +//! # use micro_autotile::{AutoTileRule, AutoRuleSet, TileLayout, TileOutput}; +//! # fn main() { +//! use micro_autotile::{TileMatcher, TileStatus}; +//! const WALL_TILE: usize = 0; +//! const GROUND_TILE: usize = 1; +//! const OTHER_TILE: usize = 342; +//! +//! let wall_rules = AutoRuleSet(vec![ +//! AutoTileRule::single_when(TileMatcher([ // 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 +//! 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), +//! // ... Etc +//! ]); +//! +//! let ground_rules = AutoRuleSet(vec![ +//! // Use decorated tiles in 10% of cases +//! AutoTileRule::single_any_chance(GROUND_TILE, vec![45, 46, 47], 0.1), +//! // Fall back to the basic tile if we don't match previously +//! AutoTileRule::exact(GROUND_TILE, 44), +//! ]); +//! +//! // Easily merge rule sets in an ordered way +//! let combined_rules = wall_rules + ground_rules; +//! +//! let sublayout = TileLayout([ +//! 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), +//! ]); +//! +//! // 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)); +//! # } +//! ``` + use std::ops::Add; +/// Represents how a single tile location should be matched when evaluating a rule #[derive(Ord, PartialOrd, Eq, PartialEq, Hash, Debug, Default, Copy, Clone)] pub enum TileStatus { + /// This tile will always match, regardless of the input tile #[default] Ignore, + /// This tile will only match when there is no input tile (`None`) Nothing, + /// This tile will always match as long as the tile exists (`Option::is_some`) Anything, + /// This tile will match as long as the input tile exists and the input value is the same as this value Is(usize), + /// This tile will match as long as the input tile exists and the input value is anything other than this value IsNot(usize), } @@ -49,11 +189,26 @@ impl TileStatus { } } +/// 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)] #[repr(transparent)] pub struct TileMatcher(pub [TileStatus; 9]); impl TileMatcher { + /// Create a 1x1 matcher, where the target tile must be the supplied `value` pub const fn single(value: usize) -> Self { Self([ TileStatus::Ignore, @@ -68,6 +223,7 @@ impl TileMatcher { ]) } + /// Create a 1x1 matcher, with any rule for the target tile pub const fn single_match(value: TileStatus) -> Self { Self([ TileStatus::Ignore, @@ -82,6 +238,7 @@ impl TileMatcher { ]) } + /// Check if the given input layout of tile data conforms to this matcher pub fn matches(&self, layout: &TileLayout) -> bool { self.0 .iter() @@ -89,6 +246,8 @@ impl TileMatcher { .all(|(status, reality)| *status == *reality) } + /// Load data from an LDTK JSON file. Currently supports 1x1 and 3x3 matchers. + /// 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]; @@ -107,14 +266,43 @@ impl TileMatcher { } } +/// Represents a grid of input data. What this data means is dependant on your application, and +/// could realistically correlate to anything. It is assumed to be a 3x3 slice of tile data from a +/// tile map #[derive(Clone, Debug, Default)] #[repr(transparent)] pub struct TileLayout(pub [Option<usize>; 9]); impl TileLayout { + /// Create a 1x1 grid of tile data pub fn single(value: usize) -> Self { TileLayout([None, None, None, None, Some(value), None, None, None, None]) } + + /// Construct a filled 3x3 grid of tile data + pub fn filled(values: [usize; 9]) -> Self { + TileLayout(values.map(Some)) + } + + /// Construct a filled 3x3 grid of identical tile data + pub fn spread(value: usize) -> Self { + TileLayout([Some(value); 9]) + } + + /// Filter the layout data so that it only contains the tiles surrounding the target tile. The main + /// utility of this is to perform set operations on every tile _other_ than the target tile. + /// + /// e.g. + /// + /// ``` + /// # use micro_autotile::TileLayout; + /// let layout = TileLayout::single(123); + /// let has_any_surrounding_tiles = layout.surrounding() + /// .iter() + /// .any(|tile| tile.is_some()); + /// + /// assert_eq!(has_any_surrounding_tiles, false); + /// ``` pub fn surrounding(&self) -> [Option<usize>; 8] { [ self.0[0], self.0[1], self.0[2], self.0[3], self.0[5], self.0[6], self.0[7], self.0[8], @@ -122,35 +310,72 @@ impl TileLayout { } } +/// Represents the value produced when a rule is matched. Will need to be inspected to find out +/// the raw data value. This value will typically correspond to an index in a spritesheet, but +/// there is no proscribed meaning - it will be application dependant and could represent some +/// other index or meaning #[derive(Clone, Debug, Default)] pub enum TileOutput { + /// This output should be skipped. Noop equivalent #[default] Skip, + /// This exact value should be produces Single(usize), + /// Some method should be used to select one of the values in this list Random(Vec<usize>), } impl TileOutput { + /// Create an output that can produce the input value when this output is selected pub const fn single(value: usize) -> Self { TileOutput::Single(value) } + /// Create an output that can produce any of these input values when this output is selected pub const fn any(value: Vec<usize>) -> Self { TileOutput::Random(value) } + + /// Produce the value this output represents. Will use a default randomly seeded RNG to + /// select from a list, if appropriate + #[cfg(feature = "impl_fastrand")] + pub fn resolve(&self) -> Option<usize> { + self.resolve_with(&fastrand::Rng::default()) + } + + /// Produce the value this output represents. Will use a default randomly seeded RNG to + /// select from a list, if appropriate + #[cfg(feature = "impl_fastrand")] + pub fn resolve_with(&self, rng: &fastrand::Rng) -> Option<usize> { + match self { + Self::Skip => None, + Self::Single(val) => Some(*val), + Self::Random(vals) => vals.get(rng.usize(0..vals.len())).copied(), + } + } } +/// Checks tile layouts against a matcher instance, and uses the output to produce a value #[derive(Clone, Debug, Default)] pub struct AutoTileRule { + /// The pattern that this rule will use for matching pub matcher: TileMatcher, + /// The value produced when this rule gets matched pub output: TileOutput, + /// When used as part of a set of rules, this value (0.0 - 1.0) determines the chance that + /// a successful match will generate an output from this rule pub chance: f32, } impl AutoTileRule { + /// Create a rule that will always produce `output_value` when the target tile matches + /// `input_value` pub const fn exact(input_value: usize, output_value: usize) -> Self { Self::exact_chance(input_value, output_value, 1.0) } + + /// Create a rule that will produce `output_value` when the target tile matches + /// `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: usize, output_value: usize, chance: f32) -> Self { AutoTileRule { matcher: TileMatcher::single(input_value), @@ -158,6 +383,9 @@ impl AutoTileRule { chance, } } + + /// Create a rule that will always produce `output_value` when `matcher` evaluates to + /// `true` pub const fn single_when(matcher: TileMatcher, output_value: usize) -> Self { AutoTileRule { matcher, @@ -166,10 +394,15 @@ impl AutoTileRule { } } + /// Create a rule that will always produce one of the values contained in `output_value` + /// when the target tile matches `input_value` pub const fn single_any(input_value: usize, output_value: Vec<usize>) -> Self { Self::single_any_chance(input_value, output_value, 1.0) } + /// Create a rule that will produce one of the values contained in `output_value` + /// when the target tile matches `input_value` and the selection chacne is rolled under the + /// value of `chance` (0.0 to 1.0) pub const fn single_any_chance( input_value: usize, output_value: Vec<usize>, @@ -182,6 +415,9 @@ impl AutoTileRule { } } + /// Create a rule that will produce one of the values contained in `output_value` + /// when when `matcher` evaluates to `true` and the selection chacne is rolled under + /// the value of `chance` (0.0 to 1.0) pub const fn any_any_chance( input_value: TileMatcher, output_value: Vec<usize>, @@ -194,6 +430,10 @@ impl AutoTileRule { } } + /// Evaluate this rule and return the unresolved output value. "None" represents either no + /// match or a match that failed its chance roll. + /// + /// Will use a default randomly seeded RNG to evaluate the chance roll for this rule #[cfg(feature = "impl_fastrand")] pub fn get_match(&self, input: &TileLayout) -> Option<&TileOutput> { let chance = fastrand::f32(); @@ -205,6 +445,10 @@ impl AutoTileRule { } } + /// Evaluate this rule and return the unresolved output value. "None" represents either no + /// match or a match that failed its chance roll. + /// + /// Will use the provided RNG to evaluate the chance roll for this rule #[cfg(feature = "impl_fastrand")] pub fn get_match_seeded( &self, @@ -219,20 +463,94 @@ impl AutoTileRule { None } } + + /// Evaluate this rule and produce an output, if a match is found. "None" represents either + /// no match, a match that resolved to `TileOutput::Skip`, or a match that failed its chance + /// roll. + /// + /// Will use a default randomly seeded RNG to select from a list, if the output resolves to + /// a random selection + #[cfg(feature = "impl_fastrand")] + pub fn resolve_match(&self, input: &TileLayout) -> Option<usize> { + self.get_match(input).and_then(|out| out.resolve()) + } + + /// Evaluate this rule and produce an output, if a match is found. "None" represents either + /// no match, a match that resolved to `TileOutput::Skip`, or a match that failed its chance + /// roll. + /// + /// Will use a the provided RNG to select from a list, if the output resolves to + /// a random selection + #[cfg(feature = "impl_fastrand")] + pub fn resolve_match_seeded( + &self, + input: &TileLayout, + seeded: &fastrand::Rng, + ) -> Option<usize> { + self.get_match_seeded(input, seeded) + .and_then(|out| out.resolve_with(seeded)) + } } +/// Holds a list of rules, for efficiently evaluating a tile layout against multiple exclusive rules. +/// Rules will be evaluated in the order they are added to the set, and will stop evaluating when +/// a match is found #[derive(Clone, Debug, Default)] pub struct AutoRuleSet(pub Vec<AutoTileRule>); impl Add<AutoRuleSet> for AutoRuleSet { type Output = AutoRuleSet; + /// Combine two AutoRuleSet values, where the rules in the right hand side + /// will be appended to the end of the set represented by the left hand + /// side fn add(self, rhs: AutoRuleSet) -> Self::Output { AutoRuleSet([self.0.as_slice(), rhs.0.as_slice()].concat()) } } +impl From<AutoTileRule> for AutoRuleSet { + /// Create a rule set from a single rule + /// + /// ```rust + /// # use micro_autotile::{AutoRuleSet, AutoTileRule}; + /// # fn main() { + /// use micro_autotile::TileLayout; + /// let rule_set: AutoRuleSet = AutoTileRule::exact(1, 2).into(); + /// + /// assert_eq!(rule_set.resolve_match(&TileLayout::single(1)), Some(2)); + /// # } + /// ``` + fn from(value: AutoTileRule) -> Self { + Self(vec![value]) + } +} + +impl From<Vec<AutoTileRule>> for AutoRuleSet { + /// Convert a set of rules into a rule set + /// + /// ```rust + /// # use micro_autotile::{AutoRuleSet, AutoTileRule}; + /// # fn main() { + /// use micro_autotile::TileLayout; + /// let rule_set: AutoRuleSet = vec![ + /// AutoTileRule::exact(1, 2), + /// AutoTileRule::exact(5123, 231) + /// ].into(); + /// + /// assert_eq!(rule_set.resolve_match(&TileLayout::single(1)), Some(2)); + /// # } + /// ``` + fn from(value: Vec<AutoTileRule>) -> Self { + Self(value) + } +} + impl AutoRuleSet { + /// Evaluate this set of rules and return the unresolved output value from the first match. + /// A return value of `None` means that no rules have matched. + /// + /// Will use a default randomly seeded RNG to evaluate the chance roll for each matching rule #[cfg(feature = "impl_fastrand")] pub fn get_match(&self, input: &TileLayout) -> Option<&TileOutput> { for rule in self.0.iter() { @@ -244,6 +562,11 @@ impl AutoRuleSet { None } + /// Evaluate this set of rules and return the unresolved output value from the first match. + /// A return value of `None` means that no rules have matched, or all matching results failed + /// their chance roll or resolved to `TileOutput::Skip`. + /// + /// Will use the provided RNG to evaluate the chance roll for each matching rule #[cfg(feature = "impl_fastrand")] pub fn get_match_seeded( &self, @@ -258,4 +581,31 @@ impl AutoRuleSet { } None } + + /// Evaluate this set of rules and produce an output, if a match is found. + /// A return value of `None` means that no rules have matched, or all matching results failed + /// their chance roll or resolved to `TileOutput::Skip`. + /// + /// Will use a default randomly seeded RNG to select from a list, if the output resolves to + /// a random selection + #[cfg(feature = "impl_fastrand")] + pub fn resolve_match(&self, input: &TileLayout) -> Option<usize> { + self.get_match(input).and_then(|out| out.resolve()) + } + + /// Evaluate this set of rules and produce an output, if a match is found. + /// A return value of `None` means that no rules have matched, or all matching results failed + /// their chance roll or resolved to `TileOutput::Skip`. + /// + /// Will use the provided RNG to select from a list, if the output resolves to + /// a random selection + #[cfg(feature = "impl_fastrand")] + pub fn resolve_match_seeded( + &self, + input: &TileLayout, + seeded: &fastrand::Rng, + ) -> Option<usize> { + self.get_match_seeded(input, seeded) + .and_then(|out| out.resolve_with(seeded)) + } } -- GitLab