在小黑盒里做一个小游戏:盒盒大作战的玩法、适配与开发记录
最近除了折腾抖音聊天机器人,我还做了一个小黑盒工坊小程序,名字叫 《盒盒大作战》。
玩法很容易理解:从一颗小球开始,吃地图上的食物长大,再吃掉比自己小的对手。想主动进攻可以分身,想和场上的绿刺互动可以吐孢子。局外用对局获得的盒币抽皮肤,再回去开下一局。
听起来是个不复杂的休闲游戏,但真正做起来,花时间最多的往往是那些不显眼的部分:手机画面怎么横过来,手指坐标怎么对应,排行榜昵称为什么没更新,分身为什么弹不远,还有被吃掉以后究竟要重开还是复活。
这篇以当前开发版本的代码为准,记录已经做进项目的内容和一些取舍,不把设计文档中的计划当成已经上线的功能。
一、先做单机对局,再接云端排行榜
这个项目目前是玩家与 AI 的单机对局。场上的其他球由本地规则控制,不是真人匹配;云端排行榜负责玩家之间的成绩比较,也不等于已经做了实时联机。
一开始我想要的节奏是碎片时间玩一两局,目标是几分钟内就有明显的成长、进攻和被追赶体验。不过,这不是严格到点就结束的游戏:实现里还有十分钟软时限的继续选择,玩家也能在被吃后选择复活。
核心循环分成两层:
- 局内:吃食物、长大、分身进攻、绕开危险,争取更高体积和击杀。
- 局外:结算盒币、收集皮肤和孢子外观、查看纪录,再开一局。
先把这两层做完整,比一开始同时堆联机、房间和匹配更方便观察玩法。当前也没有把联机写成已经完成的能力。
二、Canvas 画游戏,DOM 做菜单
技术上用了 JavaScript、Vite 和 Canvas 2D,平台接入通过 @heybox/hb-sdk,项目当前安装的 SDK 版本是 0.8.4。
我把主要代码分开了:
1 | src/game/ 世界状态、移动、碰撞、AI、复活等逻辑 |
逻辑层尽量不依赖页面元素,因此可以在没有浏览器的情况下模拟一段对局,检查质量变化、分身、吞噬和复活。菜单和榜单则用 DOM,方便排版和点击。
模拟使用固定的 30 Hz 步长,渲染在两次逻辑状态间做插值。这样不用让移动速度跟着屏幕刷新率变化,也减少了逻辑更新和画面刷新节奏不一致时的抖动。
HUD 也没有每帧重建,而是降低更新频率。时间、盒币和排行榜数字不需要每一帧都重新生成页面,在手机 WebView 里省掉这部分工作更实际。
三、成长和分身的手感,比规则说明难调
“大球吃小球”只有一句话,但真正影响操作的是很多连续变化的数值:体型越大移动越慢,分身会前冲,母球会受到后坐力,分出去以后还需要等待合体。
这次调过两处很明显的体验问题。
第一处是小球视野太近。开局时周围看不远,很难提前发现对手,所以给小球阶段加了随舞台高度变化的视野约束,成长过程中再逐渐拉远。新一局首帧也直接使用正确镜头,避免开始后才突然缩放。
第二处是分身弹射距离不够。只提高基础初速还不够,后期大球会很快碰到原有速度上限,所以又把分身爆发和普通移动的速度上限分开,同时降低母球反向冲量,让进攻距离增加时不至于把母球推得太远。
这些调整有隔离模拟作比较,也有对应的测试脚本。但数值通过检查,只能说明行为符合预期;手感是否舒服,还要放到真机上操作。
四、让 AI 有不同的打法
这里的 AI 是游戏规则和策略判断,没有调用大语言模型,也不会每次移动都产生模型 Token 开销。
普通对手分成偏休闲、普通和猎手等类型,组合会参考已有对局的隐藏统计调整。它们并不是完全相同的一群球:有些主要在场上进食,有些会形成更直接的追赶威胁。
最近还加了一种红名困难 AI,叫 “刺术师”。它不只是更积极地追着玩家跑,还会尝试从绿刺后方吐孢子推刺、养刺、借刺掩护撤离,或者摆诱饵后藏起来,等目标接近再分身扑出。
每局只保留一个刺术师名额,它被吃后以小球重新出生。它的行动仍然经过移动、吐球、分身和吞噬逻辑,推刺也要支付实际质量成本,而不是直接把攻击结果写进游戏状态。
需要说明的是,现有普通 AI 和猎手有辅助成长曲线,用来控制单机对局节奏。这套难度设计并不等于所有对手与玩家拥有完全相同的成长条件;“没有凭空执行攻击”与“没有辅助成长”是两个不同的问题。
五、皮肤扭蛋:抽到的就从池子里移走
局外商店做了五台扭蛋机:盒娘小表情、大头照 1.0、大头照 2.0,以及两组 CUBE 孢子外观。球体皮肤和吐出的孢子外观分别装备,地图自然生成的食物仍然是彩色圆点。
我选择了一个比较直接的抽取规则:只从这台机器尚未拥有的外观里均匀抽取,已经拥有的不会再出。
核心逻辑可以概括成:
1 | const remaining = pool.filter(item => !owned.includes(item.id)); |
实际实现还要检查盒币、处理免费教学抽取、保存结果,以及在集齐后禁用按钮。抽到新外观先进入收藏,玩家在结果弹窗里选择收下或立即装备,不会强制替换当前装扮。
这个规则也意味着,同一台机器有多少款,成功抽取多少次就能集齐。旧设计文档里仍有一张按“允许重复抽取”估算收集成本的表,它与当前代码不一致,不能拿来说明现在的抽取概率。
外观贴图在加载失败或尚未完成时有绘制兜底,避免整个球直接消失。盒币是项目内通过对局获得的游戏币;本文不把它与小黑盒账户里的平台货币混为一谈。
六、横屏适配不是旋转一下 CSS 就结束
这个游戏更适合手机横握,但宿主是否能切到原生横屏,要看客户端是否支持对应能力。官方文档也将窗口方向控制标为实验能力,布局仍应依据实际窗口和安全区适配。窗口方向与全屏文档
项目优先请求宿主横屏,不支持时把整个游戏舞台旋转 90 度来兜底。
画面旋转以后,至少还有三件事要一起处理:
- 舞台宽高要交换,Canvas 的绘制尺寸也要对应。
- 触摸和鼠标坐标要做逆变换,否则手指往右,球可能往另一个方向走。
- 刘海、系统栏等安全区要映射到舞台坐标,不能直接照搬竖屏页面的边距。
所以项目里统一维护舞台尺寸和坐标转换,菜单、摇杆和画布都按同一套方向状态工作。全屏成功也不能假定系统手势条一定消失,重要按钮仍要留在安全区域里。
七、分享截图,又遇到了横竖屏问题
结算和排行榜都有分享入口。我希望分享的是当前成绩画面,但在部分 Android 宿主里,原生横屏截图曾出现裁剪越界问题。
目前的处理是在截图期间临时恢复竖屏窗口,把横版游戏画面按比例放进这个窗口,等待宿主与页面尺寸稳定,再计算合法的截图区域。完成后恢复布局;方向恢复失败时,仍保留 CSS 横屏兜底。
这一步不能只取一次尺寸就立刻截图。切换方向后,宿主窗口、页面布局和 Canvas 重绘不一定同时完成,过早计算的裁剪框可能已经过期。
分享操作也做了进行中状态保护,避免连续点击启动多条截图流程。无法截图时则尝试退回文字分享入口。平台分享会进入用户操作流程,不代表小程序可以静默替用户发社区帖子。官方社区分享说明
八、排行榜和登录,要分清身份与展示资料
项目有最高体积、击杀和最快霸屏三类榜单。云端不可用或以游客体验时,先保留本地纪录;用户主动绑定本次游戏身份后,再认领游客成绩并尝试同步个人最佳成绩。
这里有两个容易混淆的概念。
平台可以返回用户资料,不代表游戏已经建立了自己的登录会话。 当前实现要求用户在本次运行中主动点击登录游戏,再绑定有效身份;这个状态放在内存,退出后不会自动恢复。项目没有因此建立开发者服务端会话或保存服务器登录 Token。平台的资料读取和服务端授权码登录本来也是不同路径。官方身份与登录说明
另一个问题是榜单上的昵称、头像。它们放在成绩记录的展示信息里,但同分或更差的成绩提交不会覆盖已有的分数和展示信息。接口返回成功,并不表示这次提交的新昵称已经保存。官方榜单提交规则
因此当前页面可以用新读取的资料更新自己的显示,其他玩家看到的旧云端成绩仍可能带着旧资料,直到有更优成绩更新。项目没有通过伪造更高分或删掉旧成绩来强行刷新昵称,因为那会改变真实成绩和排序。
身份归并也不能靠昵称,同名、改名都很常见。当前实现用平台提供的小程序内身份标识关联纪录;已经属于一个账号的成绩,不会因为切换账号就复制给另一个账号。
九、被吃后复活,保留的是同一局
最近增加了对局内复活:玩家被吃掉后暂停当前对局,有七秒选择立即复活或者结束。没有选择就自动结算,切到后台再回来时也按实际截止时间判断。
复活次数目前不限,但只重建玩家的小球和出生状态。地图、AI 的体型和位置、已有分身、局时,以及本局累计成绩和收益都继续保留,最终结束才结算一次。
如果复活时直接重建整个世界,就会变成重新开局;如果每次死亡都结算奖励,又会出现一局重复发放。因此复活被单独做成状态流程,而不是在“再来一局”按钮上改个名字。
无限复活也改变了成绩的含义:它记录的是这局累计表现,不是一次生命的生存表现。后续如果增加不同模式或调整排行榜规则,还需要重新考虑这一点,目前不把它描述成已经解决了所有竞技公平性问题。
十、项目能构建,和玩家能顺利玩完一局之间还有距离
这套代码有无头玩法检查,以及分身调参、困难 AI、复活、身份、榜单分页和截图流程等测试脚本。它们方便固定条件复现问题,也避免修一个功能时破坏另一个功能。
不过,模拟和浏览器预览不能覆盖所有宿主行为。登录层曾经在 DOM 中存在,却被更高层级的主页盖住;横屏在浏览器里正常,到了手机宿主又可能采用另一种窗口尺寸;截图调用能开始,也不意味着分享出来的图一定完整。
我会把代码检查、页面布局和真机体验当成不同层面的验证。当前文章介绍的是本地开发版本的功能,不以配置里声明的平台列表来承诺所有设备都已经验收,也不把上传提审当成已经公开上架。
接下来仍需要继续观察对局节奏、成长速度、复活后的收益,以及不同手机上的操作体验。能打开只是第一步,玩家能看清场面、理解按钮,并顺利玩到结算,才算把这个小游戏做得更完整。

