用 4500 行 TypeScript 复刻了童年方格纸上的海战棋

文章来源声明: 原文作者:用户09367175541; 来源站点:掘金; 原文链接:https://juejin.cn/post/7702895830291054619; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

对想练纯前端工程化的开发者很有参考价值:AI 分级、SVG 性能、程序化音效与无障碍细节都能直接借鉴。

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

🔗 在线玩:海战棋

首页:三个按钮,打开即玩


先说清楚它是什么

10×10 方格纸海战棋(Battleship),双盘对战:上面是敌方海域,下面是我的海域。 五艘船共 17 格(航母 5、战列舰 4、巡洋舰 3、潜艇 3、驱逐舰 2), 轮流报坐标开火,谁先打沉对方全部舰船谁赢。 击沉时必须报出舰种——这是原版最妙的规则,对手据此才能缩小搜索范围。

技术栈非常克制:

// 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 + 奇偶格剪枝

经典套路,但有两个细节值得说:

  1. two-hit 直线延伸:只在出现 ≥2 个共线连续命中时才沿两端延伸,单发命中仍然打四邻——避免过早把搜索方向锁死。
  2. 奇偶格(parity)剪枝:这是最关键的一步优化。取当前最小存活舰长度 minSize,只搜满足 (x + y) % minSize === 0 的格子:
// core/ai/huntTargetAI.ts
const minSize = Math.min(...view.remainingSizes)
// 只搜「能放下最小舰」的稀疏格,直接砍掉一半以上的搜索空间

当奇偶格全被搜完(残局),自动退化为全盘搜索,保证不会漏格。

困难:解析式概率密度图

对每艘存活舰 × 横竖 × 每个合法起点做一遍枚举,把「这一格上放得下航母的概率」累加成一张热力图,取最大值开火。核心是这个加权公式:

// 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.397
普通Hunt/Target + Parity + two-hit50.751
困难概率密度 + Parity + 命中簇加权**44.2**44
大师蒙特卡洛 60 样本 + 密度兜底45.546

自对弈还进了 CI:core/ai/ai.test.ts 里每档跑 60 局,断言「难度越高平均步数越少」。测试用的随机数是自己写的 LCG(s = (s * 1103515245 + 12345) % 2147483648),结果可复现。

顺带一提:大师档比困难档还慢 1.3 步,这是真实的。蒙特卡洛的强项是对抗非均匀的人为布阵,而自对弈里双方都是随机布阵,采样优势发挥不出来。我没有为了「排行榜好看」去调这个数字。

AI 不作弊,是架构保证的

这一点在纯前端项目里其实很难守住——你的船就在同一个 JS 堆里。我的做法是把 AI 的输入收窄成一个 AIView:

// 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):

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):

// 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():

/* app/globals.css */
@keyframes sketch-draw {
  from { stroke-dashoffset: 1 }
  to   { stroke-dashoffset: 0 }
}

<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 手工填充后缓存复用:

data[i] = Math.random() * 2 - 1

完整参数表(可以直接抄去用):

音效滤波器频率时长增益
pencil(铅笔沙沙)highpass2600 Hz0.07s0.10
paper(翻纸)bandpass Q 0.71400 → 700 Hz0.16s0.14
fire(开火)lowpass900 → 220 Hz0.28s0.16
splash(落水)highpass1800 → 2600 Hz0.22s0.12
explosion(爆炸)lowpass1300 → 90 Hz0.4s0.32
↳ 叠一层triangle osc150 Hz0.34s0.16
sunk(击沉)3× sine440 → 349 → 262 Hz—0.14
win(胜利)3× sine523 → 659 → 784 Hz(C-E-G)—0.14
lose(失败)2× sine392 → 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 里了:

  1. 字符集必须先剥掉源码里的中文注释,否则 110KB 直接变 270KB;
  2. 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() 直接抛错。所以探测方式是写-读-删探针:

// 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 接口才把这件事在架构层面钉死。
  • 最出乎意料:大师档(蒙特卡洛)在随机布阵的自对弈里打不过困难档(解析密度)。我保留了真实数字,没有去调参数让它「看起来更强」。

🚢 传送门:海战棋

打开就能玩,不用下载、不用注册。想被虐就选大师档。