Python复刻中国象棋:从数据结构到AI算法的完整实现
1. 项目概述为什么用Python复刻中国象棋是个好主意最近在整理自己的项目库翻到了一个几年前用Python写的中国象棋游戏当时纯粹是出于兴趣想挑战一下自己。现在回头看这个项目虽然不大但麻雀虽小五脏俱全它几乎涵盖了桌面游戏开发中所有核心的、让人头疼的问题。从棋盘棋子的绘制、复杂的走子规则校验到人机对战的AI逻辑每一步都踩过坑也都有对应的解决方案。如果你正在学习Python想找一个能综合锻炼编程思维、逻辑能力和图形界面开发的项目复刻中国象棋绝对是个绝佳的选择。它不像俄罗斯方块那样简单也不像大型3A游戏那样遥不可及。它有着明确的规则边界但又包含了状态管理、事件驱动、算法设计等多个层面的挑战。更重要的是当你看到自己写的程序能像真正的象棋软件一样运行那种成就感是无与伦比的。这个项目适合有一定Python基础熟悉基本语法和面向对象编程并且对游戏逻辑或图形界面开发感兴趣的开发者。接下来我会把开发过程中最关键的五个问题掰开揉碎了讲清楚并附上我当时是如何解决的思路和代码片段。2. 核心问题一如何优雅地表示棋盘与棋子状态这是所有棋盘类游戏的基石。一个糟糕的数据结构设计会让后续的走子、判断、AI计算变得异常复杂和低效。我最初尝试过用简单的二维列表比如board[9][10]里面存储代表棋子的字符串如r代表红车n代表黑马。这确实直观但很快问题就来了。2.1 从二维列表到面向对象的设计演进用二维列表存储字符串在判断某个位置是什么棋子时很快但当你需要获取一个棋子的所有信息时就非常麻烦。比如我需要知道这个“车”是红方还是黑方它是否已经移动过对于“王车易位”规则象棋里没有但设计上要考虑扩展甚至这个棋子当前的可移动位置集合。每次都需要去解析那个字符串或者维护额外的数据结构代码会变得很脏。我的解决方案是采用面向对象的思想设计一个Piece类和一个Board类。class Piece: def __init__(self, name, color, position): self.name name # 如 ‘rook‘车、‘horse‘马 self.color color # ‘red‘ 或 ‘black‘ self.position position # (x, y) 元组例如 (0, 0) 代表棋盘左上角 self.captured False def get_possible_moves(self, board): 根据棋类规则计算当前棋子在给定棋盘状态下所有可能的落子位置。 这是一个抽象方法每个棋子子类需要重写。 pass class Board: def __init__(self): self.grid [[None for _ in range(9)] for _ in range(10)] # 9列10行的棋盘 self.current_player ‘red‘ # 红方先行 self.piece_list [] # 存储所有棋子对象的引用 self.move_history [] # 记录走子历史用于悔棋等功能 self._initialize_pieces() def _initialize_pieces(self): # 初始化棋子并放置到grid和piece_list中 pieces_setup [ (‘rook‘, ‘black‘, (0, 0)), (‘horse‘, ‘black‘, (1, 0)), # ... 省略其他棋子初始化 (‘rook‘, ‘red‘, (0, 9)), (‘horse‘, ‘red‘, (1, 9)), ] for name, color, pos in pieces_setup: piece self._create_piece_by_name(name, color, pos) self.grid[pos[1]][pos[0]] piece self.piece_list.append(piece)这样设计的好处是棋盘Board成为了游戏状态的唯一权威来源。任何操作如移动棋子、判断胜负都通过Board对象的方法来完成。Piece对象只负责自己的属性和基于规则计算可能走法这部分后面会详述而Board负责协调全局规则比如是否送将、长将等。注意这里position的坐标我采用了(x, y)格式且x是列y是行原点(0,0)在左上角。这与数学坐标系和部分图形库的坐标系可能不同需要在整个项目中保持一致。我选择这个是因为它更符合列表索引grid[y][x]的访问方式。2.2 棋盘状态的高效访问与更新有了对象模型移动棋子的操作就变得清晰了。在Board类中我设计了make_move(start_pos, end_pos)方法。def make_move(self, start_pos, end_pos): 执行一步走子。假设传入的位置都是合法的。 start_x, start_y start_pos end_x, end_y end_pos moving_piece self.grid[start_y][start_x] if moving_piece is None: raise ValueError(“起始位置没有棋子”) # 1. 记录历史用于悔棋 captured_piece self.grid[end_y][end_x] self.move_history.append({ ‘start‘: start_pos, ‘end‘: end_pos, ‘piece‘: moving_piece, ‘captured‘: captured_piece }) # 2. 更新棋盘网格 self.grid[start_y][start_x] None self.grid[end_y][end_x] moving_piece # 3. 更新棋子对象自身的位置 moving_piece.position (end_x, end_y) # 4. 如果目标位置有对方棋子则将其标记为被吃 if captured_piece: captured_piece.captured True self.piece_list.remove(captured_piece) # 从活动列表移除 # 5. 切换行棋方 self.current_player ‘black‘ if self.current_player ‘red‘ else ‘red‘ # 6. 检查移动后是否形成将军状态并判断胜负 return self._check_game_state()这个方法封装了走子的所有副作用更新数据、记录历史、处理吃子、切换玩家、检查游戏状态。这保证了游戏状态变化的原子性和一致性。_check_game_state()方法会检查当前行棋方是否被将军以及是否被将死从而决定游戏是否结束。3. 核心问题二如何实现复杂且准确的走子规则校验中国象棋的走子规则是项目的核心难点。每个兵种的移动规则都不同而且有“蹩马腿”、“塞象眼”、“炮打隔山子”等特殊限制。规则校验必须100%准确否则游戏就失去了意义。3.1 基于棋子的规则分发与实现我的策略是利用多态。为每个棋子类型创建Piece的子类如RookHorseCannon等每个子类重写get_possible_moves(board)方法。Board的get_legal_moves(position)方法会调用对应棋子对象的这个方法获取理论可行点然后再结合棋盘全局状态如是否出界、是否走到己方棋子位置、是否送将等进行过滤返回最终合法走法。以“马”为例它的走法是“日”字形但受“蹩马腿”限制class Horse(Piece): def get_possible_moves(self, board): x, y self.position moves [] # 马的8个可能跳点 offsets [ (1, 2), (2, 1), (2, -1), (1, -2), (-1, -2), (-2, -1), (-2, 1), (-1, 2) ] # 对应的蹩腿点马脚位置 block_offsets [ (0, 1), (1, 0), (1, 0), (0, -1), (0, -1), (-1, 0), (-1, 0), (0, 1) ] for (dx, dy), (bx, by) in zip(offsets, block_offsets): new_x, new_y x dx, y dy block_x, block_y x bx, y by # 检查目标点是否在棋盘内 if not (0 new_x 9 and 0 new_y 10): continue # 检查蹩腿点是否有任何棋子无论敌我 if board.grid[block_y][block_x] is not None: continue # 检查目标点是否有己方棋子 target_piece board.grid[new_y][new_x] if target_piece is not None and target_piece.color self.color: continue # 通过所有检查加入可能移动列表 moves.append((new_x, new_y)) return moves“炮”的规则更特殊它需要区分移动和吃子。移动时路径上不能有任何棋子吃子时路径上必须恰好有一个棋子作为“炮架”且目标位置必须是敌方棋子。class Cannon(Piece): def get_possible_moves(self, board): x, y self.position moves [] directions [(1, 0), (0, 1), (-1, 0), (0, -1)] # 右下左上 for dx, dy in directions: found_hurdle False # 是否已经遇到一个棋子炮架 step 1 while True: nx, ny x dx*step, y dy*step if not (0 nx 9 and 0 ny 10): break # 走出棋盘 target board.grid[ny][nx] if not found_hurdle: # 炮架发现之前 if target is None: # 空位可以移动到此 moves.append((nx, ny)) else: # 遇到第一个棋子记录为炮架此位置不能走 found_hurdle True else: # 炮架发现之后 if target is not None: # 遇到第二个棋子 if target.color ! self.color: # 是敌方棋子可以吃 moves.append((nx, ny)) # 无论敌我第二个棋子都会阻挡后续路线 break step 1 return moves3.2 全局规则校验防止送将与长将棋子自身的规则校验只是第一步。在象棋中不允许“送将”即移动后自己的将/帅被对方攻击也不允许“长将”即连续不断地将军对方。这些规则需要在Board层面进行校验。在Board.get_legal_moves(position)方法中我采用了“尝试移动-检查状态-回退”的模拟方法def get_legal_moves(self, position): 获取指定位置棋子的所有合法走法。 x, y position piece self.grid[y][x] if piece is None or piece.color ! self.current_player: return [] # 不是当前行棋方的棋子或无子 pseudo_legal_moves piece.get_possible_moves(self) legal_moves [] for end_pos in pseudo_legal_moves: # 1. 模拟走子 move_record self._simulate_move(position, end_pos) # 2. 检查模拟后当前行棋方即刚刚走完的一方的将是否被攻击 # 注意此时current_player已在_simulate_move中切换 if not self._is_king_under_attack(self.current_player): # 没有送将是合法走法 legal_moves.append(end_pos) # 3. 撤销模拟 self._undo_simulated_move(move_record) return legal_moves def _is_king_under_attack(self, color): 判断指定颜色的一方是否被将军。 # 找到该颜色的将/帅的位置 king_pos None for piece in self.piece_list: if isinstance(piece, King) and piece.color color: king_pos piece.position break if king_pos is None: return True # 将帅被吃游戏应已结束 # 遍历对方所有棋子检查其可能走法中是否包含己方将帅的位置 opponent_color ‘red‘ if color ‘black‘ else ‘black‘ for piece in self.piece_list: if piece.color opponent_color and not piece.captured: if king_pos in piece.get_possible_moves(self): # 注意这里用get_possible_moves是理论攻击范围不考虑送将规则 return True return False_simulate_move和_undo_simulated_move是轻量级的临时状态操作避免深拷贝整个棋盘对象性能更高。对于“长将”的判断则需要维护move_history检查在一定步数内相同的棋盘状态是否重复出现这涉及到Zobrist哈希等更高级的技术在基础版本中可以暂时简化处理例如记录将军的连续次数。4. 核心问题三如何设计一个简单可玩的人机对战AI让电脑和你下棋是游戏可玩性的关键。对于中国象棋实现一个顶尖AI如“象棋旋风”极其复杂但做一个“有点意思”的初级AI并不难。我采用最经典的“极大极小值搜索”算法配合“Alpha-Beta剪枝”。4.1 评估函数告诉AI什么是“好”局面AI需要量化一个棋盘局面的好坏这就是评估函数。一个简单的评估函数可以基于“子力价值”和“棋子位置”PIECE_VALUE { ‘king‘: 10000, # 将/帅价值极高 ‘rook‘: 500, # 车 ‘horse‘: 350, # 马 ‘cannon‘: 350, # 炮 ‘elephant‘: 200, # 象/相 ‘guard‘: 200, # 士/仕 ‘pawn‘: 100, # 兵/卒 } def evaluate_board(board, color): 从color一方的角度评估棋盘分数。 score 0 for piece in board.piece_list: if piece.captured: continue piece_score PIECE_VALUE[piece.name] # 加上简单的位置分例如过河的兵加分车占肋线加分等这里简化 position_bonus get_position_bonus(piece) if piece.color color: score piece_score position_bonus else: score - (piece_score position_bonus) return score这个函数计算己方所有棋子总价值减去对方所有棋子总价值。加上位置奖励后AI会倾向于保住自己的大子、吃掉对方的大子并把棋子走到好位置。4.2 极大极小搜索与Alpha-Beta剪枝AI的思路是模拟未来几步所有可能的走法选择对自己最有利、对对手最不利的那一步。这就是“极大极小”。假设AI是红方它会在自己的回合极大层选择使自己评估分数最高的走法在模拟对手的回合极小层它会假设对手会选择使AI评估分数最低的走法。def minimax(board, depth, alpha, beta, maximizing_player): 极大极小搜索带Alpha-Beta剪枝。 if depth 0 or board.is_game_over(): # 到达搜索深度或游戏结束返回当前局面的评估值 return evaluate_board(board, AI_COLOR), None best_move None if maximizing_player: # AI的回合希望分数最大 max_eval float(‘-inf‘) legal_moves board.get_all_legal_moves_for_color(board.current_player) for move in legal_moves: # 模拟走子 move_record board._simulate_move(move[0], move[1]) eval, _ minimax(board, depth-1, alpha, beta, False) # 撤销模拟 board._undo_simulated_move(move_record) if eval max_eval: max_eval eval best_move move # Alpha-Beta剪枝 alpha max(alpha, eval) if beta alpha: break # Beta剪枝 return max_eval, best_move else: # 对手的回合假设对手希望AI的分数最小 min_eval float(‘inf‘) legal_moves board.get_all_legal_moves_for_color(board.current_player) for move in legal_moves: move_record board._simulate_move(move[0], move[1]) eval, _ minimax(board, depth-1, alpha, beta, True) board._undo_simulated_move(move_record) if eval min_eval: min_eval eval best_move move beta min(beta, eval) if beta alpha: break # Alpha剪枝 return min_eval, best_moveAI_COLOR是AI执棋的颜色。depth是搜索深度深度越大AI越强但耗时呈指数级增长。Alpha-Beta剪枝可以极大减少需要搜索的节点数在相同时间内搜索更深。调用时从根节点开始_, ai_move minimax(current_board, depth3, alpha-inf, betainf, maximizing_playerTrue)即可得到AI推荐的最佳走法。实操心得对于初级AI搜索深度设为2或3就足够了。深度为3意味着“AI走一步 - 你走一步 - AI走一步 - 评估”已经具备一定的战术眼光。评估函数是AI的“棋风”调整子力价值或增加位置权重可以明显改变AI的行棋风格比如让AI更激进或更保守。5. 核心问题四如何构建一个清晰响应的图形用户界面游戏不能只活在命令行里。一个直观的GUI至关重要。我选择了Pygame因为它轻量、简单非常适合这种2D棋盘游戏。5.1 使用Pygame进行图形绘制与事件循环主程序结构是一个典型的事件循环import pygame def main(): pygame.init() screen pygame.display.set_mode((BOARD_WIDTH, BOARD_HEIGHT)) clock pygame.time.Clock() board Board() # 我们的游戏逻辑核心 game Game(screen, board) # 封装了绘制和界面交互的类 selected_pos None # 当前选中的棋子位置 running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.MOUSEBUTTONDOWN: if game.current_mode ‘human_turn‘: # 将鼠标点击坐标转换为棋盘格子坐标 mouse_x, mouse_y pygame.mouse.get_pos() clicked_pos game.convert_pixel_to_board(mouse_x, mouse_y) if selected_pos is None: # 第一次点击选中棋子 piece board.grid[clicked_pos[1]][clicked_pos[0]] if piece and piece.color board.current_player: selected_pos clicked_pos legal_moves board.get_legal_moves(selected_pos) game.highlighted_moves legal_moves # 高亮显示可走位置 else: # 第二次点击走子 if clicked_pos in game.highlighted_moves: # 执行走子逻辑 success board.make_move(selected_pos, clicked_pos) if success: game.on_move_made() # 更新界面切换模式等 selected_pos None game.highlighted_moves [] else: # 点击其他位置重新选择 selected_pos None game.highlighted_moves [] elif event.type pygame.KEYDOWN: if event.key pygame.K_r: # 按R键重新开始游戏 board Board() game.reset_with_board(board) # 绘制界面 game.draw_background() game.draw_pieces() if selected_pos: game.draw_selection(selected_pos) game.draw_highlighted_moves() game.draw_game_info(board.current_player, board.is_checkmate()) pygame.display.flip() clock.tick(60) # 60 FPS pygame.quit()Game类封装了所有绘制逻辑加载棋子图片、绘制棋盘网格、在正确位置绘制棋子、高亮选中格子和合法走法格子、显示当前行棋方和游戏状态信息等。将界面逻辑与游戏核心逻辑 (Board) 分离使得代码结构更清晰也便于未来更换GUI库。5.2 界面状态管理与用户体验优化良好的用户体验在于细节。除了基本的点击走子我还实现了以下功能棋子拖动在鼠标按下时选中棋子在移动过程中让棋子图片跟随鼠标松开时判断落点是否合法并执行走子。这比“点击-再点击”更符合直觉。合法走法提示当选中一个棋子后将其所有合法落子位置用半透明的绿色圆点高亮显示极大降低了新手玩家的学习成本。行棋方提示在棋盘侧边或顶部用文字或颜色条明确显示当前该谁走棋。将军提示当一方被将军时在屏幕上显示醒目的“将军”文字并将对方的“将/帅”格子用红色边框高亮。音效为走子、吃子、将军、胜利等事件添加简单的音效能显著提升游戏沉浸感。Pygame的mixer模块可以轻松实现。悔棋功能利用Board类中记录的move_history实现“悔一步”甚至“悔多步”的功能。这是一个非常受玩家欢迎的特性。这些功能看似琐碎但组合在一起就能将一个“能用”的程序变成一个“好用”的游戏。6. 核心问题五如何打包分发与进行性能优化项目写完自己玩得开心可能还想分享给朋友。这就需要解决打包和性能问题。6.1 使用PyInstaller打包为独立可执行文件Python程序需要对方也安装Python和依赖库才能运行这很不方便。PyInstaller可以将Python脚本及其所有依赖打包成一个单独的.exe文件Windows或应用程序macOS。# 安装PyInstaller pip install pyinstaller # 基础打包命令 pyinstaller --onefile --windowed chinese_chess.py # 更推荐的命令处理资源文件图片、声音 pyinstaller --onefile --windowed --add-data “assets;assets” chinese_chess.py--onefile将所有东西打包进一个exe文件。--windowed运行时不显示控制台窗口对于GUI程序。--add-data “assets;assets”将项目目录下的assets文件夹存放图片、声音包含进打包文件并在运行时解压到临时目录。在代码中你需要使用sys._MEIPASS来获取这个临时资源路径。import sys import os def resource_path(relative_path): 获取打包后资源的绝对路径。 try: # PyInstaller创建的临时文件夹路径 base_path sys._MEIPASS except Exception: base_path os.path.abspath(“.”) return os.path.join(base_path, relative_path) # 加载图片 piece_image pygame.image.load(resource_path(‘assets/pieces/rook_red.png‘))避坑指南打包时最常见的错误就是找不到资源文件。务必使用resource_path这样的函数来定位路径。另外如果使用了某些特定的字体文件也需要用同样的方式添加。打包后务必在另一台没有Python环境的电脑上测试确保一切正常。6.2 关键性能瓶颈分析与优化随着AI搜索深度的增加程序可能会变慢。主要的性能瓶颈在于走法生成get_all_legal_moves_for_color需要为当前方每个棋子调用get_possible_moves并进行合法性校验模拟走子。这是搜索算法中调用最频繁的函数。棋盘状态评估evaluate_board函数在搜索树的每个叶子节点都会被调用。棋盘状态哈希与重复检测判断“长将”或使用“置换表”优化搜索时需要快速计算和比对棋盘状态。优化策略走法生成优化缓存棋子的可能走法。对于“车”、“炮”、“兵”这类走法受棋盘状态影响很大的棋子缓存意义不大。但对于“马”、“象”、“士”这类走法相对固定只受蹩腿、塞眼限制的棋子可以预先计算其所有理论走法及对应的蹩腿点运行时只需检查蹩腿点是否有子减少了方向循环的计算。评估函数优化使用“增量评估”。不要每次从头计算整个棋盘分数。记录当前总分当走一步棋时只计算这步棋引起的分数变化如一个棋子位置移动、一个棋子被吃。在撤销走子时反向操作即可。这可以极大提升评估速度。使用Zobrist哈希为棋盘生成一个几乎唯一的哈希值。在搜索过程中将哈希值作为键将该局面的评估结果和最佳走法存入“置换表”。当再次遇到相同的局面时可以直接查表避免重复搜索。这是象棋AI性能优化的核心手段之一。调整搜索策略采用“迭代加深”。先搜索深度1得到最佳走法和评估值再搜索深度2依此类推。这样可以在固定时间限制下比如每步棋思考1秒尽可能搜索到更深的层次。同时在搜索时优先尝试历史表中记录的好走法“杀手启发”和历史启发能提高Alpha-Beta剪枝的效率。对于个人学习项目实现前两项优化已经能带来肉眼可见的速度提升。Zobrist哈希和更复杂的启发式搜索则是通往更强AI的进阶之路。7. 常见问题与排查技巧实录在实际开发中你肯定会遇到各种各样奇怪的问题。这里记录了几个我印象最深的“坑”和解决方法。7.1 棋盘坐标系统混乱导致规则错误问题马走“日”字但规则校验总是出错有时马能穿过棋子有时又不能走到该到的位置。排查打印出马的当前位置、计算出的目标位置和蹩腿点坐标。发现是棋盘坐标(x, y)与列表索引grid[y][x]的对应关系在代码中不一致。在get_possible_moves里用(xdx, ydy)计算新位置但在用board.grid[new_y][new_x]访问时不小心写成了board.grid[new_x][new_y]。解决在整个项目中严格统一坐标规范。我定义了一个辅助函数get_piece_at(board, x, y)来封装board.grid[y][x]的访问强制自己使用(x, y)坐标参数避免直接操作二维列表时弄混行列。7.2 AI思考时间过长甚至卡死问题当搜索深度设置为4时AI在某些复杂中局思考时间超过10秒体验很差。排查使用Python的cProfile模块进行性能分析。发现evaluate_board函数和get_all_legal_moves是耗时大头。解决实现增量评估如上一节所述。对走法列表进行排序。在minimax函数中先尝试那些看起来更好的走法比如吃子的走法、将军的走法这样Alpha-Beta剪枝能更早发生剪掉更多分支。这被称为“启发式排序”。设置时间限制。实现一个“迭代加深”循环并在每次深度搜索前检查用时。如果超时就返回上一深度搜索到的最佳结果。import time def get_ai_move(board, time_limit2.0): 在时间限制内获取AI走法。 start_time time.time() best_move None best_depth 0 for depth in range(1, 6): # 从1层搜到5层 if time.time() - start_time time_limit: break # 超时使用上一深度的结果 _, move minimax(board, depth, float(‘-inf‘), float(‘inf‘), True) if move: best_move move best_depth depth print(f“深度 {depth} 搜索完成最佳走法: {best_move}“) print(f“最终使用深度 {best_depth} 的结果“) return best_move7.3 悔棋功能导致状态异常问题实现悔棋后有时棋盘状态会回退到错误的位置或者棋子图片显示错乱。排查悔棋操作不仅需要回退board.grid还需要回退piece_list中棋子的captured状态和position属性。我的_undo_move函数最初只更新了grid忘记了同步更新Piece对象的状态。解决将悔棋操作也封装成一个与make_move对称的方法undo_move()。它从move_history中弹出最后一条记录并精确地恢复所有相关状态将移动的棋子放回原位恢复被吃掉的棋子到piece_list并清除其captured标志切换回之前的行棋方。确保任何改变游戏状态的操作都有对应的逆操作并且这些操作是原子性的。7.4 打包后的exe文件运行时闪退问题在自己电脑上运行正常的脚本用PyInstaller打包后双击exe文件窗口一闪而过就关闭了。排查这是没有控制台窗口错误信息看不到导致的。首先使用--console参数重新打包不隐藏控制台运行exe就能看到错误信息。最常见的是“找不到文件”错误通常是资源文件路径问题。解决如前所述使用sys._MEIPASS来构建资源文件路径。确保spec文件或命令行中--add-data正确包含了所有非.py文件。检查代码中是否有硬编码的绝对路径全部改为使用resource_path函数。在代码开头添加一个日志文件写入功能将错误信息记录到本地文件方便在无控制台的环境下调试。import traceback import sys def excepthook(exc_type, exc_value, exc_traceback): 将未捕获的异常写入日志文件。 with open(‘error.log‘, ‘a‘) as f: traceback.print_exception(exc_type, exc_value, exc_traceback, filef) # 也可以选择重新抛出异常或退出 sys.__excepthook__(exc_type, exc_value, exc_traceback) if getattr(sys, ‘frozen‘, False): # 判断是否处于打包后环境 sys.excepthook excepthook开发这样一个项目最大的收获不是最终的游戏本身而是解决这一个个具体问题的过程。从数据结构的设计到复杂规则的编码实现再到人机交互和算法优化每一步都是对编程能力的全面锻炼。当你看到自己写的程序能够像模像样地下棋时那种感觉就像教会了电脑一项古老的技艺非常有成就感。我的代码仓库里至今还保留着这个项目偶尔打开来下一盘或者看看当年写的青涩代码总能会心一笑。如果你也打算开始我的建议是先从画出棋盘和棋子开始然后实现走子再加入规则校验最后才考虑AI。一步一个脚印每完成一个小功能就离完整的游戏更近一点。