简介:这是一份基于Java实现的经典RTS游戏《Warcraft》简化版完整源码工程,面向Java初学者与游戏开发入门者,旨在通过可运行的实战项目理解面向对象设计、事件驱动编程及简单游戏循环架构。资源包含292个文件,涵盖92个核心Java源文件(如Player、World、ModelUnit、ControlPanel等)、100个编译后class文件、37张UI与单位贴图PNG、27段音效WAV及配置类TXT与XML文件,整体压缩包仅2.24MB,轻量易部署。已有394人下载学习,适合用于课程设计、毕业设计参考或Java GUI进阶实践。读者可直接编译运行,完整体验阵营选择(人类/兽人)、双资源系统(黄金/木材采集与消耗)、多类型建筑建造、单位框选与指令调度等核心玩法逻辑,代码结构清晰,模块职责分明,具备良好的可读性与扩展基础。 先说我为什么要碰这个项目。前阵子整理硬盘,翻出一个很早以前用Java写的《Warcraft》仿制版源码,包名叫warcraft,一个字母拼错了,但一运行起来,满屏的小兵采矿、造房子、拉队伍互殴,我愣是坐下来玩了半小时没挪窝。回想当年为了把这个RTS的架子搭出来,翻了多少帖子、踩了多少坑,有些东西网上根本搜不到现成答案,全靠自己一遍遍试。所以我决定把这个项目从源码、架构到实现细节整个拆一遍,写成一篇能照着做的东西。如果你正在学Java,学完基础语法但不知道做什么项目;或者想做点“能拿得出手”的东西放到简历里;又或者你就是想搞明白那些游戏里的寻路、战斗AI到底在计算机里长什么样——这篇内容应该能给你一个足够完整的参考答案。
这个项目不是那种随便写写的小Demo,它是一个具备完整RTS玩法的可运行游戏:地图上有人口、资源、建筑、单位,鼠标框选一堆兵拉过去打架,电脑AI会造兵、会来打你。整个项目用纯Java实现,不依赖任何第三方游戏引擎,渲染用的是Swing的Graphics2D,全部源码加起来八九十个类文件。能做成这样,不是因为我水平多高,而是游戏引擎里那套核心逻辑,用Java这种“看着笨重、实际踏实”的语言,恰好能表达得非常清楚。
1. 为什么用Java写一个RTS游戏:这活儿的价值远超“练手项目”
很多人一听到“用Java做游戏”,第一反应是“不专业”。这话有一定道理——如果你要做商业游戏,Unity、Unreal、自研C++引擎才是主流,Java做游戏在工业界确实少见。但Java做RTS类游戏有一个被低估的价值:RTS几乎把程序员日常要碰的经典问题全覆盖了,而Java的语法约束和面向对象体系,恰恰能让你把这些问题的解法看得明明白白。
1.1 RTS游戏是“数据结构与算法”的终极综合实践
先想想一个即时战略游戏里到底藏着哪些计算机基础:
- 地图数据:地图是一张二维网格,每个格子存地形类型(草地、岩石、树木、矿脉)。这背后是二维数组、稀疏存储、空间索引。
- 寻路:小兵从A点走到B点,不能穿墙,要绕开障碍物。经典的A*算法就是为此而生。
- 单位管理:几十上百个单位同时移动、攻击、播放动画,背后是对象池、状态机、事件队列。
- AI决策:电脑玩家要不要造兵?造几个?什么时候来打你?这是决策树、状态机、策略模式的混合应用。
- 画面渲染:虽然用的是2D,但每一帧都要把地图、单位、血条、选中框按正确的层级绘制出来。这涉及双缓冲、脏矩形、绘制排序。
- 网络同步(扩展方向):多人对战需要帧同步或状态同步,那是并发编程的深水区。
Java的集合框架、泛型、多线程、IO,在这些模块里全部用得上。做完这个项目,你对HashMap为什么快、ArrayList为什么不适合频繁删除、线程同步为什么麻烦,会有完全没有理论距离的体感。
1.2 为什么选Swing而不是JavaFX或者LWJGL
我做这个项目时也犹豫过:JavaFX的Canvas性能更好,LWJGL可以直接调OpenGL,为什么最终选了Swing?
核心原因有两个。第一,Swing的Graphics2DAPI足够简单——fillRect画个方块、drawImage画张图、drawString写段文字,把一个原型跑起来只需要几十行代码,学习成本几乎为零。第二,Swing是JDK自带的,不需要额外配环境,任何一台装了JDK的电脑都能直接跑。JavaFX后来从JDK里剥离了,LWJGL还要引入一堆原生库,对新手都不够友好。
而且Swing没那么不堪。做2D游戏时,只要你不做全屏抗锯齿、不做粒子特效爆炸,用BufferStrategy加双缓冲,60fps完全跑得动。这个项目在普通笔记本上跑几百个单位同时行动,帧率依然稳定。
这里有个容易被人忽略的点:写游戏最怕的不是渲染慢,而是逻辑写得慢。Swing把渲染层的复杂度压到最低,你才能把精力都放在游戏逻辑本身。对于学习目的来说,这恰恰是最优选。
1.3 项目涵盖的技术点清单
我在这套源码里实际用到的核心技术点,列出来你感受一下覆盖范围:
- 面向对象设计:继承、接口、多态、组合
- 设计模式:工厂模式(创建单位)、状态模式(单位状态)、观察者模式(事件监听)、策略模式(AI策略)
- 数据结构:二维数组、ArrayList、HashMap、LinkedList、PriorityQueue(A*的open list)
- 算法:A*寻路、Bresenham直线算法(攻击弹道)、矩形碰撞检测、空间哈希
- 多线程:游戏主循环线程、A*寻路线程池、Swing EDT线程
- IO与序列化:存档/读档功能,用到了
ObjectOutputStream - 性能优化:对象池(子弹复用)、批量绘制、避免内存抖动
这些东西单独拎出来学,每一个都很抽象,但放在游戏里,它们的用途就变得极其自然。
2. 游戏核心架构:从主循环到模块划分的设计思路
这套源码不是一夜之间写出来的。最开始我只有几百行的“小人在地图上走路”,后来一点点加了采矿、建筑、战斗、AI,才慢慢变成了一个完整的游戏。这个过程中最值钱的经验,不是某段代码怎么写,而是整个游戏的骨架怎么搭——骨架对了,加功能只是往骨架上挂肉的问题;骨架错了,每加一个功能都是一次重构灾难。
2.1 包结构设计:先按“职责”分,再按“层级”分
源码的包结构大致如下,我建议你照着这个方式组织自己的项目:
com.warcraft ├── core // 游戏核心:主循环、GameState、GamePanel ├── model // 数据模型:MapData、UnitType、BuildingType ├── entity // 实体:Unit、Building、Projectile、ResourceNode ├── ai // 电脑AI:AIState、AIController、Strategy ├── pathfinding // 寻路:AStarPathfinder、GridGraph ├── render // 渲染:Camera、SpriteLoader、EffectRenderer ├── input // 输入:MouseHandler、SelectionManager ├── ui // 界面:HUD、MiniMap、CommandPanel ├── event // 事件:GameEvent、EventBus └── util // 工具:ObjectPool、MathUtils这个分包逻辑的核心原则,是“显示”和“逻辑”分离。entity里的Unit类只管自己的位置、血量、状态,它不知道自己在屏幕上长什么样;render包里有一个UnitRenderer专门负责把Unit的图片画出来。这样以后你想把Swing换成JavaFX,只要动render层,业务逻辑一行不用改。
2.2 主循环:游戏世界的“心跳”
所有游戏都有一个大循环,它的工作就是反复执行三件事:处理输入、更新逻辑、绘制画面。这个循环跑得越快,游戏体验越流畅。我的主循环大致长这样:
public class GameLoop implements Runnable { private static final int TICKS_PER_SECOND = 60; private static final double NANO_PER_TICK = 1_000_000_000.0 / TICKS_PER_SECOND; private boolean running = false; private GameState gameState; private GamePanel gamePanel; @Override public void run() { long lastTime = System.nanoTime(); double delta = 0; while (running) { long now = System.nanoTime(); delta += (now - lastTime) / NANO_PER_TICK; lastTime = now; while (delta >= 1) { gameState.update(); // 固定时间步长更新逻辑 delta--; } gamePanel.render(gameState); // 尽量快地渲染 } } }这里有两个关键设计。第一是固定时间步长——游戏逻辑每1/60秒更新一次,不管你的电脑是快还是慢,游戏里的小兵移动速度、攻击间隔都是恒定的。如果不这么做,帧率高的电脑上游戏会“加速”,帧率低的电脑上会“慢动作”。第二是逻辑更新和渲染分离——逻辑固定60Hz,渲染则越快越好,这样才能充分利用高刷新率显示器。
这个循环跑在独立线程里,不能在它里面直接操作Swing组件,否则会和EDT(Event Dispatch Thread,Swing的事件分发线程)冲突。正确做法是逻辑线程更新GameState,然后把GameState引用交给事件分发线程去绘制。这里涉及到volatile和ConcurrentLinkedQueue的使用,源码里都有。
2.3 事件系统:鼠标点一下,背后发生了什么
RTS游戏里鼠标操作极其频繁:左键框选、右键移动、点技能、点建筑。如果每一种操作都直接去改游戏状态,代码会变成一大坨if-else。我用的方案是一个简单的事件总线:
public class EventBus { private final Map<Class<?>, List<EventListener<?>>> listeners = new HashMap<>(); public <T> void register(Class<T> eventType, EventListener<T> listener) { listeners.computeIfAbsent(eventType, k -> new ArrayList<>()).add(listener); } @SuppressWarnings("unchecked") public <T> void dispatch(T event) { List<EventListener<?>> list = listeners.get(event.getClass()); if (list == null) return; for (EventListener<?> listener : list) { ((EventListener<T>) listener).onEvent(event); } } }鼠标处理器MouseHandler收到“右键点击”后,会发布一个MoveCommandEvent,携带目标坐标和选中的单位列表。单位管理系统监听这个事件,遍历选中单位,给每个单位设置一个移动目标。这样鼠标逻辑、单位逻辑、AI逻辑完全解耦——AI想给单位下命令,也只需要发布同样的MoveCommandEvent,不需要知道鼠标是怎么处理的。
2.4 单位状态机:从“走路”到“打架”的切换逻辑
每一个单位(Unit)内部有一个状态机。这是RTS单位行为的核心,也是最容易写乱的地方。我的状态定义是:
IDLE:待机,什么都不干MOVING:正在走向目标点ATTACKING:正在攻击某个敌人DEAD:已死亡
状态转移的规则也很直接:
IDLE -> 收到移动指令 -> MOVING IDLE -> 敌人进入警戒范围 -> ATTACKING MOVING -> 到达目标点 -> IDLE MOVING -> 发现可攻击目标 -> ATTACKING ATTACKING-> 目标死亡/丢失 -> IDLE ATTACKING-> 目标跑远 -> MOVING(追击)状态机放在Unit.update()方法里,每帧根据当前状态执行相应行为:
public void update() { switch (state) { case IDLE: // 看看周围有没有敌人 Unit target = acquireTarget(); if (target != null && canAttack(target)) { setState(State.ATTACKING); } break; case MOVING: moveTowardTarget(); if (reachedDestination()) { setState(State.IDLE); } break; case ATTACKING: if (currentTarget == null || currentTarget.isDead()) { setState(State.IDLE); break; } if (inRangeOf(currentTarget)) { attack(currentTarget); } else { moveToward(currentTarget.getPosition()); } break; } }有人可能会问,为什么不直接用if-else?状态机的优势在于,状态之间的转移条件和每个状态下的行为是分开管理的,后期加一个“被眩晕”状态、“施法中”状态,只需要加一个枚举值加一个case分支,完全不影响其他状态逻辑。游戏项目后期复杂度一上来,没有状态机基本寸步难行。
3. 地图与寻路:小兵怎么从A点走到B点还能绕开树林
RTS游戏里最基础也最见功夫的模块,就是地图数据的组织方式和寻路算法。你可以把地图想象成一张巨大的棋盘,地图上的每个格子要么是可以走的草地,要么是挡路的岩石和树木,要么是金矿和树木这样的资源点。单位移动的本质,就是在这张棋盘上找到一条从当前格子到目标格子的路径。
3.1 地图数据结构:一维数组比二维数组更高效
地图存储我用的是一维数组而不是二维数组。原因很简单:一维数组在内存里是连续的,访问速度更快,而且通过index = y * width + x的换算,二维坐标和一维索引可以互相转换,完全不影响可读性。
public class GameMap { private final int width; private final int height; private final Tile[] tiles; // 一维数组存储 public GameMap(int width, int height) { this.width = width; this.height = height; this.tiles = new Tile[width * height]; } public Tile getTile(int x, int y) { if (x < 0 || x >= width || y < 0 || y >= height) { return Tile.BOUNDARY; // 地图边界当成不可通行处理 } return tiles[y * width + x]; } public void setTile(int x, int y, Tile tile) { tiles[y * width + x] = tile; } }Tile是一个枚举,定义了地形类型和是否可通行:
public enum Tile { GRASS(true), ROCK(false), TREE(false), GOLD(true), // 金矿,可通行但会触发采集逻辑 WATER(false); private final boolean walkable; Tile(boolean walkable) { this.walkable = walkable; } public boolean isWalkable() { return walkable; } }这里有个小优化:在getTile方法里做边界检查,越界统一返回Tile.BOUNDARY(一个不可通行的虚拟Tile)。这样寻路算法在搜索邻节点时就不需要额外的边界判断,直接把越界当障碍物处理,代码清爽很多。
3.2 A*寻路算法:从零实现到二叉堆优化
A*是RTS寻路的事实标准。它的核心思想是:每次从待处理队列里取出一个“代价最小”的节点,计算它的周围邻居,更新邻居的代价并继续,直到找到目标点。
代价函数是:f(n) = g(n) + h(n),其中g(n)是从起点到当前节点的实际代价,h(n)是当前节点到目标点的预估代价(启发函数)。我用的是曼哈顿距离,因为单位只能上下左右走,不能斜着穿格子。
public class AStarPathfinder { private final GameMap map; private final PriorityQueue<Node> openList; private final Map<Integer, Node> allNodes; private final int width; private final int height; public List<Point> findPath(Point start, Point end) { Node startNode = getNode(start.x, start.y); Node endNode = getNode(end.x, end.y); openList.clear(); for (Node node : allNodes.values()) { node.reset(); } startNode.g = 0; startNode.f = heuristic(startNode, endNode); openList.offer(startNode); while (!openList.isEmpty()) { Node current = openList.poll(); if (current == endNode) { return reconstructPath(current); } current.closed = true; for (Point neighborPos : getNeighbors(current.x, current.y)) { Node neighbor = getNode(neighborPos.x, neighborPos.y); if (neighbor.closed || !map.getTile(neighborPos.x, neighborPos.y).isWalkable()) { continue; } double tentativeG = current.g + 1.0; if (tentativeG < neighbor.g) { neighbor.g = tentativeG; neighbor.parent = current; neighbor.f = tentativeG + heuristic(neighbor, endNode); if (!openList.contains(neighbor)) { openList.offer(neighbor); } } } } return Collections.emptyList(); // 找不到路径 } }这里有一个关键优化:open list用二叉堆(Java的PriorityQueue)。如果用普通的ArrayList,每次取最小值都要扫描整个列表,时间复杂度是O(n),地图大一点、单位一多就卡死;用堆只需要O(log n)。这个优化的代码量只有几行,但性能提升是数量级的。
还有几个我在实际测试中总结的经验:
- 单位不能都朝同一个目标点寻路,不然会在终点互相挤成一团。我给每个单位的目标点加了随机偏移,偏移量2-3格,队伍看起来就自然分散了。
- 寻路不要每帧都跑。单位每0.5秒重算一次路径就够了,或者只在目标点变化、前方遇到障碍物时才重新寻路。这个项目里我用了“每0.3秒检查一次,路径失效才重算”的策略。
- 大量单位同时寻路时性能压力很大。我后来加了一个线程池,把寻路任务丢给后台线程执行,寻路完成后再把路径设置给单位。UI线程完全不会被卡住。
3.3 战争迷雾:简单但效果拔群的实现
RTS游戏如果没有战争迷雾,玩法会失去一半。战争迷雾的实现思路其实很简单:每个格子有一个“可见度”数值,己方单位周围一定范围内的格子是“可见”的,曾经可见过但单位走开后变成“半可见”(灰色),完全没探索过的区域是“不可见”(全黑)。
渲染时,我准备了一张半透明的黑色遮罩,单位每移动一帧,就在遮罩上“挖洞”——用Composite的Clear模式把单位视野范围内的像素清掉。这样探索过的区域就是透明的,没探索的就是黑的。至于“半可见”区域,我另外做了一层灰色遮罩,单位离开后慢慢恢复灰色。
这个实现里有个不错的细节:视野范围不是圆形,而是用Bresenham直线算法做射线投射,判断哪些格子被遮挡。比如一座山后面是盲区,射线穿不过去,那个区域就保持黑色。这么做会让迷雾效果真实很多。
3.4 渲染性能优化:批量绘制和脏矩形
Swing的绘制性能是出了名的“不够快”,但通过几个优化手段,跑一个几十个单位的2D游戏完全够用。
第一个优化是批量绘制单位。把所有单位按类型分组,再一次性按组绘制图片。避免每次绘制都调用drawImage,而是先在内存里把同类型单位的图片批量处理。
第二个优化是只绘制可视区域内的物体。摄像机有一个可视矩形,只有和这个矩形相交的单位才需要绘制。地图是500x500格,每格32像素,如果不裁剪直接绘,一帧要画25万张图,神仙也扛不住。裁剪之后,可视区大约只有20x15格,只需画300张图左右。
第三个优化是对象池。子弹、飘字伤害、特效这类短暂存在的物体,频繁的new和GC会造成卡顿。我给它们建了对象池,用完回收,再创建优先从池里取。
public class ProjectilePool { private final Queue<Projectile> pool = new LinkedList<>(); private static final int MAX_POOL_SIZE = 200; public Projectile acquire() { Projectile p = pool.poll(); if (p == null) { p = new Projectile(); } return p; } public void release(Projectile p) { if (pool.size() < MAX_POOL_SIZE) { pool.offer(p); } } }这些优化手段在纯Java游戏开发中非常通用,你以后写任何Swing/JavaFX 2D程序都用得上。
4. 战斗系统与单位AI:让电脑知道什么时候该打你
战斗系统是RTS的门面。玩家点一个兵,拉过去打敌人,要打中、扣血、播动画、出伤害数字、死亡消失,整个链路要一气呵成。电脑AI则要负责它的农民采矿、基地生产军队、然后派兵来打玩家——如果AI太弱,游戏就没意思;太强,玩家又被虐得不想玩。这里的平衡是个技术加玄学的活。
4.1 攻击判定:圆形碰撞与射程检查
单位的攻击判定我用的是圆形碰撞模型。每个单位有一个radius属性,两个单位是否碰撞、是否进入射程,都检查圆心距离是否小于半径和。
public class MathUtils { public static boolean isInRange(Unit attacker, Unit target) { double dx = attacker.getX() - target.getX(); double dy = attacker.getY() - target.getY(); double distanceSq = dx * dx + dy * dy; double attackRangeSq = attacker.getAttackRange() * attacker.getAttackRange(); return distanceSq <= attackRangeSq; } }用平方距离而不是开根号,能省掉一次昂贵的Math.sqrt调用。在成百上千次的碰撞检测中,这个细节能让帧率差好几个点。
攻击动作本身有个节奏控制:单位发起攻击后要进入一个冷却时间(cooldown),不能连续无间隔地输出伤害。我在Unit里定义了一个attackCooldown属性,每次攻击后重置,每帧递减,冷却结束才能发起下一次攻击。
public void attack(Unit target) { if (attackCooldown > 0) { return; } target.takeDamage(this.attackDamage); attackCooldown = this.attackSpeed; // 攻击速度越快,冷却越短 }这样做的好处是,修改单位的攻击速度,只需要调整attackSpeed数值,不需要改攻击逻辑。
4.2 子弹与弹道:对象池和线性插值
远程单位的攻击会生成一个子弹实体。子弹沿直线飞向目标,到达后造成伤害。实现代码不长,但有小坑。
public class Projectile { private double x, y; private double targetX, targetY; private double speed; private int damage; private Unit owner; public void update() { double dx = targetX - x; double dy = targetY - y; double distance = Math.sqrt(dx * dx + dy * dy); if (distance <= speed) { hitTarget(); return; } x += (dx / distance) * speed; y += (dy / distance) * speed; } private void hitTarget() { // 目标可能已经死亡/移动,需要做有效性检查 Unit target = gameState.getUnitAt(targetX, targetY); if (target != null && !target.isDead()) { target.takeDamage(damage); } markForRemoval(); } }最容易出错的是目标在子弹飞行过程中死亡。如果直接用目标对象做碰撞判定,目标死了,子弹就会一直追着空坐标飞。我后期改成用目标坐标做判定,到达坐标后再检查那个位置有没有活着的敌方单位,逻辑就稳定了。
4.3 电脑AI:基于状态的策略决策
电脑AI的设计经历了三次重构。一开始我写了个“定时造兵,造完就派出去”的脚本,结果玩家只要前期骚扰一波就赢了,毫无挑战性。后来我改成“先攀科技,再暴兵”,但AI前期过于薄弱,中期又突然一波兵碾压过来,节奏很怪。最终我采用了状态机+策略模式的组合方案。
AI的状态机:
EARLY_GAME:前期,集中采金采木,造少量兵防守MID_GAME:中期,造兵营、升级攻击,攒一支3-5人的小部队去骚扰LATE_GAME:后期,暴兵,组织大部队总攻DEFEATED:经济崩溃或基地被毁,放弃抵抗
状态转移的触发条件是基于游戏内资源的数值判断:
public class AIController { private AIState state; private final GameState gameState; public void update() { switch (state) { case EARLY_GAME: if (gameState.getGold() > 800 && gameState.getFood() > 30) { state = AIState.MID_GAME; } break; case MID_GAME: if (gameState.getGold() > 2000 && gameState.getMilitaryUnits().size() > 8) { state = AIState.LATE_GAME; } break; } executeStrategy(); } private void executeStrategy() { switch (state) { case EARLY_GAME: trainVillagers(); gatherResources(); buildFarmIfNeeded(); break; case MID_GAME: buildBarracks(); trainSoldiers(); sendScoutParty(); break; case LATE_GAME: trainSoldiersMassively(); sendAttackWave(); break; } } }这里有个微妙的平衡:AI不能太贪资源导致前期没兵防守,也不能太早暴兵导致经济跟不上。我调的参数是——前期70%的农民在采矿、30%砍树,人口到20才开始造兵营;中期保持10个农民采矿、6个农民砍树,剩余人口全部造兵;后期经济收入大于兵营消耗时,无脑暴兵。经过十几轮测试,这个AI在“让新手觉得有压力但不至于绝望”的水平线上。
4.4 单位群体的编队算法:为什么你的兵总是挤成一团
单位编队移动是RTS里最常见的操作,也是新手最容易发现“不对劲”的地方——点了移动之后,一群兵挤在狭窄通道门口谁也过不去,阵型全乱。我一开始也遇到这个问题,后来用了两种算法组合解决。
第一个是本地避障(Local Avoidance)。每个单位每帧检查自己和周围其他单位之间的距离,如果距离小于设定的“舒适距离”,就推开一小步。代码很简单,但效果立竿见影:
public void avoidOverlap(List<Unit> nearbyUnits) { for (Unit other : nearbyUnits) { double dx = this.x - other.x; double dy = this.y - other.y; double distSq = dx * dx + dy * dy; double minDist = this.radius + other.radius; if (distSq < minDist * minDist && distSq > 0.01) { double dist = Math.sqrt(distSq); double pushForce = (minDist - dist) / dist; this.x += dx * pushForce * 0.3; this.y += dy * pushForce * 0.3; } } }第二个是目标点分散(Destination Dispersion)。单位移动指令不是让所有单位都直奔同一个目标点,而是以目标点为中心,给每个单位分配一个周围的小偏移位置。这样单位到达后自然形成弧形阵型,而不是全部叠在一个坐标上。
这套组合在实战中效果非常明显。你可以试试:框选20个兵,点地图上一个狭窄路口,它们的进场会像水一样流动,而不是像雪崩一样堵死。
5. 性能调优与那些“运行时才发现”的坑
这个章节更重要。前面讲的是怎么把功能做出来,这里讲的是一旦玩起来,你会遇到的真实卡顿、闪退和莫名其妙的问题。我当年为这些问题熬了好几个通宵,有些坑直到现在提起来还觉得肉痛。
5.1 OutOfMemoryError:单位一多就崩,原来是对象泄漏
这个项目跑着跑着,单位数量一多,就弹出一行老熟人:
Exception in thread "AWT-EventQueue-0" java.lang.OutOfMemoryError: Java heap space一个2D小游戏也能内存溢出?排查之后发现问题出在三个地方。
第一是单位死亡后没有从列表移除。我有个gameState.getUnits()的ArrayList,单位死亡时只是把状态标记为DEAD,但没从列表里移除。虽然死亡单位不再更新、不再绘制,但它仍然占着数组位置,里面的图片资源也没释放。游戏时间越长,死亡单位越多,内存迟早爆炸。解决办法是在更新循环里定期清理:
units.removeIf(Unit::isMarkedForRemoval);第二是重复创建图片对象。Swing的ImageIO.read()每次调用都会从磁盘读文件、解码、创建新图片对象。单位初始化时如果每帧都读图片,内存不炸才怪。我在SpriteLoader里做了缓存,同一个路径的图片只加载一次:
public class SpriteLoader { private static final Map<String, BufferedImage> CACHE = new HashMap<>(); public static BufferedImage load(String path) throws IOException { BufferedImage img = CACHE.get(path); if (img == null) { img = ImageIO.read(SpriteLoader.class.getResourceAsStream(path)); CACHE.put(path, img); } return img; } }第三是事件监听器泄漏。使用EventBus后,如果给某些单位注册了监听器,但单位死亡后没有注销,监听器引用就会一直持有单位对象,导致单位永远无法被GC回收。这就是典型的“监听器泄漏”。解决办法是在单位死亡方法里调用eventBus.unregisterAll(this)。
5.2 卡顿排查:多线程更新和渲染的“脏读”问题
单位一多,游戏开始掉帧。我最初怀疑是渲染太慢,折腾半天发现是另一个问题——逻辑线程和渲染线程共享同一个单位列表,逻辑线程往列表里添加新单位时,渲染线程正遍历列表绘制,就可能抛ConcurrentModificationException,或者读到一半的数据(比如读取到一个位置还在初始化中的单位)。
解决方案是把逻辑状态和渲染状态分离。逻辑线程维护一个“游戏世界”对象,渲染线程只读一个“渲染快照”。每次逻辑更新完成后,把需要绘制的单位列表复制成一个不可变的List快照,交给渲染线程:
private volatile List<Unit> renderSnapshot = Collections.emptyList(); public void update() { gameState.update(); renderSnapshot = List.copyOf(gameState.getActiveUnits()); } public void render() { for (Unit unit : renderSnapshot) { drawUnit(unit); } }List.copyOf生成不可变列表,配合volatile修饰的引用,保证渲染线程看到的永远是完整一致的数据。这个方案逻辑简单,实测下来比加锁方案性能更好,因为没有锁竞争的开销。
5.3 卡顿的另一个元凶:频繁的自动装箱
单位越多,寻路越频繁,我发现GC越来越勤快。用VisualVM一分析,发现大量Integer对象在堆积——原来是寻路算法里用了HashMap<Integer, Node>,每次坐标到索引的转换都产生一次装箱操作。坐标范围一大,装箱对象数量惊人。
优化方式是把HashMap<Integer, Node>换成Node[]数组,用索引直接定位:
private final Node[] nodes; private Node getNode(int x, int y) { int index = y * width + x; if (nodes[index] == null) { nodes[index] = new Node(x, y); } return nodes[index]; }数组访问比HashMap快,而且没有装箱开销。这个优化让寻路线程的GC频率下降了大约一半。
5.4 Swing的线程陷阱:为什么我的画面偶尔“花掉”
Swing不是线程安全的,所有UI操作必须在EDT线程执行。我在项目早期犯过一个错误:在逻辑线程里直接调用panel.repaint(),结果画面上偶尔出现残影和撕裂。后来改成通过SwingUtilities.invokeLater把重绘请求交给EDT:
public void requestRender() { SwingUtilities.invokeLater(this::repaint); }这个问题不算难,但非常容易踩。如果你写Swing游戏,从第一天就要养成“渲染操作只在EDT里做”的习惯,否则越到后期越难改。
6. 源码编译运行与二次开发:从“能跑”到“会改”
源码拿到手,第一步当然是想看到游戏跑起来。这里我整理了一份适合所有操作系统的运行指南,顺便把源码的关键入口类、核心对象的生命周期梳理一遍,方便你在此基础上做二次开发。
6.1 编译与运行环境
这个项目是标准Maven工程,依赖列表里只有一个JUnit(测试用),运行时不需要额外的第三方库。前提是你的机器装好了JDK 8或更高版本。
# 克隆或解压源码后,进入项目根目录 cd warcraft-java # 编译并打包 mvn clean package # 运行游戏 java -jar target/warcraft-java-1.0.0.jar如果你没装Maven,也可以直接用javac编译:
javac -encoding UTF-8 -d out $(find src/main/java -name "*.java") java -cp out com.warcraft.core.GameMain启动后会出现一个800x600的窗口,左侧是主游戏区,右下角是小地图,上方是资源面板。鼠标左键框选单位,右键移动/攻击,就这么简单。
6.2 核心类与启动流程
游戏入口是GameMain,它的职责很简单:创建主窗口,初始化地图、玩家和AI,启动游戏循环线程。我把启动代码精简成下面这样,你可读一下整个过程:
public class GameMain { public static void main(String[] args) { SwingUtilities.invokeLater(() -> { GameFrame frame = new GameFrame(); frame.setVisible(true); frame.startGame(); }); } }启动后发生的事情依次是:
GameFrame创建窗口,设置布局。GamePanel(游戏画布)初始化,设置双缓冲。GameState创建地图、玩家状态、AI控制器。GameLoop线程启动,进入主循环。
6.3 二次开发:加一个新兵种需要动几个文件
很多读者会问,拿到源码之后怎么练手?我最推荐的第一个练习,是给游戏加一个新兵种。这个需求涉及源码的各个层面,但每一步都很清晰。
- 定义兵种属性:在
UnitType枚举里新增一个类型,比如ARCHER,设置好血量、攻击力、攻击范围、移动速度、训练费用。 - 准备图片素材:把一张32x32的PNG图片放进
resources/sprites/units/目录。 - 注册生产逻辑:在
Building(兵营)的生产列表里加入新兵种。 - AI策略调整:如果想让人工智能也用新兵种,在
AIController的训练策略里加一行判断。 - 测试平衡性:跑几局,观察新兵种的数值是不是过于IMBA,调整
UnitType里的参数。
这个练习做完,你就基本掌握了整个项目的所有核心模块。接下来可以试试加一个新地图、给建筑增加“升级科技”机制,甚至做局域网联机对战——架构都留好了扩展口。
6.4 拿来学习比拿来玩更有价值
说句实在话,这个项目作为游戏,可玩性跟商业RTS没法比。它真正的价值,在于你可以把代码一行行读下来,理解那些游戏背后的计算机原理。我曾经把这个项目的源码发给一个刚学完Java基础的朋友,他花了两个星期对照着视频教程逐行过了一遍,然后跟我说:“感觉以前学的那些List、Map、线程、IO,终于活过来了。”
这就是我写这篇文章的初衷。源码不是用来收藏的,游戏也不是只用来玩的。把它拆开、看懂、改坏、再修好,这个过程里学到的东西,比背十道面试题都实在。
最后分享一个我的习惯:拿到任何开源游戏源码,先跑起来,再挑一个自己最感兴趣的功能(比如寻路),单独把相关类拷出来写成最小Demo,搞明白之后再回到完整项目里看它是怎么被调用的。这样学,一次只啃一个点,效率远超从头到尾通读源码。
本文还有配套的精品资源,点击获取