commit 8332a821b3c45142d7c034ade8b49b326bc7b29a Author: laowang Date: Wed Nov 5 14:14:10 2025 +0800 Initial commit diff --git a/assets/fonts/press-start-2p.ttf b/assets/fonts/press-start-2p.ttf new file mode 100644 index 0000000..39adf42 Binary files /dev/null and b/assets/fonts/press-start-2p.ttf differ diff --git a/assets/sounds/block-1-328874.mp3 b/assets/sounds/block-1-328874.mp3 new file mode 100644 index 0000000..e7437ed Binary files /dev/null and b/assets/sounds/block-1-328874.mp3 differ diff --git a/assets/sounds/clear-bell-notification-sound-351709.mp3 b/assets/sounds/clear-bell-notification-sound-351709.mp3 new file mode 100644 index 0000000..0f9e657 Binary files /dev/null and b/assets/sounds/clear-bell-notification-sound-351709.mp3 differ diff --git a/assets/sounds/game-level-complete-143022.mp3 b/assets/sounds/game-level-complete-143022.mp3 new file mode 100644 index 0000000..966b96b Binary files /dev/null and b/assets/sounds/game-level-complete-143022.mp3 differ diff --git a/assets/sounds/game-music-loop-6-144641.mp3 b/assets/sounds/game-music-loop-6-144641.mp3 new file mode 100644 index 0000000..975fb33 Binary files /dev/null and b/assets/sounds/game-music-loop-6-144641.mp3 differ diff --git a/assets/sounds/game-over-arcade-6435.mp3 b/assets/sounds/game-over-arcade-6435.mp3 new file mode 100644 index 0000000..1fd5b58 Binary files /dev/null and b/assets/sounds/game-over-arcade-6435.mp3 differ diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..9ea5ce0 Binary files /dev/null and b/favicon.ico differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..e24c71a --- /dev/null +++ b/main.py @@ -0,0 +1,541 @@ +# main.py (增强版复古俄罗斯方块) +# 版本: 2.5 (稳定版) +# 开发工程师: aistudio Gemini +# 日期: 2025-06-23 +# 更新日志: +# - [移除] 为保证游戏稳定性,彻底移除了导致按键锁死的“暂存(Hold)”功能。 +# - [修复] 将所有可配置参数整合到顶部的“配置中心”,方便用户自定义。 +# - [调整] 增加了游戏结束画面按钮的垂直间距,优化布局。 +# - [修复] 再次重构事件处理逻辑,彻底解决主菜单和游戏内侧边栏按钮无法点击的问题。 +# - [修复] 将“影子”按钮的文字改为英文,以解决因字体不支持中文而导致的乱码问题。 +# - [新增] 增加“影子方块”开关功能。 +# - [调整] 将“旋转”功能改回空格键,“硬降落”移至“上箭头(↑)”键。 + +import pygame +import random +import sys +import os +from collections import deque + +# ================================================================================= +# ====================== ⭐️ 游戏核心配置中心 (可在此处调整) ⭐️ ===================== +# ================================================================================= +CONFIG = { + # --- 界面文本与字体 (UI Text & Fonts) --- + "title_text": "Retro Tetris", + "font_sizes": { + "large": 26, # 大号字体,用于标题 + "medium": 24, # 中号字体 (未使用,备用) + "small": 16, # 小号字体,用于UI文本 + }, + + # --- 布局与尺寸 (Layout & Sizing) --- + "scale": 1.2, # 全局缩放比例 + "base_block_size": 30, # 方块的基础像素尺寸 + "sidebar_width": 220, # 侧边栏宽度 + "padding": 30, # 窗口内边距 + "gap_between_areas": 30, # 游戏区与侧边栏的间距 + "game_over_buttons_y_offset": 80, # 游戏结束画面按钮与上方文字的垂直距离 + + # --- 游戏难度与手感 (Gameplay & Feel) --- + "initial_fall_speed": 800, # 初始下落速度(毫秒),数字越大越慢 + "speed_increase_per_level": 70, # 每升一级,速度加快多少毫秒 + "lock_delay": 500, # 方块触底后的锁定延迟(毫秒) + "move_sideways_delay": 180, # 长按左右键的初始延迟(毫秒) + "move_sideways_interval": 40, # 长按左右键的移动间隔(毫秒) +} +# ================================================================================= + +# --- 从配置中心计算全局常量 --- +SCALE = CONFIG['scale'] +GRID_WIDTH, GRID_HEIGHT = 10, 20 +BASE_BLOCK_SIZE = CONFIG['base_block_size'] +BLOCK_SIZE = int(BASE_BLOCK_SIZE * SCALE) +SIDEBAR_WIDTH = int(CONFIG['sidebar_width'] * SCALE) +PADDING = int(CONFIG['padding'] * SCALE) +GAP_BETWEEN_AREAS = int(CONFIG['gap_between_areas'] * SCALE) +GAME_AREA_WIDTH = GRID_WIDTH * BLOCK_SIZE +GAME_AREA_HEIGHT = GRID_HEIGHT * BLOCK_SIZE +SCREEN_WIDTH = GAME_AREA_WIDTH + SIDEBAR_WIDTH + PADDING * 2 + GAP_BETWEEN_AREAS +SCREEN_HEIGHT = GAME_AREA_HEIGHT + PADDING * 2 +GAME_AREA_X, GAME_AREA_Y = PADDING, PADDING + +# --- 颜色定义 --- +BLACK = (20, 20, 30) +WHITE = (224, 224, 224) +GRID_COLOR = (40, 40, 50) +GHOST_COLOR_ALPHA = 80 +BUTTON_COLOR = (45, 45, 60) +BUTTON_HOVER_COLOR = (80, 80, 100) +OVERLAY_COLOR = (0, 0, 0, 180) + +# --- 方块与计分定义 --- +TETROMINO_DATA = { + 'I': {'shape': [[1, 1, 1, 1]], 'color': (30, 180, 210)}, + 'O': {'shape': [[1, 1], [1, 1]], 'color': (230, 200, 40)}, + 'T': {'shape': [[0, 1, 0], [1, 1, 1]], 'color': (180, 60, 220)}, + 'J': {'shape': [[1, 0, 0], [1, 1, 1]], 'color': (50, 100, 230)}, + 'L': {'shape': [[0, 0, 1], [1, 1, 1]], 'color': (220, 130, 30)}, + 'S': {'shape': [[0, 1, 1], [1, 1, 0]], 'color': (80, 200, 90)}, + 'Z': {'shape': [[1, 1, 0], [0, 1, 1]], 'color': (220, 50, 80)} +} +SCORE_VALUES = {0: 0, 1: 100, 2: 300, 3: 500, 4: 800} +LINES_PER_LEVEL = 10 + +# --- 资源路径处理 --- +def resource_path(relative_path): + try: base_path = sys._MEIPASS + except Exception: base_path = os.path.abspath(".") + return os.path.join(base_path, relative_path) + +# ================================================================================= +# ======================== ⭐️ 资源加载模块 ⭐️ =========================== +# ================================================================================= +class Assets: + def __init__(self): + self.large_font = self._load_font(CONFIG['font_sizes']['large']) + self.medium_font = self._load_font(CONFIG['font_sizes']['medium']) + self.small_font = self._load_font(CONFIG['font_sizes']['small']) + self.sounds = self._load_sounds() + self.icon = self._load_icon() + self.set_sound_volumes() + + def _load_font(self, size): + font_path = resource_path(os.path.join('assets', 'fonts', 'press-start-2p.ttf')) + try: + return pygame.font.Font(font_path, int(size * SCALE)) + except pygame.error: + print(f"Warning: Font file not found at {font_path}. Using default font.") + return pygame.font.Font(None, int(size * SCALE * 1.2)) + + def _load_sounds(self): + sounds = {} + sound_files = { + 'drop': 'block-1-328874.mp3', 'clear': 'clear-bell-notification-sound-351709.mp3', + 'level_up': 'game-level-complete-143022.mp3', 'game_over': 'game-over-arcade-6435.mp3' + } + try: + pygame.mixer.music.load(resource_path(os.path.join('assets', 'sounds', 'game-music-loop-6-144641.mp3'))) + for name, filename in sound_files.items(): + sounds[name] = pygame.mixer.Sound(resource_path(os.path.join('assets', 'sounds', filename))) + return sounds + except pygame.error as e: + print(f"Warning: Could not load sounds. {e}. Game will be silent.") + class DummySound: + def play(self): pass + def set_volume(self, v): pass + return {name: DummySound() for name in sound_files.keys()} + + def _load_icon(self): + try: return pygame.image.load(resource_path('favicon.ico')) + except pygame.error: print("Warning: 'favicon.ico' not found."); return None + + def set_sound_volumes(self): + self.volumes = {'music': 0.3, 'drop': 0.5, 'clear': 0.7, 'level_up': 0.8, 'game_over': 1.0} + if pygame.mixer.get_init(): + pygame.mixer.music.set_volume(self.volumes['music']) + for name, sound in self.sounds.items(): sound.set_volume(self.volumes[name]) + +# ================================================================================= +# ======================== ⭐️ 游戏核心逻辑类 ⭐️ ========================= +# ================================================================================= +class Piece: + def __init__(self, shape_name): + self.shape_name = shape_name + data = TETROMINO_DATA[shape_name] + self.matrix = data['shape'] + self.color = data['color'] + self.x = GRID_WIDTH // 2 - len(self.matrix[0]) // 2 + self.y = 0 + + def rotate(self): self.matrix = list(zip(*self.matrix[::-1])) + +class PieceGenerator: + def __init__(self): self.bag = []; self.refill_bag() + def refill_bag(self): self.bag = list(TETROMINO_DATA.keys()); random.shuffle(self.bag) + def next(self): + if not self.bag: self.refill_bag() + return Piece(self.bag.pop()) + +class Grid: + def __init__(self): self.grid = [[BLACK for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + + def is_valid_position(self, piece, ox=0, oy=0): + for y, row in enumerate(piece.matrix): + for x, cell in enumerate(row): + if cell: + new_x, new_y = piece.x + x + ox, piece.y + y + oy + if not (0 <= new_x < GRID_WIDTH and 0 <= new_y < GRID_HEIGHT and self.grid[new_y][new_x] == BLACK): + return False + return True + + def lock_piece(self, piece): + for y, row in enumerate(piece.matrix): + for x, cell in enumerate(row): + if cell: self.grid[piece.y + y][piece.x + x] = piece.color + + def clear_lines(self): + lines_to_clear = [i for i, row in enumerate(self.grid) if all(cell != BLACK for cell in row)] + if lines_to_clear: + for i in lines_to_clear: + del self.grid[i]; self.grid.insert(0, [BLACK for _ in range(GRID_WIDTH)]) + return len(lines_to_clear) + + def reset(self): self.grid = [[BLACK for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +class ScoreManager: + def __init__(self): self.reset() + + def reset(self): + self.score = 0; self.level = 1; self.lines_cleared = 0 + self.fall_speed = CONFIG['initial_fall_speed'] + + def update(self, cleared_lines): + leveled_up = False + self.score += SCORE_VALUES.get(cleared_lines, 0) * self.level + self.lines_cleared += cleared_lines + + new_level = 1 + self.lines_cleared // LINES_PER_LEVEL + if new_level > self.level: + self.level = new_level + self.fall_speed = max(100, CONFIG['initial_fall_speed'] - (self.level - 1) * CONFIG['speed_increase_per_level']) + leveled_up = True + return leveled_up + +# ================================================================================= +# ======================== ⭐️ UI 与渲染类 ⭐️ =========================== +# ================================================================================= +class Button: + def __init__(self, text, pos, size, font, callback): + self.rect = pygame.Rect(pos, size) + self.text = text; self.font = font; self.callback = callback + self.is_hovered = False + + def draw(self, screen): + color = BUTTON_HOVER_COLOR if self.is_hovered else BUTTON_COLOR + pygame.draw.rect(screen, color, self.rect, border_radius=int(8 * SCALE)) + surf = self.font.render(self.text, True, WHITE) + screen.blit(surf, surf.get_rect(center=self.rect.center)) + + def handle_event(self, event): + 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: + if self.callback: self.callback() + +class UIManager: + def __init__(self, game, assets): + self.game = game; self.assets = assets + self.buttons = {}; self._setup_buttons() + + def _setup_buttons(self): + btn_w, btn_h = int(180 * SCALE), int(45 * SCALE) + sidebar_x = GAME_AREA_X + GAME_AREA_WIDTH + GAP_BETWEEN_AREAS + btn_x = sidebar_x + (SIDEBAR_WIDTH - btn_w) // 2 + btn_gap = int(55 * SCALE) + + # --- [移除] 暂存功能后,按钮数量减少,重新布局 --- + btn_y_start = SCREEN_HEIGHT - PADDING - (btn_h + int(10*SCALE)) * 4 + self.buttons['in_game'] = [ + Button("Pause", (btn_x, btn_y_start), (btn_w, btn_h), self.assets.small_font, self.game.toggle_pause), + Button("Restart", (btn_x, btn_y_start + btn_gap), (btn_w, btn_h), self.assets.small_font, self.game.reset), + Button("Mute", (btn_x, btn_y_start + btn_gap * 2), (btn_w, btn_h), self.assets.small_font, self.game.toggle_mute), + Button("Ghost:Off", (btn_x, btn_y_start + btn_gap * 3), (btn_w, btn_h), self.assets.small_font, self.game.toggle_ghost) + ] + + menu_btn_x = SCREEN_WIDTH / 2 - btn_w / 2 + self.buttons['menu'] = [ + Button("Start Game", (menu_btn_x, SCREEN_HEIGHT / 2), (btn_w, btn_h), self.assets.small_font, self.game.start_game), + Button("Quit", (menu_btn_x, SCREEN_HEIGHT / 2 + btn_gap), (btn_w, btn_h), self.assets.small_font, sys.exit) + ] + self.buttons['paused'] = [ + Button("Resume", (menu_btn_x, SCREEN_HEIGHT / 2 - btn_gap), (btn_w, btn_h), self.assets.small_font, self.game.toggle_pause), + Button("Restart", (menu_btn_x, SCREEN_HEIGHT / 2), (btn_w, btn_h), self.assets.small_font, self.game.reset), + Button("Quit", (menu_btn_x, SCREEN_HEIGHT / 2 + btn_gap), (btn_w, btn_h), self.assets.small_font, sys.exit) + ] + + game_over_btn_y = SCREEN_HEIGHT/2 + int(CONFIG['game_over_buttons_y_offset'] * SCALE) + self.buttons['game_over'] = [ + Button("Restart", (menu_btn_x, game_over_btn_y), (btn_w, btn_h), self.assets.small_font, self.game.reset), + Button("Main Menu", (menu_btn_x, game_over_btn_y + btn_gap), (btn_w, btn_h), self.assets.small_font, self.game.go_to_menu) + ] + + def get_buttons(self, state): return self.buttons.get(state, []) + +class Renderer: + def __init__(self, screen, assets): + self.screen = screen; self.assets = assets + self.crt_scanline_surface = self._create_crt_scanline_surface() + + def _create_crt_scanline_surface(self): + w, h = self.screen.get_size() + scanline_surface = pygame.Surface((w, h), pygame.SRCALPHA) + for y in range(0, h, int(4 * SCALE)): + pygame.draw.line(scanline_surface, (0, 0, 0, 40), (0, y), (w, y), int(2 * SCALE)) + return scanline_surface + + def draw(self, game): + self.screen.fill(BLACK) + if game.state == "menu": self.draw_main_menu(game) + else: + self.draw_game_area(game.grid) + self.draw_piece(game.current_piece) + if game.show_ghost: self.draw_ghost_piece(game) + self.draw_sidebar(game) + if game.state == "paused": self.draw_overlay("PAUSED", game.ui_manager.get_buttons('paused')) + elif game.state == "game_over": self.draw_game_over_overlay(game) + self.screen.blit(self.crt_scanline_surface, (0, 0)) + pygame.display.flip() + + def draw_piece(self, piece, ghost=False): + if not piece: return + for y, row in enumerate(piece.matrix): + for x, cell in enumerate(row): + if cell: + rect = (GAME_AREA_X + (piece.x + x) * BLOCK_SIZE, GAME_AREA_Y + (piece.y + y) * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE) + if ghost: + surf = pygame.Surface((BLOCK_SIZE, BLOCK_SIZE), pygame.SRCALPHA) + surf.fill((*piece.color, GHOST_COLOR_ALPHA)) + self.screen.blit(surf, rect[:2]) + else: + pygame.draw.rect(self.screen, piece.color, rect) + pygame.draw.rect(self.screen, WHITE, rect, 1) + + def draw_ghost_piece(self, game): + if not game.current_piece: return + ghost = Piece(game.current_piece.shape_name) + ghost.matrix = game.current_piece.matrix + ghost.x = game.current_piece.x; ghost.y = game.current_piece.y + ghost.color = game.current_piece.color + while game.grid.is_valid_position(ghost, oy=1): ghost.y += 1 + self.draw_piece(ghost, ghost=True) + + def draw_game_area(self, grid_obj): + for y, row in enumerate(grid_obj.grid): + for x, color in enumerate(row): + if color != BLACK: + rect = (GAME_AREA_X + x * BLOCK_SIZE, GAME_AREA_Y + y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE) + pygame.draw.rect(self.screen, color, rect); pygame.draw.rect(self.screen, WHITE, rect, 1) + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + rect = (GAME_AREA_X + x * BLOCK_SIZE, GAME_AREA_Y + y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE) + pygame.draw.rect(self.screen, GRID_COLOR, rect, 1) + + def draw_sidebar(self, game): + sidebar_x = GAME_AREA_X + GAME_AREA_WIDTH + GAP_BETWEEN_AREAS + sidebar_center_x = sidebar_x + SIDEBAR_WIDTH // 2 + def draw_info(label, value, y_pos): + label_surf = self.assets.small_font.render(label, True, WHITE) + self.screen.blit(label_surf, label_surf.get_rect(centerx=sidebar_center_x, top=y_pos)) + value_surf = self.assets.small_font.render(str(value), True, WHITE) + self.screen.blit(value_surf, value_surf.get_rect(centerx=sidebar_center_x, top=y_pos + int(25 * SCALE))) + draw_info("SCORE", game.score_manager.score, GAME_AREA_Y + int(40*SCALE)) + draw_info("LEVEL", game.score_manager.level, GAME_AREA_Y + int(110*SCALE)) + draw_info("LINES", game.score_manager.lines_cleared, GAME_AREA_Y + int(180*SCALE)) + + next_title_y = GAME_AREA_Y + int(260*SCALE) + next_label_surf = self.assets.small_font.render("NEXT", True, WHITE) + self.screen.blit(next_label_surf, next_label_surf.get_rect(centerx=sidebar_center_x, top=next_title_y)) + if game.next_piece: self.draw_sidebar_piece(game.next_piece, sidebar_center_x, next_title_y + int(25*SCALE)) + + # --- [移除] 移除暂存方块的绘制逻辑 --- + + for btn in game.ui_manager.get_buttons('in_game'): btn.draw(self.screen) + + def draw_sidebar_piece(self, piece, center_x, top_y): + matrix = piece.matrix + piece_w_pixels = len(matrix[0]) * BLOCK_SIZE + start_x = center_x - piece_w_pixels // 2 + for y, row in enumerate(matrix): + for x, cell in enumerate(row): + if cell: + rect = (start_x + x * BLOCK_SIZE, top_y + y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE) + pygame.draw.rect(self.screen, piece.color, rect); pygame.draw.rect(self.screen, WHITE, rect, 1) + + def draw_overlay(self, text, buttons): + overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA); overlay.fill(OVERLAY_COLOR) + self.screen.blit(overlay, (0, 0)) + main_surf = self.assets.large_font.render(text, True, WHITE) + self.screen.blit(main_surf, main_surf.get_rect(center=(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 3))) + for btn in buttons: btn.draw(self.screen) + + def draw_game_over_overlay(self, game): + overlay = pygame.Surface(self.screen.get_size(), pygame.SRCALPHA); overlay.fill(OVERLAY_COLOR) + self.screen.blit(overlay, (0, 0)) + + title_surf = self.assets.large_font.render("GAME OVER", True, WHITE) + self.screen.blit(title_surf, title_surf.get_rect(center=(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 4))) + + y_start = SCREEN_HEIGHT / 4 + int(100*SCALE); line_height = int(40*SCALE) + def draw_stat(label, value, y): + text = f"{label}: {value}" + surf = self.assets.small_font.render(text, True, WHITE) + self.screen.blit(surf, surf.get_rect(centerx=SCREEN_WIDTH / 2, top=y)) + draw_stat("Final Score", game.score_manager.score, y_start) + draw_stat("Level Reached", game.score_manager.level, y_start + line_height) + draw_stat("Lines Cleared", game.score_manager.lines_cleared, y_start + line_height * 2) + + for btn in game.ui_manager.get_buttons('game_over'): btn.draw(self.screen) + + def draw_main_menu(self, game): + surf = self.assets.large_font.render(CONFIG['title_text'], True, WHITE) + self.screen.blit(surf, surf.get_rect(center=(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 3))) + for btn in game.ui_manager.get_buttons('menu'): btn.draw(self.screen) + +# ================================================================================= +# ======================== ⭐️ 游戏主控类 ⭐️ =========================== +# ================================================================================= +class TetrisGame: + def __init__(self): + pygame.init(); pygame.mixer.init() + self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) + pygame.display.set_caption(f"{CONFIG['title_text']} - 复古方块 - 像素风俄罗斯方块挑战") + self.assets = Assets() + if self.assets.icon: pygame.display.set_icon(self.assets.icon) + + self.clock = pygame.time.Clock(); self.state = "menu" + self.grid = Grid(); self.score_manager = ScoreManager() + self.piece_generator = PieceGenerator(); self.renderer = Renderer(self.screen, self.assets) + self.ui_manager = UIManager(self, self.assets) + + self.fall_time = 0; self.lock_timer = None + self.current_piece = None; self.next_piece = None + # --- [移除] 移除暂存功能相关的状态变量 --- + self.is_muted = False; self.show_ghost = False + self.move_sideways_time = 0; self.key_down_time = {'left': 0, 'right': 0} + + def reset(self): + self.grid.reset(); self.score_manager.reset(); self.piece_generator.refill_bag() + self.current_piece = self.piece_generator.next(); self.next_piece = self.piece_generator.next() + # --- [移除] 移除暂存功能相关的重置逻辑 --- + self.fall_time = pygame.time.get_ticks(); self.lock_timer = None + self.state = "playing" + if not self.is_muted and pygame.mixer.get_init(): pygame.mixer.music.play(-1) + + def start_game(self): self.reset() + def go_to_menu(self): self.state = "menu"; pygame.mixer.music.stop() if pygame.mixer.get_init() else None + + def toggle_pause(self): + if self.state == "playing": self.state = "paused"; pygame.mixer.music.pause() if pygame.mixer.get_init() else None + elif self.state == "paused": self.state = "playing"; pygame.mixer.music.unpause() if not self.is_muted and pygame.mixer.get_init() else None + + def toggle_mute(self): + self.is_muted = not self.is_muted + self.ui_manager.buttons['in_game'][2].text = "Unmute" if self.is_muted else "Mute" + if pygame.mixer.get_init(): + music_vol = self.assets.volumes['music'] if not self.is_muted else 0 + pygame.mixer.music.set_volume(music_vol) + for name, sound in self.assets.sounds.items(): sound.set_volume(self.assets.volumes[name] if not self.is_muted else 0) + + def toggle_ghost(self): + self.show_ghost = not self.show_ghost + self.ui_manager.buttons['in_game'][3].text = "Ghost: On" if self.show_ghost else "Ghost: Off" + + def move(self, dx, dy): + if not self.current_piece: return False + if self.grid.is_valid_position(self.current_piece, dx, dy): + self.current_piece.x += dx; self.current_piece.y += dy + self.reset_lock_delay(); return True + return False + + def rotate(self): + if not self.current_piece: return + original_matrix = self.current_piece.matrix + self.current_piece.rotate() + if not self.grid.is_valid_position(self.current_piece): + if self.grid.is_valid_position(self.current_piece, ox=1): self.current_piece.x += 1 + elif self.grid.is_valid_position(self.current_piece, ox=-1): self.current_piece.x -= 1 + else: self.current_piece.matrix = original_matrix; return + self.reset_lock_delay() + + def hard_drop(self): + if not self.current_piece: return + while self.grid.is_valid_position(self.current_piece, oy=1): self.current_piece.y += 1 + self.lock_piece() + + # --- [移除] 彻底移除 hold 方法 --- + + def lock_piece(self): + self.grid.lock_piece(self.current_piece) + if self.assets.sounds.get('drop'): self.assets.sounds['drop'].play() + + cleared_lines = self.grid.clear_lines() + if cleared_lines > 0: + if self.assets.sounds.get('clear'): self.assets.sounds['clear'].play() + if self.score_manager.update(cleared_lines): + if self.assets.sounds.get('level_up'): self.assets.sounds['level_up'].play() + + self.current_piece = self.next_piece + self.next_piece = self.piece_generator.next() + self.lock_timer = None + if not self.grid.is_valid_position(self.current_piece): + self.state = "game_over" + if pygame.mixer.get_init(): + pygame.mixer.music.stop() + if self.assets.sounds.get('game_over'): self.assets.sounds['game_over'].play() + + def reset_lock_delay(self): + if self.current_piece and not self.grid.is_valid_position(self.current_piece, oy=1): + self.lock_timer = pygame.time.get_ticks() + + def run(self): + while True: + now = pygame.time.get_ticks() + self.handle_events(now) + if self.state == "playing": self.update(now) + self.renderer.draw(self) + self.clock.tick(60) + + def handle_events(self, now): + if self.state == "playing": + keys = pygame.key.get_pressed() + if keys[pygame.K_DOWN] or keys[pygame.K_s]: self.move(0, 1) + if keys[pygame.K_LEFT] or keys[pygame.K_a]: + if now - self.key_down_time.get('left', 0) > CONFIG['move_sideways_delay'] and now - self.move_sideways_time > CONFIG['move_sideways_interval']: + self.move(-1, 0); self.move_sideways_time = now + if keys[pygame.K_RIGHT] or keys[pygame.K_d]: + if now - self.key_down_time.get('right', 0) > CONFIG['move_sideways_delay'] and now - self.move_sideways_time > CONFIG['move_sideways_interval']: + self.move(1, 0); self.move_sideways_time = now + for event in pygame.event.get(): + if event.type == pygame.QUIT: pygame.quit(); sys.exit() + if self.state == 'menu': self.handle_menu_events(event) + elif self.state == 'playing': self.handle_playing_events(event, now) + elif self.state == 'paused': self.handle_paused_events(event) + elif self.state == 'game_over': self.handle_game_over_events(event) + + def handle_menu_events(self, event): + for btn in self.ui_manager.get_buttons('menu'): btn.handle_event(event) + + def handle_playing_events(self, event, now): + for btn in self.ui_manager.get_buttons('in_game'): btn.handle_event(event) + if event.type == pygame.KEYDOWN: + if event.key in [pygame.K_LEFT, pygame.K_a]: self.move(-1, 0); self.key_down_time['left'] = now + elif event.key in [pygame.K_RIGHT, pygame.K_d]: self.move(1, 0); self.key_down_time['right'] = now + elif event.key in [pygame.K_SPACE, pygame.K_x, pygame.K_w]: self.rotate() + elif event.key == pygame.K_UP: self.hard_drop() + # --- [移除] 移除C键暂存的事件绑定 --- + elif event.key == pygame.K_ESCAPE: self.toggle_pause() + if event.type == pygame.KEYUP: + if event.key in [pygame.K_LEFT, pygame.K_a]: self.key_down_time['left'] = 0 + if event.key in [pygame.K_RIGHT, pygame.K_d]: self.key_down_time['right'] = 0 + + def handle_paused_events(self, event): + for btn in self.ui_manager.get_buttons('paused'): btn.handle_event(event) + if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: self.toggle_pause() + + def handle_game_over_events(self, event): + for btn in self.ui_manager.get_buttons('game_over'): btn.handle_event(event) + if event.type == pygame.KEYDOWN and event.key in [pygame.K_r, pygame.K_RETURN]: self.reset() + + def update(self, now): + if self.current_piece is None: return + if now - self.fall_time > self.score_manager.fall_speed: + self.fall_time = now + if not self.move(0, 1): + if self.lock_timer is None: self.lock_timer = now + if self.lock_timer and now - self.lock_timer > CONFIG['lock_delay']: + if not self.grid.is_valid_position(self.current_piece, oy=1): self.lock_piece() + +if __name__ == '__main__': + game = TetrisGame() + game.run() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f0a5f90 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pyinstaller +pygame \ No newline at end of file diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..f1353c8 --- /dev/null +++ b/run.bat @@ -0,0 +1,25 @@ +@echo off +chcp 65001 >nul +echo -------------------------------------------------- +echo ⻷Ƿ... +if exist venv ( + echo ⻷Ѵڡ +) else ( + echo ⻷ڣڴ... + python -m venv venv + echo ⻷װ... + call venv\Scripts\activate.bat + pip install -r requirements.txt + goto run_script +) + +echo ⻷... +call venv\Scripts\activate.bat + +:run_script +echo תű... +python main.py + +echo -------------------------------------------------- +echo ɣ +pause