เขียนเกมด้วย LibGDX #4 – Simple Game ภาคแรก

เขียนเกมด้วย LibGDX #4 – Simple Game ภาคแรก Cover Image

สวัสดีครับ บทความนี้เป็นบทความที่ 4 ครับ จากซีรีส์ สอน LibGDX ครับ

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


หลังจากได้เขียนเรื่อง LibGDX ไป 3 ตอนแล้ว ซึ่งเป็นเบสิคพื้นฐาน ที่ควรรู้ก่อนจะได้เขียนเกมโดยใช้ LibGDX วันนี้เลยนำตัวอย่างการทำเกมส์จริงๆมานำเสนอเลย เพราะคิดว่า เมื่อรู้พื้นฐานบางอย่างแล้ว มาลองดูตัวอย่างจริง แล้วลองทำไปด้วย ทำความเข้าใจไปพร้อมๆกัน และมองเห็นภาพจริงเลย มันน่าจะทำให้เราเข้าใจอะไรๆมากขึ้น

ในตัวอย่างนี้ ผมได้ทำการแปลมาจากต้นฉบับที่อยู่ใน LibGDX Wiki Page รวมถึงเพิ่มเนื้อหาและดัดแปลงบางส่วนให้เข้ากับเนื้อหาของบทความครับจริงๆ ก็ไม่ได้แปลเก่งอะไรมากมาย อาศัยเดามั่วๆครับ อันไหนแปลไม่ออกผมก็ขอข้ามนะครับ ^^ สำหรับใครอยากอ่านต้นฉบับภาษาอังกฤษก็ตามลิงค์ด้านบนได้เลยครับ (LibGDX Wiki นั้นมีการอัพเดทสม่ำเสมอ รวมถึงตัว Library ด้วย มีอัพเดทแทบทุกวัน)

Simple Game

ตัวอย่างนี้จะเป็นการเขียนเกมส์ที่ชื่อว่า Simple Game เป็นเกมแบบง่ายๆ โดยบทความนี้จะครอบคลุมเนื้อหาดังต่อไปนี้ครับ

  • Basic File Access
  • การเคลียร์ Screen หน้าจอ
  • การวาด image
  • การใช้ camera
  • พื้นฐาน input processing
  • เล่น เสีียงและเอฟเฟคต่างๆ

ซึ่งเนื้อหาด้านบน บางอย่างผมก็มีพูดถึงไปแล้ว ในซีรีย์สอน LibGDX แต่ว่าวันนี้ เอาตัว Simple Game มานำเสนอ เพราะดูแล้วเค้าอธิบาย รวมถึงให้ตัวอย่างมาดูแล้ว เข้าใจง่ายครับ

Project Setup

เริ่มแรกก็สร้างโปรเจ็คเลยครับ หากมือใหม่ ก็สร้างตามนี้ครับ สร้างโปรเจ็ค LibGDX

ส่วนรายละเอียดโปรเจ็ค ผมใช้โครงสร้างตามนี้ครับ (ใช้ชื่อตามต้นฉบับ)

  • Application name: drop
  • Package name: com.badlogic.drop
  • Game class: Drop

setup-project

หลังจากสร้างโปรเจ็คเสร็จ เราก็จะมีโปรเจ็ค 5 โปรเจ็คครับ คือ drop, drop-android, drop-desktop, drop-gwt, drop-ios (หากไม่ได้เลือกอันใดอันหนึ่งออกนะครับ )

The Game

คอนเซปของเกมงั้น ง่ายๆครับ มีเพียงแค่

  • ใช้ถังน้ำเก็บเม็ดฝนที่ตกลงมา
  • ถังน้ำจะอยู่ที่ตำแหน่งล่างสุดของหน้าจอ
  • เม็ดฝน จะสุ่มตกลงมาจากขอบบนของหน้าจอ ทุกๆวินาที
  • ผู้เล่น สามารถเคลื่อนย้ายถังน้ำได้แค่ไปซ้ายหรือไปขวา โดยใช้เมาท์หรือแตะหน้าจอ (บนมือถือ) หรือ ใช้คีย์บอร์ด ปุ่มลูกศร ซ้าย/ขวา
  • เกมไม่มีวันจบ จนกว่าจะกดปิดเกมเอง ^^

The Assets

Assets คือ รูปภาพ เสียง เอฟเฟคต่างๆ ที่ทำให้ตัวเกมออกมาดูดี สำหรับ Graphic ที่จะใช้กำหนด Resolution ให้กับหน้าจอ จะใช้ขนาด 800x480 pixels (สำหรับ Android ให้ตังโหมด landspace ที่ไฟล์ AndroidManifest.xml ด้วย) หากว่าเครื่องที่ใช้เล่นเกม มีขนาดหน้าจอไม่ตรงกับขนาดที่เรากำหนดไว้ เราจะทำการ scale ทุกอย่างให้พอดีกับหน้าจอ

สำหรับเม็ดฝนและถังน้ำ จะมีขนาด 64x64 pixels. ไฟล์ assets ต่างๆ สามารถหาดาวน์โหลดได้ตามนี้ครับ

Assets File

หลักจากโหลด assets ไฟล์ด้านบนครบหมดแล้ว ให้ก็อปปี้ทั้งหมดไปไว้ที่ assets ของโปรเจ็ค android ครับ drop-android/assets/ โดยจะมี 4 ไฟล์ด้วยกันคือ drop.wav, rain.mp3, droplet.png และ bucket.png ทำไมเราก็อปไฟล์ไปไว้แค่โฟลเดอร์ drop-android อันเดียวเอง หากเปิดบน Desktop หรือ HTML5 จะทำไง? จริงๆตัวโปรเจ็คเซ็ตไว้แล้ว ว่าให้ลิงค์ไปที่ assets ของ Android ครับ ฉะนั้นก็วาง assets ไว้ที่โฟลเดอร์เดียว แต่สามารถเรียกใช้ได้ทุก platform

Configuring the Starter Classes

มาถึงการตั้งค่าของคลาส Starter โดยจะใช้โปรเจ็ค Desktop นะครับ เปิด Main.java ใน drop-desktop/ ต้องการให้มีหน้าจอขนาด 800x480 และมีชื่อว่า Drop ฉะนั้นโค๊ดจะเป้นดังนี้

package com.badlogic.drop;

import com.badlogic.gdx.backends.lwjgl.LwjglApplication;
import com.badlogic.gdx.backends.lwjgl.LwjglApplicationConfiguration;

public class Main {
   public static void main(String[] args) {
      LwjglApplicationConfiguration cfg = new LwjglApplicationConfiguration();
      cfg.title = "Drop";
      cfg.width = 800;
      cfg.height = 480;
      new LwjglApplication(new Drop(), cfg);
   }
}

มาที่โปรเจ็คแอนดรอยส์ ต้องการเซตแอพพลิเคชันให้เปิดเฉพาะแนวนอน เราจะต้องแก้ไขไฟล์ AndroidManifest.xml ดังนี้ (จริงๆ ตัว libgdx-setup-tool.jar ตัวใหม่ ได้ setup ไว้ให้แล้ว คิดว่าไม่ต้องเปลี่ยนแปลงอะไร)

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.badlogic.drop"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="19" />
    <uses-feature android:glEsVersion="0x00020000" android:required="true" />

    <application
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name" >
        <activity
            android:name=".MainActivity"
            android:label="@string/app_name"
            android:screenOrientation="landscape"
            android:configChanges="keyboard|keyboardHidden|orientation">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

สำหรับโค๊ดด้านบน android:screenOrientation เซตค่าเป็น “landscape” เพื่อให้เปิดเกมแบบแนวนอน. หากต้องการให้เปิดเกมแบบแนวตั้ง ก็เซตค่าเป็น portrait

เพื่อประหยัดแบต ตัวเกมจะปิด accelerometer และ เข็มทิศ โดยแก้ไขไฟล์ MainActivity.java จะได้โค๊ดดังนี้

package com.badlogic.drop;

import android.os.Bundle;

import com.badlogic.gdx.backends.android.AndroidApplication;
import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration;

public class MainActivity extends AndroidApplication {
   @Override
   public void onCreate(Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);

      AndroidApplicationConfiguration cfg = new AndroidApplicationConfiguration();
      cfg.useAccelerometer = false;
      cfg.useCompass = false;

      initialize(new Drop(), cfg);
   }
}

จะเห็นว่า เราไม่สามารถกำหนด resolution ให้กับ Activity ได้เหมือนกับการกำหนดบน Desktop ตำ resolution ของแอนดรอยส์ มันถูกกำหนดโดย Operating System มันจะ scale ขนาดหน้าจอให้เอง ตามขนาดหน้าจอมือถือครับ

สุดท้าย แก้ไขที่โปรเจ็ค GWT ให้มีขนาด 800x480, เปิดไฟล์ GwtLauncher.java ใน drop-gwt/ จะได้โค๊ดประมาณนี้ (เวอร์ชั่น HTML5 จำเป็นต้องใช้ Plugin GWT และรันบน Browser หรือก็อปไฟล์ war ไปเปิด หากใครเคยเขียน java ก็คงคุ้นกับไฟล์ .war นะครับ )

package com.badlogic.drop.client;

import com.badlogic.drop.Drop;

public class GwtLauncher extends GwtApplication {
   @Override
   public GwtApplicationConfiguration getConfig () {
      GwtApplicationConfiguration cfg = new GwtApplicationConfiguration(800, 480);
      return cfg;
   }

   @Override
   public ApplicationListener getApplicationListener () {
      return new Drop();
   }
}

โอเค เราได้ทำการตั้งค่าคลาส Starter ทั้งหมดแล้ว ต่อไปก็มาดูที่ตัว main game กันครับ

The Code

โค๊ดสำหรับเกม จะใช้ไฟล์ Drop.java ที่อยู่ในโปรเจ็ค core drop-core/

Loading the Assets

เมื่อเปิดไฟล์ Drop.java มาแล้ว สิ่งแรกที่ต้องทำเลยก็คือ โหลดไฟล์ assets ขึ้นมา ไฟล์ assets ควรจะโหลดในเมธอด ApplicationListener.create() แบบนี้

public class Drop implements ApplicationListener {
   Texture dropImage;
   Texture bucketImage;
   Sound dropSound;
   Music rainMusic;

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

      ... more to come ...
   }

   // rest of class omitted for clarity

สำหรับตัวแปรที่ไว้โหลด assets ไฟล์ ได้ประกาศไว้ในคลาส Drop เพื่อจะไว้เรียกใช้ในภายหลัง. สองบรรทัดแรกในเมธอด create() คือการโหลดรูปฝนกับรูปตระกร้า. Texture ไว้สำหรับโหลดรูปภาพ โดยโหลดที่อยู่ของรูปภาพ ด้วย โมดูลของ LibGDX คือ Gdx.files มีหลายเมธอดให้เรียกใช้งาน แต่ในที่นี้เลือก internal เพื่อโหลดรูปภาพที่เก็บไว้ใน assets ของโปรเจ็ค Android นั่นเอง

ต่อไป ก็ทำการโหลดเอฟเฟคและเสียงเพลง background. Sound และ Music มีความเหมือนกันคือ โหลดเสียงเพลงและเล่นเสียงเพลง แต่มีความแตกต่างกัน คือ Sound จะโหลดมาเก็บไว้ในเมโมรี่ ส่วน Music จะถูกเล่นเลย ฉะนั้น หากเพลงมีความยาวไม่เกิน 10 วินาที ควรใช้ Sound และหากมีความยาวเกิน 10 วินาที ก็ควรใช้ Music

โหลด Sound หรือ Music โดยใช้ Gdx.audio.newSound() และ Gdx.audio.newMusic(). เมธอดทั้งคู่ใช้ FileHandle เหมือนกับ Texture

สุดท้ายที่ create() สั่งให้เล่น Music วนลูปเรื่อยๆ เมื่อเปิดแอพ ก็จะได้ยินเสียงฝนตกเรื่อยๆ จนกว่าจะปิดแอพ นั่นเอง

A Camera and a SpriteBatch

ต่อไปเราจะทำการสร้าง camera และ SpriteBatch กันครับ SpriteBatch เคยพูดถึงไปแล้วในบทความก่อนๆ คือมันเป็นคลาสที่ใช้วาดรูป 2D จาก Texture ที่ได้ทำการประกาศไว้

ก่อนอื่น เราแน่ใจว่าเราจะ render หน้าจอขนาด 800x480 ได้อย่างไม่มีปัญหา แม้ว่าจะเปิดมือถือหรือว่าคอมหลายๆหน้าจอก็ตาม โดยใช้ SpriteBatch และ camera

ผมได้สร้าง field ขึ้นมาใหม่ ชื่อว่า camera และ batch ดังนี้

   OrthographicCamera camera;
   SpriteBatch batch;

ในเมธอด create() ได้สร้าง camera ดังนี้:

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

การกำหนดแบบนี้ คือแน่ใจว่า camera จะโชว์ Game World ที่ขนาด 800x480 เสมอ ลองจินตนาการว่า camera นั้นเป็น Virtual Window ใน Game World ของเรา camera จะทำการ match หน้าจอต่างๆให้มี scale เป็น 800x480 ให้เราในหน่วย unit เอง ส่วนรายละเอียดเรื่อง OrthographicCamera เดี่ยวจะนำเสนอในบทความต่อๆไปครับ

ต่อไปสร้าง Spritebatch ยังคงอยู่ในเมธอด create()

   batch = new SpriteBatch();

ตอนนี้ก็สร้างทุกๆอย่างเกือบเสร็จแล้วสำหรับเทส Simple Game

เพิ่มรูปถังน้ำ

สำหรับรูปถังน้ำ จะมีรายละเอียดที่ยังไม่ได้พูดถึง ดังนี้

  • ถังน้ำและฝน จะมีตำแหน่ง x, y ในหน่วย 800x480 ของ Game World
  • ถังน้ำและฝน จะมีขนาดความกว้าง ความยาว
  • ถังน้ำและฝน นั้นเป็น Graphic เราจึงโหลดรูปด้วย Texture

ตามที่อธิบายไว้ด้านบน ถังน้ำหรือฝน มันจะมีตำแหน่งและขนาดของมันอยู่. LibGDX นั้นมีคลาส Rectangle เราจะใช้คลาสนี้กันครับ เพื่อแทนตำแหน่งของถังน้ำ, ทำการประกาศคลาส Rectangle ชื่อ bucket ตามนี้

Rectangle bucket;

ในเมธอด create() สร้างออปเจ็ค Rectangle ขึ้นมา และกำหนดค่าที่ต้องการ โดยให้มันมีขนาดความกว้างและความยาว ขนาด 64 หน่วย โดยมีตำแหน่งแกน x ที่กึ่งกลางหน้าจอ และตำแหน่ง y ที่ 20 จากขอบล่างของจอ

bucket = new Rectangle();
bucket.x = 800 / 2 - 64 / 2;
bucket.y = 20;
bucket.width = 64;
bucket.height = 64;

อย่างที่เคยบอกไปแล้ว ว่า LibGDX(OpenGL) นั้น ตำแหน่ง x, y จะนับตำแหน่ง 0, 0 เริ่มจากขอบล่างซ้ายของหน้าจอ

Rendering ถังน้ำ

ถึงเวลาที่จะ render ถังน้ำของเรา เพื่อแสดงบนหน้าจอกันแล้ว แต่ก่อนอื่น ต้องทำการ clear หน้าจอก่อน โดยใช้สี dark blue, เมธอด ‘render()` จะเป็นดังนี้

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

      ... more to come here ...
   }

จากโค๊ดด้านบน มันคือการเคลียร์หน้าจอ โดยเซตสีตามฟอแมตดังนี้ (สีแดง, น้ำเงิน, เขียว, alpha) โดยมีหน่วยตั้งแต่ [0, 1]

ต่อไปเราจะเซ้ตให้ camera นั้นอัพเดท โดย camera นั้นใช้การคำนวณทางคณิตศาสตร์ เราเรียกว่า matrix ซึ่งไว้สำหรับตั้งค่าพิกัดแกน x, y สำหรับ render , camera ควรจะมีการอัพเดท ทุกๆครั้งเมื่อมีการเปลี่ยน frame หรือก็คือ ทุกๆครั้งที่เมธอด render() ถูกเรียก

camera.update();

ต่อไป เรา render ถังน้ำ ด้วยคำสั่งด้านล่างนี้

batch.setProjectionMatrix(camera.combined);
batch.begin();
batch.draw(bucketImage, bucket.x, bucket.y);
batch.end();

จากด้านบน อย่าสับสนระหว่าง bucketImage และ bucket นะครับ bucket นั้นเป็นเพียงแค่คลาส Rectangle ที่เราประกาศไว้ เพื่อให้ถังน้ำนั้นมีตำแหน่ง x, y ส่วน bucketImage นั้นคือรูปภาพที่โหลดโดยใช้ Texture

เริ่มแรก เราทำการเซ้ทเพื่อให้ระบบรู้ว่า จะให้ SpriteBatch ใช้พิกัดของ camera โดย camera.combined คือสั่งให้ SpriteBatch ทำการ render ทุกๆอย่าง

หากต้องการวาดหรือ render ต่างๆ ต้องใช้คำสั่งให้อยู่ระหว่าง SpriteBatch.begin() และ SpriteBatch.end() เมื่อ SpriteBatch.end() ถูกเรียก มันถึงก็จะส่งค่าการวาดต่างๆ ไปให้ OpenGL ทำการวาด

หลังจาก render แล้ว เมื่อลองเปิดแอพดูจะได้ลักษณะนี้ ตอนนี้แอพเราก็สามารถแสดงถังน้ำได้แล้วนะครับ แต่ยังไม่สามารถบังคับมันได้ สำหรับวิธีการบังคับนั้น ผมจะมาพูดอธิบายต่อในอีกบทความนะครับ สำหรับบทความนี้ขอจบเพียงเท่านี้ก่อนครับ ส่วนใครอยากลองให้มันขยับได้ ก็ลองๆเล่นๆพวก Gdx.input.* ดูครับ แล้วจะรู้ว่า LibGDX นั้นมันไม่ยากเลย ^^

อันนี้เป็นหน้าจอเมื่อรันบน Desktop (มีเสียงฝนตกด้วยนะ^^) Drop Desktop

ส่วนอันนี้เป็นหน้าจอเมื่อรันบน Android Drop Android

โค๊ดทั้งหมด

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;

   @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;

   }


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

   @Override
   public void dispose() {
   }

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

   @Override
   public void pause() {
   }

   @Override
   public void resume() {
   }
}

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

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