Add terrain move costs and A* pathfinding

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 08:08 UTC
commit
efb6b0abff6ad3eaab93dceadadf3049dfd4a650
parent
da945f1297
tree
browse at this commit

3 files changed +225 -0

src/main.rs +1 -0

@@ -1,4 +1,5 @@
1 1 mod camera;
2 +mod pathfinding;
2 3 mod selection;
3 4 mod tilemap;
4 5 mod world;

src/pathfinding.rs +211 -0

@@ -0,0 +1,211 @@
1 +use std::{cmp::Reverse, collections::BinaryHeap};
2 +
3 +use bevy::prelude::*;
4 +
5 +use crate::world::WorldMap;
6 +
7 +const STRAIGHT: u32 = 10;
8 +const DIAGONAL: u32 = 14;
9 +const MIN_TERRAIN_COST: u32 = 100;
10 +
11 +const NEIGHBORS: [IVec2; 8] = [
12 + IVec2::new(1, 0),
13 + IVec2::new(-1, 0),
14 + IVec2::new(0, 1),
15 + IVec2::new(0, -1),
16 + IVec2::new(1, 1),
17 + IVec2::new(1, -1),
18 + IVec2::new(-1, 1),
19 + IVec2::new(-1, -1),
20 +];
21 +
22 +pub fn find_path(map: &WorldMap, start: UVec2, goal: UVec2) -> Option<Vec<UVec2>> {
23 + let start = start.as_ivec2();
24 + let goal = goal.as_ivec2();
25 + map.move_cost(start)?;
26 + map.move_cost(goal)?;
27 + if start == goal {
28 + return Some(vec![]);
29 + }
30 +
31 + let width = map.size.x as usize;
32 + let index = |p: IVec2| p.y as usize * width + p.x as usize;
33 + let tiles = map.size.element_product() as usize;
34 + let mut cost_so_far = vec![u32::MAX; tiles];
35 + let mut came_from = vec![None::<IVec2>; tiles];
36 + let mut open = BinaryHeap::new();
37 +
38 + cost_so_far[index(start)] = 0;
39 + open.push(Reverse((heuristic(start, goal), start.x, start.y)));
40 +
41 + while let Some(Reverse((_, x, y))) = open.pop() {
42 + let current = IVec2::new(x, y);
43 + if current == goal {
44 + return Some(reconstruct(&came_from, index, start, goal));
45 + }
46 + let current_cost = cost_so_far[index(current)];
47 + for step in NEIGHBORS {
48 + let next = current + step;
49 + let Some(terrain_cost) = map.move_cost(next) else {
50 + continue;
51 + };
52 + let diagonal = step.x != 0 && step.y != 0;
53 + if diagonal
54 + && (map.move_cost(current + IVec2::new(step.x, 0)).is_none()
55 + || map.move_cost(current + IVec2::new(0, step.y)).is_none())
56 + {
57 + continue;
58 + }
59 + let base = if diagonal { DIAGONAL } else { STRAIGHT };
60 + let new_cost = current_cost + base * terrain_cost;
61 + if new_cost < cost_so_far[index(next)] {
62 + cost_so_far[index(next)] = new_cost;
63 + came_from[index(next)] = Some(current);
64 + open.push(Reverse((new_cost + heuristic(next, goal), next.x, next.y)));
65 + }
66 + }
67 + }
68 + None
69 +}
70 +
71 +fn heuristic(a: IVec2, b: IVec2) -> u32 {
72 + let d = (a - b).abs();
73 + let (min, max) = (d.x.min(d.y) as u32, d.x.max(d.y) as u32);
74 + (DIAGONAL * min + STRAIGHT * (max - min)) * MIN_TERRAIN_COST
75 +}
76 +
77 +fn reconstruct(
78 + came_from: &[Option<IVec2>],
79 + index: impl Fn(IVec2) -> usize,
80 + start: IVec2,
81 + goal: IVec2,
82 +) -> Vec<UVec2> {
83 + let mut path = vec![goal.as_uvec2()];
84 + let mut current = goal;
85 + while let Some(prev) = came_from[index(current)] {
86 + if prev == start {
87 + break;
88 + }
89 + path.push(prev.as_uvec2());
90 + current = prev;
91 + }
92 + path.reverse();
93 + path
94 +}
95 +
96 +#[cfg(test)]
97 +mod tests {
98 + use super::*;
99 + use crate::world::Terrain;
100 +
101 + fn map_from(rows: &[&str]) -> WorldMap {
102 + let size = UVec2::new(rows[0].len() as u32, rows.len() as u32);
103 + let mut map = WorldMap::filled(size, Terrain::Grass);
104 + for (row, line) in rows.iter().enumerate() {
105 + let y = size.y - 1 - row as u32;
106 + for (x, c) in line.chars().enumerate() {
107 + let terrain = match c {
108 + '#' => Terrain::Stone,
109 + '~' => Terrain::ShallowWater,
110 + _ => Terrain::Grass,
111 + };
112 + map.set(UVec2::new(x as u32, y), terrain);
113 + }
114 + }
115 + map
116 + }
117 +
118 + fn is_connected(start: UVec2, path: &[UVec2]) -> bool {
119 + std::iter::once(start)
120 + .chain(path.iter().copied())
121 + .collect::<Vec<_>>()
122 + .windows(2)
123 + .all(|w| (w[0].as_ivec2() - w[1].as_ivec2()).abs().max_element() == 1)
124 + }
125 +
126 + #[test]
127 + fn path_excludes_start_and_ends_at_goal() {
128 + let map = map_from(&["....."]);
129 + let path = find_path(&map, UVec2::new(0, 0), UVec2::new(4, 0)).unwrap();
130 + assert_eq!(
131 + path,
132 + vec![UVec2::new(1, 0), UVec2::new(2, 0), UVec2::new(3, 0), UVec2::new(4, 0)]
133 + );
134 + }
135 +
136 + #[test]
137 + fn path_to_self_is_empty() {
138 + let map = map_from(&["..."]);
139 + assert_eq!(find_path(&map, UVec2::ZERO, UVec2::ZERO), Some(vec![]));
140 + }
141 +
142 + #[test]
143 + fn path_goes_around_walls() {
144 + let map = map_from(&[
145 + ".....", //
146 + ".###.",
147 + ".#.#.",
148 + "...#.",
149 + ]);
150 + let start = UVec2::new(2, 1);
151 + let goal = UVec2::new(4, 0);
152 + let path = find_path(&map, start, goal).unwrap();
153 + assert!(is_connected(start, &path));
154 + assert_eq!(path.last(), Some(&goal));
155 + assert!(path.iter().all(|p| map.move_cost(p.as_ivec2()).is_some()));
156 + }
157 +
158 + #[test]
159 + fn unreachable_or_impassable_goal_has_no_path() {
160 + let map = map_from(&[
161 + "..#..", //
162 + "..#..",
163 + ]);
164 + assert_eq!(find_path(&map, UVec2::new(0, 0), UVec2::new(4, 0)), None);
165 + assert_eq!(find_path(&map, UVec2::new(0, 0), UVec2::new(2, 0)), None);
166 + }
167 +
168 + // Colonists move in straight lines between tile centers, so a diagonal step between two
169 + // blocked orthogonal neighbours would visually clip through the corners of walls.
170 + #[test]
171 + fn diagonal_steps_never_cut_wall_corners() {
172 + let map = map_from(&[
173 + "#.", //
174 + ".#",
175 + ]);
176 + assert_eq!(find_path(&map, UVec2::new(0, 0), UVec2::new(1, 1)), None);
177 + let map = map_from(&[
178 + "..", //
179 + "#.",
180 + ]);
181 + let path = find_path(&map, UVec2::new(0, 1), UVec2::new(1, 0)).unwrap();
182 + assert_eq!(path, vec![UVec2::new(1, 1), UVec2::new(1, 0)]);
183 + }
184 +
185 + #[test]
186 + fn prefers_detour_over_slow_terrain() {
187 + let map = map_from(&[
188 + ".....", //
189 + "..~..",
190 + ]);
191 + let path = find_path(&map, UVec2::new(0, 0), UVec2::new(4, 0)).unwrap();
192 + assert!(!path.contains(&UVec2::new(2, 0)));
193 + }
194 +
195 + #[test]
196 + fn crosses_slow_terrain_when_it_is_the_only_way() {
197 + let map = map_from(&["..~.."]);
198 + let path = find_path(&map, UVec2::new(0, 0), UVec2::new(4, 0)).unwrap();
199 + assert!(path.contains(&UVec2::new(2, 0)));
200 + }
201 +
202 + #[test]
203 + fn finds_paths_across_the_static_world() {
204 + let map = crate::world::static_world();
205 + let start = UVec2::new(10, 10);
206 + let goal = UVec2::new(120, 70);
207 + let path = find_path(&map, start, goal).unwrap();
208 + assert!(is_connected(start, &path));
209 + assert_eq!(path.last(), Some(&goal));
210 + }
211 +}

src/world.rs +13 -0

@@ -26,6 +26,15 @@ impl Terrain {
26 26 self as u16
27 27 }
28 28
29 + pub fn move_cost(self) -> Option<u32> {
30 + match self {
31 + Terrain::Grass | Terrain::Soil => Some(100),
32 + Terrain::Sand => Some(130),
33 + Terrain::ShallowWater => Some(300),
34 + Terrain::DeepWater | Terrain::Stone => None,
35 + }
36 + }
37 +
29 38 pub fn color(self) -> Srgba {
30 39 match self {
31 40 Terrain::Grass => Srgba::rgb_u8(92, 140, 64),
@@ -70,6 +79,10 @@ impl WorldMap {
70 79 self.tiles[i] = terrain;
71 80 }
72 81
82 + pub fn move_cost(&self, pos: IVec2) -> Option<u32> {
83 + self.get(pos).and_then(Terrain::move_cost)
84 + }
85 +
73 86 pub fn positions(&self) -> impl Iterator<Item = UVec2> + use<> {
74 87 let size = self.size;
75 88 (0..size.y).flat_map(move |y| (0..size.x).map(move |x| UVec2::new(x, y)))