สร้างเกมยิงมุมมองบุคคลที่หนึ่ง (FPS) ด้วย Unity: หมุนกล้องด้วยเมาส์ เดิน ยิง และทดสอบ

เกม FPS (First-Person Shooter) คือเกมที่มองผ่านตาของตัวละคร ระบบพื้นฐานที่ต้องมีคือ การหมุนกล้องด้วยเมาส์ การเดินด้วย WASD การยิงด้วยรังสี และเป้าหมายที่มีเลือด บทความนี้จะทำทีละส่วนด้วย Unity ตั้งแต่โปรเจกต์ใหม่ ไปจนถึงการทดสอบโค้ดให้ผ่านจริง

หมายเหตุ: โค้ดในบทความนี้ใช้คอมเมนต์ภาษาไทย และไม่ได้รันใน Unity Editor จริง การทดสอบทำผ่านตัวจำลอง Unity API ที่เขียนขึ้นเอง ซึ่งยืนยันเฉพาะ logic ของสคริปต์

สิ่งที่ต้องเตรียม

ขั้นที่ 1: สร้างโปรเจกต์ 3D และตั้งค่าอินพุต

1. เปิด Unity Hub กด **New project** เลือกเทมเพลต **Universal 3D** (URP) หรือ **3D** (Built-in) แล้วตั้งชื่อ เช่น `MyFPS`

2. ไปที่ **Edit → Project Settings → Player** แล้วตั้ง **Active Input Handling** เป็น **Both** หรือ **Input Manager (Old)** แล้วรีสตาร์ทเอดิเตอร์

3. ตรวจว่ามีแกน **Mouse X**, **Mouse Y**, **Horizontal**, **Vertical** และปุ่ม **Jump** ใน **Edit → Project Settings → Input Manager** (มีมาให้ในเทมเพลตปกติ)

ขั้นที่ 2: สร้างพื้นและกำแพงเป้าหมาย

1. คลิกขวาใน Hierarchy เลือก **3D Object → Plane** ตั้งชื่อ `Ground` ตั้ง Scale เป็น `X = 5, Y = 1, Z = 5`

2. คลิกขวาอีกครั้งเลือก **3D Object → Cube** ตั้งชื่อ `Target` วางไว้ห่างจากผู้เล่นประมาณ 10 เมตร เช่น Position `Z = 10`, `Y = 1`

3. เพิ่มคอมโพเนนต์ **Target** (ทำในขั้นที่ 5) ให้ Cube นี้ เพราะ Cube มี Box Collider อยู่แล้ว ซึ่งจำเป็นสำหรับการยิงโดน

ขั้นที่ 3: สร้างตัวละครผู้เล่น

1. คลิกขวาใน Hierarchy เลือก **Create Empty** ตั้งชื่อ `Player` และตั้ง Position เป็น `Y = 1`

2. เพิ่ม **Character Controller** ที่ `Player` (ใช้สำหรับเดินโดยไม่ต้องใช้ Rigidbody)

3. สร้าง **Camera** เป็นลูกของ `Player` (คลิกขวา `Player` → **Camera**) แล้วตั้ง Position เป็น `Y = 0.6` เพื่อให้มองเห็นจากระดับตา

4. ลบ Main Camera เดิมออก เพราะกล้องลูกของ Player จะใช้แทน

โครงสร้างที่ได้จะเป็น `Player` → `Camera` และ `Player` มี `Weapon` (ทำในขั้นที่ 6) และ `MouseLook`, `FPSMovement`

ขั้นที่ 4: หมุนกล้องด้วยเมาส์

สร้างสคริปต์ `MouseLook` แล้วใส่โค้ดนี้

using UnityEngine;

// หมุนกล้องตามเมาส์: ก้ม-เงยที่กล้อง (pitch) และหันซ้าย-ขวาที่ตัวละคร (yaw)
public class MouseLook : MonoBehaviour
{
    [SerializeField] private float sensitivity = 2f;   // ความไวเมาส์
    [SerializeField] private float minPitch = -80f;    // ก้มลงได้สุดกี่องศา
    [SerializeField] private float maxPitch = 80f;     // เงยขึ้นได้สุดกี่องศา
    [SerializeField] private Transform body;           // ตัวละครที่ต้องหมุนตามแนวนอน

    private float pitch;

    public float Pitch => pitch;

    private void Update()
    {
        float mouseX = Input.GetAxis("Mouse X") * sensitivity;
        float mouseY = Input.GetAxis("Mouse Y") * sensitivity;

        // เมาส์ขึ้นคือเงยหน้า จึงต้องลบค่า mouseY
        pitch = Mathf.Clamp(pitch - mouseY, minPitch, maxPitch);
        transform.localRotation = Quaternion.Euler(pitch, 0f, 0f);

        body.Rotate(0f, mouseX, 0f);
    }
}

หลักการสำคัญ

ตั้งค่าใน Inspector: ลาก `Player` ไปที่ช่อง **Body** ของ `MouseLook` ที่ติดอยู่บนกล้อง

ขั้นที่ 5: เดินด้วย WASD และกระโดด

สร้างสคริปต์ `FPSMovement` แล้วใส่โค้ดนี้ และติดไว้ที่ `Player`

using UnityEngine;

// เดินแบบ FPS ด้วย WASD โดยใช้ CharacterController (ไม่ใช้ Rigidbody)
[RequireComponent(typeof(CharacterController))]
public class FPSMovement : MonoBehaviour
{
    [SerializeField] private float speed = 6f;        // ความเร็วเดิน
    [SerializeField] private float jumpHeight = 1.5f; // ความสูงกระโดด (เมตร)
    [SerializeField] private float gravity = -20f;    // แรงโน้มถ่วง

    private CharacterController controller;
    private float verticalVelocity;

    public bool IsGrounded => controller.isGrounded;

    private void Awake()
    {
        controller = GetComponent<CharacterController>();
    }

    private void Update()
    {
        // แกน x คือซ้าย-ขวา และแกน z คือหน้า-หลัง ทั้งคู่อิงทิศทางที่ตัวละครหันอยู่
        float h = Input.GetAxis("Horizontal");
        float v = Input.GetAxis("Vertical");
        Vector3 move = transform.right * h + transform.forward * v;

        // จำกัดความยาวเวกเตอร์ไม่ให้เดินเฉียงเร็วกว่าเดินตรง
        if (move.magnitude > 1f) move = move.normalized;
        controller.Move(move * speed * Time.deltaTime);

        // แรงโน้มถ่วงและการกระโดด
        if (controller.isGrounded && verticalVelocity < 0f)
        {
            verticalVelocity = -2f;                       // กดให้ติดพื้นเล็กน้อย
        }
        if (Input.GetButtonDown("Jump") && controller.isGrounded)
        {
            verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
        }
        verticalVelocity += gravity * Time.deltaTime;
        controller.Move(new Vector3(0f, verticalVelocity * Time.deltaTime, 0f));
    }
}

หลักการสำคัญ

ขั้นที่ 6: ปืนแบบ Raycast และเป้าหมายที่มีเลือด

ปืนแบบ hitscan คือการยิงรังสีจากปากกระบอกไปข้างหน้า ถ้ารังสีชนอะไรที่มีคอมโพเนนต์ `Target` ก็ลดเลือด

สร้างสคริปต์ `Weapon` แล้วใส่โค้ดนี้ ติดไว้ที่ `Player`

using UnityEngine;

// ปืนแบบ hitscan: ยิงรังสีจากปากกระบอกปืน ถ้าโดนเป้าหมายก็ลดเลือด
public class Weapon : MonoBehaviour
{
    [SerializeField] private Transform muzzle;        // จุดปากกระบอกปืน
    [SerializeField] private float range = 100f;      // ระยะยิงสูงสุด
    [SerializeField] private int damage = 25;         // ดาเมจต่อนัด
    [SerializeField] private float fireInterval = 0.2f; // เวลาขั้นต่ำระหว่างนัด (วินาที)
    [SerializeField] private int maxAmmo = 10;        // กระสุนเต็มซอง

    private int ammo;
    private float nextFireTime;

    public int Ammo => ammo;

    private void Awake()
    {
        ammo = maxAmmo;
    }

    private void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            TryShoot();
        }
    }

    // คืน true ถ้ายิงจริง, false ถ้ากระสุนหมดหรือยังยิงไม่ได้
    public bool TryShoot()
    {
        if (ammo <= 0 || Time.time < nextFireTime) return false;

        ammo--;
        nextFireTime = Time.time + fireInterval;

        RaycastHit hit;
        if (Physics.Raycast(muzzle.position, muzzle.forward, out hit, range))
        {
            Target target = hit.collider.GetComponent<Target>();
            if (target != null)
            {
                target.TakeDamage(damage);
            }
        }
        return true;
    }
}

ตั้งค่าใน Inspector: สร้าง Empty ลูกของ Camera ชื่อ `Muzzle` วางไว้หน้ากระบอกปืน แล้วลากเข้าไปที่ช่อง **Muzzle** ของ `Weapon`

สคริปต์ `Target` ใส่บน Cube เป้าหมาย

using UnityEngine;

// เป้าหมายที่ถูกยิงได้: มีเลือด เมื่อเลือดหมดจะตาย
public class Target : MonoBehaviour
{
    [SerializeField] private int maxHealth = 100;

    private int health;

    public int Health => health;
    public bool IsDead => health <= 0;

    private void Awake()
    {
        health = maxHealth;
    }

    public void TakeDamage(int amount)
    {
        if (IsDead) return;

        health -= amount;
        if (health < 0) health = 0;
        if (IsDead)
        {
            Die();
        }
    }

    private void Die()
    {
        // ในเกมจริงอาจใช้ Destroy(gameObject) หรือเล่นแอนิเมชันตาย
        gameObject.SetActive(false);
    }
}

หมายเหตุเรื่องโค้ด: เวอร์ชันแรกของ `Target` เขียน `health = Mathf.Max(0, health - amount);` ซึ่งคอมไพล์ไม่ผ่าน เพราะ `Mathf.Max` คืนค่าเป็น float แต่ `health` เป็น int การคอมไพล์จึงจับข้อผิดพลาดนี้ได้ก่อนเล่น ฉบับในบทความแก้เป็นการลบค่าตรง ๆ และกันค่าติดลบด้วย `if`

ขั้นที่ 7: ทดสอบอัตโนมัติ

การทดสอบด้วยตัวจำลองช่วยตรวจว่าการคำนวณถูกต้อง โดยไม่ต้องยิงทดสอบทีละครั้งในเอดิเตอร์ ตัวจำลองประกอบด้วยไฟล์ `UnityStubs.cs` (ตามด้านล่าง) และไฟล์ทดสอบ `FPSTests.cs`

ไฟล์จำลอง Unity API (UnityStubs.cs)

using System;
using System.Collections.Generic;
using System.Reflection;

public struct Vector3
{
    public float x, y, z;
    public Vector3(float x, float y, float z) { this.x = x; this.y = y; this.z = z; }

    public float magnitude => (float)Math.Sqrt(x * x + y * y + z * z);
    public Vector3 normalized { get { float m = magnitude; return m > 0 ? new Vector3(x / m, y / m, z / m) : this; } }

    public static Vector3 operator +(Vector3 a, Vector3 b) => new Vector3(a.x + b.x, a.y + b.y, a.z + b.z);
    public static Vector3 operator *(Vector3 a, float s) => new Vector3(a.x * s, a.y * s, a.z * s);
    public static Vector3 operator *(float s, Vector3 a) => a * s;
}

public static class Mathf
{
    public static float Clamp(float v, float min, float max) => v < min ? min : (v > max ? max : v);
    public static float Max(float a, float b) => a > b ? a : b;
    public static float Sqrt(float v) => (float)Math.Sqrt(v);
}

public struct Quaternion
{
    public float x, y, z;
    public static Quaternion Euler(float x, float y, float z) => new Quaternion { x = x, y = y, z = z };
}

public class Transform
{
    public Vector3 position;
    public Vector3 forward = new Vector3(0, 0, 1);
    public Vector3 right = new Vector3(1, 0, 0);
    public Quaternion localRotation;
    public float eulerY;   // บันทึกการหมุนแนวนอนที่ Rotate ทำ

    public void Rotate(float x, float y, float z) { eulerY += y; }
}

public static class Input
{
    public static readonly Dictionary<string, float> Axes = new Dictionary<string, float>();
    public static readonly HashSet<string> ButtonsDown = new HashSet<string>();
    public static bool MouseButtonDown0;

    public static float GetAxis(string name)
    {
        float v;
        return Axes.TryGetValue(name, out v) ? v : 0f;
    }

    public static bool GetButtonDown(string name) => ButtonsDown.Contains(name);

    public static bool GetMouseButtonDown(int button) => button == 0 && MouseButtonDown0;
}

public static class Time
{
    public static float time;
    public static float deltaTime;
}

public class GameObject
{
    public bool activeSelf = true;
    public void SetActive(bool value) { activeSelf = value; }
}

public class Collider
{
    public MonoBehaviour Owner;

    public T GetComponent<T>() where T : class => Owner.GetComponent<T>();
}

public struct RaycastHit
{
    public Collider collider;
}

public static class Physics
{
    // ค่าที่ผู้ทดสอบตั้งไว้: ถ้า HitCollider ไม่เป็น null การยิงจะโดนวัตถุนั้น
    public static Collider HitCollider;

    public static bool Raycast(Vector3 origin, Vector3 direction, out RaycastHit hit, float maxDistance)
    {
        hit = new RaycastHit { collider = HitCollider };
        return HitCollider != null;
    }
}

public class CharacterController
{
    private readonly MonoBehaviour owner;
    public bool isGrounded;

    public CharacterController(MonoBehaviour owner) { this.owner = owner; }

    // เลื่อนตัวละครตามเวกเตอร์ที่ส่งมา เหมือน CharacterController.Move ของ Unity
    public void Move(Vector3 motion) { owner.transform.position = owner.transform.position + motion; }
}

public class SerializeFieldAttribute : Attribute { }

[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class RequireComponentAttribute : Attribute
{
    public RequireComponentAttribute(Type type) { }
}

public class MonoBehaviour
{
    public readonly Dictionary<Type, object> Components = new Dictionary<Type, object>();
    public Transform transform = new Transform();
    public GameObject gameObject = new GameObject();

    public T GetComponent<T>() where T : class
    {
        object value;
        return Components.TryGetValue(typeof(T), out value) ? (T)value : null;
    }

    // เรียกเมธอดวงจรชีวิตตามชื่อ เหมือนที่ Unity เรียกให้ในแต่ละเฟรม
    public void Call(string methodName)
    {
        var method = GetType().GetMethod(methodName,
            BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
        method.Invoke(this, null);
    }

    // ตั้งค่าฟิลด์ที่เป็น private (เช่น [SerializeField]) เหมือนที่ตั้งใน Inspector
    public void SetField(string name, object value)
    {
        GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
            .SetValue(this, value);
    }
}

ไฟล์ทดสอบ (FPSTests.cs)

using System;

public static class FPSTests
{
    private static int passed = 0, failed = 0;

    private static void Check(string name, bool condition)
    {
        if (condition) { passed++; Console.WriteLine("PASS  " + name); }
        else { failed++; Console.WriteLine("FAIL  " + name); }
    }

    // รีเซ็ตค่าอินพุตและเวลาให้เป็นค่าเริ่มต้นก่อนแต่ละเฟรมทดสอบ
    private static void ResetFrame()
    {
        Input.Axes.Clear();
        Input.ButtonsDown.Clear();
        Input.MouseButtonDown0 = false;
        Time.deltaTime = 1f / 60f;
    }

    // สร้างตัวละครที่มี FPSMovement และ CharacterController พร้อมใช้งาน
    private static FPSMovement CreatePlayer()
    {
        var player = new FPSMovement();
        player.Components[typeof(CharacterController)] = new CharacterController(player);
        player.Call("Awake");
        return player;
    }

    public static int Main()
    {
        Console.OutputEncoding = System.Text.Encoding.UTF8;

        // ---------- MouseLook ----------
        {
            Physics.HitCollider = null;
            ResetFrame();
            var look = new MouseLook();
            look.SetField("body", new Transform());
            look.SetField("sensitivity", 2f);
            Input.Axes["Mouse Y"] = 1f;
            look.Call("Update");
            Check("mouse up: pitch becomes -2 (looking up)", Math.Abs(look.Pitch + 2f) < 0.001f);
        }
        {
            ResetFrame();
            var look = new MouseLook();
            look.SetField("body", new Transform());
            look.SetField("sensitivity", 2f);
            Input.Axes["Mouse Y"] = 1f;
            for (int i = 0; i < 100; i++) look.Call("Update");
            Check("mouse up for long: pitch stops at minPitch (-80)", Math.Abs(look.Pitch + 80f) < 0.001f);
        }
        {
            ResetFrame();
            var look = new MouseLook();
            var body = new Transform();
            look.SetField("body", body);
            look.SetField("sensitivity", 2f);
            Input.Axes["Mouse X"] = 1f;
            look.Call("Update");
            Check("mouse right: body turns 2 degrees", Math.Abs(body.eulerY - 2f) < 0.001f);
        }

        // ---------- FPSMovement ----------
        {
            ResetFrame();
            var player = CreatePlayer();
            var cc = player.GetComponent<CharacterController>();
            cc.isGrounded = true;
            for (int i = 0; i < 60; i++)
            {
                ResetFrame();
                Input.Axes["Vertical"] = 1f;
                player.Call("Update");
            }
            Check("walk forward 1 second: moves about 6 units", Math.Abs(player.transform.position.z - 6f) < 0.05f);
        }
        {
            ResetFrame();
            var player = CreatePlayer();
            player.GetComponent<CharacterController>().isGrounded = true;
            for (int i = 0; i < 60; i++)
            {
                ResetFrame();
                Input.Axes["Horizontal"] = 1f;
                Input.Axes["Vertical"] = 1f;
                player.Call("Update");
            }
            float distance = (float)Math.Sqrt(
                player.transform.position.x * player.transform.position.x +
                player.transform.position.z * player.transform.position.z);
            Check("diagonal walk: speed capped at about 6 (not 8.5)", Math.Abs(distance - 6f) < 0.05f);
        }
        {
            ResetFrame();
            var player = CreatePlayer();
            player.GetComponent<CharacterController>().isGrounded = true;
            Input.ButtonsDown.Add("Jump");
            player.Call("Update");
            Check("jump from ground: rises above the ground in the first frame", player.transform.position.y > 0f);
        }

        // ---------- Weapon and Target ----------
        {
            ResetFrame();
            var target = new Target();
            target.Components[typeof(Target)] = target;
            target.Call("Awake");

            var gun = new Weapon();
            gun.SetField("muzzle", new Transform());
            gun.Call("Awake");

            Physics.HitCollider = new Collider { Owner = target };
            Time.time = 10f;
            bool fired = gun.TryShoot();
            Check("shoot: fires when ammo is available", fired);
            Check("shoot hit: target health drops by damage (100 to 75)", target.Health == 75);
            Check("shoot: ammo decreases from 10 to 9", gun.Ammo == 9);
        }
        {
            ResetFrame();
            var target = new Target();
            target.Components[typeof(Target)] = target;
            target.Call("Awake");
            var gun = new Weapon();
            gun.SetField("muzzle", new Transform());
            gun.Call("Awake");
            Physics.HitCollider = new Collider { Owner = target };

            Time.time = 10f;
            gun.TryShoot();
            Time.time = 10.05f;                       // ยังไม่ถึงเวลาเว้นระยะ 0.2 วินาที
            bool secondShotAllowed = gun.TryShoot();
            Check("fire interval: second shot within 0.2 s is blocked", secondShotAllowed == false);
            Check("fire interval: ammo is not spent on the blocked shot", gun.Ammo == 9);
        }
        {
            ResetFrame();
            var target = new Target();
            target.Components[typeof(Target)] = target;
            target.Call("Awake");
            var gun = new Weapon();
            gun.SetField("muzzle", new Transform());
            gun.Call("Awake");
            Physics.HitCollider = new Collider { Owner = target };

            for (int i = 0; i < 4; i++)
            {
                Time.time += 1f;                       // รอให้พ้นเวลาเว้นระยะทุกนัด
                gun.TryShoot();
            }
            Check("4 hits of 25: target is dead", target.IsDead);
            Check("4 hits of 25: target object is deactivated", target.gameObject.activeSelf == false);
        }
        {
            ResetFrame();
            var gun = new Weapon();
            gun.SetField("muzzle", new Transform());
            gun.Call("Awake");
            Physics.HitCollider = null;               // ยิงโดนไม่มีอะไรเลย
            for (int i = 0; i < 10; i++)
            {
                Time.time += 1f;
                gun.TryShoot();
            }
            Time.time += 1f;
            bool shotWhenEmpty = gun.TryShoot();
            Check("empty magazine: after 10 shots the 11th is refused", shotWhenEmpty == false && gun.Ammo == 0);
        }

        Console.WriteLine();
        Console.WriteLine("Passed " + passed + " / Failed " + failed);
        return failed == 0 ? 0 : 1;
    }
}

วิธีคอมไพล์และรัน

1. สร้างโฟลเดอร์ `build` แล้วคัดลอก `UnityStubs.cs`, `FPSTests.cs` และสคริปต์ทั้งสี่ไฟล์ (`MouseLook.cs`, `FPSMovement.cs`, `Weapon.cs`, `Target.cs`) ไปไว้ในนั้น

2. ในสำเนาเหล่านั้น ลบบรรทัด `using UnityEngine;` ออก (ใช้เฉพาะตอนทดสอบด้วยตัวจำลอง ไฟล์จริงใน Unity ต้องคงบรรทัดนี้ไว้)

3. คอมไพล์ด้วย Roslyn (ตัวอย่าง: `csc.exe -langversion:latest -out:FPSTests.exe *.cs`) แล้วรัน `FPSTests.exe`

ผลการรันทดสอบ

PASS  mouse up: pitch becomes -2 (looking up)
PASS  mouse up for long: pitch stops at minPitch (-80)
PASS  mouse right: body turns 2 degrees
PASS  walk forward 1 second: moves about 6 units
PASS  diagonal walk: speed capped at about 6 (not 8.5)
PASS  jump from ground: rises above the ground in the first frame
PASS  shoot: fires when ammo is available
PASS  shoot hit: target health drops by damage (100 to 75)
PASS  shoot: ammo decreases from 10 to 9
PASS  fire interval: second shot within 0.2 s is blocked
PASS  fire interval: ammo is not spent on the blocked shot
PASS  4 hits of 25: target is dead
PASS  4 hits of 25: target object is deactivated
PASS  empty magazine: after 10 shots the 11th is refused

Passed 14 / Failed 0

ข้อควรระวัง: ผลนี้มาจากตัวจำลอง ไม่ใช่ Unity จริง ฟิสิกส์การชน แอนิเมชัน และระยะกล้องจริงยังต้องทดสอบด้วยการกด Play ใน Editor

ภาพจำลองมุมมองในเกม (ไม่ใช่ภาพเรนเดอร์จริง): เป้าหมายตรงกลาง เครื่องหมายกากบาท ปืนที่ถืออยู่ และ HUD แสดงกระสุนกับเลือดเป้าหมาย
ภาพจำลองมุมมองในเกม (ไม่ใช่ภาพเรนเดอร์จริง): เป้าหมายตรงกลาง เครื่องหมายกากบาท ปืนที่ถืออยู่ และ HUD แสดงกระสุนกับเลือดเป้าหมาย
ผลการรันเทสต์ที่เรนเดอร์จากเอาต์พุตจริง: ผ่าน 14 ไม่ผ่าน 0 (รันกับตัวจำลอง ไม่ใช่ Unity Editor)
ผลการรันเทสต์ที่เรนเดอร์จากเอาต์พุตจริง: ผ่าน 14 ไม่ผ่าน 0 (รันกับตัวจำลอง ไม่ใช่ Unity Editor)

ขั้นที่ 8: ทดสอบในเอดิเตอร์

กด **Play** แล้วตรวจ

ปัญหาที่พบบ่อย

สรุป

เกม FPS ประกอบด้วยสี่ระบบหลัก คือการหมุนกล้อง การเดิน การยิงด้วยรังสี และเป้าหมายที่มีเลือด แต่ละระบบเป็นสคริปต์เล็ก ๆ ที่ทดสอบได้แยกกัน การทดสอบพบข้อผิดพลาดตั้งแต่การคอมไพล์ จึงช่วยลดเวลาแก้บั๊กในเอดิเตอร์ ก่อนขยายเกม ควรเพิ่มระบบเช่น ศัตรูที่เคลื่อนที่ ตัวนับกระสุนบนจอ และการโหลดกระสุนใหม่