ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

植物大战僵尸Java+Swing实战:用面向对象和游戏循环串起Java基础

植物大战僵尸Java+Swing实战:用面向对象和游戏循环串起Java基础 简介这个项目是基于Java和Swing的经典塔防游戏复刻面向Java初学者和游戏开发爱好者涵盖Swing GUI设计、多线程编程、碰撞检测、音效处理与数据持久化等知识点可帮助理解游戏循环、实体类设计与OOP编程思路。资源包为zip压缩包共148个文件大小3.77MB包含64个png图片素材、24个jpg图片、38个class编译文件和19个java源码另有classpath、project等工程配置源码与编译文件并存便于直接运行和对照学习。已有2337人学习下载压缩包内包含Controller、Plant、Grid及多个Thread类等关键类文件可从源码中学习子弹移动线程、金币判断线程、僵尸移动等核心逻辑实现是一份适合Java游戏开发入门与实践的完整参考项目。1. 植物大战僵尸JavaSwing版一个把Java基础串起来的小游戏项目如果你正在学Java学完基础语法、面向对象、集合和Swing之后总有一种「学了不知道能干嘛」的悬浮感植物大战僵尸JavaSwing这个项目就是用来打破这种感觉的。它本质上是一个用Java语言配合Swing图形库实现的简化版塔防游戏涵盖对象建模、事件监听、碰撞检测、动画刷新和状态管理能让你把零散的知识点拼成一个完整可运行的程序。这个项目特别适合刚学完Java基础、准备找练手项目的人也适合准备Java面试前想复习面向对象设计的人——它能让你亲手看到类、继承、多态和集合在真实游戏里是怎么协作的。2. 用Swing搭游戏画布JFrame、JPanel与游戏主循环的最小结构2.1 为什么选Swing而不是JavaFX从项目诉求看选型不少人一开始会纠结都2026年了桌面Java是不是该上JavaFX我的看法是这个项目用Swing反而更合适。原因有三条第一Swing是JDK自带的不需要额外配置JavaFX SDK和模块系统双击就能跑学习成本最低第二这个项目的核心目标是练Java基础而不是学GUI框架Swing的组件模型足够简单能把精力留给游戏逻辑第三网上现成的资料、报错案例大部分都是Swing的遇到问题更容易检索到答案。JavaFX的优势是样式和动画管线但它的学习曲线对新手并不友好。等你能用Swing把游戏跑通再切JavaFX会发现思路完全一致同样是舞台Stage/Frame、场景Scene/ContentPane和刷新循环AnimationTimer/Timer的组合只是API换了一层皮。2.2 两条游戏循环路线Swing Timer与while线程的区别游戏和普通CRUD应用最大的区别在于它需要持续的「心跳」——每过一小段时间就更新一遍所有游戏对象的位置、状态然后重绘画面。这个心跳就是游戏主循环Game Loop。在Swing里实现主循环有两条路一条是javax.swing.Timer一条是new Thread()里跑while(true)循环。两条路的本质区别在于线程归属。javax.swing.Timer的回调是放在Swing事件调度线程EDTEvent Dispatch Thread上执行的你在回调里改组件状态、调用repaint()都不会触发线程安全问题。而Thread while的方式里你在子线程修改Swing组件状态是违反复位规则EDT独占规则的轻则界面刷新异常重则直接抛异常崩溃。我一般会直接选javax.swing.Timer把间隔设为16毫秒约60FPS。需要说明的是16ms只是理想值Swing的Timer精度有限实际触发间隔可能有几毫秒浮动所以驱动游戏逻辑时最好用真实流逝的时间delta time而不是按固定帧数计算。2.3 画布代码GameFrame和GamePanel的最小实现先搭一个最基础的双类骨架GameFrame负责窗口GamePanel负责绘制和逻辑更新。public class GameFrame extends JFrame { public GameFrame() { setTitle(Plants vs Zombies - Java Swing Demo); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setResizable(false); setLocationRelativeTo(null); GamePanel panel new GamePanel(); setContentPane(panel); pack(); // 按panel的preferredSize调整窗口大小 setVisible(true); } public static void main(String[] args) { SwingUtilities.invokeLater(GameFrame::new); } }SwingUtilities.invokeLater是把窗口创建任务丢到EDT上执行这是Swing程序的固定写法主线程直接new窗口在多数机器上没问题但在极端场景下会出竞态条件养成这个习惯以后写Swing工具类都受益。pack()配合setPreferredSize能避免手动算窗口边框尺寸。public class GamePanel extends JPanel { private final GameState state new GameState(); private Timer timer; private long lastFrameTime System.currentTimeMillis(); public GamePanel() { setPreferredSize(new Dimension(1000, 600)); setBackground(new Color(120, 190, 90)); setFocusable(true); timer new Timer(16, e - gameLoop()); timer.start(); } private void gameLoop() { long now System.currentTimeMillis(); float deltaTime (now - lastFrameTime) / 1000f; lastFrameTime now; state.update(deltaTime); repaint(); } Override protected void paintComponent(Graphics g) { super.paintComponent(g); state.draw(g); } }这里我把游戏状态抽成了一个GameState类而不是把所有逻辑堆在JPanel里。paintComponent只负责画gameLoop只负责更新界面组件不掺和业务逻辑后面加植物、加僵尸都是在GameState里扩展。deltaTime的单位是秒它能让游戏速度在不同机器上保持一致——同样一段移动逻辑在60帧和30帧的机器上位移距离相同。注意一点Timer启动前最好确认GamePanel已经完成了初始化否则回调里可能拿到空引用。timer.start()放在构造方法末尾就足够安全了。3. 植物怎么做面向对象设计基类、派生类和种植逻辑3.1 用Plant基类收拢共性血量、阳光消耗和攻击行为植物大战僵尸里植物种类很多向日葵产阳光、豌豆射手射豌豆、坚果墙扛伤害但它们都有公共属性血量、所在格子行、列、当前的冷却时间、种植所需的阳光消耗。面向对象编程Java里最自然的做法就是提取一个Plant抽象基类把这些共性的字段和行为收拢起来。public abstract class Plant { protected int hp; // 生命值 protected int sunCost; // 种植所需阳光 protected int row, col; // 所在格子坐标 protected float cooldown; // 当前剩余冷却秒为单位 protected float x, y; // 实际绘制坐标 public Plant(int hp, int sunCost, int row, int col, float x, float y) { this.hp hp; this.sunCost sunCost; this.row row; this.col col; this.x x; this.y y; this.cooldown 0; } public boolean isDead() { return hp 0; } public void takeDamage(int damage) { hp - damage; if (hp 0) hp 0; } public int getSunCost() { return sunCost; } public int getRow() { return row; } public float getX() { return x; } public float getY() { return y; } /** * 每帧更新逻辑由具体植物决定行为 * param dt 距离上一帧的秒数 * param state 用于访问僵尸列表、阳光数量等全局状态 */ public abstract void update(float dt, GameState state); public abstract void draw(Graphics g); }基类定了三件事数据hp、sunCost、row/col、通用行为受击、死亡、抽象行为update和draw。update是每帧推进的入口具体植物在里面写自己的逻辑draw负责把自己画到画布上。这样写着的好处是游戏循环第2章的state.update可以统一遍历ListPlant不需要判断植物类型——这就是多态在起作用也是java基础面试题里常问的「面向对象三大特性」最直观的体现。3.2 向日葵与豌豆射手派生类怎么不重复代码有了基类具体植物类只需要实现差异部分。向日葵的差异是每隔一段时间产阳光豌豆射手的差异是朝左侧发射豌豆。public class Sunflower extends Plant { private float produceTimer 0f; private static final float PRODUCE_INTERVAL 10f; public Sunflower(int row, int col, float x, float y) { super(80, 50, row, col, x, y); } Override public void update(float dt, GameState state) { produceTimer dt; if (produceTimer PRODUCE_INTERVAL) { produceTimer - PRODUCE_INTERVAL; state.addSun(new Sun(x, y 20)); // 在植物上方生成一颗阳光 } } Override public void draw(Graphics g) { g.setColor(Color.YELLOW); g.fillOval((int) x - 18, (int) y - 25, 36, 50); } }public class Peashooter extends Plant { private float shootTimer 0f; private static final float SHOOT_INTERVAL 1.5f; public Peashooter(int row, int col, float x, float y) { super(100, 100, row, col, x, y); } Override public void update(float dt, GameState state) { if (state.hasZombieInRow(row, x)) { // 本行前方有僵尸才攻击 shootTimer dt; if (shootTimer SHOOT_INTERVAL) { shootTimer - SHOOT_INTERVAL; state.addProjectile(new Projectile(x 20, y, row)); } } else { shootTimer 0f; } } Override public void draw(Graphics g) { g.setColor(Color.GREEN); g.fillOval((int) x - 20, (int) y - 20, 40, 40); g.setColor(Color.DARK_GRAY); g.fillRect((int) x 10, (int) y - 6, 22, 12); // 豌豆射手炮管 } }PRODUCE_INTERVAL和SHOOT_INTERVAL写成static final常量而不是魔法数字这是代码可读性的底线。update里用了state.hasZombieInRow(row, x)判断前方是否有僵尸避免植物在没僵尸时做无用功——这在原版游戏里也是基本AI逻辑。注意shootTimer的重置用了-而不是 0这样能保留多出来的时间攻击节奏不会因为卡顿而突然乱掉。3.3 鼠标点击种植事件监听与阳光消耗植物不是一开始就摆上的玩家需要用鼠标在合适的格子点击再消耗阳光在格子上种植。这里用的是MouseListener注意Swing有MouseAdapter这个适配器类不需要把五个回调方法全部实现。public class GamePanel extends JPanel { private PlantType selectedPlant PlantType.PEASHOOTER; public GamePanel() { // ... 原有初始化代码 addMouseListener(new MouseAdapter() { Override public void mousePressed(MouseEvent e) { handleClick(e.getX(), e.getY()); } }); } private void handleClick(int screenX, int screenY) { if (selectedPlant null) return; int row (screenY - GROUND_OFFSET_Y) / CELL_HEIGHT; int col (screenX - GROUND_OFFSET_X) / CELL_WIDTH; if (row 0 || row ROWS || col 0 || col COLS) return; int cost selectedPlant.getSunCost(); if (state.getSun() cost) return; // 阳光不够直接忽略 if (state.hasPlantAt(row, col)) return; // 格子被占直接忽略 state.plant(selectedPlant, row, col); state.spendSun(cost); } }这段逻辑里有个容易被忽略的细节mousePressed拿到的是像素坐标要先换算成行列坐标再做边界检查。换算公式依赖三个常量GROUND_OFFSET_X/Y草坪区域左上角相对于面板的偏移和CELL_WIDTH/HEIGHT格子尺寸。我见过很多新手跳过边界检查直接索引数组结果点击窗口边缘就抛ArrayIndexOutOfBoundsException。阳光消耗的判断要放在种植之前而且要用而不是因为阳光数量等于价格时应该允许种植。state.hasPlantAt(row, col)的存在是为了防止同一格子上种两颗植物这个检查看起来简单不加的话后果很严重——同一格的植物会被重复绘制碰撞体积重叠僵尸啃食判定也会错乱。4. 僵尸与碰撞检测实现中躲不开的4个坑4.1 僵尸的行为逻辑生成、移动与啃咬判定僵尸的逻辑和植物正好镜像植物向左攻击僵尸向右生长并从右侧出现向左移动。僵尸需要一个状态机移动中碰到植物就切换成攻击状态每帧对所在格的植物造成伤害植物被啃死后继续移动。public class Zombie { private int hp 200; private float x, y; // 当前位置 private int row; private static final float SPEED 20f; // 每秒移动像素 private static final int ATTACK_DAMAGE 10; private static final float ATTACK_INTERVAL 0.8f; private float attackTimer 0f; private boolean isAttacking false; public void update(float dt, GameState state) { Plant blockingPlant state.getPlantAt(row, x); if (blockingPlant ! null) { isAttacking true; attackTimer dt; if (attackTimer ATTACK_INTERVAL) { attackTimer - ATTACK_INTERVAL; blockingPlant.takeDamage(ATTACK_DAMAGE); } } else { isAttacking false; attackTimer 0f; x - SPEED * dt; } if (x 0) { state.loseGame(); // 僵尸走进房子游戏结束 } } public void draw(Graphics g) { g.setColor(new Color(100, 80, 60)); g.fillOval((int) x - 18, (int) y - 35, 36, 50); } }僵尸移动有个坑state.getPlantAt(row, x)的判定范围。如果只判断僵尸的x坐标是否等于植物x坐标那因为浮点数计算结果很少恰好相等碰撞永远不会发生。正确的做法是判断「僵尸的左边缘是否越过植物的右边缘」或者用地毯式距离判定当僵尸x坐标落在植物中心点左右一个阈值内就认为碰到植物了。我上面的代码里getPlantAt内部一般会用Math.abs(zombieX - plantX) 40这种方式来做这个阈值要略大于植物绘制半径否则视觉上僵尸还没碰到植物就开始殴打空气了。僵尸的update高频调用state.getPlantAt如果每次遍历整个植物列表当植物多、僵尸多时性能就会肉眼可见地卡顿。常见做法是用二维数组Plant[][] grid按行列存储植物getPlantAt(row, x)直接根据x坐标反算列号再索引数组O(1)复杂度。这个优化在只有几十个对象的简化版里不明显但能让代码结构更贴近「可以用」的标准。4.2 子弹与僵尸的碰撞遍历删除的正确写法豌豆子弹向左飞行命中僵尸后造成伤害并消失。这里涉及一个集合操作的高频坑在遍历ListProjectile的同时删除元素。public class GameState { private final ListProjectile projectiles new ArrayList(); private final ListZombie zombies new ArrayList(); public void updateProjectiles(float dt) { IteratorProjectile projectileIt projectiles.iterator(); while (projectileIt.hasNext()) { Projectile p projectileIt.next(); p.update(dt); Zombie hit findZombieHitBy(p); if (hit ! null) { hit.takeDamage(20); projectileIt.remove(); // 命中后子弹消失 continue; } if (p.isOutOfBounds()) { projectileIt.remove(); // 飞出屏幕回收 } } } private Zombie findZombieHitBy(Projectile p) { for (Zombie z : zombies) { if (z.getRow() p.getRow() Math.abs(z.getX() - p.getX()) 30 !z.isDead()) { return z; } } return null; } }这里的核心写法是Iterator的remove()方法而不是list.remove(projectile)。如果在for (Projectile p : projectiles)循环里直接调用remove会立刻抛出ConcurrentModificationException——这是Java面试题里八股文常客在游戏项目里则是「跑着跑着突然崩溃」的典型凶手。原因在于增强for循环隐式使用了迭代器在迭代过程中修改modCount就触发了快速失败机制。另外注意我让Projectile.update先执行再做碰撞检测和删除。这个顺序是有讲究的——先更新位置再用新位置判定碰撞否则子弹会「穿透」僵尸一帧移动了50像素但碰撞判定用的是上一帧位置僵尸就被穿过去了。如果子弹速度更快甚至需要做更精细的「扫掠检测」把移动路径分成多段逐段判定不过这个简化版里子弹速度控制在300像素/秒以内单帧位移5个像素用距离判定足够了。4.3 避坑清单画面撕裂、Timer精度与并发修改写这个项目时有几个坑是几乎每台机器上都会踩一遍的我按「现象→原因→解决」的方式列在下面。坑1画面剧烈闪烁草坪和植物像在跳迪斯科。现象是每帧刷新时组件背景先涂白再画内容视觉上就闪。原因是没有开启双缓冲系统默认在paintComponent里直接画到屏幕上绘制过程可见。解决方法是确认JPanel的双缓冲开启——Swing组件默认isDoubleBuffered()是true但如果你在paintComponent里手动创建了大量中间对象比如每次都new一个Color或Font还是可能导致绘制耗时过长。需要优化时把颜色、字体声明成static final常量在类加载时初始化而不是每帧创建。坑2Timer设置的16ms实际跑起来间隔跳到30ms甚至更高。现象是游戏运行不稳定时快时慢向日葵产阳光的间隔差距明显。原因是javax.swing.Timer的精度依赖底层线程调度当系统负载高时精度会下降。解决方法是别让逻辑依赖固定的Timer频率而是用deltaTime真实流逝时间去驱动所有update。我的做法是所有位移乘以dt、所有计时累计加上dt这样即使帧率从60掉到30游戏速度依然稳定。坑3ConcurrentModificationException。现象是游戏运行几十秒后突然崩溃堆栈指向你遍历zombies或projectiles的那一行。原因是在遍历列表的过程中调用了add或remove破坏了迭代器的预期结构。解决方法是如果只是在update里遍历后删除用Iterator如果一个循环里同时有添加和删除比如射击产生子弹的同时清理飞出屏幕的子弹就先把待删除对象放入一个临时列表遍历结束后统一删除。我在GameState里的实际写法是所有植物、僵尸、子弹的集合更新放在同一帧的固定顺序里——先更新植物可能产生新子弹、再更新子弹可能碰撞删除、再更新僵尸避免同一个循环里「既产生又消灭」。坑4图片资源加载失败植物和僵尸全是黑色方块。现象是用ImageIO.read(new File(sunflower.png))读取图片时报IOException或者返回null程序没崩但界面完全是黑块。原因是用相对路径读取文件依赖「当前工作目录」而IDE的运行配置和打包成Jar后的目录结构完全不同。解决方法是把资源放在src/main/resources下用类加载器读取ImageIO.read(getClass().getResourceAsStream(/images/sunflower.png))。注意绝对路径要带前导斜杠表示从classpath根目录查找。class里的代码和资源一起打包进Jar之后这种读取方式依然有效。5. 给它加料性能验证、存档与扩展方向5.1 用帧耗时验证游戏循环是否有性能隐患做完主体功能后我建议你给游戏加一个简单的性能计量器按F3显示当前帧耗时和FPS。做法很简单在gameLoop里统计System.nanoTime()的差值维护一个滑动平均。当平均帧耗时超过25ms也就是低于40FPS时就该排查了。排查顺序一般是是不是paintComponent里做了重活比如每帧new Font是不是碰撞检测用了O(n²)暴力遍历是不是GC频繁这个检查习惯比多写几百行业务代码更值钱。5.2 用保存与读档练习Java序列化游戏能跑之后给它加一个存档功能是个很好的进阶练习。GameState实现Serializable接口把所有非静态字段标记好然后写入文件。注意图片、字体这些资源不能被序列化你存的是路径而不是图片对象本身所以这些字段要用transient修饰读档后再重新加载资源。5.3 从练手到作品扩展植物和僵尸功能成型后扩展方向很多加入樱桃炸弹范围伤害、土豆雷埋地后触发、寒冰射手减速效果每种新植物都对应一个新的派生类扩展成本很低。但记住每加一个植物都要把平衡性调一遍——阳光消耗、冷却、攻击力、范围这几个参数联动影响游戏难度。我的经验是先改一个参数跑一局看效果不要一次改三个。玩起来手感不好往往不是程序问题是数值问题。这个项目我从「能用」到「能玩」调整过不下五轮阳光初始值从300调到150才找到压力感僵尸生成间隔从每8秒一只调到每12秒一只才不至于让新手崩溃。各种调参与踩坑的记录建议你保留一份。用这套方案跟着走一遍你会把Java基础、面向对象、Swing和集合遍历都串起来希望帮到你。本文还有配套的精品资源点击获取
返回列表