From 7dff6572462bdc59011f8ff8d1c6dc3a0494e44a Mon Sep 17 00:00:00 2001 From: Dylan Date: Thu, 22 Oct 2026 17:17:02 +1300 Subject: feat: Improvements to UI Now uses ratatui --- src/game.rs | 354 +++++++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 330 insertions(+), 24 deletions(-) (limited to 'src/game.rs') diff --git a/src/game.rs b/src/game.rs index 96f71b3..73ae583 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,44 +1,350 @@ -use crate::{board::{GameBoard, Turn}, player::Player}; +use std::io; +use ratatui::{ + crossterm::{event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}, execute}, + layout::{Constraint, Layout, Margin, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Paragraph}, + DefaultTerminal, Frame, +}; + +use crate::{board::{GameBoard, Move, Turn}, board_view::{pos_at, BoardView}, player::{Input, Player}}; + +/// Runs a game in the terminal: draws the board, the move history and the current player's panel, +/// and passes mouse clicks on the board to the player whose turn it is. +#[derive(Default)] pub struct GameHandler { - board: GameBoard, + start: GameBoard, + /// Every move played so far, with the board after it. Sheep play the even-numbered moves. + history: Vec<(Move, GameBoard)>, + /// Where the last draw put the board, for turning clicks into points. + board_area: Rect, + /// Where the last draw put each visible history entry, with its index in `history`. + history_rows: Vec<(Rect, usize)>, +} + +/// What to do after an event. +enum Action +{ + None, + Quit, + Play(Move), + Revert(usize), } -impl Default for GameHandler +fn side_name(turn: Turn) -> &'static str { - fn default() -> Self + match turn { - Self - { - board: GameBoard::default() - } + Turn::Sheep => "Sheep", + Turn::Tiger => "Tigers", } } impl GameHandler { - // fn play_move(&mut self, player: &mut T) - // { - // let m = player.pick_move(self.board); - // self.board.make_move(m).unwrap(); - // } + /// The current board. + pub fn board(&self) -> GameBoard + { + self.history.last().map_or(self.start, |&(_, board)| board) + } + + /// The side to move. Sheep move first. + pub fn turn(&self) -> Turn + { + if self.history.len().is_multiple_of(2) { Turn::Sheep } else { Turn::Tiger } + } + + /// The side that has won, if the game is over. + pub fn winner(&self) -> Option + { + // check_win runs before `turn`'s move, so whoever moved last has won. + self.board().check_win(self.turn()).then(|| self.turn().next_turn()) + } + + /// Play `m` for the side to move. Panics if it is illegal. + pub fn play(&mut self, m: Move) + { + let mut board = self.board(); + board.make_move(m).unwrap(); + self.history.push((m, board)); + } + + /// Go back to the board before move `i` (0-based), dropping it and every move after it. + pub fn revert(&mut self, i: usize) + { + self.history.truncate(i); + } - /// Play until someone wins, with `p1` as the sheep and `p2` as the tigers. Returns the winner. - pub fn play_game(&mut self, p1: &mut T1, p2: &mut T2) -> Turn + /// Play in the terminal with `p1` as the sheep and `p2` as the tigers, until someone wins and + /// the user quits. Returns the winner, or None if the user quit before the game was won. + pub fn play_game(&mut self, p1: &mut T1, p2: &mut T2) -> io::Result> { - let mut turn = Turn::Sheep; - while !self.board.check_win(turn) + let mut terminal = ratatui::init(); + let result = execute!(io::stdout(), EnableMouseCapture) + .and_then(|()| self.run(&mut terminal, [p1, p2])); + let _ = execute!(io::stdout(), DisableMouseCapture); + ratatui::restore(); + result + } + + fn run(&mut self, terminal: &mut DefaultTerminal, mut players: [&mut dyn Player; 2]) -> io::Result> + { + let idx = |turn: Turn| match turn { Turn::Sheep => 0, Turn::Tiger => 1 }; + loop { - let m = match turn + let (board, turn) = (self.board(), self.turn()); + let over = self.winner().is_some(); + players[idx(turn)].update(board, turn); + // Drawn before `pick_move`, so a computer's "thinking" panel is on screen while it searches + terminal.draw(|frame| self.draw(frame, &players))?; + + let player = &mut *players[idx(turn)]; + let action = match (!over).then(|| player.pick_move(board, turn)).flatten() { - Turn::Sheep => p1.pick_move(self.board, turn), - Turn::Tiger => p2.pick_move(self.board, turn), + Some(m) => Action::Play(m), + None => self.handle_event(event::read()?, player, board, turn, over), }; - self.board.make_move(m).unwrap(); - turn = turn.next_turn(); + + match action + { + Action::None => (), + Action::Quit => return Ok(self.winner()), + Action::Play(m) => + { + player.reset(); + self.play(m); + } + Action::Revert(i) => + { + self.revert(i); + players.iter_mut().for_each(|p| p.reset()); + } + } } - // check_win runs before `turn`'s move, so whoever moved last has won. - turn.next_turn() + } + + fn handle_event(&self, ev: Event, player: &mut dyn Player, board: GameBoard, turn: Turn, over: bool) -> Action + { + match ev + { + Event::Key(key) if key.kind == KeyEventKind::Press => + { + let ctrl_c = key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL); + if ctrl_c || matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) + { + return Action::Quit; + } + if over + { + return Action::None; + } + player.handle_input(Input::Key(key), board, turn).map_or(Action::None, Action::Play) + } + Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => + { + let at = (mouse.column, mouse.row).into(); + if let Some(&(_, i)) = self.history_rows.iter().find(|(rect, _)| rect.contains(at)) + { + return Action::Revert(i); + } + match pos_at(self.board_area, mouse.column, mouse.row) + { + Some(pos) if !over => player.handle_input(Input::Click(pos), board, turn).map_or(Action::None, Action::Play), + _ => Action::None, + } + } + _ => Action::None, + } + } + + fn draw(&mut self, frame: &mut Frame, players: &[&mut dyn Player; 2]) + { + let (_, board_h) = BoardView::size(1); + let [top, bottom] = Layout::vertical([Constraint::Min(board_h + 4), Constraint::Length(6)]).areas(frame.area()); + // History rows are like "123. ta5xc3 [↶]", so a fixed narrow pane fits them; the board gets the rest + let [board_pane, history_pane] = Layout::horizontal([Constraint::Min(0), Constraint::Length(20)]).areas(top); + + let winner = self.winner(); + let turn = self.turn(); + let player = &*players[match turn { Turn::Sheep => 0, Turn::Tiger => 1 }]; + + // Board + // Keep in step with `handle_event` + let block = Block::bordered().title(" Board ").title_bottom(" click: select / move · q, Esc: quit "); + let inner = block.inner(board_pane); + frame.render_widget(block, board_pane); + // Leave a cell of margin, then centre the biggest board that fits + let room = inner.inner(Margin::new(1, 1)); + let (board_w, board_h) = BoardView::size(BoardView::scale_to_fit(room.width, room.height)); + let padded = room.centered(Constraint::Length(board_w), Constraint::Length(board_h)); + self.board_area = padded; + let view = BoardView + { + board: self.board(), + highlights: if winner.is_none() { player.highlights() } else { Default::default() }, + last_move: self.history.last().map(|&(m, _)| m), + }; + frame.render_widget(&view, padded); + + // Move history, scrolled to keep the newest move in view + let block = Block::bordered().title(" Moves ").title_bottom(" ↶ reverts "); + let inner = block.inner(history_pane); + frame.render_widget(block, history_pane); + self.history_rows.clear(); + let first = self.history.len().saturating_sub(inner.height as usize); + for (row, (i, (m, _))) in self.history.iter().enumerate().skip(first).enumerate() + { + let rect = Rect { y: inner.y + row as u16, height: 1, ..inner }; + let side = if i % 2 == 0 { Color::White } else { Color::Yellow }; + let line = Line::from(vec![ + Span::styled(format!("{:>3}. ", i + 1), Style::new().fg(Color::DarkGray)), + Span::styled(format!("{:<8}", m.to_string()), Style::new().fg(side)), + Span::styled("[↶]", Style::new().fg(Color::Cyan)), + ]); + frame.render_widget(line, rect); + self.history_rows.push((rect, i)); + } + + // Player output, or the result once the game is over + match winner + { + Some(w) => + { + let block = Block::bordered().title(" Game over "); + let text = vec![ + Line::styled(format!("{} win!", side_name(w)), Style::new().add_modifier(Modifier::BOLD)), + Line::from("Click a move to revert to it, or press q to quit."), + ]; + frame.render_widget(Paragraph::new(text).block(block), bottom); + } + None => + { + let block = Block::bordered().title(format!(" {}: {} ", side_name(turn), player.name())); + let inner = block.inner(bottom); + frame.render_widget(block, bottom); + frame.render_widget(player, inner); + } + } + } +} + +#[cfg(test)] +mod tests +{ + use super::*; + + #[test] + fn turn_alternates_starting_with_sheep() + { + let mut game = GameHandler::default(); + assert_eq!(game.turn(), Turn::Sheep); + game.play(Move::PlaceSheep { pos: (2, 2) }); + assert_eq!(game.turn(), Turn::Tiger); + game.play(Move::MoveTiger { from: (0, 0), to: (1, 0) }); + assert_eq!(game.turn(), Turn::Sheep); + } + + #[test] + fn revert_goes_back_to_before_the_move() + { + let mut game = GameHandler::default(); + let start = game.board(); + game.play(Move::PlaceSheep { pos: (2, 2) }); + let after_first = game.board(); + game.play(Move::MoveTiger { from: (0, 0), to: (1, 0) }); + game.play(Move::PlaceSheep { pos: (3, 3) }); + + game.revert(1); + assert_eq!(game.board(), after_first); + assert_eq!(game.turn(), Turn::Tiger); + assert_eq!(game.history.len(), 1); + + game.revert(0); + assert_eq!(game.board(), start); + assert_eq!(game.turn(), Turn::Sheep); + } + + #[test] + #[should_panic] + fn play_rejects_illegal_moves() + { + GameHandler::default().play(Move::PlaceSheep { pos: (0, 0) }); + } + + use ratatui::{backend::TestBackend, crossterm::event::{MouseEvent, KeyEvent}, Terminal}; + use crate::local_player::LocalPlayer; + + fn draw(game: &mut GameHandler, players: &[&mut dyn Player; 2]) -> Terminal + { + let mut terminal = Terminal::new(TestBackend::new(60, 20)).unwrap(); + terminal.draw(|frame| game.draw(frame, players)).unwrap(); + terminal + } + + fn click(column: u16, row: u16) -> Event + { + Event::Mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column, row, modifiers: KeyModifiers::NONE }) + } + + #[test] + fn clicks_on_the_board_and_history() + { + let mut game = GameHandler::default(); + let (mut sheep, mut tiger) = (LocalPlayer::default(), LocalPlayer::default()); + sheep.update(game.board(), game.turn()); + let terminal = draw(&mut game, &[&mut sheep, &mut tiger]); + let screen = format!("{:?}", terminal.backend().buffer()); + assert!(screen.contains("Sheep: You"), "{screen}"); + assert!(screen.contains("Click an empty point to place a sheep"), "{screen}"); + assert!(screen.contains(" a b c d e"), "{screen}"); + assert!(screen.contains("5 T───·───·───·───T"), "{screen}"); + + // Click c3, the centre point + let area = game.board_area; + let action = game.handle_event(click(area.x + 3 + 8, area.y + 4), &mut sheep, game.board(), game.turn(), false); + let Action::Play(m) = action else { panic!("no move") }; + assert_eq!(m, Move::PlaceSheep { pos: (2, 2) }); + game.play(m); + + let terminal = draw(&mut game, &[&mut sheep, &mut tiger]); + let screen = format!("{:?}", terminal.backend().buffer()); + assert!(screen.contains("1. sc3 [↶]"), "{screen}"); + assert!(screen.contains("Tigers: You"), "{screen}"); + + let (rect, i) = game.history_rows[0]; + let action = game.handle_event(click(rect.x + 2, rect.y), &mut tiger, game.board(), game.turn(), false); + assert!(matches!(action, Action::Revert(0)) && i == 0); + + let q = Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)); + assert!(matches!(game.handle_event(q, &mut tiger, game.board(), game.turn(), false), Action::Quit)); + } + + #[test] + fn board_grows_in_a_big_terminal() + { + let mut game = GameHandler::default(); + game.play(Move::PlaceSheep { pos: (2, 2) }); + let (mut sheep, mut tiger) = (LocalPlayer::default(), LocalPlayer::default()); + let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap(); + terminal.draw(|frame| game.draw(frame, &[&mut sheep, &mut tiger])).unwrap(); + let area = game.board_area; + assert!(area.height > BoardView::size(1).1, "{area:?}"); + let (w, h) = (area.width, area.height); + assert_eq!(BoardView::size(BoardView::scale_to_fit(w, h)), (w, h)); + + // Find the sheep on screen and click it + let buf = terminal.backend().buffer(); + let at = (area.left()..area.right()).flat_map(|x| (area.top()..area.bottom()).map(move |y| (x, y))) + .find(|&(x, y)| buf[(x, y)].symbol() == "S").expect("sheep drawn"); + assert_eq!(pos_at(area, at.0, at.1), Some((2, 2))); + } + + #[test] + fn no_winner_at_the_start() + { + assert_eq!(GameHandler::default().winner(), None); } } -- cgit v1.3.1