ตอนนี้คุณรู้จัก C# แบบเดี่ยว ๆ แล้ว ทั้ง class, object, generic, collection แต่ Unity ไม่ได้รัน class C# ธรรมดาเป็น script — มันรัน class ที่ inherit มาจาก base class พิเศษชื่อ MonoBehaviour บทนี้จะพาไปดูว่า MonoBehaviour ให้อะไรเราบ้าง: method ที่ Unity เรียกให้อัตโนมัติในจังหวะที่แน่นอน (เรียกว่า "lifecycle") field ที่กลายเป็นช่องแก้ไขได้ใน Editor และเครื่องมือพื้นฐานที่ script เกือบทุกตัวใน Unity ต้องใช้ — การ spawn object การอ่าน input และการรันโค้ดที่กินเวลาหลายวินาทีแทนที่จะจบในบรรทัดเดียว
script ทุกตัวที่แปะไว้บน GameObject (object ที่วางอยู่ใน scene ของ Unity) คือ class C# ธรรมดา ๆ ยกเว้นอย่างเดียวคือมัน inherit มาจาก MonoBehaviour ซึ่งเป็น class ที่ Unity เตรียมไว้ให้ การ inherit จาก MonoBehaviour ทำให้เกิดสองอย่าง: หนึ่งคือลาก script ไปแปะบน GameObject ใน Editor ได้เลย สองคือ class ของเราจะได้ method พิเศษชุดหนึ่งที่ Unity เรียกให้อัตโนมัติ โดยที่เราไม่ต้องเรียกเองเลย
using UnityEngine;
public class Player : MonoBehaviour
{
// This class does nothing extra yet, but because it inherits
// from MonoBehaviour, Unity already treats it as a "live" script:
// you can drag this file onto any GameObject in the Hierarchy.
}
เกิดอะไรขึ้น: ตอนนี้ยังไม่มีอะไร print ออก Console เพราะ body ของ class ว่างเปล่า แต่ใน Editor script ตัวนี้จะโผล่เป็น component ที่แปะได้แล้ว: เลือก GameObject แล้วลาก Player.cs ไปวางบน Inspector panel ของมัน Unity จะสร้าง instance ของ class นี้ขึ้นมาหนึ่งตัวให้ object นั้น จากจุดนี้ไป Unity เป็นคนคุมทั้งหมด ทั้งสร้าง รัน และสุดท้ายทำลาย instance นั้น เราไม่ต้องเขียน new Player() เองเลย
เทียบกับ class C# ธรรมดาที่เคยเขียนมาก่อน เช่น helper คำนวณเลขเล็ก ๆ หรือ node ของ linked-list พวกนั้นเราสร้างเองด้วย new และไม่มีใครเรียก method ของมันถ้าเราไม่เรียกเอง แต่ MonoBehaviour ต่างออกไป พอมันถูกแปะอยู่บน GameObject ที่อยู่ใน scene ที่กำลังรัน engine loop ของ Unity จะเรียก method บางตัวของมันอัตโนมัติ ในจังหวะที่กำหนดไว้แน่นอนทุก ๆ เฟรม method พวกนั้นคือเนื้อหาหลักของบทนี้
Transform) และ list ของ component ที่แปะอยู่ script ที่เป็น MonoBehaviour ก็คือ component ชนิดหนึ่ง GameObject ตัวเดียวมี component ได้หลายตัวพร้อมกัน เช่น Rigidbody สำหรับ physics, Collider สำหรับการชน, script ของเราเอง และอื่น ๆเพราะ Unity เป็นคนเรียก method ให้เรา เราจึงต้องรู้ให้ชัดว่ามี method อะไรบ้าง และแต่ละตัวรันตอนไหน รวม ๆ กันเรียกว่า "lifecycle" ของ script คือลำดับเหตุการณ์ตั้งแต่ object ถูกสร้างจนถูกทำลาย ถ้าจำลำดับผิด คุณจะเจอบั๊กที่โผล่มาเป็นบางครั้งเท่านั้น เช่น โค้ดที่อ่านค่าก่อนที่ script อีกตัวจะทันตั้งค่าให้
ทุกตัวข้างบนคือแค่ชื่อ method ที่คุณเขียนไว้ใน class ของตัวเอง ไม่จำเป็นต้องมีครบทุกตัว — Unity เรียกเฉพาะตัวที่คุณเขียนไว้จริง ๆ script ที่ไม่มี method Update ก็ไม่มีใครมาเรียก Update ให้ ไม่มีต้นทุนอะไรถ้าจะไม่เขียนมัน
หัวข้อถัด ๆ ไปจะพา method แต่ละตัวมาดูทีละตัว พร้อมโค้ดที่รันได้จริง
สาม method นี้ล้วนรันครั้งเดียวช่วงต้นชีวิตของ object แต่รันคนละจังหวะกัน และการสับสนลำดับของมันคือบั๊กที่มือใหม่เจอบ่อยมาก
Awake รันครั้งเดียว เร็วที่สุดเท่าที่จะเป็นไปได้ — ตอน object ถูกสร้าง (ตอน scene โหลด หรือตอนมีใครเรียก Instantiate ระหว่างเกมรัน) มันรันแม้ checkbox enabled ของ script เองจะไม่ได้ติ๊กไว้ใน Inspector ก็ตาม ใช้มันสำหรับการตั้งค่าที่ขึ้นกับตัว object เองเท่านั้น เช่น การ cache component reference (หัวข้อ 9 พูดถึงเรื่องนี้) การตั้งค่าเริ่มต้นภายใน
OnEnable รันครั้งเดียวทันทีหลัง Awake แล้วก็รันอีกทุกครั้งที่ object กลับมา active หลังจากเคยถูกปิด — เช่น script อื่นเรียก gameObject.SetActive(true) หลังจากที่มันเคยถูกตั้งเป็น false ใช้มันสำหรับอะไรก็ตามที่อยากให้ reset ทุกครั้งที่ object กลับมาเปิดใหม่ เช่นการ subscribe event
Start รันครั้งเดียว ก่อน Update ครั้งแรกของ object นี้ ความต่างสำคัญจาก Awake คือ Unity จะเรียก Awake ของ object ทุกตัวใน scene ให้ครบก่อน แล้วค่อยเริ่มเรียก Start ของตัวไหนก็ตาม นั่นแปลว่าใน Start คุณมั่นใจได้เลยว่า Awake ของ object อื่นทุกตัวรันไปแล้ว — มีประโยชน์เวลา script หนึ่งต้องอ่านค่าที่ script อีกตัวเพิ่งตั้งไว้ใน Awake ของมันเอง
using UnityEngine;
public class LifecycleLogger : MonoBehaviour
{
void Awake()
{
Debug.Log("Awake");
}
void OnEnable()
{
Debug.Log("OnEnable");
}
void Start()
{
Debug.Log("Start");
}
}
เกิดอะไรขึ้น: แปะ script นี้ลงบน GameObject ตัวไหนก็ได้แล้วกด Play Console จะพิมพ์ตามลำดับนี้เป๊ะ ๆ:
Awake
OnEnable
Start
ลำดับนี้ — Awake แล้ว OnEnable แล้ว Start — ไม่เปลี่ยนแปลงเลยสำหรับ object ปกติที่เริ่ม active อยู่ใน scene ตั้งแต่ต้น ควรจำไว้ให้ขึ้นใจ
Awake ก็ไม่รัน — เพราะ GameObject ที่ปิดอยู่คือหลับสนิท ทั้งสามจะรันพร้อมกันทันทีที่มีใครเรียก SetActive(true) ให้มัน ตรงนี้ต่างจาก checkbox enabled ของ component เอง: กรณีนั้น Awake ยังรันทันที แต่ OnEnable กับ Start จะรอจนกว่า component จะถูก enableUpdate คือ method ที่คุณจะใช้บ่อยที่สุด Unity เรียกมันครั้งเดียวต่อเฟรมที่ render — และแต่ละเฟรมใช้เวลาไม่เท่ากัน เครื่อง PC เกมมิ่งแรง ๆ อาจ render ได้ 300 เฟรมต่อวินาที ส่วนมือถือตอนโหลดหนัก ๆ อาจ render ได้แค่ 20 เฟรม Update รันครั้งเดียวทุกครั้ง ไม่ว่าเฟรมนั้นจะใช้เวลานานแค่ไหน
using UnityEngine;
public class Spinner : MonoBehaviour
{
void Update()
{
transform.Rotate(0f, 2f, 0f); // turn 2 degrees every call to Update
}
}
เกิดอะไรขึ้น: แปะ script นี้ลงบนลูกบาศก์แล้วกด Play — มันจะหมุน แต่ลองสังเกตดี ๆ: บนเครื่องที่ render ได้ 60 เฟรมต่อวินาที Update ถูกเรียก 60 ครั้งต่อวินาที ลูกบาศก์เลยหมุน 60 x 2 = 120 องศาต่อวินาที บนเครื่องที่ช้ากว่าซึ่ง render ได้แค่ 30 เฟรมต่อวินาที โค้ดชุดเดียวกันเป๊ะกลับหมุนแค่ 30 x 2 = 60 องศาต่อวินาที script เดียวกันวิ่งด้วยความเร็วที่ตาเห็นต่างกัน ขึ้นอยู่กับว่าเครื่องแรงแค่ไหน นั่นคือบั๊ก — หัวข้อ 7 จะแก้ให้
ตอนนี้จำไว้ก่อนว่าอะไรควรอยู่ใน Update: การอ่าน input, logic ของเกม, ตัวจับเวลา, อะไรก็ตามที่ไม่ใช่ physics ส่วน physics ไปอยู่ใน method ถัดไป
FixedUpdate ต่างจาก Update ตรงจุดสำคัญจุดหนึ่ง: มันไม่ได้รันครั้งเดียวต่อเฟรมที่ render มันรันบนนาฬิกาของตัวเองที่คงที่ ตาม default คือทุก 0.02 วินาทีของเวลาเกม — นั่นคือ 50 ครั้งต่อวินาทีพอดี — ไม่ว่าเครื่องจะ render เร็วหรือช้าแค่ไหนก็ตาม physics engine ของ Unity (Rigidbody, collider, แรงต่าง ๆ) ต้องการ timestep ที่นิ่งและคาดเดาได้แบบนี้เพื่อให้แม่นยำและสม่ำเสมอ ดังนั้นอะไรก็ตามที่เกี่ยวกับ physics ควรอยู่ตรงนี้ ไม่ใช่ใน Update
using UnityEngine;
public class ConstantThrust : MonoBehaviour
{
public float thrust = 10f;
Rigidbody rb;
void Awake()
{
rb = GetComponent<Rigidbody>();
}
void FixedUpdate()
{
rb.AddForce(Vector3.forward * thrust);
}
}
เกิดอะไรขึ้น: แปะ script นี้ลงบน object ที่มี component Rigidbody แล้วกด Play — มันจะเร่งความเร็วไปข้างหน้าอย่างนุ่มนวล เพราะ FixedUpdate รันบนนาฬิกาของตัวเอง มันจึงไม่ได้ตรงกับเฟรมที่ render แบบหนึ่งต่อหนึ่งเสมอไป: ที่ 50 FPS คงที่ มันตรงกันเกือบพอดีหนึ่งครั้งต่อเฟรม ที่ 100 FPS ประมาณครึ่งหนึ่งของเฟรมจะไม่มี FixedUpdate เรียกเลย ที่ 20 FPS Unity จะเรียก FixedUpdate สองหรือสามครั้งติดกันเพื่อ "ตามให้ทัน" ก่อนจะวาดเฟรมถัดไป นี่คือ "0, 1 หรือหลายครั้ง" จาก diagram ในหัวข้อ 2 นั่นเอง
FixedUpdate ได้จาก Project Settings หมวด Time ที่ช่อง Fixed Timestep ค่า default 0.02 วินาทีคือจุดสมดุลที่ดีระหว่างความแม่นยำของ physics กับต้นทุน CPU สำหรับเกมส่วนใหญ่LateUpdate ก็รันครั้งเดียวต่อเฟรมเหมือนกัน แต่รันหลัง Update ของ object ทุกตัวจบไปแล้วเสมอสำหรับเฟรมนั้น Unity ไม่การันตีว่า Update ของ script คนละตัวจะรันตามลำดับไหน — script เคลื่อนที่ผู้เล่นกับ script กล้องของคุณอาจรันก่อนหลังสลับกันไปในแต่ละเฟรมก็ได้ LateUpdate มีไว้แก้ปัญหานี้พอดี สำหรับกรณีอย่างกล้องที่ต้องตอบสนองตามตำแหน่งสุดท้ายของผู้เล่นเสมอ ไม่ใช่ตำแหน่งเมื่อครู่
using UnityEngine;
public class CameraFollow : MonoBehaviour
{
public Transform target;
public Vector3 offset = new Vector3(0f, 5f, -10f);
void LateUpdate()
{
transform.position = target.position + offset;
}
}
เกิดอะไรขึ้น: แปะ script นี้ลงบนกล้องหลัก (Main Camera) ลาก Transform ของผู้เล่นไปใส่ target แล้วกด Play — กล้องจะอยู่ด้านหลังและด้านบนผู้เล่นอย่างนุ่มนวล ถ้าโค้ดนี้รันใน Update แทน จะมีบางเฟรมที่กล้องบังเอิญอัปเดตก่อนผู้เล่นขยับ ทำให้เห็นตำแหน่งเก่าของผู้เล่นอยู่หนึ่งเฟรม — อาการกระตุกเล็ก ๆ ที่เห็นได้ชัดโดยเฉพาะตอนเฟรมเรตต่ำ การรันใน LateUpdate การันตีว่าผู้เล่นขยับเสร็จแล้วในเฟรมนี้ก่อนที่กล้องจะก็อปตำแหน่งไป
หัวข้อ 4 โชว์บั๊กของลูกบาศก์หมุนไปแล้ว: โค้ดชุดเดียวกันเป๊ะทำให้ object หมุนด้วยความเร็วจริงต่างกันตามเฟรมเรต ทางแก้คือ Time.deltaTime — ตัวเลขที่ Unity ส่งให้ทุกเฟรม เท่ากับจำนวนวินาทีจริงที่ผ่านไปตั้งแต่เฟรมก่อนหน้า (เป็นตัวเลขเล็ก ๆ ประมาณ 0.0166 ที่ 60 FPS ประมาณ 0.0333 ที่ 30 FPS)
using UnityEngine;
public class Spinner : MonoBehaviour
{
public float degreesPerSecond = 120f;
void Update()
{
transform.Rotate(0f, degreesPerSecond * Time.deltaTime, 0f);
}
}
เกิดอะไรขึ้น: ตอนนี้ลูกบาศก์จะหมุน 120 องศาทุกวินาทีจริงเสมอ ไม่ว่าเฟรมเรตจะเป็นเท่าไหร่
เคล็ดลับคือ: "จำนวนคงที่ต่อครั้งที่เรียก" จะกลายเป็น "จำนวนคงที่ต่อวินาที" ทันทีที่คุณคูณมันด้วยจำนวนวินาทีจริงที่การเรียกครั้งนั้นครอบคลุม เฟรมที่ใช้เวลานานเป็นสองเท่าจะได้ deltaTime ที่ใหญ่เป็นสองเท่า มันเลยขยับ object ไปไกลเป็นสองเท่าเพื่อชดเชย — ระยะทางรวมที่เคลื่อนที่ต่อวินาทีจริงจึงเท่าเดิมเสมอ
นี่คือแนวคิดเดียวกันเป๊ะกับที่คุณเคยใช้ตอน interpolate ระหว่างค่าสองค่า คือผสมจากค่าเริ่มต้นไปค่าปลายทางด้วยเศษส่วน t ระหว่าง 0 กับ 1 ตรงนั้น t ต้องโตขึ้นด้วยอัตราคงที่ตามเวลาจริง ไม่ใช่ตามจำนวนเฟรม ไม่งั้นการผสมค่าจะเร็วขึ้นช้าลงตามเฟรมเรตไปด้วย Time.deltaTime คือวิธีสร้างการโตขึ้นแบบคงที่นั้น: ทุกเฟรมคุณบวกเศษเวลาจริงเล็ก ๆ เข้าไป t += Time.deltaTime / duration แทนที่จะบวกเศษคงที่ต่อการเรียกหนึ่งครั้ง การเคลื่อนที่ที่ไม่ขึ้นกับเฟรมเรตและการ interpolate ที่ไม่ขึ้นกับเฟรมเรตแก้ด้วยกฎเดียวกัน: อย่าสมมติเองว่าหนึ่ง "ก้าว" กินเวลาเท่าไหร่ — ถาม Time.deltaTime เสมอว่าเวลาผ่านไปจริงเท่าไหร่
FixedUpdate ไม่ต้องใช้เทคนิคนี้กับแรง physics เพราะ timestep ของมันคงที่และคาดเดาได้อยู่แล้ว — นั่นคือเหตุผลทั้งหมดที่มันมีอยู่ คุณจะยังเห็น Time.fixedDeltaTime ถูกใช้ใน FixedUpdate บ้างในบางกรณี แต่สำหรับการเรียก AddForce ธรรมดาแบบในหัวข้อ 5 physics engine ของ Unity จัดการเรื่อง fixed timestep ให้คุณอยู่แล้วUnity แปลง field บางตัวของ MonoBehaviour ให้กลายเป็นช่องแก้ไขได้ใน Inspector panel โดยอัตโนมัติ — ไม่ต้องเขียนโค้ด UI เพิ่มเลย มีสองวิธีทำแบบนี้:
using UnityEngine;
public class Health : MonoBehaviour
{
public int maxHealth = 100;
[SerializeField]
private int currentHealth = 100;
}
เกิดอะไรขึ้น: เลือก GameObject ที่แปะ script นี้ Inspector จะโชว์สองช่องที่แก้ไขได้คือ "Max Health" กับ "Current Health" (Unity แปลงชื่อ field แบบ camelCase ให้เป็น label อ่านง่ายให้เองอัตโนมัติ) เริ่มต้นทั้งคู่ที่ 100 แก้ไขได้ด้วยมือหรือโดยดีไซเนอร์โดยไม่ต้องแตะโค้ดเลย
ความต่างระหว่างสองแบบนี้โผล่ออกมาข้างนอก Inspector ใน script อื่น:
public int maxHealth โชว์ใน Inspector และ script อื่นตัวไหนก็อ่านหรือเขียนทับมันได้ตรง ๆ เช่น otherScript.maxHealth = 9999; จากที่ไหนก็ได้[SerializeField] private int currentHealth โชว์ใน Inspector เหมือนกันเป๊ะ แต่ยังคง private — มีแค่โค้ดที่เขียนอยู่ใน class Health เองเท่านั้นที่อ่านหรือเปลี่ยน currentHealth ตรง ๆ ได้[SerializeField] private ไว้ก่อนเป็นค่าเริ่มต้น จะทำ field ให้เป็น public ก็ต่อเมื่อ script อื่นต้องอ่านหรือเขียนมันตรง ๆ จริง ๆ เท่านั้น — และถึงตอนนั้น public method (เช่น TakeDamage(int amount)) ก็มักจะปลอดภัยกว่า public field ตรง ๆ เพราะมันให้คุณควบคุมได้ว่าค่าจะเปลี่ยนได้แบบไหนบ้างGetComponent<T>() ค้นหา component ชนิด T บน GameObject แล้วคืนค่ามันกลับมา (หรือ null ถ้าไม่มี) มันคือวิธีที่ script หนึ่งเข้าถึง component อีกตัวที่อยู่บน object เดียวกัน — เช่น script เคลื่อนที่ที่ต้องเข้าถึง Rigidbody ของ object นั้น
using UnityEngine;
public class BadMover : MonoBehaviour
{
void Update()
{
// Do not do this: GetComponent searches all over again,
// every single frame, for a component that never changes.
GetComponent<Rigidbody>().AddForce(Vector3.up);
}
}
เกิดอะไรขึ้น: โค้ดนี้ทำงานถูกต้อง แต่ GetComponent ไม่ใช่ของฟรีที่เร็วทันที มันไล่ดู list ของ component ที่แปะอยู่บน GameObject เทียบ type ทีละตัวจนกว่าจะเจอที่ตรงกัน การทำแบบนั้น 60 ครั้งขึ้นไปต่อวินาที เพื่อผลลัพธ์ที่เหมือนเดิมเป๊ะทุกครั้ง คือการเสียเวลา CPU ที่ควรเอาไปใช้กับอะไรที่เปลี่ยนแปลงจริง ๆ ในแต่ละเฟรม
using UnityEngine;
public class GoodMover : MonoBehaviour
{
Rigidbody rb; // cached reference, found once
void Awake()
{
rb = GetComponent<Rigidbody>(); // search happens ONCE, here
}
void Update()
{
rb.AddForce(Vector3.up); // reuse the stored reference, no searching
}
}
เกิดอะไรขึ้น: พฤติกรรมในเกมเหมือนเดิมทุกอย่าง แต่การค้นหาจะรันแค่ครั้งเดียวใน Awake (ซึ่งหัวข้อ 3 บอกไว้แล้วว่ารันก่อน Update เสมอ) ทุกเฟรมถัดไปแค่ใช้ field rb ที่เก็บไว้ซ้ำ ซึ่งแทบไม่มีต้นทุนอะไรเลย
GetComponent ใน Update คือความผิดพลาดด้านประสิทธิภาพที่มือใหม่เจอบ่อยที่สุดอย่างหนึ่ง มันไม่มีผลอะไรใน scene ทดสอบเล็ก ๆ ที่มี object เดียว แต่ต้นทุนจะบวกเพิ่มขึ้นเร็วมากทันทีที่ scene มี object หลายร้อยตัวที่ทำแบบนี้ทุกเฟรม cache ไว้ใน Awake (หรือ Start) แล้วใช้ field ที่ cache ไว้ทุกที่หลังจากนั้นเกมส่วนใหญ่สร้างและลบ object ตลอดเวลาระหว่างที่รัน — กระสุน ศัตรู ไอเทมเก็บได้ เอฟเฟกต์ระเบิด Instantiate สร้างสำเนาใหม่ของ object ที่มีอยู่แล้วขึ้นมาระหว่างเกมรัน Destroy ลบ object ออก ทั้งคู่ทำงานบน template ที่เรียกว่า prefab: GameObject ที่คุณตั้งค่าไว้ครั้งเดียวใน Editor (โมเดล, script, component ครบทุกอย่าง) แล้วเซฟเป็น asset ที่ใช้ซ้ำได้ใน Project window เพื่อให้คุณปั๊มสำเนาออกมาได้เท่าที่ต้องการระหว่างเกมรัน
using UnityEngine;
public class Gun : MonoBehaviour
{
public GameObject bulletPrefab; // drag the Bullet prefab here in the Inspector
public Transform muzzle; // an empty child Transform at the gun's tip
void Update()
{
if (Input.GetButtonDown("Fire1"))
{
GameObject bullet = Instantiate(bulletPrefab, muzzle.position, muzzle.rotation);
Destroy(bullet, 3f); // remove this exact bullet after 3 seconds
}
}
}
เกิดอะไรขึ้น: ทุกครั้งที่ปุ่ม Fire1 (ปุ่มซ้ายของเมาส์ตาม default) ถูกกด Instantiate จะสร้างสำเนาใหม่ของ prefab กระสุนขึ้นมาหนึ่งตัวใน scene วางตำแหน่งและหมุนตรงตาม muzzle เป๊ะ กระสุนตัวใหม่นั้นเป็น GameObject ที่เป็นอิสระจากกันโดยสิ้นเชิงตั้งแต่จุดนั้นไป — ขยับมันไม่ทำให้ prefab asset ขยับตาม และยิงอีกครั้งก็สร้างสำเนาแยกอีกตัวขึ้นมา สามวินาทีต่อมา Destroy(bullet, 3f) จะลบกระสุนตัวนั้นออกจาก scene ให้อัตโนมัติ
Destroy(bullet) เฉย ๆ โดยไม่มี delay ไม่ได้ลบ object ทันทีตรงบรรทัดนั้นเลย — มันแค่ทำเครื่องหมายไว้ว่าจะลบตอนจบเฟรมปัจจุบัน ความล่าช้าเล็กน้อยนี้ตั้งใจให้เป็นแบบนั้น: โค้ดอื่นที่เผลอถือ reference ของ bullet ไว้ก่อนหน้านี้ในเฟรมเดียวกันจะไม่จู่ ๆ เจอ object ที่ถูกลบไปครึ่งหนึ่ง
Destroy ให้มันเลย ทุกครั้งที่เขียนคำสั่ง Instantiate ให้ถามตัวเองทันทีว่า object นี้จะถูก destroy เมื่อไหร่และยังไงclass Input แบบดั้งเดิมของ Unity คือวิธีง่ายที่สุดในการอ่านคีย์บอร์ด เมาส์ และเกมแพด สาม method นี้ครอบคลุมการใช้งานส่วนใหญ่:
Input.GetKeyDown(KeyCode.Space) / GetKeyUp / GetKey — การกดปุ่มพอดี, การปล่อยปุ่ม, และสถานะกดค้างInput.GetButtonDown("Fire1") — action ที่ตั้งชื่อไว้ใน Project Settings เหมาะกับ action ที่ทำครั้งเดียวจบ เช่นการยิงหรือกระโดดInput.GetAxis("Horizontal") / "Vertical" — ค่าที่ถูก smooth มาให้แล้ว ตั้งแต่ -1 ถึง 1 ที่ผสมทั้งคีย์บอร์ดและเกมแพดเข้าด้วยกันแล้วusing UnityEngine;
public class TopDownMover : MonoBehaviour
{
public float speed = 5f;
void Update()
{
float h = Input.GetAxis("Horizontal"); // -1 (A / Left) to +1 (D / Right)
float v = Input.GetAxis("Vertical"); // -1 (S / Down) to +1 (W / Up)
Vector3 move = new Vector3(h, 0f, v) * speed * Time.deltaTime;
transform.Translate(move);
}
}
เกิดอะไรขึ้น: กดค้าง D ทำให้ object ขยับไปทาง X บวก กดค้าง W ทำให้ขยับไปทาง Z บวก กดพร้อมกันสองปุ่มก็ขยับในแนวทแยง — ทั้งหมดที่ความเร็วคงที่ speed หน่วยต่อวินาที ด้วยเทคนิค Time.deltaTime เดียวกันกับหัวข้อ 7
Unity ยังมี "Input System" package รุ่นใหม่ที่ยืดหยุ่นกว่า เหมาะกับการรีแมปปุ่มและผู้เล่นหลายคน แต่ class Input แบบดั้งเดิมที่โชว์ตรงนี้ไม่ต้องตั้งค่าเพิ่มอะไรเลย และเพียงพอสำหรับสร้างและเข้าใจทุกอย่างในหนังสือเล่มนี้
ทุกอย่างที่ผ่านมารันบนลงล่างภายในเฟรมเดียว บางครั้งคุณอยากให้โค้ดกินเวลาหลายวินาที — เอฟเฟกต์กะพริบ ดีเลย์ก่อนศัตรูโจมตี ตัวนับถอยหลัง — โดยไม่หยุดเกมทั้งเกมระหว่างที่รอ นั่นคือหน้าที่ของ coroutine: method ที่หยุดกลางคันได้แล้วกลับมาทำต่อทีหลังด้วยตัวเอง ในขณะที่ทุกอย่างอื่นยังรันตามปกติ
using UnityEngine;
using System.Collections;
public class Blinker : MonoBehaviour
{
public Renderer targetRenderer;
void Start()
{
StartCoroutine(Blink());
}
IEnumerator Blink()
{
while (true)
{
targetRenderer.enabled = false;
yield return new WaitForSeconds(0.2f);
targetRenderer.enabled = true;
yield return new WaitForSeconds(0.2f);
}
}
}
เกิดอะไรขึ้น: Start เรียก StartCoroutine(Blink()) ครั้งเดียว ซึ่งเริ่มรัน Blink renderer จะปิดตัวลง แล้ว yield return new WaitForSeconds(0.2f) หยุด เฉพาะ method นี้เท่านั้น ไว้ 0.2 วินาทีจริง — ส่วนที่เหลือของเกม รวมถึง Update ของ object ตัวนี้เองถ้ามี ยังรันตามปกติตลอดเวลานั้น พอครบ 0.2 วินาที การทำงานจะกลับมาต่อที่บรรทัดถัดไปทันที เปิด renderer กลับมา รออีกครั้ง แล้ววนไปเรื่อย ๆ — กะพริบสม่ำเสมอ โดยไม่มีตัวแปรจับเวลาที่ต้องจัดการเองเลยสักตัว
method ที่คืนค่าเป็น IEnumerator คือสิ่งที่ทำให้ coroutine เป็นไปได้: แทนที่จะรันจบตั้งแต่ต้นจนจบในทีเดียวแบบ method ธรรมดา มันถูกสร้างมาให้ Unity หยุดมันได้ที่ทุก yield return แล้วกลับมาทำต่อตรงจุดนั้นทีหลัง คุณไม่เรียก Blink() ตรง ๆ แบบ method ธรรมดาเลย — ต้องเรียกผ่าน StartCoroutine เสมอ ซึ่งส่งมอบการควบคุมเรื่องหยุดและทำต่อให้ Unity จัดการแทน
สิ่งที่มักใช้กับ yield return บ่อย ๆ:
yield return new WaitForSeconds(1f) — หยุดไว้ 1 วินาทีจริงyield return null — หยุดจนถึงเฟรมถัดไป (เหมือนหนึ่งจังหวะของ Update)yield break; — หยุด coroutine ก่อนกำหนด ตรงจุดนี้เลยStopCoroutineScriptableObject คือ base class อีกตัวที่ Unity เตรียมไว้ให้ แนวคิดคล้าย MonoBehaviour แต่สร้างมาสำหรับข้อมูล ไม่ใช่พฤติกรรม ScriptableObject ไม่ได้แปะอยู่บน GameObject ใน scene — มันอยู่เป็น asset file ของตัวเองใน Project window เหมือนกับ texture หรือ audio clip
using UnityEngine;
[CreateAssetMenu(fileName = "NewWeaponData", menuName = "Data/Weapon Data")]
public class WeaponData : ScriptableObject
{
public string weaponName = "Sword";
public int damage = 10;
public float attacksPerSecond = 1.5f;
}
เกิดอะไรขึ้น: [CreateAssetMenu(...)] เพิ่มรายการเข้าไปในเมนู "Create" ตอนคลิกขวาใน Project window คลิกมันแล้วจะได้ไฟล์ .asset จริง ๆ ขึ้นมา สมมติชื่อ Sword.asset ที่ดีไซเนอร์เปิดแก้ไขใน Inspector ได้เหมือน object ทั่วไปเลย กรอก weaponName, damage, และ attacksPerSecond ได้โดยไม่ต้องแตะโค้ดเลย
using UnityEngine;
public class Weapon : MonoBehaviour
{
[SerializeField] private WeaponData data;
public void Attack()
{
Debug.Log(data.weaponName + " hits for " + data.damage);
}
}
เกิดอะไรขึ้น: เพราะ data เป็น field แบบ [SerializeField] Inspector จะโชว์ช่องให้ลาก Sword.asset ที่สร้างไว้ข้างบนเข้ามาใส่ พอเรียก Attack() จะพิมพ์ "Sword hits for 10" โดยใช้ค่าที่เก็บไว้บน asset นั้นตอนนั้น
ผลตอบแทนจะเห็นชัดตอนที่มี object หลายตัวควรใช้ข้อมูลชุดเดียวกัน ลองนึกภาพศัตรูถือดาบห้าสิบตัวใน scene ถ้าใส่ damage กับ weaponName เป็น field ตรง ๆ บน MonoBehaviour ของศัตรูแต่ละตัว จะกลายเป็นตัวเลขชุดเดียวกันที่ก็อปไว้ห้าสิบชุดแยกกัน — เปลี่ยนค่า damage ของดาบ คุณต้องจำไปอัปเดตทั้งห้าสิบตัวด้วยมือ ไม่งั้นค่าจะเพี้ยนไม่ตรงกันแบบเงียบ ๆ ถ้าให้ศัตรูทั้งห้าสิบตัวชี้ field data ไปที่ Sword.asset ตัวเดียวกันแทน จะมีตัวเลขอยู่แค่ชุดเดียว: เปลี่ยนครั้งเดียว ศัตรูทุกตัวที่อ้างอิงถึงมันจะได้ค่าใหม่ทันที
IEnumerator พร้อม yield return) ที่หยุดและกลับมาทำต่อได้ระหว่างเวลาผ่านไป โดยไม่บล็อกส่วนที่เหลือของเกมOrderLogger ที่ log คำว่า "Awake", "OnEnable", และ "Start" (แต่ละคำครั้งเดียว) ด้วย Debug.Log แล้ว log คำว่า "First Update" ตอน Update รันครั้งแรกเท่านั้น ไม่ log ซ้ำอีกเลยหลังจากนั้น แปะมันไว้บน GameObject ที่เริ่ม active อยู่ใน scene ก่อนรัน ให้เขียนลำดับที่คุณคาดว่าจะเห็นสี่บรรทัดนี้ปรากฏใน Consoleusing UnityEngine;
public class OrderLogger : MonoBehaviour
{
private bool hasLoggedFirstUpdate = false;
void Awake()
{
Debug.Log("Awake");
}
void OnEnable()
{
Debug.Log("OnEnable");
}
void Start()
{
Debug.Log("Start");
}
void Update()
{
if (!hasLoggedFirstUpdate)
{
Debug.Log("First Update");
hasLoggedFirstUpdate = true;
}
}
}
Console จะพิมพ์ตามลำดับนี้:
Awake
OnEnable
Start
First Update
Awake กับ OnEnable ทำงานก่อนเฟรมแรกจะเริ่มด้วยซ้ำ Start ก็ทำงานก่อน Update ของเฟรมแรกเช่นกัน เพราะ Unity การันตีว่า Start รันก่อน Update ตัวไหนก็ตามของ object เดียวกันเสมอ per-frame loop เพิ่งเริ่มหลังจากนั้น "First Update" จึงมาเป็นลำดับสุดท้ายเสมอ
GetComponent ทุกเฟรมแทนที่จะ cache ไว้ และมันขยับ object เป็นระยะทางคงที่เท่าเดิมทุกเฟรมแทนที่จะคูณด้วย Time.deltaTime ทำให้มันวิ่งเร็วไม่เท่ากันบนเครื่องต่างกัน ให้เขียนใหม่แก้ทั้งสองปัญหา
using UnityEngine;
public class BuggyMover : MonoBehaviour
{
public float speed = 5f;
void Update()
{
GetComponent<Rigidbody>().MovePosition(
GetComponent<Rigidbody>().position + Vector3.forward * speed);
}
}
using UnityEngine;
public class FixedMover : MonoBehaviour
{
public float speed = 5f;
private Rigidbody rb; // cached once, instead of searched every frame
void Awake()
{
rb = GetComponent<Rigidbody>();
}
void Update()
{
rb.MovePosition(rb.position + Vector3.forward * speed * Time.deltaTime);
}
}
สองจุดที่แก้แยกกัน: cache rb ไว้ใน Awake ทำให้ GetComponent รันแค่ครั้งเดียวแทนที่จะรันสองครั้งทุกเฟรม การคูณด้วย Time.deltaTime เปลี่ยน speed จาก "หน่วยที่ขยับต่อการเรียกหนึ่งครั้ง" ให้เป็น "หน่วยที่ขยับต่อวินาที" ทำให้ object วิ่งได้ระยะทางจริงต่อวินาทีเท่าเดิมไม่ว่าเฟรมเรตจะเป็นเท่าไหร่ — เอาทางแก้จากหัวข้อ 7 มาใช้กับตัวอย่างใหม่
SpawnLoop ที่ตราบใด field bool แบบ public ชื่อ isSpawning ยังเป็น true ให้ instantiate enemyPrefab แบบ public ที่ตำแหน่งของ Transform แบบ public ชื่อ spawnPoint ทุก 2 วินาที เริ่ม coroutine ครั้งเดียวใน Start loop ควรหยุด spawn โดยไม่โยน error เมื่อไหร่ก็ตามที่ isSpawning กลายเป็น false และห้ามมี coroutine ตัวที่สองรันซ้อนพร้อมกันเด็ดขาดusing UnityEngine;
using System.Collections;
public class EnemySpawner : MonoBehaviour
{
public GameObject enemyPrefab;
public Transform spawnPoint;
public bool isSpawning = true;
void Start()
{
StartCoroutine(SpawnLoop());
}
IEnumerator SpawnLoop()
{
while (isSpawning)
{
Instantiate(enemyPrefab, spawnPoint.position, spawnPoint.rotation);
yield return new WaitForSeconds(2f);
}
}
}
เงื่อนไข while (isSpawning) ถูกตรวจสอบทุกครั้งที่ coroutine ตื่นกลับมาหลัง WaitForSeconds ไม่ใช่แค่ครั้งเดียวตอนเริ่ม — ดังนั้นการตั้ง isSpawning = false จากที่ไหนก็ตามในโค้ด (script อื่น, ปุ่ม UI) จะเงียบ ๆ จบ loop นี้ในครั้งถัดไปที่มันตื่นมา โดยไม่ต้องเขียนโค้ดเพิ่มเพื่อ "ยกเลิก" อะไรเลย มี coroutine รันอยู่แค่ตัวเดียวเสมอ เพราะ StartCoroutine(SpawnLoop()) ถูกเรียกแค่ครั้งเดียวใน Start