用 Claude Opus 5.5 做这个五子棋时,真正需要处理的是几条会同时推进的执行路径:玩家点击棋盘,Worker 在计算下一步,远端玩家发来落子消息,结算页还在播放动画。它们都可能在页面切换以后继续回调。画出棋盘并不难,难的是让每一次状态变化都有明确的入口,并让已经过期的任务失去修改状态的资格。
本文按实际实现拆解这几个问题。技术栈是 TypeScript、PixiJS 8、GSAP、PeerJS 和 Vite;游戏内 AI 使用本地搜索算法,Claude 参与开发,不参与运行时的落子决策。分析基于项目提交 810d48d,涉及的代码路径都以这个版本为准。在线试玩。
1. 从依赖方向开始:规则、调度和显示分开
项目没有把棋局直接存进 PixiJS 的精灵树。核心分成三层:GomokuGame 保存棋盘和回合,GameScreen 决定谁有权走下一步,BoardView 把模型变化转成棋子与动画。玩家、AI 和远端消息最终都通过 GameScreen.apply() 提交落子。
flowchart TD
player["玩家点击"] --> apply["GameScreen.apply(point)"]
worker["Worker 返回"] --> apply
remote["远端落子"] --> apply
apply --> play["GomokuGame.play(x, y)"]
play --> result["invalid / placed / win / draw"]
result --> board["BoardView + 下一回合 / 结算"]正在加载图表…
src/gomoku/ 不依赖 PixiJS,因此规则和搜索可以直接在 Vitest 中运行。src/net/online.ts 只负责连接、消息解析和订阅;轮到谁、这一步是否能下,仍由对局层判断。src/result/scoring.ts 根据终局信息计算奖励,结算页负责表现和写入本地档案。
这个依赖方向有一个实际收益:画面里有没有某颗棋子,不能成为判定胜负的依据。动画延迟或界面重建不应该改变规则结果。BoardView 的占位检查只是输入反馈,模型还会再次检查落点。
2. 棋局模型:225 个格子、一条历史和统一的返回值
棋盘使用长度为 225 的 Uint8Array,0、1、2 分别表示空位、黑棋、白棋,二维坐标通过 y * size + x 映射到一维。15 × 15 棋盘的单份格子数据是 225 字节,不包含数组对象和历史记录的开销。
相较于把每个格子建成对象,这个表示方便复制、传给 Worker,也方便搜索时原地试走再撤销。历史则单独保存 { x, y, stone },用于悔棋、统计步数和同步序号。
rules.ts 用判别联合描述一次落子的结果:
export type MoveResult =
| { kind: 'invalid' }
| { kind: 'placed' }
| { kind: 'win'; line: Point[] }
| { kind: 'draw' };play() 的顺序是:拒绝已结束或被占用的落点,写入当前棋色,追加历史,检查连线,再检查满盘。只有普通落子才切换回合。这样,调用方不用通过多个布尔值猜测“这一步下成功了没有”和“是不是同时结束了”。
胜负检查只沿最后一手的四个方向扩展:横、竖、主对角线和副对角线,每个方向再向两端扫描。新形成的胜利连线必然经过刚落下的棋子,因此无需重新检查所有起点。对边长为 N 的棋盘,这部分检查是 O(N);当前满盘判断仍会遍历数组,不能把整个 play() 都说成 O(N)。
返回 line 也有用途:规则层已经知道哪几颗棋子构成胜利,视图可以直接高亮这组坐标。当前采用自由五子棋规则,长度大于等于 5 即获胜,六连也算赢,没有加入禁手规则。
悔棋从历史尾部移除记录、把对应格子清空,并把回合恢复为被撤销棋子的颜色。电脑模式通常撤销两手,让玩家自己的落子和电脑回复一起回退;同机模式撤销一手;在线模式暂不提供悔棋,因为它还需要双方确认和同步协议。
3. AI:先缩小候选集合,再做有预算的搜索
AI 的基础在 src/gomoku/ai/patterns.ts。候选落点取已有棋子周围半径为 2 的空格,空棋盘只返回中心。这个筛选主动放弃了远离现有棋子的走法,是控制分支数量的启发式选择。
对每个候选位置,程序沿四个方向读取以该位置为中心的 9 个格子,用 x 表示己方,o 表示对手或边界,_ 表示空位,再匹配棋形。匹配必须包含中心位置,且中心对应棋子,避免把旁边原本就存在的棋形误算成这一步带来的收益。
几个主要权重如下,都是当前代码中的启发式常量:
棋形 | 分数 |
|---|---|
五连 | 10,000,000 |
活四 | 1,000,000 |
冲四 | 100,000 |
活三 | 12,000 |
活二 | 900 |
多个方向的分数累加,再对双四、四三和双三补充奖励。候选位置的排序分数采用 attack + defense * defenseWeight:分别计算自己下这里和对手下这里的价值,再平衡进攻与防守。这不是局面的精确胜率,也不能证明被裁掉的候选一定更差。
三个难度共用“能直接赢就先赢,否则堵住对手直接成五”的检查,之后才分流:
- 豆芽使用 0.55 的防守权重,在前 6 个候选里做偏向高排名的随机选择。
- 狐狸使用 0.95 的防守权重,从达到最高分 92% 的候选中最多取 3 个随机选择。
- 猫头鹰使用 Alpha-Beta 搜索,根节点保留前 12 个候选,后续节点保留前 9 个,从深度 2 逐步加深到 5。
迭代加深解决的是“到时间以后返回什么”。搜索先保留静态评分最高的位置,再逐层尝试;只有某一深度完整计算结束,才用这一层的最佳位置覆盖已有结果。如果中途超时,就继续使用上一轮完整搜索的结果。
下面按 owlMove() 的控制流压缩成伪代码:
let bestMove = rankedRoots[0];
for (let depth = 2; depth <= 5; depth++) {
const result = searchAllRoots(depth);
if (result.timedOut) break;
bestMove = result.bestMove;
if (result.forcedWin) break;
}
return bestMove;当前默认预算为 900 ms,通过递归入口的 Date.now() 检查退出,属于软预算:候选评分本身也需要时间,不能保证精确在 900 ms 截止。搜索还没有置换表、Zobrist 哈希或专门的威胁搜索;有限宽度搜索也不保证找到全局最优着法。
4. Worker 通信:请求配对与结果失效是两个问题
搜索进入 Web Worker 后,主线程可以继续处理输入和画面更新。AiPlayer 使用 new Worker(new URL('./ai.worker.ts', import.meta.url), { type: 'module' }) 创建 Worker,让 Vite 能识别并打包它的入口。Worker 与主线程通过消息传递数据,相关机制可参考 MDN 的 Web Worker 文档。
请求只包含搜索需要的值,响应只返回请求编号和坐标:
export type AiRequest = {
id: number;
brain: BrainId;
board: Uint8Array;
stone: Stone;
};
export type AiResponse = { id: number; x: number; y: number };发送前显式复制棋盘:
const id = this.nextId++;
const snapshot = new Uint8Array(board);
this.pending.set(id, { resolve, board: snapshot, stone });
this.worker.postMessage({
id, brain: this.brain, board: snapshot, stone,
} satisfies AiRequest);这里没有提供 transfer list,消息传递使用结构化克隆;主线程保留的 snapshot 还用于 Worker 失败后的回退。对于 225 字节的棋盘数据,这种实现优先保证快照独立和恢复路径清楚,没有为了零拷贝转移缓冲区的所有权。
id 只解决请求和 Promise 的配对,不能判断请求对应的棋局是否还有效。因此 GameScreen 另有一个递增令牌。下面是 aiTurn() 的关键逻辑摘录:
const token = ++this.token;
this.aiThinking = true;
this.undoButton.setEnabled(false);
const move = await this.ai.think(
this.game.board,
this.game.turn,
this.game.history.length < 2 ? 500 : 380,
);
if (token !== this.token || this.destroyed) return;
this.aiThinking = false;
this.apply(move);undo()、finish() 和 onLeave() 都会推进令牌。即使旧请求已经算完、正在等待展示延迟,返回时也不能再操作新的状态。思考期间悔棋按钮本身被禁用;令牌还覆盖结束对局和离开页面等失效路径。
这里的 500/380 ms 是“至少看起来思考这么久”的展示延迟,和搜索算法里的 900 ms 预算不是一回事。返回时间近似取实际计算耗时与最短展示时间的较大值,再叠加消息调度开销;不能把 380 ms 当作搜索上限。
异常路径也有取舍。Worker 加载失败或报错时,当前实现会终止 Worker,用保留的快照在主线程重新计算,避免界面永远停在“思考中”,代价是回退期间可能卡顿。dispose() 会终止 Worker 并清空等待表,但没有逐个拒绝未完成 Promise,因此它还不是一个具有完整取消语义的通用任务队列。若继续复用这层,应补充取消结果、超时和相应测试。
5. WebRTC 联机:同步落子命令,让接收端重新判断
联机使用 PeerJS。房间号拼接固定前缀后成为房主的 Peer ID,加入者据此连接。PeerServer 负责连接所需的信令交换,建立连接以后通过 DataConnection 发送消息。静态部署省掉的是自建游戏服务端,连接过程仍依赖信令与网络条件。PeerJS 官方说明
每一手发送坐标和落子前的历史长度:
type MoveMessage = {
type: 'move';
x: number;
y: number;
index: number;
};接收端分三步检查。首先 parseMessage() 检查字段类型和范围:坐标必须是 0 到 14 的整数,序号必须是 0 到 224 的整数,未知消息被丢弃。然后对局层检查轮次,最后让模型判断落点是否合法。
const theirs = opponent(this.config.myStone);
if (message.type === 'move') {
if (
message.index !== this.game.history.length ||
this.game.turn !== theirs
) return;
this.apply({ x: message.x, y: message.y });
}index 可以拒绝当前局中的重复或错位落子。比如本地已有 10 手,只接受 index === 10 的下一手,再检查现在是否轮到远端,以及目标位置是否为空。接收端不会因为消息来自已连接的对手,就直接写棋盘。
不过,这仍是面向朋友对战的轻量协议。start 消息带 round,move 却没有回合 ID;也没有棋盘哈希、确认重传、断线续局和权威服务端裁决。hello 带协议号,但现有流程没有形成严格的版本协商。字段校验能够约束消息形状,不能承担排名比赛所需的完整一致性与防作弊责任。
连接状态还要区分两条链路:信令连接断开时,现有数据通道可能仍然可用,所以代码调用 peer.reconnect(),不立即判负。数据通道自身关闭或出错才结束连接;另外每 5 秒发送心跳,90 秒未收到数据视为超时。较宽松的超时考虑了后台标签页的计时器节流。
房间等待中还有一个竞态:真人刚好加入时,30 秒倒计时也可能触发电脑替补。OnlinePopup 让真人入口和替补入口共用 started 标志,抢先进入的一方将它设为 true,另一方退出。房主目前等待 400 ms 再发送 start,给加入者留出订阅时间;固定延迟不是可靠握手,后续可以改成明确的 ready → start → ack 流程。双端跨网络联机尚未完成完整实测,这部分不能用解析器单元测试代替验收。
6. PixiJS 渲染:纹理预算和坐标转换要显式处理
PixiJS 的容器、精灵和图形组成场景树,可以把角色卡、棋盘或宝箱作为一组对象变换。场景对象文档
项目的主要美术由 SVG 源码生成。加载路径是 SVG 字符串 → Image.decode() → 2D Canvas → CanvasSource → Texture,随后精灵共享缓存中的纹理。这样颜色、边框和宝箱等级可以参数化调整,运行时使用的是已经栅格化的资源。
栅格化也需要控制分辨率。textures.ts 将设备 DPR 截到 3,小素材额外放大,再把总倍率截到 4,同时限制纹理最长边不超过 2400 像素。加载每批最多处理 6 张 SVG。主画布的渲染分辨率则单独限制为最大 DPR 2。
这些数字控制的是不同层面的成本,不能混用。作为量级参考,一张 2400 × 2400 的 RGBA 像素面约为 22 MiB;这只是宽 × 高 × 4 的估算,不包含额外缓冲和其他资源,也不是实测显存。SVG 文件体积小,并不意味着栅格化后的纹理开销也小。
输入坐标同样不能直接拿页面像素除以格子大小。棋盘容器会随窗口缩放和移动,需要先从全局坐标转换到棋盘局部坐标:
const local = this.toLocal(event.global);
const x = Math.round((local.x - this.gridStart) / this.cell);
const y = Math.round((local.y - this.gridStart) / this.cell);随后检查边界和占位。触摸操作第一次点击只设置 pendingTouch 并显示预览棋子,第二次点击同一个位置才提交;点击其他位置则更新预览。这个两阶段输入只存在于视图层,确认前不会向 GomokuGame 写入任何临时棋子。
7. 页面销毁时,动画和异步续体也要一起考虑
navigation.ts 用 Promise 队列串行执行页面替换,避免连续点击让两个页面同时挂到场景树里:
goTo(next: AppScreen): Promise<void> {
const swap = this.transition.then(() => this.swap(next));
this.transition = swap.catch(() => undefined);
return swap.then(() =>
next.destroyed ? undefined : next.show?.()
);
}这里特意把 show() 放在替换队列外。结算页可能一直等用户点宝箱,如果把完整入场流程也放进队列,后面的跳转就会被它阻塞。队列串行的是旧页移除和新页挂载,而不是用户在页面上的整个交互过程。
替换旧页时依次禁用交互、等待退场、调用 onLeave()、移出场景树、清理动画,再销毁容器。killTweensDeep() 会递归清理指向节点及其 scale、position、pivot 的 GSAP tween,因为这些对象也可能被单独作为动画目标。
只清理 tween 还不够。异步函数可能已经跨过一个 await,或者正在等待定时器,随后继续访问旧对象。结算动画因此在多个异步边界后检查 this.destroyed;对局页则注销网络订阅、终止 AI Worker,并推进前面提到的令牌。资源清理与结果失效判断分别处理“谁还在运行”和“谁还允许修改页面”。
当前实现采用这些显式检查,没有统一的 AbortSignal 生命周期作用域。以后增加更多动画任务时,可以把定时器、监听器、Worker 和取消结果集中管理,减少每个页面手工清理的遗漏。
8. 奖励结算:动画显示值和持久化写入分开
settle(outcome, streakBefore) 是一个不依赖画面的函数,输入胜负、模式、对手难度、获胜方步数和此前连胜数,输出宝箱等级、奖励数量及新的连胜数。结算页面拿到的是已经算好的结果,不在金币飞行过程中重新决定发多少奖励。
领取阶段有两个标志。collected 防止重复点击启动多组动画;credited 防止同一个结算页实例多次把奖励加进档案。下面保留核心逻辑,省略了监听器清理:
onLeave() {
this.credit();
}
private credit() {
if (this.credited) return;
this.credited = true;
const rewards = this.settlement.rewards;
updateProfile((profile) => ({
coins: profile.coins + rewards.coins,
gems: profile.gems + rewards.gems,
crowns: profile.crowns + rewards.crowns,
}));
}正常路径是所有奖励图标飞到 HUD 后调用 credit()。如果玩家在动画完成前通过应用内导航离开,onLeave() 也会调用同一个方法。HUD 中间的递增只承担视觉反馈,档案最终写入一次,不按每个飞行图标分别写 localStorage。
这个保证有明确范围:它是单个页面实例里的重复调用保护。它不等于跨刷新、跨标签页或跨设备的 exactly-once。直接关闭或刷新浏览器不保证走到这个应用内钩子;localStorage 写入失败时,代码也只是保留内存值。若奖励以后有排行榜或兑换价值,需要引入稳定的对局 ID、持久化结算记录和服务端校验,不能依赖一个内存布尔值。
9. 测试验证了什么,还缺什么
当前仓库的 29 项 Vitest 测试分布在四个文件。规则与搜索不依赖渲染,使这些测试可以直接构造棋盘并调用函数。
测试文件 | 当前覆盖的行为 |
|---|---|
| 轮流落子、重复位置、四方向获胜、六连、悔棋和认输 |
| 中心开局、直接取胜、堵五、空位选择、阻止活三扩展及一个固定种子的对局 |
| 消息解析、字段类型、坐标与序号范围 |
| 难度基础等级、快速获胜、连胜、平局及同机模式结算 |
AI 测试给随机策略注入固定种子,让同一输入可复现。其中“猫头鹰执黑战胜豆芽”只是一个固定场景,不是胜率评测,也不足以证明难度在所有局面上严格有序。
下一批最值得补的是异步边界:页面离开后旧 AI 响应不得落子,Worker 崩溃后等待状态能够结束,真人加入与电脑替补同时发生时只启动一局,重复领取与动画中途离开时奖励只写一次。它们目前没有专门的自动化覆盖。跨网络联机、触摸手感和实际帧率则还需要独立验证。
10. 部署:验证构建结果,而不只看命令是否成功
项目用 Node.js 22.23.3 固定构建环境。Netlify 绑定主分支,构建命令把测试放在产物生成之前:
[build]
command = "npm test && npm run build"
publish = "dist"
[build.environment]
NODE_VERSION = "22.23.3"npm run build 先运行 tsc --noEmit,再运行 Vite 构建。Worker 入口随构建生成独立文件,因此发布时需要上传完整的 dist,不能只传 HTML 和主脚本。正式域名通过 Cloudflare CNAME 指向 Netlify,并由托管平台提供站点 HTTPS。
仓库还保留了手动触发的 GitHub Actions 工作流:测试、构建、上传构建 artifact,再由发布 job 下载同一个 artifact 部署。scripts/verify-deploy.mjs 等待 Netlify 状态变为 ready,随后访问这一版的不可变部署地址,逐个下载文件并比较 SHA-256。
选择不可变地址,是为了让校验对象明确对应某一次部署。主域名会随新版本发布而变化,不能在并行发布时把它当成固定的构建身份。这个哈希校验属于手动工作流;Netlify 的主分支自动构建当前执行的是测试和构建,没有把两条链路混为一谈。
9 月 27 日上线时,另外核对了正式域名的 14 个构建文件与本地构建一致。文件一致证明发布的是预期产物,不能替代用户交互测试或联机验证。
11. Claude 的产出怎样进入可验证的工程流程
项目最初实现和后一次修整都有 Claude Opus 5.5 的共同署名提交。后一轮修改涉及页面切换串行化、动画销毁清理、Worker 失败回退、联机消息校验,以及结算页奖励重复写入保护。这些修改的共同点是:都围绕明确的状态条件,而不只是增加一个界面功能。
从这个项目提炼出来的协作方式,是先把可验收的约束交给模型:三种落子来源使用同一套规则;旧任务不能操作新页面;远端命令必须经过本地验证;奖励计算和展示分离。再用代码、测试和实际交互逐项检查这些约束。上面这些是根据实现归纳的方法,不是对原始提示词的逐字还原。
这套实现已经能支持一个可分享的浏览器小游戏。若继续推进,我会优先补异步生命周期测试、明确联机握手和回合身份,再测搜索耗时与低端设备帧率。先把状态和协议的边界补齐,新增角色、棋盘皮肤和奖励动画时才有稳定的基础。
