1use num::traits::{SaturatingAdd, SaturatingSub};
2use thedes_domain::{
3 game::Game,
4 geometry::{Coord, CoordPair, Rect},
5 map,
6 matter::Ground,
7};
8use thedes_geometry::orientation::Direction;
9use thedes_tui::{
10 core::{
11 App,
12 color::{
13 BasicColor,
14 mutation::{MutateBg, MutateFg},
15 },
16 grapheme,
17 mutation::{MutationExt, Set},
18 screen,
19 tile::{MutateColors, MutateGrapheme},
20 },
21 text,
22};
23use thiserror::Error;
24
25#[derive(Debug, Error)]
26#[error("Border maximum must be positive, found {given}")]
27pub struct InvalidBorderMax {
28 pub given: Coord,
29}
30
31#[derive(Debug, Error)]
32#[error("Freedom minimum must be positive, found {given}")]
33pub struct InvalidFreedomMin {
34 pub given: Coord,
35}
36
37#[derive(Debug, Error)]
38pub enum Error {
39 #[error("Camera failed to access map data")]
40 MapAccess(
41 #[from]
42 #[source]
43 map::AccessError,
44 ),
45 #[error("Failed to render text")]
46 Text(
47 #[from]
48 #[source]
49 text::Error,
50 ),
51}
52
53#[derive(Debug, Clone)]
54pub struct Config {
55 border_max: Coord,
56 freedom_min: Coord,
57}
58
59impl Default for Config {
60 fn default() -> Self {
61 Self::new()
62 }
63}
64
65impl Config {
66 pub fn new() -> Self {
67 Self { border_max: 5, freedom_min: 1 }
68 }
69
70 pub fn with_border_max(
71 self,
72 border_max: Coord,
73 ) -> Result<Self, InvalidBorderMax> {
74 if border_max < 1 {
75 Err(InvalidBorderMax { given: border_max })?
76 }
77
78 Ok(Self { border_max, ..self })
79 }
80
81 pub fn with_freedom_min(
82 self,
83 freedom_min: Coord,
84 ) -> Result<Self, InvalidFreedomMin> {
85 if freedom_min < 1 {
86 Err(InvalidFreedomMin { given: freedom_min })?
87 }
88
89 Ok(Self { freedom_min, ..self })
90 }
91
92 pub(crate) fn finish(self) -> Camera {
93 Camera::new(self)
94 }
95}
96
97#[derive(Debug, Clone)]
98pub(crate) struct Camera {
99 view: Rect,
100 offset: CoordPair,
101 config: Config,
102}
103
104impl Camera {
105 fn new(config: Config) -> Self {
106 Self {
107 view: Rect {
108 top_left: CoordPair::from_axes(|_| 0),
109 size: CoordPair::from_axes(|_| 0),
110 },
111 offset: CoordPair { y: 1, x: 0 },
112 config,
113 }
114 }
115
116 pub fn update(&mut self, app: &mut App, game: &Game) {
117 if self.view.size != app.canvas.size() - self.offset {
118 self.center_on_player(app, game);
119 } else if !self
120 .freedom_view()
121 .contains_point(game.player().position().head())
122 {
123 self.stick_to_border(game);
124 } else if !self.view.contains_point(game.player().position().head())
125 || !self.view.contains_point(game.player().position().pointer())
126 {
127 self.center_on_player(app, game);
128 }
129 }
130
131 pub fn render(&mut self, app: &mut App, game: &Game) -> Result<(), Error> {
132 app.canvas
133 .queue([screen::Command::new_clear_screen(BasicColor::Black)]);
134
135 let pos_string = format!("↱{}", game.player().position().head());
136 text::styled(app, &pos_string, &text::Style::default())?;
137
138 for y in self.view.top_left.y .. self.view.bottom_right().y {
139 for x in self.view.top_left.x .. self.view.bottom_right().x {
140 let player_pos = game.player().position();
141 let point = CoordPair { y, x };
142 let canvas_point = point - self.view.top_left + self.offset;
143
144 let ground = game.map().get_ground(point)?;
145 let bg_color = match ground {
146 Ground::Grass => BasicColor::LightGreen.into(),
147 Ground::Sand => BasicColor::LightYellow.into(),
148 Ground::Stone => BasicColor::LightGray.into(),
149 };
150
151 let fg_color = BasicColor::Black.into();
152 let char = if player_pos.head() == point {
153 'O'
154 } else if player_pos.pointer() == point {
155 match player_pos.facing() {
156 Direction::Up => 'Ʌ',
157 Direction::Down => 'V',
158 Direction::Left => '<',
159 Direction::Right => '>',
160 }
161 } else {
162 ' '
163 };
164 let grapheme = grapheme::Id::from(char);
165
166 let bg_mutation = MutateBg(Set(bg_color));
167 let fg_mutation = MutateFg(Set(fg_color));
168 let color_mutation =
169 MutateColors(bg_mutation.then(fg_mutation));
170 let grapheme_mutation = MutateGrapheme(Set(grapheme));
171 let mutation = color_mutation.then(grapheme_mutation);
172
173 app.canvas.queue([screen::Command::new_mutation(
174 canvas_point,
175 mutation,
176 )]);
177 }
178 }
179
180 Ok(())
181 }
182
183 fn border(&self) -> CoordPair {
184 self.feasible_min_freedom().zip2_with(
185 self.view.size,
186 |min_freedom, size| {
187 (size - min_freedom).min(self.config.border_max).max(1)
188 },
189 )
190 }
191
192 fn feasible_min_freedom(&self) -> CoordPair {
193 self.view
194 .size
195 .map(|coord| self.config.freedom_min.min(coord.saturating_sub(1)))
196 }
197
198 fn freedom_view(&self) -> Rect {
199 let border = self.border();
200 Rect {
201 top_left: self.view.top_left.saturating_add(&border),
202 size: self.view.size.saturating_sub(&(border * 2)),
203 }
204 }
205
206 fn center_on_player(&mut self, app: &mut App, game: &Game) {
207 let view_size = app.canvas.size() - self.offset;
208 self.view = Rect {
209 top_left: game
210 .player()
211 .position()
212 .head()
213 .saturating_sub(&(view_size / 2)),
214 size: view_size,
215 };
216 }
217
218 fn stick_to_border(&mut self, game: &Game) {
219 let border = self.border();
220 let freedom_view = self.freedom_view();
221 let head = game.player().position().head();
222 let map_rect = game.map().rect();
223 self.view.top_left = CoordPair::from_axes(|axis| {
224 let start = if freedom_view.top_left[axis] > head[axis] {
225 head[axis].saturating_sub(border[axis])
226 } else if freedom_view.bottom_right()[axis] <= head[axis] {
227 head[axis]
228 .saturating_sub(freedom_view.size[axis])
229 .saturating_sub(border[axis])
230 } else {
231 self.view.top_left[axis]
232 };
233
234 start.max(map_rect.top_left[axis]).min(
235 map_rect.bottom_right()[axis]
236 .saturating_sub(self.view.size[axis]),
237 )
238 });
239 }
240}
241
242#[cfg(test)]
243mod test {
244 use std::time::Duration;
245
246 use thedes_domain::{game::Game, map::Map, player::PlayerPosition};
247 use thedes_geometry::{CoordPair, Rect, orientation::Direction};
248 use thedes_tui::core::{
249 App,
250 runtime::{self, device::mock::RuntimeDeviceMock},
251 screen,
252 };
253 use tokio::task;
254
255 struct SetupArgs {
256 map_rect: thedes_domain::geometry::Rect,
257 player_head: thedes_domain::geometry::CoordPair,
258 player_facing: Direction,
259 camera: super::Config,
260 }
261
262 struct Resources {
263 game: Game,
264 camera: super::Camera,
265 device_mock: RuntimeDeviceMock,
266 runtime_config: runtime::Config,
267 }
268
269 fn setup_resources(args: SetupArgs) -> Resources {
270 let map = Map::new(args.map_rect).unwrap();
271 let player_position =
272 PlayerPosition::new(args.player_head, args.player_facing).unwrap();
273 let game = Game::new(map, player_position).unwrap();
274 let camera = args.camera.finish();
275
276 let device_mock = RuntimeDeviceMock::new(CoordPair { y: 24, x: 80 });
277 let device = device_mock.open();
278 let runtime_config = runtime::Config::new()
279 .with_screen(
280 screen::Config::new()
281 .with_canvas_size(CoordPair { y: 22, x: 78 }),
282 )
283 .with_device(device);
284
285 Resources { game, camera, runtime_config, device_mock }
286 }
287
288 #[tokio::test(flavor = "multi_thread")]
289 async fn correct_initial_view_min_commands() {
290 let Resources { game, mut camera, device_mock, runtime_config } =
291 setup_resources(SetupArgs {
292 map_rect: Rect {
293 top_left: CoordPair { x: 500, y: 600 },
294 size: CoordPair { x: 1000, y: 1050 },
295 },
296 player_head: CoordPair { y: 710, x: 1203 },
297 player_facing: Direction::Up,
298 camera: super::Config::default(),
299 });
300
301 device_mock.screen().enable_command_log();
302
303 let main = |mut app: App| async move {
304 camera.update(&mut app, &game);
305 camera.render(&mut app, &game).unwrap();
306 app.canvas.flush().unwrap();
307 app.tick_session.tick().await;
308 app.tick_session.tick().await;
309 };
310
311 let runtime_future = task::spawn(runtime_config.run(main));
312 tokio::time::sleep(Duration::from_millis(50)).await;
313 runtime_future.await.unwrap().unwrap();
314
315 let command_log =
316 device_mock.screen().take_command_log().unwrap().concat();
317
318 let expected_min_len = (22 - 1) * 78 + 1;
319 assert!(
320 command_log.len() >= expected_min_len,
321 "left: {}\nright: {}",
322 command_log.len(),
323 expected_min_len,
324 );
325 }
326}