You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
martian-chess/src/game.rs

1602 lines
54 KiB
Rust

use crate::prelude::*;
pub(crate) struct GamePlugin;
impl Plugin for GamePlugin {
fn build(&self, app: &mut App) {
app.add_event::<Move>()
.add_event::<Selection>()
.init_state::<TurnState>()
.insert_resource(Score { ..default() })
.add_systems(Startup, setup_board)
.add_systems(OnEnter(GameState::Play), hide_valid_moves)
.add_systems(
Update,
(
manage_state_entities::<GameState>().run_if(state_changed::<GameState>),
undo_move.run_if(just_pressed(KeyCode::KeyU)),
)
)
.add_systems(
Update,
(
update_board
.run_if(on_event::<Move>())
.after(handle_selection),
set_side.run_if(any_component_changed::<BoardIndex>()),
handle_selection.run_if(on_event::<Selection>()),
show_valid_moves.run_if(any_component_added::<Selected>()),
hide_valid_moves.run_if(any_component_removed::<Selected>()),
manage_score.run_if(any_component_added::<Captured>()),
check_endgame.run_if(resource_changed::<Board>),
).run_if(in_state(GameState::Play)),
)
.add_systems(Update,
assert_piece_consistency
.run_if(in_state(GameState::Play))
.run_if(resource_changed::<Score>)
)
.add_systems(Update, reset_game.run_if(in_state(GameState::Restart)))
.add_systems(OnEnter(GameState::Endgame),
(
manage_score,
set_endgame.after(manage_score),
)
)
.add_systems(OnExit(GameState::Endgame), clear_endgame)
.add_systems(
PreUpdate,
asserts::<display3d::Display3d>
.run_if(in_state(DisplayState::Display3d))
.run_if(in_state(GameState::Play)),
)
.add_systems(
PostUpdate,
(debug_board.run_if(in_state(debug::DebugState::Enabled)),),
)
.add_systems(OnEnter(GameState::Restart), handle_restart)
.add_systems(OnEnter(GameState::Quit), handle_quit);
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default, States)]
pub(crate) struct TurnState(pub Side);
impl std::ops::Not for TurnState {
type Output = Self;
fn not(self) -> Self::Output {
let TurnState(side) = self;
TurnState(!side)
}
}
#[derive(Debug, Component, Clone, PartialEq, Copy, Hash)]
pub(crate) enum Piece {
Pawn,
Drone,
Queen,
}
impl Piece {
fn moves<'a>(&self) -> std::slice::Iter<'_, (isize, isize)> {
match self {
Piece::Pawn => [(1, 1), (1, -1), (-1, 1), (-1, -1)].iter(),
Piece::Drone => [
(0, 2),
(0, 1),
(-2, 0),
(-1, 0),
(1, 0),
(2, 0),
(0, -1),
(0, -2),
]
.iter(),
Piece::Queen => [
(-3, 3),
(0, 3),
(3, 3),
(-2, 2),
(0, 2),
(2, 2),
(-1, 1),
(0, 1),
(1, 1),
(-7, 0),
(-6, 0),
(-5, 0),
(-4, 0),
(-3, 0),
(-2, 0),
(-1, 0),
(1, 0),
(2, 0),
(3, 0),
(4, 0),
(5, 0),
(6, 0),
(7, 0),
(-1, -1),
(0, -1),
(1, -1),
(-2, -2),
(0, -2),
(2, -2),
(-3, -3),
(0, -3),
(3, -3),
]
.iter(),
}
}
fn moves_at(&self, from: &BoardIndex) -> HashSet<BoardIndex> {
self.moves().filter_map(|(x, y)| {
let bi = (from.x as isize + x, from.y as isize + y);
// Check if this goes out of bounds, if so exclude from the list of possible moves
(bi.0 <= 7 && bi.0 >= 0 && bi.1 <= 3 && bi.1 >= 0).then_some(BoardIndex { x: bi.0 as usize, y: bi.1 as usize })
})
.collect()
}
fn value(&self) -> usize {
match self {
Piece::Pawn => 1,
Piece::Drone => 2,
Piece::Queen => 3,
}
}
}
pub(crate) fn piece_model_key(piece: Piece, side: Side) -> &'static str {
match (piece, side) {
(Piece::Pawn, Side::A) => "display3d_models_scenes_pawn_red",
(Piece::Pawn, Side::B) => "display3d_models_scenes_pawn_blue",
(Piece::Drone, Side::A) => "display3d_models_scenes_drone_red",
(Piece::Drone, Side::B) => "display3d_models_scenes_drone_blue",
(Piece::Queen, Side::A) => "display3d_models_scenes_queen_red",
(Piece::Queen, Side::B) => "display3d_models_scenes_queen_blue",
}
}
#[derive(Debug, Component, Clone, PartialEq)]
pub(crate) enum Tile {
Dark,
Light,
}
pub(crate) fn tiles() -> impl Iterator<Item = (BoardIndex, Tile)> {
(0..32).map(|i| {
let x = i % 8;
let y = i / 8;
let s = (x % 2) ^ (y % 2);
let index = BoardIndex { x, y };
let tile = if s == 0 { Tile::Dark } else { Tile::Light };
(index, tile)
})
}
#[derive(Debug, Component)]
pub(crate) struct BoardComponent;
#[derive(Debug, Component)]
pub(crate) struct ValidMove;
/// Marker for entity being captured
#[derive(Debug, Component)]
pub(crate) struct BeingCaptured;
/// Marker for component which is captured
#[derive(Debug, Component)]
pub(crate) struct Captured {
pub epoch: usize,
}
#[derive(Debug, Component)]
pub(crate) struct Promoted;
// manually for the type.
impl std::fmt::Display for Piece {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Piece::Queen => write!(f, "@"),
Piece::Drone => write!(f, "^"),
Piece::Pawn => write!(f, "*"),
}
}
}
#[derive(Debug, PartialEq)]
pub(crate) enum GameError {
NullMove,
InvalidIndex,
InvalidMove,
}
/// Tracks the score of each side of the game
#[derive(Debug, Resource, Default)]
pub(crate) struct Score {
score_a: usize,
score_b: usize,
captures_a: usize,
captures_b: usize,
}
impl Score {
pub(crate) fn _get(&self, side: Side) -> usize {
match side {
Side::A => self.score_a,
Side::B => self.score_b,
}
}
pub(crate) fn captures(&self, side: Side) -> usize {
match side {
Side::A => self.captures_a,
Side::B => self.captures_b,
}
}
}
/// The board is setup like this:
/// ```text
/// 0 1 2 3 4 5 6 7
/// +--+--+--+-----+--+--+--+
/// a | | | | l | d| Q| Q|
/// +--+--+--+--l--+--+--+--+
/// b |d |p |p | l | p| d| Q|
/// +--+--+--+--l--+--+--+--+
/// c |Q |d |p | l | p| p| d|
/// +--+--+--+--l--+--+--+--+
/// d |Q |Q |d | l | | | |
/// +--+--+--+-----+--+--+--+
/// ````
#[derive(Debug, Resource)]
pub(crate) struct Board {
inner: Vec<Vec<Option<Piece>>>,
moves: Vec<Move>,
}
#[derive(Debug, Default, Event, Clone)]
pub(crate) struct Move {
pub epoch: usize,
pub piece: Option<Piece>,
pub from: BoardIndex,
pub to: Option<BoardIndex>,
pub move_type: MoveType,
}
#[derive(Debug, Component, PartialEq, Clone, Default, Copy, Eq, Hash)]
pub(crate) struct BoardIndex {
pub x: usize,
pub y: usize,
}
impl std::ops::Add for BoardIndex {
type Output = BoardIndex;
fn add(self, other: Self) -> Self {
BoardIndex {
x: self.x + other.x,
y: self.y + other.y,
}
}
}
impl From<(usize, usize)> for BoardIndex {
fn from((x, y): (usize, usize)) -> BoardIndex {
BoardIndex { x, y }
}
}
#[derive(Debug, PartialEq, Clone, Default)]
pub(crate) enum MoveType {
Valid,
#[default]
Invalid,
Capture,
Promotion(Piece),
}
#[derive(Debug, Component, PartialEq, Clone, Copy, Eq, Hash, Default)]
pub(crate) enum Side {
A,
// HACK: Opening animation starts on B side
#[default]
B,
}
impl std::ops::Not for Side {
type Output = Self;
fn not(self) -> Self::Output {
match self {
Side::A => Side::B,
Side::B => Side::A,
}
}
}
impl Board {
/// Returns the piece at the given location
pub(crate) fn at(&self, BoardIndex { x, y }: BoardIndex) -> Option<&Piece> {
self.inner[y][x].as_ref()
}
fn from_ascii(art: &str) -> Board {
use Piece::*;
let mut inner: Vec<Vec<Option<Piece>>> = vec![
Vec::with_capacity(8),
Vec::with_capacity(8),
Vec::with_capacity(8),
Vec::with_capacity(8),
];
let mut index = BoardIndex { x: 0, y: 3 };
art.chars().for_each(|c| {
match c {
'q' | 'Q' => {
inner[index.y].push(Some(Queen));
}
'd' | 'D' => {
inner[index.y].push(Some(Drone));
}
'p' | 'P' => {
inner[index.y].push(Some(Pawn));
}
'.' | '#' => {
inner[index.y].push(None);
}
'\n' => {
assert_eq!(
inner[index.y].len(),
8,
"Each row must be 8 characters long!"
);
index.y -= 1;
}
_ => {
// Ignore all other characters
}
}
});
Board {
inner,
moves: vec![],
}
}
fn new() -> Board {
Board::from_ascii(
r#".....dqq
dpp..pdq
qdp..ppd
qqd....."#,
)
}
/// Show all pieces on one side of the board
pub(crate) fn on(&self, side: Side) -> Vec<(&Piece, BoardIndex)> {
match side {
Side::A => {
// X: 0..3, Y: 0..3
(0..=3)
.flat_map(|x| {
(0..=3).map(move |y| {
self.at(BoardIndex { x, y })
.map(|p| (p, BoardIndex { x, y }))
})
})
.flatten()
.collect()
}
Side::B => {
// X: 4..7, Y: 0..3
(4..=7)
.flat_map(|x| {
(0..=3).map(move |y| {
self.at(BoardIndex { x, y })
.map(|p| (p, BoardIndex { x, y }))
})
})
.flatten()
.collect()
}
}
}
/// Returns a list of all pieces on the board with their location
pub(crate) fn pieces(&self) -> Vec<(BoardIndex, Piece)> {
self.inner
.iter()
.enumerate()
.flat_map(|(y, nested)| {
nested
.iter()
.enumerate()
.filter_map(move |(x, p)| p.as_ref().map(|val| (BoardIndex { x, y }, *val)))
})
.collect()
}
/// Moves a piece from -> to
pub(crate) fn move_piece(
&mut self,
from: BoardIndex,
to: BoardIndex,
) -> Result<Vec<Move>, GameError> {
if from == to {
Err(GameError::NullMove)
} else {
match self.at(from) {
Some(from_piece) => {
let move_type = self.move_type(from, to);
match move_type {
MoveType::Invalid => {
Err(GameError::InvalidMove)
},
MoveType::Valid | MoveType::Capture | MoveType::Promotion(..) => {
// The current epoch is the last epoch + 1
let epoch = self.current_epoch();
// Local moves vec we can return
let mut moves = vec![];
// If the position we are moving to is occupied, capture the removal in the ledger
if self.inner[to.y][to.x].is_some() {
moves.push(Move {
epoch,
piece: self.at(to).copied(),
from: to,
to: None,
move_type: move_type.clone(),
});
}
// Capture the intended move in the moves ledger
moves.push(Move {
epoch,
piece: self.at(from).copied(),
from,
to: Some(to),
move_type: move_type.clone(),
});
self.inner[to.y][to.x] = match move_type {
MoveType::Promotion(_) => {
match (self.at(from), self.at(to)) {
(Some(Piece::Pawn), Some(Piece::Pawn)) => {
Some(Piece::Drone)
},
(Some(Piece::Pawn), Some(Piece::Drone)) | (Some(Piece::Drone), Some(Piece::Pawn)) => {
Some(Piece::Queen)
},
_ => panic!("Merges can only happen between pawn+pawn or pawn+drone!")
}
},
_ => Some(*from_piece)
};
self.inner[from.y][from.x] = None;
self.moves.extend(moves.clone());
Ok(moves)
}
}
}
None => Err(GameError::NullMove),
}
}
}
/// Undo the last move
fn undo_move(&mut self) -> Vec<Move> {
let latest_epoch = self.current_epoch() - 1;
let mut moves = vec![];
while let Some(last) = self.moves.last() {
if last.epoch == latest_epoch {
let last = self.moves.pop().unwrap();
self.inner[last.from.y][last.from.x] = last.piece;
if let Some(to) = last.to {
self.inner[to.y][to.x] = None;
}
moves.push(last);
} else {
break;
}
}
debug!("Remaining moves: {:?}", self.moves);
return moves;
}
/// Returns the Side of a piece
pub(crate) fn side(BoardIndex { x, .. }: BoardIndex) -> Result<Side, GameError> {
match x {
0..=3 => Ok(Side::A),
4..=7 => Ok(Side::B),
_ => Err(GameError::InvalidIndex),
}
}
// Returns a list of indexes between two points, excluding the start and end
fn line(&self, from: BoardIndex, to: BoardIndex) -> impl Iterator<Item = BoardIndex> {
let mut curr = from;
// Longest possible move is 10, so we create a generator over 12
(0..12)
.map(move |_| {
let x = if curr.x > to.x {
curr.x.saturating_sub(1)
} else if curr.x < to.x {
curr.x.saturating_add(1)
} else {
to.x
};
let y = if curr.y > to.y {
curr.y.saturating_sub(1)
} else if curr.y < to.y {
curr.y.saturating_add(1)
} else {
to.y
};
curr = BoardIndex { x, y };
(curr != to).then_some(curr)
})
.filter_map(|bi| bi)
}
/// Determine given a piece, a to, and a from, what type of move this would be
pub(crate) fn move_type(
&self,
from: BoardIndex,
to: BoardIndex,
) -> MoveType {
self.line(from, to)
.all(|board_index| self.at(board_index).is_none())
.then(|| {
self.at(from).map(|piece| {
// Given that the side does not have a queen||drone
// And the piece is a drone||pawn
// We can do field promotions
let side = Board::side(from).expect("Piece has valid index");
let side_has_queen = self.on(side).iter().any(|(piece, _)| **piece == Piece::Queen);
let side_has_drone = self.on(side).iter().any(|(piece, _)| **piece == Piece::Drone);
// Iterate over the piece's moves
piece
.moves_at(&from)
.iter()
// Find if the given `to` move is one of those
.find(|idx| to == **idx)
// Determine if this is valid/legal in this situation
.and_then(|_| {
let dest_at = self.at(to);
let curr_side = Board::side(from).unwrap();
let dest_side = Board::side(to).unwrap();
match (curr_side, dest_side) {
(Side::A, Side::A) | (Side::B, Side::B) => {
match dest_at {
// Cannot move on top of a friendly
Some(to_piece) => {
match (piece, to_piece) {
(Piece::Pawn, Piece::Pawn) => {
(!side_has_drone).then_some(MoveType::Promotion(Piece::Drone))
}
(Piece::Drone, Piece::Pawn) | (Piece::Pawn, Piece::Drone) => {
(!side_has_queen).then_some(MoveType::Promotion(Piece::Queen))
},
_ => {
Some(MoveType::Invalid)
}
}
},
// Any other spot is valid
None => {
Some(MoveType::Valid)
}
}
}
// Check for moving across the canal
(Side::A, Side::B) | (Side::B, Side::A) => {
match dest_at {
Some(_) => {
Some(MoveType::Capture)
}
None => {
debug!("Last move: {:?}", self.moves.last());
// move is valid if it does not un-do the previous move for this piece
match self.moves.last() {
Some(previous) => {
let is_undo = previous.from == to && previous.to == Some(from);
(!is_undo).then_some(MoveType::Valid)
}
// First move in the game, this is valid (and impossible)
None => {
// the move is valid
Some(MoveType::Valid)
}
}
}
}
}
}
})
})
}).flatten().flatten().or(Some(MoveType::Invalid)).unwrap()
}
/// Returns the possible moves the piece at this tile can make.
pub(crate) fn valid_moves(&self, current_board_index: BoardIndex) -> HashSet<BoardIndex> {
if let Some(piece) = self.at(current_board_index) {
piece.moves_at(&current_board_index).iter().filter_map(|move_index| {
// Get the move type (or none if totally invalid)
let result = self.move_type(current_board_index, *move_index);
match result {
MoveType::Invalid => None,
MoveType::Capture | MoveType::Promotion(..) | MoveType::Valid => {
Some(*move_index)
},
}
})
.collect()
} else {
HashSet::new()
}
}
pub(crate) fn current_epoch(&self) -> usize {
self.moves.last().unwrap_or(&Move { ..default() }).epoch + 1
}
}
mod test {
#[test]
fn pawn_simple_moves() {
use super::*;
let board = Board::from_ascii(
r#"........
.#.#....
..p.....
.#.#...."#,
);
let given = board.valid_moves((2, 1).into());
let expected: HashSet<BoardIndex> =
HashSet::from([(1, 0).into(), (3, 0).into(), (1, 2).into(), (3, 2).into()]);
assert_eq!(expected, given, "Basic pawn moves");
}
#[test]
fn drone_simple_moves() {
use super::*;
let board = Board::from_ascii(
r#"..#.....
..#.....
##d##...
..#....."#,
);
let given = board.valid_moves((2, 1).into());
let expected: HashSet<BoardIndex> = HashSet::from([
(2, 0).into(),
(2, 2).into(),
(2, 3).into(),
(0, 1).into(),
(1, 1).into(),
(3, 1).into(),
(4, 1).into(),
]);
assert_eq!(expected, given, "Basic drone moves");
}
#[test]
fn queen_simple_moves() {
use super::*;
let board = Board::from_ascii(
r#"...###..
####q###
...###..
..#.#.#."#,
);
let given = board.valid_moves((4, 2).into());
let expected: HashSet<BoardIndex> = HashSet::from([
(0, 2).into(),
(1, 2).into(),
(2, 0).into(),
(2, 2).into(),
(3, 1).into(),
(3, 2).into(),
(3, 3).into(),
(4, 0).into(),
(4, 1).into(),
(4, 3).into(),
(5, 1).into(),
(5, 2).into(),
(5, 3).into(),
(6, 0).into(),
(6, 2).into(),
(7, 2).into(),
]);
assert_eq!(expected, Piece::Queen.moves_at(&(4, 2).into()), "Generated queen moves");
assert_eq!(expected, given, "Basic queen moves");
}
#[test]
fn empty_moves() {
use super::*;
let board = Board::from_ascii(
r#"........
.p....q.
........
...d...."#,
);
let given = board.valid_moves((2, 1).into());
let expected: HashSet<BoardIndex> = HashSet::from([]);
assert_eq!(expected, given, "Empty moves");
}
#[test]
fn moves_multi_step() {
use super::*;
let mut board = Board::from_ascii(
r#"........
........
....p...
...p...."#,
);
// First assert that the piece can move
{
// The left side pawn can move to capture the right side pawn
let given = board.valid_moves((3, 0).into());
let expected: HashSet<BoardIndex> = HashSet::from([(4, 1).into(), (2, 1).into()]);
assert_eq!(expected, given, "Sanity check");
}
// Next move the right side pawn
let _ = board.move_piece((4, 1).into(), (3, 2).into());
// Assert the spot is now empty
assert_eq!(board.at((4, 1).into()), None);
// Now assert that the left side pawn can move into the spot taken by the right side pawn
{
// The left side pawn can move to capture the right side pawn
let given = board.valid_moves((3, 0).into());
let expected: HashSet<BoardIndex> = HashSet::from([(4, 1).into(), (2, 1).into()]);
assert_eq!(expected, given, "Second step move");
}
}
/// When a piece is blocked on all sides by friendly, cannot move
#[test]
fn blocking_friendly() {
use super::*;
let board = Board::from_ascii(
r#"p.......
..dp....
.pqp....
.ppp...."#,
);
// Check queen
{
let given = board.valid_moves((2, 1).into());
let expected: HashSet<BoardIndex> = HashSet::from([(1, 2).into()]);
assert_eq!(expected, given, "Mostly surrounded queen, some moves");
}
// Check pawn
{
let given = board.valid_moves((3, 1).into());
let expected: HashSet<BoardIndex> = HashSet::from([(4, 0).into(), (4, 2).into()]);
assert_eq!(expected, given, "Partially blocked pawn, some moves");
}
// Check drone
{
let given = board.valid_moves((2, 2).into());
let expected: HashSet<BoardIndex> =
HashSet::from([(0, 2).into(), (1, 2).into(), (2, 3).into()]);
assert_eq!(expected, given, "Partially blocked drone, some moves");
}
}
/// When an enemy is on one side, can move to capture just that piece
#[test]
fn case_01() {
use super::*;
let board = Board::from_ascii(
r#"........
........
ppp.....
pq..p..p"#,
);
{
let given = board.valid_moves((1, 0).into());
let expected: HashSet<BoardIndex> =
HashSet::from([(2, 0).into(), (3, 0).into(), (4, 0).into()]);
assert_eq!(
expected, given,
"Mostly blocked queen, moves include captures"
);
}
// Pawn can merge with pawn
{
let given = board.valid_moves((0, 0).into());
let expected: HashSet<BoardIndex> = HashSet::from([(1, 1).into()]);
assert_eq!(expected, given, "Pawn can merge to make a drone");
let move_type = board.move_type((0, 0).into(), (1, 1).into());
assert_eq!(MoveType::Promotion(Piece::Drone), move_type);
}
// Pawn cannot merge with queen
{
let given = board.valid_moves((2, 1).into());
let expected: HashSet<BoardIndex> = HashSet::from([(3, 0).into(), (3, 2).into(), (1, 2).into()]);
assert_eq!(expected, given, "Pawn cannot merge with queen");
}
}
/// Can move up to but not over friendlies
#[test]
fn case_02() {
use super::*;
let board = Board::from_ascii(
r#"..#...qq
dpp#d#d.
qd##qppd
qq.###.."#,
);
{
let given = board.valid_moves((4, 1).into());
let expected: HashSet<BoardIndex> =
HashSet::from([
(1, 1).into(),
(2, 1).into(),
(2, 3).into(),
(3, 0).into(),
(3, 1).into(),
(3, 2).into(),
(4, 0).into(),
(5, 0).into(),
(5, 2).into(),
]);
assert_eq!(
expected, given,
"Mostly blocked queen, moves include captures"
);
}
}
#[test]
fn case_03() {
use super::*;
let board = Board::from_ascii(
r#".p.....q
dp.p.pdq
qd..dppd
qqdq...."#,
);
{
let given = board.valid_moves((0, 0).into());
let expected: HashSet<BoardIndex> = HashSet::from([]);
assert_eq!(expected, given);
}
{
let given = board.valid_moves((1, 0).into());
let expected: HashSet<BoardIndex> =
HashSet::from([
(2, 1).into()
]);
assert_eq!(expected, given);
}
{
let given = board.valid_moves((2, 0).into());
let expected: HashSet<BoardIndex> =
HashSet::from([
(2, 1).into(),
(2, 2).into(),
]);
assert_eq!(expected, given);
}
}
// A strange failure mode in game which I suspect comes from
// a disconnect between board logic and display plumbing
#[test]
fn case_04() {
use super::*;
let mut board = Board::from_ascii(
r#".....dqq
dpp..pdq
qdp....d
qqd....."#,
);
{
board.move_piece((5, 3).into(), (4, 3).into()).unwrap();
board.move_piece((2, 2).into(), (1, 3).into()).unwrap();
let expected = Board::from_ascii(
r#".p..d#qq
dp...pdq
qdp....d
qqd....."#,
);
assert_eq!(expected.inner, board.inner);
}
{
let given = board.valid_moves((6, 3).into());
let expected: HashSet<BoardIndex> = HashSet::from([(5, 3).into()]);
assert_eq!(expected, given);
}
}
#[test]
fn test_piece_moves_at() {
use super::*;
let given = Piece::Drone.moves_at(&(4, 1).into());
let expected: HashSet<BoardIndex> = HashSet::from([
(4, 0).into(), (2, 1).into(), (3, 1).into(), (5, 1).into(), (6, 1).into(), (4, 2).into(), (4, 3).into(),
]);
assert_eq!(expected, given, "Drone moves at");
}
#[test]
fn test_capture_01() {
use super::*;
let board = Board::from_ascii(
r#"...p....
........
.ppd.q..
........"#,
);
// Drone can move in all expected ways
{
let expected: HashSet<BoardIndex> = HashSet::from([
(2, 1).into(),
(3, 0).into(),
(3, 2).into(),
(3, 3).into(),
(4, 1).into(),
(5, 1).into(),
]);
let actual = board.valid_moves((3, 1).into());
assert_eq!(expected, actual);
let capture_move = board.move_type((3, 1).into(), (5, 1).into());
assert_eq!(MoveType::Capture, capture_move);
let merge_move = board.move_type((3, 1).into(), (2, 1).into());
assert_eq!(MoveType::Promotion(Piece::Queen), merge_move);
let long_merge_move = board.move_type((3, 1).into(), (3, 3).into());
assert_eq!(MoveType::Promotion(Piece::Queen), long_merge_move);
let jump_move = board.move_type((3, 1).into(), (1, 1).into());
assert_eq!(MoveType::Invalid, jump_move);
}
}
#[test]
fn test_capture_02() {
use super::*;
let board = Board::from_ascii(
r#"........
..p.p...
...p....
........"#,
);
{
// All normal moves for pawn are valid
let given = board.valid_moves((3, 1).into());
let expected: HashSet<BoardIndex> = HashSet::from([(2, 0).into(), (4, 0).into(), (2, 2).into(), (4, 2).into()]);
assert_eq!(expected, given);
// Pawn + Pawn on same side = Promotion
let merge_move = board.move_type((3, 1).into(), (2, 2).into());
assert_eq!(MoveType::Promotion(Piece::Drone), merge_move);
// Pawn + Pawn on other side = Capture
let capture_move = board.move_type((3, 1).into(), (4, 2).into());
assert_eq!(MoveType::Capture, capture_move);
// Pawn + Empty = Move
let place_move = board.move_type((3, 1).into(), (2, 0).into());
assert_eq!(MoveType::Valid, place_move);
// Pawn + Invalid Empty = Invalid
let invalid_move = board.move_type((3, 1).into(), (7, 7).into());
assert_eq!(MoveType::Invalid, invalid_move);
}
}
}
impl std::fmt::Display for Board {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
self.inner.iter().rev().for_each(|row| {
let _ = writeln!(f, "+--+--+--+--+--+--+--+--+");
let _ = write!(f, "|");
row.iter().for_each(|piece| {
let _ = match piece {
Some(p) => write!(f, "{} |", p),
None => write!(f, " |"),
};
});
let _ = writeln!(f);
});
let _ = write!(f, "+--+--+--+--+--+--+--+--+");
Ok(())
}
}
/// Marker component for currently selected entities
#[derive(Debug, Default, Component)]
pub(crate) struct Selected;
/// Marker component for selectable entities
#[derive(Debug, Default, Component)]
pub(crate) struct Selectable;
/// Event for selecting board indexes for Moves
#[derive(Debug, Default, Event, Clone)]
pub(crate) struct Selection(pub BoardIndex);
fn setup_board(mut commands: Commands) {
commands.insert_resource(Board::new());
}
fn debug_board(board: Res<Board>, mut debug_info: ResMut<debug::DebugInfo>) {
debug_info.set("board".into(), format!("\n{}", *board));
}
/// Update this method to use a diff between the board and the state of the 2d/3d worlds
pub(crate) fn update_board(
mut audio_events: EventWriter<AudioEvent>,
mut events: EventReader<Move>,
mut pieces: Query<(Entity, &mut BoardIndex), With<Piece>>,
selected: Query<Entity, With<Selected>>,
mut commands: Commands,
mut played: Local<bool>,
curr_state: Res<State<TurnState>>,
mut next_state: ResMut<NextState<TurnState>>,
) {
// Each move event we get
events.read().for_each(|Move { from, to, move_type, .. }| {
// Iterate over all pieces
pieces.iter_mut().for_each(|(entity, mut index)| {
// If the current index is the 'from' for the move
// All moves cover From -> To (captures and merges have to: None)
if *index == *from {
match to {
// If we are moving on the board...
Some(to_idx) => {
debug!("Moving piece {:?} {:?} -> {:?}", entity, from, to_idx);
// Update the piece's index
*index = *to_idx;
// Play audio sfx
if !(*played) {
audio_events.send(audio::AudioEvent::PutDown);
audio_events.send(audio::AudioEvent::StopIdle);
*played = true;
}
if *from != *to_idx {
match move_type {
MoveType::Promotion(piece) => {
commands
.entity(entity)
.insert(*piece);
}
_ => ()
}
let ns = !*curr_state.get();
debug!("Piece moved, switching sides: {:?}", ns);
next_state.set(ns);
}
}
// We are moving off the board (e.g,. capture or promotion/merge)
None => {
match move_type {
MoveType::Capture => {
debug!("Capturing piece {:?}", entity);
commands
.entity(entity)
.remove::<BoardIndex>()
.insert(BeingCaptured);
audio_events.send(AudioEvent::Captured);
},
MoveType::Promotion(..) => {
commands
.entity(entity)
.remove::<BoardIndex>()
.insert((Promoted, Visibility::Hidden));
},
_ => {
panic!("How did you do this!?");
}
}
}
}
}
});
selected.iter().for_each(|entity| {
debug!("De-selecting selected piece {:?}", entity);
commands.entity(entity).remove::<Selected>();
});
});
*played = false;
}
#[derive(Debug, Component)]
struct Endgame;
fn check_endgame(board: Res<Board>, mut next_state: ResMut<NextState<GameState>>) {
if board.on(Side::A).is_empty() || board.on(Side::B).is_empty() {
warn!("The game is over!");
next_state.set(GameState::Endgame);
}
}
fn set_endgame(
score: Res<Score>,
mut commands: Commands,
tweaks_file: Res<tweak::GameTweaks>,
tweaks: Res<Assets<tweak::Tweaks>>,
ui_font: Res<ui::UiFont>,
) {
let tweak = tweaks.get(tweaks_file.handle.clone()).expect("Load tweaks");
let button_handle = tweak.get_handle::<Image>("buttons_image_resting").unwrap();
let font_handle = tweak.get_handle::<Font>("buttons_font").unwrap();
commands
.spawn((
Endgame,
NodeBundle {
style: Style {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
flex_direction: FlexDirection::Column,
position_type: PositionType::Absolute,
..default()
},
background_color: Color::NONE.into(),
visibility: Visibility::Inherited,
..default()
},
))
.with_children(|parent| {
parent.spawn(TextBundle::from_section(
"SCORE",
TextStyle {
font_size: 48.0,
color: Color::ORANGE_RED,
font: ui_font.handle.clone(),
},
));
parent.spawn(TextBundle::from_section(
format!("BLUE {}", score.score_b),
TextStyle {
font_size: 32.0,
color: Color::BLUE,
font: ui_font.handle.clone(),
},
));
parent.spawn(TextBundle::from_section(
format!("RED {}", score.score_a),
TextStyle {
font_size: 32.0,
color: Color::RED,
font: ui_font.handle.clone(),
},
));
parent
.spawn((
Endgame,
NodeBundle {
style: Style {
flex_direction: FlexDirection::Row,
..default()
},
..default()
},
))
.with_children(|parent| {
parent
.spawn((
ButtonAction(GameState::Restart),
ButtonAction(MenuState::Off),
ButtonBundle {
style: Style {
padding: UiRect::all(Val::Px(5.0)),
margin: UiRect::all(Val::Px(5.0)),
..default()
},
image: UiImage {
texture: button_handle.clone(),
..default()
},
..default()
},
))
.with_children(|parent| {
parent.spawn(TextBundle {
text: Text {
sections: vec![TextSection {
value: "N e w G a m e".into(),
style: TextStyle {
color: Color::WHITE,
font_size: 12.0,
font: font_handle.clone(),
},
}],
..default()
},
style: Style {
margin: UiRect::all(Val::Px(10.0)),
..default()
},
..default()
});
});
// Quit button
parent
.spawn((
ButtonAction(MenuState::Off),
ButtonAction(GameState::Quit),
ButtonBundle {
style: Style {
padding: UiRect::all(Val::Px(5.0)),
margin: UiRect::all(Val::Px(5.0)),
..default()
},
image: UiImage {
texture: button_handle.clone(),
..default()
},
..default()
},
))
.with_children(|parent| {
parent.spawn(TextBundle {
text: Text {
sections: vec![TextSection {
value: "Q u i t".into(),
style: TextStyle {
color: Color::WHITE,
font_size: 12.0,
font: font_handle.clone(),
},
}],
..default()
},
style: Style {
margin: UiRect::all(Val::Px(10.0)),
..default()
},
..default()
});
});
});
});
}
fn clear_endgame(query: Query<Entity, With<Endgame>>, mut commands: Commands) {
query.iter().for_each(|e| {
commands.entity(e).despawn_recursive();
})
}
/// We only track 3D (hack, for now) to prevent duplicates
/// TODO: We can calculate this declaratively:
/// * All pieces without a BoardIndex are "captured"
/// * All captured pieces have their captured side preserved
/// We can iterate over these pieces and calculate the score on the fly
fn manage_score(
query: Query<(&Side, &Piece), (Or<(With<Captured>, With<BeingCaptured>)>, With<display3d::Display3d>)>,
mut debug_info: ResMut<debug::DebugInfo>,
mut score: ResMut<Score>,
) {
// Calculate number of captures performed by either side
score.captures_a = query.iter().filter(|(s, _)| **s == Side::B).count();
score.captures_b = query.iter().filter(|(s, _)| **s == Side::A).count();
// Calculate score based on the piece values
score.score_a = query.iter().filter(|(s, _)| **s == Side::B).fold(0, |acc, (_, piece)| acc + piece.value());
score.score_b = query.iter().filter(|(s, _)| **s == Side::A).fold(0, |acc, (_, piece)| acc + piece.value());
// Debug this for good measure
debug_info.set("score".into(), format!("A:{}|B:{}", score.score_a, score.score_b));
}
pub(crate) fn set_side(
mut events: Query<(&mut Side, &BoardIndex), Or<(Changed<BoardIndex>, Added<BoardIndex>)>>,
) {
events
.iter_mut()
.for_each(|(mut side, idx)| match Board::side(*idx) {
Ok(s) => {
debug!("Set side event {:?} {:?} -> {:?}", idx, side, s);
if *side != s {
*side = s
}
}
Err(e) => warn!("{:?}", e),
});
}
// TODO: Handle 3d Pickup (Not showing hover animation, but still selected?)
// TODO: Handle cancel move (currently 2d just drops it in place)
fn handle_selection(
mut selections: EventReader<Selection>,
mut move_events: EventWriter<Move>,
selected: Query<(Entity, &BoardIndex), (With<Selected>, With<Piece>)>,
pieces: Query<(Entity, &BoardIndex), (With<Selectable>, Without<Selected>, With<Piece>)>,
mut board: ResMut<Board>,
mut commands: Commands,
mut audio_event: EventWriter<AudioEvent>,
mut done: Local<bool>, // Tracks if moves/audio submitted already even if multiple pieces (2d/3d) are moved.
mut latest: Local<Option<BoardIndex>>, // Tracks the last one worked on
) {
selections.read().for_each(|Selection(index)| {
// Skip indexes already processed
if Some(*index) != *latest {
// Set the latest index to the current index
*latest = Some(*index);
// Reset the "done" marker
*done = false;
// There are no currently selected entities
// Mark the piece at this index as selected
if selected.is_empty() {
pieces
.iter()
.filter(|(_, this_index)| *this_index == index)
.for_each(|(piece, piece_index)| {
debug!("Selecting {:?} at {:?}", piece, piece_index);
commands.entity(piece).insert(Selected);
if !(*done) {
audio_event.send(audio::AudioEvent::PickUp);
audio_event.send(AudioEvent::Idle);
*done = true;
}
});
}
// There is a currently selected entity, so submit moves
else {
assert!(
selected.iter().len() <= 2,
"There are too many pieces selected!"
);
selected.iter().for_each(|(_, current_index)| {
match board.move_piece(*current_index, *index) {
Ok(moves) => {
// De-select the piece
debug!("Applying moves {:?}", moves);
if !(*done) {
moves.iter().for_each(|m| {
move_events.send(m.clone());
});
*done = true;
}
}
Err(GameError::NullMove) => debug!("Null move!"),
Err(GameError::InvalidIndex) | Err(GameError::InvalidMove) => {
debug!("Invalid index/move!");
if !(*done) {
audio_event.send(AudioEvent::Invalid);
*done = true;
}
}
}
});
}
}
});
*done = false;
*latest = None;
}
/// Panics if more than two pieces are selected at a time
fn asserts<T: Component>(
selected_pieces: Query<Entity, (With<Piece>, With<T>, With<Selected>)>,
selected_tiles: Query<Entity, (With<Tile>, With<T>, With<Selected>)>,
cameras: Query<Entity, (With<Camera>, With<T>)>,
) {
if selected_pieces.iter().len() > 2 {
selected_pieces.iter().for_each(|e| {
debug!("Too many pieces selected, one of which is: {:?}", e);
});
panic!(
"More than two pieces selected {:?}",
selected_pieces.iter().len()
);
}
if selected_tiles.iter().len() > 2 {
panic!(
"More than two tiles selected {:?}",
selected_tiles.iter().len()
);
}
if cameras.iter().len() != 1 {
panic!(
"There should be 1 cameras in this state, but there is only {}",
cameras.iter().len()
);
}
}
/// Spawn "Valid move" indicators when a piece is selected
/// Another system registers these new entities and associates the correct models and plays animations.
fn show_valid_moves(
events: Query<&BoardIndex, (With<Piece>, Added<Selected>)>,
board: Res<Board>,
mut indicators: Query<(&BoardIndex, &mut Visibility), With<ValidMove>>,
) {
// When a piece is selected
events.iter().for_each(|idx| {
// Iterate over all ValidMove entities
// For each one with a ValidMove index, make it visible
let valid_moves = board.valid_moves(*idx);
debug!("Showing valid moves for {:?}: {:?}", idx, valid_moves);
indicators.iter_mut().for_each(|(i, mut vis)| {
if valid_moves.contains(i) {
*vis = Visibility::Inherited;
} else {
*vis = Visibility::Hidden;
}
});
});
}
/// Hide "Valid Move" indicators when a piece is de-selected
fn hide_valid_moves(mut indicators: Query<&mut Visibility, With<ValidMove>>) {
indicators.iter_mut().for_each(|mut visibility| {
*visibility = Visibility::Hidden;
});
}
// Resets the board to the "stock" setup
// Somewhat conflicts with board initialization!
fn reset_game(
pieces: Query<Entity, With<Piece>>,
mut board: ResMut<Board>,
mut score: ResMut<Score>,
mut commands: Commands,
mut turn_state: ResMut<NextState<TurnState>>,
) {
// Setup the board
*board = Board::new();
// Reset the score
*score = Score { ..default() };
// Reset to starting side
turn_state.set(TurnState(Side::B));
// Move all pieces to their correct spots
pieces
.iter()
.zip(board.pieces().iter())
.for_each(|(e, (i, p))| {
commands.entity(e)
.insert((*i, *p, Visibility::Inherited))
.remove::<(BeingCaptured, Captured, Promoted)>();
});
}
/// Very simple system to handle the "Quit" state (shutdown the game)
fn handle_quit(mut app_exit_events: EventWriter<AppExit>) {
app_exit_events.send(AppExit);
}
fn handle_restart(mut game_state: ResMut<NextState<GameState>>) {
game_state.set(GameState::Play);
}
fn assert_piece_consistency(
active: Query<Entity, (With<Piece>, With<BoardIndex>)>,
being_captured: Query<Entity, (With<Piece>, With<BeingCaptured>)>,
captured: Query<Entity, (With<Piece>, With<Captured>)>,
) {
let active_count = active.iter().len();
let being_captured_count = being_captured.iter().len();
let captured_count = captured.iter().len();
debug!("Active: {} | being captured: {} | captured: {}", active_count, being_captured_count, captured_count);
let total_count = active_count + being_captured_count + captured_count;
assert_eq!(total_count, 18, "Pieces does does not add up!");
}
// When user presses 'u' last move is undone
fn undo_move(
mut board: ResMut<Board>,
mut active_pieces: Query<&mut BoardIndex, With<Piece>>,
captured_pieces: Query<(Entity, &Captured), With<Piece>>,
turn: Res<State<TurnState>>,
mut next_turn: ResMut<NextState<TurnState>>,
mut commands: Commands,
) {
// Keep track of the current side in case we need to go back
let mut side = turn.get().0;
board
.undo_move()
.iter()
.for_each(|Move { epoch, from, to, .. }| {
// If we have any moves to do, go back to the opposite side
if side == turn.get().0 {
side = !turn.get().0;
}
debug!("Reverting move {:?} {:?} -> {:?}", epoch, from, to);
match to { Some(to_idx) => {
debug!("Moving piece back from {:?}", to_idx);
// Find piece currently at "to_idx" and update it's position to "from"
active_pieces
.iter_mut()
.filter(|idx| {
(idx.x, idx.y) == (to_idx.x, to_idx.y)
})
.for_each(|mut idx| {
*idx = *from
});
},
None => {
captured_pieces
.iter()
.find_map(|(entity, captured)| {
(captured.epoch == *epoch).then_some(entity)
})
.iter()
.for_each(|entity| {
commands
.entity(*entity)
.remove::<Captured>()
.insert(*from);
});
}
}
});
// Set the turn state (may be a no-op)
next_turn.set(TurnState(side));
}