Skip to main content

thedes_session/
camera.rs

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}