thedes_session/
camera.rs

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}