Oop lab/bullets

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

ในปฏิบัติการนี้ เราจะทดลองใช้ interface Entity และทดลองสร้าง subclass นอกจากนี้เรายังจะได้ใช้ collection LinkedList เพื่อเก็บข้อมูล entity ด้วย

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

เริ่มต้น

สร้างโปรเจ็ค bulletgame จากนั้นสร้างคลาส BulletGame ที่ extends มาจาก BasicGame ตามที่เราเคยสร้างตามปกติ เพิ่มเมท็อดที่ต้อง implement ทั้งหมด (init, update, render) จากนั้นทดลองรันให้โปรแกรมแสดงหน้าจอว่าง ๆ

Gitmark.png เก็บเวอร์ชั่นหน้าจอว่างโดยการ commit

interface Entity

ในลักษณะเดียวกับที่เราทำในคลิป YouTube เรื่อง interface เราจะสร้าง interface Entity เพื่อใช้ระบุเมท็อดพื้นฐานทั้งหมดที่ "ของ" ที่จะอยู่บนหน้าจอเกมของเราจะต้องเขียน

public interface Entity {
  void render(Graphics g);
  void update(int delta);
}

สังเกตว่าเมท็อด render จะส่ง Graphics g มาด้วย และเมท็อด update ก็จะส่ง delta มาให้ด้วยเช่นกัน

เราจะประกาศ interface ทิ้งไว้ก่อน ส่วนโค้ดที่เรียกใช้งานนั้น เราจะเขียนหลักเขียนคลาส Bullet แล้ว

คลาส Bullet

คลาส Bullet นี้จะเป็นคลาสพื้นฐานของกระสุนทั้งหมด โดยทำหน้าที่หลักเพียงแค่วาดรูปกระสุนด้วยวงกลม และสามารถขยับกระสุนไปในทิศทางแกน y เท่านั้น

สังเกตว่าในคลาสนี้ เราให้ field x และ y เป็น private แต่เราสร้าง getters/setters ดังนี้

  • getX, getY เป็น public
  • setXY เป็น protected เพราะว่าเราต้องการให้ subclass คำนวณการเคลื่อนที่ของ Bullet ได้ แต่ต้องเรียกผ่านทางเมท็อดนี้
public class Bullet implements Entity {

  private static final float BULLET_SIZE = 5;
  private float x;
  private float y;

  public Bullet(float x, float y) {
    this.setXY(x,y);    
  }
  
  @Override
  public void render(Graphics g) {
    g.fillOval(getX(), getY(), BULLET_SIZE, BULLET_SIZE);
  }

  @Override
  public void update(int delta) {
    y += 10;
  }

  public float getX() {
    return x;
  }

  public float getY() {
    return y;
  }

  protected void setXY(float x, float y) {
    this.x = x;
    this.y = y;
  }
}

เพิ่ม Bullet ใน BulletGame

เราจะสร้าง bullet เพื่อทดลองในโปรแกรม ในคลาส BulletGame โดยในโปรแกรมจะพิจารณาวัตถุทั้งหมดเป็น Entity

เพิ่มฟิลด์ entities ในคลาส BulletGame

  private LinkedList<Entity> entities;

อย่าลืมสร้าง entities ใน constructor ของ BulletGame ด้วย

สังเกตว่าเราใช้ LinkedList ในการเก็บ entity เพราะว่าสุดท้าย เราจะต้องจัดการลบ entity ที่ไม่ได้ใช้งานออกจากระบบด้วย entity พวกนี้ เช่น กระสุนที่วิ่งออกไปนอกจอแล้ว เป็นต้น ถ้าเราใช้ ArrayList การลบข้อมูลดังกล่าวจะไม่ค่อยมีประสิทธิภาพเท่า LinkedList

ปรับ render และ update ให้เรียก render และ update ทุก ๆ entity ใน entities

  @Override
  public void render(GameContainer container, Graphics g) throws SlickException {
    for (Entity entity : entities) {
      entity.render(g);
    }
  }

  @Override
  public void update(GameContainer container, int delta) throws SlickException {
    for (Entity entity : entities) {
      entity.update(delta);
    }
  }

สุดท้าย สร้างกระสุนใน init

  @Override
  public void init(GameContainer container) throws SlickException {
    entities.add(new Bullet(200,0));
  }

ทดลองเรียกโปรแกรมให้ทำงาน ดูว่ากระสุนวิ่งหรือไม่

Gitmark.png ถ้าโปรแกรมทำงานได้ ให้ commit งานของคุณด้วย

กระสุนแบบมีทิศทาง

เราจะสร้างกระสุนแบบมีทิศทาง โดยกระสุนดังกล่าว เมื่อสร้าง จะมีการกำหนดทิศทางและความเร็วได้ กระสุน DirectionalBullet นี้เป็น subclass ของ Bullet

public class DirectionalBullet extends Bullet {
  private float dir;
  private float velocity;

  public DirectionalBullet(float x, float y, float dir, float velocity) {
    super(x, y);
    this.dir = dir;
    this.velocity = velocity;
  }

  public float getVelocity() {
    return velocity;
  }

  public float getDir() {
    return dir;
  }
}

สังเกตว่า:

  • เรามี field dir และ velocity แต่ทั้งสอง field มีแต่ getters ไม่มี setters เนื่องจากเราไม่ต้องการให้มีการเปลี่ยนแปลงทิศทางของกระสุน เราจึงไม่สร้างเมท็อดเอาไว้

แบบฝึกหัด: ปรับตำแหน่ง

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

แก้ส่วน init ใน BulletGame ให้สร้าง directional bullet ดังนี้

  @Override
  public void init(GameContainer container) throws SlickException {
    entities.add(new DirectionalBullet(320,240,70,10));
  }

อย่าลืมทดสอบโดยทดลองปรับมุมเป็นค่าต่าง ๆ และความเร็วเป็นค่าต่าง ๆ ด้วย

Gitmark.png ทดลองจนใช้ได้ แล้ว commit

วิ่งแบบเป็นคลื่น SineBullet

เราจะสร้างคลาส SinceBullet ที่ทำให้กระสุนวิ่งเป็นคลื่น วิธีการที่เราจะทำให้กระสุนวิ่งเป็นคลื่นนั้น คือเราจะมี track position ที่วิ่งในลักษณะเดียวกับ directional bullet แต่เมื่อเวลาผ่านไป เราจะปรับทิศทางที่เราย้ายออกไปทางซ้ายและขวาเป็นฟังก์ชันแบบ sine แสดงดังตัวอย่างด้านล่าง

Sinebullet.png

แบบฝึกหัด: ให้คุณเขียนคลาส SineBullet ให้เป็น subclass ของ DirectionalBullet และมี constructor แบบเดียวกับ DirectionalBullet ในการเคลื่อนที่ให้กระสุนวิ่งไปในทิศที่กำหนด แต่มีการส่ายไปมาด้วย

คำใบ้: สังเกตว่าการส่ายเป็นคลื่น sine นั้น เป็นการเปลี่ยนตำแหน่งในทิศทางตั้งฉากกับทิศของกระสุน ภายใต้ทิศทางดังกล่าวระดับการเปลี่ยนนั้นเปลี่ยนแปลงตามคลื่น sine

Gitmark.png ทดลองดูหลาย ๆ มุมและหลายความเร็วต้น ถ้าทำงานได้ให้ commit

ย้ายโค้ดส่วน track position ไปที่ DirectionalBullet

สังเกตว่าความสามารถในการ track ตำแหน่งนั้นเป็นประโยชน์มาก ในการสร้างกระสุนที่วิ่งแบบมีทิศทาง เราจะย้ายความสามารถดังกล่าวมาไว้ในคลาส DirectionalBullet ดังนี้

เพิ่ม field trackX และ trackY

  private float trackX;
  private float trackY;

และเพิ่ม getters

  protected float getTrackX() {
    return trackX;
  }

  protected float getTrackY() {
    return trackY;
  }

จากนั้นใน constructor เราจะเก็บค่าเริ่มต้นของ trackX และ trackY

  public DirectionalBullet(float x, float y, float dir, float velocity) {
    // ...
    trackX = x;
    trackY = y;
  }

เราจะต้อง update ค่า field trackX และ trackY ในเมท็อด update ในลักษณะด้านล่าง

  public void update(int delta) {
    trackX += dx;
    trackY += dy;
    setXY(trackX, trackY);
  }

อย่างไรก็ตาม โค้ดดังกล่าวมีปัญหาเมื่อ subclass ต้องการใช้ค่า trackX และ trackY ในเมท็อด update ของ subclass เพราะว่าเมื่อ subclass เขียนเมท็อด update ใหม่ จะไปแทน update เดิม ดังนั้นถ้าเราต้องการปรับค่า track เราต้องเรียก update ของ DirectionalBullet อย่างไรก็ตาม ในการเรียกดังกล่าวเรามีการปรับค่า x, y ด้วย ซึ่งมักจะเป็นสิ่งที่เราต้องการให้ subclass เป็นผู้ดำเนินการ

เนื่องจากเมท็อดเป็นหน่วยย่อยสุดในการ override เราจะแยกเมท็อด update ออกเป็นสองเมท็อดย่อย โดยแยกกิจกรรมของ update เป็นสองส่วนคือ updateTrack และ updatePosition เราตั้งเป้าหมายไว้ว่า subclass จะเขียนเฉพาะเมท็อด updatePosition ใหม่เท่านั้น โค้ดของเราเปลี่ยนไปดังนี้:

  public void update(int delta) {
    updateTrack();
    updatePosition(delta);
  }

  protected void updatePosition(int delta) {
    setXY(trackX, trackY);
  }

  private void updateTrack() {
    trackX += dx;
    trackY += dy;
  }

สังเกตว่า updatePosition เป็น protected ส่วน updateTrack เราตั้งไว้ให้เป็น private

แบบฝึกหัด: แก้โค้ด SineBullet

หลังจากที่เราแยกส่วน track ตำแหน่งมาแล้ว ให้แก้คลาส SineBullet ให้เรียกใช้ความสามารถดังกล่าวจาก DirectionalBullet แทน

กระสุนสั่น RandomDirectionalBullet

เมื่อเรามีโค้ดส่วน track ตำแหน่งตามทิศทางใน DirectionalBullet แล้ว เราจะเพิ่มกระสุนประเภทนี้อีกได้โดยง่าย ด้านล่างแสดงตัวอย่างคลาส RandomDirectionalBullet

public class RandomDirectionalBullet extends DirectionalBullet {

  private Random random;

  public RandomDirectionalBullet(float x, float y, float dir, float velocity) {
    super(x, y, dir, velocity);
    random = new Random();
  }

  protected void updatePosition(int delta) {
    setXY((float)(getTrackX() + (random.nextFloat()-0.5)*5),
        (float)(getTrackY() + (random.nextFloat()-0.5)*5));
  }
}

กระสุนดาวกระจาย

ในส่วนนี้เราจะสร้างกระสุนอีกประเภท คือ StarBullet ซึ่งจริง ๆ แล้วประกอบไปด้วยกระสุนแบบมีทิศทางเป็นจำนวนมาก

กระสุนดาวกระจายด้วย DirectionalBullet

สร้างคลาส StarBullet ให้เป็น subclass ของ Bullet ใน constructor ให้รับพารามิเตอร์มากกว่า Bullet ธรรม คือให้รับความเร็วด้วย (ซึ่งเราจะส่งไปให้กับ directional bullet ต่อไป)

  public StarBullet(float x, float y, float velocity) {
    super(x, y);
    
    bullets = new ArrayList<DirectionalBullet>();
    for (int i=0; i < 36; i++) {
      bullets.add(new DirectionalBullet(x, y, i*10, velocity));
    }
  }

อย่าลืมประกาศ field bullets:

  private ArrayList<DirectionalBullet> bullets;

เมท็อด render และ update ทำงานโดยเรียก render และ update ของ DirectionalBullet

  public void render(Graphics g) {
    for (DirectionalBullet bullet : bullets) {
      bullet.render(g);
    }
  }
  
  public void update(int delta) {
    for (DirectionalBullet bullet : bullets) {
      bullet.update(delta);
    }    
  }

ทดลองเรียกให้โปรแกรมทำงาน ตรวจสอบว่าแสดงกระสุนเป็นวงกลม ขนาดขยายใหญ่ขึ้นเรื่อย ๆ

Factory Method Design Pattern

สังเกตว่าโค้ดใน StarBullet นั้นสามารถทำงานกับ DirectionalBullet อะไรก็ได้ อย่างไรก็ตาม โปรแกรมบรรทัดนี้:

     bullets.add(new DirectionalBullet(x, y, i*10, velocity));

ทำให้ StarBullet สร้างได้แต่ DirectionalBullet เท่านั้น

ในส่วนนี้เราจะทดลองใช้ Factory method pattern ในการแยกส่วนการสร้าง DirectionalBullet ออกมาจาก constructor ของ StarBullet

เราจะสร้างคลาสที่วัตถุมีเมท็อดเดียวคือ build และคืน object ที่เป็น DirectionalBullet (หรือ subclass ของ DirectionalBullet) โดยเราจะเรียกว่าคลาส DirectionalBulletFactory

public class DirectionalBulletFactory {
  public DirectionalBullet build(float x, float y, float dir, float velocity) {
    return new DirectionalBullet(x, y, dir, velocity);
  }
}

เราจะแก้โค้ด constructor ของคลาส StarBullet ให้รับ object ที่เป็น DirectionalBulletFactory เพื่อนำมาสร้าง bullets ดังด้านล่าง

  public StarBullet(float x, float y, float velocity,
      DirectionalBulletFactory factory) {
    super(x, y);
    
    bullets = new ArrayList<DirectionalBullet>();
    for (int i=0; i < 36; i++) {
      bullets.add(factory.build(x, y, i*10, velocity));
    }
  }

และโค้ดในเมท็อด init ของ BulletGame จะเปลี่ยนไปดังนี้

  @Override
  public void init(GameContainer container) throws SlickException {
    entities.add(new StarBullet(320,240,1, new DirectionalBulletFactory()));
  }

สังเกตว่าเรา new DirectionalBulletFactory ส่งไปให้เพื่อให้ constructor สามารถเรียกเพื่อสร้าง bullet ได้

เราสามารถสร้าง factory สำหรับสร้าง directional bullet ประเภทอื่น ๆ ได้ โดยเพิ่มคลาส SineBulletFactory และ RandomDirectionalBulletFactory ดังด้านล่าง

public class SineBulletFactory extends DirectionalBulletFactory {
  public DirectionalBullet build(float x, float y, float dir, float velocity) {
    return new SineBullet(x, y, dir, velocity);
  } 
}
public class RandomDirectionalBulletFactory extends DirectionalBulletFactory {
  public DirectionalBullet build(float x, float y, float dir, float velocity) {
    return new RandomDirectionalBullet(x, y, dir, velocity);
  } 
}

สังเกตว่าเมท็อด build ถึงแม้ว่าเราจะสร้าง object ของคลาส SineBullet หรือ RandomDirectionalBullet แต่เราคืนค่าเป็นคลาส DirectionalBullet ซึ่งการคืนค่าดังกล่าวทำได้ เพราะว่า SineBullet และ RandomDirectionalBullet เป็น subclass ของ DirectionalBullet นอกจากนี้การส่งค่าให้ดังกล่าว จะไม่มีการแปลงชนิดข้อมูลด้วย

ใน BulletGame.init เราสามารถสร้าง StarBullet ของ bullet ชนิดต่าง ๆ ได้ดังด้านล่าง

  @Override
  public void init(GameContainer container) throws SlickException {
    entities.add(new StarBullet(320,240,1, new DirectionalBulletFactory()));
    entities.add(new StarBullet(420,140,2, new SineBulletFactory()));
    entities.add(new StarBullet(220,340,1, new RandomDirectionalBulletFactory()));
  }

ลบ entities ที่ตกขอบจอ