ศัตรูในเกมไม่ได้เห็นหรือได้ยินอะไรจริง ๆ มันแค่รันโค้ดที่เช็คตัวเลขแล้วตัดสินใจว่า "เห็นผู้เล่น" หรือ "ไม่เห็นผู้เล่น" บทเรียนนี้ว่าด้วยการเขียนโค้ดส่วนนั้นให้ดี คุณรู้จักเครื่องมือคณิตศาสตร์สองตัวที่มันพึ่งพามากที่สุดอยู่แล้ว คือ dot product (บทที่ 2.1) ที่วัดว่าทิศทางสองทิศเข้ากันได้ดีแค่ไหน กับ raycast (บทที่ 6.2 และ 6.3) ที่ถามว่า "เส้นนี้ชนอะไรเป็นชิ้นแรก" ในบทนี้คุณจะเอาทั้งสองอย่างมารวมกันเป็นระบบ perception เต็มรูปแบบ คือ vision ด้วย field-of-view cone, hearing ด้วย sound event, memory เล็ก ๆ เพื่อไม่ให้ agent ลืมคุณทันทีที่คุณหลบหลังกำแพง แล้วค่อยขยับไปอีกขั้นที่ยากขึ้นคือ spatial reasoning ที่ agent ต้องตัดสินรูปทรงของฉากเองเพื่อหา cover หรือจุด flanking ที่ดี
ลองนึกภาพสคริปต์ศัตรูที่ง่ายที่สุดเท่าที่จะเป็นไปได้ คือทุกเฟรมมันอ่าน player.transform.position ตรง ๆ แล้วเดินเข้าหา ศัตรูตัวนั้นไม่ได้ perceive อะไรเลย มันรู้ตำแหน่งผู้เล่นแบบสมบูรณ์แบบ ทันที ทะลุกำแพงได้ทุกด้าน ไม่ว่าระยะไหนก็รู้หมด ผู้เล่นจะสังเกตได้ทันที มันรู้สึกเหมือนศัตรูโกง เพราะมันโกงจริง ๆ
agent ที่น่าเชื่อควรรู้แค่สิ่งที่มันน่าจะ sense ได้จริง ๆ เท่านั้น หมายความว่าก่อนที่ agent จะตอบสนองต่อผู้เล่นได้ ต้องมีโค้ดส่วนหนึ่งตอบคำถามสามข้อนี้อย่างตรงไปตรงมาก่อน คือ ตอนนี้เห็นไหม ตอนนี้ได้ยินไหม และจำได้ไหมว่าเมื่อกี้มันอยู่ตรงไหน ต่อเมื่อตอบคำถามพวกนี้ได้แล้วเท่านั้น การตัดสินใจ (state machine จากบทที่ 6.1 หรือ behavior tree ในบทถัดไป) ถึงจะทำงานได้ โดยทั่วไปมักอธิบายเป็น loop แบบนี้
การแบ่ง loop แบบนี้ไม่ได้มีไว้แค่เพื่อความยุติธรรมกับผู้เล่นเท่านั้น มันยังทำให้โค้ดจัดการง่ายขึ้นด้วย เพราะโค้ด vision ไม่ต้องรู้อะไรเรื่องการโจมตีเลย state machine ก็ไม่ต้องรู้ว่า raycast ทำงานยังไง และคุณสามารถทดสอบแต่ละส่วนแยกกันได้ ที่เหลือของบทเรียนนี้จะสร้างกล่อง "senses" กับ "percepts + memory" ส่วนบทที่ 8 จะต่อ state machine เล็ก ๆ เข้าไปด้านบนเพื่อให้เห็น loop ทั้งหมดทำงานจริง
field of view (FOV) sensor ตอบคำถามเดียวคือ "ตอนนี้ agent ตัวนี้เห็นเป้าหมายนั้นไหม" คำตอบที่ตรงไปตรงมาขึ้นอยู่กับสามอย่าง เช็คตามลำดับคือ เป้าหมายอยู่ใกล้พอไหม อยู่ในมุมที่ agent กำลังมองไหม และมี line of sight ที่โล่งไปถึงมันไหม section นี้จะพูดถึงเช็คแรกและถูกที่สุดคือ ระยะทาง
ศัตรูทุกตัวมี view radius คือระยะทางสูงสุดที่เกินจากนั้นไปมันมองไม่เห็นอะไรเลย คล้าย ๆ กับที่คุณอ่านป้ายไม่ออกถ้ามันไกลเกินไป ไม่ว่าคุณจะหันหน้าไปทางไหนก็ตาม
public float viewRadius = 10f; // meters
bool WithinRange(Vector3 myPosition, Vector3 targetPosition)
{
float sqrDist = (targetPosition - myPosition).sqrMagnitude;
return sqrDist <= viewRadius * viewRadius;
}
สังเกตว่าโค้ดนี้เปรียบเทียบ sqrMagnitude (ความยาวยกกำลังสอง) กับ viewRadius * viewRadius แทนที่จะคำนวณระยะทางจริงด้วย square root แล้วเทียบกับ viewRadius ตรง ๆ ทั้งสองวิธีให้คำตอบจริง/เท็จเหมือนกัน แต่ sqrMagnitude ข้าม square root ไปเลย square root ครั้งเดียวไม่ได้แพงอะไรมาก แต่การเช็ค vision แบบนี้อาจรันให้ศัตรูทุกตัว ทุกเฟรม ดังนั้นการข้าม square root ที่ไม่จำเป็นเป็นนิสัยที่ควรมีตั้งแต่แรก
นี่คือเช็คที่ถูกที่สุดในสามอย่าง เลยรันเป็นอันดับแรกเสมอ ถ้าเป้าหมายอยู่ไกลเกินไป ก็ไม่มีเหตุผลต้องเสียเวลาคำนวณมุมหรือยิง raycast เลย section ถัดไปจะเพิ่มเช็คที่สองต่อจากอันนี้
การผ่านเช็คระยะแค่บอกว่าเป้าหมายอยู่ที่ไหนสักแห่งในวงกลมเต็มรอบตัว agent ตาจริง ๆ ไม่ได้เห็นเป็นวงกลมเต็ม แต่เห็นเป็น cone ที่ชี้ไปข้างหน้า อธิบายด้วย view angle (ความกว้างเต็มของ cone นั้น หน่วยเป็นองศา) นี่คือสถานการณ์เดียวกับที่บทที่ 2.1 พูดไว้ตอนบอกว่า dot product "โผล่มาตลอดในเกม ทั้งเรื่อง lighting, AI vision cone, และ steering"
ทวนสูตรจากบทที่ 2.1: a . b = |a| * |b| * cos(theta) โดย theta คือมุมระหว่างเวกเตอร์ทั้งสอง ถ้า a กับ b เป็น normalized ทั้งคู่ (ความยาว 1) สูตรนี้จะย่อเหลือ a . b = cos(theta) นั่นคือ dot product ของ unit vector สองตัวก็คือ cosine ของมุมระหว่างมันเลย โดยไม่ต้องใช้ square root หรือตรีโกณมิติใด ๆ ในการหามาเลย
public float viewAngle = 90f; // FULL cone width in degrees, not the half-angle
bool WithinAngle(Vector3 forward, Vector3 myPosition, Vector3 targetPosition)
{
Vector3 dirToTarget = (targetPosition - myPosition).normalized;
float cosHalfAngle = Mathf.Cos(viewAngle * 0.5f * Mathf.Deg2Rad);
float cosActual = Vector3.Dot(forward, dirToTarget);
return cosActual >= cosHalfAngle;
}
ไล่ดูทีละขั้น: forward กับ dirToTarget เป็น unit vector ทั้งคู่ (ความยาว 1) ดังนั้น cosActual คือ cosine ของมุมจริงระหว่างทิศที่ agent กำลังมองกับทิศที่เป้าหมายอยู่พอดี cosHalfAngle คือ cosine ของครึ่งหนึ่งของ view cone (cone 90 องศาแผ่ออกไปข้างละ 45 องศาจาก forward) cosine จะ "เล็กลง" เมื่อมุม "ใหญ่ขึ้น" ดังนั้น "มุมจริงเล็กพอ" จะกลายเป็น "cosine จริงใหญ่พอ" นี่คือเหตุผลที่การเปรียบเทียบใช้ >= ไม่ใช่ <=
นี่คือแพทเทิร์นเรื่องเครื่องหมายกับขนาดเดียวกับที่บทเรียน dot product ในบทที่ 2.1 สอนไว้ แค่เอามาใช้กับจุดประสงค์ใหม่ เป้าหมายที่อยู่ตรงหน้าพอดีจะให้ dot product ใกล้ 1 เป้าหมายที่อยู่ด้านข้างจะให้ dot product ใกล้ 0 และเป้าหมายที่อยู่ด้านหลังจะให้ dot product เป็นค่าลบ การเทียบกับ cosHalfAngle แทนที่จะแปลงกลับเป็นมุมจริง (ซึ่งต้องใช้ acos ตัวผกผันของ cosine) เป็นการเลือกทางที่ถูกกว่าโดยตั้งใจ เหตุผลเดียวกับที่ข้าม square root ใน section 2 คุณเคยเห็น Vector3.Angle ถูกใช้เช็คความชันในบทที่ 6.2 มาแล้ว ซึ่งข้างในมันเรียก acos เหมือนกัน นั่นก็โอเคถ้าเช็คแค่ครั้งเดียวต่อเฟรม แต่สำหรับการทดสอบ cone การเทียบ dot product ตรง ๆ ช่วยประหยัดงานส่วนเกินนั้นไปได้
ระยะกับมุมอธิบายได้แค่ cone ว่าง ๆ ในอวกาศ เป้าหมายอาจอยู่ใน cone นั้นแต่ยังถูกซ่อนอยู่หลังกำแพง ลัง หรือตัวละครอื่นได้อยู่ดี เช็คที่สามคือ raycast เครื่องมือเดิมจากบทที่ 6.2 และ 6.3 ที่ยิงจากตาของ agent ไปยังเป้าหมาย โดยถามว่า "สิ่งที่แข็งชิ้นแรกที่เส้นนี้แตะคืออะไร"
public LayerMask targetMask; // what counts as "a target" (e.g. the Player layer)
public LayerMask obstacleMask; // what can block sight (e.g. walls, crates)
bool HasLineOfSight(Vector3 eyePosition, Vector3 dirToTarget, float distToTarget)
{
int mask = obstacleMask | targetMask;
if (Physics.Raycast(eyePosition, dirToTarget, out RaycastHit hit, distToTarget, mask))
{
bool hitIsTarget = ((1 << hit.collider.gameObject.layer) & targetMask) != 0;
return hitIsTarget; // true only if the FIRST thing hit is the target itself
}
return true; // raycast found nothing at all in the way -- clear line
}
raycast ถูกยิงด้วย mask ที่รวมทั้ง obstacle และ target เข้าด้วยกัน ดังนั้นอะไรก็ตามที่อยู่ใกล้ตาของ agent ทางกายภาพมากกว่าจะเป็นสิ่งที่ ray รายงานกลับมา ถ้าสิ่งแรกที่ชนเป็น layer ของ target แปลว่า line โล่ง ถ้าเป็นอย่างอื่น (กำแพง ลัง) แปลว่ามีอะไรบังอยู่ และเป้าหมายมองไม่เห็น ไม่ว่าระยะกับมุมจะดูดีแค่ไหนก็ตาม
ทีนี้เอาทั้งสามเช็คมารวมกัน เรียงจากถูกไปแพง เป็น method เดียว:
public class FieldOfViewSensor : MonoBehaviour
{
public float viewRadius = 10f;
[Range(0, 360)] public float viewAngle = 90f;
public LayerMask targetMask;
public LayerMask obstacleMask;
public bool CanSeeTarget(Transform target)
{
Vector3 toTarget = target.position - transform.position;
// 1) distance -- cheapest, do it first
float sqrDist = toTarget.sqrMagnitude;
if (sqrDist > viewRadius * viewRadius)
return false;
// 2) angle -- still cheap, no physics involved
Vector3 dirToTarget = toTarget.normalized;
float cosHalfAngle = Mathf.Cos(viewAngle * 0.5f * Mathf.Deg2Rad);
float cosActual = Vector3.Dot(transform.forward, dirToTarget);
if (cosActual < cosHalfAngle)
return false;
// 3) line of sight -- a physics query, the most expensive, so it runs last
float dist = Mathf.Sqrt(sqrDist);
int mask = obstacleMask | targetMask;
if (Physics.Raycast(transform.position, dirToTarget, out RaycastHit hit, dist, mask))
{
bool hitIsTarget = ((1 << hit.collider.gameObject.layer) & targetMask) != 0;
if (!hitIsTarget)
return false;
}
return true;
}
public List<Transform> FindVisibleTargets()
{
List<Transform> visible = new List<Transform>();
Collider[] nearby = Physics.OverlapSphere(transform.position, viewRadius, targetMask);
foreach (Collider col in nearby)
{
if (CanSeeTarget(col.transform))
visible.Add(col.transform);
}
return visible;
}
}
ไล่ trace ดู โดย agent อยู่ที่ origin หันหน้าไปทาง (0, 0, 1), viewRadius = 10, viewAngle = 90 (ดังนั้น cosHalfAngle = cos(45 deg) = 0.707):
obstacleMask อย่างเดียว แล้วไม่ใส่ targetMask เข้าไปด้วย ray จะเจอปัญหาสองแบบคือ ไม่ชนอะไรเลย (ถ้ามันทะลุ collider ของ target ไปตรง ๆ) หรือแย่กว่านั้นคือมองว่า collider ของ target เองเป็น obstacle แล้วรายงานว่า target ถูกบังโดยตัวมันเอง ให้ raycast กับ mask ที่รวมกันเสมอ แล้วเช็คว่าสิ่งแรกที่ชนอยู่ layer ไหน เหมือนที่ CanSeeTarget ทำด้านบนเป๊ะ ๆVision มีทิศทางและต้องการเส้นที่โล่ง — hearing ไม่ใช่ทั้งสองอย่างนั้นเลย เสียงกระจายออกไปทุกทิศทาง และการที่ agent จะสังเกตเห็นมันหรือไม่มักขึ้นอยู่กับสองอย่างคือ เสียงดังแค่ไหนตรงจุดกำเนิด และ agent อยู่ไกลแค่ไหน โมเดลสิ่งนี้เป็น event เล็ก ๆ ที่ broadcast ออกไปทุกครั้งที่มีอะไรส่งเสียง
public struct SoundEvent
{
public Vector3 position;
public float loudness; // radius, in meters, this sound can be heard at
public GameObject source;
}
public static class AudioPerception
{
public static event Action<SoundEvent> OnSoundEmitted;
public static void EmitSound(Vector3 position, float loudness, GameObject source)
{
OnSoundEmitted?.Invoke(new SoundEvent { position = position, loudness = loudness, source = source });
}
}
AudioPerception เป็น static event bus อะไรก็ตามที่ส่งเสียงเรียก EmitSound ได้โดยไม่ต้องมี reference ไปยังศัตรูตัวไหนเป็นการเฉพาะ และศัตรูตัวไหนที่อยากฟังก็ subscribe เข้ากับ OnSoundEmitted โดยไม่ต้องมี reference ไปยังสิ่งที่อาจส่งเสียง ตัวควบคุมเสียงฝีเท้าเป็นตัวอย่างของ emitter ทั่วไป:
public class Footsteps : MonoBehaviour
{
public bool isRunning;
public void OnFootstep() // called by an Animation Event on the footstep frame
{
float loudness = isRunning ? 15f : 6f;
AudioPerception.EmitSound(transform.position, loudness, gameObject);
}
}
ส่วน listener ก็ใช้ pattern subscribe แบบ OnEnable/OnDisable เดียวกับที่บทที่ 6.7 ใช้กับ Observer pattern:
public class HearingSensor : MonoBehaviour
{
public float hearingSensitivity = 1f; // multiplier; 1 = normal hearing
public event Action<Vector3> OnHeardSound; // reports WHERE the sound came from
void OnEnable() { AudioPerception.OnSoundEmitted += HandleSound; }
void OnDisable() { AudioPerception.OnSoundEmitted -= HandleSound; }
void HandleSound(SoundEvent sound)
{
float dist = Vector3.Distance(transform.position, sound.position);
float hearingRadius = sound.loudness * hearingSensitivity;
if (dist <= hearingRadius)
{
OnHeardSound?.Invoke(sound.position);
}
}
}
ไล่ trace ดู ศัตรูยืนอยู่ห่างจากเสียงฝีเท้า 10 เมตร ฝีเท้าตอนวิ่งมี loudness 15 และ 10 ไม่มากกว่า 15 ดังนั้น HandleSound จะ raise OnHeardSound ฝีเท้าตอนเดินมี loudness 6 และ 10 มากกว่า 6 ดังนั้นไม่มีอะไร raise เลย — ฝีเท้าแบบเดียวกัน ระยะเท่ากัน จะได้ยินก็ต่อเมื่อผู้เล่นกำลังวิ่งเท่านั้น
Physics.Raycast ระหว่าง listener กับเสียง เหมือนกับเช็ค line-of-sight ใน section 4 เป๊ะ ๆ แล้วลด loudness ที่ใช้จริงลง (หรือไม่สนใจเสียงนั้นไปเลย) ถ้ามีกำแพงบัง ขั้นตอนเพิ่มเติมนี้ปล่อยไว้เป็นส่วนต่อยอดตามธรรมชาติ เมื่อคุณคุ้นกับทั้ง raycast และ event pattern นี้ดีแล้วตอนนี้ การเห็นผู้เล่นกับการได้ยินผู้เล่นเป็นโค้ดสองส่วนที่ไม่เกี่ยวข้องกัน และมีรูปร่างต่างกัน คือ CanSeeTarget เป็น method ที่คุณเรียก ส่วน OnHeardSound เป็น event ที่มี Vector3 อะไรก็ตามที่ตัดสินใจแทนศัตรูจะต้องรู้จักทั้งสองรูปร่างแยกกัน ซึ่งทำให้โค้ดตัดสินใจผูกติดแน่นกับ senses สองอย่างนี้เป๊ะ ๆ ถ้าคุณเพิ่ม sense "smell" ทีหลัง หรือเปลี่ยน vision เป็นอย่างอื่นสำหรับบอส ทุกจุดที่ตอบสนองต่อ senses จะต้องแก้ทั้งหมด
วิธีแก้คือแนวคิดเดียวกับที่บทที่ 6.7 ใช้กับ Observer pattern คือ สร้างรูปร่างข้อมูลเล็ก ๆ ที่ใช้ร่วมกันหนึ่งอย่าง — percept (ศัพท์ทาง AI ที่แปลว่า "ข้อมูลที่ถูก sense มาหนึ่งชิ้น") — แล้วให้ทุก sense รายงานผ่านรูปร่างเดียวกันนี้
public enum PerceptType { Sight, Sound }
public struct Percept
{
public PerceptType type;
public Vector3 position;
public float timestamp;
}
public class PerceptionSystem : MonoBehaviour
{
public FieldOfViewSensor sight;
public HearingSensor hearing;
public Transform player;
public event Action<Percept> OnPercept;
void OnEnable() { hearing.OnHeardSound += HandleHeard; }
void OnDisable() { hearing.OnHeardSound -= HandleHeard; }
void Update()
{
if (sight.CanSeeTarget(player))
{
Raise(PerceptType.Sight, player.position);
}
}
void HandleHeard(Vector3 soundPosition)
{
Raise(PerceptType.Sound, soundPosition);
}
void Raise(PerceptType type, Vector3 position)
{
OnPercept?.Invoke(new Percept { type = type, position = position, timestamp = Time.time });
}
}
ไม่มีอะไรที่อยู่ปลายทางของ PerceptionSystem เรียก Physics.Raycast หรืออ่าน LayerMask อีกเลย มันเห็นแค่ค่า Percept เล็ก ๆ ที่เข้ามาผ่าน event เดียว นั่นคือจุดประสงค์ทั้งหมดของการ decouple คือ memory กับ decision-making จะทดสอบได้และนำกลับมาใช้ใหม่ได้ด้วยตัวเอง และการเปลี่ยนวิธีทำงานภายในของ vision หรือ hearing จะไม่กระทบส่วนเหล่านี้เลย
ถ้ามีแค่ PerceptionSystem อย่างเดียวตอนนี้ ศัตรูจะ "ลืม" ผู้เล่นทันทีที่ CanSeeTarget คืนค่า false — เฟรมหนึ่งไล่ตาม เฟรมถัดไปยืนนิ่งไม่รู้จะไปทางไหน เพราะไม่มีอะไรเก็บสำเนาของสิ่งสุดท้ายที่มัน perceive ไว้เลย มันดูแข็งทื่อและดูถูก ๆ memory เล็ก ๆ แก้ปัญหานี้ได้ โดยเก็บตำแหน่งสุดท้ายที่รู้ไว้ แล้วให้ความมั่นใจในตำแหน่งนั้นค่อย ๆ จางลงในเวลาไม่กี่วินาที แทนที่จะหายวับไปทันที
public class PerceptionMemory : MonoBehaviour
{
public PerceptionSystem perception;
public float decayPerSecond = 0.2f; // confidence lost per second while unseen
public float forgetThreshold = 0.05f;
public Vector3 LastKnownPosition { get; private set; }
public float Confidence { get; private set; } // 1 = certain, 0 = forgotten
void OnEnable() { perception.OnPercept += HandlePercept; }
void OnDisable() { perception.OnPercept -= HandlePercept; }
void HandlePercept(Percept p)
{
LastKnownPosition = p.position;
Confidence = 1f;
}
void Update()
{
if (Confidence <= 0f)
return;
Confidence -= decayPerSecond * Time.deltaTime;
if (Confidence < forgetThreshold)
Confidence = 0f; // forgotten -- LastKnownPosition is no longer trustworthy
}
}
ทุกครั้งที่ percept มาถึง Confidence จะกระโดดกลับขึ้นไปที่ 1 และ LastKnownPosition จะถูก refresh ทุกเฟรมที่ไม่มี percept ใหม่เข้ามา Confidence จะค่อย ๆ ลดลง พอมันตกต่ำกว่า forgetThreshold มันจะถูก clamp ให้เป็น 0 พอดี — ลืมสนิท — แทนที่จะค่อย ๆ ไหลเข้าใกล้ศูนย์ไปเรื่อย ๆ ตลอดกาล
ไล่ trace ดูโดย decayPerSecond = 0.2, percept ล่าสุดที่ t = 0.0 พร้อม Confidence = 1 และไม่มี percept ใหม่หลังจากนั้น:
state machine ที่อ่าน Confidence > 0 ตอนนี้แยกแยะได้ระหว่าง "รู้ตำแหน่งแน่ชัดตอนนี้เลย" (เพิ่ง refresh ใหม่ ใกล้ 1) กับ "รู้คร่าว ๆ แต่เริ่มเก่าแล้ว" (กำลัง decay เข้าใกล้ 0) — นี่คือสิ่งที่ทำให้ agent เดินไปยังจุดที่รู้ล่าสุดแล้วค้นหารอบ ๆ อยู่ไม่กี่วินาที แทนที่จะรู้ทุกอย่างตลอดไปหรือลืมทันที
Confidence เลยตอนที่เป้าหมายที่กำลัง track ถูกทำลายหรือ despawn ไป (เช่น ผู้เล่นตายแล้วไป respawn ที่อื่น) LastKnownPosition จะยังชี้ไปยังตำแหน่งที่ไม่เกี่ยวอะไรกับผู้เล่นอีกต่อไป และ agent จะมั่นใจเดินไปค้นหาจุดว่างเปล่า ให้ reset memory ตรง ๆ ทุกครั้งที่ตัวเป้าหมายเองกลายเป็น invalid ไม่ใช่แค่ตอนที่มองไม่เห็นเท่านั้นsection 2 ถึง 7 สร้าง senses, รูปร่าง percept ที่ใช้ร่วมกัน, และ memory ไว้แล้ว ไม่มีอะไรในนั้นขยับศัตรูได้เองเลย มันแค่ผลิตข้อมูลเท่านั้น การตัดสินใจเป็นงานคนละส่วน และคุณมีเครื่องมือสำหรับมันอยู่แล้ว คือ State pattern จากบทที่ 6.1 และบทที่ 6.7 ในที่นี้มันจะตอบสนองต่อ event Percept และต่อ PerceptionMemory.Confidence แทนที่จะตอบสนองต่อ input ดิบ ๆ
public enum AIState { Idle, Investigating, Chasing, Searching }
public class EnemyBrain : MonoBehaviour
{
public PerceptionSystem perception;
public PerceptionMemory memory;
public Transform player;
public float searchDuration = 4f;
public AIState state = AIState.Idle;
private Vector3 investigateTarget;
private float searchTimer;
void OnEnable() { perception.OnPercept += HandlePercept; }
void OnDisable() { perception.OnPercept -= HandlePercept; }
void HandlePercept(Percept p)
{
if (p.type == PerceptType.Sight)
{
state = AIState.Chasing;
}
else if (p.type == PerceptType.Sound && state == AIState.Idle)
{
state = AIState.Investigating;
investigateTarget = p.position;
}
}
void Update()
{
switch (state)
{
case AIState.Chasing:
if (memory.Confidence <= 0f)
{
state = AIState.Idle;
}
else if (!perception.sight.CanSeeTarget(player))
{
state = AIState.Searching;
searchTimer = searchDuration;
}
break;
case AIState.Searching:
searchTimer -= Time.deltaTime;
// MoveTo(memory.LastKnownPosition) would go here
if (memory.Confidence <= 0f || searchTimer <= 0f)
{
state = AIState.Idle;
}
break;
case AIState.Investigating:
// MoveTo(investigateTarget) would go here
break;
}
}
}
ทุก transition ตรงนี้อ่านค่าจากอย่างใดอย่างหนึ่งเสมอ คือ Percept ที่เพิ่งมาถึง หรือ Confidence ของ memory — ไม่เคยอ่าน player.transform.position ตรง ๆ เลย นี่คือ loop ของ section 1 ที่ต่อวงจรครบแล้ว world state ป้อนเข้า senses, senses ป้อนเข้า percept กับ memory และตอนนี้เท่านั้นที่การตัดสินใจถึงจะรันได้
ทุกอย่างที่ผ่านมาตอบคำถามว่า "ตอนนี้ฉันรู้อะไรบ้าง" แต่มีคำถามที่สองซึ่งต่างออกไปและสำคัญไม่แพ้กันสำหรับ agent ที่ดี คือ "จากสิ่งที่ฉันรู้ ฉันควรไปตรงไหนในพื้นที่นี้" การรู้ว่าผู้เล่นอยู่ห่างออกไป 8 เมตรและมองเห็นได้ ไม่ได้บอกเองว่ายืนนิ่งอยู่ตรงนี้ปลอดภัยไหม มีจุดที่ยิงได้ดีกว่านี้ไหม หรือ cover อยู่ตรงไหน นั่นคือ spatial reasoning — การตัดสินรูปทรงของฉากเอง ไม่ใช่แค่ track เป้าหมายทีละตัว
เครื่องมือคลาสสิกสำหรับเรื่องนี้คือ influence map คือเอา grid มาซ้อนทับฉากไว้ แล้วให้แต่ละ cell เก็บตัวเลขหนึ่งค่าที่อธิบายอะไรบางอย่างเกี่ยวกับจุดนั้น ที่เรียกกันทั่วไปว่า danger (จุดนั้นถูกคุกคามแค่ไหน) หรือ ally presence (มีเพื่อนร่วมทีมอยู่ใกล้แค่ไหน) แหล่งที่เกี่ยวข้องทุกแหล่ง — ศัตรูแต่ละตัว พันธมิตรแต่ละตัว — เพิ่ม influence ให้กับ cell รอบ ๆ ตัวมัน แรงที่สุดตรงตำแหน่งของมันเองและอ่อนลงตามระยะทาง แล้วทั้ง grid ก็ถูก smooth ทำให้ตัวเลขกลายเป็น field ที่นุ่มนวลแทนที่จะเป็นก้อนเหลี่ยม ๆ
จากนั้น agent ก็ถาม grid ได้ด้วยการ lookup ถูก ๆ ครั้งเดียว — "จุดนี้อันตรายแค่ไหน" — แทนที่จะต้องคิดคำนวณใหม่ตั้งแต่ต้น (ศัตรูตัวไหนเห็นบ้าง แต่ละตัวอยู่ใกล้แค่ไหน ฯลฯ) ทุกครั้งที่ต้องการคำตอบ นี่คือ trade-off แบบเดียวกับที่การ optimize ทุกครั้งก่อนหน้านี้ในบทเรียนนี้ทำ คือ คิดหนัก ๆ ครั้งเดียว เก็บสรุปเล็ก ๆ ไว้ แล้วอ่านสรุปนั้นซ้ำ ๆ
influence map ต้องการสามการทำงานคือ แปลงตำแหน่งในโลกให้เป็น grid cell, เพิ่ม influence จากแหล่งกำเนิดเข้าไปใน cell ใกล้เคียงพร้อม distance falloff, และ blur ทั้ง grid เพื่อให้ค่ากระจายอย่างนุ่มนวลแทนที่จะหยุดเป็นก้อนขอบคม
public class InfluenceMap
{
private float[,] cells;
private readonly int width;
private readonly int height;
private readonly float cellSize;
private readonly Vector3 origin;
public InfluenceMap(int width, int height, float cellSize, Vector3 origin)
{
this.width = width;
this.height = height;
this.cellSize = cellSize;
this.origin = origin;
cells = new float[width, height];
}
Vector2Int WorldToCell(Vector3 worldPos)
{
int cx = Mathf.FloorToInt((worldPos.x - origin.x) / cellSize);
int cz = Mathf.FloorToInt((worldPos.z - origin.z) / cellSize);
return new Vector2Int(cx, cz);
}
public void Clear()
{
System.Array.Clear(cells, 0, cells.Length);
}
// adds influence at worldPos, falling off linearly to 0 at radiusCells
public void AddInfluence(Vector3 worldPos, float value, int radiusCells)
{
Vector2Int center = WorldToCell(worldPos);
for (int dx = -radiusCells; dx <= radiusCells; dx++)
{
for (int dz = -radiusCells; dz <= radiusCells; dz++)
{
int x = center.x + dx;
int z = center.y + dz;
if (x < 0 || x >= width || z < 0 || z >= height)
continue;
float dist = Mathf.Sqrt(dx * dx + dz * dz);
if (dist > radiusCells)
continue;
float falloff = 1f - (dist / radiusCells);
cells[x, z] += value * falloff;
}
}
}
// box blur: replace every cell with the average of itself and its neighbors
public void Blur()
{
float[,] result = new float[width, height];
for (int x = 0; x < width; x++)
{
for (int z = 0; z < height; z++)
{
float sum = cells[x, z];
int count = 1;
if (x > 0) { sum += cells[x - 1, z]; count++; }
if (x < width - 1) { sum += cells[x + 1, z]; count++; }
if (z > 0) { sum += cells[x, z - 1]; count++; }
if (z < height - 1) { sum += cells[x, z + 1]; count++; }
result[x, z] = sum / count;
}
}
cells = result;
}
public float GetValue(Vector3 worldPos)
{
Vector2Int c = WorldToCell(worldPos);
if (c.x < 0 || c.x >= width || c.y < 0 || c.y >= height)
return 0f;
return cells[c.x, c.y];
}
}
AddInfluence ไล่เยี่ยมทุก cell ที่อยู่ในระยะ radiusCells จากแหล่งกำเนิด แล้วเพิ่มค่าที่แรงที่สุดตรงระยะ 0 และจางลงจนเหลือ 0 พอดีที่ขอบของรัศมี — แนวคิด falloff เดียวกับระยะของแสงไฟ แค่เขียนมือลงใน grid แทนที่จะให้ lighting engine จัดการให้ Blur แทนที่ทุก cell ด้วยค่าเฉลี่ยของตัวมันเองกับเพื่อนบ้านสูงสุดสี่ตัว (น้อยกว่านั้นตรงขอบ grid ที่เพื่อนบ้านบางตัวไม่มีอยู่จริง) ซึ่งก็คือสิ่งที่ photo blur ธรรมดาทำกับพิกเซลนั่นเอง
ไล่ trace ดู grid ขนาด 5x5 เรียก AddInfluence หนึ่งครั้งที่ cell กึ่งกลางด้วย value = 10 และ radiusCells = 2 ก่อนและหลังผ่าน Blur() หนึ่งรอบ:
ยอดแหลม 10.0 ตรงกลางนุ่มลงเหลือ 6.0 และ cell ที่เดิมเป็น 0.0 พอดี (อย่างเพื่อนบ้านของมุม) ได้ค่าเล็ก ๆ ที่ไม่เป็นศูนย์เพิ่มมาหลังจากนั้น เพราะตอนนี้มันเฉลี่ย influence ของเพื่อนบ้านเข้ามาด้วยเล็กน้อย ถ้ารัน Blur() มากกว่าหนึ่งครั้ง field จะกระจายและนุ่มนวลขึ้นเรื่อย ๆ ในแต่ละรอบ แลกกับการวิ่ง grid เต็มรอบเพิ่มอีกหนึ่งครั้งทุกครั้งที่ทำ
Clear(), AddInfluence() ให้ทุกแหล่งกำเนิด และ Blur() ทุกเฟรมเดียวบน grid ขนาดใหญ่ นั่นคือ nested loop จำนวนมากที่รันตลอดเวลาเพื่อข้อมูลที่ปกติแล้วไม่ค่อยเปลี่ยนมากนักจากเฟรมสู่เฟรม วิธีแก้ทั่วไปคือ rebuild influence map ตาม timer (ไม่กี่ครั้งต่อวินาที) หรือกระจายการ rebuild ออกไปหลายเฟรม แทนที่จะทำใหม่ทุกครั้งใน Update()influence map จะมีประโยชน์ก็ต่อเมื่อมีอะไรมาถามคำถามกับมัน สองคำถามที่พบบ่อยที่สุดคือ "จุดไหนปลอดภัยพอจะซ่อนตัว" (cover) กับ "จุดไหนดีพอจะโจมตีโดยไม่ถูกเห็นว่ากำลังมา" (flanking position) ทั้งสองอย่างเอา influence map มารวมกับเครื่องมือสองตัวจากก่อนหน้านี้ในบทเรียน คือ raycast กับ dot product
Cover หมายถึงจุดที่ตอนนี้ threat มองไม่เห็น — เช็คด้วยแนวคิด raycast จาก section 4 เป๊ะ ๆ แค่ยิงจากตาของ threat ไปยังแต่ละจุดที่เป็นตัวเลือก แทนที่จะยิงจากตาของ agent เองไปยังเป้าหมาย:
public Vector3 FindBestCover(Vector3 selfPos, Vector3 threatEyePos,
List<Vector3> candidates, InfluenceMap dangerMap,
LayerMask obstacleMask)
{
Vector3 best = selfPos;
float bestScore = float.MaxValue;
foreach (Vector3 candidate in candidates)
{
Vector3 toCandidate = candidate - threatEyePos;
bool blocked = Physics.Raycast(threatEyePos, toCandidate.normalized,
toCandidate.magnitude, obstacleMask);
if (!blocked)
continue; // the threat could still see this spot -- not cover
float danger = dangerMap.GetValue(candidate);
float travelCost = Vector3.Distance(selfPos, candidate);
float score = danger * 10f + travelCost; // lower is better
if (score < bestScore)
{
bestScore = score;
best = candidate;
}
}
return best;
}
raycast ที่ยิงจากตาของ threat จะจับตัวเลือกที่กำลังถูกซ่อนอยู่ตอนนี้ได้ ซึ่งต่างจากการเช็คว่าตัวเลือกนั้นแค่อยู่ไกล หรืออยู่ใน cell ที่ danger ต่ำหรือเปล่า จุดหนึ่งอาจอยู่ติดกำแพงพอดีใน cell ที่ danger ต่ำ แต่ยังมองเห็นได้เต็ม ๆ ถ้าไม่มีรูปทรงจริง ๆ มาบัง line นี่คือเหตุผลเป๊ะ ๆ ว่าทำไมเช็ค raycast ต้องเกิดขึ้นก่อนที่จะใช้อะไรจาก influence map เลย
Flanking หมายถึงจุดที่อยู่ประมาณด้านข้างหรือด้านหลังของ threat ทำให้ agent เข้าหาจากนอก forward cone ของ threat เอง — เช็คด้วยการเปรียบเทียบ dot product แบบเดียวกับ section 3 เป๊ะ ๆ แค่เอาไปใช้กับทิศทาง forward ของ threat แทนที่จะเป็นของ agent เอง:
public Vector3 FindFlankPosition(Vector3 enemyPos, Vector3 enemyForward,
List<Vector3> candidates, InfluenceMap dangerMap)
{
Vector3 best = enemyPos;
float bestScore = float.MinValue;
foreach (Vector3 candidate in candidates)
{
Vector3 dirFromEnemy = (candidate - enemyPos).normalized;
float facingDot = Vector3.Dot(enemyForward, dirFromEnemy);
if (facingDot > 0.3f)
continue; // still inside the enemy's forward cone -- not a flank
float danger = dangerMap.GetValue(candidate);
float flankScore = -facingDot - danger; // reward "behind", penalize danger
if (flankScore > bestScore)
{
bestScore = flankScore;
best = candidate;
}
}
return best;
}
facingDot ใกล้ 1 หมายความว่าตัวเลือกอยู่ด้านหน้าศัตรู (สังเกตเห็นง่าย) ใกล้ 0 หมายความว่าอยู่ด้านข้างพอดี และใกล้ -1 หมายความว่าอยู่ด้านหลังพอดี การข้ามค่าที่มากกว่า 0.3 ทำให้ตัวเลือกส่วนใหญ่หลุดออกจาก forward cone ของศัตรู ในบรรดาที่เหลือ -facingDot ให้รางวัลตำแหน่งที่อยู่ด้านหลังมากกว่า และการลบ danger ออกก็ยังช่วยหลีกเลี่ยงจุดที่ influence map ทำเครื่องหมายไว้ว่าถูก threat ตัวอื่นจับตามองหนัก ๆ
FindBestCover เทียบกับตำแหน่งตาของ threat แค่ตัวเดียว ทั้งที่ศัตรูหลายตัวมองเห็นพื้นที่นั้นได้ จุดที่ซ่อนจากผู้โจมตีคนหนึ่งอาจมองเห็นเต็ม ๆ จากอีกคนที่ยืนอยู่ที่อื่นได้ เมื่อมี threat หลายตัว ให้เลือกอย่างใดอย่างหนึ่ง คือรันเช็ค raycast กับ threat ทุกตัวแล้วต้องให้ทุกตัวถูกบังหมด หรือรวม visibility ของทุก threat เข้าไปใน influence map เอง (ทุก cell ที่มองเห็นได้จะได้ danger เพิ่มจาก threat ตัวนั้น) เพื่อให้การ lookup GetValue ครั้งเดียวสะท้อนภาพรวมทั้งหมดอยู่แล้วpublic bool WithinAngle(Vector3 forward, Vector3 myPosition, Vector3 targetPosition, float viewAngleDegrees)
{
Vector3 toTarget = targetPosition - myPosition;
float cosHalfAngle = Mathf.Cos(viewAngleDegrees * 0.5f * Mathf.Deg2Rad);
float cosActual = Vector3.Dot(forward, toTarget);
return cosActual >= cosHalfAngle;
}
บั๊กคือ toTarget ไม่เคยถูก normalize ก่อนเอาไปทำ dot product เลย ทวนจากบทที่ 2.1: a . b = |a| * |b| * cos(theta) forward มีความยาว 1 แต่ toTarget มีความยาวเท่ากับระยะทางไปยังเป้าหมายพอดี ดังนั้น cosActual จริง ๆ แล้วเท่ากับ |toTarget| * cos(theta) ไม่ใช่ cos(theta) เฉย ๆ การเอาตัวเลขที่ถูกคูณด้วยระยะทางนี้ไปเทียบกับ cosine threshold คงที่จะทำงานถูกโดยบังเอิญแค่บางระยะ แล้วพังที่ระยะอื่น — ตรงกับอาการที่อธิบายไว้เป๊ะ ๆ (2 เมตรผ่าน 8 เมตรในทิศทางเดียวกันไม่ผ่าน เพราะมุมจริงเท่าเดิมแต่ให้ค่า cosActual สองค่าที่ต่างกันมาก)
public bool WithinAngle(Vector3 forward, Vector3 myPosition, Vector3 targetPosition, float viewAngleDegrees)
{
Vector3 dirToTarget = (targetPosition - myPosition).normalized;
float cosHalfAngle = Mathf.Cos(viewAngleDegrees * 0.5f * Mathf.Deg2Rad);
float cosActual = Vector3.Dot(forward, dirToTarget);
return cosActual >= cosHalfAngle;
}
การ normalize toTarget ก่อน ทำให้เวกเตอร์ทั้งสองตัวใน dot product มีความยาว 1 ดังนั้น cosActual จะเป็น cos(theta) พอดี ไม่ว่าเป้าหมายจะอยู่ไกลแค่ไหนก็ตาม ตรงกับสิ่งที่ cosHalfAngle คาดหวังว่าจะถูกเอาไปเทียบด้วย
PerceptionMemory ของศัตรูตัวหนึ่งมี decayPerSecond = 0.25 และ forgetThreshold = 0.1 percept ล่าสุดมาถึงที่ t = 10.0s ทำให้ Confidence = 1 ไม่มี percept ใหม่เข้ามาหลังจากนั้น คำนวณ Confidence ที่ t = 12.0s, t = 13.5s, และ t = 14.0s แล้วบอกว่าเวลาไหนบ้าง (ถ้ามี) ที่ memory ถือว่าถูกลืมไปแล้วConfidence จะลดลงแบบเชิงเส้นด้วย decayPerSecond ทุก ๆ วินาทีนับจาก percept ล่าสุด แล้วถูก clamp ให้เป็น 0 เมื่อมันตกต่ำกว่า forgetThreshold
memory จะอยู่รอดไปจนถึงประมาณ 3.6 วินาทีหลัง percept ล่าสุด (จาก 1 - 0.25*T = 0.1 ได้ T = 3.6) และจะถูกลืมสนิทตอน 4.0 วินาที นี่คือเหตุผลที่ decayPerSecond กับ forgetThreshold รวมกันแล้วจริง ๆ ก็แค่ปุ่มปรับ "ศัตรูตัวนี้จำเป้าหมายที่เสียไปได้กี่วินาที" — decayPerSecond เล็กลง หรือ forgetThreshold เล็กลง ทั้งคู่หมายความว่า memory อยู่นานขึ้น
bool IsBehindTarget(Vector3 targetForward, Vector3 targetPosition, Vector3 myPosition) ที่คืนค่า true ถ้า myPosition อยู่ครึ่งหลัง (ไม่ใช่ครึ่งหน้า) ของพื้นที่ เทียบกับ targetForward ใช้ dot product ห้ามใช้ Mathf.Acos หรือฟังก์ชันตรีโกณมิติใด ๆbool IsBehindTarget(Vector3 targetForward, Vector3 targetPosition, Vector3 myPosition)
{
Vector3 dirFromTarget = (myPosition - targetPosition).normalized;
float dot = Vector3.Dot(targetForward, dirFromTarget);
return dot < 0f;
}
อันนี้ใช้กฎเรื่องเครื่องหมายจากบทที่ 2.1 ตรง ๆ เลย คือ dot product ของเวกเตอร์ normalized สองตัวจะเป็นบวกเมื่อมุมระหว่างมันน้อยกว่า 90 องศา (ด้านหน้า) เป็นศูนย์พอดีที่ 90 องศา (ด้านข้างพอดี) และเป็นลบเมื่อมุมมากกว่า 90 องศา (ด้านหลัง) การเช็ค dot < 0 ก็คือ "มุมมากกว่า 90 องศาไหม" โดยไม่ต้องคำนวณมุมจริง ๆ เลย เป็นทางลัดเดียวกับที่ section 3 ใช้กับทั้ง view cone ทั้งหมด แค่ตีบให้เหลือแค่การแบ่งหน้า/หลังธรรมดา