สองบทที่แล้วเราเรียนไปว่าโปรเจกต์ Unity ถูกสร้างจาก GameObject และ Component ยังไง (4.1) และเขียนพฤติกรรมด้วยสคริปต์ MonoBehaviour ยังไง — Awake, Start, Update, FixedUpdate, coroutine, event, ScriptableObject (4.2) นั่นคือโครงกระดูก บทนี้จะพูดถึง ระบบสำเร็จรูป ที่ทำให้เกม Unity มีความรู้สึกเป็นเกมจริง ๆ: การอ่าน input ของผู้เล่น, การจำลอง physics, การเล่น animation, การทำ เสียง, และการวาด UI (เมนู, health bar, ปุ่ม) เกม Unity ทุกเกม ตั้งแต่ prototype ทำเล่นวันหยุดจนถึงเกมระดับ HoYoverse ล้วนพึ่งพาระบบทั้ง 5 นี้เหมือนกันหมด
แต่ละหัวข้อจะมีรูปแบบเดิม: สคริปต์เล็ก ๆ, สิ่งที่เกิดขึ้นตอนรัน (Console output หรือ trace ทีละขั้นตอนที่ชัดเจน) แล้วอธิบายด้วยภาษาง่าย ๆ บางส่วนของระบบเหล่านี้ — กราฟ Animator, การผูก Input, เลย์เอาต์ UI — ปกติจะสร้างด้วยมือใน Unity Editor ไม่ใช่พิมพ์เป็นโค้ด สำหรับส่วนพวกนี้ ไดอะแกรมและ trace จะโชว์ให้เห็นว่า Editor ทำอะไรอยู่เบื้องหลัง
ระบบเหล่านี้แต่ละอันคือ Component ที่เราติดเข้ากับ GameObject — เหมือน Component ที่เจอมาแล้วใน 4.1 เป๊ะ ๆ แค่มันเป็นของที่ Unity สร้างมาให้ ไม่ใช่ที่เราเขียนเอง
หนึ่งเฟรมทั่ว ๆ ไปจะแตะทั้ง 5 ระบบ: Input อ่านว่าผู้เล่นกำลังทำอะไรอยู่ตอนนี้, Physics ขยับและชน Rigidbody, Animation เลือกว่าจะเล่นคลิปไหนตามการเคลื่อนไหวนั้น, Audio เล่นเสียงที่เข้าคู่กัน และ UI โชว์ผลลัพธ์ (เลือด, คะแนน) บนจอ ที่เหลือของบทนี้จะไล่ทีละระบบ
Unity มีสองวิธีอ่าน input: คลาส Input แบบคลาสสิก (ใช้ได้ทันที ไม่ต้องตั้งค่าอะไร) กับแพ็กเกจ Input System รุ่นใหม่ (ตั้งค่าเยอะกว่า แต่ทำอะไรได้เยอะกว่า) เรียนแบบคลาสสิกก่อน — เกมที่วางขายจริงหลายเกมก็ยังใช้อยู่ และแนวคิดจะพาไปต่อยอดกับตัวใหม่ได้ตรง ๆ
using UnityEngine;
public class InputDemo : MonoBehaviour
{
void Update()
{
// GetAxis returns a smoothed value from -1 to 1.
// "Horizontal" = A/D or Left/Right arrows (or a gamepad stick)
// "Vertical" = W/S or Up/Down arrows
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
if (h != 0f || v != 0f)
Debug.Log($"move: ({h:F1}, {v:F1})");
if (Input.GetButtonDown("Jump")) // default binding: Space bar
Debug.Log("Jump pressed!");
if (Input.GetKeyDown(KeyCode.E)) // a specific key, no Input Manager entry needed
Debug.Log("E pressed - interact!");
}
}
Console output หลังจากกดค้างปุ่ม D สักพัก แล้วแตะ Space แล้วแตะ E:
move: (1.0, 0.0)
move: (1.0, 0.0)
move: (1.0, 0.0)
Jump pressed!
E pressed - interact!
"Horizontal" กับ "Vertical" ไม่ใช่ string มายากล — มันคือ axis ที่ตั้งชื่อไว้ใน Edit > Project Settings > Input Manager และ Unity มาพร้อมสองตัวนี้บวก "Jump" ตั้งไว้ให้เรียบร้อยแล้ว GetAxis จะ smooth ค่าให้ลื่นข้ามหลายเฟรม (มันจะไล่จาก 0 ไปหา 1 ทีละนิด ซึ่งรู้สึกเป็นธรรมชาติสำหรับการเคลื่อนที่); GetAxisRaw จะให้ค่า -1/0/1 ทันทีไม่ smooth ซึ่งบางเกมชอบเพราะควบคุมได้แม่นและไวกว่า
มีสามวิธีเช็คปุ่ม และความต่างของมันสำคัญ:
GetButtonDown("Jump") — true แค่ เฟรมเดียว คือเฟรมที่กดปุ่มลงไป ใช้กับ "กระโดดครั้งเดียว"GetButton("Jump") — true ทุกเฟรม ที่ยังกดปุ่มค้างอยู่ ใช้กับ "วิ่งต่อไปเรื่อย ๆ ตราบใดที่ยังกดค้าง"GetButtonUp("Jump") — true แค่เฟรมเดียว คือเฟรมที่ปล่อยปุ่มGetButton ทั้งที่จริง ๆ ตั้งใจใช้ GetButtonDown การกระโดดที่เขียนด้วย GetButton จะยิงซ้ำทุกเฟรมตราบใดที่ยังกดปุ่มค้าง — หลายสิบครั้งต่อวินาที — ทำให้ตัวละครกระโดดซ้ำ ๆ ไม่หยุด ถ้า action ควรเกิดแค่ครั้งเดียวต่อการกดหนึ่งครั้ง เกือบทุกครั้งคำตอบคือ GetButtonDownโปรเจกต์ Unity รุ่นใหม่ ๆ ใช้แพ็กเกจ Input System (com.unity.inputsystem ติดตั้งแยกผ่าน Package Manager) แทนที่จะ hardcode string เราจะนิยาม Input Action (เช่น "Move" หรือ "Jump") ไว้ใน asset แล้วผูกแต่ละ action เข้ากับ control จริงหนึ่งอย่างหรือมากกว่า — ปุ่มคีย์บอร์ด, จอยสติ๊กเกมแพด, การปัดหน้าจอสัมผัส สคริปต์เราแค่ถามค่าปัจจุบันของ action นั้น มันไม่สนว่าอุปกรณ์ตัวไหนเป็นคนสร้างค่านั้นขึ้นมา
using UnityEngine;
using UnityEngine.InputSystem;
public class InputSystemDemo : MonoBehaviour
{
public InputAction moveAction; // bound to WASD + left stick in the Inspector
void OnEnable() { moveAction.Enable(); }
void OnDisable() { moveAction.Disable(); }
void Update()
{
Vector2 move = moveAction.ReadValue<Vector2>();
if (move != Vector2.zero)
Debug.Log($"move: {move}");
}
}
ข้อดีคือ: action เดียวชื่อ "Move" ผูกกับคีย์บอร์ด และ เกมแพด และ touch พร้อมกันได้เลย แล้วผู้เล่นยัง rebind ปุ่มเองได้โดยที่เราไม่ต้องแตะโค้ด นี่สำคัญมากสำหรับเกมที่วางขายจริง (รองรับ controller, accessibility) แต่ก็ต้องตั้งค่าจริงจังกว่า — ติดตั้งแพ็กเกจ, สร้าง Input Actions asset, ผูก binding ผ่าน editor แบบกราฟิก สำหรับที่เหลือของบทนี้ คลาส Input แบบคลาสสิกก็เพียงพอแล้วเพื่อให้ตัวอย่างสั้นกระชับ
Input แบบคลาสสิก มันยังใช้งานได้จริง ยังถูกใช้ในโปรเจกต์จริง และแนวคิดเรื่อง อ่านค่าทุกเฟรมแล้วตอบสนอง จะพาไปต่อยอดกับแพ็กเกจ Input System ได้ตรง ๆ เมื่อถึงเวลาไปเรียนต่อphysics engine ของ Unity จะสนใจ GameObject ก็ต่อเมื่อมัน component ที่ถูกต้อง สองตัวที่สำคัญตรงนี้คือ:
BoxCollider, SphereCollider, CapsuleCollider, MeshCollider...) กำหนด รูปทรง ที่ใช้สำหรับการสัมผัสทางฟิสิกส์ มันไม่จำเป็นต้องตรงกับ mesh ที่มองเห็นเป๊ะ ๆ — กล่องหรือแคปซูลง่าย ๆ ครอบโมเดลตัวละครที่ละเอียดเป็นเรื่องปกติ และคำนวณถูกกว่าเยอะพอมี Rigidbody แล้ว gravity ของ Unity จะดึงมันลงทุก physics step โดยอัตโนมัติ — เราไม่ต้องเขียนสคริปต์ "ตก" เอง เราอ่านและเปลี่ยนการเคลื่อนที่ของมันผ่าน property ของ Rigidbody เอง แทนที่จะขยับ Transform ตรง ๆ:
using UnityEngine;
public class RigidbodyPeek : MonoBehaviour
{
Rigidbody rb;
void Awake()
{
rb = GetComponent<Rigidbody>();
rb.mass = 2f; // heavier -> needs more force to push
rb.linearVelocity = new Vector3(0f, 5f, 0f); // an initial upward "pop"
}
void Start()
{
Debug.Log($"mass={rb.mass}, velocity={rb.linearVelocity}");
}
}
Output:
mass=2, velocity=(0.00, 5.00, 0.00)
(Unity รุ่นเก่ากว่าเรียก property ตัวนี้ว่า rb.velocity แทน rb.linearVelocity — แนวคิดเดียวกัน แค่เปลี่ยนชื่อในรุ่นใหม่ ๆ) การตั้งค่า linearVelocity ตรง ๆ คือการ override แบบเด็ดขาด เหมาะกับการยิงพุ่งครั้งเดียว สำหรับการเคลื่อนที่ต่อเนื่องแบบสมจริงตามฟิสิกส์ — เครื่องยนต์รถ, เครื่องพ่นจรวด — เราจะเรียก rb.AddForce(...) ทุก physics step แทน ซึ่งจะดัน velocity แทนที่จะแทนที่มันตรง ๆ รายละเอียดว่าจะเรียกตรงไหนอยู่ในหัวข้อ 5
Collider ทุกตัวมีช่องติ๊ก Is Trigger ใน Inspector แค่ช่องติ๊กเดียวนี้แบ่งพฤติกรรมมันออกเป็นสองโหมดที่ต่างกันโดยสิ้นเชิง:
สคริปต์เราจะรู้เรื่องทั้งสองแบบผ่านคู่ callback method ที่ Unity เรียกให้อัตโนมัติ — เราไม่เคยเรียกพวกนี้เอง Unity จะเรียกให้ตอนมีการสัมผัสเกิดขึ้น:
using UnityEngine;
public class ContactDemo : MonoBehaviour
{
// Fires when THIS object's solid collider physically hits another.
void OnCollisionEnter(Collision collision)
{
Debug.Log($"bumped into {collision.gameObject.name}");
}
// Fires when THIS object's TRIGGER collider overlaps another collider.
void OnTriggerEnter(Collider other)
{
Debug.Log($"entered trigger zone of {other.gameObject.name}");
}
}
ลอง trace ดู: สมมติสคริปต์นี้อยู่บนลูกบอลที่มี SphereCollider แบบปกติ (ไม่ใช่ trigger) กับ Rigidbody ลูกบอลกลิ้งไปชนกำแพง (solid collider) — OnCollisionEnter ทำงาน พิมพ์ bumped into Wall และลูกบอลหยุดจริง ๆ ทางฟิสิกส์ อีกไม่กี่วินาทีต่อมาลูกบอลกลิ้งเข้าไปใน GameObject "Coin" ที่ collider ของมันติ๊ก Is Trigger ไว้ — ลูกบอลไม่หยุด มันกลิ้งทะลุเหรียญไปเลย แต่ OnTriggerEnter ทำงาน พิมพ์ entered trigger zone of Coin ลูกบอลตัวเดียวกัน สคริปต์เดียวกัน แค่ตั้งค่า collider ต่างกัน ผลลัพธ์ก็ต่างกันคนละเรื่อง
มีข้อกำหนดหนึ่งที่คนมักพลาด: อย่างน้อยหนึ่ง ในสองวัตถุที่สัมผัสกัน (ไม่ว่า collision หรือ trigger) ต้องมี Rigidbody ไม่งั้น Unity จะตรวจจับการสัมผัสไม่ได้เลย กำแพง static (มีแค่ Collider ไม่มี Rigidbody) ก็ยังใช้งานได้เพราะ อีกฝั่ง — ตัวผู้เล่นของเรา — มี Rigidbody อยู่แล้ว
OnCollisionEnter ไม่ทำงานเลย — หรือไม่ติ๊กไว้แล้วสงสัยว่าทำไม OnTriggerEnter ไม่ทำงาน สอง callback family นี้ exclusive กันต่อ collider หนึ่งตัว: trigger collider จะให้แค่ OnTrigger* เท่านั้น solid collider จะให้แค่ OnCollision* เท่านั้นเราเจอ Update กับ FixedUpdate มาแล้วใน 4.2 ตรงนี้คือเหตุผลว่าทำไมความต่างของมันสำคัญมากโดยเฉพาะกับ physics Update รันครั้งเดียวต่อ เฟรมที่เรนเดอร์ และเฟรมพวกนี้ไม่ได้ห่างกันเท่า ๆ กัน — วินาทีนึงอาจได้ 60 เฟรม อีกวินาทีอาจได้ 144 เฟรม ขึ้นอยู่กับว่าเครื่องแรงแค่ไหน FixedUpdate รันตาม ตัวจับเวลาคงที่ (ค่า default 0.02 วินาที คือ 50 ครั้งต่อวินาที) ไม่ว่าการเรนเดอร์จะเร็วหรือช้าแค่ไหนก็ตาม physics engine ของ Unity จะ step ตามตัวจับเวลาคงที่นี้เสมอ ดังนั้นโค้ดที่แตะการเคลื่อนที่ของ Rigidbody ต้องอยู่ใน FixedUpdate ไม่ใช่ Update
ถ้าเขียนโค้ด physics ใน Update ปริมาณ "แรงดัน" ที่ใส่แต่ละครั้งจะขึ้นอยู่กับว่า Update เกิดขึ้นบ่อยแค่ไหนในวินาทีนั้น ๆ — สคริปต์เดียวกันเลยขยับวัตถุด้วยความเร็วจริงต่างกันในแต่ละเครื่อง แล้วอาจดูกระตุก ๆ ด้วย เขียนไว้ใน FixedUpdate จะทำให้ physics คงที่เหมือนกันทุกที่
รูปแบบที่ใช้กันบ่อย ๆ: อ่าน input ใน Update (จะได้ไม่พลาดการกดปุ่มเร็ว ๆ) แล้วใส่ให้ Rigidbody ใน FixedUpdate (physics จะได้ลื่นและไม่ขึ้นกับ frame rate):
using UnityEngine;
[RequireComponent(typeof(Rigidbody))]
public class PhysicsMover : MonoBehaviour
{
public float speed = 6f;
Rigidbody rb;
Vector3 inputDir; // cached between Update calls
void Awake() { rb = GetComponent<Rigidbody>(); }
void Update()
{
// Read input every rendered frame so a quick tap is never missed.
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
inputDir = new Vector3(h, 0f, v);
}
void FixedUpdate()
{
// Apply it to the physics body on the fixed timer.
Vector3 targetPos = rb.position + inputDir * speed * Time.fixedDeltaTime;
rb.MovePosition(targetPos);
}
}
Time.fixedDeltaTime คือขนาด step คงที่นั้น (0.02 โดย default) — คูณด้วยมันคือการแปลง "หน่วยต่อวินาที" ให้เป็น "หน่วยสำหรับ physics step นี้หนึ่งครั้ง" เหมือนที่ Time.deltaTime ทำกับ Update ใน 4.2 เป๊ะ rb.MovePosition ขยับ Rigidbody แบบถูกต้องตามหลักฟิสิกส์: มันยังชนกำแพงกับวัตถุอื่นระหว่างทางอยู่ ต่างจากการตั้งค่า transform.position ตรง ๆ ที่จะเทเลพอร์ตทะลุทุกอย่างไปเลย
transform.position += ... บนวัตถุที่มี Rigidbody ด้วย แบบนี้คือการแอบสู้กับ physics engine โดยไม่รู้ตัว — Rigidbody คาดว่ามันเป็นเจ้าของตำแหน่งของวัตถุนั้น การขยับ Transform ลับหลังมันทำให้เกิดอาการกระตุก, ทะลุกำแพง หรือการชนที่จู่ ๆ ก็ไม่ทำงาน ถ้าวัตถุมี Rigidbody ให้ขยับด้วย rb.MovePosition หรือ rb.AddForce ข้างใน FixedUpdateAddForce คืออีกเครื่องมือหนึ่ง เหมาะกับของที่ควรรู้สึกมีน้ำหนักและ momentum (รถ, ลูกบอล, ragdoll) แทนที่จะควบคุมตรง ๆ แบบแม่นยำ:
void FixedUpdate()
{
rb.AddForce(inputDir * accelForce); // a push; velocity builds up over time
}
MovePosition กำหนดว่าวัตถุควรอยู่ตรงไหนตอนนี้เลย (ยังคำนึงถึงการชนอยู่) — เหมาะกับตัวละครแพลตฟอร์มที่ต้องควบคุมแม่นยำ AddForce ดันวัตถุแล้วปล่อยให้ momentum กับ mass จัดการที่เหลือ — เหมาะกับของที่ควรรู้สึกหนักหรือลื่นไถล บทที่ 10 (Physics for Games) จะพูดถึง character controller, ragdoll และการผสาน gameplay ลึกกว่านี้เยอะ หัวข้อนี้คือ 80% ที่ใช้บ่อยที่สุดในชีวิตประจำวันที่ต้องรู้ตอนนี้
Component Animator เล่น animation clip บน GameObject ว่ามันจะเล่นอะไร และสลับคลิปตอนไหน ถูกควบคุมโดย asset ที่ชื่อ Animator Controller — ระบบ animation ของ Unity มักถูกเรียกว่า Mecanim ตามชื่อภายในของระบบนี้ เราสร้าง Animator Controller ด้วยภาพ ผ่านหน้าต่าง Animator เป็น state machine: กราฟของ state (แต่ละอันมักถือ animation clip หนึ่งตัว เช่น "Idle" หรือ "Run") เชื่อมกันด้วย transition (ลูกศรที่บอกว่า "สลับไป state นี้เมื่อ...")
อ่านไดอะแกรมนี้เหมือน flowchart: ตัวละครเริ่มที่ Idle แต่ละ transition มี condition ที่เขียนโดยอิง Parameter — ค่าที่มีชื่ออยู่บน Animator คล้าย ๆ ตัวแปร เมื่อ Speed ขึ้นเกิน 0.1 transition Idle -> Walk จะทำงาน แล้วคลิป Walk ก็เริ่มเล่น กล่อง Any State พิเศษตรงที่ transition ของมันทำงานได้ไม่ว่าตอนนี้เราอยู่ state ไหน นี่คือเหตุผลที่ "Jump" มาขัดจังหวะ Idle, Walk หรือ Run ได้เหมือนกันหมด
parameter สี่ชนิดครอบคลุมเกือบทุกอย่าง:
Speed ใช้กับเงื่อนไข threshold แบบ "Speed > 5"IsGrounded ค่าจะคงอยู่จนกว่าเราจะเปลี่ยนมันเองAttackComboJump มันยิง transition ครั้งเดียวแล้ว รีเซ็ตตัวเองกลับเป็น false อัตโนมัติ — เหมาะมากกับ event แบบ "เกิดขึ้นครั้งนี้ครั้งเดียว" ต่างจาก Bool ที่เราต้องรีเซ็ตเองมือIsRunning, IsGrounded) ใช้ Trigger กับ event แบบครั้งเดียว (Jump, Attack, Hit) การใช้ Bool กับ event แบบครั้งเดียวเป็นสาเหตุบ่อย ๆ ของบั๊ก "เล่นซ้ำสองรอบ" หรือ "ไม่เล่นอีกเลย" เพราะลืมปิดมันกลับเราสร้างกราฟใน Editor แต่สคริปต์ของเราตั้งค่า parameter ทุกเฟรม แล้ว state machine จะตอบสนองเอง นี่คือวิธีปกติในการเชื่อม gameplay เข้ากับ animation — เราแทบไม่เคยบอก Animator ว่า "เล่นคลิปนี้" ตรง ๆ เราบอกมันแค่ข้อเท็จจริงเกี่ยวกับโลก ("ความเร็วตอนนี้คือ 4.2") แล้วให้ transition เป็นคนตัดสินใจ
using UnityEngine;
[RequireComponent(typeof(Animator))]
public class AnimationDriver : MonoBehaviour
{
Animator animator;
void Awake() { animator = GetComponent<Animator>(); }
void Update()
{
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
float speed = new Vector2(h, v).magnitude; // 0 when still, up to ~1 when moving
animator.SetFloat("Speed", speed); // feeds the Idle/Walk/Run thresholds
animator.SetBool("IsGrounded", CheckGrounded());
if (Input.GetButtonDown("Jump"))
animator.SetTrigger("Jump"); // fires once, resets itself
}
bool CheckGrounded() { return true; } // stand-in; a real project raycasts downward
}
ลอง trace ตาม state machine จากหัวข้อ 6: ผู้เล่นยืนนิ่ง Speed = 0 Animator อยู่ที่ Idle ผู้เล่นกด D — แต่ละ Update ตอนนี้คำนวณ speed ที่มากขึ้นเรื่อย ๆ สมมติมันขึ้นถึง 0.6 เพราะ 0.6 > 0.1 เงื่อนไข transition Idle -> Walk เป็นจริง แล้ว Animator ก็สลับไป Walk คลิปเดินก็เริ่มเล่น — เราไม่เคยเรียก "เล่นคลิป walk" ที่ไหนเลย เราแค่ตั้งค่า Speed เท่านั้น จากนั้นผู้เล่นกด Space: SetTrigger("Jump") ยิง transition Any State -> Jump ทันที ขัดจังหวะ Walk แล้ว trigger ก็เคลียร์ตัวเองเพื่อไม่ให้ยิงซ้ำในเฟรมถัดไป
ยังมีทางลัดตรง ๆ อีกทาง คือ animator.Play("StateName") ซึ่งกระโดดตรงไป state นั้นเลยโดยข้าม transition condition ทั้งหมด มันมีประโยชน์ตอนต้องการบังคับรีเซ็ต (เช่น สแนปกลับไป Idle ตอน cutscene จบ) แต่ถ้าใช้บ่อยเกินไปก็เสียจุดประสงค์ของการมี state machine ไป ส่วนใหญ่แล้วการตั้งค่า parameter แล้วปล่อยให้ transition ตัดสินใจคือสิ่งที่เราต้องการ
SetFloat/SetBool/SetTrigger — Unity จับตอน compile ไม่ได้เพราะชื่อมันก็แค่ string มันจะพังแบบเงียบ ๆ ตอนรัน: ไม่มี error, animation แค่ไม่เปลี่ยนไปไหน เช็คให้ดีว่าชื่อ parameter ในหน้าต่าง Animator ตรงกับ string ในสคริปต์เป๊ะ ๆ รวมถึงตัวพิมพ์เล็กใหญ่ด้วย (บทที่ 9 Animation จะลงลึกเรื่อง blending, layer และ skeletal rig ที่ไกลกว่าที่ state machine ของ Mecanim จัดการตรงนี้)สองชิ้นส่วน และการแบ่งนี้ก็คล้ายกับที่รู้จากฝั่งกราฟิกแล้ว: AudioClip คือข้อมูลเสียงจริง ๆ (ไฟล์ที่เรา import เข้ามา เหมือน Texture ที่เป็นข้อมูลภาพที่ import เข้ามา) และ AudioSource คือ Component ที่เล่นคลิปออกไปในฉาก (เหมือน Renderer ที่แสดง Texture) ไม่มี AudioSource ก็ไม่มีเสียง — ต่อให้ตั้งค่าคลิปไว้แล้วก็ตาม
using UnityEngine;
public class AudioDemo : MonoBehaviour
{
public AudioSource source; // assigned in the Inspector
public AudioClip jumpClip;
public AudioClip musicClip;
void Start()
{
// Background music: assign + loop + play, keeps playing across frames.
source.clip = musicClip;
source.loop = true;
source.Play();
}
public void PlayJumpSound()
{
// A short one-off sound effect. PlayOneShot does NOT interrupt
// whatever the AudioSource is already playing (the music keeps going).
source.PlayOneShot(jumpClip);
}
}
เรียก PlayJumpSound() จากที่เดียวกับที่เรียก animator.SetTrigger("Jump") ในหัวข้อ 7 แล้วเสียง effect กระโดดจะเล่นซ้อนทับเพลงโดยไม่ตัดเพลงทิ้งเลย นี่คือความต่างระหว่าง Play() (เข้าครอบครองคลิปหลักของ AudioSource แทนที่สิ่งที่กำลังเล่นอยู่) กับ PlayOneShot(clip) (เล่นเสียงเพิ่มซ้อนกับคลิปหลัก ยิงแล้วปล่อยผ่าน — เหมาะกับเสียงฝีเท้า, เสียงโดนตี และเสียงเก็บไอเทมที่ซ้อนทับกันได้)
AudioSource ทุกตัวมีสไลเดอร์ Spatial Blend ตั้งแต่ 0 ถึง 1:
void Awake()
{
source.spatialBlend = 1f; // 0 = flat/2D, 1 = full 3D positional
source.minDistance = 2f; // stays at full volume within this range
source.maxDistance = 20f; // fades to silent past this range
}
AudioSource ของฝีเท้าหรือเสียงปืนมักอยู่บน GameObject ที่เป็นคนสร้างเสียงนั้น (ตำแหน่งของมันจะตามไปอัตโนมัติ) ตั้งเป็น 3D เต็ม เพลงกับเสียง UI มักอยู่บน manager object ที่ตำแหน่งคงที่ ตั้งเป็น 2D จะได้ไม่เบาลงเลย (บทที่ 13 Audio จะพูดถึง spatial audio, mixing และ middleware อย่าง Wwise/FMOD ที่สตูดิโอใหญ่ ๆ ใส่ทับ AudioSource สำเร็จรูปของ Unity อีกที)
ระบบ UI คลาสสิกของ Unity เรียกว่า UGUI ("Unity GUI") — มันสร้างเมนูกับ HUD จาก GameObject ธรรมดา ๆ โมเดล GameObject/Component เดียวกับที่เจอใน 4.1 แค่มี component เฉพาะ UI element ของ UGUI ทุกตัวต้องอยู่ใต้ Canvas ซึ่งเป็น GameObject พิเศษที่เป็นราก (root) ของต้นไม้ UI หนึ่งต้น และเป็นคนตัดสินว่าจะวาดมันยังไง
UI element ทุกตัวอยู่บน RectTransform แทนที่จะเป็น Transform ธรรมดา — แนวคิดเดียวกัน (position, rotation, scale) บวก rect (width, height) กับ anchor ซึ่งบอกว่า element ควรเกาะติดมุมหรือขอบไหนเวลาจอ resize (health bar ที่ anchor ไว้มุมบนซ้ายจะอยู่มุมบนซ้ายทั้งบนมือถือและบนจอมอนิเตอร์ แทนที่จะเลื่อนหนี)
Render Mode ของ Canvas เป็นคนตัดสินว่า UI จะอยู่ตรงไหนในพื้นที่ 3D จริง ๆ:
สาม element ครอบคลุม UI แรก ๆ ส่วนใหญ่: Image (สี่เหลี่ยมที่โชว์ sprite หรือสีล้วน — แถบเติมของ health bar, ไอคอน, พื้นหลัง panel), Text (วาด string — คะแนน, บทพูด, label; โปรเจกต์ส่วนใหญ่ตอนนี้ใช้เวอร์ชัน TextMeshPro ที่ดีกว่า มักเขียนว่า TMP_Text ซึ่งให้การเรนเดอร์ที่คมกว่าและจัดฟอร์แมตได้เยอะกว่า) และ Button (Image บวกการตรวจจับคลิก อธิบายต่อในหัวข้อถัดไป)
RectTransform เป็นไปไม่ได้ — Unity จะสลับใส่ RectTransform ให้อัตโนมัติทันทีที่ GameObject กลายเป็นลูกของ Canvas นี่คือสัญญาณว่าเรากำลังดู UI อยู่ ไม่ใช่คอนเทนต์ 3D ธรรมดา (บทที่ 12 UI/UX Programming จะไปไกลกว่านี้ เรื่อง UI Toolkit รุ่นใหม่, grid inventory ที่ซับซ้อน และ localization)Component Button จะยิง UnityEvent ชื่อ onClick ทุกครั้งที่มันถูกกดแล้วปล่อยขณะเมาส์/นิ้วยังอยู่ในขอบเขตของมัน มีสองวิธีเชื่อม event นั้นเข้ากับโค้ดเรา และทั้งคู่จบลงที่การเรียก method เดียวกัน:
using UnityEngine;
using UnityEngine.UI;
public class HealthUI : MonoBehaviour
{
public Button healUpButton;
public Image healthFillImage; // Image type set to "Filled" in the Inspector
int health = 60;
const int maxHealth = 100;
void Start()
{
healUpButton.onClick.AddListener(OnHealUpClicked); // Way 2, wired in code
RefreshBar();
}
void OnHealUpClicked()
{
health = Mathf.Min(health + 20, maxHealth);
Debug.Log($"Button clicked! Health: {health}");
RefreshBar();
}
void RefreshBar()
{
healthFillImage.fillAmount = (float)health / maxHealth; // 0..1
}
}
ลอง trace: health เริ่มที่ 60 ดังนั้น RefreshBar ตั้งค่า fillAmount เป็น 0.6 — แถบโชว์เต็ม 60% ผู้เล่นกดปุ่มหนึ่งครั้ง OnHealUpClicked ทำงาน health กลายเป็น 80 Console พิมพ์ Button clicked! Health: 80 และ fillAmount อัปเดตเป็น 0.8 กดอีกสองครั้งแล้ว health จะถูกจำกัดไว้ที่ maxHealth = 100 โดย Mathf.Min มันเลยหยุดโตแทนที่จะล้นแถบไป
ถ้าผูกผ่าน Inspector แทน (วิธีที่ 1) ไม่ต้องเขียนโค้ดสักบรรทัดสำหรับการเชื่อมนี้เลย — แค่ลาก GameObject ของ HealthUI ใส่ลิสต์ On Click () ของปุ่ม แล้วเลือก OnHealUpClicked จาก dropdown ทั้งสองวิธีเรียก method เดียวกันเป๊ะ ๆ เลือกวิธี Inspector สำหรับปุ่มที่มีอยู่แล้วในฉาก และเลือกวิธีโค้ดสำหรับปุ่มที่สคริปต์เราสร้างหรือหาเจอตอนรัน
onClick ของ Button รับ parameter ได้ศูนย์ตัว (หรือรับได้พอดีหนึ่งตัวชนิด string/int/float/bool/Object ถ้าตั้งไว้แบบนั้นใน Inspector) ถ้า method เราต้องการข้อมูลมากกว่านั้น ให้มันอ่านจาก field บนสคริปต์เดียวกันแทนที่จะพยายามส่ง argument เพิ่มผ่าน click eventทุกอย่างข้างบน — physics, animation, audio, UI — ทำงานเหมือนเดิมไม่ว่าฉากจะถูกวาดลงจอยังไงจริง ๆ งานวาดนั้นเป็นหน้าที่ของ render pipeline: โค้ดที่แปลง GameObject, material และแสงในฉากให้กลายเป็นพิกเซลทุกเฟรม Unity มีให้เลือกสามตัว เลือกครั้งเดียวต่อโปรเจกต์:
นี่คือเหตุผลว่าทำไมเป้าหมายที่พูดถึงในบทนำของหลักสูตรนี้ — เกมสไตล์ HoYoverse (Genshin Impact, Honkai: Star Rail, Zenless Zone Zero) — ชี้ตรงไปที่ URP เกมพวกนี้ต้องรันได้ทั้งบนมือถือระดับกลางและ PC/คอนโซลจากโปรเจกต์เดียวกัน แล้ว HDRP ก็ไม่ใช่ตัวเลือกบน GPU มือถือเลย เป้าหมายการออกแบบของ URP ทั้งหมดคือการครอบคลุมแบบนั้นเป๊ะ ๆ: performance ที่พอรับได้และปรับแต่งได้ ครอบคลุมฮาร์ดแวร์หลากหลายมาก รวมมือถือด้วย ถ้าเป้าหมายเราคือเกมสไตล์ stylized ที่ต้องเข้าถึงมือถือ ให้ตั้ง default เป็น URP ไว้ก่อน ยกเว้นมีเหตุผลเฉพาะที่พิสูจน์แล้วว่าไม่ควร
การเลือก pipeline เป็นการตั้งค่าระดับโปรเจกต์ ไม่ใช่สิ่งที่เราเขียนสคริปต์ตรงนี้ — หัวข้อนี้แค่อยากให้การเลือกมันสมเหตุสมผล เรื่อง shader, lighting และลุค "toon" แบบ stylized ที่ HoYoverse ขึ้นชื่อมีบทเฉพาะทีหลัง (บทที่ 7 Graphics & Rendering โดยเฉพาะ 7.4 Stylized / Toon / NPR) ตอนนี้สิ่งเดียวที่สำคัญคือ: โปรเจกต์ใหม่ที่เล็งมือถือหรือการเข้าถึงกว้าง -> URP
AddListenerSphereCollider ที่ติ๊ก Is Trigger ไว้ แต่ไม่มี Rigidbody ผู้เล่นมี CapsuleCollider (ไม่ใช่ trigger) กับ Rigidbody สคริปต์ของ Coin มีแค่ method นี้:
void OnCollisionEnter(Collision collision)
{
Debug.Log("collected " + collision.gameObject.name);
}
ผู้เล่นเดินทะลุเหรียญไปตรง ๆ บรรทัด log จะพิมพ์ไหม? ถ้าไม่ ต้องแก้ยังไงด้วยหนึ่งบรรทัด?
ไม่พิมพ์ Collider ของ Coin ติ๊ก Is Trigger ไว้ ดังนั้นมันจะให้แค่ call OnTrigger* เท่านั้น ไม่มีทางให้ OnCollision* เลย — สอง family นี้ exclusive กันต่อ collider หนึ่งตัว ตามที่พูดไว้ในหัวข้อ 4 วิธีแก้คือเปลี่ยนชื่อ method (พร้อมชนิด parameter) ให้เป็นเวอร์ชัน trigger:
void OnTriggerEnter(Collider other)
{
Debug.Log("collected " + other.gameObject.name);
}
สังเกตว่าชนิด parameter เปลี่ยนด้วย จาก Collision เป็น Collider — Unity ส่งข้อมูลต่างกันสำหรับสอง family นี้ (เงื่อนไข Rigidbody ตรงนี้ผ่านอยู่แล้ว: ผู้เล่นมี Rigidbody และแค่ฝั่งใดฝั่งหนึ่งของการสัมผัสมีก็พอ)
void Update()
{
float h = Input.GetAxis("Horizontal");
transform.position += new Vector3(h, 0, 0) * speed;
}
บอกปัญหาทั้งสองข้อ แล้วเขียนใหม่ให้ถูกต้องโดยใช้สิ่งที่เรียนไปในหัวข้อ 5
ปัญหาที่ 1 (ขึ้นกับ frame rate): ปริมาณการขยับไม่เคยถูกคูณด้วย time step เลย ดังนั้นบนเครื่องที่รัน Update ที่ 144 FPS วัตถุจะขยับไกลกว่าต่อวินาทีอย่างชัดเจนเทียบกับ 60 FPS — เรียกบ่อยกว่าต่อวินาที แต่บวกปริมาณเท่าเดิมทุกครั้ง ต้องมี * Time.deltaTime อย่างน้อยที่สุด
ปัญหาที่ 2 (ทะลุกำแพง): การเขียนใส่ transform.position ตรง ๆ ข้าม physics engine ไปเลย — มันเทเลพอร์ต Transform แทนที่จะขยับ Rigidbody ผ่านโลก ทำให้ไม่มีการเช็คการชนระหว่างทางเลย แล้ววัตถุอาจไปโผล่อยู่ในหรือทะลุกำแพงได้ เพราะ GameObject นี้มี Rigidbody การขยับควรเกิดใน FixedUpdate ผ่าน method ของ Rigidbody เอง:
Rigidbody rb;
Vector3 inputDir;
void Awake() { rb = GetComponent<Rigidbody>(); }
void Update()
{
float h = Input.GetAxis("Horizontal");
inputDir = new Vector3(h, 0, 0);
}
void FixedUpdate()
{
rb.MovePosition(rb.position + inputDir * speed * Time.fixedDeltaTime);
}
input ยังถูกอ่านทุกเฟรมที่เรนเดอร์เหมือนเดิม (จะได้ไม่พลาดการกด) แต่การขยับจริงเกิดขึ้นตามตัวจับเวลา physics คงที่ผ่าน rb.MovePosition ซึ่งคำนึงถึงการชนและไม่ขึ้นกับ frame rate
animator.SetFloat("Speed", 3f);
animator.SetFloat("Speed", 6f);
animator.SetTrigger("Jump");
animator.SetFloat("Speed", 0f);
ไล่ทีละขั้นตอนตามไดอะแกรมในหัวข้อ 6:
SetFloat("Speed", 3f) — 3 > 0.1 ดังนั้น Idle -> Walk (3 ไม่ > 5 ดังนั้นยังไปไม่ถึง Run)SetFloat("Speed", 6f) — 6 > 5 ดังนั้น Walk -> RunSetTrigger("Jump") — transition Any State ยิงได้ไม่ว่า Speed เท่าไหร่ ดังนั้น Run -> Jump แล้ว trigger ก็รีเซ็ตตัวเองอัตโนมัติSetFloat("Speed", 0f) — บรรทัดนี้เปลี่ยนแค่ parameter Speed เท่านั้น มันไม่ได้ทำให้ Animator ออกจาก Jump เองโดยอัตโนมัติ; state Jump จริง ๆ ปกติจะมี transition ของตัวเอง (เช่น กลับไป Idle/Walk/Run เมื่อคลิปกระโดดเล่นจบ หรือเมื่อ IsGrounded กลับมาเป็น true) ซึ่งตัวอย่างแบบง่ายนี้ไม่ได้จำลองไว้ ดังนั้น state จึงยังคงเป็น Jumpจุดหักมุมในขั้นตอนสุดท้ายคือประเด็นของแบบฝึกหัดนี้: การตั้งค่า parameter จะยิงแค่ transition ที่ขึ้นอยู่กับมันจริง ๆ เท่านั้น state machine จะออกจาก state ได้ก็ต่อเมื่อผ่าน transition ที่ผูกไว้จริง ๆ เท่านั้น — ไม่มีอะไรเกิดขึ้นอัตโนมัติแค่เพราะเวลาผ่านไปหรือ parameter ตัวอื่นเปลี่ยน
นั่นคือระบบทั้ง 5 ที่ครอบคลุมในบทนี้ Input บอกสคริปต์เราว่าผู้เล่นต้องการอะไร; Physics (Rigidbody, Collider, FixedUpdate) แปลงมันเป็นการเคลื่อนที่และการสัมผัสที่น่าเชื่อ; Animation (state machine ของ Animator) โชว์การเคลื่อนไหวนั้นบนจอ; Audio ให้เสียงกับมัน; UI รายงานผลลัพธ์กลับไปให้ผู้เล่น โน้ตเรื่อง render pipeline คือการตัดสินใจระดับโปรเจกต์หนึ่งเดียวที่อยู่ใต้ทุกอย่างนี้: เลือก URP สำหรับอะไรก็ตามที่ต้องเข้าถึงมือถือ — ซึ่งตรงกับเป้าหมายสไตล์ HoYoverse ที่หลักสูตรนี้เล็งไว้เป๊ะ ๆ