每个精灵都由代码绘制,以及后来加入的 PNG 管线
钢铁浪潮如何在零二进制资源的情况下发布,以及让生成的精灵图集无闪烁地覆盖程序化美术付出了什么。
钢铁浪潮的第一个版本没有任何图片文件。每辆坦克、每座建筑、每发炮弹和每个弹坑都由 TypeScript 在启动时绘制进图集,每个音效都由振荡器合成,每张地图都来自生成器或 ASCII 网格。这个约束是刻意的:纯源码的仓库容易 diff、容易 fork,也不可能在单位外形变化时出现半更新的状态。
程序化图集
调色板是 DB32,许多像素画共享的 32 色集合,外加几种阵营颜色。每个精灵都是一段小程序:车身是带边缘光的圆角矩形,履带是两条条纹柱,炮塔是一个圆加一根炮管。载具只绘制一次朝上的姿态,然后烘焙成 24 个旋转步进,所以渲染器绘制时从不旋转,每个角度都有清晰的像素。阵营颜色是烘焙时的调色板替换,这就是为什么图集里每个单位有四份副本,而它们都不多花一次绘制调用。
建筑的动画也一样:旋转的雷达天线是一个带帧序号参数的绘制例程。特效同样是生成的,连烟雾都是,整套东西在手机上不到一秒就能烘焙完成。
那为什么还要 PNG
代码绘制的精灵一致且廉价。但坦白说它们也有限:一辆 22×26 像素的坦克能有趣的方式就那么多,而图像模型在像素画上已经好到忽视它们成了错误的取舍。于是引擎长出了第二条路径。把一张图集放进 public/sprites/,在 manifest.json 里加一行,它就会在启动时覆盖该键的程序化美术。没有清单或加载失败时,游戏会安静地保留自己的绘制。
有意思的工作是让生成的图集听话,因为模型不会精确重复自己。工厂的第二帧是第一帧的重新绘制,带着几个像素的漂移,每面墙的色调都略有不同。
消灭闪烁
加载器把每张图集都当作不可信的输入:
- 背景从边缘开始泛洪填充去除,最多三种主色调,在美术的深色轮廓处停止。JPEG 也能用。
- 动画帧之间画出的帧边框会被检测并裁掉。
- 稳定化利用剪影中静止的下半身把每一帧对齐到第一帧,这样循环不会抖动。
- 静态冻结:清单声明动画所在的位置(
animRegion,比如帧的顶部 30%),区域之外的每个像素都合成为跨帧中值,让主体在每一帧都逐像素相同。 - 缩放使用确定性的面积平均重采样器,因为浏览器带平滑的
drawImage会泄露微小的跨帧差异,看起来就是闪烁。
旋转部件遵循与程序化美术相同的规则:生成一张朝上的图片,标记为 rotated,引擎烘焙 24 个方向并实时旋转。一辆坦克是两个条目,不带炮塔的车身和单独的炮塔,各有一个枢轴点。
再谈阵营颜色
程序化精灵替换调色板索引;手绘图集没有索引。约定是品红色:阵营部件用 #FF00FF 系列绘制,加载器在加载时按玩家重新上色,亮对亮、暗对暗。百科用完全相同的规则把缩略图涂成蓝色。
结果是一个在完全没有资源时依然能运行的游戏,而有了资源时会好看得多。