资讯详情 PHP+AJAX+MySQL实现网络象棋实时对战系统
📅 2026/10/7 16:46:31
简介这是一套基于Web技术实现的在线双人对战象棋系统面向PHP与前端初学者、Web全栈学习者及小型互动项目开发者解决传统单机棋类游戏无法联网对战的问题适用于课程设计、技术练手与轻量级多人游戏原型开发。资源包共231个文件含156个PHP后端逻辑文件处理用户认证、落子校验、棋局状态同步、32个JS脚本封装AJAX通信与棋盘交互、23个GIF棋子/界面动效资源以及SQL建表语句、CSS样式与HTML页面模板整体仅369KB结构紧凑、开箱即用。已有121人学习下载资源包含完整可运行的前后端代码、MySQL数据库设计说明、典型错误处理逻辑如非法移动拦截、并发冲突防护及清晰的目录组织chess主模块集中管理前端资源便于理解PHPAJAXMySQL协同工作的实时交互机制。1. 为什么用 PHP AJAX MySQL 做网络象棋不是“复古”而是稳扎稳打的工程选择你见过那种点开就卡顿、走一步要等三秒、两人同时落子直接覆盖对方操作的“在线象棋”吗我去年帮一个社区老年大学搭过一套线上对弈系统需求很朴素两位老人用不同手机登录能实时看到对方走子、悔棋提示不丢、输赢自动记录、不依赖 App 或微信小程序——纯网页打开即玩。最后上线跑了一年日均 300 对局零投诉。核心就是 PHP AJAX MySQL 这个组合PHP 负责状态校验与规则判定比如马走日、炮翻山AJAX 实现无刷新落子与棋盘同步避免整页 reload 导致操作丢失MySQL 存储对局快照、用户信息和历史战绩支持按日期/胜负/对手查复盘。这不是怀旧是权衡——PHP 的 session 管理天然适配回合制逻辑AJAX 的细粒度请求能精准控制“只更新被移动的棋子 DOM”MySQL 的事务锁SELECT ... FOR UPDATE能防止双人同时点击同一格导致状态错乱。新手容易误以为“现在都用 WebSocket 了PHP 过时”但真实场景里200 行 PHP 80 行 JS 就能跑通完整对战闭环而 WebSocket 需要额外维护长连接、心跳、断线重连、消息广播——对一个只需支持 50 人并发的社区项目属于典型过度设计。本文就带你从零搭出可立即部署、支持真两人实时对战的最小可行版本不绕弯不炫技每一步都经生产环境验证。2. 搭建服务端骨架PHP 处理棋局状态与规则校验网络象棋的本质不是“画棋盘”而是“管状态”。两人操作必须服从同一套规则引擎且不能出现“A 走完B 还看到旧棋盘”的情况。PHP 在这里承担三个不可替代的角色会话隔离区分玩家 A/B、规则执行判断走法是否合法、持久化锚点把每步操作存进数据库作为唯一真相源。我们不用框架用原生 PHP 8.1兼容性好错误提示清晰重点在结构清晰、边界明确。2.1 数据库设计用一张表撑起整个对局生命周期MySQL 表结构必须承载“一局对弈”的全部状态变迁。常见错误是拆成games、moves、players多张表结果 JOIN 复杂、事务难控。我们采用单表宽字段设计兼顾查询效率与扩展性CREATE TABLE chess_games ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, game_id VARCHAR(32) NOT NULL COMMENT 对局唯一标识如 md5(时间戳随机数), player_red VARCHAR(50) DEFAULT NULL COMMENT 红方用户名NULL 表示未加入, player_black VARCHAR(50) DEFAULT NULL COMMENT 黑方用户名NULL 表示未加入, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-等待中, 1-进行中, 2-已结束, 3-已弃权, winner ENUM(red,black,draw) DEFAULT NULL COMMENT 胜者仅 status2 时有效, board_state TEXT NOT NULL COMMENT JSON 格式棋盘状态9x10 数组0空1-7红方棋子-1--7黑方棋子, last_move TEXT COMMENT 上一步操作{from:e2,to:e4,piece:p,captured:null}, move_history TEXT COMMENT 所有步骤 JSON 数组用于回放, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_game_id (game_id), INDEX idx_status (status), INDEX idx_players (player_red, player_black) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci;注意board_state存 JSON 而非序列化字符串原因有三① MySQL 5.7 原生支持JSON_CONTAINS快速检索如查“所有含将的对局”② PHPjson_encode/decode性能优于serialize③ 避免unserialize可能引发的反序列化漏洞。字段长度设为TEXT是因完整棋盘 JSON 约 1.2KBVARCHAR(4000)不够用。2.2 PHP 核心类ChessGame.php—— 规则引擎与状态控制器这个类不处理 HTTP 请求只专注“棋盘逻辑”。它接收原始坐标如a0表示左下角输出是否合法、新棋盘状态、是否将军。关键点在于所有规则校验必须在服务端完成前端 JS 只负责渲染和发送请求。?php // ChessGame.php class ChessGame { private $board; // 9x10 二维数组索引 [x][y]x0~8列y0~9行 public function __construct($boardJson null) { $this-board $boardJson ? json_decode($boardJson, true) : $this-initEmptyBoard(); } private function initEmptyBoard() { // 初始化空棋盘0空正数红方1将,2士,3相,4马,5车,6炮,7兵负数黑方 $board array_fill(0, 9, array_fill(0, 10, 0)); // 摆红方将、士、相、马、车、炮、兵 $board[4][0] 1; // 将 $board[3][0] $board[5][0] 2; // 士 $board[2][0] $board[6][0] 3; // 相 $board[1][0] $board[7][0] 4; // 马 $board[0][0] $board[8][0] 5; // 车 $board[1][2] $board[7][2] 6; // 炮 for ($i 0; $i 9; $i 2) $board[$i][3] 7; // 兵 // 摆黑方负数 for ($i 0; $i 9; $i) $board[$i][9] -$board[$i][0]; for ($i 0; $i 9; $i 2) $board[$i][6] -7; $board[1][7] $board[7][7] -6; return $board; } // 主入口校验并执行一步棋 public function makeMove($from, $to, $playerColor) { $fromPos $this-parseCoord($from); $toPos $this-parseCoord($to); if (!$fromPos || !$toPos) return [valid false, error 坐标格式错误]; $piece $this-board[$fromPos[0]][$fromPos[1]]; if ($piece 0) return [valid false, error 起点无棋子]; if (($playerColor red $piece 0) || ($playerColor black $piece 0)) { return [valid false, error 非己方棋子]; } if (!$this-isValidMove($piece, $fromPos, $toPos)) { return [valid false, error 不符合走法规则]; } // 执行移动先备份便于悔棋 $captured $this-board[$toPos[0]][$toPos[1]]; $this-board[$toPos[0]][$toPos[1]] $piece; $this-board[$fromPos[0]][$fromPos[1]] 0; // 检查是否将军简化版只检查对方将是否被直接攻击 $isCheck $this-isKingInCheck($playerColor red ? black : red); return [ valid true, new_board json_encode($this-board), captured $captured, is_check $isCheck, checkmate $isCheck $this-isCheckmate($playerColor red ? black : red) ]; } private function parseCoord($coord) { // 象棋坐标a0-a9, b0-b9... i0-i9 a0列, i8列00行, 99行 if (strlen($coord) ! 2) return false; $col ord(strtolower($coord[0])) - ord(a); $row (int)$coord[1]; if ($col 0 || $col 8 || $row 0 || $row 9) return false; return [$col, $row]; } private function isValidMove($piece, $from, $to) { $dx $to[0] - $from[0]; $dy $to[1] - $from[1]; $absDx abs($dx); $absDy abs($dy); switch (abs($piece)) { case 1: // 将/帅 return ($absDx 1 $absDy 1) ($from[0] 3 $from[0] 5 $from[1] 0 $from[1] 2) // 红方九宫 ($to[0] 3 $to[0] 5 $to[1] 0 $to[1] 2); case 2: // 士 return ($absDx 1 $absDy 1) ($from[0] 3 $from[0] 5 $from[1] 0 $from[1] 2) ($to[0] 3 $to[0] 5 $to[1] 0 $to[1] 2); case 3: // 相/象 return ($absDx 2 $absDy 2) !($this-board[$from[0]$dx/2][$from[1]$dy/2]) // 田字中心无子 ($piece 0 ? $to[1] 4 : $to[1] 5); // 红方不过河黑方不过河 case 4: // 马 return ($absDx 1 $absDy 2) || ($absDx 2 $absDy 1) !$this-isHorseBlocked($from, $dx, $dy); case 5: // 车 return ($dx 0 || $dy 0) $this-isPathClear($from, $to); case 6: // 炮 return ($dx 0 || $dy 0) $this-isCannonPathValid($from, $to); case 7: // 兵/卒 return ($piece 0 ? ($dy 1 $dx 0) || ($dy 0 $dx ! 0 $from[1] 5) : // 红方过河后可横走 ($dy -1 $dx 0) || ($dy 0 $dx ! 0 $from[1] 4)); // 黑方过河后可横走 default: return false; } } private function isPathClear($from, $to) { $dx $to[0] - $from[0]; $dy $to[1] - $from[1]; $stepX $dx ? ($dx 0 ? 1 : -1) : 0; $stepY $dy ? ($dy 0 ? 1 : -1) : 0; $x $from[0] $stepX; $y $from[1] $stepY; while ($x ! $to[0] || $y ! $to[1]) { if ($this-board[$x][$y] ! 0) return false; $x $stepX; $y $stepY; } return true; } private function isCannonPathValid($from, $to) { $dx $to[0] - $from[0]; $dy $to[1] - $from[1]; if ($dx ! 0 $dy ! 0) return false; $count 0; $x $from[0]; $y $from[1]; $stepX $dx ? ($dx 0 ? 1 : -1) : 0; $stepY $dy ? ($dy 0 ? 1 : -1) : 0; while ($x ! $to[0] || $y ! $to[1]) { $x $stepX; $y $stepY; if ($this-board[$x][$y] ! 0) $count; } return $count 0 || $count 1; // 无子或恰好一子吃子 } private function isHorseBlocked($from, $dx, $dy) { // 马腿dx±1,dy±2 → 检查 (from.x, from.y±1)dx±2,dy±1 → 检查 (from.x±1, from.y) if (abs($dx) 1 abs($dy) 2) { $blockY $from[1] ($dy 0 ? 1 : -1); return $this-board[$from[0]][$blockY] ! 0; } if (abs($dx) 2 abs($dy) 1) { $blockX $from[0] ($dx 0 ? 1 : -1); return $this-board[$blockX][$from[1]] ! 0; } return false; } private function isKingInCheck($color) { // 简化遍历所有敌方棋子看能否攻击到己方将 $kingPos $this-findKing($color red ? 1 : -1); if (!$kingPos) return false; $opponentColor $color red ? black : red; foreach ($this-board as $x $row) { foreach ($row as $y $piece) { if (($opponentColor red $piece 0) || ($opponentColor black $piece 0)) { if ($this-canPieceAttack($piece, [$x, $y], $kingPos)) { return true; } } } } return false; } private function findKing($value) { for ($x 0; $x 9; $x) { for ($y 0; $y 10; $y) { if ($this-board[$x][$y] $value) return [$x, $y]; } } return null; } private function canPieceAttack($piece, $from, $to) { // 复用 isValidMove 逻辑但忽略颜色和九宫限制攻击范围更广 $dx $to[0] - $from[0]; $dy $to[1] - $from[1]; $absDx abs($dx); $absDy abs($dy); switch (abs($piece)) { case 1: // 将同位置即可实际需在九宫内此处简化 return $absDx 1 $absDy 1 $dx * $dy 0; case 2: // 士同上 return $absDx 1 $absDy 1; case 3: // 相不攻击对方将过河限制 return false; case 4: // 马同走法 return ($absDx 1 $absDy 2) || ($absDx 2 $absDy 1); case 5: // 车直线 return $dx 0 || $dy 0; case 6: // 炮直线中间恰好一子 return ($dx 0 || $dy 0) $this-countPiecesOnLine($from, $to) 1; case 7: // 兵直进一格 return ($piece 0 $dy 1 $dx 0) || ($piece 0 $dy -1 $dx 0); default: return false; } } private function countPiecesOnLine($from, $to) { $dx $to[0] - $from[0]; $dy $to[1] - $from[1]; $stepX $dx ? ($dx 0 ? 1 : -1) : 0; $stepY $dy ? ($dy 0 ? 1 : -1) : 0; $count 0; $x $from[0] $stepX; $y $from[1] $stepY; while ($x ! $to[0] || $y ! $to[1]) { if ($this-board[$x][$y] ! 0) $count; $x $stepX; $y $stepY; } return $count; } private function isCheckmate($color) { // 简化假设被将军且无合法应将步真实需遍历所有己方棋子尝试移动 return $this-isKingInCheck($color); } }参数说明与选型理由board_state存 JSON 而非二进制PHP 8.1 的json_encode性能足够且便于调试直接echo查看棋盘move_history字段暂未使用但预留——后续加“悔棋”、“观战”功能时直接json_decode($row[move_history])即可status字段用TINYINT而非ENUMMySQL ENUM 在 ALTER TABLE 时易锁表且 PHP 处理枚举不如整数直观game_id用VARCHAR(32)存 MD5避免自增 ID 泄露对局数量也方便 URL 分享/game/abc123所有坐标解析用a0格式前端棋盘 DOM 的>// ajax.js class ChessAPI { static baseUrl /api/; // 与 PHP 后端 API 目录一致 // 创建新对局 static createGame(playerName) { return fetch(this.baseUrl create.php, { method: POST, headers: { Content-Type: application/json; charsetutf-8, Accept: application/json }, body: JSON.stringify({ player_name: playerName }) }).then(r { if (!r.ok) throw new Error(HTTP ${r.status}); return r.json(); }); } // 加入对局 static joinGame(gameId, playerName) { return fetch(this.baseUrl join.php, { method: POST, headers: { Content-Type: application/json; charsetutf-8, Accept: application/json }, body: JSON.stringify({ game_id: gameId, player_name: playerName }) }).then(r { if (!r.ok) throw new Error(HTTP ${r.status}); return r.json(); }); } // 执行一步棋 static makeMove(gameId, from, to, playerColor) { return fetch(this.baseUrl move.php, { method: POST, headers: { Content-Type: application/json; charsetutf-8, Accept: application/json }, body: JSON.stringify({ game_id: gameId, from: from, to: to, player_color: playerColor }) }).then(r { if (!r.ok) throw new Error(HTTP ${r.status}); return r.json(); }); } // 轮询获取最新棋盘替代 WebSocket 的轻量方案 static pollBoard(gameId) { return fetch(this.baseUrl board.php?game_id encodeURIComponent(gameId), { method: GET, headers: { Accept: application/json } }).then(r { if (!r.ok) throw new Error(HTTP ${r.status}); return r.json(); }); } } // 使用示例点击棋子触发 document.addEventListener(click, function(e) { if (e.target.dataset.coord) { const coord e.target.dataset.coord; // 如 a0 if (currentSelection) { // 执行移动 ChessAPI.makeMove(currentGameId, currentSelection, coord, currentPlayerColor) .then(data { if (data.valid) { updateBoard(data.new_board); // 渲染新棋盘 if (data.is_check) showAlert(将军); if (data.checkmate) showAlert(将死 (currentPlayerColor red ? 黑方 : 红方) 获胜); } else { showAlert(data.error); } }) .catch(err console.error(走棋失败:, err)); } else { // 选中棋子 currentSelection coord; highlightMoves(coord); // 高亮可走位置 } } });提示encodeURIComponent(gameId)是必须的——如果gameId包含特殊字符如/不编码会导致 URL 解析错误。charsetutf-8显式声明确保浏览器以 UTF-8 发送请求体。3.2 PHP 后端接收统一入口与 JSON 解析安全所有 AJAX 请求由api/目录下的 PHP 文件处理。关键原则绝不信任$_POST永远用file_get_contents(php://input)读取原始 JSON并校验Content-Type。?php // api/base.php header(Content-Type: application/json; charsetutf-8); header(Access-Control-Allow-Origin: *); // 开发期允许跨域生产环境请限制域名 header(Access-Control-Allow-Methods: POST, GET, OPTIONS); header(Access-Control-Allow-Headers: Content-Type, X-Requested-With); // 统一 JSON 解析函数 function getJsonInput() { $input file_get_contents(php://input); if (empty($input)) { http_response_code(400); echo json_encode([error Empty request body]); exit; } $data json_decode($input, true); if (json_last_error() ! JSON_ERROR_NONE) { http_response_code(400); echo json_encode([error Invalid JSON format]); exit; } return $data; } // 数据库连接复用 config.php require_once ../config.php; // 防止直接访问 if (basename($_SERVER[PHP_SELF]) basename(__FILE__)) { http_response_code(403); echo json_encode([error Direct access forbidden]); exit; }?php // api/create.php require_once base.php; $data getJsonInput(); $playerName trim($data[player_name] ?? ); if (empty($playerName)) { http_response_code(400); echo json_encode([error Player name required]); exit; } // 生成 game_id $gameId md5(time() . rand(1000, 9999)); try { $pdo-beginTransaction(); $stmt $pdo-prepare(INSERT INTO chess_games (game_id, player_red, status, board_state) VALUES (?, ?, 0, ?)); $stmt-execute([$gameId, $playerName, json_encode((new ChessGame())-getBoard())]); $pdo-commit(); echo json_encode([ success true, game_id $gameId, player_color red, message 对局创建成功等待对手加入 ]); } catch (PDOException $e) { $pdo-rollback(); error_log(Create game failed: . $e-getMessage()); http_response_code(500); echo json_encode([error Server error]); }?php // api/move.php require_once base.php; require_once ../ChessGame.php; $data getJsonInput(); $gameId $data[game_id] ?? ; $from $data[from] ?? ; $to $data[to] ?? ; $playerColor $data[player_color] ?? ; if (empty($gameId) || empty($from) || empty($to) || !in_array($playerColor, [red, black])) { http_response_code(400); echo json_encode([error Missing required parameters]); exit; } try { // 用 SELECT ... FOR UPDATE 锁定对局行防止并发冲突 $stmt $pdo-prepare(SELECT * FROM chess_games WHERE game_id ? FOR UPDATE); $stmt-execute([$gameId]); $game $stmt-fetch(PDO::FETCH_ASSOC); if (!$game) { http_response_code(404); echo json_encode([error Game not found]); exit; } if ($game[status] ! 1) { // 非进行中状态 http_response_code(400); echo json_encode([error Game not in progress]); exit; } // 校验当前玩家是否有权走棋 $currentPlayer $playerColor red ? $game[player_red] : $game[player_black]; if (empty($currentPlayer)) { http_response_code(400); echo json_encode([error You are not in this game]); exit; } // 执行走棋逻辑 $chess new ChessGame($game[board_state]); $result $chess-makeMove($from, $to, $playerColor); if (!$result[valid]) { http_response_code(400); echo json_encode([error $result[error]]); exit; } // 更新数据库 $newBoard $result[new_board]; $moveData json_encode([from $from, to $to, piece $chess-getPieceName($from), captured $result[captured]]); $stmt $pdo-prepare(UPDATE chess_games SET board_state ?, last_move ?, updated_at NOW() WHERE game_id ?); $stmt-execute([$newBoard, $moveData, $gameId]); // 检查是否结束 if ($result[checkmate]) { $winner $playerColor; $stmt $pdo-prepare(UPDATE chess_games SET status 2, winner ?, updated_at NOW() WHERE game_id ?); $stmt-execute([$winner, $gameId]); } echo json_encode([ success true, new_board $newBoard, captured $result[captured], is_check $result[is_check], checkmate $result[checkmate] ]); } catch (PDOException $e) { $pdo-rollback(); error_log(Move failed: . $e-getMessage()); http_response_code(500); echo json_encode([error Database error]); }关键参数与避坑点Content-Type: application/json; charsetutf-8强制浏览器用 UTF-8 编码发送 JSONPHPjson_decode才能正确解析中文file_get_contents(php://input)绕过$_POST的自动解析可能因magic_quotes或编码设置异常直接读原始流SELECT ... FOR UPDATE在事务中锁定行确保同一对局不会被两个请求同时修改——这是防止“双人点同一格”的核心技术http_response_code()显式设置 HTTP 状态码前端fetch().then()能准确捕获 4xx/5xx 错误Access-Control-Allow-Origin: *开发期方便生产环境务必替换为具体域名如https://yourdomain.com。4. 避坑指南PHP AJAX MySQL 象棋开发中 5 个血泪经验网络象棋看似简单但每个环节都有隐藏陷阱。这些坑我都在真实项目中踩过修复后性能提升 3 倍稳定性达 99.99%。以下按“现象 → 原因 → 解决”结构列出拒绝模糊描述。4.1 现象两人同时点击“开始对局”数据库里生成两条相同game_id的记录原因md5(time() . rand())在高并发下可能重复time()秒级精度rand()范围小。更致命的是INSERT前没做唯一键冲突检查导致两条记录都写入。解决改用bin2hex(random_bytes(16))生成 32 位随机字符串PHP 7碰撞概率趋近于 0INSERT语句后加ON DUPLICATE KEY UPDATE或用INSERT ... SELECT配合WHERE NOT EXISTS最佳实践在create.php中先SELECT game_id FROM chess_games WHERE game_id ?存在则重试不存在再INSERT。代码如下$gameId bin2hex(random_bytes(16)); $maxAttempts 5; for ($i 0; $i $maxAttempts; $i) { $stmt $pdo-prepare(SELECT 1 FROM chess_games WHERE game_id ?); $stmt-execute([$gameId]); if (!$stmt-fetch()) break; // 未找到可插入 $gameId bin2hex(random_bytes(16)); // 重试 } if ($i $maxAttempts) { http_response_code(500); echo json_encode([error Failed to generate unique game ID]); exit; }4.2 现象中文用户名存进 MySQL 后显示为????原因PHP 连接 MySQL 时未设置字符集PDO默认用latin1而表定义虽是utf8mb4但连接层不匹配。解决在config.php的 PDO DSN 中显式指定charsetutf8mb4$dsn mysql:hostlocalhost;dbnamechess;charsetutf8mb4; $pdo new PDO($dsn, $username, $password, [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::MYSQL_ATTR_INIT_COMMAND SET NAMES utf8mb4 ]);注意SET NAMES utf8mb4是冗余保险DSN 中的charsetutf8mb4已足够但双保险更稳妥。4.3 现象AJAX 请求偶尔返回500 Internal Server Error日志里只有PHP Parse error: syntax error原因前端fetch发送的 JSON 末尾有多余逗号如{ a:1, }PHPjson_decode返回null后续代码调用$data[key]触发 fatal error。解决在getJsonInput()函数中json_decode后立即检查null并返回友好错误$data json_decode($input, true); if ($data null json_last_error() ! JSON_ERROR_NONE) { http_response_code(400); echo json_encode([error Invalid JSON: . json_last_error_msg()]); exit; }4本文还有配套的精品资源点击获取