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

一个没有后端、没有数据库、没有账号系统的纯静态网页游戏, 四档 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 + 奇偶格剪枝

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

  1. two-hit 直线延伸:只在出现 ≥2 个共线连续命中时才沿两端延伸,单发命中仍然打四邻------避免过早把搜索方向锁死。
  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 里了:

  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() 直接抛错。所以探测方式是写-读-删探针:

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

🚢 传送门:海战棋

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

相关推荐
CCIE-Yasuo1 小时前
网络安全-学习web开发-基础概念、常用标签
前端·学习
凉介川1 小时前
给 DeepSeek Harness 写插件:按扩展名接管 Windows 右键打开
前端
涛涛ing1 小时前
Pretext之后,2026年的前端正在被“平台”和“AI”同时重写
前端
Csvn2 小时前
Worker 与 OffscreenCanvas
前端
Csvn2 小时前
图片字体
前端
m0_738120722 小时前
JWT 安全从入门到实战:六种典型攻击场景解析
前端·网络·python·安全·网络安全
拖孩2 小时前
AI 帮我写的小程序上线 44 天:36 个人进了收银台,8 个人付钱
前端·后端·微信小程序
java1234_小锋2 小时前
【技术专题】CSS3 网页技术 - CSS3 视觉增强
前端·css·css3
Dovis(誓平步青云)2 小时前
拖动关键帧角色就抽动,动画编辑器怎样补齐中间状态
开发语言·前端·javascript·机器学习·编辑器