Oop lab/gdx/pacman

จาก Theory Wiki
ไปยังการนำทาง ไปยังการค้นหา
หน้านี้เป็นส่วนหนึ่งของ oop lab

ขั้นตอนคร่าว ๆ

  • สร้าง gradle project ของ GDX
  • (ขั้นตอนเพิ่มเติม) เขียน pacman เคลื่อนที่ไปมาบนหน้าจอ
  • เขียนส่วนแสดงแผนที่และจุด
  • บังคับ pacman เดินไปมาตามช่อง (วิ่งทะลุกำแพง)
  • บังคับ pacman เดินไปมาตามช่อง (ไม่ทะลุแล้ว)
  • กินจุด
  • แสดงคะแนน

เนื้อหา

สร้าง gradle project

เราจะสร้าง gradle project ของเกมด้วยโปรแกรม gdx-setup

ดาวนโหลด gdx-setup.jar

จากนั้นสั่ง

java -jar gdx-setup.jar

Gdx-gradle.png

ใส่ค่าให้ครบ เลือกเฉพาะ desktop เกม เพื่อความสะดวกในการทำ tutorial ให้ตั้งชื่อ game class ว่า PacmanGame

จากนั้นให้ import เข้าไปใน Eclipse หรือ NetBeans และทดลองรัน ถ้าเจอปัญหาว่าหารูป badlogic ไม่เจอ อย่าลืมแก้ไข Working Directory ที่ Run Configurations...

Gitmark.png พอเริ่มทำงานได้ อย่าลืม commit หมั่น commit เป็นระยะ ๆ ตามส่วนของงานย่อยที่ทำสำเร็จ

บังคับ 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 ด้วย

Gitmark.png หลังจากแก้โปรแกรมให้แสดง pacman ได้ ก็ควรจะ commit สักครั้ง

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 ขยับขึ้นบนและลงล่างได้ด้วย

Gitmark.png หลังจากบังคับ pacman ได้แล้ว ให้ commit งานด้วย

แยกส่วนสถานะกับการแสดงผล

เราจะแยกส่วนจัดการสถานะของเกมทั้งหมด ออกจากส่วนแสดงผล เพื่อให้สามารถทดสอบการทำงานของส่วนจัดการสถานะได้ง่าย (เช่น เขียน unit test เป็นต้น)

เราจะมีสองคลาสใหญ่ ๆ คือ

  • World - จัดการสถานะทั้งหมด
  • WorldRenderer - จัดการวาดรูปต่าง ๆ ของสถานะใน World

โดยถ้าพิจารณาจากโค้ดล่าสุดของเรา ส่วนที่เป็นเมท็อด update จะถูกย้ายไปอยู่ในคลาส World ส่วนที่เกี่ยวกับการวาดรูปในเมท็อด render จะถูกย้ายไปที่ WorldRenderer

เมื่อแก้ไขเสร็จเมท็อด render จะมีลักษณะดังนี้

    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 ขยับได้ ซึ่งเราทำได้สองแบบหลัก ๆ ตามตารางด้านล่างนี้

ระบุทิศทางในชื่อเมท็อด ระบุด้วยพารามิเตอร์
สร้างเมท็อด
  • moveLeft()
  • moveRight()
  • moveUp()
  • moveDown()
สร้างเมท็อด
  • move(1) หรือ move(Pacman.DIRECTION_LEFT) (ใช้ค่าคงที่ระบุทิศ)
  • move(2) หรือ move(Pacman.DIRECTION_RIGHT)
  • move(3) หรือ move(Pacman.DIRECTION_RIGHT)
  • move(4) หรือ move(Pacman.DIRECTION_RIGHT)

เราจะเลือกใช้แบบทางด้านขวา เพื่อความสะดวก (ดังจะเห็นต่อไป) แต่เพื่อให้โค้ดอ่านงานเราจะสร้างค่าคงที่ระบุทิศทางไว้ด้วย

เราจะเริ่มจากการประกาศค่าคงที่เป็นค่าคงที่ระดับคลาส (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_DIFF = 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

Gitmark.png อย่าลืม commit

อย่างไรก็ตาม สังเกตว่ามีการใช้ค่าคงที่ 10 ซ้ำกัน เราจะสร้างค่าคงที่ SPEED ขึ้นมาเพื่อลดความซ้ำซ้อนนี้ เพิ่มบรรทัด

    public static final int SPEED = 10;

และแก้ให้ move ใช้ SPEED แทน 10

    public void move(int dir) { 
        position.x += 10 * DIR_OFFSETS[dir][0];
        position.y += 10 * DIR_OFFSETS[dir][1];
    }

โค้ดยังมีปัญหาหรือยังสามารถแก้ไขให้ดีขึ้นได้อีก เช่น ถ้าผู้ใช้เรียก move โดยส่งค่าผิด เช่น ส่ง dir เป็น 10 หรือ -1 โปรแกรมจะ crash เราสามารถแก้ปัญหานี้โดยตรวจสอบค่า dir ก่อนทำงาน หรืออาจจะสร้าง type ใหม่ (เป็น enum) เพื่อแทนทิศทางโดยเฉพาะก็ได้ เราจะแก้ส่วนนี้ในตอนท้าย (ถ้ามีโอกาส)

Gitmark.png เมื่อแก้เสร็จ และเทสแล้วอย่าลืม commit

คลาส 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 ให้ทำงานได้ถูกต้อง

Gitmark.png อย่าลืม commit หลังเพิ่มคลาส World เสร็จ

คลาส 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);
    }
Gitmark.png เมื่อแก้เสร็จแล้ว ให้ commit project ด้วย

โครงสร้างคลาส (ปลายทาง)

แสดง 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;
                // ...
Gitmark.png เราเขียนมาได้อีกขั้นหนึ่งแล้ว อย่าลืม commit งานล่ะ

การเคลื่อนที่ของ 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 ก็อาจจะเลี้ยวไปทับขอบของช่องแผนที่ก็ได้ ดังนั้นเราจะดำเนินการดังนี้ (ดูรูปประกอบ)

Maze-movement-states.png

แทนที่เราจะให้ 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 มาที่นี่
    }
Gitmark.png ทำมาถึงจุดนี้ ก็ควรจะ commit สักครั้ง

วิ่งไม่ทะลุกำแพง

เราจะตรวจสอบว่าทิศทางที่เราจะวิ่งไปนั้นชนกำแพงหรือไม่ หลัก ๆ คือเราจะเขียนเมท็อด 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 ทำงานได้ในกรณีดังกล่าวก็ได้ แต่สำหรับเกมนี้ เนื่องจากแผนที่ที่เราทำมีขอบกำแพงทั้งหมด กรณีดังกล่าวจะไม่เกิดขึ้น

Gitmark.png ทดลองเล่นเกมว่า pacman เดินได้ถูกต้องหรือไม่ ถ้าได้ อย่าลืม commit ด้วย

กินจุด

เมท็อด 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];
    }

ปรับสถานะ

แสดงคะแนน