บทเรียนก่อนหน้านี้ในบทนี้สอนวิธีที่ตัวละครตัวเดียวเคลื่อนไหว ทั้ง skeletal animation และ skinning (9.1), blend tree กับ Animator state machine (9.2), และ IK กับ root motion (9.3) ทั้งหมดนั้นเกี่ยวกับตัวละครตัวเดียว ที่ถูกควบคุมโดยผู้เล่นหรือ AI ในแต่ละช่วงเวลา ส่วน cutscene (ฉากสั้น ๆ ที่เกมเป็นคนเล่าเรื่องเอง โดยผู้เล่นมักจะควบคุมอะไรได้น้อยมากหรือไม่ได้เลย) เป็นปัญหาคนละแบบ คือต้องมีหลายอย่างขยับพร้อมกัน ทั้งกล้อง ตัวละครหลายตัว เสียง เอฟเฟกต์ ทุกอย่างต้องเคลื่อนไปด้วยกันตามตารางเวลาที่กำหนดตายตัว เหมือนหนังสั้น ๆ เรื่องหนึ่ง บทเรียนนี้จะพูดถึงเครื่องมือที่ Unity ให้มาสำหรับงานนี้ นั่นคือ Timeline คุณจะได้เรียนว่า Timeline ถูกสร้างจาก track กับ clip ยังไง จะสั่งเล่นจาก C# ยังไง Cinemachine จัดการกล้องยังไง จะยิง gameplay event ให้ตรงจังหวะเป๊ะ ๆ ยังไง จะส่งต่อการควบคุมไปมาระหว่าง cutscene กับผู้เล่นยังไง และจะทำให้ cutscene กดข้ามได้และรองรับหลายภาษาได้โดยไม่ต้องเขียนโค้ดเฉพาะกิจกองพะเนินยังไง
Cutscene (หรือเรียกอีกอย่างว่า cinematic) คือฉากที่ถูกกำหนดไว้ล่วงหน้า โดยที่เกม — ไม่ใช่การกดของผู้เล่น — เป็นคนควบคุมว่าอะไรจะเกิดขึ้นบนหน้าจอในช่วงเวลานั้น มันอาจจะไม่ให้ผู้เล่นทำอะไรเลย (แค่ดูอย่างเดียว) หรือให้ผู้เล่นมีปฏิสัมพันธ์ได้บางส่วน (เช่นหมุนกล้องดูรอบ ๆ หรือกดข้ามได้ แต่สั่งให้ตัวละครเดินไปที่อื่นไม่ได้) เกมใช้ cutscene เพื่อเล่าจุดสำคัญของเรื่องที่เล่าผ่าน gameplay ปกติได้ยาก เพื่อแนะนำบอสก่อนเริ่มต่อสู้ เพื่อซ่อนหน้าจอโหลดเกมไว้หลังอะไรบางอย่างที่น่าดู หรือเพื่ออวดสถานที่ที่ผู้เล่นกำลังจะได้ไปสำรวจ
สิ่งที่ทำให้สร้าง cutscene ด้วยมือเปล่ายาก คือมันไม่เคยเป็นแค่ระบบเดียว cutscene ประตูเปิดยาวสองวินาทีต้องอาศัยหลายอย่างเกิดขึ้นพร้อมกันแบบซิงก์กันเป๊ะ ๆ:
ถ้าคุณเขียนสิ่งนี้เองด้วยการเรียก Invoke เป็นกองพะเนินและ coroutine ที่ใช้ WaitForSeconds คุณจะเสียเวลาส่วนใหญ่ไปกับการพิมพ์ตัวเลขเวลาใหม่ทุกครั้งที่ sound designer ขยับจังหวะไปครึ่งวินาที แถมยังไม่มีที่เดียวที่มองเห็นภาพรวมทั้งฉากได้ในทีเดียว สิ่งที่ cutscene ทุกอันต้องการจริง ๆ คือนาฬิกากลางอันเดียวที่ทุกระบบเหล่านี้อ่านค่าจาก และที่ที่เห็นทุกอย่างเรียงกันเทียบกับนาฬิกานั้นพร้อมกัน นาฬิกากลางนั้นก็คือ Timeline
Timeline คือ sequencer แนวนอน (เครื่องมือสำหรับเรียงลำดับสิ่งต่าง ๆ ตามแกนเวลา) เวลาจะไหลจากซ้ายไปขวา หน่วยเป็นวินาที ใต้แกนเวลานั้นจะมี track หลายแถวเรียงซ้อนกันในแนวตั้ง แถวละหนึ่งอย่างที่ต้องเปลี่ยนแปลงไปตามเวลา เช่น camera track, animation track, audio track, VFX track, event track แต่ละ track จะมี clip หนึ่งชิ้นหรือมากกว่านั้น ซึ่งเป็นก้อนคอนเทนต์ที่มีเวลาเริ่มต้นและความยาว วางอยู่บน track ตรงไหนก็ได้ มีตัวชี้ที่เคลื่อนที่อันเดียวเรียกว่า playhead ที่กวาดผ่านทุก track พร้อมกัน และไม่ว่า playhead จะแตะอะไรอยู่บนแต่ละ track ตอนนั้น นั่นคือสิ่งที่กำลัง active อยู่ ณ ขณะนั้น
สังเกตว่าสิ่งนี้ให้อะไรกับคุณ ที่ t = 2.5s (2.5 วินาทีเข้าไปใน Timeline) คุณเห็นได้ในแวบเดียวว่ากล้องกำลังอยู่กลางช็อต Close-Up ตัวละครกำลังสวิงกลางคัน เสียงพูดยังเล่นอยู่ และ marker ชื่อ DamageEvent เพิ่งทำงานไป ถ้าย้าย marker DamageEvent ให้ช้าลงครึ่งวินาที ทุกระบบที่เกี่ยวข้อง ไม่ว่าจะ animation, การตัดกล้อง, เสียง ก็ยังคงอยู่ตำแหน่งเดิมของมันเป๊ะ เพราะมันไม่ได้ผูกติดกับกันและกัน ผูกติดอยู่กับ timeline เดียวกันเท่านั้น การแยกแบบนี้คือประเด็นทั้งหมดเลย แทนที่จะให้สคริปต์เดียว hard-code ว่า "รอ 2.3 วินาที แล้วเล่นเสียงโดนตี" แต่ละ track เป็นเจ้าของคอนเทนต์ของตัวเอง และ Timeline เป็นเจ้าของตารางเวลา
ใน Unity, Timeline มีอยู่สองรูปแบบ Timeline window คือเครื่องมือใน editor ที่คุณลาก track กับ clip เข้าไปแล้วจัดเรียงด้วยตา สิ่งที่คุณสร้างขึ้นตรงนั้นจะถูกบันทึกเป็น TimelineAsset (ไฟล์ asset แบบเดียวกับ prefab หรือ material ที่เก็บ track, clip และจังหวะเวลาของมันไว้) เพื่อจะเล่น TimelineAsset จริง ๆ ในเกม คุณต้องแปะ component PlayableDirector ไว้บน GameObject แล้วกำหนด TimelineAsset ให้มัน PlayableDirector คือ "ตัวเล่น" ของ Timeline เหมือนกับที่ AudioSource เป็นตัวเล่นของ AudioClip — asset เป็นแค่ข้อมูล ส่วน component คือตัวที่ขับเคลื่อนมันไปข้างหน้าทีละเฟรมจริง ๆ
using UnityEngine;
using UnityEngine.Playables;
public class IntroCutscene : MonoBehaviour
{
public PlayableDirector director; // drag the GameObject holding the
// Timeline asset here in the Inspector
void Start()
{
director.Play(); // starts playing the Timeline from time 0
}
}
สิ่งที่เกิดขึ้น: ตรงนี้ไม่มี console output — ผลลัพธ์จะไปโผล่ใน Game view ไม่ใช่ใน print statement ที่เวลา 0 director จะเริ่ม evaluate ทุก track พร้อมกัน: Camera track จะกระโดด Game view ไปที่ช็อตแรก Animation track จะจัดท่าตัวละครที่เฟรมแรก และ Audio track จะตั้งคิวให้ dialogue clip เริ่มเล่น จากนั้นเป็นต้นไป director.time (เป็น double หน่วยวินาที) จะไหลไปเองประมาณเฟรมละครั้ง และทุก track จะคอย evaluate ตัวเองใหม่เทียบกับตัวเลขนั้นตลอด
member ของ PlayableDirector ที่คุณจะใช้บ่อย ๆ มีอยู่ไม่กี่ตัว: director.time (ตำแหน่งเล่นปัจจุบัน ซึ่งคุณตั้งค่าตรง ๆ ก็ได้), director.duration (ความยาวรวมของ Timeline หน่วยวินาที), director.state (enum บอกว่ากำลัง Playing หรือ Paused อยู่) และ director.Stop()/director.Pause() คุณจะได้ใช้ตัวส่วนใหญ่พวกนี้ต่อในบทเรียนนี้
การเขียนโค้ดเคลื่อนกล้องด้วยมือสำหรับทุก cutscene — lerp ตำแหน่ง, slerp การหมุน, จับจังหวะการตัดกล้อง — ทำแบบนี้ไปได้ไม่เกินฉากที่สองหรือสาม แล้วก็ไปต่อไม่ไหว คำตอบของ Unity คือ Cinemachine แพ็กเกจกล้องที่สร้างขึ้นรอบ ๆ virtual camera (มักเรียกสั้น ๆ ว่า vcam) ซึ่งเป็นอ็อบเจ็กต์ที่มองไม่เห็น แต่ละตัวอธิบายวิธีจัดเฟรมช็อตหนึ่งแบบ (ตำแหน่ง, เป้าที่จะมอง, lens field of view, กฎการ follow/look-at) ตัวมันเองไม่ได้เรนเดอร์อะไรเลยสักตัว มี component ตัวเดียวชื่อ CinemachineBrain ที่อยู่บน Camera จริงตัวเดียวของคุณ คอยจับตาดู vcam ทุกตัวใน scene แล้วคัดลอกค่าของตัวที่ "ชนะ" อยู่ ณ ตอนนั้นไปใส่กล้องจริงทุกเฟรม
vcam จะชนะได้ด้วยการมี Priority (แค่เลขจำนวนเต็มธรรมดา) สูงที่สุด เมื่อ vcam ที่ชนะอยู่เปลี่ยนไป CinemachineBrain จะไม่สลับแบบทันทีทันใด แต่จะ blend อย่างนุ่มนวลจากเฟรมของ vcam ตัวเก่าไปยังตัวใหม่ ตามระยะเวลา blend ที่ตั้งค่าได้ (blend time เท่ากับ 0 วินาทีก็คือการตัดกล้องแบบทันที)
using UnityEngine;
using Cinemachine;
public class SwitchToCloseUp : MonoBehaviour
{
public CinemachineVirtualCamera wideShot; // Priority starts at 10
public CinemachineVirtualCamera closeUp; // Priority starts at 0
public void CutToCloseUp()
{
// CinemachineBrain always follows whichever virtual camera
// currently has the highest Priority. Raising closeUp's
// priority above wideShot's makes the Brain blend to it.
closeUp.Priority = 20;
wideShot.Priority = 10;
}
}
สิ่งที่เกิดขึ้น: ก่อนที่ CutToCloseUp จะรัน wideShot (priority 10) ชนะ closeUp (priority 0) กล้องจริงเลยแสดง wide shot อยู่ พอ CutToCloseUp รันปุ๊บ priority ของ closeUp (20) กลายเป็นค่าสูงสุด แล้วในเฟรมถัดไปทันที CinemachineBrain ก็เริ่ม blend กล้องจริงไปหาเฟรมของ closeUp — โดยที่คุณไม่ต้องเขียนโค้ด lerp เองสักบรรทัด ในเทิร์มของ Timeline การสลับ priority แบบนี้ปกติแล้วจะมี Cinemachine track คอยทำให้คุณโดยเฉพาะ ซึ่งแต่ละ clip บน track นั้นจะอ้างอิงไปยัง vcam คนละตัวกัน แล้ว Timeline จะเปลี่ยน priority ให้เองตอนที่ playhead ข้ามผ่านขอบของ clip
CinemachineBrain ให้กับ Main Camera ถ้าไม่มีมัน virtual camera ก็ยังอยู่ใน scene และเปลี่ยน priority ของตัวเองถูกต้องตามปกติ แต่จะไม่มีอะไรคัดลอกเฟรมของมันไปใส่กล้องจริงเลย จอเลยดูเหมือนไม่มีอะไรเกิดขึ้นเลยสักนิด(หมายเหตุเรื่องเวอร์ชัน: แพ็กเกจ Cinemachine รุ่นใหม่ ตั้งแต่ Cinemachine 3.0 เป็นต้นไป เปลี่ยนชื่อคลาสเป็น CinemachineCamera และย้าย Priority ไปอยู่หลัง settings struct เล็ก ๆ ตัวหนึ่ง แต่แนวคิด — priority สูงสุดชนะ, Brain blend ให้อัตโนมัติ — ยังเหมือนเดิม)
ไม่ใช่ทุกช่วงเวลาของกล้องจะเป็นการตัดแบบ hard cut ระหว่างสองช็อตที่นิ่งอยู่กับที่ — บ่อยครั้งช็อตเดียวเองก็ต้องขยับ เช่นการค่อย ๆ push-in เข้าไปหาหน้าตัวละครแบบช้า ๆ วิธีทำแบบนี้คือใช้ keyframe: ค่าที่ถูกปักไว้ ณ เวลาหนึ่ง ๆ บนเส้นโค้งหนึ่งเส้น เพื่อให้ engine คำนวณค่าทุกจุดระหว่างกลางให้เราเอง ใน Timeline window เรื่องนี้มักจะแสดงออกมาเป็น Animation track ที่ผูกกับ Transform หรือค่า lens ของ virtual camera โดยมีสัญลักษณ์รูปเพชรเล็ก ๆ (keyframe) วางอยู่ตรงเวลาที่คุณปักค่าตำแหน่ง, การหมุน, หรือค่า field-of-view เอาไว้
ลึกลงไปข้างใน ทั้ง animation track ของ Timeline และ Animator ปกติของ Unity ใช้ building block ตัวเดียวกันสำหรับเรื่องนี้ นั่นคือ AnimationCurve ซึ่งเป็นลิสต์ของคู่ keyframe เวลา/ค่า บวกกับกฎว่าจะ interpolate ระหว่างกันยังไง คุณดูได้เลยว่าการเคลื่อนกล้องแบบ keyframe ทำอะไรอยู่จริง ๆ ด้วยตัวอย่างเล็ก ๆ แบบสแตนด์อโลนนี้:
using UnityEngine;
public class ZoomCurveDemo : MonoBehaviour
{
void Start()
{
// Two keyframes with straight-line (linear) interpolation:
// at 0s the Field of View is 40, at 2s it is 65.
AnimationCurve zoom = AnimationCurve.Linear(0f, 40f, 2f, 65f);
float fovAtHalfSecond = zoom.Evaluate(0.5f);
Debug.Log(fovAtHalfSecond);
}
}
ผลลัพธ์ที่คาดไว้: 46.25
ไล่ตามทีละขั้น: 0.5 วินาที คือหนึ่งในสี่ของระยะทางจาก keyframe แรก (0s) ไปยัง keyframe ที่สอง (2s) เพราะ 0.5 / 2.0 = 0.25 การ interpolate แบบ linear จะเดินไปตามสัดส่วนเดียวกันนี้จากค่าแรกไปค่าที่สอง: 40 + (65 - 40) * 0.25 = 40 + 6.25 = 46.25 การเคลื่อนกล้องแบบ keyframe จริง ๆ ใน Timeline window ก็ทำงานแบบเดียวกันนี้เป๊ะ เพียงแต่ใช้ curve editor แทนโค้ด และมักจะมี eased tangent (การเร่ง/ผ่อนความเร็วแบบโค้ง ๆ ตอนเข้าและออก) แทนที่จะเป็นเส้นตรง เพื่อไม่ให้การเคลื่อนไหวเริ่มและหยุดแบบกระชากทันที
การตัดกล้อง (cut) เป็นคนละเรื่องกันไปเลย — มันไม่ใช่เส้นโค้งอะไรทั้งนั้น cut คือขอบเขตระหว่าง clip สองอันที่แยกกัน (สองช็อตที่แยกกัน อาจจะเป็น vcam คนละตัวด้วยซ้ำ) ที่มี blend time เท่ากับศูนย์ การเคลื่อนกล้อง (move) จะอยู่ในช็อตเดียวและเปลี่ยนแปลงอย่างนุ่มนวล ส่วนการตัด (cut) จะจบช็อตหนึ่งแล้วเริ่มช็อตใหม่ทันที cutscene ที่ดีใช้ทั้งสองแบบอย่างตั้งใจ ใช้การเคลื่อนกล้องเพื่อสร้างความตึงเครียดแบบค่อยเป็นค่อยไป และใช้การตัดกล้องเพื่อเปลี่ยนพลังงานแบบฉับพลัน
cutscene ไม่ได้มีไว้แค่โชว์อย่างเดียว — บ่อยครั้งมันต้องส่งผลกับตัวเกมจริง ๆ ด้วย เช่นปลดล็อกประตู มอบไอเทม เริ่มการต่อสู้ เซฟเกม เครื่องมือในตัวของ Timeline สำหรับ "สั่งให้ทำอะไรสักอย่าง ณ ช่วงเวลานี้เป๊ะ" คือ marker (ไอคอนเล็ก ๆ ที่ปักอยู่ตรงเวลาหนึ่งบน track แนวคิดคล้าย ๆ กับ marker ชื่อ DamageEvent ในไดอะแกรมของหัวข้อ 2) marker ในตัวที่ง่ายที่สุดคือ Signal: คุณวาง marker SignalEmitter บน track ตรงเวลาที่ต้องการ ให้ชี้ไปที่ SignalAsset (asset ที่เป็นแค่ตัวตน — เป็น "ช่องสัญญาณ" ที่มีชื่อ ไม่มีข้อมูลอะไรของตัวเองเลย) ที่ไหนสักแห่งใน scene component SignalReceiver จะแม็ป SignalAsset แต่ละตัวที่มันสนใจไปสู่การตอบสนอง โดยใช้ UnityEvent ที่ตั้งค่าผ่าน Inspector — ไม่ต้องเขียนโค้ดเลยสักบรรทัด คล้าย ๆ กับการต่อสาย OnClick ของ Button
Signal เหมาะกับตัวกระตุ้นแบบง่าย ๆ ที่เกิดครั้งเดียว — เช่นสั่นจอ, ป็อปอัพ UI, ปลดล็อก achievement — ที่ designer ต่อสายเองได้ทั้งหมดผ่าน Inspector ข้อจำกัดของมันคือ SignalAsset ไม่มีข้อมูลกำหนดเองได้ (custom data) มันบอกได้แค่ว่า "สิ่งที่ชื่อนี้เกิดขึ้นแล้ว" ไม่สามารถบอกว่า "สิ่งนี้เกิดขึ้น แล้วนี่คือ string/number ที่แนบมาด้วย" ถ้าต้องการแบบนั้นคุณต้องเขียน marker type ของตัวเอง ซึ่งเป็นหัวข้อของหัวข้อถัดไป
เมื่อ marker ต้องพกข้อมูลของตัวเองไปด้วย — เช่นจะ spawn VFX ตัวไหน หรือจะเซฟ checkpoint ID ไหน — คุณจะเขียนคลาสเล็ก ๆ ของตัวเองแทนที่จะใช้ Signal ธรรมดา ลึกลงไปข้างใน ทั้ง Signal และ custom marker ทำงานผ่านกลไกสองส่วนเดียวกัน คือคลาสที่ implement INotification (สิ่งที่เกิดขึ้น) และคลาสที่ implement INotificationReceiver (สิ่งที่ตอบสนองต่อมัน) marker ที่ implement INotification ด้วย จะถูกลากไปวางบน track ได้เหมือน Signal ในตัวเป๊ะ:
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
// A custom marker that carries its own data -- which VFX to spawn.
// Because it derives from Marker and implements INotification, it
// can be dropped onto any Timeline track, just like a built-in Signal.
[System.Serializable]
public class SpawnVfxMarker : Marker, INotification
{
public string vfxId = "Spark";
// INotification only requires an id. Unity uses it so a
// receiver can tell which notification just fired.
public PropertyName id => new PropertyName(vfxId);
}
using UnityEngine;
using UnityEngine.Playables;
public class VfxNotificationReceiver : MonoBehaviour, INotificationReceiver
{
public ParticleSystem sparkVfx;
// The PlayableDirector calls this automatically every time
// playback crosses a marker on a track bound to this receiver,
// both when playing forward and when the playhead is dragged.
public void OnNotify(Playable origin, INotification notification, object context)
{
if (notification is SpawnVfxMarker marker && marker.vfxId == "Spark")
{
sparkVfx.Play();
}
}
}
สิ่งที่เกิดขึ้น: ใน Timeline window คุณลาก SpawnVfxMarker ไปวางบน track ตรงจังหวะที่ดาบควรมีประกายไฟ แล้วตั้งค่า vfxId ใน Inspector จากนั้นลาก GameObject ที่มี VfxNotificationReceiver ติดอยู่ไปวางในช่อง binding ของ track นั้นใน inspector ของ PlayableDirector เหมือนกับที่คุณ bind animation track เข้ากับตัวละครที่มันเล่น animation ให้ จากนั้นเป็นต้นไป ทุกครั้งที่ playhead ข้ามผ่าน marker นั้น Unity จะเรียก OnNotify บน INotificationReceiver ทุกตัวที่ bind กับ track นั้น แล้วโค้ดของคุณจะตัดสินใจว่าจะทำอะไรกับมัน นี่คือแพทเทิร์นเดียวกันเป๊ะที่คุณจะเอากลับมาใช้ซ้ำในหัวข้อ 11 สำหรับ subtitle และอีกครั้งใน Exercise 2 สำหรับ marker เซฟ checkpoint — กลไกเล็ก ๆ อันเดียว เอาไปใช้ซ้ำกับทุกอย่างที่ต้อง "เกิดขึ้น ณ ช่วงเวลานี้เป๊ะ"
ระหว่างที่ cutscene กำลังเล่นอยู่ input ปกติของผู้เล่นมักจะต้องหยุดมีผล — คุณไม่อยากให้ผู้เล่นเดินหนีไปในระหว่างที่กล้องกำลังเคลื่อนตามสคริปต์อยู่ และที่สำคัญไม่แพ้กัน การควบคุมต้องกลับคืนมาแบบอัตโนมัติและเกิดขึ้นแค่ครั้งเดียวเป๊ะ ไม่ว่า cutscene จะจบเองหรือถูกกดข้าม วิธีที่ผิดคือใช้ coroutine ที่เดาความยาวของ cutscene ด้วย WaitForSeconds เพราะถ้าความยาวของ Timeline เปลี่ยนไปเมื่อไหร่ หรือ cutscene ถูกข้าม การเดานั้นก็ผิดทันที วิธีที่ถูกต้องคือให้ PlayableDirector บอกคุณเองว่ามันหยุดจริง ๆ เมื่อไหร่ ผ่าน event stopped ของมัน (เป็น C# event ธรรมดา คุณ subscribe ด้วย += และ unsubscribe ด้วย -= เหมือน C# event ทั่วไป)
using UnityEngine;
using UnityEngine.Playables;
public class CutsceneController : MonoBehaviour
{
public PlayableDirector director;
public PlayerController player; // your own movement/input script
void OnEnable() { director.stopped += OnCutsceneStopped; }
void OnDisable() { director.stopped -= OnCutsceneStopped; }
public void PlayCutscene()
{
player.SetInputEnabled(false); // take control away from the player
director.Play();
}
void OnCutsceneStopped(PlayableDirector stoppedDirector)
{
if (stoppedDirector != director) return;
player.SetInputEnabled(true); // hand control back
}
void Update()
{
bool isPlaying = director.state == PlayState.Playing;
if (isPlaying && Input.GetButtonDown("Cancel"))
{
director.time = director.duration; // jump to the last frame
director.Evaluate(); // apply that frame immediately
director.Stop(); // this fires "stopped"
}
}
}
สิ่งที่เกิดขึ้น: PlayCutscene จะปิด input ของผู้เล่นแล้วเริ่มเล่น Timeline ทุกเฟรม Update จะเช็คว่ามีการกดปุ่มข้ามหรือไม่ ถ้าผู้เล่นกดข้าม จะมีสามอย่างเกิดขึ้นตามลำดับ: director.time กระโดดไปที่จุดจบทันที director.Evaluate() บังคับให้ทุก track apply สถานะที่ควรจะเป็น ณ ช่วงสุดท้ายนั้นทันที (จอจะได้ไม่กะพริบผ่านท่าที่ apply ไม่เต็มที่) และ director.Stop() จะหยุดการเล่น — ซึ่งจะยิง event stopped ตัวเดียวกันเป๊ะกับตอนที่ cutscene จบไปเองตามธรรมชาติ เพราะทั้งสองเส้นทาง (จบเองปกติ หรือถูกข้าม) จบลงที่การเรียก OnCutsceneStopped เหมือนกัน การควบคุมจึงกลับมาผ่านจุดเดียวเสมอ และคุณจะลืมเปิด input กลับคืนในเส้นทางใดเส้นทางหนึ่งโดยไม่ตั้งใจไม่ได้เลย
SetInputEnabled เป็นเมธอดที่คุณจะเพิ่มเข้าไปใน PlayerController ของคุณเอง ปกติแล้วมันแค่ set ค่า bool ตัวหนึ่งที่ Update เดิมของคุณเช็คไว้บรรทัดแรก ๆ (if (!inputEnabled) return;) ก่อนจะอ่าน input ใด ๆ
แพทเทิร์นเดียวกันนี้ต่อยอดได้เป็นธรรมชาติ: ซ่อน gameplay HUD ตอน PlayCutscene ทำงาน แล้วเปิดกลับใน OnCutsceneStopped, หยุด AI ที่อยู่ใกล้ ๆ ชั่วคราว หรือ snap ผู้เล่นไปยังจุดที่กำหนดไว้ เพื่อไม่ให้กล้อง gameplay ถัดไปเริ่มจากตำแหน่งที่ดูแปลก ๆ ทั้งหมดนี้แขวนอยู่กับฟังก์ชันสองตัวเดียวกันนี่แหละ
การให้ผู้เล่นกดข้าม cutscene ได้สำคัญกว่าแค่เรื่องความสะดวก ในการเล่นรอบที่สอง cutscene ยาวห้านาทีที่ข้ามไม่ได้จะทำให้ผู้เล่นที่กลับมาเล่นซ้ำหงุดหงิดทันที และบนหลาย ๆ console platform ตัวเลือกให้กดข้าม (หรืออย่างน้อยก็ fast-forward) เป็นข้อบังคับเพื่อให้ผ่าน first-party certification ได้เลยด้วยซ้ำ ตัวลอจิกการข้ามเองไม่ใช่ส่วนที่ยาก — คุณเห็นไปแล้วในหัวข้อ 8 ส่วนที่ยากคือทำยังไงให้การกดข้ามไม่แอบทำให้สถานะของเกมพังโดยไม่รู้ตัว
นี่ชี้ไปที่แนวคิดที่ใหญ่กว่านั้น: สร้างระบบเล่น cutscene แบบ data-driven (พฤติกรรมถูกควบคุมด้วยข้อมูล — ไฟล์ asset, ฟิลด์ใน Inspector, ScriptableObject — แทนที่จะ hardcode แยกทีละฉากใน C#) บทที่ 6 ใช้แนวคิด data-oriented เพื่อเรื่อง performance แต่ที่นี่แรงจูงใจต่างออกไป การตั้งค่า cutscene แบบ data-driven หมายความว่า:
TimelineAsset บวกกับข้อมูลบางอย่าง (บรรทัด subtitle, checkpoint id) — ไม่ใช่คลาส C# เฉพาะกิจของตัวเองเลยCutsceneController ตัวเดียว ปุ่มข้ามปุ่มเดียว ระบบ subtitle ระบบเดียว และระบบล็อก input ระบบเดียว ใช้บริการ cutscene ทุกตัวในเกม แทนที่แต่ละ cutscene จะมีสคริปต์ copy-paste ของตัวเองนี่ก็เป็นเหตุผลที่ CutsceneController จากหัวข้อ 8 ไม่เคยพูดถึงว่า cutscene แต่ละตัวเกี่ยวกับอะไรเลย มันรู้จักแค่ PlayableDirector แบบทั่ว ๆ ไปเท่านั้น ความเป็นสิ่งทั่วไปนี่แหละคือประโยชน์ทั้งหมด มันใช้ได้กับ cutscene แนะนำเกม, ฉากเปิดตัวบอส, และฉากจบ โดยไม่ต้องแก้อะไรเลย
การส่งมอบ cutscene มีอยู่สองแบบที่ต่างกันโดยพื้นฐาน และเกมใหญ่ ๆ มักใช้ทั้งสองแบบ Pre-rendered cutscene คือไฟล์วิดีโอ (ส่วนใหญ่เป็น .mp4) ที่ทำเสร็จครั้งเดียวนอกเกมที่กำลังรันอยู่ — บางทีก็ใช้ offline renderer ที่ต่างออกไปโดยสิ้นเชิงและแพงกว่ามาก — แล้วค่อยเล่นในเกมด้วย component VideoPlayer ของ Unity ส่วน in-engine cinematic (เรียกอีกอย่างว่า interactive หรือ real-time) คือสิ่งที่บทเรียนนี้กำลังสร้างขึ้นมาตลอด: Timeline ที่ขับเคลื่อนกล้อง ตัวละคร material และแสงตัวเดียวกับที่ผู้เล่นเห็นระหว่าง gameplay ปกติ เรนเดอร์แบบสด ๆ ทีละเฟรม โดย Unity เอง
แต่ละแนวทางต้องแลกอะไรบางอย่างที่อีกฝั่งมีไป:
ทางประนีประนอมที่ใช้กันจริง ๆ บ่อย ๆ คือ ใช้หนัง pre-rendered สำหรับโลโก้ผู้จัดจำหน่ายหรือฉากเปิดที่กดข้ามไม่ได้และไม่มีใครมีปฏิสัมพันธ์ด้วยอยู่แล้ว แล้วใช้ in-engine Timeline cinematic สำหรับทุกอย่างที่ต้องสะท้อนสถานะเกมจริงของผู้เล่น ซึ่งสำหรับจุดสำคัญของเรื่องส่วนใหญ่แล้ว ก็คือเกมส่วนใหญ่นั่นเอง
Subtitle ต้องมีจังหวะเวลาของตัวเอง ที่ซิงก์กับนาฬิกาเดียวกับทุกอย่างอื่น — แต่จังหวะเวลา (ตัวเลข: บรรทัดนี้เริ่มเมื่อไหร่ จบเมื่อไหร่) กับเนื้อหา (ประโยคจริง ๆ ในภาษาที่ผู้เล่นเลือก) ไม่ควรอยู่ที่เดียวกันเด็ดขาด ถ้าประโยคภาษาอังกฤษถูกฝังตรงเข้าไปในข้อมูลจังหวะเวลาของ subtitle เลย นักแปลจะแก้ข้อความภาษาไทยหรือภาษาญี่ปุ่นไม่ได้เลยถ้าไม่ให้โปรแกรมเมอร์ export ไฟล์ทั้งไฟล์ใหม่ แทนที่จะทำแบบนั้น ข้อมูลจังหวะเวลาควรเก็บ localization key (ตัวระบุสั้น ๆ ที่ไม่ผูกกับภาษาไหนภาษาหนึ่ง เช่น cutscene_intro_03) แล้วมีตารางแยกต่างหากคอยแม็ป key นั้นไปสู่ข้อความจริงในภาษาที่กำลังใช้งานอยู่
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.UI;
[System.Serializable]
public struct SubtitleLine
{
public double startTime;
public double endTime;
public string localizationKey; // e.g. "cutscene_intro_03"
}
public class SubtitleDriver : MonoBehaviour
{
public PlayableDirector director;
public SubtitleLine[] lines;
public Text subtitleLabel; // a UI Text or TMP_Text component
void Update()
{
double t = director.time;
string textToShow = "";
foreach (SubtitleLine line in lines)
{
if (t >= line.startTime && t <= line.endTime)
{
textToShow = Localization.Get(line.localizationKey);
break;
}
}
subtitleLabel.text = textToShow;
}
}
Localization.Get(key) เป็นตัวแทนของระบบ localization อะไรก็ตามที่โปรเจกต์นั้นใช้จริง ๆ — จะเป็น Localization package ของ Unity หรือตารางค้นหาที่เขียนขึ้นเองก็ได้ — ส่วนที่สำคัญมีแค่ว่า SubtitleLine ต้องไม่เก็บประโยคจริง ๆ เลย เก็บแค่ key กับตัวเลขสองตัวเท่านั้น สิ่งที่เกิดขึ้น: เมื่อ director.time เดินไปเรื่อย ๆ แต่ละเฟรมจะเช็คว่าช่วง start/end ของบรรทัดไหนครอบคลุมเวลาปัจจุบันอยู่ แล้วแสดงข้อความที่แปลแล้วของบรรทัดนั้น และจะกลับไปเป็น string ว่างเปล่าในช่วงที่ไม่มีบรรทัดไหนครอบคลุม
endTime ของ subtitle โดยก็อปความยาวของเสียงพากย์ภาษาอังกฤษมาตรง ๆ ข้อความที่แปลแล้วมักยาวกว่าต้นฉบับภาษาอังกฤษ — โดยเฉพาะข้อความภาษาไทยกับภาษาเยอรมันที่มักต้องใช้พื้นที่แนวนอนมากกว่าภาษาอังกฤษอย่างเห็นได้ชัดสำหรับประโยคเดียวกัน — ดังนั้นระยะเวลาที่ปรับมาให้พอดีกับเสียงภาษาอังกฤษอาจตัดคำแปลที่ยาวกว่าให้หายไปก่อนที่ผู้เล่นจะอ่านจบ วิธีที่ปลอดภัยกว่าคือคำนวณระยะเวลาแสดงผลขั้นต่ำจากจำนวนตัวอักษรหรือจำนวนคำของข้อความที่แปลแล้ว (ประมาณการความเร็วในการอ่านง่าย ๆ เช่นจำนวนตัวอักษรต่อวินาทีคงที่ค่าหนึ่ง) แล้วใช้ค่าที่มากกว่าระหว่างระยะเวลาขั้นต่ำที่คำนวณได้กับระยะเวลาที่ match กับเสียงต้นฉบับกลไก custom marker แบบเดียวกับหัวข้อ 7 ก็ใช้กับ subtitle ได้เหมือนกัน — ShowSubtitleMarker ที่พก localizationKey ไปด้วย แล้วยิงผ่าน INotificationReceiver จะแม่นยำกว่าการ poll array ทุกเฟรม และไม่พลาดบรรทัดไหนไปแม้เกมจะเฟรมดรอปแวบหนึ่ง เวอร์ชัน polling ในหัวข้อ 11 นี้อ่านง่ายกว่าสำหรับการทำความเข้าใจรอบแรก และ Exercise 3 จะให้คุณทำให้มันมีประสิทธิภาพมากขึ้นโดยไม่ต้องเปลี่ยนไปใช้ marker
ตัวกระตุ้น cutscene ที่สมบูรณ์แบบใช้เกือบทุกอย่างจากบทเรียนนี้พร้อมกัน แต่ละชิ้นทำหน้าที่ของตัวเองอย่างเดียว:
สังเกตว่าไม่มีสคริปต์ตัวไหนที่รู้จักทั้งฉากเลย CutsceneController รู้แค่เรื่องเริ่ม หยุด และ input VfxNotificationReceiver รู้แค่วิธีตอบสนองต่อ marker ประเภทเดียว SubtitleDriver รู้แค่วิธีแสดงข้อความในช่วงเวลาหนึ่ง มีแค่ TimelineAsset เองเท่านั้นที่รู้ shot list ทั้งหมดจริง ๆ และ designer แก้ asset นั้นได้โดยที่สคริปต์เหล่านี้ไม่ต้องเปลี่ยนแม้แต่ตัวเดียว นั่นคือเป้าหมายเดียวกับที่บทเรียนทั้งบทนี้กำลังมุ่งไปหา: ระบบเล็ก ๆ ที่โฟกัสแคบ ๆ หลายระบบ ทุกระบบอ่านค่าจากนาฬิกากลางแบบ data-driven ตัวเดียวกัน
CutsceneController จากหัวข้อ 8 ให้ผู้เล่นกด "Cancel" ได้ทุกเมื่อ แม้แต่ในเฟรมแรกสุด — ซึ่งอาจทำให้ผู้เล่นกดข้ามผ่าน establishing shot ไปได้เลยก่อนที่จะรู้ตัวด้วยซ้ำว่า cutscene เริ่มแล้ว จงแก้ไขเมธอด Update() ของมัน ให้การกดข้ามถูกละเว้นจนกว่า cutscene จะเล่นไปแล้วอย่างน้อย 1 วินาทีจริงของ director.time
void Update()
{
bool isPlaying = director.state == PlayState.Playing;
bool longEnough = director.time >= 1.0;
if (isPlaying && longEnough && Input.GetButtonDown("Cancel"))
{
director.time = director.duration;
director.Evaluate();
director.Stop();
}
}
มีการเพิ่มเงื่อนไขแค่ตัวเดียว: longEnough เทียบ director.time (เป็น double หน่วยวินาที) กับ 1.0 ส่วนที่เหลือทั้งหมด — การกระโดดไป duration, การบังคับเรียก Evaluate(), และการเรียก Stop() ที่ยิง stopped — ไม่เปลี่ยนแปลงเลย เพราะการแก้ไขนี้ต้องเปลี่ยนแค่ เมื่อไหร่ ที่อนุญาตให้กดข้ามได้ ไม่ใช่เปลี่ยนว่าการกดข้ามทำอะไรบ้าง
INotificationReceiver แบบเดียวกับ SpawnVfxMarker ในหัวข้อ 7 จงเขียน SaveCheckpointMarker ที่เก็บ string checkpointId ไว้ และ MonoBehaviour ชื่อ SaveCheckpointReceiver ที่เรียก (ของสมมติ) SaveSystem.SaveCheckpoint(string id) เมื่อ marker ทำงาน
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
[System.Serializable]
public class SaveCheckpointMarker : Marker, INotification
{
public string checkpointId = "AfterIntroCutscene";
public PropertyName id => new PropertyName(checkpointId);
}
public class SaveCheckpointReceiver : MonoBehaviour, INotificationReceiver
{
public void OnNotify(Playable origin, INotification notification, object context)
{
if (notification is SaveCheckpointMarker marker)
{
SaveSystem.SaveCheckpoint(marker.checkpointId);
}
}
}
นี่คือรูปแบบเดียวกันเป๊ะกับ SpawnVfxMarker / VfxNotificationReceiver — สิ่งที่เปลี่ยนมีแค่ข้อมูลที่ marker พกไปด้วย (checkpointId แทนที่จะเป็น vfxId) และสิ่งที่ receiver ทำกับมัน รูปแบบที่เอาไปใช้ซ้ำได้แบบนี้แหละคือประเด็นจริง ๆ ของแพทเทิร์นนี้ พอคุณรู้จักมันแล้ว การเพิ่ม gameplay event ที่ต้องจับจังหวะเวลาแบบใหม่ ๆ จะไม่ต้องคิดกลไกใหม่ขึ้นมาเลย ต้องการแค่ marker class ใหม่กับ OnNotify เล็ก ๆ อันหนึ่งเท่านั้น
SubtitleDriver ในหัวข้อ 11 สแกน array lines ทั้งหมดด้วย linear search ทุกเฟรมเลย — สำหรับ cutscene ที่มี subtitle 200 บรรทัด นั่นคือ 200 การเปรียบเทียบ ประมาณ 60 ครั้งต่อวินาที ซึ่งเกือบทั้งหมดเสียเปล่าไปกับบรรทัดที่ไม่ได้อยู่ใกล้เวลาปัจจุบันเลย จงเขียน Update() ใหม่ให้ใช้ cursor index ที่เดินหน้าอย่างเดียว แทนที่จะสแกนใหม่จากจุดเริ่มต้นทุกเฟรม ในคำอธิบายของคุณ ให้บอกด้วยว่าทำไม cursor ถึงต้องรีเซ็ตกลับไปที่ 0 ได้ในบางสถานการณ์ และสถานการณ์นั้นเกิดขึ้นเมื่อไหร่
public class SubtitleDriver : MonoBehaviour
{
public PlayableDirector director;
public SubtitleLine[] lines; // must be sorted by startTime
public Text subtitleLabel;
int cursor = 0;
void Update()
{
double t = director.time;
// If time jumped backward (scrubbing in the Editor, replaying
// the cutscene, or the player rewinding), the cursor can no
// longer assume it is already ahead of the playhead -- reset it.
if (cursor > 0 && t < lines[cursor - 1].startTime)
{
cursor = 0;
}
// Advance forward only -- never re-scan lines already passed.
while (cursor < lines.Length && t > lines[cursor].endTime)
{
cursor++;
}
if (cursor < lines.Length &&
t >= lines[cursor].startTime && t <= lines[cursor].endTime)
{
subtitleLabel.text = Localization.Get(lines[cursor].localizationKey);
}
else
{
subtitleLabel.text = "";
}
}
}
ระหว่างเล่นแบบเดินหน้าปกติ director.time จะเพิ่มขึ้นอย่างเดียว ดังนั้น cursor ก็จะขยับไปข้างหน้าอย่างเดียวเหมือนกัน — ตลอดทั้ง cutscene มันจะเดินผ่านครบทั้ง 200 บรรทัดรวมแล้วแค่ 200 ครั้ง ไม่ใช่ 200 ครั้งต่อเฟรม ซึ่งเป็นการประหยัดแบบเดียวกับ "ทำงานครั้งเดียว ไม่ใช่ทุกเฟรม" ที่คุณเคยเห็นมาแล้วตอน cache ผลลัพธ์ของ GetComponent ในบทก่อน ๆ ส่วนการเช็ครีเซ็ตมีอยู่เพราะสมมติฐาน "เดินหน้าอย่างเดียว" นั้นพังทันทีที่เวลาเดินถอยหลัง เช่น designer ลาก playhead ของ Timeline ไปทางซ้ายใน editor หรือเกมที่ให้ผู้เล่น replay/rewind cutscene ได้ ถ้าไม่มีการรีเซ็ต cursor ก็จะค้างอยู่เลยบรรทัดที่ต้องแสดงซ้ำไปแล้ว การ์ดนี้เทียบกับ startTime ของบรรทัดก่อนหน้า เพื่อไม่ให้ cursor ที่อยู่พอดีตรงขอบบรรทัดถูกรีเซ็ตทุกเฟรมโดยไม่ตั้งใจ