Skip to content

Commit 99d8d7b

Browse files
committed
Add PyBananas docstrings and update README.md
1 parent c6f2ec0 commit 99d8d7b

8 files changed

Lines changed: 137 additions & 39 deletions

File tree

PyBananas/README.md

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,13 @@
22

33
PyBananas is a Python wrapper of Bananas ROM for AI/ML training tasks.
44

5-
## Usage
6-
7-
Install wheel packges:
5+
## Install PIP Wheel
86

97
```bash
10-
pip install https://github.com/mdeclerk/Bananas/releases/latest/download/pybananas-0.1.0-py3-none-any.whl
8+
pip install https://github.com/mdeclerk/Bananas/releases/latest/download/pybananas-0.1.1-py3-none-any.whl
119
```
1210

13-
Use in code:
11+
## Quickstart
1412

1513
```python
1614
from pybananas import BananasEnv, GameInput, GameStateEnum
@@ -23,9 +21,26 @@ with BananasEnv() as env:
2321
print(f"P1: {state.players[0].score} pts, {state.players[0].lives} lives")
2422
```
2523

26-
## Pixi Build Workflow
24+
## PyBananas API
25+
26+
27+
| API | Purpose |
28+
| --- | --- |
29+
| `BananasEnv(window="null", frame_skip=4, ...)` | Create a headless training environment. |
30+
| `env.reset()` | Start a randomized episode and return the first observation. |
31+
| `env.step(action)` | Apply `GameInput` or an 8-value button array and return the next observation. |
32+
| `env.observe()` | Read the current frame without stepping. |
33+
| `env.game_state()` | Read decoded scores, lives, terrain, projectile, and turn state. |
34+
| `env.tick(count=1, render=True)` | Manually advance emulator frames. |
35+
| `env.set_emulation_speed(speed)` | Set emulator speed; `0` means unlimited. |
36+
| `env.close()` | Stop the emulator. |
37+
38+
Observations are grayscale `uint8` arrays with shape `(144, 160)` and values
39+
`0..3`. Action arrays use `(UP, DOWN, LEFT, RIGHT, A, B, START, SELECT)` order.
40+
41+
## Pixi Build Environment
2742

28-
Install [Pixi](https://pixi.sh) as dev environment and task runner.
43+
Install [Pixi](https://pixi.sh) as dev environment and task runner for PyBananas.
2944

3045
```bash
3146
cd PyBananas
@@ -37,4 +52,4 @@ pixi run test # run tests
3752
pixi run play # play in PyBoy for testing
3853
```
3954

40-
Commands are also available as VS Code tasks via **Terminal → Run Task**.
55+
Commands are also available as VS Code tasks via **Terminal → Run Task**.

PyBananas/pixi.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

PyBananas/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "pybananas"
7-
version = "0.1.0"
7+
version = "0.1.1"
88
license = "MIT"
99
license-files = ["LICENSE"]
1010
requires-python = ">=3.10"
@@ -52,4 +52,4 @@ depends-on = ["build-rom"]
5252

5353
[tool.pixi.tasks.test]
5454
cmd = "pytest -v"
55-
depends-on = ["build-rom"]
55+
depends-on = ["build-rom"]

PyBananas/src/pybananas/__main__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
"""Open an interactive PyBoy window for playing Bananas."""
2-
31
from __future__ import annotations
42

53
from .env import BananasEnv

PyBananas/src/pybananas/env.py

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,12 @@
3636

3737

3838
class BananasEnv:
39-
"""PyBoy wrapper for bananas.gb.
39+
"""Headless PyBoy environment for the Bananas ROM.
4040
41-
Observation: grayscale (144, 160) uint8 in [0, 3] (0 = lightest).
42-
Action: a `GameInput` (preferred) or any length-8 bool/0-1 sequence over
43-
(UP, DOWN, LEFT, RIGHT, A, B, START, SELECT).
44-
Step: presses the action mask for `frame_skip` ticks, then releases it.
41+
Observations are grayscale ``uint8`` arrays with shape ``(144, 160)`` and
42+
values ``0..3`` where ``0`` is lightest. Actions are either ``GameInput``
43+
values or length-8 bool/0-1 sequences in ``(UP, DOWN, LEFT, RIGHT, A, B,
44+
START, SELECT)`` order.
4545
"""
4646

4747
def __init__(
@@ -54,6 +54,22 @@ def __init__(
5454
sound: bool = False,
5555
no_input: bool = True,
5656
) -> None:
57+
"""Create a Bananas environment.
58+
59+
Args:
60+
rom_path: Optional path to a ``.gb`` ROM. Defaults to the packaged
61+
Bananas ROM.
62+
window: PyBoy window backend. Use ``"null"`` for headless training.
63+
scale: Display scale used when a visible PyBoy window is enabled.
64+
frame_skip: Emulator frames advanced by each ``step`` call.
65+
skip_splash: If true, advance from the splash screen to gameplay.
66+
sound: Whether PyBoy should emulate sound.
67+
no_input: Whether PyBoy should ignore host keyboard input.
68+
69+
Raises:
70+
FileNotFoundError: If the ROM or matching ``.sym`` file is missing.
71+
ValueError: If ``frame_skip`` is less than 1.
72+
"""
5773
if rom_path is not None:
5874
rom = validate_rom(Path(rom_path))
5975
else:
@@ -91,10 +107,16 @@ def __init__(
91107
# ------------------------------------------------------------------ core
92108

93109
def reset(self) -> np.ndarray:
94-
"""Reload the boot state, idle a random number of frames on the splash
95-
screen so the hardware DIV register varies, then press START. The game
96-
seeds its RNG from DIV_REG at the moment START is pressed, producing
97-
unique terrain each episode."""
110+
"""Start a new randomized episode and return the first observation.
111+
112+
Reloads the boot state, idles for a random number of splash-screen
113+
frames, then presses START. This varies the hardware DIV register used
114+
by the game seed so terrain changes between episodes.
115+
116+
Returns:
117+
Grayscale ``uint8`` observation with shape ``(144, 160)`` and
118+
values ``0..3``.
119+
"""
98120
self._pyboy.load_state(io.BytesIO(self._boot_state))
99121
warmup = int(self._rng.integers(0, SPLASH_WARMUP_MAX))
100122
self._pyboy.tick(count=warmup, render=False)
@@ -103,6 +125,18 @@ def reset(self) -> np.ndarray:
103125
return self.observe()
104126

105127
def step(self, action) -> np.ndarray:
128+
"""Apply an action for one environment step.
129+
130+
Args:
131+
action: ``GameInput`` or any length-8 bool/0-1 sequence in
132+
``(UP, DOWN, LEFT, RIGHT, A, B, START, SELECT)`` order.
133+
134+
Returns:
135+
Next grayscale ``uint8`` observation with shape ``(144, 160)``.
136+
137+
Raises:
138+
ValueError: If ``action`` is not a valid 8-button mask.
139+
"""
106140
mask = _action_mask(action)
107141
pressed = [name for bit, name in zip(mask, BUTTON_NAMES, strict=True) if bit]
108142
for name in pressed:
@@ -115,41 +149,63 @@ def step(self, action) -> np.ndarray:
115149
return self.observe()
116150

117151
def observe(self) -> np.ndarray:
152+
"""Return the current frame without advancing the emulator.
153+
154+
Returns:
155+
Grayscale ``uint8`` observation with shape ``(144, 160)`` and
156+
values ``0..3``.
157+
"""
118158
return (255 - self._pyboy.screen.ndarray[..., 0]) >> 6
119159

120160
# ---------------------------------------------------------------- state
121161

122162
def game_state(self) -> GameState:
163+
"""Return the decoded game state from emulator memory."""
123164
return decode_game_state(self.g_game_bytes())
124165

125166
def g_game_bytes(self) -> bytes:
167+
"""Return raw bytes for the ROM's ``g_game`` state struct."""
126168
return bytes(self._pyboy.memory[self._g_game_addr : self._g_game_addr + GAME_STATE_SIZE])
127169

128170
@property
129171
def g_game_addr(self) -> int:
172+
"""Memory address of the ROM's ``g_game`` state struct."""
130173
return self._g_game_addr
131174

132175
# ----------------------------------------------------------- emulation
133176

134177
def tick(self, count: int = 1, render: bool = True) -> bool:
135-
"""Advance the emulator by *count* frames.
178+
"""Advance the emulator by ``count`` frames.
136179
137-
Returns ``False`` when the emulator window has been closed.
180+
Args:
181+
count: Number of emulator frames to advance.
182+
render: Whether PyBoy should render frames while ticking.
183+
184+
Returns:
185+
``False`` when the emulator window has been closed, otherwise
186+
``True``.
138187
"""
139188
return self._pyboy.tick(count=count, render=render)
140189

141190
def set_emulation_speed(self, speed: int) -> None:
142-
"""Set the target emulation speed (1 = real-time, 0 = unlimited)."""
191+
"""Set target emulation speed.
192+
193+
Args:
194+
speed: PyBoy speed multiplier. ``1`` is real-time; ``0`` is
195+
unlimited.
196+
"""
143197
self._pyboy.set_emulation_speed(speed)
144198

145199
# -------------------------------------------------------------- lifecycle
146200

147201
def close(self) -> None:
202+
"""Stop the PyBoy emulator and release environment resources."""
148203
if self._pyboy is not None:
149204
self._pyboy.stop()
150205
self._pyboy = None # type: ignore[assignment]
151206

152207
def __enter__(self) -> BananasEnv:
208+
"""Return this environment for ``with`` statement use."""
153209
return self
154210

155211
def __exit__(
@@ -158,6 +214,7 @@ def __exit__(
158214
exc: BaseException | None,
159215
tb: TracebackType | None,
160216
) -> None:
217+
"""Close the environment when leaving a ``with`` block."""
161218
self.close()
162219

163220

@@ -166,7 +223,6 @@ def __exit__(
166223

167224

168225
def skip_through_splash(pyboy: PyBoy, g_game_addr: int) -> None:
169-
"""Pulse START until g_game.state == AIM and players[0].lives == 3."""
170226
for _ in range(0, SPLASH_MAX_FRAMES, 8):
171227
if _in_game(pyboy, g_game_addr):
172228
return

PyBananas/src/pybananas/input.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
"""Button enum + GameInput: composable, named-button replacement for raw arrays."""
2-
31
from __future__ import annotations
42

53
from dataclasses import dataclass, fields
@@ -16,6 +14,8 @@
1614

1715

1816
class Button(IntEnum):
17+
"""Game Boy controller button index used by action arrays."""
18+
1919
UP = 0
2020
DOWN = 1
2121
LEFT = 2
@@ -32,11 +32,11 @@ class GameInput:
3232
3333
Construct one of three ways:
3434
35-
GameInput() # no buttons (alias: GameInput.NOOP)
36-
GameInput(a=True, up=True) # kwargs
37-
GameInput.A | GameInput.UP # combine named singletons
35+
``GameInput()`` # no buttons
36+
``GameInput(a=True, up=True)`` # keyword flags
37+
``GameInput.A | GameInput.UP`` # combine named singletons
3838
39-
`BananasEnv.step` accepts both `GameInput` and raw 8-element arrays.
39+
``BananasEnv.step`` accepts both ``GameInput`` and raw 8-element arrays.
4040
"""
4141

4242
up: bool = False
@@ -61,15 +61,26 @@ class GameInput:
6161

6262
@classmethod
6363
def from_buttons(cls, *buttons: Button) -> "GameInput":
64-
"""`GameInput.from_buttons(Button.A, Button.UP)`."""
64+
"""Create an input from one or more ``Button`` values."""
6565
kwargs: dict[str, bool] = {}
6666
for btn in buttons:
6767
kwargs[BUTTON_NAMES[Button(btn).value]] = True
6868
return cls(**kwargs)
6969

7070
@classmethod
7171
def from_array(cls, arr) -> "GameInput":
72-
"""Inverse of `to_array()`; accepts any 8-element bool/0-1 sequence."""
72+
"""Create an input from an 8-value button mask.
73+
74+
Args:
75+
arr: Bool/0-1 sequence in ``(UP, DOWN, LEFT, RIGHT, A, B, START,
76+
SELECT)`` order.
77+
78+
Returns:
79+
Equivalent ``GameInput`` instance.
80+
81+
Raises:
82+
ValueError: If ``arr`` is not a valid 8-button mask.
83+
"""
7384
a = np.asarray(arr)
7485
if a.shape != (ACTION_SIZE,):
7586
raise ValueError(f"expected shape ({ACTION_SIZE},), got {a.shape}")
@@ -78,26 +89,28 @@ def from_array(cls, arr) -> "GameInput":
7889
return cls(**{name: bool(a[i]) for i, name in enumerate(BUTTON_NAMES)})
7990

8091
def to_array(self) -> np.ndarray:
81-
"""Length-8 uint8 array in (UP, DOWN, LEFT, RIGHT, A, B, START, SELECT) order."""
92+
"""Return a length-8 ``uint8`` button mask in canonical button order."""
8293
return np.array(
8394
[getattr(self, name) for name in BUTTON_NAMES],
8495
dtype=np.uint8,
8596
)
8697

8798
def pressed(self) -> tuple[Button, ...]:
88-
"""Buttons currently down, in canonical order."""
99+
"""Return pressed buttons in canonical button order."""
89100
return tuple(
90101
Button(i) for i, name in enumerate(BUTTON_NAMES) if getattr(self, name)
91102
)
92103

93104
def __or__(self, other: "GameInput") -> "GameInput":
105+
"""Combine two button sets."""
94106
if not isinstance(other, GameInput):
95107
return NotImplemented
96108
return GameInput(
97109
**{name: getattr(self, name) or getattr(other, name) for name in BUTTON_NAMES}
98110
)
99111

100112
def __iter__(self) -> Iterator[bool]:
113+
"""Iterate button states in canonical button order."""
101114
# Lets `np.asarray(game_input)` work transparently.
102115
return (getattr(self, name) for name in BUTTON_NAMES)
103116

PyBananas/src/pybananas/rom.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333

3434

3535
def validate_rom(rom: Path = BANANAS_ROM) -> Path:
36-
"""Validate ROM and .sym exist. Returns the rom path."""
3736
if not rom.exists():
3837
raise FileNotFoundError(
3938
f"ROM not found: {rom}\n"
@@ -49,7 +48,6 @@ def validate_rom(rom: Path = BANANAS_ROM) -> Path:
4948

5049

5150
def resolve_symbol(rom: Path, name: str) -> int:
52-
"""Look up `name` in the .sym file (`bank:addr name`)."""
5351
sym = rom.with_suffix(".sym")
5452
for line in sym.read_text().splitlines():
5553
parts = line.strip().split()

0 commit comments

Comments
 (0)