เขียนเกมด้วย LibGDX #6 – Simple Game ภาคพิเศษ

เขียนเกมด้วย LibGDX #6 – Simple Game ภาคพิเศษ Cover Image

กลับมาพบกับซีรีย์เขียนเกมด้วย LibGDX กันต่อครับ บทความนี้เป็นบทความที่ 6 แล้วครับ จะมาพูดต่อ จากบทความที่แล้ว คือ เขียนเกมด้วย LibGDX #5 – Simple Game ตอนจบ โดยในบทความนี้จะทำการเพิ่มหน้า เมนู และระบบต่างๆที่ทำให้เกมดูสมจริงขึ้นครับ

บทความตอนอื่นๆ ในซีรีย์ เขียนเกมด้วย LibGDX ติดตามอ่านได้ตามลิงค์ข้างล่างเลยครับ


เริ่มกันเลยครับ มาพูดถึงคลาสในเกมของเราก่อนดีกว่า เริ่มจาก

The Screen interface

Screens ถือเป็นพื้นฐานของทุกๆเกมส์เลยก็ว่าได้ครับ Screens นั้นมีเมธอดให้เลือกใช้จาก ออปเจ็ค ApplicationListener รวมถึงเมธอด show และ hide ที่จะถูกเรียกเมื่อเวลาโฟกัสและไม่ได้โฟกัสหน้าจอ

The Game Class

abstract คลาส Game นั้น implement ApplicationListener สำหรับใช้เป็น helper เมธอด สำหรับจัดการการเรนเดอร์ต่างๆของหน้าจอครับ

ทั้งออปเจ็ค Screen และ Game ใช้สำหรับเป็นโครงสร้างพื้นฐานสำหรับเขียนเกมครับ เราจะมาเริ่มโดยการสร้างออปเจ็ค Game กันครับ จากบทความเก่า คลาส Drop ของเรา ทีแรกจะ implements ApplicationListener ใช่มั้ย มาอันนี้เราจะให้ทำการ extends Game ครับ ซึ่งออปเจ็คเกมนั้นก็ implements ApplicationListener มาอีกทีครับ เวลาเรา extends เราก็ไม่จำเป้นต้อง implement ทุกเมธอดครับ สนใจเฉพาะ create, render และ dispose

package com.badlogic.drop;

import com.badlogic.gdx.Game;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;


public class Drop extends Game {

    public SpriteBatch batch;
    public BitmapFont font;

    public void create() {
        batch = new SpriteBatch();
                        //LibGDX ใช้ฟ้อน Arial เป้นฟ้อนหลักครับ
        font = new BitmapFont();
        this.setScreen(new MainMenuScreen(this));
    }

    public void render() {
        super.render(); //important!
    }

    public void dispose() {
        batch.dispose();
        font.dispose();
    }

}

เราเริ่มต้นด้วยการสร้างแอพพลิเคชัน โดยสร้างออปเจ็ค SpriteBatch และ BitmapFont ใหม่ (มันอาจจะเป็นตัวอย่างที่ไม่ดีนัก หากพูดถึงกฎเรื่อง DRY Don’t Repeat Yourself. ออปเจ็ค SpriteBatch ใช้สำหรับเรนเดอร์ออปเจ็คบนหน้าจอ เช่น Texture ส่วน BitmapFont ใช้สำหรับเรนเดอร์ข้อความบนหน้าจอครับ โดยใช้ร่วมกับ SpriteBatch

ถัดมา เราทำการสร้าง Screen ของเกมครับ โดยใช้คลาส MainMenuScreen จะใช้เป็นหน้าจอเมนูตอนเริ่มเข้าสู่เกม โดยรับคลาส Drop เป็น parameter ครับ (คลาส MainMenuScreen ยังไม่ได้สร้างนะครับ หากว่าตอนนี้มี error ก็ปล่อยไปก่อน)

อย่าลืมเรียก super.render() ในเมธอด render ที่เรา implement คลาส Game มาด้วยนะครับ ไม่อย่างนั้น Screen ที่เราทำการสร้างตรงเมธอด create() จะไม่เรนเดอร์อะไรออกมาเลย

สุดท้าย อย่าลืมเมธอด dispose() ครับ อ่านเพิ่มเติม การจัดการ Assets

The Main Menu

มาถึงการสร้างคลาส MainMenuScreen แล้วครับ โดยทำการ implement Screen ก่อนเลยครับ

package com.badlogic.drop;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;

public class MainMenuScreen implements Screen {

    final Drop game;

    OrthographicCamera camera;

    public MainMenuScreen(final Drop gam) {
        game = gam;

        camera = new OrthographicCamera();
        camera.setToOrtho(false, 800, 480);

    }


        //...Rest of class omitted for succinctness.

}

จากโค๊ดด้านบน เราได้สร้าง constructor สำหรับคลาส MainMenuScreen ที่ implements อินเตอร์เฟส Screen. อินเตอร์เฟส Screen นั้นไม่มีเมธอด create() มาให้นะครับ นั่นจึงเป็นเหตุผลว่าทำไมเราต้องสร้าง Constructor ของเราขึ้นเอง สำหรับ constructor เราก็รับออปเจ็ค Drop เข้ามาครับ ตัวที่ส่งมาจากคลาส Drop ตรงเมธอด create#setScreen

ถัดมาทำการ override เมธอด render ครับ หากยังจำโค๊ดจากบทความเก่าได้ เมธอด render ของ ApplicationListener จะไม่มี parameter เลย ขณะที่ เมธอด render อันนี้รับ parameter เป็น float ครับ ซึ่งก็คือ deltaTime เราไม่ต้องไปใช้ Gdx.graphics.getDeltaTime() แล้ว

public class MainMenuScreen implements Screen {

    //public MainMenuScreen(final Drop gam)....        

    @Override
    public void render(float delta) {
        Gdx.gl.glClearColor(0, 0, 0.2f, 1);
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

        camera.update();
        game.batch.setProjectionMatrix(camera.combined);

        game.batch.begin();
        game.font.draw(game.batch, "Welcome to Drop!!! ", 100, 150);
        game.font.draw(game.batch, "Tap anywhere to begin!", 100, 100);
        game.batch.end();

        if (Gdx.input.isTouched()) {
            game.setScreen(new GameScreen(game));
            dispose();
        }
    }

    //Rest of class still omitted...
}

โค๊ดด้านบน สังเกตที่ SpriteBatch และ BitmapFont เราไม่ได้สร้างออปเจ็คมันขึ้นมา แต่เราใช้อ้อปเจ็คทั้งสองที่ได้สร้างจากคลาส Game สำหรับ game.font.draw(SpriteBatch, String, float, float) คือเมธอดที่ใช้สำหรับแสดงข้อความบทหน้าจอ สำหรับฟ้อนพื้นฐานที่มีมาให้คือฟ้อน Arial ครับ

ถัดมาก็ทำการเช็คว่ามีการแตะที่หน้าจอหรือไม่ ถ้ามี ก็ให้ setScreen คือเปลี่ยนหน้าจอ ไปที่หน้าจอ GameScreen ครับ โดยส่งอ็อปเจ็ค game ไปเป็น parameter ด้วย ครับ (ลองนึกถึง เวลาเข้าเกมมาครับ หน้าแรก จะโชว์หน้าจอนี้ แล้วเมื่อผู้เล่นทำการแตะหน้าจอ ก็จะไปหน้าจอเล่นเกมครับ)

หน้าตาที่ได้ก็จะได้ประมาณนี้

MenuScreen

The Game Screen

มาถึง Game Screen บ้างครับหลังจากที่ทำหน้า Menu เสร็จไปแล้ว ตัว GameScreen อันนี้จะเป็นหน้าจอหลักที่ไว้สำหรับเล่นเกมเลยครับ โค๊ดส่วนใหญ่ก็เหมือน บทความที่แล้วครับ ต่างกันที่ GameScreen จะทำการ implements Screen แล้วรับออปเจ็ค Game เป็น parameter ในขณะที่โค๊ดเก่า เราสร้างคลาส Drop โดย implements ApplicationListener

package com.badlogic.drop;

import java.util.Iterator;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input.Keys;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.audio.Music;
import com.badlogic.gdx.audio.Sound;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.math.Vector3;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.TimeUtils;

public class GameScreen implements Screen {
    final Drop game;

    Texture dropImage;
    Texture bucketImage;
    Sound dropSound;
    Music rainMusic;
    OrthographicCamera camera;
    Rectangle bucket;
    Array<Rectangle> raindrops;
    long lastDropTime;
    int dropsGathered;

    public GameScreen(final Drop gam) {
        this.game = gam;

                        // โหลดไฟล์รูปถังน้ำและเม็ดฝน ขนาด 64x64 
        dropImage = new Texture(Gdx.files.internal("droplet.png"));
        bucketImage = new Texture(Gdx.files.internal("bucket.png"));

                        // โหลดเสียงเม็ดฝน และ effect
        dropSound = Gdx.audio.newSound(Gdx.files.internal("drop.wav"));
        rainMusic = Gdx.audio.newMusic(Gdx.files.internal("rain.mp3"));
        rainMusic.setLooping(true);

                        // สร้าง camera และ SpriteBatch
        camera = new OrthographicCamera();
        camera.setToOrtho(false, 800, 480);

                        // สร้างออปเจ็ค bucket จากคลาส Rectangle
        bucket = new Rectangle();
        bucket.x = 800 / 2 - 64 / 2; // center the bucket horizontally
        bucket.y = 20; 
        bucket.width = 64;
        bucket.height = 64;

                        // สร้าง array เม็ดฝน และเริ่มโปรยเม็ดฝนเม็ดแรก
        raindrops = new Array<Rectangle>();
        spawnRaindrop();

    }

    private void spawnRaindrop() {
        Rectangle raindrop = new Rectangle();
        raindrop.x = MathUtils.random(0, 800 - 64);
        raindrop.y = 480;
        raindrop.width = 64;
        raindrop.height = 64;
        raindrops.add(raindrop);
        lastDropTime = TimeUtils.nanoTime();
    }

    @Override
    public void render(float delta) {
                // เคลียร์หน้าจอ ด้วยสี Dark Blue 
                // โดย argument ของ glClearColor คือ red, green, blue, alpha
                // ค่าอยู่ระหว่าง [0, 1] (float)
        Gdx.gl.glClearColor(0, 0, 0.2f, 1);
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

                        // camera อัพเดท matrix
        camera.update();

                        // ให้ SpriteBatch ทำการเรนเดอร์พิกัดทั้งหมดของระบบ
        game.batch.setProjectionMatrix(camera.combined);

                        // เริ่มวาด เรนเดอร์ ตะกร้าและเม็ดฝน รวมถึงนับจำนวนเม็ดฝนที่เก็บได้
        game.batch.begin();
        game.font.draw(game.batch, "Drops Collected: " + dropsGathered, 0, 480);
        game.batch.draw(bucketImage, bucket.x, bucket.y);
        for (Rectangle raindrop : raindrops) {
            game.batch.draw(dropImage, raindrop.x, raindrop.y);
        }
        game.batch.end();

                        // เช็คว่า มีการคลิกเมาท์หรือแตะหน้าจอหรือไม่
        if (Gdx.input.isTouched()) {
            Vector3 touchPos = new Vector3();
            touchPos.set(Gdx.input.getX(), Gdx.input.getY(), 0);
            camera.unproject(touchPos);
            bucket.x = touchPos.x - 64 / 2;
        }

                // เช็คว่า มีการกดคีย์บอร์ดปุ่มลูกศรซ้าย/ขวา หรือไม่
        if (Gdx.input.isKeyPressed(Keys.LEFT))
            bucket.x -= 200 * Gdx.graphics.getDeltaTime();
        if (Gdx.input.isKeyPressed(Keys.RIGHT))
            bucket.x += 200 * Gdx.graphics.getDeltaTime();

                // เช็คไม่ให้ถังน้ำมันล้นหน้าจอ
        if (bucket.x < 0)
            bucket.x = 0;
        if (bucket.x > 800 - 64)
            bucket.x = 800 - 64;

                        // เช็คว่าถึงเวลาที่จะโปรยเม็ดฝนเม็ดถัดไปหรือยัง
        if (TimeUtils.nanoTime() - lastDropTime > 1000000000)
            spawnRaindrop();

                // ทำให้เม็ดฝนขยับ โดยลบเม็ดฝนทุกครั้งที่ตกลงพ้นขอบจอหรือว่าไปชนกับถังน้ำ 
                // รวมถึงให้มันเล่นเสียงเวลาโดนถังน้ำ
        Iterator<Rectangle> iter = raindrops.iterator();
        while (iter.hasNext()) {
            Rectangle raindrop = iter.next();
            raindrop.y -= 200 * Gdx.graphics.getDeltaTime();
            if (raindrop.y + 64 < 0)
                iter.remove();
            if (raindrop.overlaps(bucket)) {
                dropsGathered++;
                dropSound.play();
                iter.remove();
            }
        }
    }

    @Override
    public void resize(int width, int height) {
    }

    @Override
    public void show() {
                // เริ่มเล่นเสียงเพลง (เสียงฝนตก) เมื่อหน้าจอนี้แสดง
        rainMusic.play();
    }

    @Override
    public void hide() {
    }

    @Override
    public void pause() {
    }

    @Override
    public void resume() {
    }

    @Override
    public void dispose() {
        dropImage.dispose();
        bucketImage.dispose();
        dropSound.dispose();
        rainMusic.dispose();
    }
}

โค๊ดด้านบนนั้น 95% นั้นเหมือนกับโค๊ดจากคลาส Drop ในบทความที่แล้ว ต่างกันที่เราใช้ constructor แทนที่เมธอด create() ของ ApplicationListener แล้วก็ส่งค่าออปเจ็ค Drop เหมือนอย่างคลาส MainMenuScreen สุดท้ายก็ทำการเล่นเสียงเพลง เมื่อหน้า GameScreen นี้แสดง

รวมถึงได้เพิ่มข้อความที่มุมุซ้ายบนของหน้าจอเกม เพื่อเก็บบันทึกว่าเก็บเม็ดฝนไปกี่เม็ดแล้ว

เอาละ สุดท้ายตัวเกมก็เสร็จเรียบร้อยแล้วครับ ตอนนี้ถ้าได้อ่านมาถึงบทความนี้ เชื่อว่าหลายๆคนต้องพอรู้พื้นฐาน LibGDX รวมถึงรู้ ในเรื่องอินเตอร์เฟซ Screen , abstract คลาส Game และ Game State ต่างๆมากขึ้นครับ

สำหรับหน้าจอตัวอย่างเกมก็จะได้แบบนี้ ลองนำโค๊ดที่ได้ไปปรับใช้ แล้วลองเรียนรู้ ลองเล่นกันดูนะครับ การเรียนโปรแกรมมิ่งที่ดีที่สุด ไม่ใช่การอ่านหรือทำตาม Tutorial ครับ แต่มันคือการทดลอง ลองผิดลองถูกกับมันครับ

GameScreen

โค๊ดทั้งหมด

Drop.java

package com.badlogic.drop;

import com.badlogic.gdx.Game;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;

public class Drop extends Game {

    SpriteBatch batch;
    BitmapFont font;

    public void create() {
        batch = new SpriteBatch();
                        // LibGDX ใช้ฟ้อน Arial เป้นฟ้อนหลักครับ
        font = new BitmapFont();
        this.setScreen(new MainMenuScreen(this));
    }

    public void render() {
        super.render(); // important!
    }

    public void dispose() {
        batch.dispose();
        font.dispose();
    }

}

GameScreen.java

package com.badlogic.drop;

import java.util.Iterator;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input.Keys;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.audio.Music;
import com.badlogic.gdx.audio.Sound;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.math.Vector3;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.TimeUtils;

public class GameScreen implements Screen {
    final Drop game;

    Texture dropImage;
    Texture bucketImage;
    Sound dropSound;
    Music rainMusic;
    OrthographicCamera camera;
    Rectangle bucket;
    Array<Rectangle> raindrops;
    long lastDropTime;
    int dropsGathered;

    public GameScreen(final Drop gam) {
        this.game = gam;

        // โหลดไฟล์รูปถังน้ำและเม็ดฝน ขนาด 64x64
        dropImage = new Texture(Gdx.files.internal("droplet.png"));
        bucketImage = new Texture(Gdx.files.internal("bucket.png"));

        // โหลดเสียงเม็ดฝน และ effect
        dropSound = Gdx.audio.newSound(Gdx.files.internal("drop.wav"));
        rainMusic = Gdx.audio.newMusic(Gdx.files.internal("rain.mp3"));
        rainMusic.setLooping(true);

        // สร้าง camera และ SpriteBatch
        camera = new OrthographicCamera();
        camera.setToOrtho(false, 800, 480);

        // สร้างออปเจ็ค bucket จากคลาส Rectangle
        bucket = new Rectangle();
        bucket.x = 800 / 2 - 64 / 2; // center the bucket horizontally
        bucket.y = 20;
        bucket.width = 64;
        bucket.height = 64;

        // สร้าง array เม็ดฝน และเริ่มโปรยเม็ดฝนเม็ดแรก
        raindrops = new Array<Rectangle>();
        spawnRaindrop();

    }

    private void spawnRaindrop() {
        Rectangle raindrop = new Rectangle();
        raindrop.x = MathUtils.random(0, 800 - 64);
        raindrop.y = 480;
        raindrop.width = 64;
        raindrop.height = 64;
        raindrops.add(raindrop);
        lastDropTime = TimeUtils.nanoTime();
    }

    @Override
    public void render(float delta) {
        // เคลียร์หน้าจอ ด้วยสี Dark Blue
        // โดย argument ของ glClearColor คือ red, green, blue, alpha
        // ค่าอยู่ระหว่าง [0, 1] (float)
        Gdx.gl.glClearColor(0, 0, 0.2f, 1);
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

        // camera อัพเดท matrix
        camera.update();

        // ให้ SpriteBatch ทำการเรนเดอร์พิกัดทั้งหมดของระบบ
        game.batch.setProjectionMatrix(camera.combined);

        // เริ่มวาด เรนเดอร์ ตะกร้าและเม็ดฝน รวมถึงนับจำนวนเม็ดฝนที่เก็บได้
        game.batch.begin();
        game.font.draw(game.batch, "Drops Collected: " + dropsGathered, 0, 480);
        game.batch.draw(bucketImage, bucket.x, bucket.y);
        for (Rectangle raindrop : raindrops) {
            game.batch.draw(dropImage, raindrop.x, raindrop.y);
        }
        game.batch.end();

        // เช็คว่า มีการคลิกเมาท์หรือแตะหน้าจอหรือไม่
        if (Gdx.input.isTouched()) {
            Vector3 touchPos = new Vector3();
            touchPos.set(Gdx.input.getX(), Gdx.input.getY(), 0);
            camera.unproject(touchPos);
            bucket.x = touchPos.x - 64 / 2;
        }

        // เช็คว่า มีการกดคีย์บอร์ดปุ่มลูกศรซ้าย/ขวา หรือไม่
        if (Gdx.input.isKeyPressed(Keys.LEFT))
            bucket.x -= 200 * Gdx.graphics.getDeltaTime();
        if (Gdx.input.isKeyPressed(Keys.RIGHT))
            bucket.x += 200 * Gdx.graphics.getDeltaTime();

        // เช็คไม่ให้ถังน้ำมันล้นหน้าจอ
        if (bucket.x < 0)
            bucket.x = 0;
        if (bucket.x > 800 - 64)
            bucket.x = 800 - 64;

        // เช็คว่าถึงเวลาที่จะโปรยเม็ดฝนเม็ดถัดไปหรือยัง
        if (TimeUtils.nanoTime() - lastDropTime > 1000000000)
            spawnRaindrop();

        // ทำให้เม็ดฝนขยับ
        // โดยลบเม็ดฝนทุกครั้งที่ตกลงพ้นขอบจอหรือว่าไปชนกับถังน้ำ
        // รวมถึงให้มันเล่นเสียงเวลาโดนถังน้ำ
        Iterator<Rectangle> iter = raindrops.iterator();
        while (iter.hasNext()) {
            Rectangle raindrop = iter.next();
            raindrop.y -= 200 * Gdx.graphics.getDeltaTime();
            if (raindrop.y + 64 < 0)
                iter.remove();
            if (raindrop.overlaps(bucket)) {
                dropsGathered++;
                dropSound.play();
                iter.remove();
            }
        }
    }

    @Override
    public void resize(int width, int height) {
    }

    @Override
    public void show() {
        // เริ่มเล่นเสียงเพลง (เสียงฝนตก) เมื่อหน้าจอนี้แสดง
        rainMusic.play();
    }

    @Override
    public void hide() {
    }

    @Override
    public void pause() {
    }

    @Override
    public void resume() {
    }

    @Override
    public void dispose() {
        dropImage.dispose();
        bucketImage.dispose();
        dropSound.dispose();
        rainMusic.dispose();
    }
}

MainMenuScreen.java

package com.badlogic.drop;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;

public class MainMenuScreen implements Screen {

    final Drop game;

    OrthographicCamera camera;

    public MainMenuScreen(final Drop gam) {
        game = gam;

        camera = new OrthographicCamera();
        camera.setToOrtho(false, 800, 480);

    }

    @Override
    public void render(float delta) {
        Gdx.gl.glClearColor(0, 0, 0.2f, 1);
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

        camera.update();
        game.batch.setProjectionMatrix(camera.combined);

        game.batch.begin();
        game.font.draw(game.batch, "Welcome to Drop!!! ", 100, 150);
        game.font.draw(game.batch, "Tap anywhere to begin!", 100, 100);
        game.batch.end();

        if (Gdx.input.isTouched()) {
            game.setScreen(new GameScreen(game));
            dispose();
        }
    }

    @Override
    public void resize(int width, int height) {
    }

    @Override
    public void show() {
    }

    @Override
    public void hide() {
    }

    @Override
    public void pause() {
    }

    @Override
    public void resume() {
    }

    @Override
    public void dispose() {
    }
}

Reference :

Chai Chai Phonbopit : MEAN Stack @Nextzy • ผู้ชายธรรมดาๆ ที่ชื่นชอบ Android, JavaScript (Node.js) และ Open Source มีงานอดิเรกเป็น Acoustic Guitar และ Football

บทความล่าสุด