[关闭]
@Darcy 2017-08-11T03:17:32.000000Z 字数 17511 阅读 2847

第十一章 回归图形—乒乓球游戏

JavaForKids

在5,6,7章中,我们使用到了一些AWTSwing的组件。现在我将向你展示如何在窗口中绘制和移动类似椭圆,矩形,线条类型的对象。你还会学到如何处理鼠标和键盘事件。为了增添一些乐趣,在这一章中我们将会通过创建乒乓球游戏的形式来学习。这个游戏会有两个玩家,我将他们分别称作小孩和电脑。

1. 游戏策略

先列出这个游戏的一些规则:

  1. 当其中一个玩家(小孩或者电脑)拿到21分时,游戏结束。
  2. 小孩的球拍由电脑鼠标来控制操作。
  3. 游戏分数展示在窗口的底部。
  4. 当一名玩家按下N键时开始一轮新游戏,按下Q键结束当局游戏,按下S键进行发球。
  5. 只有小孩可以发球。
  6. 把球击过竖线而且对方没有接到,获得一分。
  7. 电脑击中球后,只能水平向右移动。
  8. 如果小孩的球拍在桌子上半部分击中球,那么球应该向左上方移动;如果在桌面下半部分接到球,就向左下方移动。

你一定觉得这个程序太难写不出来。秘诀是我们可以将一个复杂的任务分解成一系列更小更简单的任务,再逐个击破。

这个秘诀叫做分散思维,这种思维方式不仅能帮助我们编程,还能在生活中广泛应用:如果你不能达到某个大目标,不要沮丧,将这个目标分解成一系列的小目标,然后逐一实现。

这也是为什么第一个版本的游戏只实现三条规则:只需要绘制球台,移动球拍,点击鼠标时能展示鼠标指针的坐标。

分散思维例子:

与其说“我的电脑不能使用了”(一个大问题),还不如尝试看看到底是哪个地方出问题了(找到小问题)。
1. 电脑有接通电源吗(是/否)? 是。
2. 当我开机时,有看到屏幕上有这些图标吗(是/否)? 是。
3. 我的鼠标能在屏幕上移动吗?(是/否)? 否。
4. 鼠标线有插紧了吗?(是/否)? 否。
只需要把鼠标插好,电脑就能再次工作了!一个大问题的解决方式仅仅只是插紧鼠标线而已。

2. 代码结构

这个游戏将由下面三个类组成:

乒乓球台看起来如下图所示:

游戏第一版将只完成三件事:

  1. 显示绿色的乒乓球台。
  2. 在你点击鼠标时显示鼠标指针的坐标。
  3. 将小孩的球拍上下移动。

在下面内容中你会看到PingPongGreenTableSwing包中JPanel类的子类。请务必要对照代码阅读以下内容:

由于在游戏中需要鼠标指针的确切位置,在这里我们通过在PingPongGreenTable的构造方法中创建PingPongGameEngine这个监听器实例来实现。当小孩点击鼠标或者移动鼠标时,PingPongGameEngine类会做出事件响应。

addPaneltoFrame()方法创建了一个展示鼠标坐标的标签。

这个类并不是一个applet 程序,这也是为什么我们要用paintComponent()方法而不是paint()paintComponent()方法在两种情况下会被JVM调用:在程序需要刷新窗口时,或者当程序调用了repaint()方法时。前面已经学习过,repaint()方法内部调用了paintComponent(),并回传Graphics对象,这样你就能绘制窗口了。每一次重新计算了球拍或者球的坐标后,我们都需要调用repaint()方法使球拍或者球展示在正确的位置上。

为了绘制球拍,首先设置需要的颜色,然后用fillRect()方法将颜色填充到矩形内。这个方法需要知道矩形左上角的x坐标、y坐标以及矩形宽度width、高度height,它们的单位都是像素。球的绘制使用fillOval()方法,这个方法需要知道椭圆的中心点坐标(x,y),高度height和宽度width。当椭圆的高度和宽度一致时,就成了圆。

在窗口中,x坐标从左向右递增,y坐标至上向下递增。比如,下面这个矩形的宽度是100个像素,高度是70个像素:

xy的坐标写在圆括号中展示在矩形的各个角附近。

getPreferredSize()方法也很有意思。我们创建一个Dimension类的实例来设置球桌的尺寸。JVM通过调用PingPongGreenTable对象的getPreferredSize()方法来获得窗口的大小。这个方法会根据球台的尺寸给JVM返回一个Dimension对象。

球台和引擎类都用到到不需要改变的常量值。比如PingPongGreenTable用到了球台的宽度和高度,PingPongGameEngine需要知道球的移动增量:增量越小,移动越平滑。在接口中声明这些常量(final变量)是很方便的。在我们的游戏程序中,接口名称是GameConstants。如果一个类需要用到这些值,只需要在类声明时加上implements GameConstants,然后就可以任意使用接口类中的所有final变量的,就像已经在自身类中声明了一样。这也是为什么PingPongGreenTablePingPongGameEngine都实现了GameConstants接口。

如果你想要改变球台,球或者球拍的尺寸,只需要在一个地方进行改动—GameConstants接口中。现在来看看PingPongGreenTable类和GameConstants接口。

我们先来看看接口类GameConstants,所有的变量值单位都是像素,使用大写字母来命名final变量。

  1. package screens;
  2. public interface GameConstants {
  3. public final int TABLE_WIDTH = 320;
  4. public final int TABLE_HEIGHT = 220;
  5. public final int KID_RACKET_Y_START = 100;
  6. public final int KID_RACKET_X_START = 300;
  7. public final int TABLE_TOP = 12;
  8. public final int TABLE_BOTTOM = 180;
  9. public final int RACKET_INCREMENT = 4;
  10. }

程序在运行中不能修改声明为final类型的变量的值。但如果你一定要修改,比如你想要将球台的尺寸扩大,那么就需要修改TABLE_WIDTHTABLE_HEIGHT的值,然后重新编译GameConstants接口。

下面是我们游戏中创建的乒乓球台的有关常量大小:

下面是乒乓球桌的实现类,你可以对照着上面的图或者运行的效果来阅读下面的代码。

  1. /**
  2. *
  3. * 这个类负责绘制绿色的乒乓球桌,球拍,鼠标点击会显示坐标
  4. *
  5. */
  6. public class PingPongGreenTable extends JPanel implements GameConstants {
  7. JLabel label;
  8. public Point point = new Point(0, 0);
  9. public int ComputerRacket_X = 15;
  10. private int kidRacket_Y = KID_RACKET_Y_START;
  11. Dimension preferredSize = new Dimension(TABLE_WIDTH, TABLE_HEIGHT);
  12. // 这个方法会被JVM调用来设置窗口框架的大小
  13. public Dimension getPreferredSize() {
  14. return preferredSize;
  15. }
  16. // 构造方法。创建并添加各种事件
  17. PingPongGreenTable() {
  18. PingPongGameEngine gameEngine = new PingPongGameEngine(this);
  19. // 监听鼠标的点击并显示点击坐标
  20. addMouseListener(gameEngine);
  21. // 监听鼠标移动,让球拍随之移动
  22. addMouseMotionListener(gameEngine);
  23. }
  24. // 把当前的面板和一个提示标签添加到窗口框架中
  25. void addPaneltoFrame(Container container) {
  26. container.setLayout(new BoxLayout(container, BoxLayout.Y_AXIS));
  27. container.add(this);
  28. label = new JLabel("点击显示该点坐标");
  29. container.add(label);
  30. }
  31. // 重绘窗口。当刷新屏幕或者调用repaint()的时候会被JVM调用
  32. @Override
  33. public void paintComponent(Graphics g) {
  34. super.paintComponent(g);
  35. g.setColor(Color.GREEN);
  36. // 绘制绿色的球桌
  37. g.fillRect(0, 0, TABLE_WIDTH, TABLE_HEIGHT);
  38. // 绘制右边小孩的黄色球拍
  39. g.setColor(Color.yellow);
  40. g.fillRect(KID_RACKET_X_START, kidRacket_Y, 5, 30);
  41. // 绘制左边电脑的蓝色球拍
  42. g.setColor(Color.blue);
  43. g.fillRect(ComputerRacket_X, 100, 5, 30);
  44. // 绘制红色小球
  45. g.setColor(Color.red);
  46. g.fillOval(25, 110, 10, 10);
  47. // 绘制四周和中间的白线
  48. g.setColor(Color.white);
  49. g.drawRect(10, 10, 300, 200);
  50. g.drawLine(160, 10, 160, 210);
  51. // 显示一点2×2像素大小的矩形小点
  52. if (point != null) {
  53. label.setText("Coordinates (x,y): " + point.x + ", " + point.y);
  54. g.fillRect(point.x, point.y, 2, 2);
  55. }
  56. }
  57. /**
  58. * 设置小孩球拍的Y坐标
  59. *
  60. * @param yCoordinate
  61. * 从球桌顶部开始向下算,单位是像素
  62. */
  63. public void setKidRacket_Y(int xCoordinate) {
  64. this.kidRacket_Y = xCoordinate;
  65. }
  66. /**
  67. * 获取小孩球拍的Y坐标
  68. *
  69. * @return
  70. */
  71. public int getKidRacket_Y(int xCoordinate) {
  72. return kidRacket_Y;
  73. }
  74. public static void main(String[] args) {
  75. // 创建窗口框架实例
  76. JFrame f = new JFrame("乒乓球游戏");
  77. // 设置点击窗口右上角的关闭按钮可退出
  78. f.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
  79. PingPongGreenTable table = new PingPongGreenTable();
  80. table.addPaneltoFrame(f.getContentPane());
  81. // 自适应尺寸和显示窗口
  82. f.pack();
  83. f.setVisible(true);
  84. }
  85. }

这个程序中的决策者是PingPongGameEngine类,这个类实现了两个鼠标相关的接口。MouseListener中只有mousePressed()方法有实现代码。鼠标每点击一次,这个方法就会在球桌上绘制一个小白点,并展示它的坐标。实际上,这段代码对我们这个游戏是没有用的,但是它向你展示了获取鼠标坐标的简单方式:通过JVM提供的MouseEvent对象。

mousePressed()方法根据玩家点击鼠标时指针的位置来给point变量设置值。在坐标值设定后,该方法向JVM请求重新绘制球桌。

MouseMotionListener对玩家在球桌上移动鼠标做出响应。我们会用到它的mouseMoved()方法来操作小孩的球拍上下移动。

我们可以在mouseMoved()里面计算小孩球拍的下一个位置:如果鼠标指针的位置比球拍高(也就是鼠标的y坐标比球拍的y坐标小),则让球拍的位置加上相应的移动距离,反之,要减去相应的移动距离,但是要保证球拍的坐标不能超过球桌的高度范围。

当我们在PingPongGreenTable的构造方法里面创建engine对象的时候,我们还向engine传入了这个table对象本身的引用(这里的this关键字表示对当前PingPongGreenTable对象的引用)。现在engine就可以和table“对话”了,比如设置乒乓球坐标或者重新绘制球桌。如果这一部分的内容你不太清楚,或许你需要回头看看第六章关于类之间参数传递的部分。

GameConstants接口中定义了球拍每次的移动增量(engine类实现了这个接口),球拍从一个位置垂直地移动到另一个位置移动距离是RACKET_INCREMENT[4]个像素。比如,下一行代码表示kidRacket_Y变量减去4:

  1. kidRacket_Y -= RACKET_INCREMENT;

举个例子,如果球拍的y坐标原本为100,在这行代码之后就变成了96,也就意味着球拍向上移动了。使用下面的语句你也能得到相同的结果:

  1. kidRacket_Y = kidRacket_Y - RACKET_INCREMENT;

如果你还记得,我们在第三章的时候讨论过修改变量值的不同方式。

PingPongGameEngine如下所示:

  1. public class PingPongGameEngine implements MouseListener, MouseMotionListener,
  2. GameConstants {
  3. PingPongGreenTable table;
  4. public int kidRacket_Y = KID_RACKET_Y_START;
  5. public PingPongGameEngine(PingPongGreenTable greenTable) {
  6. table = greenTable;
  7. }
  8. /**
  9. * 按下鼠标的是否JVM会回调这个方法
  10. */
  11. public void mousePressed(MouseEvent e) {
  12. // 获取鼠标按下时的坐标,并设置到table对象中,对应的就是table中的白点。
  13. table.point.x = e.getX();
  14. table.point.y = e.getY();
  15. // 调用这个方法会触发table的paintComponent()去刷新窗口
  16. table.repaint();
  17. }
  18. public void mouseReleased(MouseEvent e) {
  19. };
  20. public void mouseEntered(MouseEvent e) {
  21. };
  22. public void mouseExited(MouseEvent e) {
  23. };
  24. public void mouseClicked(MouseEvent e) {
  25. };
  26. public void mouseDragged(MouseEvent e) {
  27. }
  28. public void mouseMoved(MouseEvent e) {
  29. int mouse_Y = e.getY();
  30. // 如果鼠标在小孩的球拍之上并且没有超过球桌的顶部,那就让球拍向上移动,反之向下
  31. if (mouse_Y < kidRacket_Y && kidRacket_Y > TABLE_TOP) {
  32. kidRacket_Y -= RACKET_INCREMENT;
  33. } else if (kidRacket_Y < TABLE_BOTTOM) {
  34. kidRacket_Y += RACKET_INCREMENT;
  35. }
  36. // 设置小孩球拍新的Y坐标
  37. table.setKidRacket_Y(kidRacket_Y);
  38. table.repaint();
  39. }
  40. }

3. Java线程基础

到目前为止,我们的程序都是按顺序逐个执行的。如果一个程序调用两个方法,那么等到第一个方法执行完毕后第二个方法才开始运行。换句话说,我们的每个程序都只有一个执行线程。

然后在现实生活中,我们可以在同一时刻同时做好几件事情,比如边吃东西边讲电话、看电视或者做家庭作业。为了让这些行为同步进行,我们用到了好几个处理器:手,眼睛和嘴巴。

一些比较贵的电脑会有两个甚至更多的处理器[1]。但是大部分的电脑都只有一个处理器来执行计算,给监听器发命令,访问磁盘,访问远程计算机等等。

但是如果一个程序使用多线程,那么即使一个处理器也能并行执行好几个动作[2]。一个Java类可以开启多条执行线程来排队等待处理器的时间片。

Web浏览器是一个程序创建多条线程的典型例子。你可以在浏览网页的同时下载一些文件:那么这时候浏览器程序就是在执行两条线程。

我们乒乓球游戏的下一个版本将会有一条线程来展示球桌,另一条线程会计算球和球拍的坐标并发送给第一条线程使其重绘窗口。但是我先要展示两个简单的程序让你对“线程为什么有用?”有更好的理解。

每一个示例程序都会显示一个按钮以及一个文本框。

当你按下按钮Kill Time后,这个程序会开启循环将让一个变量自增30,000次,并且让每次循环都“休息”一小段时间。变量的当前值会展示在窗口的标题栏上。NoThreadsSample类只有一条执行线程,在循环执行的过程中,你无法在文本框中输入任何内容。这个循环占据了处理器的全部时间,这就是为什么程序窗口被锁住了。

编译和运行这个类,你可以看到窗口在有些时候会被锁住。请注意,这个类没有声明一个变量来保存对JTextField实例的引用,而是直接创建了一个JTextField的实例,并直接传递给内容面板作为参数。如果你不打算在程序中设置或者获取这个对象中的一些属性值,你不需要使用引用变量。

  1. public class NoThreadsSample extends JFrame implements ActionListener {
  2. //构造方法
  3. NoThreadsSample() {
  4. // 创建一个有一个按钮和一个输入文本框的窗口
  5. GridLayout gl = new GridLayout(2, 1);
  6. this.getContentPane().setLayout(gl);
  7. JButton myButton = new JButton("Kill Time");
  8. myButton.addActionListener(this);
  9. this.getContentPane().add(myButton);
  10. this.getContentPane().add(new JTextField());
  11. }
  12. // 处理按钮点击
  13. public void actionPerformed(ActionEvent e) {
  14. // 消耗一些时间去阻塞窗口的控件
  15. for (int i = 0; i < 30000; i++) {
  16. try {
  17. Thread.sleep(100);
  18. } catch (InterruptedException e1) {
  19. e1.printStackTrace();
  20. }
  21. this.setTitle("i=" + i);
  22. }
  23. }
  24. public static void main(String[] args) {
  25. NoThreadsSample myWindow = new NoThreadsSample();
  26. myWindow.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
  27. myWindow.setBounds(0, 0, 150, 100);
  28. myWindow.setVisible(true);
  29. }
  30. }

这个小窗口程序的下一个版本将会创建并启动一条单独的线程来做循环操作,而窗口的主线程使你可以在循环运行时向文本框中输入内容。

你可以采用下面列举的任一方法创建Java线程:

1、创建Java类Thread的实例,并将实现了Runnable接口的对象实例作为this参数传入。如果你的类实现了Runnable接口,那么代码看起来会是这样的:

  1. Thread worker = new Thread(this);

这个接口要求你在run方法中写入需要独立线程运行的代码。但是为了启动这条线程,你需要调用方法start()start()方法才能真正地调用你的run()方法。我知道这有点让你困惑,但这就是你启动这条线程的方式:

  1. worker.start();

2、创建一个Thread类的子类,并实现run方法。启动这条线程的方法是调用start()

  1. public class MyThread extends Thread{
  2. public static void main(String[] args) {
  3. MyThread worker = new MyThread();
  4. worker.start();
  5. }
  6. public void run(){
  7. // 在这里写线程运行的代码
  8. }
  9. }

ThreadsSample中,我会采用第一种方式来实现多线程,因为ThreadsSample类已经继承了JFrame,而在Java中,一个类是不能有多个父类的。

  1. public class ThreadsSample extends JFrame implements ActionListener, Runnable {
  2. ThreadsSample() {
  3. GridLayout gl = new GridLayout(2, 1);
  4. this.getContentPane().setLayout(gl);
  5. JButton myButton = new JButton("Kill Time");
  6. myButton.addActionListener(this);
  7. this.getContentPane().add(myButton);
  8. this.getContentPane().add(new JTextField());
  9. }
  10. public void actionPerformed(ActionEvent e) {
  11. //创建一个消耗时间的线程,同时让窗口处于可活动状态
  12. Thread worker = new Thread(this);
  13. worker.start(); // 开启线程,它会执行run里面的代码
  14. }
  15. public void run() {
  16. for (int i = 0; i < 30000; i++) {
  17. try {
  18. Thread.sleep(100);
  19. } catch (InterruptedException e1) {
  20. e1.printStackTrace();
  21. }
  22. this.setTitle("i=" + i);
  23. }
  24. }
  25. public static void main(String[] args) {
  26. ThreadsSample myWindow = new ThreadsSample();
  27. myWindow.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
  28. myWindow.setBounds(0, 0, 150, 100);
  29. myWindow.setVisible(true);
  30. }
  31. }

当你点击Kill Time时,ThreadsSample启动了一条新的线程。在这之后,这条控制循环的线程和主线程轮流获取处理器的时间片。现在你可以在另一条线程运行循环的同时向文本框中输入内容(主线程)了。

多线程不仅仅只局限于这几页的学习,我建议你进行多线程相关的延伸阅读。

4. 完成乒乓球游戏

简单地介绍过多线程后,我们可以来修改乒乓球游戏的代码了。

让我们从PingPongGreenTable类开始。我们不需要在用户点击鼠标时展示一个小白点--这只是为了学习如何展示鼠标坐标的小练习。这也是为什么我们移除了PingPongGreenTablepoint变量的声明和在paintComponent()方法里面绘制小白点的几行代码。同样,构造器也不需要添加MouseListener,因为它的作用仅仅是为了展示小白点。

在另一个方面,这个类需要对键盘按钮点击做出响应(N开始游戏,S发球,Q结束游戏)。addKeyListener()可以做到这些。

为了让我们的代码具备更好的封装性,我还要将repaint()方法从engine类移到PingPongGreenTable。通过这样修改之后,从现在开始,在有需要(比如球的位置发生了改变等)的时候,table就会自动重绘自己了。

我还添加了方法来改变球的位置,改变电脑球拍的位置,展示信息。

  1. /**
  2. *
  3. * 这个类负责绘制绿色的乒乓球桌,球拍和显示分数
  4. *
  5. */
  6. public class PingPongGreenTable extends JPanel implements GameConstants {
  7. private JLabel label;
  8. private int computerRacket_Y = COMPUTER_RACKET_Y_START;
  9. private int kidRacket_Y = KID_RACKET_Y_START;
  10. private int ballX = BALL_START_X;
  11. private int ballY = BALL_START_Y;
  12. Dimension preferredSize = new Dimension(TABLE_WIDTH, TABLE_HEIGHT);
  13. //这个方法会被JVM调用来设置窗口框架的大小
  14. @Override
  15. public Dimension getPreferredSize() {
  16. return preferredSize;
  17. }
  18. // 构造方法。创建并添加各种事件
  19. PingPongGreenTable() {
  20. PingPongGameEngine gameEngine = new PingPongGameEngine(this);
  21. // 添加鼠标移动事件
  22. addMouseMotionListener(gameEngine);
  23. // 添加键盘事件
  24. addKeyListener(gameEngine);
  25. }
  26. // 把当前的面板和一个提示标签添加到窗口框架中
  27. void addPaneltoFrame(Container container) {
  28. container.setLayout(new BoxLayout(container, BoxLayout.Y_AXIS));
  29. container.add(this);
  30. label = new JLabel("按下N键开始新游戏,S键开球,Q键退出。");
  31. container.add(label);
  32. }
  33. // 重绘窗口。当刷新屏幕或者调用repaint()的时候会被JVM调用
  34. @Override
  35. public void paintComponent(Graphics g) {
  36. super.paintComponent(g);
  37. g.setColor(Color.GREEN);
  38. // 绘制绿色的球桌
  39. g.fillRect(0, 0, TABLE_WIDTH, TABLE_HEIGHT);
  40. // 绘制右边小孩的黄色球拍
  41. g.setColor(Color.yellow);
  42. g.fillRect(KID_RACKET_X, kidRacket_Y, RACKET_WIDTH, RACKET_LENGTH);
  43. // 绘制左边电脑的绿色球拍
  44. g.setColor(Color.blue);
  45. g.fillRect(COMPUTER_RACKET_X, computerRacket_Y, RACKET_WIDTH,RACKET_LENGTH);
  46. // 绘制小球
  47. g.setColor(Color.red);
  48. g.fillOval(ballX, ballY, 10, 10);
  49. // 绘制四周和中间的白线
  50. g.setColor(Color.white);
  51. g.drawRect(10, 10, 300, 200);
  52. g.drawLine(160, 10, 160, 210);
  53. // 设置面板的状态为focus, 这样键盘事件才会传递到这个面板上
  54. requestFocus();
  55. }
  56. /**
  57. * 设置小孩球拍的Y坐标
  58. * @param yCoordinate 从球桌顶部开始向下算,单位是像素
  59. */
  60. public void setKidRacket_Y(int yCoordinate) {
  61. this.kidRacket_Y = yCoordinate;
  62. repaint();
  63. }
  64. /**
  65. * 获取小孩球拍的Y坐标
  66. * @return
  67. */
  68. public int getKidRacket_Y() {
  69. return kidRacket_Y;
  70. }
  71. /**
  72. * 设置电脑球拍的Y坐标
  73. * @param yCoordinate 从球桌顶部开始向下算,单位是像素
  74. */
  75. public void setComputerRacket_Y(int yCoordinate) {
  76. this.computerRacket_Y = yCoordinate;
  77. repaint();
  78. }
  79. /**
  80. * 设置在面板下面的游戏消息提示。
  81. * @param text
  82. */
  83. public void setMessageText(String text) {
  84. label.setText(text);
  85. repaint();
  86. }
  87. /**
  88. * 设置球在球桌上的坐标
  89. * @param xPos 从球桌左边开始向右算,单位是像素
  90. * @param yPos 从球桌顶部开始向下算,单位是像素
  91. */
  92. public void setBallPosition(int xPos, int yPos) {
  93. ballX = xPos;
  94. ballY = yPos;
  95. repaint();
  96. }
  97. /**
  98. * 程序入口
  99. * @param args
  100. */
  101. public static void main(String[] args) {
  102. // 创建窗口框架实例
  103. JFrame f = new JFrame("乒乓球游戏");
  104. PingPongGreenTable table = new PingPongGreenTable();
  105. table.addPaneltoFrame(f.getContentPane());
  106. // 设置窗口大小
  107. f.setBounds(0, 0, TABLE_WIDTH + 20, TABLE_HEIGHT + 60);
  108. f.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
  109. f.setVisible(true);
  110. }
  111. }

以下是我在PingPongGameEngine类中重点改变的部分:

  1. 我移除了MouseListener接口及其所有方法,因为我们不再需要处理鼠标点击事件。MouseMotionListener会处理所有的鼠标移动事件。
  2. 这个类实现了Runnable接口,你可以在run()方法中找到游戏策略的实现代码。看看构造器——我在里面创建并启动了一条新的线程。run()方法分步骤实现游戏策略的规则,这些规则写在if语句if(ballServed)中。if(ballServed)if(ballServed==true)的简写。
  3. 请留意if条件语句刚开始给变量canBounce的赋值的地方,这里使用了条件表达式来给这个变量赋值,根据前面条件的成立与否来决定赋值为true或者false
  4. 这个类实现了KeyListener接口,keyPressed()根据键盘输入的字母来决定是开始游戏还是结束游戏,或者是发球。这个方法的代码实现了允许用户随意输入大小写,比如N或者n。
  5. 我还添加了一些private的方法比如displayScore(), kidServe()还有 isBallOnTheTable()。这些方法被声明为private是因为他们只在这个类的内部用到,其他类完全不需要知道它们。这也是封装性的实际应用。
  6. 一些电脑的运行速度太快,使得球的移动很难控制。这也是为什么我调用了Thread.sleep()来放慢游戏。静态方法sleep()会把当前运行的线程暂停一段时间,它的参数单位是毫秒。
  7. 为了给这个游戏增添些乐趣,当小孩的球拍击中球后会向对角移动。这也是为什么代码不仅要改变x坐标还要改变y坐标。
  1. /**
  2. *
  3. * 这个类是是鼠标和键盘的监听处理类。它负责计算球和拍的移动,改变它们的坐标。
  4. *
  5. */
  6. public class PingPongGameEngine implements Runnable, MouseMotionListener,
  7. KeyListener, GameConstants {
  8. private PingPongGreenTable table;// 球桌对象的引用
  9. private int kidRacket_Y = KID_RACKET_Y_START;
  10. private int computerRacket_Y = COMPUTER_RACKET_Y_START;
  11. private int kidScore;
  12. private int computerScore;
  13. private int ballX; // 小球的X坐标
  14. private int ballY; // 小球的Y坐标
  15. private boolean movingLeft = true; //true为向左移动,false为向右移动
  16. private volatile boolean ballServed = false;
  17. // 球在垂直方向的移动分量
  18. private int verticalSlide;
  19. // 带参数的构造方法,参数为球桌对象
  20. public PingPongGameEngine(PingPongGreenTable greenTable) {
  21. table = greenTable;
  22. //创建和开启小球和电脑球拍的移动线程
  23. Thread worker = new Thread(this);
  24. worker.start();
  25. }
  26. //MouseMotionListener接口中的方法(接口中的方法必须覆盖,即使是个空方法)
  27. @Override
  28. public void mouseDragged(MouseEvent e) {
  29. }
  30. //鼠标移动时会被JVM调用这个方法
  31. @Override
  32. public void mouseMoved(MouseEvent e) {
  33. int mouse_Y = e.getY();
  34. // 如果鼠标在小孩的球拍之上并且没有超过球桌的顶部,那就让球拍向上移动,反之向下
  35. if (mouse_Y < kidRacket_Y && kidRacket_Y > TABLE_TOP) {
  36. kidRacket_Y -= RACKET_INCREMENT;
  37. } else if (kidRacket_Y < TABLE_BOTTOM) {
  38. kidRacket_Y += RACKET_INCREMENT;
  39. }
  40. // 设置小孩球拍新的Y坐标
  41. table.setKidRacket_Y(kidRacket_Y);
  42. }
  43. //键盘按下时会被JVM调用这个方法
  44. @Override
  45. public void keyPressed(KeyEvent e) {
  46. char key = e.getKeyChar();
  47. if ('n' == key || 'N' == key) {
  48. startNewGame();
  49. } else if ('q' == key || 'Q' == key) {
  50. endGame();
  51. } else if ('s' == key || 'S' == key) {
  52. kidServe();
  53. }
  54. }
  55. @Override
  56. public void keyReleased(KeyEvent e) {
  57. }
  58. @Override
  59. public void keyTyped(KeyEvent e) {
  60. }
  61. /**
  62. * 重置分数,开始新游戏
  63. */
  64. public void startNewGame() {
  65. computerScore = 0;
  66. kidScore = 0;
  67. table.setMessageText("Score Computer: 0 Kid: 0");
  68. kidServe();
  69. }
  70. /**
  71. * 退出程序
  72. */
  73. public void endGame() {
  74. System.exit(0);
  75. }
  76. /**
  77. * Runnable 接口的方法,会在线程开启之后被调用
  78. */
  79. @Override
  80. public void run() {
  81. boolean canBounce = false; //是否往回弹
  82. while (true) {
  83. if (ballServed) { //球是否在运动
  84. //第1步:球是否向左边移动
  85. if (movingLeft && ballX > BALL_MIN_X) {
  86. //是否能击中小球
  87. canBounce = computerRacket_Y <= ballY && ballY < (computerRacket_Y + RACKET_LENGTH) ? true: false;
  88. //加上水平移动分量
  89. ballX -= BALL_INCREMENT;
  90. // 加上垂直移动分量
  91. ballY -= verticalSlide;
  92. table.setBallPosition(ballX, ballY);
  93. //已经到了对面,能击中小球?
  94. if (ballX <= COMPUTER_RACKET_X && canBounce) {
  95. movingLeft = false;
  96. }
  97. }
  98. //第2步:球是否向右边移动
  99. if (!movingLeft && ballX <= BALL_MAX_X) {
  100. canBounce = ballY >= kidRacket_Y&& ballY < (kidRacket_Y + RACKET_LENGTH) ? true: false;
  101. ballX += BALL_INCREMENT;
  102. table.setBallPosition(ballX, ballY);
  103. if (ballX >= KID_RACKET_X && canBounce) {
  104. movingLeft = true;
  105. }
  106. }
  107. // 第3步:上或下移动电脑球拍
  108. if (computerRacket_Y < ballY && computerRacket_Y < TABLE_BOTTOM) {
  109. computerRacket_Y += RACKET_INCREMENT;
  110. } else if (computerRacket_Y > TABLE_TOP) {
  111. computerRacket_Y -= RACKET_INCREMENT;
  112. }
  113. table.setComputerRacket_Y(computerRacket_Y);
  114. // 第4步: 让线程睡一下,这样小球可以移动地慢些
  115. try {
  116. Thread.sleep(SLEEP_TIME);
  117. } catch (InterruptedException e) {
  118. e.printStackTrace();
  119. }
  120. // 第5步: 如果球还在球桌内,并且不再移动了,就显示分数
  121. if (isBallOnTheTable()) {
  122. if (ballX > BALL_MAX_X) {
  123. computerScore++;
  124. displayScore();
  125. } else if (ballX < BALL_MIN_X) {
  126. kidScore++;
  127. displayScore();
  128. }
  129. }
  130. }
  131. }
  132. }
  133. // 小孩发球
  134. private void kidServe() {
  135. ballServed = true;
  136. ballX = KID_RACKET_X - 1;
  137. ballY = kidRacket_Y;
  138. if (ballY > TABLE_HEIGHT / 2) {
  139. verticalSlide = -1;
  140. } else {
  141. verticalSlide = 1;
  142. }
  143. table.setBallPosition(ballX, ballY);
  144. table.setKidRacket_Y(kidRacket_Y);
  145. }
  146. private void displayScore() {
  147. ballServed = false;
  148. if (computerScore == WINNING_SCORE) {
  149. table.setMessageText("Computer won! " + computerScore + ":"+ kidScore);
  150. } else if (kidScore == WINNING_SCORE) {
  151. table.setMessageText("You won! " + kidScore + ":" + computerScore);
  152. } else {
  153. table.setMessageText("Computer: " + computerScore + " Kid: "+ kidScore);
  154. }
  155. }
  156. /**
  157. * 检查球是否还在球桌上
  158. * @return 如果还在球桌范围,返回true; 否则返回false
  159. */
  160. private boolean isBallOnTheTable() {
  161. if (ballY >= BALL_MIN_Y && ballY <= BALL_MAX_Y) {
  162. return true;
  163. } else {
  164. return false;
  165. }
  166. }
  167. }

NOTE:细心的同学相信已经发现有个成员变量被添加了volatile关键字,这是译者在检查代码的时候发现程序运行不正常之后加上的,这可能是由于新版本的JDK的内存模型发生了变化,在这里我们的目的是为了让它在内存中值发生变化的时候立即对另外一个线程可见。实际上源代码还存在不少的多线程问题,当然这是比较高级的话题了,你可以先忽略这个关键字,它不会影响你对程序的整体理解的。

好吧,恭喜你!现在已经完成了你的第二个游戏。编译这个类开始玩乒乓球游戏吧。在你对代码更熟悉后,尝试着去修改它,我相信你会有更好的主意把这个游戏写得更好。

5. 关于游戏编程你还可以阅读的内容

  1. CodeRally是一个由IBM赞助的,基于Java的实时编程游戏,它基于Eclipse平台。它可以让不熟悉Java的用户更容易地完成Java语言学习。玩家开发一部赛车,再根据其他玩家或者检查站的位置,以及他们当前的油量等等其他因素来决定将汽车加速、转弯、减速。
    http://www.alphaworks.ibm.com/tech/codeRally

  2. Robocode是一个有趣的编程游戏,通过让你创建Java机器人来进行Java教学。
    http://www.alphaworks.ibm.com/tech/robocode

6. 扩展阅读

Java多线程指南:
http://java.sun.com/docs/books/tutorial/essential/threads/
Java多线程介绍:
http://www-106.ibm.com/developerworks/edu/j-dwjavathread-i.html
java.awt.Graphics类:
http://t.cn/RLmNSCg

7. 练习

  1. PingPongGameEngine使用下面代码来设置白点的坐标:
  1. table.point.x = e.getX();。

PingPongGreenTable中的point变量变成private类型,并添加public权限的setPointCoordinates(int x, int y)来修改point的值。

使用这个方法修改engine类的代码。

2.我们的乒乓球游戏有个bug: 在已经决出胜负后,你仍然可以按下s键,游戏会继续进行。修复这个bug。

8. 进一步的练习

  1. 尝试修改RACKET_INCREMENTBALL_INCREMENT的值。值越大球拍和球的速度越快。修改代码使玩家可以选择1-10关,将选择的值作为球和球拍的增量。

  2. 目前的规则是当小孩在球桌的顶部击中球时,球会按对角线向上移动再快速掉落在桌面。修改程序将球按对角线从顶部向下移动,或者按对角线从底部向上移动。


創用 CC 授權條款
本著作係採用創用 CC 姓名標示-非商業性-禁止改作 2.5 中國大陸 授權條款授權.


[1] 现在大部分的电脑都有多个处理器,而且价格也不贵。
[2] 一个处理器并行处理多个任务,就想当于你写一下作业,然后去看看电视,接着又回来写作业,是一种在时间上复用计算资源的方法,我们可以把它叫做时分复用。
添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注