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.
1292 lines
42 KiB
Rust
1292 lines
42 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>),
|
|
)
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
update_board
|
|
.run_if(on_event::<Move>())
|
|
.after(handle_selection),
|
|
set_side.run_if(any_component_changed::<BoardIndex>()),
|
|
cancel_place.run_if(just_pressed(MouseButton::Right)),
|
|
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>),
|
|
reset_game.run_if(in_state(GameState::Restart)),
|
|
),
|
|
)
|
|
.add_systems(OnEnter(GameState::Endgame), 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()
|
|
}
|
|
}
|
|
|
|
#[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;
|
|
|
|
#[derive(Debug, Component)]
|
|
pub(crate) struct Captured;
|
|
|
|
// 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 {
|
|
a: usize,
|
|
b: usize,
|
|
captures_a: usize,
|
|
captures_b: usize,
|
|
}
|
|
|
|
impl Score {
|
|
fn increment(&mut self, side: Side, piece: Piece) {
|
|
let i = match piece {
|
|
Piece::Queen => 3,
|
|
Piece::Drone => 2,
|
|
Piece::Pawn => 1,
|
|
};
|
|
match side {
|
|
Side::A => {
|
|
self.captures_a += 1;
|
|
self.a += i;
|
|
}
|
|
Side::B => {
|
|
self.captures_b += 1;
|
|
self.b += i;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn _get(&self, side: Side) -> usize {
|
|
match side {
|
|
Side::A => self.a,
|
|
Side::B => self.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 from: BoardIndex,
|
|
pub to: Option<BoardIndex>,
|
|
}
|
|
|
|
/// Enum for the Capture event flow
|
|
#[derive(Debug, Clone, Copy, Resource)]
|
|
pub(crate) enum CaptureFlow {
|
|
// Run the "fade out" animation
|
|
FadeOut(Entity),
|
|
// Put the captured piece next to the board
|
|
Store(Entity),
|
|
// Run the "fade in" animation
|
|
FadeIn(Entity),
|
|
}
|
|
|
|
impl CaptureFlow {
|
|
/// The capture flow so we can move from one "capture flow state" to the next
|
|
/// Fade out, then store, then fade in
|
|
pub(crate) fn next(&self) -> Option<Self> {
|
|
match self {
|
|
Self::FadeOut(e) => Some(Self::Store(*e)),
|
|
Self::Store(e) => Some(Self::FadeIn(*e)),
|
|
Self::FadeIn(_) => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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)]
|
|
pub(crate) enum MoveType {
|
|
Valid,
|
|
Invalid,
|
|
Capture,
|
|
}
|
|
|
|
#[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#"...###..
|
|
####q###
|
|
...###..
|
|
..#.#.#."#,
|
|
// 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) => {
|
|
// Check if this is a valid move for this piece
|
|
if self.valid_moves(from).contains(&to) {
|
|
// 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,
|
|
from: to,
|
|
to: None,
|
|
});
|
|
}
|
|
|
|
// Capture the intended move in the moves ledger
|
|
moves.push(Move {
|
|
epoch,
|
|
from,
|
|
to: Some(to),
|
|
});
|
|
|
|
self.inner[to.y][to.x] = Some(*from_piece);
|
|
self.inner[from.y][from.x] = None;
|
|
|
|
self.moves.extend(moves.clone());
|
|
|
|
Ok(moves)
|
|
} else {
|
|
Err(GameError::InvalidMove)
|
|
}
|
|
}
|
|
None => Err(GameError::NullMove),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
piece: Piece,
|
|
from: BoardIndex,
|
|
to: BoardIndex,
|
|
) -> Option<MoveType> {
|
|
// 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(_) => Some(MoveType::Invalid),
|
|
// Any other spot is valid
|
|
None => {
|
|
// If there is another piece between A and B
|
|
if self
|
|
.line(from, to)
|
|
.any(|board_index| self.at(board_index).is_some())
|
|
{
|
|
// Invalid move, there is a piece between A and B
|
|
Some(MoveType::Invalid)
|
|
} else {
|
|
// Otherwise it's a valid
|
|
Some(MoveType::Valid)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Check for moving across the canal
|
|
(Side::A, Side::B) | (Side::B, Side::A) => {
|
|
match dest_at {
|
|
Some(_) => {
|
|
// If there is another piece between A and B
|
|
if self
|
|
.line(from, to)
|
|
.any(|board_index| self.at(board_index).is_some())
|
|
{
|
|
// Invalid move, there is a piece between A and B
|
|
Some(MoveType::Invalid)
|
|
} else {
|
|
// Otherwise it's a capture
|
|
Some(MoveType::Capture)
|
|
}
|
|
}
|
|
None => {
|
|
// move is valid if it does not un-do the previous move
|
|
match self.moves.last() {
|
|
Some(previous) => {
|
|
// If the last `from` is the current destination
|
|
// The move is not valid
|
|
if previous.from == to {
|
|
Some(MoveType::Invalid)
|
|
// Otherwise we're good to move there
|
|
} else {
|
|
Some(MoveType::Valid)
|
|
}
|
|
}
|
|
// First move in the game, this is valid (and impossible)
|
|
None => {
|
|
// If there is another piece between A and B
|
|
if self
|
|
.line(from, to)
|
|
.any(|board_index| self.at(board_index).is_some())
|
|
{
|
|
// Invalid move, there is a piece between A and B
|
|
Some(MoveType::Invalid)
|
|
} else {
|
|
// Otherwise it's a valid
|
|
Some(MoveType::Valid)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/// 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(¤t_board_index).iter().filter_map(|move_index| {
|
|
// Get the move type (or none if totally invalid)
|
|
match self.move_type(*piece, current_board_index, *move_index) {
|
|
None | Some(MoveType::Invalid) => None,
|
|
_ => 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 {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn pawn_simple_moves() {
|
|
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() {
|
|
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() {
|
|
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() {
|
|
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");
|
|
}
|
|
|
|
/// When a piece is blocked on all sides by friendly, cannot move
|
|
#[test]
|
|
fn blocking_friendly() {
|
|
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_03() {
|
|
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"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Can move up to but not over friendlies
|
|
#[test]
|
|
fn case_02() {
|
|
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(),
|
|
]);
|
|
println!("Moves: {:?}", Piece::Queen.moves_at(&(4, 1).into()));
|
|
assert_eq!(
|
|
expected, given,
|
|
"Mostly blocked queen, moves include captures"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn case_01() {
|
|
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);
|
|
}
|
|
|
|
}
|
|
|
|
#[test]
|
|
fn test_piece_moves_at() {
|
|
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");
|
|
}
|
|
}
|
|
|
|
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>>,
|
|
) {
|
|
events.read().for_each(|Move { from, to, .. }| {
|
|
pieces.iter_mut().for_each(|(entity, mut index)| {
|
|
if *index == *from {
|
|
match to {
|
|
Some(to_idx) => {
|
|
info!("Moving piece {:?} {:?} -> {:?}", entity, from, to_idx);
|
|
*index = *to_idx;
|
|
if !(*played) {
|
|
audio_events.send(audio::AudioEvent::PutDown);
|
|
audio_events.send(audio::AudioEvent::StopIdle);
|
|
*played = true;
|
|
if *from != *to_idx {
|
|
let ns = !*curr_state.get();
|
|
info!("Piece moved, switching sides: {:?}", ns);
|
|
next_state.set(ns);
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
info!("Capturing piece {:?}", entity);
|
|
commands
|
|
.entity(entity)
|
|
.remove::<BoardIndex>()
|
|
.insert(Captured);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
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>>,
|
|
) {
|
|
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(
|
|
"S C O R E",
|
|
TextStyle {
|
|
font_size: 48.0,
|
|
color: Color::ORANGE_RED,
|
|
..default()
|
|
},
|
|
));
|
|
parent.spawn(TextBundle::from_section(
|
|
format!("BLUE {}", score.b),
|
|
TextStyle {
|
|
font_size: 32.0,
|
|
color: Color::BLUE,
|
|
..default()
|
|
},
|
|
));
|
|
parent.spawn(TextBundle::from_section(
|
|
format!("RED {}", score.a),
|
|
TextStyle {
|
|
font_size: 32.0,
|
|
color: Color::RED,
|
|
..default()
|
|
},
|
|
));
|
|
|
|
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(
|
|
events: Query<(&Side, &Piece), (Added<Captured>, With<display3d::Display3d>)>,
|
|
mut debug_info: ResMut<debug::DebugInfo>,
|
|
mut score: ResMut<Score>,
|
|
) {
|
|
events.iter().for_each(|(side, piece)| {
|
|
score.increment(!*side, *piece);
|
|
debug_info.set("score".into(), format!("A:{}|B:{}", score.a, 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)| {
|
|
info!("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
|
|
info!("Applying moves {:?}", moves);
|
|
if !(*done) {
|
|
moves.iter().for_each(|m| {
|
|
move_events.send(m.clone());
|
|
});
|
|
*done = true;
|
|
}
|
|
}
|
|
Err(GameError::NullMove) => warn!("Null move!"),
|
|
Err(GameError::InvalidIndex) | Err(GameError::InvalidMove) => {
|
|
warn!("Invalid index/move!");
|
|
if !(*done) {
|
|
audio_event.send(AudioEvent::Invalid);
|
|
*done = true;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
*done = false;
|
|
*latest = None;
|
|
}
|
|
|
|
/// Triggered when right-mouse-button clicked
|
|
fn cancel_place(current: Query<&BoardIndex, With<Selected>>, mut events: EventWriter<Move>) {
|
|
current.iter().for_each(|board_index| {
|
|
info!("De-selecting piece at {:?}", board_index);
|
|
events.send(Move {
|
|
from: *board_index,
|
|
to: Some(*board_index),
|
|
..default()
|
|
});
|
|
});
|
|
}
|
|
|
|
/// 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| {
|
|
info!("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
|
|
board.valid_moves(*idx).iter().for_each(|idx| {
|
|
indicators.iter_mut().for_each(|(i, mut vis)| {
|
|
if i == idx {
|
|
*vis = Visibility::Inherited;
|
|
}
|
|
});
|
|
})
|
|
});
|
|
}
|
|
|
|
/// 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,
|
|
) {
|
|
// Setup the board
|
|
*board = Board::new();
|
|
|
|
// Reset the score
|
|
*score = Score { ..default() };
|
|
|
|
// Move all pieces to their correct spots
|
|
pieces
|
|
.iter()
|
|
.zip(board.pieces().iter())
|
|
.for_each(|(e, (i, p))| {
|
|
commands.entity(e).insert((*i, *p)).remove::<Captured>();
|
|
});
|
|
}
|
|
|
|
/// 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);
|
|
}
|