三子棋 · 米字棋 Three Men's Morris
交付目标:给用户一个双击即玩的米字棋网页,以及一套可以继续改比例、改难度、验证正确性的方法。
一、这东西是什么(先别叫错名字)
中文口语里"三子棋"被三盘棋抢用,动手前先确认用户要的是哪一个:
| 棋名 | 棋盘 | 每人棋子 | 特征 |
|---|---|---|---|
| 井字棋 tic-tac-toe | 3×3 九宫格,无斜线 | 不限 | 下在格子里,摆满即止,不能走子 |
| 米字棋 Three Men's Morris | 3×3 加双斜线(形如"米"字) | 各 3 子 | 下在交点上,摆完能走子,无吃子 ← 本技能 |
| 成三棋 Nine Men's Morris | 三个同心正方形 | 各 9 子 | 下在交点上,成三可吃子 |
别名:米字棋、走三棋、三三棋、九孔棋、Tapatan(菲律宾)、Picaria(美洲原住民)。同一块棋盘的两种规则版本:能走到任意空位 = Nine Holes(九孔棋);只能走到相邻空位 = Three Men's Morris。
> 用户只说"三子棋"时,默认是井字棋。若用户说"米字棋 / 走三棋 / 带斜线的三子棋",才是本技能。不确定就先问一句。
二、本作规则(已实现,勿擅自改)
- 9 个交点,8 条获胜线:3 行 + 3 列 + 2 条大斜线。
- 两阶段:摆放(各 3 子轮流落空点)→ 走子(把自己一子挪到相邻空点)。
- 走子为八方向全通(含斜向)。方向数:角 3 / 边中点 5 / 中心 8。
- 3 子连成横、竖或斜一线即胜;没有吃子。
- 轮到你却无子可走 → 判负。
- 斜线一律画虚线(只表示"能走",不算获胜线);实线只有 3×3 网格。
- 只做人机对战,人执黑先手,电脑穷举全解。
本作与标准版的差别:标准 Three Men's Morris 只能沿棋盘画出的线走(边中点不能斜走)→ 先手必胜;本作放宽为八方向全通 → 理论结果和棋。改规则会改变棋的理论性质,动之前先跟用户确认。
三、怎么用(交给用户)
index.html 就是成品,独立单文件,直接打开即可:
| 场景 | 做法 |
|---|---|
| 电脑上玩 | 双击 index.html(推荐 Chrome / Edge) |
| 手机上玩 | 连同文件一起托管到任意静态服务,或直接发给手机的浏览器打开 |
| 微信里分享给别人 | 用能返回正式分享域名的托管渠道(workbuddy.link 一类),别用会现造沙箱子域的渠道,否则微信会弹安全提醒(详见 @references/mizqi-notes.md 第六节) |
| 要"打开就有永久链接" | 走资料库页面发布(轻量发布),得到形如 https://workbuddy.link/p/<页面ID> 的短链 |
操作:点 9 个交叉点摆子 → 摆完点自己的棋子(出金圈)→ 再点墨绿色提示点走子 → 连成一线即胜。顶部按钮:重新开始 / 难度(简单·普通·困难) / 声音(默认关)。难度切换立即生效,不用重开,默认普通。
四、改比例(最常被要求的一步)
棋盘几何由三个常量派生,改完必须让闭合等式成立:
const DESIGN = 608, FRAME0 = 42, MARGIN0 = 88, CELL0 = 174;
// 2*FRAME0 + 2*(MARGIN0 + CELL0) = DESIGN
// 2*42 + 2*(88 + 174) = 608 ✓
const LABEL_POS = 0.40; // 坐标标号在留白中的位置:0=贴木板边,1=贴网格
| 需求 | 怎么改 |
|---|---|
| 木框更厚、网格不变 | FRAME0↑,加厚的量从 MARGIN0 等量扣掉,CELL0 不动 |
| 厚边框 + 窄棋盘 | FRAME0↑、CELL0↓、MARGIN0↑ |
| 棋盘铺满 | FRAME0↓、CELL0↑、MARGIN0↓ |
| 棋子大小 | 只改 STONE_R(当前 0.22),所有棋子与标记都由它派生 |
⚠️ 三个坑:
- 改完一定同步 play(play = DESIGN - 2*FRAME0,当前 524),它是缩放基准。
- MARGIN0 有下限 ≈86 —— 留白里还要放 A–C / 1–3 坐标标号,再小标号就贴上外侧棋子。真到极限时先把 LABEL_POS 往 0 调,再不够只能回头缩 CELL0。
- 棋子/格距比恒为 0.44,用户说"棋子太靠边"时,根因通常是留白不足,不是棋子太大。
五、改完必须自测(别只做语法检查)
node scripts/selftest-stub-dom.mjs index.html --verbose
node scripts/selftest-difficulty.mjs index.html --games 300 # 改了难度参数必跑
selftest-stub-dom.mjs 用 Proxy 造假 canvas context + 假 document/window,把页面脚本 new Function(...) 真跑一遍,能抓到"元素删了但 JS 还在引用"这类语法检查查不出的 boot 期崩溃(症状是整页白屏)。
selftest-difficulty.mjs 把脚本内部符号(aiPick / genMoves / SOLVER)拉出来反复对局,断言三件事:困难档人类一局都赢不了、简单档人类能赢、简单档比普通档好赢。
两个必踩的坑:① new Function 的 window 形参不是全局对象,脚本里裸引用的全局名必须另挂 globalThis;② 桩赋值有先后,globalThis.X = win.X 必须写在 win.X = {...} 之后,否则桩拿到 undefined,脚本静默早退成"假通过"。
六、AI 是怎么做到"不会走错"的
用 BFS 枚举全部可达局面 + 逆向归纳标定每个局面的胜负,结果存进 Map,电脑每步查表选最优。逆向归纳的顺序是最大的坑:
- 存在能立刻赢的分支 → 直接判赢;
- 否则等全部分支定完 → 全输则输,否则和;
- 循环到不动点后,剩余未定的统一归为和棋。
✗ 若要求"所有分支都解析完才判赢",循环(和棋)局面永远等不到 → 完全不收敛。
难度三档(简单 / 普通 / 困难)
同一张解表既当"上帝视角",也当难度旋钮。先把每一手的成色归一化成三档:
function moveScore(m) { // 3 = 电脑必胜手 / 2 = 和棋手 / 1 = 送输手
const r = SOLVER.get(skey(applyMove(board, AI, m), HUMAN));
return r === AI ? 3 : r === 'D' ? 2 : r === HUMAN ? 1 : 2;
}
| 档位 | 键值 | 失误概率 | 效果(人类=贪心求胜,300 局实测) |
|---|---|---|---|
| 简单 | easy |
30% 纯随机 + 55% 退次优 | 人类胜 81.7% |
| 普通 | normal |
30% | 人类胜 48.0% |
| 困难 | hard |
0% | 人类胜 0.0%(电脑永不放水) |
要点:
- 困难档就是原始逻辑,okWrong = 0 时那条失误分支根本不进 —— 这是"电脑不失误"的实现保证,别去改它。
- 米字棋理论结果是和棋(空盘 9 个开局手成色全为 D)。困难档"至少不输";它不是比人强,而是逼人类走对才守得住和。
- 失误只在成色组的次序里退一档,退无可退自动落回最优档 → 永不产生非法手。
- 改手感就动 okWrong(简单 0.55 / 普通 0.30 / 困难 0)和简单档那个 Math.random() < 0.30。
七、交付前检查清单
- [ ]
node scripts/selftest-stub-dom.mjs index.html --verbose→ 全部通过 - [ ]
node scripts/selftest-difficulty.mjs index.html --games 300→ 五条断言全 PASS(困难档 0 胜) - [ ] 页面零外部请求(无 CDN、无上报),断网可玩
- [ ] 改了几何常量 → 闭合等式仍成立、
play已同步、标号没撞棋子 - [ ] 交付给用户的文件后缀已改回
.html - [ ] 若托管上线:发布态是否单独同步过(
commit只更新在线编辑版,对外链接不会自动变新)
八、附件
| 文件 | 说明 |
|---|---|
index.html |
成品:单文件、零依赖、零联网,双击即玩 |
references/mizqi-notes.md |
完整制作与核验记录:名称考证、规则、几何推导、自测方法、踩坑、发布渠道选型 |
references/board-comparison.svg |
井字棋 / 米字棋 / 成三棋 三种棋盘对比图 |
scripts/selftest-stub-dom.mjs |
桩 DOM 自测脚本(可复用于任何单文件网页) |
scripts/selftest-difficulty.mjs |
难度行为自测脚本:断言困难档零失误、简单档可赢 |
> 注:技能图标不在本包内,由发布平台单独存放(用户安装到本地时才会回填图标文件),发布时在控制台上传 512×512 PNG 即可。
作者与联系
- 作者:安朋康|348760700@qq.com