เขียนเกมด้วย LibGDX #8 – Simple Game - Actor

เขียนเกมด้วย LibGDX #8 – Simple Game - Actor Cover Image

สวัสดีครับ กลับมาพบกับซีรีส์ สอน LibGDX ในตอนสุดท้ายกันนะครับ พอดีว่าลง Ubuntu ใหม่ ทำให้ source code ที่ใช้สอน หายไปด้วยครับ เลยทำให้บทความออกมาช้าพอสมควร ทีแรกนึกว่า อัพลง bitbucket ไปแล้ว แต่ที่ไหนได้ แค่ commit ลงไว้ในเครื่องเท่านั้น ยังไม่ได้ push ไปเลย ก็เลยต้องมานั่งเขียนโค๊ดใหม่ ก็อาศัยจากบทความเก่าๆครับ ทำให้ได้รู้ว่าบทความที่เขียนไว้นั้น ใช้งานได้ครับ หากใครอ่านถึงบทความนี้แล้วมีปัญหา รันไม่ได้ หรือโค๊ดไม่ถูกต้อง ลองอ่านดูใหม่ครับ เชื่อว่าอ่านจะตกหล่น หรือข้ามไปบ้าง เพราะผมก็ลองนั่งทำตั้งแต่บทความแรกเลย ก็ไม่มีปัญหาครับ

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

วันนี้จะมาพูดถึงเรื่อง Actor ครับ และก็จะนำมาประยุกต์ใช้กับ Simple Game ครับ โดยทีแรก SimpleGame นั้นเป็นเกมที่คอยเก็บเม็ดฝนที่ตกลงมา โดยใช้ถังน้ำรับ หยดน้ำ ใช่มั้ยครับ หลักการก็คือใช้ Rectangle.overlaps แต่ว่าบทความนี้ผมจะเปลี่ยนแนวเกมนิดหน่อย คือว่า จะไม่ใช้ถังน้ำแล้ว แต่จะเปลี่ยนเป็น ใช้มือจิ้มไปที่หยดน้ำแทน เมื่อมือจิ้มถูกหยดน้ำ ก็จะได้คะแนนไปนั่นเอง

สำหรับหลักการ ก็จะใช้ Actor เข้ามาช่วย แล้วใช้การรับ Listener จากผู้ใช้นั่นเอง มาดูขั้นตอนกันเลยครับ ผมอ้างอิง โค๊ดล่าสุดเลยนะครับ จากบทความที่แล้ว http://devahoy.com/posts/libgdx-tutorial-simple-game-part-4-scene2d

เริ่มแรก ผมก็สร้างคลาส สำหรับเม็ดฝนขึ้นมาครับ ตั้งชื่อว่า Rain ทำการ extends คลาส Actor จากนั่นทำการ override เมธอดสำคัญเลยครับ เมธอด draw() ชื่อก็น่าจะบอกแล้ว คือเมธอดที่ไว้ใช้สำหรับ วาด texture ต่างๆนั่นเอง

package com.badlogic.drop;

import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.scenes.scene2d.Actor;

public class Rain extends Actor {

    @Override
    public void draw(Batch batch, float parentAlpha) {
        super.draw(batch, parentAlpha);
    }
}

จากนั้น ลองกลับไปดูคลาส GameScreen เราทำการสร้างเม็ดฝน โดยโหลด Texture มาก่อน จากนั้นก็ใช้เมธอด spawnRaindrop() เพื่อทำการ random และโปรยฝนลงมาใช่มั้ยครับ ที่นี่ไอ้ logic ต่างๆ ที่ใช้ในการทำเม็ดฝน เราจะไม่ใช้ในคลาส GameScreen ละ แต่เราจะเปลี่ยนมาใช้ในคลาส Rain แทน Logic ต่างๆ ยังคงคล้ายๆเดิม คือ มี Texture สำหรับเก็บภาพของเม็ดฝน ทำการ render ที่เมธอด draw() และเปลี่ยนค่า y ทุกๆครั้ง ( 200 * Gdx.graphics.getDeltaTime())

ที่คลาส Rain ทำการสร้าง Constructor และรับ arguments เป็น GameScreen เนื่องจาก ใน GameScreen เราได้ทำการสร้าง Texture ของเม็ดฝนไว้แล้ว ก็ส่งค่านั้นเป็นพารามิเตอร์มาเลย แล้วก็นำมาใช้กับคลาส Rain ได้

public class Rain extends Actor {
    final GameScreen gameScreen;

    public Rain(GameScreen gameScreen) {
        this.gameScreen = gameScreen;
        setWidth(64);
        setHeight(64);

        setX(MathUtils.random(0, 800 - 64));
        setY(480);

        setTouchable(Touchable.enabled);
    }

    @Override
    public void draw(Batch batch, float parentAlpha) {
        batch.draw(gameScreen.dropImage, getX(), getY(), 64, 64);

        setY(getY() - 200 * Gdx.graphics.getDeltaTime());

        if (getY() + 64 < 0)
            this.remove();
            }
        }
}

จะเห็นได้ว่า ที่ Constructor ทำการรับ GameScreen มา ก็ทำการเซตความกว้าง ความยาว แล้วก็เซตตำแหน่ง x แบบสุ่ม จากนั้นก็ ใช้เมธอด setTouchable() ส่วนเมธอด draw ลักษณะการ render จะคล้ายๆ ใน GameScreen คือทำการวาด Texture จากนั้นก็เปลี่ยนค่า y ไปเรื่อยๆ

ต่อมากลับมาที่คลาส GameScreen ตอนนี้เราจะไม่ใช้ Rectangle หรือว่าเมธอด spawnRaindrop แล้ว ก็ทำการลบในส่วนที่ไม่ได้ใช้ทิ้งซะ จะเหลือแค่นี้

package com.badlogic.drop;

import com.badlogic.gdx.Gdx;
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.Vector3;
import com.badlogic.gdx.scenes.scene2d.InputEvent;
import com.badlogic.gdx.scenes.scene2d.InputListener;
import com.badlogic.gdx.utils.TimeUtils;

public class GameScreen implements Screen {
    final Drop game;

    Texture dropImage;
    Texture bucketImage;
    Sound dropSound;
    Music rainMusic;
    OrthographicCamera camera;
    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);
    }

    @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.end();

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

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

        }
    }

    @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();
    }
}

ที่นี่ เราจะ render ไอคลาส Rain ที่เราเพิ่งสร้างได้จากตรงไหน? ก็ตรงที่เช็ค lastDropTime นั่นแหละครับ จากเมื่อถึงเวลาโปรยเม็ดฝน จากเดิม เรียกเมธอด spawnRaindrop ก็เปลี่ยนมาเป็นสร้างออปเจ็ค rain ขึ้นมาแทน

// เช็คว่าถึงเวลาที่จะโปรยเม็ดฝนเม็ดถัดไปหรือยัง
if (TimeUtils.nanoTime() - lastDropTime > 1000000000) {
    final Rain rain = new Rain(this);
    rain.addListener(new InputListener() {
        @Override
        public boolean touchDown(InputEvent event, float x, float y,
                                 int pointer, int button) {

            rain.remove();
            dropsGathered += 1;
            return true;
        }
    });
}

ข้างบนคือ สร้างออปเจ็ค Rain จากนั้นเพิ่ม addListener สำหรับเช็คว่ามีการ input หรือไม่ หากมีการ input และเป็นการกดลงบนหน้าจอ (touchDown) ก็ให้ลบออปเจ็คตัวนี้ทิ้งซะ ก็คือหายไปจากจอนั่นเอง และได้คะแนน 1 คะแนน

มันง่ายแค่นี้เองหรอ สำหรับการใช้ Actor ลองทดสอบรันผลลัพธ์ดูครับ

แป่วๆ ~~~ มีแต่เสียงฝนตก ไม่มีเม็ดฝนหยดมาซักเม็ดเลย หรือว่ามันไม่ได้สร้างออปเจ็ค Rain หว่า ว่าแล้วก็ลองใช้ Log ดู ผมเพิ่ม Log ไว้เช็คดู ดังนี้

if (TimeUtils.nanoTime() - lastDropTime > 1000000000) {
    Gdx.app.log("MyTag", "Create New Rain");
    ...
}

ลองสั่งรันใหม่ ปรากฎว่า Log ก็โชว์ข้อความ Create new Rain นิ แล้วทำไมไม่มีเม็ดฝนขึ้นมา ? งงมั้ยเอ่ย?

ก็เพราะว่า Actor จำเป็นจะต้องใช้ร่วมกับ Stage ผมได้พูดไปแล้ว ในบทความที่แล้ว และได้ทำการใช้งาน Stage รวมกับ scene2d.ui ในการสร้างเมนู ในหน้า MainMenuScreen ครับ หากจำไม่ได้ กลับไปอ่านเลย บทความที่แล้ว

ที่นี่เมื่อรู้ว่า Actor ต้องใช้ Stage เราก็มาสร้าง Stage ในคลาส GameScreen เลยครับ สร้างตัวแปร Stage ชื่อ stage จากนั้น สร้างนิวออปเจ็ค ที่ constructor ของ GameScreen รวมถึง setInputProcessor ด้วยครับ ตามนี้

public class GameScreen implements Screen {
    Stage stage;
    public GameScreen(final Drop gam) {
        ...
        stage = new Stage(new StretchViewport(800, 480));
        Gdx.input.setInputProcessor(stage);
    }
}

จากนั้นที่เมธอด render ของคลาส GameScreen ก็แค่สั่งให้ stage ทำการ act() และ draw() แต่จะ draw อะไรหากใน Stage ไม่มี Actor เลย ฮ่าๆ อย่าลืมให้ stage.addActor(rain) ด้วยครับ สุดท้าย เกมก็เล่นได้เหมือนเดิม เปลี่ยนจากใช้ถังเก็บน้ำ เป็นเอามือจิ้มแทน แค่นี้แหละ ^^

หน้าตาเกมส์ก็คล้ายๆเดิม แค่ไม่มีถังน้ำแล้ว ใช้มือจิ้มซะ!

Screen shot

หมดแล้วครับ สำหรับซีรียส์สอนเขียนเกมด้วย LibGDX ในเบื้องต้น คาดว่าเมื่อทุกคนอ่านถึงบทความนี้ ก็คิดว่าเขียนเกมเป็นระดับหนึ่ง สามารถไปต่อยอดได้อีกครับ การจะเขียนให้เก่ง หรือเป็นโปรแกรมเมอร์ที่ดี เราจำเป็นต้องมีการฝึกฝนและพัฒนาตัวเองอยู่เสมอครับ จริงๆ ผมก็ยังเพิ่งเริ่มศึกษาเหมือนกัน คิดว่าระดับเริ่มต้น ผมพอเข้าใจแล้ว ก็นำมาประยุกต์และเรียบเรียง ทำเป็นบทความสอนเผื่อเป็นประโยชน์แก่คนสนใจด้วยครับ ส่วนบทความครั้งหน้า อาจจะไม่ได้นำมา implements ร่วมกับโค๊ดเดิมแล้ว อาจจะเป็นเรื่องใหม่ไปเลย หรือเป็นเรื่องๆแยกๆ ไป ใครจะเอามา implement ยังไง ก็แล้วแต่สะดวกครับ เช่นพวกเรื่อง Viewport, Box2d, OrthographicCamera, Tile Map, Preferences, TexturePacker, Font รวมถึง GameState ต่างๆ สำหรับ LibGDX นั้น ยังมีอะไรให้ศึกษากันอีกเยอะครับ หากใครสนใจหาบทสอนเพิ่มเติม ก็ตามนี้ครับ บอกได้เลยว่า เนื้อหาแน่น อัพเดทบ่อยมาก (จริงๆ LibGDX นั้นมีการ build เกือบทุกวันครับ ทำให้บางทีต้องตาม update ตลอดครับ)

*LibGDX Wiki on Github

โค๊ดทั้งหมด

ไฟล์ GameScreen.java

package com.badlogic.drop;

import com.badlogic.gdx.Gdx;
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.scenes.scene2d.InputEvent;
import com.badlogic.gdx.scenes.scene2d.InputListener;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.utils.TimeUtils;
import com.badlogic.gdx.utils.viewport.StretchViewport;

public class GameScreen implements Screen {

    public static final String TAG = GameScreen.class.getName();
    final Drop game;
    private Stage stage;

    Texture dropImage;
    Texture bucketImage;
    Sound dropSound;
    Music rainMusic;
    OrthographicCamera camera;
    long lastDropTime;
    int dropsGathered;

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

        stage = new Stage(new StretchViewport(800, 480));
        Gdx.input.setInputProcessor(stage);

        // โหลดไฟล์รูปถังน้ำและเม็ดฝน ขนาด 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);

        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.end();

        // เช็คว่าถึงเวลาที่จะโปรยเม็ดฝนเม็ดถัดไปหรือยัง
        if (TimeUtils.nanoTime() - lastDropTime > 1000000000) {
            final Rain rain = new Rain(this);
            rain.addListener(new InputListener() {
                @Override
                public boolean touchDown(InputEvent event, float x, float y,
                                         int pointer, int button) {
                    rain.remove();
                    dropsGathered += 1;
                    return false;
                }


            });
            stage.addActor(rain);
            lastDropTime = TimeUtils.nanoTime();
        }


        stage.act(delta);
        stage.draw();
            }

            @Override
            public void resize(int width, int height) {
        stage.getViewport().update(width, height, true);
            }

            @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();
        stage.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;
import com.badlogic.gdx.scenes.scene2d.InputEvent;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.ui.Skin;
import com.badlogic.gdx.scenes.scene2d.ui.TextButton;
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener;
import com.badlogic.gdx.utils.viewport.StretchViewport;

public class MainMenuScreen implements Screen {

    final Drop game;

    OrthographicCamera camera;

    private Stage stage;
    private Skin skin;

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

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

        stage = new Stage(new StretchViewport(800, 480));
        Gdx.input.setInputProcessor(stage);

        skin = new Skin(Gdx.files.internal("uiskin.json"));

        TextButton buttonStart = new TextButton("START", skin);
        buttonStart.setWidth(200);
        buttonStart.setHeight(50);
        buttonStart.setPosition(800 / 2 - 200 / 2, 300);

        stage.addActor(buttonStart);

        buttonStart.addListener(new ClickListener() {
            @Override
            public void clicked(InputEvent event, float x, float y) {
                super.clicked(event, x, y);
                game.setScreen(new GameScreen(game));
            }
        });

    }

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

        stage.act(Gdx.graphics.getDeltaTime());
        stage.draw();
    }

    @Override
    public void resize(int width, int height) {
        stage.getViewport().update(width, height, true);
    }

    @Override
    public void show() {
    }

    @Override
    public void hide() {
    }

    @Override
    public void pause() {
    }

    @Override
    public void resume() {
    }

    @Override
    public void dispose() {
        stage.dispose();
        skin.dispose();
    }
}

ไฟล์ 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(); // สำคัญครับ อย่าลืม!
    }

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

ไฟล์ Rain.java

package com.badlogic.drop;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.scenes.scene2d.Actor;
import com.badlogic.gdx.scenes.scene2d.Touchable;

public class Rain extends Actor {
    final GameScreen gameScreen;

    public Rain(GameScreen gameScreen) {
        this.gameScreen = gameScreen;

        setWidth(64);
        setHeight(64);

        setX(MathUtils.random(0, 800 - 64));
        setY(480);

        setTouchable(Touchable.enabled);
    }

    @Override
    public void draw(Batch batch, float parentAlpha) {
        batch.draw(gameScreen.dropImage, getX(), getY(), 64, 64);

        setY(getY() - 200 * Gdx.graphics.getDeltaTime());

        if (getY() + 64 < 0)
            this.remove();
    }
}

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

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