离线小游戏做“每日挑战”:日期当随机种子,整局存成 JSON,再加一个“我不是机器人”

说明:这个小游戏是和 AI 编程助手协作开发的,本文也由 AI 协助整理成文。文中代码摘自项目源码(注释改成了中文,有删节),数字来自单元测试的输出。游戏内容纯属虚构,和任何 AI 公司无关。

前言

《AI 封号模拟器》是一个安卓卡牌小游戏:每天一张事件卡,两个选项,赶在"自动审核"把你送走之前把项目做完。前三篇讲了规则、怎么加玩法不破坏平衡、怎么用字符串画像素画。

这一篇的目标只有一个:让人每天都有理由打开它。做了三件事:

  1. 存档:随时关掉,回来接着玩。
  2. 今日挑战:每天一副固定的牌,同一天所有人都一样------而游戏完全离线,没有服务器。
  3. 人机验证:一个讽刺 AI 审核的游戏,反过来让玩家证明自己不是 AI。

一、最该先修的不是玩法,是存档

把代码从头看了一遍,发现最大的问题根本不是玩法:一局游戏只活在内存里。

状态放在 ViewModel 里,转屏不会丢,但只要切出去回个消息、被系统回收了进程,回来就是首页;点了"退出"也一样,首页连个"继续"都没有。一个每天掏出来玩几分钟的游戏,这比少几张卡伤得多。

存什么

游戏状态是一个不可变的 data class(天数、数值、手里的道具、人物好感、记下的账......),存它本身不难。容易漏的是状态之外的东西:后面要发的牌和新闻的顺序。

规则是"每张卡出现一遍以后才会重复",所以 Game 里有一个洗好的牌堆。如果只存状态,读回来以后重新洗牌,玩家就可能马上又遇到刚见过的卡。所以存档里连牌堆一起存:

kotlin 复制代码
fun save(): String {
    val s = state
    val o = JSONObject()
        .put("phase", s.phase.name).put("day", s.day).put("risk", s.risk)
        .put("card", s.card.id)                                   // 卡只存 id,读的时候去卡表里找
        .put("items", JSONArray(s.items.map { it.id }))
        .put("trail", JSONArray(s.trail.map { /* 记下的每一笔账 */ }))
        // ......其余字段
    return JSONObject()
        .put("v", SAVE_VERSION)                                   // 格式变了就换版本号,旧存档不硬读
        .put("role", role?.id).put("scene", scene?.id)
        .put("deck", JSONArray(pile.map { it.id }))               // 还没发的牌,按顺序
        .put("headlines", JSONArray(headlines.map { it.id }))     // 还没出的新闻,按顺序
        .put("state", o)
        .toString()
}

读的时候,任何一步对不上(版本号不同、某张卡在新版本里被删了)就整个放弃、什么都不改,而不是读出一个半残的局面:

kotlin 复制代码
fun load(text: String, roles: List<Role>, scenes: List<Scene>): Boolean {
    val loaded = runCatching { /* 解析每个字段;找不到的卡、道具会抛异常 */ }.getOrNull() ?: return false
    // 走到这里才真正替换
    pile = loaded.deck.toMutableList()
    state = loaded.state
    return true
}

一个小坑:引用相等

成就"好孩子"的判断写的是"刚才选的是不是这张卡的第一个答案",用的是 ===(同一个对象)。存档把答案序列化成 JSON,读回来是一个内容相同的新对象 ,=== 就不成立了。解决办法是读回来以后对一下,如果和卡上的答案内容一样,就换回卡上的那个对象。

怎么测

随机挑 300 局,每局玩到一个随机的时刻存下来,再读进一个全新的 Game:状态、后面的牌、后面的新闻必须一模一样,而且再存一次得到的字符串也一样。

kotlin 复制代码
val saved = game.save()
val back = Game(cards, Random(99), news, gigs, items = items, jobs = jobs, people = people)
assertTrue(back.load(saved, roles, scenes))
assertEquals(game.state, back.state)
assertEquals(game.deck, back.deck)
assertEquals(saved, back.save())

界面上就简单了:每走一步存一次(几 KB 的字符串写进 SharedPreferences),首页显示"继续 · 第 N 天"。普通局和今日挑战各存一份,互不覆盖。

二、今日挑战:日期当随机种子

想要"同一天所有人同一副牌",第一反应是要个服务器下发。其实不用:洗牌、新闻、二手市场的行情,全都来自同一个随机数生成器。只要种子一样,生成的序列就一样。

kotlin 复制代码
/**
 * 新开一局。给了 seed 就用它发牌:同一个种子、同样的选法,
 * 牌、新闻、行情的顺序都一样(今日挑战用)。
 */
fun restart(role: Role?, gifts: List<Item>, scene: Scene?, seed: Long? = null) {
    random = seed?.let { Random(it) } ?: dice      // 不给种子就用平时的随机数
    // ......
}

种子就是日期:2026 年 10 月 15 日是 20261015。身份也按日期轮换(roles[key % roles.size]),今天大家都是同一个人。

测试很直接:两个用不同随机数创建的游戏,用同一个种子重开,必须发出同样的局面;而且用同样的方式回答,之后每一天都一样:

kotlin 复制代码
val one = Game(cards, Random(1), news, gigs).apply { restart(roles[0], emptyList(), scenes[0], seed) }
val two = Game(cards, Random(2), news, gigs).apply { restart(roles[0], emptyList(), scenes[0], seed) }
assertEquals(one.state, two.state)
repeat(8) {
    if (one.state.phase == Phase.CARD) { one.choose(true); two.choose(true) }
    if (one.state.phase == Phase.RESULT) { one.nextDay(); two.nextDay() }
    assertEquals(one.state, two.state)
}

当然,玩家一旦做了不同的选择(比如今天去打工),用掉的随机数个数不同,后面的牌就分岔了------这正是"同一副牌、不同的打法"。

一天只算一次成绩;连续打卡数的是"到今天(今天还没玩就到昨天)为止,连着有成绩的天数":

kotlin 复制代码
val streak: Int get() {
    var date = LocalDate.now()
    if (dailyResult(key(date)) == null) date = date.minusDays(1)
    var days = 0
    while (dailyResult(key(date)) != null) { days++; date = date.minusDays(1) }
    return days
}

打完可以分享,发出去的只是一段文字(系统自带的分享面板),游戏本身仍然不联网。

三、"我不是机器人"

这个游戏讽刺的是"看不见的自动审核"。这次反过来:情况不妙的早上,系统会先弹出验证,要你证明自己是个人。

  • 第 4 天以后、而且"直觉"已经发慌(风控 35 以上)才会出现:18%,风控 65 以上时 30%,不会连着两天。
  • 12 秒内在九宫格里选出所有的猫(或红绿灯、公交车、咖啡);有时是"选出所有不是机器人的图片";偶尔是"选出所有由 AI 生成的图片"------答案是全选,因为这些图确实全是电脑画的。
  • 过了,风控 −6;没过(包括超时),风控 +8、心态 −3,还会记进结局页"审核系统记的账"。

题目是这样出的:

kotlin 复制代码
fun make(random: Random): Puzzle {
    val roll = random.nextInt(100)
    return when {
        roll < 12 -> {                                   // 一道陷阱题:全是 AI 画的
            val tiles = List(9) { Kind.entries.random(random) }
            Puzzle(Ask.ALL_AI, null, tiles, tiles.indices.toSet())
        }
        roll < 34 -> { /* 人和机器人混在一起,选出人 */ }
        else -> {                                        // 选出 3~4 张猫(或别的)
            val kind = askable.random(random)
            val count = random.nextInt(3, 5)
            val tiles = (List(count) { kind } + List(9 - count) { (Kind.entries - kind).random(random) }).shuffled(random)
            Puzzle(Ask.PICK, kind, tiles, tiles.indices.filter { tiles[it] == kind }.toSet())
        }
    }
}

格子里的图和成就徽章一个画法:8×8,一个字母一个像素。

kotlin 复制代码
// 一只橘猫的脸:o 橘色,k 黑色,p 粉色,. 透明
Kind.CAT to listOf("o......o", "oo....oo", "oooooooo", "ookookoo", "oooooooo", "oooppooo", ".oooooo.", "..oooo.."),

一个只有截图才发现的问题:公交车的轮子一开始是黑色的,画在深色格子上几乎看不见。

它会不会破坏平衡

照旧让假玩家去试。每组 2000 局"认真玩":

假玩家 做完项目
没有人机验证 1265
有验证,85% 能答对 1258
有验证,只有 40% 答对 1248

几乎没有差别:它是一点手上的事,不是捷径,也不是陷阱。另一条测试在 600 局里检查了冒出来的全部 775 次验证:第 4 天以前不出现、不连着两天、只在风控 35 以上出现。

顺带一提:给假玩家加上"遇到验证就答一下"的逻辑时,要让它不开验证的老测试完全不受影响。做法是验证由一个开关控制、默认关着,而且判断"今天有没有验证"的那一次掷骰子写在开关后面------开关关着就连骰子都不掷,老测试用掉的随机数一个不多。

四、拖动卡片看涨跌,但看不见风控

卡片可以左右滑来选。这次拖过一半时,三条数值旁边会出现 ▲/▼(变化 10 以上是两个箭头),只告诉方向,不告诉多少。

风控(被封的概率)依旧完全看不见,只有一句"直觉"------这是游戏的核心,不能因为加交互就把它露出来。

小结

  • 先查最伤留存的地方:对一个碎片时间的游戏,"切出去就丢"比少几张卡严重得多。存档要连"后面要发的牌"一起存。
  • 离线也能做"全世界同一副牌":所有随机都走同一个生成器,日期当种子。
  • 加交互之前先写"不是捷径"的测试;新开关默认关着,不让它改变老测试用掉的随机数。

游戏还在 Google Play 封闭测试里,个人开发者的新账号要凑够 12 人连续测 14 天才能正式上架。愿意帮忙的朋友:

  1. 加入测试群组(点"加入群组"就行,不用等审批):https://groups.google.com/g/ai-ban-simulator-testers
  2. 打开测试链接,点"成为测试人员":https://play.google.com/apps/testing/com.weijiazhao.bansim
  3. 用同一个账号在 Play 里安装,装着别卸载

它完全离线,不要任何权限,不收集任何数据。

相关推荐
用户2788607285671 小时前
战争雷霆换区记录:字段里的「未知」不能写成正常
网络协议·游戏开发
一铭111992 小时前
海豹办公 1.9.15 跨端办公软件实践 文档兼容 Access 数据库与移动 WebView 排错
android·数据库·electron·开源软件·access
geats人山人海3 小时前
java 7异常
android·java·开发语言
2601_962071004 小时前
Kimi K3 vs DeepSeek V4-Pro实测对比:2026年同一入口十道题,耗时与token消耗差在哪
android·java·数据库
右手吉他5 小时前
高通平台IA APN问题分析之二
android·物联网
夜雪一千5 小时前
MySQL 自增 ID 的底层原理与坑点
android·mysql·adb
不浪费一个字节6 小时前
一个纯 Python 实现的 APK 解析与设备操控工具箱
android
itmigrate6 小时前
PHP 导出大 CSV 时内存溢出的一次排查
android·开发语言·php
hai_android6 小时前
LiveEventBusCore 跨 Activity 通讯源码解析
android·kotlin·android jetpack