ESP32로 만든 레트로 감성, 퐁(PONG) 게임기

비디오 게임 역사의 시조새이자 대중화의 일등 공신인 퐁(Pong)의 유래

💡 퐁(Pong)의 탄생과 유래

1. 앨런 알코어와 아타리(Atari)의 만남

퐁은 1972년, 전설적인 게임 회사 **아타리(Atari)**의 공동 창업자인 **놀런 부슈널(Nolan Bushnell)**의 아이디어에서 시작되었습니다. 부슈널은 갓 입사한 엔지니어 **앨런 알코어(Allan Alcorn)**의 실력을 테스트하기 위해 간단한 과제를 하나 주었습니다. 그것이 바로 ‘화면에 점 하나와 막대기 두 개가 있는 테니스 게임’을 만드는 것이었습니다.

2. 마그나복스 오디세이에서의 영감

사실 퐁은 완전한 무(無)에서 탄생한 것은 아닙니다. 부슈널은 세계 최초의 가정용 게임기인 **마그나복스 오디세이(Magnavox Odyssey)**의 탁구 게임 시연을 본 적이 있었고, 여기서 영감을 얻어 알코어에게 제작을 지시했습니다.

TMI: 이후 마그나복스는 아타리를 상대로 저작권 소송을 제기했고, 아타리가 로열티를 지불하며 합의에 이르게 됩니다. 이것이 비디오 게임 역사상 최초의 저작권 분쟁으로 기록되어 있습니다.

3. ‘퐁’이라는 이름의 유래

원래 게임의 이름은 단순히 ‘테니스(Tennis)’가 될 뻔했습니다. 하지만 이미 다른 게임들이 그 이름을 사용하고 있었기 때문에, 새로운 이름이 필요했습니다. 부슈널은 공이 패들에 부딪힐 때 나는 특유의 “퐁(Pong)” 하는 효과음에서 착안하여 이름을 지었습니다.


🚀 퐁이 세운 기록들

  • 최초의 상업적 성공: 캘리포니아의 한 술집(Andy Capp’s Tavern)에 설치된 퐁 아케이드 기기는 며칠 만에 동전 통이 꽉 차서 고장 날 정도로 폭발적인 인기를 끌었습니다.
  • 비디오 게임 산업의 시작: 퐁의 성공은 비디오 게임이 단순히 연구소의 실험물이 아니라, 거대한 상업적 가치가 있는 산업임을 증명했습니다.
  • 가정용 게임기 열풍: 1975년 출시된 ‘홈 퐁(Home Pong)’ 버전은 크리스마스 시즌에 엄청난 판매고를 올리며 거실을 오락실로 바꾸어 놓았습니다.

🎨 퐁의 시각적 구성

퐁의 그래픽은 지금 보면 매우 단순하지만, 당시에는 혁명이었습니다.

  • 패들(Paddle): 플레이어가 조종하는 수직 막대
  • 볼(Ball): 화면을 오가는 사각형 점
  • 점수판: 화면 상단에 표시되는 숫자
출처 : 유튜브 https://youtube/sPsAYDMgjvE?si=0uzZDce-8tUOireC

부품기능ESP32-C3 핀비고
TFT LCDSCK (Clock)GPIO 4하드웨어 SPI 고정
MOSI (Data)GPIO 6하드웨어 SPI 고정
RES (Reset)GPIO 10일반 출력
DC (Data/Cmd)GPIO 1일반 출력
CS (Select)GPIO 0일반 출력
입력 장치POT_L (가변저항)GPIO 3ADC1_CH3
POT_R (가변저항)GPIO 2ADC1_CH2
BUTTON (시작/정지)GPIO 9내부 풀업 사용
출력 장치BUZZERGPIO 7PWM 출력

Python pong.py

from machine import Pin, SPI, ADC, PWM
import st7789py as st7789
import time
import random
import vga2_8x16 as font

# ==========================================================
# 1. HARDWARE PIN CONFIGURATION (핀 설정)
# ==========================================================
LCD_SCK  = 4
LCD_MOSI = 6
LCD_RES  = 10
LCD_DC   = 1
LCD_CS   = 0

PIN_POT_L = 3   # ADC
PIN_POT_R = 2   # ADC
PIN_BTN   = 9   # Digital (Pull-up)
PIN_BUZZER = 7  # PWM

# ==========================================================
# 2. 초기화 (Hardware Initialization)
# ==========================================================
spi = SPI(1, baudrate=60000000, sck=Pin(LCD_SCK), mosi=Pin(LCD_MOSI))
tft = st7789.ST7789(spi, 240, 320, reset=Pin(LCD_RES, Pin.OUT), 
                    cs=Pin(LCD_CS, Pin.OUT), dc=Pin(LCD_DC, Pin.OUT), rotation=1)

pot_l = ADC(Pin(PIN_POT_L))
pot_r = ADC(Pin(PIN_POT_R))
pot_l.atten(ADC.ATTN_11DB)
pot_r.atten(ADC.ATTN_11DB)

buzzer = PWM(Pin(PIN_BUZZER), duty_u16=0)
btn = Pin(PIN_BTN, Pin.IN, Pin.PULL_UP)

# ==========================================================
# 3. 게임 설정 및 상태 변수
# ==========================================================
WIDTH, HEIGHT = 320, 240
P_W, P_H = 8, 50
P1_X, P2_X = 8, 304
BALL_SIZE = 8
SENSITIVITY, FIXED_SPEED = 3.5, 3.2
SCORE_AREA_Y, RESTART_THRESHOLD = 25, 150
WINNING_SCORE = 5

CYAN, MAGENTA = st7789.CYAN, st7789.MAGENTA
WHITE, BLACK, YELLOW, GRAY = st7789.WHITE, st7789.BLACK, st7789.YELLOW, 0x4208
COLORS = [st7789.RED, st7789.GREEN, st7789.BLUE, st7789.YELLOW, st7789.MAGENTA, st7789.CYAN, st7789.WHITE]

smooth_l, smooth_r, ALPHA = 2048, 2048, 0.2
game_running, first_start = False, True
played_boot_melody = False 
score_l, score_r = 0, 0
p1_y, p2_y = (HEIGHT-P_H)//2, (HEIGHT-P_H)//2
last_btn_state = 1
ready_l, ready_r = False, False

# ==========================================================
# 4. 핵심 기능 함수
# ==========================================================
def play_sound(freq, duration):
    try: buzzer.freq(freq); buzzer.duty_u16(32768); time.sleep(duration); buzzer.duty_u16(0)
    except: pass

def play_start_melody():
    for f in [523, 659, 784]:
        play_sound(f, 0.08); time.sleep(0.02)

def draw_court_static():
    tft.fill(BLACK)
    tft.fill_rect(0, SCORE_AREA_Y, WIDTH, 2, WHITE) 
    tft.fill_rect(0, HEIGHT-2, WIDTH, 2, WHITE)    
    for y in range(SCORE_AREA_Y + 5, HEIGHT, 20):
        tft.fill_rect(WIDTH//2 - 1, y, 2, 10, GRAY)

def draw_score_only(sl, sr):
    tft.fill_rect(40, 0, 240, SCORE_AREA_Y, BLACK)
    tft.text(font, f"SCORE {sl}", 45, 5, CYAN)
    tft.text(font, f"SCORE {sr}", WIDTH-115, 5, MAGENTA)

def show_winner(winner_text, color):
    tft.fill(BLACK)
    for _ in range(50):
        x, y = random.randint(50, 270), random.randint(50, 190)
        c = random.choice(COLORS)
        size = random.randint(3, 8)
        for i in range(10): tft.fill_rect(x + random.randint(-20, 20), y + random.randint(-20, 20), size, size, c)
        play_sound(random.randint(400, 1200), 0.02)
    tft.fill(BLACK)
    tft.text(font, "VICTORY!", 120, 80, YELLOW)
    tft.text(font, winner_text, 100, 110, color)
    tft.text(font, "PRESS BUTTON TO RESTART", 65, 160, WHITE)
    while btn.value() == 1: time.sleep(0.1)
    global score_l, score_r, first_start, played_boot_melody
    score_l, score_r = 0, 0
    first_start, played_boot_melody = True, True # 재시작 시 멜로디 생략
    draw_court_static(); draw_score_only(0, 0)
    tft.text(font, "PONG CLASSIC", 110, 100, YELLOW)
    tft.text(font, "PRESS BUTTON TO START", 75, 130, WHITE)
    time.sleep(0.5)

def init_ball(direction=None):
    if direction is None: # 첫 판 (가운데 출발)
        return [WIDTH//2, (HEIGHT+SCORE_AREA_Y)//2, random.choice([-1, 1]) * FIXED_SPEED, (random.random() - 0.5) * 2]
    elif direction == 1: # 왼쪽 승자 서브
        return [P1_X + P_W + 5, p1_y + (P_H // 2) - (BALL_SIZE // 2), FIXED_SPEED, (random.random() - 0.5) * 2]
    else: # 오른쪽 승자 서브
        return [P2_X - BALL_SIZE - 5, p2_y + (P_H // 2) - (BALL_SIZE // 2), -FIXED_SPEED, (random.random() - 0.5) * 2]

# ==========================================================
# 5. 메인 실행 루프
# ==========================================================
draw_court_static(); draw_score_only(0, 0)
tft.text(font, "PONG CLASSIC", 110, 100, YELLOW)
tft.text(font, "PRESS BUTTON TO START", 75, 130, WHITE)

ball = init_ball()
last_raw_l, last_raw_r = pot_l.read(), pot_r.read()

while True:
    btn_val = btn.value()
    if first_start:
        if not played_boot_melody:
            play_start_melody()
            played_boot_melody = True
        if btn_val == 0:
            first_start, game_running = False, True
            play_sound(1000, 0.1); tft.fill_rect(70, 100, 200, 50, BLACK)
            draw_score_only(score_l, score_r); time.sleep(0.2)
    else:
        if last_btn_state == 1 and btn_val == 0:
            game_running = not game_running
            if not game_running: tft.text(font, "- PAUSED -", 125, 120, YELLOW, BLACK)
            else: tft.fill_rect(120, 120, 100, 20, BLACK)
            time.sleep(0.2)
    last_btn_state = btn_val

    # 1. 패들 업데이트
    smooth_l = (ALPHA * pot_l.read()) + ((1 - ALPHA) * smooth_l)
    smooth_r = (ALPHA * pot_r.read()) + ((1 - ALPHA) * smooth_r)
    target_p1 = max(SCORE_AREA_Y+2, min(HEIGHT-P_H-2, int((smooth_l * SENSITIVITY / 4095) * (HEIGHT-P_H-SCORE_AREA_Y) + SCORE_AREA_Y)))
    target_p2 = max(SCORE_AREA_Y+2, min(HEIGHT-P_H-2, int((smooth_r * SENSITIVITY / 4095) * (HEIGHT-P_H-SCORE_AREA_Y) + SCORE_AREA_Y)))
    p1_move_delta = (target_p1 - p1_y) * 0.3
    p2_move_delta = (target_p2 - p2_y) * 0.3

    for p_info in [(target_p1, p1_y, P1_X, CYAN), (target_p2, p2_y, P2_X, MAGENTA)]:
        t_y, c_y, x, col = p_info
        if t_y != c_y:
            diff = t_y - c_y
            if diff > 0:
                tft.fill_rect(x, c_y, P_W, diff, BLACK)
                tft.fill_rect(x, t_y + P_H - diff, P_W, diff, col)
            else:
                tft.fill_rect(x, c_y + P_H + diff, P_W, -diff, BLACK)
                tft.fill_rect(x, t_y, P_W, -diff, col)
    p1_y, p2_y = target_p1, target_p2

    if not game_running:
        if not first_start and (score_l > 0 or score_r > 0) and not (ready_l and ready_r):
            if abs(smooth_l - last_raw_l) > RESTART_THRESHOLD: ready_l = True
            if abs(smooth_r - last_raw_r) > RESTART_THRESHOLD: ready_r = True
            tft.text(font, "READY" if ready_l else "WAIT ", 55, 110, CYAN if ready_l else GRAY, BLACK)
            tft.text(font, "READY" if ready_r else "WAIT ", 225, 110, MAGENTA if ready_r else GRAY, BLACK)
            if ready_l and ready_r:
                game_running, ready_l, ready_r = True, False, False
                play_sound(1200, 0.05); tft.fill_rect(50, 105, 220, 25, BLACK)
                last_raw_l, last_raw_r = smooth_l, smooth_r
        continue

    # 2. 공 물리 엔진 (진동 해결 및 패들 반사)
    last_ball = ball[:]
    ball[0] += ball[2]; ball[1] += ball[3]

    if ball[1] <= SCORE_AREA_Y + 2:
        ball[3] = abs(ball[3]); ball[1] = SCORE_AREA_Y + 3; play_sound(800, 0.01)
    elif ball[1] >= HEIGHT - BALL_SIZE - 2:
        ball[3] = -abs(ball[3]); ball[1] = HEIGHT - BALL_SIZE - 3; play_sound(800, 0.01)

    if ball[0] <= P1_X + P_W and p1_y <= ball[1] <= p1_y + P_H:
        ball[2] = abs(ball[2]); ball[0] = P1_X + P_W + 1
        hit_f = (ball[1] + BALL_SIZE/2 - (p1_y + P_H/2)) / (P_H/2)
        ball[3] = (hit_f * FIXED_SPEED) + p1_move_delta; play_sound(1000, 0.01)
    elif ball[0] >= P2_X - BALL_SIZE and p2_y <= ball[1] <= p2_y + P_H:
        ball[2] = -abs(ball[2]); ball[0] = P2_X - BALL_SIZE - 1
        hit_f = (ball[1] + BALL_SIZE/2 - (p2_y + P_H/2)) / (P_H/2)
        ball[3] = (hit_f * FIXED_SPEED) + p2_move_delta; play_sound(1000, 0.01)

    # 3. 득점 및 승자 판정
    if ball[0] < -BALL_SIZE or ball[0] > WIDTH:
        tft.fill_rect(int(last_ball[0]), int(last_ball[1]), BALL_SIZE, BALL_SIZE, BLACK)
        tft.fill_rect(0, SCORE_AREA_Y + 2, P1_X, HEIGHT - SCORE_AREA_Y - 4, BLACK)
        tft.fill_rect(P2_X + P_W, SCORE_AREA_Y + 2, WIDTH - (P2_X + P_W), HEIGHT - SCORE_AREA_Y - 4, BLACK)
        
        if ball[0] < 0: score_r += 1; next_w = -1
        else: score_l += 1; next_w = 1
        
        draw_score_only(score_l, score_r)
        if score_l >= WINNING_SCORE: show_winner("PLAYER 1 WINS", CYAN); ball = init_ball(); continue
        elif score_r >= WINNING_SCORE: show_winner("PLAYER 2 WINS", MAGENTA); ball = init_ball(); continue
        
        ball = init_ball(next_w); last_raw_l, last_raw_r = smooth_l, smooth_r; game_running = False; continue

    # 4. 드로잉
    tft.fill_rect(int(last_ball[0]), int(last_ball[1]), BALL_SIZE, BALL_SIZE, BLACK)
    if last_ball[1] <= SCORE_AREA_Y + BALL_SIZE + 2: tft.fill_rect(int(last_ball[0]), SCORE_AREA_Y, BALL_SIZE, 2, WHITE)
    if last_ball[1] >= HEIGHT - BALL_SIZE - 4: tft.fill_rect(int(last_ball[0]), HEIGHT-2, BALL_SIZE, 2, WHITE)
    if abs(int(last_ball[0]) - WIDTH//2) < 5:
        for y in range(SCORE_AREA_Y + 5, HEIGHT, 20):
            if int(last_ball[1]) - 10 < y < int(last_ball[1]) + BALL_SIZE:
                tft.fill_rect(WIDTH//2 - 1, y, 2, 10, GRAY)
    tft.fill_rect(int(ball[0]), int(ball[1]), BALL_SIZE, BALL_SIZE, YELLOW)
    time.sleep(0.005)

코멘트

“ESP32로 만든 레트로 감성, 퐁(PONG) 게임기” 에 하나의 답글

  1. micro2iot 아바타

    제 블로그에서 주로 다루는 스마트홈이나 RC카와는 조금 다른 결의 DIY였지만, 사실 모든 메이킹의 뿌리는 같습니다. 이번 퐁 게임 제작은 ESP32의 ADC 제어와 하드웨어 인터럽트, 그리고 사운드 피드백을 정교하게 다뤄보는 일종의 **’기초 체력 훈련’**이었습니다. RC카의 정밀한 조향이나 스마트홈의 센싱 데이터 처리 역시 이러한 기본기에서 시작되니까요. 가벼운 연습이었지만, 덕분에 하드웨어를 다루는 감각이 한층 더 날카로워진 기분입니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다