上古塔防游戏 RoboDefense 在桌面端实现

71 阅读13分钟

引言

这是一个关于 APK 逆向、代码考古和 AI 辅助开发的故事。

我有一款很喜欢的 Android 塔防游戏——《星际塔防》(Robo Defense),MagicWach 开发,Build 2900。但手机屏幕太小,操作不便,而且我想在 PC 上玩。于是我做了一个决定:把它搬到桌面。

一周后,87 次提交,desktop.jar 43MB,完整可玩。这不是我一个人的工作——我指挥了一个 AI 代理团队:5 个同时分析代码,29 个逐任务施工,还有几个专门做代码审查。我是架构师兼"项目经理",它们是我的工程师。

这篇文章不是什么"从零开始学移植"的教程——它是我在这 72 小时里做的每一个技术决策的记录:怎么从 362 个反编译类中找到 60 个核心游戏类,怎么逐方法对比验证,怎么在保住原版玩法的基础上做 UI 重设计,以及——AI 辅助开发的真实体验。

(先叠个甲..界面做的并不好看,还是复用的原本游戏的资源。欢迎来交流或者Github交流)


一、拆解:把 .apk 变成可读的 Java 代码

工具链

jadx --no-res → /tmp/apk_decompiled/sources/com/magicwach/rdefense/
unzip   → assets/ (85 张精灵图 + 6 个 OGG 音效)
strings_zh.properties → 897 个唯一汉字(字体生成用)

jadx 是这场游戏的第一把钥匙。它把 classes.dex 反编译回 Java——不是完美的反向,但足够可读。362 个类,从 AchievementActivity 到 VectorLookup,原版游戏的所有秘密都摊在面前。

筛出游戏核心

362 个类中,大量是 Android 框架胶水:

  • R.java — Android 资源 ID 生成类,无意义
  • *Activity.java(9 个)— Android 生命周期容器
  • GameInput.java — 触控手势→游戏命令翻译器
  • ConcurrentBackground.java — 后台加载动画
  • SDBackup.java — SD 卡数据迁移
  • 广告/统计库 — 商业 SDK,非游戏逻辑

筛完后真正的游戏核心在 com.magicwach.rdefense 包:60 个纯 Java 类。这个数字成了后续所有工作的基准。

最小可行验证

理论分析够了吗?不。我跑了一遍"最小可行移植"验证:

原始循环 → GameState.nextState() → 调用链 → BattlePhase.update() → 存档加载

验证方法:运行→观察行为→推测逻辑→对比确认。比如 saveScore 的四奖金公式,是在一个完整的游戏胜利-结算-查积分流程里追踪出来的,不是 grep 出来的。


二、对齐:60 类 × 5 域 = 全量差距分析

有了双边源码(原版 60 类 ↔ 当前 63 类),就可以做系统性的差距分析了。这是我的方法论:

方法:分组 + 分级 + 证据

分组:按功能域把 60 类分成 5 组:

域数量内容
① 核心玩法数值18Enemy, GameTower, Bullet, MovementGrid 等
② 游戏状态与事件7GameState, GameEvent, RewardData 等
③ UI 界面与控件249 个 Activity + GameHud, TowerButton 等
④ 平台服务与工具7SoundManager, GameInput, ImageLoader 等
⑤ 存档与持久化3QuickSave, SDBackup, OptionsData

分级:P0(缺失功能)→ P1(行为偏差)→ P2(平台不适用)→ P3(有意差异)

证据标准:每条 P0/P1 附双边源代码行号,如 原版 GameState.java:661 ↔ 当前 GameState.java:225。

验证流程:代理分析→主线程逐条复核原始源码→确认后写入报告。代理结论不直接采信——AI 在广角扫描时很好,在精准判断时容易出错。

执行方式

这是个经典的并行工作流——5 个分组彼此独立,可以同时进行分析:

同时派发 5 个只读分析代理
    ↓
主线程逐条复核 P0/P1(打开双边源码确认)
    ↓
撰写报告(docs/06-APK差距分析报告.md)

最终产出:6 个 P0 缺失 + 41 条 P1 偏差,每条有精确的双边行号证据。

三个最让我震惊的发现

发现 1:得分计算被"优化"成了另一个游戏

原版 enemyDefeated() 里的击杀得分是这样算的:

// 原版:除数 500
int base_score = (maxHealth * scoreMultiplier) / 500;

而当前版本:

// 当前:除数 100 + Math.max 下限
int base_score = Math.max(1, (maxHealth * scoreMultiplier) / 100);

差了 5 倍。但还不止于此——GameRewardCalculator.calculateSimple() 竟然是 难度 × 地图系数 × 100,其中"地图系数"表的命名暴露了它是完全臆造的:

// 这个表里出现了 ICE, LAVA, EXTREME —— 但本游戏根本没有这些地图!
private static final float[] MAP_COEFFICIENTS = {
    1.0f,   // BASIC ✓
    1.2f,   // COURTYARD ✓
    1.3f,   // ICE ?根本不存在
    1.5f,   // LAVA ?根本不存在
    2.0f    // EXTREME ?根本不存在
};

发现 2:溅射半径差了 9.8 倍

这是典型的"硬编码代替公式"问题:

// 当前:硬编码 2500
public static final int SPLASH_RADIUS_SQ = 2500;

// 原版:公式计算
SPLASH_RADIUS_SQ = (GRID_PIXEL_SIZE * GRID_PIXEL_SIZE) / 4;  // = 256

2500 ÷ 256 = 9.8 倍。这意味着溅射效果覆盖了整个屏幕而不是一个小范围——游戏难度完全不同了。

发现 3:成就弹窗的完整代码已经写好,但被遗忘了

// AchievementRenderer.java —— 完整实现了 5 帧动画 + 音效 + 中文文案
public void showAchievement(int type) { ... }

// 但全代码库里,showAchievement() 和 dequeueEarned() 的调用次数是:0

88 项成就,全部静默达成。不是因为代码没写——代码写得很完整。是因为调用链断了,就像一根电线,两头都接好了,中间缺了 1 厘米。


三、修复:87 次提交,29 个 AI 代理并行施工

有了差距清单,接下来是执行。修复分 7 个组、4 份实施计划,按依赖关系分组推进。

结算体系(计划①):最核心的修复

// 恢复后的 saveScore 四奖金公式:
if (new_run_state == GAME_WON) {
    won_bonus     = (score * 20) / 100;               // +20% 胜利奖金
    health_bonus  = (score * health) / 100;            // +1%/HP 生命奖金
    perfect_bonus = (score * 20) / 100;                // 满血 +20% 完美奖金
    money_bonus   = money * difficulty_level * 2;      // 金钱×难度×2
}

同时恢复了 RewardData.gameWon:每通一关,难度 +1。这是原版的核心进度机制——在此之前,玩家永远停留在同一难度。

代理施工流水线

每个代理收到精确的任务规格(包含具体文件路径、代码块、编译命令),完成工作后自己跑编译验证,提交。主线程审查差异后再派下一个任务。

这不是"一个 AI 帮我写代码",这是"一个 AI 团队在并行施工,我审核他们的 PR"。

最意外的一个 Bug

审查代码时发现:成就弹窗修复后仍然不触发。追踪数据流发现——GameState.nextState() 的 dequeueAchievements() 和 GamePlayScreen 的轮询同时消费了成就队列。nextState 先把队列掏空了,等 GamePlayScreen 读取时永远得到 -1。

修复:删掉一行调用。但发现这个竞争条件花了 2 小时的根因追踪。


四、重构:不是搬家,是重新装修

4.1 上帝类的解体

重构前,GamePlayScreen.java 有 931 行,承担四种职责:

  • 渲染(61.5%)— 9 个 draw* 方法
  • 状态管理(19%)— 生命周期/runState 切换
  • 输入处理(5%)— ESC/SPACE/F9 按键检测
  • 游戏循环(0.2%)— gameLoop.tick 集成

任何一个人的大脑都 hold 不住这种文件。把它拆了:

GamePlayScreen.java(931 行)
    ↓
├── GameSceneRenderer.java(446 行)—— 渲染
├── GameInputController.java(67 行)—— 输入
└── GamePlayScreen.java(553 行)—— 协调(纯策略模式)

拆解后每个文件职责单一,GamePlayScreen 缩减到 553 行(-41%),成了纯粹的场景编排者。

4.2 渲染管线的真相

常规优化思路是"合并 begin/end 对"——当前每帧 10-19 对。但真正的问题是:

// LibGdxRenderer.drawRect() 的实现
batch.end();          // ← 每次调用 flush GPU!
shapeRenderer.begin();
shapeRenderer.rect(...);
shapeRenderer.end();
batch.begin();

每个 drawRect 调用都触发一次完整的 GPU flush。 单帧的情况下:HUD 7 次 + 敌人血条 ~20 次 + 塔按钮 ~12 次 = ~40 次 GPU flush/帧。这才是帧率不稳的根因——不是 begin/end 碎片化,而是 drawRect 的 ShapeRenderer 切换。

优化策略因此变了:先消除不必要的 drawRect(脏标记),再合并 begin/end。

4.3 Y 排序:一个只有玩家才能发现的 Bug

玩家报告:升级火焰塔到二级后,近处塔被远处塔盖住了。

这是典型的渲染顺序错误——原版 Android 中所有对象通过 GridObjectOrder 链表按 Y 坐标排序绘制。但当前版本用了插入顺序的 tower_list、enemy_list 分别遍历,完全绕过了 Y 排序。

修复:用 grid_order.getSortedList() 单次遍历,按 classType(1=塔/2=敌人)分发绘制。一行排序修复解决了"穿模"问题。


五、设计:从 Android 原生到指挥中心

5.1 为什么不做复刻

移植 UI 有三个选择:

  • A. 像素复刻:照抄原版 Layout XML → 用 libGDX Scene2D 重建。工作量极大,且桌面用触屏布局本身就是错的。
  • B. 完全原创:不看原版,从零设计。抛弃了"塔防游戏"的视觉遗产,玩家认知成本高。
  • C. 提取模式 + 重设计:保留原版的布局逻辑(主菜单有 7 个按钮垂直排列,HUD 顶部信息条等),用桌面端合适的视觉语言重新表达。

我选了 C。原版的交互模式(触屏拖拽放置塔、双指 Pinch 缩放、Android Options 菜单)在桌面上无等价物——这是根本性的差异,不是"调调颜色"能解决的。

5.2 主菜单:指挥中心

┌──────────────────────────────────┐
│                                  │
│       ★ 星 际 塔 防 ★           │  ← 金色信标标题(唯一暖色)
│       ROBODEFENSE                │  ← 全息青副标题
│     ── 扫描线缓缓划过 ──         │  ← 唯一动画:极细线条每 4 秒扫一次
│                                  │
│    ┌──────────────────┐          │
│    │   开 始 游 戏     │         │  ← 最大的按钮,指挥金边框
│    └──────────────────┘          │
│    ┌─────────┐ ┌─────────┐       │
│    │ 继续游戏  │ │ 载入存档  │     │  ← 钢板蓝,等宽
│    └─────────┘ └─────────┘       │
│                                  │
│  [成就] [奖励] [制作] [设置] [退出]  │ ← 小方块按钮,锈红退出
│                                  │
└──────────────────────────────────┘

签名元素:一条极细的扫描线从标题上方慢慢扫下、淡出、等待、再循环。不是粒子暴,不是闪烁,是克制的单一动画——就像数据中心的全息屏。

5.3 战斗界面:战术覆盖层

设计纪律:暖色是行动(金钱、攻击),冷色是状态(护盾、科技)。

┌──────────────────────────────────────────────┐
│ 草地 L:5  难12  $12,500  杀+15  HP█░░  PTS:850│ ← 紧凑一行页眉
├──────────────────────────────────────────────┤
│                                              │
│               ⚔ 战 场 ⚔                      │
│                                              │
│                                ┌────────┐     │
│                                │  🏰    │     │  ← 纯图标商店
│                                │  🚀    │     │    钢板蓝=可买
│                                │  ❄️    │     │    暗深红=钱不够
│                                └────────┘     │    全息青=选中
├──────────────────────────────────────────────┤
│ [1]机枪 [2]冰塔 [3]火箭  空格暂停  F快进  运行中│ ← 脚注
└──────────────────────────────────────────────┘

HP 呼吸脉冲:当血量 ≤ 3 时,HP 条以 60 帧周期缓慢明暗呼吸——不是闪烁,是克制的节奏感。这是战斗界面的唯一动画。

升级弹窗改为半透明玻璃面板(alpha = 0.82),游戏画面透出。玩家可以在升级时看到战场状态。

5.4 底层基础设施:标题栏和返回按钮的"去重"

重构过程中发现:7 个 Screen 的标题栏是逐字复制粘贴——相同的 3 行 drawRect/drawText,仅标题文字不同。6 个 Screen 的返回按钮也是如此。这是典型的"复制即重用"模式。

提取到 GameScreen 基类后:

// 之前:每个 Screen 重复 3 行
renderer.drawRect(0, sh - 34, sw, 34, ...);
renderer.drawRect(0, sh - 1, sw, 2, ...);
renderer.drawText("关卡选择", 16, sh - 20, ...);

// 之后:一行
drawTitleBar("关卡选择");

消除了 17 处硬编码颜色值——这是 DRY 原则最朴素的胜利。


六、AI 协作的真实体验

代理靠谱,但别信

5 个只读分析代理产出了 52KB 的差距分析草稿——速度快得吓人。但主线程复核时发现了:

  • 4 条误报("bullet_pool 未重置"——原版同样不重置)
  • 7 条分级错误(动画缺失应该是 P3 而非 P0)

AI 在广度扫描上好得惊人——它们能同时阅读 9 个 Activity 类和 11 个 UI 组件类,交叉比较,给出完整的映射表。但在精准判断时会犯错——比如把一个"nice to have"的动画标记为"破坏性缺失"。

所以工作流是:代理发现→主线程复核→确认写入。代理是雷达,人类是炮手。

脏标记是诱惑,也是陷阱

有一个看起来非常正确的优化:塔按钮的背景在大多数帧里是完全不变的。给它加一个脏标记——只有当 money 或 activeTowerId 变化时才重绘。5 行代码,最高 ROI。

然后在运行测试时出现了这个 bug:塔按钮的图标和文字只在杀敌得分时闪现了一下,其他时间全部消失。 因为 money 只有杀敌/造塔时才变,其他时间脏标记 skip 了整帧的绘制。

教训:渲染器和游戏逻辑的职责边界必须分明。脏标记对游戏数据(money)敏感,但 UI 元素需要无条件每帧绘制。这是性能优化和 UI 正确性之间的冲突——UI 正确性赢了。

87 次提交的节奏感

这不是 87 个孤立的修改——它是 9 个计划文件驱动的、5 个阶段的结构化工作:

APK 差距分析(60 类全覆盖)
    ↓
计划① 结算/数值/健壮性(9 任务)
    ↓
计划② 成就/存档/事件/HUD(6 任务)
    ↓
计划③ 混合器/UI/星空(7 任务)
    ↓
计划④ 微调/死代码(4 任务)
    ↓
UI 双报告评审(设计 + 架构)
    ↓
三阶段重构(提取→架构→管线)
    ↓
设计落地(主菜单 + 战斗界面)
    ↓
审查闭环 + 发布

每次提交都有明确的"为什么"(commit message 带有双语描述和原版代码引用)。这不是"快糙猛"的移植——这是考古学式的软件工程。


七、你可以跑起来了

# 下载 JAR 后直接运行
java -jar desktop.jar

# JDK 21+ 需要额外参数
java --enable-native-access=ALL-UNNAMED -jar desktop.jar

推荐 JDK 8-17。JDK 21+ 退出时可能报 Lwjgl3Cursor 提示(libGDX 1.12.1 已知兼容性问题),不影响游戏运行。


后记:为什么这件事值得做

塔防游戏是一个微缩的软件工程世界。它包含:状态机(6 种游戏状态)→ 事件系统(12 种事件类型)→ 对象池(敌人/塔/子弹/事件四个链表池)→ 路径算法(BFS 寻路 + 连通性验证)→ 存档系统(SQLite + 魔数版本校验)→ UI 系统(9 个 Screen + 渲染管线)。

把这个小世界从 Android 搬到桌面,是理解"软件移植"的本质——不是"把代码拷贝过来能编译",而是搞清楚每一行代码为什么这样写,然后决定在目标平台上是否保持、如何保持。

还有一个技术人的"私心":我想验证一个假设——一个人 + AI 代理团队,能否在合理时间内完成一个中等规模产品的完整重构? 87 次提交,6 P0 全修,40+ 条 P1 修正,UI 全线重设计——答案是能。


GitHub:github.com/turnarond/R…

相关文档(在 docs/ 下):

  • 06-APK差距分析报告.md — 60 类全覆盖差距分析
  • 07-UI设计交互评审报告.md — 设计一致性与交互可用性评审
  • 08-UI渲染架构评审报告.md — 渲染性能与代码架构评审

协议:MIT · 仅供学习交流 · 原游戏版权归 MagicWach 所有