C# (อ่านว่า "ซี ชาร์ป") เป็นภาษาโปรแกรมที่ Microsoft สร้างขึ้น เกมเอนจิน Unity ที่ใช้ทำเกมอย่างเกมของ HoYoverse เขียนโค้ดเกมทั้งหมดด้วย C# ดังนั้นถ้าอยากทำเกมบน Unity C# คือภาษาที่คุณต้องเขียนทุกวัน
คุณเพิ่งเรียนภาษา C จบไป C# หน้าตาคล้าย C อยู่บ้าง (ใช้ปีกกา เซมิโคลอน int if for เหมือนกัน) แต่ข้างในทำงานต่างกันมาก จุดต่างที่ใหญ่ที่สุดคือเรื่องหน่วยความจำ (memory) ใน C คุณขอ memory ด้วย malloc แล้วคืนด้วย free เองทุกครั้ง แต่ใน C# คุณแทบไม่ต้องทำแบบนั้นเลย เพราะภาษาจัดการคืน memory ให้เอง
ตอนคุณคอมไพล์ (compile) ภาษา C คอมไพเลอร์แปลงโค้ดของคุณเป็น machine code (เลขดิบ ๆ ที่ CPU รันได้) ตรง ๆ เลย แต่ C# มีขั้นตอนแทรกกลางเพิ่มมาอีกหนึ่งขั้น
.csนี่คือโปรแกรม C# ที่เล็กที่สุดที่พิมพ์ข้อความออกมา ยังไม่ต้องเข้าใจทุกคำ เดี๋ยวเราอธิบายทีละส่วนทีหลัง
using System;
class Program
{
static void Main()
{
Console.WriteLine("Hello from C#");
}
}
ผลลัพธ์ (output):
Hello from C#
เกิดอะไรขึ้น: Console.WriteLine พิมพ์ข้อความออกทางหน้าจอหนึ่งบรรทัด ส่วน Main คือ method (บล็อกโค้ดที่มีชื่อ) ที่โปรแกรมเริ่มทำงาน ใน C คุณก็มี main() เหมือนกัน แนวคิดเดียวกัน เพียงแต่ของ C# มันอยู่ข้างใน class (เดี๋ยวเราจะพูดถึง class เร็ว ๆ นี้)
using System; ข้างบนแปลว่า "ขอใช้เครื่องมือในกล่อง System นะ" ตัว Console อยู่ในกล่องนั้น ใน C ก็คล้าย ๆ #includeตัวแปร (variable) คือกล่องที่มีชื่อไว้เก็บค่า ส่วน ชนิด (type) บอก C# ว่ากล่องนี้เก็บค่าแบบไหน (เลขจำนวนเต็ม เลขทศนิยม ข้อความ จริง/เท็จ) C# ตรวจ type ให้คุณ คุณเลยเผลอเอาข้อความไปใส่ในที่ที่ควรเป็นตัวเลขไม่ได้
int hp = 100; // whole number
float speed = 5.5f; // decimal number (the f means "this is a float")
bool isAlive = true; // true or false
char grade = 'A'; // a single character
string name = "Kimchi";// text (many characters)
Console.WriteLine(hp);
Console.WriteLine(speed);
Console.WriteLine(isAlive);
Console.WriteLine(name);
ผลลัพธ์:
100
5.5
True
Kimchi
สังเกตว่า isAlive พิมพ์ออกมาเป็น True ขึ้นต้นตัว T ใหญ่ นั่นคือวิธีที่ C# แสดงค่า bool และสังเกตตัว f ที่ต่อท้าย 5.5 ด้วย C# ต้องการมันเพื่อจะได้รู้ว่าคุณหมายถึง float ไม่ใช่ double (type ทศนิยมที่ใหญ่และละเอียดกว่า) เกมมักใช้ float เพราะเล็กกว่าและเร็วพอ
ถ้าค่าทางขวามือทำให้ type ชัดเจนอยู่แล้ว คุณเขียน var ได้เลย แล้ว C# จะหา type ให้เอง ตัวแปรก็ยังมี type ตายตัวอยู่ดี var ไม่ได้แปลว่า "อะไรก็ได้" มันแค่เขียนสั้นลงเฉย ๆ
var count = 10; // C# sees 10, so count is an int
var title = "Boss"; // C# sees text, so title is a string
Console.WriteLine(count);
Console.WriteLine(title);
ผลลัพธ์:
10
Boss
นี่คือแนวคิดที่สำคัญที่สุดในบททั้งบท และมันโยงตรงกับเรื่อง pointer ที่คุณเพิ่งเรียนใน C ใน C# ทุก type เป็นหนึ่งในสองแบบนี้
int float bool char และ struct ทุกตัวclass ทุกตัว string และ arrayทวนจากบท C: stack คือ memory ที่เร็วและอยู่ชั่วคราวสำหรับตัวแปรในเครื่อง ส่วน heap คือแหล่งใหญ่สำหรับของที่ต้องอยู่นานกว่า value type มักวางอยู่บน stack ตรง ๆ ส่วนตัวแปรที่เป็น reference type ก็อยู่บน stack เหมือนกัน แต่ object ที่มันชี้ไปอยู่บน heap
ลองสร้าง struct (value type) แล้วก็อปมันดู
struct PointStruct
{
public int x;
public int y;
}
// ... inside Main ...
PointStruct a;
a.x = 1;
a.y = 2;
PointStruct b = a; // this copies the WHOLE thing
b.x = 99; // change b only
Console.WriteLine(a.x); // still 1
Console.WriteLine(b.x); // 99
ผลลัพธ์:
1
99
ตอนคุณเขียน PointStruct b = a; C# ก็อป a ออกมาเป็นก้อนใหม่แยกกันคนละอันเลย พอแก้ b.x จึงไม่ไปแตะ a มันเป็นสองกล่องที่ไม่เกี่ยวกัน
ทีนี้รูปแบบเดิม แต่ทำเป็น class (reference type) ดูสิว่าอะไรเปลี่ยน
class PointClass
{
public int x;
public int y;
}
// ... inside Main ...
PointClass c = new PointClass(); // new makes an object on the heap
c.x = 1;
c.y = 2;
PointClass d = c; // this copies the REFERENCE, not the object
d.x = 99; // change through d...
Console.WriteLine(c.x); // 99 (!!)
Console.WriteLine(d.x); // 99
ผลลัพธ์:
99
99
เซอร์ไพรส์: แก้ d.x แล้ว c.x เปลี่ยนตามด้วย ทำไม? เพราะ c กับ d ไม่ได้เก็บตัว object เอง แต่ทั้งคู่เก็บ reference ที่ชี้ไปยัง object ตัวเดียวกัน บน heap มันเหมือน pointer สองตัวใน C ที่เก็บ address เดียวกันเป๊ะ พอแก้สิ่งที่ address ชี้ไป ทั้งคู่ก็ "เห็น" การเปลี่ยนแปลงนั้น
คำว่า new คือสิ่งที่สร้าง object ขึ้นบน heap แล้วส่ง reference ที่ชี้ไปหามันกลับมาให้คุณ ใน C คุณทำแบบนี้ด้วย malloc แต่ใน C# คุณเขียน new แล้วไม่ต้องเรียก free เลย เพราะ garbage collector จะคืน object ให้ทีหลังเองเมื่อไม่มีใครชี้ไปหามันแล้ว
d = c คือการก็อปตัว object ออกมา สำหรับ class มันไม่ใช่ มันก็อปแค่ reference ถ้าอยากได้ object แยกก้อนจริง ๆ คุณต้องสร้างก้อนใหม่แล้วก็อปค่าฟิลด์เองreference type สามารถชี้ไปที่ "ไม่มีอะไร" ได้ เจ้า "ไม่มีอะไร" นั้นเรียกว่า null แปลว่า "ตัวแปรนี้ยังไม่ได้ชี้ไปที่ object ไหนเลย" ใน C มันคือ pointer ที่เป็น NULL นั่นเอง
ถ้าคุณพยายามใช้ object ผ่าน reference ที่เป็น null โปรแกรมจะแครช (crash) พร้อมกับ NullReferenceException นี่น่าจะเป็นการแครชที่โปรแกรมเมอร์ Unity มือใหม่เจอบ่อยที่สุด
PointClass p = null; // points to nothing
Console.WriteLine(p.x); // CRASH: there is no object to read x from
ผลลัพธ์:
Unhandled exception. System.NullReferenceException:
Object reference not set to an instance of an object.
วิธีแก้คือเช็ก null ก่อนจะใช้ object
PointClass p = null;
if (p != null)
{
Console.WriteLine(p.x);
}
else
{
Console.WriteLine("p is null, nothing to read");
}
ผลลัพธ์:
p is null, nothing to read
C# ยังมีวิธีเขียนสั้น ๆ ที่แปลว่า "ใช้มันก็ต่อเมื่อมันไม่ null นะ" คือตัวดำเนินการ ?. (เรียกว่า null-conditional operator)
PointClass p = null;
Console.WriteLine(p?.x); // if p is null, this gives null instead of crashing
ผลลัพธ์:
มันพิมพ์บรรทัดว่างออกมา เพราะ p?.x คืนค่าเป็น null (ไม่มี object อยู่) และการพิมพ์ null ก็ไม่แสดงอะไร ไม่แครช เจ้า ?. นี้มีประโยชน์มากในโค้ดเกมจริง
class คือพิมพ์เขียว (blueprint) ส่วน object คือของหนึ่งชิ้นที่สร้างจากพิมพ์เขียวนั้น ถ้า Enemy เป็น class สไลม์ตัวหนึ่งบนจอก็คือ object ของ class นั้น class เก็บของพวกนี้ได้
new เพื่อตั้งค่า object ให้พร้อมใช้class Enemy
{
public string name; // field
public int hp; // field
// constructor: runs when we write "new Enemy(...)"
public Enemy(string startName, int startHp)
{
name = startName;
hp = startHp;
}
// method: an action the enemy can do
public void TakeDamage(int amount)
{
hp = hp - amount;
Console.WriteLine(name + " took " + amount + " damage, hp = " + hp);
}
}
// ... inside Main ...
Enemy slime = new Enemy("Slime", 30);
slime.TakeDamage(10);
slime.TakeDamage(25);
ผลลัพธ์:
Slime took 10 damage, hp = 20
Slime took 25 damage, hp = -5
constructor รันหนึ่งครั้ง (ตั้ง name เป็น "Slime" และ hp เป็น 30) จากนั้นการเรียก TakeDamage แต่ละครั้งก็ไปแก้ hp ของ object เอง สังเกตว่า hp ลงไปถึง -5 โดยไม่มีอะไรมาห้าม ตรงนี้แหละที่ property ช่วยได้
property เมื่อมองจากข้างนอกจะเหมือน field แต่จริง ๆ มันรันโค้ดตอนคุณอ่านหรือเขียน ตรงนี้เราห้าม hp ตกต่ำกว่า 0
class Player
{
private int hp; // private: only this class can touch it directly
public int Hp
{
get { return hp; } // runs when someone reads Hp
set
{
if (value < 0) hp = 0; // "value" is the incoming number
else hp = value;
}
}
}
// ... inside Main ...
Player player = new Player();
player.Hp = -50; // the set block clamps it
Console.WriteLine(player.Hp); // reads through get
player.Hp = 80;
Console.WriteLine(player.Hp);
ผลลัพธ์:
0
80
การเขียน player.Hp = -50; กลายเป็น 0 เงียบ ๆ เพราะบล็อก set เช็กก่อน คำว่า value คือตัวเลขที่กำลังถูกกำหนดเข้ามา นี่คือจุดประสงค์ทั้งหมดของ property มันใช้ง่ายเหมือน field แต่ให้คุณใส่กฎเข้าไปได้
ถ้าไม่ต้องการกฎอะไรเลย C# มี "auto-property" แบบสั้นที่สร้าง field ซ่อนให้คุณเอง
class Item
{
public string Name { get; set; }
public int Price { get; set; }
}
// ... inside Main ...
Item sword = new Item();
sword.Name = "Iron Sword";
sword.Price = 120;
Console.WriteLine(sword.Name + " costs " + sword.Price);
ผลลัพธ์:
Iron Sword costs 120
สองฟีเจอร์นี้ช่วยให้ class ต่าง ๆ แชร์พฤติกรรมกันได้ ทั้งคู่ถูกใช้ตลอดในโค้ดเกม
inheritance (การสืบทอด) แปลว่า class หนึ่งสามารถสร้างต่อจาก class อื่น แล้วได้ field และ method ทั้งหมดของมันมาฟรี ๆ จากนั้นค่อยเพิ่มของใหม่ เราพูดว่า class ใหม่ "สืบทอดจาก" หรือ "ต่อยอด" class เก่า
class Character
{
public string name;
public void SayName()
{
Console.WriteLine("I am " + name);
}
}
// Mage inherits everything from Character, then adds CastSpell
class Mage : Character
{
public void CastSpell()
{
Console.WriteLine(name + " casts Fireball");
}
}
// ... inside Main ...
Mage m = new Mage();
m.name = "Lila";
m.SayName(); // came from Character
m.CastSpell(); // added by Mage
ผลลัพธ์:
I am Lila
Lila casts Fireball
ส่วน : Character แปลว่า "Mage เป็น Character ชนิดหนึ่ง" ดังนั้น Mage รู้จักวิธี SayName อยู่แล้วโดยไม่ต้องเขียนโค้ดนั้นซ้ำ method ของ class แม่สามารถทำเครื่องหมาย virtual ไว้เพื่อให้ class ลูกเขียนทับด้วย override ได้ แต่ตรงนี้เราขอเก็บไว้แบบง่าย ๆ ก่อน
interface คือรายการ method ที่ class สัญญาว่าจะมี โดยข้างในไม่มีโค้ด มันคือสัญญา (contract) class ไหนที่บอกว่า "ฉัน implement interface นี้" ก็ต้องมี method เหล่านั้นให้ครบ วิธีนี้ทำให้เราปฏิบัติกับ object ที่ต่างกันมากได้เหมือน ๆ กัน ตราบใดที่มันรักษาสัญญา
interface IDamageable
{
void TakeDamage(int amount); // just the promise, no body
}
class Barrel : IDamageable
{
public void TakeDamage(int amount)
{
Console.WriteLine("The barrel breaks into pieces!");
}
}
class Goblin : IDamageable
{
public void TakeDamage(int amount)
{
Console.WriteLine("Goblin yells and loses " + amount + " hp");
}
}
// ... inside Main ...
IDamageable target1 = new Barrel();
IDamageable target2 = new Goblin();
target1.TakeDamage(10);
target2.TakeDamage(10);
ผลลัพธ์:
The barrel breaks into pieces!
Goblin yells and loses 10 hp
object ทั้งสองถูกเก็บในตัวแปร type IDamageable และทั้งคู่เข้าใจ TakeDamage ทั้งที่ถังไม้กับก็อบลินไม่เหมือนกันเลย ในเกมจริงดาบของคุณแค่บอกว่า "ถ้าสิ่งที่ฉันตีเป็น IDamageable ก็เรียก TakeDamage" แล้วมันก็ใช้ได้กับถังไม้ ก็อบลิน ประตู อะไรก็ได้ ชื่อ interface มักขึ้นต้นด้วยตัว I ใหญ่ตามธรรมเนียม
บ่อยมากที่คุณต้องมีรายการของหลาย ๆ อย่าง เช่นศัตรูทุกตัวบนจอ ไอเทมทุกชิ้นในกระเป๋า C# มี collection สำเร็จรูปให้ใช้ สองตัวที่มีประโยชน์ที่สุดคือ List<T> และ Dictionary<TKey, TValue>
ส่วน <T> คือ generic generic แปลว่า "ใช้กับ type อะไรก็ได้ แต่คุณเลือกเอง" List<int> คือ list ของ int ส่วน List<string> คือ list ของ string ตัว T คือช่องว่างที่คุณเติมเอง วิธีนี้ทำให้ type-safe คือ List<int> จะปฏิเสธการเก็บข้อความ
List<string> party = new List<string>();
party.Add("Kimchi");
party.Add("Miso");
party.Add("Tofu");
Console.WriteLine(party.Count); // how many items
Console.WriteLine(party[0]); // read by position, starts at 0
foreach (string member in party) // visit each item in turn
{
Console.WriteLine(member);
}
ผลลัพธ์:
3
Kimchi
Kimchi
Miso
Tofu
List เหมือน array ใน C แต่ขยายได้ด้วย Add และมันรู้จำนวนของตัวเองผ่าน Count คุณอ่านสมาชิกด้วยตำแหน่ง (index) โดยเขียน party[0] (สมาชิกตัวแรกคือ index 0 เหมือน array ใน C) ส่วนลูป foreach จะไล่ทุกสมาชิกให้โดยที่คุณไม่ต้องคุม index เอง
dictionary เก็บเป็นคู่ ๆ คือ key กับ value คุณค้นหา value โดยใช้ key นึกถึงกระดานคะแนน: ชื่อผู้เล่นคือ key ส่วนคะแนนคือ value
Dictionary<string, int> scores = new Dictionary<string, int>();
scores["Kimchi"] = 10;
scores["Miso"] = 25;
Console.WriteLine(scores["Miso"]); // look up Miso's score
if (scores.ContainsKey("Tofu"))
{
Console.WriteLine(scores["Tofu"]);
}
else
{
Console.WriteLine("no score for Tofu yet");
}
ผลลัพธ์:
25
no score for Tofu yet
ค้นหา scores["Miso"] ได้ 25 มาทันที ควรเช็ก ContainsKey ก่อนอ่าน key ที่อาจไม่มีอยู่เสมอ เพราะการอ่าน key ที่ไม่มีจะโยน exception (แครช) ซึ่งเราจะเจอในหัวข้อ 11
หัวข้อนี้สำคัญกับ Unity มาก เพราะ Unity ใช้มันแทบทุกที่สำหรับ input และ UI (ตอนกดปุ่ม ตอนผู้เล่นโดนดาเมจ ฯลฯ)
delegate คือตัวแปรที่เก็บฟังก์ชัน ใน C คุณมี function pointer ส่วน delegate คือเวอร์ชันที่เป็นมิตรกว่าของ C# คุณเก็บฟังก์ชันไว้ในนั้นแล้วเรียกใช้ทีหลังได้
// a delegate TYPE: something that takes an int and returns nothing
delegate void DamageHandler(int amount);
class Program
{
static void PrintDamage(int amount)
{
Console.WriteLine("Damage dealt: " + amount);
}
static void Main()
{
DamageHandler handler = PrintDamage; // store the function
handler(15); // call it through the variable
}
}
ผลลัพธ์:
Damage dealt: 15
ตัวแปร handler เก็บฟังก์ชัน PrintDamage ไว้ การเรียก handler(15) ก็เหมือนเรียก PrintDamage(15) การที่ส่งฟังก์ชันไปมาได้เหมือนข้อมูลนี่แหละคือสิ่งที่ทำให้แนวคิดถัดไปเป็นไปได้
event คือวิธีที่ object หนึ่งประกาศว่า "มีอะไรเกิดขึ้นแล้วนะ!" แล้ว object อื่น ๆ คอยฟังและตอบสนอง object ที่ประกาศไม่จำเป็นต้องรู้ว่าใครกำลังฟังอยู่ C# มี delegate สำเร็จรูปชื่อ Action (ฟังก์ชันที่ไม่รับ argument และไม่คืนค่า) ที่เราเอามาใช้กับตัวฟังได้
class Button
{
public event Action OnClick; // a list of listeners
public void Press()
{
Console.WriteLine("Button pressed");
if (OnClick != null) // is anyone listening?
{
OnClick(); // tell every listener
}
}
}
// ... inside Main ...
Button b = new Button();
// += means "add me as a listener". These are lambdas (tiny inline functions).
b.OnClick += () => Console.WriteLine("Play click sound");
b.OnClick += () => Console.WriteLine("Open the menu");
b.Press();
ผลลัพธ์:
Button pressed
Play click sound
Open the menu
มีตัวฟังสองตัวสมัคร (subscribe) ด้วย += พอ Press ยิง OnClick() ตัวฟังทั้งสองก็ทำงานตามลำดับที่ถูกเพิ่มเข้ามา ส่วน () => ... คือ lambda (ฟังก์ชันเล็ก ๆ ไม่มีชื่อ เขียนตรงจุดที่ต้องใช้เลย) รูปแบบ "สมัครฟังไว้แล้วค่อยได้รับแจ้งทีหลัง" นี้คือวิธีที่ Unity จัดการปุ่ม UI และ input เป๊ะ ๆ เลย จำไว้ให้ดี
LINQ (Language Integrated Query) คือชุดเครื่องมือสำหรับตั้งคำถามกับ collection: กรอง เรียง นับ แปลงร่าง จบในบรรทัดเดียว คุณใช้มันได้โดยเพิ่ม using System.Linq; ไว้ข้างบน
using System;
using System.Collections.Generic;
using System.Linq;
// ... inside Main ...
List<int> numbers = new List<int> { 4, 9, 2, 15, 7, 20 };
// keep only the numbers bigger than 8
List<int> big = numbers.Where(n => n > 8).ToList();
foreach (int n in big)
{
Console.WriteLine(n);
}
ผลลัพธ์:
9
15
20
Where(n => n > 8) อ่านว่า "เก็บ n แต่ละตัวที่ n มากกว่า 8" ส่วน n => n > 8 ก็เป็น lambda อีกเช่นกัน เป็นเงื่อนไขเล็ก ๆ ที่เอาไปใช้กับทุกสมาชิก ส่วน ToList() รวบรวมตัวที่ผ่านเงื่อนไขมาเป็น list ใหม่ ถ้าไม่มี LINQ คุณต้องเขียนลูป for กับ if แต่ LINQ พูดเรื่องเดียวกันในบรรทัดเดียว
อีกตัวอย่างสั้น ๆ นับว่ามีเลขคู่กี่ตัว:
int evenCount = numbers.Count(n => n % 2 == 0);
Console.WriteLine(evenCount); // 4, 2, and 20 are even
ผลลัพธ์:
3
บางอย่างมันช้า เช่นโหลดไฟล์ใหญ่ ดาวน์โหลดข้อมูลจากเซิร์ฟเวอร์ รอสักครู่ ถ้าโปรแกรมของคุณนั่งรอเฉย ๆ ทั้งเกมจะค้างไปตลอดช่วงเวลานั้น async/await ช่วยให้คุณรอสิ่งที่ช้าได้โดยไม่ทำให้ทุกอย่างค้าง
async ทำเครื่องหมาย method ว่า "method นี้หยุดพักแล้วค่อยไปต่อทีหลังได้"await แปลว่า "พักตรงนี้จนกว่าสิ่งที่ช้าจะเสร็จ แต่ระหว่างนั้นให้งานอื่นทำต่อไปได้"using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Console.WriteLine("Start loading...");
await Task.Delay(1000); // wait 1 second (1000 ms) without freezing
Console.WriteLine("Done loading!");
}
}
ผลลัพธ์ (บรรทัดที่สองโผล่หลังบรรทัดแรกประมาณหนึ่งวินาที):
Start loading...
Done loading!
Task.Delay(1000) เป็นตัวแทนของงานช้า ๆ อะไรก็ได้ ตัว await พัก Main ไว้ที่บรรทัดนั้น แล้วอีกหนึ่งวินาทีถัดมามันก็กลับมาทำต่อจากจุดเดิมและพิมพ์บรรทัดที่สอง ตอนนี้แค่จำรูปแบบไว้: async ไว้ที่ method และ await ไว้หน้าการเรียกที่ช้า ใน Unity คุณจะใช้มันกับงานอย่างเช่นโหลดด่าน (level) อยู่เบื้องหลัง
เมื่อมีอะไรพังตอนรัน (null reference, index เกินขอบเขต, เลขที่ผิด) C# จะ โยน exception ถ้าไม่มีใครรับ โปรแกรมก็แครช บล็อก try / catch ช่วยให้คุณรับ error ไว้แล้วรันต่อได้
try
{
int[] nums = { 1, 2, 3 };
Console.WriteLine(nums[10]); // there is no index 10 -> throws
}
catch (IndexOutOfRangeException e)
{
Console.WriteLine("Caught a problem: " + e.Message);
}
Console.WriteLine("The program keeps running");
ผลลัพธ์:
Caught a problem: Index was outside the bounds of the array.
Program keeps running
โค้ดในบล็อก try รันไปจนถึงตอน nums[10] พัง แทนที่จะแครช การควบคุมกระโดดไปที่บล็อก catch ซึ่งพิมพ์ข้อความที่เป็นมิตรออกมาโดยใช้ e.Message (คำอธิบายของ exception) แล้วโปรแกรมก็ไปต่อ ใช้ try/catch ห่อสิ่งที่อาจพังด้วยเหตุนอกเหนือการควบคุมของคุณ (อ่านไฟล์ แปลงข้อความที่ผู้ใช้พิมพ์) อย่าใช้มันกลบบั๊กที่คุณควรจะแก้ตรง ๆ
เอนจินและเกมใหญ่ ๆ หลายเจ้าใช้ C++ ส่วนเกม Unity ใช้ C# จุดต่างที่ใหญ่ที่สุดในการทำงานประจำวันคือเรื่อง memory และมันมีทั้งดีและร้าย
ใน C และ C++ คุณเรียก free หรือ delete เอง ลืมเมื่อไหร่ก็ memory leak (memory รั่ว) ทำสองครั้งหรือใช้ pointer หลังจาก free ไปแล้วก็เจอบั๊กร้าย ๆ (dangling pointer แครช) ใน C# garbage collector จัดการเรื่องพวกนี้ให้หมด คุณเขียน new แล้วก็แค่เลิกใช้ object เดี๋ยว GC คืนให้ทีหลังเอง บั๊กสาย C/C++ ทั้งกลุ่มหายไปเลย
garbage collector ไม่ได้ฟรี ทุกครั้งที่คุณใช้ new สร้าง object บน heap คุณสร้าง garbage ทิ้งไว้ให้เก็บทีหลัง พอสะสมมากพอ GC ก็หยุดเกมของคุณชั่วขณะเพื่อไปเก็บกวาด ในโปรแกรมเอกสารทั่วไปการหยุดแบบนี้ไม่มีผลอะไร แต่ในเกมที่รัน 60 เฟรมต่อวินาที การหยุดแม้แค่ไม่กี่มิลลิวินาทีก็โผล่ออกมาเป็นอาการ กระตุก (stutter) ที่มองเห็นได้ นี่เรียกว่า GC spike
วิธีแก้คือ อย่าสร้าง garbage ใน hot path (โค้ดที่รันทุกเฟรม) มีสองนิสัยที่ควรทำ
new ข้างใน Update ถ้าเลี่ยงได้ ให้สร้าง object ครั้งเดียวแล้วใช้ซ้ำตอนนี้ยังไม่ต้องเชี่ยวชาญเรื่องนี้ แค่ปักไอเดียไว้ก่อน: ใน C++ คุณกังวลเรื่องคืน memory ส่วนใน C# คุณกังวลเรื่องอย่าสร้างมันมากเกินไปบ่อยเกินไป เราจะกลับมาพูดเรื่อง pooling ในบทหลัง
ทุกอย่างข้างบนคือ C# ล้วน ๆ ทีนี้มันไปโผล่ตรงไหนใน Unity? สคริปต์ Unity คือ class ของ C# ที่สืบทอดจาก class แม่พิเศษชื่อ MonoBehaviour เมื่อ class ของคุณสืบทอดจาก MonoBehaviour คุณจะแปะมันเข้ากับ object ในเกมได้ (ผู้เล่น ศัตรู) แล้ว Unity จะเรียก method บางตัวให้คุณโดยอัตโนมัติ คุณไม่ต้องเขียน Main เพราะ Unity เป็นฝ่ายเรียกเอง
สอง method ที่สำคัญที่สุดที่ Unity เรียก:
Start() รันครั้งเดียว ตอน object เพิ่งตื่นขึ้นมา เหมาะกับการตั้งค่าUpdate() รัน ทุกเฟรม ประมาณ 60 ครั้งต่อวินาที ตรงนี้คือที่ที่ใส่ตรรกะที่ต้องทำทุกเฟรม เช่นการเคลื่อนที่ input ตัวจับเวลาusing UnityEngine;
public class PlayerMover : MonoBehaviour
{
public float speed = 5f;
void Start()
{
Debug.Log("Player is ready"); // Debug.Log is Unity's Console.WriteLine
}
void Update()
{
// read the left/right input, scaled so it is smooth on any machine
float move = Input.GetAxis("Horizontal") * speed * Time.deltaTime;
transform.Translate(move, 0f, 0f); // move this object left/right
}
}
สิ่งที่ต้องสังเกต: ไม่มี Main เลย Unity เห็น class นี้แปะอยู่กับ game object ก็เรียก Start หนึ่งครั้ง แล้วเรียก Update ทุกเฟรม ส่วน Time.deltaTime คือเวลาที่ผ่านไปตั้งแต่เฟรมที่แล้ว การคูณด้วยมันทำให้การเคลื่อนที่มีความเร็วเท่ากันไม่ว่าเครื่องเร็วหรือช้า และเพราะ Update รันบ่อยมาก มันจึงเป็น "hot path" ตามหัวข้อ 12 เป๊ะ ที่ที่คุณต้องเลี่ยง new ที่ไม่จำเป็นและ LINQ หนัก ๆ
นั่นคือภาพรวมทั้งหมด: C# คือภาษา ส่วน MonoBehaviour กับ Update คือที่ที่ C# ของคุณได้รันจริงในเกม รายละเอียดลึก ๆ ของ Unity จะมาในบทหลัง ตอนนี้คุณมีตัวภาษาติดตัวไว้แล้ว
free เองint float bool struct) ก็อปทีก็อปทั้งค่าclass string array) ก็อปทีก็อปแค่ referencenullList<T>List หรือ Dictionaryn => n > 8try/catch รับไว้เพื่อให้โปรแกรมไปต่อได้Start และ Update ของมันให้struct และ class ที่มี field เหมือนกัน โค้ดนี้พิมพ์อะไรออกมา และเพราะอะไร
struct SBox { public int n; }
class CBox { public int n; }
// ... inside Main ...
SBox s1; s1.n = 5;
SBox s2 = s1; s2.n = 100;
CBox c1 = new CBox(); c1.n = 5;
CBox c2 = c1; c2.n = 100;
Console.WriteLine(s1.n);
Console.WriteLine(c1.n);
ผลลัพธ์:
5
100
SBox เป็น struct ซึ่งเป็น value type SBox s2 = s1; ก็อปมาเป็นก้อนใหม่ทั้งก้อน ดังนั้นการแก้ s2.n เป็น 100 จึงไม่กระทบ s1.n ที่ยังเป็น 5
CBox เป็น class ซึ่งเป็น reference type CBox c2 = c1; ก็อปแค่ reference ดังนั้น c1 กับ c2 ชี้ไปที่ object ก้อนเดียวกันบน heap การแก้ c2.n เป็น 100 จึงเปลี่ยนสิ่งที่ c1 เห็นด้วย นี่คือแนวคิดจากหัวข้อ 3 ในโจทย์เดียว
HealthBar ที่มี property Value ซึ่งไม่มีวันต่ำกว่า 0 หรือเกิน 100 การอ่านให้คืนตัวเลขที่เก็บไว้เฉย ๆ แล้วแสดงว่าตั้งเป็น 150 จะเก็บ 100 และตั้งเป็น -30 จะเก็บ 0class HealthBar
{
private int value;
public int Value
{
get { return value; }
set
{
if (value > 100) this.value = 100;
else if (value < 0) this.value = 0;
else this.value = value;
}
}
}
// ... inside Main ...
HealthBar hb = new HealthBar();
hb.Value = 150;
Console.WriteLine(hb.Value);
hb.Value = -30;
Console.WriteLine(hb.Value);
ผลลัพธ์:
100
0
บล็อก set เช็กค่า value ที่เข้ามาแล้วบีบให้อยู่ในช่วงก่อนเก็บ เราเขียน this.value เพื่อหมายถึง "ตัว field" เพราะ field กับคีย์เวิร์ด value ชื่อชนกันตรงนี้ นี่แหละคือเหตุผลที่ property มีอยู่ มันใช้เหมือน field ธรรมดาแต่บังคับกฎได้
List<int> hps = new List<int> { 12, 0, 5, 0, 30, 0, 8 };
using System;
using System.Collections.Generic;
using System.Linq;
// ... inside Main ...
List<int> hps = new List<int> { 12, 0, 5, 0, 30, 0, 8 };
int aliveCount = hps.Count(h => h > 0);
Console.WriteLine("Alive: " + aliveCount);
List<int> aliveHps = hps.Where(h => h > 0).ToList();
foreach (int h in aliveHps)
{
Console.WriteLine(h);
}
ผลลัพธ์:
Alive: 4
12
5
30
8
Count(h => h > 0) นับสมาชิกที่ hp มากกว่า 0 (มีสี่ตัว) ส่วน Where(h => h > 0) เก็บเฉพาะตัวเหล่านั้น แล้ว ToList() รวบรวมมาให้เราวนพิมพ์ได้ เป็นไอเดียการกรองเดียวกับหัวข้อ 9 เอามาใช้กับคำถามที่คล้ายในเกมจริง