# MiniPZ-V2 **Repository Path**: frsf/MiniPZ-V2 ## Basic Information - **Project Name**: MiniPZ-V2 - **Description**: 植物大战僵尸 Plants V.S. Zombies JavaSwing版 V2版本 - **Primary Language**: Java - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 3 - **Forks**: 0 - **Created**: 2025-08-01 - **Last Updated**: 2025-12-14 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # MiniPZ-V2 一个基于Java的极简迷你植物大战僵尸游戏实现,开局即战斗,没有关卡设计。历时3天进行V2重制版开发,添加了配置文件设置、将各个对象进行了较大的重构,方便扩展。 感谢开源项目:https://gitee.com/MoonNi/plants_vs_zombies_java ,借鉴了很多设计思路及图片素材资源。 ## 项目截图 ![](readme/home.png) ![](readme/game.png) ## 项目特点 - 面向对象设计,结构清晰 - 包含基本的植物(豌豆射手、寒冰射手)与普通zom,但是保留了较大的可扩展性 - 使用配置文件实现数据驱动设计 - 内置背景音乐与音效控制 - 适合入门学习 ## 技术栈 - Java 11+ (JDK8的JFrame底层没有兼容电脑界面缩放) - Swing 图形界面 ## 目录结构说明 ``` src/ ├── main/ │ ├── java/ # Java源代码 │ │ └── top/frsf/ # 项目包结构 │ │ ├── action/ # 行为接口定义 │ │ ├── constants/ # 常量定义 │ │ ├── context/ # 场景上下文 │ │ ├── enums/ # 枚举类型(游戏状态、子弹类型等) │ │ ├── factory/ # 工厂模式实现 │ │ ├── input/ # 鼠标事件处理 │ │ ├── music/ # 音乐播放器 │ │ ├── object/ # 游戏对象基类与实现 │ │ ├── panel/ # 游戏主面板 │ │ ├── schedule/ # 定时任务 │ │ ├── starter/ # 场景启动器 │ │ └── utils/ # 工具类 │ └── resources/ # 资源文件 │ ├── music/ # 音乐文件 │ └── object/ # 图片资源 ``` ## 游戏元素 - **植物** - 豌豆射手(Pea):基础攻击植物 - 寒冰射手(SnowPea):减速效果攻击 - 双发射手(Repeater): 发射两枚豌豆 - **僵尸** - 普通僵尸(NormalZom):基础敌人 - 支持多种状态:移动、攻击、被减速等 - **子弹** - 普通豌豆(PeaBullet) - 寒冰豌豆(SnowPeaBullet) - **卡片** - 普通豌豆卡片(PeaCard) - 寒冰豌豆卡片(SnowPeaCard) - **环境** - 背景(Background) - 草地(Grass) - 阳光(Sun) ## 核心类说明 - `Application.java`:程序入口 - `ConfigLoader.java`:配置文件加载器 - `GamePanel.java`:游戏主面板,管理游戏循环与渲染 - `StarterContext.java`:场景启动器上下文 - `GameObject.java` 游戏对象抽象基类 ## 部分源码说明 ### 配置文件相关 配置文件读取通过`ConfigLoader`类实现,底层利用`Java Properties`系统类实现,配置文件格式为properties文件,示例如下: ````properties # 游戏标题 game.title=MiniPZ-V2 # 窗口参数 game.width=900 game.height=630 # 背景音乐路径 music.path=src/main/resources/music/bgm.wav # 背景音乐音量(0-100) music.volume=20 # 游戏渲染频率 repaint.fps=90 ```` ### 游戏初始化 程序主窗口`GamePanel`通过继承`Swing JFrame`类实现,通过`launch()`在主类`Application`中启动,其中对游戏窗体、背景音乐、鼠标事件、渲染定时任务进行了初始化。 ````java public void launch() { /* 背景音乐初始化 */ musicInit(); /* 鼠标事件初始化 */ mouseInit(); /* 窗体初始化 */ windowsInit(); /* 游戏启动 */ startGame(); } ```` ### 游戏渲染逻辑 1. `Swing JFrame`组件的`paint()`方法进行界面绘制渲染,方法参数为`Graphics类`,用于绘制游戏元素。 2. `Graphics类`的`drawImage()`方法用于绘制图片,这是我们游戏的主要运行逻辑。 3. `FPS`: frame per second,每秒帧数,控制游戏渲染速度,90帧,即每秒图像刷新90次。 4. `实现FPS`: 我们知道paint()方法是绘制,那么定时绘制就可以模拟FPS逻辑,startGame()方法中添加了定时任务,定时向系统传达绘制任务,即可刷新界面。 ### 游戏对象基类 > 所有游戏对象都在object包下,继承基类GameObject #### GameObject-基础贴图抽象 当前类封装了一些基础贴图信息,用于实现简单静态元素,如背景、草地、阳光、铲子、卡片等。 ````java public class GameObject { /* 基础属性(坐标 + 长宽) */ private int x; private int y; private int width; private int height; /* 图像 */ private Image img; } ```` #### AnimatedGameObject-基础动画抽象 当前类封装了带有动画效果的信息,比如说动画帧集合、播放索引、播放间隔、动画名称、动画是否启用、动画是否循环播放、动画是否播放完成、当前动画名称等信息,用于实现多帧动画绘制、切换的动态元素,如植物、僵尸等元素。 ````java public abstract class AnimatedGameObject extends GameObject { /** * 动画帧集合,支持多套动画 */ protected Map animationSets = new HashMap<>(); /** * 当前动画帧集合 */ protected Image[] animationFrames; /** * 当前帧索引 */ protected int currentFrame = 0; /** * 每帧间隔时间(毫秒) */ protected long frameInterval = 100; /** * 上一次播放动画的时间(毫秒) */ protected long lastFrameTime = 0; /** * 动画是否启用 */ protected boolean animationEnabled = false; /** * 标记是否循环播放 */ protected boolean loopAnimation = true; /** * 标记一次性动画是否播放完成 */ protected boolean animationFinished = false; /** * 当前动画名称 */ protected String currentAnimationName = ""; } ```` ### 游戏对象行为 #### TimeAttackAble-计时攻击接口 在FPS刷新时,进行UPS状态刷新,这里封装了定时攻击逻辑,如植物的发射子弹及僵尸的攻击。主要代码如下,这里利用了模板方法去进行延时逻辑重复代码抽离,具体的攻击行为由用户实现类控制。最后使用WeekHashMap是因为引用对象不论是植物还是僵尸,都会有死亡清除逻辑,这里使用弱引用集合避免内存泄漏。 ````java public interface TimeAttackAble { /** * 攻击间隔MAP */ Map CD_MAP = new WeakHashMap<>(); /** * 最后攻击时间MAP */ Map LAST_CD_TIME_MAP = new WeakHashMap<>(); /** * 攻击模板方法 * * @param target 目标 */ default void attack(GameObject target) { if (canAttack()) { performAttack(target); setLastAttackTime(System.currentTimeMillis()); } } /** * 是否可以攻击判断 * * @return ture-可以攻击,反之false */ default boolean canAttack() { return System.currentTimeMillis() - getLastAttackTime() >= getAttackCd(); } /** * 攻击行为,需要实现类提供 * * @param target 攻击对象 */ void performAttack(GameObject target); } ```` #### TimeMoveAble-计时移动接口 在FPS刷新时,进行UPS状态刷新,这里封装了定时移动逻辑,如僵尸的移动,与上面一致,不过多介绍。 ````java public interface TimeMoveAble { /** * 间隔MAP */ Map CD_MAP = new WeakHashMap<>(); /** * 上次时间MAP */ Map LAST_CD_TIME_MAP = new WeakHashMap<>(); /** * 攻击模板方法 */ default void move() { if (canMove()) { performMove(); setLastMoveTime(System.currentTimeMillis()); } } /** * 是否可以进行动作 * * @return ture-可以攻击,反之false */ default boolean canMove() { return System.currentTimeMillis() - getLastMoveTime() >= getMoveCd(); } /** * 具体行为,需要实现类提供 */ void performMove(); } ```` ### 场景启动器 这里将不同的场景进行分类,如子弹、卡片、植物、僵尸以及环境等,方便模块化设计开发。Starter提供了初始化方法,用于一些场景的初始化逻辑,比如环境的阳光定时任务初始化以及zom场景的随机僵尸创建逻辑。最终所有的场景被注册到场景上下文`StarterContext`中,统一执行初始化逻辑,并交由游戏面板`GamePanel`控制`start()方法`进行FPS绘制。 ````java /** * 场景启动器 * * @author FRSF * @since 2025/8/1 14:05 */ public interface Starter { /** * 初始化场景 */ void init(); /** * 启动场景 * * @param graphics 画笔 */ void start(Graphics graphics); } /** * 植物启动器 * * @author FRSF * @since 2025/8/3 0:21 */ @Data @Slf4j public class PlantStarter implements Starter { /** * 植物列表 */ private final ArrayList plants = new ArrayList<>(); /** * 死亡植物列表 */ private final ArrayList deadPlants = new ArrayList<>(); @Override public void init() { log.info("植物启动器初始化"); } @Override public void start(Graphics graphics) { if (GamePanel.state == GameStateEnum.RUNNING) { plants.forEach(plant -> plant.paintSelfImage(graphics)); plants.removeAll(deadPlants); deadPlants.clear(); } } } ```` ### 场景上下文 启动器上下文,用于管理所有启动器,并提供泛型方法帮助不同场景、不同实体之前快速获取对应启动器及启动器下的资源。 ````java /** * @author FRSF * @since 2025/8/1 15:31 */ public class StarterContext { /** * 启动器列表 */ private static final List starters = new ArrayList<>(); /** * 构造函数 */ public StarterContext() { // add starters.add(new EnvStarter()); starters.add(new CardStarter()); starters.add(new PlantStarter()); starters.add(new ZomStarter()); starters.add(new BulletStarter()); // init starters.forEach(Starter::init); } /** * 获取指定类型的启动器 * * @param clazz 启动器类 * @return 启动器实例 */ public T getStarter(Class clazz) { for (Starter starter : starters) { if (clazz.isInstance(starter)) { return clazz.cast(starter); } } throw new RuntimeException("启动器 " + clazz.getName() + " 不存在"); } /** * 启动所有启动器 * * @param graphics 画布 */ public void run(Graphics graphics) { for (Starter starter : starters) { starter.start(graphics); } } } ```` ### 双缓存机制渲 单Image绘画会存在闪烁问题,因为重绘时候,会先清空画布再进行绘制,这样会存在频闪,这里进行了双画布绘制并切换解决。 ````java public class GamePanel extends JFrame { @Override public void paint(Graphics graphics) { // 双缓存机制,新Image绘制完成之后,再显示到屏幕上,避免clean and paint闪烁问题 Image flashImage = this.createImage(width, height); Graphics g = flashImage.getGraphics(); // 场景上下文启动器开启绘制 starterContext.run(g); // 绘制替换 graphics.drawImage(flashImage, 0, 0, null); } } ```` ### 游戏坐标说明 坐标轴及常用尺寸如图所示 ![](readme/xy.png) ## 如何添加新植物(案例:双发射手) > 图片资源素材需要自行搜寻或者让AI创作 ### 新增卡片 #### 1.添加植物类型枚举 ````java // 当前枚举作为卡片成员字段,目的在于让卡片绑定关联的植物 package top.frsf.enums; public enum PlantTypeEnum { PEA, // 豌豆射手 SNOW_PEA, // 寒冰射手 REPEATER_PEA; // 双发射手(新增) } ```` #### 2.添加卡片图片资源,修改常量类 卡片资源需要三张图片,一张正常图片,一张冷却图片,一张被选中图片。将其添加到`resources/object/repeater`目录下,并且修改`constants.ImageConstant`类,添加图片路径地址。 ![](readme/plant/step1.png) #### 3.创建植物卡片类 ````java // CardObject已经封装好渲染、冷却计算逻辑,所以我们继承即可。super方法中设置三种卡片图片常量地址、售价、冷却、关联植物枚举信息。 package top.frsf.object.card; public class RepeaterPeaCard extends CardObject { public RepeaterPeaCard() { super(REPEATER_PEA_CARD, REPEATER_PEA_CARD_DARK, REPEATER_PEA_CARD_MOVE, 200, 10000, PlantTypeEnum.REPEATER_PEA); } } ```` #### 4.加入CardStarter卡片环境启动器管理 ````java package top.frsf.starter.impl; public class CardStarter implements Starter { /** * 豌豆射手卡片 */ private final PeaCard peaCard = new PeaCard(); /** * 寒冰射手卡片 */ private final SnowPeaCard snowPeaCard = new SnowPeaCard(); /** * 双发射手卡片 */ private final RepeaterPeaCard repeaterPeaCard = new RepeaterPeaCard(); // 新增 /** * 卡片列表 */ private final ArrayList cards = new ArrayList<>(); /** * 卡片宽度 */ private final int cardWidth = 52; @Override public void init() { log.info("卡片启动器初始化"); cards.add(peaCard); cards.add(snowPeaCard); cards.add(repeaterPeaCard); // 新增 for (int i = 1; i < cards.size(); i++) { CardObject cardObject = cards.get(i); cardObject.setX(cardObject.getX() + cardWidth * i); } } @Override public void start(Graphics graphics) { if (GamePanel.state == GameStateEnum.RUNNING) { cards.forEach(card -> card.paintSelfImage(graphics)); } } } ```` #### 5.启动游戏,可以看到卡片已经添加成功 ![](readme/plant/step2.png) ### 新增子弹 #### 1.添加子弹类型枚举 子弹类型枚举用于植物绑定子弹,植物攻击时确定创建什么对象 ````java package top.frsf.enums; public enum BulletTypeEnum { PEA_BULLET, // 豌豆子弹 (已存在,无需新增) SNOW_PEA_BULLET; // 寒冰豌豆子弹 } ```` #### 2.新增子弹类 子弹类继承`BulletObject`,基类中封装了子弹移动、子弹攻击、子弹销毁行为,子类可以沿用或者重写。 ````java package top.frsf.object.bullet; public class PesBullet extends BulletObject { // 已存在,无需新增 public PesBullet(int x, int y) { super(ImageConstant.Bullet.PEA, x, y, 30, 30, 5, 10); } } ```` #### 3.加入子弹工厂 ````java package top.frsf.factory; public class BulletFactory { /** * 创建子弹 * * @param bulletType 子弹类型 * @param x x坐标 * @param y y坐标 * @return 子弹对象 */ public static BulletObject createBullet(BulletTypeEnum bulletType, int x, int y) { switch (bulletType) { case PEA_BULLET: return new PesBullet(x, y); case SNOW_PEA_BULLET: return new SnowPesBullet(x, y); default: throw new IllegalArgumentException("Invalid bullet type"); } } } ```` ### 新增植物 #### 1.添加植物动画帧素材 这里参考上述卡片,不再过多介绍 ![](readme/plant/step3.png) #### 2.创建植物类 ````java package top.frsf.object.plant; public class RepeaterPea extends PlantObject { /** * 动画帧列表,这里只有一个动画帧,就是植物默认的左右摇摆动作,如果是僵尸的话至少两组,一个是正常移动,一个是攻击动作 */ private static final Image[] imageList = new Image[13]; static { try { for (int i = 0; i < imageList.length; i++) { String imageUrl = String.format(ImageConstant.Plant.REPEATER_PEA, i); imageList[i] = ImageIO.read(new File(imageUrl)); } } catch (Exception e) { log.error("图片加载失败", e); throw new RuntimeException(e); } } public RepeaterPea(int x, int y) { super(ImageConstant.Plant.REPEATER_PEA, x, y, 70, 70, 80, BulletTypeEnum.PEA_BULLET); // 设置图片地址、坐标、宽高、HP、子弹类型 addAnimation(ImageConstant.Plant.REPEATER_PEA, imageList); // 添加动画 switchAnimation(ImageConstant.Plant.REPEATER_PEA, 100, true); // 设置默认动画、动画播放延时、是否循环播放 setAttackCd(1500); // 设置攻击间隔 } } ```` #### 3.加入植物工厂 ````java package top.frsf.factory; public class PlantFactory { public static PlantObject createPlant(PlantTypeEnum plantType, int x, int y) { switch (plantType) { case PEA: return new Pea(x, y); case SNOW_PEA: return new SnowPea(x, y); case REPEATER_PEA: return new RepeaterPea(x, y); // 新增 default: return null; } } } ```` #### 4.启动游戏,查看功能 并没有像我们预想那样连续发射两颗豌豆,这是因为`RepeaterPea`继承了`PlantObject`,但是沿用了`PlantObject`中的攻击行为,这里我们需要重写`performAttack`方法,去完成新的的逻辑。 ![](readme/plant/step4.png) #### 5.重写攻击行为 ````java package top.frsf.object.plant; public class RepeaterPea extends PlantObject { /** * 动画帧列表 */ private static final Image[] imageList = new Image[13]; static { try { for (int i = 0; i < imageList.length; i++) { String imageUrl = String.format(ImageConstant.Plant.REPEATER_PEA, i); imageList[i] = ImageIO.read(new File(imageUrl)); } } catch (Exception e) { log.error("图片加载失败", e); throw new RuntimeException(e); } } public RepeaterPea(int x, int y) { super(ImageConstant.Plant.REPEATER_PEA, x, y, 70, 70, 80, BulletTypeEnum.PEA_BULLET); addAnimation(ImageConstant.Plant.REPEATER_PEA, imageList); switchAnimation(ImageConstant.Plant.REPEATER_PEA, 100, true); setAttackCd(1500); } @Override public void performAttack(GameObject target) { // 重写攻击行为,一次创建两个子弹 ZomStarter zomStarter = starterContext.getStarter(ZomStarter.class); BulletStarter bulletStarter = starterContext.getStarter(BulletStarter.class); for (ZomObject zomObject : zomStarter.getZomList()) { double abs = Math.abs(getRectangle().getCenterY() - zomObject.getRectangle().getCenterY()); // 是否同一行存在zom if (abs < 30) { bulletStarter.pesBulls.add(BulletFactory.createBullet(bulletType, getX() + getWidth() + 18, getY() + 5)); bulletStarter.pesBulls.add(BulletFactory.createBullet(bulletType, getX() + getWidth() - 8, getY() + 5)); break; } } } } ```` #### 6.创建植物成功 成功连发豌豆,测试成功 ![](readme/plant/step5.png)