1. 项目概述与技术选型
1.1 先拆清楚需求:我们做的到底是什么
做小游戏最怕一上来就写代码。PixiJS跳棋小游戏听起来简单,但“跳棋”这个词在不同语境下指的东西完全不同。我做的是中国跳棋的简化版本,也就是那个六角星棋盘、双方各十颗棋子、隔着子跳的经典玩法。在动手之前,先把需求拆成了三层:第一层是“能跑”,也就是棋盘能画出来、棋子能点选、能落子;第二层是“有规则”,要走子合法、跳跃合法、胜负结果判得清楚;第三层是“有点意思”,至少有一个能跟你下几回合的简单电脑对手。
这个拆法很关键。很多初学者一上来就想着做完整AI、做双人联机、做华丽的棋子拖拽动画,结果做到一半卡死在基础交互上。我这次给自己定的范围就一个核心目标:单机双人模式 + 一个弱AI。所有功能都围绕“在一套可靠规则引擎之上做渲染和交互”展开,而不是反过来被渲染效果牵着走。
适合参考这份实践记录的人,是那种已经能写基本的前端代码,但对PixiJS还不熟、想拿一个带完整交互逻辑的小游戏练手的朋友。如果你只想看引擎API怎么调,那官方文档更全;但如果你想看“一套小游戏项目从棋盘坐标到AI落子是怎么串起来的”,这篇应该对你有用。
1.2 渲染引擎选型:PixiJS的取舍逻辑
选PixiJS而不是原生Canvas或者纯DOM,最核心的原因是PixiJS把“场景树管理”和“交互事件”这两件最繁琐的事封装好了。你可以把PixiJS理解成一个专门为2D场景设计的调度器:它用Graphics画矢量图形、用Sprite做位图渲染、用Container做层级管理,再用统一的ticker驱动重绘。对于跳棋这种大量重复图形、高频交互的小游戏,省掉的代码量非常可观。
对比来看,原生Canvas不是不行,但你需要自己维护脏矩形、自己做点击命中测试、自己管理重绘节奏,这些听起来不难,真做起来全是细节。DOM方案就更不用说了,几十个棋子节点做动画移动还能扛住,但一旦加了棋盘格高亮、路径预览、AI推算这些动态表现,DOM的布局引擎会成为瓶颈。PixiJS则是一套GPU加速的渲染管线,绘制几十个圆形和方块对它是基本零压力,这就给后面加功能留出了余地。
不过选型也有代价。PixiJS v7以后事件系统改成了EventSystem,API和v6不完全兼容,网上很多旧文章在interactive和pointerdown上直接让你踩坑。此外PixiJS本身不提供任何游戏逻辑能力,规则、AI、状态管理全都是你自己写。这个边界一定要搞清楚——PixiJS负责“让画面出现、让点击响应”,不等于你有了一个游戏。
2. 整体架构与游戏设计
2.1 目录结构与模块划分
一个跳棋游戏,代码量看起来不大,但如果不分模块,写到AI那一步基本就乱套了。我这次把工程按职责拆成了五个部分:入口初始化、棋盘数据、规则判定、渲染层、AI决策。这里贴一下目录结构,方便你对照着看后文。
src/ ├── main.js // PixiJS应用初始化、启动游戏 ├── config.js // 棋盘尺寸、格子半径、颜色、层级常量 ├── core/ │ ├── board.js // 棋盘数据结构:格点生成、坐标换算 │ ├── rules.js // 走子判定、跳跃判定、胜负判定 │ └── ai.js // 评估函数与搜索策略 ├── render/ │ ├── boardView.js // 棋盘图形绘制、格子高亮 │ ├── pieceView.js // 棋子的创建、选中态、移动动画 │ └── interaction.js // 点击事件绑定、选中流程控制 └── state/ └── gameState.js // 当前回合、选中的棋子、当前可选落点这个拆分最核心的意图是:渲染层绝对不能直接改游戏数据。比如说,你点击了一个棋盘格,interaction模块只负责“发出意图”,真正的状态变更必须经过rules模块校验,再由gameState统一更新,最后反过来通知view层刷新。这样做的直接好处是,AI和玩家共用同一套规则引擎,不存在“AI走了一步,换玩家时状态不一致”的恶心问题。
2.2 游戏状态机:把规则关进笼子里
棋类游戏最适合用状态机来管。我定义的几个状态是最小够用的一组:WAITING_PLAYER(等待玩家选子)、PIECE_SELECTED(棋子已选中,等待选目标)、AI_THINKING(AI计算中)、GAME_OVER(分胜负)。每个状态里能做的事是被严格限制的,这比用一堆布尔标记来管流程可靠得多。
举个例子,玩家在PIECE_SELECTED状态下点击另一个自己的棋子,应该切换选中到新棋子上;但点击一个空格子且该格子不在可走范围内,就应该视为无效点击,不产生任何变化。如果不用状态机,每次点击都要写两三个if判断,很容易漏。状态机把“当前允许的操作”收敛成几张明确的表,实现和查错都轻松。
我在实现上做了一个小机关:状态迁移统一收敛到一个transition(state, action, payload)方法里。每次玩家点击、AI落子,都走这一个入口,状态变更的前后都会打印日志,方便调试。这个习惯在排错阶段救了我好几次。
3. 渲染核心:棋盘和棋子的实现
3.1 棋盘定位坐标的计算:网格才是骨架
跳棋棋盘不像国际象棋那样是规整矩形。中国跳棋的棋盘是一个六角星,由121个格点组成,每个格点与周围六个方向的格点相邻。如果直接用美术素材贴图,做交互时坐标系会非常痛苦——你需要反向算出“点击位置的像素坐标对应棋盘的哪个交叉点”。我的做法是:棋盘完全用程序生成,格点坐标统一用逻辑坐标(行号、列号)表示,渲染时再换算为像素。
这里的关键是坐标换算公式。以棋盘中心为原点,我定义列方向为x轴,行方向为y轴。每个格点存储一组逻辑坐标(row, col),在六边形网格里它的像素位置是:
function hexToPixel(row, col, gridSize) { const x = col * gridSize * Math.sqrt(3) + (row % 2) * gridSize * Math.sqrt(3) / 2; const y = row * gridSize * 1.5; return { x, y }; }为什么横坐标要加row % 2修正?因为奇数行的格点在水平方向上正好错开半个网格间距,这也是六边形网格看起来有“蜂窝”感的来源。颜色上,我直接把有效格点画成棋盘表面,无效格点不渲染,这样视觉上就自动形成了六角星轮廓,不需要额外处理形状裁剪。
基于程序生成棋盘还有一个隐形收益:边框、网格线、高亮区域这些视觉元素全部可以通过同一个坐标系统换算,不存在“美术图上棋盘格和真实可点区域不一致”的问题。这是我在项目里坚持不用纯贴图的根本原因。
3.2 棋子从图形到精灵的落地细节
棋子的渲染我用了Graphics而不是Sprite,原因很简单:棋子就是带高光的圆,没有复杂的纹理,用矢量绘制代码量更少、颜色调整更灵活。每个棋子我封装成一个PieceView类,它内部包含一个Graphics实例和一个逻辑位置。棋子状态有三种渲染形态:默认、选中(加亮边框、轻微放大)、目标落点提示(周围画虚线圆)。
用Graphics绘制棋子有一个必须注意的点:每次修改图形属性都要调用重绘方法。我最早在这里犯了个错——直接修改某个棋子的填充颜色变量,但是忘了重绘,结果屏幕上死活不变。后来在PieceView内部统一了update()方法,每次状态变化都走“清空-重绘-更新交互区域”的流程,才算稳定下来。
另外,PixiJS里Graphics的hitArea在事件命中时是按图形实际绘制内容来算的。对棋子这种带半透明高光的图形来说,hitArea如果用默认的像素命中,在某些抗锯齿边缘会出现“点了没反应”的错觉。稳妥做法是给每个棋子容器手动设置一个矩形hitArea,稍微大出几个像素,点击手感会明显改善。
3.3 交互:点击选中的几个隐蔽坑
PixiJS v7的交互系统,核心变化就是必须显式设置eventMode为'static'或'dynamic',否则容器默认不监听事件。这和v6的interactive = true写法完全不同,是新手在升级版本后最容易踩的坑,没有之一。
其次是事件派发层级。棋盘上既有棋盘格container,又有棋子container,还有高亮落点提示。如果都监听点击事件,很容易出现连点一次却触发多个handler的情况。我的处理方式很粗暴:让棋子处理点击事件时把棋盘格的高亮响应暂时屏蔽。具体做法是给棋盘底下的棋盘格容器设置eventMode = 'static'并监听pointerdown,但是只有当当前没有选中棋子、或者点击的是有效可走位置时才响应用户的意图。所有这些逻辑都收敛到interaction模块,避免在视图类里散落一堆业务判断。
还有一个容易被忽略的点:pointerdown和pointertap的区别。在快速连点时,pointertap会更稳定,因为它自带了几像素的移动容差,手指轻微抖动不会误触。我最终全项目统一用pointertap做落点选择,避免了移动端测试时“我明明点到了却选错格子”的惨痛经验。
4. 规则与AI逻辑
4.1 走子与跳跃判定:不要忘了递归
跳棋的规则核心就两条:相邻空格可以走一步;隔一个棋子跳到对方后方空格,可以连续跳。听起来简单,实现时真正复杂的是“连续跳跃的可能性展开”。
我设计的数据结构是棋盘格点邻接表。每个有效格点预先算好六个方向的邻居,存储在一个字典里。走一步的合法性判断只需要查表:目标格点在当前格点的邻居列表中、且目标格点为空。
跳跃判断就要麻烦一些。canJump(from, to)不仅要判断“from和to之间隔着且只隔着一个棋子”,还需要考虑“跳到to之后,下一步还能不能继续跳”。这里要是用循环写,连跳步数一多就会乱。我换成递归写法:
function findJumps(pos, visited, results) { for (const dir of DIRECTIONS) { const over = neighborAt(pos, dir, 1); const jumpPos = neighborAt(pos, dir, 2); if (over && jumpPos && isOccupied(over) && !isOccupied(jumpPos) && !visited.has(jumpPos.id)) { const newVisited = new Set(visited); newVisited.add(jumpPos.id); findJumps(jumpPos, newVisited, results); } } results.push(pos); }这段代码里visited集合是关键。它避免了一个棋子在同一回合里反复跳回同一个位置导致死循环。注意我不是简单记录“已经跳过哪些棋子”,而是记录“已经停留过哪些格点”,两者在规则语义上是有差别的:同一条路径上不能重复落点,但不同分支可以穿过同一颗棋子。
递归的终止条件就是“没有可跳的相邻方向了”。收集完所有可能落点之后,玩家能走的位置就是“一步相邻空位 + 所有可达跳跃落点”。这套算法在游戏里用到两个地方:玩家交互时计算可走高亮,AI决策时枚举全部走法。一次实现,两处复用,很划算。
4.2 电脑对手:最小可行AI方案
做一个能跟人下棋的电脑对手,很多人第一反应是上蒙特卡洛树搜索或者深度学习。实话说,十颗棋子规模下的跳棋,一个几百行代码的极小化极大搜索配合良好的评估函数就已经能打得新手很难受了。我这次做的AI设计思路是:先让评估函数靠谱,再做一层有限搜索。
评估函数是AI唯一的“智力来源”。跳棋的目标是让己方所有棋子到达对方阵营。我用三个维度评分:
function evaluatePosition(board, side) { let score = 0; for (const piece of board.pieces) { const progress = side === 'red' ? piece.row : (maxRow - piece.row); score += progress * 1.0; if (piece.isInTargetZone(side)) score += 5.0; if (piece.isBlocked()) score -= 2.0; } return score; }第一个维度是“进度”,棋子越靠近对方营地,评分越高,这是主力权重。第二个维度是“已经到达目标区的棋子数”,对胜利起到关键引导作用。第三个维度是“是否被堵死”,如果一颗棋子的六个邻居全被占满、又没有可跳路径,这棋子在短期内基本废了,必须给予负分。
搜索策略我用的是固定深度二层的极小化极大。为什么只做两层?因为两步以内的搜索只需要枚举“我方走一步、对方走一步”的所有组合,计算量还在可接受范围;再往下加深,全盘121个格点的扩展量会指数级增长,再加上跳跃路径的递归展开,帧率会肉眼可见地掉。对于移动端的弱AI需求,两层搜索加一个干净的评估函数,已经能做到“有威胁、会堵截、偶尔下出好棋”的观感了。
还有一个细节:AI落子之后要做的第一件事不是刷新画面,而是检查自己是否“被困在逻辑状态里”。我早年吃过一次亏,AI枚举了所有走法都是死路,直接返回null导致游戏卡死。后来在AI模块里加了兜底逻辑:如果当前没有任何合法走法,就强制让AI跳过回合。这个兜底放在棋类游戏里永远不该省。
5. 性能优化与多端适配
5.1 渲染优化:图形缓存和对象复用
跳棋小游戏场景里就几十个图形对象,性能压力并不大,但“不大”不代表可以胡写。PixiJS的Graphics每次调用beginFill、drawCircle都会动态生成几何数据,如果每帧都在重复创建新的Graphics,即使对象量小也会引发不必要的内存分配和GC停顿。
我做的第一层优化是缓存静态网格。棋盘背景、有效格点区域、边缘轮廓这些元素在游戏过程中完全不变,我在初始化时一次性画到独立的Container里,之后不再重绘,移动或高亮时只移动它的引用。棋子的图形定义也做了缓存复用,颜色、描边、高光路径全部是构造时确定的,后续只改位置和可见性。
第二层优化是尽量复用对象而非无限新建。每个PieceView在创建时把Graphics实例固定下来,状态切换时只调整图形属性并标记重绘,而不是销毁旧的再new一个。这套“创建-复用-复绘”的模式,对后续想加棋子入场动画、移动轨迹动画也非常友好,因为对象句柄一直是同一个。
另外我保留了PixiJS的自动渲染机制,没有手动调用renderer.render()。对大多数小项目来说,Application.ticker驱动的自动渲染已经足够高效,过早自己写渲染调度反而容易引入“某些帧漏更新”的不确定性。
5.2 窗口缩放与高清屏适配
跳棋棋盘是正方形风格布局,在设计分辨率下看起来正好,但换到不同比例的浏览器窗口或者手机上,就容易出现棋盘画不全或者留大片空白的问题。
我的适配思路是统一走“等比缩放”策略:先确定一个设计分辨率(我用的是960 x 960),把整个游戏场景放入一个Container里,窗口变化时用CSS的min(宽高比)计算缩放比例,然后同时设置Container的scale和位置居中。这样做的视觉效果等级,有点像是看地图时用固定视野比例缩放,永远不改排版,只改缩放。
高清屏的问题单独处理。PixiJS在初始化Application时如果不设置resolution,默认用设备像素比1。在iPhone那种3倍屏上,图形边缘会发虚。正确做法是初始化时读取window.devicePixelRatio,并在resize时叠加这个系数。注意坑点在于:渲染分辨率提升后,所有坐标和点击精度也要跟着对应,直接把resolution调大但scale不变会导致事件坐标错位。这里有个简单的换算公式:
app.renderer.resize(cssWidth * dpr, cssHeight * dpr); view.scale.set(cssWidth / designWidth, cssHeight / designHeight);实测下来这一套在PC、平板和手机上都能在加载后自动适应,不需要别的插件。不过Android不同浏览器对devicePixelRatio的返回时机略有差异,稳妥做法是初始化后延迟半秒在resize事件里统一校正一次,我就遇到过初始化时dpr还是1、加载完变2的情况。
6. 常见问题与排错记录
6.1 高频问题速查表
这里把开发过程中常见的问题整理成一张表,都是我亲身踩过或者朋友做类似项目时问过我的,直接抄作业就好。
| 现象 | 根本原因 | 解决办法 |
|---|---|---|
| 点击棋子无任何反应 | 容器没设eventMode,或事件绑定在子元素上但没冒泡 | 给需要交互的容器设置eventMode='static' |
| 棋子能选,但选不中落点 | 高亮落点的container在棋盘底图下面,被遮挡 | 检查zIndex和sortableChildren,让高亮层在上 |
| 棋盘错位、棋子位置怪 | 六边形网格的奇数行偏移忘了算 | 检查hexToPixel中row % 2修正逻辑 |
| 连续跳跃的路径反复横跳 | 递归时未记录已有落点 | 使用visited集合记录已停留格点 |
| 高DPI屏幕棋子模糊 | 未设置resolution或分辨率与scale不一致 | 初始化传入resolution: window.devicePixelRatio |
| AI有时会卡住 | AI枚举走法后所有结果均为null | 在AI返回null时强制跳过本回合 |
| 重新开局后旧状态残留 | 没有重建棋盘数据的干净实例 | 重新初始化board对象而非清空数组 |
| 移动端点击偏移 | 使用了pointermove或未适配缩放后的坐标映射 | 统一用pointertap,并基于全屏坐标换算 |
6.2 现场排错一个诡异Bug:同一颗棋子跳了没完
有一个bug我排查了整整半小时,很典型。现象是:第一步点击一颗棋子,高亮落点正常;选一个跳位过去之后,这颗棋子突然又进入了选中状态,而且棋盘上出现了一堆离谱的可跳位置。
初看以为是递归跳跃的判定出了问题,但我打印路径后发现递归本身没毛病。最后定位到是交互流程的问题:我处理“跳到新位置”时,修改了棋子的逻辑坐标并同步更新渲染坐标,但交互层检测“当前是否有点击了新棋子”时,被点中的那颗棋子的位置已经移动了,事件命中区域还停留在旧位置,导致未结束的点击状态被错误地当成“点击了同一颗棋子”。
修复方案是加一个状态判断:当玩家完成一步落子后,强制将state切回WAITING_PLAYER,并清除所有选中高亮。同时我把交互层的点击目标句柄从“棋子当前视图形状”改成了“棋子容器持久引用”,事件目标识别不再依赖图形位置,彻底杜绝了坐标偏移带来的误判。
排这个bug的核心教训只有一句话:交互状态和逻辑状态的切换时序一定要同步。代码逻辑正确不一定代表交互体验正确,渲染层和状态层的微小脱节会在真实操作中无限放大。
最后分享一点实际体会
做这个PixiJS跳棋小游戏,最大的收获不是学会了某个具体API,而是把“渲染、交互、逻辑、决策”这四层清晰地分开后,整个项目到了后期几乎没再出现牵一发动全身的重构。很多功能在架构正确的前提下,改动都发生在局部模块里,比如给AI增强搜索深度时,我完全不用碰渲染层。
如果你想在这个项目基础上继续扩展,我个人建议优先做三件事:第一,给棋盘格增加“可走路径预览”的连接线绘制,这个视觉效果会让游戏观感提升一个档次;第二,把静止的AI评估函数升级成带微调的权重配置界面,方便调试棋力;第三,考虑把棋盘坐标系统抽象出来,方便未来替换成国际跳棋或黑白棋的格点模型,一旦几何坐标层独立,换玩法比想象中快得多。