ผลต่างระหว่างรุ่นของ "Oop lab/flappy dot"
Jittat (คุย | มีส่วนร่วม) |
Jittat (คุย | มีส่วนร่วม) |
||
(ไม่แสดง 10 รุ่นระหว่างกลางโดยผู้ใช้คนเดียวกัน) | |||
แถว 124: | แถว 124: | ||
// ... | // ... | ||
</syntaxhighlight> | </syntaxhighlight> | ||
+ | |||
+ | '''หมายเหตุ''': ในโค้ด main ในเอกสารก่อน เรียก container ว่า <tt>appgc</tt> | ||
นอกจากนี้ โค้ดของคลาส Dot ยังมีการใช้ค่าคงที่ 20 ซึ่งครึ่งหนึ่งของความสูง และความกว้างของรูป เราจะแก้ไขโค้ดโดยเพิ่มค่าคงที่ | นอกจากนี้ โค้ดของคลาส Dot ยังมีการใช้ค่าคงที่ 20 ซึ่งครึ่งหนึ่งของความสูง และความกว้างของรูป เราจะแก้ไขโค้ดโดยเพิ่มค่าคงที่ | ||
แถว 188: | แถว 190: | ||
ค่าดังกล่าวคือระยะเวลาเป็นมิลลิวินาทีระหว่างการสั่ง update สังเกตว่าเราใส่ 1000 / 60 เพื่อระบุว่าต้องการประมาณ 60 เฟรมต่อวินาที | ค่าดังกล่าวคือระยะเวลาเป็นมิลลิวินาทีระหว่างการสั่ง update สังเกตว่าเราใส่ 1000 / 60 เพื่อระบุว่าต้องการประมาณ 60 เฟรมต่อวินาที | ||
+ | |||
+ | '''หมายเหตุ''': ในโค้ด main ในเอกสารก่อน เรียก container ว่า <tt>appgc</tt> | ||
=== เก็บกวาดค่าคงที่ === | === เก็บกวาดค่าคงที่ === | ||
แถว 206: | แถว 210: | ||
public void update() { | public void update() { | ||
y += vy; | y += vy; | ||
− | vy | + | vy += FlappyDotGame.G; |
} | } | ||
</syntaxhighlight> | </syntaxhighlight> | ||
แถว 521: | แถว 525: | ||
== การตรวจสอบการชน == | == การตรวจสอบการชน == | ||
+ | |||
+ | ตอนนี้เกมของเราน่าจะเคลื่อนไหวได้แล้ว อย่างไรก็ตามเกมของเราจะเป็นเกมไปไม่ได้ถ้าไม่ได้มีการตรวจสอบว่า dot ชนกับ pillars แล้ว | ||
+ | |||
+ | การตรวจสอบการชนนี้เป็นกิจกรรมพื้นฐานของการเขียนเกม ดังนั้น Slick2D จึงมีฟังก์ชันสำหรับการจัดการเรื่องนี้อยู่พอสมควร อย่างไรก็ตามในงานนี้ เพื่อเป็นการฝึกหัดการเขียนและทดสอบโปรแกรมเราจะเขียนส่วนดังกล่าวเอง | ||
+ | |||
+ | '''หมายเหตุ:''' ในการพัฒนาโปรแกรมจริง ส่วนนี้ควรจะใช้ฟังก์ชันที่ Slick2D มีให้ (ดูคลาส Shape เมท็อด intersects) | ||
+ | |||
+ | เราจะเรียกการส่วนสอบการชนที่เมท็อดของคลาส Dot หรือคลาส PillarPair โดยในที่นี้เราจะเขียนไว้ในคลาส Dot | ||
+ | |||
+ | <syntaxhighlight lang="java"> | ||
+ | public boolean isCollide(PillarPair p) { | ||
+ | return false; | ||
+ | } | ||
+ | </syntaxhighlight> | ||
+ | |||
+ | ในขั้นต้นเราจะตรวจสอบแบบง่ายมาก ๆ ก่อน ว่าพิกัดแกน x ของ dot นั้นอยู่ใกล้กับพิกัดแกน x ของ pillar pair หรือไม่ ในการตรวจสอบดังกล่าว เมท็อด isCollide ของเราจะต้องอ้างถึงพิกัดของ pillar pair ดังนั้นให้เพิ่ม getters สองเมท็อดใน PillarPair ดังนี้ | ||
+ | |||
+ | <syntaxhighlight lang="java"> | ||
+ | public float getX() { return x; } | ||
+ | public float getY() { return y; } | ||
+ | </syntaxhighlight> | ||
+ | |||
+ | เมท็อด isCollide เขียนได้ดังนี้ | ||
+ | |||
+ | <syntaxhighlight lang="java"> | ||
+ | public boolean isCollide(PillarPair p) { | ||
+ | return Math.abs(x - p.getX()) < PillarPair.WIDTH/2; | ||
+ | } | ||
+ | </syntaxhighlight> | ||
+ | |||
+ | สังเกตว่าที่เราเขียนมานี้ เรายังไม่สามารถ test อะไรได้เลย (เพราะว่ายังไม่มีการเรียกใช้) ให้ไปแก้เมท็อด FlappyDotGame.update ให้เรียกเมท็อด isCollide เพื่อตรวจสอบการชนระหว่าง dot กับทุก ๆ pillar pair ถ้ามีการชน ให้โปรแกรมแสดงผล (ทางอ้อม) โดยสั่งพิมพ์ข้อความออกมาทาง System.out เช่น | ||
+ | |||
+ | System.out.println("Collision!"); | ||
+ | |||
+ | เป็นต้น | ||
+ | |||
+ | ทดลองโปรแกรมว่าโปรแกรมจะพิมพ์ข้อความดังกล่าวเมื่อ dot วิ่งผ่านท่อในแกน x หรือไม่ | ||
+ | |||
+ | === แบบฝึกหัด: สถานะเกม === | ||
+ | |||
+ | เราต้องการให้เกมหยุดเมื่อมีการชน | ||
+ | |||
+ | ให้เพิ่ม field <tt>isGameOver</tt> ในคลาส FlappyDotGame | ||
+ | |||
+ | private boolean isGameOver; | ||
+ | |||
+ | โดยมีค่าเริ่มต้นเป็น false | ||
+ | |||
+ | จากนั้นให้ ตรวจสอบการชนในเมท็อด update ถ้ามีการชนให้ปรับสถานะ isGameOver ให้เป็น true พร้อมทั้งแก้ให้โปรแกรมหยุดการ update ต่าง ๆ ตามตัวแปร isGameOver ด้วย | ||
=== แยกส่วนตรวจสอบการชน: คลาส CollisionDetector === | === แยกส่วนตรวจสอบการชน: คลาส CollisionDetector === | ||
+ | |||
+ | เมท็อด isCollide ของเรานั้น ยังทำงานไม่ถูกต้อง อย่างไรก็ตาม การทดสอบเมท็อดดังกล่าวโดยการไล่เล่นโปรแกรมไปเรื่อย ๆ นั้น ไม่ค่อยมีประสิทธิภาพ เพราะว่าขึ้นกับทักษะการกดและโชคเป็นหลัก | ||
+ | |||
+ | แน่นอนว่าสุดท้ายเราต้องทดสอบกับโปรแกรมจริงอยู่ดี แต่เพื่อลดเวลาและเพิ่มประสิทธิภาพของการทดสอบขึ้น เราจะใช้ unit test ในการทดสอบ แทนที่เราจะต้องใช้ความสามารถและดวงเป็นหลัก | ||
+ | |||
+ | ก่อนที่จะทดสอบอะไรได้ เราต้องทำให้เราสามารถเรียก "ส่วน" ที่ต้องการที่ทดสอบได้ง่าย ๆ ก่อน มีหลายวิธีที่ทำได้ แต่เราจะใช้วิธีที่ง่ายที่สุดในงานนี้ คือเราจะแยกส่วนทดสอบออกมาเป็นฟังก์ชันใส่ไว้อีกคลาส ดังด้านล่าง | ||
+ | |||
+ | เราจะสร้างคลาส CollisionDetector ที่มีเมท็อด isCollide | ||
<syntaxhighlight lang="java"> | <syntaxhighlight lang="java"> | ||
public class CollisionDetector { | public class CollisionDetector { | ||
แถว 530: | แถว 591: | ||
} | } | ||
</syntaxhighlight> | </syntaxhighlight> | ||
+ | |||
+ | เมท็อดนี้จะถูกเรียกโดย Dot.isCollide ดังด้านล่าง | ||
+ | |||
+ | <syntaxhighlight lang="java"> | ||
+ | public boolean isCollide(PillarPair p) { | ||
+ | return CollisionDetector.isCollide(x, y, p.getX(), p.getY()); | ||
+ | } | ||
+ | </syntaxhighlight> | ||
+ | |||
+ | === JUnit Test Case === | ||
+ | ให้เขียนเมท็อด isCollide พร้อม ๆ กับเขียน test case ไปด้วย ด้านล่างแสดงบาง test case เพื่อเป็นจุดเริ่มต้นของการเขียนเมท็อดของคุณ | ||
<syntaxhighlight lang="java"> | <syntaxhighlight lang="java"> | ||
แถว 537: | แถว 609: | ||
public void testNotCollideFarLeft() { | public void testNotCollideFarLeft() { | ||
assertFalse(CollisionDetector.isCollide(0, 100, 320, 320)); | assertFalse(CollisionDetector.isCollide(0, 100, 320, 320)); | ||
− | |||
− | |||
− | |||
− | |||
− | |||
} | } | ||
แถว 559: | แถว 626: | ||
} | } | ||
− | + | // ... | |
− | |||
− | |||
− | |||
} | } | ||
</syntaxhighlight> | </syntaxhighlight> | ||
− | + | เมื่อเขียนเมท็อด isCollide และทดสอบจนเป็นที่พอใจแล้ว ทดลองรันเกมว่าการตรวจสอบการชนเมื่อใช้ในเกมจริง ๆ ทำงานได้ถูกต้องหรือไม่ | |
− | + | == เกม == | |
− | + | โปรแกรมของเราเป็นเกมที่เล่นได้หนึ่งรอบ และยังขนาดลักษณะที่ดีของเกมอีกหลายอย่าง | |
− | |||
− | |||
− | |||
− | + | ให้คุณทดลองเพิ่มเกมให้สมบูรณ์ขึ้น เช่น | |
− | + | * เพิ่มการนับคะแนน | |
− | + | * ให้กดปุ่ม enter เพื่อ restart เกม เป็นต้น | |
+ | * ตรวจสอบว่า dot ตกสัมผัสพื้น หรือทะลุจอด้านบนหรือไม่ |
รุ่นแก้ไขปัจจุบันเมื่อ 08:14, 8 กันยายน 2557
- หน้านี้เป็นส่วนหนึ่งของ oop lab
- อ่านเวอร์ชันที่เป็น JavaScript บน Cocos2d-html5
เนื้อหา
การแยกงานเป็นงานย่อย
ในเกมดังกล่าวมีงานจำนวนมากที่เราต้องทำ เราไม่ควรเขียนโปรแกรมทั้งหมดให้เสร็จในจังหวะเดียว แต่ควรจะค่อย ๆ ทยอยทำทีละส่วน และทดสอบว่าโปรแกรมทำงานได้ไปเรื่อย ๆ ตลอดเวลาการพัฒนา เพื่อเป็นการฝึกพื้นฐาน ก่อนเริ่มอ่านต่อไปให้คุณคิดวิเคราะห์สักพักว่ามีงานย่อยใด ๆ บ้างที่ต้องทำในการพัฒนาเกมนี้
แสดง Dot
เริ่มโปรเจ็คและหน้าจอว่าง
สร้าง project ชื่อ flappydot จากนั้นสร้าง Git repository สำหรับ project นี้ด้วย
สร้าง class FlappyDotGame ซึ่งจะเป็นคลาสหลักของเกมเรา จากนั้นให้เพิ่มเมท็อด render, init, update และสร้างเมท็อด main ที่เริ่มเกม (ดูจากเอกสารก่อน)
public class FlappyDotGame extends BasicGame {
public FlappyDotGame(String title) {
super(title);
}
// ... เพิ่มเอง
}
เมื่อทดลองรันและเห็นหน้าจอว่าง ให้ commit งานลงใน Git ต่อไปเพื่อเป็นการเตือนให้คุณ commit งาน เราจะใส่ย่อหน้าดังด้านล่างไว้เตือน
แสดงสี background
เกมนี้เราจะไม่ใช้ background เป็นสีดำแล้ว เราจะแก้สี background ได้ โดยกำหนด background ลงใน graphics ของเกม เพิ่มคำสั่งเหล่านี้ในเมท็อด init
@Override
public void init(GameContainer container) throws SlickException {
Color background = new Color(128, 128, 128);
container.getGraphics().setBackground(background);
}
ทดลองรันเพื่อดูว่า background แสดงสีเทาหรือไม่? ปรับค่าสีตามใจชอบ
แสดง Dot
สร้างรูป dot.png สำหรับแสดงตัวผู้เล่น ให้สร้างให้มีขนาด 40 x 40 จุด จากนั้นให้ copy ไฟล์ไปไว้ที่ res/dot.png
เราจะสร้างคลาส Dot สำหรับจัดการแสดงผลตัวผู้เล่น และคำนวณการเคลื่อนที่ รวมถึงจัดการอื่น ๆ คลาสดังกล่าวจะถูกเรียกใช้โดย FlappyDotGame ดังนี้:
@Override
public void init(GameContainer container) throws SlickException {
dot = new Dot(320, 240);
}
@Override
public void render(GameContainer container, Graphics g) throws SlickException {
dot.render();
}
อย่าลืมเพิ่มบรรทัดที่ประกาส field ของ Dot ด้วย
private Dot dot;
ด้านล่างแสดงคลาส Dot
public class Dot {
private float x;
private float y;
private Image image;
public Dot(float x, float y) throws SlickException {
this.x = x;
this.y = y;
image = new Image("res/dot.png");
}
public void render() {
image.draw(x - 20, 480 - (y + 20));
}
}
ข้อสังเกต:
- พิกัดต่าง ๆ เป็น float (เพราะว่าเราต้องการปรับความเร็ว/ความเร่งให้ละเอียดขึ้น และ Slick2D รับพิกัดเป็น float อยู่แล้ว)
- ในเมท็อด render เรามีการแปลงตำแหน่งที่จะวาดรูป โดยเราเลื่อนพิกัดแกน x ไป -20 หน่วย, ส่วนพิกัดในแกน y นั้น เรากลับข้างมัน เพราะว่าในการคิด physics เรามักนิยมคิดค่าในแกน y เป็นความสูง แต่ทิศทางของแกน y ในการวาด graphics นั้น นับจุด (0,0) เป็นจุดมุมบนซ้าย
จัดการกับค่าคงที่ (magic number)
สังเกตว่าในโปรแกรมมีการใช้ค่าคงที่ต่าง ๆ เป็นจำนวนมาก เช่น ค่าคงที่ที่เกี่ยวกับกับขนาดหน้าจอของเกม (640, 480, 320, และ 240) การใช้ค่าดังกล่าวทำให้ถ้ามีการแก้ไข เราจะต้องไปแก้หลายที่ และทำให้เกิดความยุ่งยากเวลาเราอ่านโค้ด เพราะว่าเราอาจจะไม่แน่ใจว่าค่าดังกล่าวหมายถึงอะไร
เราจะประกาศค่าคงที่ดังกล่าวในคลาส FlappyDotGame และใช้ค่าคงที่นั้น (แบบที่มีชื่อแล้ว) ในโปรแกรมแทน เราสามารถประกาศค่าคงที่ด้วย keyword final โดยเราจะประกาศแบบ public static ให้เป็นค่าคงที่ของคลาส และเปิดให้คลาสอื่น ๆ ใช้ได้ด้วย
เพิ่มบรรทัดเหล่านี้ในตอนต้นคลาส FlappyDotGame
public class FlappyDotGame extends BasicGame {
public static final int GAME_WIDTH = 640;
public static final int GAME_HEIGHT = 480;
// ...
}
จากนั้นให้แก้โค้ดบรรทัดต่าง ๆ ที่มีการใช้ค่าเหล่านี้ให้เรียกผ่านทางค่าคงที่แบบมีชื่อที่เราสร้างนี้ เช่น
// ...
@Override
public void init(GameContainer container) throws SlickException {
dot = new Dot(GAME_WIDTH/2, GAME_HEIGHT/2);
}
public static void main(String[] args) {
// ...
container.setDisplayMode(GAME_WIDTH, GAME_HEIGHT, false);
}
// ...
หมายเหตุ: ในโค้ด main ในเอกสารก่อน เรียก container ว่า appgc
นอกจากนี้ โค้ดของคลาส Dot ยังมีการใช้ค่าคงที่ 20 ซึ่งครึ่งหนึ่งของความสูง และความกว้างของรูป เราจะแก้ไขโค้ดโดยเพิ่มค่าคงที่
public static final int WIDTH = 40; public static final int HEIGHT = 40;
และแก้เมท็อด render เป็น
public void render() {
image.draw(x - WIDTH/2,
FlappyDotGame.GAME_HEIGHT - y - (HEIGHT/2));
}
การเคลื่อนที่และการควบคุม Dot
การเคลื่อนที่ของ Dot
เราจะให้ Dot เปลี่ยนตำแหน่งเองได้ (เพื่อที่เราจะได้ไม่ต้องไปดูแลมัน) การเปลี่ยนตำแหน่งนี้จะทำให้เมท็อด update ของ FlappyDotGame โดยเราจะเรียกดังนี้:
@Override
public void update(GameContainer container, int delta) throws SlickException {
dot.update();
}
การจะเปลี่ยนตำแหน่งได้เหมือนของตกนั้นเราจะต้องทำงานกับ physics พอตัว เราจะให้ Dot มีความเร็ว จากนั้นเราจะปรับความเร็วนั้นด้วยค่าความเร่งจากแรงโน้มถ่วงโลก
โค้ดเริ่มต้นของเราเป็นดังนี้
public class Dot {
// ...
private float vy;
public Dot(float x, float y, float vy) throws SlickException {
// ...
this.vy = vy;
}
// ...
public void update() {
y += vy;
vy -= 0.1;
}
}
สังเกตว่าเราจะรับค่าความเร็วในแกน y เริ่มต้นมาด้วย ดังนั้นโค้ดที่สร้าง dot ในเมท็อด FlappyDotGame.init จะต้องส่งค่าดังกล่าวให้กับ constructor
// ...
dot = new Dot(GAME_WIDTH/2, GAME_HEIGHT/2, 10);
ให้ทดลองดูว่าจุดเคลื่อนได้ที่ลักษณคล้ายกับการหล่นหรือไม่ ให้ปรับค่าความเร็วเริ่มต้นและความเร่งตามพอเหมาะ (จากการทดลอง 10 กับ -0.5 จะกำลังดี)
ถ้าเครื่องคุณเร็วไป (เช่น ทำงานแล้วได้ FPS = 1000) ให้เพิ่มบรรทัดด้านล่างลงใน main ก่อน container.start(); เพื่อเพิ่มระยะเวลาระหว่างการเรียก update
container.setMinimumLogicUpdateInterval(1000 / 60);
ค่าดังกล่าวคือระยะเวลาเป็นมิลลิวินาทีระหว่างการสั่ง update สังเกตว่าเราใส่ 1000 / 60 เพื่อระบุว่าต้องการประมาณ 60 เฟรมต่อวินาที
หมายเหตุ: ในโค้ด main ในเอกสารก่อน เรียก container ว่า appgc
เก็บกวาดค่าคงที่
เราใช้ค่าคงที่อีกแล้ว... เก็บกวาดค่าต่าง ๆ โดยประกาศค่าคงที่ด้านล่างที่ตอนต้น FlappyDotGame
//...
public static final float DOT_INITIAL_VY = 10;
public static final float G = (float) -0.5;
หมายเหตุ: ที่เราต้องคาส (float) ที่ค่าคงที่ G เพราะว่า Java ไม่ยอมแปลง -0.5 ที่เป็น double ให้เป็น float ให้เรา
จากนั้นไปแก้จุดที่เรียกใช้ค่าดังกล่าว เช่น
// ...
public void update() {
y += vy;
vy += FlappyDotGame.G;
}
และ
public void init(GameContainer container) throws SlickException {
dot = new Dot(GAME_WIDTH/2, GAME_HEIGHT/2, DOT_INITIAL_VY);
}
การอ่าน input แบบ event-based
- อ่านเพิ่มเกี่ยวกับวิธีการอ่าน input แบบต่าง ๆ ใน Slick2D ได้ที่: Event based input
ในการเขียนครั้งก่อน เราอ่าน input โดยใช้การ polling (คือไปถามสถานะของปุ่มทุกครั้งที่มีการเรียก update) อย่างไรก็ตาม การถามดังกล่าวนั้นหลายครั้งทำได้ลำบาก (เพราะว่าต้องคอยหาจังหวะไปถาม) ซึ่งอาจจะทำให้พลาดเหตุการณ์ที่เราต้องการได้ และอาจจะทำให้เสียประสิทธิภาพเกิดความจำเป็น
สำหรับในเกม FlappyDot นี้ เราจะอ่าน input แบบ event-based นั่นคือ เราจะเขียนเมท็อดพิเศษขึ้นมา ซึ่งระบบจะเรียกเมท็อดนี้ เมื่อมีเหตุการณ์ที่เราสนใจขึ้นเองโดยอัตโนมัติ สิ่งที่เราต้องทำคือจัดการกับเหตุการณ์ตามที่เราต้องการ สำหรับเหตุการณ์การกดปุ่มนั้น ถ้าเราเขียนเกมโดยสร้างมาจาก BasicGame (โดยการ extends เช่นที่เราทำ) เราจะสามารถรับการกดปุ่มโดยเขียนเมท็อด keyPressed ดังด้านล่าง
โค้ดดังกล่าวตรวจสอบว่ามีการกดปุ่ม space bar หรือไม่ (อย่าลืม import org.newdawn.slick.Input)
@Override
public void keyPressed(int key, char c) {
if (key == Input.KEY_SPACE) {
// do something
}
}
สิ่งที่เราจะทำคือ เราจะไปกำหนดความเร็วเริ่มต้นในแกน y ให้กับ Dot ใหม่ เราอาจจะอยากสั่งเป็น
dot.vy = DOT_INITIAL_VY;
แต่เราไม่สามารถทำได้ เพราะว่า vy เป็น field แบบ private เราสามารถแก้ปัญหานี้ตรง ๆ ได้สองแบบ (จริง ๆ มี 3 แบบ แบบที่ 3 อยู่ส่วนถัดไป) คือ
- เปลี่ยน vy เป็น public
- เขียนเมท็อดเพื่อกำหนดค่า vy
เราจะใช้วิธีที่ 2 เมท็อดดังกล่าวนิยมเรียกว่า setter (เพราะว่าเอาไว้กำหนดค่า) การเขียน setter นี้ ทำให้เรามีอิสระที่จะเปลี่ยนการ implement ภายในของคลาสเกี่ยวกับ field vy ได้ในภายหลัง (เพราะว่าคลาสต่าง ๆ เมื่อจะอ้างถึงและแก้ไขจะต้องทำผ่านเมท็อดนี้
เพิ่มเมท็อด setVy ในคลาส Dot
public void setVy(float vy) {
this.vy = vy;
}
จากนั้นในเมท็อด keyPressed เราสามารถสั่งดังต่อไปนี้ได้
if (key == Input.KEY_SPACE) {
dot.setVy(DOT_INITIAL_VY);
}
เมท็อด jump
โค้ดบรรรทัดด้านล่าง
dot.setVy(DOT_INITIAL_VY);
มีความชัดเจนในแง่ของการระบุ "การทำงาน" อย่างไรก็ตาม โค้ดดังกล่าวไม่ได้สื่อถึงสิ่งที่เรา "ต้องการจะทำ"
ในการพัฒนาโปรแกรม การเขียนโค้ดให้สื่อถึงเป้าหมายที่ต้องการเป็นสิ่งที่สำคัญมาก ในโค้ดบรรทัดดังกล่าวเราต้องการอะไร? เราต้องการให้ dot นั้น กระโดดขึ้นหนึ่งครั้ง (ก่อนจะร่วงหล่นลงมา) โค้ดดังกล่าวควรจะเขียนเป็น
dot.jump();
เราจะแก้โค้ดส่วนอื่น ๆ เพื่อทำให้บรรทัดดังกล่าวเขียนได้อย่างชัดแจ้งดังข้างต้น
สังเกตว่า dot จะ jump ได้ ตัว object จะต้องทราบความเร็วต้นในการกระโดด ดังนั้นเราจะต้องส่งค่า DOT_INITITAL_VY ให้วัตถุจำไว้ด้วย (ไม่ใช่แค่กำหนดเป็นค่าเริ่มต้นของ vy อย่างเดียว) นอกจากนี้ การตั้งชื่อว่า DOT_INITIAL_VY นั้นไม่ค่อยจะสื่อความหมายของความเร็วดังกล่าวที่เราจะใช้ ดังนั้นเราจะ rename DOT_INITIAL_VY ให้เป็น DOT_JUMP_VY แน่นอนว่าการแก้ชื่อดังกล่าว ไม่ได้แก้ที่เดียว แต่ IDE เช่น Eclipse หรือ NetBeans มีความสามารถที่เรียกว่า refactor ที่ทำให้เราสามารถแก้ชื่อค่าคงที่ในที่เดียวและ IDE จะไปแก้ไขชื่อดังกล่าวให้ทุกที่ในโปรแกรมของเราได้
ในการดำเนินการดังกล่าว เราจะเลือกค่าคงที่ดังกล่าว จากนั้นกดปุ่มเลือก rename จากเมนูย่อย refactor แล้วพิมพ์ชื่อใหม่ใส่ไปเป็น DOT_JUMP_VY เมื่อกดเรียบร้อย IDE จะแก้ทุกอย่างให้เราโดยอัตโนมัติ
หมายเหตุ: ความสามารถที่ refactor ได้โดยอัตโนมัตินี่เป็นสิ่งที่ทำให้ภาษาพวก Java เข้มแข็งมาก
จากนั้นเราจะแก้โค้ด Dot โดยจะแก้ constructor และเพิ่มเมท็อด jump (และจะลบเมท็อด setVy ทิ้งเลยด้วยก็ได้)
public Dot(float x, float y, float vjump) throws SlickException {
// ...
this.vy = vjump;
this.vjump = vjump;
// ...
}
public void jump() {
vy = vjump;
}
สังเกตว่าเราไม่ต้องแก้ส่วนที่สร้าง dot ใน FlappyDotGame.init แต่อย่างใด ซึ่งโค้ดดังกล่าวตอนนี้กลายเป็น:
dot = new Dot(GAME_WIDTH/2, GAME_HEIGHT/2, DOT_JUMP_VY);
ไปแล้ว ตั้งแต่ตอนที่เราสั่ง refactor
ขั้นสุดท้าย อย่าลืมไปแก้บรรทัด dot.setVy ให้เป็น dot.jump()
สถานะเกม
เกมของเราเมื่อเปิดมาก็เริ่มทันที่ เราอาจจะเริ่มกดปุ่มไม่ทันก็ได้ ในส่วนนี้เราจะเพิ่ม field
private boolean isStarted;
ในคลาส FlappyDotGame และเราจะให้ isStarted มีค่าเป็น false ในตอนเริ่มต้น เราจะเพิ่มบรรทัด
isStarted = false;
ในเมท็อด init
แบบฝึกหัด: ให้แก้เมท็อด update และ keyPressed ให้จัดการกับ isStarted โดยที่เราจะให้ isStarted เป็น true เมื่อมีการเริ่มกดปุ่ม นอกจากนี้ เมท็อด update จะไม่เรียกให้ dot นั้น update จนกว่า isStarted จะเป็นจริง
ท่อและการเคลื่อนที่ของท่อ
ในส่วนนี้เราจะเพิ่มคลาส PillarPair ที่จัดการเกี่ยวกับท่อคู่ในเกม
สำหรับการทำ sprite ท่อนี้ เราทำได้สองแบบ คือ สร้าง sprite ใหญ่ๆ ที่มีท่อสองอันโดยเว้นช่องตรงกลางไว้ หรือสร้างรูปของท่อสองรูป เป็นท่อบนและท่อล่าง ดังรูปด้านล่าง
การสร้างแบบแรก อาจจะง่ายในการใช้งาน แต่มีข้อจำกัดหลายอย่าง
คำถาม: ข้อจำกัดดังกล่าวคืออะไร?
ดังนั้นเราจะใช้รูปสองรูปเพื่อสร้างท่อคู่นี้ เราจะให้พิกัดของท่อคู่ (pillar pair) ระบุตำแหน่งตรงกลางของคู่ของท่อ ดังรูปด้านล่าง
ความยากอีกอย่างคือการทำให้ท่อเปลี่ยนความสูง เราจะแก้ปัญหาดังกล่าวคือเราจะสร้างท่อให้มีความสูงมาก ๆ แล้วเราวาดรูปลงไปบนจอ เราจะวาดส่วนของท่อให้ทะลุจอออกไปแทน ดังรูปด้านล่าง
ให้วาดรูปท่อ โดยสร้างรูปท่อสองรูป โดยมีขนาด 80 x 600 และเก็บเป็นไฟล์ชื่อ pillar-top.png และ pillar-bottom.png ใน directory res (ที่เดียวกับที่เก็บ dot.png)
แสดงท่อ
ด้านล่างแสดงโค้ดของคลาส PillarPair ในตอนแรก ให้พิจารณาโค้ดของเมท็อด render ที่แสดงการคำนวณตำแหน่งในการวาดรูปส่วนต่าง ๆ ของ pillar pair (สังเกตการกลับข้างของพิกัด y)
public class PillarPair {
private Image topPillar;
private Image bottomPillar;
private float x;
private float y;
public PillarPair(float x, float y) throws SlickException {
this.x = x;
this.y = y;
topPillar = new Image("res/pillar-top.png");
bottomPillar = new Image("res/pillar-bottom.png");
}
public void render() {
topPillar.draw(x - 40,
FlappyDotGame.GAME_HEIGHT - (y + 700));
bottomPillar.draw(x - 40,
FlappyDotGame.GAME_HEIGHT - (y - 100));
}
}
สังเกตว่าสำหรับพิกัดแกน y เรา +700 และ -100 เพราะว่ารูปของท่อมีความสูง 600 และเราต้องการให้ช่องระหว่างท่อมีระยะเท่ากับ 200 เลยทำให้เราต้องปรับค่าไปอีก 100 (ครึ่งหนึ่งของ 200)
เราจะสร้าง pillar pair ในเมท็อด FlappyDotGame.init ดังนี้ (เริ่มต้นเราจะให้อยู่ตรงกลางก่อน)
public void init(GameContainer container) throws SlickException { // ... pillar = new PillarPair(GAME_WIDTH/2, GAME_HEIGHT/2); }
และสั่ง render ในเมท็อด FlappyDotGame.render ดังด้านล่าง
public void render(GameContainer container, Graphics g) throws SlickException { pillar.render(); // ... }
ทดลองว่าโปรแกรมแสดงคู่ของท่อได้ถูกต้องหรือไม่ แต่ก่อนจะ commit เราควรตรวจสอบโค้ดเราว่าอ่านและเข้าใจได้ง่ายหรือยัง
เก็บกวาดค่าคงที่
โค้ดเรามีการใช้ค่าคงที่มากมายอีกแล้ว (เช่น 40, 700, หรือ 100) ค่าเหล่านี้ ทำให้เราแก้โปรแกรมในอนาคตได้ยาก
เราจะเพิ่มค่าคงที่เหล่านี้ในคลาส PillarPair
static public final int WIDTH = 80; static public final int IMAGE_HEIGHT = 600; static public final int PILLAR_SPACE = 200;
จากให้ให้เก็บกวาดค่าคงที่ต่าง ๆ ที่ปรากฏในโค้ดด้วยค่าคงที่ที่มีชื่อเหล่านี้
ท่อเลื่อน
เราจะแก้ constructor ของ PillarPair ให้รับค่า vx และเพิ่มเมท็อด update ให้ปรับตำแหน่งของท่อ
public PillarPair(float x, float y, float vx) throws SlickException {
// ...
this.vx = vx;
}
public void update() {
x += vx;
}
ในคลาส FlappyDotGame เราจะเพิ่มค่าคงที่
public static final float PILLAR_VX = -4;
และแก้บรรทัดที่สร้าง pillar pair เป็น
pillar = new PillarPair(GAME_WIDTH/2, GAME_HEIGHT/2, PILLAR_VX);
หมายเหตุ 1: ปรับความเร็วท่อตามชอบ
หมายเหตุ 2: ตอนนี้เราคิดไว้ก่อนว่าต้องมีค่าคงที่ PILLAR_VX เราจึงไปเพิมไว้ก่อน แล้วค่อยใช้ (แตกต่างจากที่ผ่าน ๆ มา ที่เราใช้ไปก่อน แล้วค่อยมาเก็บกวาด)
แบบฝึกหัด: ตอนนี้พอท่อเลื่อนไปจนทะลุด้านซ้ายแล้ว ก็จะเลื่อนจนหายไปเลย ให้เขียนโปรแกรมเพิ่มเติมให้เมื่อท่อเลื่อนจนทะลุขอบด้านซ้ายแล้ว ให้ย้อนกลับมาทางขอบด้านขวาใหม่
ท่อหลายท่อ
เมื่อเราสร้างท่อได้หนึ่งท่อที่วิ่งเองได้แล้ว การจะเพิ่มท่อให้เป็นหลายท่อก็ไม่ใช่เรื่องยากอีกต่อไป เราจะเก็บท่อเหล่านี้ไว้ในอาร์เรย์ pillars
ประกาศ field ดังกล่าวใน FlappyDotGame (เมื่อทำเสร็จเราจะลบ field pillar ทิ้งไปด้วย)
private PillarPair[] pillars;
โค้ดด้านล่างในเมท็อด init จะสร้างอาร์เรย์ของ pillars
pillars = new PillarPair[3];
for (int i = 0; i < 3; i++) {
pillars[i] = new PillarPair(GAME_WIDTH + 100 + 250*i, GAME_HEIGHT/2, PILLAR_VX);
}
สังเกตว่าเราใช้ค่าคงที่อีกหลายค่า ทั้งในการคำนวณตำแหน่งของท่อแต่ละอันก และจำนวนท่อ เราจะจัดการกับจำนวนท่อเสียก่อนเพราะว่ามีการใช้หลายที่ เราจะสร้างค่าคงที่
public static final int PILLAR_COUNT = 3;
อย่าลืมแก้ 3 ในโค้ดดังกล่าวให้เป็น PILLAR_COUNT
โค้ดในเมท็อด init ชักเริ่มยาว เราจะแยกโค้ดส่วนที่สร้าง pillar ออกมาเป็นอีกเมท็อดชื่อ initPillars เราสามารถจัดตัดออกด้วยมือ หรือจะให้ IDE refactor ออกมาให้เราก็ได้ การ refactor รูปแบบนี้เรียกว่า Extract Method
โค้ดใน FlappyDotGame.init จะเหลือประมาณนี้หลังจาก extract method ไปแล้ว
@Override
public void init(GameContainer container) throws SlickException {
dot = new Dot(GAME_WIDTH/2, GAME_HEIGHT/2, DOT_JUMP_VY);
initPillars();
Color background = new Color(128, 128, 128);
container.getGraphics().setBackground(background);
// ...
}
เราต้องการจะ render และ update บรรดา pillar ในอาร์เรย์ pillars ของเรา
ใน Java เรามีคำสั่ง for รูปแบบพิเศษที่จะพิจารณาข้อมูลทุกตัวในอาร์เรย์ได้ โดยรูปแบบการเขียน จะเขียนดังนี้
for (TYPE VAR : ARRAY) { // do something with VAR }
เราจะแก้เมท็อด render ให้เป็นดังนี้
@Override
public void render(GameContainer container, Graphics g) throws SlickException {
for (PillarPair pillar : pillars) {
pillar.render();
}
dot.render();
}
แบบฝึกหัด: แก้เมท็อด update ให้เรียกการ update ของ pillar pair
แบบฝึกหัด: สุ่มความสูงท่อ
เขียนเมท็อด randomY ในคลาส PillarPair ให้สุ่มตำแหน่ง (ในพิกัด y) ของท่อ
public void randomY() {
//...
}
ให้เรียกเมท็อดนี้เมื่อสร้าง pillar pair และให้ pillar pair เรียกเมท็อดนี้ทุกครั้งที่ท่อวิ่งทะลุไปออกด้านขวาของจอ (ความสูงของท่อจะได้ไม่ซ้ำ)
การตรวจสอบการชน
ตอนนี้เกมของเราน่าจะเคลื่อนไหวได้แล้ว อย่างไรก็ตามเกมของเราจะเป็นเกมไปไม่ได้ถ้าไม่ได้มีการตรวจสอบว่า dot ชนกับ pillars แล้ว
การตรวจสอบการชนนี้เป็นกิจกรรมพื้นฐานของการเขียนเกม ดังนั้น Slick2D จึงมีฟังก์ชันสำหรับการจัดการเรื่องนี้อยู่พอสมควร อย่างไรก็ตามในงานนี้ เพื่อเป็นการฝึกหัดการเขียนและทดสอบโปรแกรมเราจะเขียนส่วนดังกล่าวเอง
หมายเหตุ: ในการพัฒนาโปรแกรมจริง ส่วนนี้ควรจะใช้ฟังก์ชันที่ Slick2D มีให้ (ดูคลาส Shape เมท็อด intersects)
เราจะเรียกการส่วนสอบการชนที่เมท็อดของคลาส Dot หรือคลาส PillarPair โดยในที่นี้เราจะเขียนไว้ในคลาส Dot
public boolean isCollide(PillarPair p) {
return false;
}
ในขั้นต้นเราจะตรวจสอบแบบง่ายมาก ๆ ก่อน ว่าพิกัดแกน x ของ dot นั้นอยู่ใกล้กับพิกัดแกน x ของ pillar pair หรือไม่ ในการตรวจสอบดังกล่าว เมท็อด isCollide ของเราจะต้องอ้างถึงพิกัดของ pillar pair ดังนั้นให้เพิ่ม getters สองเมท็อดใน PillarPair ดังนี้
public float getX() { return x; }
public float getY() { return y; }
เมท็อด isCollide เขียนได้ดังนี้
public boolean isCollide(PillarPair p) {
return Math.abs(x - p.getX()) < PillarPair.WIDTH/2;
}
สังเกตว่าที่เราเขียนมานี้ เรายังไม่สามารถ test อะไรได้เลย (เพราะว่ายังไม่มีการเรียกใช้) ให้ไปแก้เมท็อด FlappyDotGame.update ให้เรียกเมท็อด isCollide เพื่อตรวจสอบการชนระหว่าง dot กับทุก ๆ pillar pair ถ้ามีการชน ให้โปรแกรมแสดงผล (ทางอ้อม) โดยสั่งพิมพ์ข้อความออกมาทาง System.out เช่น
System.out.println("Collision!");
เป็นต้น
ทดลองโปรแกรมว่าโปรแกรมจะพิมพ์ข้อความดังกล่าวเมื่อ dot วิ่งผ่านท่อในแกน x หรือไม่
แบบฝึกหัด: สถานะเกม
เราต้องการให้เกมหยุดเมื่อมีการชน
ให้เพิ่ม field isGameOver ในคลาส FlappyDotGame
private boolean isGameOver;
โดยมีค่าเริ่มต้นเป็น false
จากนั้นให้ ตรวจสอบการชนในเมท็อด update ถ้ามีการชนให้ปรับสถานะ isGameOver ให้เป็น true พร้อมทั้งแก้ให้โปรแกรมหยุดการ update ต่าง ๆ ตามตัวแปร isGameOver ด้วย
แยกส่วนตรวจสอบการชน: คลาส CollisionDetector
เมท็อด isCollide ของเรานั้น ยังทำงานไม่ถูกต้อง อย่างไรก็ตาม การทดสอบเมท็อดดังกล่าวโดยการไล่เล่นโปรแกรมไปเรื่อย ๆ นั้น ไม่ค่อยมีประสิทธิภาพ เพราะว่าขึ้นกับทักษะการกดและโชคเป็นหลัก
แน่นอนว่าสุดท้ายเราต้องทดสอบกับโปรแกรมจริงอยู่ดี แต่เพื่อลดเวลาและเพิ่มประสิทธิภาพของการทดสอบขึ้น เราจะใช้ unit test ในการทดสอบ แทนที่เราจะต้องใช้ความสามารถและดวงเป็นหลัก
ก่อนที่จะทดสอบอะไรได้ เราต้องทำให้เราสามารถเรียก "ส่วน" ที่ต้องการที่ทดสอบได้ง่าย ๆ ก่อน มีหลายวิธีที่ทำได้ แต่เราจะใช้วิธีที่ง่ายที่สุดในงานนี้ คือเราจะแยกส่วนทดสอบออกมาเป็นฟังก์ชันใส่ไว้อีกคลาส ดังด้านล่าง
เราจะสร้างคลาส CollisionDetector ที่มีเมท็อด isCollide
public class CollisionDetector {
static boolean isCollide(float dotX, float dotY, float pX, float pY) {
return false;
}
}
เมท็อดนี้จะถูกเรียกโดย Dot.isCollide ดังด้านล่าง
public boolean isCollide(PillarPair p) {
return CollisionDetector.isCollide(x, y, p.getX(), p.getY());
}
JUnit Test Case
ให้เขียนเมท็อด isCollide พร้อม ๆ กับเขียน test case ไปด้วย ด้านล่างแสดงบาง test case เพื่อเป็นจุดเริ่มต้นของการเขียนเมท็อดของคุณ
public class CollisionDetectorTest {
@Test
public void testNotCollideFarLeft() {
assertFalse(CollisionDetector.isCollide(0, 100, 320, 320));
}
@Test
public void testNotCollideThrough() {
assertFalse(CollisionDetector.isCollide(320, 320, 320, 320));
}
@Test
public void testNotCollideThroughHigh() {
assertFalse(CollisionDetector.isCollide(320, 240, 320, 320));
}
@Test
public void testCollideTop() {
assertTrue(CollisionDetector.isCollide(320, 500, 320, 320));
}
// ...
}
เมื่อเขียนเมท็อด isCollide และทดสอบจนเป็นที่พอใจแล้ว ทดลองรันเกมว่าการตรวจสอบการชนเมื่อใช้ในเกมจริง ๆ ทำงานได้ถูกต้องหรือไม่
เกม
โปรแกรมของเราเป็นเกมที่เล่นได้หนึ่งรอบ และยังขนาดลักษณะที่ดีของเกมอีกหลายอย่าง
ให้คุณทดลองเพิ่มเกมให้สมบูรณ์ขึ้น เช่น
- เพิ่มการนับคะแนน
- ให้กดปุ่ม enter เพื่อ restart เกม เป็นต้น
- ตรวจสอบว่า dot ตกสัมผัสพื้น หรือทะลุจอด้านบนหรือไม่