Skip to content

Commit 3b22ce1

Browse files
feat(scores): persist pacman top score
1 parent 1d48532 commit 3b22ce1

6 files changed

Lines changed: 291 additions & 7 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ Notes:
6161
the Kitty graphics protocol.
6262
- Download Ghostty: <https://ghostty.org/download>
6363
- Download Warp: <https://www.warp.dev/download>
64+
- The top score now persists between runs in `~/.pacman/high_score.txt`; set
65+
`PACMAN_DATA_DIR` to redirect that file for local experiments or tests.
6466
- If `pacman` is not found after installation, ensure `~/.cargo/bin` is on your
6567
`PATH`.
6668

src/app.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ pub fn run() -> Result<()> {
3838
let mut renderer = Renderer::new(terminal_geometry);
3939
let mut graphics = KittyGraphics::new(terminal_geometry.cols, terminal_geometry.rows);
4040
let mut input = InputController::default();
41-
let mut game = Game::new();
41+
let mut game = Game::load();
4242
let mut audio = AudioManager::new();
4343
for event in game.drain_events() {
4444
audio.handle_event(event);

src/game.rs

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use crate::{
1515
},
1616
fruit::Fruit,
1717
ghosts::{GhostGroup, GhostGroupUpdateContext},
18+
high_scores::HighScoreTable,
1819
mazedata::MazeSpec,
1920
modes::GhostMode,
2021
nodes::NodeGroup,
@@ -123,6 +124,7 @@ struct GameplayState {
123124
level: u32,
124125
lives: u32,
125126
score: u32,
127+
high_score: u32,
126128
pacman_pause_remaining: f32,
127129
fruit_thresholds_spawned: [bool; 2],
128130
personal_dot_counters: [usize; 4],
@@ -179,6 +181,7 @@ struct TitleScreenState {
179181
prompt_blink_timer: f32,
180182
prompt_visible: bool,
181183
title_image: Arc<RenderedImage>,
184+
high_score_image: Arc<RenderedImage>,
182185
company_image: Arc<RenderedImage>,
183186
bonus_image: Arc<RenderedImage>,
184187
push_start_image: Arc<RenderedImage>,
@@ -216,6 +219,8 @@ struct AttractNicknameRow {
216219
struct AppState {
217220
title_screen: TitleScreenState,
218221
gameplay: Option<GameplayState>,
222+
high_scores: HighScoreTable,
223+
persist_high_scores: bool,
219224
events: Vec<GameEvent>,
220225
}
221226

@@ -227,6 +232,13 @@ impl Game {
227232
}
228233
}
229234

235+
pub fn load() -> Self {
236+
Self {
237+
state: AppState::load(),
238+
quit_requested: false,
239+
}
240+
}
241+
230242
/// Updates with input.
231243
pub fn update_with_input(&mut self, dt: f32, input: UpdateInput) {
232244
let q_pressed = input
@@ -403,11 +415,26 @@ impl GameplayState {
403415
const READY_TIME: f32 = 3.0;
404416

405417
fn new() -> Self {
406-
Self::start_level(1, 5, 0, Vec::new())
418+
Self::with_high_score(0)
419+
}
420+
421+
fn with_high_score(high_score: u32) -> Self {
422+
Self::start_level_with_high_score(1, 5, 0, Vec::new(), high_score)
407423
}
408424

425+
#[cfg(test)]
409426
/// Starts level.
410427
fn start_level(level: u32, lives: u32, score: u32, fruit_captured: Vec<usize>) -> Self {
428+
Self::start_level_with_high_score(level, lives, score, fruit_captured, 0)
429+
}
430+
431+
fn start_level_with_high_score(
432+
level: u32,
433+
lives: u32,
434+
score: u32,
435+
fruit_captured: Vec<usize>,
436+
high_score: u32,
437+
) -> Self {
411438
let maze_spec = MazeSpec::arcade();
412439
let (nodes, pacman, pellets, ghosts) = build_gameplay_level(maze_spec, level);
413440
let maze_sprites = MazeSprites::from_layout(maze_spec.layout);
@@ -416,6 +443,7 @@ impl GameplayState {
416443

417444
let mut text_group = TextGroup::new();
418445
text_group.update_score(score);
446+
text_group.update_high_score(high_score.max(score));
419447
text_group.update_level(level);
420448
text_group.show_status(StatusText::Ready);
421449

@@ -429,6 +457,7 @@ impl GameplayState {
429457
level,
430458
lives,
431459
score,
460+
high_score: high_score.max(score),
432461
pacman_pause_remaining: 0.0,
433462
fruit_thresholds_spawned: [false; 2],
434463
personal_dot_counters: [0; 4],
@@ -976,11 +1005,24 @@ impl GameplayState {
9761005
fn update_score(&mut self, points: u32) {
9771006
self.score += points;
9781007
self.text_group.update_score(self.score);
1008+
self.sync_high_score();
9791009
}
9801010

9811011
/// Updates score headless.
9821012
fn update_score_headless(&mut self, points: u32) {
9831013
self.score += points;
1014+
self.sync_high_score();
1015+
}
1016+
1017+
fn sync_high_score(&mut self) {
1018+
if self.score > self.high_score {
1019+
self.high_score = self.score;
1020+
self.text_group.update_high_score(self.high_score);
1021+
}
1022+
}
1023+
1024+
fn high_score(&self) -> u32 {
1025+
self.high_score.max(self.score)
9841026
}
9851027

9861028
/// Checks pellet events.
@@ -1242,11 +1284,12 @@ impl GameplayState {
12421284
GameplayAction::ResetLevel => self.reset_level(),
12431285
GameplayAction::NextLevel => {
12441286
let flags = self.secret_mode_flags();
1245-
*self = Self::start_level(
1287+
*self = Self::start_level_with_high_score(
12461288
self.level + 1,
12471289
self.lives,
12481290
self.score,
12491291
self.fruit_captured.clone(),
1292+
self.high_score,
12501293
);
12511294
self.apply_secret_mode_flags(flags);
12521295
}
@@ -1262,11 +1305,12 @@ impl GameplayState {
12621305
GameplayAction::ResetLevel => self.reset_level_headless(),
12631306
GameplayAction::NextLevel => {
12641307
let flags = self.secret_mode_flags();
1265-
*self = Self::start_level(
1308+
*self = Self::start_level_with_high_score(
12661309
self.level + 1,
12671310
self.lives,
12681311
self.score,
12691312
self.fruit_captured.clone(),
1313+
self.high_score,
12701314
);
12711315
self.apply_secret_mode_flags(flags);
12721316
self.pause.set_paused(false);
@@ -1540,13 +1584,23 @@ impl TitleAttractScene {
15401584
}
15411585

15421586
impl TitleScreenState {
1587+
#[cfg(test)]
15431588
fn new() -> Self {
1589+
Self::with_high_score(0)
1590+
}
1591+
1592+
fn with_high_score(high_score: u32) -> Self {
15441593
Self {
15451594
scene: TitleAttractScene::Title,
15461595
scene_timer: 0.0,
15471596
prompt_blink_timer: 0.0,
15481597
prompt_visible: true,
15491598
title_image: rasterize_text_image("PACMAN", YELLOW, 64.0),
1599+
high_score_image: rasterize_text_image(
1600+
&format!("HIGH SCORE {high_score:08}"),
1601+
WHITE,
1602+
16.0,
1603+
),
15501604
company_image: rasterize_text_image("1980 MIDWAY MFG CO", WHITE, 16.0),
15511605
bonus_image: rasterize_text_image("BONUS PACMAN FOR 10000 PTS", WHITE, 16.0),
15521606
push_start_image: rasterize_text_image("PUSH START BUTTON", YELLOW, 16.0),
@@ -1680,6 +1734,7 @@ impl TitleScreenState {
16801734
/// Appends title scene.
16811735
fn append_title_scene(&self, frame: &mut FrameData) {
16821736
append_centered_text(frame, &self.title_image, 68.0);
1737+
append_centered_text(frame, &self.high_score_image, 136.0);
16831738
append_centered_text(frame, &self.company_image, 168.0);
16841739
append_centered_text(frame, &self.bonus_image, 204.0);
16851740
if self.prompt_visible {
@@ -1825,9 +1880,19 @@ fn append_actor_sprite(frame: &mut FrameData, image: Arc<RenderedImage>, center:
18251880

18261881
impl AppState {
18271882
fn new() -> Self {
1883+
Self::with_high_scores(HighScoreTable::default(), false)
1884+
}
1885+
1886+
fn load() -> Self {
1887+
Self::with_high_scores(HighScoreTable::load_default(), true)
1888+
}
1889+
1890+
fn with_high_scores(high_scores: HighScoreTable, persist_high_scores: bool) -> Self {
18281891
Self {
1829-
title_screen: TitleScreenState::new(),
1892+
title_screen: TitleScreenState::with_high_score(high_scores.top_score()),
18301893
gameplay: None,
1894+
high_scores,
1895+
persist_high_scores,
18311896
events: vec![GameEvent::TitleScreenEntered],
18321897
}
18331898
}
@@ -1840,11 +1905,15 @@ impl AppState {
18401905
input.pause_requested,
18411906
&input.typed_chars,
18421907
);
1908+
let high_score_changed = self.high_scores.record(gameplay.high_score());
1909+
if high_score_changed && self.persist_high_scores {
1910+
let _ = self.high_scores.save_default();
1911+
}
18431912
self.events.extend(gameplay.drain_events());
18441913

18451914
if gameplay.return_to_title_requested {
18461915
self.gameplay = None;
1847-
self.title_screen = TitleScreenState::new();
1916+
self.title_screen = TitleScreenState::with_high_score(self.high_scores.top_score());
18481917
self.events.push(GameEvent::TitleScreenEntered);
18491918
}
18501919
return;
@@ -1863,7 +1932,7 @@ impl AppState {
18631932
}
18641933

18651934
if should_start {
1866-
self.gameplay = Some(GameplayState::new());
1935+
self.gameplay = Some(GameplayState::with_high_score(self.high_scores.top_score()));
18671936
self.events.push(GameEvent::GameStarted);
18681937
}
18691938
}

0 commit comments

Comments
 (0)