import os import math import random from collections import deque import pygame # Use the same wrapper you used for the demo from pygame_qpad import PygameQPAD # ----------------------- # Touch bit mapping # (adjust to your pad order if needed) # ----------------------- TOUCH_LEFT = 3 TOUCH_RIGHT = 4 TOUCH_UP = 5 TOUCH_DOWN = 2 TOUCH_SELECT = 0 TOUCH_BACK = 1 LONGPRESS_MS = 1500 # ----------------------- # Characters # ----------------------- CHARACTERS = [ {"name": "Turbo", "speed": 2.0, "sprite": None}, {"name": "Classic", "speed": 1.0, "sprite": "assets/mascot.bmp"}, # your mascot here {"name": "Chill", "speed": 0.5, "sprite": None}, ] # ----------------------- # Level (28 x 31 maze) # X = wall, . = pellet, ' ' = empty # ----------------------- LEVEL = [ "XXXXXXXXXXXXXXXXXXXXXXXXXXXX", "X............XX............X", "X.XXXX.XXXXX.XX.XXXXX.XXXX.X", "XoXXXX.XXXXX.XX.XXXXX.XXXXoX", "X.XXXX.XXXXX.XX.XXXXX.XXXX.X", "X..........................X", "X.XXXX.XX.XXXXXXXX.XX.XXXX.X", "X......XX....XX....XX......X", "XXXXXX.XXXXX XX XXXXX.XXXXXX", " X.XX XX.X ", "XXXXXX.XX XXX--XXX XX.XXXXXX", " . X G G X . ", "XXXXXX.XX XXXXXXXX XX.XXXXXX", " X.XX XX.X ", "XXXXXX.XX XXXXXXXX XX.XXXXXX", "X............XX............X", "X.XXXX.XXXXX.XX.XXXXX.XXXX.X", "Xo..XX................XX..oX", "XXX.XX.XX.XXXXXXXX.XX.XX.XXX", "X......XX....XX....XX......X", "X.XXXXXXXXXX.XX.XXXXXXXXXX.X", "X..........................X", "XXXXXXXXXXXXXXXXXXXXXXXXXXXX", ] # Pad the level to 31 rows with empty margins like classic (keeps proportions) while len(LEVEL) < 31: LEVEL.insert(0, " " * 28) LEVEL.append(" " * 28) TILE_W = len(LEVEL[0]) TILE_H = len(LEVEL) # ----------------------- # Game config # ----------------------- WIN_W, WIN_H = 896, 704 # nice multiple of 28x31 (tile=32ish) FPS = 60 PACMAN_BASE_SPEED = 2.8 # tiles per second baseline (scaled by character) GHOST_SPEED = 2.3 # tiles per second PELLET_SCORE = 10 # ----------------------- # Helpers # ----------------------- def neighbors(grid, cx, cy): for dx, dy in ((-1,0),(1,0),(0,-1),(0,1)): x, y = cx+dx, cy+dy if 0 <= x < TILE_W and 0 <= y < TILE_H and grid[y][x] != "X": yield x, y def flood_next_step(grid, start, goal): """Return the next tile from start towards goal via BFS, or random if blocked.""" if start == goal: return start q = deque([start]) prev = {start: None} while q: c = q.popleft() if c == goal: # backtrack to first step cur = c while prev[cur] and prev[cur] != start: cur = prev[cur] return cur for n in neighbors(grid, *c): if n not in prev: prev[n] = c q.append(n) # no path; pick a random free neighbor ns = list(neighbors(grid, *start)) return random.choice(ns) if ns else start def find_spawn(ch): """Find a spawn roughly center-bottom.""" for y in range(TILE_H//2, TILE_H): for x in range(1, TILE_W-1): if LEVEL[y][x] in ch: return x, y # fallback return 14, 23 # ----------------------- # Entities # ----------------------- class Entity: def __init__(self, x, y, color, speed_tps=1.0): self.tx = float(x) # tile-position (float for sub-tile movement) self.ty = float(y) self.dir = (0, 0) # desired direction self.color = color self.speed = speed_tps # tiles per second def set_dir(self, dx, dy): self.dir = (dx, dy) def step(self, dt, grid): # sub-tile movement with collision on walls nx = self.tx + self.dir[0] * self.speed * dt ny = self.ty + self.dir[1] * self.speed * dt def passable(x, y): xi = int(round(x)) yi = int(round(y)) if 0 <= xi < TILE_W and 0 <= yi < TILE_H: return LEVEL[yi][xi] != "X" return False # Attempt axis-by-axis to allow sliding # X if passable(nx, self.ty): self.tx = nx # Y if passable(self.tx, ny): self.ty = ny @property def tile(self): return int(round(self.tx)), int(round(self.ty)) # ----------------------- # Pacman Game # ----------------------- class PacmanGame: STATE_SELECT = 0 STATE_PLAY = 1 STATE_WIN = 2 STATE_LOSE = 3 def __init__(self, screen, qpad): self.screen = screen self.qpad = qpad self.oled_surf = pygame.Surface((128, 64)) # Build pellet map self._build_level() self._setup_game() # Spawns def _build_level(self): # Build pellet map self.grid = [list(row) for row in LEVEL] self.pellets = 0 for y in range(TILE_H): for x in range(TILE_W): if self.grid[y][x] == ".": self.pellets += 1 def _setup_game(self): px, py = find_spawn(" .") gx, gy = find_spawn("G") self.character_idx = 1 # Classic default self.state = self.STATE_SELECT self.score = 0 self.lives = 3 self.last_touch = 0 self.pac = Entity(px, py, (255, 255, 0), PACMAN_BASE_SPEED) self.ghosts = [Entity(gx, gy, (255, 64, 64), GHOST_SPEED)] self._tile_px = min(WIN_W // TILE_W, WIN_H // TILE_H) self._ox = (WIN_W - self._tile_px * TILE_W) // 2 self._oy = (WIN_H - self._tile_px * TILE_H) // 2 # long-press tracking self.hold_ms = 0 self.last_hold_down = False # ---------- OLED UI ---------- def _oled_clear(self): self.oled_surf.fill((0,0,0)) def _oled_draw_progress(self, fraction: float, label="Hold to restart"): """Draw a sliding/filling bar from 0..1. Includes moving chevrons while holding.""" self._oled_clear() # Title font = pygame.font.SysFont(None, 18) t = font.render(label, True, (255,255,255)) self.oled_surf.blit(t, (64 - t.get_width()//2, 6)) # Bar outline pd = 2 x, y, w, h = 14, 36, 100, 12 pygame.draw.rect(self.oled_surf, (255,255,255), pygame.Rect(x - pd, y - pd, w + 2*pd, h + 2*pd), width=1) pygame.draw.rect(self.oled_surf, (0,0,0), pygame.Rect(x, y, w, h), width=1) # Fill fw = max(0, min(int(w * fraction), w)) if fw > 0: pygame.draw.rect(self.oled_surf, (255,255,255), pygame.Rect(x+1, y+1, fw-2 if fw>=2 else 0, h-2)) # Sliding chevrons animation on top chevron_w = 8 phase = (pygame.time.get_ticks() // 80) % chevron_w for cx in range(x + phase, x + w, chevron_w): pygame.draw.line(self.oled_surf, (0,0,0), (cx, y+1), (cx-4, y+h-2)) pygame.draw.line(self.oled_surf, (0,0,0), (cx+1, y+1), (cx-3, y+h-2)) # ---------- UI ---------- def draw_select(self): self.screen.fill((10, 10, 10)) font = pygame.font.SysFont(None, 48) small = pygame.font.SysFont(None, 28) title = font.render("Choose Your Muncher", True, (240, 240, 240)) self.screen.blit(title, (WIN_W//2 - title.get_width()//2, 40)) for i, ch in enumerate(CHARACTERS): x = WIN_W//2 + (i-1)*260 y = 180 w, h = 220, 220 rect = pygame.Rect(x - w//2, y - h//2, w, h) pygame.draw.rect(self.screen, (70,70,70), rect, border_radius=18) if i == self.character_idx: pygame.draw.rect(self.screen, (255, 255, 0), rect, 4, border_radius=18) name = font.render(ch["name"], True, (220, 220, 220)) self.screen.blit(name, (x - name.get_width()//2, y - 90)) desc = small.render(f"Speed {ch['speed']}x", True, (180, 180, 180)) self.screen.blit(desc, (x - desc.get_width()//2, y + 70)) hint = small.render("LEFT/RIGHT to cycle, SELECT to start", True, (160,160,160)) self.screen.blit(hint, (WIN_W//2 - hint.get_width()//2, WIN_H - 60)) def apply_character(self): mult = CHARACTERS[self.character_idx]["speed"] self.pac.speed = PACMAN_BASE_SPEED * mult # ---------- Game ---------- def update_select(self, touch_state): changed = False if self.qpad.button_left: if (self.last_touch & (1 << TOUCH_LEFT)) == 0: self.character_idx = (self.character_idx - 1) % len(CHARACTERS) changed = True if self.qpad.button_right: if (self.last_touch & (1 << TOUCH_RIGHT)) == 0: self.character_idx = (self.character_idx + 1) % len(CHARACTERS) changed = True if self.qpad.button_b: if (self.last_touch & (1 << TOUCH_SELECT)) == 0: self.apply_character() self.state = self.STATE_PLAY if changed: # subtle haptic if you add one later; for now nothing pass def tile_at(self, x, y): if 0 <= x < TILE_W and 0 <= y < TILE_H: return self.grid[y][x] return "X" def eat_pellet(self, tx, ty): if self.grid[ty][tx] == ".": self.grid[ty][tx] = " " self.score += PELLET_SCORE self.pellets -= 1 if self.pellets <= 0: self.state = self.STATE_WIN def ghost_ai(self, ghost, dt): # Move one full tile step per second scaled; steer toward the pacman tile gx, gy = ghost.tile px, py = self.pac.tile # re-target occasionally if random.random() < 0.15: nxt = flood_next_step(self.grid, (gx, gy), (px, py)) dx = max(-1, min(1, nxt[0] - gx)) dy = max(-1, min(1, nxt[1] - gy)) ghost.set_dir(dx, dy) ghost.step(dt, self.grid) def check_collision(self): # If ghost shares tile with pacman, lose a life pt = self.pac.tile for g in self.ghosts: if g.tile == pt: self.lives -= 1 # reset positions px, py = find_spawn(" .") gx, gy = find_spawn("G") self.pac.tx, self.pac.ty = float(px), float(py) for gg in self.ghosts: gg.tx, gg.ty = float(gx), float(gy) if self.lives <= 0: self.state = self.STATE_LOSE break def update_play(self, keys, dt): # Direction from touch or keyboard (keyboard for testing) dx = dy = 0 if self.qpad.button_left: dx = -1 elif self.qpad.button_right: dx = 1 if self.qpad.button_up: dy = -1 elif self.qpad.button_down: dy = 1 self.pac.set_dir(dx, dy) self.pac.step(dt, self.grid) # Eat pellet at current tile px, py = self.pac.tile self.eat_pellet(px, py) # Ghosts for g in self.ghosts: self.ghost_ai(g, dt) self.check_collision() def draw_game(self): self.screen.fill((0,0,0)) # Draw maze for y in range(TILE_H): for x in range(TILE_W): ch = self.grid[y][x] rx = self._ox + x*self._tile_px ry = self._oy + y*self._tile_px r = pygame.Rect(rx, ry, self._tile_px, self._tile_px) if ch == "X": pygame.draw.rect(self.screen, (25, 70, 200), r) elif ch == ".": cx = rx + self._tile_px//2 cy = ry + self._tile_px//2 pygame.draw.circle(self.screen, (240, 240, 240), (cx, cy), max(2, self._tile_px//8)) # Draw Pac-Man rx = self._ox + int(self.pac.tx*self._tile_px) ry = self._oy + int(self.pac.ty*self._tile_px) pygame.draw.circle(self.screen, self.pac.color, (rx, ry), max(6, self._tile_px//2)) # Draw Ghosts for g in self.ghosts: gx = self._ox + int(g.tx*self._tile_px) gy = self._oy + int(g.ty*self._tile_px) pygame.draw.circle(self.screen, g.color, (gx, gy), max(6, self._tile_px//2)) # HUD font = pygame.font.SysFont(None, 32) hud = font.render(f"Score {self.score} Lives {self.lives} {CHARACTERS[self.character_idx]['name']}", True, (240,240,240)) self.screen.blit(hud, (20, 10)) def draw_outcome(self, text): self.screen.fill((0,0,0)) big = pygame.font.SysFont(None, 72) t = big.render(text, True, (255, 255, 255)) self.screen.blit(t, (WIN_W//2 - t.get_width()//2, WIN_H//2 - t.get_height()//2)) small = pygame.font.SysFont(None,36) subt = small.render("Hold to Restart", True, (128, 128, 128)) self.screen.blit(subt, (WIN_W//2 - t.get_width()//2, WIN_H//2 + t.get_height()//2)) def wait_for_selection(self, dt_ms, touch_state,text=""): # Consider a hold when SELECT bit is down continuously is_select_down = (touch_state & (1 << TOUCH_SELECT)) != 0 if is_select_down: self.hold_ms = self.hold_ms + dt_ms else: self.hold_ms = 0 # OLED: show progress bar (clamped 0..1) frac = max(0.0, min(1.0, self.hold_ms / LONGPRESS_MS)) self._oled_draw_progress(frac, text) # If reached or exceeded threshold -> reset if self.hold_ms >= LONGPRESS_MS: self.reset_to_select() self._oled_clear() def reset_to_select(self): # rebuild pellets/maze + reset player/ghosts/score/lives self._build_level() self._setup_game() # ---------- Main step ---------- def step(self, dt): keys = pygame.key.get_pressed() # Get touch state by running qpad update (also mirrors to OLED) # PygameQPAD.update() uses the current display surface and sets qpad.state. # Some versions also return the state; support both. ret = self.qpad.update(self.oled_surf) touch_state = ret if isinstance(ret, int) else getattr(self.qpad, "state", 0) if self.state == self.STATE_SELECT: self.draw_select() self.update_select(touch_state) elif self.state == self.STATE_PLAY: self.update_play( keys, dt) self.draw_game() elif self.state == self.STATE_WIN: self.draw_outcome("YOU WIN!") self.wait_for_selection(dt*1000.0, touch_state, "Hold to Restart") elif self.state == self.STATE_LOSE: self.draw_outcome("GAME OVER") self.wait_for_selection(dt*1000.0, touch_state, "Hold to Restart") self.last_touch = touch_state # ----------------------- # Main # ----------------------- def main(): pygame.init() screen = pygame.display.set_mode((WIN_W, WIN_H)) pygame.display.set_caption("QPAD Pac-Man") qpad = PygameQPAD("/dev/cu.usbmodem1101", 84.5) clock = pygame.time.Clock() game = PacmanGame(screen, qpad) running = True while running: for e in pygame.event.get(): if e.type == pygame.QUIT: running = False dt = clock.tick(FPS) / 1000.0 game.step(dt) pygame.display.flip() pygame.quit() if __name__ == "__main__": main()