ทุกบทที่ผ่านมาใช้ 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 ไปตลอดทาง เพราะการเทียบแบบนี้คือทางลัดที่เร็วที่สุดในการเรียนเอนจิ้นตัวที่สอง คุณไม่ได้เรียน "เกมเอนจิ้นคืออะไร" จากศูนย์ แต่กำลังเรียนว่า "เอนจิ้นตัวนี้ทำสิ่งที่คุณรู้จักอยู่แล้วยังไง"
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 — มันแก้ปัญหาเดียวกันแต่มีความเห็นต่างกันว่าโค้ดควรจัดระเบียบยังไง
ใน 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 แต่การแบ่งงานต่างกัน:
มีสองความต่างที่ควรพูดให้ชัด:
AEnemy, APickup, ADoor) แต่ละตัวอาจใช้ Component จำนวนหนึ่งสำหรับชิ้นส่วนที่ใช้ซ้ำได้ ไม่มีวิธีไหน "ถูกกว่า" กัน Unreal แค่ default ไปคนละทางกับ Unityแทบทุก engine class ที่คุณแตะใน Unreal — AActor, UActorComponent, แม้แต่ helper object ที่ไม่ใช่ Actor — สุดท้ายสืบทอดมาจาก root class เดียว: UObject AActor ก็คือ UObject ตัวหนึ่ง (ผ่านสาย base class) ซึ่งเป็นเหตุผลทั้งหมดที่ Actor ได้สามอย่างนี้มาฟรี ๆ ซึ่งไม่มีใน C++ ธรรมดา:
new กับ delete เอง (คุณเห็นแล้วในบท memory ว่ามันอันตรายแค่ไหน) UObject ถูกติดตามและปล่อยคืน memory อัตโนมัติโดย garbage collector ของ Unreal เอง ตราบใดที่คุณถือ reference ไปหามันตามที่เอนจิ้นคาดหวัง (นั่นคือส่วนหนึ่งของหน้าที่ UPROPERTY ในหัวข้อถัดไป)UObject ได้ตอน runtime: type ที่แน่นอน, parent class, รายการ property และ function ของมัน โดยไม่ต้องเขียน metadata แยกเอง C++ มาตรฐานไม่มี reflection ในตัวเลย — Epic สร้างระบบของตัวเองขึ้นมา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 ต้องเห็นทุกอย่างข้างบนมันก่อน
#include "MyActor.generated.h" ไว้ที่ไหนก็ได้ที่ไม่ใช่ท้ายสุด หรือลืมมันไปเลย ทั้งสองแบบทำให้ compile error ในโปรเจกต์ Unreal จริง ไฟล์ generated ต้องมาท้ายสุดเพราะมันขึ้นกับทุกอย่างที่ประกาศไว้ข้างบนในไฟล์เดียวกัน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 อ่าน มีสามตัวที่คุณจะใช้ตลอด:
UCLASS(...) — วางไว้เหนือ class declaration โดยตรง mark class นั้นเข้า reflection class ต้อง inherit จาก UObject (ทางตรงหรือทางอ้อม) และบรรทัดแรกในตัว class ต้องเป็น macro GENERATED_BODY() ซึ่งดึง boilerplate ที่ UHT generate ให้ class นี้เข้ามาUPROPERTY(...) — วางไว้เหนือ member variable โดยตรง เปิดให้ระบบข้างบนเข้าถึงตัวแปรนั้น: EditAnywhere ทำให้แก้ไขได้ใน Details panel ของ editor (ทุก instance), VisibleAnywhere โชว์แต่แก้ไม่ได้, BlueprintReadWrite / BlueprintReadOnly ให้ graph ของ Blueprint อ่าน (และอาจเขียน) มันได้, Category = "..." จัดกลุ่มมันใน Details panel มันยังมีหน้าที่เงียบ ๆ ที่สำคัญ: มันบอก garbage collector ว่าตัวแปรนี้ถือ reference ไปยัง UObject ตัวหนึ่ง ทำให้ collector จะไม่ปล่อยคืน object นั้นทิ้งไปโดยไม่บอกกล่าว raw C++ pointer ไปยัง UObject ที่ ไม่ mark UPROPERTY ถูก garbage-collect ทิ้งได้โดยไม่มีเตือน — เป็นสาเหตุ crash ที่เจอบ่อยจริง ๆ สำหรับมือใหม่UFUNCTION(...) — วางไว้เหนือ member function โดยตรง BlueprintCallable ให้ graph ของ Blueprint เรียกมันเหมือน node ได้, BlueprintImplementableEvent ประกาศ function ที่ implementation อยู่ใน Blueprint ทั้งหมด, specifier อย่าง Server, Client, NetMulticast mark มันเป็น remote-procedure call ผ่านเครือข่าย engine lifecycle method บางตัว (อย่าง BeginPlay คุยกันในหัวข้อ 7) เป็น UFUNCTION อยู่แล้วใน base class ดังนั้น override ของคุณไม่ต้องใส่ macro ซ้ำนี่คือ 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 ที่แก้ไขได้:
วางสามข้อดีเทียบกัน: 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 คู่เดียว สามผลตอบแทน
ตอนนี้ reflection เข้าใจแล้ว กลับมาที่ตัว Actor เอง AActor คือ base class สำหรับทุกอย่างที่อยู่ใน level ได้: ตัวละคร, แสง, pickup, trigger volume ที่มองไม่เห็น แม้แต่ camera ทุก Actor มีสิ่งเหล่านี้จาก base class อย่างเดียว ก่อนที่คุณจะเพิ่มอะไรเลย:
RootComponent ของมันBeginPlay, Tick, EndPlayGetWorld()->SpawnActor<T>(...) และ Destroy() — นี่คือเวอร์ชัน Unreal ของ Instantiate กับ Destroy ของ Unityสังเกตว่าชื่อ 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() ว่างเปล่า
Actor เดี่ยว ๆ มี transform กับ lifecycle call แต่ไม่มีอะไรให้ render และไม่มี collision คุณเพิ่มความสามารถพวกนี้ได้โดย attach Component — object (สืบทอดจาก UActorComponent) ที่เสียบเข้ากับ Actor และทำงานอย่างละหนึ่งอย่าง สามตัวที่คุณจะเจอทันที:
USceneComponent — base สำหรับ component ที่มี transform ของตัวเองและ attach ใต้ตัวอื่นได้ กลายเป็นโครงสร้างต้นไม้ RootComponent ของ Actor ปกติเป็น USceneComponent (หรือ subclass ของมัน)UStaticMeshComponent — render mesh 3D แบบ static (ไม่ขยับ) เทียบเท่า MeshFilter + MeshRenderer ของ Unity รวมกันเป็น component เดียวUCameraComponent — จุดมอง camera เทียบเท่า component Camera ของ Unityใน 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 มีตัวตนอยู่ในโลก:
pointer ของ component ทุกตัวที่นี่ก็เป็น VisibleAnywhere ด้วย ดังนั้นเลือก APickupActor ที่วางไว้จะเห็น component ทั้งสองตัวอยู่ใน Details panel — คุณ (หรือ designer) กำหนด mesh asset จริงให้ Mesh ได้โดยไม่ต้องเขียนโค้ดเพิ่ม
ทุก Actor ผ่านลำดับ call เดียวกัน ถ้าคุณรู้จัก Awake/Start/Update/OnDestroy ของ Unity อยู่แล้ว อันนี้จะรู้สึกคุ้นเคย แค่ชื่อต่างและมีกับดักสำคัญอันหนึ่ง
AMyActor()) ตรงนี้คือที่คุณเรียก CreateDefaultSubobject ไม่เหมือน Awake ของ Unity, constructor ยังรันสำหรับ object ที่มีตัวตนแค่เป็น editor data เท่านั้น (Class Default Object ใช้ preview และเก็บค่า default ใน editor) ไม่ใช่แค่ actor ที่ถูกวางในเกมที่กำลังรัน เพราะแบบนั้น โค้ดตรงนี้ควรจำกัดอยู่แค่ตั้งค่า component กับค่า default — อย่าสมมติว่า level, actor อื่น, หรือ game world ที่กำลังรันมีตัวตนอยู่แล้วBeginPlay() — ใกล้เคียง Start() ของ Unity ที่สุด ถูกเรียกครั้งเดียวเป๊ะ ๆ เมื่อ actor เป็นส่วนหนึ่งของ world ที่กำลังรันอยู่จริงเท่านั้น ตรงนี้คือที่คุณหา reference ไป actor อื่น, เริ่ม timer, หรือเล่น effect เปิดตัวTick(float DeltaTime) — ใกล้เคียง Update() ของ Unity ที่สุด ถูกเรียกทุกเฟรม แต่ต้อง opt-in ก่อน: PrimaryActorTick.bCanEverTick = true; ใน constructor Unreal ตั้งใจให้ tick เป็น opt-in ต่อ actor แต่ละตัว — Tick override เปล่า ๆ บน actor ที่ไม่ได้ใช้เป็นพัน ๆ ตัวคือ overhead เปล่าประโยชน์ เอนจิ้นเลยบังคับให้คุณขอมันชัดเจนEndPlay(EEndPlayReason::Type Reason) — ใกล้เคียง OnDestroy() ของ Unity ที่สุด ถูกเรียกครั้งเดียวเมื่อ actor กำลังจะหยุดมีตัวตนใน world ที่กำลังรัน ไม่ว่าจะเพราะถูกทำลายโดยตรง, level กำลัง unload, หรือ play mode กำลังหยุดตัวอย่างที่แตะสามตัวนี้พร้อมกัน คือ 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 ค่าล่าสุดที่มันไปถึง
GetWorld() — ไว้ใน constructor แทนที่จะเป็น BeginPlay() constructor รันได้แม้กับ object ที่เป็น editor-only preview ที่ไม่มี running world เลย ดังนั้นโค้ดที่ต้องพึ่ง world ตรงนั้น crash ได้ หรือไม่ทำอะไรเลยแบบเงียบ ๆ ได้ Constructor: สร้าง component กับตั้งค่า default Tick: ทุกอย่างที่ต้องการ game world ที่มีตัวตนอยู่จริงSuper::BeginPlay(), Super::Tick(DeltaTime), และ Super::EndPlay(...) เสมอในตัว override ของคุณ รีเฟล็กซ์เดียวกับเรียก base.Start() ใน Unity script ที่ override method ของ base class logic ของ base class เองมักสำคัญ — ข้ามมันแล้วอะไรบางอย่างจะพังในแบบที่ตามหาสาเหตุยากUnity ให้ scene เปล่า ๆ กับคุณโดยไม่มีความเห็นเลยว่า "player", "match", หรือ "input" ควรจัดโครงสร้างยังไง — คุณสร้าง GameManager singleton กับ player controller script เองตั้งแต่ศูนย์ ทุกโปรเจกต์ Unreal มี starter structure ที่มีความเห็นชัดเจนสำหรับเรื่องนี้โดยเฉพาะ เรียกว่า Gameplay Framework: ชุด built-in Actor class เล็ก ๆ ที่หน้าที่ถูกแบ่งไว้แล้ว และรู้วิธีคุยกันเองอยู่แล้ว บทนี้เน้นสี่ตัว:
AGameModeBase (หรือญาติที่รองรับ multiplayer AGameMode) — คัมภีร์กฎ ตัดสินว่า class ไหนจะถูก spawn สำหรับ matchAPawn — อะไรก็ตามในโลกที่ controller (มนุษย์หรือ AI) possess และขับได้ACharacter — APawn พิเศษที่ทำมาสำหรับตัวละครสองขาเดินได้ มี movement กับ collision ต่อสายไว้ให้แล้วAPlayerController — object ที่แทนการเชื่อมต่อและ input ของผู้เล่นมนุษย์คนหนึ่ง แยกจาก Pawn ที่มันกำลังขับอยู่นี่คือวิธีที่ session single-player ต่อสี่ตัวนี้เข้าด้วยกันตอน Play เริ่ม:
อ่านแผนภาพเป็นลำดับ: GameMode spawn PlayerController กับ Pawn ตัวหนึ่ง (ใช้ class ไหนก็ตามที่มันถูกบอกให้ใช้) จากนั้น PlayerController possess Pawn (แปลว่า: input จากจุดนี้ไปถูก route เข้า Pawn ตัวนั้น) และ Pawn เหมือน Actor ทุกตัว เป็นเจ้าของ Component ที่มันต้องการเพื่อให้หน้าตาและพฤติกรรมเป็นอะไรสักอย่าง ในโปรเจกต์ใหญ่ขึ้น โดยเฉพาะ multiplayer คุณจะเจอ AGameState (ข้อมูล match ที่ share ให้ทุกคนเห็น อย่างคะแนน) กับ APlayerState (ข้อมูลรายผู้เล่น อย่างคะแนนของผู้เล่นคนหนึ่ง) ด้วย — มันอยู่ข้าง ๆ แผนภาพนี้แต่อยู่นอกขอบเขตของบทนี้
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 เลยโดยไม่ตั้งใจไม่ได้
APawn คือ base class สำหรับอะไรก็ตามในโลกที่ Controller — มนุษย์หรือ AI — possess และขับได้ ไม่ใช่ทุก Actor ที่เป็น Pawn: ประตู, ของประดับแบบ static, trigger volume เป็น Actor แต่ไม่มีทางเป็น Pawn เพราะไม่มีใครมีไว้ "ขับ" มัน Pawn คือ Actor ประเภทเฉพาะที่ทำมาให้รับ control input
ACharacter เป็น subclass ของ APawn ทำมาสำหรับตัวละครเดินสองขาโดยเฉพาะ มันเพิ่มสองอย่างที่ APawn ธรรมดาไม่มีตั้งแต่แรก:
UCapsuleComponent เป็น root ของมัน — collision volume รูปแคปซูล เป็นรูปทรงมาตรฐานสำหรับตัวละครที่เดิน (นี่คือเวอร์ชัน Unreal ของ component CharacterController ของ Unity ซึ่งก็ใช้แคปซูลเหมือนกัน)UCharacterMovementComponent — จัดการกฎเดิน, วิ่ง, กระโดด, ตก, และว่ายน้ำให้คุณ รวมถึง slope limit กับความสูงที่ step-up ได้ ใน Unity คุณเขียนเองบน CharacterController หรือใช้ method ในตัวไม่กี่ตัวของมันตรง ๆ เวอร์ชันของ Unreal เป็นระบบในตัวที่ใหญ่กว่าและครบกว่า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 มันอยู่ คุยต่อในหัวข้อถัดไป
Super::SetupPlayerInputComponent(PlayerInputComponent); ไว้บนสุดของ override ถ้า class คุณถูกสืบทอดต่อไปอีก หรือ base class bind input ของตัวเอง การข้าม Super call ทำให้ behavior นั้นหายไปแบบเงียบ ๆ — กับดักเดียวกับลืม Super::BeginPlay()ใน 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
หัวข้อ 8 โชว์แผนภาพนิ่ง ตรงนี้คือภาพเดียวกันในรูปแบบ event trace เพื่อให้เห็นลำดับที่สิ่งต่าง ๆ เกิดขึ้นจริงเมื่อคุณกด Play ใน editor สมมติ: AMyGameMode จากหัวข้อ 9 ใช้ AMyCharacter จากหัวข้อ 10 กับ AMyPlayerController จากหัวข้อ 11 และทุก class log BeginPlay ของตัวเอง
ใส่เข้า 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 เป๊ะ ๆ
ตารางอ้างอิงตัวเดียว รวบรวมทุกอย่างที่บทนี้แตะไว้:
ธีมที่อยู่ใต้ตาราง: Unity มีความเห็นในตัวน้อยกว่าและคาดหวังให้คุณสร้าง convention ของตัวเอง (GameManager ของตัวเอง, pattern player script ของตัวเอง) ทุกโปรเจกต์ Unreal ให้ structure ในตัวมากกว่า (GameMode, การแบ่ง Pawn/Character, PlayerController) และวิธี expose สิ่งต่าง ๆ เข้า editor กับ Blueprint ที่หนักกว่า ขับเคลื่อนด้วย macro แลกกับ structure นั้น ไม่มีตัวไหนดีกว่ากันแบบเบ็ดเสร็จ — มันคือ trade-off ต่างกันระหว่าง "ยืดหยุ่น สร้างเองหมด" กับ "มีความเห็น เสียบเข้า framework" — และการรู้ทั้งสองแบบหมายความว่าคุณเปิดประกาศงานของเอนจิ้นไหนก็ได้แล้วจำรูปร่างของโค้ดได้ทันทีก่อนเปิดโปรเจกต์ด้วยซ้ำ
AActor) — base class สำหรับทุกอย่างที่วางลงใน level ได้ มี transform และ lifecycle call ในตัวUActorComponent) — object ที่ attach เข้ากับ Actor เพิ่มความสามารถอย่างหนึ่ง (mesh, camera, collision, movement).generated.h ของแต่ละ classUCLASS() — mark class เข้า reflection ต้องมี GENERATED_BODY() เป็นบรรทัดแรกใน class bodyUPROPERTY(...) — mark member variable ให้ editor exposure, Blueprint access, serialization, และ garbage-collector trackingUFUNCTION(...) — mark member function ให้ Blueprint access หรือ remote call ผ่านเครือข่ายGENERATED_BODY() — macro ที่ฉีด boilerplate ที่ UHT สร้างเข้าไปใน reflected classTSubclassOf<T> — reference แบบ type-safe ไปยัง class (ไม่ใช่ instance) ที่ต้องเป็น T หรือ subclass ของมันBeginPlay() — ถูกเรียกครั้งเดียวเมื่อ actor เริ่มมีตัวตนใน world ที่กำลังรัน ใกล้เคียง Start() ของ Unity ที่สุดTick(float DeltaTime) — ถูกเรียกทุกเฟรมถ้าเปิดใช้งาน ใกล้เคียง Update() ของ Unity ที่สุดEndPlay(Reason) — ถูกเรียกครั้งเดียวเมื่อ actor ถูกเอาออกจาก world ที่กำลังรัน ใกล้เคียง OnDestroy() ของ Unity ที่สุดAGameModeBase) — Actor คัมภีร์กฎของ level/session ตัดสินว่า Pawn กับ PlayerController class ไหนจะถูก spawnAPawn) — base class สำหรับอะไรก็ตามที่ Controller possess และขับได้ACharacter) — Pawn subclass ที่ทำมาสำหรับตัวละครเดินได้ มี capsule collider กับ movement component มาให้แล้วAPlayerController) — แทนการเชื่อมต่อ input ของผู้เล่นมนุษย์ แยกจาก Pawn ที่มัน possessCreateDefaultSubobject<T>(...) — call ตอน constructor ที่สร้าง component เริ่มต้นของ ActorUE_LOG — macro logging ของ Unreal เขียนลงหน้าต่าง Output Log ของ editorATimedActor ในหัวข้อ 7 ตอนนี้ Tick ของมัน log "been alive over 5 seconds" ทุก เฟรมเมื่อ TimeAlive เกิน 5.0 แทนที่จะเป็นครั้งเดียว เพิ่ม member variable และแก้ Tick เล็กน้อยให้ข้อความพิมพ์แค่ครั้งเดียวเป๊ะ ๆ บอก type และ UPROPERTY (หรือ member ธรรมดา แล้วแต่คุณ) ที่คุณเพิ่ม และโชว์ function Tick ที่แก้แล้วเพิ่ม 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()
AMyActor::AMyActor()
{
PrimaryActorTick.bCanEverTick = false;
AActor* Other = GetWorld()->SpawnActor<AActor>(AActor::StaticClass());
}
อธิบายว่าอะไรผิด แล้วเขียนใหม่ให้ถูก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() ปลอดภัยที่จะใช้ตรงนั้นเสมอ
GameMode, PlayerController, หรือ Character/Pawn):
BeginPlay() ของ Character รันDefaultPawnClass แล้ว spawn Characterลำดับที่ถูก: (d), (c), (b), (a)
GameMode มันต้องการ PlayerController ให้มีตัวตนก่อนถึงจะมีใครไป possess Pawn ได้GameMode อีกครั้ง มันอ่าน DefaultPawnClass ของตัวเองแล้ว spawn Character ลง level ปกติที่ PlayerStartPlayerController พอทั้งมันและ Character มีตัวตนแล้ว มัน possess Character และ OnPossess ยิงบน controllerBeginPlay ของตัวเอง ซึ่งยิงเมื่อมันเป็นส่วนหนึ่งของ 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 ที่ลึกขึ้น