Oop lab/simple ship game

จาก Theory Wiki
ไปยังการนำทาง ไปยังการค้นหา
หน้านี้เป็นส่วนหนึ่งของ oop lab

เราจะเขียนเกมยานอวกาศบน Slick2D กัน สำหรับส่วนนี้เราจะได้ทดลองใช้ Slick2D ในการแสดงรูปภาพและอ่านการกดปุ่มจากผู้ใช้แบบการถาม (polling)

โครงสร้างเกมพื้นฐาน

เกมใน Slick2D จะเป็นการทำงานร่วมกันของ object สอง object หลัก คือ

  • Game (อ่าน javadoc) - เป็น object ที่มีเมท็อดพื้นฐานของวนรอบของเกม คือ init, update, และ render
    • void init(GameContainer container)
    • void update(GameContainer container, int delta)
    • void render(GameContainer container, Graphics g)
  • GameContainer (อ่าน javadoc) - เป็น object ที่จัดการเกี่ยวกับการอ่านข้อมูลจากผู้ใช้ (input) การวนรอบเกม และการจัดการเกี่ยวกับการแสดงค่า fps (frame per seconds)

หมายเหตุ: โดยทางเทคนิคแล้ว Game เป็น interface และ GameContainer เป็น abstract class เราจะได้เรียนแนวคิดดังกล่าวต่อไป

เราไม่มีความจำเป็นต้องเขียนคลาส GameContainer ขึ้นมาใหม่ โดยเราจะใช้วัตถุจากคลาส AppGameContainer (ซึ่งจัดเป็น GameContainer ประเภทหนึ่ง) ในโปรแกรมของเรา อย่างไรก็ตาม เราต้องสร้างวัตถุประเภท Game ขึ้นมาเอง เพราะว่านั่นคือแกนหลักของเกมของเรา

เมท็อดทั้งสามมีหน้าที่ดังนี้

  • เมท็อด init จะถูกเรียกเพื่อให้เราเตรียมวัตถุต่าง ๆ ในเกม เราจะสร้างวัตถุต่าง ๆ เก็บไว้กับวัตถุคลาส Game ที่เราสร้าง
  • เมท็อด render มีหน้าที่แสดงภาพหน้าจอ สังเกตว่าเมท็อดจะได้รับ Graphics g มาด้วย เราสามารถสั่งวาดรูปบนหน้าจอได้ผ่านทางวัตถุ g
  • เมท็อด update จะถูกใช้เพื่อปรับค่าต่าง ๆ ของเกม ตัวแปร delta จะเก็บเวลาที่ผ่านไปจากการเรียกเมท็อดครั้งก่อน มีหน่วยเป็นมิลลิวินาที

ลำดับการเรียกเมท็อดจะเป็นดังนี้

init -> render -> update -> render -> update -> render -> update -> ...

เริ่มต้นด้วยเกมว่าง ๆ

สร้าง project ชื่อ shipgame อย่าลืมเพิ่ม Slick2D เข้าไปใน library ใน Build Path (ดูรายละเอียดที่ วิธีติดตั้ง)

จากนั้นให้สร้างคลาสชื่อ ShipGame เมื่อสร้างแล้ว ให้แก้หัวคลาสให้เป็น ดังด้านล่าง (เพิ่ม extends BasicGame)

public class ShipGame extends BasicGame {

เมื่อเพิ่มแล้ว IDE จะแจ้งว่าไม่รู้จัก BasicGame ให้ไปกดให้ IDE เพิ่มการ import ให้เรา โดย IDE น่าจะเพิ่มบรรทัดนี้มาให้ (อย่าลืมเลือกให้ถูก)

import org.newdawn.slick.BasicGame;

IDE จะยังคงบ่นต่อว่าเราไม่มี constructor (เมท็อดที่กำหนดค่าเริ่มต้นให้กับ object ของคลาส) เราก็กดให้มันเพิ่มให้ เราจะได้เมท็อดด้านล่างมา

  public ShipGame(String title) {
    super(title);
  }

ยังไม่พอ IDE ยังบ่นต่อว่าเราไม่ได้ implement method ที่ต้องมี เช่นเคย เราก็กดให้ IDE เติมให้เราโดยอัตโนมัติ มันจะเติมมาให้ 3 เมท็อดดังนี้:

  @Override
  public void render(GameContainer arg0, Graphics arg1) throws SlickException {
    // TODO Auto-generated method stub
  }

  @Override
  public void init(GameContainer arg0) throws SlickException {
    // TODO Auto-generated method stub
  }

  @Override
  public void update(GameContainer arg0, int arg1) throws SlickException {
    // TODO Auto-generated method stub
  }

สังเกตว่าชื่อ argument นั่นดูไม่สื่อความหมายใด ๆ เลย ไปแก้ให้มันดูสื่อความหมายโดยแก้หัวเมท็อดต่าง ๆ ให้เป็นดังนี้

 public void render(GameContainer container, Graphics g) throws SlickException {

 public void init(GameContainer container) throws SlickException {

 public void update(GameContainer container, int delta) throws SlickException {

สังเกตว่า ท้าย signature ของเมท็อด มีคำว่า throws SlickException อยู่ด้วย เราจะอธิบายแนวคิดของ exception ที่ตอนท้ายส่วนนี้

ตอนนี้ไล่ไปไล่มาใน IDE น่าจะไม่มี error อะไรแล้วนะครับ แต่เรายังไม่มีโปรแกรมหลักเลย

ใน Java เมท็อดที่จะเป็นโปรแกรมหลักได้จะต้องชื่อ main และเป็น static method ในคลาสบางคลาส ซึ่งเราก็จะให้อยู่ในคลาส ShipGame นี่เลย

โดยทั่วไปแล้วโปรแกรมหลักของโปรแกรมที่เขียนเชิงวัตถุจะทำหน้าที่แค่สร้าง object ที่จำเป็นแล้วก็จัดการทำให้มันรู้จักกัน แล้วก็เรียก object หลักให้ทำงาน โปรแกรมหลักของเราก็เช่นกัน เพิ่มเมท็อด main ด้านล่างลงในโค้ดของเรา

  public static void main(String[] args) {
    try {
      ShipGame game = new ShipGame("Super Ship Game");
      AppGameContainer appgc = new AppGameContainer(game);
      appgc.setDisplayMode(640, 480, false);
      appgc.start();
    } catch (SlickException e) {
      e.printStackTrace();
    }
  }

อย่าลืมไปสั่งให้ IDE import ของเพื่อให้เรารู้จัก AppGameContainer ด้วย

บรรทัดที่สำคัญมาก ๆ คือ

     ShipGame game = new ShipGame("Super Ship Game");
     AppGameContainer appgc = new AppGameContainer(game);

ซึ่งเป็นบรรทัดที่เราสร้าง object จากคลาส ShipGame และสร้าง AppGameContainer และส่ง game ของเราไปให้

ถ้าเราทดลองรัน เราจะเห็นหน้าจอสีดำเปล่า ๆ ขึ้นมา พร้อมด้วยจำนวน frame per second ที่มุมด้านซ้ายบน

แผนภาพของ object ทั้งสองแสดงด้านล่าง

Oop-lab-ship-diagram1.png

แสดง sprite

ในเกม 2 มิติ ตัวละครที่แสดงบนจอมักจะเป็นรูปที่มีพื้นหลังเป็นสีใส ๆ แสดงทับกันไป รูปดังกล่าวอาจจะมีการเปลี่ยนไปมาเพื่อแสดง animation ก็ได้ เราเรียกรูปดังกล่าวว่า sprite

สร้างรูป sprite

Use a graphical software such as GIMP, Photoshop, Paint.NET to create a 64 pixel x 64 pixel image. Draw a simple spaceship whose head is in an upward direction. Make sure that the background is transparent.

Usually, when the image has transparent background, the graphic editor usually shows it like this:

219245-spaceship-sprite-transparent.png

If you background is not transparent, when you show the sprite you'll see it as an image in a white box.

We will create a directory res in your project directory for keeping all our image assets. Save the image as ship.png in directory res that we have just created.

คลาส Image

คลาสใน Slick2D ที่ใช้แสดงรูปคือคลาส Image (อ่าน javadoc) โดย object ของคลาสนี้จะต้องสร้างเมื่อโปรแกรมเริ่มมีการกำหนดค่าเริ่มต้นแล้ว ดังนั้นเราจะสร้างในเมท็อด init ของคลาส ShipGame

เราจะสร้าง field ที่ชื่อว่า shipImage เพื่อเป็น Image ของรูปยานอวกาศที่เราวาดขึ้น โดยประกาศที่ตอนต้นของคลาส จากนั้นในเมท็อด init เราจะสั่ง

   shipImage = new Image("res/ship.png");

เพื่อสร้าง Image ของรูปเรือดังกล่าว สุดท้าย เราจะแสดงผล sprite ในเมท็อด render โดยเรียกเมท็อด draw

ด้านล่างแสดงโค้ดที่เพิ่มขึ้นที่ทำงานดังกล่าว

public class ShipGame extends BasicGame {

  private Image shipImage;

  public ShipGame(String title) {
    // ..
  }

  @Override
  public void render(GameContainer container, Graphics g) throws SlickException {
    shipImage.draw(100,100);
  }

  @Override
  public void init(GameContainer container) throws SlickException {
    shipImage = new Image("res/ship.png");
  }
}

แผนภาพของวัตถุทั้งสามแสดงด้านล่าง

Oop-lab-ship-diagram2.png

ทำให้ยานเคลื่อนที่

สังเกตว่าในโปรแกรมที่ผ่านมา ในเมท็อด render เราวาดยานไปที่ตำแหน่ง (100,100) ถ้าเราต้องการให้ยานเคลื่อนที่ เราจะต้องวาดยานที่ตำแหน่งต่าง ๆ ไป เราจะแก้โปรแกรมดังนี้

  • เราจะเพิ่ม field shipX และ shipY เพื่อเก็บตำแหน่งยานนี้
  • เราจะเปลี่ยนตำแหน่งของยานที่ในเมท็อด update โดยปรับค่า field ทั้งสอง
  • ใน render เราจะแสดงยานที่ตำแหน่ง shipX, shipY

ด้านล่างเป็นส่วนของโปรแกรมที่เราแก้

public class ShipGame extends BasicGame {

  private Image shipImage;
  private int shipX;
  private int shipY;

  @Override
  public void init(GameContainer container) throws SlickException {
    shipImage = new Image("res/ship.png");
    shipX = 100;
    shipY = 100;
  }

  @Override
  public void render(GameContainer container, Graphics g) throws SlickException {
    shipImage.draw(shipX, shipY);
  }

  @Override
  public void update(GameContainer container, int delta) throws SlickException {
    shipX += 1;
  }

  // ...
}

สังเกตว่าเรากำหนดค่าเริ่มต้นของตำแหน่งยานในเมท็อด init ไม่ใช่ใน constructor ของ ShipGame

คำถาม: (1) ถ้าต้องการให้ยานขยับเร็วขึ้นต้องทำอย่างไร (2) ถ้าต้องการให้ยานเคลื่อนที่ไปด้านหน้าต้องแก้อย่างไร

บังคับควบคุม: Polling

อ่านรายละเอียดของการอ่าน input แบบสมบูรณ์ขึ้นที่เว็บ slick2d wiki

สังเกตว่าเราปรับตำแหน่งของยานในเมท็อด update ถ้าเราต้องการให้ยานเปลี่ยนตำแหน่งในทิศทางต่าง ๆ เราสามารถทำได้ในเมท็อดดังกล่าว

สิ่งที่เราต้องการคือการรู้ให้ได้ว่าขณะนี้ผู้ใช้กดปุ่มทิศทางใดอยู่ วิธีการแบบหนึ่งก็คือการถามไปที่ระบบว่าขณะนี้ผู้ใช้กดปุ่มใดอยู่หรือไม่ วิธีการแบบนี้เรียกว่าการ polling เนื่องจากโค้ดส่วนสอบถามของเราจะทำงานตลอดไม่ว่าจะมีการกดปุ่มหรือไม่

การสอบถามเกี่ยวกับการกดปุ่มนั้น เราจะทำผ่านทาง object Input (อ่าน javadoc) ซึ่่งเราจะอ้างถึง object input นี้ได้ผ่านทาง GameContainer

ด้านล่างแสดงตัวอย่างการรับปุ่มซ้ายขวา และให้ยานขยับ

  @Override
  public void update(GameContainer container, int delta) throws SlickException {
    Input input = container.getInput();
    if (input.isKeyDown(Input.KEY_LEFT)) { 
      shipX -= 1;
    }
    if (input.isKeyDown(Input.KEY_RIGHT)) {
      shipX += 1;      
    }
  }

แบบฝึกหัด: ให้แก้โปรแกรมข้างต้นให้บังคับยานได้ 4 ทิศทาง

หมุนยาน

เราสามารถระบุให้วัตถุคลาส Image หมุนได้ โดยสั่งเมท็อด setRotation เช่น ถ้าเราสั่ง

     shipImage.setRotation(270);

ยานจะหมุนหัวไปด้านซ้าย

แบบฝึกหัด: ให้แก้โปรแกรมข้างต้นให้ยานหันหัวไปในทิศทางที่เคลื่อนที่

ยานทะลุซ้ายขวา

ตอนนี้ยานของเราวิ่งออกไปทางขอบจอแล้วจะหายไปเลย ให้ปรับให้ยานวิ่งทะลุด้านซ้านแล้วมาโผล่ด้านขวา และกลับกันด้วย

คลาส Ship

ข้อมูลของยานที่เกาะเกี่ยวกันอยู่ในคลาส ShipGame คือ field:

  • shipImage
  • shipX
  • shipY

เราจะสร้างคลาส Ship ขึ้นมาเพื่อที่จะย้ายการจัดการเหล่านี้ออกไปไว้ที่เดียวกัน และเพื่อให้เราสามารถเพิ่มยานในเกมได้โดยง่าย

สร้างคลาส Ship และเริ่มใส่โค้ดส่วน constructor ดังนี้ (สังเกตว่าเราก็แกะมาจากโค้ดของ ShipGame ได้)

public class Ship {
  private Image image;
  private int x;
  private int y;

  public Ship(int x, int y) throws SlickException {
    image = new Image("res/ship.png");
    this.x = x;
    this.y = y;
  }
}

หมายเหตุ: ส่วน throws นั่น IDE จะขึ้นคำเตือนในบรรทัดที่เรา new Image ให้เราใส่เพิ่ม เราก็กดให้ IDE เติมให้เราโดยอัตโนมัติ

ให้เพิ่มเมท็อด draw, moveLeft, moveRight และอื่น ๆ เพื่อให้ใน ShipGame เราสามารถจัดการกับยานได้โดยผ่านเมท็อดเหล่านี้ ด้านล่างเป็นตัวอ่างของบางฟังก์ชันในคลาส ShipGame ที่แก้ให้ใช้เมท็อดในคลาส Ship แล้ว (อย่าลืมลบ shipX, shipY, และ shipImage ทิ้งด้วย)

public class ShipGame extends BasicGame {

  private Ship ship;

  public ShipGame(String title) {
    super(title);
  }

  @Override
  public void render(GameContainer container, Graphics g) throws SlickException {
    ship.draw();
  }

  @Override
  public void init(GameContainer container) throws SlickException {
    ship = new Ship(100,100);
  }

  @Override
  public void update(GameContainer container, int delta) throws SlickException {
    Input input = container.getInput();
    if (input.isKeyDown(Input.KEY_LEFT)) {
      ship.moveLeft();
    }
    if (input.isKeyDown(Input.KEY_RIGHT)) {
      ship.moveRight();
    }
    // ...
  }

  // ...
}

แยกเมท็อด update

เมท็อด update จะเริ่มรกขึ้นเรื่อย ๆ เราจะแยกโค้ดส่วนจัดการรับข้อมูลนำเข้าและขยับยานออกจาก update โดยสร้างเมท็อดใหม่ชื่อ updateShipMovement และลบโค้ดออกจาก update ดังด้านล่าง

  void updateShipMovement(Input input, int delta) {
    if (input.isKeyDown(Input.KEY_LEFT)) {
      ship.moveLeft();
    }
    if (input.isKeyDown(Input.KEY_RIGHT)) {
      ship.moveRight();
    }
    // ...
  }
  
  @Override
  public void update(GameContainer container, int delta) throws SlickException {
    Input input = container.getInput();
    updateShipMovement(input, delta);
  }

ทอง

เราจะแสดงทองให้ยานอวกาศบินไปเก็บ

ก่อนอื่นวาดรูปแท่งทองเสียก่อน วาดรูปขนาด 40 x 40 ดังด้านล่าง เก็บในไฟล์ชื่อ res/gold.png

Gold.png

เราจะสร้างคลาส Gold เพื่อจัดการเกี่ยวกับทองในลักษณะเดียวกับคลาส Ship

public class Gold {
  private Image image;
  private int x;
  private int y;

  public Gold(int x, int y) throws SlickException {
    this.x = x;
    this.y = y;
    image = new Image("res/gold.png");
  }

  public void draw() {
    image.draw(x, y);
  }
}

จากนั้นเพิ่มให้มีการแสดงแท่งทองในจอ โดยเพิ่มบรรทัดด้านล่างใน init และ render

  @Override
  public void render(GameContainer container, Graphics g) throws SlickException {
    // ...
    gold.draw();
  }

  @Override
  public void init(GameContainer container) throws SlickException {
    // ...
    gold = new Gold(200, 200);
  }

ตรวจสอบการชน: เมท็อดสำหรับอ้างถึงและกำหนดตำแหน่งให้กับแท่งทอง

เราต้องการตรวจสอบการชนระหว่างยานกับแท่งทอง ถ้ามีการชน เราจะตอบสนองโดยย้ายแท่งทองไปอีกตำแหน่งหนึ่ง

โค้ดปลายทางที่เราต้องการเพิ่มในคลาส ShipGame เป็นดังนี้

  private void handleCollision() {
    if (ship.closeTo(gold.getCenterX(), gold.getCenterY())) {
      gold.setPosition(300, 300);
    }
  }

  @Override
  public void update(GameContainer container, int delta) throws SlickException {
    Input input = container.getInput();
    updateShipMovement(input, delta);
    handleCollision();
  }

สังเกตว่าเราต้องการเพิ่มเมท็อด handleCollision เพื่อจัดการกับการชนนี้

ในเมท็อดดังกล่าวเรียกใช้เมท็อด getCenterX, getCenterY, และ setPosition ของคลาส Gold

เมท็อดสองเมท็อดแรกเราจำเป็นต้องเรียกเพราะว่าเวลาเราวาดรูปในจอนั้น ตำแหน่ง x,y ที่เราวาด จะเป็นจุดมุมบนซ้ายของรูป อย่างไรก็ตาม เมื่อเราคิดการชน เราจะพิจารณาตำแหน่งจากตรงกลางแท่งทอง

โค้ดของทั้งสามเมท็อดแสดงดังนี้:

  public int getCenterX() {
    return x + 20;
  }
  public int getCenterY() {
    return y + 20;
  }
  public void setPosition(int x, int y) {
    this.x = x;
    this.y = y;
  }

สังเกตว่าเมท็อด setPosition นั้นทำงานซ้ำกับโค้ดใน constructor เราจึงแก้โค้ดใน constructor ให้มาเรียกใช้เมท็อด setPosition ดังด้านล่าง

  public Gold(int x, int y) throws SlickException {
    setPosition(x, y);
    image = new Image("res/gold.png");
  }

ตรวจสอบการชน: เมท็อด closeTo

แบบฝึกหัด: เขียนเมท็อด closeTo(int x, int y) ในคลาส Ship เพื่อตรวจสอบว่ายานเข้าใกล้ตำแหน่ง x,y หรือไม่

อย่าลืมว่าพิกัดของยานคือจุดมุมบนซ้าย

Ooplab-ship-pos-ref.png

คำใบ้: เราอาจจะหาจุดศูนย์กลางของยาน แล้วตรวจสอบว่าจุดกังกล่าวใกล้กับจุด (x,y) มากพอหรือไม่ (เช่นในระยะ 40 จุด เป็นต้น) เราอาจจะไม่จำเป็นต้องคำนวณระยะที่ชัดเจนก็ได้ ขอแค่ให้ใกล้พอก็ได้

สุ่มตำแหน่งทอง

แบบฝึกหัด: เขียนเมท็อด randomPosition ในคลาส Gold จากนั้นปรับส่วน handleCollision ให้เรียกเมท็อดดังกล่าวเมื่อยานบินชน

แสดงคะแนน

เราสามารถเก็บคะแนนและปรับค่าเมื่อเราชนแท่งทอง ในการแสดงคะแนนนั้น เราสามารถสั่งเมท็อด drawString ของ object graphics ใน render ได้ ดังตัวอย่างด้านล่าง

  @Override
  public void render(GameContainer container, Graphics g) throws SlickException {
    // ...
    g.drawString("" + score, 600, 0);
  }

หมายเหตุ: ที่เราต้องบวก score เข้ากับ "" เพื่อแปลง int ให้เป็น String

เกมของคุณ

มาถึงตรงนี้ คิดว่าคุณน่าจะปรับเกมให้สนุกมากขึ้นได้ตามที่คุณต้องการแล้ว :) ถ้าคุณนึกไม่ออก คุณอาจจะเริ่มปรับโดยเพิ่มความท้าทาย หรือเพิ่มตัวช่วยเพื่อทำให้เกมง่ายขึ้นก็ได้