-
Notifications
You must be signed in to change notification settings - Fork 537
Add game wrapper for Pac-Man #419
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| # | ||
| # License: See LICENSE.md file | ||
| # GitHub: https://github.com/Baekalfen/PyBoy | ||
| # | ||
| __pdoc__ = { | ||
| "GameWrapperPacMan.cartridge_title": False, | ||
| "GameWrapperPacMan.post_tick": False, | ||
| } | ||
|
|
||
| import pyboy | ||
| from pyboy.utils import PyBoyException | ||
|
|
||
| from .base_plugin import PyBoyGameWrapper | ||
|
|
||
| logger = pyboy.logging.get_logger(__name__) | ||
|
|
||
| def enabled(self): | ||
| import hashlib | ||
|
|
||
| try: | ||
| with open(self.pyboy.gamerom_file, "rb") as f: | ||
| rom_hash = hashlib.md5(f.read()).hexdigest() | ||
| return rom_hash == "cd9027e147f4605f26ee261c537441b3" | ||
| except Exception as e: | ||
| logger.error(f"Error occurred while checking ROM hash: {e}") | ||
| return False | ||
|
|
||
|
|
||
| ADDR_SCORE_LO = 0xD637 # tens + ones | ||
| ADDR_SCORE_MID = 0xD638 # thousands + hundreds | ||
| ADDR_SCORE_HI = 0xD639 # hundred-thousands + ten-thousands | ||
|
Comment on lines
+29
to
+31
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good |
||
|
|
||
| ADDR_LIVES = 0xD641 | ||
| ADDR_LEVEL = 0xD643 | ||
|
|
||
|
|
||
| def _bcd_score(lo, mid, hi): | ||
|
|
||
| def bcd(b): | ||
| return (b >> 4) * 10 + (b & 0x0F) | ||
|
Comment on lines
+39
to
+40
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can find bcd->dec and dec->bcd functions in utils: from pyboy.utils import dec_to_bcd, bcd_to_dec |
||
|
|
||
| return bcd(hi) * 10_000 + bcd(mid) * 100 + bcd(lo) | ||
|
|
||
|
|
||
| class GameWrapperPacMan(PyBoyGameWrapper): | ||
|
|
||
|
|
||
| cartridge_title = "PAC-MAN" | ||
|
|
||
| def __init__(self, *args, **kwargs): | ||
| self.score = 0 | ||
| self.lives_left = 0 | ||
| self.level = 0 | ||
| self._game_over = False | ||
|
|
||
| super().__init__(*args, game_area_section=(0, 2, 20, 16), game_area_follow_scxy=True, **kwargs) | ||
|
|
||
|
|
||
| def post_tick(self): | ||
| self._tile_cache_invalid = True | ||
| self._sprite_cache_invalid = True | ||
|
Comment on lines
+60
to
+61
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You should call |
||
|
|
||
| self.score = _bcd_score( | ||
| self.pyboy.memory[ADDR_SCORE_LO], | ||
| self.pyboy.memory[ADDR_SCORE_MID], | ||
| self.pyboy.memory[ADDR_SCORE_HI], | ||
| ) | ||
|
|
||
| prev_lives = self.lives_left | ||
| self.lives_left = self.pyboy.memory[ADDR_LIVES] | ||
| #Calculating game over based on the previous lives | ||
| if prev_lives > 0 and self.lives_left == 0: | ||
| self._game_over = True | ||
|
|
||
| self.level = self.pyboy.memory[ADDR_LEVEL] | ||
|
|
||
| def game_over(self): | ||
| return self._game_over | ||
|
|
||
| def start_game(self, timer_div=None): | ||
| if self.game_has_started: | ||
| raise PyBoyException("Game already started. Call reset_game() to restart.") | ||
|
|
||
| # Tick until the title screen score row shows the "1UP" label tile (287). | ||
| # That tile only appears once the title-screen HUD has been drawn, which | ||
| # means the attract loop has completed its boot sequence. | ||
| while self.tilemap_background[0, 0] != 287: | ||
| self.pyboy.tick(1, False) | ||
|
|
||
| self.pyboy.button("start") | ||
| self.pyboy.tick(1, False) | ||
|
|
||
| # Wait until LIVES register is populated — that confirms Pac-Man has | ||
| # spawned and the first playable frame is ready. | ||
| while self.pyboy.memory[ADDR_LIVES] == 0: | ||
| self.pyboy.tick(1, False) | ||
|
|
||
| PyBoyGameWrapper.start_game(self, timer_div=timer_div) | ||
|
|
||
| def reset_game(self, timer_div=None): | ||
|
|
||
| PyBoyGameWrapper.reset_game(self, timer_div=timer_div) | ||
| self._game_over = False | ||
|
|
||
| def __repr__(self): | ||
| return ( | ||
| f"Pac-Man\n" | ||
| f"Score: {self.score}\n" | ||
| f"Lives left: {self.lives_left}\n" | ||
| f"Level: {self.level}\n" | ||
| f"Game over: {self._game_over}\n" | ||
| ) + super().__repr__() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -118,4 +118,4 @@ def prep_pxd_py_files(): | |
| "build_ext": build_ext, | ||
| }, | ||
| ext_modules=[Extension("", [""])], # Added to trigger a binary wheel | ||
| ) | ||
| ) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unrelated formating |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| # | ||
| # License: See LICENSE.md file | ||
| # GitHub: https://github.com/Baekalfen/PyBoy | ||
| # | ||
|
|
||
| import pytest | ||
| from pyboy import PyBoy | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Helpers | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| def _boot(rom): | ||
| """Start the emulator and advance to the first playable frame.""" | ||
| pyboy = PyBoy(rom, window="null") | ||
| pyboy.set_emulation_speed(0) | ||
| for _ in range(700): | ||
| pyboy.tick(1, False) | ||
| pyboy.button("start") | ||
| pyboy.tick(1, False) | ||
| for _ in range(1000): | ||
| pyboy.tick(1, False) | ||
| return pyboy | ||
|
Comment on lines
+15
to
+24
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be handled by |
||
|
|
||
|
|
||
| def _read_score(pyboy): | ||
| def bcd(b): | ||
| return (b >> 4) * 10 + (b & 0xF) | ||
| return ( | ||
| bcd(pyboy.memory[0xD639]) * 10_000 + | ||
| bcd(pyboy.memory[0xD638]) * 100 + | ||
| bcd(pyboy.memory[0xD637]) | ||
| ) | ||
|
Comment on lines
+27
to
+34
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can use: from pyboy.utils import dec_to_bcd, bcd_to_dec |
||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Tests | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| def test_pac_man_score_starts_nonzero(pac_man_rom): | ||
| """ | ||
| Score at the first playable frame is 50 because Pac-Man spawns on a dot. | ||
| Verifies the BCD decoder and that the three score addresses are readable. | ||
| """ | ||
| pyboy = _boot(pac_man_rom) | ||
| assert _read_score(pyboy) == 50 | ||
| pyboy.stop() | ||
|
|
||
|
|
||
| def test_pac_man_score_increases_on_movement(pac_man_rom): | ||
| """ | ||
| Holding UP for 250 frames moves Pac-Man through the maze and eats dots. | ||
| Score must be strictly greater than the starting value afterwards. | ||
| """ | ||
| pyboy = _boot(pac_man_rom) | ||
| score_before = _read_score(pyboy) | ||
|
|
||
| for _ in range(250): | ||
| pyboy.button_press("up") | ||
| pyboy.tick(1, False) | ||
| pyboy.button_release("up") | ||
| pyboy.tick(20, False) | ||
|
|
||
| assert _read_score(pyboy) > score_before | ||
| pyboy.stop() | ||
|
|
||
|
|
||
| def test_pac_man_score_never_decreases(pac_man_rom): | ||
| """ | ||
| Score is monotonically non-decreasing throughout a session. | ||
| Runs until game over or 5000 ticks, whichever comes first. | ||
| """ | ||
| pyboy = _boot(pac_man_rom) | ||
| prev = _read_score(pyboy) | ||
|
|
||
| for _ in range(5000): | ||
| pyboy.tick(1, False) | ||
| score = _read_score(pyboy) | ||
| assert score >= prev, f"Score decreased: {prev} -> {score}" | ||
| prev = score | ||
| if pyboy.memory[0xD641] == 0: | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is this address? It's used in multiple tests, so best declare it globally. |
||
| break | ||
|
|
||
| pyboy.stop() | ||
|
|
||
|
|
||
| def test_pac_man_lives_start_at_two(pac_man_rom): | ||
| """ | ||
| The lives register (0xD641) must equal 2 at the start of a new game. | ||
| Pac-Man has 3 lives total; this register counts the extra two shown as icons. | ||
| """ | ||
| pyboy = _boot(pac_man_rom) | ||
| assert pyboy.memory[0xD641] == 2 | ||
| pyboy.stop() | ||
|
|
||
|
|
||
| def test_pac_man_lives_decrease_on_death(pac_man_rom): | ||
| """ | ||
| Running long enough guarantees ghosts will catch Pac-Man. | ||
| Lives must drop below the starting value of 2 within 8000 ticks. | ||
| """ | ||
| pyboy = _boot(pac_man_rom) | ||
| starting_lives = pyboy.memory[0xD641] | ||
|
|
||
| for _ in range(8000): | ||
| pyboy.tick(1, False) | ||
| if pyboy.memory[0xD641] < starting_lives: | ||
| break | ||
| else: | ||
| pytest.fail("Lives never decreased within 8000 ticks") | ||
|
|
||
| pyboy.stop() | ||
|
|
||
|
|
||
| def test_pac_man_game_over_when_lives_zero(pac_man_rom): | ||
| """ | ||
| When lives reach 0 the game is over. | ||
| Runs until lives == 0, then asserts the score is a valid BCD value (no | ||
| corruption) and that the run actually ended. | ||
| """ | ||
| pyboy = _boot(pac_man_rom) | ||
|
|
||
| for _ in range(2000): | ||
| pyboy.tick(1, False) | ||
| if pyboy.memory[0xD641] == 0: | ||
| break | ||
| else: | ||
| pytest.fail("Game never reached game over within 20000 ticks") | ||
|
|
||
| # Score must still be a valid, non-negative integer | ||
| assert _read_score(pyboy) >= 0 | ||
| pyboy.stop() | ||
|
|
||
|
|
||
| def test_pac_man_level_starts_at_one(pac_man_rom): | ||
| """Level register (0xD643) must be 1 at the start of the first game.""" | ||
| pyboy = _boot(pac_man_rom) | ||
| assert pyboy.memory[0xD643] == 1 | ||
| pyboy.stop() | ||
|
|
||
|
|
||
| def test_pac_man_level_stable_during_normal_play(pac_man_rom): | ||
| """ | ||
| Level should not change during a short session on the first maze. | ||
| Runs 3000 ticks and asserts level stays at 1. | ||
| """ | ||
| pyboy = _boot(pac_man_rom) | ||
|
|
||
| for _ in range(3000): | ||
| pyboy.tick(1, False) | ||
| assert pyboy.memory[0xD643] == 1, \ | ||
| f"Level changed unexpectedly at tick {_}" | ||
|
|
||
| pyboy.stop() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can be removed. Unused.