ARTICLE DETAIL

资讯详情

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

Java实现植物大战僵尸游戏:Swing开发与碰撞检测实战解析

Java实现植物大战僵尸游戏:Swing开发与碰撞检测实战解析 简介这是一份基于Java实现的植物大战僵尸游戏完整课程设计资源面向高校Java课程设计、期末实训及对游戏开发感兴趣的初学者。资源包含可运行的游戏完整Java源码覆盖植物种植、僵尸生成、碰撞检测、关卡判定等核心逻辑并配有课程论文Word文档论文中附有系统设计思路、模块划分与实现说明。游戏采用经典玩法玩家消耗阳光种植不同类型植物抵御僵尸进攻关卡内僵尸全部被消灭则玩家获胜若僵尸越界到达地图右边界则失败。资源共209个文件以56个Java源码文件、100个gif动画素材和24个wav音效为三大主体另有png图片、xml配置等辅助文件用于界面与资源管理压缩包整体约65.92MB目录结构清晰便于阅读、调试与二次开发。目前已有1801人学习下载适合需要参考完整课程设计源码、撰写课程论文或学习Java游戏开发逻辑的同学。1. 基于Java实现的植物大战僵尸游戏一个能跑通完整塔防闭环的练手项目你有没有过这种经历Java语法背得很熟集合、泛型、异常处理都学过但真让你写一个有窗口、有动画、有交互的东西还是不知道从哪下手。基于Java实现的植物大战僵尸游戏zip包就是用来补上这段空白的。它不是一个演示用的半成品而是一个种植、产出、射击、碰撞、僵尸推进、胜负判定全都有的大作业级项目。拿到手能直接编译运行也能改代码观察游戏行为跟着变化。适合刚学完Java SE、想找项目巩固面向对象编程的人也适合准备蓝桥杯或软考时想练手写设计的人。更重要的是它把多线程、事件监听、碰撞检测这些平时只能在八股文里见到的概念全部变成了看得见摸得着的代码。2. 先拆核心类这个游戏到底由哪些对象协作组成2.1 入口与主窗口从main方法到游戏画布常见做法是把入口类单独放不跟游戏逻辑混在一起。一个最简单的Main类只负责三件事创建JFrame窗口、设置关闭策略、把GamePanel塞进窗口并启动游戏线程。窗口标题、宽高、缩放比例这类常量放进一个专门的Config类不要散落在各处。public class Main { public static void main(String[] args) { JFrame frame new JFrame(Plants vs Zombies - Java Edition); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setResizable(false); frame.setContentPane(new GamePanel()); frame.pack(); frame.setLocationRelativeTo(null); frame.setVisible(true); } }这里的GamePanel继承JPanel并重写paintComponent所有游戏画面都在这个面板上绘制。frame.pack()会按GamePanel重写的getPreferredSize()自适应窗口大小。窗口大小建议直接用地图网格像素计算比如9列乘5行、每格80像素再加上顶部信息栏高度得到固定尺寸避免后期缩放带来坐标换算问题。2.2 Plant与Zombie的继承体系把共同行为抽到父类游戏里植物和僵尸都有生命值、都有位置、都在每帧更新状态。最自然的抽象是各建一个父类Plant负责阳光经济与攻击Zombie负责移动与啃食。父类里放通用字段子类只放差异化的数值和行为。public abstract class Plant { protected int hp; protected int row; protected int col; protected int sunCost; protected int healthMax; protected long lastAttackTime; protected int attackInterval; // 攻击间隔单位毫秒 protected BufferedImage[] frames; protected int currentFrame; public Plant(int row, int col, int hp, int sunCost, int attackInterval) { this.row row; this.col col; this.hp hp; this.healthMax hp; this.sunCost sunCost; this.attackInterval attackInterval; } public abstract void attack(ListZombie zombies, ListBullet bullets); public boolean isAlive() { return hp 0; } }把attack做成抽象方法后豌豆射手和向日葵各自实现自己的攻击逻辑。向日葵的attack方法里不生成子弹而是把阳光值加到全局计数器上。这样GamePanel的主循环统一调用每个植物的attack方法不需要用instanceof判断植物类型新增植物时也只需继承Plant并重写attack符合开闭原则。2.3 枚举驱动植物类型用Java数据类型减少魔法值写游戏最怕满屏魔法数字一个植物编号用了两套映射改起来会翻车。常见做法是用枚举把植物类型、名称、生命值、阳光消耗绑定在一起。public enum PlantType { SUNFLOWER(0, 向日葵, 80, 50, 3000, 0), PEASHOOTER(1, 豌豆射手, 100, 100, 4000, 30), WALLNUT(2, 坚果墙, 400, 50, 0, 0); public final int id; public final String name; public final int maxHp; public final int sunCost; public final int produceInterval; // 向日葵产阳光间隔 public final int bulletDamage; // 豌豆伤害非攻击植物为0 PlantType(int id, String name, int maxHp, int sunCost, int produceInterval, int bulletDamage) { this.id id; this.name name; this.maxHp maxHp; this.sunCost sunCost; this.produceInterval produceInterval; this.bulletDamage bulletDamage; } }这样设计有个直接的好处UI层渲染植物选择栏时可以直接遍历PlantType.values()拿到所有可种植类型价格也不需要从另一个配置文件里读取。如果你想把数值和代码解耦也可以把这个枚举改成从properties或json加载但课程设计级别用枚举就足够了而且类型安全。2.4 容器选择与每帧遍历ArrayList为什么够用游戏运行中植物和僵尸的数量级通常是几十个不用纠结性能。植物用二维数组管理按下标访问最方便僵尸和子弹用ArrayList因为会频繁增删。遍历僵尸时不能直接在for-each里移除元素常见做法是用迭代器或者倒序遍历。// 倒序删除删除元素时不会影响未遍历元素的下标 for (int i zombies.size() - 1; i 0; i--) { if (!zombies.get(i).isAlive()) { zombies.remove(i); } }这个细节很容易在面试中被问到为什么for-each删除会抛ConcurrentModificationException但在游戏里更重要的是它的实际语义正序删元素会让后面的元素下标前移导致跳过一个僵尸。用倒序遍历一次就能同时完成更新和清理。3. 把交互循环跑起来主循环、事件分发与碰撞判定3.1 用Swing Timer驱动帧循环还是用线程循环这是游戏最核心的架构决策。Swing Timer的好处是回调自动在事件分发线程EDT上执行绘画不会出现并发修改缺点是精度一般独立线程的好处是控制精度高但所有UI操作都要通过SwingUtilities.invokeLater切回EDT。对于植物大战僵尸这种慢节奏游戏我建议用Swing Timer代码简单且不会遇到奇怪的线程问题。public class GamePanel extends JPanel implements ActionListener { private static final int FRAME_INTERVAL 16; // 约60FPS private Timer gameTimer; private int gameTime; private void startGameLoop() { gameTimer new Timer(FRAME_INTERVAL, this); gameTimer.start(); } Override public void actionPerformed(ActionEvent e) { gameTime FRAME_INTERVAL; updateGameState(); repaint(); } }Timer的构造参数分别是延迟毫秒数和监听器。FRAME_INTERVAL设16毫秒看起来是60帧但由于Swing Timer本身不是实时定时器实际帧率会略低于60帧这个项目完全够用。如果你要做的是弹幕游戏或音游这个方案就不合适了那类游戏需要固定时间步长的独立线程加插值渲染。3.2 双缓冲为什么很重要paintComponent与闪烁问题Java Swing的JPanel默认就开了双缓冲所以很多人感知不到这个问题。但如果你直接继承Canvas或者自己重写了update方法关闭双缓冲后画面会闪烁得让人怀疑人生。原理是把所有绘制先画到一张内存图片上再一次性把整张图片拷贝到屏幕上避免逐像素绘制时产生的撕裂感。Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2d (Graphics2D) g; // 绘制草坪背景 for (int row 0; row ROWS; row) { for (int col 0; col COLS; col) { g2d.drawImage(GRASS_TILES[(row col) % 2], col * GRID_SIZE, row * GRID_SIZE, null); } } // 绘制植物 for (Plant plant : plants) { plant.draw(g2d); } // 绘制僵尸 for (Zombie zombie : zombies) { zombie.draw(g2d); } }注意paintComponent里不要做任何游戏逻辑更新只负责根据当前状态绘制画面。否则绘制耗时会导致逻辑和渲染耦合帧率波动时游戏速度会发生变化。这是写Swing游戏的一个极常见误用很多人把碰撞检测也写进paintComponent里一重绘就全量检测一次性能浪费极大。3.3 鼠标点击与网格换算为什么点不准种植交互的体验取决于鼠标坐标到网格坐标的换算是否正确。监听mousePressed事件拿到的是JPanel内的相对坐标直接用除以格子宽度的方式换算但必须考虑GamePanel是否设置了边框或内边距。addMouseListener(new MouseAdapter() { Override public void mousePressed(MouseEvent e) { int col (e.getX() - BOARD_X_OFFSET) / GRID_SIZE; int row (e.getY() - BOARD_Y_OFFSET) / GRID_SIZE; if (row 0 row ROWS col 0 col COLS) { handlePlantSelection(row, col); } } });BOARD_X_OFFSET和BOARD_Y_OFFSET是游戏画布相对JPanel左上角的偏移量通常顶部有一条阳光数和选择栏这部分高度会占掉几十像素。如果不减偏移点击最上面一行网格时会被系统错误识别成点击选择栏。设置格子大小建议用80像素或100像素太小了手指和鼠标都容易误触太大会让顶层僵尸走得太慢。3.4 碰撞检测矩形相交、坐标偏移与攻击判定植物大战僵尸里的碰撞分两类子弹与僵尸的碰撞、僵尸与植物的碰撞。最简单可靠的方式是矩形相交检测给每个实体一个getBounds()方法返回Rectangle然后调用intersects判断。public Rectangle getCollisionBox() { // 碰撞盒比贴图小一圈x和y都加偏移宽高各缩20像素 return new Rectangle(x 10, y 5, width - 20, height - 10); } // 子弹更新时检测 for (Bullet bullet : bullets) { Rectangle bulletBox bullet.getCollisionBox(); for (Zombie zombie : zombies) { if (!zombie.isDead() bulletBox.intersects(zombie.getCollisionBox())) { zombie.takeDamage(bullet.getDamage()); bullet.setDead(true); break; } } }碰撞盒为什么要比贴图小一圈这是血泪经验。植物大战僵尸的贴图大部分是竖向的植物的茎叶和僵尸的手臂会伸出到实际占位格之外如果用整个贴图矩形检测会出现豌豆在空中被拦下的观感。每个实体建议按比例收缩10%-20%碰撞体集中到主体躯干上。判断子弹打到僵尸后要break因为一颗子弹只能命中一个僵尸理想情况是命中离起点最近的僵尸如果要更精确可以先把命中的僵尸按x坐标排序再选最小的那个。4. 从图片加载到关卡生成把资源与逻辑分离4.1 图片资源加载ClassLoader.getResource的正确用法很多初学者用绝对路径比如C:/Users/xxx/plant.png来加载图片项目一换电脑就崩了。正确做法是把图片放进src下的resources目录通过类加载器读取这样打包成jar后也能正常找到资源。private BufferedImage loadImage(String path) { try (InputStream is getClass().getClassLoader().getResourceAsStream(path)) { if (is null) { throw new IllegalArgumentException(资源不存在: path); } return ImageIO.read(is); } catch (IOException e) { throw new RuntimeException(图片加载失败: path, e); } }这里用了try-with-resources自动关闭输入流避免野资源占用文件句柄。getResourceAsStream的路径是相对于classpath根目录的如果你把图片放在src/main/resources/images/那path要写成images/peashooter.png。一个常见踩坑是eclipse和idea的默认资源目录不同eclipse是src目录下直接建文件夹idea要标记为resources根目录。如果加载出来是null优先检查资源是否打进了classpath。4.2 多帧动画一张雪碧图切成帧序列游戏里的植物种下去后会有轻微的摇晃动画僵尸走路要达到比较好的效果至少要两帧交替播放。常见做法是减少文件数一张图按固定尺寸切割。public BufferedImage[] cutFrames(BufferedImage sheet, int cols, int rows, int frameWidth, int frameHeight) { BufferedImage[] frames new BufferedImage[cols * rows]; for (int y 0; y rows; y) { for (int x 0; x cols; x) { frames[y * cols x] sheet.getSubimage( x * frameWidth, y * frameHeight, frameWidth, frameHeight ); } } return frames; }getSubimage返回的图片与原始雪碧图共享数据数组不能对帧做修改否则会污染整张雪碧图。如果你需要缩放或裁剪帧创建一个新的BufferedImage再画进去。动画播放不要用帧计数自增而应该用时间累计累计时间超过帧间隔就切到下一帧这样动画速度与机器性能无关。private void updateAnimation() { frameAccumulator FRAME_INTERVAL; if (frameAccumulator frameDuration) { currentFrame (currentFrame 1) % frames.length; frameAccumulator 0; } }4.3 地图分区绘制只在视野内才渲染游戏窗口固定900乘600左右地图也就这么大全量绘制还能接受。但如果你把这个项目扩展成横板过关式的地图就需要做可见区裁剪了。基本思路是只绘制与可视矩形相交的物体。Rectangle viewRect new Rectangle(0, 0, getWidth(), getHeight()); for (Zombie zombie : zombies) { if (zombie.getBounds().intersects(viewRect)) { zombie.draw(g2d); } }这段代码的意义在于为后期扩展铺路实际数据量小时看不出来性能差异。但配合后文提到的对象复用可以避免每帧创建大量矩形对象减少GC压力。4.4 关卡波次生成用一个配置表描述僵尸出现的节奏打僵尸游戏的趣味全部来自波次设计第一波先来三个普通僵尸中间给玩家喘息时间然后来一波带路障的。用硬编码在代码里写wave逻辑会让主循环变得又臭又长。常见做法是定义一个波次配置类。public class WaveConfig { public final int delaySeconds; public final String zombieType; public final int count; public final int spawnIntervalMillis; public WaveConfig(int delaySeconds, String zombieType, int count, int spawnIntervalMillis) { this.delaySeconds delaySeconds; this.zombieType zombieType; this.count count; this.spawnIntervalMillis spawnIntervalMillis; } }实际关卡中用一个ArrayList排好所有WaveConfigGamePanel每帧扫描表头判断游戏时间是否达到delaySeconds到了就按spawnIntervalMillis间隔生成僵尸。生成僵尸的位置固定在屏幕右边界外x坐标略大于面板宽度让僵尸走进来时有一个“刚刚出现”的过渡感。随机行数用Random.nextInt(ROWS)不要用Math.random乘行数再强转可读性差别不大但后者容易在边界出问题。5. 避坑碰撞判定偏移、线程死锁与内存泄漏的三类翻车现场5.1 植物种不上点击没反应现象鼠标点了网格植物没有种下去控制台也没报错。原因通常是两类一是鼠标坐标没减画布偏移量算出来的网格越界被拦截二是点击处理里没有检查当前格是否已有植物。检查方法是在handlePlantSelection里打印row和col如果row始终是0或负数就确认是偏移量问题如果打印正确但没反应检查格子占用数组是否初始化成默认值。解决方式是统一走坐标换算方法并在种植物前判断grid[row][col] ! null种完立即赋值避免双击一棵植物同时种两次。5.2 僵尸穿过植物继续走现象僵尸走到植物面前没有停直接穿过植物走到房子。原因碰撞检测写的是x坐标比较比如zombie.x plant.x就算碰撞但植物贴图宽度80像素僵尸走到植物中心时x坐标已经比植物的x大很多条件永远不成立。解决方式是用矩形相交检测替代单点比较并且每帧检测一次而不是只在僵尸位置变化时检测。还有一种隐蔽情况是僵尸的List里存的是普通僵尸但绘制时画的是另一套坐标这种多半是备份了引用而不是复制对象导致的。5.3 豌豆射手攻击时灵时不灵现象植物刚种下的时候打得出子弹过一会儿就不打了或者攻击频率忽快忽慢。原因是把攻击间隔判断写成了“当前时间大于上次攻击时间加间隔”但游戏里所有实体应该用自己的内部计时器Latency累加而不是依赖System.currentTimeMillis()。主循环暂停恢复时用System.currentTimeMillis会导致计时突变出现连续攻击几发后又长时间沉默。解决方式是给Plant加一个tick(int dt)方法在GamePanel统一传FRAME_INTERVAL进来时间累计靠累加器完成。5.4 运行一段时间后游戏越来越卡现象刚启动很流畅玩五分钟开始掉帧后期波次越多越明显。原因有两个方面一是每帧在update方法里创建了新的ArrayList或者Rectangle比如收集死亡实体时用stream.filter生成新list老对象没有及时清理二是僵尸死亡后的尸体对象还留在List里虽然isAlive()为false但没有真正移除。排查方法是在update里统计每次update后的集合大小打印到控制台对比开局和现在的差距。解决方式是用迭代器移除死亡实体子弹和特效也不例外任何实体死亡都要从所属容器里remove不要只打一个标记位。5.5 声音播放阻塞导致画面冻结现象播放音效的瞬间游戏卡顿一下如果用的是循环背景音乐会直接卡死。原因是AudioClip的play方法在某些老JDK实现里调用时会阻塞到播放完在游戏循环线程里播放长音效就会冻结画面。解决方式不要在游戏主循环里直接new AudioClip并play而是在初始化时就预加载所有音效播放时调用新线程执行或者干脆只放一次性的短音效背景音乐用Clip的loop方法在独立线程里循环。如果你的项目里引入了第三方音效库要注意音频格式转换Java原生支持wav和aump3多半要额外依赖。5.6 打包成jar后图片和音频消失现象在IDE里运行一切正常打jar包后图片不显示、音效不出声。原因资源文件没被打进jar包或者读取方式用的是File而不是流。解决方式在构建工具中把resources目录标记为包含在产物里main方法内部所有资源读取统一走getClass().getClassLoader().getResourceAsStream()。马文和Gradle配置比较常规但另一个坑是打包后有路径大小写不一致linux服务器上运行直接报错因为解压后的文件系统区分大小写。检查你代码里写的路径字母大小写是否和resource目录完全一致。6. 再往前走一步存档系统与性能剖析实战当这个项目的主要功能跑通之后不妨做两个锦上添花的扩展一个是用序列化保存游戏进度另一个是给游戏加内存监控和性能日志。前者能让你体会到Java对象持久化在真实项目里的用法后者能帮你找到代码里的隐形性能瓶颈这个能力在面试聊项目时很加分。存档系统的常见做法是对GameStatus类实现Serializable接口把当前游戏时间、阳光数、网格上的植物列表、僵尸列表全部打包。植物和僵尸类需要保证字段都可序列化图片帧这种瞬态资源要用transient关键字排除加载存档后重新通过图片路径恢复。用一个示例方法说明核心逻辑public void saveGame(String filePath) throws IOException { ListPlant plantSnapshot new ArrayList(plantList); ListZombie zombieSnapshot new ArrayList(zombieList); GameStatus status new GameStatus( gameTime, sunAmount, waveIndex, plantSnapshot, zombieSnapshot ); try (ObjectOutputStream oos new ObjectOutputStream(new FileOutputStream(filePath))) { oos.writeObject(status); } }序列化不是所有对象都能自动处理如果Plant里持有BufferedImage它会因为不可序列化直接抛NotSerializableException。所以必须在建模时就把“运行时渲染数据”和“存档逻辑数据”分开图片帧不参与存档。加载存档同理用ObjectInputStream读回GameStatus后要重建植物的图片帧并重新绑定GamePanel引用。性能剖析可以做一个简单的FPS计数器每帧在累计时间满一秒时打印帧率int frameCount 0; long lastFpsTime System.nanoTime(); // 在actionPerformed里调用updateStats() private void updateStats() { frameCount; long now System.nanoTime(); if (now - lastFpsTime 1_000_000_000L) { float fps frameCount * 1f / ((now - lastFpsTime) / 1_000_000_000f); System.out.printf(FPS: %.1f, 实体数量: %d%n, fps, plantList.size() zombieList.size()); frameCount 0; lastFpsTime now; } }不要小看这一行输出它能直观暴露很多黑匣子问题。帧率突然下跌的那一瞬间去对照实体数量就能知道是哪一波新增了什么东西导致的。我之前调试时发现向日葵的产阳光逻辑每帧都在遍历所有植物并调用一个含字符串拼接的日志方法日志输出成为最大的性能杀手。把日志级别调低之后帧率立刻恢复了这个排查过程比任何性能教程都有说服力。加了这两个扩展之后这个项目就不只是一个大作业了它变成了一个能体现持久化设计、资源生命周期管理和性能分析能力的完整作品。把做过的优化点和排查过程记下来面试时这就是你的真实项目经验。希望这些踩坑记录能帮到你少走几段弯路。本文还有配套的精品资源点击获取
返回列表