Cách thiết kế game Tower Defense bằng Pyglet [P1]
Tags: python
Published: 2020-03-27 20:45:12

Hi all! Trong bài viết này mình xin giới thiệu với tất cả mọi người một thư viện yêu thích của mình để viết game bằng Python, đó là Pyglet. Pyglet rất đơn giản và tiện lợi với những đoạn code ngắn để thiết kế game, giúp cho việc development nhanh hơn rất nhiều so với những ngôn ngữ khác. Thư viện này rất phù hợp cho những bạn mới bắt đầu vào con đường lập trình game, hoặc chỉ đơn giản để viết bài tập lớn cho những môn học trên trường :) 

Sau đây chúng ta sẽ thử viết 1 project bằng Pyglet, cụ thể là 1 game Tower Defense vô cùng hấp dân.

# 1. Cài đặt Python3 trên máy tính
Đối với máy Windows, các bạn vào link sau để tải Python3:
https://www.python.org/downloads/release/python-382/
Sau đó các bạn chạy file tải về để cài đặt, lưu ý trong khi cài nhớ chọn option "Add Python to PATH" để sau này ta có thể chạy Python3 thuận tiện trên cmd.
Với phần lớn các phiên bản của Linux, các bạn đã có python3 ngay trong máy của mình

# 2. Cài đặt Pyglet trên máy tính
- Việc cài đặt thư viện này vô cùng đơn giản, ta chỉ cần chạy 1 lệnh (trên cmd với Windows/ trên Terminal với Linux) như sau:
_pip install pyglet_
Đợi lệnh chạy xong là chúng ta đã cài thành công thư viện của mình.
Việc gọi thư viện trong mã nguồn cũng rất đơn giản như sau:
```python
import pyglet
```

# 3. Thiết lập các folder cho project
Trước khi bắt tay vào việc viết mã nguồn, mình tạo các folder và file như sau:
```
.
├── res
│   └── res
│       ├── GFX
│       │   ├── Game
│       │   │   ├── Enemy
│       │   │   │   ├── Boss Enemy
│       │   │   │   ├── HealthBar
│       │   │   │   ├── Normal Enemy
│       │   │   │   ├── Smaller Enemy
│       │   │   │   └── Tanker Enemy
│       │   │   ├── Tilemap
│       │   │   │   ├── Ground
│       │   │   │   ├── Road
│       │   │   │   ├── Spawner
│       │   │   │   └── Target
│       │   │   └── Tower
│       │   │       ├── BuyNUpgrade.png
│       │   │       ├── Machine Gun Tower
│       │   │       ├── Normal Tower
│       │   │       └── Sniper Tower
│       │   └── GUI
│       │       └── Button
│       └── SFX
└── src
    ├── Entity
    ├── __init__.py
    ├── main.py
    ├── mapInfo.txt
    ├── Screen
    └── Utils
```
Trong lập trình game, OOP là vô cùng quan trọng, vì vậy việc chia các folder cho những đối tượng làm việc trong game giúp chúng ta dễ dàng quản lý dự án và viết code nhanh hơn.
Thư mục `res` sẽ chứa toàn bộ các file đồ họa (GFX), âm thanh (SFX), mọi tài nguyên này sẽ có trong repo Github của dự án mình đính kèm cuối bài viết.
Thư mục `src` tất nhiên là sẽ chứa mã nguồn, trong đó :
- `main.py` là chương trình chính, để khởi động game ta sẽ chạy file này.
- `mapInfo.txt` chứa dữ liệu map, đường đi trong game
- `Screen` là folder chứa interface cho màn hình hiển thị game, cùng với mã nguồn của màn hình menu bắt đầu game và màn hình chơi
- `Utils` chứa interface và mã nguồn của một số công cụ hỗ trợ render đồ họa, tính toán trong game
- `Entity` chứa interface và mã nguồn của các đối tượng game như tháp, kẻ địch, đạn và đường đi, shop mua bán vật phẩm,...
Các bạn cũng đừng quên tạo các file 
`__init__.py` trống ở mỗi thư mục để mọi file mã nguồn có thể import các mã nguồn khác, làm việc liên kết với nhau.

# Và giờ chúng ta hãy bắt tay vào viết code thôi !
## 1. Viết class Texture để render các đối tượng đồ họa :
Đây là class quan trọng nhất của game, thực hiện công việc hiển thị, di chuyển các object của game, ta sẽ viết mã nguồn cho class này ở file `texture.py` trong thư mục `Utils`:
```python
import pyglet
from Utils.drawing_util import Utils

class Texture(Utils):
    def __init__(self, *args):
        if len(args) == 1:
            path = args[0]
            self.image = pyglet.image.load(path)
            self.sprite = pyglet.sprite.Sprite(self.image)
        elif len(args) == 3:
            path, x, y = args[0], args[1], args[2]
            self.image = pyglet.image.load(path)
            self.sprite = pyglet.sprite.Sprite(self.image)
            self.sprite.x, self.sprite.y = x, y

    def change_image(self, path):
        backup_data = (self.sprite.scale, self.sprite.x, self.sprite.y)
        self.image = pyglet.image.load(path)
        self.sprite = pyglet.sprite.Sprite(self.image)
        self.sprite.scale = backup_data[0]
        self.sprite.x, self.sprite.y = backup_data[1], backup_data[2]
```
Từ đó chúng ta có thể sử dụng class này ở các file mã nguồn khác, tạo ra những đối tượng đồ họa và làm việc với chúng.
Giải thích các thuộc tính của class Texture:
- `image`: chứa ảnh hiển thị của đối tượng
- `sprite`: có thể coi chính là bản thân đối tượng được tạo từ ảnh, thư viện Pyglet sẽ draw từ thuộc tính
- Thuộc tính `sprite` cũng là class có sẵn trong Pyglet, có các thuộc tính `x`, `y` là vị trí hiển thị trên màn hình. `scale` là kích cỡ hiển thị, nếu `scale == 1.0` thì đối tượng sẽ có kích cỡ y hệt như ảnh.

Class Texture được thừa kế từ interface Utils, được cài đặt trong file Utils/drawing_utils.py - chứa các hàm để xử lý đối tượng sprite như căn chỉnh kích cỡ, đổi vị trí,... File này được viết như sau:
```python
class Utils(object):
    def __init__(self):
        pass

    def get_x(self):
        return self.sprite.x

    def get_y(self):
        return self.sprite.y

    def set_x(self, value):
        self.sprite.x = value

    def set_y(self, value):
        self.sprite.y = value

    def set_coordinate(self, x, y):
        self.sprite.x, self.sprite.y = x, y

    def scale(self, ratio):
        self.sprite.scale = ratio

    def get_width(self):
        return self.sprite.width

    def get_height(self):
        return self.sprite.height

    def set_width(self, value):
        self.sprite.width = value

    def set_height(self, value):
        self.sprite.height = value

    def draw(self):
        self.sprite.draw()
```

## 2. Viết phần khởi động chương trình game ở `main.py`
```python
import pyglet
from pyglet.gl import gl
from pkg_resources import parse_version

window = pyglet.window.Window(fullscreen=False)
win_max_x = window.width
win_max_y = window.height

@window.event
def on_draw():
    pass

if __name__ == "__main__":
    pyglet.app.run()
```
Mã nguồn trên sẽ tạo ra 1 cửa sổ mới khi chúng ta chạy chương trình. Cửa sổ này đang đen xì vì chúng ta chưa render gì lên cả (hàm `on_draw()` chưa làm gì). Nếu muốn game chạy toàn màn hình các bạn có thể thay đổi tham số `fullscreen=True` trong constructor `pyglet.window.Window()`. `win_max_x`, `win_max_y` chứa thông tin kích cỡ màn hình. Tiếp đó chúng ta sẽ viết code cho các màn hình game .
## 2. Viết mã nguồn cho screen
Vì game sẽ có nhiều màn hình chơi (menu start game, màn hình chơi game chính,..) mỗi màn có các chức năng khác nhau nên mình sẽ viết ra 1 interface chung cho các screen trong file `screen.py`:
```python
class Screen(object):
    def __init__(self):
        self.on_display = False

    def draw(self):
        pass

    def mouse_clicked(self, x, y):
        pass

    def on_mouse_motion(self, x, y):
        pass

    def on_hover(self, x, y, sprite):
        if sprite.get_x() <= x <= sprite.get_x() + sprite.get_width():
            if sprite.get_y() <= y <= sprite.get_y() + sprite.get_height():
                return True
        return False
```
Với code như trên, chúng ta mặc định khi một màn mới được tạo ra thì sẽ không hiển thị, các thao tác làm việc với chuột, phím chưa được định nghĩa mà sẽ mô tả cụ thể ở các class con ( các màn hình cụ thể ). Hàm `on_hover()` dùng để xác định xem chúng ta có di chuột lên một đối tượng (spirte) cụ thể nào trên màn hình không - một hàm mà ta sẽ sử dụng rất nhiều sau này, thậm chí là ngay tới đây khi ta viết code cho màn hình bắt đầu game tại file `start_screen.py` trong folder `Screen`.
```python
from Utils.texture import Texture
from Screen.screen import Screen
import pyglet

class StartScreen(Screen):
    def __init__(self, x, y):
        self.on_display = True
        self.width, self.height = x, y
        self.start_button_on_hover, self.load_button_on_hover = False, False
        self.switched, self.load = False, False

        path = ["./../res/res/GFX/GUI/Background/Background_main_screen.jpg",
                "./../res/res/GFX/GUI/Button/button.png",
                "./../res/res/GFX/GUI/Button/button-selected.png",
                "./../res/res/GFX/GUI/Button/LoadButton.png",
                "./../res/res/GFX/GUI/Button/LoadButton_selected.png",]
        background_image_path = path[0]
        self.start_button_image_path = [path[1], path[2]]
        self.load_button_image_path = [path[3], path[4]]

        self.background = Texture(path[0])
        self.background.scale(self.width / self.background.get_width())

        self.start_button = Texture(self.start_button_image_path[0])
        x = self.width // 2 - (self.start_button.get_width() // 2)
        y = self.height // 2 - 150
        self.start_button.set_coordinate(x, y)

        self.load_button = Texture(self.load_button_image_path[0])
        x = self.width // 2 - (self.start_button.get_width() // 2)
        y = self.height // 2 - self.start_button.get_height() - 150
        scale_ratio = self.start_button.get_width() / self.load_button.get_width()
        self.load_button.set_coordinate(x, y)
        self.load_button.scale(scale_ratio)

    def draw(self):
        self.background.draw()
        self.start_button.draw()
        self.load_button.draw()

    def mouse_clicked(self, x, y):
        if self.start_button_on_hover:
            self.on_display, self.switched = False, True
        elif self.load_button_on_hover:
            try:
                with open("save_data.txt", "r") as f:
                    self.on_display, self.switched = False, True
                    self.load = True
            except FileNotFoundError:
                pass

    def on_mouse_motion(self, x, y):
        if self.on_hover(x, y, self.start_button):
            if not self.start_button_on_hover:
                self.start_button.change_image(self.start_button_image_path[1])
                self.start_button_on_hover = True
        elif self.start_button_on_hover:
                self.start_button.change_image(self.start_button_image_path[0])
                self.start_button_on_hover = False
        if self.on_hover(x, y, self.load_button):
            if not self.load_button_on_hover:
                self.load_button.change_image(self.load_button_image_path[1])
                self.load_button_on_hover = True
        elif self.load_button_on_hover:
                self.load_button.change_image(self.load_button_image_path[0])
                self.load_button_on_hover = False
```
Trong class này, ta khai báo một số đường dẫn chứa các file ảnh cho các nút (Button) ở mảng `path`. Từ đó tạo các texture với file ảnh để hiển thị lên màn hình ở đoạn code sau đó. Công đoạn render/display được chạy ở hàm `on_draw()`.
Tiếp đó ta sẽ tạo hiệu ứng cho các nút khi chuột được di đè lên các nút này. Cách thực hiện rất đơn giản trên hàm `on_mouse_motion()`, ta sẽ gọi đến hàm `on_hover()` đã được định nghĩa trước ở lớp `Screen` cha, từ đó thay đổi ảnh hiển thị của nút bấm tùy vào việc có chuột di lên nút hay không.
Cuối cùng là việc chuyển sang màn hình chơi game khi các nút Start, hoặc Load game được bấm vào, ta sẽ gán biến `self.on_display = False` để ngưng hiển thị màn hình bắt đầu, biến `self.switched = False` để thông báo màn hình chơi game bắt đầu hiển thị.

Sau đó, ta viết 1 file `game_screen.py` đơn giản chưa chạy công việc gì như sau:
```python
from Utils.texture import Texture
from Screen.screen import Screen

class GameScreen(Screen):
    def __init__(self, x, y):
        self.on_display = False
        self.width, self.height = x, y

    def draw(self):
        pass

    def on_mouse_motion(self, x, y):
        pass
```
Khi từ màn hình bắt đầu chuyển qua màn hình chơi game, ta sẽ thấy màn hình đen xì như khi mới chạy chương trình chính, vì game_screen chưa render gì cả.

Cuối cùng, ta chỉnh sửa file `main.py` để chạy chức năng chuyển màn hình như sau:
```python
import pyglet
from pyglet.gl import gl
from pkg_resources import parse_version
from Screen.start_screen import StartScreen
from Screen.game_screen import GameScreen

window = pyglet.window.Window(fullscreen=True)
win_max_x = window.width
win_max_y = window.height

start_screen = StartScreen(win_max_x, win_max_y)
game_screen = None

@window.event
def on_draw():
    global game_screen
    window.clear()
    if start_screen.on_display:
        start_screen.draw()
    else:
        if start_screen.switched:
            start_screen.switched = False
            game_screen = GameScreen(win_max_x, win_max_y)
        game_screen.draw()

@window.event
def on_mouse_motion(x, y, dx, dy):
    if start_screen.on_display:
        start_screen.on_mouse_motion(x, y)
    else:
        game_screen.on_mouse_motion(x, y)

@window.event('on_mouse_press')
def mouse_clicked(x, y, button, modifiers):
    if start_screen.on_display:
        start_screen.mouse_clicked(x, y)
    else:
        game_screen.mouse_clicked(x, y)

if __name__ == "__main__":
    screen_index = 0
    pyglet.app.run()
```
Lần này ở chương trình chính ta import 2 class Screen ta đã viết ở trước, hàm `on_draw()` sẽ render 1 trong 2 màn hình, các thao tác click, di chuột cũng sẽ gọi hàm `on_mouse_motion()`, `mouse_clicked()` ở 1 trong 2 màn hình tương ứng, tùy vào việc màn hình nào đang được hiển thị.

Như vậy là xong rồi ! Chúng ta hãy chạy thử game xem sao.
Trên CMD (Windows) hoặc Terminal (Linux), ta dùng lệnh `cd` để di chuyển tới thư mục project game, sau đó chạy lệnh sau:
`python3 main.py`
Ta sẽ thấy màn hình bắt đầu game như sau:
![Màn hình bắt đầu game](https://dev-to-uploads.s3.amazonaws.com/i/pr9wv6ntkxwwqn5ln7bp.png)
Màn hình render khá đẹp phải không ? Các bạn có thể di chuột lên các nút để check hiệu ứng hover, sau đó click vào nút Start để chuyển sang màn hình chơi game.
Lúc này màn hình sẽ hiển thị chỉ 1 màu đen vì game_screen chưa làm gì. Các bạn nhấn nút `Esc` để dừng chương trình.

Ở các phần tiếp theo, chúng ta sẽ viết mã nguồn cho game_screen để game chạy tiếp một số chức năng.

Cảm ơn các bạn đã theo dõi bài viết của mình ! Hãy đón đọc các bài ở phần sau!

Link github của project: {% github vietanhtran2710/PythonTowerDefense %}