Oop lab/maze game
- หน้านี้เป็นส่วนหนึ่งของ oop lab
- เนื้อหาส่วนนี้ ถ้าต้องการดูเป็นภาษา JavaScript กรุณาดูที่ 01219245/cocos2d/Maze
ในส่วนนี้เราจะพิจารณาการเขียนเกมที่เป็นตารางและผู้เล่นเครื่องที่ไปมาในตาราง เกมที่เป็นตัวอย่างคลาสสิกของเกมตระกูลนี้คือ PacMan หน้าตาของเกมนี้แสดงดังด้านล่าง
ขั้นตอน
เราจะค่อย ๆ เขียนโปรแกรมไปทีละขั้น ๆ ดังนี้
- แสดงแผนที่
- แสดงตัวผู้เล่นและขยับตัวผู้เล่น แบ่งเป็นขั้นย่อย ๆ หลายขั้น
- แสดงตัวผู้เล่น
- ขยับตัวผู้เล่นตามการกดปุ่ม โดยไม่สนใจแผนที่
- ขยับตัวผู้เล่นให้ตรงช่องแผนที่ แต่อาจวิ่งทะลุกำแพง
- ขยับตัวผู้เล่นให้ตรงแผนที่และไม่วิ่งทะลุกำแพง
- แสดงจุด และให้ผู้เล่นกินจุดได้
ในหลาย ๆ ขั้นตอนสามารถเขียนได้หลายแบบ โดยมีข้อดีและข้อเสียแตกต่างกันไป ดังนั้นในการเขียนจริง ผู้เขียนอาจจะเลือกเขียนไม่เหมือนในเอกสารนี้ก็ได้
โค้ดทั้งหมดอยู่ที่: https://github.com/jittat/slick2d-mazegame
แสดงแผนที่
หน้าจอเปล่า/คลาส MazeGame
เช่นเคย เราจะเริ่มโดยสร้างโปรแกรมที่แสดงหน้าจอเปล่า
public class MazeGame extends BasicGame {
public static final int GAME_WIDTH = 640;
public static final int GAME_HEIGHT = 480;
public MazeGame(String title) {
super(title);
}
@Override
public void init(GameContainer container) throws SlickException {
}
@Override
public void render(GameContainer container, Graphics g) throws SlickException {
}
@Override
public void update(GameContainer container, int delta) throws SlickException {
}
public static void main(String[] args) {
try {
MazeGame game = new MazeGame("Maze Game");
AppGameContainer container = new AppGameContainer(game);
container.setDisplayMode(GAME_WIDTH, GAME_HEIGHT, false);
container.setMinimumLogicUpdateInterval(1000 / 60);
container.start();
} catch (SlickException e) {
e.printStackTrace();
}
}
}
คลาส Maze: แสดงหนึ่งช่อง
เราจะสร้างคลาส Maze ที่แสดงแผนที่ โดยในการแสดงแผนที่เราจะแสดงโดยใช้รูปเล็ก ๆ ขนาด 40 x 40 มาประกอบกันเพื่อแสดงเป็นแผนที่ ดังนั้นในขั้นแรกให้สร้างไฟล์ wall.png ที่เป็นรูปกำแพงขนาด 40 x 40 และเก็บไว้ในไดเร็กทอรี res
ก่อนที่เราจะจัดการเรื่องการเก็บข้อมูลต่าง ๆ เรามาทำคลาส Maze ให้แสดงรูปนี้ให้ได้ก่อน คลาส Maze จะมี constructor เราจะสร้าง Image ของช่องที่จะเป็นผนัง
public class Maze {
private Image wallImage = null;
public Maze() {
try {
wallImage = new Image("res/wall.png");
} catch (SlickException e) {
e.printStackTrace();
}
}
// ...
}
เราจะสร้างเมท็อด render เพื่อให้คลาส MazeGame มาเรียกให้ Maze แสดงผล เราจะแสดงผลแบบง่าย ๆ ก่อน ดังนี้
public void render() {
wallImage.draw(100, 100);
}
จากนั้นในคลาส MazeGame เราก็ไปเพิ่มการสร้าง maze และการสั่งให้ maze แสดงผล
ด้านล่างแสดงโค้ดในคลาส MazeGame ที่เราแก้ไข
class MazeGame extends BasicGame {
private Maze maze;
// ...
@Override
public void init(GameContainer container) throws SlickException {
maze = new Maze();
}
@Override
public void render(GameContainer container, Graphics g) throws SlickException {
maze.render();
}
// ...
}
การเก็บและจัดการแผนที่
สำหรับเกมนี้ เพื่อความง่าย เราจะระบุขนาดของแผนที่ให้เหมาะสมกับหน้าจอของเกมของเราไปเลย สังเกตว่าหน้าจอของเราขนาด 640 x 480 ถ้าช่องของเราขนาด 40 x 40 เราจะแสดง maze ได้ 12 แถว x 16 คอลัมน์ เราจะเว้นแถวบนกับแถวล่างไว้เพื่อใช้แสดงข้อมูลอื่น ๆ ดังนั้นเราจะได้ว่าเราจะใช้แผนที่ขนาด 16 คอลัมน์ x 10 แถว
เราจะเพิ่มค่าเหล่านี้เป็นค่าคงที่ของคลาส ดังนี้
public class Maze {
static public int ROWS = 10;
static public int COLS = 16;
static public int BLOCK_SIZE = 40;
// ...
}
เกม maze จำนวนมากมายสามารถเปลี่ยนแผนที่ได้ สำหรับเกมนี้แม้เรายังไม่ได้พัฒนาไประดับนั้น แต่เราก็จะเก็บแผนที่แยกไว้เป็นค่าคงที่ของคลาส ถ้าในอนาคตต้องการเปลี่ยนแผนที่ก็น่าจะทำได้โดยง่าย ในคลาส Maze ประกาศค่าคงที่ของคลาสเพิ่มเติมเป็นแผนที่ โดยเราจะเก็บเป็นอาร์เรย์ของสตริง ดังนี้
static private String[] MAP = {
"################",
"#..............#",
"#.#.###..###.#.#",
"#...#......#...#",
"#.#...#..#...#.#",
"#.#...#..#...#.#",
"#...#......#...#",
"#.#.###..###.#.#",
"#..............#",
"################"
};
ในการเก็บข้อมูลดังกล่าว ถ้าต้องการทราบข้อมูลของแผนที่แถวที่ r คอลัมน์ที่ c (นับดัชนีเริ่มที่ 0) เราสามารถสั่งได้ดังนี้
MAP[r].charAt(c)
ด้วยวิธีดังกล่าว เมท็อด render ของคลาส Maze แก้ใหม่ได้เป็นดังนี้
public void render() {
for (int r = 0; r < ROWS; r++) {
for (int c = 0; c < COLS; c++) {
if (MAP[r].charAt(c) == '#') {
wallImage.draw(leftX + (c * BLOCK_SIZE),
topY + (r * BLOCK_SIZE));
}
}
}
}
เก็บกวาดโค้ด
ก่อนที่เราจะไปต่อเราจะเก็บกวาดโค้ดให้อ่านง่ายขึ้น
เราจะมีการอ่านค่าจาก MAP บ่อยมาก เราจะเขียนเมท็อด mapAt เพื่ออ่านค่าจากอาร์เรย์ MAP
private char mapAt(int r, int c) {
return MAP[r].charAt(c);
}
นอกจากนี้การคำนวณคำแหน่งที่การวาดช่องนั้น จะเป็นการคำนวณที่เราทำบ่อยมาก ดังนั้นเราจะเพิ่มเมท็อดสองเมท็อด ดังนี้
public int getCellX(int r, int c) {
return leftX + c * BLOCK_SIZE;
}
public int getCellY(int r, int c) {
return topY + r * BLOCK_SIZE;
}
ด้วยเมท็อดที่เราเพิ่มขึ้น เมท็อด render จะถูกแก้ให้อ่านง่ายขึ้นเป็นดังนี้
public void render() {
for (int r = 0; r < ROWS; r++) {
for (int c = 0; c < COLS; c++) {
if (mapAt(r,c) == '#') {
wallImage.draw(getCellX(r,c), getCellY(r,c));
}
}
}
}
ตัว Pacman และการเคลื่อนที่
แสดง Pacman
เราจะสร้างคลาส Pacman เพื่อจัดการกับการควบคุมตำแหน่งของ Pacman แต่ในส่วนของการแสดงผล เราจะใช้รูปแบบเหมือนในแลบครั้งก่อน กล่าวคือ เราจะมี interface Renderable และมีคลาม PacmanImage ที่แสดงผล Pacman
อย่างไรก็ตาม เพื่อการเทสที่ค่อยเป็นค่อยไป เราจะสร้างคลาส Pacman ที่แสดงผลตัวเองก่อน
ขั้นแรก สร้างรูป pacman ขนาด 40x40 แล้วเก็บไว้ที่ res/pacman.png
คลาส Pacman ในตอนต้นจะมีตำแหน่งของ Pacman เราจะอ้างอิงตำแหน่งของ pacman จากจุดศูนย์กลางของตัว pacman ซึ่งจะทำให้ในการแสดงรูป เราต้องเลื่อนตำแหน่งที่จะแสดงรูป x,y ให้ตรงกับมุมบนซ้ายของ pacman (โดยการลบ 20 จากทั้งพิกัด x และ y)
public class Pacman {
private int x;
private int y;
private Image image;
Pacman(int x, int y) {
this.x = x;
this.y = y;
try {
this.image = new Image("res/pacman.png");
} catch (SlickException e) {
e.printStackTrace();
}
}
public void render(Graphics g) {
image.draw(x - 20, y - 20);
}
}
จากนั้นแก้ MazeGame ให้สร้าง pacman และเรียกเมท็อด render
public class MazeGame extends BasicGame {
// ...
private Pacman pacman;
// ...
@Override
public void init(GameContainer container) throws SlickException {
// ...
pacman = new Pacman(20, 60);
}
// ...
@Override
public void render(GameContainer container, Graphics g) throws SlickException {
maze.render();
pacman.render(g);
}
// ...
}
จากนั้นทดสอบว่ารูป pacman แสดงบนหน้าจอจริง ๆ
แยก Renderable
การใช้วัตถุใด ๆ ในการเก็บและจัดการข้อมูล พร้อมทั้งให้วัตถุนั้นจัดการการแสดงผลด้วย มักเป็นสิ่งที่ทำให้เราทดสอบโปรแกรมได้ยาก ดังนั้นในส่วนนี้เราจะแยกหน้าที่การทำงานทั้งสองให้ชัดเจน โดยเราจะใช้วิธีแบบเดียวกับในแลบก่อนหน้านี้
ด้านล่างเป็น interface Renderable:
public interface Renderable {
void render(Graphics g);
}
เราจะแยกคลาส Pacman ออกเป็นคลาส Pacman เดิม และคลาส PacmanImage ที่รับผิดชอบการแสดงผล pacman ซึ่งโค้ดส่วนนี้เราก็จะนำออกมาจากคลาส Pacman นี่เอง วัตถุของคลาสนี้จะมี reference ไปยัง Pacman ด้วย เพื่ออ่านตำแหน่งมาใช้ในการแสดงผล
ขั้นแรกเราจะแก้คลาส Pacman โดยเพิ่มเมท็อด getX และ getY เพื่อเตรียมให้ PacmanImage มาเรียก
public int getX() {
return x;
}
public int getY() {
return y;
}
จากนั้นแยกโค้ดส่วนแสดงผลมาเป็นคลาส PacmanImage ดังด้านล่าง (หมายเหตุ: ในโค้ดด้านล่าง เราแก้ให้โปรแกรมอ่านขนาดรูปจากไฟล์รูปภาพโดยตรง (เพื่อลบค่า magic number 20 ในโค้ดออกด้วย))
public class PacmanImage implements Renderable {
private Pacman pacman;
private Image image;
private int width;
private int height;
PacmanImage(Pacman pacman) {
this.pacman = pacman;
try {
this.image = new Image("res/pacman.png");
} catch (SlickException e) {
e.printStackTrace();
}
width = image.getWidth();
height = image.getHeight();
}
@Override
public void render(Graphics g) {
image.draw(pacman.getX() - width/2,
pacman.getY() - height/2);
}
}
จากนั้นเราจะตัดเมท็อด render และ field image รวมทั้งคำสั่งที่จัดการกับ field นี้ ออกจากคลาส Pacman
และเพิ่มเมท็อด getRenderable ใน Pacman
Renderable getRenderable() {
return new PacmanImage(this);
}
สุดท้าย ใน MazeGame เราต้องแก้เรียก render จาก renderable แทน
public class MazeGame extends BasicGame {
// ...
private Renderable pacmanImage;
@Override
public void init(GameContainer container) throws SlickException {
// ...
pacmanImage = pacman.getRenderable();
}
@Override
public void render(GameContainer container, Graphics g) throws SlickException {
// ...
pacmanImage.render(g);
}
}
ทิศทางการวิ่ง
เราจะให้ Pacman วิ่ง ดังนั้นตัว pacman จะต้องมีสถานะว่าปัจจุบันกำลังวิ่งไปในทิศทางใด ที่เราเคยดำเนินการมา เรามักจะใช้ตัวแปรแบบจำนวนเต็มเก็บทิศทาง และมีการใช้ค่าจำนวนเต็มต่าง ๆ ในการระบุทิศ เช่น อาจจะเขียนเป็น
public static final int LEFT = 1;
public static final int RIGHT = 2;
private int dir;
// To set direction we do this
dir = LEFT;
อย่างไรก็ตามการใช้ค่าคงที่ดังกล่าว ทำให้เราเต็มไปด้วยค่าคงที่ที่ไม่ได้จัดเป็นหมวดหมู่ และเราอาจจะเขียนโปรแกรมผิด โดยเอาค่าคงที่อื่น ๆ ไปใส่ในตัวแปร dir ได้ ใน Java เราสามารถกำหนดข้อมูลที่มี type แบบ enum เพื่อใช้แทนค่าคงที่ที่มี type ได้ โดยสำหรับทิศทาง เราจะสร้าง enum ชื่อ Direction ในคลาส Pacman ดังนี้
public class Pacman {
public enum Direction {
STILL, LEFT, RIGHT, UP, DOWN;
}
// ...
private Direction dir;
// ...
}
เราจะเพิ่มใน constructor ให้รับทิศทางเริ่มต้น
Pacman(int x, int y, Direction dir, Maze maze) {
// ...
this.dir = dir;
}
จากนั้นจะเขียนเมท็อด update เพื่อปรับทิศทาง โดยเราจะสร้างค่าคงที่ STEP_SIZE เอาไว้ใช้ระบุขนาดในการขยับ (เช่น ในกรณีนี้เราขยับทีละ 5 จุด)
public static final int STEP_SIZE = 5;
และเขียนเมท็อด update ได้ดังด้านล่าง (ละการขยับในทิศทางอื่นไว้ กรุณาเพิ่มเอง)
public void update() {
switch (dir) {
case LEFT:
x -= STEP_SIZE;
break;
// ... *************** added all other directions
}
}
สุดท้าย เราต้องเรียกเมท็อดนี้ใน MazeGame update
@Override
public void update(GameContainer container, int delta) throws SlickException {
pacman.update();
}
เราสามารถทดลองให้ pacman วิ่งในทิศทางต่าง ๆ ได้โดยแก้การเรียก constructor ในเมท็อด init ในคลาส MazeGame
การรับการกดแป้น
เราจะรับการกดปุ่มใน MazeGame และส่งการเปลี่ยนทิศทางให้กับ Pacman
โดยการรับการกดปุ่มทำโดยเขียนเมท็อด keyPressed ใน MazeGame
@Override
public void keyPressed(int key, char c) {
// ... code that checks keys here
}