เขียนเกมด้วย LibGDX #5 – Simple Game ภาคจบ

เขียนเกมด้วย LibGDX #5 – Simple Game ภาคจบ Cover Image

หลังจากบทความที่แล้ว เขียนเกมด้วย LibGDX #4 – Simple Game ตอนแรก ได้นำเสนอตัวอย่างการเขียนเกมส์ด้วย LibGDX โดยยกตัวอย่าง Simple Game จาก LibGDX Wiki มาให้ดู ตอนนี้แอพพลิเคชันของเรา สามารถที่จะแสดงรูปถังน้ำได้แล้ว รวมถึงมีเสียงดนตรี(เสียงฝนตก) นั่นเอง ต่อไป ก็มาถึงการใส่ Action ให้กับถังน้ำ คือทำให้ถังน้ำขยับครับ รวมถึงวาดรูปเม็ดฝน และรายละเอียดส่วนอื่นๆของเกมครับ จบบทความนี้ จะได้ Simple Game ที่สามารถนำไปเล่นได้เลยครับ

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


ทำให้ถังน้ำขยับ (คลิ๊ก/touch)

หลังจาก render ถังน้ำได้แล้ว ต่อไปก็ถึงเวลาบังคับถังน้ำกันแล้วครับ เกมนี้จะไม่สามารถลากถังน้ำได้นะครับ ทำได้เพียงแตะที่หน้าจอ หรือว่าคลิ๊กเมาท์เท่านั้นถังน้ำก็จะย้ายไปยังตำแหน่งนั้นๆ โค๊ด render() จะได้ดังนี้ (วางไว้หลัง 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;
}

เริ่มแรก เราเช็คว่ามีการคลิกที่หน้าจอ หรือว่ามีการแตะที่หน้าจอมือถือหรือไม่ โดยใช้ Gdx.input.isTouched() ต่อมาก็แปลงหน่วย x,y ตำแหน่งที่เมาท์หรือมือแตะหน้าจอ ไปเป็นหน่วยของ camera เนื่องจากตำแหน่ง x,y จากเมาท์ กับตำแหน่ง x,y ใน Game World ของเรานั้นมันจะแตกต่างกัน

Gdx.input.getX() และ Gdx.input.getY() จะได้ค่าตำแหน่งเมาท์คลิก/มือแตะหน้าจอ เพื่อที่จะแปลงหน่วยไปเป็นหน่วยที่ camera ใช้ใน Game World เราจึงต้องใช้เมธอด camera.unproject() โดยส่ง Vector3 เป็น argument ไป, Vector3 คือ เวคเตอร์ 3 มิติ มีแกน x, y และ z โดยเราได้สร้างเวคเตอร์ จากนั้นเซตตำแหน่ง x และ y ให้กับ Vector3 ฉะนั้น Vector3 ก็จะมี x,y ตำแหน่งเดียวกับที่เราใช้นิ้วแตะหรือว่าเมาท์คลิกนั้นเอง สุดท้ายเปลี่ยนตำแหน่งของถังน้ำ ไปยังตำแหน่งที่แตะนิ้วหรือเมาท์คลิก

*Note: ไม่ควรสร้าง Object ใหม่ทุกๆครั้ง แบบที่ Vector3 ทำนะครับ ควรจะประกาศ touchPos เป็น field ในคลาส Drop แทนการสร้าง Object ใหม่ทุกๆครั้งจะดีกว่า

ทำให้ถังน้ำขยับ (Keyboard)

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

   if(Gdx.input.isKeyPressed(Keys.LEFT)) bucket.x -= 200 * Gdx.graphics.getDeltaTime();
   if(Gdx.input.isKeyPressed(Keys.RIGHT)) bucket.x += 200 * Gdx.graphics.getDeltaTime();

เมธอด Gdx.input.isKeyPressed() สำหรับเช็คว่ามีการกดแป้นคีย์บอร์ดหรือไม่ โดยรับ argument เป็น คีย์นั้นๆ เช่น Gdx.input.isKeyPressed(Keys.LEFT) เช็คว่ามีการกดแป้นปุ่มลูกศรซ้ายหรือไม่ เมธอด Gdx.graphics.getDeltaTime() จะได้ค่า DeltaTime : ค่าเวลาระหว่างเฟรมล่าสุด กับเฟรมปัจจุบัน ในหน่วยวินาที (หากงง ลองดูบทความเก่าครับ มีพูดถึงอยู่นิดหน่อย) โดยสิ่งที่เราจะทำการเปลี่ยนเมื่อมีการกดคีย์บอร์ดก็คือ เมื่อมีการกดลูกศรซ้าย ตำแหน่ง x ก็จะถูกลบไป 200 รูปมันก็จะเลื่อนไปซ้าย 200 * delTaTime ครับ อาจจะเลื่อนไปประมาณ 1-2 หน่วย ต่อเฟรม

และก็เช็คให้แน่ใจว่า ถังน้ำของเราจะไม่ตกขอบครับ (กรณีที่มันอยู่ซ้ายสุดแล้ว เราไปกดลูกศรซ้ายให้มันอีก )

if(bucket.x < 0) bucket.x = 0;
if(bucket.x > 800 - 64) bucket.x = 800 - 64;

เพิ่มเม็ดฝน

สำหรับเม็ดฝน เราก็จะใช้คลาส Rectangle เหมือนกับถังน้ำ แต่ต่างกันที่ จะมีเม็ดฝนตกลงมาหลายๆเม็ด ทำให้เราต้องใช้ list มาช่วยนะครับ จะได้ดังนี้

Array<Rectangle> raindrops;

คลาส Array อันนี้เป็นคลาสของ libgdx com.badlogic.gdx.utils.Array นะครับ คนละตัวกับ java.utils นะครับโดยตัว Array นี้จะทำงานคล้ายๆกับ ArrayList ของ Java Collection เลย แต่อาจจะมีฟังค์ชันเพิ่มเข้ามาเพื่อความง่าย

ต่อมา เราก็ต้องมีการเก็บเวลาล่าสุดที่เม็ดฝน ตกลงมาครับ ประกาศเป็น field ภายในคลาสเลย เพื่อเอาไว้คำนวณให้เม็ดอื่นๆตกมาครับ เช่น เช็คว่าเมื่อเม็ดล่าสุดตกลงมา 1วิ ค่อยให้อีกเม็ดตกลงมาตาม

long lastDropTime;

สำหรับเวลา lastDropTime เราจะเก็บเวลาเป็นหน่วย นาโนวินาทีนะครับ เลยต้องประกาศเป็น long

สำหรับการสร้างเม็ดฝน ผมได้สร้างเมธอดใหม่ขึ้นมา ชื่อว่า spawnRaindrop() ภายในเมธอด จะสร้างออปเจ็ค Rectangle ขึ้นมาใหม่ และก็กำหนดตำแหน่ง x, y ให้กับมันแบบสุ่มนะครับ จากนั้นก็เพิ่มมันเข้าไปใน Array ที่ได้ประกาศไว้ก่อนหน้านี้แล้ว

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

เมธอดด้านบน ดูแล้วก็ธรรมดานะครับ ไม่มีอะไรพิเศษน่าจะเข้าใจไม่ยาก ส่วน MathUtils นั้นเป็นคลาสของ libgdx นะครับ โดยในตัวอย่างคือจะสุ่มเม็ดฝนอยู่ที่แกน x ระหว่าง x = 0 ถึง x = (800 - 64) ส่วน TimeUtils คือคลาสอีกคลาสของ libgdx เช่นกัน สำหรับแสดงเวลาล่าสุดครับ โดยในทีนี้เป็นหน่วย นาโนวินาที

ในส่วนเมธอด create() สร้างออปเจ็ค Array<Rectangle>() ในชื่อ raindrops และก็เริ่มทำการโปรยเม็ดฝนเลยครับ ฮ่าๆ

raindrops = new Array<Rectangle>();
spawnRaindrop();

ต่อไป ก็ทำการเพิ่มโค๊ดนิดหน่อยที่เมธอด render() เพื่อเช็คว่าเวลาผ่านมาเท่าไหร่แล้ว ถึงกำหนดที่จะปล่อยเม็ดฝนเม็ดถัดไปหรือยัง

if(TimeUtils.nanoTime() - lastDropTime > 1000000000) spawnRaindrop();

1000000000 ในหน่วย นาโนวินาที มีค่าเท่ากับ 1 วินาทีนะครับ ก็คือเม็ดแรกตกมาแล้ว 1 วิ เม็ดถัดมาถึงจะตกตาม แล้วก็นับ 1 วิ อีกเม็ดตกตาม ไปเรื่อยๆครับ

ต่อไป เม็ดฝน ยังอยู่นิ่งไม่ได้ขยับ เพราะเรายังไมไ่ด้เปลี่ยนแปลงค่า y ในเมธอด render() โดยในทีนี้กำหนดความเร็วมันเท่ากับ 200 pixels/ยูนิต/วินาที หากเม็ดฝนพ้นขอบจอ เราก็จะลบมันทิ้งออกจาก array ครับ

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

ทีนี้เราต้องการจะ render เม็ดฝน โดยใช้ SpriteBatch เหมือนกับถังน้ำเลยครับ โค๊ด render ก็จะประมาณนี้

batch.begin();
batch.draw(bucketImage, bucket.x, bucket.y);
for(Rectangle raindrop: raindrops) {
    batch.draw(dropImage, raindrop.x, raindrop.y);
}
batch.end();

สุดท้าย หากเม็ดฝนโดยถังน้ำ เราจะให้มันเล่นเสียงเม็ดฝน แล้วก็ทำการลบเม็ดฝนนั้นออกจาก array โค๊ดก็จะได้ประมาณนี้

if(raindrop.overlaps(bucket)) {
    dropSound.play();
    iter.remove();
}

เมธอด Rectangle.overlaps() คือเมธอดที่เช็คว่า Rectangle (สี่เหลี่ยมผืนผ้า) พื้นที่มันทับซ้อนกับสี่เหลี่ยมอันอื่นหรือไม่ ในกรณีเราแค่เช็ค หากมันทับซ้อนกัน ก็แค่ให้เล่นเสียง แล้วก็ลบมันออกจาก array เท่านั้น (นึกถึง เม็ดฝนตกลงมา แล้วเราเอาถังมารองน้ำครับ ก็มีเสียงน้ำหยด แล้วน้ำนั้นก็มาอยู่ในถัง)

Cleaning Up

ผู้เล่นสามารถที่จะปิดแอพพลิเคชันของเราได้ตลอดเวลา สำหรับตัวอย่างนี้ก็ไม่มีอะไรมาก อย่างไรก็ตาม โดยทั่วไปแล้วเราควรจะช่วย clean up สิ่งที่ไม่ใช้แล้วครับ ทุกๆคลาสของ libgdx นั้นได้ implements อินเตอร์เฟส Disposable และมีเมธอด dispose() สำหรับทำความสะอาดสิ่งที่ไม่ได้ใช้แล้ว เช่นในตัวอย่าง Texture, Music SpriteBatch เราก็ clean up มันด้วยเมธอด dispose() ตามตัวอย่างนี้ครับ โดย implements ApplicationListener#dispose()

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

เมื่อเราเรียกเมธอด dispose แล้ว เราก็จะไม่สามารถใช้คลาสเหล่านี้ได้แล้ว. จะเห็นว่า เราเรียก dispose() ในเมธอด ApplicationListener#dispose() เนื่องจาก dispose มันจะถูกเรียกสุดท้าย ก่อนปิดแอพครับ หากไม่เข้าใจ ลองดู Life Cycle ของ LibGDX เพิ่มเติมครับ

รองรับ Pause/Resume

สำหรับแอพแอนดรอยส์ เราสามารถที่จะ pause และ resume แอพได้ทุกเวลา เช่น กดปุ่ม Home หรือว่ามีสายเข้า ในตัวอย่างหากมีสายเข้าขึ้นมา แล้วกลับมาเล่นต่อ เกมส์ก็จะเล่นได้ต่อจากที่เราออกไปครับ โดยในต้นฉบับ เค้าให้เราทำเป็นการบ้านครับ ว่าเราจะ implements เมธอด ApplicationListener.pause() และ ApplicationListener.resume() อย่างไร เมื่อเวลาผู้เล่น resume กลับมาก็ให้แตะที่หน้าจอตรงไหนก็ได้ เกมก็จะดำเนินต่อไป ลองคิดดูครับ :)

เมื่อถึงตรงนี้ ทุกคนจะสามารถเล่นเกม Simple Game ได้แล้วนะครับ โดยมีเม็ดฝนตกลงมา เราก็เลื่อนถังเพื่อไปเก็บเม็ดฝนกันครับ สำหรับใครที่อยากลองปรับแก้ไข ก็ลองปรับเปลี่ยนให้เม็ดฝนตกลงถี่ขึ้นทุกๆครั้งที่เก็บลงถังได้ หรือเมื่อเวลาผ่านไปนานขึ้น ฝนก็จะตกถี่ขึ้น ลองเล่นๆกันดูนะครับ

โค๊ดทั้งหมด

package com.badlogic.drop;

import java.util.Iterator;

import com.badlogic.gdx.ApplicationListener;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input.Keys;
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.graphics.g2d.SpriteBatch;
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 Drop implements ApplicationListener {
   Texture dropImage;
   Texture bucketImage;
   Sound dropSound;
   Music rainMusic;
   SpriteBatch batch;
   OrthographicCamera camera;
   Rectangle bucket;
   Array<Rectangle> raindrops;
   long lastDropTime;

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

      // ตั้งค่าให้เปิดเสียง effect เรื่อยๆ (เ่สียง background)
      rainMusic.setLooping(true);
      rainMusic.play();

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

      // สร้างออปเจ็ค bucket จากคลาส Rectangle
      bucket = new Rectangle();
      bucket.x = 800 / 2 - 64 / 2; // ตั้งค่ากึ่งกลาง
      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() {
      // เคลียร์หน้าจอ ด้วยสี 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 ทำการเรนเดอร์พิกัดทั้งหมดของระบบ
      batch.setProjectionMatrix(camera.combined);

      // เริ่มวาด เรนเดอร์ ถังน้ำและเม็ดฝน
      batch.begin();
      batch.draw(bucketImage, bucket.x, bucket.y);
      for(Rectangle raindrop: raindrops) {
         batch.draw(dropImage, raindrop.x, raindrop.y);
      }
      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)) {
            dropSound.play();
            iter.remove();
         }
      }
   }

   @Override
   public void dispose() {
      // dispose resourse ทุกๆอย่าง
      dropImage.dispose();
      bucketImage.dispose();
      dropSound.dispose();
      rainMusic.dispose();
      batch.dispose();
   }

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

   @Override
   public void pause() {
        // ควรจะบันทึก game state ที่นี่ครับ เพื่อเวลา resume มาแล้วจะใช้ค่าเดิม
   }

   @Override
   public void resume() {
        // ให้ยูเซอร์แตะหน้าจอเพื่อเริ่มเกมต่อ
   }
}

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

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