|
| 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