# main.py — RP2040 Pico + SSD1306 I2C (128x64) + 2 botones + Audio PWM (FINAL sin NeoPixel)
# Start screen: "PICO" / "PONG!" (3x) centrado (en área útil), pelota sólida con contorno rebotando (velocidad 4x vs original),
# y '>' parpadeante alineado a la derecha en la última línea. La pelota crea efecto "negativo" sobre el texto (relleno negro + contorno blanco).
# HUD: 3 bolitas con contorno (vidas); Game Over con banda blanca y texto negro.
from machine import Pin, I2C, PWM
import framebuf
import time
import urandom
# =========================
# Configuración
# =========================
WIDTH, HEIGHT = 128, 64
I2C_ID = 0
I2C_SDA = 0 # GP0
I2C_SCL = 1 # GP1
OLED_ADDR = 0x3C
BTN_LEFT = 16 # "<"
BTN_RIGHT = 17 # ">" (start / reset)
AUDIO_PIN = 18 # PWM buzzer pasivo
BALLS_START = 3
# =========================
# Driver mínimo SSD1306
# =========================
class SSD1306:
def __init__(self, width, height, external_vcc=False):
self.width = width
self.height = height
self.external_vcc = external_vcc
self.pages = self.height // 8
self.buffer = bytearray(self.width * self.pages)
self.fb = framebuf.FrameBuffer(self.buffer, self.width, self.height, framebuf.MONO_VLSB)
self.init_display()
def init_display(self):
for cmd in (0xAE, 0x20, 0x00, 0x40, 0xA1, 0xC8, 0xDA, 0x12, 0x81, 0xCF,
0xA4, 0xA6, 0xD5, 0x80, 0x8D, 0x14, 0xD9, 0xF1, 0xDB, 0x40, 0x2E, 0xAF):
self.write_cmd(cmd)
self.fill(0); self.show()
def fill(self, c): self.fb.fill(c)
def pixel(self, x, y, c): self.fb.pixel(x, y, c)
def rect(self, x, y, w, h, c): self.fb.rect(x, y, w, h, c)
def fill_rect(self, x, y, w, h, c): self.fb.fill_rect(x, y, w, h, c)
def line(self, x0, y0, x1, y1, c): self.fb.line(x0, y0, x1, y1, c)
def text(self, s, x, y, c=1): self.fb.text(s, x, y, c)
def show(self):
for page in range(self.pages):
self.write_cmd(0xB0 | page)
self.write_cmd(0x00)
self.write_cmd(0x10)
i = self.width * page
self.write_data(self.buffer[i:i + self.width])
class SSD1306_I2C(SSD1306):
def __init__(self, w, h, i2c, addr=0x3C, external_vcc=False):
self.i2c = i2c; self.addr = addr
super().__init__(w, h, external_vcc)
def write_cmd(self, cmd): self.i2c.writeto(self.addr, bytes([0x80, cmd]))
def write_data(self, buf): self.i2c.writeto(self.addr, bytes([0x40]) + buf)
# =========================
# Inicialización HW
# =========================
i2c = I2C(I2C_ID, sda=Pin(I2C_SDA), scl=Pin(I2C_SCL), freq=400_000)
oled = SSD1306_I2C(WIDTH, HEIGHT, i2c, addr=OLED_ADDR)
btnL = Pin(BTN_LEFT, Pin.IN, Pin.PULL_UP)
btnR = Pin(BTN_RIGHT, Pin.IN, Pin.PULL_UP)
buzzer = PWM(Pin(AUDIO_PIN))
buzzer.duty_u16(0)
# =========================
# Utilidades
# =========================
_now = time.ticks_ms
_diff = time.ticks_diff
def text_w(s): return len(s) * 8
def text_h(): return 8
# --- Helpers de texto escalado (MONO_VLSB correcto) ---
def text_scale(oled, s, x, y, scale=2, color=1):
"""Dibuja 's' escalado (2x, 3x, ...) con el color indicado (1=blanco, 0=negro)."""
if not s or scale <= 0:
return
w_small = len(s) * 8
h_small = 8
tmp = bytearray(w_small * h_small // 8)
fb = framebuf.FrameBuffer(tmp, w_small, h_small, framebuf.MONO_VLSB)
fb.fill(0)
fb.text(s, 0, 0, 1)
for yy in range(h_small):
byte_row_offset = (yy >> 3) * w_small
bit_mask = 1 << (yy & 7)
for xx in range(w_small):
byte_i = xx + byte_row_offset
if tmp[byte_i] & bit_mask:
oled.fill_rect(x + xx*scale, y + yy*scale, scale, scale, color)
def measure_text_scale(s, scale=2):
return (len(s) * 8 * scale, 8 * scale)
# Círculos (contorno / relleno)
def draw_circle(x0, y0, r, color=1):
x = r; y = 0; err = 1 - x
while x >= y:
for dx, dy in ((x, y), (y, x)):
oled.pixel(x0 + dx, y0 + dy, color)
oled.pixel(x0 + dy, y0 + dx, color)
oled.pixel(x0 - dy, y0 + dx, color)
oled.pixel(x0 - x, y0 + y, color)
oled.pixel(x0 - x, y0 - y, color)
oled.pixel(x0 - dy, y0 - dx, color)
oled.pixel(x0 + dy, y0 - dx, color)
oled.pixel(x0 + x, y0 - y, color)
y += 1
if err < 0:
err += 2 * y + 1
else:
x -= 1
err += 2 * (y - x + 1)
def fill_circle(x0, y0, r, color=1):
for y in range(-r, r + 1):
yy = y0 + y
if yy < 0 or yy >= HEIGHT: continue
dx = int((r*r - y*y) ** 0.5)
x1 = max(0, x0 - dx)
x2 = min(WIDTH - 1, x0 + dx)
oled.hline(x1, yy, x2 - x1 + 1, color)
# Añadir hline/vline si faltan
if not hasattr(oled, 'hline'):
def _hline(x, y, w, c): oled.line(x, y, x + w - 1, y, c)
oled.hline = _hline
if not hasattr(oled, 'vline'):
def _vline(x, y, h, c): oled.line(x, y, x, y + h - 1, c)
oled.vline = _vline
# =========================
# Estados / Juego
# =========================
STATE_START, STATE_PLAYING, STATE_GAMEOVER = 0, 1, 2
state = STATE_START
score = BALLS_START
# Paleta / Bola / Modos (modo juego)
paddleW = 24
paddleH = 4
paddleY = HEIGHT - paddleH - 1
paddleX = (WIDTH - paddleW) // 2
paddleSpeed = 5.2
ballX = WIDTH / 2
ballY = HEIGHT / 3
ballVX = 1.7
ballVY = 1.7
ballR = 2
MODE_NORMAL, MODE_FAST, MODE_BIGSLOW = 0, 1, 2
DUR_NORMAL, DUR_FAST, DUR_BIGSLOW = 20, 5, 3
speedMode = MODE_NORMAL
speedMultiplier = 1.0
modeStartSec = 0
# Timing, cronómetro y parpadeo
FPS = 60
FRAME_MS = int(1000 / FPS)
lastFrame = _now()
runStartMs = _now()
lastSecondMs = runStartMs
elapsedSec = 0
finalTimeCaptured = False
finalElapsedSec = 0
BEST_FILE = 'pong_best.txt'
blinkOn = True
lastBlink = _now()
BLINK_INTERVAL = 500
hasPlayedFirstStartBeep = False
# =========================
# Audio (tone-like)
# =========================
VOLUME = 0.45
def tone(freq, ms):
if freq <= 0 or ms <= 0: return
buzzer.freq(int(freq)); buzzer.duty_u16(int(32767 * VOLUME))
time.sleep_ms(ms); buzzer.duty_u16(0)
def beep(freq, ms, gap=0):
tone(freq, ms)
if gap: time.sleep_ms(gap)
def sfxPaddleHit(): beep(2400, 25, 0)
def sfxWallBounce(): beep(1800, 4, 2); beep(1500, 3, 0)
def sfxLosePoint():
for _ in range(2): beep(300, 120, 80)
def sfxStartFirst(): beep(1100, 120, 0)
def sfxParty(ms=2000):
notes = [880, 988, 1047, 1175, 1319, 1480]
onMs, offMs = 90, 30
end = _now() + ms; i = 0
while _diff(end, _now()) > 0:
beep(notes[i % len(notes)], onMs, offMs); i += 1
# =========================
# Persistencia BEST
# =========================
def load_best():
try:
with open(BEST_FILE, 'r') as f:
return max(0, int(f.read().strip()))
except Exception:
return 0
def save_best(v):
try:
with open(BEST_FILE, 'w') as f:
f.write(str(int(v)))
except Exception:
pass
bestTimeSec = load_best()
# =========================
# Lógica / helpers
# =========================
def format_mmss(secs):
mm = secs // 60; ss = secs % 60
return "%02d:%02d" % (mm, ss)
def reset_ball():
global ballX, ballY, ballVX, ballVY
ballX = WIDTH / 2; ballY = HEIGHT / 3
s = 1.7
ballVX = s if (urandom.getrandbits(1)) else -s
ballVY = s
def set_speed_mode(m):
global speedMode, speedMultiplier, modeStartSec
speedMode = m
speedMultiplier = 1.0 if m == MODE_NORMAL else (2.0 if m == MODE_FAST else 0.25)
modeStartSec = elapsedSec
def next_speed_mode():
set_speed_mode(MODE_FAST if speedMode == MODE_NORMAL else (MODE_BIGSLOW if speedMode == MODE_FAST else MODE_NORMAL))
def update_speed_mode():
dtm = elapsedSec - modeStartSec
if (speedMode == MODE_NORMAL and dtm >= DUR_NORMAL) or (speedMode == MODE_FAST and dtm >= DUR_FAST) or (speedMode == MODE_BIGSLOW and dtm >= DUR_BIGSLOW):
next_speed_mode()
def reset_runtime():
global runStartMs, lastSecondMs, elapsedSec, finalTimeCaptured, finalElapsedSec
runStartMs = _now(); lastSecondMs = runStartMs; elapsedSec = 0
set_speed_mode(MODE_NORMAL)
finalTimeCaptured = False; finalElapsedSec = 0
def reset_game():
global score, paddleX
score = BALLS_START
paddleX = (WIDTH - paddleW) / 2
reset_ball(); reset_runtime()
# --- HUD: vidas con contorno ---
def draw_lives(count, total=BALLS_START, r=2, spacing=10, x0=2, y=4):
xs = [x0 + i*spacing for i in range(total)]
for i in range(min(count, total)): # relleno de las vivas
fill_circle(xs[i], y, r, 1)
for i in range(total): # contorno para todas
draw_circle(xs[i], y, r, 1)
def draw_hud(best):
draw_lives(score)
t = format_mmss(elapsedSec)
oled.text(t, WIDTH - text_w(t) - 1, 0)
def draw_paddle():
oled.fill_rect(int(paddleX), paddleY, paddleW, paddleH, 1)
def draw_ball():
bx = int(round(ballX)); by = int(round(ballY)); r = ballR
if speedMode == MODE_BIGSLOW:
r = ballR * 2; fill_circle(bx, by, r, 1)
elif speedMode == MODE_FAST:
draw_circle(bx, by, r, 1)
else:
fill_circle(bx, by, r, 1)
def handle_buttons_paddle():
global paddleX
if btnL.value() == 0: paddleX -= paddleSpeed
if btnR.value() == 0: paddleX += paddleSpeed
if paddleX < 0: paddleX = 0
if paddleX > (WIDTH - paddleW): paddleX = WIDTH - paddleW
def capture_final_and_update_best():
global finalTimeCaptured, finalElapsedSec, bestTimeSec
if not finalTimeCaptured:
finalElapsedSec = int(_diff(_now(), runStartMs) / 1000)
finalTimeCaptured = True
if finalElapsedSec > bestTimeSec:
bestTimeSec = finalElapsedSec; save_best(bestTimeSec); sfxParty(2000)
def update_ball():
global ballX, ballY, ballVX, ballVY, score, state
ballX += ballVX * speedMultiplier
ballY += ballVY * speedMultiplier
if ballX - ballR <= 0:
ballX = ballR; ballVX = abs(ballVX); sfxWallBounce()
elif ballX + ballR >= WIDTH:
ballX = WIDTH - ballR; ballVX = -abs(ballVX); sfxWallBounce()
if ballY - ballR <= 0:
ballY = ballR; ballVY = abs(ballVY); sfxWallBounce()
if ballVY > 0 and (ballY + ballR >= paddleY):
if paddleX <= ballX <= (paddleX + paddleW):
ballY = paddleY - ballR; ballVY = -abs(ballVY)
cx = paddleX + paddleW / 2
offset = (ballX - cx) / (paddleW / 2)
maxVX = 2.8
ballVX += offset * 0.8
if ballVX > maxVX: ballVX = maxVX
if ballVX < -maxVX: ballVX = -maxVX
acc = 1.04; ballVX *= acc; ballVY *= acc
sfxPaddleHit()
else:
score -= 1; sfxLosePoint()
if score <= 0:
capture_final_and_update_best(); state = STATE_GAMEOVER
else:
reset_ball()
# ============ Animación en Start: pelota rebotando (ahora 4× vs original base) ==========
# Nota: antes teníamos ~1.35/1.10 (1x). Luego 2x: 2.70/2.20. Ahora 4x: 5.40/4.40.
sball_x = 64.0
sball_y = 24.0
sball_vx = 5.40
sball_vy = 4.40
sball_r = 5
START_USABLE_H = 56 # altura útil (reservamos la última fila 56..63 para el prompt)
def init_start_anim():
global sball_x, sball_y, sball_vx, sball_vy, sball_r
sball_x = 64.0
sball_y = 24.0
sball_vx = (5.40) * (1 if urandom.getrandbits(1) else -1)
sball_vy = (4.40) * (1 if urandom.getrandbits(1) else -1)
sball_r = 5
def update_start_ball():
global sball_x, sball_y, sball_vx, sball_vy
x_min = sball_r
x_max = WIDTH - 1 - sball_r
y_min = sball_r
y_max = START_USABLE_H - 1 - sball_r
sball_x += sball_vx
sball_y += sball_vy
if sball_x <= x_min:
sball_x = x_min; sball_vx = -sball_vx
elif sball_x >= x_max:
sball_x = x_max; sball_vx = -sball_vx
if sball_y <= y_min:
sball_y = y_min; sball_vy = -sball_vy
elif sball_y >= y_max:
sball_y = y_max; sball_vy = -sball_vy
def draw_start_screen():
oled.fill(0)
# Texto 3x centrado horizontal y vertical dentro de área útil (0..55)
title1 = 'PICO'
title2 = 'PONG!'
scale = 3
w1, h1 = measure_text_scale(title1, scale)
w2, h2 = measure_text_scale(title2, scale)
spacing = 4
total_h = h1 + spacing + h2 # 24 + 4 + 24 = 52
# Centro vertical en 0..55
y_top = (START_USABLE_H - total_h) // 2
x1 = (WIDTH - w1) // 2
x2 = (WIDTH - w2) // 2
text_scale(oled, title1, x1, y_top, scale, 1)
text_scale(oled, title2, x2, y_top + h1 + spacing, scale, 1)
# Pelota con efecto negativo: primero relleno negro, luego contorno blanco
update_start_ball()
bx, by, rr = int(sball_x), int(sball_y), sball_r
fill_circle(bx, by, rr, 0)
draw_circle(bx, by, rr, 1)
# Prompt inferior: '>' parpadeante alineado a la derecha (última línea)
if blinkOn:
arrow = '>'
ax = WIDTH - text_w(arrow) - 8
oled.text(arrow, ax, 56, 1)
oled.show()
# --- GAME OVER ---
def draw_game_over(blink):
oled.fill(0)
header_h = 14
oled.fill_rect(0, 0, WIDTH, header_h, 1) # blanco
title = 'GAME OVER'
tw = text_w(title); tx = (WIDTH - tw)//2
oled.text(title, tx, 3, 0) # negro sobre blanco
t = 'Tiempo: ' + format_mmss(finalElapsedSec)
bt = 'Best: ' + format_mmss(bestTimeSec)
oled.text(t, (WIDTH - text_w(t)) // 2, 26)
oled.text(bt, (WIDTH - text_w(bt)) // 2, 38)
if blink:
arrow = '>'
ax = (WIDTH - text_w(arrow)) // 2
oled.text(arrow, ax, 56, 1)
oled.show()
# =========================
# Setup / estado inicial
# =========================
state = STATE_START
blinkOn = True
lastBlink = _now()
init_start_anim()
# =========================
# Bucle principal
# =========================
lastFrame = _now()
while True:
now = _now()
if _diff(now, lastFrame) < FRAME_MS:
time.sleep_ms(1); continue
lastFrame = now
if state == STATE_START:
if _diff(now, lastBlink) >= BLINK_INTERVAL:
blinkOn = not blinkOn; lastBlink = now
draw_start_screen()
if btnR.value() == 0:
if not hasPlayedFirstStartBeep:
sfxStartFirst(); hasPlayedFirstStartBeep = True
time.sleep_ms(60)
while btnR.value() == 0: time.sleep_ms(10)
reset_game(); state = STATE_PLAYING
elif state == STATE_PLAYING:
if _diff(now, lastSecondMs) >= 1000:
steps = _diff(now, lastSecondMs) // 1000
elapsedSec += int(steps); lastSecondMs += steps * 1000
update_speed_mode()
handle_buttons_paddle(); update_ball()
oled.fill(0); draw_hud(bestTimeSec); draw_paddle(); draw_ball(); oled.show()
else: # STATE_GAMEOVER
if _diff(now, lastBlink) >= BLINK_INTERVAL:
blinkOn = not blinkOn; lastBlink = now
draw_game_over(blinkOn)
if btnR.value() == 0:
time.sleep_ms(60)
while btnR.value() == 0: time.sleep_ms(10)
state = STATE_START; blinkOn = True; lastBlink = _now()
init_start_anim()