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.
596 lines
20 KiB
Rust
596 lines
20 KiB
Rust
use bevy::utils::HashSet;
|
|
|
|
use crate::audio::AudioEvent;
|
|
use crate::prelude::*;
|
|
|
|
pub(crate) struct GamePlugin;
|
|
|
|
impl Plugin for GamePlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.add_event::<Move>()
|
|
.add_event::<Selection>()
|
|
.add_state::<TurnState>()
|
|
.add_systems(Startup, setup_board)
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
menu::exit_to_menu.run_if(in_state(GameState::Play)),
|
|
update_board
|
|
.run_if(on_event::<Move>())
|
|
.after(handle_selection),
|
|
set_side.run_if(on_event::<Move>()).after(update_board),
|
|
cancel_place.run_if(|buttons: Res<Input<MouseButton>>| -> bool {
|
|
buttons.just_pressed(MouseButton::Right)
|
|
}),
|
|
handle_selection.run_if(on_event::<Selection>()),
|
|
capture_piece.run_if(any_component_added::<Captured>),
|
|
switch_sides.run_if(|input: Res<Input<KeyCode>>| -> bool {
|
|
input.just_pressed(KeyCode::N)
|
|
}),
|
|
),
|
|
)
|
|
.add_systems(
|
|
PreUpdate,
|
|
(
|
|
asserts::<display2d::Display2d>.run_if(in_state(DisplayState::Display2d)),
|
|
asserts::<display3d::Display3d>.run_if(in_state(DisplayState::Display3d)),
|
|
)
|
|
.run_if(in_state(GameState::Play)),
|
|
)
|
|
.add_systems(
|
|
PostUpdate,
|
|
(debug_board.run_if(resource_exists::<debug::DebugEnabled>()),),
|
|
);
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default, States)]
|
|
pub(crate) enum TurnState {
|
|
#[default]
|
|
SideA,
|
|
SideB,
|
|
}
|
|
|
|
#[derive(Debug, Component, Clone, PartialEq, Copy)]
|
|
pub(crate) enum Piece {
|
|
Pawn,
|
|
Drone,
|
|
Queen,
|
|
}
|
|
|
|
#[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 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,
|
|
}
|
|
|
|
/// The board is setup like this:
|
|
/// ```text
|
|
/// 0 1 2 3 4 5 6 7
|
|
/// +--+--+--+--+--+--+--+--+
|
|
/// a | | | | I | d| Q| Q|
|
|
/// +--+--+--+--+--+--+--+--+
|
|
/// b |d |p |p | I | p| d| Q|
|
|
/// +--+--+--+--+--+--+--+--+
|
|
/// c |Q |d |p | I | p| p| d|
|
|
/// +--+--+--+--+--+--+--+--+
|
|
/// d |Q |Q |d | I | | | |
|
|
/// +--+--+--+--+--+--+--+--+
|
|
/// ````
|
|
#[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>,
|
|
}
|
|
|
|
#[derive(Debug, Component, PartialEq, Clone, Default, Copy, Eq, Hash)]
|
|
pub(crate) struct BoardIndex {
|
|
pub x: usize,
|
|
pub y: usize,
|
|
}
|
|
|
|
#[derive(Debug, Component, PartialEq)]
|
|
pub(crate) enum Side {
|
|
A,
|
|
B,
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
/// 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.clone()))
|
|
})
|
|
})
|
|
.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.clone()) {
|
|
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.clone(),
|
|
to: None,
|
|
});
|
|
}
|
|
|
|
// Capture the intened move in the moves ledger
|
|
moves.push(Move {
|
|
epoch,
|
|
from: from.clone(),
|
|
to: Some(to.clone()),
|
|
});
|
|
|
|
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 the possible moves the piece at this tile can make.
|
|
/// !!TODO: exclude pieces on your own side!!
|
|
pub(crate) fn valid_moves(&self, current_board_index: BoardIndex) -> HashSet<BoardIndex> {
|
|
let BoardIndex { x, y } = current_board_index;
|
|
|
|
let f = |(a, b): (Option<usize>, Option<usize>)| {
|
|
if let (Some(this_x), Some(this_y)) = (a, b) {
|
|
// This has a valid x position
|
|
let valid_x = (0..=7).contains(&this_x);
|
|
if valid_x {
|
|
// It has a valid y position
|
|
let valid_y = (0..=3).contains(&this_y);
|
|
if valid_y {
|
|
// The checked board index
|
|
let this_board_index = BoardIndex {
|
|
x: this_x,
|
|
y: this_y,
|
|
};
|
|
// Only propose tiles that are empty or capture a piece on the other side
|
|
let valid_capture = {
|
|
match self.at(this_board_index) {
|
|
Some(_) => {
|
|
let same_side = Board::side(current_board_index);
|
|
Board::side(this_board_index) != same_side
|
|
}
|
|
None => true,
|
|
}
|
|
};
|
|
if valid_capture {
|
|
// You cannot move a piece from SideB->SideA when it was just moved from SideA->SideB
|
|
// Move rejection is not allowed
|
|
let rejection = {
|
|
if let Some(Move {
|
|
from: last_from,
|
|
to: Some(last_to),
|
|
..
|
|
}) = self.moves.last()
|
|
{
|
|
// TODO: I think this is more logic than we need to express
|
|
// the sentiment...
|
|
Board::side(*last_from) == Board::side(this_board_index)
|
|
&& Board::side(*last_to) == Board::side(current_board_index)
|
|
&& Board::side(this_board_index)
|
|
!= Board::side(current_board_index)
|
|
} else {
|
|
false
|
|
}
|
|
};
|
|
if !rejection {
|
|
// If all tests pass, this is a valid move
|
|
Some(this_board_index)
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
|
|
match self.at(BoardIndex { x, y }) {
|
|
// One space in any diagonal
|
|
Some(Piece::Pawn) => std::iter::empty()
|
|
.chain(
|
|
(-1..=1)
|
|
.zip(-1..=1)
|
|
.map(move |(a, b)| (x.checked_add_signed(a), y.checked_add_signed(b))),
|
|
)
|
|
.chain(
|
|
(-1..=1)
|
|
.zip((-1..=1).rev())
|
|
.map(move |(a, b)| (x.checked_add_signed(a), y.checked_add_signed(b))),
|
|
)
|
|
.filter_map(f)
|
|
.collect(),
|
|
// One or two spaces in either horizontal
|
|
Some(Piece::Drone) => std::iter::empty()
|
|
.chain((-2..=2).map(|i| (x.checked_add_signed(i), Some(y))))
|
|
.chain((-2..=2).map(|i| (Some(x), y.checked_add_signed(i))))
|
|
.filter_map(f)
|
|
.collect(),
|
|
// Any distance in any straight line
|
|
Some(Piece::Queen) => std::iter::empty()
|
|
.chain((-7..=7).map(|i| (x.checked_add_signed(i), Some(y))))
|
|
.chain((-3..=3).map(|i| (Some(x), y.checked_add_signed(i))))
|
|
.chain(
|
|
(-3..=3)
|
|
.zip(-3..=3)
|
|
.map(move |(a, b)| (x.checked_add_signed(a), y.checked_add_signed(b))),
|
|
)
|
|
.chain(
|
|
(-3..=3)
|
|
.zip((-3..=3).rev())
|
|
.map(move |(a, b)| (x.checked_add_signed(a), y.checked_add_signed(b))),
|
|
)
|
|
.filter_map(f)
|
|
.collect(),
|
|
None => std::iter::empty().collect(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn current_epoch(&self) -> usize {
|
|
self.moves.last().unwrap_or(&Move { ..default() }).epoch + 1
|
|
}
|
|
}
|
|
|
|
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 _ = write!(f, "+--+--+--+--+--+--+--+--+\n");
|
|
let _ = write!(f, "|");
|
|
row.iter().for_each(|piece| {
|
|
let _ = match piece {
|
|
Some(p) => write!(f, "{} |", p),
|
|
None => write!(f, " |"),
|
|
};
|
|
});
|
|
let _ = write!(f, "\n");
|
|
});
|
|
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) {
|
|
use Piece::*;
|
|
commands.insert_resource(Board {
|
|
moves: vec![],
|
|
inner: vec![
|
|
vec![
|
|
Some(Queen),
|
|
Some(Queen),
|
|
Some(Drone),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
],
|
|
vec![
|
|
Some(Queen),
|
|
Some(Drone),
|
|
Some(Pawn),
|
|
None,
|
|
None,
|
|
Some(Pawn),
|
|
Some(Pawn),
|
|
Some(Drone),
|
|
],
|
|
vec![
|
|
Some(Drone),
|
|
Some(Pawn),
|
|
Some(Pawn),
|
|
None,
|
|
None,
|
|
Some(Pawn),
|
|
Some(Drone),
|
|
Some(Queen),
|
|
],
|
|
vec![
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(Drone),
|
|
Some(Queen),
|
|
Some(Queen),
|
|
],
|
|
],
|
|
});
|
|
}
|
|
|
|
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>,
|
|
) {
|
|
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 {:?} to {:?}", entity, to_idx);
|
|
*index = to_idx.clone();
|
|
if !(*played) {
|
|
audio_events.send(audio::AudioEvent::PutDown);
|
|
audio_events.send(audio::AudioEvent::StopIdle);
|
|
*played = true;
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
|
|
pub(crate) fn set_side(mut events: Query<(&mut Side, &BoardIndex), Changed<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<BoardIndex>, // Tracks the last one worked on
|
|
) {
|
|
selections.read().for_each(|Selection(index)| {
|
|
// Skip indexes already processed
|
|
if *index != *latest {
|
|
// Set the latest index to the current index
|
|
*latest = *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 = BoardIndex::default();
|
|
}
|
|
|
|
/// 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.clone(),
|
|
to: Some(board_index.clone()),
|
|
..default()
|
|
});
|
|
});
|
|
}
|
|
|
|
/// When a piece's _BoardIndex_ is removed, we hide that entity from the viewer
|
|
fn capture_piece(mut events: Query<&mut Visibility, Added<Captured>>) {
|
|
events.iter_mut().for_each(|mut vis| {
|
|
info!("Hiding captured piece");
|
|
*vis = Visibility::Hidden
|
|
});
|
|
}
|
|
|
|
/// 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()
|
|
);
|
|
}
|
|
}
|
|
|
|
fn switch_sides(state: Res<State<TurnState>>, mut next: ResMut<NextState<TurnState>>) {
|
|
match state.get() {
|
|
TurnState::SideA => next.set(TurnState::SideB),
|
|
TurnState::SideB => next.set(TurnState::SideA),
|
|
}
|
|
}
|