11.3 การตัดสินใจ (Behavior Tree, FSM, GOAP, Utility)

เฟส 11 · AI สำหรับเกม · เวลาเรียน: 25–45 h

agent เลือกว่าจะทำอะไรยังไง — finite state machine, behavior tree (มาตรฐานวงการ), goal-oriented planning และ utility scoring

บทนี้พูดถึงวิธีที่ game AI agent ตัดสินใจว่าจะทำอะไร บทก่อนหน้าสอนให้เรารู้วิธีขยับวัตถุให้ลื่นไหล และวิธีจัดระเบียบโค้ดเกมด้วย component, event และ State pattern แต่สิ่งเหล่านั้นยังตอบคำถามจริง ๆ ที่ดีไซเนอร์ถามเวลาเห็นศัตรูทำอะไรแปลก ๆ ไม่ได้ นั่นคือ "ทำไมมันเพิ่งทำแบบนั้น" บทนี้จะพาไปดู 4 วิธีตอบคำถามนั้น เรียงจากง่ายสุดไปยืดหยุ่นสุด ได้แก่ Finite State Machine (FSM), Behavior Tree (BT), Goal-Oriented Action Planning (GOAP) และ Utility AI เราจะเขียน C# จริงสำหรับสองอันแรก เข้าใจแนวคิดพร้อมดูตัวอย่างที่คลี่ทีละขั้นสำหรับสองอันหลัง แล้วจบด้วยการรู้ว่าควรหยิบเครื่องมือไหนมาใช้ในโปรเจกต์จริง

1. Game AI Agent ตัดสินใจอย่างไรว่าจะทำอะไร

ไม่ว่า game AI จะถูกสร้างมาแบบไหน มันวนลูป 3 ขั้นตอนเดิมทุกเฟรม คือ มองดูโลกรอบตัว (perception — การรับรู้), ตัดสินใจว่าจะทำอะไรกับสิ่งที่เห็น (decision-making — การตัดสินใจ), แล้วลงมือทำจริง (action — การกระทำ) เรารู้วิธีเขียนส่วน "action" อยู่แล้ว ไม่ว่าจะเป็นการขยับ Transform เล่นแอนิเมชัน หรือยิงกระสุน บทนี้จะพูดถึงขั้นตอนกลางเพียงอย่างเดียว คือส่วนของโค้ดที่เลือกว่าจะรัน action ไหน

+------------+ +--------------+ +------------+ | Perception | ---> | Decision- | ---> | Action | | เห็นผู้เล่น | | making | | เดิน, เล็ง,| | ไหม? | | (FSM / BT / | | โจมตี, | | เลือดเหลือ | | GOAP / | | เล่นแอนิ | | เท่าไหร่? | | Utility AI) | | เมชัน | +------------+ +--------------+ +------------+ ^ | +------------------------------------------+ action เปลี่ยนแปลงโลก ทำให้ perception ของ เฟรมถัดไปอ่านเจอสิ่งใหม่

"ระบบตัดสินใจ" ก็คือส่วนของโค้ดที่แปลง perception ให้กลายเป็นการเลือก action นั่นเอง ระบบทั้งสี่ในบทนี้เป็นสี่วิธีที่ต่างกันในการเขียนโค้ดส่วนนั้น มันไม่ใช่ "ยี่ห้อ" AI ที่แข่งกันทำงานเดียวกัน แต่แต่ละอันแก้ปัญหาการตัดสินใจด้วยข้อแลกเปลี่ยน (trade-off) ที่ต่างกัน และเกมจริงมักผสมหลายอันเข้าด้วยกัน:

2. Finite State Machine: State และ Transition

finite state machine (FSM) คือระบบที่อยู่ใน state (สถานะที่มีชื่อเรียก) ได้แค่หนึ่งอันเสมอ จากชุด state ที่กำหนดไว้ตายตัว พร้อมกฎชัดเจน — เรียกว่า transition (การเปลี่ยนสถานะ) — สำหรับสลับจาก state หนึ่งไปอีก state หนึ่ง เราเคยเจอรูปแบบนี้มาแล้วในชื่อ "State pattern" ตอนจัดระเบียบโค้ดเกม ที่นี่เราใช้แนวคิดเดียวกันเป๊ะ ๆ แต่เจาะจงมาเพื่อตัดสินใจพฤติกรรมศัตรู

ศัตรูแบบคลาสสิกมี 3 state คือ Patrol (เดินตามเส้นทางที่กำหนด), Chase (วิ่งเข้าหาผู้เล่น) และ Attack (โจมตีระยะประชิด) กฎการสลับระหว่างกันมีดังนี้:

Patrol --(เห็นผู้เล่น)---------> Chase Chase --(เป้าหมายอยู่ในระยะโจมตี)--> Attack Chase --(มองไม่เห็นผู้เล่นแล้ว)-----> Patrol Attack --(เป้าหมายออกนอกระยะ)--------> Chase

มีแค่ 4 transition เชื่อม 3 state และแต่ละอันอธิบายด้วยปากได้ง่ายมาก เช่น "ถ้าเห็นผู้เล่นก็ควรไล่ล่า ถ้าเข้าใกล้พอก็ควรโจมตี" ความชัดเจนแบบนี้แหละคือจุดขายทั้งหมดของ FSM คุณชี้ไปที่บรรทัดพฤติกรรมไหนก็อธิบายได้ทันทีว่ามันทำงานตอนไหน

3. เขียน FSM ด้วย C#

การเขียน FSM แบบสะอาดคือให้แต่ละ state มีคลาสเล็ก ๆ ของตัวเอง แทนที่จะยัดทุกอย่างไว้ในเมธอดยักษ์เดียวที่เต็มไปด้วย if แต่ละ state ต้องรู้ว่าเมื่อเริ่มต้น (Enter) ต้องทำอะไร และทุกเฟรมที่ยัง active อยู่ (Tick) ต้องทำอะไร:


public interface IEnemyState
{
    void Enter(EnemyAI enemy);
    void Tick(EnemyAI enemy);
}

แต่ละ state ที่เป็นรูปธรรมจะ implement interface นี้ แล้วตัดสินใจข้างใน Tick ว่าถึงเวลาสลับไป state อื่นหรือยัง:


using UnityEngine;

public class PatrolState : IEnemyState
{
    public void Enter(EnemyAI enemy)
    {
        Debug.Log("Enter Patrol");
    }

    public void Tick(EnemyAI enemy)
    {
        enemy.MoveAlongPatrolRoute();

        if (enemy.CanSeePlayer())
        {
            enemy.ChangeState(new ChaseState());
        }
    }
}

public class ChaseState : IEnemyState
{
    public void Enter(EnemyAI enemy)
    {
        Debug.Log("Enter Chase");
    }

    public void Tick(EnemyAI enemy)
    {
        enemy.MoveTowardPlayer();

        if (enemy.DistanceToPlayer() < enemy.attackRange)
        {
            enemy.ChangeState(new AttackState());
        }
        else if (!enemy.CanSeePlayer())
        {
            enemy.ChangeState(new PatrolState());
        }
    }
}

public class AttackState : IEnemyState
{
    public void Enter(EnemyAI enemy)
    {
        Debug.Log("Enter Attack");
    }

    public void Tick(EnemyAI enemy)
    {
        enemy.AttackPlayer();

        if (enemy.DistanceToPlayer() >= enemy.attackRange)
        {
            enemy.ChangeState(new ChaseState());
        }
    }
}

คอมโพเนนต์ EnemyAI แค่เก็บ state ปัจจุบันไว้ แล้วส่งการควบคุมให้มันทุกเฟรม:


using UnityEngine;

public class EnemyAI : MonoBehaviour
{
    public float attackRange = 2f;
    private IEnemyState currentState;

    void Start()
    {
        ChangeState(new PatrolState());
    }

    void Update()
    {
        currentState.Tick(this);
    }

    public void ChangeState(IEnemyState newState)
    {
        currentState = newState;
        currentState.Enter(this);
    }

    public bool CanSeePlayer() { /* raycast toward the player */ return false; }
    public float DistanceToPlayer() { /* Vector3.Distance to the player */ return 999f; }
    public void MoveAlongPatrolRoute() { /* walk to the next waypoint */ }
    public void MoveTowardPlayer() { /* walk toward the player */ }
    public void AttackPlayer() { /* deal damage on a cooldown */ }
}

ผลลัพธ์ที่คาดไว้: ตอนศัตรูเกิด คอนโซลจะพิมพ์ Enter Patrol พอ CanSeePlayer() คืนค่า true จะพิมพ์ Enter Chase และพอ DistanceToPlayer() ต่ำกว่า attackRange จะพิมพ์ Enter Attack ทุกครั้งที่เรียก ChangeState จะมีการสร้าง state object ใหม่ ดังนั้น field ที่เก็บข้อมูลเฉพาะของ state นั้น (เช่น timer หรือตำแหน่งเป้าหมาย) จะเริ่มต้นใหม่เสมอ

ข้อผิดพลาดที่พบบ่อย การแคช instance เดียวของแต่ละ state ไว้ใช้ร่วมกัน (เช่น ChaseState object เดียวที่ศัตรูทุกตัวใช้ร่วมกัน) เพื่อ "ประหยัด allocation" ถ้า state นั้นมีการเก็บข้อมูลเฉพาะรอบการใช้งาน เช่น timer หรือตำแหน่งที่จำไว้ ข้อมูลนั้นจะรั่วไหลข้ามระหว่างศัตรูคนละตัว หรือระหว่างการเข้า state เดิมคนละรอบ เพราะทุกตัวแอบใช้ object เดียวกันอยู่ การสร้าง state object เล็ก ๆ ใหม่ทุกครั้งที่ transition แบบในตัวอย่างข้างบน มีต้นทุนต่ำและหลีกเลี่ยงบั๊กแบบนี้ได้ทั้งหมด

4. ทำไม FSM ใหญ่ ๆ ถึงกลายเป็นสปาเก็ตตี้

3 state กับ 4 transition คิดตามได้ง่าย แต่ศัตรูจริง ๆ ในที่สุดก็ต้องการมากกว่านี้ เช่น Flee (หนี) เมื่อเลือดต่ำ, Search (ค้นหา) เมื่อเคยเห็นผู้เล่นแต่ตอนนี้หายไป, Stunned (มึนงง) หลังโดนโจมตีหนัก, Dead (ตาย) 6 state ไม่ได้ต้องการแค่ 6 transition แต่ต้องการมากถึง 6 × 5 = 30 transition ที่เป็นไปได้ เพราะโดยหลักการแล้ว state ไหนก็อาจต้องขัดจังหวะ state อื่นได้ทั้งนั้น:

Patrol --> Chase Chase --> Attack Chase --> Search Patrol --> Stunned Attack --> Flee Search --> Chase Chase --> Flee Attack --> Stunned Search --> Patrol Flee --> Stunned Flee --> Search Stunned --> Patrol Attack --> Search Flee --> Patrol Stunned --> Chase ... และทุก state ก็ต้องไปถึง Dead ได้ตลอดเวลาด้วย 6 state, transition ที่เป็นไปได้สูงสุด 30 อัน ทุกครั้งที่เพิ่ม state ใหม่ ต้องเอาไปเช็คกับ state เดิมทุกตัวว่า "จาก state นี้ไปถึง state X ได้ไหม? โดนตีหนักควรขัดจังหวะ Chase ไหม? Attack? Flee?"

ปัญหาไม่ใช่แค่การนับลูกศรเท่านั้น แต่คือ state class แต่ละอันจบลงด้วยการต้องรู้จัก state อื่น ๆ ทุกตัวที่อาจมาขัดจังหวะตัวเอง:


// FleeState now has to know about every other state that
// might need to interrupt fleeing -- coupling grows with
// every new feature anyone adds to the game.
public class FleeState : IEnemyState
{
    public void Tick(EnemyAI enemy)
    {
        if (enemy.Health <= 0)
        {
            enemy.ChangeState(new DeadState());
        }
        else if (enemy.IsCornered())
        {
            enemy.ChangeState(new StunnedState());
        }
        else if (enemy.Health > enemy.fleeThreshold)
        {
            enemy.ChangeState(new PatrolState());
        }
        else if (enemy.CanSeePlayer() && enemy.HasBackup())
        {
            enemy.ChangeState(new ChaseState());
        }
        // ...one more line for every future state, forever.
    }
}

นี่คือปัญหาที่รู้กันดีของ FSM เมื่อขยายสเกล คือจำนวน transition ที่ต้องคิดถึงจะโตขึ้นประมาณกำลังสองของจำนวน state, state class แต่ละอันเชื่อมโยงแน่นกับ class อื่นจำนวนมาก และเงื่อนไขตรวจสอบแบบเดิม ๆ (ศัตรูตายไหม? มึนงงไหม? กระสุนหมดไหม?) ก็ถูกก๊อปวางซ้ำ ๆ ในทุก state นี่คือปัญหาโค้ดพันกันจนตามยาก ที่เรียกว่า spaghetti code (สปาเก็ตตี้โค้ด) จากบทเรื่อง gameplay architecture นั่นเอง แต่คราวนี้มันเกิดขึ้นข้างใน AI ของคุณ นี่ไม่ใช่สัญญาณว่าคุณเขียน FSM แย่ แต่มันคือข้อจำกัดเชิงโครงสร้างของรูปแบบ FSM เอง และนี่คือเหตุผลว่าทำไมเครื่องมือถัดไปถึงมีอยู่

5. Behavior Tree: Node และ Tick

behavior tree (BT) คือต้นไม้ของ node เล็ก ๆ ที่ถูกประเมินจากบนลงล่าง ซ้ายไปขวา ครั้งละหนึ่งเฟรม — การประเมินหนึ่งรอบนี้เรียกว่า tick ทุก node จะคืนค่าผลลัพธ์อย่างใดอย่างหนึ่งจากสามแบบเมื่อถูก tick:


public enum NodeStatus
{
    Success,
    Failure,
    Running
}

public interface IBTNode
{
    NodeStatus Tick();
}

node มีสองตระกูล Composite node มีลูก (children) และรวมผลลัพธ์ของลูกเข้าด้วยกัน ส่วน leaf node (node ใบ) ทำหน้าที่เช็คหรือลงมือทำจริง ๆ และไม่มีลูก composite node สองตัวที่ต้องรู้จักก่อนคือ:

Sequence (เช็คลิสต์ / AND) Selector (ลำดับความสำคัญ / OR) หยุดที่ FAILURE ตัวแรก หยุดที่ SUCCESS ตัวแรก Sequence Selector +-- A +-- A +-- B +-- B +-- C +-- C A=Success, B=Success, C=Success A=Failure, B=Success -> Sequence คืนค่า Success -> Selector หยุดที่ B, A=Success, B=Failure คืนค่า Success (C ไม่ถูก tick) -> Sequence หยุดที่ B, A=Failure, B=Failure, C=Failure คืนค่า Failure (C ไม่ถูก tick) -> Selector คืนค่า Failure

สังเกตว่า composite ทั้งสองแบบ short-circuit (หยุดกลางคันได้): Sequence จะไม่ tick ลูกตัวถัดไปหลังเจอ failure ตัวแรก และ Selector จะไม่ tick ลูกตัวถัดไปหลังเจอ success ตัวแรก พฤติกรรม "หยุดเร็ว" แบบนี้แหละที่ทำให้ต้นไม้แสดงลำดับความสำคัญได้ กิ่งซ้ายสุดของ Selector จะถูกลองก่อนเสมอ และจะดูตัวเลือกถัดไปก็ต่อเมื่อกิ่งนั้น fail เท่านั้น

6. สร้าง Behavior Tree Framework ด้วย C#

Composite node: Sequence กับ Selector


public class Sequence : IBTNode
{
    private readonly IBTNode[] children;

    public Sequence(params IBTNode[] children)
    {
        this.children = children;
    }

    public NodeStatus Tick()
    {
        foreach (var child in children)
        {
            NodeStatus status = child.Tick();
            if (status != NodeStatus.Success)
            {
                return status; // Failure or Running: stop here
            }
        }
        return NodeStatus.Success; // every child succeeded
    }
}

public class Selector : IBTNode
{
    private readonly IBTNode[] children;

    public Selector(params IBTNode[] children)
    {
        this.children = children;
    }

    public NodeStatus Tick()
    {
        foreach (var child in children)
        {
            NodeStatus status = child.Tick();
            if (status != NodeStatus.Failure)
            {
                return status; // Success or Running: stop here
            }
        }
        return NodeStatus.Failure; // every child failed
    }
}

Leaf node: Condition กับ Action

Condition node เช็คบางอย่างเกี่ยวกับโลกแล้วคืนค่า Success หรือ Failure ทันที — ไม่มีทางเป็น Running ส่วน Action node ลงมือทำจริง ๆ และสามารถคืนค่า Running ได้ระหว่างที่ action ยังไม่เสร็จ:


using System;

public class ConditionNode : IBTNode
{
    private readonly Func<bool> condition;

    public ConditionNode(Func<bool> condition)
    {
        this.condition = condition;
    }

    public NodeStatus Tick()
    {
        return condition() ? NodeStatus.Success : NodeStatus.Failure;
    }
}

public class ActionNode : IBTNode
{
    private readonly Func<NodeStatus> action;

    public ActionNode(Func<NodeStatus> action)
    {
        this.action = action;
    }

    public NodeStatus Tick()
    {
        return action();
    }
}

เท่านี้ก็ได้ framework ครบแล้ว composite สองแบบ leaf สองแบบ และสัญญาการทำงาน Tick() ที่ใช้ร่วมกันหนึ่งอัน behavior tree ทุกต้นที่คุณจะสร้างต่อจากนี้ ไม่ว่าจะใหญ่แค่ไหน ก็ประกอบขึ้นจากชิ้นส่วนพื้นฐานสี่แบบนี้เท่านั้น

Tip เกมที่ใช้งานจริงมักไม่นั่งเขียนโครงแบบนี้เอง Unreal Engine มี Behavior Tree editor ในตัว ส่วน Unity มีทั้งแพ็กเกจทางการ com.unity.behavior และเครื่องมือจากภายนอกที่ได้รับความนิยม (Behavior Designer, NodeCanvas) ที่มาพร้อม node graph แบบมองเห็นภาพ การเรียนรู้เวอร์ชัน C# ล้วน ๆ ก่อนแบบนี้แหละที่ทำให้เครื่องมือแบบภาพเหล่านั้นเข้าใจง่ายขึ้น เพราะมันแค่วาดต้นไม้แบบเดียวกับที่คุณกำลังจะสร้างในโค้ดนั่นเอง

7. Behavior Tree ฉบับเต็ม: Patrol, Chase, Attack

นี่คือศัตรูตัวเดิมจากข้อ 3 — Patrol, Chase, Attack — แต่สร้างใหม่เป็น behavior tree แทน FSM เพื่อให้เทียบกันได้โดยตรง:

Selector (root) +-- Sequence | +-- Condition: CanSeePlayer? | +-- Selector | +-- Sequence | | +-- Condition: InAttackRange? | | +-- Action: Attack | +-- Action: Chase +-- Action: Patrol อ่านจากบนลงล่าง กิ่งซ้ายก่อน: ลอง "เห็นผู้เล่นไหม แล้ว (โจมตีหรือไล่ล่า)" ก่อนที่จะตกลงไปที่ Patrol ธรรมดา

อ่านต้นไม้นี้แบบเดียวกับที่อ่านความหมายของ Selector ในข้อ 5 คือลองกิ่งซ้ายสุดก่อน แล้วค่อยตกไปกิ่งถัดไปเมื่อกิ่งนั้น fail เท่านั้น


using UnityEngine;

public class EnemyAI : MonoBehaviour
{
    public float attackRange = 2f;
    private IBTNode root;

    void Start()
    {
        root = new Selector(
            new Sequence(
                new ConditionNode(CanSeePlayer),
                new Selector(
                    new Sequence(
                        new ConditionNode(InAttackRange),
                        new ActionNode(AttackPlayer)
                    ),
                    new ActionNode(ChasePlayer)
                )
            ),
            new ActionNode(Patrol)
        );
    }

    void Update()
    {
        root.Tick();
    }

    bool CanSeePlayer() { /* raycast toward the player */ return false; }
    bool InAttackRange() { /* distance check against attackRange */ return false; }

    NodeStatus AttackPlayer()
    {
        Debug.Log("Attack!");
        return NodeStatus.Running; // the attack animation is still playing
    }

    NodeStatus ChasePlayer()
    {
        Debug.Log("Chasing...");
        return NodeStatus.Running; // still closing the distance
    }

    NodeStatus Patrol()
    {
        Debug.Log("Patrolling...");
        return NodeStatus.Running; // walking to the next waypoint
    }
}

ผลลัพธ์ที่คาดไว้ ไล่ทีละขั้นด้วยมือสำหรับสามสถานการณ์:

เทียบกับ FSM ในข้อ 3: พฤติกรรมสามแบบเดียวกัน กฎเดียวกัน แต่ที่นี่ไม่มีอะไรถูกเขียนตายตัวว่า "state X ไปที่ state Y" ลำดับความสำคัญและตรรกะสำรองอยู่ในรูปทรงของต้นไม้ทั้งหมด

8. ทำไม Behavior Tree ถึงขยายสเกลได้ดีกว่า FSM

ย้อนกลับไปดูปัญหา Flee/Search/Stunned/Dead จากข้อ 4 แล้วลองจินตนาการว่ามันเป็นต้นไม้แทน การเพิ่ม "หนีเมื่อเลือดต่ำ" หมายถึงการเพิ่มกิ่ง Sequence ใหม่หนึ่งอันไว้ใกล้ด้านบนของ Selector:


root = new Selector(
    new Sequence(new ConditionNode(IsCritical), new ActionNode(Flee)),   // new
    new Sequence(new ConditionNode(CanSeePlayer), /* chase/attack subtree */ null),
    new ActionNode(Patrol)
);

ข้างในกิ่งย่อย chase/attack ไม่มีอะไรเปลี่ยนเลย PatrolState, ChaseState และ AttackState ไม่ต้องเพิ่มบรรทัดใหม่แม้แต่บรรทัดเดียวเพื่อเช็คว่า "ตอนนี้ศัตรูกำลังหนีอยู่หรือเปล่า" — โครงสร้างต้นไม้เองรับประกันอยู่แล้วว่ากิ่ง Flee จะถูกเช็คก่อนเสมอ และถ้ามัน fail (เลือดยังปกติ) ทุกอย่างข้างใต้ก็ทำงานเหมือนเดิมทุกประการ นี่คือเหตุผลหลักที่ behavior tree ขยายสเกลได้ดีกว่า FSM:

Tip behavior tree ก็ไม่ได้ปราศจากข้อเสีย ต้นไม้ที่จัดระเบียบไม่ดีและมีเป็นร้อย node ก็ยังตามอ่านยากอยู่ดี และการดีบักส่วนใหญ่หมายถึงการดูต้นไม้ทำงานสด ๆ ผ่าน visual debugger มากกว่าการอ่านโค้ด แต่รูปแบบความล้มเหลวคือ "ต้นไม้ใหญ่ต้นนี้อ่านยาก" ไม่ใช่ "แก้ไฟล์นี้แล้วพฤติกรรมที่ไม่เกี่ยวข้องกันสามอย่างพัง" ซึ่งเป็นปัญหาที่คมกว่าที่ FSM เจอ

9. GOAP: Goal-Oriented Action Planning

ทั้ง FSM และ behavior tree ต้องการให้คนมานั่งเขียนทุกเส้นทางที่ agent เดินได้ด้วยมือ GOAP (Goal-Oriented Action Planning) ใช้วิธีที่ต่างออกไป แทนที่จะเขียนเส้นทางเอง คุณแค่บรรยาย action แต่ละตัวที่ agent ทำได้ แล้วให้ planner ค้นหาตอนรันจริงว่าลำดับ action แบบไหนพาไปถึง goal (เป้าหมาย) ได้ นี่คือเทคนิคที่ทหารในเกมยิงปี 2005 อย่าง F.E.A.R. ทำให้โด่งดัง แทนที่จะสคริปต์ตายตัวว่า "หลบก่อน แล้วรีโหลด แล้วยิง" เกมแค่บอกเป้าหมาย ("ฆ่าเป้าหมาย") แล้วปล่อยให้ทหารแต่ละคนคิดแผนของตัวเองจาก action ที่มีอยู่และเป็นประโยชน์ในตอนนั้น

Action: Precondition กับ Effect

แต่ละ action มี precondition (เงื่อนไขที่โลกต้องเป็นจริงอยู่แล้วก่อน action นี้จะทำได้) และ effect (สิ่งที่จะกลายเป็นจริงในโลกเมื่อ action ทำเสร็จ) บวกกับ cost (ต้นทุน) ที่ planner จะพยายามทำให้น้อยที่สุด:


using System.Collections.Generic;

public class GoapAction
{
    public string Name;
    public float Cost = 1f;
    public Dictionary<string, bool> Preconditions = new Dictionary<string, bool>();
    public Dictionary<string, bool> Effects = new Dictionary<string, bool>();
}

GoapAction moveToCover = new GoapAction
{
    Name = "MoveToCover",
    Cost = 1f,
    // no preconditions -- the agent can always try to move to cover
    Effects = { ["InCover"] = true }
};

GoapAction reload = new GoapAction
{
    Name = "Reload",
    Cost = 1f,
    Preconditions = { ["AmmoInReserve"] = true },
    Effects = { ["HasAmmo"] = true }
};

GoapAction attackFromCover = new GoapAction
{
    Name = "AttackFromCover",
    Cost = 2f,
    Preconditions = { ["InCover"] = true, ["HasAmmo"] = true },
    Effects = { ["EnemyDead"] = true }
};

Planner: ค้นหาแผน

เมื่อมี world state เริ่มต้นกับ world state เป้าหมาย planner จะค้นหาย้อนกลับ: "effect ของ action ไหนที่ทำให้เป้าหมายเป็นจริงได้บ้าง? action นั้นต้องการ precondition อะไร? ไล่ย้อนกลับแบบนี้ไปเรื่อย ๆ จนกว่าทุกความต้องการที่เหลือจะเป็นจริงอยู่แล้ว ณ ตอนนี้" นี่คือการค้นหาแบบกราฟชนิดเดียวกับ pathfinding เพียงแต่ "แผนที่" ทำจาก world state แทนที่จะเป็นตำแหน่งจริง และ "ถนน" ระหว่างกันคือ action:

Goal: EnemyDead = true Start: HasAmmo=false, InCover=false, AmmoInReserve=true, EnemyDead=false [HasAmmo=false, InCover=false] | MoveToCover (cost 1) v [HasAmmo=false, InCover=true] | Reload (cost 1) v [HasAmmo=true, InCover=true] | AttackFromCover (cost 2) v [EnemyDead=true] -- ถึงเป้าหมาย ต้นทุนรวมแผน = 4 planner จะลองลำดับอื่นด้วย (เช่น Reload ก่อน MoveToCover) แล้วเก็บเส้นทางที่ถูกต้องและต้นทุนต่ำสุดไว้

// Conceptual pseudocode for how the plan gets used once found.
// Real planners run an A*-style search with a priority queue --
// this sketch shows the idea, not a production implementation.
WorldState goal = new WorldState { EnemyDead = true };
WorldState current = agent.GetCurrentWorldState();

List<GoapAction> plan = Planner.FindPlan(current, goal, availableActions);

foreach (GoapAction step in plan)
{
    Debug.Log("Plan step: " + step.Name);
}
// Prints, for the example above:
//   Plan step: MoveToCover
//   Plan step: Reload
//   Plan step: AttackFromCover

ผลตอบแทนคือความสามารถในการปรับตัว ถ้าโลกเปลี่ยนกลางแผน (ที่กำบังถูกทำลาย กระสุนหมดที่อื่น) agent ก็แค่วางแผนใหม่จาก world state ปัจจุบันของมัน ไม่มีใครต้องนั่งเขียนกฎเฉพาะสำหรับสถานการณ์นั้น เพราะ planner ค้นหาใหม่ทุกครั้งที่ถูกเรียกใช้

ข้อผิดพลาดที่พบบ่อย การเพิ่ม action จำนวนมากโดยตั้งค่า cost แบบหลวม ๆ แล้วเชื่อว่า planner จะ "ดูฉลาดเอง" การค้นหาทำการ optimize แค่ต้นทุนรวมต่ำสุดเท่านั้น ถ้าไอเทมฟื้นฟูเลือดที่อยู่ไกลบังเอิญมีตัวเลข cost ต่ำกว่าอันที่อยู่ใกล้ agent ก็จะเดินผ่านอันที่ใกล้ไปเฉย ๆ แล้วมันจะดูเหมือนบั๊ก ทั้งที่ planner ทำตามที่สั่งไว้เป๊ะ ๆ GOAP ต้องการการปรับจูน cost อย่างระมัดระวัง และแผนที่ได้ก็ทำนายและดีบักยากกว่าต้นไม้ที่เขียนด้วยมือ นี่คือเหตุผลว่าทำไมมันถึงพบเห็นบ่อยในเกมทุนสูงหรือเกมที่มีระบบซับซ้อนลึก มากกว่าโปรเจกต์เล็ก ๆ

10. Utility AI: ให้คะแนนทุกตัวเลือก

Utility AI ข้ามทั้งต้นไม้และการวางแผนไปเลย action ที่เป็นไปได้ทุกตัวจะได้ score (คะแนน หรือเรียกอีกอย่างว่า utility) เป็นตัวเลข คำนวณจากสถานการณ์ปัจจุบัน โดยใช้ scoring function (ฟังก์ชันให้คะแนน) เล็ก ๆ ต่อ action หนึ่งตัว แล้ว agent ก็แค่รัน action ที่คะแนนสูงสุดในเฟรมนั้น มันคือเครื่องมือที่ถูกต้องเมื่อตัวเลือกที่ "ถูกต้อง" ขึ้นอยู่กับการผสมปัจจัยต่อเนื่องหลายอย่างพร้อมกันจริง ๆ ไม่ใช่กฎ yes/no ที่ชัดเจน


public interface IUtilityAction
{
    string Name { get; }
    float Score(EnemyAI enemy);
    void Execute(EnemyAI enemy);
}

public class FleeAction : IUtilityAction
{
    public string Name => "Flee";

    public float Score(EnemyAI enemy)
    {
        // Full health never wants to flee; near-death wants it badly.
        float missingHealthFraction = 1f - (enemy.Health / enemy.MaxHealth);
        return missingHealthFraction * 10f; // ranges 0 .. 10
    }

    public void Execute(EnemyAI enemy) { enemy.RunAwayFromPlayer(); }
}

public class AttackAction : IUtilityAction
{
    public string Name => "Attack";

    public float Score(EnemyAI enemy)
    {
        if (enemy.Ammo <= 0) return 0f; // can't attack with no ammo

        float closeness = Mathf.Clamp01(1f - enemy.DistanceToPlayer() / enemy.MaxAttackDistance);
        return closeness * 6f; // ranges 0 .. 6, higher when the player is close
    }

    public void Execute(EnemyAI enemy) { enemy.AttackPlayer(); }
}

public class PatrolAction : IUtilityAction
{
    public string Name => "Patrol";

    public float Score(EnemyAI enemy) => 1f; // a low, constant fallback

    public void Execute(EnemyAI enemy) { enemy.MoveAlongPatrolRoute(); }
}

using System.Collections.Generic;
using UnityEngine;

public class UtilityAI : MonoBehaviour
{
    public EnemyAI enemy;
    private List<IUtilityAction> actions;

    void Awake()
    {
        actions = new List<IUtilityAction>
        {
            new FleeAction(),
            new AttackAction(),
            new PatrolAction()
        };
    }

    void Update()
    {
        IUtilityAction best = null;
        float bestScore = float.MinValue;

        foreach (var action in actions)
        {
            float score = action.Score(enemy);
            if (score > bestScore)
            {
                bestScore = score;
                best = action;
            }
        }

        best.Execute(enemy);
    }
}

ไล่คำนวณทีละขั้น: สมมติ Health = 20, MaxHealth = 100, Ammo = 3, DistanceToPlayer = 4, MaxAttackDistance = 10:

Flee : (1 - 20/100) * 10 = 0.8 * 10 = 8.0 Attack : ammo > 0, closeness = clamp01(1 - 4/10) = 0.6, 0.6 * 6 = 3.6 Patrol : constant = 1.0 คะแนนของเฟรมนี้: Flee [########################] 8.0 <- สูงสุด ชนะ Attack [#############] 3.6 Patrol [####] 1.0

Flee ชนะ ทั้งที่ผู้เล่นอยู่ในระยะโจมตีสบาย ๆ และศัตรูยังมีกระสุน เพราะเลือดเหลือน้อยวิกฤต นี่คือความละเอียดอ่อนแบบ "ขึ้นอยู่กับหลายอย่างพร้อมกัน" ที่ FSM จะต้องเพิ่มกฎพิเศษเข้าไปอีกอัน และ behavior tree จะต้องเพิ่ม Condition node อีกตัว ส่วน Utility AI ได้สิ่งนี้มาฟรี เพราะ score function ผสมทุกปัจจัยเข้าด้วยกันอยู่แล้ว

Tip รักษาคะแนนของทุก action ให้อยู่ในสเกลที่เทียบกันได้ (ในตัวอย่างนี้ประมาณ 0..10) เพื่อไม่ให้ action ตัวใดตัวหนึ่งครองคะแนนอย่างไม่เป็นธรรมแค่เพราะสูตรของมันบังเอิญให้ตัวเลขดิบที่ใหญ่กว่า ระบบ utility AI ส่วนใหญ่จะ normalize ปัจจัยการให้คะแนนทุกอันให้อยู่ในช่วง 0..1 แล้วค่อยคูณด้วยน้ำหนัก (weight) ต่อปัจจัย ดังนั้นการปรับจูนก็แค่ปรับน้ำหนัก ไม่ต้องเขียนสูตรใหม่

11. เลือกระหว่าง FSM, BT, GOAP และ Utility AI

ไม่มีเครื่องมือไหนใน 4 อันนี้ที่ "ดีกว่า" อีกอันแบบเด็ดขาด แต่ละอันเหมาะกับรูปแบบปัญหาการตัดสินใจที่ต่างกัน:

กฎง่าย ๆ ที่ใช้งานได้จริง: เริ่มจากเครื่องมือที่เรียบง่ายที่สุดที่เหมาะกับงานจริง ๆ — มักเป็น FSM สำหรับโปรโตไทป์เล็ก ๆ — แล้วค่อยย้ายไป behavior tree ทันทีที่รู้สึกว่ากำลังสู้กับปัญหาตาราง transition จากข้อ 4 หยิบ GOAP หรือ Utility AI มาใช้ก็ต่อเมื่อมีการตัดสินใจที่เป็นรูปธรรมซึ่งเครื่องมือที่ง่ายกว่าแสดงออกมาให้ชัดเจนไม่ได้จริง ๆ ไม่ใช่เพราะมันฟังดูล้ำกว่า

12. ผสมผสานเทคนิค: ตัวอย่างไฮบริดที่ใช้งานจริง

เกมที่วางขายจริงแทบไม่เคยใช้แค่อันใดอันหนึ่งแบบโดด ๆ วิธีผสมที่พบบ่อยและใช้งานได้จริงคือ ใช้ Utility AI (หรือตัวเลือก goal แบบ GOAP เบา ๆ) หนึ่งครั้งในระดับหยาบเพื่อเลือกเป้าหมายระดับสูง แล้วส่งเป้าหมายนั้นให้ behavior tree ที่รู้วิธีลงมือทำทีละขั้น:

UtilityAI.Update() ให้คะแนนเป้าหมายระดับสูงในเฟรมนี้: ConsiderAttack() score 3 ConsiderDefend() score 7 <- ถูกเลือก ConsiderFlee() score 2 เป้าหมายที่ถูกเลือก: Defend | v BehaviorTree สำหรับเป้าหมาย "Defend" ทำงานตามปกติ: Selector Sequence( EnemyInMeleeRange?, BlockAction ) Sequence( HasShieldCharge?, RaiseShieldAction ) RetreatToCoverAction

Utility AI เก่งเรื่องคำถามหยาบ ๆ ที่ต้องผสมหลายปัจจัยแบบ "ควรให้ความสำคัญกับอะไรตอนนี้" ส่วน behavior tree เก่งเรื่องการลงมือทำขั้นตอนที่เป็นรูปธรรมของสิ่งที่ชนะได้อย่างน่าเชื่อถือ ไม่มีเครื่องมือไหนต้องทำงานแทนอีกอัน รูปแบบเดียวกันนี้ใช้ได้กับ GOAP ที่มายืนแทนชั้นบนสุดเช่นกัน คือ GOAP ตัดสินใจลำดับ action ระดับสูง แล้วแต่ละ action นั้นก็อาจถูก implement เป็น behavior tree เล็ก ๆ ของตัวเองได้ ไม่มีกฎว่าโปรเจกต์ต้องเลือกใช้แค่หนึ่งในสี่ระบบนี้เท่านั้น เลือกตามปัญหาแต่ละอัน แล้วผสมมันเข้าด้วยกันตรงจุดที่ทำให้โค้ดง่ายขึ้นจริง ๆ

13. อภิธานศัพท์ (Glossary)

14. แบบฝึกหัด

แบบฝึกหัดที่ 1 — เพิ่ม Flee State จาก FSM ในข้อ 3 ให้เพิ่ม state ที่สี่ชื่อ FleeState กฎคือ: ถ้า enemy.Health <= 20 ศัตรูควรสลับไป FleeState ได้จากstate ไหนก็ตามแล้ววิ่งหนี พอ enemy.Health > 50 ก็ควรกลับไปที่ PatrolState จงเขียนคลาส FleeState พร้อมทั้งเขียนเงื่อนไขเพิ่มหนึ่งบรรทัดที่ต้องใส่ไว้ข้างในเมธอด Tick ของ PatrolState, ChaseState และ AttackState แต่ละตัว เพื่อให้กฎ "หนีได้จาก state ไหนก็ตาม" ทำงานได้จริง
Show answer

public class FleeState : IEnemyState
{
    public void Enter(EnemyAI enemy)
    {
        Debug.Log("Enter Flee");
    }

    public void Tick(EnemyAI enemy)
    {
        enemy.RunAwayFromPlayer();

        if (enemy.Health > 50)
        {
            enemy.ChangeState(new PatrolState());
        }
    }
}

// And inside EACH of PatrolState.Tick, ChaseState.Tick, AttackState.Tick,
// as the very first check:
if (enemy.Health <= 20)
{
    enemy.ChangeState(new FleeState());
    return;
}

สังเกตว่าคุณต้องเปิดและแก้ไขคลาสเดิมที่เคยทำงานได้ดีอยู่แล้วถึงสามคลาส แค่เพื่อเพิ่ม state ใหม่หนึ่งอัน นี่คือการเติบโตแบบสปาเก็ตตี้ที่อธิบายไว้ในข้อ 4 เป๊ะ ๆ state ที่สี่ไม่ควรต้องแตะสามอันแรก แต่กับ FSM ธรรมดามันต้องแตะ แบบฝึกหัดที่ 2 จะแสดงฟีเจอร์เดียวกันนี้ที่ถูกเพิ่มเข้าไปใน behavior tree ซึ่งกิ่งเดิมไม่ถูกแตะต้องเลย

แบบฝึกหัดที่ 2 — สร้าง Behavior Tree สำหรับยาม ใช้คลาส Sequence, Selector, ConditionNode และ ActionNode จากข้อ 6 สร้าง behavior tree สำหรับยามที่มีลำดับความสำคัญนี้: (1) ถ้า HeardNoise() เป็น true ให้รัน Investigate; ถ้าไม่ใช่ (2) ถ้า CanSeePlayer() เป็น true ให้รัน Chase; ถ้าไม่ใช่ทั้งคู่ (3) ให้รัน Patrol
Show answer

root = new Selector(
    new Sequence(
        new ConditionNode(HeardNoise),
        new ActionNode(Investigate)
    ),
    new Sequence(
        new ConditionNode(CanSeePlayer),
        new ActionNode(Chase)
    ),
    new ActionNode(Patrol)
);

bool HeardNoise() { /* check a recent noise event */ return false; }
bool CanSeePlayer() { /* raycast toward the player */ return false; }

NodeStatus Investigate()
{
    Debug.Log("Investigating noise...");
    return NodeStatus.Running;
}

NodeStatus Chase()
{
    Debug.Log("Chasing...");
    return NodeStatus.Running;
}

NodeStatus Patrol()
{
    Debug.Log("Patrolling...");
    return NodeStatus.Running;
}

root Selector ลอง Sequence "ได้ยินเสียง" ก่อน เพราะการตรวจสอบเสียงควรมีความสำคัญเหนือทั้งการไล่ล่าและการลาดตระเวน ถ้า Sequence นั้น fail (ไม่ได้ยินเสียง) Selector จะตกไปที่ Sequence "เห็นผู้เล่น" และจะตกไปถึง Patrol ก็ต่อเมื่ออันนั้น fail ด้วยเช่นกัน การเพิ่มพฤติกรรม "สืบสวน" ทั้งหมดนี้ไม่ต้องแก้อะไรเลยในวิธีการทำงานของการไล่ล่าหรือการลาดตระเวน

แบบฝึกหัดที่ 3 — คำนวณคะแนนด้วยมือ ใช้สูตรให้คะแนนจากข้อ 10 คำนวณคะแนน Flee, Attack และ Patrol ด้วยมือ สำหรับศัตรูที่มี Health = 60, MaxHealth = 100, Ammo = 0, DistanceToPlayer = 3, MaxAttackDistance = 10 action ไหนชนะ? แล้วอธิบายในหนึ่งประโยคว่าทำไมผลลัพธ์นี้อาจแปลว่าสูตรของ FleeAction ต้องปรับจูนใหม่
Show answer

Flee   : (1 - 60/100) * 10 = 0.4 * 10 = 4.0
Attack : Ammo <= 0, so Score() returns 0 immediately, regardless of distance
Patrol : constant                                                    = 1.0

Winner: Flee, with a score of 4.0

Flee ชนะ ทั้งที่ศัตรูเหลือเลือด 60% ซึ่งสำหรับคนดูการต่อสู้แล้วไม่รู้สึกว่า "วิกฤตหนัก" เลย มันแค่บังเอิญได้คะแนนสูงกว่า 1.0 ที่ตายตัวของ Patrol เพราะ Attack ถูกล็อกไว้ที่ 0 คะแนนสนิทเมื่อกระสุนหมด นี่คือสัญญาณว่าสูตรของ FleeAction ก้าวร้าวเกินไปในช่วงกลางของสเกล นักออกแบบน่าจะลดตัวคูณลง หรือเปลี่ยนจากเส้นตรงให้เป็นเส้นโค้งที่อยู่ใกล้ศูนย์จนกว่าเลือดจะลดต่ำกว่าเกณฑ์อันตรายที่ชัดเจนกว่านี้ (เช่น 30%) เพื่อให้การหนีชนะก็ต่อเมื่อมันดูสมเหตุสมผลจริง ๆ

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