5.1 แกน Unreal และ Actor Model

เฟส 5 · Unreal Engine (ทางเลือก) · เวลาเรียน: 30–50 h

Unreal จัดโครงเกมด้วย Actor, Component และ Gameplay Framework และระบบ reflection / UObject ของมัน

ทุกบทที่ผ่านมาใช้ Unity กับ C# บทนี้เปลี่ยนเอนจิ้น Unreal Engine คือเกมเอนจิ้นอีกตัวที่ทำโดย Epic Games และมันมองเกมที่กำลังรันอยู่ต่างจาก Unity ข่าวดีคือ คุณรู้อยู่แล้วว่าเกมเอนจิ้น "คืออะไร" — โปรแกรมที่ให้โลก 3D, renderer, physics, input, และ editor แล้วเรียกโค้ดคุณทุกเฟรม สิ่งที่เปลี่ยนคือคำศัพท์กับ building block บทนี้จะพูดถึง building block หลักของ Unreal คือ Actor, ระบบ reflection ที่ทำให้ editor กับ Blueprint ของ Unreal ทำงานได้ และชุด framework class เล็ก ๆ (GameMode, Pawn, Character, PlayerController) ที่ทุกโปรเจกต์ Unreal ถูกสร้างขึ้นมาจากมัน

เราจะเทียบกับ Unity ไปตลอดทาง เพราะการเทียบแบบนี้คือทางลัดที่เร็วที่สุดในการเรียนเอนจิ้นตัวที่สอง คุณไม่ได้เรียน "เกมเอนจิ้นคืออะไร" จากศูนย์ แต่กำลังเรียนว่า "เอนจิ้นตัวนี้ทำสิ่งที่คุณรู้จักอยู่แล้วยังไง"

1. Unreal Engine คืออะไร ใครใช้บ้าง

Unreal Engine เป็นเกมเอนจิ้นแบบ general-purpose พัฒนาต่อเนื่องมาตั้งแต่ปี 1998 ตอนนี้อยู่เวอร์ชันหลักที่ห้า (UE5) เหมือน Unity มันมี editor, renderer, physics, animation tools, audio, networking และวิธี package build สำหรับ PC, console, หรือมือถือ สิ่งที่ต่างจาก Unity ในทางปฏิบัติมีสองอย่าง:

Unreal ขึ้นชื่อเรื่องความสวยงามระดับสูงและเป็นตัวเลือกหลักของงาน AAA บน console/PC จำนวนมาก สตูดิโอและเกมที่ใช้มันมี Fortnite และซีรีส์ Gears of War ของ Epic Games เอง, Final Fantasy VII Remake/Rebirth (Square Enix), ซีรีส์ Star Wars Jedi (Respawn), Tekken 8 (Bandai Namco), Black Myth: Wukong (Game Science), และเกม Witcher ภาคใหม่ของ CD Projekt Red ที่ย้ายจากเอนจิ้นของตัวเองมาเป็น UE5 สตูดิโอมือถือและ live-service หลายแห่ง — รวมถึงบางแห่งที่ทำเกมด้วย Unity อย่าง HoYoverse ที่เราใช้อ้างอิงในบทก่อน ๆ — ก็ใช้ Unreal ผสมกับบางโปรเจกต์ หรือประเมินมันสำหรับงานที่ต้องการคุณภาพ rendering แบบ Unreal การรู้ทั้งสองเอนจิ้นเป็นเรื่องปกติและมีค่าในเรซูเม่ของ game programmer — มันแก้ปัญหาเดียวกันแต่มีความเห็นต่างกันว่าโค้ดควรจัดระเบียบยังไง

Tip source code ของ Unreal เปิดให้อ่านได้ (ฟรี ถ้ามี Epic account) บน GitHub เวลาไม่แน่ใจว่าฟังก์ชันหนึ่งทำอะไรจริง ๆ คุณเปิด source ของเอนจิ้นเองได้เลย — สิ่งที่ Unity แบบ closed-source ไม่ได้ให้แบบเดียวกัน

2. Actor กับ Component เทียบกับ GameObject ของ Unity

ใน Unity วัตถุที่คุณวางใน scene คือ GameObject — container เปล่า มันไม่ทำอะไรด้วยตัวเอง ทุกความสามารถ รวมถึง Transform (position/rotation/scale) เอง มาจาก Component ที่ attach ไว้ และ MonoBehaviour script ก็เป็นแค่ component อีกประเภทหนึ่ง

ใน Unreal วัตถุที่คุณวางใน level คือ Actor (base class ใน C++ คือ AActor) Actor คือ "สิ่งที่อยู่ในโลกได้" — มันมาพร้อม position/rotation/scale และชุด lifecycle call (คุยกันในหัวข้อ 7) ในตัว base class เองเลย Actor ยังมี Component ได้ (base class ใน C++ คือ UActorComponent) ซึ่งคือ object ที่ attach เข้ากับ Actor เพื่อเพิ่มความสามารถเฉพาะอย่าง: mesh สำหรับ render, camera, collision shape, กฎการเคลื่อนที่ แนวคิดนี้ใกล้เคียงกับ GameObject + Component ของ Unity แต่การแบ่งงานต่างกัน:

Unity object "Player" GameObject | +-- Transform (component) +-- MeshRenderer (component) +-- CapsuleCollider (component) +-- PlayerScript : MonoBehaviour (component; your gameplay code) Unreal object "AMyCharacter" AMyCharacter (an AActor subclass -- your gameplay code can live directly here, in C++ methods, not only in components) | +-- RootComponent (a USceneComponent; holds the transform) +-- CapsuleComponent (collision) +-- SkeletalMeshComponent (the mesh) +-- CharacterMovementComponent (movement rules)

มีสองความต่างที่ควรพูดให้ชัด:

Tip ทั้งสองเอนจิ้นให้คุณทำแบบไหนก็ได้ถ้าต้องการ — Unity รองรับโค้ดสไตล์ composition-heavy แบบ ECS ได้ และ Unreal ก็สร้าง ActorComponent เล็ก ๆ จำนวนมากได้ สิ่งที่สำคัญในบทนี้คือ default และ built-in class ของแต่ละเอนจิ้นผลักคุณไปทางไหน

3. UObject: ฐานที่ทุกอย่างวางอยู่

แทบทุก engine class ที่คุณแตะใน Unreal — AActor, UActorComponent, แม้แต่ helper object ที่ไม่ใช่ Actor — สุดท้ายสืบทอดมาจาก root class เดียว: UObject AActor ก็คือ UObject ตัวหนึ่ง (ผ่านสาย base class) ซึ่งเป็นเหตุผลทั้งหมดที่ Actor ได้สามอย่างนี้มาฟรี ๆ ซึ่งไม่มีใน C++ ธรรมดา:

UObject | +-- AActor | | | +-- APawn | | | | | +-- ACharacter | | | +-- AGameModeBase | +-- APlayerController | +-- (your own actors: AHealthActor, APickupActor, ...) | +-- UActorComponent | +-- USceneComponent (adds a transform) | +-- UStaticMeshComponent +-- UCameraComponent +-- USpringArmComponent

Reflection ไม่ใช่มายากล — มันคือโค้ดที่ถูก generate ขึ้นมา ขั้นตอน build ของ Unreal รันเครื่องมือชื่อ Unreal Header Tool (UHT) ก่อนตัว compiler C++ จริง ๆ จะทำงาน UHT สแกน header file ของคุณหา macro ชุดหนึ่ง (คุยต่อไป) และสำหรับทุก class ที่เจอ มันเขียนไฟล์ C++ เพิ่มเต็มไปด้วย boilerplate — ไฟล์ .generated.h ของ class นั้น — ที่เสียบ class คุณเข้าระบบ reflection นี่คือเหตุผลที่ header ทุก class ของ Unreal จบด้วย include แบบ #include "MyActor.generated.h" และทำไม include นั้นต้องอยู่ ท้ายสุด ของไฟล์เสมอ: UHT ต้องเห็นทุกอย่างข้างบนมันก่อน

Common mistake วาง #include "MyActor.generated.h" ไว้ที่ไหนก็ได้ที่ไม่ใช่ท้ายสุด หรือลืมมันไปเลย ทั้งสองแบบทำให้ compile error ในโปรเจกต์ Unreal จริง ไฟล์ generated ต้องมาท้ายสุดเพราะมันขึ้นกับทุกอย่างที่ประกาศไว้ข้างบนในไฟล์เดียวกัน

4. Reflection Macro: UCLASS, UPROPERTY, UFUNCTION

Unity ได้ reflection มา "ฟรี" จาก .NET runtime — field C# ที่ mark ด้วย attribute [SerializeField] ถูกค้นเจอได้ตอน runtime ผ่าน System.Reflection ในตัวของ .NET ไม่ต้องมีขั้นตอน build เพิ่ม C++ ไม่มี runtime reflection แบบนั้น ดังนั้น Unreal เลือก opt-in class, property, และ function แต่ละตัวเข้าระบบ reflection ที่สร้างเองผ่าน macro ที่ UHT อ่าน มีสามตัวที่คุณจะใช้ตลอด:

นี่คือ Actor เล็ก ๆ ที่สมบูรณ์ ใช้ทั้งสามตัว เทียบเท่ากับ Unity script ที่มี [SerializeField] private float health = 100f; กับ public method ตัวหนึ่ง

// HealthActor.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "HealthActor.generated.h"     // must be the LAST include

UCLASS()
class MYGAME_API AHealthActor : public AActor
{
    GENERATED_BODY()   // must be the first line in the class body

public:
    AHealthActor();

    // Shows up as an editable slider in the Details panel, grouped
    // under "Health". Blueprint graphs can read AND write it too.
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Health")
    float Health = 100.0f;

    // Blueprint graphs can call this like a node. C++ callers can
    // call it directly, same as any normal member function.
    UFUNCTION(BlueprintCallable, Category = "Health")
    void TakeDamage(float Amount);

protected:
    virtual void BeginPlay() override;
};
// HealthActor.cpp
#include "HealthActor.h"

AHealthActor::AHealthActor()
{
    PrimaryActorTick.bCanEverTick = false;  // this actor never needs per-frame work
}

void AHealthActor::BeginPlay()
{
    Super::BeginPlay();
    UE_LOG(LogTemp, Warning, TEXT("HealthActor spawned with %f HP"), Health);
}

void AHealthActor::TakeDamage(float Amount)
{
    Health -= Amount;
    UE_LOG(LogTemp, Warning, TEXT("Took %f damage, Health is now %f"), Amount, Health);
}

UE_LOG คือเวอร์ชัน Unreal ของ std::cout หรือ Debug.Log ของ Unity — มันเขียนบรรทัดหนึ่งลงหน้าต่าง Output Log ของ editor TEXT("...") ห่อ string literal ไว้ให้ตรงกับ string type ของ Unreal บนทุกแพลตฟอร์ม คุณจะเห็นมันรอบ ๆ แทบทุก string literal ใน Unreal C++ ถ้าคุณวาง Actor นี้ลง level แล้วกด Play, Output Log จะโชว์ trace นี้:

LogTemp: Warning: HealthActor spawned with 100.000000 HP

แล้วเรียก TakeDamage(25.0f) จากที่ไหนก็ได้ที่มี pointer ไปยัง actor นี้ (โค้ดของ Actor อื่น หรือ node ของ Blueprint) log จะเพิ่มบรรทัดที่สอง:

LogTemp: Warning: Took 25.000000 damage, Health is now 75.000000

และเพราะ Health เป็น EditAnywhere การเลือก actor นี้ใน level จะเปิด Details panel ที่หน้าตาประมาณนี้ — ไม่ต้องเขียนโค้ดเพิ่มเพื่อได้ slider ที่แก้ไขได้:

Details panel (Unreal Editor) -- AHealthActor selected in the level +----------------------------------------+ | Health | | Health [ 100.0 ] | +----------------------------------------+

วางสามข้อดีเทียบกัน: EditAnywhere ของ UPROPERTY คือ editor exposure (designer ปรับ Health ทีละ instance ได้โดยไม่ต้องแตะโค้ด), reflection data ที่อยู่หลัง UPROPERTY ทุกตัวคือสิ่งที่ทำให้ serialization ทำงานได้ (ค่า 100.0 นั้นถูก save ลงไฟล์ level และ save ลง save-game slot ได้), และ BlueprintReadWrite/BlueprintCallable คือสิ่งที่ให้ Blueprint access (graph ของ Blueprint ของ designer อ่าน Health หรือเรียก TakeDamage ได้โดยไม่ต้องเห็นโค้ด C++ สักบรรทัด) macro คู่เดียว สามผลตอบแทน

5. AActor: สิ่งที่คุณวางลงในโลก

ตอนนี้ reflection เข้าใจแล้ว กลับมาที่ตัว Actor เอง AActor คือ base class สำหรับทุกอย่างที่อยู่ใน level ได้: ตัวละคร, แสง, pickup, trigger volume ที่มองไม่เห็น แม้แต่ camera ทุก Actor มีสิ่งเหล่านี้จาก base class อย่างเดียว ก่อนที่คุณจะเพิ่มอะไรเลย:

สังเกตว่าชื่อ class ขึ้นต้นด้วย A Unreal ใช้ prefix ตัวอักษรเดียวชุดเล็ก ๆ เพื่อให้คุณบอก category ของ type ได้จากชื่ออย่างเดียว: A สำหรับ Actor subclass, U สำหรับ class อื่นที่สืบทอดจาก UObject (รวมถึง Component ทุกตัว), F สำหรับ struct C++ ธรรมดา (อย่าง FVector, FString), E สำหรับ enum, และ I สำหรับ interface Unity ไม่มี convention แบบนี้เลย — MonoBehaviour ของ Unity กับ class C# ธรรมดาหน้าตาเหมือนกันหมด เป็นชื่อ PascalCase ไม่มี prefix

นี่คือ Actor ที่สมบูรณ์ที่เล็กที่สุดที่เขียนได้ — ไม่มี component ไม่มี property มีแค่เปลือก:

// MyActor.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MyActor.generated.h"

UCLASS()
class MYGAME_API AMyActor : public AActor
{
    GENERATED_BODY()

public:
    AMyActor();

protected:
    virtual void BeginPlay() override;

public:
    virtual void Tick(float DeltaTime) override;
};
// MyActor.cpp
#include "MyActor.h"

AMyActor::AMyActor()
{
    PrimaryActorTick.bCanEverTick = true;   // allow Tick() to run (see section 7)
}

void AMyActor::BeginPlay()
{
    Super::BeginPlay();
}

void AMyActor::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
}

วางตัวนี้ลง level แล้วกด Play: ไม่มีอะไรเกิดขึ้นให้เห็น เพราะมันไม่มี mesh และยังไม่ทำงานอะไร แต่มันมีตัวตนอยู่ — มัน location, มันได้รับ BeginPlay ครั้งเดียว, และมันกำลังรับ Tick ทุกเฟรม "เปล่าแต่มีชีวิต" แบบนี้เทียบเท่ากับ Unity GameObject เปล่าที่มี script attach อยู่แล้วโดยมี Start() กับ Update() ว่างเปล่า

6. Component: การ attach ข้อมูลและพฤติกรรมให้ Actor

Actor เดี่ยว ๆ มี transform กับ lifecycle call แต่ไม่มีอะไรให้ render และไม่มี collision คุณเพิ่มความสามารถพวกนี้ได้โดย attach Component — object (สืบทอดจาก UActorComponent) ที่เสียบเข้ากับ Actor และทำงานอย่างละหนึ่งอย่าง สามตัวที่คุณจะเจอทันที:

ใน Unity คุณ attach component ปกติใน editor โดยลากมันลงบน GameObject หรือตอน runtime ด้วย gameObject.AddComponent<T>() ใน Unreal C++ ที่ที่มาตรฐานสำหรับสร้าง component เริ่มต้นของ Actor คือในตัว constructor ของมัน โดยใช้ CreateDefaultSubobject<T>(TEXT("Name")) มันรันครั้งเดียวตอน class ถูก set up ครั้งแรก (พูดให้ถูกคือ ตอน Class Default Object ของมันถูกสร้าง) และผลิต component ที่ทุก instance ของ Actor นี้เริ่มต้นด้วย — ใกล้เคียงกับการตั้งค่า prefab ของ Unity มากกว่าการเรียก AddComponent ตอน runtime

// PickupActor.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PickupActor.generated.h"

UCLASS()
class MYGAME_API APickupActor : public AActor
{
    GENERATED_BODY()

public:
    APickupActor();

    UPROPERTY(VisibleAnywhere, Category = "Components")
    USceneComponent* Root;

    UPROPERTY(VisibleAnywhere, Category = "Components")
    UStaticMeshComponent* Mesh;
};
// PickupActor.cpp
#include "PickupActor.h"

APickupActor::APickupActor()
{
    PrimaryActorTick.bCanEverTick = false;

    // Create a root so the actor has a transform to attach things to.
    Root = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
    SetRootComponent(Root);

    // Create the mesh and attach it under the root.
    Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
    Mesh->SetupAttachment(Root);
}

โครงสร้าง component tree ที่โค้ดนี้สร้างขึ้น หน้าตาแบบนี้เมื่อ object มีตัวตนอยู่ในโลก:

APickupActor instance | +-- Root (USceneComponent) <- the RootComponent, holds the transform | +-- Mesh (UStaticMeshComponent) <- attached under Root, renders the mesh

pointer ของ component ทุกตัวที่นี่ก็เป็น VisibleAnywhere ด้วย ดังนั้นเลือก APickupActor ที่วางไว้จะเห็น component ทั้งสองตัวอยู่ใน Details panel — คุณ (หรือ designer) กำหนด mesh asset จริงให้ Mesh ได้โดยไม่ต้องเขียนโค้ดเพิ่ม

Tip คุณไม่จำเป็นต้องเขียน C++ เพื่อเพิ่ม component เลยก็ได้ ใน Unreal Editor คุณเปิด Blueprint class แล้วเพิ่ม component แบบเห็นภาพได้ เหมือนกับลาก component ลงบน prefab ของ Unity C++ ไว้สำหรับ component ที่ทุก instance ควรมีตั้งแต่แรก หรือ logic ที่หนักเกินไปหรือไวต่อ performance เกินกว่า Blueprint จะรับไหว ส่วน Blueprint ไว้สำหรับปรับจูนเฉพาะ level และงานที่ designer เป็นเจ้าของ บน C++ base นั้นอีกที

7. Actor Lifecycle: Constructor, BeginPlay, Tick, EndPlay

ทุก Actor ผ่านลำดับ call เดียวกัน ถ้าคุณรู้จัก Awake/Start/Update/OnDestroy ของ Unity อยู่แล้ว อันนี้จะรู้สึกคุ้นเคย แค่ชื่อต่างและมีกับดักสำคัญอันหนึ่ง

AMyActor() constructor: runs when the object is FIRST | constructed -- including in the EDITOR, not | only when the game is playing. Create | components here. Avoid gameplay logic here. v BeginPlay() runs ONCE, only when the actor exists in a | RUNNING world (editor Play, or a real game). | Safe to look up other actors, start timers, | the world is guaranteed to be ready. v Tick(DeltaTime) runs every frame, only if ticking is enabled | (repeats) (PrimaryActorTick.bCanEverTick = true). | DeltaTime is the seconds since last frame -- | same idea as Unity's Time.deltaTime. v EndPlay(Reason) runs once when the actor is removed from a running world: level unload, Destroy() call, or the game/editor session ending.

ตัวอย่างที่แตะสามตัวนี้พร้อมกัน คือ track ว่า actor มีชีวิตอยู่มานานแค่ไหน:

// TimedActor.h  (declarations only -- assume the usual .generated.h setup)
UCLASS()
class MYGAME_API ATimedActor : public AActor
{
    GENERATED_BODY()
public:
    ATimedActor();
protected:
    virtual void BeginPlay() override;
public:
    virtual void Tick(float DeltaTime) override;
    virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

    UPROPERTY(VisibleAnywhere, Category = "Timer")
    float TimeAlive = 0.0f;
};
// TimedActor.cpp
#include "TimedActor.h"

ATimedActor::ATimedActor()
{
    PrimaryActorTick.bCanEverTick = true;
}

void ATimedActor::BeginPlay()
{
    Super::BeginPlay();
    UE_LOG(LogTemp, Warning, TEXT("TimedActor: BeginPlay, TimeAlive = %f"), TimeAlive);
}

void ATimedActor::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
    TimeAlive += DeltaTime;
    if (TimeAlive > 5.0f)
    {
        UE_LOG(LogTemp, Warning, TEXT("TimedActor: been alive over 5 seconds"));
    }
}

void ATimedActor::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    UE_LOG(LogTemp, Warning, TEXT("TimedActor: EndPlay, TimeAlive = %f"), TimeAlive);
    Super::EndPlay(EndPlayReason);
}

ไล่ดู: เฟรม 0, BeginPlay ยิงครั้งเดียว พิมพ์ TimeAlive = 0.000000 ทุกเฟรมถัดไป Tick บวก DeltaTime ของเฟรมนั้น (เศษส่วนของวินาที) เข้า TimeAlive พอผลรวมเกิน 5.0 บรรทัด "been alive over 5 seconds" จะเริ่มพิมพ์ทุกเฟรมตั้งแต่นั้นไป (โค้ดนี้ไม่มี flag ให้พิมพ์แค่ครั้งเดียว — เก็บไว้เป็นแบบฝึกหัดข้อ 1) ถ้าคุณหยุด Play ใน editor, EndPlay จะยิงครั้งเดียวพร้อม TimeAlive ค่าล่าสุดที่มันไปถึง

Common mistake ใส่ gameplay logic — spawn actor อื่น, อ่าน player state, เรียก GetWorld() — ไว้ใน constructor แทนที่จะเป็น BeginPlay() constructor รันได้แม้กับ object ที่เป็น editor-only preview ที่ไม่มี running world เลย ดังนั้นโค้ดที่ต้องพึ่ง world ตรงนั้น crash ได้ หรือไม่ทำอะไรเลยแบบเงียบ ๆ ได้ Constructor: สร้าง component กับตั้งค่า default Tick: ทุกอย่างที่ต้องการ game world ที่มีตัวตนอยู่จริง
Tip เรียก Super::BeginPlay(), Super::Tick(DeltaTime), และ Super::EndPlay(...) เสมอในตัว override ของคุณ รีเฟล็กซ์เดียวกับเรียก base.Start() ใน Unity script ที่ override method ของ base class logic ของ base class เองมักสำคัญ — ข้ามมันแล้วอะไรบางอย่างจะพังในแบบที่ตามหาสาเหตุยาก

8. Gameplay Framework: ชิ้นส่วนต่อกันยังไง

Unity ให้ scene เปล่า ๆ กับคุณโดยไม่มีความเห็นเลยว่า "player", "match", หรือ "input" ควรจัดโครงสร้างยังไง — คุณสร้าง GameManager singleton กับ player controller script เองตั้งแต่ศูนย์ ทุกโปรเจกต์ Unreal มี starter structure ที่มีความเห็นชัดเจนสำหรับเรื่องนี้โดยเฉพาะ เรียกว่า Gameplay Framework: ชุด built-in Actor class เล็ก ๆ ที่หน้าที่ถูกแบ่งไว้แล้ว และรู้วิธีคุยกันเองอยู่แล้ว บทนี้เน้นสี่ตัว:

นี่คือวิธีที่ session single-player ต่อสี่ตัวนี้เข้าด้วยกันตอน Play เริ่ม:

+--------------+ | GameMode | decides: which Pawn class, which +--------------+ PlayerController class to use | | spawns spawns | | v v +-----------------+ +----------------+ |PlayerController | | Pawn/Character | +-----------------+ +----------------+ | ^ | possesses | +--------------------+ | owns (has many) | v +----------------+ | Components | | Mesh, Camera, | | Capsule, etc. | +----------------+

อ่านแผนภาพเป็นลำดับ: GameMode spawn PlayerController กับ Pawn ตัวหนึ่ง (ใช้ class ไหนก็ตามที่มันถูกบอกให้ใช้) จากนั้น PlayerController possess Pawn (แปลว่า: input จากจุดนี้ไปถูก route เข้า Pawn ตัวนั้น) และ Pawn เหมือน Actor ทุกตัว เป็นเจ้าของ Component ที่มันต้องการเพื่อให้หน้าตาและพฤติกรรมเป็นอะไรสักอย่าง ในโปรเจกต์ใหญ่ขึ้น โดยเฉพาะ multiplayer คุณจะเจอ AGameState (ข้อมูล match ที่ share ให้ทุกคนเห็น อย่างคะแนน) กับ APlayerState (ข้อมูลรายผู้เล่น อย่างคะแนนของผู้เล่นคนหนึ่ง) ด้วย — มันอยู่ข้าง ๆ แผนภาพนี้แต่อยู่นอกขอบเขตของบทนี้

9. GameMode: กฎของ Match

Unity ไม่มีอะไรเทียบเท่า GameMode ในตัวเลย — ทุกโปรเจกต์ Unity คิด logic "ใคร spawn ก่อน, class ไหนคือ player" ขึ้นมาเอง ปกติเป็น singleton ที่เขียนมือ Unreal ทำให้หน้าที่นี้เป็นทางการด้วย class เดียว: AGameModeBase มีไว้เพื่อตอบคำถาม "กฎของ session นี้คืออะไร" โดยเฉพาะ และกฎที่มันตอบบ่อยที่สุดคือ class ไหนจะถูก spawn

ตั้งค่าต่อ level (หรือเป็น default ทั้งโปรเจกต์) GameMode subclass ปกติแค่กรอก class reference สองสามตัวใน constructor ของมัน:

// MyGameMode.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "MyGameMode.generated.h"

UCLASS()
class MYGAME_API AMyGameMode : public AGameModeBase
{
    GENERATED_BODY()
public:
    AMyGameMode();
};
// MyGameMode.cpp
#include "MyGameMode.h"
#include "MyCharacter.h"
#include "MyPlayerController.h"

AMyGameMode::AMyGameMode()
{
    DefaultPawnClass = AMyCharacter::StaticClass();
    PlayerControllerClass = AMyPlayerController::StaticClass();
}

นั่นคือ class ทั้งหมด: การ assign สองบรรทัด StaticClass() คืน handle ไปยัง class เอง (reflection data ของมัน) ไม่ใช่ instance — Unreal ต้องการ class ตรงนี้เพราะมันจะ SpawnActor จากมันทีหลัง ครั้งละหนึ่งต่อผู้เล่น เมื่อ Play เริ่ม เอนจิ้นดูว่า level ปัจจุบันใช้ GameMode ไหน อ่าน DefaultPawnClass กับ PlayerControllerClass จากมัน แล้ว spawn อย่างละหนึ่ง — นั่นคือกล่องบนสุดในแผนภาพหัวข้อ 8 กำลังทำหน้าที่ของมัน

ถ้าอยากให้ designer เลือก Pawn class จาก editor เองแทนที่จะ hard-code ใน C++ ให้ expose มันเป็น UPROPERTY ด้วย TSubclassOf — "pointer ไปยัง class" (ไม่ใช่ instance) ที่ type-safe ซึ่ง Details panel โชว์เป็น dropdown ของ class ที่ตรงเงื่อนไขได้:

UPROPERTY(EditDefaultsOnly, Category = "Classes")
TSubclassOf<APawn> DefaultPawnClassOverride;

TSubclassOf<APawn> อ่านว่า "class ที่เป็น APawn หรือ subclass ของมัน" — ทั้ง compiler และ editor บังคับเงื่อนไขนี้ ดังนั้นคุณ assign class ที่ไม่เกี่ยวกับ Pawn เลยโดยไม่ตั้งใจไม่ได้

10. Pawn กับ Character: อะไรถูกควบคุมบ้าง

APawn คือ base class สำหรับอะไรก็ตามในโลกที่ Controller — มนุษย์หรือ AI — possess และขับได้ ไม่ใช่ทุก Actor ที่เป็น Pawn: ประตู, ของประดับแบบ static, trigger volume เป็น Actor แต่ไม่มีทางเป็น Pawn เพราะไม่มีใครมีไว้ "ขับ" มัน Pawn คือ Actor ประเภทเฉพาะที่ทำมาให้รับ control input

ACharacter เป็น subclass ของ APawn ทำมาสำหรับตัวละครเดินสองขาโดยเฉพาะ มันเพิ่มสองอย่างที่ APawn ธรรมดาไม่มีตั้งแต่แรก:

Character ที่เล่นได้แบบพื้นฐานที่สุด รวม camera บน spring arm (component ที่รักษา camera ให้อยู่ระยะคงที่หลังตัวละคร และดันเข้ามาใกล้ถ้ามีอะไรบัง view — เวอร์ชัน Unreal ของการเขียน third-person camera-collision code เองใน Unity):

// MyCharacter.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "MyCharacter.generated.h"

class USpringArmComponent;
class UCameraComponent;
class UInputComponent;

UCLASS()
class MYGAME_API AMyCharacter : public ACharacter
{
    GENERATED_BODY()

public:
    AMyCharacter();

protected:
    virtual void BeginPlay() override;
    virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override;

    void MoveForward(float Value);
    void MoveRight(float Value);

    UPROPERTY(VisibleAnywhere, Category = "Camera")
    USpringArmComponent* SpringArm;

    UPROPERTY(VisibleAnywhere, Category = "Camera")
    UCameraComponent* Camera;
};
// MyCharacter.cpp
#include "MyCharacter.h"
#include "GameFramework/SpringArmComponent.h"
#include "Camera/CameraComponent.h"
#include "Components/InputComponent.h"

AMyCharacter::AMyCharacter()
{
    // RootComponent, CapsuleComponent, and the movement component
    // already exist -- ACharacter's own constructor made them.
    SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
    SpringArm->SetupAttachment(RootComponent);
    SpringArm->TargetArmLength = 400.0f;

    Camera = CreateDefaultSubobject<UCameraComponent>(TEXT("Camera"));
    Camera->SetupAttachment(SpringArm);
}

void AMyCharacter::BeginPlay()
{
    Super::BeginPlay();
}

void AMyCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    Super::SetupPlayerInputComponent(PlayerInputComponent);
    PlayerInputComponent->BindAxis("MoveForward", this, &AMyCharacter::MoveForward);
    PlayerInputComponent->BindAxis("MoveRight", this, &AMyCharacter::MoveRight);
}

void AMyCharacter::MoveForward(float Value)
{
    if (Controller && Value != 0.0f)
    {
        AddMovementInput(GetActorForwardVector(), Value);
    }
}

void AMyCharacter::MoveRight(float Value)
{
    if (Controller && Value != 0.0f)
    {
        AddMovementInput(GetActorRightVector(), Value);
    }
}

SetupPlayerInputComponent คือจุดที่ Pawn/Character bind input axis ดิบ ๆ เข้ากับ function ของตัวเอง — ตัวอย่างนี้ใช้ระบบ Axis Mapping แบบเก่า (ตั้งค่าใน Project Settings) วิธีง่ายที่สุดที่จะเห็นแนวคิด Unreal เวอร์ชันใหม่กว่าเน้นระบบที่ยืดหยุ่นกว่าชื่อ Enhanced Input ซึ่งควรเรียนต่อเมื่อพื้นฐานพวกนี้แน่นแล้ว สังเกต guard if (Controller && ...)Controller เป็น pointer ที่ Pawn ตัวนี้มีก็ต่อเมื่อมีอะไรบางอย่าง possess มันอยู่ คุยต่อในหัวข้อถัดไป

Common mistake ลืม Super::SetupPlayerInputComponent(PlayerInputComponent); ไว้บนสุดของ override ถ้า class คุณถูกสืบทอดต่อไปอีก หรือ base class bind input ของตัวเอง การข้าม Super call ทำให้ behavior นั้นหายไปแบบเงียบ ๆ — กับดักเดียวกับลืม Super::BeginPlay()

11. PlayerController: สะพานระหว่าง Input กับ Pawn

ใน Unity ปกติไม่มี object แยกสำหรับ "การเชื่อมต่อของผู้เล่น" — คุณอ่าน Input.GetAxis(...) ตรง ๆ ใน script ที่อยู่บน player GameObject และถ้า GameObject นั้นถูกทำลาย โค้ดจัดการ input ของคุณก็หายไปด้วย Unreal แยกเรื่องนี้โดยตั้งใจ: APlayerController แทนการเชื่อมต่อควบคุมของผู้เล่นมนุษย์คนหนึ่ง และมัน ไม่ใช่ สิ่งของทางกายภาพใน level — ไม่มี mesh ปกติไม่มีตัวตนที่มองเห็นเลย หน้าที่ทั้งหมดของมันคือ: อ่าน hardware input และ route คำสั่งที่ได้เข้าไปยัง Pawn ที่มันกำลัง possess อยู่ตอนนั้น

การแยกนี้สำคัญด้วยเหตุผลที่จับต้องได้อย่างหนึ่ง: Controller อยู่ต่อ ข้ามการเปลี่ยน Pawn ถ้า Character ของคุณตายและตัวใหม่ถูก spawn มาให้ผู้เล่น respawn PlayerController ตัวเดิม possess Pawn ตัวใหม่ได้เลย — การเชื่อมต่อของผู้เล่น, การตั้งค่า camera, และ UI state ใด ๆ ที่ผูกกับ controller อยู่ต่อ มีแค่ Pawn (ร่างกายจริงในโลก) ที่ถูกแทนที่

// MyPlayerController.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "MyPlayerController.generated.h"

UCLASS()
class MYGAME_API AMyPlayerController : public APlayerController
{
    GENERATED_BODY()
protected:
    virtual void BeginPlay() override;
    virtual void OnPossess(APawn* InPawn) override;
};
// MyPlayerController.cpp
#include "MyPlayerController.h"
#include "GameFramework/Pawn.h"

void AMyPlayerController::BeginPlay()
{
    Super::BeginPlay();
    UE_LOG(LogTemp, Warning, TEXT("PlayerController: BeginPlay"));
}

void AMyPlayerController::OnPossess(APawn* InPawn)
{
    Super::OnPossess(InPawn);
    UE_LOG(LogTemp, Warning, TEXT("PlayerController: now possessing %s"), *InPawn->GetName());
}

OnPossess ยิงบน Controller ทันทีที่มัน take control ของ Pawn — นี่คือลูกศร "possesses" จากแผนภาพหัวข้อ 8 ที่เป็นรูปธรรม *InPawn->GetName() dereference FString ที่ GetName() คืนมา ให้ %s พิมพ์มันได้ ถ้า InPawn คือ AMyCharacter ของคุณจากหัวข้อ 10 ที่วางไว้เป็น player start ค่า default ของ level, trace อ่านได้ว่า:

LogTemp: Warning: PlayerController: BeginPlay
LogTemp: Warning: PlayerController: now possessing MyCharacter_C_0

12. ไล่ดู Play Session ตั้งแต่ต้นจนจบ

หัวข้อ 8 โชว์แผนภาพนิ่ง ตรงนี้คือภาพเดียวกันในรูปแบบ event trace เพื่อให้เห็นลำดับที่สิ่งต่าง ๆ เกิดขึ้นจริงเมื่อคุณกด Play ใน editor สมมติ: AMyGameMode จากหัวข้อ 9 ใช้ AMyCharacter จากหัวข้อ 10 กับ AMyPlayerController จากหัวข้อ 11 และทุก class log BeginPlay ของตัวเอง

1. Level loads. Engine picks the level's GameMode: AMyGameMode. 2. GameMode reads DefaultPawnClass / PlayerControllerClass off itself. 3. GameMode spawns an AMyPlayerController. 4. GameMode spawns an AMyCharacter at a PlayerStart in the level. 5. The PlayerController possesses the Character -- OnPossess() fires. 6. BeginPlay() fires on every actor now in the world, GameMode included. 7. Tick() begins running every frame on every actor that opted in.

ใส่เข้า Output Log ลำดับหนึ่งที่เป็นไปได้หน้าตาแบบนี้ (ลำดับที่แน่นอนระหว่าง BeginPlay ของ actor ต่างกันไม่ใช่สิ่งที่คุณควร hard-code logic รอบมัน — มีแค่ความสัมพันธ์ possession-ก่อน-BeginPlay-ของ-pawn กับ "GameMode มีตัวตนก่อน" ที่รับประกันได้):

LogTemp: Warning: PlayerController: now possessing MyCharacter_C_0
LogTemp: Warning: PlayerController: BeginPlay
LogTemp: Warning: HealthActor spawned with 100.000000 HP

ข้อสรุปเชิงปฏิบัติ: พอถึงเวลาที่ BeginPlay ของ actor ตัวไหนก็ตาม รัน GameMode ตัดสินกฎไปแล้ว และ PlayerController ถูกสร้างไปแล้ว — ดังนั้นการถาม "GameMode ไหนกำลังรัน" หรือ "ใครคือ controller ของ player" จากใน BeginPlay ปลอดภัยเสมอ แต่การสมมติแบบนั้นจากใน constructor ไม่ปลอดภัย นี่คือกับดักที่พูดถึงในหัวข้อ 7 เป๊ะ ๆ

13. Unreal เทียบกับ Unity แบบเคียงข้าง

ตารางอ้างอิงตัวเดียว รวบรวมทุกอย่างที่บทนี้แตะไว้:

Concept | Unity | Unreal ------------------------+----------------------------+---------------------------- Main language | C# | C++ (plus Blueprint, visual) World object base class | GameObject | AActor Holds the transform | Transform component | RootComponent (built in) Where behavior lives | MonoBehaviour component | Actor subclass itself, | | or an ActorComponent Per-frame update | Update() | Tick(float DeltaTime) First-frame setup | Start() | BeginPlay() Cleanup on removal | OnDestroy() | EndPlay(Reason) Editor-exposed field | [SerializeField] private | UPROPERTY(EditAnywhere) Reflection source | .NET runtime (built in) | Unreal Header Tool (UHT), | | driven by macros Player input owner | usually the player's script| APlayerController Match rules / spawning | your own GameManager | AGameModeBase (built in) Instantiate / destroy | Instantiate() / Destroy() | SpawnActor<T>() / Destroy() Naming convention | PascalCase, no prefix | A/U/F/E/I prefix by category

ธีมที่อยู่ใต้ตาราง: Unity มีความเห็นในตัวน้อยกว่าและคาดหวังให้คุณสร้าง convention ของตัวเอง (GameManager ของตัวเอง, pattern player script ของตัวเอง) ทุกโปรเจกต์ Unreal ให้ structure ในตัวมากกว่า (GameMode, การแบ่ง Pawn/Character, PlayerController) และวิธี expose สิ่งต่าง ๆ เข้า editor กับ Blueprint ที่หนักกว่า ขับเคลื่อนด้วย macro แลกกับ structure นั้น ไม่มีตัวไหนดีกว่ากันแบบเบ็ดเสร็จ — มันคือ trade-off ต่างกันระหว่าง "ยืดหยุ่น สร้างเองหมด" กับ "มีความเห็น เสียบเข้า framework" — และการรู้ทั้งสองแบบหมายความว่าคุณเปิดประกาศงานของเอนจิ้นไหนก็ได้แล้วจำรูปร่างของโค้ดได้ทันทีก่อนเปิดโปรเจกต์ด้วยซ้ำ

14. Glossary

15. Exercises

Exercise 1 ย้อนกลับไปดูโค้ด ATimedActor ในหัวข้อ 7 ตอนนี้ Tick ของมัน log "been alive over 5 seconds" ทุก เฟรมเมื่อ TimeAlive เกิน 5.0 แทนที่จะเป็นครั้งเดียว เพิ่ม member variable และแก้ Tick เล็กน้อยให้ข้อความพิมพ์แค่ครั้งเดียวเป๊ะ ๆ บอก type และ UPROPERTY (หรือ member ธรรมดา แล้วแต่คุณ) ที่คุณเพิ่ม และโชว์ function Tick ที่แก้แล้ว
Show answer

เพิ่ม boolean flag ที่เริ่มด้วย false และถูก set เป็น true เมื่อข้อความยิงไปแล้ว:

UPROPERTY(VisibleAnywhere, Category = "Timer")
bool bHasLoggedFiveSeconds = false;
void ATimedActor::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
    TimeAlive += DeltaTime;
    if (TimeAlive > 5.0f && !bHasLoggedFiveSeconds)
    {
        UE_LOG(LogTemp, Warning, TEXT("TimedActor: been alive over 5 seconds"));
        bHasLoggedFiveSeconds = true;
    }
}

เช็ค &&!bHasLoggedFiveSeconds ทำให้ body รันแค่ครั้งแรกที่ TimeAlive ข้าม 5.0 เท่านั้น — หลังจากนั้น bHasLoggedFiveSeconds เป็น true ดังนั้นเงื่อนไขเป็น false ทุกเฟรมถัดไปไม่ว่า TimeAlive จะโตแค่ไหน นี่คือ pattern "พิมพ์ครั้งเดียว" แบบเดียวกับที่คุณใช้ใน Unity ด้วย bool field ที่เช็คใน Update()

Exercise 2 เพื่อนร่วมทีมเขียน constructor นี้แล้วถามว่าทำไมเกม crash ทันทีที่ level โหลด ก่อนกด Play ด้วยซ้ำ:
AMyActor::AMyActor()
{
    PrimaryActorTick.bCanEverTick = false;
    AActor* Other = GetWorld()->SpawnActor<AActor>(AActor::StaticClass());
}
อธิบายว่าอะไรผิด แล้วเขียนใหม่ให้ถูก
Show answer

constructor รันได้แม้ตอนไม่มี running game world เลย — เช่นตอน editor สร้าง Class Default Object ของ Actor นี้เพื่อโชว์ใน menu กับ preview ตอนนั้น GetWorld() คืนค่า null pointer ได้ (หรือ world ที่ไม่พร้อม spawn) ดังนั้นเรียก SpawnActor บนมัน crash หรือทำงานผิดพลาดได้ กฎจากหัวข้อ 7 ใช้ได้พอดีตรงนี้: โค้ดที่ต้องพึ่ง world ควรอยู่ใน BeginPlay() ไม่ใช่ constructor

// MyActor.h  (just the two overrides that matter)
protected:
    virtual void BeginPlay() override;

// MyActor.cpp
AMyActor::AMyActor()
{
    PrimaryActorTick.bCanEverTick = false;
    // no world-dependent calls here
}

void AMyActor::BeginPlay()
{
    Super::BeginPlay();
    AActor* Other = GetWorld()->SpawnActor<AActor>(AActor::StaticClass());
}

พอถึงเวลาที่ BeginPlay รัน actor รับประกันว่าเป็นส่วนหนึ่งของ world ที่มีตัวตนอยู่จริงและกำลังรันอยู่แล้ว ดังนั้น GetWorld() ปลอดภัยที่จะใช้ตรงนั้นเสมอ

Exercise 3 ใช้แผนภาพ framework จากหัวข้อ 8 และตัวอย่าง trace จากหัวข้อ 12 เรียงสี่ event นี้ให้ถูกลำดับ และบอกด้วยว่าแต่ละอันเป็นหน้าที่ของ class ไหน (GameMode, PlayerController, หรือ Character/Pawn):
  • (a) BeginPlay() ของ Character รัน
  • (b) PlayerController possess Character
  • (c) GameMode อ่าน DefaultPawnClass แล้ว spawn Character
  • (d) GameMode spawn PlayerController
Show answer

ลำดับที่ถูก: (d), (c), (b), (a)

  • (d) เกิดก่อน — เป็นหน้าที่ของ GameMode มันต้องการ PlayerController ให้มีตัวตนก่อนถึงจะมีใครไป possess Pawn ได้
  • (c) เกิดต่อมา — เป็นหน้าที่ของ GameMode อีกครั้ง มันอ่าน DefaultPawnClass ของตัวเองแล้ว spawn Character ลง level ปกติที่ PlayerStart
  • (b) เกิดต่อมา — เป็นหน้าที่ของ PlayerController พอทั้งมันและ Character มีตัวตนแล้ว มัน possess Character และ OnPossess ยิงบน controller
  • (a) เกิดสุดท้ายในสี่ตัวนี้ — Character เองเป็นหน้าที่ของ BeginPlay ของตัวเอง ซึ่งยิงเมื่อมันเป็นส่วนหนึ่งของ world ที่กำลังรัน (จริง ๆ อันนี้เกิดใกล้เคียงกับการ possess มากได้ แต่ในทางตรรกะ Character ต้องมีตัวตนก่อน คือขั้นตอน (c) ก่อนที่มันจะถูก possess หรือรับ BeginPlay ได้)

Pattern ที่ควรจำ: GameMode มีหน้าที่แค่ สร้าง สิ่งของ (มันคือคัมภีร์กฎ ไม่ใช่ตัวตนทางกายภาพ), PlayerController คือตัวที่ เชื่อมต่อ input เข้ากับ Pawn และ BeginPlay ของ actor แต่ละตัวเป็นเรื่องการตั้งค่าของ actor ตัวเองล้วน ๆ ไม่เกี่ยวกับว่ามันถูก spawn มาทำไม

นั่นคือ Actor model กับ Gameplay Framework แบบภาพรวม ตอนนี้คุณรู้แล้วว่า Actor กับ Component คืออะไร และเทียบกับคู่ GameObject/Component ของ Unity ยังไง, ทำไม UCLASS/UPROPERTY/UFUNCTION ถึงมีอยู่และแต่ละตัวให้อะไรคุณบ้าง, รูปร่างของ lifecycle ของ Actor, และ GameMode, Pawn/Character, กับ PlayerController แบ่งหน้าที่การเริ่ม play session กันยังไง บทถัดไปจะต่อยอดจากตรงนี้โดยตรง: Gameplay Framework เพิ่มเติม แล้วก็ Blueprint/C++ interaction ที่ลึกขึ้น

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