تمرین و پروژه‌های پایتون

ساخت بازی مار با پایتون و Pygame؛ پروژه کامل Snake با سورس کد

ساخت بازی مار با پایتون و Pygame

🗓 آخرین به‌روزرسانی:

ساخت بازی مار (Snake) با پایتون یکی از بهترین پروژه‌ها برای قدم گذاشتن در دنیای بازی‌سازی است. این بازی ساده به نظر می‌رسد، اما تقریباً همه مفاهیم اصلی یک بازی واقعی را در خود دارد: حلقه بازی، دریافت ورودی از کیبورد، حرکت روی شبکه، تشخیص برخورد، امتیاز و شروع دوباره. در این آموزش یک بازی مار کامل با کتابخانه Pygame می‌سازیم؛ ولی برخلاف بیشتر آموزش‌ها، منطق بازی را از بخش نمایش جدا می‌کنیم تا هر قانون بازی را بتوانیم جداگانه تست کنیم. همه کدها روی پایتون ۳.۱۳ و Pygame نسخه ۲.۶ اجرا شده‌اند، خروجی‌ها واقعی هستند و سورس کامل پروژه را هم می‌توانید به‌صورت فایل ZIP دانلود کنید.

در این پروژه چه چیزی می‌سازیم؟

نسخه نهایی بازی ما یک پنجره ۶۰۰ در ۴۰۰ پیکسلی است که به شبکه‌ای از خانه‌های ۲۰ پیکسلی تقسیم شده است. مار با کلیدهای جهت‌دار یا W، A، S و D حرکت می‌کند، با خوردن هر غذا یک خانه بلندتر می‌شود و اگر به دیوار یا بدن خودش بخورد، بازی تمام می‌شود. امکانات بازی این‌هاست:

  • حرکت روان روی شبکه با سرعت قابل تنظیم
  • جلوگیری از چرخش ۱۸۰ درجه که باعث مرگ ناگهانی می‌شود
  • غذای تصادفی که هیچ‌وقت روی بدن مار ظاهر نمی‌شود
  • امتیاز فعلی و بهترین امتیاز در بالای صفحه
  • پیام Game Over و شروع دوباره با کلید Space
  • یک حالت نمایشی خودکار (Demo) که بازی را بدون دخالت کاربر اجرا و تست می‌کند

نصب Pygame

اگر Pygame را نصب ندارید، بهتر است ابتدا یک محیط مجازی بسازید و سپس کتابخانه را با pip نصب کنید. جزئیات ساخت محیط مجازی را در آموزش pip و venv و رفع خطاهای نصب را در نصب کتابخانه در پایتون ببینید:

python -m venv venv
# Windows: venv\Scripts\activate   macOS/Linux: source venv/bin/activate
pip install pygame
python -c "import pygame; print(pygame.version.ver)"

ایده اصلی: جدا کردن منطق بازی از نمایش

بیشتر آموزش‌های بازی مار همه چیز را داخل یک حلقه بزرگ می‌نویسند: خواندن کلید، جابه‌جایی مار، بررسی برخورد و رسم صفحه. این روش برای بازی کوچک کار می‌کند، اما خیلی زود کد شلوغ می‌شود و پیدا کردن باگ سخت است. ما بازی را دو بخش می‌کنیم. بخش اول کلاس SnakeGame است که فقط قوانین بازی را می‌داند و هیچ ارتباطی با Pygame ندارد؛ مار کجاست، غذا کجاست و بعد از هر حرکت چه اتفاقی می‌افتد. بخش دوم تابع main است که با Pygame پنجره را می‌سازد، کلیدها را می‌خواند و وضعیت بازی را رسم می‌کند.

مزیت بزرگ این طراحی این است که قوانین بازی را بدون باز کردن پنجره و فقط با چند خط کد تست می‌کنیم؛ همان کاری که در ادامه برای هر قانون انجام می‌دهیم. همین الگو در بازی‌ها و برنامه‌های بزرگ‌تر هم استفاده می‌شود و به آن جداسازی مدل از نما می‌گویند.

مدل‌سازی مار با لیست مختصات

مار را به‌صورت لیستی از تاپل‌های مختصات (x, y) نگه می‌داریم. اولین عضو لیست سر مار و آخرین عضو دم آن است. حرکت مار ترفند ساده‌ای دارد: یک سر جدید در جهت حرکت به ابتدای لیست اضافه می‌کنیم و دم را از انتهای لیست برمی‌داریم. به این ترتیب طول مار ثابت می‌ماند و فقط جابه‌جا می‌شود:

snake = [(5, 5), (4, 5), (3, 5)]   # head first
direction = (1, 0)                   # moving right

for step in range(3):
    head_x, head_y = snake[0]
    new_head = (head_x + direction[0], head_y + direction[1])
    snake.insert(0, new_head)        # add a new head
    snake.pop()                      # remove the tail
    print(f"step {step + 1}: {snake}")

خروجی:

step 1: [(6, 5), (5, 5), (4, 5)]
step 2: [(7, 5), (6, 5), (5, 5)]
step 3: [(8, 5), (7, 5), (6, 5)]

جهت حرکت را هم به‌صورت یک بردار دو عددی نشان می‌دهیم: (1, 0) یعنی راست، (-1, 0) چپ، (0, -1) بالا و (0, 1) پایین. دقت کنید که در Pygame محور y رو به پایین است، پس بالا رفتن یعنی کم شدن y.

کلاس SnakeGame: قوانین بازی در یک جا

حالا همین ایده را داخل کلاس قرار می‌دهیم. متد reset مار سه‌خانه‌ای را وسط صفحه می‌گذارد، place_food یک خانه خالی تصادفی برای غذا انتخاب می‌کند و step مار را یک خانه جلو می‌برد. برای اینکه نتیجه‌ها قابل تکرار باشند، به کلاس یک seed می‌دهیم تا اعداد تصادفی هر بار یکسان باشند. سورس کامل کلاس را در انتهای مقاله آورده‌ایم؛ این‌جا رفتار آن را بررسی می‌کنیم:

from snake import SnakeGame

game = SnakeGame(cols=10, rows=8, seed=1)
print("snake:", game.snake)
print("food:", game.food, "score:", game.score)

for _ in range(2):
    game.step()
print("after 2 steps:", game.snake)
print("alive:", game.alive)

خروجی:

snake: [(5, 4), (4, 4), (3, 4)]
food: (2, 1) score: 0
after 2 steps: [(7, 4), (6, 4), (5, 4)]
alive: True

تابع place_food اول فهرست همه خانه‌هایی را می‌سازد که مار روی آن‌ها نیست و سپس یکی را تصادفی انتخاب می‌کند. این کار تضمین می‌کند غذا هیچ‌وقت زیر بدن مار ظاهر نشود؛ اشتباهی که در خیلی از نسخه‌های ساده بازی دیده می‌شود. برای آشنایی بیشتر با تولید عدد تصادفی، پروژه شبیه‌ساز تاس را ببینید.

جلوگیری از برگشت مار روی خودش

اگر مار به سمت راست حرکت می‌کند و بازیکن کلید چپ را بزند، سر مار مستقیم به گردنش برخورد می‌کند و بازی بی‌دلیل تمام می‌شود. متد turn این حرکت را رد می‌کند: اگر جمع بردار جهت جدید و جهت فعلی صفر شود، یعنی دو جهت دقیقاً مخالف هم هستند. نکته دیگر این است که جهت جدید را در next_direction ذخیره می‌کنیم و فقط در حرکت بعدی اعمال می‌کنیم؛ پس اگر بازیکن دو کلید را خیلی سریع پشت سر هم بزند، مار باز هم روی خودش برنمی‌گردد:

from snake import SnakeGame, UP, LEFT

game = SnakeGame(seed=3)
print("moving:", game.direction)

game.turn(LEFT)          # opposite direction: ignored
game.step()
print("after LEFT:", game.direction, "alive:", game.alive)

game.turn(UP)            # a real turn
game.step()
print("after UP:", game.direction, "head:", game.snake[0])

خروجی:

moving: (1, 0)
after LEFT: (1, 0) alive: True
after UP: (0, -1) head: (16, 9)

خوردن غذا و بلند شدن مار

وقتی سر جدید مار روی خانه غذا قرار بگیرد، امتیاز یکی زیاد می‌شود و این بار دم مار را حذف نمی‌کنیم؛ پس مار یک خانه بلندتر می‌شود. سپس غذای جدیدی در یک خانه خالی دیگر ظاهر می‌شود. برای تست، غذا را دستی درست جلوی سر مار می‌گذاریم:

from snake import SnakeGame

game = SnakeGame(seed=5)
head_x, head_y = game.snake[0]
game.food = (head_x + 1, head_y)     # put food right in front of the head

print("length before:", len(game.snake), "score:", game.score)
game.step()
print("length after:", len(game.snake), "score:", game.score)
print("new food is free:", game.food not in game.snake)

خروجی:

length before: 3 score: 0
length after: 4 score: 1
new food is free: True

تشخیص برخورد با دیوار و بدن

بازی در دو حالت تمام می‌شود: سر مار از شبکه بیرون برود یا به یکی از خانه‌های بدن خودش برسد. در بررسی برخورد با بدن از self.snake[:-1] استفاده کرده‌ایم، نه کل لیست؛ چون در همین حرکت دم مار یک خانه جلو می‌رود و رسیدن سر به خانه‌ای که دم همین حالا ترکش کرده، برخورد نیست. این جزئیات کوچک تفاوت یک بازی باگ‌دار با یک بازی درست است:

from snake import SnakeGame, UP, LEFT, DOWN

# 1) wall: keep moving right until the snake leaves the grid
game = SnakeGame(cols=10, rows=8, seed=2)
game.food = (0, 0)
steps = 0
while game.alive:
    game.step()
    steps += 1
print("hit the wall after", steps, "steps, head:", game.snake[0])

# 2) self: a long snake that turns into its own body
game = SnakeGame(cols=10, rows=8, seed=2)
game.snake = [(4, 4), (4, 5), (5, 5), (6, 5), (6, 4), (6, 3)]
print("head:", game.snake[0], "moving:", game.direction)
game.turn(DOWN)          # (4, 5) is part of the body
game.step()
print("after DOWN alive:", game.alive)

خروجی:

hit the wall after 5 steps, head: (9, 4)
head: (4, 4) moving: (1, 0)
after DOWN alive: False

در تست اول، سر مار در آخرین خانه مجاز یعنی (9, 4) می‌ماند و حرکت بعدی آن را از شبکه بیرون می‌برد. در تست دوم، مار به شکل قلاب خم شده و با چرخیدن به پایین به خانه (4, 5) می‌رسد که بخشی از بدن خودش است؛ پس alive به False تغییر می‌کند. بعد از مرگ، متد step هیچ کاری نمی‌کند تا بازیکن با Space بازی را از نو شروع کند.

حلقه اصلی بازی با Pygame

حالا به بخش نمایش می‌رسیم. هر بازی Pygame یک حلقه اصلی دارد که در هر دور سه کار انجام می‌دهد: رویدادها (Events) مثل فشردن کلید یا بستن پنجره را می‌خواند، وضعیت بازی را یک قدم جلو می‌برد و صفحه را دوباره رسم می‌کند. تابع clock.tick(FPS) سرعت حلقه را محدود می‌کند؛ با FPS برابر ۱۰، مار ده خانه در ثانیه حرکت می‌کند. ساختار حلقه در فایل نهایی این‌طور است:

while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            if event.key in KEYS:
                game.turn(KEYS[event.key])
            elif event.key == pygame.K_SPACE and not game.alive:
                game.reset()
    game.step()
    best = max(best, game.score)
    draw(screen, font, game, best)
    clock.tick(FPS)

دیکشنری KEYS هر کلید را به یک جهت نگاشت می‌کند تا به‌جای چند if پشت سر هم، با یک جست‌وجوی ساده در دیکشنری جهت را پیدا کنیم. تابع draw هم پس‌زمینه را با رنگ سرمه‌ای پر می‌کند، هر خانه مار را با pygame.draw.rect سبز و غذا را قرمز رسم می‌کند و امتیاز را با font.render بالای صفحه می‌نویسد. در پایان، pygame.display.flip() تصویر آماده را یک‌جا نمایش می‌دهد تا صفحه چشمک نزند.

یک نکته مهم در رسم این است که منطق بازی با «خانه» کار می‌کند و Pygame با «پیکسل». برای همین هنگام رسم، مختصات هر خانه را در اندازه خانه یعنی CELL ضرب می‌کنیم؛ مثلاً خانه (3, 2) در پیکسل (60, 40) رسم می‌شود. یک پیکسل هم از عرض و ارتفاع هر مربع کم کرده‌ایم تا بین خانه‌های بدن مار خط باریکی دیده شود و بدن مار قطعه‌قطعه و خواناتر به نظر برسد. اگر بخواهید اندازه صفحه را عوض کنید، کافی است ثابت‌های COLS، ROWS و CELL را تغییر دهید؛ بقیه کد خودش را با آن‌ها هماهنگ می‌کند.

اجرای بازی و حالت نمایشی خودکار

برای بازی کردن کافی است فایل را با python snake.py اجرا کنید. اما برای اینکه مطمئن شویم کل بازی، از حلقه Pygame تا رسم صفحه، بدون خطا کار می‌کند، یک حالت نمایشی هم ساخته‌ایم. در این حالت تابع auto_direction نقش بازیکن را دارد: از بین حرکت‌هایی که باعث برخورد نمی‌شوند، آن را انتخاب می‌کند که مار را به غذا نزدیک‌تر کند. کد زیر بازی واقعی را ۳۰۰ فریم بدون پنجره اجرا می‌کند:

from snake import main

score, best = main(max_frames=300, autoplay=True)
print(f"Demo finished after 300 frames: score={score}, best={best}")

خروجی:

Demo finished after 300 frames: score=15, best=15

این ربات ساده فقط یک قدم جلوتر را می‌بیند و برای همین در مارهای خیلی بلند گیر می‌افتد؛ اما برای تست بازی کاملاً کافی است. اگر علاقه دارید، می‌توانید آن را با الگوریتم جست‌وجوی مسیر مثل BFS هوشمندتر کنید.

سورس کامل بازی مار با پایتون

کد کامل بازی در ادامه آمده است. همین فایل را همراه با requirements.txt و راهنمای اجرا به‌صورت ZIP هم آماده کرده‌ایم:

⬇ دانلود سورس کامل بازی مار (ZIP)

"""Snake game with Pygame - Dev Source (dev-source.ir)"""
import random
import sys

import pygame

CELL = 20
COLS, ROWS = 30, 20
WIDTH, HEIGHT = CELL * COLS, CELL * ROWS
FPS = 10

UP, DOWN, LEFT, RIGHT = (0, -1), (0, 1), (-1, 0), (1, 0)


class SnakeGame:
    """Game rules only: no drawing, so it is easy to test."""

    def __init__(self, cols=COLS, rows=ROWS, seed=None):
        self.cols, self.rows = cols, rows
        self.rng = random.Random(seed)
        self.reset()

    def reset(self):
        cx, cy = self.cols // 2, self.rows // 2
        self.snake = [(cx, cy), (cx - 1, cy), (cx - 2, cy)]
        self.direction = RIGHT
        self.next_direction = RIGHT
        self.score = 0
        self.alive = True
        self.food = self.place_food()

    def place_food(self):
        free = [(x, y) for x in range(self.cols) for y in range(self.rows)
                if (x, y) not in self.snake]
        return self.rng.choice(free)

    def turn(self, new_direction):
        # ignore a turn that would reverse the snake into itself
        if (new_direction[0] + self.direction[0],
                new_direction[1] + self.direction[1]) != (0, 0):
            self.next_direction = new_direction

    def step(self):
        if not self.alive:
            return
        self.direction = self.next_direction
        head_x, head_y = self.snake[0]
        new_head = (head_x + self.direction[0], head_y + self.direction[1])

        hit_wall = not (0 <= new_head[0] < self.cols and 0 <= new_head[1] < self.rows)
        hit_self = new_head in self.snake[:-1]
        if hit_wall or hit_self:
            self.alive = False
            return

        self.snake.insert(0, new_head)
        if new_head == self.food:
            self.score += 1
            self.food = self.place_food()
        else:
            self.snake.pop()


KEYS = {pygame.K_UP: UP, pygame.K_DOWN: DOWN, pygame.K_LEFT: LEFT, pygame.K_RIGHT: RIGHT,
        pygame.K_w: UP, pygame.K_s: DOWN, pygame.K_a: LEFT, pygame.K_d: RIGHT}


def auto_direction(game):
    """Simple bot for the demo: the safe move that gets closest to the food."""
    hx, hy = game.snake[0]
    fx, fy = game.food
    best, best_dist = game.direction, None
    for d in (UP, DOWN, LEFT, RIGHT):
        if (d[0] + game.direction[0], d[1] + game.direction[1]) == (0, 0):
            continue
        nx, ny = hx + d[0], hy + d[1]
        if not (0 <= nx < game.cols and 0 <= ny < game.rows) or (nx, ny) in game.snake[:-1]:
            continue
        dist = abs(fx - nx) + abs(fy - ny)
        if best_dist is None or dist < best_dist:
            best, best_dist = d, dist
    return best


def draw(screen, font, game, best):
    screen.fill((11, 29, 63))
    for x, y in game.snake:
        pygame.draw.rect(screen, (61, 220, 132), (x * CELL, y * CELL, CELL - 1, CELL - 1))
    fx, fy = game.food
    pygame.draw.rect(screen, (255, 92, 92), (fx * CELL, fy * CELL, CELL - 1, CELL - 1))
    text = font.render(f"Score: {game.score}   Best: {best}", True, (255, 255, 255))
    screen.blit(text, (8, 6))
    if not game.alive:
        msg = font.render("Game Over - press SPACE to restart", True, (255, 201, 40))
        screen.blit(msg, msg.get_rect(center=(WIDTH // 2, HEIGHT // 2)))
    pygame.display.flip()


def main(max_frames=None, autoplay=False):
    pygame.init()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption("Snake - Dev Source")
    font = pygame.font.Font(None, 28)
    clock = pygame.time.Clock()
    game = SnakeGame(seed=7 if autoplay else None)
    best = 0
    frames = 0
    running = True
    while running:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN:
                if event.key in KEYS:
                    game.turn(KEYS[event.key])
                elif event.key == pygame.K_SPACE and not game.alive:
                    game.reset()
                elif event.key == pygame.K_ESCAPE:
                    running = False
        if autoplay:
            game.turn(auto_direction(game))
        game.step()
        best = max(best, game.score)
        draw(screen, font, game, best)
        clock.tick(FPS if not autoplay else 0)
        frames += 1
        if max_frames and frames >= max_frames:
            running = False
    pygame.quit()
    return game.score, best


if __name__ == "__main__":
    if "--demo" in sys.argv:
        score, best = main(max_frames=300, autoplay=True)
        print(f"Demo finished: score={score}, best={best}")
    else:
        main()

ایده‌هایی برای توسعه بازی

  • افزایش سرعت: بعد از هر ۵ امتیاز مقدار FPS را یکی زیاد کنید تا بازی سخت‌تر شود.
  • ذخیره بهترین امتیاز: بهترین امتیاز را در فایل JSON نگه دارید تا با بستن بازی پاک نشود؛ روش آن را در کار با فایل JSON ببینید.
  • عبور از دیوار: به‌جای مردن، سر مار را با عملگر باقی‌مانده (%) به طرف دیگر صفحه منتقل کنید.
  • صدا و تصویر: با pygame.mixer صدای خوردن غذا اضافه کنید و به‌جای مربع، تصویر سر مار را نمایش دهید.
  • مانع و مرحله: در هر مرحله چند دیوار ثابت داخل صفحه بگذارید.

خطاهای رایج و راه رفع

  • ModuleNotFoundError: No module named pygame: Pygame در محیط فعلی نصب نیست؛ راه‌حل را در رفع خطای ModuleNotFoundError ببینید.
  • پنجره «Not Responding» می‌شود: حلقه بازی باید در هر دور pygame.event.get() را صدا بزند؛ در غیر این صورت سیستم‌عامل فکر می‌کند برنامه قفل شده است.
  • مار خیلی سریع حرکت می‌کند: clock.tick(FPS) را فراموش کرده‌اید.
  • مار ناگهان می‌میرد: احتمالاً چرخش ۱۸۰ درجه را کنترل نکرده‌اید یا برخورد را با کل لیست بدن، به‌جای snake[:-1]، بررسی می‌کنید.
  • IndexError یا غذا روی بدن: غذا را فقط از بین خانه‌های خالی انتخاب کنید. برای خطاهای فهرست، رفع خطای IndexError را ببینید.

سوالات متداول

برای ساخت بازی مار با پایتون چه کتابخانه‌ای لازم است؟

رایج‌ترین انتخاب Pygame است که با دستور pip install pygame نصب می‌شود. نسخه‌های ساده‌تری هم با turtle یا tkinter ساخته می‌شوند، اما Pygame برای بازی‌سازی امکانات بیشتری دارد.

بازی مار با پایتون برای مبتدی‌ها مناسب است؟

بله. اگر با لیست، شرط، حلقه و تابع آشنا باشید، می‌توانید آن را بسازید. این پروژه مفاهیم حلقه بازی، ورودی کیبورد و تشخیص برخورد را به‌خوبی تمرین می‌دهد.

چطور سرعت مار را تغییر دهم؟

مقدار FPS در clock.tick تعداد حرکت مار در ثانیه را تعیین می‌کند. عدد بزرگ‌تر یعنی مار سریع‌تر.

چرا مار با زدن کلید مخالف می‌میرد و چطور جلویش را بگیرم؟

چون سر مار مستقیم به گردنش برخورد می‌کند. در متد turn اگر جمع جهت جدید و جهت فعلی صفر باشد، تغییر جهت را نادیده بگیرید.

آیا می‌توانم این بازی را روی گوشی اجرا کنم؟

Pygame برای کامپیوتر طراحی شده است. برای اندروید می‌توانید از ابزارهایی مثل Pydroid استفاده کنید؛ راهنمای آن را در آموزش اجرای پایتون روی گوشی ببینید.

چطور از بازی فایل اجرایی exe بسازم؟

با کتابخانه PyInstaller و دستور pyinstaller –onefile –noconsole snake.py یک فایل اجرایی ویندوزی ساخته می‌شود.

آموزش‌های مرتبط

آموزش‌های تازه، کد آماده و رفع خطاهر هفته در کانال Dev Source؛ رایگان.

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *