ساخت بازی ۲۰۴۸ با پایتون و Pygame

پروژه‌های پیشرفته پایتون و بازی‌سازی

این مطلب از سایت tool.hamidvalad.ir گرفته شده است

معرفی

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

نصب و پیش‌نیازها

برای شروع، نیاز دارید که پایتون (نسخه ۳ را توصیه می‌کنیم) بر روی سیستم شما نصب باشد. سپس باید کتابخانه مورد نیاز را از طریق خط فرمان نصب کنید:

pip install pygame

همچنین یک ویرایشگر کد مانند VS Code یا PyCharm برای راحتی کار مفید خواهد بود.

ساختار کلی کد

کد بازی از بخش‌های اصلی زیر تشکیل شده است:

  • مقداردهی اولیه Pygame و تعیین پارامترهای بازی (مانند ابعاد پنجره و نرخ فریم).
  • تعریف کلاس Tile برای نمایش و مدیریت هر کاشی روی صفحه.
  • توابع کمکی برای رسم شبکه، تولید کاشی‌های تصادفی و به‌روزرسانی رابط کاربری.
  • توابع اصلی برای پردازش حرکات (چپ، راست، بالا، پایین) و منطق ادغام کاشی‌ها.
  • تابع main که حلقه اصلی بازی را اجرا می‌کند.

توضیح کلاس Tile

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

class Tile:
    COLORS = [
        (237, 229, 218),
        (238, 225, 201),
        (243, 178, 122),
        # ... سایر رنگ‌ها
    ]

    def __init__(self, value, row, col):
        self.value = value
        self.row = row
        self.col = col
        self.x = col * RECT_WIDTH
        self.y = row * RECT_HEIGHT

    def get_color(self):
        color_index = int(math.log2(self.value)) - 1
        color = self.COLORS[color_index]
        return color

    def draw(self, window):
        color = self.get_color()
        pygame.draw.rect(window, color, (self.x, self.y, RECT_WIDTH, RECT_HEIGHT))
        text = FONT.render(str(self.value), 1, FONT_COLOR)
        window.blit(text, (self.x + (RECT_WIDTH / 2 - text.get_width() / 2),
                           self.y + (RECT_HEIGHT / 2 - text.get_height() / 2)))

منطق حرکت و ادغام

عملکرد اصلی بازی در تابع move_tiles تعریف شده است. این تابع با توجه به جهت حرکت (چپ، راست، بالا، پایین)، کاشی‌ها را مرتب کرده و آن‌ها را به صورت پله‌ای حرکت می‌دهد. اگر دو کاشی با مقدار برابر در مسیر حرکت به هم برسند، با یکدیگر ادغام شده و مقدارشان دوبرابر می‌شود. تابع با استفاده از Lambda Functionها و Closureها، منطق خاص هر جهت را پیاده‌سازی می‌کند.

def move_tiles(window, tiles, clock, direction):
    updated = True
    blocks = set()
    # ... تعریف پارامترهای ویژه هر جهت (sort_func, delta, boundary_check و ...)
    while updated:
        clock.tick(FPS)
        updated = False
        sorted_tiles = sorted(tiles.values(), key=sort_func, reverse=reverse)
        for i, tile in enumerate(sorted_tiles):
            # ... بررسی مرز، کاشی بعدی و شرایط ادغام یا حرکت
            if not next_tile:
                tile.move(delta)
            elif (tile.value == next_tile.value and tile not in blocks and next_tile not in blocks):
                if merge_check(tile, next_tile):
                    tile.move(delta)
                else:
                    next_tile.value *= 2
                    sorted_tiles.pop(i)
                    blocks.add(next_tile)
            elif move_check(tile, next_tile):
                tile.move(delta)
            else:
                continue
            tile.set_pos(ceil)
            updated = True
        update_tiles(window, tiles, sorted_tiles)
    return end_move(tiles)

کد کامل بازی

در زیر کد کامل و یکپارچه پروژه قرار داده شده است. شما می‌توانید این کد را در یک فایل با نام 2048.py ذخیره و اجرا کنید.

import pygame
import random
import math

pygame.init()

FPS = 60
WIDTH, HEIGHT = 800, 800
ROWS = 4
COLS = 4
RECT_HEIGHT = HEIGHT // ROWS
RECT_WIDTH = WIDTH // COLS
OUTLINE_COLOR = (187, 173, 160)
OUTLINE_THICKNESS = 10
BACKGROUND_COLOR = (205, 192, 180)
FONT_COLOR = (119, 110, 101)
FONT = pygame.font.SysFont("comicsans", 60, bold=True)
MOVE_VEL = 20
WINDOW = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("2048")

class Tile:
    COLORS = [
        (237, 229, 218),
        (238, 225, 201),
        (243, 178, 122),
        (246, 150, 101),
        (247, 124, 95),
        (247, 95, 59),
        (237, 208, 115),
        (237, 204, 99),
        (236, 202, 80),
    ]
    def __init__(self, value, row, col):
        self.value = value
        self.row = row
        self.col = col
        self.x = col * RECT_WIDTH
        self.y = row * RECT_HEIGHT
    def get_color(self):
        color_index = int(math.log2(self.value)) - 1
        color = self.COLORS[color_index]
        return color
    def draw(self, window):
        color = self.get_color()
        pygame.draw.rect(window, color, (self.x, self.y, RECT_WIDTH, RECT_HEIGHT))
        text = FONT.render(str(self.value), 1, FONT_COLOR)
        window.blit(
            text,
            (
                self.x + (RECT_WIDTH / 2 - text.get_width() / 2),
                self.y + (RECT_HEIGHT / 2 - text.get_height() / 2),
            ),
        )
    def set_pos(self, ceil=False):
        if ceil:
            self.row = math.ceil(self.y / RECT_HEIGHT)
            self.col = math.ceil(self.x / RECT_WIDTH)
        else:
            self.row = math.floor(self.y / RECT_HEIGHT)
            self.col = math.floor(self.x / RECT_WIDTH)
    def move(self, delta):
        self.x += delta[0]
        self.y += delta[1]

def draw_grid(window):
    for row in range(1, ROWS):
        y = row * RECT_HEIGHT
        pygame.draw.line(window, OUTLINE_COLOR, (0, y), (WIDTH, y), OUTLINE_THICKNESS)
    for col in range(1, COLS):
        x = col * RECT_WIDTH
        pygame.draw.line(window, OUTLINE_COLOR, (x, 0), (x, HEIGHT), OUTLINE_THICKNESS)
    pygame.draw.rect(window, OUTLINE_COLOR, (0, 0, WIDTH, HEIGHT), OUTLINE_THICKNESS)

def draw(window, tiles):
    window.fill(BACKGROUND_COLOR)
    for tile in tiles.values():
        tile.draw(window)
    draw_grid(window)
    pygame.display.update()

def get_random_pos(tiles):
    row = None
    col = None
    while True:
        row = random.randrange(0, ROWS)
        col = random.randrange(0, COLS)
        if f"{row}{col}" not in tiles:
            break
    return row, col

def move_tiles(window, tiles, clock, direction):
    updated = True
    blocks = set()
    if direction == "left":
        sort_func = lambda x: x.col
        reverse = False
        delta = (-MOVE_VEL, 0)
        boundary_check = lambda tile: tile.col == 0
        get_next_tile = lambda tile: tiles.get(f"{tile.row}{tile.col - 1}")
        merge_check = lambda tile, next_tile: tile.x > next_tile.x + MOVE_VEL
        move_check = (
            lambda tile, next_tile: tile.x > next_tile.x + RECT_WIDTH + MOVE_VEL
        )
        ceil = True
    elif direction == "right":
        sort_func = lambda x: x.col
        reverse = True
        delta = (MOVE_VEL, 0)
        boundary_check = lambda tile: tile.col == COLS - 1
        get_next_tile = lambda tile: tiles.get(f"{tile.row}{tile.col + 1}")
        merge_check = lambda tile, next_tile: tile.x < next_tile.x - MOVE_VEL
        move_check = (
            lambda tile, next_tile: tile.x + RECT_WIDTH + MOVE_VEL < next_tile.x
        )
        ceil = False
    elif direction == "up":
        sort_func = lambda x: x.row
        reverse = False
        delta = (0, -MOVE_VEL)
        boundary_check = lambda tile: tile.row == 0
        get_next_tile = lambda tile: tiles.get(f"{tile.row - 1}{tile.col}")
        merge_check = lambda tile, next_tile: tile.y > next_tile.y + MOVE_VEL
        move_check = (
            lambda tile, next_tile: tile.y > next_tile.y + RECT_HEIGHT + MOVE_VEL
        )
        ceil = True
    elif direction == "down":
        sort_func = lambda x: x.row
        reverse = True
        delta = (0, MOVE_VEL)
        boundary_check = lambda tile: tile.row == ROWS - 1
        get_next_tile = lambda tile: tiles.get(f"{tile.row + 1}{tile.col}")
        merge_check = lambda tile, next_tile: tile.y < next_tile.y - MOVE_VEL
        move_check = (
            lambda tile, next_tile: tile.y + RECT_HEIGHT + MOVE_VEL < next_tile.y
        )
        ceil = False
    while updated:
        clock.tick(FPS)
        updated = False
        sorted_tiles = sorted(tiles.values(), key=sort_func, reverse=reverse)
        for i, tile in enumerate(sorted_tiles):
            if boundary_check(tile):
                continue
            next_tile = get_next_tile(tile)
            if not next_tile:
                tile.move(delta)
            elif (
                tile.value == next_tile.value
                and tile not in blocks
                and next_tile not in blocks
            ):
                if merge_check(tile, next_tile):
                    tile.move(delta)
                else:
                    next_tile.value *= 2
                    sorted_tiles.pop(i)
                    blocks.add(next_tile)
            elif move_check(tile, next_tile):
                tile.move(delta)
            else:
                continue
            tile.set_pos(ceil)
            updated = True
        update_tiles(window, tiles, sorted_tiles)
    return end_move(tiles)

def end_move(tiles):
    if len(tiles) == 16:
        return "lost"
    row, col = get_random_pos(tiles)
    tiles[f"{row}{col}"] = Tile(random.choice([2, 4]), row, col)
    return "continue"

def update_tiles(window, tiles, sorted_tiles):
    tiles.clear()
    for tile in sorted_tiles:
        tiles[f"{tile.row}{tile.col}"] = tile
    draw(window, tiles)

def generate_tiles():
    tiles = {}
    for _ in range(2):
        row, col = get_random_pos(tiles)
        tiles[f"{row}{col}"] = Tile(2, row, col)
    return tiles

def main(window):
    clock = pygame.time.Clock()
    run = True
    tiles = generate_tiles()
    while run:
        clock.tick(FPS)
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                run = False
                break
            if event.type == pygame.KEYDOWN:
                if event.key == pygame.K_LEFT:
                    move_tiles(window, tiles, clock, "left")
                if event.key == pygame.K_RIGHT:
                    move_tiles(window, tiles, clock, "right")
                if event.key == pygame.K_UP:
                    move_tiles(window, tiles, clock, "up")
                if event.key == pygame.K_DOWN:
                    move_tiles(window, tiles, clock, "down")
        draw(window, tiles)
    pygame.quit()

if __name__ == "__main__":
    main(WINDOW)

نحوه اجرا

پس از ذخیره کردن کد در یک فایل با پسوند .py، می‌توانید آن را از طریق خط فرمان یا ترمینال اجرا کنید. کافیست در مسیر فایل، دستور زیر را وارد نمایید:

python 2048.py

پس از اجرا، پنجره بازی باز می‌شود. با استفاده از کلیدهای جهت‌دار صفحه کلید (بالا، پایین، چپ، راست) می‌توانید کاشی‌ها را حرکت دهید. هدف، رسیدن به کاشی ۲۰۴۸ است. در صورت پر شدن تمام خانه‌ها و عدم امکان حرکت، بازی با پیام مناسبی پایان می‌یابد (که در این نسخه ساده، خروج از بازی است).

توسعه و بهبود پروژه

می‌توانید قابلیت‌های زیر را به این پروژه اضافه کنید تا بازی کامل‌تر و جذاب‌تر شود:

  • افزایش اندازه صفحه (مثلاً ۵ در ۵) و تغییر رنگ‌ها.
  • اضافه کردن سیستم امتیازدهی (Score) برای هر ادغام.
  • ذخیره بهترین امتیاز (High Score) در یک فایل.
  • اضافه کردن منوی شروع بازی (Start Menu) و صفحه پایان بازی (Game Over).
  • افزودن قابلیت بازی مجدد (Restart) بدون نیاز به بستن پنجره.
  • بهینه‌سازی منطق حرکت و ادغام برای کارایی بهتر.

منابع و مستندات