Skip to content

Commit 190354f

Browse files
DumontALINEnedseb
andauthored
feat(bq27441): Add battery pet (tamagotchi) example with OLED. (#399)
* feat: Initialize Tamagotchi game with needs system and health mechanic. * feat: Add first character sprites in different situations. * feat: Add a Tamagotchi aging system based on battery percentage. * refactor: Move file to correct directory. * feat: Add sound effects to the Tamagotchi. * chore: Fix code with ruff. * chore: Remove debugging code. * refactor: Fix typos, move sound dict, add buzzer cleanup. * style: Fix ruff E201/E231 lint errors in sprite arrays. * fix: Fix lint E221 and invisible text on GS4 OLED. * fix: Fix SPRITE_BASE formatting to comply with ruff E201/E231. * fix: Remove redundant upper bound in charge elif condition. * fix: Fix ruff linting errors. * fix(bq27441): Fix tamagotchi example naming, logic and style. Address review comments from #399: 1. Rename tamagotchie.py -> tamagotchi.py (fix English spelling). 2. Harmonize NEED casing: "i'm hungry" -> "I'm hungry" and update the matching logic in action_check accordingly. 3. Simplify action_check: remove the unused `win` parameter (it was always overwritten before being read) and return a boolean directly. 4. Remove unused "evolution" sound (defined but never played). 5. Rename SPRITE_HANGRY -> SPRITE_ANGRY (standard English). 6. Fix all style issues: spaces before colons (E203), inconsistent SOUND dict indentation, missing blank lines before functions (E302), missing operator spacing in draw_character (E225), double blank lines inside blocks (E303). 7. Add docstrings to all functions and a module-level docstring. 8. Name magic timing constants: IDLE_DISPLAY_MS, RESPONSE_TIMEOUT_MS, RESULT_DISPLAY_MS. 9. Add phase comments in main() for readability (idle / need / response / result). --------- Co-authored-by: Sébastien NEDJAR <sebastien@nedjar.com>
1 parent a44d2d8 commit 190354f

1 file changed

Lines changed: 353 additions & 0 deletions

File tree

lib/bq27441/examples/tamagotchi.py

Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
"""Battery Tamagotchi example using BQ27441, MCP23009E D-PAD, SSD1327 OLED and buzzer.
2+
3+
The creature ages based on the real battery percentage and periodically asks
4+
to be fed or played with via the D-PAD. A correct answer triggers a happy
5+
sprite + success sound; a wrong or missed answer triggers an angry sprite +
6+
fail sound. When the battery drops below 10 %, the game is over.
7+
8+
Controls:
9+
UP / DOWN -> navigate the action menu
10+
LEFT -> confirm selection
11+
"""
12+
13+
import random
14+
from time import sleep_ms, ticks_diff, ticks_ms
15+
16+
import ssd1327
17+
from bq27441 import BQ27441
18+
from machine import I2C, SPI, Pin
19+
from mcp23009e import MCP23009E
20+
from mcp23009e.const import (
21+
MCP23009_BTN_DOWN,
22+
MCP23009_BTN_LEFT,
23+
MCP23009_BTN_RIGHT,
24+
MCP23009_BTN_UP,
25+
MCP23009_DIR_INPUT,
26+
MCP23009_I2C_ADDR,
27+
MCP23009_LOGIC_LOW,
28+
MCP23009_PULLUP,
29+
)
30+
from pyb import Timer
31+
32+
# --- Hardware setup ---
33+
34+
spi = SPI(1)
35+
dc = Pin("DATA_COMMAND_DISPLAY")
36+
res = Pin("RST_DISPLAY")
37+
cs = Pin("CS_DISPLAY")
38+
display = ssd1327.WS_OLED_128X128_SPI(spi, dc, res, cs)
39+
40+
i2c = I2C(1)
41+
reset_expander = Pin("RST_EXPANDER", Pin.OUT)
42+
mcp = MCP23009E(i2c, address=MCP23009_I2C_ADDR, reset_pin=reset_expander)
43+
44+
fg = BQ27441(i2c)
45+
46+
buzzer_tim = Timer(1, freq=1000)
47+
buzzer_ch = buzzer_tim.channel(4, Timer.PWM, pin=Pin("SPEAKER"))
48+
buzzer_ch.pulse_width_percent(0)
49+
50+
# --- Constants ---
51+
52+
BUTTONS = {
53+
MCP23009_BTN_UP: "UP",
54+
MCP23009_BTN_DOWN: "DOWN",
55+
MCP23009_BTN_LEFT: "LEFT",
56+
MCP23009_BTN_RIGHT: "RIGHT",
57+
}
58+
59+
ACTION = ["food", "play"]
60+
NEED = ["I'm bored", "I'm hungry"]
61+
62+
IDLE_DISPLAY_MS = 1000
63+
RESPONSE_TIMEOUT_MS = 5000
64+
RESULT_DISPLAY_MS = 1000
65+
66+
X0 = 35
67+
ITEM_Y = 100
68+
ITEM_SPACING = 14
69+
70+
# --- Sprites ---
71+
72+
SPRITE_BASE = [
73+
[0, 0, 15, 15, 0, 0, 0, 0, 0, 15, 15, 0, 0],
74+
[0, 15, 15, 15, 15, 0, 0, 0, 15, 15, 15, 15, 0],
75+
[0, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 0],
76+
[0, 15, 15, 0, 0, 0, 0, 0, 0, 0, 15, 15, 0],
77+
[0, 15, 0, 0, 15, 0, 0, 0, 15, 0, 0, 15, 0],
78+
[15, 0, 0, 0, 0, 15, 15, 15, 0, 0, 0, 0, 15],
79+
[15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15],
80+
[0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 0],
81+
[15, 0, 0, 15, 0, 0, 0, 0, 0, 15, 0, 15, 0],
82+
[0, 15, 15, 0, 0, 0, 0, 0, 0, 15, 15, 15, 0],
83+
[0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 0],
84+
[0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 15, 0, 0],
85+
[0, 0, 0, 15, 0, 15, 15, 15, 0, 15, 0, 0, 0],
86+
[0, 0, 0, 15, 0, 15, 0, 15, 0, 15, 0, 0, 0],
87+
[0, 0, 0, 0, 15, 0, 0, 0, 15, 0, 0, 0, 0],
88+
]
89+
90+
SPRITE_HUNGRY = [
91+
[0, 0, 15, 15, 0, 0, 0, 0, 0, 15, 15, 0, 0],
92+
[0, 15, 15, 15, 15, 0, 0, 0, 15, 15, 15, 15, 0],
93+
[0, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 0],
94+
[0, 15, 15, 0, 0, 0, 0, 0, 0, 0, 15, 15, 0],
95+
[0, 15, 0, 0, 15, 0, 0, 0, 15, 0, 0, 15, 0],
96+
[15, 0, 0, 0, 0, 15, 15, 15, 0, 0, 0, 0, 15],
97+
[15, 0, 0, 0, 0, 15, 15, 15, 0, 0, 0, 0, 15],
98+
[0, 15, 0, 0, 0, 15, 15, 15, 0, 0, 0, 15, 0],
99+
[15, 0, 0, 15, 0, 0, 0, 0, 0, 15, 0, 15, 0],
100+
[0, 15, 15, 0, 0, 0, 0, 0, 0, 15, 15, 15, 0],
101+
[0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 0],
102+
[0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 15, 0, 0],
103+
[0, 0, 0, 15, 0, 15, 15, 15, 0, 15, 0, 0, 0],
104+
[0, 0, 0, 15, 0, 15, 0, 15, 0, 15, 0, 0, 0],
105+
[0, 0, 0, 0, 15, 0, 0, 0, 15, 0, 0, 0, 0],
106+
]
107+
108+
SPRITE_SAD = [
109+
[0, 0, 15, 15, 0, 0, 0, 0, 0, 15, 15, 0, 0],
110+
[0, 15, 15, 15, 15, 0, 0, 0, 15, 15, 15, 15, 0],
111+
[0, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 0],
112+
[0, 15, 15, 0, 0, 0, 0, 0, 0, 0, 15, 15, 0],
113+
[0, 15, 0, 15, 15, 0, 0, 0, 15, 15, 0, 15, 0],
114+
[15, 0, 0, 0, 0, 0, 15, 0, 0, 0, 0, 0, 15],
115+
[15, 0, 0, 0, 0, 15, 0, 15, 0, 0, 0, 0, 15],
116+
[0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 0],
117+
[15, 0, 0, 15, 0, 0, 0, 0, 0, 15, 0, 15, 0],
118+
[0, 15, 15, 0, 0, 0, 0, 0, 0, 15, 15, 15, 0],
119+
[0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 0],
120+
[0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 15, 0, 0],
121+
[0, 0, 0, 15, 0, 15, 15, 15, 0, 15, 0, 0, 0],
122+
[0, 0, 0, 15, 0, 15, 0, 15, 0, 15, 0, 0, 0],
123+
[0, 0, 0, 0, 15, 0, 0, 0, 15, 0, 0, 0, 0],
124+
]
125+
126+
SPRITE_HAPPY = [
127+
[0, 0, 15, 15, 0, 0, 0, 0, 0, 15, 15, 0, 0],
128+
[0, 15, 15, 15, 15, 0, 0, 0, 15, 15, 15, 15, 0],
129+
[0, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 0],
130+
[0, 15, 15, 0, 0, 0, 0, 0, 0, 0, 15, 15, 0],
131+
[0, 15, 0, 0, 15, 0, 0, 0, 15, 0, 0, 15, 0],
132+
[15, 0, 0, 15, 0, 15, 0, 15, 0, 15, 0, 0, 15],
133+
[15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15],
134+
[0, 15, 0, 0, 0, 15, 15, 15, 0, 0, 0, 15, 0],
135+
[15, 0, 0, 15, 0, 0, 0, 0, 0, 15, 0, 15, 0],
136+
[0, 15, 15, 0, 0, 0, 0, 0, 0, 15, 15, 15, 0],
137+
[0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 0],
138+
[0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 15, 0, 0],
139+
[0, 0, 0, 15, 0, 15, 15, 15, 0, 15, 0, 0, 0],
140+
[0, 0, 0, 15, 0, 15, 0, 15, 0, 15, 0, 0, 0],
141+
[0, 0, 0, 0, 15, 0, 0, 0, 15, 0, 0, 0, 0],
142+
]
143+
144+
SPRITE_ANGRY = [
145+
[0, 0, 15, 15, 0, 0, 0, 0, 0, 15, 15, 0, 0],
146+
[0, 15, 15, 15, 15, 0, 0, 0, 15, 15, 15, 15, 0],
147+
[0, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 0],
148+
[0, 15, 15, 0, 0, 0, 0, 0, 0, 0, 15, 15, 0],
149+
[0, 15, 0, 15, 0, 0, 0, 0, 0, 15, 0, 15, 0],
150+
[15, 0, 0, 0, 15, 0, 0, 0, 15, 0, 0, 0, 15],
151+
[15, 0, 0, 0, 0, 0, 15, 0, 0, 0, 0, 0, 15],
152+
[0, 15, 0, 0, 0, 15, 0, 15, 0, 0, 0, 15, 0],
153+
[15, 0, 0, 15, 0, 0, 0, 0, 0, 15, 0, 15, 0],
154+
[0, 15, 15, 0, 0, 0, 0, 0, 0, 15, 15, 15, 0],
155+
[0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 0],
156+
[0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 15, 0, 0],
157+
[0, 0, 0, 15, 0, 15, 15, 15, 0, 15, 0, 0, 0],
158+
[0, 0, 0, 15, 0, 15, 0, 15, 0, 15, 0, 0, 0],
159+
[0, 0, 0, 0, 15, 0, 0, 0, 15, 0, 0, 0, 0],
160+
]
161+
162+
SPRITE_DEAD = [
163+
[0, 0, 0, 0, 0, 0, 15, 15, 15, 15, 15, 15, 0, 0, 0, 0, 0],
164+
[0, 0, 0, 0, 15, 15, 0, 0, 0, 0, 0, 0, 15, 15, 0, 0, 0],
165+
[0, 0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 0, 0],
166+
[0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 0],
167+
[0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 15, 0, 15, 0, 15, 0],
168+
[0, 15, 0, 0, 15, 15, 0, 0, 0, 0, 0, 15, 0, 15, 0, 0, 15],
169+
[0, 15, 0, 15, 0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15],
170+
[0, 15, 0, 15, 0, 0, 0, 0, 0, 0, 15, 0, 15, 0, 15, 0, 15],
171+
[15, 0, 0, 0, 15, 0, 0, 0, 0, 0, 15, 15, 15, 15, 15, 0, 15],
172+
[15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 15, 15, 15, 15, 0, 15],
173+
[15, 0, 0, 0, 0, 0, 0, 0, 0, 15, 15, 15, 15, 15, 0, 15, 0],
174+
[0, 15, 0, 0, 0, 0, 0, 0, 0, 15, 0, 15, 0, 15, 0, 15, 0],
175+
[0, 0, 15, 15, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15, 0, 0],
176+
[0, 0, 0, 0, 0, 15, 15, 0, 0, 0, 0, 15, 15, 15, 0, 0, 0],
177+
[0, 0, 0, 0, 0, 0, 0, 15, 15, 15, 15, 0, 0, 0, 0, 0, 0],
178+
]
179+
180+
# --- Sounds ---
181+
182+
SOUND = {
183+
"start": [
184+
(523, 120),
185+
(659, 120),
186+
(784, 120),
187+
(1047, 400),
188+
],
189+
"hungry": [
190+
(400, 150),
191+
(350, 150),
192+
(300, 300),
193+
],
194+
"bored": [
195+
(500, 120),
196+
(650, 120),
197+
(800, 200),
198+
],
199+
"success": [
200+
(600, 100),
201+
(800, 100),
202+
(1000, 200),
203+
],
204+
"fail": [
205+
(500, 150),
206+
(400, 150),
207+
(300, 400),
208+
],
209+
}
210+
211+
# --- Helpers ---
212+
213+
214+
def setup_buttons():
215+
"""Configure all D-PAD buttons as inputs with pull-ups."""
216+
for pin_number in BUTTONS:
217+
mcp.setup(pin_number, MCP23009_DIR_INPUT, pullup=MCP23009_PULLUP)
218+
219+
220+
def wait_for_button():
221+
"""Poll D-PAD once and return the pressed button name, or None."""
222+
for pin_number, name in BUTTONS.items():
223+
if mcp.get_level(pin_number) == MCP23009_LOGIC_LOW:
224+
while mcp.get_level(pin_number) == MCP23009_LOGIC_LOW:
225+
sleep_ms(20)
226+
return name
227+
return None
228+
229+
230+
def draw_character(cx, cy, scale, sprite):
231+
"""Draw a scaled pixel-art sprite on the display framebuf."""
232+
fb = display.framebuf
233+
for y, row in enumerate(sprite):
234+
for x, color in enumerate(row):
235+
for dy in range(scale):
236+
for dx in range(scale):
237+
fb.pixel(cx + x * scale + dx, cy + y * scale + dy, color)
238+
239+
240+
def create_screen(selected_index, need, sprite, charge):
241+
"""Render one game frame: sprite, need text, charge and action menu."""
242+
display.fill(0)
243+
display.text(need, 25, 20, 15)
244+
display.text(str(charge), 50, 10, 15)
245+
246+
if charge >= 70:
247+
scale, x, y = 1, 55, 60
248+
elif charge >= 40:
249+
scale, x, y = 2, 45, 50
250+
else:
251+
scale, x, y = 3, 40, 40
252+
253+
draw_character(x, y, scale, sprite)
254+
255+
for index, label in enumerate(ACTION):
256+
row_y = ITEM_Y + index * ITEM_SPACING
257+
prefix = ">" if index == selected_index else " "
258+
display.text(prefix + label, X0, row_y, 15)
259+
260+
display.show()
261+
262+
263+
def create_game_over_screen():
264+
"""Display the game-over screen."""
265+
display.fill(0)
266+
display.text("Game Over", 25, 20, 15)
267+
draw_character(35, 45, 3, SPRITE_DEAD)
268+
display.show()
269+
270+
271+
def action_check(selected_index, need):
272+
"""Check if the selected action matches the current need."""
273+
name = ACTION[selected_index]
274+
return (need == "I'm bored" and name == "play") or (need == "I'm hungry" and name == "food")
275+
276+
277+
def sound_effect(name):
278+
"""Play a short melody from the SOUND dictionary."""
279+
for freq, duration_ms in SOUND[name]:
280+
buzzer_tim.freq(freq)
281+
buzzer_ch.pulse_width_percent(10)
282+
sleep_ms(duration_ms)
283+
buzzer_ch.pulse_width_percent(0)
284+
sleep_ms(30)
285+
286+
287+
# --- Main game loop ---
288+
289+
290+
def main():
291+
"""Run the Tamagotchi game."""
292+
setup_buttons()
293+
sound_effect("start")
294+
is_alive = True
295+
296+
try:
297+
while is_alive:
298+
selected_index = 0
299+
charge = fg.state_of_charge()
300+
301+
# Idle phase
302+
create_screen(selected_index, " ", SPRITE_BASE, charge)
303+
sleep_ms(IDLE_DISPLAY_MS)
304+
305+
# Need phase
306+
need = random.choice(NEED)
307+
if need == "I'm bored":
308+
sprite = SPRITE_SAD
309+
sound_effect("bored")
310+
else:
311+
sprite = SPRITE_HUNGRY
312+
sound_effect("hungry")
313+
314+
create_screen(selected_index, need, sprite, charge)
315+
316+
# Response phase
317+
start = ticks_ms()
318+
win = None
319+
320+
while True:
321+
elapsed = ticks_diff(ticks_ms(), start)
322+
if elapsed >= RESPONSE_TIMEOUT_MS:
323+
break
324+
325+
button = wait_for_button()
326+
if button == "UP":
327+
selected_index = (selected_index - 1) % len(ACTION)
328+
create_screen(selected_index, need, sprite, charge)
329+
elif button == "DOWN":
330+
selected_index = (selected_index + 1) % len(ACTION)
331+
create_screen(selected_index, need, sprite, charge)
332+
elif button == "LEFT":
333+
win = action_check(selected_index, need)
334+
break
335+
sleep_ms(20)
336+
337+
# Result phase
338+
if win:
339+
create_screen(selected_index, need, SPRITE_HAPPY, charge)
340+
sound_effect("success")
341+
else:
342+
create_screen(selected_index, need, SPRITE_ANGRY, charge)
343+
sound_effect("fail")
344+
sleep_ms(RESULT_DISPLAY_MS)
345+
346+
if charge < 10:
347+
is_alive = False
348+
create_game_over_screen()
349+
finally:
350+
buzzer_ch.pulse_width_percent(0)
351+
352+
353+
main()

0 commit comments

Comments
 (0)