สคริปต์ Unity ทุกตัวที่คุณเขียนมาจนถึงตอนนี้เป็น runtime code (โค้ดที่ทำงานเฉพาะตอนเกมกำลังเล่นอยู่จริง) บทนี้พูดถึงโค้ดอีกแบบหนึ่ง — editor scripting คือ C# ที่รัน อยู่ข้างใน Unity Editor เอง ก่อนที่เกมจะเริ่มเล่นด้วยซ้ำ เพื่อช่วยคนที่กำลังสร้างเกมอยู่
ทุกหัวข้อในบทนี้จะเป็นรูปแบบเดียวกัน: โค้ด C# สั้น ๆ, สิ่งที่เกิดขึ้นเมื่อคุณใช้มันใน Unity Editor, แล้วค่อยอธิบายแบบเข้าใจง่าย สร้างสคริปต์ตามไปด้วยระหว่างอ่าน แล้วลองกดปุ่มดูเอง — การได้เห็น Editor ตอบสนองจริง ๆ คือวิธีที่ทำให้จำได้ นี่ยังเป็นสกิลหลักของสาย Tools และ Technical Art ในสตูดิโอด้วย — ทีมพวกนี้ใช้เวลาส่วนใหญ่ในแต่ละวันสร้างของแบบในบทนี้เลย
สตูดิโอขนาดเท่า HoYoverse หรือ Riot ไม่ได้มีแค่โปรแกรมเมอร์ ยังมี level designer ที่วางตำแหน่งศัตรูและจัดฉาก encounter, artist ที่เซ็ตอัพตัวละครและเอฟเฟกต์, และตำแหน่งที่อยู่ตรงกลางระหว่างสองฝั่งเรียกว่า Technical Artist (artist ที่เขียนโค้ดได้ด้วย ปกติจะเขียนใน editor ของ engine) คนพวกนี้ไม่ได้อยากเปิดไฟล์ C# แล้วมานั่งแก้ตัวเลขเอง พวกเขาอยากลากสไลเดอร์ กดปุ่ม แล้วเห็นผลลัพธ์ทันที — ตรงนั้นเลยใน Editor
Custom editor tools คือ UI ที่คุณสร้างขึ้นข้างใน Unity เพื่อให้คนที่ไม่ใช่โปรแกรมเมอร์ทำงานของตัวเองได้อย่างปลอดภัยและรวดเร็ว โดยไม่ต้องรอวิศวกรมาทำให้ทุกครั้ง
ทุกอย่างในบทนี้ — attribute ของ Inspector, คำสั่งเมนู, custom inspector, editor window, gizmo, และ property drawer — ล้วนเป็นชิ้นส่วนขนาดต่าง ๆ กันของไอเดียเดียวกันนี้: เปลี่ยนงานที่ต้องทำมือและพลาดง่าย ให้กลายเป็น UI ที่เพื่อนร่วมทีมใช้เองได้
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 โดยอัตโนมัติ
ถ้าคุณเขียนสคริปต์ที่ใช้ 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)
ก่อนจะไปปรับปรุงอะไร มาดูก่อนว่า 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 ให้:
แค่นี้ก็ใช้งานได้แล้ว — designer พิมพ์ตัวเลขลงในช่องพวกนี้ได้ แต่มันมีปัญหาจริง ๆ อยู่: ไม่มีอะไรกัน Enemy Count ไม่ให้พิมพ์เป็น -5, ไม่มีอะไรอธิบายว่า Spawn Radius วัดเป็นหน่วยอะไร และถ้าสคริปต์นี้โตขึ้นเป็นยี่สิบ field มันก็จะเรียงกันเป็น list เดียวไม่แบ่งกลุ่มเลย หัวข้อ 4 กับ 6 จะแก้ปัญหานี้โดยไม่ต้องเขียนโค้ด editor-only สักบรรทัด
Attribute คือ metadata ที่คุณแปะไว้บน field หรือ class ด้วยวงเล็บเหลี่ยม เช่น [Range(1, 10)] attribute ไม่ได้รันเป็นโค้ดด้วยตัวมันเอง — มันคือคำสั่งที่ Inspector ของ Unity อ่านแล้วตอบสนอง มี 4 attribute ที่แก้ปัญหาส่วนใหญ่ที่หัวข้อ 3 ขาดไป:
[Header("text")] — วาดเส้นคั่นตัวหนาพร้อม label ไว้เหนือ field ถัดไป เพื่อจัดกลุ่ม field ที่เกี่ยวข้องกันให้เห็นภาพชัด[Tooltip("text")] — โชว์ข้อความ hint เวลาเมาส์ hover บน label ของ field อธิบายว่ามันคืออะไร[Range(min, max)] — เปลี่ยน field ตัวเลขให้เป็นสไลเดอร์ที่ล็อกไว้ระหว่าง min กับ max ทำให้พิมพ์ค่าที่นอกช่วงไม่ได้เลยทางกายภาพ[SerializeField] — ทำให้ field ที่เป็น private โผล่ขึ้นมาและถูกบันทึกใน Inspector ได้ โดยไม่ต้องทำให้มันเป็น publicusing 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;
}
Field สุดท้ายนี่แหละที่น่าสนใจ totalSpawnedSoFar เป็น private — ไม่มีสคริปต์อื่นเข้าถึงมันตรง ๆ ได้เลย ซึ่งตรงกับกฎเรื่อง encapsulation ที่เรียนไปแล้วในบท C# basics แต่ตัว serializer ของ Unity ไม่ได้ใช้กฎการเข้าถึงของ C# ปกติ มันใช้ reflection (การส่องดู member ของ type ตอน runtime) แล้วเช็กแค่ว่ามี attribute [SerializeField] ติดอยู่หรือเปล่า ดังนั้น field นี้จะโผล่ใน Inspector และถูกบันทึกไปกับ scene ได้ ในขณะที่โค้ด C# ตัวอื่นของคุณยังแตะมันไม่ได้อยู่ดี นี่คือวิธีมาตรฐานในการเปิดค่าให้ designer เห็นหรือปรับได้ โดยไม่ต้องเปิดให้สคริปต์ทุกตัวเข้าถึงได้
public ธรรมดาก็ถูก serialize และมองเห็นได้ใน Inspector เหมือนกัน โดยไม่ต้องมี attribute เลย ใช้ public เมื่อสคริปต์อื่นควรอ่าน/เขียนค่านั้นได้จริง ๆ ด้วย ส่วน [SerializeField] private ให้ใช้เมื่อต้องการให้แค่ Inspector มองเห็นเท่านั้น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 — จุดเล็ก ๆ แบบนี้ช่วยไม่ให้เพื่อนร่วมทีมงงว่าทำไมคำสั่งที่กดไปแล้วไม่เห็นเกิดอะไรขึ้นเลย
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() บนมันได้
กด Spawn Wave Now ตอนที่เกม ไม่ได้ เล่นอยู่ แล้ว goblin ห้าตัวจะโผล่ขึ้นมารอบ ๆ spawner ตรงนั้นเลยใน Scene view — ทันที โดยไม่ต้องกดปุ่ม Play เลย นี่คือพลังที่แท้จริงของ editor scripting: มันรันเมธอดของเกมเองได้ตอน author time ทำให้ designer ปรับตำแหน่งและตัวเลขได้โดยไม่ต้องเข้า Play mode เลยสักครั้ง
Undo.RecordObject(spawner, "Spawn Wave") ก่อนที่จะเปลี่ยนค่า และเรียก EditorUtility.SetDirty(spawner) หลังจากนั้น ตัวแรกทำให้การเปลี่ยนแปลง undo ได้ด้วย Ctrl+Z เหมือน action อื่น ๆ ของ Editor ส่วนตัวที่สองบอก Unity ว่า scene มีการเปลี่ยนแปลงที่ยังไม่ได้ save ทำให้ไม่มีอะไรหายไปเงียบ ๆ tool เล็ก ๆ ที่ข้ามขั้นตอนนี้ไปสามารถทำงานของเพื่อนร่วมทีมพังแบบเงียบ ๆ ได้target ด้วย (EnemySpawner) ตรง ๆ ใช้ได้กับปุ่มง่าย ๆ แบบนี้ แต่สำหรับ field ที่ต้องการ multi-object editing (เลือก spawner หลายตัวแล้วแก้พร้อมกัน) และรองรับ Undo อัตโนมัติด้วย tool จริง ๆ จะใช้ serializedObject กับ SerializedProperty แทนการแตะ field ของ target ตรง ๆ DrawDefaultInspector() จัดการเรื่องนี้ให้ถูกต้องอยู่แล้ว — มีแค่โค้ด GUI ที่คุณเขียนเองเท่านั้นที่ต้องระวังเป็นพิเศษ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 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 ตัวเดียว
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);
}
}
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 ธรรมดาได้
OnDrawGizmos ก็แสดงตลอด) มันไม่มีต้นทุนอะไรเลยใน build ที่ ship ออกไป ดังนั้นใส่ได้ตามสบายระหว่างสร้าง levelบางครั้ง 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 อื่น ๆ ทุกตัวในบทนี้
ผลตอบแทนคือคุณเขียน drawer นี้ ครั้งเดียว แล้วทุกสคริปต์ในโปรเจกต์ทั้งหมดที่มี field เป็น MinMaxFloat — ไม่ว่าจะตอนนี้หรือที่เพิ่มมาปีหน้า — จะได้ layout แบบกระชับนี้อัตโนมัติ โดยไม่ต้องทำงานเพิ่มต่อสคริปต์เลย
ลองมองภาพรวมทั้งหมดพร้อมกันจะช่วยได้: editor scripting อยู่ฝั่งหนึ่งของเส้นแบ่งที่ runtime code ไม่มีวันข้ามไปเลย
สังเกตว่าจุดแบ่งอยู่ตรงไหน: ทุกอย่างเหนือเส้น "saved into files" มีอยู่แค่เพื่อทำให้ การสร้างงาน เร็วขึ้นและปลอดภัยขึ้นเท่านั้น ทุกอย่างใต้เส้นนั้นคือตัวเกมจริง ๆ และมันไม่รู้ด้วยซ้ำว่า tool พวกนั้นเคยถูกใช้หรือเปล่า — มันแค่อ่านตัวเลขและ prefab ที่ถูก save ไว้ นี่คือเหตุผลที่กฎเรื่องโฟลเดอร์ Editor จากหัวข้อ 2 ถึงเข้มงวดขนาดนั้น: มันบังคับเส้นแบ่งนี้ในทางกายภาพ ทำให้ editor tool ไม่มีวันกลายเป็นสิ่งที่ผู้เล่นต้องพึ่งพาโดยไม่ตั้งใจ
สรุปสั้น ๆ ที่ใช้งานได้จริงก่อนไปทำแบบฝึกหัด:
UnityEditor ต้องอยู่ในโฟลเดอร์ชื่อ Editor ตรงไหนก็ได้ใต้ Assets ไม่งั้นโปรเจกต์จะ build ไม่ผ่านพร้อม error แบบ "type or namespace name could not be found" (หัวข้อ 2)OnDrawGizmos / OnDrawGizmosSelected เป็นข้อยกเว้นเดียว: มันอยู่ในสคริปต์ runtime ธรรมดา ไม่ใช่โฟลเดอร์ Editor และ Unity ตัดมันออกจาก build ให้อัตโนมัติ (หัวข้อ 8)Undo.RecordObject(...) และ mark ว่า dirty ด้วย EditorUtility.SetDirty(...) เพื่อให้ undo ได้และถูก save จริง ๆDrawDefaultInspector() กับ field แบบ public/[SerializeField] ปกติจัดการเรื่อง undo และ multi-object editing ให้ถูกต้องอยู่แล้วโดยตัวมันเอง มีแค่โค้ด GUI ที่คุณเขียนเอง (ปุ่ม, layout กำหนดเอง) เท่านั้นที่ต้องระวังเรื่องนี้มี 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
}
}
#if UNITY_EDITOR มาใช้ทุกที่แทนที่จะแยกไปโฟลเดอร์ Editor มันก็ใช้ได้อยู่ แต่ทำให้ไฟล์ runtime รกไปด้วยเรื่องของ editor และพลาดง่าย (ลืม #endif คู่กัน หรืออ้างอิง UnityEditor นอก block ที่ครอบไว้) ควรแยกเป็นไฟล์ต่างหากไปไว้ในโฟลเดอร์ Editor เมื่อส่วนที่เป็น editor-only มีมากกว่าหนึ่งหรือสองบรรทัด — ซึ่งเป็นแบบนั้นเกือบทั้งหมดในหัวข้อ 5 ถึง 9Editor, EditorWindow, EditorGUILayout, ...) ที่มีให้ใช้แค่ข้างใน Editor เท่านั้น ไม่มีใน build เลยEditor ตรงตัว อยู่ตรงไหนก็ได้ใต้ Assets; สคริปต์ข้างในถูก compile แค่สำหรับ Editor และถูกตัดออกจากทุก build[Range(1,10)]) ที่แปะไว้บน field หรือ class เพื่อเปลี่ยนวิธีที่ Unity จัดการกับมัน[MenuItem] ตัวที่สอง (path, true) ที่ return bool เพื่อเปิดใช้งานหรือทำให้ menu item จางลงEditor มาแทน Inspector แบบ default ของ component type นั้นEditor override เพื่อวาด Inspector UI ของตัวเองEditor ไปยัง object ที่กำลังถูก inspect อยู่ตอนนี้EditorWindow (หรือ custom editor) เรียกทุกครั้งที่วาดใหม่ เพื่อจัด layout UI ของมัน[CustomPropertyDrawer] ที่เปลี่ยนวิธีวาด type หนึ่งใน Inspector ทุกที่ที่มันปรากฏ[Header("Player Settings")], แต่ละ field มี [Tooltip] อธิบายว่ามันคืออะไรในหนึ่งประโยคสั้น ๆ และ maxHealth ใช้ [Range(50, 500)]
using UnityEngine;
public class PlayerStats : MonoBehaviour
{
public int maxHealth;
public float moveSpeed;
}
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
[MenuItem] ที่ path "Tools/Player/Log Selected Count" ซึ่ง log ว่ามี GameObject กี่ตัวที่ถูกเลือกอยู่ใน Hierarchy ตอนนี้ (คำใบ้: คลาส Selection ที่เป็น editor-only จาก UnityEditor มี static property ชื่อ gameObjects ที่ return array ของ object ที่ถูกเลือกอยู่ตอนนี้)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.
Assets/Scripts/EnemySpawnerEditor.cs — สังเกตนะ: Scripts ไม่ใช่ Scripts/Editor
using UnityEditor;
[CustomEditor(typeof(EnemySpawner))]
public class EnemySpawnerEditor : Editor
{
// ... OnInspectorGUI ...
}
มันทำงานปกติดีตอนที่เขาเทสต์เล่นใน Editor แล้วอะไรจะเกิดขึ้นครั้งแรกที่มีใครรัน File -> Build Settings -> Build และมีวิธีแก้กี่แบบ อะไรบ้าง?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 ออกไปโดยไม่ตั้งใจ