4.1 แกน Unity: GameObject, Component, Scene, Prefab

เฟส 4 · Unity (เอนจินหลัก) · เวลาเรียน: 30–50 h

โมเดล GameObject/Component, scene, prefab และ inspector — เกม Unity ถูกจัดโครงสร้างและประกอบขึ้นยังไงจริง ๆ

ทุกบทที่ผ่านมาเป็นโค้ดล้วน ๆ: โปรแกรมที่ compile แล้วรันจาก terminal มี input/output เป็นข้อความ Unity ต่างออกไป มันเป็นแอปพลิเคชันใหญ่ที่มี visual editor และสคริปต์ C# ของคุณไม่ได้รันเดี่ยว ๆ — มันรันแบบผูกติดกับ object ที่อยู่ใน scene ซึ่ง Unity เป็นคนจัดการเอง บทนี้คือทางเข้าสู่ Unity: ไอเดียไม่กี่ตัวที่ทุกอย่างใน Unity ถูกสร้างขึ้นมาจากมัน เข้าใจตรงนี้ให้แน่นแล้วบทต่อ ๆ ไปจะเข้าใจง่ายขึ้นเยอะ ถ้าข้ามตรงนี้ไป บทหลัง ๆ จะเหมือนเดามั่ว ๆ ไปเรื่อย

สตูดิโออย่าง HoYoverse สร้างเกมด้วย Unity และ C# ดังนั้นเรื่องที่เรียนตรงนี้ไม่ใช่เวอร์ชันของเล่น — โมเดล GameObject/Component/Prefab ที่จะพูดถึงข้างล่างนี้คือสิ่งที่โปรเจกต์ Unity จริง ๆ ในสตูดิโอมีหน้าตาแบบนี้เป๊ะ ๆ

บทนี้จะมี terminal output น้อยกว่าบทก่อน ๆ เพราะสิ่งที่คุณทำใน Unity ส่วนใหญ่คือการคลิกใน window ต่าง ๆ ไม่ใช่การรันโปรแกรมจาก command line ตรงไหนที่มีสคริปต์ เราจะโชว์โค้ดพร้อม output ที่คาดว่าจะเห็นใน Debug.Log ที่ Console window เหมือนกับที่บทก่อน ๆ โชว์ output ของ std::cout

1. Unity Editor: window ที่คุณจะอยู่ด้วยตลอด

Unity คือแอปพลิเคชันเดียวที่เรียกว่า Editor เมื่อคุณเปิด project (โฟลเดอร์บนดิสก์ที่เก็บไฟล์ของเกม) Editor จะจัด window ต่าง ๆ ให้ คุณจะเปิดค้างไว้ห้าตัวนี้แทบตลอดเวลา:

+------------------------+------------------------------+ | Hierarchy | Scene view / Game view | | (tree of GameObjects | (Scene: editing camera | | in the open scene) | Game: what the player sees) | | | | | Main Camera | | | Directional Light | | | Player | | | Weapon | | | Enemies | | | Goblin_01 | | +------------------------+------------------------------+ | Project | Inspector | | Assets/ | Player (selected) | | Scripts/ | Transform | | Prefabs/ | Mesh Renderer | | Scenes/ | PlayerController (script) | +------------------------+------------------------------+

การเลือก GameObject ทำงานเหมือนกันทุกที่: คลิกมันใน Hierarchy หรือคลิกตรง ๆ ใน Scene view ก็ได้ แล้ว Inspector จะอัปเดตทันทีให้โชว์ component ของ object นั้น นิสัยเดียวนี้ — เลือกใน Hierarchy หรือ Scene แล้วอ่าน/แก้ใน Inspector — คือสิ่งที่คุณทำเกือบตลอดเวลาตอนสร้าง level

Tip ถ้า window ไหนหายไป (ปิดมันไปโดยไม่ตั้งใจ) เมนู Window ด้านบนของ Editor มีรายชื่อ window ทุกตัว ให้เปิดกลับมาได้

2. GameObject: container เปล่า ๆ ที่ยังไม่ทำอะไรเลย

GameObject คือ "สิ่งของ" แบบเดียวที่มีอยู่ใน Unity scene ตัวละครทุกตัว แสงทุกดวง กล้องทุกตัว กำแพงทุกอัน แม้แต่ trigger zone ที่มองไม่เห็น ก็คือ GameObject ทั้งนั้น ตัวมันเองพอสร้างใหม่แล้วแทบไม่มีอะไรเลย: มีแค่ชื่อ กับตำแหน่งในพื้นที่ — จบแค่นั้น ไม่มีรูปทรง ไม่วาดอะไรบนจอ ไม่มี behavior ใด ๆ

ให้นึกภาพ GameObject เป็นกล่องเปล่า กล่องเปล่าไม่มีอะไรน่าสนใจ สิ่งที่ทำให้มันมีประโยชน์คือของที่คุณใส่เข้าไปข้างใน ใน Unity ของที่ใส่เข้าไปใน GameObject เรียกว่า component

an empty GameObject, right after creating it: GameObject "Empty" +--------------------------+ | Transform | <- the only thing every GameObject always has +--------------------------+ the SAME GameObject after adding components: GameObject "Player" +--------------------------+ | Transform | position / rotation / scale | Mesh Renderer | makes it visible on screen | Box Collider | gives it a physical shape | PlayerController (script) | your own C# behavior +--------------------------+

คุณสร้าง GameObject เปล่า ๆ ได้จากเมนู (GameObject > Create Empty) หรือคลิกขวาใน Hierarchy window ก็ได้ ในโค้ด new GameObject(...) ก็ทำแบบเดียวกันตอน runtime:

using UnityEngine;

public class SpawnExample : MonoBehaviour
{
    void Start()
    {
        GameObject empty = new GameObject("MyEmptyObject");
        Debug.Log(empty.name);
        Debug.Log(empty.transform.position);
    }
}

Console output ตอนรัน:

MyEmptyObject
(0.0, 0.0, 0.0)

สังเกตบรรทัดที่สอง: ถึงเราจะขอ GameObject เปล่า ๆ ล้วน ๆ มันก็ยังมี transform พร้อมตำแหน่งอยู่ดี เพราะ ทุก GameObject มี Transform component ติดมาให้อัตโนมัติ — Unity ใส่ให้เองและไม่ให้เอาออกด้วย ที่เหลือทั้งหมด — หน้าตาเป็นยังไง เคลื่อนไหวยังไง ตอบสนอง physics ยังไง หรือ gameplay logic ที่คุณเขียนเอง — ต้องเพิ่มเข้ามาทีหลังทั้งนั้น

3. Component: composition ไม่ใช่ inheritance

บทก่อน ๆ การให้ object มี behavior ใหม่มักหมายถึงเขียน class ใหม่ที่ inherit (สืบทอด) มาจากอีก class หนึ่ง — เป็นสายแบบ Enemy : Character : Entity Unity ส่วนใหญ่ไม่ทำงานแบบนั้น แทนที่จะซ้อน behavior ผ่าน inheritance คุณสร้าง GameObject โดยติด component เล็ก ๆ อิสระจากกันหลายตัวเข้าไป สไตล์นี้เรียกว่า composition — คุณ compose (ประกอบ) object ขึ้นจากชิ้นส่วนต่าง ๆ แทนที่จะ inherit มาจาก parent class

INHERITANCE (deep chain of classes - NOT how Unity behavior works) Entity | Character | Enemy | FlyingEnemy <- adding "can fly" means writing a whole new subclass COMPOSITION (Unity's way - assemble from independent parts) GameObject "Enemy" - Transform - Mesh Renderer - EnemyHealth (component) - EnemyAI (component) - FlightMovement (component) <- "can fly" is just one more component

แต่ละ component เป็น C# class ของตัวเอง สคริปต์ที่คุณเขียนเองแล้วเอาไปติดบน GameObject ก็คือ component เหมือนกัน — แค่ต้อง inherit มาจาก MonoBehaviour base class ของ Unity (นี่คือ inheritance ชิ้นเดียวที่ Unity ขอให้คุณทำ จากนั้นคุณก็ compose ต่อไปเรื่อย ๆ ไม่ต้องไล่ subclass ต่อ) นี่คือ component แบบง่ายที่สุด:

using UnityEngine;

public class Health : MonoBehaviour   // a Component you can attach to any GameObject
{
    public int maxHP = 100;
    public int currentHP;

    void Awake()   // Unity calls this once, automatically, when the object loads
    {
        currentHP = maxHP;
        Debug.Log(gameObject.name + " has " + currentHP + " HP");
    }
}

เอาสคริปต์ Health ตัวเดียวกันนี้ไปติดบน GameObject สองตัว ชื่อ "Knight" กับ "Slime" แล้วกด Play Console output:

Knight has 100 HP
Slime has 100 HP

มันคือ component class เดียวกัน แต่แต่ละ GameObject ที่ติดมันเข้าไปจะได้ สำเนา field ของตัวเอง แยกกัน currentHP ของ Knight กับ currentHP ของ Slime คือคนละตัวแปรในหน่วยความจำ ทำ Knight เจ็บไม่มีทางไปแตะ HP ของ Slime นี่คือประเด็นทั้งหมดของ composition: component เป็นชิ้นส่วนที่จบในตัวเอง เอาไปติดกับ GameObject ตัวไหนก็ได้ และแต่ละ instance เก็บ state ของตัวเองแยกกัน

Tip Awake() กับ Start() ถูก Unity เรียกอัตโนมัติทั้งคู่ ครั้งเดียว ตอน GameObject กลายเป็น active — คุณไม่ต้องเรียกเองเลย Awake() รันก่อน (เหมาะกับตั้งค่า state ของตัวเอง) Start() รันต่อจากนั้นทันที ก่อนเฟรมแรก (เหมาะกับตอนต้องคุยกับ component ตัวอื่น เพราะตอนนั้น Awake ของทุกตัวรันเสร็จหมดแล้ว) บทหลัง ๆ จะพูดถึงลำดับเต็ม ๆ ของ callback พวกนี้

4. Transform component: position, rotation, scale

Transform ของทุก GameObject เก็บสามอย่าง แต่ละอย่างเป็น Vector3 (ตัวเลขสามตัว: x, y, z):

โลกของ Unity ใช้ Y เป็นทิศขึ้น X เป็นทิศขวา และ Z เป็นทิศหน้า:

Y (up) | | +------ X (right) / Z (forward)

คุณอ่านและเขียนทั้งสามค่าผ่าน transform ซึ่งทุก MonoBehaviour มี reference ไปยัง Transform component ของ GameObject ตัวเองอยู่แล้ว:

using UnityEngine;

public class TransformDemo : MonoBehaviour
{
    void Start()
    {
        transform.position = new Vector3(2f, 0f, 5f);     // move to (2,0,5)
        transform.eulerAngles = new Vector3(0f, 90f, 0f);  // face 90 degrees around Y
        transform.localScale = new Vector3(2f, 2f, 2f);    // twice as big

        Debug.Log("pos=" + transform.position);
        Debug.Log("rot=" + transform.eulerAngles);
        Debug.Log("scale=" + transform.localScale);
    }
}
pos=(2.0, 0.0, 5.0)
rot=(0.0, 90.0, 0.0)
scale=(2.0, 2.0, 2.0)

คุณจะเห็นข้อมูลชุดเดียวกันนี้เป็นช่องตัวเลขแก้ไขได้ใน Inspector ทุกครั้งที่เลือก GameObject — field Transform ใน Inspector กับโค้ด transform.position/eulerAngles/localScale ข้างบนคือข้อมูลชุดเดียวกัน มองจากสองมุมเท่านั้นเอง

5. Parenting: local space กับ world space

Hierarchy window เป็น tree เพราะ GameObject ทำ parenting ได้: ลาก GameObject ตัวหนึ่งไปวางบนอีกตัวใน Hierarchy ตัวที่ลากจะกลายเป็น child ของตัวที่วางทับ การทำแบบนี้เปลี่ยนความหมายของตัวเลข Transform ของ child: มันเลิกเป็น world position แล้วกลายเป็น local position — ค่า offset เทียบกับ parent ไม่ใช่เทียบกับจุดกำเนิดของทั้ง scene

Hierarchy tree: Car world position: (10, 0, 0) |- Wheel_FrontLeft local position: (1, -0.5, 2) |- Wheel_FrontRight local position: (-1, -0.5, 2) Wheel_FrontLeft's WORLD position = Car's world position + Wheel's local position = (10, 0, 0) + (1, -0.5, 2) = (11, -0.5, 2)

ผลลัพธ์ที่ได้: ขยับหรือหมุน Car แล้วล้อทั้งสองจะขยับและหมุนตามอัตโนมัติ เพราะตำแหน่งของมันถูกเก็บเป็น offset จาก Car ไม่ใช่ตัวเลขตายตัวใน world นี่คือเหตุผลที่คุณทำ parenting — จัดกลุ่ม object ที่ควรเคลื่อนที่ไปด้วยกันไว้ใต้ parent ตัวเดียว แล้วขยับแค่ parent ก็พอ

ในโค้ด transform.position คือ world space เสมอ ส่วน transform.localPosition คือค่าเทียบกับ parent (และเท่ากับ world position ถ้า object ไม่มี parent):

using UnityEngine;

public class ParentDemo : MonoBehaviour
{
    void Start()
    {
        Debug.Log("local pos: " + transform.localPosition);
        Debug.Log("world pos: " + transform.position);
    }
}

ติดสคริปต์นี้บน Wheel_FrontLeft จากไดอะแกรมข้างบน Console output:

local pos: (1.0, -0.5, 2.0)
world pos: (11.0, -0.5, 2.0)
Common mistake ตั้งค่า transform.position ทั้งที่ตั้งใจจะตั้ง transform.localPosition (หรือกลับกัน) บน object ที่ไม่มี parent สองค่านี้เท่ากันเป๊ะ บั๊กเลยซ่อนอยู่จนกว่า object จะถูก parent ทีหลัง — แล้วจู่ ๆ มันก็กระโดดไปอยู่ผิดที่ ถ้า child object อยู่ผิดตำแหน่งหลังจากคุณเพิ่ม parent เข้าไปเท่านั้น ให้เช็กว่าใช้ตัวไหนอยู่

6. เพิ่ม component ผ่าน Inspector

ก่อนจะแตะโค้ด ควรรู้จัก workflow แบบมือก่อน เพราะคุณจะใช้มันบ่อยมากแม้แต่ในโปรเจกต์ที่เขียนโค้ดหนัก ๆ เลือก GameObject ใน Hierarchy Inspector จะโชว์ component ปัจจุบันของมัน ปิดท้ายด้วยปุ่ม Add Component คลิกปุ่มนั้น พิมพ์ชื่อ component ที่ต้องการ (เช่น "Rigidbody" ซึ่งเป็น physics component ที่ Unity มีให้) แล้วคลิกเลือกจากรายการ — ตอนนี้มันติดแล้ว พร้อม field ของมันเองที่โชว์อยู่ตรงนั้นเลยใน Inspector

เรื่องนี้ใช้ได้กับสคริปต์ของคุณเองด้วย field ไหนก็ตามที่เป็น public บน MonoBehaviour (หรือ private field ที่ติด [SerializeField]) จะโผล่เป็นช่องแก้ไขได้ใน Inspector อัตโนมัติ — ไม่ต้องทำอะไรเพิ่ม เอาสคริปต์ Health จากหัวข้อ 3 มาดู:

Inspector for GameObject "Goblin" +----------------------------------------+ | Transform | | Position X:0 Y:0 Z:0 | | Rotation X:0 Y:0 Z:0 | | Scale X:1 Y:1 Z:1 | +----------------------------------------+ | Health (Script) | | Max Hp [ 100 ] | +----------------------------------------+ | [ Add Component ] | +----------------------------------------+

maxHP โผล่มาเป็น "Max Hp" — Unity แปลงชื่อ field ให้เป็น label อ่านง่าย designer (หรือตัวคุณเองโดยไม่ต้องแตะโค้ดอีก) แก้ตัวเลขนั้นแยกตาม GameObject ได้ตรง ๆ ใน Inspector เลย ถ้าจะเอา component ออก คลิกไอคอนเฟืองเล็ก ๆ ตรง header ของมัน แล้วเลือก Remove Component (Transform เป็น component ตัวเดียวที่ไม่มีตัวเลือกนี้ให้ — เอาออกไม่ได้)

Tip ทั้ง maxHP และ currentHP ในสคริปต์นั้นเป็น public ทั้งคู่ ดังนั้นทั้งสองจะโผล่ใน Inspector — รวมถึง currentHP ที่อัปเดตสด ๆ ตอนเกมรันอยู่ใน Play mode ด้วย เป็นวิธีที่สะดวกในการดูค่าเปลี่ยนโดยไม่ต้องใช้ Debug.Log เลยสักบรรทัด

7. เพิ่มและหา component ด้วย C#: AddComponent กับ GetComponent

โค้ดก็ต้องการความสามารถสองอย่างนี้เหมือนกัน GetComponent<T>() หาบน GameObject ตัวเดียวกัน ว่ามี component ชนิด T อยู่ไหม แล้วคืน reference ของมันมา หรือคืน null ถ้า GameObject นั้นไม่มี AddComponent<T>() ติด component ชนิด T ตัวใหม่เข้าไปที่ GameObject ทันที ตอน runtime เลย

using UnityEngine;

public class ComponentDemo : MonoBehaviour
{
    void Start()
    {
        // Look for a component that should already be on this GameObject.
        Health hp = GetComponent<Health>();
        if (hp != null)
            Debug.Log("Found Health, maxHP = " + hp.maxHP);
        else
            Debug.Log("No Health component here.");

        // Attach a brand new component at runtime.
        Rigidbody rb = gameObject.AddComponent<Rigidbody>();
        rb.mass = 5f;
        Debug.Log("Added Rigidbody, mass = " + rb.mass);
    }
}

บน GameObject ที่มี Health ติดอยู่แล้ว (จากหัวข้อ 3) และยังไม่มี Rigidbody Console output:

Found Health, maxHP = 100
Added Rigidbody, mass = 5

ถ้า GameObject นั้นไม่มี Health component บรรทัดแรกจะพิมพ์ "No Health component here." แทนที่จะ crash — เพราะเราเช็ก null ก่อนแล้ว

Common mistake ข้ามการเช็ก null แล้วเขียน GetComponent<Health>().maxHP ตรง ๆ เลย ถ้า GameObject นั้นไม่มี Health component GetComponent จะคืน null แล้วการถาม .maxHP จาก null จะโยน NullReferenceException — อาจเป็น error message ที่คุณจะเจอบ่อยที่สุดตอนเริ่มเขียน Unity เลย เช็ก null เสมอเวลาไม่แน่ใจร้อยเปอร์เซ็นต์ว่า component นั้นมีอยู่จริง

GetComponent หาแค่บน GameObject ตัวที่คุณเรียกมันเท่านั้น — ไม่ค้นหาใน children หรือ parent เวลาต้องการแบบนั้น Unity มี GetComponentInChildren<T>() กับ GetComponentInParent<T>() ให้ ซึ่งจะไล่ Hierarchy tree ลงไปหรือขึ้นไปจาก GameObject นั้น

8. Scene: tree ของ GameObject ที่เซฟและโหลดได้

Scene คือไฟล์เดียว (นามสกุล .unity) ที่เก็บ tree ทั้งหมดของ GameObject — component ของมัน ความสัมพันธ์ parent/child ของมัน ทุกอย่าง "level" หนึ่งด่าน, หน้าเมนูหลัก, หน้าตั้งค่า ปกติแล้วแต่ละอันจะเป็น Scene ของตัวเอง Hierarchy window จะโชว์ tree ของ Scene ที่เปิดอยู่ตอนนั้นเสมอ

Scene: "Level1" |- Main Camera |- Directional Light |- Environment | |- Ground | |- Trees | |- Tree_01 | |- Tree_02 |- Player |- Enemies |- Goblin_01 |- Goblin_02

เวลาจะสลับ scene ตอนเกมกำลังรันอยู่ ใช้ UnityEngine.SceneManagement.SceneManager:

using UnityEngine;
using UnityEngine.SceneManagement;   // needed to load scenes

public class LevelExit : MonoBehaviour
{
    public void GoToLevel2()
    {
        Debug.Log("Loading Level2...");
        SceneManager.LoadScene("Level2");   // must be listed in Build Settings
    }
}

ตรงนี้ไม่มี console บรรทัดเดียวให้ trace — สิ่งที่เกิดขึ้นเป็นเรื่องโครงสร้าง: เรียก LoadScene("Level2") แล้วมันจะรื้อ GameObject ทุกตัว ใน scene ที่เปิดอยู่ตอนนั้นทิ้ง แล้วแทนที่ Hierarchy tree ทั้งหมดด้วยอันที่เซฟไว้ใน Level2 อะไรก็ตามที่อยากเก็บไว้ข้ามการสลับนี้ (คะแนน, inventory) ต้องอยู่ในที่ที่ไม่ได้เป็นส่วนหนึ่งของ scene ที่ถูก unload — เป็นหัวข้อของบทหลัง ๆ

Tip SceneManager.LoadScene(name, LoadSceneMode.Additive) โหลด scene ซ้อนทับ scene ปัจจุบันแทนที่จะแทนที่มัน มีประโยชน์เช่นโหลด scene ของ UI overlay โดยไม่ต้อง unload level ที่อยู่ข้างล่าง
Common mistake เรียก SceneManager.LoadScene("Level2") ทั้งที่ "Level2" ไม่เคยถูกเพิ่มเข้า File > Build Settings > Scenes In Build เลย มันอาจรันได้ปกติใน Editor แล้วพังตอน build จริง เพราะมีแค่ scene ที่อยู่ใน Build Settings เท่านั้นที่จะถูกรวมเข้าไปตอน compile เกม

9. Prefab: สร้างครั้งเดียว ปั๊มออกมาได้หลายตัว

สมมติคุณสร้าง GameObject "Goblin" ที่ตั้งค่าครบแล้ว — Transform, model, Health, สคริปต์ AI — และ level ของคุณต้องการ 30 ตัว copy-paste 30 รอบก็ใช้ได้แหละ จนกว่าจะเจอบั๊กในสคริปต์ AI: ทีนี้ต้องไปแก้ทีละ 30 ที่ Prefab แก้ปัญหานี้ ลาก GameObject ที่ตั้งค่าไว้แล้วจาก Hierarchy ไปวางใน Project window Unity จะเซฟมันเป็นไฟล์ template ที่ใช้ซ้ำได้ (.prefab) ทุก copy ที่คุณวางหลังจากนั้นคือ instance ที่ผูกกลับไปยัง template ตัวเดียวนั้น

Project window: Assets/Prefabs/Goblin.prefab (the template, saved on disk) Scene Hierarchy (Level1): Goblin (instance) -> linked to Goblin.prefab Goblin (instance) -> linked to Goblin.prefab Goblin (instance) -> linked to Goblin.prefab Edit Goblin.prefab once (e.g. Max Hp 100 -> 150) | v ALL THREE instances update to Max Hp 150 automatically

เวลาจะสร้าง instance จากโค้ด — วิธีปกติในการ spawn ศัตรู, กระสุน, ไอเทม — ใช้ Instantiate:

using UnityEngine;

public class GoblinSpawner : MonoBehaviour
{
    public GameObject goblinPrefab;   // drag Goblin.prefab onto this slot in the Inspector

    void Start()
    {
        for (int i = 0; i < 3; i++)
        {
            Vector3 spot = new Vector3(i * 2f, 0f, 0f);
            GameObject g = Instantiate(goblinPrefab, spot, Quaternion.identity);
            g.name = "Goblin_" + i;
            Debug.Log("Spawned " + g.name + " at " + spot);
        }
    }
}
Spawned Goblin_0 at (0.0, 0.0, 0.0)
Spawned Goblin_1 at (2.0, 0.0, 0.0)
Spawned Goblin_2 at (4.0, 0.0, 0.0)

Instantiate(prefab, position, rotation) สร้างสำเนาแบบเต็ม ๆ อิสระจากกันของทุกอย่างที่เซฟไว้ใน prefab — ทุก component ทุกค่า field — แล้วเอาไปวางใน scene ปัจจุบันตรงตำแหน่งที่กำหนด Quaternion.identity แปลว่า "ไม่หมุนเลย" ตอนนี้ goblin ทั้งสามตัวมีตัวตนเป็น GameObject ของตัวเอง มี Health.currentHP ของตัวเอง เหมือนกับ Knight กับ Slime ในหัวข้อ 3 เป๊ะ

Common mistake ปล่อย field goblinPrefab ว่างไว้ใน Inspector มัน compile ผ่านสบาย ๆ แต่ตอน runtime Instantiate(goblinPrefab, ...) จริง ๆ แล้วคือ Instantiate(null, ...) ซึ่งจะโยน error ทันทีที่ Start() รัน ถ้า spawner "ไม่ทำอะไรเลย" ให้เช็กก่อนว่า prefab slot ของมันถูกใส่ค่าจริงหรือเปล่า

10. Prefab Variant กับ Override

บางทีคุณอยากได้ object ที่เหมือน prefab เกือบทุกอย่างแต่ไม่เป๊ะ — เช่น "Goblin Boss" ที่มี HP เยอะกว่าและตัวใหญ่กว่า Unity มีสองวิธีทำแบบนี้โดยไม่ต้อง duplicate prefab ทั้งตัว

Instance override

เลือก instance ที่วางไว้ตัวหนึ่งใน Hierarchy แล้วแก้ field ตรง ๆ — เช่น Max Hp จาก 100 เป็น 500 Inspector จะทำเครื่องหมายที่ field นั้น (ปกติเป็นตัวหนาหรือแถบเล็ก ๆ ทางซ้าย) เพื่อบอกว่าตอนนี้มันเป็น override: instance ตัวนี้ต่างจาก prefab ตรง field นั้น field เดียว ส่วนที่เหลือยังตาม prefab อยู่เหมือนเดิม

Prefab Variant

คลิกขวาที่ prefab ใน Project window แล้วเลือก Create > Prefab Variant Variant คือ prefab asset ใหม่ แยกต่างหาก ที่ inherit ทุกอย่างมาจาก base prefab ของมัน แต่ override field เฉพาะบางตัว (หรือแม้แต่เพิ่ม component ใหม่) ของตัวเองได้ ที่สำคัญคือ Variant ยังตาม base prefab อยู่สำหรับทุกอย่างที่มันไม่ได้ override

Goblin.prefab (base) Max Hp = 100, Scale = 1 +-- plain instance in Level1, with an INSTANCE OVERRIDE: | Max Hp = 500 (this one field only) | +-- GoblinBoss.prefab, a VARIANT of Goblin: Max Hp = 500 <- overridden in the variant Scale = 1.5 <- overridden in the variant EnemyAI, Mesh Renderer, everything else <- still inherited from Goblin.prefab

ทีนี้ลองไปแก้ logic ของ AI ข้างใน Goblin.prefab เอง ทั้ง instance ธรรมดาและ GoblinBoss.prefab จะได้รับการเปลี่ยนแปลงนั้นอัตโนมัติ เพราะทั้งคู่ไม่ได้ override component AI ไว้เลย — มีแค่ Max Hp กับ Scale เท่านั้นที่เคยถูกทำเครื่องหมายว่าต่างกัน

Common mistake field ที่ถูก override จะโชว์ปุ่มสองปุ่มใน Inspector: Revert (ทิ้ง override ทิ้งไป กลับไปตรงกับ prefab) กับ Apply (เอาค่าของ instance ตัวนี้ดันกลับเข้าไปใน base prefab ซึ่งจะเปลี่ยน instance และ variant ตัวอื่น ๆ ทั้งหมดด้วย) คลิก Apply พลาดบนค่าที่ตั้งใจจะเปลี่ยนแค่ object เดียว จะเขียนทับ template ที่ใช้ร่วมกันทั้งโปรเจกต์แบบเงียบ ๆ อ่านให้ดีว่ากำลังจะคลิกปุ่มไหนก่อนกด

11. สคริปต์แรกที่ขยับ GameObject

ถึงเวลารวมทุกอย่างเข้าด้วยกัน: component ที่เปลี่ยน Transform ของตัวเองทุกเฟรม Unity เรียกชื่อ method บางตัวบนทุก MonoBehaviour อัตโนมัติ ในเวลาที่กำหนดไว้ — คุณไม่ต้องเรียกเองเลย Update() คือตัวหลัก: Unity เรียกมันครั้งเดียวต่อเฟรมที่ render ทุกเฟรม สำหรับทุกสคริปต์ที่ enable อยู่ใน scene

using UnityEngine;

public class SimpleMover : MonoBehaviour
{
    public float speed = 3f;   // units per second, editable in the Inspector

    void Update()               // Unity calls this once per frame, automatically
    {
        float step = speed * Time.deltaTime;   // deltaTime = seconds since last frame
        transform.Translate(Vector3.forward * step);
        Debug.Log("moved by " + step + ", now at " + transform.position);
    }
}

ติดสคริปต์นี้บน GameObject ตัวไหนก็ได้แล้วกด Play ตรงนี้ไม่มี console output ตายตัวให้ copy เพราะ Time.deltaTime ขึ้นอยู่กับว่าแต่ละเฟรมใช้เวลานานแค่ไหนจริง ๆ — แต่ trace ตามได้ ที่ประมาณ 60 เฟรมต่อวินาที แต่ละเฟรมใช้เวลาประมาณ 0.016 วินาที:

frame 1: dt=0.016s step=0.048 pos.z: 0.000 -> 0.048 frame 2: dt=0.017s step=0.051 pos.z: 0.048 -> 0.099 frame 3: dt=0.015s step=0.045 pos.z: 0.099 -> 0.144 ... after about 60 frames (~1 second): pos.z is close to 3.0

transform.Translate(Vector3.forward * step) ขยับ object ไปข้างหน้า step หน่วย บวกเข้ากับตำแหน่งที่มันอยู่อยู่แล้ว เพราะ step คือ speed * Time.deltaTime object จะเคลื่อนที่ speed หน่วยทุก ๆ วินาทีจริง ไม่ว่าจะใช้กี่เฟรมก็ตาม — เฟรมที่เร็วก็ก้าวเล็กหน่อย เฟรมที่ช้าก็ก้าวใหญ่หน่อย รวม ๆ แล้วออกมาเท่ากัน

Common mistake เขียน transform.Translate(Vector3.forward * speed) แล้วลืม Time.deltaTime แบบนั้น object จะขยับ speed หน่วย ทุกเฟรม แทนที่จะเป็นทุกวินาที บนเครื่องที่รัน 30 เฟรมต่อวินาที มันจะขยับได้ระยะทางต่อวินาทีแค่ครึ่งเดียวเทียบกับเครื่องที่รัน 60 เฟรมต่อวินาที — เกมของคุณจะวิ่งเร็วไม่เท่ากันขึ้นอยู่กับเครื่องของใคร การคูณด้วย Time.deltaTime คือสิ่งที่ทำให้การเคลื่อนที่ไม่ขึ้นกับ frame rate
Tip Vector3.forward เป็นตัวย่อของ new Vector3(0f, 0f, 1f) Unity ยังมี Vector3.up, Vector3.right และค่าติดลบของมันให้ด้วย เลยแทบไม่ต้องพิมพ์ค่าแกน Vector3 ดิบ ๆ เอง

12. Glossary

13. แบบฝึกหัด

แบบฝึกหัดที่ 1 คุณติดสคริปต์นี้บน GameObject สองตัวชื่อ "Knight" กับ "Slime":
using UnityEngine;

public class Health : MonoBehaviour
{
    public int maxHP = 100;
    public int currentHP;

    void Awake()
    {
        currentHP = maxHP;
    }

    public void TakeDamage(int amount)
    {
        currentHP -= amount;
        Debug.Log(gameObject.name + " now has " + currentHP + " HP");
    }
}
ตอน runtime สคริปต์อื่นเรียก knight.GetComponent<Health>().TakeDamage(30) แค่นั้น ไม่มีอย่างอื่นอีก Console จะพิมพ์อะไร และทำไม HP ของ Slime ถึงไม่ถูกแตะเลยทั้งที่มันมีสคริปต์ตัวเดียวกันเป๊ะติดอยู่? ตอบเป็นหนึ่งหรือสองประโยค
Show answer

Console พิมพ์แค่บรรทัดเดียว:

Knight now has 70 HP

มีแค่ TakeDamage ที่ถูกเรียกบน component Health ของ Knight เท่านั้น field currentHP ของ Knight เลยเป็นตัวเดียวที่เปลี่ยน ถึง "Slime" จะมีสคริปต์ Health ตัวเดียวกัน composition ทำให้แต่ละ GameObject ถือ สำเนา field ของ component นั้นแยกกันcurrentHP ของ Slime เป็นตัวแปรคนละตัวในหน่วยความจำจากของ Knight เลยไม่ถูกแตะต้องเลย

แบบฝึกหัดที่ 2 ใน Hierarchy Turret อยู่ที่ world position (20, 0, 0) ไม่มี parent มันมี child ตัวเดียวคือ Barrel ซึ่ง Transform ใน Inspector โชว์ local position (0, 3, 1)
  • (a) world position ของ Barrel คืออะไร?
  • (b) สคริปต์บน Barrel ขยับ Turret (parent ของมัน) ไปที่ world position (20, 0, 5) และไม่แตะ Barrel เองเลยแม้แต่นิดเดียว ตอนนี้ Barrel.transform.localPosition จะพิมพ์อะไร แล้ว Barrel.transform.position จะพิมพ์อะไร?
Show answer

(a) world position = world position ของ parent + local position ของ child = (20,0,0) + (0,3,1) = (20, 3, 1)

(b) Barrel เองไม่เคยถูกแตะเลย ดังนั้น localPosition ของมันยังเป็น (0, 3, 1) เป๊ะ — ไม่เปลี่ยน แต่ position (world space) ของมันถูกคำนวณใหม่จาก parent ทุกครั้งที่คุณอ่านมัน ดังนั้นตอนนี้มันจะอ่านได้ (20,0,5) + (0,3,1) = (20, 3, 6) นี่แหละคือเหตุผลที่ parenting มีประโยชน์: แค่ขยับ Turret ตัวเดียวก็พา Barrel ไปด้วยได้แล้ว

แบบฝึกหัดที่ 3 สคริปต์ spawner นี้มีบั๊กแยกกันสองตัวที่จะทำปัญหาตอน runtime หาให้เจอทั้งสองตัวแล้วอธิบายว่าแต่ละตัวทำอะไรพัง
using UnityEngine;

public class EnemySpawner : MonoBehaviour
{
    public GameObject enemyPrefab;
    public float moveSpeed = 4f;

    void Update()
    {
        transform.Translate(Vector3.right * moveSpeed);

        Rigidbody rb = GetComponent<Rigidbody>();
        rb.mass = 2f;
    }

    void SpawnOne()
    {
        Instantiate(enemyPrefab, transform.position, Quaternion.identity);
    }
}
Show answer

บั๊กที่ 1 — ลืม Time.deltaTime: transform.Translate(Vector3.right * moveSpeed) ขยับ object moveSpeed หน่วยทุก เฟรม ไม่ใช่ทุกวินาที วิธีแก้คือ Vector3.right * moveSpeed * Time.deltaTime เพื่อให้ความเร็วเท่าเดิมไม่ว่า frame rate จะเป็นเท่าไหร่

บั๊กที่ 2 — GetComponent ที่ไม่ได้เช็ก: GetComponent<Rigidbody>() จะคืน null ถ้า GameObject นี้ไม่มี Rigidbody ติดอยู่ แล้วบรรทัดถัดไปเรียก .mass บนมันทันที ซึ่งจะโยน NullReferenceException แล้ว Update() ก็หยุดทำงานตั้งแต่เฟรมนั้น วิธีแก้คือเช็ก if (rb != null) ก่อนใช้มัน — หรือดีกว่านั้นคือ cache Rigidbody ไว้ครั้งเดียวใน Awake() แทนที่จะเรียก GetComponent ทุกเฟรม ซึ่งมันสิ้นเปลืองด้วย

(SpawnOne เองไม่มีปัญหาตามที่เขียนไว้ ตราบใดที่ enemyPrefab ถูกใส่ค่าจริงใน Inspector — slot ที่ว่างจะทำให้ Instantiate พังแบบเดียวกับบั๊กที่ 2 คือทำงานบน null)

นี่คือโมเดลหลักทั้งหมด GameObject คือ container เปล่า ๆ component — เริ่มจาก Transform ที่ทุก object มีอยู่แล้ว — ให้ข้อมูลและ behavior กับมัน Scene คือ tree ของ GameObject ที่เซฟไว้ และ Prefab คือ template ที่คุณแก้ครั้งเดียวแล้วใช้ซ้ำได้ทุกที่ ทุกบทของ Unity หลังจากนี้ — physics, animation, UI — ก็แค่ component เพิ่มเติมที่ติดอยู่บน GameObject ข้างใน scene สร้างขึ้นด้วยวิธีเดียวกันนี้เอง

← กลับไปหน้ารวมบท