说明:这个小游戏是和 AI 编程助手协作开发的,本文也由 AI 协助整理成文。文中代码摘自项目源码(注释改成了中文,有删节),数字来自源码和单元测试的输出。游戏内容纯属虚构,和任何 AI 公司无关;文中没有、游戏里也没有任何"防封技巧"。
前言
《AI 封号模拟器》是一个安卓小游戏:每天一张事件卡,两个选项,赶在"自动审核"把你送走之前把项目做完。前两篇讲了它的规则和怎么给它加玩法。
这一篇讲最近一次大更新里的三件事:
- 给游戏加上像素小人、小房间和成就徽章,安装包里没有多一张图片。
- 一天之内加了盲盒、副本、上课、人物关系四套东西,胜率没有失控。
- 从两种语言加到四种,卡片文件一个字没改。
一、小人是 16 行字符串
没有美术,也不想去找素材(版权说不清)。办法是把像素画直接写成字符串,一个字母一个像素:
kotlin
// 一张脸在不同心情下的样子:只画第 5 行以下有变化的部分。'.' 表示这一格不画
Mood.UNEASY to listOf(
"............b...", // b:一滴汗
".....e....e..b..", // e:眼睛
"................",
".......mm.......", // m:嘴,拿不准的那种
),
一个人不是一张图,而是拼出来的:一个头,加一种发型、一件上衣、一张当下的脸,一层层盖上去。
kotlin
fun person(look: Look, mood: Mood, blink: Boolean, bob: Boolean): List<String> {
val rows = Array(SIZE) { BLANK.toCharArray() }
fun lay(over: List<String>, from: Int) { // 把一层盖到画上,'.' 的地方透过去
over.forEachIndexed { y, row -> row.forEachIndexed { x, c -> if (c != '.') rows[from + y][x] = c } }
}
lay(head, 0)
lay(faces.getValue(mood), 5)
lay(hairs[look.hair], 0)
lay(look.extra, 0) // 眼镜、黑眼圈
lay(tops[look.top], 11)
return rows.map { String(it) }
}
13 个人、6 种表情,如果一张张画是 78 张图;实际只写了 8 种发型、8 种上衣、6 张脸。加一个新人物就是加一行:
kotlin
"mom" to Look(HAIR_LONG, 0xFF4A3328, TOP_TEE, 0xFFE58FB0),
几个顺手得到的好处:
- 改起来像改文字。第一版的眼镜是一条黑杠,看着像打了码,改成灰色只动了一个字母。
- 能测。有一项测试检查每张画都是 16×16、每个字母都有颜色、任何两个人都不长得一样。
- 图标和游戏里是同一份数据。测试把图标上那个小人导出成"坐标 + 颜色",一个脚本读它生成启动器图标和商店图标。改了小人,图标不会忘了改。
被封的样子没有画"难过"。16×16 的脸上,嘴角往下撇两个像素几乎看不出来。最后的做法是在眼睛上盖一条红杠,这条红杠后来成了图标。
二、房间是一个个矩形
小人后来有了一个房间:24×16 个像素,窗、架子、台灯、桌子、屏幕。房间也没有图,是代码里一个矩形一个矩形涂上去的:
kotlin
val lit = view.sanity >= 40 // 心态还够,灯就亮着
val rows = List(HEIGHT) { LongArray(WIDTH) { if (lit) WALL else WALL_DIM } }
fun box(x: Int, y: Int, w: Int, h: Int, color: Long) { /* 把这一块涂成这个颜色 */ }
box(1, 1, 6, 6, FRAME) // 窗框
box(2, 2, 4, 4, if (view.night) NIGHT_SKY else DAY_SKY) // 答完今天的卡,天就黑了
if (view.money >= 55) box(18, 2, 3, 2, GREEN) // 钱够,架子上有一盆绿植
box(20, 9, 3, 2, when { // 屏幕的颜色就是局势
view.banned -> RED
view.nearlyDone -> GREEN
else -> BLUE
})
房间里的每样东西都对应一个数值:昼夜对应"今天答没答",台灯对应心态,架子对应钱包,屏幕对应账号。玩家不用读数字,瞄一眼房间就知道自己什么处境。原来屏幕上只有三条进度条。
三、加玩法之前,先写"这不是捷径"
这次一天里加了四套东西。每一套都可能让游戏变得太容易,所以每一套都先写一条测试,再调数值。
测试的写法很土:写几种"假玩家",让它们各玩两千局,数赢了多少。
kotlin
val plain = (0 until 2_000).count { play(it, careful = true).state.ending in shipped }
val boxing = (0 until 2_000).count { play(it, careful = true, boxes = true).state.ending in shipped }
assertTrue("盲盒最多帮一点忙", boxing < plain + 240 && boxing < 1_800)
assertTrue("也不是坑", boxing > plain - 240)
careful = true 是一个会权衡的假玩家;boxes = true 是在它身上加一个毛病:钱包里有闲钱就去开盲盒。
跑出来的数(两千局里赢的局数):
| 假玩家 | 赢 | 胜率 |
|---|---|---|
| 认真玩 | 1265 | 63% |
| 认真玩,爱开盲盒 | 1360 | 68% |
| 认真玩,肯上课、接好工作 | 1447 | 72% |
| 认真玩,谁肯帮忙就找谁 | 1273 | 64% |
| 随便乱选 | 450 | 23% |
第一版的数值几乎一定偏强。盲盒最初卖 9 块,爱开盲盒的假玩家胜率到了 74%,测试没过,涨到 12 块;课最初 6 块一堂,肯上课的到了 75%,涨到 9 块起。如果没有这条测试,这两个数只能凭感觉。
还有一条测试专门防"写了但永远出不来的卡":每种身份、每个副本各玩一千五百局,96 张卡每一张都必须出现过。
一个小坑:要比较"有某件道具"和"没有"的差别,得让两局的运气完全一样------同一个随机种子开两局。但两边消耗的随机数个数也必须一样,否则后面的牌就对不上了。我第一次让一边去打工(要随机抽一份零活)、另一边去上课(不抽),第二天两边看到的就不是同一张卡。
四、多语言:原句当钥匙
游戏文字原来在数据里是 {zh, en} 两个字段。加第三种语言时没有再加字段,而是换了个思路:中英照旧,其他语言各一张"简体原句 → 译文"的表。
kotlin
fun get(language: String, ai: String): String = when (language) {
CHINESE -> zh
ENGLISH -> en
else -> Translations.of(language)[zh] ?: if (language.startsWith(CHINESE)) zh else en
}.replace("{ai}", ai)
加一种语言 = 加一个表 + 一份界面文字,卡片文件和代码都不动。代价是原句改一个字,译文就对不上,所以有一条测试把游戏里能出现的每一句话过一遍:哪种语言漏了哪句、多了哪句没人用的旧话、占位符少了一个,都会报出来。
繁体中文是用 OpenCC 在电脑上转好再放进安装包的,用的是台湾的说法(软件→軟體,项目→專案),手机上不带转换库。日语是逐句翻的。
译文比原文长,会把界面撑破:日语的"プロジェクト"比"项目进度"宽,进度条左边那一栏被截掉了一个字,最后改成了"進捗"。每加一种语言,至少把主要页面截图看一遍。
五、给截图留一个后门
商店截图要拍"被封了""开出了最稀有的道具"这种画面,靠在模拟器里一路点过去,既慢又碰运气。于是在调试版里留了一个入口:启动时带一个参数,直接摆出准备好的局面。
adb shell am start -n <包名>/.MainActivity --es pose banned
发布版不认这个参数。四种语言、每种十一个画面,一个脚本几分钟拍完,文案改了就重拍。
小结
- 像素画写成字符串、房间写成矩形,好处不只是省了美术:它们能被测试、能被脚本读、改起来像改文字。
- 加系统之前先写"不是捷径"的测试,数值才有地方落脚。
- 多语言用原句当钥匙,再用一条测试守住"别漏翻"。
游戏完全离线,不要任何权限,不收集任何数据。它还在 Google Play 封闭测试里,个人开发者的新账号要凑够 12 人连续测 14 天才能发正式版。有兴趣的朋友可以这样参加:
- 加入测试群组(点"加入群组"就行,不用等审批):https://groups.google.com/g/ai-ban-simulator-testers
- 打开测试链接,点"成为测试人员":https://play.google.com/apps/testing/com.weijiazhao.bansim
- 用同一个账号在 Play 里安装,装着别卸载,留 14 天