use crate::app::campaign::CampaignKind;
use crate::app::{Campaign, Phase, Sim, art, layout, palette};
use crate::sim::Placement;
use bevy::prelude::*;
pub const STUCK_AFTER: u32 = 3;
#[derive(Resource, Default)]
pub struct Hints {
level: Option<(CampaignKind, usize)>,
losses: u32,
shown: Option<Placement>,
}
impl Hints {
pub fn offered(&self) -> bool {
self.losses >= STUCK_AFTER
}
pub fn showing(&self) -> bool {
self.shown.is_some()
}
fn reset(&mut self, level: (CampaignKind, usize)) {
*self = Hints {
level: Some(level),
losses: 0,
shown: None,
};
}
}
fn level_of(campaign: &Campaign) -> (CampaignKind, usize) {
(campaign.kind, campaign.index)
}
#[derive(Component)]
pub struct HintGhost;
pub fn record_loss(campaign: Res<Campaign>, mut hints: ResMut<Hints>) {
if hints.level != Some(level_of(&campaign)) {
hints.reset(level_of(&campaign));
}
hints.losses += 1;
hints.shown = None;
}
pub fn reset_on_level(campaign: Res<Campaign>, mut hints: ResMut<Hints>) {
if hints.level != Some(level_of(&campaign)) {
hints.reset(level_of(&campaign));
} else {
hints.shown = None;
}
}
fn next_step(campaign: &Campaign, sim: &Sim) -> Option<Placement> {
campaign
.current()
.solution
.iter()
.copied()
.find(|&(x, y, dir)| sim.0.signpost_at(x, y).is_none_or(|sp| sp.dir != dir))
}
pub fn hint_input(
keys: Res<ButtonInput<KeyCode>>,
campaign: Res<Campaign>,
sim: Res<Sim>,
mut hints: ResMut<Hints>,
) {
if !keys.just_pressed(KeyCode::KeyH) || !hints.offered() {
return;
}
hints.shown = next_step(&campaign, &sim);
}
pub fn draw_hint(
mut commands: Commands,
hints: Res<Hints>,
sim: Res<Sim>,
art: Res<art::Art>,
ghosts: Query<Entity, With<HintGhost>>,
) {
if !hints.is_changed() && !sim.is_changed() {
return;
}
for ghost in &ghosts {
commands.entity(ghost).despawn();
}
let Some((x, y, dir)) = hints.shown else {
return;
};
let pos = layout::tile_center(&sim.0, x, y);
commands.spawn((
HintGhost,
Sprite {
image: art.arrow.clone(),
color: palette::PARCHMENT.with_alpha(0.35),
custom_size: Some(Vec2::splat(layout::TILE * 0.88)),
..default()
},
Transform::from_translation(pos.extend(layout::z::SIGNPOST - 0.1))
.with_rotation(layout::dir_rotation(dir)),
));
}
pub fn clear_hint_ghosts(mut commands: Commands, ghosts: Query<Entity, With<HintGhost>>) {
for ghost in &ghosts {
commands.entity(ghost).despawn();
}
}
pub fn hint_line(tr: &crate::app::i18n::Tr, hints: &Hints, phase: &Phase) -> Option<String> {
if !hints.offered() || !matches!(phase, Phase::Setup | Phase::Lost) {
return None;
}
Some(if hints.showing() {
tr.hint_showing.to_string()
} else {
tr.hint_offer.to_string()
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::CampaignKind;
use crate::sim::{Level, campaign_levels};
fn campaign_at(index: usize) -> Campaign {
let levels = campaign_levels();
let builtins = levels.len();
Campaign {
kind: CampaignKind::TidePool,
levels,
index,
builtins,
}
}
#[test]
fn the_hint_skips_what_is_already_placed() {
let campaign = campaign_at(27);
let level: &Level = campaign.current();
assert_eq!(level.solution.len(), 2, "picked a two-post level");
let mut sim = Sim(level.board());
assert_eq!(next_step(&campaign, &sim), Some(level.solution[0]));
let (x, y, dir) = level.solution[0];
assert!(sim.0.place_signpost(0, x, y, dir));
assert_eq!(
next_step(&campaign, &sim),
Some(level.solution[1]),
"the first step is done, so show the second"
);
let (x, y, dir) = level.solution[1];
assert!(sim.0.place_signpost(0, x, y, dir));
assert_eq!(next_step(&campaign, &sim), None, "nothing left to show");
let mut wrong = Sim(level.board());
let (x, y, dir) = level.solution[0];
assert!(wrong.0.place_signpost(0, x, y, dir.right()));
assert_eq!(next_step(&campaign, &wrong), Some(level.solution[0]));
}
#[test]
fn three_losses_open_the_hint_and_h_shows_one_step() {
use bevy::ecs::system::RunSystemOnce;
let mut app = App::new();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<Hints>();
app.insert_resource(campaign_at(27));
let level = campaign_at(27).current().clone();
app.insert_resource(Sim(level.board()));
let lose = |app: &mut App| {
let _ = app.world_mut().run_system_once(record_loss);
};
let ask = |app: &mut App| {
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.reset_all();
keys.press(KeyCode::KeyH);
let _ = app.world_mut().run_system_once(hint_input);
};
lose(&mut app);
lose(&mut app);
ask(&mut app);
assert!(!app.world().resource::<Hints>().offered());
assert!(!app.world().resource::<Hints>().showing());
lose(&mut app);
assert!(app.world().resource::<Hints>().offered());
ask(&mut app);
assert_eq!(
app.world().resource::<Hints>().shown,
Some(level.solution[0]),
"one signpost, and the first one they are missing"
);
let (x, y, dir) = level.solution[0];
assert!(
app.world_mut()
.resource_mut::<Sim>()
.0
.place_signpost(0, x, y, dir)
);
ask(&mut app);
assert_eq!(
app.world().resource::<Hints>().shown,
Some(level.solution[1]),
"the hint follows the player"
);
}
#[test]
fn switching_levels_starts_the_count_over() {
let mut hints = Hints::default();
for _ in 0..STUCK_AFTER {
hints.level = Some((CampaignKind::TidePool, 4));
hints.losses += 1;
}
assert!(hints.offered());
hints.reset((CampaignKind::TidePool, 5));
assert!(!hints.offered(), "a new level is not a stuck one");
}
#[test]
fn the_other_list_at_the_same_index_is_another_level() {
use bevy::ecs::system::RunSystemOnce;
let mut app = App::new();
app.init_resource::<Hints>();
app.insert_resource(campaign_at(4));
for _ in 0..STUCK_AFTER {
let _ = app.world_mut().run_system_once(record_loss);
}
assert!(app.world().resource::<Hints>().offered());
app.world_mut().resource_mut::<Campaign>().kind = CampaignKind::BeachDay;
let _ = app.world_mut().run_system_once(reset_on_level);
assert!(
!app.world().resource::<Hints>().offered(),
"Beach Day's fourth level is not the one they were stuck on"
);
}
}