Skip to content
Snippets Groups Projects
lib.rs 21 KiB
Newer Older
Louis's avatar
Louis committed
//! `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));
//! # }
//! ```

Louis's avatar
Louis committed
/// 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 {
Louis's avatar
Louis committed
	/// This tile will always match, regardless of the input tile
Louis's avatar
Louis committed
	/// This tile will only match when there is no input tile (`None`)
Louis's avatar
Louis committed
	/// This tile will always match as long as the tile exists (`Option::is_some`)
Louis's avatar
Louis committed
	/// This tile will match as long as the input tile exists and the input value is the same as this value
Louis's avatar
Louis committed
	/// This tile will match as long as the input tile exists and the input value is anything other than this value
	IsNot(usize),
}

impl PartialEq<Option<usize>> for TileStatus {
	fn eq(&self, other: &Option<usize>) -> bool {
		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,
		}
	}
}

impl TileStatus {
	pub fn to_ldtk_value(&self) -> i64 {
		match self {
			Self::Ignore => 0,
			Self::Nothing => -1000001,
			Self::Anything => 1000001,
			Self::Is(value) => *value as i64,
			Self::IsNot(value) => -(*value as i64),
		}
	}

	pub fn from_ldtk_value(value: i64) -> Self {
		match value {
			0 => Self::Ignore,
			1000001 => Self::Anything,
			-1000001 => Self::Nothing,
			other => {
				if other > 0 {
					Self::Is(other as usize)
				} else {
					Self::IsNot(other.unsigned_abs() as usize)
				}
			}
		}
	}
}

Louis's avatar
Louis committed
/// 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 {
Louis's avatar
Louis committed
	/// Create a 1x1 matcher, where the target tile must be the supplied `value`
	pub const fn single(value: usize) -> Self {
		Self([
			TileStatus::Ignore,
			TileStatus::Ignore,
			TileStatus::Ignore,
			TileStatus::Ignore,
			TileStatus::Is(value),
			TileStatus::Ignore,
			TileStatus::Ignore,
			TileStatus::Ignore,
			TileStatus::Ignore,
		])
	}

Louis's avatar
Louis committed
	/// 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,
		])
	}

Louis's avatar
Louis committed
	/// Check if the given input layout of tile data conforms to this matcher
	pub fn matches(&self, layout: &TileLayout) -> bool {
		self.0
			.iter()
			.zip(layout.0.iter())
			.all(|(status, reality)| *status == *reality)
	}

Louis's avatar
Louis committed
	/// 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];
			Some(Self::single_match(TileStatus::from_ldtk_value(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_ldtk_value),
			))
		} else {
			None
		}
	}
}

Louis's avatar
Louis committed
/// 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 {
Louis's avatar
Louis committed
	/// Create a 1x1 grid of tile data
	pub fn single(value: usize) -> Self {
		TileLayout([None, None, None, None, Some(value), None, None, None, None])
	}
Louis's avatar
Louis committed

	/// 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],
		]
	}
}

Louis's avatar
Louis committed
/// 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 {
Louis's avatar
Louis committed
	/// This output should be skipped. Noop equivalent
Louis's avatar
Louis committed
	/// This exact value should be produces
Louis's avatar
Louis committed
	/// Some method should be used to select one of the values in this list
	Random(Vec<usize>),
}

impl TileOutput {
Louis's avatar
Louis committed
	/// Create an output that can produce the input value when this output is selected
	pub const fn single(value: usize) -> Self {
		TileOutput::Single(value)
	}

Louis's avatar
Louis committed
	/// 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)
	}
Louis's avatar
Louis committed

	/// 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(),
		}
	}
Louis's avatar
Louis committed
/// Checks tile layouts against a matcher instance, and uses the output to produce a value
#[derive(Clone, Debug, Default)]
pub struct AutoTileRule {
Louis's avatar
Louis committed
	/// The pattern that this rule will use for matching
	pub matcher: TileMatcher,
Louis's avatar
Louis committed
	/// The value produced when this rule gets matched
	pub output: TileOutput,
Louis's avatar
Louis committed
	/// 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 {
Louis's avatar
Louis committed
	/// 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)
	}
Louis's avatar
Louis committed

	/// 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),
			output: TileOutput::single(output_value),
			chance,
		}
	}
Louis's avatar
Louis committed

	/// 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,
			output: TileOutput::single(output_value),
			chance: 1.0,
		}
	}

Louis's avatar
Louis committed
	/// 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)
	}

Louis's avatar
Louis committed
	/// 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>,
		chance: f32,
	) -> Self {
		AutoTileRule {
			matcher: TileMatcher::single(input_value),
			output: TileOutput::any(output_value),
			chance,
		}
	}

Louis's avatar
Louis committed
	/// 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>,
		chance: f32,
	) -> Self {
		AutoTileRule {
			matcher: input_value,
			output: TileOutput::any(output_value),
			chance,
		}
	}

Louis's avatar
Louis committed
	/// 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();

		if chance <= self.chance && self.matcher.matches(input) {
			Some(&self.output)
		} else {
			None
		}
	}

Louis's avatar
Louis committed
	/// 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,
		input: &TileLayout,
		seeded: &fastrand::Rng,
	) -> Option<&TileOutput> {
		let chance = seeded.f32();

		if chance <= self.chance && self.matcher.matches(input) {
			Some(&self.output)
		} else {
			None
		}
	}
Louis's avatar
Louis committed

	/// 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))
	}
Louis's avatar
Louis committed
/// 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;

Louis's avatar
Louis committed
	/// 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())
	}
}

Louis's avatar
Louis committed
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 {
Louis's avatar
Louis committed
	/// 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() {
			let result = rule.get_match(input);
			if result.is_some() {
				return result;
			}
		}
		None
	}

Louis's avatar
Louis committed
	/// 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,
		input: &TileLayout,
		seeded: &fastrand::Rng,
	) -> Option<&TileOutput> {
		for rule in self.0.iter() {
			let result = rule.get_match_seeded(input, seeded);
			if result.is_some() {
				return result;
			}
		}
		None
	}
Louis's avatar
Louis committed

	/// 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))
	}