Oop lab/unit testing slick2d
- หน้านี้เป็นส่วนหนึ่งของ oop lab.
เราต้องการจะเขียน unit test กับโค้ดใน Slick2D อย่างไรก็ตามโค้ดส่วนของ Entity ของเรานั้นมักมีส่วนการแสดงผลอยู่ด้วย และส่วนเหล่านี้โดยมากจะทำงานไม่ได้ใน unit test หรือไม่ก็ทำให้การเตรียมการต่าง ๆ มีปัญหา
เพื่อเพิ่มความเข้าใจ เราจะยกตัวอย่างจากโค้ด BulletGame แต่เราจะเพิ่มให้คลาส Bullet ใช้รูปลูกกระสุน แทนที่จะเป็นการวาดรูปธรรมดา
ในการทำ unit test นั้น เราจะทดลองเพิ่มเมท็อด distanceTo(float x, float y) ให้กับคลาส Bullet เป็นตัวอย่าง
เนื้อหา
Bullet ที่แสดงโดยการวาดรูป
เพื่อเป็นการทดลอง เราจะใช้รูป ship จาก ship เกมในการแสดงกระสุน เมื่อรันแล้วอาจจะมีหน้าตาแบบนี้
เราจะคัดลอกไฟล์ 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 ก็สามารถรันได้แล้ว
เมื่อโค้ดรันได้ลักษณะนี้ เราก็สามารถเขียน 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);
}
}