一个没有后端、没有数据库、没有账号系统的纯静态网页游戏, 四档 AI 里最强的一档平均 44 步清场, 界面上每一根抖动的铅笔线都是 rough.js 现算的, 爆炸声是 Web Audio 现场合成的。
这篇写给程序员:聊聊里面几个我自认为有点意思的工程决策。
🔗 在线玩:海战棋

先说清楚它是什么
10×10 方格纸海战棋(Battleship),双盘对战:上面是敌方海域,下面是我的海域。 五艘船共 17 格(航母 5、战列舰 4、巡洋舰 3、潜艇 3、驱逐舰 2), 轮流报坐标开火,谁先打沉对方全部舰船谁赢。 击沉时必须报出舰种------这是原版最妙的规则,对手据此才能缩小搜索范围。
技术栈非常克制:
bash
// package.json ------ 运行时依赖只有 4 个
"dependencies": {
"next": "^15.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"roughjs": "^4.6.6" // 唯一的三方「业务」依赖
}
没有状态管理库、没有 UI 组件库、没有动画库、没有音频素材、没有图标库。 Tailwind 只在 devDependencies 里,构建后全量静态导出(next.config.ts 里 output: 'export')。 源码 48 个 .ts/.tsx,约 4500 行,其中 core/ + lib/ 这 24 个文件是零 UI 依赖的纯逻辑层,可以在 Node 里直接跑测试------这点后面 AI 章节会吃到红利。

一、四档 AI:从「乱打」到「蒙特卡洛」
四档难度不是一个模型的四个参数,而是四条完全不同的决策路径 ,由 createAI() 一个 switch 分发(core/ai/index.ts)。
简单:纯随机
只在「没打过的格子」里均匀抽一个。95.3 步才能清场。
普通:Hunt / Target + 奇偶格剪枝
经典套路,但有两个细节值得说:
- two-hit 直线延伸:只在出现 ≥2 个共线连续命中时才沿两端延伸,单发命中仍然打四邻------避免过早把搜索方向锁死。
- 奇偶格(parity)剪枝 :这是最关键的一步优化。取当前最小存活舰长度
minSize,只搜满足(x + y) % minSize === 0的格子:
bash
// core/ai/huntTargetAI.ts
const minSize = Math.min(...view.remainingSizes)
// 只搜「能放下最小舰」的稀疏格,直接砍掉一半以上的搜索空间
当奇偶格全被搜完(残局),自动退化为全盘搜索,保证不会漏格。
困难:解析式概率密度图
对每艘存活舰 × 横竖 × 每个合法起点做一遍枚举,把「这一格上放得下航母的概率」累加成一张热力图,取最大值开火。核心是这个加权公式:
bash
// core/ai/densityAI.ts
const coveredHits = cells.reduce((n, idx) => n + (hits.has(idx) ? 1 : 0), 0)
const weight = 1 + coveredHits * opts.targetWeight + (coveredHits >= 2 ? opts.chainWeight : 0)
for (const idx of cells) scores[idx] += weight
HARD_OPTIONS 是 targetWeight: 6, chainWeight: 4。 也就是说:一个能同时盖住 1 个已知命中的摆法,权重是纯猜测的 7 倍;能盖住 2 个以上再加 4 分------这就是「顺着命中簇加权追击」。 同分时随机取一个,避免每局走位完全雷同。
大师:蒙特卡洛采样 60 个「可能的敌方舰队」
不解析计算,而是采样:在「当前已知信息下自洽」的所有可能布局里随机采样 60 份,统计每格出现频率,取最高频的开火。采样约束有三条:
- 舰体不得压在 miss / 已沉格上;
- 每个「命中但未沉」的格子必须被某艘存活舰覆盖;
- 剩余舰船随机合法落位。
单船最多重试 120 次;一旦 60 次采样全失败(残局出现矛盾),自动退回解析密度兜底。
这里有一个反直觉的实测结论 ,我把它原样写在代码注释里了(core/ai/monteCarloAI.ts):
实测(各 400 局自对弈):加直线延伸反而变差 45.7 vs 44.6 ------ 采样本身已经隐含了「沿命中串延伸」的倾向,再硬约束会丢掉转角解,故关闭。
校准数据怎么来的
跑分不是我拍脑袋写的,是 scripts/calibrate.ts 跑自对弈跑出来的(默认 2000 局)。模拟器 core/ai/simulate.ts 是纯函数、无副作用,返回 { difficulty, games, avg, min, max, median },还带一个 shots > 100 的安全阀防止死循环。
| 难度 | 策略 | 平均步数 | 中位数 |
|---|---|---|---|
| 简单 | 纯随机 | 95.3 | 97 |
| 普通 | Hunt/Target + Parity + two-hit | 50.7 | 51 |
| 困难 | 概率密度 + Parity + 命中簇加权 | 44.2 | 44 |
| 大师 | 蒙特卡洛 60 样本 + 密度兜底 | 45.5 | 46 |
自对弈还进了 CI:core/ai/ai.test.ts 里每档跑 60 局,断言「难度越高平均步数越少」。测试用的随机数是自己写的 LCG(s = (s * 1103515245 + 12345) % 2147483648),结果可复现。
顺带一提:大师档比困难档还慢 1.3 步,这是真实的。蒙特卡洛的强项是对抗非均匀的人为布阵,而自对弈里双方都是随机布阵,采样优势发挥不出来。我没有为了「排行榜好看」去调这个数字。
AI 不作弊,是架构保证的
这一点在纯前端项目里其实很难守住------你的船就在同一个 JS 堆里。我的做法是把 AI 的输入 收窄成一个 AIView:
bash
// core/ai/view.ts
export interface AIView {
fired: Set<number> // 已开火格
hits: Set<number> // 命中格
misses: Set<number> // 未命中格
sunkCells: Set<number> // 已沉船占格
remainingSizes: number[] // 未沉舰的长度列表
}
注释里我写得很直白:「未沉舰船的位置绝不可见 ------ 这是纯前端也无法作弊的关键。」 AI 拿不到全量游戏状态,因为它压根不在接口上。

二、整张 10×10 网格只有一条 SVG path
手绘效果全部来自 rough.js:它会对每条线做随机扰动,模拟铅笔的抖动。
坑一:SSR 与 CSR 画得不一样
rough.js 内部用随机数制造抖动。如果直接用默认随机种子,服务端渲染和客户端 hydration 画出来的线不一样 ,页面会闪一下。 解法是固定 seed 的 generator(lib/sketch/rough.ts):
bash
export function createGenerator(seed: number) {
return rough.generator({ options: { seed } })
// 默认风格:roughness 1.2 / bowing 1 / strokeWidth 1.1
}
seed 由棋盘基准值派生,同一元素每次渲染结果恒定:外框 seed + 7、命中标记 seed + x*31 + y*17、未命中圈 seed + x*13 + y*29、弹道 seed + 53、准星 seed + 97。
坑二:性能红线
绝对不能 对 100 个格子逐个生成 rough 图形。网格是一次性构建的单条 path(lib/sketch/board.ts):
bash
// 11 横 + 11 竖,各复描 2 遍 = 44 条线,全部拼成一个 d
for (let pass = 0; pass < 2; pass++) {
for (let i = 0; i <= BOARD_SIZE; i++) {
const pos = geo.offset + i * geo.cell
parts.push(linePath(gen, start, pos, end, pos, style)) // 横线
parts.push(linePath(gen, pos, start, pos, end, style)) // 竖线
}
}
return joinPaths(parts)
「复描两遍」不是为了省事------视觉上正好就是「铅笔描了两遍」的手感。
还有一个 hidden trap:rough.js 的 hachure(排线)填充也是用 stroke 画的 ,所以「击沉涂黑」必须分两次生成------一次 stroke: 'none' 只留排线,一次只画轮廓------才能让扫刷动画只作用在排线上。
点击热区则故意和手绘线解耦 :100 个规整的透明 <rect> 盖在上面,保证点击判定精确,不受抖动影响。
纸张纹理也是零图片 的:一个 <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves={3} /> + feColorMatrix saturate 0,opacity 0.05。
三、描边动画:零动画库,零 JS 测量
「线条被一笔一笔画出来」的效果,用 pathLength 属性归一化就够了------不需要 anime.js,也不需要 getTotalLength():
bash
/* app/globals.css */
@keyframes sketch-draw {
from { stroke-dashoffset: 1 }
to { stroke-dashoffset: 0 }
}
bash
<path d={gridD} pathLength={1} className="sketch-draw" />
pathLength="1" 把路径长度归一化成 1,于是 stroke-dasharray: 1 + dashoffset 从 1 走到 0,就是完整的描边动画。浏览器帮你算长度,JS 一行都不用写。
各类时长:通用描边 400ms,命中 ✗ 两笔各 150ms(第二笔延迟 150ms,看起来才像「先画一撇再画一捺」),弹道 320ms,击沉排线 200ms × 3 alternate,棋盘抖动 140ms。

四、音效是现场合成的,仓库里没有一个音频文件
lib/audio/sfx.ts 用 Web Audio 程序化生成全部 8 个音效。骨架很简单:BufferSource → BiquadFilter → GainNode(包络) → master,白噪声 buffer 手工填充后缓存复用:
bash
data[i] = Math.random() * 2 - 1
完整参数表(可以直接抄去用):
| 音效 | 滤波器 | 频率 | 时长 | 增益 |
|---|---|---|---|---|
| pencil(铅笔沙沙) | highpass | 2600 Hz | 0.07s | 0.10 |
| paper(翻纸) | bandpass Q 0.7 | 1400 → 700 Hz | 0.16s | 0.14 |
| fire(开火) | lowpass | 900 → 220 Hz | 0.28s | 0.16 |
| splash(落水) | highpass | 1800 → 2600 Hz | 0.22s | 0.12 |
| explosion(爆炸) | lowpass | 1300 → 90 Hz | 0.4s | 0.32 |
| ↳ 叠一层 | triangle osc | 150 Hz | 0.34s | 0.16 |
| sunk(击沉) | 3× sine | 440 → 349 → 262 Hz | --- | 0.14 |
| win(胜利) | 3× sine | 523 → 659 → 784 Hz(C-E-G) | --- | 0.14 |
| lose(失败) | 2× sine | 392 → 247 Hz | --- | 0.12 |
关键在频率扫动 :exponentialRampToValueAtTime 让 lowpass 从 1300Hz 扫到 90Hz,听起来才像爆炸而不是白噪声。
三个工程细节:
- 默认静音 (
let muted = true),右上角一键开; - AudioContext 只在用户首次交互后创建,避免浏览器自动播放策略报错;
- 所有节点在
onended后disconnect(),不泄漏。
五、字体:120KB 全本地,一个 CDN 请求都没有
用 scripts/build-font-subset.py 离线子集化(一次性脚本,不参与运行时):
- Caveat (手写英文数字):只保留
U+0030-0039, U+0041-004A, U+0061-006A------正好是坐标 A--J 和数字,5.83 KB。 - 霞鹜文楷 (中文):按
unicode-range切成 23 个分片 ,浏览器按需下载,实际一屏通常只拉 3~5 片。最小的subset-88只有 744 B (就一个U+25cb字符○)。
两个踩过的坑,写在 README 里了:
- 字符集必须先剥掉源码里的中文注释,否则 110KB 直接变 270KB;
unicode-range必须写精确区间,写宽了会显示豆腐块。

六、无障碍:键盘真的能玩
role="grid"+ 100 个role="gridcell",每个格子带aria-label(如"C7,命中");- 方向键移准星并
preventDefault(带边界 clamp),Enter/Space开火; - 键盘光标优先于 鼠标 hover(
const aim = cursor ?? hover,因为鼠标可能停在别处); prefers-reduced-motion下animation: none,但保留手绘线条本身 ------静态的抖动线是视觉风格,不是动效,不能一起干掉。纯装饰的弹道动画才直接display: none;- 焦点用 2px 实线描边,因为手绘虚线根本看不出焦点在哪。
七、localStorage 的三个防御
战绩存在本地,key 是 bs:stats:v1 / bs:history:v1(最多 50 条,每条约 150B,总共不到 10KB)。
① 不能只判断 typeof localStorage。 有些浏览器(隐私模式、部分 WebView)暴露了对象但 setItem() 直接抛错。所以探测方式是写-读-删探针:
bash
// lib/storage/safeStorage.ts
localStorage.setItem('__bs_probe__', '1') // 真的写一次
失败就降级成 MemoryStorage,并给 UI 一个 persistent 字段提示「战绩仅本次有效」。
② 脏数据不崩 UI。 解析失败时把原值备份到 bs:stats:corrupt-${Date.now()} 再重置------不丢证据,也不让界面白屏。
③ 统计口径要防刷。 命中率只算我方开火;热座(双人同屏)模式不计入统计 ;连胜跨难度累计、失败清零;bestTurns 只在胜局里取最小值;没打完退出不写入。
八、关于「无后端」这句话,我想说得准确一点
游戏逻辑是零后端、零数据上报 的:全仓库业务代码里没有 fetch、没有 XHR、没有 WebSocket,战绩只存在浏览器 localStorage。 页面里唯一一个外部请求是百度统计的一个 <script>(就一行 hm.js)。我不打算把它说成「完全无网络请求」------准确的说法是:关掉网络它照样能玩,你的战绩和棋局数据一个字节都不会离开这台设备。
小结:我最后悔和最不后悔的决定
- 最不后悔 :把
core/和lib/做成零 UI 依赖的纯逻辑层。收益是 67 个 Vitest 用例可以在environment: 'node'下跑(连 jsdom 都不需要),AI 自对弈直接进 CI,手绘 path 的构建也能用快照断言。 - 最后悔(早期) :一开始让 AI 直接读游戏状态,写起来爽,但很快就分不清「AI 聪明」和「AI 偷看」。后来重构出
AIView接口才把这件事在架构层面钉死。 - 最出乎意料 :大师档(蒙特卡洛)在随机布阵的自对弈里打不过困难档(解析密度)。我保留了真实数字,没有去调参数让它「看起来更强」。
🚢 传送门:海战棋
打开就能玩,不用下载、不用注册。想被虐就选大师档。