จนถึงตอนนี้คุณเรียน C, C++, C# และ data structure แบบแยกเดี่ยว ๆ ไม่ผูกกับ engine ตัวไหนเป็นพิเศษ เริ่มจากบทนี้คุณจะเปิด Unity แล้วสร้างเกมที่รันได้จริงด้วยมันเอง แทบทุกอย่างที่ Unity ทำ — ขยับตัวละคร, เล่น animation, รัน physics, อ่านค่าจาก controller, วาดภาพขึ้นจอ — เกิดขึ้นภายใต้ไอเดียเดียวที่วนซ้ำไปเรื่อย ๆ คือ game loop เข้าใจ loop นี้แล้วส่วนที่เหลือของ engine จะไม่รู้สึกเหมือนเวทมนตร์อีกต่อไป
ทุกหัวข้อข้างล่างนี้มีรูปแบบเดียวกัน: โค้ด C# สั้น ๆ ที่รันได้จริง (หรือ trace มือเมื่อไม่มี console ให้พิมพ์ output) แล้วอธิบายว่ามันทำอะไรและทำไม บางสคริปต์สมมติว่ามี Unity GameObject ที่แนบสคริปต์นั้นไว้แล้ว แบบที่คุณรู้จักมาจากบทก่อน ๆ
เกมไม่ใช่โปรแกรมที่รันครั้งเดียวแล้วจบ มันคือโปรแกรมที่รันงานก้อนเล็ก ๆ ก้อนหนึ่ง แสดงผลลัพธ์ให้เห็น แล้วรันซ้ำทันที — ปกติ 30 ถึง 240+ ครั้งต่อวินาที การรันงานก้อนนั้นหนึ่งรอบเรียกว่า frame (เฟรม) ส่วนตัวงานก้อนนั้นเองคือ game loop ซึ่งมีหน้าที่หลัก 3 อย่างเรียงตามลำดับคือ:
อ่าน input, update โลกของเกมตามนั้น, วาดโลกใหม่ขึ้นจอ, แล้ววนซ้ำ นี่คือไอเดียทั้งหมด ทุกเกมที่คุณเคยเล่น ตั้งแต่ text adventure ไปจนถึงเกมแข่งรถ ล้วนสร้างจาก loop แบบนี้ในเวอร์ชันใดเวอร์ชันหนึ่ง นี่คือรูปร่างของมันเขียนเป็น C# ธรรมดา นอกกรอบของ Unity เพื่อให้คุณเห็นมันด้วยตาตัวเองก่อนที่ Unity จะซ่อนมันไป:
using System;
class ToyLoop
{
static void Main()
{
bool isRunning = true;
int frame = 0;
while (isRunning)
{
frame++;
Console.WriteLine("frame " + frame + ": input -> update -> render");
if (frame >= 5) isRunning = false; // stop after 5, just for this demo
}
}
}
Output:
frame 1: input -> update -> render
frame 2: input -> update -> render
frame 3: input -> update -> render
frame 4: input -> update -> render
frame 5: input -> update -> render
ในเกมจริง loop นี้ไม่หยุดเองเด็ดขาด — มันวนต่อไปเรื่อย ๆ ตราบใดที่เกมยังรันอยู่ นั่นคือเหตุผลที่มันต้องเร็ว: ทุก ๆ เฟรมมันต้องอ่าน input, ขยับทุกอย่าง, แล้ววาดทั้งจอ และต้องทำทั้งหมดนี้ให้เสร็จในเสี้ยววินาทีเล็ก ๆ ไม่งั้นเกมจะรู้สึกช้าและกระตุก
คุณไม่มีทางเขียน while (isRunning) เองใน Unity Unity มี loop นั้นอยู่แล้วข้างใน — ภายในเรียกว่า player loop — และมันรันอยู่ก่อนโค้ดของคุณจะเกิดขึ้นด้วยซ้ำ และรันต่อไปหลังเกมปิดไปแล้ว สิ่งที่คุณเขียนแทนคือชิ้นส่วนพฤติกรรมเล็ก ๆ แล้ว loop ของ Unity จะเรียกมันให้คุณเองตอนที่ถูกต้อง ทุกเฟรม โดยอัตโนมัติ ชิ้นที่พบบ่อยที่สุดคือ method ชื่อ Update ข้างใน MonoBehaviour (คลาสฐานที่ Unity script ทุกตัวที่คุณแนบกับ GameObject สืบทอดมาจากมัน):
using UnityEngine;
public class LoopDemo : MonoBehaviour
{
void Update()
{
// Unity calls this once per frame, automatically.
Debug.Log("Update ran at time " + Time.time);
}
}
คุณไม่ได้เขียน loop ไว้ตรงไหนในสคริปต์นั้นเลย แต่ Update ก็ยังรันหนึ่งครั้งทุกเฟรมตราบใดที่ GameObject active และ enabled อยู่ นี่คือทริกทั้งหมดของ engine: มันเป็นเจ้าของ loop เอง แล้วเปิดช่องให้โค้ดของคุณเข้าไปแทรกที่จุดตายตัวข้างในนั้น ที่เหลือของบทนี้พูดถึงจุดพวกนั้น — อะไรรันตอนไหน, "เฟรม" หนึ่งเฟรมกินเวลาจริงเท่าไหร่ และจะอ่าน input ให้ถูกต้องข้างใน loop นั้นได้ยังไง
MonoBehaviour มี loop-callback method มากกว่าหนึ่งตัว (Update, FixedUpdate, LateUpdate, และอีกไม่กี่ตัว) แต่ละตัวถูกเรียกโดย player loop ของ Unity ที่จุดที่ต่างกันเฉพาะเจาะจงในเฟรม หัวข้อ 3 กับ 4 จะอธิบายว่าเมื่อไหร่และทำไมอย่างละเอียดไม่ใช่ทุกเฟรมที่ใช้เวลาจริงเท่ากัน เฟรมที่มีศัตรู 3 ตัวบนจออาจใช้เวลา 8 มิลลิวินาที ส่วนเฟรมที่ระเบิดลูกหนึ่งสร้าง particle พันตัวอาจใช้เวลา 30 มิลลิวินาที Unity วัดว่าเฟรมก่อนหน้าใช้เวลานานแค่ไหน แล้วส่งตัวเลขนั้นให้คุณผ่าน Time.deltaTime — เป็น float หน่วยวินาที ที่เปลี่ยนค่าทุกเฟรม "Delta" แปลว่า "การเปลี่ยนแปลงของ" — deltaTime คือ "การเปลี่ยนแปลงของเวลา" นับจากเฟรมที่แล้ว
ทำไมเรื่องนี้ถึงสำคัญ? เพราะถ้าคุณขยับอะไรบางอย่างด้วยจำนวนคงที่ทุกเฟรมแทนที่จะเป็นทุกวินาที ความเร็วจริงของมันจะขึ้นอยู่กับว่าเครื่องของผู้เล่นรันเร็วแค่ไหน:
using UnityEngine;
public class MoverBad : MonoBehaviour
{
public float speed = 5f;
void Update()
{
// BAD: moves 5 units every FRAME, not every SECOND.
transform.position += Vector3.right * speed;
}
}
บนเครื่องที่รัน 30 เฟรมต่อวินาที object ตัวนี้เคลื่อนที่ 150 หน่วยทุกวินาที บนเครื่องที่รัน 60 เฟรมต่อวินาที โค้ดตัวเดียวกันเป๊ะ ๆ เคลื่อนที่ 300 หน่วยทุกวินาที — เร็วเป็นสองเท่า โดยที่ speed ไม่ได้เปลี่ยนเลย นี่คือบั๊กชัด ๆ: คอมพิวเตอร์ที่แรงกว่าไม่ควรทำให้ตัวละครของคุณกลายเป็นตัวละครอีกตัวที่เร็วกว่า ทางแก้คือ scale การเคลื่อนที่ด้วย Time.deltaTime เปลี่ยนจาก "หน่วยต่อเฟรม" ให้กลายเป็น "หน่วยต่อวินาที":
using UnityEngine;
public class MoverGood : MonoBehaviour
{
public float speed = 5f; // units per SECOND
void Update()
{
// GOOD: scaled by deltaTime, so it always moves 5 units per second.
transform.position += Vector3.right * speed * Time.deltaTime;
}
}
ลองไล่ด้วยมือดูหนึ่งวินาทีที่ frame rate สองแบบต่างกัน:
คุณสมบัตินี้ — ผลลัพธ์เหมือนเดิมไม่ว่าเวลารวมเท่ากันนั้นจะถูกแบ่งเป็นเฟรมยังไง — เรียกว่า frame-rate independence เวลาไหนก็ตามที่คุณขยับ, หมุน, ปรับขนาด, fade หรือเปลี่ยนค่าตัวเลขไปตามเวลาข้างใน Update ให้เอา rate ต่อวินาที คูณด้วย Time.deltaTime
Time.deltaTime กับค่าที่เปลี่ยนไปตามเวลา — health ที่ฟื้นคืน, fade-out, cooldown timer — เป็นบั๊กเริ่มต้นที่พบบ่อยที่สุดอันหนึ่งใน Unity ปกติมัน "ดูเหมือนทำงานถูก" ตอนทดสอบ (เพราะ frame rate เครื่องคุณค่อนข้างคงที่) แล้วค่อยพังให้เห็นชัด ๆ บนเครื่องที่ช้ากว่าหรือเร็วกว่า หรือตอนที่ frame rate ตกฮวบในฉากที่งานหนักTime.deltaTime ตามที่คุณเพิ่งเห็น มีค่าต่างกันทุกเฟรม — นั่นคือ variable timestep มันใช้ได้ดีเลยกับโค้ด gameplay ส่วนใหญ่ และกับอะไรก็ตามที่ผู้เล่นแค่มองเห็น เช่นการเคลื่อนที่หรือ animation แต่มันสร้างปัญหาจริงจังให้กับ physics และเพื่อจะเข้าใจว่าทำไม ลองย้อนกลับไปนึกถึงบทแคลคูลัสดู
จำได้ไหม: velocity คืออัตราการเปลี่ยนแปลงของตำแหน่ง และ acceleration คืออัตราการเปลี่ยนแปลงของ velocity — นี่คือ derivative (อนุพันธ์) ส่วน physics engine ต้องทำย้อนกลับ: ให้ acceleration มา (เช่นแรงโน้มถ่วง) กับ velocity เริ่มต้น มันต้องหาตำแหน่งใหม่ตามเวลาที่ผ่านไป — นี่คือ integral (ปริพันธ์) เพราะไม่มีสูตรสวย ๆ สำหรับแรงในเกมที่กำหนดเองได้ทุกแบบ engine จึงประมาณค่า integral แบบตัวเลข ทีละก้าวเล็ก ๆ เวอร์ชันง่ายที่สุดของวิธีนี้คือ Euler integration ไอเดียเดียวกับ numerical method ในบทแคลคูลัส: เอา rate ปัจจุบัน สมมติว่ามันคงที่ตลอดก้าวเล็ก ๆ ขนาด dt แล้วบวก rate * dt เข้าไปในผลรวม
using UnityEngine;
public class SimpleFall : MonoBehaviour
{
public float gravity = -9.81f;
private float velocityY = 0f;
void FixedUpdate()
{
// Euler integration -- same idea as the calculus chapter:
// velocity changes by acceleration * dt, position changes by velocity * dt.
velocityY += gravity * Time.fixedDeltaTime;
transform.position += new Vector3(0f, velocityY * Time.fixedDeltaTime, 0f);
}
}
ลองไล่ 5 tick ด้วย fixed step ค่าเริ่มต้น 0.02 วินาที:
ประเด็นสำคัญคือ: เหมือน numerical approximation ทั่วไป Euler integration จะแม่นยำและ stable (หมายถึงไม่ระเบิดกลายเป็นตัวเลขเพี้ยน ๆ) ได้แค่ไหนก็ขึ้นอยู่กับขนาด step ของมัน step เล็กและสม่ำเสมอจะตาม physics จริงได้ใกล้เคียง ส่วน step ใหญ่หรือไม่สม่ำเสมอจะสะสม error เร็วมาก และ physics ไวต่อเรื่องนี้เป็นพิเศษ — object ที่เร็วอาจทะลุกำแพงบาง ๆ ไปเลยในหนึ่ง step ที่ใหญ่เกินไป หรือกองลังอาจระเบิดกระจายทันทีเพราะ error ทำให้ลังสองใบซ้อนทับกัน แล้ว engine ก็ดันมันแยกออกจากกันแรงเกินไป เพราะ deltaTime ของ Update อาจพุ่งสูงได้ทุกครั้งที่เกมสะดุด (โหลด texture, garbage collection มาหยุดพัก, เฟรมที่งานหนัก) มันจึงเป็นนาฬิกาที่แย่สำหรับ physics คำตอบของ Unity คือ loop callback อีกตัวแยกต่างหาก ที่มี step คงที่ไม่เปลี่ยนแปลงของตัวเอง: FixedUpdate ขับเคลื่อนด้วย Time.fixedDeltaTime (ค่าเริ่มต้น 0.02 วินาที หรือ 50 ครั้งต่อวินาที — คุณเปลี่ยนได้ใน Project Settings แต่มันจะไม่แปรผันไปมาระหว่างเฟรมแบบที่ deltaTime ของ Update เป็น)
กฎง่าย ๆ คือ: อะไรก็ตามที่ใช้ physics ของ Unity (Rigidbody, Rigidbody2D, หรือ force/velocity ที่คุณตั้งเองเพื่อขยับ physics object) ควรอยู่ใน FixedUpdate โดยใช้ Time.fixedDeltaTime ส่วนที่เหลือ — อ่าน input, ขยับ Transform ตรง ๆ, animation, UI — ควรอยู่ใน Update โดยใช้ Time.deltaTime
FixedUpdate พยายาม "ตามให้ทัน" ไปเรื่อย ๆ หลังจากสะดุดหนัก ๆ — มี setting ชื่อ Maximum Allowed Timestep ที่คอยจำกัดมันไว้ ถ้าไม่มี cap นี้ การสะดุดที่ใหญ่พอจะทำให้เกมใช้เวลานานมากไปกับการรัน physics step ไล่ตาม จนทำให้เฟรมถัดไปสะดุดตามไปด้วย แล้วก็ตามหลังไปเรื่อย ๆ ไม่มีที่สิ้นสุด อาการพังแบบนี้มีชื่อเรียกว่า spiral of death การมี cap คือการแลกความแม่นยำของ physics แบบสมบูรณ์แบบ กับการที่เกมยังตอบสนองได้อยู่ ซึ่งแทบทุกครั้งเป็นการแลกที่คุ้มค่าทีนี้เอาหัวข้อ 1 ถึง 3 มารวมกัน ข้างในหนึ่งเฟรมของ Unity โค้ดของคุณจะรันที่จุดต่าง ๆ กันหลายจุด เรียงลำดับเดิมเสมอ:
LateUpdate มีไว้เฉพาะสำหรับโค้ดที่ต้องตอบสนองต่อสิ่งที่เกิดขึ้นไปแล้วใน Update ของเฟรมนี้ ตัวอย่างคลาสสิกคือกล้องที่ตามผู้เล่น:
using UnityEngine;
public class CameraFollow : MonoBehaviour
{
public Transform target;
public Vector3 offset = new Vector3(0f, 5f, -10f);
void LateUpdate()
{
// runs AFTER every Update this frame, so target has already moved
transform.position = target.position + offset;
}
}
ถ้าโค้ดนี้รันใน Update แทน มันอาจรันก่อนที่ Update ของผู้เล่นเองจะขยับผู้เล่นในเฟรมนี้ (Unity ไม่การันตีว่า Update ของสคริปต์ไหนจะรันก่อนกัน) ทำให้กล้องไปตามตำแหน่งของเฟรมที่แล้วแทน — lag หนึ่งเฟรมแบบนี้จะโผล่มาเป็นอาการสั่นเล็ก ๆ (jitter) โดยเฉพาะตอนกล้องขยับเร็ว การเอา camera-follow และอะไรก็ตามที่ต้องพึ่งพา "ทุกอย่างไปจบที่ไหนในเฟรมนี้" ไปไว้ใน LateUpdate จะกำจัดปัญหาเรื่องลำดับนี้ไปได้เลย
state คือโหมดที่เกมทั้งเกมอยู่ในนั้น ซึ่งเกมจะทำงานต่างกันไปคนละแบบเลยขึ้นอยู่กับว่าโหมดไหน active อยู่ main menu รอการกดปุ่มและไม่รัน gameplay Playing รัน gameplay และไม่โชว์เมนู Paused หยุด gameplay ค้างไว้และโชว์ pause panel Game over หยุด gameplay และโชว์หน้าจอผลลัพธ์ สี่อย่างนี้คือชุดเริ่มต้นคลาสสิกของเกมแทบทุกเกม และ state machine ก็แค่โค้ดที่มีหน้าที่ทั้งหมดคือติดตามว่า "ตอนนี้อันไหนกำลัง active อยู่" แล้วสลับระหว่างมันอย่างเรียบร้อย
state machine แบบง่ายที่สุดคือ enum (type ที่เก็บได้แค่ค่าเดียวจากรายการชื่อค่าที่กำหนดตายตัวไว้) บวกกับ switch statement:
using UnityEngine;
public class GameManagerSimple : MonoBehaviour
{
public enum GameState { Menu, Playing, Paused, GameOver }
public GameState currentState = GameState.Menu;
void Update()
{
switch (currentState)
{
case GameState.Menu:
UpdateMenu();
break;
case GameState.Playing:
UpdatePlaying();
break;
case GameState.Paused:
UpdatePaused();
break;
case GameState.GameOver:
UpdateGameOver();
break;
}
}
void UpdateMenu()
{
if (Input.GetKeyDown(KeyCode.Return))
{
Debug.Log("Menu -> Playing");
currentState = GameState.Playing;
}
}
void UpdatePlaying()
{
if (Input.GetKeyDown(KeyCode.Escape))
{
Debug.Log("Playing -> Paused");
currentState = GameState.Paused;
}
}
void UpdatePaused()
{
if (Input.GetKeyDown(KeyCode.Escape))
{
Debug.Log("Paused -> Playing");
currentState = GameState.Playing;
}
}
void UpdateGameOver()
{
if (Input.GetKeyDown(KeyCode.Return))
{
Debug.Log("GameOver -> Menu");
currentState = GameState.Menu;
}
}
}
ลองไล่ด้วยมือดูสำหรับลำดับการกดปุ่มสั้น ๆ:
player presses Enter: Menu -> Playing
player presses Escape: Playing -> Paused
player presses Escape: Paused -> Playing
player presses Escape: Playing -> Paused
นี่คือ state กับ transition ชุดเดียวกันในรูปแบบภาพ:
สไตล์ enum + switch นี้ใช้ได้และโอเคเลยสำหรับ prototype เล็ก ๆ แต่ลองดูว่าจะเกิดอะไรขึ้นเมื่อเกมโตขึ้น: โค้ด setup ของทุก state, logic รายเฟรมของทุก state, และโค้ด exit/cleanup ของทุก state ต่างก็ไปอัดกันอยู่ในคลาส GameManagerSimple คลาสเดียว เพิ่ม state ที่ห้าเข้าไป — สมมติว่าเป็น cutscene — คุณก็ต้องมาแก้คลาสยาว ๆ อันนี้อีกครั้ง ต้องไล่หา logic ของอีกสี่ state เพื่อหาที่ใส่โค้ดใหม่
State pattern แก้ปัญหาความอัดแน่นนี้ด้วยการให้แต่ละ state มีคลาสเล็ก ๆ ของตัวเอง แทนที่จะเป็น branch หนึ่งใน switch ที่ใช้ร่วมกัน ทุกคลาส state ยึดตาม contract เดียวกัน — interface — ที่มี 3 method: Enter (รันครั้งเดียว ตอนที่ state นั้นเพิ่งกลาย active), Tick (รันทุกเฟรมตราบใดที่ state นั้นยัง active), และ Exit (รันครั้งเดียว ตอนที่ state นั้นหยุด active พอดีก่อนสลับไป state ถัดไป)
public interface IGameState
{
void Enter();
void Tick(); // called every frame while this state is active
void Exit();
}
คลาส StateMachine เล็ก ๆ ตัวหนึ่งเก็บไว้ว่า "ตอนนี้ state ไหนกำลังทำงานอยู่" และไม่ได้ทำอะไรซับซ้อนไปกว่าการเรียก method ที่ถูกต้องตอนเวลาที่ถูกต้อง:
public class StateMachine
{
private IGameState currentState;
public void ChangeState(IGameState newState)
{
currentState?.Exit(); // clean up the old state, if any
currentState = newState;
currentState.Enter(); // set up the new state
}
public void Tick()
{
currentState?.Tick();
}
}
ทีนี้แต่ละ state ก็เป็นคลาสเล็ก ๆ อิสระของตัวเอง:
using UnityEngine;
public class MenuState : IGameState
{
private readonly StateMachine machine;
public MenuState(StateMachine machine) { this.machine = machine; }
public void Enter() { Debug.Log("enter MENU"); }
public void Exit() { Debug.Log("exit MENU"); }
public void Tick()
{
if (Input.GetKeyDown(KeyCode.Return))
machine.ChangeState(new PlayingState(machine));
}
}
public class PlayingState : IGameState
{
private readonly StateMachine machine;
public PlayingState(StateMachine machine) { this.machine = machine; }
public void Enter() { Debug.Log("enter PLAYING"); }
public void Exit() { Debug.Log("exit PLAYING"); }
public void Tick()
{
if (Input.GetKeyDown(KeyCode.Escape))
machine.ChangeState(new PausedState(machine));
}
}
public class PausedState : IGameState
{
private readonly StateMachine machine;
public PausedState(StateMachine machine) { this.machine = machine; }
public void Enter() { Time.timeScale = 0f; Debug.Log("enter PAUSED"); }
public void Exit() { Time.timeScale = 1f; Debug.Log("exit PAUSED"); }
public void Tick()
{
if (Input.GetKeyDown(KeyCode.Escape))
machine.ChangeState(new PlayingState(machine));
}
}
แล้ว MonoBehaviour เล็ก ๆ ตัวหนึ่งก็แค่ขับเคลื่อน machine — มันไม่รู้อะไรเลยเกี่ยวกับเมนู, การ pause หรือ gameplay เอง:
using UnityEngine;
public class GameManager : MonoBehaviour
{
private StateMachine machine;
void Start()
{
machine = new StateMachine();
machine.ChangeState(new MenuState(machine));
}
void Update()
{
machine.Tick();
}
}
ลองไล่ลำดับการกดปุ่มชุดเดิมผ่านเวอร์ชันนี้ดู:
(game starts) enter MENU
player presses Enter: exit MENU
enter PLAYING
player presses Esc: exit PLAYING
enter PAUSED
player presses Esc: exit PAUSED
enter PLAYING
สังเกตสิ่งที่เวอร์ชัน enum ทำได้ไม่สะอาดเท่า: PausedState.Enter ตั้งค่า Time.timeScale = 0f (หยุด physics และโค้ดไหนก็ตามที่ใช้การเคลื่อนที่แบบอิง deltaTime) และ PausedState.Exit ก็ตั้งค่ากลับเป็น 1f อย่างแน่นอนทันทีที่คุณออกจาก pause ไม่ว่าจะออกด้วยวิธีไหนก็ตาม มีที่เดียวเป๊ะ ๆ ที่ setup กับ teardown แบบนี้อยู่ และมันลืมไม่ได้เพราะไม่ได้ซ่อนอยู่ใน branch ไหนของ switch ยักษ์ การเพิ่ม state ที่ห้า (cutscene, loading screen) หมายถึงแค่เขียนคลาสใหม่หนึ่งคลาส — คลาส state เดิมทุกตัวไม่ต้องแตะเลย
bool CanEnter() หรือคล้าย ๆ กัน เพื่อให้ state ปฏิเสธการ transition ได้ (เช่น ปฏิเสธที่จะออกจาก GameOver จนกว่า animation หน้าจอผลลัพธ์จะเล่นจบ) คุณไม่จำเป็นต้องใช้สิ่งนี้ในเกมแรกของคุณ แต่มันเป็นส่วนต่อขยายตามธรรมชาติเมื่อคุณเข้าใจ Enter/Tick/Exit แล้วcurrentState = newState ตรง ๆ แทนที่จะผ่าน ChangeState ซึ่งจะข้าม Exit() ไปเลย นี่คือสาเหตุตรง ๆ ที่ทำให้เกมค้างอยู่ที่ Time.timeScale = 0f ถาวรหลังออกจาก pause — การหยุดถูกตั้งไว้ใน Enter แต่ไม่มีใครเรียก Exit คู่กันมายกเลิกมันเลย ให้ผ่าน transition method เดียวเสมอมีวิธีพื้นฐานสองแบบที่ต่างกันโดยสิ้นเชิงที่โค้ดจะรู้ว่าปุ่มถูกกด Polling หมายถึงถามคำถามเองทุกเฟรม: "ปุ่มนี้กดอยู่ตอนนี้ไหม?" Event หมายถึงตรงข้ามกัน — คุณลงทะเบียนฟังก์ชันไว้ล่วงหน้าครั้งเดียว แล้วระบบจะเรียกฟังก์ชันนั้นให้คุณเองตอนที่สิ่งนั้นเกิดขึ้น โดยที่คุณไม่ต้องถามเลย
using UnityEngine;
public class PollingExample : MonoBehaviour
{
void Update()
{
// POLLING: ask the question every single frame.
if (Input.GetKeyDown(KeyCode.Space))
{
Debug.Log("jump (polled)");
}
}
}
นี่คือไอเดียเดียวกันแต่ใช้ C# event แทน — ฟีเจอร์ในตัวภาษาสำหรับ "ให้โค้ดอื่น subscribe ฟังก์ชันไว้ให้ถูกเรียกตอนมีอะไรเกิดขึ้น":
using System;
using UnityEngine;
public class Button : MonoBehaviour
{
public event Action OnPressed; // other code subscribes to this
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
OnPressed?.Invoke(); // fire the event -- tell everyone listening
}
}
public class Listener : MonoBehaviour
{
public Button button;
void Start()
{
// EVENT: we register once, and get called only when it happens.
button.OnPressed += HandlePress;
}
void HandlePress()
{
Debug.Log("jump (event)");
}
}
ทั้งสองสคริปต์พิมพ์ข้อความ jump ทันทีที่กด Space ดังนั้นพฤติกรรมที่เห็นดูเหมือนกันเป๊ะตรงนี้ — เพราะข้างใต้ฝากระโปรง Button ยัง polling ทุกเฟรมอยู่ดี มันแค่ซ่อน polling นั้นไว้หลัง event เพื่อให้ Listener ไม่ต้องทำเองอีกที การซ่อนนี้แหละคือประเด็นจริง ๆ ลองนึกภาพระบบสิบระบบต่างก็สนใจปุ่ม jump: player controller, เสียงเอฟเฟกต์, tutorial hint, achievement tracker ถ้าใช้ polling ทั้งสิบระบบต้องเขียน Input.GetKeyDown check ของตัวเองทุกเฟรม ถ้าใช้ event ที่เดียว poll ครั้งเดียว แล้วสิบที่ subscribe
package input รุ่นใหม่ของ Unity เองเน้นสไตล์ event หนักมาก แล้วยังไปไกลกว่านั้นอีกขั้น: มันยิง event ได้ตรงจากข้อความ hardware ของระบบปฏิบัติการเลย ทำให้ไม่มีส่วนไหนในเกมของคุณต้องรัน polling loop ของตัวเองเพื่อให้ event เกิดขึ้นได้ด้วยซ้ำ package นั้นคือหัวข้อถัดไป
การอ่าน KeyCode.Space ตรง ๆ แบบตัวอย่างที่ผ่านมา คือการ hardcode เกมของคุณให้ผูกกับปุ่มคีย์บอร์ดปุ่มเดียว ถ้าคุณอยากให้ผู้เล่นที่ใช้ gamepad กระโดดได้ด้วย คุณต้องเพิ่ม check ที่สองสำหรับปุ่มของ gamepad แล้วก็ที่สามสำหรับการแตะจอสัมผัส กระจายอยู่ใน method เดียวกัน package Input System ของ Unity แก้ปัญหานี้ด้วยการเพิ่มชั้นของการตั้งชื่อคั่นกลาง: แทนที่โค้ดจะถามถึงปุ่มหรือคีย์เจาะจงตัวหนึ่ง มันถามถึง action ที่มีชื่อ เช่น "Jump" แล้ว action นั้นก็ถูก bind แยกไว้กับ physical control ตัวไหนก็ตามที่ควรสั่งให้มันทำงาน — space bar, ปุ่ม South ของ gamepad, พื้นที่สัมผัสหน้าจอ พร้อมกันทั้งหมดเลยก็ได้
using UnityEngine;
using UnityEngine.InputSystem;
public class JumpAction : MonoBehaviour
{
public InputActionReference jumpActionRef; // assigned in the Inspector
void OnEnable()
{
// EVENT: Unity calls HandleJump only when the action "performs".
jumpActionRef.action.performed += HandleJump;
jumpActionRef.action.Enable();
}
void OnDisable()
{
jumpActionRef.action.performed -= HandleJump;
jumpActionRef.action.Disable();
}
void HandleJump(InputAction.CallbackContext context)
{
Debug.Log("jump (Input System event)");
}
}
HandleJump ไม่สนใจเลยว่า action นั้นถูกยิงเพราะคีย์บอร์ด, gamepad, หรือจอสัมผัส — การตัดสินใจนั้นถูกทำไว้ครั้งเดียวตอน binding ไม่ได้กระจัดกระจายอยู่ในโค้ด gameplay ของคุณ
action ถูกจัดกลุ่มเป็น action map ที่มีชื่อ — action ที่เกี่ยวข้องกันซึ่งมักจะ active พร้อมกัน เกมทั่วไปจะมี map "Gameplay" (Move, Jump, Pause) กับ map "UI" (Navigate, Submit, Cancel) และปกติจะเปิดใช้แค่ map เดียวในแต่ละครั้ง:
เรื่องนี้เชื่อมกลับไปที่หัวข้อ 6 โดยตรง component PlayerInput (component ของ Unity ที่จัดการ action asset ทั้งชุดให้คุณ) มี method SwitchCurrentActionMap ให้เรียกใช้ และที่ที่เหมาะจะเรียกมันคือข้างใน method Enter ของคลาส state ของคุณ:
using UnityEngine.InputSystem;
public class InputMapSwitcher : MonoBehaviour
{
public PlayerInput playerInput; // has "Gameplay" and "UI" action maps
public void EnterPaused()
{
playerInput.SwitchCurrentActionMap("UI"); // jump/move stop firing
}
public void EnterPlaying()
{
playerInput.SwitchCurrentActionMap("Gameplay");
}
}
ทันทีที่ PausedState.Enter สลับไปที่ map "UI" ปุ่ม Space จริง ๆ ก็เลิกหมายถึง "Jump" ไปเลย — ตอนนี้มันอาจไม่หมายถึงอะไรเลย หรือหมายถึง "Submit" บนปุ่มเมนู ขึ้นอยู่กับว่า map "UI" bind มันไว้กับอะไร โค้ด gameplay ของคุณไม่ต้องมี guard แบบ if (state == Playing) อยู่ที่ไหนเลย ตัว input system เองปฏิเสธที่จะยิง action จาก map ที่ disabled อยู่แล้ว การ rebind — ให้ผู้เล่นเปลี่ยนว่าปุ่มไหนหมายถึง "Jump" ในเมนู option — ก็สร้างบนไอเดียเดียวกันนี้เอง: คุณแค่เปลี่ยนว่า control ไหน bind กับอะไร ไม่เคยต้องแตะชื่อ action หรือโค้ด gameplay ที่อ่านมันเลย
Enable() บน action หรือเรียก SwitchCurrentActionMap ไปยังชื่อ map ผิด ทำให้ action ไม่ยิงเลยเงียบ ๆ แล้วดูเหมือนไม่มีอะไรเกิดขึ้นตอนกดปุ่ม เพราะไม่มี error message บอกว่า "action ตัวนี้ไม่เคยถูก enable" นี่เป็นหนึ่งในบั๊กแรก ๆ ที่งงที่สุดกับ Input System — ถ้ากดปุ่มแล้วไม่มีอะไรเกิดขึ้น ให้เช็ค map กับการเรียก Enable() ก่อนที่จะสรุปว่าโค้ด gameplay ของคุณผิดInput.GetKeyDown (และ event performed ของ Input System) จะเป็น true แค่ในเฟรมเดียวเป๊ะ ๆ ที่การกดเกิดขึ้น ถ้าตัวละครของคุณทำอะไรไม่ได้ในเฟรมนั้นพอดี — เช่น ปุ่ม jump ถูกกดก่อนตัวละครจะลงพื้นจริง ๆ หนึ่งเฟรม การกดนั้นก็หายไปเฉย ๆ การกดรัว ๆ ก็ช่วยไม่ได้ เพราะการกดจริงหนึ่งครั้งให้เฟรมที่เป็น true แค่เฟรมเดียว ถ้าเฟรมนั้นผิดจังหวะ มันก็หายไปเลย สำหรับผู้เล่นแล้วมันรู้สึกเหมือนเกม "กิน" การกดที่จังหวะสมบูรณ์แบบไปดื้อ ๆ และนี่เป็นหนึ่งในคำบ่นที่พบบ่อยที่สุดเกี่ยวกับเกม platformer ที่นอกนั้นเล่นดีทุกอย่าง
ทางแก้คือจดจำไว้ว่าการกดเกิดขึ้นแล้วสำหรับช่วงเวลาสั้น ๆ — เรียกว่า buffer — แทนที่จะเช็คแค่จังหวะเดียวที่มันเกิดขึ้น แล้วนำมันไปใช้ทันทีที่เกมพร้อม:
using UnityEngine;
public class JumpBuffer : MonoBehaviour
{
public float bufferTime = 0.15f; // how long a press "stays valid"
private float bufferTimer = -1f; // -1 means "no buffered press"
public bool isGrounded; // set elsewhere by a ground check
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
bufferTimer = bufferTime; // remember: "jump was requested"
}
if (bufferTimer > 0f)
{
bufferTimer -= Time.deltaTime;
if (isGrounded)
{
Debug.Log("jump! (buffered)");
bufferTimer = -1f; // consumed, clear it
}
}
else
{
bufferTimer = -1f;
}
}
}
ลองไล่กรณีผู้เล่นกด jump ก่อนลงพื้นเล็กน้อย โดยมี buffer 0.15 วินาที:
ช่วง buffer ประมาณ 100-150 มิลลิวินาทีเป็นค่าที่ใช้กันทั่วไป มันสั้นพอที่ผู้เล่นจะกด jump ล่วงหน้าเป็นวินาทีเต็ม ๆ แล้ว "เก็บสะสมไว้" ไม่ได้ แต่ก็ยาวพอที่จะครอบคลุมเวลาตอบสนองปกติของมนุษย์ และอีกไม่กี่เฟรมของ animation หรือ physics ที่กำลังนิ่งตัว
Input buffering ให้อภัยการกดที่เกิดเร็วไปนิดหน่อย ส่วน coyote time ให้อภัยกรณีตรงข้าม: การกดที่เกิดช้าไปนิดหน่อย ทันทีหลังจากตัวละครเดินตกขอบแท่นไปแล้ว ชื่อนี้มาจากโคโยตี้ในการ์ตูนที่วิ่งตกหน้าผาไปตรง ๆ แล้วไม่ตกจนกว่าจะก้มมองลงไป — สำหรับอีกไม่กี่เฟรมหลังออกจากพื้นแข็ง ตัวละครยังถูกอนุญาตให้กระโดดได้เหมือนยังอยู่บนพื้น ถ้าไม่มีสิ่งนี้ ผู้เล่นที่ก้าวออกจากแท่นแล้วกด jump ช้าไปเสี้ยววินาที — ซึ่งสำหรับมนุษย์แล้วรู้สึกเหมือนจังหวะสมบูรณ์แบบ — จะไม่ได้อะไรเลย เพราะ check แบบเข้มงวด if (isGrounded) เห็นว่าพวกเขาลอยอยู่กลางอากาศไปแล้ว
using UnityEngine;
public class JumpWithCoyoteTime : MonoBehaviour
{
public float bufferTime = 0.15f;
public float coyoteTime = 0.1f;
private float bufferTimer = -1f;
private float coyoteTimer = 0f;
public bool isGrounded;
void Update()
{
// track how recently we were grounded
if (isGrounded)
coyoteTimer = coyoteTime; // reset the grace window
else
coyoteTimer -= Time.deltaTime; // grace window ticking away
if (Input.GetKeyDown(KeyCode.Space))
bufferTimer = bufferTime;
else
bufferTimer -= Time.deltaTime;
bool canJump = coyoteTimer > 0f; // grounded OR just left the ground
bool wantsToJump = bufferTimer > 0f; // pressed recently
if (canJump && wantsToJump)
{
Debug.Log("jump! (coyote time saved it)");
bufferTimer = -1f;
coyoteTimer = 0f; // used up -- no double jump from this
}
}
}
ลองไล่กรณีผู้เล่นเดินตกขอบแท่นแล้วกด jump ช้าไป 60 มิลลิวินาที โดยมี coyote window 0.1 วินาที:
เอาทั้งสอง window มาวางบน timeline เดียวกัน แล้วไอเดียก็จะเห็นภาพชัดขึ้น: การกระโดดจะสำเร็จเมื่อไหร่ก็ตามที่การกดกับจังหวะ "ใกล้เคียงกับอยู่บนพื้นพอ" มาซ้อนทับกัน
สคริปต์ jump ทุกตัวที่ผ่านมาเรียก Input.GetKeyDown ตรง ๆ ข้างใน method เดียวกับที่ตัดสินใจว่าจะกระโดดจริงหรือไม่ นั่นก็โอเคสำหรับ demo เล็ก ๆ แต่มันแอบผสมงานสองอย่างที่แยกกันเข้าไปในโค้ดชิ้นเดียว: การหาว่าผู้เล่นต้องการอะไร (อ่าน hardware) กับการตัดสินใจว่าตัวละครจะทำอะไรกับมัน (game logic) การผสมสองอย่างนี้เข้าด้วยกันสร้างปัญหาจริงจังเมื่อโปรเจกต์โตขึ้น: คุณเทสต์ logic การกระโดดโดยไม่มีคีย์บอร์ดจริงเสียบอยู่ไม่ได้ คุณ replay การเล่นที่บันทึกไว้หรือขับตัวละครเดียวกันด้วย AI ง่าย ๆ โดยไม่ต้องแก้โค้ดการเคลื่อนที่ก็ไม่ได้ และทุกที่ที่อ่าน Input.* ตรง ๆ ก็เป็นอีกที่หนึ่งที่ต้องคอยเคารพ game state จากหัวข้อ 5 กับ 6 แยกกันเอง (input ควรทำอะไรไหมตอนเกม pause อยู่?)
ทางแก้คือชั้นกลาง (middle layer) เล็ก ๆ — input reader — ที่เปิดเผยแค่ "ตอนนี้ผู้เล่นต้องการอะไร" ในรูปข้อมูลธรรมดา และไม่มีส่วนอื่นในเกมได้รับอนุญาตให้อ่าน Input.* หรือ Input System ตรง ๆ อีก
public interface IPlayerInput
{
float MoveInput { get; } // -1 to 1
bool JumpPressed { get; } // true only on the press frame
}
using UnityEngine;
public class KeyboardInputReader : MonoBehaviour, IPlayerInput
{
public float MoveInput { get; private set; }
public bool JumpPressed { get; private set; }
void Update()
{
// this is the ONLY place in the whole game that touches Input.*
MoveInput = Input.GetAxis("Horizontal");
JumpPressed = Input.GetKeyDown(KeyCode.Space);
}
}
ตอนนี้ character controller พึ่งพาแค่ interface เท่านั้น ไม่พึ่ง Input เองเลย:
using UnityEngine;
public class PlayerController : MonoBehaviour
{
public MonoBehaviour inputSource; // must implement IPlayerInput
private IPlayerInput input;
public float speed = 5f;
void Awake()
{
input = inputSource as IPlayerInput;
}
void Update()
{
// PlayerController never reads Input.* directly -- only the interface
transform.position += Vector3.right * input.MoveInput * speed * Time.deltaTime;
if (input.JumpPressed)
Debug.Log("jump!");
}
}
นี่คือผลตอบแทน คลาสที่สอง implement interface ตัวเดียวกันเป๊ะโดยไม่มี hardware เกี่ยวข้องเลย — เป็นรูทีนสคริปต์เล็ก ๆ ที่ขับ tutorial, cutscene หรือ AI ชั่วคราวได้:
using UnityEngine;
public class ScriptedInputReader : MonoBehaviour, IPlayerInput
{
public float MoveInput { get; private set; } = 1f; // always walk right
public bool JumpPressed { get; private set; }
private float timer;
void Update()
{
timer += Time.deltaTime;
JumpPressed = (timer > 2f && timer < 2.02f); // jump once, at t=2s
}
}
แค่สลับว่าสคริปต์ไหนถูก assign ให้ inputSource ใน Inspector PlayerController ก็รันเหมือนกันทั้งสองแบบ โดยไม่ต้องแก้โค้ดของตัวมันเองเลยสักนิด คลาสเดียวกันนี้ตอนนี้ขับผู้เล่นจริง, cutscene ที่สคริปต์ไว้, หรือ — ทีหลังพอคุณเรียนเรื่อง testing — automated test ที่เช็คสูตรคำนวณการเคลื่อนที่โดยไม่ต้องแตะคีย์บอร์ดเลย นี่ยังเป็นที่ที่เหมาะที่สุดสำหรับการเช็ค game state จากหัวข้อ 6 ด้วย: ให้ input reader รายงาน MoveInput = 0 กับ JumpPressed = false เมื่อไหร่ก็ตามที่เกมไม่ได้อยู่ใน state Playing แล้ว PlayerController ก็ไม่จำเป็นต้องรู้ด้วยซ้ำว่าการ pause มีอยู่จริง
PlayerController รู้แค่ contract เล็ก ๆ ของ IPlayerInput ไม่เคยรู้เลยว่าคลาสจริง ๆ ข้างหลังมันคืออะไร การเก็บโค้ดที่ผูกกับ engine เฉพาะและเทสต์ยาก (การอ่าน hardware จริง) ไว้ในชั้นบาง ๆ แยกต่างหาก ห่างจาก core gameplay logic ของคุณ เป็นนิสัยที่มีประโยชน์ที่สุดอย่างหนึ่งที่คุณสร้างได้ตั้งแต่เนิ่น ๆMenuState, PlayingState, PausedState) เกมเริ่มขึ้น แล้วผู้เล่นกดปุ่มตามลำดับนี้: Enter, Esc, Esc, Esc, Enter ให้ระบุทุกบรรทัดที่โค้ดจะ Debug.Log เรียงตามลำดับ รวมถึงจังหวะที่การกดปุ่มไม่ทำให้เกิดบรรทัด log เลย (ถ้ามี) และอธิบายว่าทำไมenter MENU
exit MENU
enter PLAYING
exit PLAYING
enter PAUSED
exit PAUSED
enter PLAYING
exit PLAYING
enter PAUSED
ไล่ทีละ state: เริ่มที่ MenuState (enter MENU) Enter สลับไป PlayingState (exit MENU, enter PLAYING) Esc ตัวแรกสลับไป PausedState (exit PLAYING, enter PAUSED) Esc ตัวที่สองสลับกลับไป PlayingState (exit PAUSED, enter PLAYING) Esc ตัวที่สามสลับไป PausedState อีกครั้ง (exit PLAYING, enter PAUSED) ส่วน Enter ตัวสุดท้ายไม่เกิดอะไรเลย — PausedState.Tick ตอบสนองแค่ KeyCode.Escape เท่านั้น ดังนั้นการกด Return ตอนอยู่ใน pause จึงไม่เรียก ChangeState และไม่ log อะไรออกมาเลย นี่คือกับดักเดียวกับที่ diagram ในหัวข้อ 5 แสดงไว้: ไม่มีลูกศรวาดจาก PAUSED กลับไป MENU ตอนกด Enter มีแต่ PAUSED ไป PLAYING ตอนกด Esc เท่านั้น
transform.position += Vector3.right * speed * Time.deltaTime; โดยมี speed = 12 (หน่วยต่อวินาที) ตลอด 3 เฟรมติดต่อกัน Time.deltaTime วัดได้ 0.02 วินาที, จากนั้น 0.05 วินาที (สะดุด), แล้ว 0.03 วินาที ยานเคลื่อนที่ไปเท่าไหร่ในแต่ละเฟรมทั้งสาม และระยะทางรวมหลังครบสามเฟรมคือเท่าไหร่ จากนั้นอธิบายในหนึ่งประโยคว่าทำไมผลรวมถึงจะออกมาเกือบเท่าเดิม ถ้าคุณบวก 60 เฟรมที่สม่ำเสมอที่ 60 FPS ครอบคลุมเวลาจริงเท่ากันแทนframe 1: 12 * 0.02 = 0.24 units
frame 2: 12 * 0.05 = 0.60 units
frame 3: 12 * 0.03 = 0.36 units
total: 0.24 + 0.60 + 0.36 = 1.20 units
เวลาจริงรวมที่ผ่านไปตลอดสามเฟรมคือ 0.02 + 0.05 + 0.03 = 0.10 วินาที และ 12 หน่วย/วินาที * 0.10 วินาที = 1.20 หน่วย — ตรงกันเป๊ะ นั่นไม่ใช่เรื่องบังเอิญ: เพราะการเคลื่อนที่แต่ละเฟรมคือ speed * deltaTime ระยะทางรวมตลอดช่วงเฟรมไหนก็ตามจะเท่ากับ speed * (ผลรวมของ deltaTime พวกนั้น) เสมอ ซึ่งขึ้นอยู่แค่ว่าเวลาจริงผ่านไปเท่าไหร่ ไม่เคยขึ้นอยู่กับว่าเวลานั้นถูกหั่นเป็นเฟรมยังไง 60 เฟรมที่สม่ำเสมอที่ 60 FPS ครอบคลุมเวลาจริง 0.10 วินาทีเท่ากันก็จะรวมได้ 1.20 หน่วยเท่าเดิม แค่ถูกแบ่งเป็นชิ้นเล็ก ๆ ที่เท่ากันมากขึ้นเท่านั้น — นี่คือ frame-rate independence จากหัวข้อ 2 พอดี
coyoteTime = 0.15 และ bufferTime = 0.1: ผู้เล่นเดินตกขอบแท่นที่ t=3.00s (isGrounded กลายเป็น false) กด Space ที่ t=3.20s และไม่ลงพื้นอีกเลยจนถึง t=3.50s การกระโดดจะเกิดขึ้นไหม? คำนวณค่า coyoteTimer ตอนที่กดปุ่ม แล้วอธิบายคำตอบของคุณการกระโดดไม่เกิดขึ้น coyoteTimer เริ่มนับถอยหลังจาก 0.15 ทันทีที่ผู้เล่นออกจากพื้นที่ t=3.00s ดังนั้นมันจะถึง 0 พอดีที่ t=3.15s แล้วเดินหน้าติดลบต่อไปเรื่อย ๆ หลังจากนั้น (ไม่มีอะไรมา reset มันเลย เพราะผู้เล่นยังไม่ลงพื้น) พอถึงตอนที่กด Space ที่ t=3.20s เวลาผ่านไปแล้ว 0.20 วินาทีนับจากออกจากพื้น — เกิน coyote window 0.15 วินาทีไป 0.05 วินาที — ดังนั้น coyoteTimer ติดลบไปแล้ว และ canJump เป็น false ส่วน bufferTimer ก็ถูกตั้งเป็น 0.1 จากการกด ทำให้ wantsToJump เป็น true แต่โค้ดจะยิงก็ต่อเมื่อทั้ง canJump และ wantsToJump เป็น true พร้อมกัน จึงไม่มีอะไรเกิดขึ้น แล้ว buffer window ก็หมดอายุไปด้วยที่ t=3.30s ก่อนที่ผู้เล่นจะลงพื้นจริงที่ t=3.50s เสียอีก ดังนั้นการลงพื้นก็ไม่ได้ย้อนกลับไปสั่งให้เกิดอะไรเช่นกัน ผู้เล่นแค่กระโดดช้าเกินไปสำหรับ coyote window นี้ — coyoteTime ที่ใหญ่ขึ้นอีกนิด (หรือผู้เล่นตอบสนองเร็วขึ้นอีกหน่อย) เป็นสิ่งเดียวที่จะช่วยได้
เท่านี้ก็ครอบคลุมหัวใจของเกม Unity แล้ว: loop ที่อ่าน input, update โลก, แล้ว render มันหลายครั้งต่อวินาที; นาฬิกาสองแบบที่ต่างกัน — deltaTime แบบ variable สำหรับทุกอย่างที่คุณเห็น, fixedDeltaTime แบบ fixed สำหรับ physics ที่เสถียร — และทำไม physics ถึงยืนกรานต้องใช้ตัวหลัง; state machine ที่สะอาดพอจะกันไม่ให้ logic ของ menu, playing, paused และ game-over พันกันยุ่งเหยิง; และการอ่าน input ที่ละเอียดพอจนการกระโดดรู้สึกยุติธรรม แม้การกดจะมาเร็วหรือช้าไปสองสามมิลลิวินาทีก็ตาม ทุกบทถัดไปที่เพิ่มเรื่องการเคลื่อนที่, การต่อสู้, หรือ AI ล้วนสร้างต่อยอดจากสี่ไอเดียนี้โดยตรง