libGDX เขียนเกม part 5: Sprite Sheets และ Game DeltaTime
มารู้จัก Sprite Sheets เนื่องจากการโหลด sprite sheet texture มีผลต่อการจัดการหน่วยความจำของ GPU โอกาสนี้เราจะนำ sprite sheet มาแยกและจัดกลุ่มท่าทาง รวมถึงการควบคุมท่าทางโดยนำค่า delta time มาช่วยครับ
ความเดิมจากตอนที่แล้ว
เรารู้จัก
- Texture
- SpriteBatch
- Screen width and Screen height
Texture คืออะไร? คือพื้นผิว คือรูปภาพ (เช่น .PNG) ที่ถูกโหลด (ถูก decode) เข้าสู่หน่วยความจำของ GPU (Graphics Processing Unit) โดยถูกคำนวณทางเรขาคณิต สมมติว่าเรขาคณิตนี้เป็นรูปสี่เหลี่ยมผืนผ้า แต่ละมุมของ texture ก็จะต้องวางลงพอดีกับแต่ละมุมของรูปสี่เหลี่ยมผืนผ้านั่นเอง การคำนวณค่านี้ libGDX ใช้ความสามารถของ OpenGL Viewport ครับ
นั่นหมายความว่า viewport กับ screen คนละเรื่องกัน ข่าวดีก็คือเราสามารถกำหนดค่าของ viewport ตามที่ต้องการได้ และข่าวดีกว่านั้นก็คือระบบเกม 2D ทั่วไปค่าของ viewport จะเท่ากับ screen อยู่แล้ว (by default) ทีนี้เวลานึกจะวาด texture ก็วาดลงบน viewport ครับ
อ่อเกือบลืมบอกไป Texture สามารถแบ่งออกเป็นส่วนย่อยได้ เรียกส่วนย่อยนี้ว่า Texture Region
สมมติเรามีตัวละครในเกม 2 ตัว ชื่อ Glen กับ Reona (อุ้ยฟิน~)
Glen เป็นอัศวินมังกร เขาเป็น 1 ใน 7 ขุนพลแห่งยุคซึ่งได้รู้จักกับเจ้าหญิงน้อย Reona
อายุไขของนักรบมังกรนั้นยาวนาน ในปีที่ 300 ขวบของเขา ชายหนุ่มก็ได้พบกับเจ้าหญิงวัย 10 ขวบและสัญญาว่าจะปกป้องเธอตลอดไป
Reona ไม่เพียงเอาแต่ใจ นางยังปรารถนาดอกไม้แห่งปีศาจ เชื่อว่าชะลอความแก่เฒ่า เผ่าพันธ์ุมนุษย์จะได้มีอายุยืนยาวทั้งทรงพละกําลังไม่ต่างจากเผ่าพันธ์ุมังกร
“เพื่อข้าจะได้อยู่ให้ท่านรับใช้ตราบนานเท่านาน” เจ้าหญิงกล่าวกับอัศวิน
เอาล่ะครับ เนื้อเรื่องประมาณนี้ อัศวินออกตามหาดอกไม้แห่งปีศาจ เวลานี้ผมอยากให้รู้จักรูปภาพที่เรียกว่า Sprite Sheet
Sprite Sheets
คำว่า Sprite คือภาพบิตแม็ปสองมิติ (2D bitmap) ฉะนั้น Sprite Sheet ก็คือรูปภาพจำนวนหนึ่งที่จัดวางในรูปของตารางหรือจะมองเป็นแผ่นกระเบื้องหลายแผ่นมาเรียงต่อกันก็ได้ โดยแต่ละแผ่นก็มีหนึ่งภาพ เราจึงเห็นว่าหนึ่ง sprite sheet ประกอบด้วยรูปภาพหลากหลายท่าทาง ตัวอย่าง
ระหว่างรูปภาพหลายรูปในหลายไฟล์ กับรูปภาพหลายรูปในไฟล์เดียว ในการคำนวณและจัดการหน่วยความจำของ GPU นั้น รูปภาพหลายรูปในไฟล์เดียวทำได้ดีกว่าและเร็วกว่าครับ เช่นกัน sprite sheet ของท่าทางของตัวละครหนึ่งตัวในหนึ่งไฟล์ย่อมดีกว่าแต่ละท่าทางที่แยกออกหลายไฟล์
มาโหลด sprite sheet กันเถอะ
ขอให้เครดิต sprite sheet นี้แก่
ขอขอบคุณ sprite sheet สวยๆของคุณ chimericalbard
เพื่อนๆสามารถโหลด sprite sheet ตามตัวอย่างใน part นี้ได้ที่นี่
ก่อนจะโค้ดอยากให้รู้จักกับคลาส SpriteBatch อีกสักเล็กน้อย
SpriteBatch อย่างที่เพื่อนๆได้รู้จักมา มันใช้วาดรูปภาพลงบน screen เริ่มด้วย begin และสิ้นสุด้วย end คลาสนี้จะทำงานโดยเลือกรูปภาพที่แตกต่างจากก่อนหน้านี้ส่งให้กับ GPU เพื่อลดการคำนวณค่าทางเรขาคณิตที่ไม่จำเป็น (ไม่ให้ GPU ทำงานหนักเกินไป) เหตุนี้เราจึงสามารถสร้างอัลกอริทึมระหว่าง begin และ end โดยไม่ต้องกังวลถึงการทำงาน (หนัก) ของ GPU มากนัก
ถ้าถามว่า GPU ทำงานลดลง แล้วงานนั้นที่เป็นของ SpriteBatch ใครทำ? คำตอบคือ CPU ครับ
เอาล่ะ ถึงเวลาโค้ดกันแล้ว
Playing Glen Sprite Sheet
เมื่อโหลดมาแล้วจะได้ folder ชื่อ PixelChampionsV2 เบื้องต้นนี้เรายังเป็นมือใหม่ แนะนำให้ copy ทั้ง folder ไปวางไว้ที่ folder ชื่อ assets ของโปรเจกต์เลย
ตรงไปที่คลาส Main แล้วกำหนด path แก่ตัวแปร texture ใน create state
override fun create() {
texture = Texture("PixelChampionsV2/Overworld/Glenys-the-Demonswordsman.png")
batch = SpriteBatch()
}
วาดใน render state
override fun render() {
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT)
Gdx.gl.glClearColor(1F, 1F, 1F, 1F)
batch.begin()
batch.draw(texture, 0f, 0f)
batch.end()
}
ขอให้สังเกตว่าหลังตัวเลขได้เพิ่ม F หรือ f ลงไป มีความหมายว่า Float เหมือนกันทั้งคู่ หมายถึงค่าที่มีจุดทศนิยมได้ จำเป็นต้องใส่เพื่อให้ชนิดของข้อมูลเหมือนกันระหว่างเลข 0 เฉยๆที่ถูก Kotlin ตีความเป็น Int ให้เป็น Float ซึ่งฟังก์ชัน draw ต้องการชนิดข้อมูล Float
รัน กลับไม่พบอะไรเลย
นั่นเพราะที่ความสูงของ screen เท่ากับ 300px แสดงได้แค่บางส่วนที่เป็นพื้นที่ว่างของด้านล่างของไฟล์ที่เพิ่งโหลดมาเท่านั้น (ตำแหน่ง 0, 0 ของ screen อยู่มุมล่างซ้าย)
แก้ไขโดยการปรับขนาดของ screen เปิดไฟล์ DesktopMain.kt เปลี่ยนความกว้างและสูงตามขนาดจริงของภาพ
ตัวละคร Glen (ผมอยากให้เขาชื่อนี้ อิอิ) มีทั้งหมด 12 ท่าทางย่อย นี่คือ 1 texture ที่ประกอบด้วย 12 texture regions ถูกไหม (เราคุยเรื่องนี้กันไปแล้วตอนต้นของบทความ part นี้)
เราลองง่ายที่สุดในโลกก่อน ตัด (crop) มาแค่ 1 texture region พอ
ให้ประกาศตัวแปรใหม่ในระดับคลาสชื่อ textureRegion มีชนิดเป็น TextureRegion
package com.pros
import com.badlogic.gdx.ApplicationAdapter
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.Input
import com.badlogic.gdx.graphics.GL20
import com.badlogic.gdx.graphics.Texture
import com.badlogic.gdx.graphics.g2d.SpriteBatch
import com.badlogic.gdx.graphics.g2d.TextureRegion
class Main : ApplicationAdapter() {
lateinit var texture: Texture
lateinit var batch: SpriteBatch
lateinit var textureRegion: TextureRegion...}
ผมกะว่า (ลองเองเพราะไม่รู้ว่าจริงๆขนาดเท่าไร) แต่ละ region น่าจะมีความกว้างและสูงเท่ากับ 72 พิกเซล คลาส TextureRegion นี้ coordinate ต่างจาก screen โดย x และ y จะมีค่าเท่ากับ (0, 0) ตรงมุมบนซ้าย
ง่ายที่สุดในโลกก่อน ตัด (crop) มาแค่ 1 texture region ได้
- x เป็น 0
- y เป็น 0
- กว้าง 72 และสูง 72
override fun create() {
texture = Texture("PixelChampionsV2/Overworld/Glenys-the-Demonswordsman.png")
batch = SpriteBatch()
textureRegion = TextureRegion(texture, 0, 0, 72, 72)
}
วาด
override fun render() {
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT)
Gdx.gl.glClearColor(1F, 1F, 1F, 1F)
batch.begin()
batch.draw(textureRegion, 0f, 0f)
batch.end()
}
รัน
แล้วถ้าอยากได้รูปที่ 2 ที่หันหน้าไปทางซ้ายล่ะ อื่ม…ก็ต้อง
- x เป็น 0 เพราะยังเป็นท่าแรก
- y เป็น 72 เพราะความสูงเดิมคือ 72 และต้องการรูปที่ 2 (ที่หันหน้าไปทางซ้าย)
- กว้าง 72 และ สูง 72 เท่าเดิม
textureRegion = TextureRegion(texture, 0, 72, 72, 72)
แล้วถ้าอยากได้รูปที่ 3 ท่าแรกล่ะ
- x เป็น 0 เพราะยังเป็นท่าแรก
- y บวกเพิ่มจากเดิมอีก 72
- กว้าง 72 และ สูง 72 เท่าเดิม
textureRegion = TextureRegion(texture, 0, 72 + 72, 72, 72)
รูปสุดท้ายมา!
textureRegion = TextureRegion(texture, 0, 72 + 72 + 72, 72, 72)
เคลื่อนที่หน่อยได้ป๊ะ!
มาถึงตรงนี้ก็คงจะหยุดกันไม่อยู่แล้ว คันมือจริงๆ! แต่ขอให้ใจเย็นๆก่อนมิเช่นนั้นจะล่มกันไม่เป็นท่า โปรดสังเกตว่าแต่ละรูปจะมีท่าทางย่อย ตัวอย่างนี้แต่ละรูปประกอบไปด้วย 3 ท่าทางย่อย
- รูป 1 ท่าทางการเดินสลับเท้าไปข้างหน้า
- รูป 2 ท่าทางการเดินสลับเท้าซ้ายและขวาโดยหันหน้าไปทางซ้าย
- รูป 3 ท่าทางการเดินสลับเท้าขวาและซ้ายโดยหันหน้าไปทางขวา
- รูป 4 ท่าทางการเดินสลับเท้าโดยมองจากด้านหลัง
4 x 3 = 12 เท่ากับว่าต้องมี TextureRegion จำนวน 12 ออบเจ็กต์
แบบนี้
ให้รูปที่ 1 ใช้พิกัด 0, 0 ถึง 0, 2 แถวแรกทั้งหมด
ให้รูปที่ 2 ใช้พิกัด 1, 0 ถึง 1, 2 แถวที่สองทั้งหมด
ให้รูปที่ 3 ใช้พิกัด 2, 0 ถึง 2, 2 แถวที่สามทั้งหมด
และให้รูปที่ 4 ใช้พิกัด 3, 0 ถึง 3, 2 แถวสุดท้าย
ตารางลักษณะนี้เรียกว่า ตาราง 2 มิติ ภาษา Kotlin มีโครงสร้างข้อมูลลักษณะเดียวกันนี้เรียกว่า Array
สมบัติทั่วไปของ array คือใช้เก็บค่าของข้อมูลที่มีความสัมพันธ์กัน อาจมีชนิดข้อมูลเดียวกันหรือต่างกันก็ได้ (Kotlin ให้ความสำคัญกับชนิดข้อมูลมาก ดังนั้นมันจะเก็บชนิดข้อมูลเดียวกันเท่านั้น) เราสามารถอ้างอิงแต่ละตำแหน่งของข้อมูลที่ถูกเก็บไว้ด้วย index ซึ่งมีค่าเริ่มต้นที่ 0 และนับเพิ่มทีละ 1
ถ้าเราต้องการสร้างตัวแปรชื่อ Glen (ชื่อตัวละคร) เป็นชนิด array แทนแต่ละรูป ก็จะได้ array จำนวน 4 ตัวแปร ตัวอย่าง
val glenP1 = arrayOfNulls<TextureRegion>(3)
val glenP2 = arrayOfNulls<TextureRegion>(3)
val glenP3 = arrayOfNulls<TextureRegion>(3)
val glenP4 = arrayOfNulls<TextureRegion>(3)
โดยที่ P แทนคำว่า Picture มองเป็นตารางได้ดังนี้
เราลองง่ายที่สุดในโลกก่อน ตัด (crop) มาแค่ 1 texture region ใส่ในตัวแปร glenP1 พอ
override fun create() {
texture = Texture("PixelChampionsV2/Overworld/Glenys-the-Demonswordsman.png")
batch = SpriteBatch()
glenP1[0] = TextureRegion(texture, 0, 0, 72, 72)
glenP1[1] = TextureRegion(texture, 72, 0, 72, 72)
glenP1[2] = TextureRegion(texture, 72 + 72, 0, 72, 72)
}
override fun render() {
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT)
Gdx.gl.glClearColor(1F, 1F, 1F, 1F)
batch.begin()
batch.draw(glenP1[0], 0f, 0f)
batch.end()
}
ก็จะต้องได้ท่าทางเหมือนกับ รูปที่หนึ่งท่าแรก
และหากเพิ่มค่า index จาก 0 เป็น 1 ก็จะได้รูปที่ 1 ท่าที่ 2
override fun render() {
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT)
Gdx.gl.glClearColor(1F, 1F, 1F, 1F)
batch.begin()
batch.draw(glenP1[1], 0f, 0f)
batch.end()
}
สำหรับท่าที่ 3 ก็ให้เพิ่ม index ไปอีก 1
คราวนี้เราจะมาเล่น animation กันด้วยอัลกอริทึมนี้ครับ
fun getLoopIndex(index: Int, max: Int) = (index % max) + 1
ประกาศตัวแปรระดับคลาสชื่อ index
var index = 0
จากนั้นใส่โค้ด
override fun render() {
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT)
Gdx.gl.glClearColor(1F, 1F, 1F, 1F)
batch.begin()
batch.draw(glenP1[index], 0f, 0f)
batch.end()
index = getLoopIndex(index, glenP1.size - 1)
}
ลองรันในเครื่องแล้ว Glen จะซอยเท้าไวมาก หว่างขาลุกเป็นไฟกันพอดี!
น่าจะทำให้ช้ากว่านี้หน่อยนะ
รู้จัก Game Speed ไหม รู้จักค่าของเวลาที่เรียกว่า Delta Time กันเถอะ
Delta Time คือค่าเวลาระหว่างที่เริ่มต้นไปก่อนหน้านี้กับค่าเวลาที่เพิ่งเรียก render ตอนนี้ นำมาลบกัน จะได้ผลต่างเป็นเสี้ยววินาทีและไม่มีทางเท่ากับ 1 วินาที
ทั้งนี้ขึ้นอยู่กับความพึงพอใจของผู้สร้างเกม ว่าต้องการกำหนดค่าคงที่เท่าไรจึงจะเหมาะสมกับตัวเกมของเขา เพื่อจะได้นำค่าคงที่นี้ไปคำนวณกับ delta time อีกที เราเรียกอัลกอริทึมลักษณะนี้ว่า Game Speed
libGDX จัดเตรียม delta time ไว้ให้แล้ว
Gdx.graphics.deltaTime
ลองพิมพ์ออกมาดู
เราจะสร้างตัวแปรระดับคลาส 2 ตัวแปร ได้แก่
- ชื่อ DELAY_TIME มีค่าเท่ากับ 0.2f
- ชื่อ delayTime มีค่าเท่ากับ DELAY_TIME ข้างต้น
Game Speed ในที่นี้เกิดจากค่าของตัวแปร delayTime ลบออกด้วย delta time ที่ได้รับ เมื่อค่าของตัวแปร delayTime ถูกลบจนน้อยกว่าหรือเท่ากับ 0 นั่นหมายความว่าถึงเวลาที่ต้อง update ท่าทางของตัวละครแล้ว
val DELAY_TIME = 0.2f
var delayTime = DELAY_TIME
สร้างอัลกอริทึม
override fun render() {
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT)
Gdx.gl.glClearColor(1F, 1F, 1F, 1F)
batch.begin()
batch.draw(glenP1[index], 0f, 0f)
batch.end()
delayTime -= Gdx.graphics.deltaTime
if (delayTime <= 0) {
delayTime = DELAY_TIME
index = getLoopIndex(index, glenP1.size - 1)
}
}
รัน
ส่วนรูปและท่าทางที่เหลือของ glenP2, glenP3 และ glenP4 ก็ทำลักษณะไม่ต่างกัน
โค้ดทั้งหมด Main.kt
เป็นว่า part นี้ยาวมากจึงขอจบ ณ เท่านี้นะครับ โอกาสถัดไปเรามาเล่น move ตัวละครด้วยคีย์บอร์ดกันนะเพื่อนๆ