4.4 Editor Scripting และเครื่องมือเอง

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

ขยาย Unity Editor ด้วย custom inspector, window และ tool — ทักษะหลักของสาย tools programming และ technical art

สคริปต์ Unity ทุกตัวที่คุณเขียนมาจนถึงตอนนี้เป็น runtime code (โค้ดที่ทำงานเฉพาะตอนเกมกำลังเล่นอยู่จริง) บทนี้พูดถึงโค้ดอีกแบบหนึ่ง — editor scripting คือ C# ที่รัน อยู่ข้างใน Unity Editor เอง ก่อนที่เกมจะเริ่มเล่นด้วยซ้ำ เพื่อช่วยคนที่กำลังสร้างเกมอยู่

ทุกหัวข้อในบทนี้จะเป็นรูปแบบเดียวกัน: โค้ด C# สั้น ๆ, สิ่งที่เกิดขึ้นเมื่อคุณใช้มันใน Unity Editor, แล้วค่อยอธิบายแบบเข้าใจง่าย สร้างสคริปต์ตามไปด้วยระหว่างอ่าน แล้วลองกดปุ่มดูเอง — การได้เห็น Editor ตอบสนองจริง ๆ คือวิธีที่ทำให้จำได้ นี่ยังเป็นสกิลหลักของสาย Tools และ Technical Art ในสตูดิโอด้วย — ทีมพวกนี้ใช้เวลาส่วนใหญ่ในแต่ละวันสร้างของแบบในบทนี้เลย

1. ทำไมสตูดิโอถึงสร้าง custom editor tools

สตูดิโอขนาดเท่า HoYoverse หรือ Riot ไม่ได้มีแค่โปรแกรมเมอร์ ยังมี level designer ที่วางตำแหน่งศัตรูและจัดฉาก encounter, artist ที่เซ็ตอัพตัวละครและเอฟเฟกต์, และตำแหน่งที่อยู่ตรงกลางระหว่างสองฝั่งเรียกว่า Technical Artist (artist ที่เขียนโค้ดได้ด้วย ปกติจะเขียนใน editor ของ engine) คนพวกนี้ไม่ได้อยากเปิดไฟล์ C# แล้วมานั่งแก้ตัวเลขเอง พวกเขาอยากลากสไลเดอร์ กดปุ่ม แล้วเห็นผลลัพธ์ทันที — ตรงนั้นเลยใน Editor

Custom editor tools คือ UI ที่คุณสร้างขึ้นข้างใน Unity เพื่อให้คนที่ไม่ใช่โปรแกรมเมอร์ทำงานของตัวเองได้อย่างปลอดภัยและรวดเร็ว โดยไม่ต้องรอวิศวกรมาทำให้ทุกครั้ง

without a tool: with a tool: designer wants to place 20 enemies designer wants to place 20 enemies -> asks an engineer -> opens "Wave Generator" window -> engineer writes/edits a script -> sets count = 20, clicks Generate -> designer waits, tests, repeats -> sees result immediately, repeats (slow, engineer becomes a bottleneck) (fast, designer is self-sufficient)

ทุกอย่างในบทนี้ — attribute ของ Inspector, คำสั่งเมนู, custom inspector, editor window, gizmo, และ property drawer — ล้วนเป็นชิ้นส่วนขนาดต่าง ๆ กันของไอเดียเดียวกันนี้: เปลี่ยนงานที่ต้องทำมือและพลาดง่าย ให้กลายเป็น UI ที่เพื่อนร่วมทีมใช้เองได้

2. กฎทอง: โค้ด Editor ต้องอยู่ในโฟลเดอร์ Editor

Editor script ใช้ namespace พิเศษชื่อ UnityEditor ซึ่งให้คุณเข้าถึงคลาสของ Editor เอง เช่น Editor, EditorWindow, EditorGUILayout และอื่น ๆ นี่คือกฎที่ทำให้ทุกอย่างในบทนี้ใช้ได้อย่างปลอดภัย: UnityEditor namespace มีอยู่แค่ข้างใน Unity Editor เท่านั้น มันไม่ได้เป็นส่วนหนึ่งของเกมที่คุณ ship ออกไป เกมที่ build แล้วของผู้เล่น (ไฟล์ .exe, แอปมือถือ, build บนคอนโซล) ไม่มี namespace นี้อยู่เลย

Unity บังคับกฎนี้ด้วยข้อตกลงเรื่องโฟลเดอร์ สคริปต์ตัวไหนก็ตามที่อยู่ในโฟลเดอร์ที่ชื่อ Editor ตรงตัว — จะอยู่ตรงไหนก็ได้ใต้ Assets จะซ้อนลึกแค่ไหนก็ได้ — จะถูกถือว่าเป็น editor-only: compile ได้ตอนทำงานใน Editor และจะถูกตัดออกจากทุก build โดยอัตโนมัติ

Assets/ Scripts/ EnemySpawner.cs <-- normal folder: RUNTIME code, ships in the build PlayerStats.cs <-- runtime code, ships in the build Editor/ EnemySpawnerEditor.cs <-- inside "Editor": EDITOR-ONLY, never ships EnemySpawnerMenu.cs <-- editor-only, never ships

ถ้าคุณเขียนสคริปต์ที่ใช้ UnityEditor แล้วลืมเอาไปไว้ในโฟลเดอร์ Editor มันจะยัง compile ผ่านตอนคุณทำงานใน Editor ตามปกติ (เพราะ Editor โหลด UnityEditor ไว้อยู่แล้วเสมอ) ปัญหาจะโผล่มาตอนที่คุณลอง build เกมเท่านั้น เพราะขั้นตอน build จะ compile สคริปต์ของคุณ โดยไม่มี assembly ของ Editor:

Assets/Scripts/EnemySpawnerEditor.cs(1,7): error CS0246:
The type or namespace name 'UnityEditor' could not be found
(are you missing a using directive or an assembly reference?)

นี่คือข้อผิดพลาดของมือใหม่ที่พบบ่อยที่สุดในเรื่อง editor scripting วิธีแก้เหมือนกันเสมอ: ย้ายไฟล์ไปไว้ในโฟลเดอร์ชื่อ Editor (หรือถ้าไฟล์นั้นจำเป็นต้องผสมโค้ด editor เล็ก ๆ เข้ากับสคริปต์ที่ส่วนใหญ่เป็น runtime ก็ให้ครอบเฉพาะส่วนนั้นด้วย #if UNITY_EDITOR / #endif — จะเห็น pattern นี้ในหัวข้อ 11)

Common mistake เขียน logic ที่ผู้เล่นต้องใช้จริง ๆ ไว้ ข้างใน สคริปต์ในโฟลเดอร์ Editor มันจะไม่มีวันรันในเกมที่ ship ออกไปเลย เพราะทั้งไฟล์ถูกตัดออกจาก build ไปแล้ว Editor script มีไว้สำหรับคนที่กำลังสร้างเกม ไม่ใช่สำหรับตัวเกมเอง

3. สคริปต์ธรรมดา: Inspector แบบ default ให้อะไรบ้าง

ก่อนจะไปปรับปรุงอะไร มาดูก่อนว่า Unity ให้อะไรมาฟรี ๆ นี่คือ MonoBehaviour ธรรมดา ๆ ที่ไม่มี attribute เลยแม้แต่ตัวเดียว — spawner ที่จะสร้าง wave ของศัตรู:

using UnityEngine;

public class EnemySpawner : MonoBehaviour
{
    public GameObject enemyPrefab;
    public int enemyCount;
    public float spawnRadius;
    public string waveName;
}

ผูกสคริปต์นี้เข้ากับ GameObject แล้ว Inspector แบบ default ของ Unity จะวาดหนึ่งแถวต่อหนึ่ง public field ให้อัตโนมัติ โดยใช้ชื่อและ type ของ field มาเดา label ให้:

Inspector (default, no attributes) ----------------------------------- Enemy Spawner (Script) Enemy Prefab [ None (Game Object) ] Enemy Count [ 0 ] Spawn Radius [ 0 ] Wave Name [ ]

แค่นี้ก็ใช้งานได้แล้ว — designer พิมพ์ตัวเลขลงในช่องพวกนี้ได้ แต่มันมีปัญหาจริง ๆ อยู่: ไม่มีอะไรกัน Enemy Count ไม่ให้พิมพ์เป็น -5, ไม่มีอะไรอธิบายว่า Spawn Radius วัดเป็นหน่วยอะไร และถ้าสคริปต์นี้โตขึ้นเป็นยี่สิบ field มันก็จะเรียงกันเป็น list เดียวไม่แบ่งกลุ่มเลย หัวข้อ 4 กับ 6 จะแก้ปัญหานี้โดยไม่ต้องเขียนโค้ด editor-only สักบรรทัด

4. Attribute ที่ทำให้ Inspector ดีขึ้น

Attribute คือ metadata ที่คุณแปะไว้บน field หรือ class ด้วยวงเล็บเหลี่ยม เช่น [Range(1, 10)] attribute ไม่ได้รันเป็นโค้ดด้วยตัวมันเอง — มันคือคำสั่งที่ Inspector ของ Unity อ่านแล้วตอบสนอง มี 4 attribute ที่แก้ปัญหาส่วนใหญ่ที่หัวข้อ 3 ขาดไป:

using UnityEngine;

public class EnemySpawner : MonoBehaviour
{
    [Header("Wave Settings")]
    [Tooltip("Prefab to spawn for each enemy in the wave.")]
    public GameObject enemyPrefab;

    [Tooltip("How many enemies to spawn in one wave.")]
    [Range(1, 50)]
    public int enemyCount = 5;

    [Tooltip("Radius around this object where enemies can appear.")]
    public float spawnRadius = 3f;

    [Header("Debug Info")]
    [SerializeField] private int totalSpawnedSoFar;
}
Inspector (with attributes) ----------------------------------- Enemy Spawner (Script) --- Wave Settings --- Enemy Prefab [ None (Game Object) ] (hover -> tooltip text) Enemy Count [====O-----------] 5 (slider, locked to 1..50) Spawn Radius [ 3 ] --- Debug Info --- Total Spawned So Far [ 0 ]

Field สุดท้ายนี่แหละที่น่าสนใจ totalSpawnedSoFar เป็น private — ไม่มีสคริปต์อื่นเข้าถึงมันตรง ๆ ได้เลย ซึ่งตรงกับกฎเรื่อง encapsulation ที่เรียนไปแล้วในบท C# basics แต่ตัว serializer ของ Unity ไม่ได้ใช้กฎการเข้าถึงของ C# ปกติ มันใช้ reflection (การส่องดู member ของ type ตอน runtime) แล้วเช็กแค่ว่ามี attribute [SerializeField] ติดอยู่หรือเปล่า ดังนั้น field นี้จะโผล่ใน Inspector และถูกบันทึกไปกับ scene ได้ ในขณะที่โค้ด C# ตัวอื่นของคุณยังแตะมันไม่ได้อยู่ดี นี่คือวิธีมาตรฐานในการเปิดค่าให้ designer เห็นหรือปรับได้ โดยไม่ต้องเปิดให้สคริปต์ทุกตัวเข้าถึงได้

Tip field แบบ public ธรรมดาก็ถูก serialize และมองเห็นได้ใน Inspector เหมือนกัน โดยไม่ต้องมี attribute เลย ใช้ public เมื่อสคริปต์อื่นควรอ่าน/เขียนค่านั้นได้จริง ๆ ด้วย ส่วน [SerializeField] private ให้ใช้เมื่อต้องการให้แค่ Inspector มองเห็นเท่านั้น

5. MenuItem: เพิ่มคำสั่งของตัวเองเข้าไปในเมนูของ Unity

Attribute [MenuItem] เพิ่มรายการใหม่เข้าไปในแถบเมนูของ Unity (หรือเมนูคลิกขวา) ที่จะรันเมธอดเมื่อถูกคลิก เมธอดต้องเป็น static (เรียกจากตัว class เอง ไม่ใช่จาก instance — static ตัวเดียวกับที่เรียนไปในบท C# basics) และ class ต้องอยู่ในโฟลเดอร์ Editor เพราะ MenuItem มาจาก UnityEditor

using UnityEditor;
using UnityEngine;

public static class EnemySpawnerMenu
{
    [MenuItem("Tools/Enemy Spawner/Log All Spawners In Scene")]
    private static void LogAllSpawners()
    {
        EnemySpawner[] spawners = Object.FindObjectsOfType<EnemySpawner>();
        Debug.Log("Found " + spawners.Length + " EnemySpawner(s) in the scene.");
    }
}

สตริง "Tools/Enemy Spawner/Log All Spawners In Scene" คือ path แต่ละ / จะสร้าง submenu ซ้อนกันไป ดังนั้นมันจะไปโผล่เป็น Tools -> Enemy Spawner -> Log All Spawners In Scene ในแถบเมนูบนสุดของ Unity คลิกแล้วมันจะเรียก LogAllSpawners() ทันที — ไม่ต้องกด Play เลย

Console output (คลิกหนึ่งครั้ง โดยมี EnemySpawner อยู่ใน scene 3 ตัว):

Found 3 EnemySpawner(s) in the scene.

คุณยังเพิ่ม validate function คู่กันได้ด้วย: เมธอดตัวที่สองที่ใช้ path string เดียวกัน แต่ใส่ argument ตัวที่สองของ attribute เป็น true มัน return bool และ Unity จะเรียกมันตลอดเวลาเพื่อตัดสินใจว่าจะทำให้ menu item จางลง (grey out) หรือเปล่า

[MenuItem("Tools/Enemy Spawner/Log All Spawners In Scene", true)]
private static bool ValidateLogAllSpawners()
{
    // return false to grey the item out, e.g. only allow it
    // when the scene actually has a spawner in it.
    return Object.FindObjectsOfType<EnemySpawner>().Length > 0;
}

ตอนนี้ menu item จะคลิกได้ก็ต่อเมื่อมี EnemySpawner อย่างน้อยหนึ่งตัวให้ log — จุดเล็ก ๆ แบบนี้ช่วยไม่ให้เพื่อนร่วมทีมงงว่าทำไมคำสั่งที่กดไปแล้วไม่เห็นเกิดอะไรขึ้นเลย

6. Custom Inspector: [CustomEditor] และ OnInspectorGUI

Custom inspector จะแทนที่ Inspector แบบ field-by-field อัตโนมัติของ component ตัวหนึ่งด้วยเมธอดที่คุณเขียนเอง นี่คือจุดที่คุณเพิ่มปุ่มที่รันโค้ดจริง ๆ ได้ตอน edit time ก่อนอื่นให้ EnemySpawner มีอะไรสักอย่างที่คุ้มค่าจะมีปุ่ม — เมธอดที่ spawn wave ทันที:

using UnityEngine;

public class EnemySpawner : MonoBehaviour
{
    // ... fields from section 4 stay the same ...

    public void SpawnWave()
    {
        for (int i = 0; i < enemyCount; i++)
        {
            Vector3 offset = Random.insideUnitSphere * spawnRadius;
            offset.y = 0f;
            Instantiate(enemyPrefab, transform.position + offset, Quaternion.identity);
        }
        totalSpawnedSoFar += enemyCount;
    }
}

ต่อไปคือ custom inspector ซึ่งต้องอยู่ในโฟลเดอร์ Editor:

using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(EnemySpawner))]
public class EnemySpawnerEditor : Editor
{
    public override void OnInspectorGUI()
    {
        DrawDefaultInspector();   // draw all the normal fields, as before

        EnemySpawner spawner = (EnemySpawner)target;

        GUILayout.Space(10);
        if (GUILayout.Button("Spawn Wave Now"))
        {
            spawner.SpawnWave();
        }
    }
}

[CustomEditor(typeof(EnemySpawner))] บอก Unity ว่า "ใช้ class นี้แทน Inspector แบบ default ทุกครั้งที่มีการเลือก EnemySpawner" ข้างใน OnInspectorGUI คลาสฐาน Editor ให้ target มาให้คุณ — คือ reference ไปยัง object ที่ถูกเลือกอยู่ โดยมี type แบบทั่วไปเป็น Object เรา cast มัน (แปลงให้เป็น type ที่เฉพาะเจาะจงขึ้น ตามที่เรียนไปในบท C# basics) ให้เป็น EnemySpawner เพื่อที่จะเรียก SpawnWave() บนมันได้

Inspector (custom editor) ----------------------------------- Enemy Spawner (Script) --- Wave Settings --- Enemy Prefab [ Goblin (Game Object) ] Enemy Count [====O-----------] 5 Spawn Radius [ 3 ] --- Debug Info --- Total Spawned So Far [ 0 ] [ Spawn Wave Now ] <-- new button, added by our editor

กด Spawn Wave Now ตอนที่เกม ไม่ได้ เล่นอยู่ แล้ว goblin ห้าตัวจะโผล่ขึ้นมารอบ ๆ spawner ตรงนั้นเลยใน Scene view — ทันที โดยไม่ต้องกดปุ่ม Play เลย นี่คือพลังที่แท้จริงของ editor scripting: มันรันเมธอดของเกมเองได้ตอน author time ทำให้ designer ปรับตำแหน่งและตัวเลขได้โดยไม่ต้องเข้า Play mode เลยสักครั้ง

Tip tool ที่เปลี่ยนแปลง scene ควรเรียก Undo.RecordObject(spawner, "Spawn Wave") ก่อนที่จะเปลี่ยนค่า และเรียก EditorUtility.SetDirty(spawner) หลังจากนั้น ตัวแรกทำให้การเปลี่ยนแปลง undo ได้ด้วย Ctrl+Z เหมือน action อื่น ๆ ของ Editor ส่วนตัวที่สองบอก Unity ว่า scene มีการเปลี่ยนแปลงที่ยังไม่ได้ save ทำให้ไม่มีอะไรหายไปเงียบ ๆ tool เล็ก ๆ ที่ข้ามขั้นตอนนี้ไปสามารถทำงานของเพื่อนร่วมทีมพังแบบเงียบ ๆ ได้
Common mistake การ cast target ด้วย (EnemySpawner) ตรง ๆ ใช้ได้กับปุ่มง่าย ๆ แบบนี้ แต่สำหรับ field ที่ต้องการ multi-object editing (เลือก spawner หลายตัวแล้วแก้พร้อมกัน) และรองรับ Undo อัตโนมัติด้วย tool จริง ๆ จะใช้ serializedObject กับ SerializedProperty แทนการแตะ field ของ target ตรง ๆ DrawDefaultInspector() จัดการเรื่องนี้ให้ถูกต้องอยู่แล้ว — มีแค่โค้ด GUI ที่คุณเขียนเองเท่านั้นที่ต้องระวังเป็นพิเศษ

7. EditorWindow: หน้าต่าง tool ของตัวเองที่ dock ได้

Custom inspector จะโผล่ก็ต่อเมื่อ component ของมันถูกเลือกอยู่เท่านั้น บางครั้ง tool ก็ไม่ได้ "เกี่ยวกับ" object ตัวใดตัวหนึ่งโดยเฉพาะ — มันเป็นพื้นที่ทำงานของตัวเอง เช่น level batch-generator สำหรับกรณีนี้ Unity ให้ EditorWindow มา: หน้าต่างที่คุณออกแบบเอง เปิดจากเมนู ลอยหรือ dock ข้าง ๆ แท็บ Scene กับ Game ได้เหมือนแผงในตัวของ Unity เอง

using UnityEditor;
using UnityEngine;

public class WaveGeneratorWindow : EditorWindow
{
    private int waveCount = 3;
    private float spacing = 2f;

    [MenuItem("Tools/Wave Generator")]
    private static void ShowWindow()
    {
        GetWindow<WaveGeneratorWindow>("Wave Generator");
    }

    private void OnGUI()
    {
        GUILayout.Label("Wave Generator Settings", EditorStyles.boldLabel);

        waveCount = EditorGUILayout.IntField("Wave Count", waveCount);
        spacing   = EditorGUILayout.FloatField("Spacing", spacing);

        if (GUILayout.Button("Generate Waves"))
        {
            for (int i = 0; i < waveCount; i++)
            {
                GameObject wave = new GameObject("Wave " + i);
                wave.transform.position = new Vector3(i * spacing, 0f, 0f);
            }
            Debug.Log("Generated " + waveCount + " wave marker(s).");
        }
    }
}

ShowWindow() ถูกเรียกจาก menu item Tools/Wave Generator และ GetWindow<WaveGeneratorWindow>("Wave Generator") จะสร้าง (หรือ focus ถ้าเปิดอยู่แล้ว) หน้าต่างที่มี title นั้น OnGUI คือ Unity message ที่หน้าต่างนี้เรียกทุกครั้งที่ต้องวาดตัวเองใหม่ — ทุกเฟรมที่มันมองเห็นได้ คล้าย ๆ กับที่ Update รันทุกเฟรมใน MonoBehaviour ธรรมดา เพียงแต่ตัวนี้วาด editor UI แทนที่จะเป็น game logic

+---------------------------------------+ | Wave Generator [x] | +---------------------------------------+ | Wave Generator Settings | | | | Wave Count [ 4 ] | | Spacing [ 2 ] | | | | [ Generate Waves ] | +---------------------------------------+

ตั้ง Wave Count เป็น 4 แล้วกด Generate Waves: GameObject เปล่า ๆ สี่ตัวชื่อ Wave 0 ถึง Wave 3 จะโผล่ใน Hierarchy ห่างกัน 2 หน่วยตามแกน X และ Console จะพิมพ์:

Generated 4 wave marker(s).

ความต่างสำคัญจากหัวข้อ 6: EditorWindow ไม่ได้ผูกอยู่กับ object ที่ถูกเลือกตัวไหนเลย มันเปิดค้างอยู่ได้แม้เปลี่ยนการเลือก object, ลากไป dock ข้าง ๆ แผงอื่นของ Unity ได้ และเป็นตัวเลือกที่ถูกต้องสำหรับงานแบบ batch, tool ที่ครอบคลุมทั้ง level, หรืออะไรก็ตามที่ต้องการ UI ของตัวเองแบบถาวร แทนที่จะอยู่ใน Inspector ของ component ตัวเดียว

8. Gizmos: เครื่องมือช่วยวาดใน Scene view

Gizmo คือรูปทรงที่วาดตรง ๆ ลงใน Scene view เพื่อทำให้เห็นภาพของสิ่งที่ไม่มีรูปร่างให้เห็นด้วยตัวเอง — อย่างเช่น spawnRadius ซึ่งเป็นแค่ตัวเลขจนกว่าคุณจะเห็นวงกลมที่มันอธิบายจริง ๆ Gizmo ถูกวาดโดย Unity message พิเศษสองตัว และต่างจากทุกอย่างในบทนี้ตรงที่ มันอยู่ในสคริปต์ธรรมดา ไม่ใช่โฟลเดอร์ Editor:

using UnityEngine;

public class EnemySpawner : MonoBehaviour
{
    // ... fields and SpawnWave() from earlier sections stay the same ...

    private void OnDrawGizmosSelected()
    {
        Gizmos.color = Color.red;
        Gizmos.DrawWireSphere(transform.position, spawnRadius);
    }
}
Scene view, EnemySpawner selected: . . . . . . . . (spawner) . <-- red wire sphere, radius = spawnRadius . . . . . . . designer can now SEE the spawn range without pressing Play

OnDrawGizmosSelected จะวาดก็ต่อเมื่อ object ตัวนี้ (หรือ parent ของมัน) ถูกเลือกอยู่ใน Hierarchy เท่านั้น ส่วนพี่น้องของมัน OnDrawGizmos จะวาด ตลอดเวลา ที่ Scene view เปิดอยู่ ไม่ว่าจะเลือกหรือไม่ก็ตาม — มีประโยชน์กับอะไรที่อยากให้เห็นตลอด เช่น ขอบเขตของ level แต่จะรกกว่าถ้ามี object เป็นร้อย ๆ ตัว

ตรงนี้คือส่วนที่ดูเหมือนจะขัดกับหัวข้อ 2 เลยคุ้มค่าที่จะพูดให้ชัด: OnDrawGizmos กับ OnDrawGizmosSelected อ้างอิง Gizmos ได้และยังอยู่ในสคริปต์ runtime ธรรมดา นอกโฟลเดอร์ Editor ได้ Unity ทำเคสพิเศษให้ชื่อเมธอดสองตัวนี้ — มันตัดออกจาก build ที่ ship ออกไปโดยอัตโนมัติ เหมือนกับที่มันตัด comment ออก โดยที่คุณไม่ต้องใช้ UnityEditor หรือโฟลเดอร์พิเศษเลย กฎเรื่องโฟลเดอร์ Editor เป็นเรื่องของ namespace UnityEditor; ส่วน Gizmos เป็นข้อยกเว้นเล็ก ๆ แยกต่างหากที่ฝังอยู่ใน engine โดยเฉพาะ เพื่อให้คุณผสม visualization เข้าไปในสคริปต์ gameplay ธรรมดาได้

Tip Gizmo ไม่เคยแสดงผลในเกมที่รันจริง แม้แต่ใน Game view ตอน Play mode ของ Editor เอง — มันแสดงแค่ใน Scene view เท่านั้น และแสดงแค่ตอน edit อยู่ (หรือถ้าเป็น OnDrawGizmos ก็แสดงตลอด) มันไม่มีต้นทุนอะไรเลยใน build ที่ ship ออกไป ดังนั้นใส่ได้ตามสบายระหว่างสร้าง level

9. Property drawer: เปลี่ยนวิธีวาด type หนึ่ง ให้เปลี่ยนทุกที่

บางครั้ง custom type เล็ก ๆ ตัวเดียวกันโผล่เป็น field ในสคริปต์หลายตัวมาก และ layout แบบสองแถวต่อหนึ่ง field ที่เป็น default ก็เปลืองพื้นที่ทุกครั้งไป Property drawer ให้คุณปรับแต่ง layout ของ Inspector สำหรับ type หนึ่ง ครั้งเดียว แล้วมันจะใช้ได้อัตโนมัติทุกที่ที่ type นั้นถูกใช้เป็น field เริ่มจาก serializable class เล็ก ๆ ในสคริปต์ธรรมดา (ไม่ใช่ Editor):

[System.Serializable]
public class MinMaxFloat
{
    public float min;
    public float max;
}

เมื่อใช้เป็น field — เช่น public MinMaxFloat spawnDelay; บน EnemySpawner — Inspector แบบ default ของ Unity จะวาดมันเป็น sub-block ย่อหน้าเข้าไป โดยมี min กับ max เรียงกันคนละบรรทัด Property drawer บีบมันให้เหลือแถวเดียวที่เรียบร้อยได้:

using UnityEditor;
using UnityEngine;

[CustomPropertyDrawer(typeof(MinMaxFloat))]
public class MinMaxFloatDrawer : PropertyDrawer
{
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        EditorGUI.BeginProperty(position, label, property);

        SerializedProperty minProp = property.FindPropertyRelative("min");
        SerializedProperty maxProp = property.FindPropertyRelative("max");

        Rect labelRect = new Rect(position.x, position.y, 60, position.height);
        Rect minRect   = new Rect(position.x + 65,  position.y, 60, position.height);
        Rect maxRect   = new Rect(position.x + 130, position.y, 60, position.height);

        EditorGUI.LabelField(labelRect, label);
        EditorGUI.PropertyField(minRect, minProp, GUIContent.none);
        EditorGUI.PropertyField(maxRect, maxProp, GUIContent.none);

        EditorGUI.EndProperty();
    }
}

[CustomPropertyDrawer(typeof(MinMaxFloat))] บอก Unity ว่า "เมื่อไหร่ก็ตามที่คุณกำลังจะวาด field type MinMaxFloat ให้เรียกอันนี้แทน" property.FindPropertyRelative("min") เจาะเข้าไปข้างใน serialized object เพื่อดึง field min ของมันด้วยชื่อ และค่า Rect จะหั่นแถวเดียวที่ Unity ให้มาออกเป็นสามกล่องเล็ก ๆ เรียงข้างกัน ไฟล์นี้ก็ต้องอยู่ในโฟลเดอร์ Editor เหมือนกัน — เพราะมันใช้ UnityEditor กฎเดียวกับ tool อื่น ๆ ทุกตัวในบทนี้

before (default drawer): after (custom drawer): Spawn Delay Spawn Delay Min[1 ] Max[3 ] Min [ 1 ] Max [ 3 ] (2 lines, indented) (1 line, compact)

ผลตอบแทนคือคุณเขียน drawer นี้ ครั้งเดียว แล้วทุกสคริปต์ในโปรเจกต์ทั้งหมดที่มี field เป็น MinMaxFloat — ไม่ว่าจะตอนนี้หรือที่เพิ่มมาปีหน้า — จะได้ layout แบบกระชับนี้อัตโนมัติ โดยไม่ต้องทำงานเพิ่มต่อสคริปต์เลย

10. Editor tool อยู่ตรงไหนใน workflow

ลองมองภาพรวมทั้งหมดพร้อมกันจะช่วยได้: editor scripting อยู่ฝั่งหนึ่งของเส้นแบ่งที่ runtime code ไม่มีวันข้ามไปเลย

DESIGNER / ARTIST / TECHNICAL ARTIST | v Unity Editor UI <-- built by YOU, this chapter: - MenuItem commands (section 5) - Custom Inspectors + buttons (section 6) - EditorWindows (section 7) - Gizmos for feedback (section 8) - Property drawers (section 9) | v edits GameObjects, ScriptableObjects, prefabs, scenes ( all of this happens ONLY inside the Editor, at "author time" ) | v saved into .unity / .prefab / .asset files | v RUNTIME CODE (plain MonoBehaviours, plain classes) reads that saved data when the game actually PLAYS -- runs in the Editor's Play mode AND in the shipped build | v PLAYER sees the result: enemies, levels, balance, UI

สังเกตว่าจุดแบ่งอยู่ตรงไหน: ทุกอย่างเหนือเส้น "saved into files" มีอยู่แค่เพื่อทำให้ การสร้างงาน เร็วขึ้นและปลอดภัยขึ้นเท่านั้น ทุกอย่างใต้เส้นนั้นคือตัวเกมจริง ๆ และมันไม่รู้ด้วยซ้ำว่า tool พวกนั้นเคยถูกใช้หรือเปล่า — มันแค่อ่านตัวเลขและ prefab ที่ถูก save ไว้ นี่คือเหตุผลที่กฎเรื่องโฟลเดอร์ Editor จากหัวข้อ 2 ถึงเข้มงวดขนาดนั้น: มันบังคับเส้นแบ่งนี้ในทางกายภาพ ทำให้ editor tool ไม่มีวันกลายเป็นสิ่งที่ผู้เล่นต้องพึ่งพาโดยไม่ตั้งใจ

11. กฎ, จุดพลาดที่พบบ่อย, และ best practices

สรุปสั้น ๆ ที่ใช้งานได้จริงก่อนไปทำแบบฝึกหัด:

มี tool อีกตัวที่ควรรู้ไว้สำหรับกรณีที่พบไม่บ่อย ที่ไฟล์เดียวจำเป็นต้องมีทั้ง runtime code และโค้ด editor-only เล็ก ๆ ผสมกันจริง ๆ: preprocessor directive #if UNITY_EDITOR Unity จะ define UNITY_EDITOR ให้อัตโนมัติตอนคุณทำงานใน Editor และปล่อยให้มันไม่ถูก define ใน build ของผู้เล่น ดังนั้นโค้ดระหว่าง #if UNITY_EDITOR กับ #endif จะถูก compile แค่ใน Editor และถูกเอาออกจาก build อย่างเงียบ ๆ โดยไม่ต้องมีไฟล์หรือโฟลเดอร์แยกเลย

using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif

public class EnemySpawner : MonoBehaviour
{
    public void SpawnWave()
    {
        // ... runtime logic, ships in every build ...

#if UNITY_EDITOR
        // Editor-only convenience: also select the spawner after spawning,
        // so the designer sees it highlighted. Compiled out of real builds.
        Selection.activeGameObject = gameObject;
#endif
    }
}
Common mistake คว้า #if UNITY_EDITOR มาใช้ทุกที่แทนที่จะแยกไปโฟลเดอร์ Editor มันก็ใช้ได้อยู่ แต่ทำให้ไฟล์ runtime รกไปด้วยเรื่องของ editor และพลาดง่าย (ลืม #endif คู่กัน หรืออ้างอิง UnityEditor นอก block ที่ครอบไว้) ควรแยกเป็นไฟล์ต่างหากไปไว้ในโฟลเดอร์ Editor เมื่อส่วนที่เป็น editor-only มีมากกว่าหนึ่งหรือสองบรรทัด — ซึ่งเป็นแบบนั้นเกือบทั้งหมดในหัวข้อ 5 ถึง 9

12. Glossary (ศัพท์)

13. แบบฝึกหัด

Exercise 1 นี่คือสคริปต์ธรรมดาที่ไม่มี attribute เลยสักตัว เขียนใหม่ให้: field ทั้งสองตัวอยู่ใต้ [Header("Player Settings")], แต่ละ field มี [Tooltip] อธิบายว่ามันคืออะไรในหนึ่งประโยคสั้น ๆ และ maxHealth ใช้ [Range(50, 500)]
using UnityEngine;

public class PlayerStats : MonoBehaviour
{
    public int maxHealth;
    public float moveSpeed;
}
Show answer
using UnityEngine;

public class PlayerStats : MonoBehaviour
{
    [Header("Player Settings")]
    [Tooltip("Maximum health points before the player dies.")]
    [Range(50, 500)]
    public int maxHealth = 100;

    [Tooltip("Movement speed in meters per second.")]
    public float moveSpeed = 5f;
}

[Header] ต้องใส่แค่ครั้งเดียว เหนือ field แรกของกลุ่ม — มันวาดเส้นคั่นแล้ว field ถัดไปทุกตัวจะอยู่ในกลุ่มนั้นจนกว่าจะเจอ [Header] ตัวถัดไป [Range(50, 500)] เปลี่ยน maxHealth ให้เป็นสไลเดอร์ที่ลงต่ำกว่า 50 หรือสูงกว่า 500 ไม่ได้เลย แม้ว่าจะมีใครพิมพ์ตัวเลขลงไปตรง ๆ ในช่องก็ตาม moveSpeed มี tooltip ของตัวเองแต่ไม่มี range เพราะค่าต่ำสุด/สูงสุดที่สมเหตุสมผลของความเร็วไม่ชัดเจนเท่ากับ health

Exercise 2 เขียน static editor class ที่มี [MenuItem] ที่ path "Tools/Player/Log Selected Count" ซึ่ง log ว่ามี GameObject กี่ตัวที่ถูกเลือกอยู่ใน Hierarchy ตอนนี้ (คำใบ้: คลาส Selection ที่เป็น editor-only จาก UnityEditor มี static property ชื่อ gameObjects ที่ return array ของ object ที่ถูกเลือกอยู่ตอนนี้)
Show answer
using UnityEditor;
using UnityEngine;

public static class SelectionLogger
{
    [MenuItem("Tools/Player/Log Selected Count")]
    private static void LogSelectedCount()
    {
        int count = Selection.gameObjects.Length;
        Debug.Log("You have " + count + " GameObject(s) selected.");
    }
}

ไฟล์นี้ต้องอยู่ในโฟลเดอร์ Editor เพราะมันใช้ทั้ง UnityEditor.MenuItem และ UnityEditor.Selection เลือก object สามตัวใน Hierarchy แล้วคลิก Tools -> Player -> Log Selected Count จะพิมพ์:

You have 3 GameObject(s) selected.
Exercise 3 เพื่อนร่วมทีมเอาไฟล์นี้ไปวางไว้ที่ Assets/Scripts/EnemySpawnerEditor.cs — สังเกตนะ: Scripts ไม่ใช่ Scripts/Editor
using UnityEditor;

[CustomEditor(typeof(EnemySpawner))]
public class EnemySpawnerEditor : Editor
{
    // ... OnInspectorGUI ...
}
มันทำงานปกติดีตอนที่เขาเทสต์เล่นใน Editor แล้วอะไรจะเกิดขึ้นครั้งแรกที่มีใครรัน File -> Build Settings -> Build และมีวิธีแก้กี่แบบ อะไรบ้าง?
Show answer

Build จะล้มเหลวพร้อม compile error เพราะขั้นตอน build จะ compile สคริปต์โดยไม่มี assembly ของ Editor ทำให้หา UnityEditor ไม่เจอ:

Assets/Scripts/EnemySpawnerEditor.cs(1,7): error CS0246:
The type or namespace name 'UnityEditor' could not be found
(are you missing a using directive or an assembly reference?)

วิธีแก้ที่ 1: ย้ายไฟล์ไปไว้ในโฟลเดอร์ที่ชื่อ Editor ตรงตัว เช่น Assets/Scripts/Editor/EnemySpawnerEditor.cs นี่คือวิธีแก้มาตรฐาน และเป็นวิธีที่ถูกต้องในเคสนี้ เพราะทั้งไฟล์เป็น editor-only ล้วน ๆ

วิธีแก้ที่ 2: ครอบส่วนที่ใช้ UnityEditor ด้วย #if UNITY_EDITOR / #endif แทนการย้ายไฟล์ วิธีนี้จะสมเหตุสมผลก็ต่อเมื่อไฟล์เดียวกันนั้นมี runtime code ที่ต้องคงอยู่ด้วย สำหรับไฟล์ที่เป็น editor code ทั้งหมด แบบนี้ การย้ายไปโฟลเดอร์ Editor (วิธีที่ 1) ง่ายกว่าและเป็นวิธีที่โปรเจกต์จริงใช้กัน

นี่คือ toolkit หลักทั้งหมด: attribute ที่ทำให้ Inspector แบบ default ดีขึ้น, คำสั่ง [MenuItem], custom inspector พร้อมปุ่ม, tool แบบ EditorWindow ที่ยืนได้ด้วยตัวเอง, gizmo สำหรับ feedback ใน Scene view, และ property drawer ที่แก้ layout ของ type หนึ่งให้ทุกที่พร้อมกัน กฎเดียวที่ร้อยทุกอย่างเข้าด้วยกันคือเส้นแบ่งโฟลเดอร์ Editor: editor code ทำให้คนที่กำลังสร้างเกมทำงานเร็วขึ้น ส่วน runtime code คือตัวเกมเอง และ Unity ป้องกันไม่ให้สองอย่างนี้ปนกันเข้าไปใน build ที่ ship ออกไปโดยไม่ตั้งใจ

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