use crate::prelude::*; pub(crate) struct TutorialPlugin; impl Plugin for TutorialPlugin { fn build(&self, app: &mut App) { app.init_state::() .init_resource::() .add_systems( OnExit(GameState::Loading), initialize_tutorial .run_if(resource_exists::) .run_if(run_once()), ) .add_systems( Update, ( // Evaluate if a piece is selected step.run_if( // A piece changes sides any_component_changed::() // When a piece is selected, we .or_else(any_component_added::()) // A piece is de-selected .or_else(any_component_removed::()) // TEMP: The user hits 'enter' .or_else( just_pressed(KeyCode::Enter) .or_else(just_pressed(MouseButton::Left)), ), ), ), ) // Manage visible/hidden states .add_systems( Update, ( manage_state_entities::(), activate_tutorial_step, ) .run_if(not(in_state(GameState::Loading))) .run_if(state_changed::), ) .add_systems( Update, ( start_tutorial_on_play .run_if(in_state(GameState::Play)) .run_if(not(resource_exists::)) .run_if(any_component_removed::()), clear_tutorial_progress.run_if(on_event::>()), ) ); } } #[derive(Debug, States, Hash, Default, PartialEq, Eq, Clone, Component)] pub(crate) enum TutorialState { #[default] None, Intro, Objective, Empty, PieceIntro, PieceQueen, PieceDrone, PiecePawn, PieceJump, PieceEnd, Ownership, Promotions, Outro, } // Assertion: "Tutorial" just starts a regular game with TutorialState set to Intro // This plays out a usual game, but with tutorial text overlayed // Once tutorial is done, the game can continue as usual // Init animations, initialize all text windows but make invisible // This should be run when TutorialState::Intro fn initialize_tutorial( ui_font: Res, tweaks_file: Res, tweaks: Res>, mut commands: Commands, ) { let tweak = tweaks.get(tweaks_file.handle.clone()).expect("Load tweaks"); info!("Initializing tutorial entities"); let background_hex = tweak.get::("tutorial_rgba_background").unwrap(); let text_visible_hex = tweak.get::("tutorial_rgba_visible").unwrap(); let button_handle = tweak.get_handle::("buttons_image_resting").unwrap(); let font_handle = tweak.get_handle::("buttons_font").unwrap(); // List of (state, lines) for tutorial steps [ ( TutorialState::Intro, tweak .get::>("tutorial_intro") .expect("Tutorial intro"), ), ( TutorialState::Objective, tweak .get::>("tutorial_objective") .expect("Tutorial objective"), ), ( TutorialState::Ownership, tweak .get::>("tutorial_ownership") .expect("Tutorial ownership"), ), ( TutorialState::Promotions, tweak .get::>("tutorial_promotions") .expect("Tutorial promotions"), ), ( TutorialState::Outro, tweak .get::>("tutorial_outro") .expect("Tutorial pieces end"), ), ( TutorialState::PieceIntro, tweak .get::>("tutorial_pieces_prompt") .expect("Tutorial pieces prompt"), ), ( TutorialState::PieceJump, tweak .get::>("tutorial_pieces_jumping") .expect("Tutorial pieces jumping"), ), ( TutorialState::PieceEnd, tweak .get::>("tutorial_pieces_end") .expect("Tutorial pieces end"), ), ( TutorialState::PieceQueen, tweak .get::>("tutorial_pieces_queen") .expect("Tutorial pieces queen"), ), ( TutorialState::PieceDrone, tweak .get::>("tutorial_pieces_drone") .expect("Tutorial pieces drone"), ), ( TutorialState::PiecePawn, tweak .get::>("tutorial_pieces_pawn") .expect("Tutorial pieces pawn"), ), ] .iter() .for_each(|(step, lines)| { commands .spawn(( step.clone(), NodeBundle { style: Style { width: Val::Percent(100.0), height: Val::Percent(100.0), justify_content: JustifyContent::Center, align_items: AlignItems::FlexEnd, flex_direction: FlexDirection::Column, position_type: PositionType::Absolute, padding: UiRect::all(Val::Px(25.0)), ..default() }, background_color: Color::NONE.into(), visibility: Visibility::Hidden, ..default() }, )) .with_children(|parent| { // Skip button parent .spawn(( menu::ButtonAction(tutorial::TutorialState::None), menu::ButtonAction(GameState::Play), menu::ButtonAction(menu::MenuState::Off), ButtonBundle { style: Style { margin: UiRect::all(Val::Px(5.0)), position_type: PositionType::Absolute, bottom: Val::Px(0.0), right: Val::Px(0.0), ..default() }, background_color: Color::WHITE.with_a(0.3).into(), image: UiImage { texture: button_handle.clone(), ..default() }, ..default() }, )) .with_children(|parent| { parent.spawn(TextBundle { text: Text { sections: vec![TextSection { value: "Skip Tutorial".into(), style: TextStyle { color: Color::WHITE.with_a(0.3), font_size: 8.0, font: font_handle.clone(), }, }], ..default() }, style: Style { margin: UiRect::all(Val::Px(20.0)), ..default() }, ..default() }); }); // Content parent .spawn(( step.clone(), NodeBundle { style: Style { width: Val::Percent(33.0), max_height: Val::Percent(100.0), padding: UiRect::all(Val::Px(25.0)), flex_direction: FlexDirection::Column, ..default() }, background_color: Color::hex(&background_hex).unwrap().into(), ..default() }, )) .with_children(|parent| { parent.spawn(( step.clone(), TextBundle { text: Text { sections: lines .iter() .map(|line| TextSection { value: line.clone(), style: TextStyle { font_size: 14.0, color: Color::hex(&text_visible_hex).unwrap(), font: ui_font.handle.clone(), }, }) .collect(), justify: JustifyText::Left, ..default() }, ..default() }, )); if *step == TutorialState::Outro { parent .spawn(NodeBundle { style: Style { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, justify_items: JustifyItems::Center, align_content: AlignContent::Center, justify_content: JustifyContent::Center, ..default() }, ..default() }) .with_children(|parent| { parent .spawn(( menu::ButtonAction(tutorial::TutorialState::None), menu::ButtonAction(GameState::Restart), menu::ButtonAction(menu::MenuState::Off), ButtonBundle { style: Style { margin: UiRect::all(Val::Px(5.0)), align_content: AlignContent::Center, justify_content: JustifyContent::Center, ..default() }, image: UiImage { texture: button_handle.clone(), ..default() }, ..default() }, )) .with_children(|parent| { parent.spawn(TextBundle { text: Text { sections: vec![TextSection { value: "Reset".into(), style: TextStyle { color: Color::WHITE, font_size: 10.0, font: font_handle.clone(), }, }], ..default() }, style: Style { margin: UiRect::all(Val::Px(20.0)), ..default() }, ..default() }); }); parent .spawn(( menu::ButtonAction(tutorial::TutorialState::None), menu::ButtonAction(GameState::Play), menu::ButtonAction(menu::MenuState::On), ButtonBundle { style: Style { margin: UiRect::all(Val::Px(5.0)), align_content: AlignContent::Center, justify_content: JustifyContent::Center, ..default() }, image: UiImage { texture: button_handle.clone(), ..default() }, ..default() }, )) .with_children(|parent| { parent.spawn(TextBundle { text: Text { sections: vec![TextSection { value: "Menu".into(), style: TextStyle { color: Color::WHITE, font_size: 10.0, font: font_handle.clone(), }, }], ..default() }, style: Style { margin: UiRect::all(Val::Px(20.0)), ..default() }, ..default() }); }); }); } }); }); }) } #[derive(Debug, Resource, Default)] struct SeenStates(HashSet); fn step( pieces: Query<&game::Piece, Added>, transitions: Query>, curr_state: Res>, all_pieces: Query<(&Piece, &BoardIndex, &Side)>, side_state: Res>, mut valid_moves: Query<(&mut Visibility, &BoardIndex), With>, mut next_state: ResMut>, mut seen: ResMut, ) { info!("Evalute tutorial state"); // Store the current state // Used to avoid doing a prevoius state again seen.0.insert(curr_state.get().clone()); info!("Curr: {:?}, Seen: {:?}", curr_state.get(), seen); match curr_state.get() { // None does not go to Intro, that is controlled by the button handler TutorialState::None => next_state.set(TutorialState::None), // From Intro we always go to Objective TutorialState::Intro => next_state.set(TutorialState::Objective), // From objective we go to the Pieces flow intro TutorialState::Objective => next_state.set(TutorialState::PieceIntro), // From PiecesIntro we can go to any other Pieces if a piece is selected // Each pieces tutorial can transition to any other, as well as Ownership TutorialState::PieceIntro | TutorialState::PieceQueen | TutorialState::PieceDrone | TutorialState::PiecePawn | TutorialState::PieceEnd | TutorialState::PieceJump | TutorialState::Empty | TutorialState::Promotions | TutorialState::Ownership => { // PERF: Doing all of this work up front results in unnecessary work let piece_selected = !pieces.is_empty(); let all_moves_done = seen.0.contains(&TutorialState::PieceIntro) && seen.0.contains(&TutorialState::PieceQueen) && seen.0.contains(&TutorialState::PieceDrone) && seen.0.contains(&TutorialState::PiecePawn); let ownership_done = seen.0.contains(&TutorialState::Ownership); let ownership_check = !ownership_done && transitions.iter().count() > 0; let jump_done = seen.0.contains(&TutorialState::PieceJump); let promotions_done = seen.0.contains(&TutorialState::Promotions); let queen_selected = pieces.iter().filter(|&p| *p == game::Piece::Queen).count() > 0; let queen_seen = seen.0.contains(&TutorialState::PieceQueen); let drone_selected = pieces.iter().filter(|&p| *p == game::Piece::Drone).count() > 0; let drone_seen = seen.0.contains(&TutorialState::PieceDrone); let pawn_selected = pieces.iter().filter(|&p| *p == game::Piece::Pawn).count() > 0; let pawn_seen = seen.0.contains(&TutorialState::PiecePawn); // A piece is selected, so talk about it let next = if piece_selected { // When a queen is selected for the first time... if queen_selected && !queen_seen { TutorialState::PieceQueen // When a drone is selected for the first time... } else if drone_selected && !drone_seen { TutorialState::PieceDrone // When a pawn is selected for the first time... } else if pawn_selected && !pawn_seen { TutorialState::PiecePawn } else { TutorialState::Empty } // There are no pieces selected } else { // All move, jump, and ownership tutorials done, say goodbye if all_moves_done && jump_done && ownership_done && promotions_done { TutorialState::Outro } // A piece moves sides, so talk about ownership else if ownership_check { TutorialState::Ownership } // We have not touched on jumping yet else if all_moves_done && !jump_done { TutorialState::PieceJump } // We have not touched on field promotions yet else if all_moves_done && !promotions_done { TutorialState::Promotions } // All pieces tutorialized, so prompt user to move pieces for more tutorial else if all_moves_done { TutorialState::PieceEnd } // Default, empty (tutorial doesn't always need to show something) else { // When prompting to show a piece, find a random piece on this side and high-light it all_pieces.iter() .find_map(|(piece, board_index, side)| { if *side == side_state.get().0 { if !queen_seen && *piece == Piece::Queen { Some(board_index) } else if !drone_seen && *piece == Piece::Drone { Some(board_index) } else if !pawn_seen && *piece == Piece::Pawn { Some(board_index) } else { None } } else { None } }) .iter() .for_each(|hilight_index| { valid_moves .iter_mut() .filter(|(_, board_index)| board_index == hilight_index) .for_each(|(mut vis, _)| *vis = Visibility::Inherited); }); TutorialState::PieceIntro } }; next_state.set(next); } // After the outro, we exit the tutorial TutorialState::Outro => error!("Press a button!"), } } fn activate_tutorial_step( state: Res>, mut query: Query<(&mut Visibility, &TutorialState), Without>, ) { info!("Activating tutorial step {:?}", state.get()); // Iterate over all entities with TutorialState components query.iter_mut().for_each(|(mut v, s)| { // If their component matches the current state, make visible if s == state.get() { *v = Visibility::Inherited; // Otherwise hide it (again) } else { *v = Visibility::Hidden; } }); } #[derive(Debug, Resource)] struct TutorialStarted; fn start_tutorial_on_play( query: Query, With)>, state: Res>, mut next_state: ResMut>, mut commands: Commands, ) { if query.iter().len() == 0 || *state.get() != TutorialState::None { info!("Intro dissolve is done!"); next_state.set(TutorialState::Intro); commands.insert_resource(TutorialStarted); } } fn clear_tutorial_progress( mut events: EventReader>, mut seen: ResMut, ) { events.read().filter(|StateTransitionEvent { after, .. }| *after == TutorialState::None).for_each(|_| seen.0.clear()); }