การพัฒนา Unity AI: บทช่วยสอนเกี่ยวกับเครื่องจักรที่มีสถานะจำกัด
เผยแพร่แล้ว: 2022-03-11ในโลกแห่งการแข่งขันของเกม นักพัฒนามุ่งมั่นที่จะมอบประสบการณ์ผู้ใช้ที่สนุกสนานสำหรับผู้ที่โต้ตอบกับตัวละครที่ไม่ใช่ผู้เล่น (NPC) ที่เราสร้างขึ้น นักพัฒนาสามารถส่งมอบการโต้ตอบนี้ได้โดยใช้เครื่อง finite-state (FSM) เพื่อสร้างโซลูชัน AI ที่จำลองข้อมูลอัจฉริยะใน NPC ของเรา
แนวโน้มของ AI ได้เปลี่ยนไปเป็นแผนผังพฤติกรรม แต่ FSM ยังคงมีความเกี่ยวข้อง สิ่งเหล่านี้รวมอยู่ในเกมอิเล็กทรอนิกส์แทบทุกเกม ไม่ว่าจะเป็นความจุเดียวหรืออย่างอื่น
กายวิภาคของ FSM
FSM เป็นแบบจำลองของการคำนวณซึ่งมีสถานะสมมุติฐานเพียงหนึ่งในจำนวนจำกัดเท่านั้นที่สามารถใช้งานได้ในคราวเดียว FSM เปลี่ยนจากสถานะหนึ่งไปอีกสถานะหนึ่ง โดยตอบสนองต่อเงื่อนไขหรืออินพุต ส่วนประกอบหลักประกอบด้วย:
| ส่วนประกอบ | คำอธิบาย |
|---|---|
| สถานะ | หนึ่งในชุดตัวเลือกที่มีขอบเขตจำกัดซึ่งระบุถึงสภาพโดยรวมในปัจจุบันของ FSM สถานะใด ๆ รวมถึงชุดของการกระทำที่เกี่ยวข้อง |
| หนังบู๊ | รัฐทำอะไรเมื่อ FSM ทำการสอบถาม |
| การตัดสินใจ | ตรรกะที่เกิดขึ้นเมื่อการเปลี่ยนแปลงเกิดขึ้น |
| การเปลี่ยนแปลง | กระบวนการเปลี่ยนสถานะ |
ในขณะที่เราจะมุ่งเน้นไปที่ FSM จากมุมมองของการนำ AI ไปใช้ แนวคิดเช่นเครื่องแสดงสถานะแอนิเมชั่นและสถานะเกมทั่วไปก็ตกอยู่ภายใต้ร่ม FSM
การแสดงภาพ FSM
ลองมาดูตัวอย่างของเกมอาร์เคดสุดคลาสสิค Pac-Man ในสถานะเริ่มต้นของเกม (สถานะ "ไล่ล่า") NPC เป็นผีหลากสีที่ไล่ตามและแซงหน้าผู้เล่นในที่สุด ผีจะเข้าสู่สถานะหลบเลี่ยงเมื่อใดก็ตามที่ผู้เล่นกินยาเม็ดพลังและสัมผัสกับการเพิ่มพลัง ทำให้ได้รับความสามารถในการกินผี ผีซึ่งตอนนี้เป็นสีน้ำเงิน จะหลบเลี่ยงผู้เล่นจนกว่าจะหมดเวลาเพิ่มพลัง และผีจะเปลี่ยนกลับไปเป็นสถานะการไล่ล่า ซึ่งพฤติกรรมและสีดั้งเดิมของพวกมันจะกลับคืนมา
ผีแพคแมนมักจะอยู่ในสถานะใดสถานะหนึ่งจากสองสถานะ: ไล่หรือหลบเลี่ยง โดยธรรมชาติแล้ว เราต้องจัดให้มีช่วงการเปลี่ยนภาพสองแบบ แบบหนึ่งจากการไล่ล่าเป็นการหลบเลี่ยง อีกช่วงหนึ่งจากการหลบเลี่ยงเป็นการไล่ล่า:
โดยการออกแบบเครื่องจำกัดสถานะจะสอบถามสถานะปัจจุบัน ซึ่งจะสอบถามการตัดสินใจและการดำเนินการของสถานะนั้น ไดอะแกรมต่อไปนี้เป็นตัวอย่าง Pac-Man ของเราและแสดงการตัดสินใจที่ตรวจสอบสถานะของการเพิ่มพลังของผู้เล่น หากการเพิ่มพลังได้เริ่มขึ้น NPC จะเปลี่ยนไปจากการไล่ล่าเป็นการหลบเลี่ยง หากการเพิ่มพลังสิ้นสุดลง NPC จะเปลี่ยนจากการหลบเลี่ยงเป็นการไล่ล่า สุดท้าย หากไม่มีการเปลี่ยนแปลงการเพิ่มพลัง การเปลี่ยนแปลงจะไม่เกิดขึ้น
ความสามารถในการปรับขนาด
FSM ปล่อยให้เราสร้าง AI แบบแยกส่วนได้ ตัวอย่างเช่น ด้วยการกระทำใหม่เพียงครั้งเดียว เราสามารถสร้าง NPC ที่มีพฤติกรรมใหม่ได้ ดังนั้น เราสามารถกำหนดการกระทำใหม่—การกินเม็ดพลัง—ให้กับผี Pac-Man ตัวหนึ่งของเรา ทำให้มันสามารถกินเม็ดพลังในขณะที่หลบเลี่ยงผู้เล่น เราสามารถนำการกระทำ การตัดสินใจ และการเปลี่ยนแปลงที่มีอยู่มาใช้ซ้ำเพื่อสนับสนุนพฤติกรรมนี้ได้
เนื่องจากทรัพยากรที่จำเป็นในการพัฒนา NPC เฉพาะนั้นมีน้อย เราจึงอยู่ในตำแหน่งที่ดีที่จะตอบสนองความต้องการโครงการที่กำลังพัฒนาของ NPC ที่ไม่ซ้ำกันหลายตัว ในทางกลับกัน จำนวนสถานะและช่วงการเปลี่ยนภาพที่มากเกินไปอาจทำให้เรายุ่งเหยิงใน เครื่องสถานะสปาเก็ตตี้ — FSM ที่มีการเชื่อมต่อมากเกินไปทำให้ยากต่อการดีบักและบำรุงรักษา
การนำ FSM ไปใช้ใน Unity
เพื่อสาธิตวิธีการใช้เครื่องจำกัดสถานะใน Unity ให้สร้างเกมการลักลอบอย่างง่าย สถาปัตยกรรมของเราจะรวม ScriptableObject s ซึ่งเป็นที่เก็บข้อมูลที่สามารถจัดเก็บและแบ่งปันข้อมูลทั่วทั้งแอปพลิเคชัน เพื่อที่เราจะไม่ต้องทำซ้ำ ScriptableObject มีความสามารถในการประมวลผลที่จำกัด เช่น การเรียกใช้การดำเนินการและการตัดสินใจสอบถาม นอกเหนือจากเอกสารอย่างเป็นทางการของ Unity แล้ว Game Architecture รุ่นเก่าที่มี Scriptable Objects talk ยังคงเป็นแหล่งข้อมูลที่ยอดเยี่ยมหากคุณต้องการเจาะลึกลงไปอีก
ก่อนที่เราจะเพิ่ม AI ในโครงการพร้อมคอมไพล์เริ่มต้นนี้ ให้พิจารณาสถาปัตยกรรมที่เสนอ:
ในเกมตัวอย่างของเรา ศัตรู (NPC ที่แทนด้วยแคปซูลสีน้ำเงิน) ลาดตระเวน เมื่อศัตรูเห็นผู้เล่น (แสดงด้วยแคปซูลสีเทา) ศัตรูจะเริ่มติดตามผู้เล่น:
ตรงกันข้ามกับ Pac-Man ศัตรูในเกมของเราจะไม่กลับสู่สถานะเริ่มต้น ("การลาดตระเวน") เมื่อมันติดตามผู้เล่น
การสร้างชั้นเรียน
เริ่มต้นด้วยการสร้างชั้นเรียนของเรา ในโฟลเดอร์ scripts ใหม่ เราจะเพิ่มบล็อคการสร้างสถาปัตยกรรมที่เสนอทั้งหมดเป็นสคริปต์ C#
การใช้งาน BaseStateMachine Class
คลาส BaseStateMachine เป็น MonoBehavior เดียวที่เราจะเพิ่มเพื่อเข้าถึง NPC ที่เปิดใช้งาน AI ของเรา เพื่อความเรียบง่าย BaseStateMachine ของเราจะเป็นแบบเปล่าๆ อย่างไรก็ตาม หากเราต้องการ เราสามารถเพิ่ม FSM แบบกำหนดเองที่สืบทอดมาซึ่งเก็บพารามิเตอร์เพิ่มเติมและการอ้างอิงไปยังส่วนประกอบเพิ่มเติม โปรดทราบว่าโค้ดจะไม่คอมไพล์อย่างถูกต้องจนกว่าเราจะเพิ่มคลาส BaseState ซึ่งเราจะทำในภายหลังในบทช่วยสอน
รหัสสำหรับ BaseStateMachine อ้างถึงและดำเนินการสถานะปัจจุบันเพื่อดำเนินการและดูว่ารับประกันการเปลี่ยนแปลงหรือไม่:
using UnityEngine; namespace Demo.FSM { public class BaseStateMachine : MonoBehaviour { [SerializeField] private BaseState _initialState; private void Awake() { CurrentState = _initialState; } public BaseState CurrentState { get; set; } private void Update() { CurrentState.Execute(this); } } } การใช้งาน BaseState Class
สถานะของเราอยู่ในประเภท BaseState ซึ่งเราได้มาจาก ScriptableObject BaseState มีเมธอดเดียว Execute รับ BaseStateMachine เป็นอาร์กิวเมนต์และส่งผ่านไปยังแอคชันและทรานซิชัน นี่คือลักษณะ BaseState มีลักษณะ:
using UnityEngine; namespace Demo.FSM { public class BaseState : ScriptableObject { public virtual void Execute(BaseStateMachine machine) { } } } การดำเนินการของ State และ RemainInState ชั้นเรียนของรัฐ
ตอนนี้เราได้รับสองคลาสจาก BaseState อันดับแรก เรามี State class ซึ่งจัดเก็บการอ้างอิงถึงการดำเนินการและการเปลี่ยน รวมถึงสองรายการ (รายการหนึ่งสำหรับการดำเนินการ อีกรายการสำหรับการเปลี่ยน) และการแทนที่และเรียกฐาน Execute เกี่ยวกับการดำเนินการและการเปลี่ยน:
using System.Collections.Generic; using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/State")] public sealed class State : BaseState { public List<FSMAction> Action = new List<FSMAction>(); public List<Transition> Transitions = new List<Transition>(); public override void Execute(BaseStateMachine machine) { foreach (var action in Action) action.Execute(machine); foreach(var transition in Transitions) transition.Execute(machine); } } } ประการที่สอง เรามีคลาส RemainInState ซึ่งบอก FSM เมื่อไม่ทำการเปลี่ยนแปลง:
using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/Remain In State", fileName = "RemainInState")] public sealed class RemainInState : BaseState { } } โปรดทราบว่าคลาสเหล่านี้จะไม่คอมไพล์จนกว่าเราจะเพิ่ม FSMAction , Decision และ Transition
การใช้งาน FSMAction Class
ในไดอะแกรมสถาปัตยกรรม FSM ที่เสนอ คลาส FSMAction พื้นฐานจะมีป้ายกำกับว่า "การดำเนินการ" อย่างไรก็ตาม เราจะสร้างคลาส FSMAction พื้นฐาน และใช้ชื่อ FSMAction (เนื่องจากมี Action ใช้งานเนมสเปซ .NET System แล้ว)
FSMAction ซึ่งเป็น ScriptableObject ไม่สามารถประมวลผลฟังก์ชันได้อย่างอิสระ ดังนั้นเราจะกำหนดให้เป็นคลาสนามธรรม ในขณะที่การพัฒนาของเราดำเนินไป เราอาจต้องการการดำเนินการเพียงครั้งเดียวเพื่อให้บริการมากกว่าหนึ่งรัฐ โชคดีที่เราสามารถเชื่อมโยง FSMAction กับสถานะต่างๆ จาก FSM ได้มากเท่าที่เราต้องการ
คลาสนามธรรม FSMAction มีลักษณะดังนี้:
using UnityEngine; namespace Demo.FSM { public abstract class FSMAction : ScriptableObject { public abstract void Execute(BaseStateMachine stateMachine); } } การดำเนินการตามชั้นเรียนการ Decision และการ Transition
เพื่อเสร็จสิ้น FSM ของเรา เราจะกำหนดอีกสองคลาส อันดับแรก เรามี Decision ซึ่งเป็นคลาสนามธรรมซึ่งการตัดสินใจอื่น ๆ ทั้งหมดจะกำหนดพฤติกรรมที่กำหนดเอง:

using UnityEngine; namespace Demo.FSM { public abstract class Decision : ScriptableObject { public abstract bool Decide(BaseStateMachine state); } } ชั้นที่สอง Transition ประกอบด้วย Decision object และสองสถานะ:
- สถานะที่จะเปลี่ยนไปหากการ
Decisionเป็นจริง - สถานะอื่นที่จะเปลี่ยนไปหากการ
Decisionให้ผลเท็จ
ดูเหมือนว่านี้:
using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/Transition")] public sealed class Transition : ScriptableObject { public Decision Decision; public BaseState TrueState; public BaseState FalseState; public void Execute(BaseStateMachine stateMachine) { if(Decision.Decide(stateMachine) && !(TrueState is RemainInState)) stateMachine.CurrentState = TrueState; else if(!(FalseState is RemainInState)) stateMachine.CurrentState = FalseState; } } }ทุกสิ่งที่เราสร้างขึ้นมาจนถึงจุดนี้ควรรวบรวมโดยไม่มีข้อผิดพลาด หากคุณประสบปัญหา ให้ตรวจสอบเวอร์ชัน Unity Editor ซึ่งอาจทำให้เกิดข้อผิดพลาดได้หากล้าสมัย ตรวจสอบให้แน่ใจว่าไฟล์ทั้งหมดได้รับการโคลนอย่างถูกต้องจากโฟลเดอร์โปรเจ็กต์ดั้งเดิม และตัวแปรที่เข้าถึงแบบสาธารณะทั้งหมดไม่ได้ประกาศให้เป็นแบบส่วนตัว
การสร้างการดำเนินการและการตัดสินใจที่กำหนดเอง
ตอนนี้ เมื่อเสร็จสิ้นภาระกิจ เราพร้อมที่จะใช้การดำเนินการและการตัดสินใจแบบกำหนดเองในโฟลเดอร์ scripts ใหม่
การดำเนินการตามชั้นเรียน Patrol และ Chase
เมื่อเราวิเคราะห์องค์ประกอบหลักของแผนภาพ FSM ตัวอย่างเกม Stealth ของเรา เราจะเห็นว่า NPC ของเราสามารถอยู่ในสถานะใดสถานะหนึ่งจากสองสถานะ:
- รัฐตระเวน — ที่เกี่ยวข้องกับรัฐคือ:
- หนึ่งการกระทำ: NPC เยี่ยมชมจุดลาดตระเวนแบบสุ่มทั่วโลก
- หนึ่งช่วงการเปลี่ยนภาพ: NPC จะตรวจสอบว่าผู้เล่นอยู่ในสายตาหรือไม่ และหากใช่ จะเปลี่ยนเป็นสถานะการไล่ล่า
- หนึ่งการตัดสินใจ: NPC ตรวจสอบว่าผู้เล่นอยู่ในสายตาหรือไม่
- Chase state — ที่เกี่ยวข้องกับรัฐคือ:
- หนึ่งการกระทำ: NPC ไล่ล่าผู้เล่น
เราสามารถนำการนำการเปลี่ยนแปลงที่มีอยู่ไปใช้ซ้ำได้ผ่าน GUI ของ Unity ตามที่เราจะพูดถึงในภายหลัง การดำเนินการนี้เหลือสองการดำเนินการ ( PatrolAction และ ChaseAction ) และการตัดสินใจให้เราเขียนโค้ด
การดำเนินการสถานะตระเวน (ซึ่งมาจาก FSMAction ฐาน) จะแทนที่เมธอด Execute เพื่อรับสององค์ประกอบ:
-
PatrolPointsซึ่งติดตามจุดลาดตระเวน -
NavMeshAgentการใช้งานของ Unity สำหรับการนำทางในพื้นที่ 3 มิติ
การแทนที่จะตรวจสอบว่าเอเจนต์ AI ไปถึงปลายทางหรือไม่ และหากเป็นเช่นนั้น ให้ย้ายไปยังปลายทางถัดไป ดูเหมือนว่านี้:
using Demo.Enemy; using Demo.FSM; using UnityEngine; using UnityEngine.AI; namespace Demo.MyFSM { [CreateAssetMenu(menuName = "FSM/Actions/Patrol")] public class PatrolAction : FSMAction { public override void Execute(BaseStateMachine stateMachine) { var navMeshAgent = stateMachine.GetComponent<NavMeshAgent>(); var patrolPoints = stateMachine.GetComponent<PatrolPoints>(); if (patrolPoints.HasReached(navMeshAgent)) navMeshAgent.SetDestination(patrolPoints.GetNext().position); } } } เราอาจต้องพิจารณาการแคช PatrolPoints และ NavMeshAgent การแคชจะทำให้เราสามารถแบ่งปัน ScriptableObject สำหรับการดำเนินการระหว่างเอเจนต์โดยไม่กระทบต่อประสิทธิภาพการทำงานของการรัน GetComponent ในแต่ละเคียวรีของเครื่อง finite-state
เพื่อความชัดเจน เราไม่สามารถแคชอินสแตนซ์ของส่วนประกอบในวิธี Execute การได้ ดังนั้น เราจะเพิ่มเมธอด GetComponent แบบกำหนดเองให้กับ BaseStateMachine แทน GetComponent แบบกำหนดเองของเราจะแคชอินสแตนซ์ในครั้งแรกที่มีการเรียก โดยคืนค่าอินสแตนซ์ที่แคชไว้ในการเรียกต่อเนื่องกัน สำหรับการอ้างอิง นี่คือการใช้งาน BaseStateMachine ด้วยการแคช:
using System; using System.Collections.Generic; using UnityEngine; namespace Demo.FSM { public class BaseStateMachine : MonoBehaviour { [SerializeField] private BaseState _initialState; private Dictionary<Type, Component> _cachedComponents; private void Awake() { CurrentState = _initialState; _cachedComponents = new Dictionary<Type, Component>(); } public BaseState CurrentState { get; set; } private void Update() { CurrentState.Execute(this); } public new T GetComponent<T>() where T : Component { if(_cachedComponents.ContainsKey(typeof(T))) return _cachedComponents[typeof(T)] as T; var component = base.GetComponent<T>(); if(component != null) { _cachedComponents.Add(typeof(T), component); } return component; } } } เช่นเดียวกับ PatrolAction คลาส ChaseAction จะแทนที่เมธอด Execute เพื่อรับส่วนประกอบ PatrolPoints และ NavMeshAgent ในทางตรงกันข้าม หลังจากตรวจสอบว่าเอเจนต์ AI ไปถึงที่หมายแล้วหรือไม่ การดำเนินการคลาส ChaseAction จะตั้งค่าปลายทางเป็น Player.position :
using Demo.Enemy; using Demo.FSM; using UnityEngine; using UnityEngine.AI; namespace Demo.MyFSM { [CreateAssetMenu(menuName = "FSM/Actions/Chase")] public class ChaseAction : FSMAction { public override void Execute(BaseStateMachine stateMachine) { var navMeshAgent = stateMachine.GetComponent<NavMeshAgent>(); var enemySightSensor = stateMachine.GetComponent<EnemySightSensor>(); navMeshAgent.SetDestination(enemySightSensor.Player.position); } } } การนำ InLineOfSightDecision Class ไปใช้
ชิ้นสุดท้ายคือคลาส InLineOfSightDecision ซึ่งสืบทอดการ Decision พื้นฐานและรับส่วนประกอบ EnemySightSensor เพื่อตรวจสอบว่าผู้เล่นอยู่ในสายตาของ NPC หรือไม่:
using Demo.Enemy; using Demo.FSM; using UnityEngine; namespace Demo.MyFSM { [CreateAssetMenu(menuName = "FSM/Decisions/In Line Of Sight")] public class InLineOfSightDecision : Decision { public override bool Decide(BaseStateMachine stateMachine) { var enemyInLineOfSight = stateMachine.GetComponent<EnemySightSensor>(); return enemyInLineOfSight.Ping(); } } }การแนบพฤติกรรมกับรัฐ
ในที่สุดเราก็พร้อมที่จะแนบพฤติกรรมกับตัวแทน Enemy สิ่งเหล่านี้ถูกสร้างขึ้นในหน้าต่างโครงการของ Unity Editor
เพิ่ม Patrol และ Chase รัฐ
มาสร้างสองสถานะและตั้งชื่อพวกเขาว่า "Patrol" และ "Chase":
- คลิกขวา > สร้าง > FSM > สถานะ
ขณะอยู่ที่นี่ เรามาสร้างวัตถุ RemainInState กัน:
- คลิกขวา > สร้าง > FSM > อยู่ในสถานะ
ถึงเวลาสร้างการกระทำที่เราเพิ่งเขียนโค้ด:
- คลิกขวา > สร้าง > FSM > การดำเนินการ > Patrol
- คลิกขวา > สร้าง > FSM > การกระทำ > Chase
เพื่อเข้ารหัสการ Decision :
- คลิกขวา > สร้าง > FSM > การตัดสินใจ > อยู่ในแนวสายตา
ในการเปิดใช้งานการเปลี่ยนจาก PatrolState เป็น ChaseState อื่นให้สร้างวัตถุทรานซิชันสคริปต์ได้ก่อน:
- คลิกขวา > สร้าง > FSM > การเปลี่ยนผ่าน
- เลือกชื่อที่คุณชอบ ฉันเรียกฉันว่า Spotted Enemy
เราจะเติมหน้าต่างตัวตรวจสอบที่เป็นผลลัพธ์ดังนี้:
จากนั้นเราจะดำเนินการกล่องโต้ตอบตัวตรวจสอบ Chase State ให้สมบูรณ์ดังนี้:
ต่อไป เราจะทำกล่องโต้ตอบ Patrol State ให้เสร็จ:
สุดท้าย เราจะเพิ่มองค์ประกอบ BaseStateMachine ให้กับวัตถุศัตรู: ในหน้าต่างโครงการของ Unity Editor เปิดเนื้อหา SampleScene เลือกวัตถุศัตรูจากแผงลำดับชั้น และในหน้าต่างตัวตรวจสอบ ให้เลือก เพิ่มส่วนประกอบ > เครื่องสถานะฐาน :
สำหรับปัญหาใดๆ ให้ตรวจสอบอีกครั้งว่าวัตถุเกมของคุณได้รับการกำหนดค่าอย่างถูกต้อง ตัวอย่างเช่น ยืนยันว่าอ็อบเจ็กต์ Enemy มีส่วนประกอบสคริปต์ PatrolPoints และอ็อบเจ็กต์ Point1 , Point2 เป็นต้น ข้อมูลนี้อาจสูญหายได้ด้วยการกำหนดเวอร์ชันของตัวแก้ไขที่ไม่ถูกต้อง
ตอนนี้คุณพร้อมที่จะเล่นเกมตัวอย่างและสังเกตว่าศัตรูจะติดตามผู้เล่นเมื่อผู้เล่นก้าวเข้าไปในแนวสายตาของศัตรู
การใช้ FSM เพื่อสร้างประสบการณ์ผู้ใช้แบบโต้ตอบที่สนุกสนาน
ในบทช่วยสอนเกี่ยวกับเครื่องที่มีสถานะจำกัดนี้ เราได้สร้าง AI ที่ใช้ FSM แบบโมดูลาร์สูง (และ GitHub repo ที่สอดคล้องกัน) ที่เราสามารถนำมาใช้ซ้ำได้ในโครงการในอนาคต ต้องขอบคุณโมดูลนี้ เราสามารถเพิ่มพลังให้กับ AI ของเราได้เสมอโดยการแนะนำส่วนประกอบใหม่
แต่สถาปัตยกรรมของเรายังปูทางสำหรับการออกแบบ FSM ที่เน้นกราฟิกเป็นหลัก ซึ่งจะยกระดับประสบการณ์ของนักพัฒนาซอฟต์แวร์ไปสู่ระดับใหม่ของความเป็นมืออาชีพ จากนั้น เราสามารถสร้าง FSM สำหรับเกมของเราได้รวดเร็วยิ่งขึ้น—และด้วยความแม่นยำในการสร้างสรรค์ที่ดีขึ้น
