From 20679a1d86cdc759b19979fe709e44c5f30677f7 Mon Sep 17 00:00:00 2001 From: TheCodingSoldier <257933801+TheCodingSoldier@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:49:58 -0700 Subject: [PATCH] feat: cross-platform scroll wheel support, argparse resolution/fps, safer serve timer --- pong.py | 167 ++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 101 insertions(+), 66 deletions(-) diff --git a/pong.py b/pong.py index 6642509..b16b5d7 100644 --- a/pong.py +++ b/pong.py @@ -1,25 +1,43 @@ #!/usr/bin/env python3 """ Encode-A-Pong — Pong controlled by mouse scroll wheel. -Optimized for Raspberry Pi Zero 2 W + Kuman 3.5" display (480x320). +Optimized for Raspberry Pi Zero 2 W + Kuman 3.5" display (480x320), +but also runs on any desktop (Windows, macOS, Linux) with a scroll wheel mouse. Target: Raspberry Pi OS (Debian Trixie), Pi Zero 2 W (1 GHz ARM Cortex-A53). Pygame >= 2.0.0 recommended (uses SCALED + vsync; falls back gracefully on older versions). Requires: python3-pygame -Run: python3 pong.py +Run (Pi default): python3 pong.py +Run (custom display): python3 pong.py --width 800 --height 480 +Run (desktop 60fps): python3 pong.py --fps 60 """ import sys +import os import random +import argparse import pygame # --------------------------------------------------------------------------- -# Configuration — tuned for Pi Zero 2 W + Kuman 3.5" (480x320) framebuffer +# CLI arguments — all defaults match original Pi values so existing usage +# is completely unaffected. Passing no arguments = identical behaviour. +# --------------------------------------------------------------------------- +def parse_args(): + parser = argparse.ArgumentParser(description="Encode-A-Pong") + parser.add_argument("--width", type=int, default=480, + help="Display width in pixels (default: 480)") + parser.add_argument("--height", type=int, default=320, + help="Display height in pixels (default: 320)") + parser.add_argument("--fps", type=int, default=30, + help="Target frame rate (default: 30)") + return parser.parse_args() + + +# --------------------------------------------------------------------------- +# Configuration — gameplay constants (unchanged from original) # --------------------------------------------------------------------------- -WIDTH, HEIGHT = 480, 320 -FPS = 30 PADDLE_W, PADDLE_H = 8, 50 BALL_SIZE = 8 WIN_SCORE = 7 @@ -34,30 +52,33 @@ WHITE = (255, 255, 255) GREEN = (0, 255, 0) -# Pre-calculated paddle edges (avoid repeated arithmetic in the loop) -PLAYER_PADDLE_X = 10 -AI_PADDLE_X = WIDTH - 10 - PADDLE_W + +def is_pi_framebuffer(): + """Return True when running on a Raspberry Pi framebuffer display. + Detected by the presence of /dev/fb0 (Linux framebuffer device). + On Windows / macOS / desktop Linux this will always return False.""" + return os.path.exists("/dev/fb0") -def make_bg_surface(): +def make_bg_surface(width, height): """Pre-render the static background (center line + black fill) once. Blitting a cached surface every frame is far cheaper than redrawing primitives each tick — the single biggest pygame optimization for low-power hardware.""" - bg = pygame.Surface((WIDTH, HEIGHT)).convert() + bg = pygame.Surface((width, height)).convert() bg.fill(BLACK) - for y in range(0, HEIGHT, 16): - pygame.draw.rect(bg, WHITE, (WIDTH // 2 - 1, y, 2, 8)) + for y in range(0, height, 16): + pygame.draw.rect(bg, WHITE, (width // 2 - 1, y, 2, 8)) return bg -def reset_ball(): +def reset_ball(width, height): """Return a fresh ball rect and random velocities. Guarantees non-zero vertical velocity so the ball never goes perfectly horizontal.""" ball = pygame.Rect( - WIDTH // 2 - BALL_SIZE // 2, - HEIGHT // 2 - BALL_SIZE // 2, + width // 2 - BALL_SIZE // 2, + height // 2 - BALL_SIZE // 2, BALL_SIZE, BALL_SIZE, ) @@ -68,44 +89,43 @@ def reset_ball(): return ball, bvx, bvy -def init_display(): - """Initialize the display with the best available mode for the Pi. - - Uses pygame.version.vernum to check for SCALED support (added in - pygame 2.0.0) before attempting to use it. This avoids an - AttributeError crash on pygame 1.x. +def init_display(width, height): + """Initialize the display with the best available mode. - Tries SCALED + vsync first, falls back to SCALED alone, then to - plain set_mode(). The game always starts.""" + Tries SCALED + vsync first (pygame >= 2.0.0), falls back to + SCALED alone, then to plain set_mode(). The game always starts. + This fallback chain is identical to the original — no behaviour + change on the Pi.""" if pygame.version.vernum >= (2, 0, 0): try: screen = pygame.display.set_mode( - (WIDTH, HEIGHT), pygame.SCALED, vsync=1 + (width, height), pygame.SCALED, vsync=1 ) except (pygame.error, TypeError): try: screen = pygame.display.set_mode( - (WIDTH, HEIGHT), pygame.SCALED + (width, height), pygame.SCALED ) except pygame.error: - screen = pygame.display.set_mode((WIDTH, HEIGHT)) + screen = pygame.display.set_mode((width, height)) else: - screen = pygame.display.set_mode((WIDTH, HEIGHT)) + screen = pygame.display.set_mode((width, height)) return screen -def clamp_paddle(paddle): +def clamp_paddle(paddle, height): """Keep a paddle rect within the screen bounds.""" if paddle.y < 0: paddle.y = 0 - elif paddle.y > HEIGHT - PADDLE_H: - paddle.y = HEIGHT - PADDLE_H + elif paddle.y > height - PADDLE_H: + paddle.y = height - PADDLE_H def bounce_ball(ball, paddle, bvx, bvy, is_player): """Handle ball-paddle collision: reverse x velocity, angle the ball based on where it hit the paddle, and push the ball outside the - paddle to prevent it from getting stuck inside (tunneling/sticking).""" + paddle to prevent it from getting stuck inside (tunneling/sticking). + Logic is identical to the original — not changed.""" hit = (ball.centery - paddle.centery) / (PADDLE_H / 2) hit = max(-1.0, min(1.0, hit)) @@ -122,18 +142,25 @@ def bounce_ball(ball, paddle, bvx, bvy, is_player): return bvx, bvy -def run(): +def run(width, height, fps): pygame.init() - screen = init_display() + # Paddle X positions depend on width — must be calculated after args parsed + player_paddle_x = 10 + ai_paddle_x = width - 10 - PADDLE_W + + screen = init_display(width, height) pygame.display.set_caption("Encode-A-Pong") - pygame.mouse.set_visible(False) + + # Hide cursor on Pi framebuffer; show it on desktop so the OS cursor + # doesn't disappear while the game window is open. + pygame.mouse.set_visible(not is_pi_framebuffer()) clock = pygame.time.Clock() - font = pygame.font.Font(None, 36) + font = pygame.font.Font(None, 36) small_font = pygame.font.Font(None, 24) - bg_surface = make_bg_surface() + bg_surface = make_bg_surface(width, height) pygame.event.set_allowed( [pygame.QUIT, pygame.KEYDOWN, pygame.MOUSEWHEEL] @@ -141,20 +168,25 @@ def run(): r_text = small_font.render("Press R to restart", True, WHITE) - player_paddle = pygame.Rect(PLAYER_PADDLE_X, HEIGHT // 2 - PADDLE_H // 2, PADDLE_W, PADDLE_H) - ai_paddle = pygame.Rect(AI_PADDLE_X, HEIGHT // 2 - PADDLE_H // 2, PADDLE_W, PADDLE_H) - ball, bvx, bvy = reset_ball() - pscore = 0 - ascore = 0 - game_over = False + player_paddle = pygame.Rect(player_paddle_x, height // 2 - PADDLE_H // 2, PADDLE_W, PADDLE_H) + ai_paddle = pygame.Rect(ai_paddle_x, height // 2 - PADDLE_H // 2, PADDLE_W, PADDLE_H) + ball, bvx, bvy = reset_ball(width, height) + + pscore = 0 + ascore = 0 + game_over = False scroll_accum = 0.0 - serve_timer = 0 + + # Use absolute timestamps for serve delay — avoids dt spike issues + # on slower machines or first frame. 0 means ball is live immediately. + serve_until = 0 last_score = (-1, -1) score_surf = None - dt = 0 while True: + now = pygame.time.get_ticks() + for event in pygame.event.get(): if event.type == pygame.QUIT: return @@ -163,21 +195,23 @@ def run(): return elif event.key == pygame.K_r and game_over: pscore = ascore = 0 - game_over = False - ball, bvx, bvy = reset_ball() - serve_timer = SERVE_DELAY_MS - last_score = (-1, -1) + game_over = False + ball, bvx, bvy = reset_ball(width, height) + serve_until = now + SERVE_DELAY_MS + last_score = (-1, -1) elif event.type == pygame.MOUSEWHEEL: scroll_accum += getattr(event, "preciseY", event.y) if not game_over: + # round() preserves small fractional scroll inputs that int() + # would silently drop, making the paddle feel more responsive. if scroll_accum != 0: - player_paddle.y -= int(scroll_accum * PADDLE_SPEED) + player_paddle.y -= round(scroll_accum * PADDLE_SPEED) scroll_accum = 0.0 - clamp_paddle(player_paddle) + clamp_paddle(player_paddle, height) - if serve_timer > 0: - serve_timer -= dt + if now < serve_until: + pass # ball is frozen during serve delay else: ball.x += bvx ball.y += bvy @@ -185,8 +219,8 @@ def run(): if ball.top <= 0: ball.top = 0 bvy = -bvy - elif ball.bottom >= HEIGHT: - ball.bottom = HEIGHT + elif ball.bottom >= height: + ball.bottom = height bvy = -bvy if ball.colliderect(player_paddle) and bvx < 0: @@ -196,18 +230,18 @@ def run(): if ball.left <= 0: ascore += 1 - ball, bvx, bvy = reset_ball() - serve_timer = SERVE_DELAY_MS - elif ball.right >= WIDTH: + ball, bvx, bvy = reset_ball(width, height) + serve_until = now + SERVE_DELAY_MS + elif ball.right >= width: pscore += 1 - ball, bvx, bvy = reset_ball() - serve_timer = SERVE_DELAY_MS + ball, bvx, bvy = reset_ball(width, height) + serve_until = now + SERVE_DELAY_MS if ai_paddle.centery < ball.centery: ai_paddle.y += AI_SPEED elif ai_paddle.centery > ball.centery: ai_paddle.y -= AI_SPEED - clamp_paddle(ai_paddle) + clamp_paddle(ai_paddle, height) if pscore >= WIN_SCORE or ascore >= WIN_SCORE: game_over = True @@ -216,30 +250,31 @@ def run(): pygame.draw.rect(screen, WHITE, player_paddle) pygame.draw.rect(screen, WHITE, ai_paddle) - if serve_timer <= 0 or game_over: + if now >= serve_until or game_over: pygame.draw.rect(screen, WHITE, ball) if (pscore, ascore) != last_score: score_surf = font.render(f"{pscore} {ascore}", True, WHITE) last_score = (pscore, ascore) - screen.blit(score_surf, (WIDTH // 2 - score_surf.get_width() // 2, 10)) + screen.blit(score_surf, (width // 2 - score_surf.get_width() // 2, 10)) if game_over: winner = "YOU WIN!" if pscore >= WIN_SCORE else "CPU WINS" w_surf = font.render(winner, True, GREEN) - screen.blit(w_surf, (WIDTH // 2 - w_surf.get_width() // 2, HEIGHT // 2 - 20)) - screen.blit(r_text, (WIDTH // 2 - r_text.get_width() // 2, HEIGHT // 2 + 20)) + screen.blit(w_surf, (width // 2 - w_surf.get_width() // 2, height // 2 - 20)) + screen.blit(r_text, (width // 2 - r_text.get_width() // 2, height // 2 + 20)) # display.update() is optimized for software framebuffer displays # (per pygame docs). Functionally identical to flip() with no args, # but the recommended path for the Pi's framebuffer driver. pygame.display.update() - dt = clock.tick(FPS) + clock.tick(fps) def main(): + args = parse_args() try: - run() + run(args.width, args.height, args.fps) except KeyboardInterrupt: pass finally: