[Java] javax 中 KeyListener 的遲鈍問題

GrandmaCan -我阿嬤都會(小白) 的3小時用 Pygame 做出遊戲影片,想說自己來用 Java 做做看,在移動太空船的地方,想法大概是用 JLabel 來做太空船,在 JFrame 用 .addKeyListener(new KeyListener(){ }) 來取得鍵盤輸入,當輸入 'a' 時( code 65)將太空船的位置 x 減少就會左移,輸入 'd' 時(code 68)將 x 增加就會右移,程式碼大概如下:

基本的 KeyListener 聽取鍵盤輸入

mainStart.java

public class mainStart {
  public static void main(String[] args) {
    new TestBasicKeyListener();
	}
}

TestBasicKeyListener.java

import java.awt.Color;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;

import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;

public class TestBasicKeyListener extends JFrame{
  JPanel myPanel;
  JLabel myLabel;

  public TestBasicKeyListener() {
    myPanel = new JPanel();
    myLabel = new JLabel();
		
    myPanel.setSize(500,600);

    myLabel.setSize(50,40);      //設定一個長50寬40的JLabel
    myLabel.setBackground(Color.orange);
    myLabel.setOpaque(true);     //背景橘色,不透明

    myPanel.setLayout(null);
    myPanel.add(myLabel);
    myPanel.setVisible(true);

    this.setSize(500,600);
    this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    this.addKeyListener(new KeyListener() {

      @Override
      public void keyTyped(KeyEvent e) {
                  // 其實操作若是abcd等字母鍵可用 keyTyped() 即可
      }           // 當操作有方向鍵等要用 keyPressed()

      @Override
      public void keyPressed(KeyEvent e) {		
        int posX = (int)label.getLocation().getX();
        int posY = (int)label.getLocation().getY();

        if(e.getKeyCode()==65) {    //按下'a'時將JLabel坐標x減少
          label.setLocation(posX - 8, posY);
        }
        if(e.getKeyCode()==68) {    //按下'd'時將JLabel坐標x增加
          label.setLocation(posX + 8, posY);
        }
      }

      @Override
      public void keyReleased(KeyEvent e) {

      }
    });
    this.setVisible(true);
    this.add(myPanel);
    myLabel.setLocation(300,300);
  }

}

這是上述的結果,如影片的左邊

有很大的問題是 KeyListener 在取得按鍵後有一個遲鈍感,這個在 StackOverflow 有很多人問過,這篇 Java KeyListener stutters 的描述非常到位:

他呈現的樣子並不是 aaaaaaaaaa 而是 a [頓一下] aaaaaaaaa,問題是怎麼把那個頓點取消掉?

這篇 StackOverflow 的答案大概是這樣:他並沒有用 KeyListener ,而是做了一個類別 KeyboardAnimation,參數有 JComponent(像是 JLabel)和延遲值,物件有個 HashMap pressedKeys 將字串與 Point 對應(Point 是做為向量位移用),實做了 javax.swing.Action 和 KeyStroke (聽說這叫 key binding,我還要再研究一下),但因為遊戲後期需要定義太空船按下空白鍵發射字彈,所以我暫時不考慮他的作法,僅使用整個問題最關鍵的「取消按鍵的那個頓點」

關鍵是聽取到按下 a 鍵的時候,也就是 pressed 時,打開 timer 讓計時器高速執行一個函式,這個函式另外寫而不是放在 KeyListener() 之中;而當放開 a 鍵也就是 released 時,則停止 timer 不再執行。如此一來就沒有那個 a [頓一下] aaaaa 的問題了。程式碼像是這樣:

改良後,不會頓一下

mainStart.java

public class mainStart {
  public static void main(String[] args) {
    new NoStutterFrame();
	}
}

NoStutterFrame.java

import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;

import javax.swing.JFrame;
import javax.swing.JLabel;

public class NoStutterFrame extends JFrame{

  NoShutterPanel myPanel;

  public MainFrame() {

    myPanel = new NoStutterPanel();

    this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    this.setSize(500,600);
    this.add(myPanel);

    this.addKeyListener(new KeyListener() {

      @Override
      public void keyTyped(KeyEvent e) {     // 同上,暫不考慮用 keyTyped()

      }

      @Override
      public void keyPressed(KeyEvent e) {   
      // 在 NoStutterPanel 中有一個 ArrayList<Integer>存放目前按下的所有鍵的 code
        int pressedCode = e.getKeyCode();
        if(!myPanel.pressedKeyList.contains(pressedCode)) {    //不存在就加入 code
          myPanel.pressedKeyList.add(pressedCode);
        }

        if(myPanel.pressedKeyList.size() == 1) {      // 若按下鍵則計時器啓動
          myPanel.timerKeyboard.start();              // 開始處理有按的鍵
        }
      }

      @Override
      public void keyReleased(KeyEvent e) {
      // 當放掉按鍵,就將 pressedKeyList 之中的 code 值去掉,這樣就不會處理到該鍵
        int pressedCode = e.getKeyCode();
        if(myPanel.pressedKeyList.contains(pressedCode)) {    // 若存在就刪去
          myPanel.pressedKeyList.remove(
                  myPanel.pressedKeyList.indexOf(pressedCode)
          );
        }

        if(myPanel.pressedKeyList.size() == 0) {      // 若清空到完全沒有按鍵
          myPanel.timerKeyboard.stop();               // 則計時器停止,不再處理
        }
      }
    });
    this.setVisible(true);
  }
}

NoStutterPanel.java

import java.awt.Color;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

import java.util.ArrayList;

import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.Timer;

public class NoStutterPanel extends JPanel implements ActionListener{

  Timer timerKeyboard;
  JLabel myLabel;
  public ArrayList<Integer> pressedKeyList;

  public NoStutterPanel() {

    myLabel = new JLabel();    
    myLabel.setSize(50,40);     // 設定一個長50寬40的JLabel
    myLabel.setBackground(Color.orange);  //背景橘色,不透明
    myLabel.setOpaque(true);
		
    myLabel.setLocation(300, 300);
		
    timerKeyboard = new Timer(16, this);
    timerKeyboard.setInitialDelay(0);     // 初次執行計時器距離多久
    pressedKeyList = new ArrayList<Integer>();
    this.setSize(500,600);
    this.setLayout(null);
    this.add(myLabel);
    this.setVisible(true);
  }

  private void handleKey() {               // 處理按下的鍵

    int posX = (int)myLabel.getLocation().getX();
    int posY = (int)myLabel.getLocation().getY();

    if(pressedKeyList.contains(65)) {      // 有按 a 的話
      myLabel.setLocation(posX -8, posY);  // 向左移
    }
    if(pressedKeyList.contains(68)) {      // 有按 d 的話
      myLabel.setLocation(posX +8, posY);  // 向右移
    }
  }

  @Override
  public void actionPerformed(ActionEvent e) {
    handleKey();            // 計時器會呼叫 handleKey()
  }
}

解釋一下為什麼會有 ArrayList<Integer>,因為在 KeyListener 之中即使一直按著 a 鍵,可能會判斷一次也可能判斷好幾次,這在用 System.out.println( e.getKeyCode() ); 就會發現,所以要用一個集合蒐集現在共按了哪些鍵,StackOverflow 上的解答者也是用類似的 HashMap 。但是我的做法在將來做出空白鍵射子彈的部分可能要重做,因為不用 HashMap 而一個個用 if ,使得無法做到「同時按下」的功能,我上面的寫法如果加入「按下 w 鍵則 posY 減少也就是上移」,會發現同時按下 a 和 w 並不會往左上方移動,除非在 if 中單獨判斷是否 pressedKeyList 中同時有 a 和 w 的 code,所以要來瞭解實做 Action 和 KeyStroke 的做法了,上面只是一個簡單的記錄,取消 KeyListener 中延遲 lag 的情形。

簡單的 InputMap、ActionMap 和 KeyStroke 用法

留言