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

กลับมาพบกับซีรีย์เขียนเกมด้วย LibGDX กันต่อครับ บทความนี้เป็นบทความที่ 6 แล้วครับ จะมาพูดต่อ จากบทความที่แล้ว คือ เขียนเกมด้วย LibGDX #5 – Simple Game ตอนจบ โดยในบทความนี้จะทำการเพิ่มหน้า เมนู และระบบต่างๆที่ทำให้เกมดูสมจริงขึ้นครับ
บทความตอนอื่นๆ ในซีรีย์ เขียนเกมด้วย LibGDX ติดตามอ่านได้ตามลิงค์ข้างล่างเลยครับ
- เขียนเกมด้วย libGDX #1 – สร้างโปรเจ็ค
- เขียนเกมด้วย LibGDX #2 – Hello World
- เขียนเกมด้วย LibGDX #3 – Render และการรับ input
- เขียนเกมด้วย LibGDX #4 – Simple Game ภาคแรก
- เขียนเกมด้วย LibGDX #5 – Simple Game ภาคจบ
- เขียนเกมด้วย LibGDX #6 – Simple Game ภาคพิเศษ
- เขียนเกมด้วย LibGDX #7 - Simple Game - scene2d.ui
- เขียนเกมด้วย LibGDX #8 - Simple Game - Actor
เริ่มกันเลยครับ มาพูดถึงคลาสในเกมของเราก่อนดีกว่า เริ่มจาก
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 ด้วย ครับ (ลองนึกถึง เวลาเข้าเกมมาครับ หน้าแรก จะโชว์หน้าจอนี้ แล้วเมื่อผู้เล่นทำการแตะหน้าจอ ก็จะไปหน้าจอเล่นเกมครับ)
หน้าตาที่ได้ก็จะได้ประมาณนี้
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 ครับ แต่มันคือการทดลอง ลองผิดลองถูกกับมันครับ
โค๊ดทั้งหมด
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 :
- Authors
-
Chai Phonbopit
เป็น Web Dev ในบริษัทแห่งหนึ่ง ทำงานมา 10 ปีกว่าๆ ด้วยภาษาและเทคโนโลยี เช่น JavaScript, Node.js, React, Vue และปัจจุบันกำลังสนใจในเรื่องของ Blockchain และ Crypto กำลังหัดเรียนภาษา Rust