diff --git a/README.md b/README.md index c5bdb55..d409c58 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,129 @@ -# POOL GAME -The Pool Game project is a Python-based endeavor utilizing libraries such as pygame and pymunk. This project aims to create a virtual representation of the classic game of pool, implementing realistic physics and interactive gameplay features for an engaging user experience. +# 🎱 Pool Game -## IMAGE DEMO -

- +[![Python](https://img.shields.io/badge/Python-3.11+-3776AB?logo=python&logoColor=white)](https://www.python.org/) +[![Pygame](https://img.shields.io/badge/Pygame-2.x-00D162?logo=pygame)](https://www.pygame.org/) +[![Pymunk](https://img.shields.io/badge/Pymunk-Physics-FF6B35)](http://www.pymunk.org/) +[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + +Trò chơi **Bi-a 2D** mô phỏng vật lý thực tế, xây dựng bằng **Python** với **Pygame** (render đồ họa) và **Pymunk** (engine vật lý rigid-body). Người chơi sử dụng chuột để điều khiển cơ bi-a, tích lực bằng cách giữ chuột, và đánh tất cả 15 bi màu vào lỗ trước khi hết mạng. + +## 📸 Demo + +

+Pool Game Demo

-## CODE DEMO -```python -# create pool cue -class Cue: - def __init__(self, pos): - self.original_image = cue_image - self.angle = 0 - self.image = pygame.transform.rotate(self.original_image, self.angle) - self.rect = self.image.get_rect() - self.rect.center = pos - - def update(self, angle): - self.angle = angle - - def draw(self, surface): - self.image = pygame.transform.rotate(self.original_image, self.angle) - surface.blit( - self.image, - ( - self.rect.centerx - self.image.get_width() / 2, - self.rect.centery - self.image.get_height() / 2, - ), - ) +## 🏗️ Kiến trúc kỹ thuật + +### Hằng số cấu hình + +| Nhóm | Hằng số | Giá trị | Mô tả | +|------|---------|---------|-------| +| Cửa sổ | `SCREEN_WIDTH × SCREEN_HEIGHT` | 1200 × 678 | Kích thước bàn bi-a (pixel) | +| Bi | `BALL_COUNT` | 16 | 15 bi màu + 1 bi trắng | +| Bi | `BALL_MASS` / `BALL_ELASTICITY` | 5 / 0.8 | Khối lượng & đàn hồi | +| Bàn | `FRICTION` | 1000 | Ma sát mô phỏng qua PivotJoint | +| Bàn | `CUSHION_ELASTICITY` | 0.6 | Đàn hồi thành bàn | +| Bắn | `MAX_FORCE` / `FORCE_STEP` | 10000 / 100 | Lực tối đa & bước tăng mỗi frame | +| FPS | `FPS` | 120 | Tốc độ khung hình | + +### Engine vật lý (Pymunk) + +- **Không gian (Space)**: Quản lý toàn bộ thân vật lý, shape, và joint. +- **Bi (Ball)**: `pymunk.Circle` gắn với `pymunk.Body` — có khối lượng, đàn hồi. +- **Ma sát lăn**: Mô phỏng bằng `PivotJoint` với `max_force` giới hạn, `max_bias = 0`. +- **Thành bàn (Cushion)**: `pymunk.Poly` tĩnh (`STATIC body`) — 6 polygon. +- **Lỗ bi (Pocket)**: Kiểm tra khoảng cách Euclid (`math.dist`) giữa bi và tâm lỗ. + +### Các class & hàm chính + +| Thành phần | Loại | Mô tả | +|------------|------|-------| +| `Cue` | Class | Cơ bi-a — xoay theo hướng chuột, vẽ bằng `pygame.transform.rotate` | +| `create_ball()` | Function | Tạo bi vật lý: Body + Circle + PivotJoint (ma sát) | +| `create_cushion()` | Function | Tạo thành bàn tĩnh từ tọa độ polygon | +| `draw_text()` | Function | Render text lên surface tại tọa độ (x, y) | +| `main()` | Function | Hàm chính — khởi tạo, game loop, xử lý sự kiện | + +### Game Loop + +``` +┌─────────────────────────────────────────────┐ +│ clock.tick(FPS) → space.step(1/FPS) │ +│ │ +│ 1. Vẽ nền + bàn │ +│ 2. Kiểm tra bi vào lỗ (gom → xóa an toàn) │ +│ 3. Vẽ các bi │ +│ 4. Kiểm tra bi đã dừng → cho phép bắn │ +│ 5. Vẽ cơ + tính góc theo chuột │ +│ 6. Tích lực / bắn xung lực │ +│ 7. Vẽ panel dưới (bi đã vào lỗ + mạng) │ +│ 8. Kiểm tra thắng/thua │ +│ 9. Xử lý sự kiện (chuột, thoát) │ +│ 10. pygame.display.update() │ +└─────────────────────────────────────────────┘ +``` + +## 📁 Cấu trúc dự án + ``` +Pool Game/ +├── main.py # Source code chính +├── requirements.txt # Thư viện phụ thuộc +├── Pool Game.sln # Visual Studio Solution +├── Pool Game.pyproj # Visual Studio Python Project +├── assets/ +│ └── images/ +│ ├── table.png # Ảnh bàn bi-a +│ ├── cue.png # Ảnh cơ bi-a +│ ├── ball_1.png # Bi số 1 +│ ├── ... # Bi số 2–15 +│ └── ball_16.png # Bi trắng (cue ball) +├── pic/ +│ └── 0.jpg # Ảnh demo cho README +├── .github/ +│ ├── FUNDING.yml # Cấu hình sponsor +│ └── workflows/ +│ └── deploy-pygame.yml # CI/CD build WebAssembly (Pygbag) +├── .gitattributes # Git LFS tracking +└── .gitignore # Ignore rules +``` + +## 🚀 Cài đặt & Chạy + +### Yêu cầu +- Python 3.11+ +- pip + +### Cài đặt thư viện + +```bash +pip install -r requirements.txt +``` + +### Chạy game + +```bash +python main.py +``` + +## 🎮 Điều khiển + +| Thao tác | Hành động | +|----------|-----------| +| **Di chuột** | Xoay cơ bi-a theo hướng chuột | +| **Giữ chuột trái** | Tích lực (thanh đỏ dao động) | +| **Thả chuột trái** | Bắn bi trắng theo hướng cơ | +| **Đóng cửa sổ** | Thoát game | + +## 📜 Luật chơi + +- Bạn có **3 mạng**. Mỗi lần bi trắng rơi vào lỗ sẽ mất 1 mạng. +- Đánh hết **15 bi màu** vào lỗ để **thắng**. +- Hết mạng → **GAME OVER**. + +## 💖 Sponsor + +- [GitHub Sponsors](https://github.com/sponsors/tynab) +- [Patreon](https://patreon.com/yamiannephilim) +- [PayPal](https://paypal.me/yamiannephilim) diff --git a/main.py b/main.py index 0c78fb8..e629ffd 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,27 @@ +""" +Pool Game — Trò chơi Bi-a 2D mô phỏng vật lý. + +Sử dụng Pygame để render đồ họa và Pymunk làm engine vật lý 2D (rigid-body). +Người chơi điều khiển cơ bi-a bằng chuột, tích lực bằng cách giữ chuột, +và cố gắng đánh hết tất cả các bi vào lỗ trước khi hết mạng. + +Thư viện: + - pygame: Render đồ họa, xử lý sự kiện, quản lý vòng lặp game. + - pymunk: Mô phỏng vật lý 2D (va chạm, ma sát, đàn hồi). +""" + +import math +import sys + import pygame import pymunk import pymunk.pygame_util -import math -# common +# ============================================================================ +# HẰNG SỐ CẤU HÌNH +# ============================================================================ + +# --- Cửa sổ & giao diện --- TITLE = "Pool Game" SCREEN_WIDTH = 1200 SCREEN_HEIGHT = 678 @@ -11,149 +29,188 @@ BACKGROUND_COLOR = (50, 50, 50) TEXT_COLOR = (255, 255, 255) -# ball data -MAX_BALL = 17 -BALL_MASS = 5 -BALL_ELASTICITY = 0.8 -BALL_DIAMETER = 36 - -# wall data -FRICTION = 1000 -CUSHION_ELASTICITY = 0.6 -POCKET_DIAMETER = 70 - -# shooting data -MAX_FORCE = 10000 -FORCE_STEP = 100 - -# power bar -BAR_WIDTH = 10 -BAR_HEIGHT = 20 -BAR_SENSTIVITY = 1000 -BAR_COLOR = (255, 0, 0) - -# create six pockets on table -POCKETS = [(55, 63), (592, 48), (1134, 64), (55, 616), (592, 629), (1134, 616)] +# --- Thông số bi --- +BALL_COUNT = 16 # Tổng số bi (15 bi màu + 1 bi trắng) +BALL_MASS = 5 # Khối lượng mỗi bi (đơn vị Pymunk) +BALL_ELASTICITY = 0.8 # Hệ số đàn hồi khi va chạm +BALL_DIAMETER = 36 # Đường kính bi (pixel) +BALL_RADIUS = BALL_DIAMETER / 2 # Bán kính bi (pixel) + +# --- Thông số bàn (tường & lỗ) --- +FRICTION = 1000 # Lực ma sát mô phỏng qua PivotJoint +CUSHION_ELASTICITY = 0.6 # Hệ số đàn hồi của thành bàn +POCKET_DIAMETER = 70 # Đường kính lỗ bi (pixel) + +# --- Thông số bắn --- +MAX_FORCE = 10000 # Lực bắn tối đa +FORCE_STEP = 100 # Bước tăng lực mỗi frame khi giữ chuột + +# --- Thanh lực (power bar) --- +BAR_WIDTH = 10 # Chiều rộng mỗi ô thanh lực (pixel) +BAR_HEIGHT = 20 # Chiều cao mỗi ô thanh lực (pixel) +BAR_SENSITIVITY = 1000 # Số lực cần cho mỗi ô hiển thị +BAR_COLOR = (255, 0, 0) # Màu thanh lực (đỏ) + +# --- Vị trí bi trắng --- +CUE_BALL_X = 888 # Tọa độ X đặt bi trắng (bên phải bàn) + +# --- Vị trí xếp tam giác bi --- +RACK_START_X = 250 # Tọa độ X bắt đầu xếp tam giác +RACK_START_Y = 267 # Tọa độ Y bắt đầu xếp tam giác + +# --- Vị trí ngoài màn hình (khi bi rơi vào lỗ) --- +OFFSCREEN_POS = (-444, -444) + +# --- Thanh lực: offset hiển thị so với bi trắng --- +POWER_BAR_OFFSET_X = 70 # Khoảng cách ngang từ bi trắng đến thanh lực +POWER_BAR_OFFSET_Y = 30 # Khoảng cách dọc từ bi trắng đến thanh lực +POWER_BAR_SPACING = 15 # Khoảng cách giữa các ô thanh lực + +# --- Panel dưới: hiển thị bi đã vào lỗ --- +POTTED_BALL_X_START = 10 # Tọa độ X bắt đầu vẽ bi đã vào lỗ +POTTED_BALL_SPACING = 50 # Khoảng cách giữa các bi đã vào lỗ +POTTED_BALL_Y_OFFSET = 10 # Khoảng cách dọc từ đường kẻ panel + +# --- Vị trí text --- +LIVES_TEXT_X_OFFSET = 200 # Khoảng cách từ cạnh phải cho text "LIVES" +LIVES_TEXT_Y_OFFSET = 10 # Khoảng cách dọc từ đường kẻ panel +GAMEOVER_X_OFFSET = 160 # Offset X từ tâm cho text "GAME OVER"/"YOU WIN" +GAMEOVER_Y_OFFSET = 100 # Offset Y từ tâm cho text "GAME OVER"/"YOU WIN" + +# --- Tọa độ 6 lỗ bi trên bàn (x, y) --- +POCKETS = [ + (55, 63), (592, 48), (1134, 64), # 3 lỗ phía trên + (55, 616), (592, 629), (1134, 616), # 3 lỗ phía dưới +] -# create pool table cushions +# --- Tọa độ đỉnh của 6 thành bàn (polygon) --- CUSHIONS = [ - [(88, 56), (109, 77), (555, 77), (564, 56)], - [(621, 56), (630, 77), (1081, 77), (1102, 56)], - [(89, 621), (110, 600), (556, 600), (564, 621)], - [(622, 621), (630, 600), (1081, 600), (1102, 621)], - [(56, 96), (77, 117), (77, 560), (56, 581)], - [(1143, 96), (1122, 117), (1122, 560), (1143, 581)], + [(88, 56), (109, 77), (555, 77), (564, 56)], # Thành trên-trái + [(621, 56), (630, 77), (1081, 77), (1102, 56)], # Thành trên-phải + [(89, 621), (110, 600), (556, 600), (564, 621)], # Thành dưới-trái + [(622, 621), (630, 600), (1081, 600), (1102, 621)], # Thành dưới-phải + [(56, 96), (77, 117), (77, 560), (56, 581)], # Thành trái + [(1143, 96), (1122, 117), (1122, 560), (1143, 581)], # Thành phải ] -# initilize the modules -pygame.init() +# --- Tốc độ khung hình --- +FPS = 120 -# fonts -font = pygame.font.SysFont("Lato", 30) -large_font = pygame.font.SysFont("Lato", 60) -# clock -FPS = 120 -clock = pygame.time.Clock() - -# game window -screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT + BOTTOM_PANEL)) -pygame.display.set_caption(TITLE) - -# pymunk space -space = pymunk.Space() -static_body = space.static_body -draw_options = pymunk.pygame_util.DrawOptions(screen) - -# game variables -lives = 3 -force = 0 -force_direction = 1 -game_running = True -cue_ball_potted = False -taking_shot = True -powering_up = False -potted_balls = [] - -# load images -cue_image = pygame.image.load("assets/images/cue.png").convert_alpha() -table_image = pygame.image.load("assets/images/table.png").convert_alpha() -ball_images = [] -for i in range(1, MAX_BALL): - ball_image = pygame.image.load(f"assets/images/ball_{i}.png").convert_alpha() - ball_images.append(ball_image) - - -# function for outputting text onto the screen -def draw_text(text, font, text_col, x, y): - screen.blit(font.render(text, True, text_col), (x, y)) - - -# function for creating balls -def create_ball(radius, pos): +# ============================================================================ +# HÀM TIỆN ÍCH +# ============================================================================ + + +def draw_text( + surface: pygame.Surface, + text: str, + font: pygame.font.Font, + color: tuple[int, int, int], + x: float, + y: float, +) -> None: + """Vẽ chuỗi text lên bề mặt tại tọa độ (x, y). + + Args: + surface: Bề mặt Pygame để vẽ lên. + text: Nội dung chuỗi cần hiển thị. + font: Font chữ sử dụng. + color: Màu chữ dạng RGB tuple. + x: Tọa độ X góc trên-trái của text. + y: Tọa độ Y góc trên-trái của text. + """ + surface.blit(font.render(text, True, color), (x, y)) + + +def create_ball( + space: pymunk.Space, + static_body: pymunk.Body, + radius: float, + pos: tuple[float, float], +) -> pymunk.Circle: + """Tạo một bi vật lý trong không gian Pymunk. + + Mỗi bi gồm: + - Body: thân vật lý có vị trí và vận tốc. + - Circle shape: hình tròn với bán kính, khối lượng, đàn hồi. + - PivotJoint: khớp nối giả để mô phỏng ma sát lăn. + + Args: + space: Không gian vật lý Pymunk. + static_body: Thân tĩnh của space, dùng làm neo cho PivotJoint. + radius: Bán kính bi (pixel). + pos: Vị trí ban đầu (x, y). + + Returns: + Shape hình tròn (pymunk.Circle) đại diện cho bi. + """ body = pymunk.Body() body.position = pos shape = pymunk.Circle(body, radius) shape.mass = BALL_MASS shape.elasticity = BALL_ELASTICITY - # use pivot joint to add friction + # Dùng PivotJoint để mô phỏng ma sát lăn trên mặt bàn pivot = pymunk.PivotJoint(static_body, body, (0, 0), (0, 0)) - pivot.max_bias = 0 # disable joint correction - pivot.max_force = FRICTION # emulate linear friction + pivot.max_bias = 0 # Tắt tự hiệu chỉnh vị trí của joint + pivot.max_force = FRICTION # Giới hạn lực = mô phỏng ma sát tuyến tính space.add(body, shape, pivot) return shape -# setup game balls -balls = [] -rows = 5 - -# potting balls -for col in range(5): - for row in range(rows): - balls.append( - create_ball( - BALL_DIAMETER / 2, - ( - 250 + col * (BALL_DIAMETER + 1), - 267 + row * (BALL_DIAMETER + 1) + col * BALL_DIAMETER / 2, - ), - ) - ) - rows -= 1 - -# cue ball -pos = (888, SCREEN_HEIGHT / 2) -cue_ball = create_ball(BALL_DIAMETER / 2, pos) -balls.append(cue_ball) - +def create_cushion( + space: pymunk.Space, + poly_dims: list[tuple[int, int]], +) -> None: + """Tạo một thành bàn (cushion) tĩnh trong không gian vật lý. -# function for creating cushions -def create_cushion(poly_dims): + Args: + space: Không gian vật lý Pymunk. + poly_dims: Danh sách tọa độ đỉnh polygon của thành bàn. + """ body = pymunk.Body(body_type=pymunk.Body.STATIC) - # body.position = (0, 0) shape = pymunk.Poly(body, poly_dims) shape.elasticity = CUSHION_ELASTICITY space.add(body, shape) -for cushion in CUSHIONS: - create_cushion(cushion) - - -# create pool cue class Cue: - def __init__(self, pos): - self.original_image = cue_image - self.angle = 0 + """Cơ bi-a — hiển thị và xoay theo hướng chuột. + + Attributes: + original_image: Ảnh gốc của cơ (không xoay). + angle: Góc xoay hiện tại (độ). + image: Ảnh đã xoay theo góc hiện tại. + rect: Hình chữ nhật bao quanh ảnh, dùng để định vị. + """ + + def __init__(self, image: pygame.Surface, pos: tuple[float, float]) -> None: + """Khởi tạo cơ bi-a tại vị trí cho trước. + + Args: + image: Ảnh gốc của cơ bi-a. + pos: Vị trí tâm ban đầu (x, y). + """ + self.original_image = image + self.angle = 0.0 self.image = pygame.transform.rotate(self.original_image, self.angle) self.rect = self.image.get_rect() self.rect.center = pos - def update(self, angle): + def update(self, angle: float) -> None: + """Cập nhật góc xoay của cơ. + + Args: + angle: Góc mới (độ), tính từ trục X dương ngược chiều kim đồng hồ. + """ self.angle = angle - def draw(self, surface): + def draw(self, surface: pygame.Surface) -> None: + """Vẽ cơ bi-a lên bề mặt, xoay quanh tâm rect. + + Args: + surface: Bề mặt Pygame để vẽ lên. + """ self.image = pygame.transform.rotate(self.original_image, self.angle) surface.blit( self.image, @@ -164,150 +221,242 @@ def draw(self, surface): ) -cue = Cue(balls[-1].body.position) - -# create power bars to show how hard the cue ball will be hit -power_bar = pygame.Surface((BAR_WIDTH, BAR_HEIGHT)) -power_bar.fill(BAR_COLOR) - -# game loop -game_on = True - -while game_on: - clock.tick(FPS) - space.step(1 / FPS) - - # fill background - screen.fill(BACKGROUND_COLOR) - - # draw pool table - screen.blit(table_image, (0, 0)) +# ============================================================================ +# HÀM CHÍNH +# ============================================================================ + + +def main() -> None: + """Hàm chính — khởi tạo game và chạy vòng lặp chính. + + Luồng thực thi: + 1. Khởi tạo Pygame, Pymunk, tải tài nguyên. + 2. Tạo các bi, thành bàn, cơ bi-a. + 3. Vòng lặp game: xử lý sự kiện → cập nhật vật lý → vẽ → hiển thị. + 4. Thoát khi người chơi đóng cửa sổ. + """ + # --- Khởi tạo Pygame --- + pygame.init() + font = pygame.font.SysFont("Lato", 30) + large_font = pygame.font.SysFont("Lato", 60) + clock = pygame.time.Clock() + + # --- Tạo cửa sổ game --- + screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT + BOTTOM_PANEL)) + pygame.display.set_caption(TITLE) + + # --- Khởi tạo không gian vật lý Pymunk --- + space = pymunk.Space() + static_body = space.static_body + + # --- Biến trạng thái game --- + lives = 3 # Số mạng còn lại + force = 0 # Lực bắn hiện tại + force_direction = 1 # Hướng tăng/giảm lực (+1 tăng, -1 giảm) + game_running = True # Game đang chạy (chưa thua/thắng) + cue_ball_potted = False # Bi trắng vừa rơi vào lỗ + taking_shot = True # Đang ở trạng thái chờ bắn + powering_up = False # Đang giữ chuột tích lực + potted_balls = [] # Danh sách ảnh bi đã vào lỗ (hiển thị ở panel dưới) + cue_angle = 0.0 # Góc cơ bi-a (khởi tạo tránh lỗi tham chiếu) + + # --- Tải hình ảnh --- + cue_image = pygame.image.load("assets/images/cue.png").convert_alpha() + table_image = pygame.image.load("assets/images/table.png").convert_alpha() + ball_images = [ + pygame.image.load(f"assets/images/ball_{i}.png").convert_alpha() + for i in range(1, BALL_COUNT + 1) + ] + + # --- Tạo các bi trên bàn --- + balls = [] + rows = 5 + # Xếp 15 bi màu theo hình tam giác (5 cột, số hàng giảm dần) + for col in range(5): + for row in range(rows): + pos = ( + RACK_START_X + col * (BALL_DIAMETER + 1), + RACK_START_Y + row * (BALL_DIAMETER + 1) + col * BALL_RADIUS, + ) + balls.append(create_ball(space, static_body, BALL_RADIUS, pos)) + rows -= 1 + + # Bi trắng (luôn ở cuối danh sách balls) + cue_ball_pos = (CUE_BALL_X, SCREEN_HEIGHT / 2) + cue_ball = create_ball(space, static_body, BALL_RADIUS, cue_ball_pos) + balls.append(cue_ball) + + # --- Tạo 6 thành bàn --- + for cushion_vertices in CUSHIONS: + create_cushion(space, cushion_vertices) + + # --- Tạo cơ bi-a --- + cue = Cue(cue_image, balls[-1].body.position) + + # --- Tạo thanh lực (power bar) --- + power_bar = pygame.Surface((BAR_WIDTH, BAR_HEIGHT)) + power_bar.fill(BAR_COLOR) + + # ======================================================================== + # VÒNG LẶP CHÍNH + # ======================================================================== + game_on = True + + while game_on: + clock.tick(FPS) + space.step(1 / FPS) + + # --- Vẽ nền và bàn bi-a --- + screen.fill(BACKGROUND_COLOR) + screen.blit(table_image, (0, 0)) + + # --- Kiểm tra bi rơi vào lỗ --- + # Gom các bi cần xóa để tránh lỗi khi sửa list đang duyệt + balls_to_remove = [] + for i, ball in enumerate(balls): + for pocket in POCKETS: + ball_pos = ball.body.position + distance = math.dist((ball_pos[0], ball_pos[1]), pocket) + if distance <= POCKET_DIAMETER / 2: + if i == len(balls) - 1: + # Bi trắng rơi vào lỗ → mất mạng, đặt ra ngoài tạm + lives -= 1 + cue_ball_potted = True + ball.body.position = OFFSCREEN_POS + ball.body.velocity = (0.0, 0.0) + else: + # Bi màu rơi vào lỗ → đánh dấu cần xóa + balls_to_remove.append(i) + ball.body.position = OFFSCREEN_POS + ball.body.velocity = (0.0, 0.0) + break # Mỗi bi chỉ rơi vào 1 lỗ + + # Xóa bi từ index lớn → nhỏ để không bị lệch index + for idx in sorted(balls_to_remove, reverse=True): + removed_ball = balls.pop(idx) + space.remove(removed_ball.body) + potted_balls.append(ball_images.pop(idx)) + + # --- Vẽ các bi trên bàn --- + for i, ball in enumerate(balls): + screen.blit( + ball_images[i], + (ball.body.position[0] - ball.radius, ball.body.position[1] - ball.radius), + ) - # check if any balls have been potted - for i, ball in enumerate(balls): - for pocket in POCKETS: - if ( - math.sqrt( - (abs(ball.body.position[0] - pocket[0]) ** 2) - + (abs(ball.body.position[1] - pocket[1]) ** 2) + # --- Kiểm tra tất cả bi đã dừng chưa --- + taking_shot = True + for ball in balls: + if int(ball.body.velocity[0]) != 0 or int(ball.body.velocity[1]) != 0: + taking_shot = False + break + + # --- Vẽ cơ bi-a khi đang chờ bắn --- + if taking_shot and game_running: + if cue_ball_potted: + # Đặt lại bi trắng về vị trí ban đầu + balls[-1].body.position = (CUE_BALL_X, SCREEN_HEIGHT / 2) + cue_ball_potted = False + # Tính góc cơ theo hướng chuột + mouse_pos = pygame.mouse.get_pos() + cue.rect.center = balls[-1].body.position + cue_ball_pos_current = balls[-1].body.position + cue_angle = math.degrees( + math.atan2( + -(cue_ball_pos_current[1] - mouse_pos[1]), + cue_ball_pos_current[0] - mouse_pos[0], ) - <= POCKET_DIAMETER / 2 - ): - ball.body.position = (-444, -444) - ball.body.velocity = (0.0, 0.0) - # check if the potted ball was the cue ball - if i == len(balls) - 1: - lives -= 1 - cue_ball_potted = True - else: - space.remove(ball.body) - balls.remove(ball) - potted_balls.append(ball_images[i]) - ball_images.pop(i) - - # draw pool balls - for i, ball in enumerate(balls): - screen.blit( - ball_images[i], - (ball.body.position[0] - ball.radius, ball.body.position[1] - ball.radius), - ) - - taking_shot = True - - # check if all the balls have stopped moving - for ball in balls: - if int(ball.body.velocity[0]) != 0 or int(ball.body.velocity[1]) != 0: - taking_shot = False - - # draw pool cue - if taking_shot and game_running: - if cue_ball_potted: - # reposition cue ball - balls[-1].body.position = (888, SCREEN_HEIGHT / 2) - cue_ball_potted = False - # calculate pool cue angle - mouse_pos = pygame.mouse.get_pos() - cue.rect.center = balls[-1].body.position - cue_angle = math.degrees( - math.atan2( - -(balls[-1].body.position[1] - mouse_pos[1]), - balls[-1].body.position[0] - mouse_pos[0], ) + cue.update(cue_angle) + cue.draw(screen) + + # --- Tích lực hoặc bắn --- + if powering_up and game_running: + # Đang giữ chuột → tăng/giảm lực theo hướng dao động + force += FORCE_STEP * force_direction + if force >= MAX_FORCE or force <= 0: + force_direction *= -1 + # Vẽ các ô thanh lực + for bar_idx in range(math.ceil(force / BAR_SENSITIVITY)): + screen.blit( + power_bar, + ( + balls[-1].body.position[0] - POWER_BAR_OFFSET_X + bar_idx * POWER_BAR_SPACING, + balls[-1].body.position[1] + POWER_BAR_OFFSET_Y, + ), + ) + elif not powering_up and taking_shot: + # Vừa thả chuột → áp dụng xung lực lên bi trắng + impulse_x = force * -math.cos(math.radians(cue_angle)) + impulse_y = force * math.sin(math.radians(cue_angle)) + balls[-1].body.apply_impulse_at_local_point((impulse_x, impulse_y), (0, 0)) + force = 0 + force_direction = 1 + + # --- Vẽ panel dưới --- + pygame.draw.rect( + screen, BACKGROUND_COLOR, (0, SCREEN_HEIGHT, SCREEN_WIDTH, BOTTOM_PANEL) ) - cue.update(cue_angle) - cue.draw(screen) - - # power up pool cue - if powering_up and game_running: - force += FORCE_STEP * force_direction - if force >= MAX_FORCE or force <= 0: - force_direction *= -1 - # draw power bars - for adjustment in range(math.ceil(force / BAR_SENSTIVITY)): + + # Vẽ các bi đã vào lỗ ở panel dưới + for i, ball_img in enumerate(potted_balls): screen.blit( - power_bar, - ( - balls[-1].body.position[0] - 70 + adjustment * 15, - balls[-1].body.position[1] + 30, - ), + ball_img, + (POTTED_BALL_X_START + i * POTTED_BALL_SPACING, SCREEN_HEIGHT + POTTED_BALL_Y_OFFSET), ) - elif not powering_up and taking_shot: - balls[-1].body.apply_impulse_at_local_point( - ( - force * -math.cos(math.radians(cue_angle)), - force * math.sin(math.radians(cue_angle)), - ), - (0, 0), - ) - force = 0 - force_direction = 1 - - # draw bottom panel - pygame.draw.rect( - screen, BACKGROUND_COLOR, (0, SCREEN_HEIGHT, SCREEN_WIDTH, BOTTOM_PANEL) - ) - - # draw potted balls in bottom panel - for i, ball in enumerate(potted_balls): - screen.blit(ball, (10 + (i * 50), SCREEN_HEIGHT + 10)) - # draw lives - draw_text( - f"LIVES: {str(lives)}", font, TEXT_COLOR, SCREEN_WIDTH - 200, SCREEN_HEIGHT + 10 - ) - - # check for game over - if lives <= 0: + # Hiển thị số mạng còn lại draw_text( - "GAME OVER", - large_font, + screen, + f"LIVES: {lives}", + font, TEXT_COLOR, - SCREEN_WIDTH / 2 - 160, - SCREEN_HEIGHT / 2 - 100, + SCREEN_WIDTH - LIVES_TEXT_X_OFFSET, + SCREEN_HEIGHT + LIVES_TEXT_Y_OFFSET, ) - game_running = False - # check if all balls are potted - if len(balls) == 1: - draw_text( - "YOU WIN", - large_font, - TEXT_COLOR, - SCREEN_WIDTH / 2 - 160, - SCREEN_HEIGHT / 2 - 100, - ) - game_running = False + # --- Kiểm tra thua --- + if lives <= 0: + draw_text( + screen, + "GAME OVER", + large_font, + TEXT_COLOR, + SCREEN_WIDTH / 2 - GAMEOVER_X_OFFSET, + SCREEN_HEIGHT / 2 - GAMEOVER_Y_OFFSET, + ) + game_running = False + + # --- Kiểm tra thắng (chỉ còn bi trắng) --- + if len(balls) == 1: + draw_text( + screen, + "YOU WIN", + large_font, + TEXT_COLOR, + SCREEN_WIDTH / 2 - GAMEOVER_X_OFFSET, + SCREEN_HEIGHT / 2 - GAMEOVER_Y_OFFSET, + ) + game_running = False + + # --- Xử lý sự kiện --- + for event in pygame.event.get(): + if event.type == pygame.MOUSEBUTTONDOWN and taking_shot: + powering_up = True + if event.type == pygame.MOUSEBUTTONUP and taking_shot: + powering_up = False + if event.type == pygame.QUIT: + game_on = False + + pygame.display.update() + + pygame.quit() + sys.exit() - # event handler - for event in pygame.event.get(): - if event.type == pygame.MOUSEBUTTONDOWN and taking_shot: - powering_up = True - if event.type == pygame.MOUSEBUTTONUP and taking_shot: - powering_up = False - if event.type == pygame.QUIT: - game_on = False - # space.debug_draw(draw_options) - pygame.display.update() +# ============================================================================ +# ĐIỂM VÀO CHƯƠNG TRÌNH +# ============================================================================ -pygame.quit() +if __name__ == "__main__": + main()