Oop lab/gdx/pacman
- หน้านี้เป็นส่วนหนึ่งของ oop lab
เราจะเขียนเกม pacman แบบง่าย ๆ กันด้วย libGDX
- หมายเหตุ: เนื้อหาหลายส่วนและรูปมาจาก Tutorial เดียวกันของวิชา 01219245
ขั้นตอนคร่าว ๆ
- สร้าง gradle project ของ GDX
- (ขั้นตอนเพิ่มเติม) เขียน pacman เคลื่อนที่ไปมาบนหน้าจอ
- เขียนส่วนแสดงแผนที่และจุด
- บังคับ pacman เดินไปมาตามช่อง (วิ่งทะลุกำแพง)
- บังคับ pacman เดินไปมาตามช่อง (ไม่ทะลุแล้ว)
- กินจุด
- แสดงคะแนน
เนื้อหา
สร้าง gradle project
เราจะสร้าง gradle project ของเกมด้วยโปรแกรม gdx-setup
ดาวนโหลด gdx-setup.jar
จากนั้นสั่ง
java -jar gdx-setup.jar
ใส่ค่าให้ครบ เลือกเฉพาะ desktop เกม เพื่อความสะดวกในการทำ tutorial ให้ตั้งชื่อ game class ว่า PacmanGame
จากนั้นให้ import เข้าไปใน Eclipse หรือ NetBeans และทดลองรัน ถ้าเจอปัญหาว่าหารูป badlogic ไม่เจอ อย่าลืมแก้ไข Working Directory ที่ Run Configurations...
บังคับ pacman วิ่งไปมา
ในส่วนแรกเราจะทดลองเขียนโดยไม่แยกคลาสที่จัดการสถานะกับคลาสที่แสดงผล เพื่อให้เห็นภาพของ update loop ของเกมที่ชัดเจน จากนั้นเราจะค่อยแยกส่วนสถานะออกเป็นคลาสกลุ่ม World และส่วนแสดงผลออกเป็นคลาสกลุ่ม WorldRenderer ต่อไป
ปรับขนาดหน้าจอ, ลบรูป badlogic
เกมของเราจะเป็นเกมตาราง ช่องของตารางจะมีขนาด 40 x 40 จุด มีความกว้างของตาราง 20 ช่อง (กว้าง 800 จุด) เราจะปรับขนาดของหน้าจอเมื่อเราเริ่มสร้าง application โดยแก้ไขขนาดหน้าจอที่คลาส DesktopLauncher ในโปรเจ็คย่อย xxxx-desktop
public class DesktopLauncher {
public static void main (String[] arg) {
LwjglApplicationConfiguration config = new LwjglApplicationConfiguration();
config.width = 800;
config.height = 600;
new LwjglApplication(new PacmanGame(), config);
}
}
จากนั้นในโปรเจ็คย่อย xxxx-core ให้แก้ไขคลาส PacmanGame (เป็นคลาสหลักที่เรา generate มาจากขั้นตอนก่อน) โดยให้ลบส่วนที่เกี่ยวข้องกับ Texture img และแก้ส่วน render ให้เป็นดังด้านล่าง
public class PacmanGame extends ApplicationAdapter {
SpriteBatch batch;
@Override
public void create () {
batch = new SpriteBatch();
}
@Override
public void render () {
super.render();
}
@Override
public void dispose () {
batch.dispose();
}
}
ทดลองเรียกให้ทำงาน (เรียก run ที่ DesktopLauncher) สังเกตว่าหน้าจอเป็นสีดำและมีขนาดตามที่เราระบุ
เราต้องการจะใช้ sprite batch อันเดียวที่สร้างใน PacmanGame ดังนั้นเราจะแก้ให้ทุกคลาสสามารถเข้าถึงได้ โดยเปลี่ยนประเภท field เป็น public
public class PacmanGame extends ApplicationAdapter {
public SpriteBatch batch;
//...
}
แยกคลาส GameScreen
คลาส PacmanGame ในปัจจุบันนั้น extends ApplicationAdapter ซึ่งเป็นคลาสที่ flexible มากในการเขียนเกม อย่างไรก็ตาม ในกรณีที่เราต้องการหลาย ๆ หน้าจอ (screen) เราสามารถเริ่มต้นเขียนโดย inherit มาจากคลาส Game จะสะดวกกว่า
เราจะปรับคลาสที่เป็นคลาสแม่ของ PacmanGame เป็นดังนี้ อย่าลืมกดให้ Eclipse/NetBeans import คลาสต่าง ๆ มาให้ครบด้วย
public class PacmanGame extends Game {
จากนั้นสร้างคลาส GameScreen จากนั้นแก้ให้คลาส extends ScreenAdapter
import com.badlogic.gdx.ScreenAdapter;
public class GameScreen extends ScreenAdapter {
}
หมายเหตุ: คลาสที่เป็น Adapter เป็นคลาสที่ implement method ของ interface บางอย่างจนครบด้วย method ว่าง ๆ ทำให้เวลาเราจะเขียนคลาสที่ implement บาง interface เราสามารถเขียนได้โดยไม่ต้องคอยไล่ implement ทุกเมท็อดเอง
เราจะสร้าง GameScreen โดยให้มีการอ้างถึง PacmanGame ได้ด้วย (เพื่อที่จะอ้างถึง SprintBatch batch) เราจะเพิ่ม constructor และ field ดังนี้
public class GameScreen extends ScreenAdapter {
private PacmanGame pacmanGame;
public GameScreen(PacmanGame pacmanGame) {
this.pacmanGame = pacmanGame;
}
}
จากนั้นในเมท็อด create ใน PacmanGame ให้สั่ง setScreen ด้วย instance ของ GameScreen
public void create () {
batch = new SpriteBatch();
setScreen(new GameScreen(this));
}
ทดลองสั่งให้โปรแกรมทำงาน อย่างไรก็ตาม เราจะยังไม่เห็นอะไรเลย
ให้ทดลองเขียนเมท็อด render ในคลาส GameScreen ให้พิมพ์ข้อความและ delta แล้วทดลองสั่งให้ทำงาน เมื่อมีหน้าจอเกมสีดำขึ้นมา ถ้าไปกดดู console จะเห็นข้อความพร้อมเลขขึ้นมาเรื่อย ๆ ตามที่มีการเรียก render
@Override
public void render(float delta) {
System.out.println("Hello " + delta);
}
ถ้าได้ผลตามที่ระบุนี้ แสดงว่าเราปรับแก้ให้โปรแกรมมาเรียกใช้งาน GameScreen ได้แล้ว ในขั้นตอนไปเราจะแก้ GameScreen ให้แสดงตัว pacman วิ่งไปมาบนหน้าจอ
แสดง pacman
เราจะใช้ assets ที่เตรียมไว้ให้แล้ว ถ้าต้องการแบบอื่นสามารถเปลี่ยนรูปได้ตามสะดวก ดาวน์โหลดไฟล์รูปที่ http://theory.cpe.ku.ac.th/~jittat/courses/ooplab/pacman/ จากนั้นจัดเก็บในไดเร็กทอรี core/assets (ที่เดียวกับที่เก็บไฟล์ badlogic.jpg)
เราจะโหลดรูปโดยสร้างเป็น Texture และเก็บเป็น field ชื่อ pacmanImg โดยแก้คลาส GameScreen ดังนี้
public class GameScreen extends ScreenAdapter {
// ...
private Texture pacmanImg;
public GameScreen(PacmanGame pacmanGame) {
// ...
pacmanImg = new Texture("pacman.png");
}
}
เราจะวาดรูปลงบนหน้าจอผ่านทาง SpriteBatch (batch จากคลาส PacmanGame) แก้เมท็อด render ให้วาด pacmanImg ลงไปที่ตำแหน่ง 100,100 โดยโค้ดเป็นดังนี้
@Override
public void render(float delta) {
SpriteBatch batch = pacmanGame.batch;
batch.begin();
batch.draw(pacmanImg, 100, 100);
batch.end();
}
ทดลองสั่งให้ทำงาน
เราควรจะ commit งานเป็นระยะ ๆ อย่าลืมเพิ่ม assets พวก png ลงใน git repository ด้วย
pacman วิ่ง
ในขั้นต่อไปเราจะทำให้ pacman เคลื่อนที่โดยบังคับไม่ได้ก่อน เราจะเพิ่ม field x และ y เพื่อแทนตำแหน่งของ pacman
public class GameScreen extends ScreenAdapter {
// ...
private int x;
private int y;
public GameScreen(PacmanGame pacmanGame) {
// ...
x = 100;
y = 100;
}
}
จากนั้นในเมท็อด render เราจะปรับค่า x ขึ้น 5 หน่วย เพื่อให้ pacman วิ่งไปทางขวา
public void render(float delta) {
x += 5;
// ...
batch.draw(pacmanImg, x, y);
}
ทดลองรัน...
เราจะเห็น pacman วิ่งเป็นสาย เพราะว่าเราไม่ได้ลบหน้าจอ เราจะลบหน้าจอด้วยคำสั่งของ OpenGL โดยเพิ่มคำสั่งสองบรรทัดนี้ลงไปตอนต้นของ render
Gdx.gl.glClearColor(0, 0, 0, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
หลังจากทดลองรัน เราจะเห็น pacman วิ่งไปทางขวาแล้ว
ก่อนจะไปขั้นต่อไป เราจะแยกเมท็อด update ออกมาจาก render โดยเมท็อดนี้จะทำหน้าที่หลักในการแก้ไขสถานะต่าง ๆ ของเกม คำสั่งที่จะแยกออกมาจาก render คือ x += 5; นั่นเอง
ประกาศเมท็อด update ดังนี้ (หมายเหตุ: อย่าลืมลบ x += 5 ออกจาก render ด้วย ไม่เช่นนั้นขั้นตอนถัดไปจะพังได้)
private void update(float delta) {
x += 5;
}
และเรียก update จาก render (อย่าลืมลบ x+= 5 จาก render)
public void render(float delta) {
update(delta);
// ...
}
ทดลองรันอีกครั้งเพื่อทดสอบว่าโปรแกรมทำงานถูกต้องแล้ว (โปรแกรมควรทำงานเหมือนเดิมก่อนจะแยก update)
รับการกดปุ่ม
เราจะใช้เมท็อด Gdx.input.isKeyPressed ในการตรวจสอบการกดปุ่ม ในการตรวจสอบดังกล่าว เราจะต้องส่งรหัสหมายเลขปุ่ม ซึ่งถูกระบุเป็นค่าคงที่ไว้ในคลาส Keys
เมท็อด update ด้านล่างบังคับ pacman ให้ขยับซ้ายขวา
private void update(float delta) {
if(Gdx.input.isKeyPressed(Keys.LEFT)) {
x -= 10;
}
if(Gdx.input.isKeyPressed(Keys.RIGHT)) {
x += 10;
}
}
งานของคุณ: ให้แก้โค้ด update ให้สามารถบังคับ pacman ขยับขึ้นบนและลงล่างได้ด้วย
แยกส่วนสถานะกับการแสดงผล
เราจะแยกส่วนจัดการสถานะของเกมทั้งหมด ออกจากส่วนแสดงผล เพื่อให้สามารถทดสอบการทำงานของส่วนจัดการสถานะได้ง่าย (เช่น เขียน unit test เป็นต้น)
เราจะมีสองคลาสใหญ่ ๆ คือ
- World - จัดการสถานะทั้งหมด
- WorldRenderer - จัดการวาดรูปต่าง ๆ ของสถานะใน World
โดยถ้าพิจารณาจากโค้ดล่าสุดของเรา ส่วนที่เป็นเมท็อด update จะถูกย้ายไปอยู่ในคลาส World ส่วนที่เกี่ยวกับการวาดรูปในเมท็อด render จะถูกย้ายไปที่ WorldRenderer
เมื่อแก้ไขเสร็จเมท็อด render จะมีลักษณะดังนี้
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// อย่าเพิ่งเขียน เมท็อดตรงนี้ให้ดูผลลัพธ์ของโค้ดที่สั้นลง ใน GameScreen เฉย ๆ
public void render(float delta) {
world.update(delta);
Gdx.gl.glClearColor(0, 0, 0, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
worldRenderer.render();
}
เราจะทยอยแก้ไปทีละขั้น หลักการคร่าว ๆ คือเราจะทยอยสร้างคลาสมาเพื่อ "ครอบ" รายละเอียดแต่ละส่วน เมื่อทำเสร็จแต่ละส่วนเราจะเทสเพื่อรับประกันว่าโปรแกรมยังทำงานได้อยู่
คลาส Pacman
ขั้นแรกเราจะสร้างคลาส Pacman เพื่อจัดการการเคลื่อนที่ของ pacman ก่อน จากนั้นค่อยย้ายการอ้างอิงส่วนนี้ไปไว้ในคลาส World
ก่อนที่เราจะเขียน ให้กลับไปพิจารณาก่อนคร่าว ๆ ว่าในโค้ดเก่าของเรา ส่วนใดบ้างที่เกี่ยวข้องกับกระบวนการ กิจกรรม หรือคุณสมบัติของอะไรต่าง ๆ ที่เราจะย้ายมาใส่ในคลาส pacman
. . . . กรุณาย้อนกลับไปดูโค้ดเก่าก่อนอ่านต่อ . . .
กดปุ่ม "ขยาย" ด้านขวาเพื่อดูเฉลย
- ตำแหน่ง x, y ของ pacman
- การสั่งให้ pacman เปลี่ยนตำแหน่งในเมท็อด update (เช่นสั่ง x += 10 เป็นต้น)
- การอ้างตำแหน่งของ pacman ในการวาด texture
แนวคิดและโค้ดเหล่านี้จะต้องถูกย้ายไปปรากฏในคลาส pacman และจะต้องมีช่องทางให้โค้ดเก่าที่เหลือเรียกใช้ได้ เราจะค่อย ๆ ทยอยย้ายโค้ดเหล่านี้
ด้านล่างเป็นโค้ดเริ่มต้นของคลาส Pacman
public class Pacman {
private Vector2 position;
public Pacman(int x, int y) {
position = new Vector2(x,y);
}
public Vector2 getPosition() {
return position;
}
}
สังเกตว่าเราใช้ Vector2 ในการเก็บตำแหน่งด้วย (อย่าลืมกดให้ import คลาสดังกล่าวเอง) เราสร้างเมท็อด getPosition เพื่อเป็นช่องทางให้สามารถอ่านตำแหน่งของ pacman มาวาดรูปบนหน้าจอได้
อย่างไรก็ตามสังเกตว่า field position นี้เป็น private การกำหนดขอบเขตดังกล่าวรับประกันว่าจะไม่มีโค้ดส่วนอื่นเข้ามาแก้ไขค่า field นี้ได้โดยที่ pacman นั้นไม่ทราบ
เรายังไม่มีโค้ดในส่วนของการปรับตำแหน่ง เราจะเพิ่มโค้ดส่วนนี้ในขั้นตอนถัด ๆ ไป
นำคลาส Pacman ไปใช้ใน GameScreen
เราจะแก้ไข constructor ของ GameScreen ให้เป็นดังด้านล่าง ในโค้ดดังกล่าว เราจะสร้าง object คลาส Pacman เพื่อไปใช้แทน field x และ y ก่อน และลบการประกาศ field x และ y ออก
public class GameScreen extends ScreenAdapter {
// ...
// int x,y; ---- delete this line
private Pacman pacman;
public GameScreen(PacmanGame pacmanGame) {
this.pacmanGame = pacmanGame;
pacmanImg = new Texture("pacman.png");
pacman = new Pacman(100,100);
// x = 100; ---- delete these lines
// y = 100;
}
// ...
}
จากนั้น ในส่วนของการวาด texture เราจะปรับให้อ่านตำแหน่งทาง getPosition
batch.begin();
Vector2 pos = pacman.getPosition();
batch.draw(pacmanImg, pos.x, pos.y);
batch.end();
เชื่อมโยงการปรับตำแหน่ง
จากนั้นในส่วนตรวจสอบการกดปุ่ม เราจะสั่งให้ pacman ขยับ ถ้าเราพิจารณาเมท็อด update เราจะพบโค้ดดังด้านล่าง
private void update(float delta) {
if(Gdx.input.isKeyPressed(Keys.LEFT)) {
x -= 10;
}
// ... และอีก 3 ทิศทาง
}
เราจะต้องออกแบบ interface (หรือ api) ให้ update สามารถแจ้งให้ pacman ขยับได้ ซึ่งเราทำได้สองแบบหลัก ๆ ตามตารางด้านล่างนี้
ระบุทิศทางในชื่อเมท็อด | ระบุด้วยพารามิเตอร์ |
---|---|
สร้างเมท็อด
|
สร้างเมท็อด
|
เราจะเลือกใช้แบบทางด้านขวา เพื่อความสะดวก (ดังจะเห็นต่อไป) แต่เพื่อให้โค้ดอ่านงานเราจะสร้างค่าคงที่ระบุทิศทางไว้ด้วย
เราจะเริ่มจากการประกาศค่าคงที่เป็นค่าคงที่ระดับคลาส (static) ที่ระบุทิศทาง
public class Pacman {
// ...
public static final int DIRECTION_UP = 1;
public static final int DIRECTION_RIGHT = 2;
public static final int DIRECTION_DOWN = 3;
public static final int DIRECTION_LEFT = 4;
public static final int DIRECTION_STILL = 0;
}
เราสามารถเขียนเมท็อด move ได้ดังนี้
public void move(int dir) {
switch(dir) {
case DIRECTION_UP:
position.y -= 10;
break;
case DIRECTION_RIGHT:
position.x += 10;
break;
case DIRECTION_DOWN:
position.y += 10;
break;
case DIRECTION_LEFT:
position.x -= 10;
break;
}
}
จากนั้นเราต้องไปแก้เมท็อด update ของ GameScreen เป็นดังนี้
private void update(float delta) {
if(Gdx.input.isKeyPressed(Keys.UP)) {
pacman.move(Pacman.DIRECTION_UP);
}
// ... แก้ที่เหลือเอง
}
เมื่อสั่งให้โปรแกรมทำงาน เราจะสามารถขยับ pacman ได้
อาจจะเห็นว่าเวลา pacman กดขึ้นแล้วขยับลง เพราะว่าการคิดพิกัดพื้นฐานของ GDX จะคิดจุด (0,0) ที่มุมซ้ายล่าง ซึ่งออกจะดูขัด ๆ เราจะใช้พิกัดที่ (0,0) อยู่ที่มุมซ้ายบน ดังนั้นในส่วนของ WorldRenderer เราจะต้องไปจัดการส่วนนี้เอง
เราจะแก้โปรแกรมเพิ่มเติม เพื่อลบการใช้คำสั่ง switch ในเมท็อด move กล่าวคือ เราจะสร้างอาร์เรย์ DIR_DIFF เก็บรูปแบบในการปรับตำแหน่งเทียบกับทิศทาง และแก้ move ให้เป็นดังด้านล่าง
public class Pacman {
// ...
private static final int [][] DIR_OFFSETS = new int [][] {
{0,0},
{0,-1},
{1,0},
{0,1},
{-1,0}
};
// ...
public void move(int dir) {
position.x += 10 * DIR_OFFSETS[dir][0];
position.y += 10 * DIR_OFFSETS[dir][1];
}
}
โค้ดด้านบนสามารถทำงานได้เรียบร้อยแล้ว ให้ทดลองรันและถ้าใช้งานได้ อย่าลืม commit
อย่างไรก็ตาม สังเกตว่ามีการใช้ค่าคงที่ 10 ซ้ำกัน เราจะสร้างค่าคงที่ SPEED ขึ้นมาเพื่อลดความซ้ำซ้อนนี้ เพิ่มบรรทัด
public static final int SPEED = 10;
และแก้ให้ move ใช้ SPEED แทน 10
public void move(int dir) {
position.x += SPEED * DIR_OFFSETS[dir][0];
position.y += SPEED * DIR_OFFSETS[dir][1];
}
โค้ดยังมีปัญหาหรือยังสามารถแก้ไขให้ดีขึ้นได้อีก เช่น ถ้าผู้ใช้เรียก move โดยส่งค่าผิด เช่น ส่ง dir เป็น 10 หรือ -1 โปรแกรมจะ crash เราสามารถแก้ปัญหานี้โดยตรวจสอบค่า dir ก่อนทำงาน หรืออาจจะสร้าง type ใหม่ (เป็น enum) เพื่อแทนทิศทางโดยเฉพาะก็ได้ เราจะแก้ส่วนนี้ในตอนท้าย (ถ้ามีโอกาส)
คลาส World
สร้างคลาส World คลาสดังกล่าวจะเก็บสถานะของเกมและรวมโค้ดเกี่ยวกับการจัดการเชิงตรรกะทั้งหมดของเกม เราจะให้คลาสนี้มี pacman และเพื่อความสะดวกในการจัดการต่อ ๆ ไปเราจะให้คลาสนี้อ้างอิงถึง pacmangame ได้ด้วย เพราะว่าในคลาสดังกล่าว เราอาจจะเก็บการตั้งค่าต่าง ๆ ไว้ เช่น ขนาดจอหรืออาจจะระบุความเร็วการวิ่งของ pacman และอื่น ๆ
public class World {
private Pacman pacman;
private PacmanGame pacmanGame;
World(PacmanGame pacmanGame) {
this.pacmanGame = pacmanGame;
pacman = new Pacman(100,100);
}
Pacman getPacman() {
return pacman;
}
}
จากนั้นให้ลบ field pacman ออกจาก GameScreen และสร้าง world ใน constructor แทน
public class GameScreen extends ScreenAdapter {
// ...
World world;
public GameScreen(PacmanGame pacmanGame) {
// ...
// pacman = new Pacman(100,100); -------- delete this line
world = new World(pacmanGame);
}
// ...
}
จากนั้นให้แก้ส่วนอื่นของ GameScreen ให้ทำงานได้ถูกต้อง
คลาส WorldRenderer
สร้างคลาส WorldRenderer โดยระบุ constructor ที่รับ pacman game และ world
public WorldRenderer(PacmanGame pacmanGame, World world) {
this.pacmanGame = pacmanGame;
batch = pacmanGame.batch;
this.world = world;
pacmanImg = new Texture("pacman.png");
}
อย่าลืมสร้าง field ต่าง ๆ ให้ครบ สังเกตว่าเราย้าย pacmanImg มาที่คลาสนี้ด้วย และเราเก็บ sprite batch ไว้ที field batch เพื่อให้สะดวกในการเรียกใช้
สร้างเมท็อด render ดังด้านล่าง
public render(float delta) {
// ...
}
จากนั้นย้ายส่วนต่าง ๆ ของเมท็อด render ใน GameScreen ไปอยู่ในเมท็อด render ข้างต้น และแก้เมท็อด render ใน GameScreen ให้เป็นดังด้านล่าง (สังเกตว่าเราเก็บส่วน clear screen เอาไว้ใน GameScreen หมายเหตุ: อย่าลืมสร้างวัตถุ worldRenderer ใน GameScreen ด้วย
// ==== ระวัง นี่เป็น render ในคลาส GameScreen
public void render(float delta) {
update(delta);
Gdx.gl.glClearColor(0, 0, 0, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
worldRenderer.render(delta);
}
ถ้าเจอ NullPointerException: อย่าลืมว่าเราสร้าง worldRenderer แล้วหรือยัง
โครงสร้างคลาส (ปลายทาง)
ด้านล่างเป็นโครงสร้างของคลาสต่าง ๆ ที่เราพยายามทำ กล่าวคือเราพยายามแยกคลาสที่ทำงานด้านสถานะของเกม เช่น แพคแมนอยู่ตรงไหน แผนที่เป็นอย่างไร จุดอยู่ตรงไหน (ในวงกลมสีฟ้า) ออกจากส่วนที่แสดงผลและรับข้อมูลจากผู้ใช้ ทั้งนี้เนื่องจากเราต้องการให้สามารถแยกส่วนดังกล่าวไปเทสได้โดยไม่ต้องติดกับส่วนอื่น ๆ
นอกจากประโยชน์ในการเทสแล้ว ลองนึกถึงกรณีที่เราต้องการเปลี่ยน framework เกม (ที่อาจจะมีวิธีอ่านอินพุตและแสดงผลที่แตกต่างจาก GDX) ส่วนที่เอาไปใช้ได้ (หรือส่วนที่แทบไม่ต้องมีการเปลี่ยนแปลง) ก็คือส่วนสีฟ้านั่นเอง
แสดง Maze
เราจะแบ่งส่วนที่เกี่ยวข้องกับแผนที่ออกเป็นสองคลาสคือ Maze (เป็นคลาสจัดการด้านตรรกะและสถานะ) และ MazeRenderer ใช้วาดรูป
Maze
เราจะสร้างตลาส Maze เพื่อเก็บแผนที่ และเก็บสถานะของจุดที่ pacman กิน ในขั้นแรก เราจะพยายามสร้างคลาสส่วนที่เป็นข้อมูลแผนที่ก่อน และจะยังไม่สนในการกินของ pacman เราจะเก็บแผนที่ลงไปตรง ๆ เป็นค่าคงที่ก่อนในขั้นนี้ อีกหน่อยถ้าเรามีหลายฉาก เราอาจจะเขียนให้โปรแกรมโหลดแผนที่จากไฟล์ก็ได้
public class Maze {
private String[] MAP = new String [] {
"####################",
"#..................#",
"#.###.###..###.###.#",
"#.#...#......#...#.#",
"#.#.###.####.###.#.#",
"#.#.#..........#.#.#",
"#.....###..###.....#",
"#.#.#..........#.#.#",
"#.#.###.####.###.#.#",
"#.#...#......#...#.#",
"#.###.###..###.###.#",
"#..................#",
"####################"
};
// ...
}
เราจะเก็บขนาดของแผนที่ไว้ใน field ชื่อ height และ width ตามลำดับ โดยดูจากขนาดของอาร์เรย์ และความยาวของสตริงในบรรทัดแรก นอกจากนี้เราสร้างเมท็อด getHeight และ getWidth ไว้เพื่ออ่านขนาดของ maze ด้วย
public class Maze {
// ...
private int height;
private int width;
public Maze() {
height = MAP.length;
width = MAP[0].length();
}
public int getHeight() {
return height;
}
public int getWidth() {
return width;
}
// ...
}
ในการอ้างถึงข้อมูลของ maze เราจะสร้างเมท็อด hasWallAt และ hasDotAt ซึ่งรับตำแหน่งในแผนที่เป็น r (row) และ c (column) เมท็อด hasDotAt ตอนนี้คืนค่าจากแผนที่โดยตรง ในการแก้ถัด ๆ ไป เราจะต้องเก็บข้อมูลด้วยว่าจุดใดถูกกินไปแล้วหรือยัง เราจะกลับมาแก้เมท็อดนี้อีกครั้ง
public boolean hasWallAt(int r, int c) {
return MAP[r].charAt(c) == '#';
}
public boolean hasDotAt(int r, int c) {
return MAP[r].charAt(c) == '.';
}
เราจะสร้าง maze ไว้ใน world โดยเพิ่มโค้ดเหล่านี้ลงใน World.java สังเกตว่าเราสร้างเมท็อด getMaze ไว้เพื่อให้ WorldRenderer สามารถเข้าถึงแผนที่ได้
public class World {
// ...
private Maze maze;
World(PacmanGame pacmanGame) {
// ...
maze = new Maze();
}
// ...
Maze getMaze() {
return maze;
}
}
MazeRenderer
เราจะสร้างคลาส MazeRenderer เพื่อวาดรูปแผนที่ เราจะสร้างคลาสและเชื่อมวัตถุต่าง ๆ เข้าด้วยกัน ก่อนจะเขียนส่วนแสดงผลจริง ๆ
วัตถุของคลาส MazeRenderer จะสร้างโดยทราบ sprite batch และ maze และจะมีเมท็อด render ให้เรียกใช้
public class MazeRenderer {
private Maze maze;
private SpriteBatch batch;
public MazeRenderer(SpriteBatch batch, Maze maze) {
this.maze = maze;
this.batch = batch;
}
public void render() {
}
}
เราจะสร้าง mazeRenderer ใน constructor ของ WorldRenderer และเรียกเมท็อด mazeRenderer.render ใน render ของ WorldRenderer สังเกตว่าเราจะต้องเรียก mazeRenderer.render ก่อนวาดรูป pacman เพื่อให้รูป pacman ไม่โดนวาดทับ
public class WorldRenderer {
// ...
private MazeRenderer mazeRenderer;
public WorldRenderer(PacmanGame pacmanGame, World world) {
// ...
mazeRenderer = new MazeRenderer(pacmanGame.batch, world.getMaze());
}
public void render(float delta) {
mazeRenderer.render();
// ...
}
}
เราจะต้องโหลด sprite เพิ่มเติมจาก http://theory.cpe.ku.ac.th/~jittat/courses/ooplab/pacman/ โดยโหลด dot.png และ wall.png และเก็บลงที่ core/assets (ที่เดียวกับที่เก็บ pacman.png) รูปทั้งสองมีขนาด 40 x 40
เราจะโหลด sprite ทั้งสองรูปเป็น field ประเภท Texture ใน MazeRenderer ดังโค้ดด้านล่าง
public class MazeRenderer {
// ...
private Texture wallImage;
private Texture dotImage;
public MazeRenderer(SpriteBatch batch, Maze maze) {
// ...
wallImage = new Texture("wall.png");
dotImage = new Texture("dot.png");
}
// ...
}
เราจะเขียนเมท็อด render โดยคำนวณตำแหน่งที่จะวาดรูป maze และใช้เมท็อด draw วาด Texture ลงไปให้เป็นแผนที่
ก่อนทีเราจะเขียนต่อไป เราจะเลือกใช้วิธีการอ้างตำแหน่ง x, y ให้สอดคล้องกับการอ้างอิง row, column ในตาราง นั่นคือจุด (0,0) อยู่มุมบนซ้าย และแกน y มีทิศทางชี้ลง อย่างไรก็ตาม ในการวาดรูปใน GDX นั้น โดยปกติจะคิดจุด (0,0) ที่มุมล่างซ้าย และแกน y มีทิศทางชี้ขึ้น ดังนั้นในการพิจารณาตำแหน่ง เราต้องมีการคำนวณเพิ่มเติมเล็กน้อย (อย่างไรก็ตาม เราสามารถปรับการคิดพิกัดของ GDX ให้สอดคล้องกันได้โดยใช้ camera แต่ในที่นี้เราจะเขียนตรง ๆ แทน)
โค้ดด้านล่างเป็นโค้ดของเมท็อด render ให้สังเกตวิธีการคำนวณตำแหน่งที่จะวาด sprite ที่ตัวแปร x และ y
public void render() {
batch.begin();
for(int r = 0; r < maze.getHeight(); r++) {
for(int c = 0; c < maze.getWidth(); c++) {
int x = c * 40;
int y = 600 - (r * 40) - 40;
if(maze.hasWallAt(r, c)) {
batch.draw(wallImage, x, y);
} else if(maze.hasDotAt(r, c)) {
batch.draw(dotImage, x, y);
}
}
}
batch.end();
}
จัดการกับค่าคงที่ 600 และ 800 (ขนาดจอ)
โค้ดด้านบนมีการใช้ค่าคงที่ 600 แทนความสูงอยู่ ค่า 600 นี้ยังปรากฏในคลาส DesktopLauncher ค่านี้เป็นสิ่งที่เราต้องแก้ ถ้าเราจะปรับขนาดหน้าจอ เราจะสร้างค่าคงที่ในคลาส PacmanGame เพื่อเก็บค่านี้ ทำให้การแก้ไขในอนาคตทำได้สะดวกขึ้น (ไม่ต้องไปตามหา ตามแก้ในหลาย ๆ ที่)
เรากำหนดค่าคงที่ใน PacmanGame
public class PacmanGame extends Game {
public static final int HEIGHT = 600;
public static final int WIDTH = 800;
// ...
}
จากนั้นแก้ค่า 600 ให้เป็น PacmanGame.HEIGHT ใน DesktopLauncher
public class DesktopLauncher {
public static void main (String[] arg) {
LwjglApplicationConfiguration config = new LwjglApplicationConfiguration();
config.width = PacmanGame.WIDTH;
config.height = PacmanGame.HEIGHT;
new LwjglApplication(new PacmanGame(), config);
}
}
และใน MazeRenderer
// ...
int y = PacmanGame.HEIGHT - (r * 40) - 40;
// ...
การเคลื่อนที่ของ pacman บน maze (วิ่งทะลุกำแพง)
เรามาถึงส่วนที่น่าสนใจที่สุดของเกม คือการบังคับให้ pacman เคลื่อนที่ไปมาบนเกม
สังเกตว่าโค้ดเดิมที่เราเคยเขียนนั้น pacman จะวิ่งไปมาบนหน้าจอแบบต่อเนื่อง ทำให้สามารถวิ่งไปทับกำแพงและขอบอื่น ๆ ได้ เราจะปรับให้ pacman วิ่งได้ถูกต้อง โดยจะแก้เป็นขั้น ๆ ไป
ก่อนอื่นเราต้องจัดการปรับการแสดงผล pacman ในอยู่ในกรอบระบบพิกัดเดียวกับ maze เสียก่อน
ปรับการแสดง pacman
ถ้ายังจำได้ pacman ของเราจะวิ่งบน-ล่างสลับกัน เพราะว่าการเพิ่มค่าในพิกัดแกน y แทนที่จะทำให้ pacman วิ่งลง กลับทำให้ pacman วิ่งขึ้น เราจะปรับโค้ดการแสดงผลให้จัดการเรื่องระบบพิกัดตรงนี้ก่อนที่จะเขียนส่วนอื่น ๆ
การอ้างตำแหน่ง สังเกตว่าในการใช้คำสั่ง draw นั้นเราจะระบุพิกัดที่มุมล่างซ้ายของ Texture แต่สำหรับกรณีของ pacman เพื่อความสะดวกในการคำนวณอื่นๆ เราจะอ้างตำแหน่งของ pacman ที่จุดตรงกลางของ pacman แทน (ดูรูปประกอบ)
TODO: รูปประกอบ
เราจะแก้โค้ดส่วนแสดง pacman เป็นดังด้านล่าง
public void render(float delta) {
// ...
SpriteBatch batch = pacmanGame.batch;
Vector2 pos = world.getPacman().getPosition();
batch.begin();
batch.draw(pacmanImg, pos.x - 20, PacmanGame.HEIGHT - pos.y - 20);
batch.end();
}
และปรับการกำหนดตำแหน่งเริ่มต้นของ pacman ให้สร้างที่จุด 60,60 แทน โดยแก้ที่ constructor ของ World
World(PacmanGame pacmanGame) {
pacman = new Pacman(60,60);
// ...
}
ปรับการอ้างอิงค่า 40/20 (ค่าขนาด sprite / block แสดงผล) ถ้าสังเกตให้ดี ๆ เราจะพบว่าเรามีการใช้ค่าคงที่ 40 และ 20 ในความหมายที่เกี่ยวกับขนาดของ texture เราจะสร้างค่าคงที่ BLOCK_SIZE เพื่อเก็บค่านี้
public class WorldRenderer {
public static final int BLOCK_SIZE = 40;
// ...
}
จากนั้นไปแก้ในเมท็อด render
batch.draw(pacmanImg, pos.x - BLOCK_SIZE/2,
PacmanGame.HEIGHT - pos.y - BLOCK_SIZE/2);
และในเมท็อด render ใน MazeRenderer
int x = c * WorldRenderer.BLOCK_SIZE;
int y = PacmanGame.HEIGHT -
(r * WorldRenderer.BLOCK_SIZE) - WorldRenderer.BLOCK_SIZE;
ควบคุมทิศทาง
ถ้าเรายอมให้ pacman เปลี่ยนทิศได้ทันทีที่มีการกดปุ่มลูกศร pacman ก็อาจจะเลี้ยวไปทับขอบของช่องแผนที่ก็ได้ ดังนั้นเราจะดำเนินการดังนี้ (ดูรูปประกอบ)
แทนที่เราจะให้ pacman เปลี่ยนทิศทันทีหรือหยุดทันทีที่เรายกมือออกจากปุ่ม keyboard เราจะให้ pacman วิ่งไปเรื่อย ๆ จะหยุดหรือเปลี่ยนทิศทางก็ต่อเมื่ออยู่ที่ตรงกลางช่อง ในการดำเนินการดังกล่าว เราจะให้ pacman เก็บสถานะทิศทางปัจจุบัน (currentDirection) และทิศทางที่จะเปลี่ยนไปในอนาคต (nextDirection)
ก่อนที่เราจะเริ่มแก้ ให้ลองกลับไปดูโค้ดก่อนว่าเราต้องแก้อะไรตรงไหนบ้าง
. . . . คิดก่อนไปต่อ... ดูเฉลยได้ด้านล่าง . . .
กดปุ่ม "ขยาย" ด้านขวาเพื่อดูเฉลย
งานที่ต้องทำ เช่น
- สร้าง field currentDirection และ nextDirection ในคลาส Pacman
- แก้ให้ GameScreen เลิกเรียก move แต่สั่งกำหนด nextDirection แทน
- ทำให้ pacman ยังวิ่งต่อแม้จะเลิกกดปุ่มแล้ว จนกระทั่งไปหยุดที่กลางช่อง ในการดำเนินการดังกล่าว เราต้องเลือกว่าจะให้คลาสใดสั่งให้ pacman update ตำแหน่งของตนเอง
- ตรวจสอบการหยุดและเปลี่ยนทิศทางเมื่อไปถึงกลางช่อง
- เขียนเมท็อดเพื่อตรวจสอบว่า pacman อยู่กลางช่องหรือยัง
public class Pacman {
// ...
private int currentDirection;
private int nextDirection;
// ...
public Pacman(int x, int y) {
// ...
currentDirection = DIRECTION_STILL;
nextDirection = DIRECTION_STILL;
}
}
เราจะให้โลกภายนอกกำหนดทิศทางของ pacman ในอนาคตได้ (next direction) ผ่านทางเมท็อด
public void setNextDirection(int dir) {
nextDirection = dir;
}
เราจะต้องแก้การเรียกเมท็อด Pacman.move จาก GameScreen โดยเปลี่ยนไปเรียก setNextDirection แทน
// --- คสาส GameScreen ----
private void update(float delta) {
Pacman pacman = world.getPacman();
if(Gdx.input.isKeyPressed(Keys.UP)) {
pacman.setNextDirection(Pacman.DIRECTION_UP);
}
// แก้ที่เหลือเอง
}
หลังจากที่เรากำหนดให้ pacman มีทิศทางต่าง ๆ ในอนาคตแล้ว เรายังจะต้องสั่งให้ pacman ปรับตำแหน่งตนเอง เราสามารถเขียนในเมท็อด update ของ GameScreen ได้เลย แต่เราจะย้ายการ update ทั้งหมดที่ไม่เกี่ยวกับการติดต่อกับ user interface ไปไว้ใน world ดังนั้น เราจะเขียน method เพิมดังนี้
เพิ่มเมท็อด update ใน Pacman
public void update() {
if(isAtCenter()) {
currentDirection = nextDirection;
}
position.x += SPEED * DIR_OFFSETS[currentDirection][0];
position.y += SPEED * DIR_OFFSETS[currentDirection][1];
}
เมท็อดด้านบนมีการตรวจสอบว่า pacman อยู่กลางช่องหรือไม่ ด้านล่างเป็นโค้ดของ isAtCenter
public boolean isAtCenter() {
int blockSize = WorldRenderer.BLOCK_SIZE;
return ((((int)position.x - blockSize/2) % blockSize) == 0) &&
((((int)position.y - blockSize/2) % blockSize) == 0);
}
สังเกตว่าเราแปลง position.x และ position.y ให้เป็น integer ก่อนจะทำการคำนวณ
เพิ่มเมท็อด update ใน World ให้เรียก pacman.update
// --- คสาส World ----
public void update(float delta) {
pacman.update();
}
ปรับเมท็อด update ใน GameScreen ให้เรียก world.update
// --- คสาส GameScreen ----
private void update(float delta) {
// ...
world.update(delta);
}
ทดลองรัน
เราจะพบปัญหาสองอย่างคือ 1) เมื่อเรายกปุ่มออก pacman ยังวิ่งอยู่ และ 2) pacman วิ่งเร็วไป
ปัญหาที่ 2 สามารถแก้ได้โดยปรับค่าคงที่ SPEED ในคลาส Pacman ให้ลดลง แต่ต้องให้ค่านั้นหาร 40 ลงตัว (เพื่อที่ pacman จะได้หยุดตรงกลางช่องพอดี)
public static final int SPEED = 5;
งานของคุณ
งานของคุณคือแก้ปัญหาที่ 1: ให้แก้ไขเมท็อด update ใน GameScreen เพื่อที่จะรับประกันว่า pacman หยุดเมื่อผู้ใช้ยกนิ้วออกจากปุ่มแล้ว
กดปุ่ม "ขยาย" ด้านขวาเพื่อดูคำใบ้
เรามีทิศทาง DIRECTION_STILL ซึ่งถ้ากำหนดให้กับ pacman แล้ว จะหยุดนิ่ง คุณต้องหาวิธีการส่งทิศทางดังกล่าวให้กับ pacman
Clean up เมท็อด update ใน GameScreen
เมท็อด update ใน GameScreen เริ่มจะยาวและอ่านยาก เราจะแยกโค้ดส่วนนั้นออกไปยังเมท็อด updatePacmanDirection ทำให้เมท็อด update สั้นและอ่านเข้าใจได้ในการดูผ่าน ๆ
private void update(float delta) {
updatePacmanDirection();
world.update(delta);
}
private void updatePacmanDirection() {
//... ย้ายโค้ดเกี่ยวกับการปรับทิศทางของ pacman มาที่นี่
}
วิ่งไม่ทะลุกำแพง
เราจะตรวจสอบว่าทิศทางที่เราจะวิ่งไปนั้นชนกำแพงหรือไม่ หลัก ๆ คือเราจะเขียนเมท็อด canMoveInDirection เพื่อตรวจสอบ
private boolean canMoveInDirection(int dir) {
return true; // ยอมหมดไปก่อน เดี๋ยวเราจะทยอยเขียน
}
ถ้าเรามีเมท็อด canMoveInDirection แล้ว การป้องกันไม่ให้วิ่งชนกำแพงก็ทำได้ไม่ยากเลย
public void update() {
if(isAtCenter()) {
if(canMoveInDirection(nextDirection)) {
currentDirection = nextDirection;
} else {
currentDirection = DIRECTION_STILL;
}
}
position.x += SPEED * DIR_OFFSETS[currentDirection][0];
position.y += SPEED * DIR_OFFSETS[currentDirection][1];
}
ตำแหน่งในตาราง
ก่อนอื่นเราต้องทราบตำแหน่งปัจจุบันก่อน
private int getRow() {
return ((int)position.y) / WorldRenderer.BLOCK_SIZE;
}
private int getColumn() {
return ((int)position.x) / WorldRenderer.BLOCK_SIZE;
}
จากตำแหน่งปัจจุบันและทิศทางใหม่ เราจะหาตำแหน่งบนตารางใหม่
private boolean canMoveInDirection(int dir) {
int newRow = ________________________________; // คำนวณเอง hint: DIR_OFFSETS
int newCol = ________________________________; //
return true;
}
ตรวจสอบกับ maze
เมื่อเราทราบตำแหน่งบนตารางใหม่ที่เราจะเดินไปแล้ว เราจะตรวจสอบกับ maze อย่าลืมว่าเรามีเมท็อด hasWallAt ใน maze อยู่แล้ว สิ่งที่เราต้องทำคือหาทางให้ pacman อ้างถึง maze ได้
เราจะแก้ constructor ของคลาส Pacman ให้รับ maze ด้วย
public Pacman(int x, int y, Maze maze) {
// ...
this.maze = maze;
}
จากนั้นแก้ constructor ของคลาส World ที่สร้าง pacman
World(PacmanGame pacmanGame) {
maze = new Maze();
pacman = new Pacman(60,60,maze);
this.pacmanGame = pacmanGame;
}
งานของคุณ เมื่อ pacman อ้างถึง maze ได้แล้ว คุณก็สามารถเขียนเมท็อด canMoveInDirection ได้แล้ว
private boolean canMoveInDirection(int dir) {
int newRow = ________________________________; // คำนวณเอง hint: DIR_OFFSETS ระวังสลับ row/column
int newCol = ________________________________; //
// ...... ใส่โค้ดตรวจสอบตำแหน่งว่าเป็นกำแพงหรือไม่ที่นี่
}
หมายเหตุ ในกรณีทั่วไป เป็นไปได้ที่ newRow และ newCol จะออกนอกขอบเขตตาราง เราควรจะเขียนตรวจสอบไว้ด้วย โดยอาจจะเขียนที่ canMoveInDirection หรือจะแก้ให้ isWallAt ทำงานได้ในกรณีดังกล่าวก็ได้ แต่สำหรับเกมนี้ เนื่องจากแผนที่ที่เราทำมีขอบกำแพงทั้งหมด กรณีดังกล่าวจะไม่เกิดขึ้น
กินจุด
เมท็อด getRow กับ getColumn ใน Pacman นั้นยังถูกนำมาใช้ในการจัดการเรื่องการกินจุดด้วย แต่ก่อนที่จะเริ่มกินจุด เราจะต้องมาจัดการเกี่ยวข้อมูลของจุดบน maze ก่อน
ข้อมูลจุดใน maze
เราจะสร้างอาร์เรย์สองมิติเพื่อเก็บว่าแต่ละช่องมีจุดอยู่หรือไม่ เราสามารถใส่โค้ดในการกำหนดค่าเริ่มต้นให้กับอาร์เรย์ดังกล่าวลงใน constructor Maze ได้เลย แต่จะทำให้เมท็อดนั้นยาว เราเลือกที่จะเขียนเมท็อดใหม่และเรียกใช้ใน Maze แทน
public class Maze {
// ...
private boolean [][] hasDots;
public Maze() {
// ...
initDotData();
}
private void initDotData() {
hasDots = new boolean[height][width];
for(int r = 0; r < height; r++) {
for(int c = 0; c < width; c++) {
hasDots[r][c] = MAP[r].charAt(c) == '.';
}
}
}
// ...
}
จากนั้นเราจะแก้เมท็อด hasDotAt ให้ใช้งานข้อมูลดังกล่าว
public boolean hasDotAt(int r, int c) {
return hasDots[r][c];
}
ปรับสถานะ
เราจะเขียนเมท็อด removeDotAt เพื่อแจ้งกับ maze ว่าได้มีการกินจุดที่ตำแหน่งนั้นไปแล้ว
public void removeDotAt(int r, int c) {
hasDots[r][c] = false;
}
จากนั้นให้แก้ไขเมท็อด update ในคลาส Pacman ให้เมื่อเดินถึงตรงกลางช่อง ให้ตรวจสอบว่ามีจุดหรือเปล่า ถ้ามีก็ให้ลบออก
// ---- คลาส Pacman -----
public void update() {
if(isAtCenter()) {
// ...
// เพิ่มโค้ดตรวจสอบและกินที่นี่
}
// ...
}
สังเกตว่าถ้าเราตรวจสอบและสั่ง removeDotAt ถูกต้อง เมื่อทดลองเล่นเกม จุดจะหายไปจากหน้าจอโดยอัตโนมัติ เพราะว่าเป็นหน้าที่ของ MazeRenderer ที่จะต้องวาดหน้าจอให้ถูกต้อง
แสดงคะแนน
เราจะให้คะแนนเป็นส่วนหนึ่งของ World และให้ WorldRenderer แสดงคะแนนทางหน้าจอ
ขั้นแรกเราจะเพิ่ม field score ลงในคลาส World และเพิ่มเมท็อด getScore เพื่ออ่านคะแนน
public class World {
// ...
private int score;
World(PacmanGame pacmanGame) {
// ...
score = 0;
}
// ...
public int getScore() {
return score;
}
}
จากนั้นเราแก้โค้ดให้ WorldRenderer แสดงคะแนนที่อ่านจาก world ลงบนหน้าจอก่อน เราจะใช้ BitmatFont ที่ติดมากับ libGDX
- ในอนาคตถ้าต้องการใช้ฟอนต์ที่หลากหลายขึ้น สามารถอ่านรายละเอียดและวิธีการแปลงฟอนต์ในระบบให้เป็น BitmapFont ได้ที่ บทความ Bitmap fonts ใน libGDX wiki
ก่อนอื่นเราสร้าง field font ใน WorldRenderer ก่อน
public class WorldRenderer {
// ...
private BitmapFont font;
public WorldRenderer(PacmanGame pacmanGame, World world) {
// ...
font = new BitmapFont();
}
// ...
}
จากนั้นเราจะแสดงเลขคะแนนที่มุมขวาล่าง โดยเพิ่มโค้ดที่สั่ง font.draw ลงในเมท็อด render เรานำค่าคะแนนที่อ่านมาได้ บวกกับ "" (สตริงว่าง) เพื่อสร้างสตริงของคะแนนนั้นก่อนจะส่งให้เมท็อด draw ต่อไป
อย่าลืมว่าต้องเพิ่มคำสั่งให้อยู่ในกรอบ batch.begin() และ batch.end()
public void render(float delta) {
// ...
batch.begin();
// ...
font.draw(batch, "" + world.getScore(), 700, 60);
batch.end();
}
pacman แจ้งกับ world: การสร้างวัตถุและการขึ้นต่อกัน
วิธีที่ง่ายที่สุดในการปรับคะแนนคือให้ pacman เรียกเมท็อดของ world เพื่อปรับคะแนน เราสามารถเพิ่มเมท็อด increaseScore ในคลาส World
// --- World.java
public void increaseScore() {
score += 1;
}
จากนั้นเมื่อ pacman กินจุด ให้เรียกเมท็อดนี้ นั่นคือเพิ่มคำสั่งนี้ลงใน เมท็อด update ของ pacman
// --- Pacman.java
public void update() {
if(isAtCenter()) {
// ...
if(.............) {
// ... ขอละโค้ดสำหรับตรวจสอบการกิน และสั่งกิน ...
world.increaseScore();
}
// ...
}
}
อย่างไรก็ตาม ในขั้นตอนที่เราสร้าง pacman นั้น เราไม่ได้ส่ง world ให้กับ pacman ทำให้วัตถุคลาส pacman ไม่สามารถอ้างถึง world ได้
เราสามารถเพิ่ม constructor ของ Pacman ให้รับ world ด้วยได้ โดยประกาศเป็น
// !!!!!!!!!!!! อย่าเพิ่งรีบทำตาม อันนี้เป็นตัวอย่างเฉย ๆ !!!!!!!!!!!
public Pacman(int x, int y, Maze maze, World world) { // เพิ่ม world
// ...
}
แต่เราอาจจะสังเกตว่า จริง ๆ แล้วเราสามารถอ่าน maze มาจาก world ได้ โดยเราอาจจะแก้ constructor เป็นดังด้านล่าง
// !!!!!!!!!!!! อย่าเพิ่งรีบทำตาม อันนี้เป็นตัวอย่างเฉย ๆ !!!!!!!!!!!
public Pacman(int x, int y, World world) { // เพิ่ม world
// ...
maze = world.getMaze();
}
และโค้ดเราก็อาจจะทำงานได้โดยไม่มีปัญหา อย่างไรก็ตาม การเขียนดังกล่าวอาจสร้างปัญหาให้เราได้ในอนาคต กล่าวคือ โค้ดดังกล่าวจะทำงานได้ ก็ต่อเมื่อตอนที่เราสร้าง pacman นั้น เราสร้าง maze ขึ้นมาใน world ก่อนแล้ว ถ้าไม่เช่นนั้น world.getMaze อาจจะคืนค่า null ได้
หมายเหตุ: การสร้างวัตถุในระบบของโปรแกรมที่เขียนแบบ object-oriented นั้นเป็นอีกสิ่งหนึ่งที่เราต้องใส่ใจ ในระบบขนาดใหญ่ ขั้นตอนการสร้างวัตถุนี้อาจจะต้องมีเฟรมเวิร์คช่วยจัดการ (นิยมเรียกว่า dependency injection framework)
ดังนั้นเราแก้ปัญหาโดยไม่เก็บ maze ไว้เป็น field ตั้งแต่ตอนสร้างวัตถุ แต่จะปรับ field และ constructor ใน pacman ให้เป็นดังนี้
public class Pacman {
// ...
// ----- private Maze maze; ---- ลบ Maze maze ออก
private World world;
// ...
public Pacman(int x, int y, World world) { // เพิ่ม world
// ...
this.world = world;
}
// ...
}
จากนั้นทุก ๆ ที่ที่มีการเรียนใช้ maze เราก็จะเรียกหาจาก world.getMaze() แทน เช่น ในเมท็อด update
public void update() {
Maze maze = world.getMaze();
// ...
}
หรือเมท็อด canMoveInDirection เป็นต้น
public boolean canMoveInDirection(intdir) {
Maze maze = world.getMaze();
// ...
}
อย่าลืมแก้โค้ดที่สร้าง pacman ใน World ด้วย
// --- World.java
World(PacmanGame pacmanGame) {
// ..
pacman = new Pacman(60,60,this);
}
Observer pattern
แม้ว่าวิธีแจ้งการกินโดยให้ pacman เรียกเมท็อด increaseScore ใน world จะสะดวกแล้วทำได้ง่าย แต่เป็นการสร้าง dependency ระหว่างคลาส Pacman และ World แบบเข้มข้นมาก (นั่นคือเราแทบจะทดสอบคลาส Pacman โดยไม่รวม World ด้วยไม่ได้เลย
นอกจากนี้ ถ้าเราต้องการแจ้งการกินไปยังหลาย ๆ วัตถุก็ทำได้ยากอีกเช่นกัน
ในส่วนนี้ เราจะทำตาม observer pattern ในการแจ้งข้อมูลนี้ หลักการคร่าว ๆ คือเราจะเปิดให้วัตถุใดก็ได้ที่สนใจเหตุการณ์ "กิน" มาลงทะเบียนเอาไว้ เมื่อเรากิน เราก็จะไป "แจ้ง" วัตถุเหล่านั้น (โดยเรียกเมท็อดตามที่ระบุ)
แก้คลาส Pacman
ขั้นแรกเราจะแก้คลาส Pacman ให้เปิดให้มีการลงทะเบียนและแจ้งข่าวดังกล่าวเมื่อมีการกิน
เราจะสร้าง interface เพื่อรับการแจ้งข่าวนี้ไว้ในคลาส Pacman โดยให้ชื่อว่า DotEattenListener
public class Pacman {
// ...
public interface DotEattenListener {
void notifyDotEatten();
}
// ...
}
จากนั้นเราจะเก็บ list ของ listeners เอาไว้ด้วย ประกาศลิสต์ดังกล่าวในคลาส Pacman เราต้อง import List โดยในการ import อัตโนมัติให้เลือก java.util.List จากนั้นเราจะสร้าง linked list เอาไว้เก็บข้อมูลนี้ (สร้างใน constructor Pacman) (เวลา import ใช้ java.util.LinkedList)
// --- Pacman.java
private List<DotEattenListener> listeners;
// ...
public Pacman(int x, int y, World world) {
// ...
listeners = new LinkedList<DotEattenListener>();
}
สองเมท็อดด้านล่างเป็นเมท็อดที่ให้วัตถุต่าง ๆ มาลงทะเบียนรับข่าว และเป็นเมท็อดที่ให้ pacman ใช้แจ้งข่าว
public void registerDotEattenListener(DotEattenListener l) {
listeners.add(l);
}
private void notifyDotEattenListeners() {
for(DotEattenListener l : listeners) {
l.notifyDotEatten();
}
}
จากนั้นเราจะแก้ update ให้แทนที่จะเรียก world.increaseScore ให้เรียก notifyDotEattenListeners แทน
public void update() {
if(isAtCenter()) {
if(.....) {
// ...
// ---- world.increaseScore(); ---- ลบบรรทัดนี้ออก
notifyDotEattenListeners();
}
// ...
}
//...
}
แก้คลาส World: anonymous class
เราจะต้องส่งวัตถุที่ implements interface DotEattenListener ไปลงทะเบียน ถ้าเขียนแบบธรรมดาเราจะต้องสร้างคลาสใหม่มาเพื่อใช้งานง่าย ๆ เช่น ปรับค่า score อย่างไรก็ตาม เราจะใช้ความสามารถที่น่าสนใจของ Java คือการสร้าง anonymous class เพื่อสร้างวัตถุของคลาสที่ implement interface ที่เราระบุโดยไม่ระบุชื่อ ดังโค้ดด้านล่าง (ในส่วน registerDotEattenListener)
public class World {
// ...
World(PacmanGame pacmanGame) {
// ...
registerDotEattenListener();
}
// ...
private void registerDotEattenListener() {
pacman.registerDotEattenListener(new Pacman.DotEattenListener() {
@Override
public void notifyDotEatten() {
score += 1;
}
});
}
// ...
}
สรุปรายการแนวคิดด้าน oop (ทั้งทั่วไปและแนวคิดบน Java) ที่ใช้ในการเขียนเกมนี้
- จะมาสรุปเพิ่ม พร้อมทั้งใส่ลิงก์
- Observer pattern
- Interface
- Anonymous class