Oop lab/unit testing slick2d

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

เราต้องการจะเขียน unit test กับโค้ดใน Slick2D อย่างไรก็ตามโค้ดส่วนของ Entity ของเรานั้นมักมีส่วนการแสดงผลอยู่ด้วย และส่วนเหล่านี้โดยมากจะทำงานไม่ได้ใน unit test หรือไม่ก็ทำให้การเตรียมการต่าง ๆ มีปัญหา

เพื่อเพิ่มความเข้าใจ เราจะยกตัวอย่างจากโค้ด BulletGame แต่เราจะเพิ่มให้คลาส Bullet ใช้รูปลูกกระสุน แทนที่จะเป็นการวาดรูปธรรมดา

ในการทำ unit test นั้น เราจะทดลองเพิ่มเมท็อด distanceTo(float x, float y) ให้กับคลาส Bullet เป็นตัวอย่าง

Bullet ที่แสดงโดยการวาดรูป

เพื่อเป็นการทดลอง เราจะใช้รูป ship จาก ship เกมในการแสดงกระสุน เมื่อรันแล้วอาจจะมีหน้าตาแบบนี้

Ship-bullets.png

เราจะคัดลอกไฟล์ ship.png มาใส่ในไดเร็กทอรี res และเพิ่ม field image, แก้ constructor, และปรับเมท็อด render ในคลาส Bullet ดังนี้

public class Bullet implements Entity {
  //...
  protected Image image;

  public Bullet(float x, float y) {
    try {
      image = new Image("res/ship.png");   // this line is problematic.
    } catch (SlickException e) {
      e.printStackTrace();
    }
    this.setXY(x,y);    
  }

  public void render(Graphics g) {
    image.draw(getX(), getY());
    //g.fillOval(getX(), getY(), BULLET_SIZE, BULLET_SIZE);   --- this is removed line
  }
  //...
}

หมายเหตุ: block try-catch นั้นอาจจะดูรกรุงรัง แต่จำเป็นเพราะว่าการเปิด image ใหม่นั้น อาจจะเกิด exception ขึ้นได้

ทดลอง unit test

ในโค้ด unit test ของเรา เราจะทดลองแค่สร้าง bullet มาก่อน

public class BulletTest {
  @Test
  public void testBulletCanBeCreated() {
    Bullet b = new Bullet(10,20);
  }
}

เมื่อเราเรียกให้ unit test ทำงาน เราจะพบ error ดังนี้

java.lang.RuntimeException: No OpenGL context found in the current thread.
	at org.lwjgl.opengl.GLContext.getCapabilities(GLContext.java:124)
	at org.lwjgl.opengl.GL11.glGetError(GL11.java:1289)
	at org.newdawn.slick.opengl.renderer.ImmediateModeOGLRenderer.glGetError(ImmediateModeOGLRenderer.java:384)
	at org.newdawn.slick.opengl.InternalTextureLoader.getTexture(InternalTextureLoader.java:249)
	at org.newdawn.slick.opengl.InternalTextureLoader.getTexture(InternalTextureLoader.java:187)
	...
	at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:192)

เพราะว่าจะเปิด Image ได้นั้น โปรแกรมของเราจะต้องมีการจัดการเปิดหน้าต่างและจัดการเกี่ยวกับ OpenGL ก่อน ซึ่งในส่วนนี้ เรามักจะไม่ดำเนินการใน Unit test

การเขียนโปรแกรมให้ test ได้ง่ายนั้น เป็นทักษะแบบหนึ่งที่มีประโยชน์มาก และโดยมาก ความพยายามที่จะทำให้โปรแกรม test ได้ง่าย จะทำให้เราได้รับโค้ดที่ดีมีคุณภาพด้วย

วิธีที่ 1: สร้าง Image ยามต้องการ

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

เราจะแก้ constructor ของเราดังนี้

  public Bullet(float x, float y) {
    image = null;
    this.setXY(x,y);    
  }

จากนั้นโค้ดที่เหลือทั้งหมดจะต้องไม่ยุ่งกับ image ยกเว้นโค้ดที่จำเป็นจะต้องเกี่ยวข้องกับเมท็อด render หรือถ้าจะมีการยุ่งเกี่ยว ต้องตรวจสอบก่อนว่า image ต้องไม่เป็น null ในส่วนนี้อาจจะทำได้ง่ายถ้าเป็นกรณีของคลาส Bullet แต่ถ้าเราต้องการให้ยานอวกาสหมุนหัวไปตามทิศทางด้วย (ในกรณีของ directional bullet) อาจจะมีความยุ่งเกิดขึ้นได้

เพื่อให้โปรแกรมเมื่อรันแสดงผลได้ เราจะแก้โค้ด render ให้สร้าง image ก่อนที่จะวาด

  @Override
  public void render(Graphics g) {
    if (image == null) {
      try {
        image = new Image("res/ship.png");
      } catch (SlickException e) {
        e.printStackTrace();
      }
    }
    image.draw(getX(), getY());
  }

เมื่อเขียนส่วนนี้แล้ว โค้ดเราจะสามารถทำงานได้ และ unit test ก็สามารถรันได้แล้ว

Ship-unittest-success.png

เมื่อโค้ดรันได้ลักษณะนี้ เราก็สามารถเขียน test สำหรับทดสอบเมท็อด distanceTo ได้

วิธีที่ 2: Renderable

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

ดังนั้นเราจะแยกส่วน render ออกมาจากส่วน Entity ซึ่งการแก้ไขที่โครงสร้างระดับพื้นฐานนี้ จะทำให้เราต้องแก้โค้ดเป็นจำนวนมาก แต่อาจจะคุ้มค่า เพราะจะทำให้เราสามารถเทสโค้ดของเราได้ง่ายขึ้น

เราจะสร้าง interface Renderable ที่มีเมท็อด render

import org.newdawn.slick.Graphics;

public interface Renderable {
  void render(Graphics g);
}

และแก้ interface Entity โดยตัดเมท็อด render ทิ้ง และเพิ่มเมท็อด getRenderable

public interface Entity {
  Renderable getRenderable();
  void update(int delta);
  boolean isDeletable();
}

แยก Bullet กับ BulletImage

เราจะแยกส่วน render ออกจาก Bullet โดยเราจะเริ่มโดยการสร้างคลาส BulletImage ที่ implement Renderable โดย object ของคลาสนี้จะมี reference ไปยัง bullet (เพื่อสอบถามตำแหน่ง) และจัดการวาดรูป Bullet ในตำแหน่งดังกล่าว

public class BulletImage implements Renderable {
  Bullet bullet;
  Image image;
  
  public BulletImage(Bullet bullet) {
    this.bullet = bullet;
    try {
      image = new Image("res/ship.png");
    } catch (SlickException e) {
      e.printStackTrace();
    }    
  }
  
  public void render(Graphics g) {
    image.draw(bullet.getX(), bullet.getY());
  }

}

ในคลาส Bullet เราจะตัดเมท็อด render และ reference Image ทิ้งให้หมด และเพิ่มเมท็อด getRenderable ที่คืน object ของคลาส BulletImage สำหรับวาดรูป Bullet นี้

public class Bullet implements Entity {
  //...
  public Renderable getRenderable() {
    return new BulletImage(this);
  }
}

BulletGame: update and render loop

เราจะเก็บวัตถุที่ใช้สำหรับการวาดรูปใน hash map renderables (hash map เป็น map ที่ implement โดยใช้ hash) เราจะใช้ renderables เก็บว่า entity ใด จะใช้ object ใดในการ render

  private HashMap<Entity, Renderable> renderables;

  public BulletGame(String title) {
    //...
    renderables = new HashMap<Entity, Renderable>();
  }

ในโค้ดส่วน render ของ BulletGame เราจะพิจารณา entity ทั้งหมด และเรียก renderable ของ entity ที่ได้เก็บไว้มา render ส่วนในกรณีที่เรายังไม่เคยเก็บมาก่อน เราก็จะเรียก getRenderable ของ entity เพื่อเอา renderable ของวัตถุนั้นมาเก็บใน hash map

  @Override
  public void render(GameContainer container, Graphics g) throws SlickException {
    for (Entity entity : entities) {
      if (!renderables.containsKey(entity)) {
        renderables.put(entity, entity.getRenderable());
      }
      renderables.get(entity).render(g);
    }
  }

สังเกตว่าสำหรับ entity ที่ถูกลบไปนั้น ใน render loop ไม่ถูกเรียกมาวาดซ้ำอีก อย่างไรก็ตาม renderable ของมัน จะยังค้างอยู่ใน hash map renderables ซึ่งวัตถุที่ค้างอยู่นี้ทำให้ garbage collector ของ Java ไม่เก็บกวาดทั้งมันและ entity ของมันที่ถูกเราลบไปแล้ว ดังนั้นในลูป update เราจะต้องลบข้อมูลนี้ออกจาก hash map ด้วย

  @Override
  public void update(GameContainer container, int delta) throws SlickException {
      //...
      if (entity.isDeletable()) {
        iterator.remove();
        renderables.remove(entity); 
      }
      //...
  }

StarBullet

ส่วนของ StarBullet อาจจะยุ่งมากนิดหน่อย เราจะแยกส่วน render ไปเป็นคลาส StarBulletRenderable โดยคลาสนี้จะเรียก Bullet มาจากรายการ ของ star bullet เพื่อสร้าง renderable เก็บไว้ใช้ render

public class StarBulletRenderable implements Renderable {
  StarBullet starBullet;
  LinkedList<Renderable> bulletRenderables;

  public StarBulletRenderable(StarBullet starBullet) {
    this.starBullet = starBullet;
    bulletRenderables = new LinkedList<Renderable>();
    for (DirectionalBullet bullet : starBullet.getBullets()) {
      bulletRenderables.add(bullet.getRenderable());
    }
  }
  
  @Override
  public void render(Graphics g) {
    for (Renderable renderable : bulletRenderables) {
      renderable.render(g);
    }
  }
}

จากนั้นเราลบเมท็อด render และเพิ่มเมท็อด getBullets และ getRenderable ในคลาส StarBullet

public class StarBullet extends Bullet {
  //...
  public List<DirectionalBullet> getBullets() {
    return bullets;
  }
  
  @Override
  public Renderable getRenderable() {
    return new StarBulletRenderable(this);
  }
}