java基于AWT的对战小游戏

这学期的 java 课设弄完了,写个博客总结一下。

哔哩哔哩对应视频的传送门

课设目的与要求

根据讲义中策略模式的案例,设计和实现一个基于策略模式的角色扮演游戏。其中包括主要有角色类及其子类、相关的行为类集合和测试类等。

通过本次实验,能够在掌握面向对象程序设计的基本思想基础上;深化理解 Java 面向对象程序设计中消息、继承、多态、接口、抽象类和抽象方法等概念和实现方式;并进一步掌握 Java 程序设计中的基本语法和 Java 程序运行方法等;理解和应用包(package)。

内容

一个游戏中有多种角色(Character),例如:国王(King)、皇后(Queen)、骑士(Knight)、老怪(Troll)。角色之间可能要发生战斗(fight),每场战斗都是一个角色与另一角色之间的一对一战 斗。

每个角色都有自己的生命值 (hitPoint) 、 魔法值(magicPoint)、攻击力值(damage)和防御力值(defense)。

每种角色都有一种武器进行攻击(fight);在程序运行中,可以动态修改角色的武器(setWeaponBehavior)。

每种角色都有一种魔法对自己或者其他角色施法(performMagic);可以动态改变拥有的魔法(setMagicBehavior)。

  1. 首先设计和实现抽象类 Characters。

  2. 设计和实现 Character 类的几个子类:King、Queen、Knight、Troll。位

  3. 设计接口 WeaponBehavior 和 MagicBehavior。

    • 接 口 WeaponBehavior 的 实 现 类 :

      • KnifeBehavior ( 用 刀 )

      • BowAndArrowBehavior ( 用 弓 箭 )

      • AxeBehavior ( 用 斧 )

      • SwordBehavior(用剑)

    • 接口 MagicBehavior 的实现类:

      • HealBehavior(治疗)
      • InvisibleBehavior(隐身)。

实现接口中的抽象方法,可以只在屏幕输出简单信息,也可以结合生命值(hitPoint)、攻击力值(damage)和防御力值(defense)计算。

  1. 编写测试代码,对以上设计的系统进行测试。要求在程序运行期间,能动态改变角色拥有的武器或者魔法。

  2. 自己添加一种角色、或者添加一种武器及魔法,设计相应的类,并编写测试代码进行测试。

  3. 按照 Java 的规范,添加详细的文档注释,并用 Javadoc 生成标准的帮助文档。

  4. 将上述编译、运行、生成帮助文档的命令,填写至实验报告相应位置。

  5. 填写实验报告。并将程序代码及生成的帮助文档打包上交。

涉及的主要内容

  1. 单例模式。游戏窗口只能有一个对象,因此使用了单例模式。
  2. 策略模式。在角色类中有两个抽象策略(武器策略和魔法策略),具体策略在类中实现。
  3. 双缓冲技术。在绘制游戏画面的时候使用了双缓冲技术,防止画面闪烁。
  4. 多线程。在两处使用了多线程,一处是为了解决按键冲突的问题,另一处是为了实现游戏周期性判定的功能。
  5. awt。

基本逻辑流程

  1. 抽象角色类由具体子类实现,子类主要实现了抽象方法getAppearance,用于获取角色的外貌(即图片),外貌会根据角色状态的不同而改变,比如角色死亡时外貌是墓碑;
  2. 根据角色的坐标以及属性(例如是否隐身,当前武器是什么)来绘制角色以及属性条、武器栏和魔法栏。
  3. 游戏时钟周期线程用于周期性地执行一些操作,例如每秒钟恢复一定的 HP 和 MP,对于隐身状态的角色,每秒钟扣除一定量的 MP 等。

游戏说明

  1. 玩家 1 操作:键盘上 A 键 D 键分别对应左右移动,J 键使用武器攻击,K 键使用魔法,L 键切换武器,O 键切换魔法;
  2. 玩家 2 操作:键盘上 ← 键 → 键分别对应左右移动,小键盘上,1 键使用武器攻击,2 键使用魔法,3 键切换武器,6 键切换魔法;
  3. 每把武器有自己的攻击威力和攻击距离,只有在两个角色的距离在武器的攻击范围内时,才能够攻击成功;
  4. 伤害计算公式为:被攻击者受到的最终伤害=攻击者攻击力+攻击者武器威力-被攻击者的防御力。若伤害小于等于 0,则不予扣除;
  5. 每秒钟会恢复一定量的 HP 和 MP;
  6. 一方死亡(HP 降为 0 及以下)则游戏结束。

设计与实现

主要框架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class FightFieldFrame extends Frame{
//一些游戏常量以及窗口
public static final Dimension SCREEN_DIMENSION=Toolkit.getDefaultToolkit().getScreenSize();
public static final int FFF_X=0;
public static final int FFF_Y=0;
public static final int FFF_HEIGHT=SCREEN_DIMENSION.height;
public static final int FFF_WIDTH=SCREEN_DIMENSION.width;
//……省略其他成员函数,下面会列举来说明
/*******************main函数**************************/
public static void main(String args[]) {
FightFieldFrame f=getInstance("战斗领域");
f.initFrame();
//初始化角色
f.initCharacter();
//添加事件监听者
f.addWindowListener(new MyWindowListener());
f.addKeyListener(new GamePad(player1,player2,f));
//新建时钟线程,用于游戏中的周期性属性检查
Thread clockThread=new Thread(new ClockThread(player1, player2, fff));
clockThread.start();
}
}

单例模式

FightFieldFrame 类中:

1
2
3
4
5
6
7
8
9
10
11
//只能有一个窗体对象,使用单例模式
private static FightFieldFrame fff;//单例模式使用的对象
private FightFieldFrame(String title) {
super(title);
}
public static FightFieldFrame getInstance(String title) {
if(fff==null) {
fff=new FightFieldFrame(title);
}
return fff;
}

双缓冲

双缓冲因为有两个绘图对象而得名,先在一个 image 对象上绘图然后再将此对象绘制到 Frame 上,用于减少重绘时的闪烁。

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 初始化框架的位置和大小,以及缓冲对象
*/
public void initFrame() {
//这里准备一些对象构造完成之后才能做的事情
fff.setVisible(true);
setBounds(FFF_X, FFF_Y, FFF_WIDTH, FFF_HEIGHT);

Dimension d=getSize();
imgBuffer=createImage(d.width, d.height);
gBuffer=imgBuffer.getGraphics();
}

创建好缓冲对象后,在缓冲对象上绘制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void paint(Graphics g) {
//全都先绘制在缓冲区
//绘制背景
Image background=getToolkit().getImage("image\\background.jpg");
if(background!=null) {
gBuffer.drawImage(background, FFF_X, FFF_Y, FFF_WIDTH, FFF_HEIGHT, this);
}
//绘制人物
if(player1!=null) {
drawCharacter(gBuffer,player1);
drawStrand(gBuffer, player1);
}

if(player2!=null) {
drawCharacter(gBuffer,player2);
drawStrand(gBuffer, player2);

}

drawSlot(gBuffer);
//drawStrand(gBuffer);//绘制绝对位置的属性条,由于没有什么技术含量就只做了一个示例
//由于使用了背景图片,所以不必特地清空背景
g.drawImage(imgBuffer, 0, 0, this);
}

但是,即便如此仍然会闪烁,这是因为重绘时调用的 update 函数会将 Frame 用背景色填充一次 再绘制。所以应该覆盖掉原本的方法,让它只绘制,不清空:

1
2
3
4
5
6
//======================//
public void update(Graphics g) {
//覆盖原本的方法
paint(g);
}
//======================/*/

玩家操纵

使用 GamePad 类作为键盘监听者,监听 Frame 的按键,调用角色对应的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
/**
* 游戏手柄类
* 用于将键位与角色的动作对应起来
*/
public class GamePad implements KeyListener{
private Characters player1;//玩家1
private Characters player2;//玩家2
private FightFieldFrame fff;
public GamePad(Characters p1, Characters p2, FightFieldFrame f) {
// TODO Auto-generated constructor stub
player1=p1;
player2=p2;
fff=f;
}
@Override
public void keyPressed(KeyEvent e) {
int code=e.getKeyCode();

switch (code) {
case KeyEvent.VK_J://玩家1攻击
player1.fight(player2);
player2.display();
break;
case KeyEvent.VK_K://玩家1使用魔法
player1.performMagic(player2);
break;
case KeyEvent.VK_A://玩家1左
player1.setMoveLeftFlag(true);
player1.setDirection(true);//false为朝右,true为朝左
break;
case KeyEvent.VK_D://玩家1右
player1.setMoveRightFlag(true);
player1.setDirection(false);
break;
case KeyEvent.VK_L://玩家1切换武器
player1.changeWeapon();
break;
case KeyEvent.VK_O://玩家1切换魔法
player1.changeMagic();
break;
/****************************************************************/
case KeyEvent.VK_NUMPAD1://玩家2攻击
player2.fight(player1);
player1.display();
break;
case KeyEvent.VK_NUMPAD2://玩家2使用魔法
player2.performMagic(player1);
break;
case KeyEvent.VK_LEFT://玩家2左
player2.setMoveLeftFlag(true);
player2.setDirection(true);//false为朝右,true为朝左
break;
case KeyEvent.VK_RIGHT://玩家2右
player2.setMoveRightFlag(true);
player2.setDirection(false);
break;

case KeyEvent.VK_NUMPAD3://玩家2切换武器
player2.changeWeapon();
break;
case KeyEvent.VK_NUMPAD6://玩家2切换魔法
player2.changeMagic();
break;
default:
break;
}
fff.repaint();//重绘

}
@Override
public void keyReleased(KeyEvent e) {
int code=e.getKeyCode();
switch (code) {
case KeyEvent.VK_J:
break;
case KeyEvent.VK_A://左
player1.setMoveLeftFlag(false);
break;
case KeyEvent.VK_D://右
player1.setMoveRightFlag(false);
break;
case KeyEvent.VK_K:
break;
case KeyEvent.VK_NUMPAD1:
break;
case KeyEvent.VK_LEFT:
player2.setMoveLeftFlag(false);
break;
case KeyEvent.VK_RIGHT:
player2.setMoveRightFlag(false);
break;
default:
break;
}
fff.repaint();//重绘
}
@Override
public void keyTyped(KeyEvent e) {}
}

注意,这里控制角色左右移动并不是直接调用角色的移动方法,而是更改角色移动的标志变量,利用线程来调用角色的移动方法。这样可以解决角色的按键冲突问题。

角色移动线程

移动线程只负责发送消息给角色,而角色移动的具体判定由角色自身完成,从而更好地实现面向对象的思想。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MoveThread implements Runnable{
private Characters character;
public MoveThread(Characters c) {
character=c;
}
public void run() {
while(true) {
//线程只负责发送消息,让角色自己判断移动
character.moveRight();
character.moveLeft();
}
}

}

下面这是 Characters 类中的角色移动函数,添加了延时以免在按下移动按键的一瞬间,角色移动太快出了屏幕外面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 向左移动<br/>
* 由于两个线程各自操作自己的角色,所以此函数不需要同步
*/
public void moveLeft() {
if(!isAliveFlag) return;
if(moveLeftFlag) {
x-=1;
try {
Thread.sleep(1);//防止跑得太快
} catch (InterruptedException e) {
e.printStackTrace();
}
}

}
/**
* 向右移动<br/>
* 由于两个线程各自操作自己的角色,所以此函数不需要同步
*/
public void moveRight() {
if(!isAliveFlag) return;
if(moveRightFlag) {
x+=1;
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

武器攻击实现机制

在 Characters 类中,使用武器进行攻击的方法如下,它的主要逻辑是调用 useWeapon 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 攻击某个角色
* @param c 要攻击的角色
* @return 造成的真实伤害
*/
public int fight(Characters c) {
//由于武器有不同的特性,所以伤害的逻辑让武器实现
//比如后期编写高级玩法时,弓需要计算射程
if(!isAliveFlag) return 0;//如果已死亡,直接返回,下同
if(weapon==null) {
System.out.println(name+"没有武器,无法攻击");
return 0;
}
int attackRange=weapon.getAttackRange();
if(attackRange>distance(c)) {
//攻击距离大于角色之间的距离才可攻击
return weapon.useWeapon(this,c);//此角色攻击角色c
}
else {
return 0;
}

}

角色类的两个属性,武器和魔法,使用的都是对应接口的引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
protected WeaponBehavior weapon;//武器
protected MagicBehavior magic;//魔法

以下是武器接口:
public interface WeaponBehavior {
/**
* 使用武器
* @param attacker 武器持有者
* @param victim 被攻击者
* @return 造成的真实伤害
*/
public int useWeapon(Characters attacker,Characters victim);//使用武器
public String getName();
public int getAttackRange();
public Image getAppearance();
}

以下是具体的武器实现(以剑为例,其他大同小异):
/**
* 剑
* 实现武器接口
* 威力中等,攻击距离中等
*/
public class SwordBehavior implements WeaponBehavior {
private String name="剑";
private Image appearance;
private static final int DAMAGE=6;//武器基础威力
private static final int ATTACK_RANGE=200;//武器攻击距离,单位px
private static final String APPEARANCE_PATH="image\\Weapon\\Sword.png";
public SwordBehavior() {}
public SwordBehavior(String _name) {
name=_name;//剑,岂能无名OVO
}
/**
* 使用武器攻击
* @param attacker 攻击者
* @param victim 被攻击者
*/
@Override
public int useWeapon(Characters attacker,Characters victim) {
int attackDamage=DAMAGE+attacker.getDamage();//造成的伤害为攻击者的伤害加上武器威力
int finalDamage=victim.hitBy(attacker, attackDamage);
System.out.println(attacker.getName()+"使用"+name+"对"+victim.getName()+"造成了"+finalDamage+"点伤害");
return finalDamage;//返回最终伤害
}
public String getName() {return name;}
public int getAttackRange() {return ATTACK_RANGE;}
public Image getAppearance() {
appearance=Toolkit.getDefaultToolkit().getImage(APPEARANCE_PATH);
return appearance;
}
}

这里面主要的代码是 useWeapon 方法里面调用的角色类的 hitBy 方法,里面有着伤害计算逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 被某个角色攻击
* @param attacker 攻击者
* @param attackDamage 攻击者给予的攻击伤害
* @return 最后造成的真实伤害
*/
public int hitBy(Characters attacker,int attackDamage) {//被攻击
if(!isAliveFlag) return 0;
int finalDamage=(attackDamage-defense);//伤害计算:最终伤害=敌方攻击伤害-我方防御力
if(incHP(-finalDamage)==-1) {//如果血量被扣到负数
this.killedBy(attacker);
}

return finalDamage;//返回最后造成的真实伤害
}

魔法的实现机制大同小异,不做特殊说明。

武器切换和魔法切换

实现方法是在角色类里面声明数组:

1
2
3
4
5
protected WeaponBehavior weaponSlots[];//武器栏位,用于存储角色携带的武器
protected MagicBehavior magicSlots[];//魔法栏位
protected int weaponSlotsIndex=0;//栏位索引,指示当前武器
protected int magicSlotsIndex=0;

以切换武器为例,如果当前武器是最后一把,那么换回第一把,否则索引自增:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 按顺序切换武器
*/
public void changeWeapon() {
setWeaponBehavior(weaponSlots[weaponSlotsIndex]);
if(weaponSlotsIndex+1>=weaponSlots.length) {
weaponSlotsIndex=0;
}
else {
weaponSlotsIndex++;
}
}

时钟线程

时钟线程用于进行一些游戏周期性方法的调用,比如周期性恢复 HP,对角色属性值的判断等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
* 时钟线程,用于一些周期性的计算
*/
public class ClockThread implements Runnable{
private Characters player1;//玩家1
private Characters player2;//玩家2
private FightFieldFrame fff;
private int interval=1000;//时钟周期
public ClockThread(Characters p1, Characters p2, FightFieldFrame f) {
player1=p1;
player2=p2;
fff=f;
}
/**
* 核对属性,并对于特定属性作出不同的事情
* @param c 核对角色c的属性
*/
public void cheackStatus(Characters c) {
switch (c.getStatus()) {
case Characters.ST_INVISIBLE://隐身魔法每个周期扣除一定的魔力
if(c.incMP(-InvisibleBehavior.COST)==-1) {//如果魔力不够
c.setStatus(Characters.ST_NORMAL);
}
break;
default:
break;
}
}

/**
* 周期性恢复属性值(回血回魔)
* @param c 周期性恢复角色c的HP和MP
*/
public void recover(Characters c) {
if(!c.getIsAliveFlag()) return;//角色死亡就不再回血
c.incHP(Characters.HP_RECOVER);
c.incMP(Characters.MP_RECOVER);
}
public void run() {
while(true) {
//做这个周期要做的事情
cheackStatus(player1);
cheackStatus(player2);
recover(player1);
recover(player2);
fff.repaint();
//等待下一个周期
try {
Thread.sleep(interval);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

参考链接

体会

这次课设对我来说是个挑战,首先时间比较紧张,和考试放在了一周,并且用的是学了几周还没私底下练习多少的 JAVA。不过还是做的让我自己比较满意。

我选择的是看上去较为简单的一道题目,虽然简单,但是这个题目的可扩展性很强,可以尽情开脑洞,我看中的就是这一点。我在高中的时候就尝试使用 Visual Basic 来编写类似的小游戏,一些可能会遇到的困难在那时已经思考过了,所以总体来说没有遇到太过麻烦的地方。

随着经验的增长,我逐渐开始一边编程一边整理,让以后的自己也能够回顾这一次的项目。在写完这个课设之后,我用录屏软件录制了一个视频来整体讲述我编写过程中的思路,并上传到了 Bilibili 弹幕视频网站,总结经验,分享思路,以及为了便于以后回顾。地址是(https://www.bilibili.com/video/av54526303/)

当然,过程中也遇到了一些问题。

比如绘制图片的时候遇到了只能使用绝对路径的问题,在老师上课演示的过程中也遇到过这个问题,后来我知道了 JAVA 相对路径是以项目根目录为基准而不是以文件目录为基准的。

比如角色控制按键冲突。解决方法是使用多线程,两个线程控制分别控制两个角色。

比如游戏周期性事件。在以前我使用 Visual Basic 的时候,是利用时钟控件来解决这个问题的,而 JAVA 里面可以使用线程来模拟那个时钟控件。这让我对时钟控件的原理有了比较好的认识。

在假期里面,我可能会通过继续完善这个小游戏,来更加深入地学习 JAVA。

作者

憧憬少

发布于

2019-06-13

更新于

2019-06-13

许可协议