ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP+AJAX+MySQL实现网络象棋实时对战

PHP+AJAX+MySQL实现网络象棋实时对战 简介这是一套基于Web技术实现的在线双人对战象棋系统面向PHP与前端初学者及Web全栈学习者解决传统单机棋类无法实时联网互动的问题适用于课程设计、毕业项目或Web交互式游戏开发实践。资源共231个文件包含156个PHP后端逻辑文件处理用户认证、落子校验与状态同步、32个JavaScript文件含AJAX通信与棋盘交互逻辑、23个GIF棋子素材及7个HTML页面结构文件辅以SQL建表语句与CSS样式资源整体压缩包仅369KB轻量易部署。已有121人下载学习可直接运行调试完整呈现PHPMySQL服务端架构与AJAX无刷新更新机制的协同实现代码结构清晰含棋局管理、玩家匹配、移动合法性验证等核心模块附带.bak备份文件便于版本比对与错误回溯是理解Web实时交互游戏开发流程的典型范例。1. PHP AJAX MYSQL 网络象棋不是玩具是理解 Web 实时交互的「最小可运行黑匣子」你有没有试过——在浏览器里点两下对方棋盘立刻同步刷新落子音效刚响对手的将帅就已暴露在你的炮口之下这不是 WebSocket 推送也不是 Node.js 实时服务而是一套用 PHP AJAX MySQL 搭出来的纯 LAMP 架构网络象棋。它不炫技不堆框架但把「状态同步」「并发冲突」「请求幂等」「前端响应式反馈」这些 Web 开发核心痛点全塞进一个不到 200 行 PHP、300 行 JS、4 张表的极简系统里。它适合刚学完 PDO 和 XMLHttpRequest 的人用来亲手拆解「为什么我的 AJAX 请求总拿不到最新数据」「为什么两人同时走马数据库里只剩一匹」这类血泪问题也适合老手在重构高并发对战逻辑前先用它验证自己的锁策略和事务边界是否真能扛住真实点击节奏。这不是教学 Demo是能跑通、能 debug、能改规则、能加音效、甚至能部署到学生作业服务器上的完整可执行体。2. 架构选型与数据模型为什么不用 WebSocket而用「轮询事务乐观锁」组合拳2.1 为什么坚持用 AJAX 而非 WebSocket——成本、兼容性与教学穿透力很多人看到「实时对战」第一反应就是 WebSocket。但本项目刻意回避它原因很实在部署零门槛学生机房、校园虚拟主机、甚至老旧 VPS只要支持 PHP 7.4 和 MySQL 5.7就能跑WebSocket 需额外配置 Nginx 反向代理或独立服务进程对新手是隐形门槛调试可见性AJAX 请求在浏览器 Network 面板里一目了然status code、payload、timing 全透明WebSocket 是二进制流抓包需专用工具初学者容易陷入「请求发了但没响应」的玄学黑洞教学聚焦点明确本项目核心要练的是「如何用 HTTP 协议模拟实时」重点在SELECT ... FOR UPDATE、INSERT ... ON DUPLICATE KEY UPDATE、JSON.stringify()与JSON.parse()的边界处理——这些能力迁移到任何后端语言都通用而非绑定某个协议栈。提示本方案本质是「伪实时」但通过 1.5s 定时轮询 前端防抖 后端状态快照实测在局域网内延迟 300ms完全满足象棋对战节奏平均每步 8–12 秒。2.2 四张表的设计哲学用最少字段承载「棋局生命周期」MySQL 表结构不是按「对象建模」设计的而是按「操作原子性」切分的。共 4 张表无外键全靠应用层保证一致性表名字段关键作用设计要点gamesid,status(waiting/playing/finished),created_at,updated_at棋局元信息status用 ENUM避免字符串拼写错误updated_at用ON UPDATE CURRENT_TIMESTAMP自动记录最后操作时间playersgame_id,user_id,side(red/black),joined_at玩家归属复合主键(game_id, side)确保一局只有一红一黑user_id允许 NULL未登录游客用 session_id 临时占位movesgame_id,move_id,from,to,piece,player_side,created_at走子日志move_id自增但业务上用game_id move_id唯一标识一步from/to存a1格式字符串非坐标数字降低前端计算负担boardsgame_id,snapshot_json,version,updated_at棋盘快照snapshot_json存完整棋盘 JSON如{a1:rook_red, e1:king_red, ...}version为乐观锁版本号每次更新必须WHERE version ?这个设计放弃「规范化」换来了三件事单次查询可加载整局SELECT * FROM boards WHERE game_id ? ORDER BY updated_at DESC LIMIT 1直接拿到最新快照无需 JOIN 多表走子逻辑原子化INSERT INTO moves (...)和UPDATE boards SET ... WHERE game_id ? AND version ?必须在同一事务中执行失败则回滚杜绝「日志写了但棋盘没更新」历史回放开箱即用SELECT * FROM moves WHERE game_id ? ORDER BY created_at就是完整对局录像。2.3 PHP 后端核心流程从 AJAX 请求到数据库事务的七步链一个典型的「玩家走马」请求POST /api/move.php背后PHP 要完成以下七步缺一不可// move.php 核心逻辑简化版 session_start(); require_once db.php; // PDO 连接 $game_id (int)$_POST[game_id]; $from $_POST[from]; // e.g. e2 $to $_POST[to]; // e.g. e4 $side $_SESSION[side] ?? null; try { $pdo-beginTransaction(); // Step 1: 检查游戏状态必须 playing $stmt $pdo-prepare(SELECT status, version FROM boards WHERE game_id ? ORDER BY updated_at DESC LIMIT 1); $stmt-execute([$game_id]); $board $stmt-fetch(PDO::FETCH_ASSOC); if (!$board || $board[status] ! playing) { throw new Exception(Game not in playing state); } // Step 2: 检查轮到谁走用 players 表 moves 表 last move 判断 $last_move $pdo-query(SELECT player_side FROM moves WHERE game_id $game_id ORDER BY created_at DESC LIMIT 1)-fetchColumn(); if ($last_move $side) { throw new Exception(Not your turn); } // Step 3: 校验走法合法性此处省略具体算法实际需调用 validateMove($from, $to, $board[snapshot_json]) if (!validateMove($from, $to, $board[snapshot_json])) { throw new Exception(Invalid move); } // Step 4: 更新棋盘快照乐观锁 $new_snapshot updateBoardSnapshot($board[snapshot_json], $from, $to, $side); $stmt $pdo-prepare(UPDATE boards SET snapshot_json ?, version version 1, updated_at NOW() WHERE game_id ? AND version ?); $affected $stmt-execute([$new_snapshot, $game_id, $board[version]]); if ($affected ! 1) { throw new Exception(Concurrent update conflict); // 乐观锁失败 } // Step 5: 记录走法 $stmt $pdo-prepare(INSERT INTO moves (game_id, from, to, piece, player_side) VALUES (?, ?, ?, ?, ?)); $stmt-execute([$game_id, $from, $to, getPieceAt($from, $board[snapshot_json]), $side]); // Step 6: 检查是否将军/将死触发 game status change if (isCheckmate($new_snapshot, $side)) { $pdo-exec(UPDATE games SET status finished WHERE id $game_id); } // Step 7: 提交事务 $pdo-commit(); echo json_encode([success true, new_version $board[version] 1]); } catch (Exception $e) { $pdo-rollback(); http_response_code(400); echo json_encode([error $e-getMessage()]); }关键参数说明validateMove()函数必须实现中国象棋全部规则马走日、象飞田、炮翻山、将帅不能照面等这是项目最重的业务逻辑建议单独抽成ChessRule.phpupdateBoardSnapshot()不是简单字符串替换需解析 JSON修改from位置为空to位置为对应棋子并处理吃子逻辑isCheckmate()需遍历所有己方棋子可能走法判断是否无合法应将这是性能瓶颈点实际部署时建议加缓存或限制深度所有 SQL 使用 PDO 预处理杜绝 SQL 注入$game_id强制(int)转换防类型绕过。3. AJAX 前端交互从「点击落子」到「全局状态同步」的三次握手3.1 轮询机制用setTimeout而非setInterval的底层原因前端不依赖长连接而是用「智能轮询」初始间隔 1500ms若连续 3 次轮询返回no_new_move间隔拉长至 3000ms若收到新走法立即重置为 1500ms 并播放音效用户主动走子后强制立即轮询一次避免等待间隙。为什么不用setInterval因为setInterval会不管上一次请求是否完成就发起下一次极易造成请求堆积、状态错乱。正确做法是用setTimeout递归链// board.js 中的轮询函数 let pollTimeout null; let lastVersion 0; function startPolling(gameId) { clearTimeout(pollTimeout); // 清除上一次定时器 fetch(/api/poll.php?game_id${gameId}version${lastVersion}) .then(res res.json()) .then(data { if (data.moves data.moves.length 0) { // 应用新走法更新棋盘 UI applyMoves(data.moves); lastVersion data.version; // 更新本地版本号 playSound(move); // 播放落子音效 } // 无论有无新数据都设置下一次轮询 pollTimeout setTimeout(() startPolling(gameId), data.moves?.length ? 1500 : 3000); }) .catch(err { console.warn(Poll failed, retry in 5s, err); pollTimeout setTimeout(() startPolling(gameId), 5000); }); } // 用户走子后立即触发一次轮询避免等待 function onPlayerMove() { // ... 发送 move 请求 startPolling(currentGameId); // 强制刷新 }参数说明version参数是乐观锁的关键后端poll.php会SELECT * FROM boards WHERE game_id ? AND version ?只返回比客户端已知版本更新的数据data.moves是数组因可能一次轮询收到多步如对手连走两步前端需按created_at排序逐条应用playSound()使用 Web Audio API避免audio标签在 iOS 上的自动播放限制。3.2 棋盘渲染用 CSS Grid 实现「像素级精准定位」而非 Canvas象棋棋盘用纯 HTML/CSS 实现优势是可直接用document.querySelector(#e4)获取格子元素事件委托清晰支持屏幕阅读器无障碍友好无需处理 Canvas 坐标转换from/to字符串如e4直接映射到 DOM ID。HTML 结构精简如下div classchess-board idboard !-- 生成 10×9 网格 -- div classrow>.chess-board { display: grid; grid-template-columns: repeat(9, 1fr); grid-template-rows: repeat(10, 1fr); width: 540px; height: 600px; /* 60×60px per cell */ border: 2px solid #333; } .cell { border: 1px solid #ccc; display: flex; align-items: center; justify-content: center; font-size: 24px; cursor: pointer; } /* 特殊线楚河汉界 */ .row:nth-child(5) .cell, .row:nth-child(6) .cell { border-top: 2px dashed #900; }关键技巧每个.cell的id用a0到i9编码a–i对应列红方右→左0–9对应行红方下→上与 FEN 格式一致棋子用 Unicode 字符如 红马、 黑马或 SVG 图标避免图片加载延迟拖拽效果用dragstart/dragover/drop事件但最终落子仍以点击为准防止拖拽误操作。3.3 并发冲突的前端防御防抖 禁用 状态锁三重保险当用户快速双击同一格子或网络延迟导致多次点击前端必须拦截let isMoving false; // 全局移动锁 let moveDebounce null; function handleCellClick(cellId) { if (isMoving || !currentSide || !isMyTurn()) return; clearTimeout(moveDebounce); moveDebounce setTimeout(() { isMoving true; document.getElementById(board).classList.add(busy); // 禁用整个棋盘 fetch(/api/move.php, { method: POST, headers: { Content-Type: application/x-www-form-urlencoded }, body: game_id${currentGameId}from${selectedFrom}to${cellId} }) .then(res res.json()) .then(data { if (data.success) { // 成功后重置状态 selectedFrom null; highlightCells([]); // 清除高亮 } else { alert(走法无效 data.error); } }) .catch(err { alert(网络错误请重试); }) .finally(() { isMoving false; document.getElementById(board).classList.remove(busy); }); }, 300); // 300ms 防抖过滤误触 }避坑 / 常见问题 / 排查现象用户点击后按钮没反应Network 面板显示pending状态卡死原因PHPmove.php中事务未commit()或rollback()连接被占用后续请求阻塞解决在try块末尾强制加finally { $pdo-rollBack(); }保底生产环境开启PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION现象两人同时走同一格数据库里出现两个move记录但棋盘只更新一次原因UPDATE boards乐观锁失败后INSERT INTO moves仍被执行事务未回滚解决所有 DB 操作必须包裹在beginTransaction()/commit()内catch中必须rollback()且INSERT必须在UPDATE成功后才执行现象轮询时poll.php返回空数组但实际对手已走子原因version参数未随每次成功轮询更新导致永远请求旧版本解决前端必须在applyMoves()后立即更新lastVersion data.version且data.version来自后端boards.version字段非moves.id现象Chrome 控制台报Uncaught (in promise) TypeError: Failed to fetch原因AJAX 请求跨域开发时 localhost:8000 访问 127.0.0.1:80或 PHP 输出了 BOM 头UTF-8 with BOM导致 JSON 解析失败解决统一用http://localhost/chess/访问用 Notepad 将所有 PHP 文件另存为「UTF-8 无 BOM」格式现象iOS Safari 上音效不播放原因iOS 要求音效必须在用户手势click/touch上下文中触发且不能是异步回调解决将playSound()放在fetch().then()之前或用AudioContext在首次点击时初始化并保存 context4. 部署与调试从本地 XAMPP 到 Linux 服务器的六步落地清单4.1 本地开发环境XAMPP / WAMP / MAMP 的 PHP 版本陷阱本项目最低要求 PHP 7.4因用到了??空合并操作符但强烈建议用 PHP 8.1理由match()表达式可替代冗长switch简化validateMove()中的棋子规则分支str_contains()替代strpos() ! false减少易错写法错误提示更友好TypeError明确指出参数类型不符。XAMPP 配置检查清单php.ini中确认extensionmysqli和extensionpdo_mysql已取消注释date.timezone Asia/Shanghai避免NOW()返回 UTC 时间max_execution_time 30默认足够但若isCheckmate()递归过深需调大upload_max_filesize 2M本项目无需上传但防止其他脚本干扰Apache 的.htaccess需启用mod_rewrite用于友好的/api/move路由非必需但推荐。4.2 MySQL 创建与权限用CREATE USER而非 root 的安全实践不要用rootlocalhost连接生产数据库。标准做法-- 创建专用用户 CREATE USER chess_applocalhost IDENTIFIED BY StrongPass123!; -- 仅授予必要权限 GRANT SELECT, INSERT, UPDATE ON chess_db.games TO chess_applocalhost; GRANT SELECT, INSERT, UPDATE ON chess_db.players TO chess_applocalhost; GRANT SELECT, INSERT ON chess_db.moves TO chess_applocalhost; GRANT SELECT, INSERT, UPDATE ON chess_db.boards TO chess_applocalhost; FLUSH PRIVILEGES;验证连接在db.php中测试try { $pdo new PDO(mysql:hostlocalhost;dbnamechess_db;charsetutf8mb4, chess_app, StrongPass123!); $pdo-setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); echo ✅ 数据库连接成功; } catch (PDOException $e) { die(❌ 连接失败: . $e-getMessage()); }4.3 Linux 服务器部署Nginx PHP-FPM 的最小化配置Apache 会自动处理.htaccess但 Nginx 需手动配置路由。关键配置片段/etc/nginx/sites-available/chessserver { listen 80; server_name chess.example.com; root /var/www/chess; index index.html index.php; location / { try_files $uri $uri/ /index.html; } # API 路由全部转给 PHP 处理 location ~ ^/api/(.\.php)$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.1-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } # 静态资源缓存 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { expires 1y; add_header Cache-Control public, immutable; } }部署后必做三件事sudo chown -R www-data:www-data /var/www/chess确保 Web 服务器有写权限如需日志sudo systemctl restart nginx php8.1-fpm访问http://chess.example.com/api/test.php内容为?php echo OK; ?确认 PHP 正常。4.4 调试工具链用error_log() Chrome Network 面板定位真实瓶颈不要依赖echo调试 PHP因为 AJAX 请求看不到输出。正确做法// 在 move.php 开头加入 error_log(MOVE START: game_id{$game_id}, from{$from}, to{$to}, 3, /var/log/chess.log); // 在关键步骤后记录 error_log(BOARD UPDATE SUCCESS: version{$board[version]1}, 3, /var/log/chess.log);然后终端实时查看sudo tail -f /var/log/chess.logChrome Network 面板必查项Size列确认响应体大小 10KB过大说明snapshot_json未压缩Waterfall看Waiting (TTFB)是否 500ms若是则 PHP 或 MySQL 慢Preview展开 JSON确认error字段是否存在success是否为trueResponse Headers检查Content-Type: application/json; charsetutf-8是否存在缺失会导致res.json()失败。5. 进阶实战给网络象棋加「悔棋」功能——从需求分析到 SQL 事务改造5.1 悔棋需求的本质不是删除而是「状态回滚」与「版本降级」用户点「悔棋」不是删掉最后一步move记录而是将boards表中game_id对应的最新快照回退到上一个version删除moves表中该game_id的最后一条记录更新games.updated_at最关键确保整个过程原子性避免「删了 move 但没改 board」导致棋盘与日志不一致。因此undo.php必须是一个独立事务且逻辑顺序严格// undo.php $game_id (int)$_POST[game_id]; try { $pdo-beginTransaction(); // Step 1: 获取当前最新快照和上一版本 $stmt $pdo-prepare( SELECT b1.snapshot_json AS current, b2.snapshot_json AS prev, b1.version AS current_ver, b2.version AS prev_ver FROM boards b1 LEFT JOIN boards b2 ON b1.game_id b2.game_id AND b2.version b1.version - 1 WHERE b1.game_id ? AND b1.version (SELECT MAX(version) FROM boards WHERE game_id ?) ORDER BY b1.updated_at DESC LIMIT 1 ); $stmt-execute([$game_id, $game_id]); $row $stmt-fetch(PDO::FETCH_ASSOC); if (!$row || !$row[prev]) { throw new Exception(No previous state to undo); } // Step 2: 删除最后一步走法 $stmt $pdo-prepare(DELETE FROM moves WHERE game_id ? ORDER BY created_at DESC LIMIT 1); $stmt-execute([$game_id]); // Step 3: 将棋盘回退到上一版本注意不是 UPDATE而是 INSERT 新快照 $stmt $pdo-prepare(INSERT INTO boards (game_id, snapshot_json, version, updated_at) VALUES (?, ?, ?, NOW())); $stmt-execute([$game_id, $row[prev], $row[prev_ver]]); // Step 4: 更新游戏时间戳 $pdo-exec(UPDATE games SET updated_at NOW() WHERE id $game_id); $pdo-commit(); echo json_encode([success true, restored_version $row[prev_ver]]); } catch (Exception $e) { $pdo-rollback(); http_response_code(400); echo json_encode([error $e-getMessage()]); }为什么用INSERT而非UPDATEUPDATE boards SET version version - 1会破坏乐观锁的单调递增性导致后续UPDATE ... WHERE version ?失败INSERT新记录保持version严格递增且boards表天然支持历史追溯SELECT * FROM boards WHERE game_id ? ORDER BY version DESCmoves表删除后SELECT * FROM moves仍能反映真实对局过程符合审计要求。5.2 前端悔棋按钮状态机驱动的「可撤销」UI悔棋不是随时可用需满足三个条件当前是自己的回合否则对手刚走完你不能悔至少已走一步moves表中该game_id记录数 ≥ 1未处于finished状态。前端用状态机管理按钮// 状态定义 const UNDO_STATES { DISABLED: disabled, // 默认禁用 AVAILABLE: available, // 可点击满足条件 PROCESSING: processing, // 请求中 SUCCESS: success, // 成功高亮 2s FAILED: failed // 失败显示错误 }; let undoState UNDO_STATES.DISABLED; function checkUndoAvailability() { const moveCount document.querySelectorAll(.move-history li).length; const isMyTurn isMyTurn(); // 从后端 poll 结果获取 const isPlaying gameStatus playing; if (isPlaying isMyTurn moveCount 1) { undoState UNDO_STATES.AVAILABLE; document.getElementById(undo-btn).disabled false; document.getElementById(undo-btn).textContent 悔棋; } else { undoState UNDO_STATES.DISABLED; document.getElementById(undo-btn).disabled true; document.getElementById(undo-btn).textContent 悔棋不可用; } } // 悔棋点击事件 document.getElementById(undo-btn).addEventListener(click, async () { if (undoState ! UNDO_STATES.AVAILABLE) return; undoState UNDO_STATES.PROCESSING; document.getElementById(undo-btn).disabled true; document.getElementById(undo-btn).textContent ⏳ 处理中...; try { const res await fetch(/api/undo.php, { method: POST, headers: { Content-Type: application/x-www-form-urlencoded }, body: game_id${currentGameId} }); const data await res.json(); if (data.success) { undoState UNDO_STATES.SUCCESS; document.getElementById(undo-btn).textContent ✅ 已悔棋; setTimeout(() { undoState UNDO_STATES.AVAILABLE; document.getElementById(undo-btn).textContent 悔棋; document.getElementById(undo-btn).disabled false; }, 2000); // 强制重新轮询加载新快照 startPolling(currentGameId); } else { throw new Error(data.error); } } catch (err) { undoState UNDO_STATES.FAILED; document.getElementById(undo-btn).textContent ❌ ${err.message}; setTimeout(() { undoState UNDO_STATES.AVAILABLE; document.getElementById(undo-btn).textContent 悔棋; document.getElementById(undo-btn).disabled false; }, 3000); } });5.3 性能压测与边界验证用ab测试并发悔棋的锁表现悔棋是高风险操作必须验证其在并发下的可靠性。用 Apache Bench 模拟 10 个用户同时悔棋ab -n 100 -c 10 -p undo_data.txt -T application/x-www-form-urlencoded http://localhost/chess/api/undo.php其中undo_data.txt内容为game_id1预期结果Complete requests 100Failed requests 0Requests per second≥ 30本地 SSD 环境MySQLSHOW ENGINE INNODB STATUS\G中TRANSACTIONS部分无lock wait timeout。若失败排查路径查error_log确认是否因SELECT ... FROM boards未加FOR UPDATE导致幻读在undo.php的SELECT语句末尾加FOR UPDATE强制行锁将INSERT INTO boards改为INSERT IGNORE避免重复插入相同version报错。从那以后我每次加新功能比如「观战模式」或「AI陪练」都强制走一遍这四步① 画出数据流向图谁读谁写哪张表② 在move.php里找到所有涉及的 SQL标出SELECT是否需要FOR UPDATE③ 写一个最小ab脚本用-c 5并发压测④ 故意在undo.php的commit()前加sleep(5)用另一个终端SELECT * FROM information_schema.INNODB_TRX看锁等待。这套动作做完代码才能从「能跑」变成「敢上线」。希望帮到你。本文还有配套的精品资源点击获取
返回列表