likes
comments
collection
share

运用Java多线程实现坦克移动

作者站长头像
站长
· 阅读数 14

前一小节,咱们实现了一个包含能够绘制坦克图片的绘图板的游戏窗体小程序。这一小节,咱们要实现的目标如下:

  1. 设计坦克的基类
  2. 实现各种类型带血条坦克的绘制
  3. 实现坦克移动
  4. 实现通过方向键控制玩家坦克移动

先调整上一小节的类设计,将MyPanel作为MyFrame的成员变量,在MyFrame无参构造中对其进行实例化和赋值;而MyPanel中也持有对MyFrame的依赖,调整如下:

package com.pf.java.tankbattle;

import ...

public class MyFrame extends JFrame {
    
    private MyPanel panel;

    public MyFrame() {
        ...

        // 将面板组件添加到窗口对象的内容面板中
        this.panel = new MyPanel(this);
        getContentPane().add(panel);

        ...

    }
}
package com.pf.java.tankbattle;

import ...

public class MyPanel extends JPanel {
    
    private MyFrame frame;

    public MyPanel(MyFrame frame) {
        this.frame = frame;
        ...
    }
}

这样主类就简化为:

package com.pf.java.tankbattle;

import ...

public class GameMain {

    public static void main(String[] args) {
        ...
        // 创建窗体对象
        new MyFrame();
    }

}

以上的操作还是遵循面向对象封装的思想,客户端(游戏主类)不需要关心游戏窗体组件内部的部件,这也不应该对客户端暴露出来。复习了下Java中面向对象的封装思想,我们再来看看面向对象中的继承。

坦克类设计

运用Java多线程实现坦克移动

这里咱们首先考虑一个坦克有哪些属性和行为,为其设计一个基类。然后再扩展两个具体的坦克类:玩家的英雄坦克和电脑的敌军坦克来继承这个基类。一起来看下基类中的属性(这里省略了getter和setter方法)和方法(省略了特定的构造器)。

基本的属性:

  • x、y坐标

    代表坦克在绘图板中被绘制时的左上角的坐标位置,坦克在前进时会导致某个方向的坐标值变化,转向时也可能导致坐标点的变化。

  • speed

    坦克前进的速度,也就是每1000毫秒坦克移动的像素数,如果坦克的速度是40,则移动一个像素需要25毫秒。如果通过多线程来控制坦克移动,则只要每休眠25毫秒让坦克往前移动一个像素即可。

  • direction

    枚举类型,坦克前进的方向。

  • blood

    坦克的血点,体现坦克血条的长度,被敌方坦克炮弹击中后会掉血,掉到0则坦克会被摧毁(调用其die()方法)。

  • picIndex

    在绘制坦克时要确定的索引位置,取值范围0至13。

    运用Java多线程实现坦克移动

  • gearToggle

    记录履带交替改变的布尔变量,坦克每向前移动一个像素,就会在两个只有履带纹样不同的坦克图片之间进行切换:

    运用Java多线程实现坦克移动

  • frame

    游戏窗体对象

关于坦克基本的行为,这里我们暂时提供几个方法:

/**
 * 坦克移动的方法,每次移动一个像素的距离
 * @return 是否被阻挡的布尔值,如果被阻挡则不会往前移动一个像素
 */
public boolean move() {
    // todo 待实现
    return false;
}

/**
 * 坦克转向的方法
 * @param direction 调转的方向
 */
public void turnRound(Direction direction) {
    // 调用direction属性的setter方法设置新的方向
    setDirection(direction);
}

/**
 * 坦克被绘制的方法
 * @param g 绘图板的画笔对象
 */
public void paint(Graphics g) {
    // todo 待实现
}

/**
 * 坦克被摧毁的方法
 */
public void die() {
    // todo 待实现
}

接下来我们着重实现paint(Graphics g)move()方法。

实现paint方法

首先我们封装一个绘制各种类型坦克的方法,实现代码如下:

public void paint(Graphics g) {
    // 根据坦克的方向获取其索引 ↓1处
    int index = direction.ordinal();
    // 计算截取坦克图片的起始位置 ↓2处
    int subX = (picIndex + 28 * index + (gearToggle ? 14 : 0)) * SIZE;
    // 抠图并绘制 ↓3处
    g.drawImage(ResourceMgr.tank.getSubimage(subX, 0, SIZE, SIZE), x, y, null);
}

代码详解:

  1. 1处获取枚举项所在的索引值,我们在定义方向枚举时是按照上、右、下、左的顺序定义的:

    package com.pf.java.tankbattle.enums;
    
    /**
     * 方向枚举类
     */
    public enum Direction {
        UP, RIGHT, DOWN, LEFT;
    }
    

    方向和索引的关系如下:

    运用Java多线程实现坦克移动

    因此,如果坦克的方向为DOWN,则通过direction.ordinal()我们将得到索引值2

  2. 2处计算要绘制的坦克的起始位置

    从下图中不难发现,当picIndex确定后,即要绘制的坦克类型确定后,假设picIndex0,我们发现每经过28个SIZE的像素单位后坦克的方向发生了变化,自然按照第一步确定的index计算出的偏移量为28 * index,再加上控制履带变化的变量,索引的偏移量为28 * index + (gearToggle ? 14 : 0),再算上picIndex和坦克的SIZE,最终得到计算坦克抠图的起始位置的表达式为:

    (picIndex + 28 * index + (gearToggle ? 14 : 0)) * SIZE
    

    运用Java多线程实现坦克移动

  3. 3处按照第二步计算出来的坦克图片的扣取区域的起点位置,扣取坦克SIZE宽高的区域,并以坦克当前的(x, y)坐标点进行绘制。

下面测试下坦克的绘制:

package com.pf.java.tankbattle;

import ...

public class MyPanel extends JPanel {

    // 临时在绘图板中定义一个英雄
    private HeroTank heroTank;
    
    ...

    public MyPanel(MyFrame frame) {
        ...

        // 实例化我们的英雄
        heroTank = new HeroTank(Direction.DOWN, 32, 32, 80, 0, frame);

    }

    @Override
    protected void paintComponent(Graphics g) {
        ...

        // 测试坦克的绘制,因为该方法会被调用多次,为防止被覆盖,x坐标每次都设置下    
        heroTank.setX(32);
        heroTank.paint(g);

        // 改变履带和x坐标位置再绘制一次
        heroTank.setGearToggle(!heroTank.isGearToggle());
        heroTank.setX(64);
        heroTank.paint(g);
    }
}

程序运行截图:

运用Java多线程实现坦克移动

现在咱再给坦克安上血条,首先我们编写一个工具类,以不同的颜色代表不同的血量范围,工具类代码如下:

package com.pf.java.tankbattle.util;

import ...

public class LifeColorUtil {

    /**
     * 根据血量计算出要显示的血条颜色
     * @param blood
     * @return
     */
    public static Color parseColor(int blood) {
        Color c;
        if (blood >= 90) {
            c = new Color(127, 255, 0);
        } else if (blood >= 80) {
            c = new Color(118, 238, 0);
        } else if (blood >= 60) {
            c = new Color(179, 238, 58);
        } else if (blood >= 50) {
            c = new Color(238, 238, 0);
        } else if (blood >= 40) {
            c = new Color(238, 220, 130);
        } else if (blood >= 30) {
            c = new Color(255, 193, 37);
        } else if (blood >= 15) {
            c = new Color(255, 127, 36);
        } else {
            c = new Color(255, 48, 48);
        }
        return c;
    }

}

好在idea支持色值展示,我们可以看到随着血量的减少,相应的色值的变化:

运用Java多线程实现坦克移动

Tank类的paint方法的最后再绘制上血条:

public void paint(Graphics g) {
    ...

    // 根据血量计算出血条颜色
    g.setColor(LifeColorUtil.parseColor(blood));
    // 绘制血条
    g.fillRect(x, y == 0 ? y : y - 2, 32 * blood / 100, 2);
}

MyPanel类中完善测试代码:

package com.pf.java.tankbattle;

import ...

public class MyPanel extends JPanel {

    ...

    @Override
    protected void paintComponent(Graphics g) {
        ...

        ...
        // 设置血量
        heroTank.setBlood(80);
        heroTank.paint(g);

        ...
        // 设置血量
        heroTank.setBlood(40);
        heroTank.paint(g);
        
        ...
        heroTank.setBlood(12);
        heroTank.paint(g);
    }
}

效果:

运用Java多线程实现坦克移动

实现坦克移动

要实现坦克的移动很简单,暂时不考虑与边界和障碍物的碰撞检测,在Tank基类中实现如下:

public boolean move() {
    // 让坦克履带转动起来
    gearToggle = !gearToggle;
    // 实现在前进方向移动一个像素的距离
    switch (direction) {
        case LEFT:
            x--;
            break;
        case UP:
            y--;
            break;
        case RIGHT:
            x++;
            break;
        case DOWN:
            y++;
    }
    return true;
}

为了让坦克在绘图板中“活”起来,我们需要不断的刷新绘图板的画面,也就是先清除画布,再重新在新的位置绘制坦克,这样坦克就动起来了。为此我们在游戏窗体中创建一个线程,来对整个窗体进行不断的重绘:

package com.pf.java.tankbattle;

import ...

public class MyFrame extends JFrame {

    private Thread paintThread;
    
    ...

    public MyFrame() {
        ...

        // 创建一个线程,不停执行对游戏窗体进行重绘
        paintThread = new Thread(() -> {
            while (true) {
                try {
                    // 刷新的频率越快,动画越流畅,但也要考虑CPU的开销
                    Thread.sleep(20);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                repaint();
            }
        });
        
        paintThread.start();

    }
}

对当前的窗体对象进行repaint时,MyPanel中的paintComponent方法会自动被调用,因此该方法只要简化为如下即可:

protected void paintComponent(Graphics g) {
    super.paintComponent(g);
    heroTank.paint(g);
}

剩下的事则是,在游戏启动后,控制坦克移动(调用其move()方法)即可。

package com.pf.java.tankbattle.entity.tank;

import ...

public class HeroTank extends Tank {

    /** 坦克发动机线程 */
    private Thread moveThread;

    public HeroTank(Direction direction, int x, int y, int speed, int picIndex, MyFrame frame) {
        super(direction, x, y, speed, picIndex, frame);

        // 构造和启动坦克引擎
        moveThread = new Thread(() -> {
            // todo 这里先不考虑坦克被摧毁的情况,引擎发动后就一直持续下去
            while (true) {
                // 只管向前冲
                move();
                try {
                    // 计算每走一个像素花费的毫秒数,并以此作为休眠时间
                    Thread.sleep(1000 / speed);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        
        moveThread.start();

    }
}

效果如下:

运用Java多线程实现坦克移动

通过方向键操控玩家坦克

是时候将我们的意志注入给英雄的坦克了。接下来我们要实现通过上下左右方向键控制玩家坦克移动。玩家可以同时按下多个方向键,最后按下的起作用,当松开一个方向键后,最近一次按下的起作用,而当全部方向键都松开后,坦克停下来,可以参考下面的示意图:

运用Java多线程实现坦克移动

我们将通过Java AWT组件提供的键盘事件监听器,再结合多线程来实现上述需求。具体代码如下:

package com.pf.java.tankbattle.entity.tank;

import ...

public class HeroTank extends Tank {

    /** 坦克发动机线程 */
    private Thread moveThread;

    public HeroTank(Direction direction, int x, int y, int speed, int picIndex, MyFrame frame) {
        super(direction, x, y, speed, picIndex, frame);
        // 注册键盘事件
        frame.addKeyListener(new MyKeyListener());
    }

    /**
     * 内部类,实现了键盘事件(键按下、键松开)的处理方法
     */
    class MyKeyListener extends KeyAdapter {

        /** 记录已按下的方向键的数值 */
        private LinkedList<Integer> oprs;
        /** 坦克是否处于静止状态,注意必须要保证多线程的可见性,用volatile修饰 */
        private volatile boolean stop = true;

        public MyKeyListener() {
            oprs = new LinkedList<>();
            moveThread = new Thread(() -> {
                // todo 这里先不考虑坦克被摧毁的情况
                while (true) {
                    // 如果坦克处于停止状态则将线程park住
                    if (stop) {
                        LockSupport.park();
                    }
                    // 在前进方向移动坦克
                    move();
                    try {
                        // 计算每走一个像素花费的毫秒数,并以此作为休眠时间
                        Thread.sleep(1000 / getSpeed());
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
            moveThread.start();
        }

        @Override
        public void keyPressed(KeyEvent e) {
            int key = e.getKeyCode();
            switch (key) {
                case KeyEvent.VK_LEFT:
                case KeyEvent.VK_UP:
                case KeyEvent.VK_RIGHT:
                case KeyEvent.VK_DOWN:
                    break;
                default:
                    return;
            }
            // 如果不包含在控制列表中则添加进来
            if (!oprs.contains(key)) {
                oprs.add(key);
            }
            // 准备启动坦克
            if (stop) {
                stop = false;
                LockSupport.unpark(moveThread);
            }
            // 设置坦克转向为最新按下的方向键
            setDirection(getDirectionByKey(key));
        }

        @Override
        public void keyReleased(KeyEvent e) {
            // 注意下面调用oprs.remove方法传入的参数必须是包装类型
            Integer key = e.getKeyCode();
            switch (key) {
                case KeyEvent.VK_LEFT:
                case KeyEvent.VK_UP:
                case KeyEvent.VK_RIGHT:
                case KeyEvent.VK_DOWN:
                    break;
                default:
                    return;
            }
            // 移除松开的方向键
            oprs.remove(key);
            if (oprs.isEmpty()) {
                // 所有方向键都松开,则控制线程的状态变量设为停止
                stop = true;
            } else {
                // 否则取方向控制列表中最近一次添加的
                setDirection(getDirectionByKey(oprs.getLast()));
            }
        }

        private Direction getDirectionByKey(int key) {
            switch (key) {
                case KeyEvent.VK_LEFT:
                    return Direction.LEFT;
                case KeyEvent.VK_UP:
                    return Direction.UP;
                case KeyEvent.VK_RIGHT:
                    return Direction.RIGHT;
                case KeyEvent.VK_DOWN:
                    return Direction.DOWN;
                default:
                    return null;
            }
        }
    }
}

说明

  1. 这里控制moveThread线程的运行和停止采用的是juc包中的LockSupport类,调用其pack()挂起当前线程,但是持有的锁不会被释放,和Thread.sleep(millis)类似,只是前者唤醒可以由其他线程控制,调用LockSupport.unpark(thread)即可唤醒先前被park住的线程。
  2. 这里定义的stop变量会有多个线程访问,监听键盘事件的后台线程会对该变量进行读写,而我们创建的moveThread也会读取它,因此必须用volatile关键字来修饰它,确保其可见性。
  3. 对方向键的存取这里采用的是LinkedList,而不是ArrayList,因为有频繁的插入和删除操作,自然链表结构实现的效率会更高。

运行程序,玩家可以顺畅的操作方向键来灵活的控制玩家坦克,手感杠杠滴,效果如下:

运用Java多线程实现坦克移动

但存在一个很明显的瑕疵,当短暂的切换方向键时,无法控制坦克只转向而不移动,实际坦克还是会移动一段距离,效果如下:

运用Java多线程实现坦克移动

修复办法:当坦克由静止状态时,按下一个方向键,moveThread线程会继续执行LockSupport.park()后续的代码,此时可以适当休眠下,在这个时间间隙里,坦克不会移动,而超过这个时间间隔后才继续调用move()方法。增加的控制逻辑:

moveThread = new Thread(() -> {
    while (true) {
        if (stop) {
            LockSupport.park();
            // 控制坦克只转向而不移动
            try {
                // 这里短暂休眠下再进行下一轮判断,以便实现短暂按键下只转向不移动
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            continue;
        }
        ...
    }
});

效果如下:

运用Java多线程实现坦克移动

通过这一小节的学习,相信大伙儿在敲代码中慢慢找到了学习Java的乐趣,把多线程和集合的知识也运用进来了,对于面向对象也理解的更深刻些了吧。不过这才是开始,后续我们将逐步的过渡到设计模式的实操上来,大家加油!