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::() .add_event::() .add_state::() .insert_resource(Score { ..default() }) .add_systems(Startup, setup_board) .add_systems(OnEnter(GameState::Play), hide_valid_moves) .add_systems( Update, ( menu::exit_to_menu.run_if(in_state(GameState::Play)), update_board .run_if(on_event::()) .after(handle_selection), set_side.run_if(on_event::()).after(update_board), cancel_place.run_if(|buttons: Res>| -> bool { buttons.just_pressed(MouseButton::Right) }), handle_selection.run_if(on_event::()), show_valid_moves.run_if(any_component_added::), hide_valid_moves.run_if(any_component_removed::()), manage_score.run_if(any_component_added::), check_endgame.run_if(resource_changed::()), ), ) .add_systems(OnEnter(GameState::Endgame), set_endgame) .add_systems(OnExit(GameState::Endgame), clear_endgame) .add_systems( PreUpdate, asserts:: .run_if(in_state(DisplayState::Display3d)) .run_if(in_state(GameState::Play)), ) .add_systems( PostUpdate, (debug_board.run_if(resource_exists::()),), ); } } #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default, States)] pub(crate) enum TurnState { SideA, // HACK: Opening animation starts on B side #[default] SideB, } impl std::ops::Not for TurnState { type Output = Self; fn not(self) -> Self::Output { match self { TurnState::SideA => TurnState::SideB, TurnState::SideB => TurnState::SideA, } } } // Allow comparison between turn state and sides impl PartialEq for TurnState { fn eq(&self, other: &Side) -> bool { match (self, other) { (TurnState::SideA, Side::A) | (TurnState::SideB, Side::B) => true, (TurnState::SideB, Side::A) | (TurnState::SideA, Side::B) => false, } } } #[derive(Debug, Component, Clone, PartialEq, Copy, Hash)] pub(crate) enum Piece { Pawn, Drone, Queen, } #[derive(Debug, Component, Clone, PartialEq)] pub(crate) enum Tile { Dark, Light, } pub(crate) fn tiles() -> impl Iterator { (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, } impl Score { fn increment(&mut self, side: Side) { match side { Side::A => self.a += 1, Side::B => self.b += 1, } } pub(crate) fn get(&self, side: Side) -> usize { match side { Side::A => self.a, Side::B => self.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>>, moves: Vec, } #[derive(Debug, Default, Event, Clone)] pub(crate) struct Move { pub epoch: usize, pub from: BoardIndex, pub to: Option, } /// 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 { match self { Self::FadeOut(e) => Some(Self::Store(e.clone())), Self::Store(e) => Some(Self::FadeIn(e.clone())), Self::FadeIn(_) => None, } } } #[derive(Debug, Component, PartialEq, Clone, Default, Copy, Eq, Hash)] pub(crate) struct BoardIndex { pub x: usize, pub y: usize, } #[derive(Debug, Component, PartialEq, Clone, Default, Copy, Eq, Hash)] pub(crate) struct Previous { board_index: BoardIndex } #[derive(Debug, Component, PartialEq, Clone, Copy)] pub(crate) enum Side { A, 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() } /// Show all pieces on one side of the board /// OPTIMIZE: This is only used to tell if a side is empty, so it is more work than we need to do. 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 })) }) }).filter_map(|r| r) .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 })) }) }).filter_map(|r| r) .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.clone())) }) }) .collect() } /// Moves a piece from -> to pub(crate) fn move_piece( &mut self, from: BoardIndex, to: BoardIndex, ) -> Result, 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 { 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: Implement "no jumping" over pieces pub(crate) fn valid_moves(&self, current_board_index: BoardIndex) -> HashSet { let BoardIndex { x, y } = current_board_index; let f = |(a, b): (Option, Option)| { 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 all tests pass, this is a valid move (!rejection).then_some(this_board_index) } 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), None, // Some(Queen), None, // Some(Drone), None, None, None, None, None, ], vec![ None, // Some(Queen), None, // Some(Drone), None, // Some(Pawn), None, // None, None, // None, None, // Some(Pawn), None, // Some(Pawn), None, // Some(Drone), ], vec![ None, // Some(Drone), None, // Some(Pawn), None, // Some(Pawn), None, // None, None, // None, None, // Some(Pawn), None, // Some(Drone), None, // Some(Queen), ], vec![ None, None, None, None, None, None, // Some(Drone), None, // Some(Queen), Some(Queen), ], ], }); } fn debug_board(board: Res, mut debug_info: ResMut) { 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, mut events: EventReader, mut pieces: Query<(Entity, &mut BoardIndex), With>, selected: Query>, mut commands: Commands, mut played: Local, curr_state: Res>, mut next_state: ResMut>, ) { 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.clone(); 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::() .insert(Captured); } } } }); selected.iter().for_each(|entity| { debug!("De-selecting selected piece {:?}", entity); commands.entity(entity).remove::(); }); }); *played = false; } // Track the last spot that a piece was at fn track_previous_move( events: Query<&BoardIndex, (With, Changed)>, mut commands: Commands, ) { todo!() } #[derive(Debug, Component)] struct Endgame; fn check_endgame( board: Res, mut next_state: ResMut>, ) { 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, mut commands: Commands, ) { 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() }, )); }); } fn clear_endgame( query: Query>, 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, (Added, With)>, mut debug_info: ResMut, mut score: ResMut, ) { events.iter().for_each(|side| { score.increment(!*side); debug_info.set("score".into(), format!("A:{}|B:{}", score.a, score.b)); }); } pub(crate) fn set_side(mut events: Query<(&mut Side, &BoardIndex), Changed>) { 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, mut move_events: EventWriter, selected: Query<(Entity, &BoardIndex), (With, With)>, pieces: Query<(Entity, &BoardIndex), (With, Without, With)>, mut board: ResMut, mut commands: Commands, mut audio_event: EventWriter, mut done: Local, // Tracks if moves/audio submitted already even if multiple pieces (2d/3d) are moved. mut latest: Local>, // 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>, mut events: EventWriter) { 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() }); }); } /// Panics if more than two pieces are selected at a time fn asserts( selected_pieces: Query, With, With)>, selected_tiles: Query, With, With)>, cameras: Query, With)>, ) { 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, Added)>, board: Res, mut indicators: Query<(&BoardIndex, &mut Visibility), With>, ) { // 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>) { indicators.iter_mut().for_each(|mut visibility| { *visibility = Visibility::Hidden; }); }