1use num::traits::{CheckedSub, SaturatingAdd, SaturatingSub};
2use thedes_domain::{
3 block::{Block, PlaceableBlock, SpecialBlock},
4 game::Game,
5 geometry::{Coord, CoordPair, Rect},
6 map,
7 matter::Ground,
8 monster,
9};
10use thedes_geometry::orientation::Direction;
11use thedes_tui::{
12 core::{
13 App,
14 color::{
15 BasicColor,
16 Rgb,
17 mutation::{MutateBg, MutateFg},
18 },
19 grapheme,
20 mutation::{MutationExt, Set},
21 screen,
22 tile::{MutateColors, MutateGrapheme},
23 },
24 text,
25};
26use thiserror::Error;
27
28#[derive(Debug, Error)]
29#[error("Border maximum must be positive, found {given}")]
30pub struct InvalidBorderMax {
31 pub given: Coord,
32}
33
34#[derive(Debug, Error)]
35#[error("Freedom minimum must be positive, found {given}")]
36pub struct InvalidFreedomMin {
37 pub given: Coord,
38}
39
40#[derive(Debug, Error)]
41pub enum Error {
42 #[error("Camera failed to access map data")]
43 MapAccess(
44 #[from]
45 #[source]
46 map::AccessError,
47 ),
48 #[error("Failed to render text")]
49 Text(
50 #[from]
51 #[source]
52 text::Error,
53 ),
54 #[error("Found invalid monster ID")]
55 InvalidMonsterId(
56 #[from]
57 #[source]
58 monster::InvalidId,
59 ),
60 #[error("Insufficient view for the camera")]
61 InsufficientView(#[from] InsufficientView),
62}
63
64#[derive(Debug, Error)]
65#[error(
66 "Canvas size {} cannot produce a view for margins \
67 top={}, left={}, bottom={}, left={}",
68 .canvas_size,
69 .dynamic_style.margin_top_left.y,
70 .dynamic_style.margin_top_left.x,
71 .dynamic_style.margin_bottom_right.y,
72 .dynamic_style.margin_bottom_right.x,
73)]
74pub struct InsufficientView {
75 dynamic_style: DynamicStyle,
76 pub canvas_size: CoordPair,
77}
78
79#[derive(Debug, Clone)]
80pub struct Config {
81 border_max: Coord,
82 freedom_min: Coord,
83}
84
85impl Default for Config {
86 fn default() -> Self {
87 Self::new()
88 }
89}
90
91impl Config {
92 pub fn new() -> Self {
93 Self { border_max: 5, freedom_min: 1 }
94 }
95
96 pub fn with_border_max(
97 self,
98 border_max: Coord,
99 ) -> Result<Self, InvalidBorderMax> {
100 if border_max < 1 {
101 Err(InvalidBorderMax { given: border_max })?
102 }
103
104 Ok(Self { border_max, ..self })
105 }
106
107 pub fn with_freedom_min(
108 self,
109 freedom_min: Coord,
110 ) -> Result<Self, InvalidFreedomMin> {
111 if freedom_min < 1 {
112 Err(InvalidFreedomMin { given: freedom_min })?
113 }
114
115 Ok(Self { freedom_min, ..self })
116 }
117
118 pub(crate) fn finish(self) -> Camera {
119 Camera::new(self)
120 }
121}
122
123#[derive(Debug, Clone)]
124pub(crate) struct DynamicStyle {
125 pub margin_top_left: CoordPair,
126 pub margin_bottom_right: CoordPair,
127}
128
129#[derive(Debug, Clone)]
130pub(crate) struct Camera {
131 view: Rect,
132 config: Config,
133}
134
135impl Camera {
136 fn new(config: Config) -> Self {
137 Self {
138 view: Rect {
139 top_left: CoordPair::from_axes(|_| 0),
140 size: CoordPair::from_axes(|_| 0),
141 },
142 config,
143 }
144 }
145
146 fn update(&mut self, available_canvas: CoordPair, game: &Game) {
147 if self.view.size != available_canvas {
148 self.center_on_player(available_canvas, game);
149 } else if !self
150 .freedom_view()
151 .contains_point(game.player().position().head())
152 {
153 self.stick_to_border(game);
154 } else if !self.view.contains_point(game.player().position().head())
155 || !self.view.contains_point(game.player().position().pointer())
156 {
157 self.center_on_player(available_canvas, game);
158 }
159 }
160
161 pub fn render(
162 &mut self,
163 app: &mut App,
164 game: &Game,
165 dynamic_style: &DynamicStyle,
166 ) -> Result<(), Error> {
167 let available_canvas =
168 self.available_canvas_size(app, dynamic_style)?;
169
170 self.update(available_canvas, game);
171
172 app.canvas
173 .queue([screen::Command::new_clear_screen(BasicColor::Black)]);
174
175 let pos_string = format!("↱{}", game.player().position().head());
176 text::styled(app, &pos_string, &text::Style::default())?;
177
178 for y in self.view.top_left.y .. self.view.bottom_right().y {
179 for x in self.view.top_left.x .. self.view.bottom_right().x {
180 let player_pos = game.player().position();
181 let point = CoordPair { y, x };
182 let canvas_point =
183 point - self.view.top_left + dynamic_style.margin_top_left;
184
185 let ground = game.map().get_ground(point)?;
186 let bg_color = match ground {
187 Ground::Grass => Rgb::new(0x00, 0xff, 0x80).into(),
188 Ground::Sand => Rgb::new(0xff, 0xff, 0x80).into(),
189 Ground::Stone => Rgb::new(0xc0, 0xc0, 0xc0).into(),
190 };
191
192 let block = game.map().get_block(point)?;
193
194 let fg_color = BasicColor::Black.into();
195 let char = match block {
196 Block::Special(SpecialBlock::Player) => {
197 if player_pos.head() == point {
198 'O'
199 } else {
200 match player_pos.facing() {
201 Direction::Up => 'Ʌ',
202 Direction::Down => 'V',
203 Direction::Left => '<',
204 Direction::Right => '>',
205 }
206 }
207 },
208 Block::Special(SpecialBlock::Monster(id)) => {
209 let monster_pos =
210 game.monster_registry().get_by_id(id)?.position();
211 match monster_pos.facing() {
212 Direction::Up => 'ɷ',
213 Direction::Down => 'ო',
214 Direction::Left => 'ɞ',
215 Direction::Right => 'ʚ',
216 }
217 },
218 Block::Placeable(PlaceableBlock::Air) => ' ',
219 };
220 let grapheme = grapheme::Id::from(char);
221
222 let bg_mutation = MutateBg(Set(bg_color));
223 let fg_mutation = MutateFg(Set(fg_color));
224 let color_mutation =
225 MutateColors(bg_mutation.then(fg_mutation));
226 let grapheme_mutation = MutateGrapheme(Set(grapheme));
227 let mutation = color_mutation.then(grapheme_mutation);
228
229 app.canvas.queue([screen::Command::new_mutation(
230 canvas_point,
231 mutation,
232 )]);
233 }
234 }
235
236 Ok(())
237 }
238
239 pub fn contains(&self, point: CoordPair) -> bool {
240 self.view.contains_point(point)
241 }
242
243 pub fn half_view_perimeter(&self) -> Coord {
244 self.view.size.y + self.view.size.x
245 }
246
247 fn border(&self) -> CoordPair {
248 self.feasible_min_freedom().zip2_with(
249 self.view.size,
250 |min_freedom, size| {
251 (size - min_freedom).min(self.config.border_max).max(1)
252 },
253 )
254 }
255
256 fn feasible_min_freedom(&self) -> CoordPair {
257 self.view
258 .size
259 .map(|coord| self.config.freedom_min.min(coord.saturating_sub(1)))
260 }
261
262 fn freedom_view(&self) -> Rect {
263 let border = self.border();
264 Rect {
265 top_left: self.view.top_left.saturating_add(&border),
266 size: self.view.size.saturating_sub(&(border * 2)),
267 }
268 }
269
270 fn center_on_player(&mut self, available_canvas: CoordPair, game: &Game) {
271 let view_size = available_canvas;
272 self.view = Rect {
273 top_left: game
274 .player()
275 .position()
276 .head()
277 .saturating_sub(&(view_size / 2)),
278 size: view_size,
279 };
280 }
281
282 fn stick_to_border(&mut self, game: &Game) {
283 let border = self.border();
284 let freedom_view = self.freedom_view();
285 let head = game.player().position().head();
286 let map_rect = game.map().rect();
287 self.view.top_left = CoordPair::from_axes(|axis| {
288 let start = if freedom_view.top_left[axis] > head[axis] {
289 head[axis].saturating_sub(border[axis])
290 } else if freedom_view.bottom_right()[axis] <= head[axis] {
291 head[axis]
292 .saturating_sub(freedom_view.size[axis])
293 .saturating_sub(border[axis])
294 } else {
295 self.view.top_left[axis]
296 };
297
298 start.max(map_rect.top_left[axis]).min(
299 map_rect.bottom_right()[axis]
300 .saturating_sub(self.view.size[axis]),
301 )
302 });
303 }
304
305 fn available_canvas_size(
306 &mut self,
307 app: &mut App,
308 dynamic_style: &DynamicStyle,
309 ) -> Result<CoordPair, Error> {
310 let canvas_size = app.canvas.size();
311
312 let available = canvas_size
313 .checked_sub(&dynamic_style.margin_top_left)
314 .and_then(|size| {
315 size.checked_sub(&dynamic_style.margin_bottom_right)
316 })
317 .ok_or_else(|| InsufficientView {
318 canvas_size,
319 dynamic_style: dynamic_style.clone(),
320 })?;
321
322 Ok(available)
323 }
324}
325
326#[cfg(test)]
327mod test {
328 use std::time::Duration;
329
330 use thedes_domain::{
331 game::Game,
332 map::Map,
333 player::{Player, PlayerPosition},
334 };
335 use thedes_geometry::{CoordPair, Rect, orientation::Direction};
336 use thedes_tui::core::{
337 App,
338 runtime::{self, device::mock::RuntimeDeviceMock},
339 screen,
340 };
341 use tokio::task;
342
343 use crate::camera::DynamicStyle;
344
345 struct SetupArgs {
346 map_rect: thedes_domain::geometry::Rect,
347 player_head: thedes_domain::geometry::CoordPair,
348 player_facing: Direction,
349 camera: super::Config,
350 }
351
352 struct Resources {
353 game: Game,
354 camera: super::Camera,
355 device_mock: RuntimeDeviceMock,
356 runtime_config: runtime::Config,
357 }
358
359 fn setup_resources(args: SetupArgs) -> Resources {
360 let map = Map::new(args.map_rect).unwrap();
361 let player_position =
362 PlayerPosition::new(args.player_head, args.player_facing).unwrap();
363 let player_hp = Player::DEFAULT_HP;
364 let player = Player::new(player_position, player_hp);
365 let game = Game::new(map, player).unwrap();
366 let camera = args.camera.finish();
367
368 let device_mock = RuntimeDeviceMock::new(CoordPair { y: 24, x: 80 });
369 let device = device_mock.open();
370 let runtime_config = runtime::Config::new()
371 .with_screen(
372 screen::Config::new()
373 .with_canvas_size(CoordPair { y: 22, x: 78 }),
374 )
375 .with_device(device);
376
377 Resources { game, camera, runtime_config, device_mock }
378 }
379
380 #[tokio::test(flavor = "multi_thread")]
381 async fn correct_initial_view_min_commands() {
382 let dynamic_style = DynamicStyle {
383 margin_top_left: CoordPair { y: 1, x: 0 },
384 margin_bottom_right: CoordPair { y: 0, x: 0 },
385 };
386
387 let Resources { game, mut camera, device_mock, runtime_config } =
388 setup_resources(SetupArgs {
389 map_rect: Rect {
390 top_left: CoordPair { x: 500, y: 600 },
391 size: CoordPair { x: 1000, y: 1050 },
392 },
393 player_head: CoordPair { y: 710, x: 1203 },
394 player_facing: Direction::Up,
395 camera: super::Config::default(),
396 });
397
398 device_mock.screen().enable_command_log();
399
400 let main = |mut app: App| async move {
401 camera.render(&mut app, &game, &dynamic_style).unwrap();
402 app.canvas.flush().unwrap();
403 app.tick_session.tick().await;
404 app.tick_session.tick().await;
405 };
406
407 let runtime_future = task::spawn(runtime_config.run(main));
408 tokio::time::sleep(Duration::from_millis(50)).await;
409 runtime_future.await.unwrap().unwrap();
410
411 let command_log =
412 device_mock.screen().take_command_log().unwrap().concat();
413
414 let expected_min_len = (22 - 1) * 78 + 1;
415 assert!(
416 command_log.len() >= expected_min_len,
417 "left: {}\nright: {}",
418 command_log.len(),
419 expected_min_len,
420 );
421 }
422}