Files
game-2046/game_v2.py
2025-11-05 13:37:16 +08:00

625 lines
37 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# game.py
# ==============================================================================
# --- 1. 导入所有需要的库 (IMPORT LIBRARIES) ---
# ==============================================================================
# 导入库就像是为你的项目准备工具箱,每个库都有特定的功能。
import pygame # Pygame库是制作2D游戏的核心负责处理图形、声音、用户输入等。
import sys # 系统库,在这里主要用于安全地退出程序 (sys.exit())。
import os # 操作系统库用于处理文件和文件夹路径能确保在Windows、Mac、Linux上路径格式都正确。
import random # 随机库,用于生成随机数,比如随机生成方块的数字。
import time # 时间库,用于处理时间相关的操作,比如实现动画的延迟效果。
from typing import List, Tuple, Dict, Any, Optional # 类型提示库,它不影响程序运行,但能让代码更清晰、更易读,方便开发者理解变量应该是什么类型。
# ==============================================================================
# --- 2. 游戏配置 (GAME CONFIGURATION) ---
# 您可以在此区域内,自由修改游戏的所有参数,无需触碰下方的核心代码。
# 这里就像是游戏的“控制面板”,调整数值就能改变游戏玩法和外观。
# ==============================================================================
# --- 2.1 核心逻辑配置 (Core Logic) ---
# ------------------------------------------------------------------------------
BOARD_SIZE = 6 # 棋盘尺寸。例如 6 就是 6x6 的棋盘。
INITIAL_OPPORTUNITIES = 10 # 【重要】初始机会次数。这是游戏开始时玩家拥有的点击次数。
MAX_OPPORTUNITIES = 10 # 允许通过消除方块累计的最大机会次数。
MAX_INITIAL_NUMBER = 6 # 游戏开始或补充新方块时生成的数字的最大值。例如设为10则会随机生成1到10的整数。
# --- 2.2 屏幕与布局 (Screen & Layout) ---
# ------------------------------------------------------------------------------
SCREEN_WIDTH = 900 # 游戏窗口的宽度(单位:像素)。
SCREEN_HEIGHT = 1000 # 游戏窗口的高度(单位:像素)。
FPS = 60 # 游戏的目标帧率 (Frames Per Second)。越高画面越流畅但对电脑性能要求也越高。60是理想值。
CELL_SIZE = 100 # 棋盘上每个方块的边长(单位:像素)。
GRID_GAP = 12 # 棋盘上每个方块之间的间隙(单位:像素)。
BOARD_BG_COLOR = (44, 62, 80) # 棋盘的背景颜色 (RGB格式)。
EMPTY_CELL_COLOR = (52, 73, 94) # 棋盘上空格子(等待方块填充时)的颜色 (RGB格式)。
# --- 2.3 UI 可视化配置 (UI VISUAL SETTINGS) ---
# ------------------------------------------------------------------------------
# 【重要】这里是您定制游戏“颜值”的核心区域。
UI_SETTINGS = {
# --- 主菜单界面 (Main Menu) ---
"MENU_TITLE_LINE1": "Number", # 菜单标题第一行
"MENU_TITLE_LINE2": "Evolve", # 菜单标题第二行
"MENU_TITLE_POS": (SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 - 150), # 标题的中心坐标 (X, Y)。修改第二个值可上下移动标题。
"MENU_TITLE_LINE_SPACING": 70, # 标题有多行时,行与行之间的垂直距离。
"MENU_TITLE_FONT_SIZE": 40, # 标题的字体大小。
"MENU_SUBTITLE_POS": (SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 - 40), # 副标题的中心坐标 (X, Y)。
"MENU_SUBTITLE_FONT_SIZE": 14, # 副标题的字体大小。
"MENU_START_BUTTON_POS": (SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 + 80), # “开始游戏”按钮的中心坐标 (X, Y)。
"MENU_BUTTON_SIZE": (360, 80), # 菜单上按钮的尺寸 (宽度, 高度)。
"MENU_BUTTON_FONT_SIZE": 32, # 菜单上按钮的字体大小。
# --- 游戏内方块 (In-Game Cells) ---
"CELL_NUMBER_FONT_SIZE": 45, # 【重要】棋盘方块内数字的字体大小。
# --- 顶部信息栏 - 通用设置 (In-Game HUD - General) ---
"HUD_HEIGHT": 200, # 顶部信息栏的整体高度。
"HUD_BG_COLOR": (52, 73, 94), # 顶部信息栏的背景颜色。
"HUD_LABEL_VALUE_SPACING": 40, # 【重要】信息栏中“标签(如Score)”和其下方“数值(如100)”之间的【行间距】。
# --- 顶部信息栏 - “分数”模块 (HUD - Score Module) ---
"HUD_SCORE_LABEL_POS": (225, 140), # “Score”标签的 (X, Y) 坐标。
"HUD_SCORE_LABEL_FONT_SIZE": 22, # “Score”标签的字体大小。
"HUD_SCORE_VALUE_FONT_SIZE": 36, # 分数数值的字体大小。
"HUD_SCORE_VALUE_COLOR": (255, 215, 0), # 分数数值的颜色 (金色)。
# --- 顶部信息栏 - “机会”模块 (HUD - Tries Module) ---
"HUD_TRIES_LABEL_POS": (450, 140), # “Tries”标签的 (X, Y) 坐标。
"HUD_TRIES_LABEL_FONT_SIZE": 22, # “Tries”标签的字体大小。
"HUD_TRIES_STARS_FONT_SIZE": 36, # 机会星星的字体大小。
"HUD_TRIES_STARS_COLOR": (255, 215, 0), # 机会星星的颜色 (金色)。
# --- 顶部信息栏 - “最高分”模块 (HUD - Highest Module) ---
"HUD_HIGHEST_LABEL_POS": (675, 140), # “Highest”标签的 (X, Y) 坐标。
"HUD_HIGHEST_LABEL_FONT_SIZE": 22, # “Highest”标签的字体大小。
"HUD_HIGHEST_VALUE_FONT_SIZE": 36, # 最高分数值的字体大小。
"HUD_HIGHEST_VALUE_COLOR": (255, 215, 0), # 最高分数值的颜色 (金色)。
# --- 游戏内顶部按钮 (In-Game Top Buttons) ---
"INGAME_BUTTON_SIZE": (150, 50), # 游戏内顶部按钮的尺寸。
"INGAME_BUTTON_FONT_SIZE": 16, # 游戏内顶部按钮的字体大小。
"INGAME_BUTTON_Y": 35, # 游戏内顶部按钮的Y坐标。
"INGAME_BUTTON_SPACING": 20, # 游戏内顶部按钮之间的水平间距。
# --- 游戏结束菜单 (Game Over Menu) ---
"GAMEOVER_TITLE_POS": (SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 - 180), # "Game Over"标题的中心坐标。
"GAMEOVER_TITLE_FONT_SIZE": 70, # "Game Over"标题的字体大小。
"GAMEOVER_SCORE_TEXT_BASE_POS": (SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 - 60), # 最终分数文本的【基准】中心坐标。
"GAMEOVER_SCORE_TEXT_FONT_SIZE": 18, # 最终分数文本的字体大小。
"GAMEOVER_SCORE_LINE_SPACING": 50, # 【重要】最终分数和最高数字之间的【行间距】。
"GAMEOVER_RESTART_BUTTON_POS": (SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 + 80), # “再玩一局”按钮的中心坐标。
# --- 游戏内按钮颜色 (In-Game Button Colors) ---
"BUTTON_RESTART_BG": (26, 188, 156), # 重玩按钮的背景色
"BUTTON_RESTART_HOVER": (22, 160, 133), # 重玩按钮悬停时的颜色
"BUTTON_MUTE_BG": (230, 126, 34), # 静音按钮的背景色
"BUTTON_MUTE_HOVER": (211, 84, 0), # 静音按钮悬停时的颜色
"BUTTON_EXIT_BG": (231, 76, 60), # 退出按钮的背景色
"BUTTON_EXIT_HOVER": (192, 57, 43), # 退出按钮悬停时的颜色
"BUTTON_MENU_START_BG": (46, 204, 113), # 菜单“开始”按钮的背景色
"BUTTON_MENU_START_HOVER": (39, 174, 96), # 菜单“开始”按钮悬停时的颜色
}
# --- 2.4 单元格颜色 (Cell Colors) ---
# ------------------------------------------------------------------------------
# 此颜色表定义了不同数字的方块的背景色和文字颜色。
# 字典的键是数字,值是包含背景('bg')和文字('text')颜色的另一个字典。
# 'default' 用于处理所有未明确定义的更高数字的颜色。
COLOR_MAP = {
1: {'bg': (236, 240, 241), 'text': (52, 73, 94)},
2: {'bg': (52, 152, 219), 'text': (255, 255, 255)},
3: {'bg': (46, 204, 113), 'text': (255, 255, 255)},
4: {'bg': (241, 196, 15), 'text': (255, 255, 255)},
5: {'bg': (230, 126, 34), 'text': (255, 255, 255)},
6: {'bg': (231, 76, 60), 'text': (255, 255, 255)},
7: {'bg': (155, 89, 182), 'text': (255, 255, 255)},
8: {'bg': (26, 188, 156), 'text': (255, 255, 255)},
9: {'bg': (211, 84, 0), 'text': (255, 255, 255)},
10: {'bg': (192, 57, 43), 'text': (255, 255, 255)},
11: {'bg': (142, 68, 173), 'text': (255, 255, 255)},
12: {'bg': (39, 174, 96), 'text': (255, 255, 255)},
'default': {'bg': (127, 140, 141), 'text': (255, 255, 255)}
}
# --- 2.5 资源文件路径 (Asset Paths) ---
# ------------------------------------------------------------------------------
# 【打包EXE修复】下面的函数和路径定义是为了解决打包成exe后闪退的问题。
def resource_path(relative_path: str) -> str:
"""
获取资源的绝对路径。这是解决PyInstaller打包后找不到资源文件问题的关键。
当程序被打包成一个文件(.exe)时, 资源文件会被解压到一个临时的系统文件夹中。
这个函数能正确地找到那个临时文件夹的路径。
"""
try:
# PyInstaller 会创建一个名为 _MEIPASS 的临时文件夹,并把路径存储在 sys 模块中
base_path = sys._MEIPASS
except Exception:
# 如果不是在打包环境下运行(即直接运行.py文件则基路径就是当前工作目录
base_path = os.path.abspath(".")
# 将基础路径和相对路径拼接成一个完整的绝对路径
return os.path.join(base_path, relative_path)
# 【打包EXE修复】使用 resource_path 函数来重新定义所有资源文件的路径
# 这样做可以确保无论是在开发环境还是在打包后的exe中程序都能找到这些文件。
ASSETS_DIR_NAME = 'assets' # 资源文件夹的名称保持不变
FONT_PATH = resource_path(os.path.join(ASSETS_DIR_NAME, 'press-start-2p.ttf'))
ICON_PATH = resource_path(os.path.join(ASSETS_DIR_NAME, 'favicon.ico'))
CLICK_SOUND_PATH = resource_path(os.path.join(ASSETS_DIR_NAME, 'click.mp3'))
MERGE_SOUND_PATH = resource_path(os.path.join(ASSETS_DIR_NAME, 'merge.mp3'))
CHAIN_SOUND_PATH = resource_path(os.path.join(ASSETS_DIR_NAME, 'chain reaction.mp3'))
GAMEOVER_SOUND_PATH = resource_path(os.path.join(ASSETS_DIR_NAME, 'game over.mp3'))
MUSIC_PATH = resource_path(os.path.join(ASSETS_DIR_NAME, 'game-music-loop.mp3'))
# 定义音量
MUSIC_VOLUME, SFX_VOLUME = 0.3, 0.5
# --- 2.6 其他配置 (Other Settings) ---
# ------------------------------------------------------------------------------
ANIMATION_SPEED = 0.2 # 动画速度。值越大,方块移动和缩放越快。
SCORE_POPUP_DURATION = 60 # 分数弹出动画的持续时间以帧为单位。60帧大约是1秒。
SCORE_POPUP_SPEED = 2 # 分数向上漂浮的速度(每帧移动的像素数)。
WHITE = (255, 255, 255) # 预定义白色,方便使用
GOLD = (255, 215, 0) # 预定义金色,方便使用
DARK_OVERLAY = (0, 0, 0, 180) # 菜单背景的半透明黑色遮罩。第四个值是透明度(0-255)。
# ==============================================================================
# --- 3. 游戏核心代码 (CORE GAME CODE) ---
# 下方为游戏的核心实现,通常无需修改。这里是游戏“引擎”的部分。
# ==============================================================================
def draw_text(surface: pygame.Surface, text: str, font: pygame.font.Font, color: Tuple, center: Tuple):
"""一个通用的函数,用于在指定位置绘制【居中】的文本。"""
# 1. 使用字体和颜色渲染文本,生成一个"文本表面"
text_surface = font.render(str(text), True, color)
# 2. 获取这个文本表面的矩形区域并将其中心点设置为指定的center坐标
text_rect = text_surface.get_rect(center=center)
# 3. 将文本表面绘制到主屏幕(surface)上
surface.blit(text_surface, text_rect)
def draw_rounded_rect(surface: pygame.Surface, rect: pygame.Rect, color: Tuple, radius: int):
"""一个通用的函数,用于绘制圆角矩形。"""
# Pygame内置的draw.rect函数支持border_radius参数可以直接画出圆角
pygame.draw.rect(surface, color, rect, border_radius=radius)
class Cell:
"""此类代表棋盘上的一个单元格(方块)。它负责管理自己的所有信息和行为。"""
def __init__(self, value: int, row: int, col: int, board_top_left: Tuple):
"""初始化一个单元格对象。"""
self.value = value # 方块上的数字
self.row, self.col = row, col # 在棋盘网格中的行和列
self.board_top_left = board_top_left # 棋盘左上角的屏幕坐标,用于计算自身位置
# 动画相关的属性
self.x, self.y = self.get_target_pos() # 当前的像素坐标,用于平滑移动
self.scale = 0.0 # 初始缩放比例为0实现“凭空出现”的动画效果
self.is_merging = False # 标记此方块是否正在被合并掉
def get_target_pos(self) -> Tuple[float, float]:
"""根据单元格的行列位置,计算它在屏幕上的【目标】像素坐标。"""
board_x, board_y = self.board_top_left
# 公式:棋盘左上角坐标 + 间隙 + 自身所在行列 * (方块大小 + 间隙)
target_x = board_x + GRID_GAP + self.col * (CELL_SIZE + GRID_GAP)
target_y = board_y + GRID_GAP + self.row * (CELL_SIZE + GRID_GAP)
return (target_x, target_y)
def update(self):
"""每帧更新单元格的状态,用于实现平滑的动画效果(缓动动画)。"""
# --- 位置动画 ---
target_x, target_y = self.get_target_pos()
# 核心公式: new_pos = current_pos + (target_pos - current_pos) * speed
# 这会让方块每帧都靠近目标位置一段距离,速度会越来越慢,看起来很自然。
self.x += (target_x - self.x) * ANIMATION_SPEED
self.y += (target_y - self.y) * ANIMATION_SPEED
# --- 缩放动画 ---
# 如果方块正在被合并目标缩放为0 (消失)否则为1 (正常大小)。
target_scale = 0.0 if self.is_merging else 1.0
self.scale += (target_scale - self.scale) * ANIMATION_SPEED
def draw(self, surface: pygame.Surface):
"""将单元格绘制到屏幕上。"""
if self.scale < 0.01: return # 如果方块太小,不绘制以提高性能
colors = COLOR_MAP.get(self.value, COLOR_MAP['default'])
scaled_size = CELL_SIZE * self.scale
rect = pygame.Rect(self.x + (CELL_SIZE - scaled_size) / 2,
self.y + (CELL_SIZE - scaled_size) / 2,
scaled_size, scaled_size)
draw_rounded_rect(surface, rect, colors['bg'], 15)
# 当方块放大到一定程度时才绘制数字
if self.scale > 0.8:
font_size = int(UI_SETTINGS["CELL_NUMBER_FONT_SIZE"] * self.scale)
cell_font = pygame.font.Font(FONT_PATH, font_size)
draw_text(surface, str(self.value), cell_font, colors['text'], rect.center)
class ScorePopup:
"""此类代表合并时弹出的得分数字,它会向上漂浮并逐渐消失。"""
def __init__(self, value: int, pos: Tuple, font: pygame.font.Font):
self.value = value
self.pos = list(pos) # 转换为列表因为元组tuple不能修改
self.font = font
self.lifetime = SCORE_POPUP_DURATION # 存活时间(帧数)
self.alpha = 255 # 初始透明度 (255为完全不透明)
def update(self):
"""每帧更新弹出分数的位置和透明度。"""
self.pos[1] -= SCORE_POPUP_SPEED # Y坐标减小实现向上漂浮
self.lifetime -= 1
self.alpha = max(0, 255 * (self.lifetime / SCORE_POPUP_DURATION))
def draw(self, surface: pygame.Surface):
"""将弹出分数绘制到屏幕上。"""
text_surface = self.font.render(f"+{self.value}", True, WHITE)
text_surface.set_alpha(self.alpha)
surface.blit(text_surface, self.pos)
class Button:
"""一个通用的按钮类,处理绘制、鼠标悬停和点击事件。"""
def __init__(self, rect: Tuple, text: str, font: pygame.font.Font, text_color: Tuple, bg_color: Tuple, hover_color: Tuple):
self.rect = pygame.Rect(rect)
self.text = text
self.font = font
self.text_color = text_color
self.colors = {'normal': bg_color, 'hover': hover_color}
self.is_hovered = False
def set_text(self, text: str):
"""允许在游戏过程中改变按钮的文字,比如"Mute" -> "Unmute"."""
self.text = text
def handle_event(self, event: pygame.event.Event) -> bool:
"""处理单个事件,判断按钮是否被点击。"""
if event.type == pygame.MOUSEMOTION:
self.is_hovered = self.rect.collidepoint(event.pos)
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1 and self.is_hovered:
return True # 返回True表示按钮被点击了
return False
def draw(self, surface: pygame.Surface):
"""将按钮绘制到屏幕上。"""
color = self.colors['hover'] if self.is_hovered else self.colors['normal']
draw_rounded_rect(surface, self.rect, color, 15)
draw_text(surface, self.text, self.font, self.text_color, self.rect.center)
class Game:
"""游戏的主控制类,像一个“总司令”,管理着游戏的所有方面。"""
def __init__(self):
"""初始化整个游戏。这个函数在游戏开始时只运行一次。"""
pygame.init(); pygame.mixer.init()
self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Number Evolve")
# 【打包EXE修复】原版代码没有设置图标这里也保持一致仅在资源路径定义中保留ICON_PATH变量
# 如果需要设置图标,可以取消下面这行代码的注释
# try: pygame.display.set_icon(pygame.image.load(ICON_PATH))
# except: print("Warning: Icon file not found or could not be loaded.")
self.clock = pygame.time.Clock()
self.calculate_layout()
self.load_assets()
self.create_ui()
self.reset_game_state()
# 游戏状态管理
self.flow_state = "START_MENU" # 'START_MENU', 'PLAYING', 'GAME_OVER'
self.turn_state = "IDLE" # 'IDLE', 'CHECK_MATCHES', 'MERGING', 'DROPPING', 'REFILLING'
self.animation_end_time = 0
self.is_muted = False
def calculate_layout(self):
"""计算棋盘在屏幕上的位置,使其居中。"""
board_width = BOARD_SIZE * CELL_SIZE + (BOARD_SIZE + 1) * GRID_GAP
self.board_rect = pygame.Rect(
(SCREEN_WIDTH - board_width) / 2,
UI_SETTINGS["HUD_HEIGHT"] + (SCREEN_HEIGHT - UI_SETTINGS["HUD_HEIGHT"] - board_width) / 2,
board_width, board_width
)
def load_assets(self):
"""加载所有外部资源文件,如字体和声音。"""
try:
# 【打包EXE修复】这里的 FONT_PATH 等变量现在由 resource_path 函数生成,路径总是正确的
if not os.path.exists(FONT_PATH): raise FileNotFoundError(f"Font not found: {FONT_PATH}")
self.fonts = {
'title': pygame.font.Font(FONT_PATH, UI_SETTINGS["MENU_TITLE_FONT_SIZE"]),
'subtitle': pygame.font.Font(FONT_PATH, UI_SETTINGS["MENU_SUBTITLE_FONT_SIZE"]),
'popup': pygame.font.Font(FONT_PATH, 36),
'button': pygame.font.Font(FONT_PATH, UI_SETTINGS["MENU_BUTTON_FONT_SIZE"]),
'ingame_button': pygame.font.Font(FONT_PATH, UI_SETTINGS["INGAME_BUTTON_FONT_SIZE"]),
'score_label': pygame.font.Font(FONT_PATH, UI_SETTINGS["HUD_SCORE_LABEL_FONT_SIZE"]),
'score_value': pygame.font.Font(FONT_PATH, UI_SETTINGS["HUD_SCORE_VALUE_FONT_SIZE"]),
'tries_label': pygame.font.Font(FONT_PATH, UI_SETTINGS["HUD_TRIES_LABEL_FONT_SIZE"]),
'tries_stars': pygame.font.Font(FONT_PATH, UI_SETTINGS["HUD_TRIES_STARS_FONT_SIZE"]),
'highest_label': pygame.font.Font(FONT_PATH, UI_SETTINGS["HUD_HIGHEST_LABEL_FONT_SIZE"]),
'highest_value': pygame.font.Font(FONT_PATH, UI_SETTINGS["HUD_HIGHEST_VALUE_FONT_SIZE"]),
'gameover_title': pygame.font.Font(FONT_PATH, UI_SETTINGS["GAMEOVER_TITLE_FONT_SIZE"]),
'gameover_score': pygame.font.Font(FONT_PATH, UI_SETTINGS["GAMEOVER_SCORE_TEXT_FONT_SIZE"]),
}
self.sounds = {'click': pygame.mixer.Sound(CLICK_SOUND_PATH), 'merge': pygame.mixer.Sound(MERGE_SOUND_PATH),
'chain': pygame.mixer.Sound(CHAIN_SOUND_PATH), 'over': pygame.mixer.Sound(GAMEOVER_SOUND_PATH)}
for s in self.sounds.values(): s.set_volume(SFX_VOLUME)
pygame.mixer.music.load(MUSIC_PATH)
pygame.mixer.music.set_volume(MUSIC_VOLUME)
except Exception as e:
# 如果任何资源加载失败,打印错误信息并退出。
print(f"FATAL ERROR loading assets: {e}"); sys.exit()
def create_ui(self):
"""根据配置信息,创建游戏中所有的按钮实例。"""
w, h = UI_SETTINGS["MENU_BUTTON_SIZE"]; cx, start_y = UI_SETTINGS["MENU_START_BUTTON_POS"]
self.start_button = Button((cx - w/2, start_y, w, h), "Start Game", self.fonts['button'], WHITE, UI_SETTINGS["BUTTON_MENU_START_BG"], UI_SETTINGS["BUTTON_MENU_START_HOVER"])
restart_cx, restart_y = UI_SETTINGS["GAMEOVER_RESTART_BUTTON_POS"]
self.restart_button = Button((restart_cx - w/2, restart_y, w, h), "Play Again", self.fonts['button'], WHITE, UI_SETTINGS["BUTTON_MENU_START_BG"], UI_SETTINGS["BUTTON_MENU_START_HOVER"])
w_ingame, h_ingame = UI_SETTINGS["INGAME_BUTTON_SIZE"]; y_ingame = UI_SETTINGS["INGAME_BUTTON_Y"]
spacing = UI_SETTINGS["INGAME_BUTTON_SPACING"]; total_width = 3 * w_ingame + 2 * spacing
start_x = (SCREEN_WIDTH - total_width) / 2
self.ingame_restart_button = Button((start_x, y_ingame, w_ingame, h_ingame), "Restart", self.fonts['ingame_button'], WHITE, UI_SETTINGS["BUTTON_RESTART_BG"], UI_SETTINGS["BUTTON_RESTART_HOVER"])
self.mute_button = Button((start_x + w_ingame + spacing, y_ingame, w_ingame, h_ingame), "Mute", self.fonts['ingame_button'], WHITE, UI_SETTINGS["BUTTON_MUTE_BG"], UI_SETTINGS["BUTTON_MUTE_HOVER"])
self.exit_button = Button((start_x + 2 * (w_ingame + spacing), y_ingame, w_ingame, h_ingame), "Exit", self.fonts['ingame_button'], WHITE, UI_SETTINGS["BUTTON_EXIT_BG"], UI_SETTINGS["BUTTON_EXIT_HOVER"])
self.score_popups = []
def reset_game_state(self):
"""重置所有与一局游戏相关的变量,用于开始新游戏或重玩。"""
self.board = [[None for _ in range(BOARD_SIZE)] for _ in range(BOARD_SIZE)]
self.score, self.opportunities, self.highest_number, self.combo_counter = 0, INITIAL_OPPORTUNITIES, 0, 0
self.last_clicked_pos = None
def start_new_game(self):
"""开始一局全新的游戏。"""
self.reset_game_state()
values = [[random.randint(1, MAX_INITIAL_NUMBER) for _ in range(BOARD_SIZE)] for _ in range(BOARD_SIZE)]
# 循环生成,直到初始棋盘上没有任何可以消除的组合,确保开局公平
while self.check_for_matches(values)[0]:
values = [[random.randint(1, MAX_INITIAL_NUMBER) for _ in range(BOARD_SIZE)] for _ in range(BOARD_SIZE)]
for r in range(BOARD_SIZE):
for c in range(BOARD_SIZE):
self.board[r][c] = Cell(values[r][c], r, c, self.board_rect.topleft)
if values[r][c] > self.highest_number: self.highest_number = values[r][c]
self.flow_state, self.turn_state = "PLAYING", "IDLE"
if not self.is_muted: pygame.mixer.music.play(-1) # -1表示无限循环播放
def play_sound(self, sound_key: str):
"""播放一个指定的音效,但会检查静音状态。"""
if not self.is_muted: self.sounds[sound_key].play()
def run(self):
"""游戏的主循环。这个循环会一直运行,直到玩家关闭窗口。"""
while True:
# 1. 处理用户输入 (键盘、鼠标)
self.handle_events()
# 2. 更新游戏状态 (移动方块、计算分数、检查逻辑)
self.update()
# 3. 将所有东西绘制到屏幕上
self.draw()
# 4. 控制游戏速度,确保在所有电脑上运行速度一致
self.clock.tick(FPS)
def handle_events(self):
"""处理所有来自用户的事件。"""
for event in pygame.event.get():
if event.type == pygame.QUIT: pygame.quit(); sys.exit()
# 根据当前的游戏流程状态,将事件分发给对应的处理器
if self.flow_state == "START_MENU":
if self.start_button.handle_event(event): self.play_sound('click'); self.start_new_game()
elif self.flow_state == "GAME_OVER":
if self.restart_button.handle_event(event): self.play_sound('click'); self.start_new_game()
elif self.flow_state == "PLAYING":
if self.ingame_restart_button.handle_event(event): self.play_sound('click'); self.start_new_game()
if self.exit_button.handle_event(event): pygame.quit(); sys.exit()
if self.mute_button.handle_event(event):
self.is_muted = not self.is_muted; self.mute_button.set_text("Unmute" if self.is_muted else "Mute")
if self.is_muted: pygame.mixer.music.pause()
else: pygame.mixer.music.unpause()
# 只有在等待玩家操作(IDLE)时,才处理棋盘点击
if self.turn_state == "IDLE" and event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
self.handle_board_click(event.pos)
def update(self):
"""每帧更新游戏世界中的所有动态对象。"""
for r in range(BOARD_SIZE):
for c in range(BOARD_SIZE):
if self.board[r][c]: self.board[r][c].update()
for p in self.score_popups[:]:
p.update()
if p.lifetime <= 0: self.score_popups.remove(p)
# 持续更新按钮的悬停状态
mouse_pos = pygame.mouse.get_pos()
# 伪造一个MOUSEMOTION事件传递给按钮让它们能实时响应悬停
fake_event = pygame.event.Event(pygame.MOUSEMOTION, pos=mouse_pos)
if self.flow_state == "START_MENU":
self.start_button.handle_event(fake_event)
elif self.flow_state == "GAME_OVER":
self.restart_button.handle_event(fake_event)
elif self.flow_state == "PLAYING":
self.ingame_restart_button.handle_event(fake_event)
self.mute_button.handle_event(fake_event)
self.exit_button.handle_event(fake_event)
# 如果不在游戏进行中,或者动画正在播放,则不执行后续的游戏逻辑更新
if self.flow_state != "PLAYING" or time.time() < self.animation_end_time: return
# --- 游戏逻辑状态机:定义了消除、下落、填充的自动流程 ---
if self.turn_state == "CHECK_MATCHES":
values = [[cell.value if cell else 0 for cell in row] for row in self.board]
has_matches, matches_list = self.check_for_matches(values)
if not has_matches:
self.turn_state = "IDLE"; self.combo_counter = 0
if self.opportunities <= 0:
self.play_sound('over'); self.flow_state = "GAME_OVER"; pygame.mixer.music.stop()
else:
self.combo_counter += 1; self.play_sound('chain' if self.combo_counter > 1 else 'merge')
if self.opportunities < MAX_OPPORTUNITIES: self.opportunities += 1
self.process_merges(matches_list)
self.turn_state = "MERGING"; self.animation_end_time = time.time() + 0.3
elif self.turn_state == "MERGING":
self.turn_state = "DROPPING"; self.process_gravity(); self.animation_end_time = time.time() + 0.3
elif self.turn_state == "DROPPING":
self.turn_state = "REFILLING"; self.process_refill(); self.animation_end_time = time.time() + 0.3
elif self.turn_state == "REFILLING":
self.turn_state = "CHECK_MATCHES"; self.last_clicked_pos = None
def draw(self):
"""将所有游戏元素绘制到屏幕上。绘制顺序很重要,像画油画一样,一层一层往上画。"""
self.screen.fill(UI_SETTINGS["HUD_BG_COLOR"]) # 1. 绘制最底层的背景
self.draw_stats_bar() # 2. 绘制信息栏
self.draw_board_bg() # 3. 绘制棋盘背景
for r in range(BOARD_SIZE): # 4. 绘制所有方块
for c in range(BOARD_SIZE):
if self.board[r][c]: self.board[r][c].draw(self.screen)
for p in self.score_popups: p.draw(self.screen) # 5. 绘制分数弹出效果
# 6. 根据游戏流程绘制最上层的UI菜单或按钮
if self.flow_state == "PLAYING": self.draw_ingame_ui()
elif self.flow_state == "START_MENU": self.draw_start_menu()
elif self.flow_state == "GAME_OVER": self.draw_gameover_menu()
pygame.display.flip() # 7. 将内存中画好的一切,更新到屏幕上
def draw_board_bg(self):
"""绘制棋盘的深色背景和浅色网格。"""
draw_rounded_rect(self.screen, self.board_rect, BOARD_BG_COLOR, 20)
for r in range(BOARD_SIZE):
for c in range(BOARD_SIZE):
rect = pygame.Rect(self.board_rect.left + GRID_GAP + c * (CELL_SIZE + GRID_GAP),
self.board_rect.top + GRID_GAP + r * (CELL_SIZE + GRID_GAP), CELL_SIZE, CELL_SIZE)
draw_rounded_rect(self.screen, rect, EMPTY_CELL_COLOR, 15)
def draw_stats_bar(self):
"""绘制顶部的分数、机会、最高数字等信息。"""
spacing = UI_SETTINGS["HUD_LABEL_VALUE_SPACING"]
label_pos = UI_SETTINGS["HUD_SCORE_LABEL_POS"]; value_pos = (label_pos[0], label_pos[1] + spacing)
draw_text(self.screen, "Score", self.fonts['score_label'], WHITE, label_pos)
draw_text(self.screen, self.score, self.fonts['score_value'], UI_SETTINGS["HUD_SCORE_VALUE_COLOR"], value_pos)
label_pos = UI_SETTINGS["HUD_TRIES_LABEL_POS"]; value_pos = (label_pos[0], label_pos[1] + spacing)
draw_text(self.screen, "Tries", self.fonts['tries_label'], WHITE, label_pos)
draw_text(self.screen, '' * self.opportunities, self.fonts['tries_stars'], UI_SETTINGS["HUD_TRIES_STARS_COLOR"], value_pos)
label_pos = UI_SETTINGS["HUD_HIGHEST_LABEL_POS"]; value_pos = (label_pos[0], label_pos[1] + spacing)
draw_text(self.screen, "Highest", self.fonts['highest_label'], WHITE, label_pos)
draw_text(self.screen, self.highest_number, self.fonts['highest_value'], UI_SETTINGS["HUD_HIGHEST_VALUE_COLOR"], value_pos)
def draw_start_menu(self):
"""绘制开始菜单界面。"""
overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.SRCALPHA); overlay.fill(DARK_OVERLAY)
self.screen.blit(overlay, (0, 0))
title_x, title_y = UI_SETTINGS["MENU_TITLE_POS"]; line_spacing = UI_SETTINGS["MENU_TITLE_LINE_SPACING"]
draw_text(self.screen, UI_SETTINGS["MENU_TITLE_LINE1"], self.fonts['title'], GOLD, (title_x, title_y - line_spacing/2))
draw_text(self.screen, UI_SETTINGS["MENU_TITLE_LINE2"], self.fonts['title'], GOLD, (title_x, title_y + line_spacing/2))
draw_text(self.screen, "Click to upgrade, Match 3 to merge!", self.fonts['subtitle'], WHITE, UI_SETTINGS["MENU_SUBTITLE_POS"])
self.start_button.draw(self.screen)
def draw_gameover_menu(self):
"""绘制游戏结束界面。"""
overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT), pygame.SRCALPHA); overlay.fill(DARK_OVERLAY)
self.screen.blit(overlay, (0, 0))
draw_text(self.screen, "Game Over", self.fonts['gameover_title'], GOLD, UI_SETTINGS["GAMEOVER_TITLE_POS"])
base_x, base_y = UI_SETTINGS["GAMEOVER_SCORE_TEXT_BASE_POS"]; line_spacing = UI_SETTINGS["GAMEOVER_SCORE_LINE_SPACING"]
score_text = f"Final Score: {self.score}"; highest_text = f"Highest Tile: {self.highest_number}"
draw_text(self.screen, score_text, self.fonts['gameover_score'], WHITE, (base_x, base_y - line_spacing/2))
draw_text(self.screen, highest_text, self.fonts['gameover_score'], WHITE, (base_x, base_y + line_spacing/2))
self.restart_button.draw(self.screen)
def draw_ingame_ui(self):
"""绘制游戏进行时顶部的几个功能按钮。"""
self.ingame_restart_button.draw(self.screen); self.mute_button.draw(self.screen); self.exit_button.draw(self.screen)
def handle_board_click(self, mouse_pos: Tuple):
"""处理玩家在棋盘区域的点击。"""
if self.opportunities <= 0: return
for r in range(BOARD_SIZE):
for c in range(BOARD_SIZE):
rect = pygame.Rect(self.board_rect.left + GRID_GAP + c * (CELL_SIZE + GRID_GAP),
self.board_rect.top + GRID_GAP + r * (CELL_SIZE + GRID_GAP), CELL_SIZE, CELL_SIZE)
if rect.collidepoint(mouse_pos) and self.board[r][c]:
self.play_sound('click'); self.opportunities -= 1
cell = self.board[r][c]; cell.value += 1
if cell.value > self.highest_number: self.highest_number = cell.value
self.last_clicked_pos, self.turn_state, self.combo_counter = (r, c), "CHECK_MATCHES", 0
return
def check_for_matches(self, board_values: List[List[int]]) -> Tuple[bool, List]:
"""检查整个棋盘找出所有可消除的组合使用广度优先搜索BFS"""
matches, matches_list = set(), []
for r in range(BOARD_SIZE):
for c in range(BOARD_SIZE):
if board_values[r][c] == 0 or (r,c) in matches: continue
q, group = [(r, c)], set([(r, c)])
while q:
curr_r, curr_c = q.pop(0)
for dr, dc in [(0,1), (0,-1), (1,0), (-1,0)]:
nr, nc = curr_r + dr, curr_c + dc
if 0 <= nr < BOARD_SIZE and 0 <= nc < BOARD_SIZE and \
board_values[nr][nc] == board_values[r][c] and (nr, nc) not in group:
group.add((nr, nc)); q.append((nr, nc))
if len(group) >= 3: matches.update(group); matches_list.append(list(group))
return len(matches) > 0, matches_list
def process_merges(self, matches_list: List[List[Tuple]]):
"""处理所有找到的匹配组合,计算分数并更新方块数值。"""
for group in matches_list:
value = self.board[group[0][0]][group[0][1]].value
if value is None: continue
new_value = value + 1
target_pos = group[-1]
if self.last_clicked_pos and self.last_clicked_pos in group: target_pos = self.last_clicked_pos
score = new_value + (new_value - 1)**2
total_points = round(score * (1 + (self.combo_counter - 1) * 0.5))
self.score += total_points
if new_value > self.highest_number: self.highest_number = new_value
target_cell = self.board[target_pos[0]][target_pos[1]]
if target_cell:
popup_pos = target_cell.get_target_pos()
self.score_popups.append(ScorePopup(total_points, popup_pos, self.fonts['popup']))
for r, c in group:
if (r, c) == target_pos:
if self.board[r][c]: self.board[r][c].value = new_value
elif self.board[r][c]:
self.board[r][c].is_merging = True
def process_gravity(self):
"""处理重力效果,让上方的方块掉落以填补空位。"""
for r in range(BOARD_SIZE):
for c in range(BOARD_SIZE):
if self.board[r][c] and self.board[r][c].is_merging: self.board[r][c] = None
for c in range(BOARD_SIZE):
empty_row = BOARD_SIZE - 1
for r in range(BOARD_SIZE - 1, -1, -1):
if self.board[r][c]:
if r != empty_row:
cell = self.board[r][c]; self.board[empty_row][c] = cell
self.board[r][c] = None; cell.row = empty_row
empty_row -= 1
def process_refill(self):
"""在所有空位上生成新的随机方块。"""
for r in range(BOARD_SIZE):
for c in range(BOARD_SIZE):
if not self.board[r][c]: self.board[r][c] = Cell(random.randint(1, MAX_INITIAL_NUMBER), r, c, self.board_rect.topleft)
# ==============================================================================
# --- 4. 游戏入口 (GAME ENTRY POINT) ---
# ==============================================================================
if __name__ == '__main__':
# 这是Python程序的标准入口。
# 当你直接运行 `python game.py` 时,这部分代码会被执行。
# 1. 创建一个Game类的实例 (一个游戏对象)
# 2. 调用它的run()方法,启动游戏主循环
Game().run()