- 操作系统:
Windows11 家庭版 - 主要开发工具:
Intellij IDEA Community 2024.1.4:编写项目代码。PlantUML:绘制项目UML类图。Swing UI Designer:可视化绘制UI界面。Git + GitHub:控制和迭代游戏版本。
在飞机大战游戏中,Game类构造器初始化时需要实例化一个HeroAircraft类的对象,但是我们只需要实例化一次,所以需要用到单例模式。
原代码中存在的问题主要集中在HeroAircraft类的设计上:
- 构造器是公共的,外部可以随意对其实例化,创建无数个英雄机对象;
- 可能造成重复的资源占用,导致内存浪费和性能下降。
使用单例模式的优势:
- 将构造器私有,我采用的是饿汉式单例,提前将唯一的英雄机对象实例化出来,与此同时提供公共方法
public HeroAircraft getHeroAircraft()返回该唯一对象,避免外部随意实例化; - 控制实例数量,节约系统资源。
我主要对HeroAircraft这个类进行简要说明:
private static HeroAircraft heroAircraft=new HeroAircraft(...):提前实例化一个英雄机静态对象,保证实例唯一性,同时也能够被类直接调用。private HeroAircraft(...):将类的构造器私有化,避免被外部随意调用。public HeroAircraft getHeroAircraft():提供一个公共方法,供外部获取唯一的英雄机对象。
在Game类中有一段关于敌机产生的逻辑,原本如下:
private EnemyAircraft generateEnemy(){
EnemyAircraft enemy;
int choice = random.nextInt(4)+1;
if(choice == EliteEnemy.CHOICE){
enemy=new EliteEnemy(
(int) (Math.random() * (Main.WINDOW_WIDTH - ImageManager.MOB_ENEMY_IMAGE.getWidth())),
(int) (Math.random() * Main.WINDOW_HEIGHT * 0.05),
0,
10,
Constants.HP_ELITE_ENEMY
);
} else {
enemy=new MobEnemy(
(int) (Math.random() * (Main.WINDOW_WIDTH - ImageManager.MOB_ENEMY_IMAGE.getWidth())),
(int) (Math.random() * Main.WINDOW_HEIGHT * 0.05),
0,
10,
Constants.HP_MOB_ENEMY
);
}
return enemy;
}可以发现:原代码中敌机的创建与使用没有分离,违反了开闭原则,耦合度高,日后难以维护和扩展。
而使用工厂模式后,代码重构如下:
private EnemyAircraft generateEnemy() {
EnemyFactory enemyFactory;
EnemyAircraft enemy;
int choice = random.nextInt(4)+1;
if (choice == EliteEnemy.CHOICE) {
enemyFactory = new EliteEnemyFactory();
} else {
enemyFactory = new MobEnemyFactory();
}
enemy = enemyFactory.createEnemy();
return enemy;
}优势在于将敌机对象的创建与使用分离,完全符合开闭原则,大大降低了耦合度。此外,应用了多态:enemy = enemyFactory.createEnemy(),这样做的好处是日后若我们要增加一种敌机(比如Boss敌机),只需要新增一个Boss敌机工厂,并在上述代码中新加一个条件判断语句enemyFactory = new BossEnemyFactory()即可。
同样的道理适用于道具工厂,此处不再赘述。
创建一个抽象敌机工厂接口EnemyFactory,规定抽象方法EnemyAircraft createEnemy(),这样可以强制实现类MobEnemyFactory和EliteEnemyFactory对该方法重写,从而生产出对应的敌机。
创建一个抽象道具工厂接口SupplyFactory,规定抽象方法BaseSupply createSupply(int locationX,int locationY),这样可以强制实现类HealSupplyFactory、FireSupplyFactory和BombSupplyFactory对该方法重写,从而生产出对应的道具。
飞机大战游戏中,要想实现英雄机弹道在直射、散射和环射三种方式间灵活切换,需要用到策略模式。
原代码中各种飞机的弹道模式均硬编码在具体的飞机类中,导致弹道无法灵活切换,不利于扩展和维护。
使用策略模式的优势:可以将以上三种弹道模式从类中分离出来,并且可以互相替换,让弹道模式的切换独立于使用它的飞机,大大降低了代码耦合度。
ShootStrategy接口:定义了所有实现类都必须重写的方法List<BaseBullet> execute(AbstractAircraft aircraft)。DirectShootStrategy,ScatterShootStrategy,RingShootStrategy实现类:它们都实现了ShootStrategy接口,并在各自的execute方法中封装了直射、散射、环射的弹道模式,并且定义了各自弹道具体实现中所需的常量,比如SHOOT_NUM,ANGLE。AbstractAircraft类:抽象飞机类。我对它进行了一些新增,首先声明一个ShootStrategy类的私有实例对象,然后提供它的getter&setter以便外部可以调用。
飞机大战游戏中,为了实现每局游戏结束后记录游戏相关信息,并可以永久存储到排行榜中,我们需要用到数据访问对象模式(以下简称DAO模式)。
若不采用DAO模式,则代码会出现耦合度过高的问题:业务逻辑与持久化技术高度绑定,若后续要将数据存储到数据库,则需要重新编写大量代码。
使用DAO模式的优势:
- 清晰的职责分离:
Game类无需关心数据存储到哪里,也无需关心数据是从哪里获取的。 - 便于快速切换持久化技术:可以在文件、Oracle数据库、MySQL数据库...之间无缝切换。
Record实体类:它是核心数值对象,封装了游戏相关信息:id、玩家名、得分、记录时间,并且向外界暴露了调用接口getter&setter。此外,我还编写了一个实用性极高的方法:String formatRecordTime(Date recordTime):用于将获取到的记录时间由Date类转为北京时间下的MM-dd HH:mm格式。
RecordDao接口:定义了所有实现类都必须重写的方法:增删改查...RecordDaoImpl实现类:除了实现接口的具体业务逻辑之外,我还编写了读写文件的操作方法saveRecordsToFile及loadRecordsFromFile,分别运用了序列化和反序列化。在后续实验中,为了能够存储不同游戏难度下的游戏记录,我又新增了一个根据难度选择要操作的文件路径的方法setFilePathByDifficulty。RecordComparator类:它实现了Comparator<T>这一泛型接口,内部定义了记录按得分从高到低排序的逻辑。之所以将此逻辑从RecordDaoImpl中分离出来,是为了方便扩展,比如以后想要修改排序规则,只需要改动这个类即可。Game类:我新增了recordGame方法,实例化了RecordDao接口,然后就可以将游戏记录存储到文件中,并将得分排行榜打印到控制台上。
飞机大战游戏中,要实现炸弹爆炸这一效果需要用到观察者模式。敌机坠毁时会以较低概率掉落炸弹道具,它会对普通、精英、先锋、Boss这4种敌机以及这些敌机的子弹产生不同的影响。
若不使用观察者模式,设计中会遇到如下问题:
- 代码高耦合性:炸弹道具直接依赖于所有受到炸弹影响的具体类,一旦某个受影响类的方法名或参数发生变动,就必须修改
BombSupply类的代码。 - 违反开闭原则:开闭原则要求软件对扩展开放,对修改关闭。在这种低质量设计下,若未来需要新增一种受影响类,则程序员必须修改
BombSupply类的源代码,严重违反开闭原则,大大降低代码的可扩展性。 - 违反单一职责原则:管理这些受影响对象(比如查找所有范围内的敌机和子弹)的职责被强加给了炸弹类,违反了单一职责原则。
使用观察者模式的优势:
- 实现松散耦合:炸弹只负责维护一个观察者列表,并在炸弹爆炸时通知它们,而不需要知道任何一个观察者的具体类型。具体观察者自主决定如何响应爆炸事件,减少了它们与
BombSupply类的依赖关系,耦合度明显降低。 - 符合开闭原则,扩展性强:当未来需要新增一种会被炸弹影响的对象时,只需创建一个新的类并实现观察者接口即可,无需对
BombSupply类的代码做任何修改。系统易于扩展,灵活性极高。 - 职责清晰,利于维护:炸弹类的职责变得单一而明确:管理观察者并在适当时机发出通知。各个敌机类的职责也变得一目了然:在收到通知时,根据自己的类型执行相应的行为。
Subject:被观察者接口,定义了实现类(即BombSupply类)需要实现的3个方法,分别是添加观察者、移除观察者和通知所有观察者。Observer:观察者接口,定义了实现类需要实现的方法update,实现类MobEnemy、EliteEnmey、PioneerEnmey、BossEnemy、EnemyBullet分别自定义炸弹爆炸时的update逻辑。Game:依赖于被观察者和观察者的具体实现类。
在飞机大战游戏中,要想实现不同的游戏难度需要用到模板模式。
原设计存在一系列问题:
- 多种游戏难度既有共性,也有个性。如果全部放在
Game类中进行实现,会导致代码冗余重复,不利于维护。 - 不利于扩展。若要新增一种游戏难度,需要改动所有与难度设置相关的属性和方法。
模板模式的优势:
- 实现代码复用:共性由父类
Game实现,个性则由子类EasyGame~HellGame实现,大大提高代码可读性和可维护性。 - 符合开闭原则:若要新增一种游戏难度,只需要创建一个新的类并继承父类
Game,然后实现父类定义的模板方法即可。
Game:游戏父类,将一些属性改用protected修饰,并新增相应的setter,便于子类修改,同时定义了一系列子类需要实现的模板方法。- 模板属性:
int enemyMaxNumber:屏幕上允许同时出现的最大敌机数量。int heroShootCD:英雄机射击周期。int enemiesShootCD:敌机射击周期。int enemyGenerateCD:敌机产生周期。int initialBossHp:初始Boss血量。int initialBossPower:初始Boss子弹伤害。
- 模板方法:
rampUpDifficultyByTime():游戏难度随时间提升。generateEnemyAction():产生敌机的具体逻辑。printDifficultyInfo():控制台打印游戏难度提升相关信息。increaseBossDifficulty():每次击败Boss后,提升下一次登场Boss的难度。
- 模板属性:
EasyGame~HellGame:主要是定义了实现模板方法过程中各自所需的静态变量,以及实现模板方法的具体逻辑。RAMP_UP_INTERVAl:难度提升间隔,决定了难度提升频率。MIN_ENEMIES_SHOOT_CD:最小敌机射击周期。DECREASE_ENEMIES_SHOOT_CD:每次难度提升后,减少的敌机射击周期。- ……
-
第一次构建了较为复杂的小项目,培养了系统架构意识,大大提高了实践能力。
-
在不断实践中意识到:
- 变化是永恒的:每次实验都会带来新的需求,都需要重构原有代码和编写新代码,从而更好地适应新需求。
- 变化中也存在着不变的部分:设计模式指导着我如何搭建项目框架,虽然需求不断变更,但一个好的框架就如同人体的骨架一样,能够适应这些频繁更新的需求,具有良好的可维护性和可扩展性。
-
学习了单例模式、工厂模式、策略模式、数据访问对象模式、观察者模式和模板模式一共6种设计模式,这些模式在项目的不同阶段解决了不同的设计问题,具有前瞻性的优势,为以后我的项目设计和开发提供了更广阔的视野和更有借鉴性的思路。
-
此外,我深深感到设计模式也蕴含着哲学思想:
设计模式 概述 启示 单例模式 有且只有一个 独特角色不应被复制,一个系统只需要一个核心决策者 策略模式 同一目标,不同做法 面对问题要抽象到“策略”层面思考,而非局限于单一方法 观察者模式 一个人的改变影响一群人 广播式通知能降低协调成本,提高系统响应性 -
在项目开发过程中,我频繁使用了
Git工具进行版本控制和迭代,懂得了如何维护和更新代码,培养了提交代码时撰写详细注释的习惯,也学会了将项目打包成jar,再打包成exe,然后发布了各版本的releases。
我举一个最典型的问题进行说明:
随着实验不断深入,我发现Game类编写的代码越来越多,十分冗余,也不利于阅读。于是我尝试进行重构,将action、paint、播放音效、敌机和道具生成概率配置等逻辑分离出去,成为新的类。但是这一过程中有时候会出现问题:明明在新类中对相关属性进行了操作,为什么没有生效?
我仔细阅读了代码,也询问了一些AI工具,最终发现是因为新类部分方法的形参是按值传递,而非按引用传递,这正是不生效的根本原因!
由此可见,在编写代码时,一定要注意按值传递和按引用传递的区别,稍不留神就会酿成大错!
本实验很有意义,大大锻炼了我的动手能力。
我希望该实验可以增加更多学时:
- Lab7:采用数据库(如
MySQl)进行数据持久化操作。 - Lab8:引入网络编程,使得飞机大战游戏支持联机。







