use backgammon::prelude::*;
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Terminal,
backend::CrosstermBackend,
buffer::Buffer,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Widget, Wrap},
};
use std::{fmt::Write, io};
struct App {
r#match: Match,
message: String,
input_buffer: String,
}
impl App {
fn new() -> Self {
App {
r#match: Match::new(),
message: "Welcome! Press 'r' to roll the dice and start the game.".to_string(),
input_buffer: String::new(),
}
}
fn current_player(&self) -> Player {
self.r#match.get_player().unwrap_or(Player::Nobody)
}
fn is_active(&self) -> bool {
self.current_player() != Player::Nobody
}
fn parse_move_sequence(&self, input: &str) -> Result<Vec<(Position, Position)>, String> {
let parts: Vec<&str> = input.split_whitespace().collect();
if !parts.len().is_multiple_of(2) {
return Err(
"Move sequence must have pairs of positions (from to from to ...)".to_string(),
);
}
parts
.chunks(2)
.map(|chunk| {
let from = self.parse_position(chunk[0])?;
let to = self.parse_position(chunk[1])?;
Ok((from, to))
})
.collect()
}
fn parse_position(&self, s: &str) -> Result<Position, String> {
match s.to_ascii_lowercase().as_str() {
"b" | "bar" => Ok(Position::Bar),
"o" | "off" => Ok(Position::Off),
_ => s
.parse::<usize>()
.ok()
.filter(|&pos| (1..=24).contains(&pos))
.map(Position::Board)
.ok_or_else(|| {
format!(
"Invalid position '{}'. Use 1-24, 'b' for bar, or 'o' for off",
s
)
}),
}
}
fn format_available_moves(&self, moves: &[Vec<(Position, Position)>]) -> String {
if moves.is_empty() {
return "No available moves".to_string();
}
let mut result = String::from("Available moves:\n");
for (idx, move_seq) in moves.iter().enumerate().take(25) {
let _ = write!(result, " ({}) ", idx + 1);
for (i, (from, to)) in move_seq.iter().enumerate() {
if i > 0 {
result.push_str(" -> ");
}
let _ = write!(result, "{:?} to {:?}", from, to);
}
}
if moves.len() > 25 {
let _ = writeln!(result, " ... and {} more options", moves.len() - 25);
}
result
}
}
struct PointWidget {
index: usize,
checker_count: i8,
is_top: bool,
}
impl Widget for PointWidget {
fn render(self, area: Rect, buf: &mut Buffer) {
let point_style = if self.index.is_multiple_of(2) {
Style::default().bg(Color::Rgb(25, 25, 25))
} else {
Style::default().bg(Color::Rgb(45, 45, 45))
};
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
buf[(x, y)].set_style(point_style);
}
}
let abs_count = self.checker_count.unsigned_abs() as u16;
let checker_style = if self.checker_count > 0 {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::Red)
};
for i in 0..abs_count.min(area.height) {
let y = if self.is_top {
area.top() + i
} else {
area.bottom() - 1 - i
};
buf.set_string(area.left() + 1, y, "●", checker_style.patch(point_style));
}
}
}
struct BarWidget {
p0_count: u8,
p1_count: u8,
}
impl Widget for BarWidget {
fn render(self, area: Rect, buf: &mut Buffer) {
let bar_style = Style::default().bg(Color::Rgb(80, 60, 20));
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
buf[(x, y)].set_style(bar_style);
}
}
let mid_y = area.top() + area.height / 2;
buf.set_string(
area.left(),
mid_y,
"BAR",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
.patch(bar_style),
);
for i in 0..(self.p1_count as u16).min(mid_y - area.top()) {
buf.set_string(
area.left() + 1,
area.top() + i,
"●",
Style::default().fg(Color::Red).patch(bar_style),
);
}
for i in 0..(self.p0_count as u16).min(area.bottom() - mid_y - 1) {
buf.set_string(
area.left() + 1,
area.bottom() - 1 - i,
"●",
Style::default().fg(Color::Cyan).patch(bar_style),
);
}
}
}
struct BoardWidget<'a> {
display: &'a BoardDisplay,
perspective: Player,
}
fn horizontal_split(row_area: Rect) -> std::rc::Rc<[Rect]> {
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(18),
Constraint::Length(3),
Constraint::Length(18),
Constraint::Length(10),
])
.split(row_area)
}
impl Widget for BoardWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let vertical_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(5),
Constraint::Length(1),
Constraint::Length(5),
Constraint::Length(1),
])
.split(area);
let top_chunks = horizontal_split(vertical_chunks[1]);
let bottom_chunks = horizontal_split(vertical_chunks[3]);
let render_quadrant = |q_area: Rect, indices: &[usize], is_top: bool, buf: &mut Buffer| {
let p_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(3); 6])
.split(q_area);
for (i, &idx) in indices.iter().enumerate() {
PointWidget {
index: idx,
checker_count: self.display.board[idx],
is_top,
}
.render(p_chunks[i], buf);
}
};
render_quadrant(top_chunks[0], &[12, 13, 14, 15, 16, 17], true, buf);
render_quadrant(top_chunks[2], &[18, 19, 20, 21, 22, 23], true, buf);
render_quadrant(bottom_chunks[0], &[11, 10, 9, 8, 7, 6], false, buf);
render_quadrant(bottom_chunks[2], &[5, 4, 3, 2, 1, 0], false, buf);
let bar_area = Rect::new(top_chunks[1].x, top_chunks[1].y, 3, 11);
BarWidget {
p0_count: self.display.bar.0,
p1_count: self.display.bar.1,
}
.render(bar_area, buf);
let num_style = Style::default().fg(Color::Yellow);
let get_num = |idx: usize| {
if self.perspective == Player::Player1 {
24 - idx
} else {
idx + 1
}
};
let top_nums = horizontal_split(vertical_chunks[0]);
let bottom_nums = horizontal_split(vertical_chunks[4]);
let render_numbers = |area: Rect, indices: &[usize], buf: &mut Buffer| {
for (i, &idx) in indices.iter().enumerate() {
buf.set_string(
area.x + (i as u16 * 3) + 1,
area.y,
format!("{}", get_num(idx)),
num_style,
);
}
};
render_numbers(top_nums[0], &[12, 13, 14, 15, 16, 17], buf);
render_numbers(top_nums[2], &[18, 19, 20, 21, 22, 23], buf);
render_numbers(bottom_nums[0], &[11, 10, 9, 8, 7, 6], buf);
render_numbers(bottom_nums[2], &[5, 4, 3, 2, 1, 0], buf);
let off_style_p0 = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let off_style_p1 = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
buf.set_string(
top_chunks[3].x + 1,
top_chunks[3].y,
format!("OFF: {}", self.display.off.1),
off_style_p1,
);
buf.set_string(
bottom_chunks[3].x + 1,
bottom_chunks[3].y + 4,
format!("OFF: {}", self.display.off.0),
off_style_p0,
);
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
loop {
terminal.draw(|f| {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(15),
Constraint::Length(5),
Constraint::Min(8),
])
.split(f.area());
let title = Paragraph::new(
"Backgammon Example -- https://crates.io/crates/backgammon by Carlo Strub",
)
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(title, chunks[0]);
let player_perspective = match app.current_player() {
Player::Nobody => Player::Player0,
p => p,
};
let board_display = app.r#match.get_board();
f.render_widget(
BoardWidget {
display: &board_display,
perspective: player_perspective,
},
chunks[1],
);
let dice_values = app.r#match.get_dice();
let current = app.current_player();
let player_style = match current {
Player::Player0 => Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
Player::Player1 => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
_ => Style::default().fg(Color::Yellow),
};
let info_lines = vec![
Line::from(vec![
Span::raw("Current Player: "),
Span::styled(format!("{:?}", current), player_style),
]),
Line::from(format!(
"Dice Rolled: ({}, {})",
dice_values.0, dice_values.1
)),
Line::from(format!(
"Available Dice: {:?}",
app.r#match.dice_available()
)),
];
f.render_widget(
Paragraph::new(info_lines)
.style(Style::default().fg(Color::Yellow))
.block(Block::default().borders(Borders::ALL).title("Game Info")),
chunks[2],
);
let controls = vec![
Line::from(vec![
Span::styled("Controls: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw("'r' = Roll | 'm' = Available Moves | 'q' = Quit"),
]),
Line::from(vec![
Span::styled(
"Move format: ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw("'24 21 8 5' or 'b 20'"),
]),
Line::from(""),
Line::from(Span::styled(
&app.message,
Style::default().fg(Color::Green),
)),
Line::from(format!("Input: {}", app.input_buffer)),
];
f.render_widget(
Paragraph::new(controls)
.block(Block::default().borders(Borders::ALL).title("Messages"))
.wrap(Wrap { trim: true }),
chunks[3],
);
})?;
if event::poll(std::time::Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
match key.code {
KeyCode::Char('q') => break,
KeyCode::Char('m') if app.is_active() => {
let player = app.current_player();
match app.r#match.available_moves(player) {
Ok(moves) if moves.is_empty() => {
app.message = "No available moves. Press 'r' to pass turn.".into();
}
Ok(moves) => {
app.message = app.format_available_moves(&moves);
}
Err(e) => app.message = format!("Error: {:?}", e),
}
}
KeyCode::Char('m') => {
app.message = "Roll the dice first ('r').".into();
}
KeyCode::Char('r') => {
let player = app.current_player();
match app.r#match.roll(player) {
Ok(_) => {
let dice = app.r#match.get_dice();
let current = app.current_player();
let mut msg =
format!("Rolled: ({}, {}). {} plays!", dice.0, dice.1, current);
if let Ok(moves) = app.r#match.available_moves(current)
&& moves.is_empty()
{
msg.push_str("\nNo moves. Press 'r' to pass.");
}
app.message = msg;
app.input_buffer.clear();
}
Err(e) => app.message = format!("Error rolling: {:?}", e),
}
}
KeyCode::Char(c) if app.is_active() => {
app.input_buffer.push(c);
}
KeyCode::Backspace => {
app.input_buffer.pop();
}
KeyCode::Enter if !app.input_buffer.is_empty() && app.is_active() => {
let input = app.input_buffer.clone();
app.input_buffer.clear();
match app.parse_move_sequence(&input) {
Ok(moves) => {
let player = app.current_player();
match app.r#match.move_checkers(player, moves) {
Ok(_) => {
app.message =
format!("Success! Roll for {}.", app.current_player());
}
Err(e) => app.message = format!("Invalid move: {:?}", e),
}
}
Err(e) => app.message = format!("Parse error: {}", e),
}
}
_ => {}
}
}
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}