这学期的 java 课设弄完了,写个博客总结一下。
哔哩哔哩对应视频的传送门
课设目的与要求 根据讲义中策略模式的案例,设计和实现一个基于策略模式 的角色扮演游戏。其中包括主要有角色类及其子类、相关的行为类集合和测试类等。
通过本次实验,能够在掌握面向对象程序设计的基本思想基础上;深化理解 Java 面向对象程序设计中消息、继承、多态、接口、抽象类和抽象方法等概念和实现方式;并进一步掌握 Java 程序设计中的基本语法和 Java 程序运行方法等;理解和应用包(package)。
内容 一个游戏中有多种角色(Character),例如:国王(King)、皇后(Queen)、骑士(Knight)、老怪(Troll)。角色之间可能要发生战斗(fight),每场战斗都是一个角色与另一角色之间的一对一战 斗。
每个角色都有自己的生命值 (hitPoint) 、 魔法值(magicPoint)、攻击力值(damage)和防御力值(defense)。
每种角色都有一种武器进行攻击(fight);在程序运行中,可以动态修改角色的武器(setWeaponBehavior)。
每种角色都有一种魔法对自己或者其他角色施法(performMagic);可以动态改变拥有的魔法(setMagicBehavior)。
首先设计和实现抽象类 Characters。
设计和实现 Character 类的几个子类:King、Queen、Knight、Troll。位
设计接口 WeaponBehavior 和 MagicBehavior。
实现接口中的抽象方法,可以只在屏幕输出简单信息,也可以结合生命值(hitPoint)、攻击力值(damage)和防御力值(defense)计算。
编写测试代码,对以上设计的系统进行测试。要求在程序运行期间,能动态改变角色拥有的武器或者魔法。
自己添加一种角色、或者添加一种武器及魔法,设计相应的类,并编写测试代码进行测试。
按照 Java 的规范,添加详细的文档注释,并用 Javadoc 生成标准的帮助文档。
将上述编译、运行、生成帮助文档的命令,填写至实验报告相应位置。
填写实验报告。并将程序代码及生成的帮助文档打包上交。
涉及的主要内容
单例模式。游戏窗口只能有一个对象,因此使用了单例模式。
策略模式。在角色类中有两个抽象策略(武器策略和魔法策略),具体策略在类中实现。
双缓冲技术。在绘制游戏画面的时候使用了双缓冲技术,防止画面闪烁。
多线程。在两处使用了多线程,一处是为了解决按键冲突的问题,另一处是为了实现游戏周期性判定的功能。
awt。
基本逻辑流程
抽象角色类由具体子类实现,子类主要实现了抽象方法getAppearance
,用于获取角色的外貌(即图片),外貌会根据角色状态的不同而改变,比如角色死亡时外貌是墓碑;
根据角色的坐标以及属性(例如是否隐身,当前武器是什么)来绘制角色以及属性条、武器栏和魔法栏。
游戏时钟周期线程用于周期性地执行一些操作,例如每秒钟恢复一定的 HP 和 MP,对于隐身状态的角色,每秒钟扣除一定量的 MP 等。
游戏说明
玩家 1 操作: 键盘上 A 键 D 键分别对应左右移动,J 键使用武器攻击,K 键使用魔法,L 键切换武器,O 键切换魔法;
玩家 2 操作: 键盘上 ← 键 → 键分别对应左右移动,小键盘上,1 键使用武器攻击,2 键使用魔法,3 键切换武器,6 键切换魔法;
每把武器有自己的攻击威力和攻击距离,只有在两个角色的距离在武器的攻击范围内时,才能够攻击成功;
伤害计算公式为:被攻击者受到的最终伤害=攻击者攻击力+攻击者武器威力-被攻击者的防御力。若伤害小于等于 0,则不予扣除;
每秒钟会恢复一定量的 HP 和 MP;
一方死亡(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; 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); 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; private Characters player2; private FightFieldFrame fff; public GamePad (Characters p1, Characters p2, FightFieldFrame f) { player1=p1; player2=p2; fff=f; } @Override public void keyPressed (KeyEvent e) { int code=e.getKeyCode(); switch (code) { case KeyEvent.VK_J: player1.fight(player2); player2.display(); break ; case KeyEvent.VK_K: player1.performMagic(player2); break ; case KeyEvent.VK_A: player1.setMoveLeftFlag(true ); player1.setDirection(true ); break ; case KeyEvent.VK_D: player1.setMoveRightFlag(true ); player1.setDirection(false ); break ; case KeyEvent.VK_L: player1.changeWeapon(); break ; case KeyEvent.VK_O: player1.changeMagic(); break ; case KeyEvent.VK_NUMPAD1: player2.fight(player1); player1.display(); break ; case KeyEvent.VK_NUMPAD2: player2.performMagic(player1); break ; case KeyEvent.VK_LEFT: player2.setMoveLeftFlag(true ); player2.setDirection(true ); break ; case KeyEvent.VK_RIGHT: player2.setMoveRightFlag(true ); player2.setDirection(false ); break ; case KeyEvent.VK_NUMPAD3: player2.changeWeapon(); break ; case KeyEvent.VK_NUMPAD6: 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 public void moveLeft () { if (!isAliveFlag) return ; if (moveLeftFlag) { x-=1 ; try { Thread.sleep(1 ); } catch (InterruptedException e) { e.printStackTrace(); } } } 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 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); } 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 { 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 ; private static final String APPEARANCE_PATH="image\\Weapon\\Sword.png" ; public SwordBehavior () {} public SwordBehavior (String _name) { name=_name; } @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 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; private Characters player2; private FightFieldFrame fff; private int interval=1000 ; public ClockThread (Characters p1, Characters p2, FightFieldFrame f) { player1=p1; player2=p2; fff=f; } 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 ; } } 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。