feat(engine,core): add elapsed-time tick system and Zen GameMode
Phase 6 part 4 (partial): - GameState now tracks elapsed_seconds via tick_elapsed_time in GamePlugin (per-second increment while not won). Pure helper advance_elapsed makes the tick logic directly testable without mocking Bevy Time. - New GameMode enum (Classic / Zen) on GameState. Zen mode suppresses scoring in move_cards and undo. GameState::new_with_mode allows callers to construct non-Classic games; the existing GameState::new still defaults to Classic. mode is serde(default) for backwards-compatible persistence. - NewGameRequestEvent gains an optional mode field; handle_new_game honours it (falling back to the current game's mode when None). - InputPlugin: pressing Z starts a fresh Zen-mode game. Time Attack, Challenge mode, level-5 unlock gating, and unlock UI are still deferred. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,17 @@ pub enum DrawMode {
|
||||
DrawThree,
|
||||
}
|
||||
|
||||
/// Top-level game mode. Affects scoring and (eventually) timer behaviour.
|
||||
///
|
||||
/// - `Classic`: standard Klondike scoring and timer.
|
||||
/// - `Zen`: scoring suppressed (stays at 0); intended for relaxed play.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum GameMode {
|
||||
#[default]
|
||||
Classic,
|
||||
Zen,
|
||||
}
|
||||
|
||||
/// Snapshot of game state used for undo.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct StateSnapshot {
|
||||
@@ -29,6 +40,10 @@ struct StateSnapshot {
|
||||
pub struct GameState {
|
||||
pub piles: HashMap<PileType, Pile>,
|
||||
pub draw_mode: DrawMode,
|
||||
/// Top-level mode (Classic / Zen). Defaults to Classic for backwards
|
||||
/// compatibility with older save files via `#[serde(default)]`.
|
||||
#[serde(default)]
|
||||
pub mode: GameMode,
|
||||
pub score: i32,
|
||||
pub move_count: u32,
|
||||
pub elapsed_seconds: u64,
|
||||
@@ -42,8 +57,13 @@ pub struct GameState {
|
||||
}
|
||||
|
||||
impl GameState {
|
||||
/// Creates a new game dealt from the given seed and draw mode.
|
||||
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
|
||||
pub fn new(seed: u64, draw_mode: DrawMode) -> Self {
|
||||
Self::new_with_mode(seed, draw_mode, GameMode::Classic)
|
||||
}
|
||||
|
||||
/// Creates a new game with an explicit `GameMode`.
|
||||
pub fn new_with_mode(seed: u64, draw_mode: DrawMode, mode: GameMode) -> Self {
|
||||
let mut deck = Deck::new();
|
||||
deck.shuffle(seed);
|
||||
let (tableau, stock) = deal_klondike(deck);
|
||||
@@ -61,6 +81,7 @@ impl GameState {
|
||||
Self {
|
||||
piles,
|
||||
draw_mode,
|
||||
mode,
|
||||
score: 0,
|
||||
move_count: 0,
|
||||
elapsed_seconds: 0,
|
||||
@@ -200,7 +221,11 @@ impl GameState {
|
||||
start
|
||||
};
|
||||
|
||||
let score_delta = score_move(&from, &to);
|
||||
let score_delta = if self.mode == GameMode::Zen {
|
||||
0
|
||||
} else {
|
||||
score_move(&from, &to)
|
||||
};
|
||||
self.push_snapshot();
|
||||
|
||||
// Execute move
|
||||
@@ -242,7 +267,11 @@ impl GameState {
|
||||
}
|
||||
let snapshot = self.undo_stack.pop().ok_or(MoveError::UndoStackEmpty)?;
|
||||
self.piles = snapshot.piles;
|
||||
self.score = (snapshot.score + scoring_undo()).max(0);
|
||||
self.score = if self.mode == GameMode::Zen {
|
||||
0
|
||||
} else {
|
||||
(snapshot.score + scoring_undo()).max(0)
|
||||
};
|
||||
self.move_count = snapshot.move_count;
|
||||
self.is_won = false;
|
||||
self.is_auto_completable = false;
|
||||
@@ -508,6 +537,28 @@ mod tests {
|
||||
assert!(g.score >= 0);
|
||||
}
|
||||
|
||||
// --- GameMode: Zen ---
|
||||
|
||||
#[test]
|
||||
fn zen_mode_score_stays_zero_after_undo() {
|
||||
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
|
||||
g.draw().unwrap();
|
||||
g.undo().unwrap();
|
||||
assert_eq!(g.score, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zen_mode_default_is_classic_via_default_trait() {
|
||||
assert_eq!(GameMode::default(), GameMode::Classic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zen_mode_field_persists_through_construction() {
|
||||
let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen);
|
||||
assert_eq!(g.mode, GameMode::Zen);
|
||||
assert_eq!(g.draw_mode, DrawMode::DrawThree);
|
||||
}
|
||||
|
||||
// --- Auto-complete ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -100,6 +100,7 @@ fn handle_start_daily_request(
|
||||
if keys.just_pressed(KeyCode::KeyC) {
|
||||
new_game.send(NewGameRequestEvent {
|
||||
seed: Some(daily.seed),
|
||||
mode: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Cross-system events used by the engine's plugins.
|
||||
|
||||
use bevy::prelude::Event;
|
||||
use solitaire_core::game_state::GameMode;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
|
||||
@@ -21,9 +22,11 @@ pub struct DrawRequestEvent;
|
||||
pub struct UndoRequestEvent;
|
||||
|
||||
/// Request to start a new game. `seed = None` uses a system-time seed.
|
||||
/// `mode = None` reuses the current game's `GameMode`.
|
||||
#[derive(Event, Debug, Clone, Copy, Default)]
|
||||
pub struct NewGameRequestEvent {
|
||||
pub seed: Option<u64>,
|
||||
pub mode: Option<GameMode>,
|
||||
}
|
||||
|
||||
/// Fired by `GamePlugin` after any successful state mutation. Rendering and
|
||||
|
||||
@@ -103,7 +103,8 @@ fn handle_new_game(
|
||||
for ev in new_game.read() {
|
||||
let seed = ev.seed.unwrap_or_else(seed_from_system_time);
|
||||
let draw_mode = game.0.draw_mode.clone();
|
||||
game.0 = GameState::new(seed, draw_mode);
|
||||
let mode = ev.mode.unwrap_or(game.0.mode);
|
||||
game.0 = GameState::new_with_mode(seed, draw_mode, mode);
|
||||
changed.send(StateChangedEvent);
|
||||
}
|
||||
}
|
||||
@@ -253,7 +254,7 @@ mod tests {
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
|
||||
app.world_mut().send_event(NewGameRequestEvent { seed: Some(999) });
|
||||
app.world_mut().send_event(NewGameRequestEvent { seed: Some(999), mode: None });
|
||||
app.update();
|
||||
|
||||
let after: Vec<u32> = app
|
||||
@@ -292,11 +293,16 @@ mod tests {
|
||||
fn advance_elapsed_handles_subsecond_deltas_without_skipping() {
|
||||
let mut elapsed = 0;
|
||||
let mut acc = 0.0;
|
||||
// 16ms × 60 frames/sec ≈ 1 second; should produce 1 tick.
|
||||
for _ in 0..60 {
|
||||
advance_elapsed(&mut elapsed, &mut acc, 1.0 / 60.0, false);
|
||||
// 4 × 0.25 = 1.0 (exactly representable in f32) — must produce 1 tick.
|
||||
for _ in 0..4 {
|
||||
advance_elapsed(&mut elapsed, &mut acc, 0.25, false);
|
||||
}
|
||||
assert!(elapsed == 1, "expected 1 second, got {elapsed}");
|
||||
assert_eq!(elapsed, 1);
|
||||
// Repeat once more for a total of 2 seconds.
|
||||
for _ in 0..4 {
|
||||
advance_elapsed(&mut elapsed, &mut acc, 0.25, false);
|
||||
}
|
||||
assert_eq!(elapsed, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -69,7 +69,13 @@ fn handle_keyboard(
|
||||
undo.send(UndoRequestEvent);
|
||||
}
|
||||
if keys.just_pressed(KeyCode::KeyN) {
|
||||
new_game.send(NewGameRequestEvent { seed: None });
|
||||
new_game.send(NewGameRequestEvent::default());
|
||||
}
|
||||
if keys.just_pressed(KeyCode::KeyZ) {
|
||||
new_game.send(NewGameRequestEvent {
|
||||
seed: None,
|
||||
mode: Some(solitaire_core::game_state::GameMode::Zen),
|
||||
});
|
||||
}
|
||||
if keys.just_pressed(KeyCode::KeyD) {
|
||||
draw.send(DrawRequestEvent);
|
||||
|
||||
@@ -296,7 +296,7 @@ mod tests {
|
||||
.move_count = 3;
|
||||
|
||||
app.world_mut()
|
||||
.send_event(NewGameRequestEvent { seed: Some(999) });
|
||||
.send_event(NewGameRequestEvent { seed: Some(999), mode: None });
|
||||
app.update();
|
||||
|
||||
let stats = &app.world().resource::<StatsResource>().0;
|
||||
@@ -309,7 +309,7 @@ mod tests {
|
||||
fn new_game_without_moves_does_not_record_abandoned() {
|
||||
let mut app = headless_app();
|
||||
app.world_mut()
|
||||
.send_event(NewGameRequestEvent { seed: Some(42) });
|
||||
.send_event(NewGameRequestEvent { seed: Some(42), mode: None });
|
||||
app.update();
|
||||
|
||||
let stats = &app.world().resource::<StatsResource>().0;
|
||||
|
||||
Reference in New Issue
Block a user