sokoban_elements/tile.rs
1/// Shape of single game board tile.
2///
3/// In different [`Tessellation`](crate::Tessellation)s, board tiles have different shapes.
4///
5/// Shape of board tile can also depend on position of that tile on game board.
6///
7/// For example, in [`Trioban`](crate::Tessellation::Trioban), origin of coordinate system is
8/// triangle pointing upwards. Shape of all other triangles depends on orientation of origin and may
9/// be either triangle pointing upwards or triangle pointing downwards.
10///
11/// In [`Octoban`](crate::Tessellation::Octoban) tessellation, board is laid on alternating squares
12/// and octagons. Again, shape of each tile depends on position of that tile.
13#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub enum TileShape {
16 Square,
17 Hexagon,
18 TriangleUp,
19 TriangleDown,
20 Octagon,
21}
22
23/// What kind of game board element is one tile of that board.
24#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Default)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26pub enum TileKind {
27 Box,
28 BoxOnGoal,
29 Pusher,
30 PusherOnGoal,
31 #[default]
32 Floor,
33 Goal,
34 Wall,
35}
36
37impl TileKind {
38 /// Tile is a wall.
39 pub fn is_wall(&self) -> bool { *self == Self::Wall }
40
41 /// Tile carries a pusher, whether or not it also carries a goal.
42 pub fn has_pusher(&self) -> bool { *self == Self::Pusher || *self == Self::PusherOnGoal }
43
44 /// Tile carries a box, whether or not it also carries a goal.
45 pub fn has_box(&self) -> bool { *self == Self::Box || *self == Self::BoxOnGoal }
46
47 /// Tile carries a goal, whether or not a box or a pusher stands on it.
48 ///
49 /// [`BoxOnGoal`](Self::BoxOnGoal) and [`PusherOnGoal`](Self::PusherOnGoal) both match. Because
50 /// of that, counting goals and counting boxes are two independent scans of a board and the
51 /// n-th box is not the counterpart of the n-th goal.
52 pub fn has_goal(&self) -> bool {
53 *self == Self::Goal || *self == Self::BoxOnGoal || *self == Self::PusherOnGoal
54 }
55
56 /// Tile holds nothing at all: no wall, no piece and no goal.
57 ///
58 /// This is [`Floor`](Self::Floor), the [`Default`](std::default::Default) variant, and it is
59 /// the only tile kind that carries no board content. Its negation is exactly what
60 /// [`GameBoard::trim()`](crate::GameBoard::trim()) retains, and a board of nothing but empty
61 /// floor is what [`GameBoard::is_blank()`](crate::GameBoard::is_blank()) reports.
62 pub fn is_empty_floor(&self) -> bool { *self == Self::Floor }
63
64 /// Tile may be the first non-empty tile of a board row.
65 ///
66 /// This is a `SokobanYASC` board condition: the first non-empty tile in each row must be a wall
67 /// or a box standing on a goal. A plain [`Box`](Self::Box) does **not** qualify, and neither
68 /// does a lone goal or a pusher.
69 ///
70 /// Nothing in this crate enforces the condition - [`GameBoard`](crate::GameBoard) places no
71 /// restrictions on itself, and boards are edited long before they are played. The predicate is
72 /// here for callers that do want to check it.
73 pub fn can_start_a_row(&self) -> bool { self.is_wall() || *self == Self::BoxOnGoal }
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79
80 const ALL_KINDS: [TileKind; 7] = [
81 TileKind::Box,
82 TileKind::BoxOnGoal,
83 TileKind::Pusher,
84 TileKind::PusherOnGoal,
85 TileKind::Floor,
86 TileKind::Goal,
87 TileKind::Wall,
88 ];
89
90 // Every kind the predicate accepts, so each test pins a whole membership set rather than a
91 /// few hand-picked variants.
92 fn matching(predicate: impl Fn(&TileKind) -> bool) -> Vec<TileKind> {
93 ALL_KINDS
94 .into_iter()
95 .filter(|kind| predicate(kind))
96 .collect()
97 }
98
99 #[test]
100 fn only_the_wall_is_a_wall() {
101 assert_eq!(matching(TileKind::is_wall), vec![TileKind::Wall]);
102 }
103
104 #[test]
105 fn a_pusher_on_a_goal_still_has_a_pusher() {
106 assert_eq!(
107 matching(TileKind::has_pusher),
108 vec![TileKind::Pusher, TileKind::PusherOnGoal]
109 );
110 }
111
112 #[test]
113 fn a_box_on_a_goal_still_has_a_box() {
114 assert_eq!(matching(TileKind::has_box), vec![TileKind::Box, TileKind::BoxOnGoal]);
115 }
116
117 #[test]
118 fn an_occupied_goal_still_has_a_goal() {
119 assert_eq!(
120 matching(TileKind::has_goal),
121 vec![TileKind::BoxOnGoal, TileKind::PusherOnGoal, TileKind::Goal]
122 );
123 }
124
125 #[test]
126 fn only_the_floor_is_empty_floor() {
127 assert_eq!(matching(TileKind::is_empty_floor), vec![TileKind::Floor]);
128 }
129
130 #[test]
131 fn the_default_tile_is_empty_floor() {
132 assert!(TileKind::default().is_empty_floor());
133 }
134
135 #[test]
136 fn only_walls_and_boxes_on_goals_can_start_a_row() {
137 assert_eq!(
138 matching(TileKind::can_start_a_row),
139 vec![TileKind::BoxOnGoal, TileKind::Wall]
140 );
141 }
142
143 #[test]
144 fn a_plain_box_cannot_start_a_row() {
145 assert!(!TileKind::Box.can_start_a_row());
146 assert!(TileKind::BoxOnGoal.can_start_a_row());
147 }
148}