给网站加了个小游戏:「口袋幻兽」的实现与取舍

一、为什么要在个人网站里塞一个游戏

博客搭好之后过了段时间,总觉得缺点什么:读者来了、看完、走了,页面全是静的。某天突然想,能不能加点"活"的东西——不用登录、不用服务器,打开就能玩两分钟。于是有了「口袋幻兽」,路径 /game/:喂食、玩耍、搜草丛、投球收服,外加图鉴和事件日志。约束和网站一脉相承:零依赖、纯前端,一个约 1300 行的 game.js,没有任何图片和音频文件。

二、核心思路:一切从种子出发

游戏里最怕的不是难写,是内容不够。手工画一百只幻兽不现实,所以换个思路:幻兽不是数据,是算法的输出。每只幻兽由一颗种子(seed 字符串)完全决定——名字、属性、稀有度、数值、16×16 外形甚至风味文本,全是 hash(seed) 的纯函数,同一个 seed 在任何浏览器里都得到同一只幻兽。

随机数用 FNV-1a 变体做字符串哈希,喂给 mulberry32——十几行、周期够长、速度快,且跨浏览器结果完全一致,这一点对"可复现"是硬要求。生成一只幻兽,就是从这一个 rng 里按固定顺序取数:

js
function hashStr(s) {
  let h = 2166136261 >>> 0; // FNV-1a 偏移基线
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return h >>> 0;
}
const rng = mulberry32(hashStr(seed)); // mulberry32 本体见 game.js

三、16×16 像素外形是怎么画出来的

外形生成是最像手工活的部分,但同样没有一张图是画的。16×16 网格只有几种值:0 空、1 身体、2 阴影、3 眼睛嘴、4 花纹、9 描边。生成分四步:左半拼轮廓(圆头 + 矩形身 + 双腿 + 手臂,尺寸全部由 rng 在小范围内取),镜像到右半,叠随机特征(耳朵/角/触角加权抽一个,尾巴和翅膀各有概率出现,尾巴故意只长在右侧打破对称),最后自动描边。

js
// 镜像铺一行:天然保证左右对称
const mirrorRow = (y, from, to) => {
  for (let c = from; c <= to; c++) { set(c, y, 1); set(15 - c, y, 1); }
};

// 自动描边:所有紧贴身体的空格子涂深色
const outline = [];
for (let y = 0; y < SH; y++)
  for (let x = 0; x < SW; x++)
    if (!g[y][x] && (g[y-1][x] || g[y+1][x] || g[y][x-1] || g[y][x+1]))
      outline.push([x, y]);

描边不是画上去的,是检测出来的:轮廓怎么长,描边就怎么跟,改任何身体参数都不用手动调描边。同一张网格换个属性主题色(草绿、火橙、水深蓝),就是另一只幻兽。

四、关键取舍:存档只存种子

第一个版本把 16×16 网格原样存进 JSON,很快吃了两个亏:体积膨胀,且外形算法一升级,老存档的像素就和新算法脱节。想清楚"同一个 seed 永远得到同一只幻兽"之后豁然开朗——读档时重新算一遍即可。

现在每只幻兽在存档里就是 seed、名字、属性、稀有度、等级、经验、心情等十几个字段,整个存档 JSON 不到 2KB。配套的第二个取舍:数据随机数和像素随机数必须相互独立:

js
const dataRng = mulberry32(hashStr(seed));
const spriteRng = mulberry32(hashStr(seed + '|sprite'));

两条流各走各的。以后重画外形算法、改网格定义,都不影响任何一只幻兽的数值平衡——改美术不用回档,改数值不用重画。

五、稀有度与捕获率

稀有度五档:常见、少见、稀有、史诗、传说,加权随机(权重 52/26/14/6.5/1.5),夜晚(19:00–06:00)对稀有以上权重 ×2.2,给夜猫子玩家一点念想。捕获率是乘起来的公式:

js
function catchChance(c, baitUsed) {
  const base = CATCH_BASE[c.rarity];      // 0.42 ~ 0.04
  let p = base;
  p *= 0.75 + 0.5 * (c.happiness / 100);  // 心情越高越好抓
  p *= 1 + 0.35 * baitUsed;               // 每个诱饵 +35%
  if (isNight()) p *= 1.15;               // 夜晚加成
  return clamp(p, 0.02, 0.92);            // 上下限兜底
}

clamp 到 0.02~0.92 不能省:没有下限,脸再好也可能永远抓不到;没有上限,传说级被诱饵堆死就贬值了。心情、诱饵、夜晚三个旋钮,都是为了让"抓不到"变成"再试一次",而不是"不可能"。

六、玩法循环与离线结算

玩法四个面板:家园(喂食、玩耍小游戏、抚摸点击,涨心情和经验)、探险(搜草丛遭遇野生幻兽、投球捕捉、放诱饵)、图鉴(未收服的显示深色剪影)、日志(localStorage 存最近 120 条,可导出/导入 base64 存档换设备)。离线收益按 lastTick 时间差一次结清:精灵球每 40 秒恢复 1 个(上限 12)、果实每 50 秒 1 个(上限 8),心情随时间衰减。全是整数运算加 clamp,不设定时器轮询——关掉页面睡一晚,回来领球就行。

七、渲染与音效:零素材

渲染同样是零素材:Canvas 逻辑分辨率固定 200×150,CSS 放大加 image-rendering: pixelated,又干净又怀旧。主循环 requestAnimationFrame,白天飘云、夜晚星空、粒子、投球抛物线全是程序绘制,fillRect 一把梭。音效用 WebAudio 振荡器现场合成:一个 beep 函数,频率、时长、波形、音量几个参数,收服成功是两声上行音,失败是一声锯齿波惨叫,可静音,零音频文件。

八、接进静态站点

接入刻意做到无侵入:build.mjs 里新增 gamePage(),只有 page 为 game 时才额外引入 game.css / game.js,其他页面一个字节都不多加载;site.json 导航加一项,sitemap 加 /game/,完事。页面样式只复用站点已有 CSS 变量(--surface/--border/--accent 等),自动跟随全站明暗主题,一行主题代码都没写。

部署还是老办法:dist/ 纯静态,scp 到服务器,一段 nginx 就够:

nginx
location / {
    try_files $uri $uri/ $uri.html =404;
}

游戏页面没有服务端、没有接口,localStorage 就是数据库——某种意义上,这是我写过部署最简单的"游戏后端"。

九、小结

「口袋幻兽」把工程习惯又演练了一遍:用算法代替素材、用种子代替存储、用纯函数代替状态同步。整个游戏一千多行、零依赖,构建一秒,部署一条命令。接下来想加进化链和属性克制,收服到传说级的欢迎来 /game/ 截图交流。