花了一段时间啃Java,语法、集合、常用API都过了一遍,多线程面试题也能背出几道了。但心里清楚,这些东西停留在“我能看懂”,远没到“我能写出来”的水平。于是我给自己定了一个小目标:用纯Java从零写一个坦克大战,不带任何游戏引擎。这个v1.0版本,核心练的就是两件事——多线程和OOP。之所以选坦克大战,是因为它看起来简单,实则处处是坑:多个坦克同时移动、子弹异步飞行、画面每隔几十毫秒刷新一次,天然逼着你去处理并发和类设计的问题。这篇文章我就把整个从零开发的过程记录下来,包括我的错误设计、调试过程、最后怎么收敛到v1.0,希望能给同样在学Java、想做点实战项目的人一个参考。
1. 为什么拿坦克大战练手:多线程和OOP最容易“听懂却写不出”
很多学Java的人跟我一样,经历过一个阶段:继承多态能说出个大概,能自己写个学生管理系统,但一旦面对需要几十个类协作、还需要并发处理的项目,就开始迷茫。坦克大战正好卡在这个痛点上,它比控制台项目复杂,又比企业级项目简单,是练手的好靶子。
1.1 为什么我放弃了CRUD管理系统,选择游戏Demo
我不否认学生管理系统、博客系统这些项目有学习价值,但它们有一个共同问题:多线程和OOP往往被框架包住了。你写Spring Boot CRUD,线程池是框架起的,事务是框架管的,你根本感知不到并发压力。写坦克大战不一样,线程是你自己开的,数据是你自己共享的,出了问题你必须一行一行查,这才是对多线程真正的理解。
我见过太多人刷Java面试八股文,synchronized、volatile、线程池背得滚瓜烂熟,真让他写一个“两个线程交替打印”之外的项目就卡住了。游戏Demo恰好能打破这种状态。它的需求非常明确:画布上有坦克、子弹、墙壁,它们要按照一定规则运动。反馈可视化,写对了画面流畅,写错了立刻抽风,调试起来比隐晦的日志更直观。
另一个原因,是游戏天然包含了“状态”和“行为”两个维度,这对OOP设计是很好的训练。坦克有坐标、方向、血量,子弹有速度、方向,墙壁有耐久度,爆炸有生命周期。这些实体之间既要协作又要互相独立,正是面向对象设计发挥价值的地方。
1.2 坦克大战里的三个并发场景:从上帝视角看需求
动手之前,我先梳理了游戏里有哪些实体在同时活动,很快就发现这是个多线程问题集中营。
第一个场景,多个坦克同时移动。玩家坦克由键盘控制,敌方坦克有自己的简单AI,哪怕只是随机改方向,它们都在同一个画布上活动。如果程序是单线程的,就会出现“按一下键盘坦克动一步,然后轮到敌人动一步”的排队效果,体验非常差。
第二个场景,子弹异步飞行。开局一颗子弹还好,当玩家和多个敌方坦克同时开火,战场上会有多颗子弹对象同时移动。每一颗子弹都是一个独立的活动实体,互不等待,还可能在任何时刻被创建和销毁。
第三个场景,游戏逻辑与画面渲染分离。逻辑层在更新坐标时,渲染层可能正在读取坐标来绘制。处理不好就会出现画面撕裂、坦克瞬移、子弹漂移。
这三个场景单独拆开都不难,组合在一起就麻烦了,因为它们同时发生,又共享同一份游戏状态。这种复杂度,正是多线程实战项目该有的味道。
1.3 v1.0的目标清单:哪些做,哪些坚决不做
新手写项目最容易翻车的就是需求蔓延,写着写着想加功能,最后什么都没做扎实。我给自己定了一条规矩:v1.0只有一个目标,把多线程和OOP的基础打牢。任何跟这个目标无关的功能,都记在待办里,不写进代码。
| 功能 | v1.0是否包含 | 原因 |
|---|---|---|
| 玩家单机对战电脑 | 是 | 核心玩法,跑通主循环 |
| 敌方坦克基础AI | 是 | 随机移动和射击,够用即可 |
| 子弹、墙壁、爆炸 | 是 | 碰撞检测和对象生命周期的载体 |
| 计分系统 | 是 | 状态管理,练习枚举和界面更新 |
| 双人对战 | 否 | 涉及多键盘输入,v1.0先不做 |
| 音效与真实贴图 | 否 | 资源加载和播放会增加复杂度 |
| 地图编辑器 | 否 | 老老实实内置一张图 |
这个表格我后来回看,依然觉得很值。边界画清楚之后,写代码的心态完全不一样:每一步都朝着“跑通”去,而不是朝着“完美”去。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从上帝类到职责清晰:v1.0的面向对象结构设计
面向对象设计这件事,停留在理论上永远觉得简单,一到实际项目就容易把它做成“把代码分到几个文件里”。我v1.0的第一版就是典型的上帝类,后来才一点点重构出清晰的结构。
2.1 第一版的“上帝类”:所有逻辑塞进GamePanel
说实话,最开始写的时候我还挺兴奋:一个GamePanel类,里面放了玩家坦克坐标、三个敌人坦克坐标、所有子弹的List、所有墙壁的List、键盘事件处理、碰撞检测方法、绘制方法……全部堆在一起。刚开始能跑,但代码很快膨胀到800多行,每加一个功能都要在类里到处找位置,改一个变量可能影响三个功能。
这是当时的典型结构:
java复制public class GamePanel extends JPanel implements ActionListener, KeyListener {
private int playerX, playerY, playerDir;
private int enemy1X, enemy1Y, enemy1Dir;
private int enemy2X, enemy2Y, enemy2Dir;
private List<Bullet> bullets = new ArrayList<>();
private List<Wall> walls = new ArrayList<>();
// 800行之后……
}
这种写法最大的问题不是行数多,而是字段重复。玩家坦克和敌方坦克都是坦克,但它们的坐标、方向、血量这些属性全都重复定义了一遍。更麻烦的是,以后每增加一个敌人,都要复制粘贴一坨代码,改字段名改到怀疑人生,典型的面向过程写法套了一层类的外壳。
2.2 GameObject抽象基类:把公共状态与行为收拢
重构的时候,我首先想到的是把所有游戏对象的公共属性和行为提取出来,做成一个抽象基类。这个类就是所有实体的“根”,坦克、子弹、墙壁、爆炸都继承它。
java复制public abstract class GameObject {
protected int x, y;
protected int width, height;
protected int speed;
protected int direction; // 0上 1右 2下 3左
protected boolean alive = true;
public abstract void update();
public abstract void draw(Graphics g);
public Rectangle getRect() {
return new Rectangle(x, y, width, height);
}
}
为什么要用Rectangle?Swing自带这个矩形类,可以直接用来做碰撞检测,不用自己写点乘之类的算法。getRect()里new一个Rectangle返回,而不是直接返回内部的Rectangle对象,是为了防止外部代码无意中修改坐标。这是个很小的防御性编程习惯,但能避免很多莫名其妙的问题。
2.3 Tank、Bullet、Wall、Explosion:继承与多态的展开
有了GameObject之后,坦克就可以单独抽象出来了。坦克有血量、有开火能力,这些特性是子弹和墙壁没有的,所以在GameObject和具体坦克之间加一层Tank抽象类,很有必要。
java复制public abstract class Tank extends GameObject {
protected int hp;
protected int bulletSpeed;
protected long lastFireTime;
protected int fireInterval = 500;
public void move() {
switch (direction) {
case 0: y -= speed; break;
case 1: x += speed; break;
case 2: y += speed; break;
case 3: x -= speed; break;
}
}
public Bullet fire() {
int bulletX = x + width / 2 - 5;
int bulletY = y + height / 2 - 5;
return new Bullet(bulletX, bulletY, direction, bulletSpeed);
}
}
然后PlayerTank和EnemyTank分别继承Tank,一个读取键盘输入,一个跑随机AI。Bullet、Wall、Explosion也都继承GameObject,各自实现update和draw。
这个设计的好处,在GamePanel里体现得很直接。整个主循环只需要维护一个List
java复制public void update() {
for (GameObject obj : gameObjects) {
if (obj.isAlive()) {
obj.update();
}
}
}
这就是多态的直观收益。我不需要写一长串if-else判断“这是坦克还是子弹”,对象自己知道怎么更新。
2.4 抽象类与接口的选择:我为什么没让Tank实现接口
很多人纠结抽象类vs接口,我当时也查了很多资料,最后的选择是:GameObject和Tank都用抽象类,另外定义了一个Fireable接口表示“可以开火的实体”。
选择抽象类的核心原因,是子类需要直接访问坐标、速度这些字段。如果定义成接口,每个子类都要重复定义这些字段,代码冗余不说,还容易忘。抽象类把公共字段放在父类里,用protected修饰,子类可以随意访问,省掉一堆getter/setter。
但接口也有它的价值。Fireable是个能力标记,目前只有坦克需要开火,但以后如果要加炮台、飞机这些可攻击对象,它们并不一定需要继承Tank,只要实现Fireable接口就可以。在这个设计里,抽象类负责“是什么”,接口负责“能做什么”,各司其职。
| 类/接口 | 类型 | 职责 |
|---|---|---|
| GameObject | 抽象类 | 所有可绘制实体的公共属性和行为契约 |
| Tank | 抽象类 | 坦克特有的移动、开火能力 |
| PlayerTank | 具体类 | 读取键盘输入,控制玩家坦克 |
| EnemyTank | 具体类 | 随机AI,自动移动和射击 |
| Bullet | 具体类 | 子弹的移动和消毁逻辑 |
| Wall | 具体类 | 墙壁的耐久与销毁 |
| Explosion | 具体类 | 爆炸动画的生命周期 |
| Fireable | 接口 | 能力标记:可开火的实体 |
重构前和重构后,同样一个“增加新敌人”的需求,差别非常大。
| 场景 | 重构前 | 重构后 |
|---|---|---|
| 增加一种敌人 | 复制粘贴代码,字段从enemy1改到enemy2 | 构造一个EnemyTank,或加个子类 |
| 让子弹有不同的威力 | 在GamePanel里判断子弹的type | 在Bullet里加属性,由子弹自己处理 |
| 添加新的地图元素 | 在GamePanel里加if-else判断 | 新建类继承GameObject即可 |
这个设计虽然简单,但让我真正理解了“面向对象不是把代码分成几个文件就完事,而是要让每个类只负责一件事”。
3. 多线程的三种并发方案:我为什么最终放弃了“每坦克一线程”
多线程是坦克大战的核心难点,也是我花时间最多的地方。最开始的想法很简单粗暴:每个坦克一个线程,每个子弹一个线程,它们各自跑,不就可以达到并发效果了吗?后来发现这个方案在坦克大战里走不通。
3.1 方案A:每个坦克一个线程(初版直觉方案,翻车了)
第一版我确实写了每坦克一线程的代码。刚写出来跑了两次,问题接踵而至。
第一个问题是线程数量不稳定。子弹会被创建和销毁,如果每颗子弹都new Thread,短时间内可能冒出几十上百个线程。线程虽然不重,但堆积起来线程切换开销很大,游戏帧率会剧烈波动。
第二个问题是共享资源的竞争。所有坦克都操作同一个画布,多个线程同时修改和读取坐标,必须大量加锁,否则数据错乱。加了锁又容易出死锁,或者性能下降,总之体验极差。
第三个问题是Swing的线程安全限制。repaint()虽然在EDT之外调用是线程安全的,但如果副线程里直接操作组件,比如调用setText或者修改组件属性,就会触发异常。游戏线程里到处都是界面刷新,稍不注意就出事。
这是当时的反面教材:
java复制// 反面示例:每个子弹一个线程
new Thread(() -> {
while (bullet.isAlive()) {
bullet.update();
Thread.sleep(30);
panel.repaint(); // 跨线程调用Swing组件,很容易出问题
}
}).start();
这个方案在子弹少的时候勉强能跑,但只要战场上子弹超过五颗,画面就开始抽风,坦克移动也明显不流畅。后来我想明白了一个原理:游戏引擎的架构里,逻辑更新和渲染输出通常是同一个循环驱动的,而不是每个实体一个线程。
3.2 方案B:固定频率主循环(游戏开发的标准答案)
正确的做法是把整个游戏当作一个循环来推进。一个线程负责“更新游戏逻辑加触发重绘”,每30毫秒走一帧,就像电影的每一帧。玩家操作则由AWT的事件线程接收,只修改操作意图,真正的移动发生在主循环里。
java复制public void startGame() {
Thread gameThread = new Thread(() -> {
while (running) {
update();
repaint();
try {
Thread.sleep(30);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
gameThread.start();
}
这里要注意,repaint()本身是线程安全的,Swing会把重绘请求投递到EDT队列中,所以主循环可以直接调用repaint()。但如果要直接操作组件,就需要用SwingUtilities.invokeLater。
这个方案的优势很明显:线程数量只有一个,所有游戏对象的update()都在同一个线程里按顺序执行,天然避免了多线程修改共享坐标的问题。帧率通过sleep时间控制,节奏一致。
我还研究过逻辑线程和渲染线程分离的方案,也就是一个线程算逻辑,另一个线程负责绘制。这是商业游戏引擎的常见做法,但复杂度高不少,v1.0用不上。对于坦克大战这个量级,单线程主循环足够稳定,也更适合理解游戏运行的本质。
3.3 玩家输入与游戏线程的分工:EDT做的事越少越好
键盘监听的回调发生在EDT线程。在回调里直接修改坦克坐标,是很多新手会犯的错,因为它看起来“天经地义”。但你按一下方向键,EDT线程马上把坐标改了,而游戏线程正在用旧坐标做碰撞检测,然后覆盖掉新坐标,画面就会“卡一下”甚至“弹回去”。
我的做法是:在KeyListener里只维护一个“正在按下的按键”集合,主循环读取这个集合,统一处理。
java复制private Set<Integer> pressedKeys = ConcurrentHashMap.newKeySet();
@Override
public void keyPressed(KeyEvent e) {
pressedKeys.add(e.getKeyCode());
}
@Override
public void keyReleased(KeyEvent e) {
pressedKeys.remove(e.getKeyCode());
}
然后在update里统一处理,玩家操作只是发指令,指令在合适的时机生效:
java复制if (pressedKeys.contains(KeyEvent.VK_W)) player.turn(0);
if (pressedKeys.contains(KeyEvent.VK_S)) player.turn(2);
if (pressedKeys.contains(KeyEvent.VK_A)) player.turn(3);
if (pressedKeys.contains(KeyEvent.VK_D)) player.turn(1);
if (pressedKeys.contains(KeyEvent.VK_SPACE)) player.requestFire();
这里我用ConcurrentHashMap.newKeySet()而不是普通HashSet,是因为pressedKeys会被EDT线程写、游戏线程读,普通HashSet在并发读写时可能产生不一致。有人可能觉得“就一个Set,能有什么问题”,但多线程编程的准则就是:共享可变状态一定要做好同步,否则就是给自己埋雷,出问题了都是偶发难复现的雷。
3.4 线程安全的边界:哪些数据需要加锁,哪些不需要
写多线程代码,最怕的不是不知道怎么写,而是不知道哪些地方要同步、哪些地方不需要。我在这个项目里慢慢整理出了一个清单:
| 数据 | 读写线程 | 处理方式 |
|---|---|---|
| pressedKeys | EDT线程写、游戏线程读 | ConcurrentHashMap.newKeySet() |
| running标志位 | 游戏线程读写、其他线程改 | volatile boolean |
| 游戏对象列表 | 游戏线程读写 | CopyOnWriteArrayList或先收集后删除 |
| 玩家分数 | 游戏线程写、EDT可能读 | int加volatile |
| 各对象坐标 | 仅游戏线程读写 | 不需要额外加锁 |
重点解释下volatile。running标志位用volatile修饰,保证线程间可见性。为什么不用synchronized?因为running只是“一个线程写、多个线程读”的标志位,volatile足够。如果既要读又要写、且读改写是复合操作,才需要加锁。像得分这种变量,如果只是游戏线程自己更新、最多界面读一下,volatile就够了,不需要synchronized。
我见过有人把所有共享变量都粗暴地用synchronized包裹,结果代码慢得不行。其实多线程的核心不是“处处加锁”,而是“明确谁写谁读,把并发边界压缩到最小”。
4. 碰撞检测与游戏状态流转:从矩形相交到胜负判定
多线程跑起来之后,游戏终于能动了。接下来就是游戏逻辑的核心:碰撞检测和状态管理。这部分看似简单,其实细节不少。
4.1 矩形碰撞检测的原理:不写数学公式也能懂
坦克、墙壁、子弹在画布上都是矩形。判断两个对象是否碰撞,本质上就是判断两个矩形是否有重叠区域。Java的Rectangle自带intersects方法,底层逻辑很直观:两个矩形在左右方向和上下方向都有重叠,就说明碰上了。
java复制public boolean hitTest(GameObject a, GameObject b) {
return a.getRect().intersects(b.getRect());
}
这个方案对轴对齐矩形非常高效,坦克大战的实体基本都是轴对齐矩形,所以非常契合。需要注意的是,Rectangle是可变对象,我getRect()里每次都new一个新的返回,避免调用方改坏内部坐标。这同样是防御性编程,虽然多new了一点对象,但逻辑安全很多。
4.2 碰撞矩阵:子弹vs坦克、坦克vs墙壁、子弹vs墙壁、坦克vs坦克
游戏里不是所有对象都要互相检测,乱检测不仅浪费时间,还会产生错误逻辑。我整理了一个碰撞矩阵:
| 碰撞检测项 | 发生条件 | v1.0处理 |
|---|---|---|
| 子弹 vs 坦克 | 子弹矩形与坦克矩形相交 | 子弹消失,坦克HP减1,HP为0则销毁并播放爆炸 |
| 坦克 vs 墙壁 | 坦克移动后与墙壁相交 | 坦克坐标回退到移动前 |
| 子弹 vs 墙壁 | 子弹矩形与墙壁矩形相交 | 子弹消失,墙壁耐久减1 |
| 坦克 vs 坦克 | 两个坦克矩形相交 | 禁止移动,把移动中的坦克回退 |
最难处理的是“坦克碰到墙壁回退”。一开始移动坦克是直接修改x/y,发现撞了再回退。但回退之后,坦克可能还是卡在墙壁边缘,因为速度是10像素一帧,直接从墙壁里弹出来的位置不一定正确。后来我把移动改成了“先试探,后提交”:
java复制public boolean tryMove(int dx, int dy, List<GameObject> walls) {
int oldX = x, oldY = y;
x += dx;
y += dy;
Rectangle next = getRect();
for (GameObject wall : walls) {
if (wall != this && next.intersects(wall.getRect())) {
x = oldX;
y = oldY;
return false;
}
}
return true;
}
这样坦克永远不会进入墙壁内部。虽然这个方法在碰撞检测的教科书里不算最优,但胜在简单可靠,足够支撑v1.0。
4.3 游戏状态:运行、暂停、结束与重开的状态机
随着功能增多,游戏开始有“暂停”“胜利”“失败”这些状态。我把状态定义成枚举,形成一个简单的状态机:
java复制public enum GameState {
RUNNING, PAUSED, WIN, GAME_OVER
}
主循环会先判断状态,再决定是否执行update():
java复制while (running) {
if (state == GameState.RUNNING) {
update();
}
render();
Thread.sleep(30);
}
这里有个多线程细节:state变量可能被EDT线程(用户按P键暂停)和游戏线程同时访问,所以用volatile修饰。状态切换的逻辑不复杂,但很考验对线程同步的理解:如果state不是volatile,其他线程改了state,游戏线程可能一直看不到变化,表现就是“按了暂停键,画面还在动”。
4.4 帧率控制:为什么Thread.sleep(30)够用
睡30毫秒不是精确的30fps,因为update和render本身也要花时间,实际帧率大约在25到30fps之间。对坦克大战来说完全够用,画面已经足够流畅。
如果要做精确帧率控制,可以在每一帧记录时间戳,动态计算下一帧应该睡多久。v1.0没必要这么复杂。我在实际运行里发现一个细节:如果机器性能差,update耗时超过30毫秒,画面会越来越卡。最简单的优化是减少每帧的碰撞检测次数,比如只有子弹移动时才做子弹相关的碰撞检测,而不是每帧全量检测所有对象。
5. 多线程游戏调试记录:那些并发下的经典翻车现场
这个项目最大的财富,不是“会写坦克大战了”,而是踩了一堆多线程的坑之后,记住了它们的长相。我把印象最深的几个问题记录下来。
5.1 ConcurrentModificationException:在foreach中删除子弹的翻车
子弹击中墙壁后要从列表里移除,如果你直接在前台遍历时remove,马上就会抛ConcurrentModificationException。原因很简单:ArrayList的迭代器会记录modCount,遍历过程中一旦发现modCount变了,就立刻失败。
这是我最初踩坑的代码:
java复制// 错误写法
for (Bullet b : bullets) {
if (!b.isAlive()) {
bullets.remove(b); // 抛ConcurrentModificationException
}
}
修复方案有两类。第一种用迭代器remove:
java复制Iterator<Bullet> it = bullets.iterator();
while (it.hasNext()) {
Bullet b = it.next();
if (!b.isAlive()) {
it.remove();
}
}
第二种先收集后统一删除:
java复制List<Bullet> deadBullets = new ArrayList<>();
for (Bullet b : bullets) {
if (!b.isAlive()) {
deadBullets.add(b);
}
}
bullets.removeAll(deadBullets);
我最终用的是第二种,因为它更清晰,配合CopyOnWriteArrayList还能抵抗并发遍历。如果你在更复杂的场景里,还可以用Stream的filter收集新列表再替换旧列表,但要小心性能问题。
5.2 子弹“穿墙”:移动步长太大导致的隧道效应
一个很有趣的问题:子弹速度是12像素一帧,如果墙的厚度只有10像素,子弹一帧跳过去,从墙的左边直接跑到右边,两个矩形没有相交,就穿墙了。这叫隧道效应,处理不好玩家会觉得子弹没打中墙。
解决方案有几个方向:
- 限制速度:让子弹每帧移动距离小于最小的墙体厚度
- 精确检测:把子弹的移动轨迹视为线段,检测线段与矩形是否相交
- 细分步长:把一次移动拆成两步,分两次检测
v1.0里我选了最务实的做法:把子弹速度设为8像素一帧,墙厚度设为16像素,这样单帧位移小于墙厚度,基本不会穿墙。虽然不优雅,但非常好用。我做了一个经验对照表:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 限制速度 | 实现简单,不影响性能 | 速度有上限 |
| 线段检测 | 精确,适合高速子弹 | 实现复杂,需要处理斜率 |
| 细分步长 | 通用,速度可调 | 计算量略增 |
5.3 坦克“瞬移”:坐标读写的竞态条件
有几次快速交替按方向键,坦克偶尔会往后跳一下。排查了很久,最后发现是键盘监听里直接改了坐标。流程是:EDT线程收到按键事件,把坦克的y坐标减了10;同时游戏线程执行update,根据旧方向计算出来的坐标写回去,覆盖了新值。两个线程同时写x/y,就发生了“这次更新丢了一个操作”的问题。
修复方法就是我在3.3里说的,键盘监听只改pressedKeys,绝不在EDT里直接改坦克坐标。这个坑留给我的教训是:多线程之间共享数据,必须有明确的边界。哪些线程写、哪些线程读,必须写清楚,否则出了随机性bug很难复现。
5.4 用日志时间戳定位卡顿:简单粗暴的性能分析
v1.0后期游戏偶尔卡一下,当时完全不知道卡点在哪。我用了一个非常土的办法:在update的开头和结尾打印System.currentTimeMillis(),记录每帧耗时,然后跑一段对局,把日志导出来看。
定位结果让我意外:问题出在碰撞检测里反复new Rectangle对象。每帧都要把玩家坦克、所有敌方坦克、所有子弹、所有墙壁两两检测,检测时new Rectangle,导致GC频繁,画面间歇性卡顿。
优化方案很简单:在GameObject里缓存一个Rectangle成员变量,每次修改坐标后更新这个Rectangle,而不是每次检测时新建。改动不大,但帧率从波动变成稳定,效果立竿见影。
通过这个经历我意识到:性能问题往往不在“算法复杂度”,而在“无谓的对象创建”。多线程游戏跑久了卡顿,很多时候是GC在背后搞鬼。
6. v1.0交付后的重构反思:哪些设计我会推到重来
v1.0能跑,敌人会被消灭,玩家也会阵亡,胜负判定正常,对我来说意味着一个里程碑。但冷静下来回看代码,还是能看到很多让人脸红的设计。
6.1 最后悔的五个设计决定
第一个,绘制和逻辑没有完全分离。虽然我分了update和draw两个方法,但draw里偶尔会混入逻辑判断,导致状态更新和画面表现不一致。
第二个,魔法数字太多。30毫秒的帧间隔、8像素的子弹速度、500毫秒的开火间隔,全都散落在各个类里,没有定义成常量。后期想调数值,得全局搜索。
第三个,碰撞检测是广播式全量检测。虽然对象不多,每帧几百次检测也能扛住,但不是好习惯。后续对象多了,必须用空间划分降低检测次数。
第四个,敌方AI太弱,只是随机改方向和随机射击,没有策略性。这也让多线程的优势展现得不够充分。
第五个,资源加载没有做抽象。贴图用drawRect和fillOval画的色块,后续加真实图片资源时,所有绘制代码都要改。如果一开始就定义好贴图接口,扩展成本会低很多。
6.2 下一步迭代:AI状态机、双人模式、对象池
有了v1.0的类结构,下一步迭代方向其实很清晰。
AI状态机:让敌方坦克在“巡逻、攻击、撤退”之间切换。每种状态对应不同的行为和参数,坦克就会更有灵性。
双人模式:一个键盘控制玩家1,另一个键盘或手柄控制玩家2。这里最有挑战的是输入冲突处理,正好可以继续练并发。
对象池:子弹和爆炸频繁创建销毁,可以用对象池复用,减少GC压力。这也是多线程项目里常用的优化手段。
地图系统:从文件读取地图数据,而不是硬编码在代码里。这样关卡设计就和程序逻辑解耦了。
这些功能都建立在v1.0的OOP结构上,如果类职责清晰,加功能不需要大改。这也是我觉得v1.
