ผลต่างระหว่างรุ่นของ "Oop lab/maze game"

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

รุ่นแก้ไขปัจจุบันเมื่อ 02:06, 6 ตุลาคม 2557

หน้านี้เป็นส่วนหนึ่งของ oop lab
เนื้อหาส่วนนี้ ถ้าต้องการดูเป็นภาษา JavaScript กรุณาดูที่ 01219245/cocos2d/Maze

ในส่วนนี้เราจะพิจารณาการเขียนเกมที่เป็นตารางและผู้เล่นเครื่องที่ไปมาในตาราง เกมที่เป็นตัวอย่างคลาสสิกของเกมตระกูลนี้คือ PacMan หน้าตาของเกมนี้แสดงดังด้านล่าง

Java-mazegame.png

ขั้นตอน

เราจะค่อย ๆ เขียนโปรแกรมไปทีละขั้น ๆ ดังนี้

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

ในหลาย ๆ ขั้นตอนสามารถเขียนได้หลายแบบ โดยมีข้อดีและข้อเสียแตกต่างกันไป ดังนั้นในการเขียนจริง ผู้เขียนอาจจะเลือกเขียนไม่เหมือนในเอกสารนี้ก็ได้

โค้ดทั้งหมดอยู่ที่: 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
  }

หยุดวิ่งเมื่อปล่อยปุ่ม

การวิ่งให้ตรงช่อง (แต่ยังทะลุกำแพง)

เก็บกวาดข้อมูลทิศทาง

จัดการวิ่งให้อยู่ในกำแพง

จุดและการกิน

คะแนน