כיצד ליצור FPS עם תמיכת AI ב-Unity

יריות מגוף ראשון (FPS) הוא תת-ז'אנר של משחקי יריות שבו השחקן נשלט מנקודת מבט מגוף ראשון.

כדי ליצור משחק FPS ב-Unity נצטרך בקר שחקן, מערך של פריטים (נשק במקרה זה), ואת האויבים.

שלב 1: צור את בקר הנגן

כאן ניצור בקר שישמש את הנגן שלנו.

  • צור אובייקט משחק חדש (Game Object -> Create Empty) ותן לו שם "Player"
  • צור קפסולה חדשה (אובייקט משחק -> אובייקט תלת מימדי -> קפסולה) והזז אותה בתוך האובייקט "Player"
  • הסר את רכיב Capsule Collider מהקפסולה ושנה את מיקומו ל- (0, 1, 0)
  • הזז את המצלמה הראשית בתוך האובייקט "Player" ושנה את מיקומו ל- (0, 1.64, 0)
  • צור סקריפט חדש, שם לו "SC_CharacterController" והדבק בתוכו את הקוד למטה:

SC_CharacterController.cs

using UnityEngine;

[RequireComponent(typeof(CharacterController))]

public class SC_CharacterController : MonoBehaviour
{
    public float speed = 7.5f;
    public float jumpSpeed = 8.0f;
    public float gravity = 20.0f;
    public Camera playerCamera;
    public float lookSpeed = 2.0f;
    public float lookXLimit = 45.0f;

    CharacterController characterController;
    Vector3 moveDirection = Vector3.zero;
    Vector2 rotation = Vector2.zero;

    [HideInInspector]
    public bool canMove = true;

    void Start()
    {
        characterController = GetComponent<CharacterController>();
        rotation.y = transform.eulerAngles.y;
    }

    void Update()
    {
        if (characterController.isGrounded)
        {
            // We are grounded, so recalculate move direction based on axes
            Vector3 forward = transform.TransformDirection(Vector3.forward);
            Vector3 right = transform.TransformDirection(Vector3.right);
            float curSpeedX = canMove ? speed * Input.GetAxis("Vertical") : 0;
            float curSpeedY = canMove ? speed * Input.GetAxis("Horizontal") : 0;
            moveDirection = (forward * curSpeedX) + (right * curSpeedY);

            if (Input.GetButton("Jump") && canMove)
            {
                moveDirection.y = jumpSpeed;
            }
        }

        // Apply gravity. Gravity is multiplied by deltaTime twice (once here, and once below
        // when the moveDirection is multiplied by deltaTime). This is because gravity should be applied
        // as an acceleration (ms^-2)
        moveDirection.y -= gravity * Time.deltaTime;

        // Move the controller
        characterController.Move(moveDirection * Time.deltaTime);

        // Player and Camera rotation
        if (canMove)
        {
            rotation.y += Input.GetAxis("Mouse X") * lookSpeed;
            rotation.x += -Input.GetAxis("Mouse Y") * lookSpeed;
            rotation.x = Mathf.Clamp(rotation.x, -lookXLimit, lookXLimit);
            playerCamera.transform.localRotation = Quaternion.Euler(rotation.x, 0, 0);
            transform.eulerAngles = new Vector2(0, rotation.y);
        }
    }
}
  • צרף SC_CharacterController לאובייקט "Player" (תבחין שהוא הוסיף גם רכיב נוסף בשם Character Controller, משנה את הערך המרכזי שלו ל-(0, 1, 0))
  • הקצה את המצלמה הראשית למשתנה Player Camera ב-SC_CharacterController

בקר הנגן מוכן כעת:

שלב 2: צור את מערכת הנשק

מערכת הנשק של השחקן תהיה מורכבת מ-3 רכיבים: מנהל נשק, תסריט נשק ותסריט של כדור.

  • צור סקריפט חדש, שם לו "SC_WeaponManager" והדבק בתוכו את הקוד למטה:

SC_WeaponManager.cs

using UnityEngine;

public class SC_WeaponManager : MonoBehaviour
{
    public Camera playerCamera;
    public SC_Weapon primaryWeapon;
    public SC_Weapon secondaryWeapon;

    [HideInInspector]
    public SC_Weapon selectedWeapon;

    // Start is called before the first frame update
    void Start()
    {
        //At the start we enable the primary weapon and disable the secondary
        primaryWeapon.ActivateWeapon(true);
        secondaryWeapon.ActivateWeapon(false);
        selectedWeapon = primaryWeapon;
        primaryWeapon.manager = this;
        secondaryWeapon.manager = this;
    }

    // Update is called once per frame
    void Update()
    {
        //Select secondary weapon when pressing 1
        if (Input.GetKeyDown(KeyCode.Alpha1))
        {
            primaryWeapon.ActivateWeapon(false);
            secondaryWeapon.ActivateWeapon(true);
            selectedWeapon = secondaryWeapon;
        }

        //Select primary weapon when pressing 2
        if (Input.GetKeyDown(KeyCode.Alpha2))
        {
            primaryWeapon.ActivateWeapon(true);
            secondaryWeapon.ActivateWeapon(false);
            selectedWeapon = primaryWeapon;
        }
    }
}
  • צור סקריפט חדש, שם לו "SC_Weapon" והדבק בתוכו את הקוד למטה:

SC_Weapon.cs

using System.Collections;
using UnityEngine;

[RequireComponent(typeof(AudioSource))]

public class SC_Weapon : MonoBehaviour
{
    public bool singleFire = false;
    public float fireRate = 0.1f;
    public GameObject bulletPrefab;
    public Transform firePoint;
    public int bulletsPerMagazine = 30;
    public float timeToReload = 1.5f;
    public float weaponDamage = 15; //How much damage should this weapon deal
    public AudioClip fireAudio;
    public AudioClip reloadAudio;

    [HideInInspector]
    public SC_WeaponManager manager;

    float nextFireTime = 0;
    bool canFire = true;
    int bulletsPerMagazineDefault = 0;
    AudioSource audioSource;

    // Start is called before the first frame update
    void Start()
    {
        bulletsPerMagazineDefault = bulletsPerMagazine;
        audioSource = GetComponent<AudioSource>();
        audioSource.playOnAwake = false;
        //Make sound 3D
        audioSource.spatialBlend = 1f;
    }

    // Update is called once per frame
    void Update()
    {
        if (Input.GetMouseButtonDown(0) && singleFire)
        {
            Fire();
        }
        if (Input.GetMouseButton(0) && !singleFire)
        {
            Fire();
        }
        if (Input.GetKeyDown(KeyCode.R) && canFire)
        {
            StartCoroutine(Reload());
        }
    }

    void Fire()
    {
        if (canFire)
        {
            if (Time.time > nextFireTime)
            {
                nextFireTime = Time.time + fireRate;

                if (bulletsPerMagazine > 0)
                {
                    //Point fire point at the current center of Camera
                    Vector3 firePointPointerPosition = manager.playerCamera.transform.position + manager.playerCamera.transform.forward * 100;
                    RaycastHit hit;
                    if (Physics.Raycast(manager.playerCamera.transform.position, manager.playerCamera.transform.forward, out hit, 100))
                    {
                        firePointPointerPosition = hit.point;
                    }
                    firePoint.LookAt(firePointPointerPosition);
                    //Fire
                    GameObject bulletObject = Instantiate(bulletPrefab, firePoint.position, firePoint.rotation);
                    SC_Bullet bullet = bulletObject.GetComponent<SC_Bullet>();
                    //Set bullet damage according to weapon damage value
                    bullet.SetDamage(weaponDamage);

                    bulletsPerMagazine--;
                    audioSource.clip = fireAudio;
                    audioSource.Play();
                }
                else
                {
                    StartCoroutine(Reload());
                }
            }
        }
    }

    IEnumerator Reload()
    {
        canFire = false;

        audioSource.clip = reloadAudio;
        audioSource.Play();

        yield return new WaitForSeconds(timeToReload);

        bulletsPerMagazine = bulletsPerMagazineDefault;

        canFire = true;
    }

    //Called from SC_WeaponManager
    public void ActivateWeapon(bool activate)
    {
        StopAllCoroutines();
        canFire = true;
        gameObject.SetActive(activate);
    }
}
  • צור סקריפט חדש, שם לו "SC_Bullet" והדבק בתוכו את הקוד למטה:

SC_Bullet.cs

using System.Collections;
using UnityEngine;

public class SC_Bullet : MonoBehaviour
{
    public float bulletSpeed = 345;
    public float hitForce = 50f;
    public float destroyAfter = 3.5f;

    float currentTime = 0;
    Vector3 newPos;
    Vector3 oldPos;
    bool hasHit = false;

    float damagePoints;

    // Start is called before the first frame update
    IEnumerator Start()
    {
        newPos = transform.position;
        oldPos = newPos;

        while (currentTime < destroyAfter && !hasHit)
        {
            Vector3 velocity = transform.forward * bulletSpeed;
            newPos += velocity * Time.deltaTime;
            Vector3 direction = newPos - oldPos;
            float distance = direction.magnitude;
            RaycastHit hit;

            // Check if we hit anything on the way
            if (Physics.Raycast(oldPos, direction, out hit, distance))
            {
                if (hit.rigidbody != null)
                {
                    hit.rigidbody.AddForce(direction * hitForce);

                    IEntity npc = hit.transform.GetComponent<IEntity>();
                    if (npc != null)
                    {
                        //Apply damage to NPC
                        npc.ApplyDamage(damagePoints);
                    }
                }

                newPos = hit.point; //Adjust new position
                StartCoroutine(DestroyBullet());
            }

            currentTime += Time.deltaTime;
            yield return new WaitForFixedUpdate();

            transform.position = newPos;
            oldPos = newPos;
        }

        if (!hasHit)
        {
            StartCoroutine(DestroyBullet());
        }
    }

    IEnumerator DestroyBullet()
    {
        hasHit = true;
        yield return new WaitForSeconds(0.5f);
        Destroy(gameObject);
    }

    //Set how much damage this bullet will deal
    public void SetDamage(float points)
    {
        damagePoints = points;
    }
}

כעת, תבחין שבסקריפט SC_Bullet יש כמה שגיאות. זה בגלל שיש לנו דבר אחרון לעשות, שהוא להגדיר את ממשק IENtity.

ממשקים ב-C# שימושיים כאשר אתה צריך לוודא שלסקריפט שמשתמש בו יש שיטות מסוימות מיושמות.

לממשק IEntity תהיה שיטה אחת שהיא ApplyDamage, שבהמשך תשתמש בה כדי לגרום נזק לאויבים ולשחקן שלנו.

  • צור סקריפט חדש, שם לו "SC_InterfaceManager" והדבק בתוכו את הקוד למטה:

SC_InterfaceManager.cs

//Entity interafce
interface IEntity
{ 
    void ApplyDamage(float points);
}

הגדרת מנהל נשק

מנהל נשק הוא אובייקט שיימצא מתחת לאובייקט המצלמה הראשי ויכיל את כל הנשקים.

  • צור GameObject חדש ושם לו "WeaponManager"
  • הזז את WeaponManager בתוך המצלמה הראשית של הנגן ושנה את מיקומו ל- (0, 0, 0)
  • צרף סקריפט SC_WeaponManager ל "WeaponManager"
  • הקצה את המצלמה הראשית למשתנה Player Camera ב-SC_WeaponManager

הקמת רובה

  • גרור ושחרר את דגם האקדח שלך לתוך הסצנה (או פשוט צור קובייה ומותח אותה אם אין לך עדיין דגם).
  • קנה קנה מידה של הדגם כך שגודלו יהיה ביחס לקפסולת Player

במקרה שלי, אני אשתמש בדגם רובה בהתאמה אישית (BERGARA BA13):

BERGARA BA13

  • צור GameObject חדש ושם לו "Rifle" ואז הזיז את דגם הרובה בתוכו
  • הזז את האובייקט "Rifle" בתוך האובייקט "WeaponManager" והצב אותו מול המצלמה כך:

תקן את בעיית גזירת המצלמה ב-Unity.

כדי לתקן את חיתוך האובייקט, פשוט שנה את מישור הגזירה הקרוב של המצלמה למשהו קטן יותר (במקרה שלי הגדרתי אותו ל-0.15):

BERGARA BA13

הרבה יותר טוב.

  • צרף סקריפט SC_Weapon לאובייקט רובה (תבחין שהוא הוסיף גם רכיב מקור אודיו, זה נחוץ כדי להפעיל את האש ולטעון מחדש את השמע).

כפי שאתה יכול לראות, ל-SC_Weapon יש 4 משתנים להקצות. אתה יכול להקצות אודיו Fire ו-Reload משתני אודיו מיד אם יש לך קטעי אודיו מתאימים בפרויקט שלך.

המשתנה Bullet Prefab יוסבר בהמשך מדריך זה.

לעת עתה, נקצה את המשתנה Fire point:

  • צור GameObject חדש, שנה את שמו ל-"FirePoint" והעבר אותו לתוך Rifle Object. הנח אותו ממש מול החבית או מעט בפנים, כך:

  • הקצה FirePoint Transform למשתנה Fire Point ב-SC_Weapon
  • הקצה רובה למשתנה נשק משני בסקריפט SC_WeaponManager

הגדרת תת מקלע

  • שכפל את אובייקט הרובה ושנה את שמו לתת מקלע
  • החלף את דגם האקדח שבתוכו בדגם אחר (במקרה שלי אשתמש בדגם המותאם אישית של TAVOR X95)

תבור X95

  • הזיזו טרנספורמציה של Fire Point עד שתתאים לדגם החדש

הגדרת אובייקטים של Weapon Fire Point ב-Unity.

  • הקצה Submachinegun למשתנה נשק ראשי בסקריפט SC_WeaponManager

הגדרת תבליטים טרומיים

כדור טרומי יושר בהתאם לקצב האש של נשק ותשתמש ב-Raycast כדי לזהות אם הוא פגע במשהו וגרם נזק.

  • צור GameObject חדש ושם לו "Bullet"
  • הוסף אליו את רכיב ה-Trail Renderer ושנה את משתנה הזמן שלו ל-0.1.
  • הגדר את עקומת הרוחב לערך נמוך יותר (לדוגמה. התחל 0.1 סוף 0), כדי להוסיף שביל בעל מראה מחודד
  • צור חומר חדש וקרא לו bullet_trail_material ושנה את ה-Shader שלו ל- Particles/Additive
  • הקצה חומר חדש שנוצר למעבד שביל
  • שנה את הצבע של ה-Trail Renderer למשהו אחר (למשל התחלה: כתום בהיר סוף: כתום כהה יותר)

  • שמור את אובייקט הכדור ב-Prefab ומחק אותו מהסצנה.
  • הקצה Prefab חדש שנוצר (גרור ושחרר מתצוגת הפרויקט) למשתנה Rifle ו-Submachinegun Bullet Prefab

תת מקלע:

רובה:

כלי הנשק מוכנים כעת.

שלב 3: צור את ה-Enemy AI

האויבים יהיו קוביות פשוטות שעוקבות אחר השחקן ותוקפות ברגע שהן קרובות מספיק. הם יתקפו בגלים, כאשר לכל גל יהיו עוד אויבים לחסל.

הגדרת בינה מלאכותית של האויב

להלן יצרתי 2 וריאציות של הקוביה (השמאלית מיועדת למופע החי והימנית תיווצר ברגע שהאויב ייהרג):

  • הוסף רכיב Rigidbody למקרים מתים וחיים כאחד
  • שמור את המופע המת ל-Prefab ומחק אותו מהסצנה.

כעת, המופע החי יצטרך עוד כמה רכיבים כדי להיות מסוגל לנווט ברמת המשחק ולהסב נזק לשחקן.

  • צור סקריפט חדש ושם לו "SC_NPCEnemy" ואז הדבק בתוכו את הקוד למטה:

SC_NPCEnemy.cs

using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshAgent))]

public class SC_NPCEnemy : MonoBehaviour, IEntity
{
    public float attackDistance = 3f;
    public float movementSpeed = 4f;
    public float npcHP = 100;
    //How much damage will npc deal to the player
    public float npcDamage = 5;
    public float attackRate = 0.5f;
    public Transform firePoint;
    public GameObject npcDeadPrefab;

    [HideInInspector]
    public Transform playerTransform;
    [HideInInspector]
    public SC_EnemySpawner es;
    NavMeshAgent agent;
    float nextAttackTime = 0;

    // Start is called before the first frame update
    void Start()
    {
        agent = GetComponent<NavMeshAgent>();
        agent.stoppingDistance = attackDistance;
        agent.speed = movementSpeed;

        //Set Rigidbody to Kinematic to prevent hit register bug
        if (GetComponent<Rigidbody>())
        {
            GetComponent<Rigidbody>().isKinematic = true;
        }
    }

    // Update is called once per frame
    void Update()
    {
        if (agent.remainingDistance - attackDistance < 0.01f)
        {
            if(Time.time > nextAttackTime)
            {
                nextAttackTime = Time.time + attackRate;

                //Attack
                RaycastHit hit;
                if(Physics.Raycast(firePoint.position, firePoint.forward, out hit, attackDistance))
                {
                    if (hit.transform.CompareTag("Player"))
                    {
                        Debug.DrawLine(firePoint.position, firePoint.position + firePoint.forward * attackDistance, Color.cyan);

                        IEntity player = hit.transform.GetComponent<IEntity>();
                        player.ApplyDamage(npcDamage);
                    }
                }
            }
        }
        //Move towardst he player
        agent.destination = playerTransform.position;
        //Always look at player
        transform.LookAt(new Vector3(playerTransform.transform.position.x, transform.position.y, playerTransform.position.z));
    }

    public void ApplyDamage(float points)
    {
        npcHP -= points;
        if(npcHP <= 0)
        {
            //Destroy the NPC
            GameObject npcDead = Instantiate(npcDeadPrefab, transform.position, transform.rotation);
            //Slightly bounce the npc dead prefab up
            npcDead.GetComponent<Rigidbody>().velocity = (-(playerTransform.position - transform.position).normalized * 8) + new Vector3(0, 5, 0);
            Destroy(npcDead, 10);
            es.EnemyEliminated(this);
            Destroy(gameObject);
        }
    }
}
  • צור סקריפט חדש, שם לו "SC_EnemySpawner" ואז הדבק בתוכו את הקוד למטה:

SC_EnemySpawner.cs

using UnityEngine;
using UnityEngine.SceneManagement;

public class SC_EnemySpawner : MonoBehaviour
{
    public GameObject enemyPrefab;
    public SC_DamageReceiver player;
    public Texture crosshairTexture;
    public float spawnInterval = 2; //Spawn new enemy each n seconds
    public int enemiesPerWave = 5; //How many enemies per wave
    public Transform[] spawnPoints;

    float nextSpawnTime = 0;
    int waveNumber = 1;
    bool waitingForWave = true;
    float newWaveTimer = 0;
    int enemiesToEliminate;
    //How many enemies we already eliminated in the current wave
    int enemiesEliminated = 0;
    int totalEnemiesSpawned = 0;

    // Start is called before the first frame update
    void Start()
    {
        //Lock cursor
        Cursor.lockState = CursorLockMode.Locked;
        Cursor.visible = false;

        //Wait 10 seconds for new wave to start
        newWaveTimer = 10;
        waitingForWave = true;
    }

    // Update is called once per frame
    void Update()
    {
        if (waitingForWave)
        {
            if(newWaveTimer >= 0)
            {
                newWaveTimer -= Time.deltaTime;
            }
            else
            {
                //Initialize new wave
                enemiesToEliminate = waveNumber * enemiesPerWave;
                enemiesEliminated = 0;
                totalEnemiesSpawned = 0;
                waitingForWave = false;
            }
        }
        else
        {
            if(Time.time > nextSpawnTime)
            {
                nextSpawnTime = Time.time + spawnInterval;

                //Spawn enemy 
                if(totalEnemiesSpawned < enemiesToEliminate)
                {
                    Transform randomPoint = spawnPoints[Random.Range(0, spawnPoints.Length - 1)];

                    GameObject enemy = Instantiate(enemyPrefab, randomPoint.position, Quaternion.identity);
                    SC_NPCEnemy npc = enemy.GetComponent<SC_NPCEnemy>();
                    npc.playerTransform = player.transform;
                    npc.es = this;
                    totalEnemiesSpawned++;
                }
            }
        }

        if (player.playerHP <= 0)
        {
            if (Input.GetKeyDown(KeyCode.Space))
            {
                Scene scene = SceneManager.GetActiveScene();
                SceneManager.LoadScene(scene.name);
            }
        }
    }

    void OnGUI()
    {
        GUI.Box(new Rect(10, Screen.height - 35, 100, 25), ((int)player.playerHP).ToString() + " HP");
        GUI.Box(new Rect(Screen.width / 2 - 35, Screen.height - 35, 70, 25), player.weaponManager.selectedWeapon.bulletsPerMagazine.ToString());

        if(player.playerHP <= 0)
        {
            GUI.Box(new Rect(Screen.width / 2 - 85, Screen.height / 2 - 20, 170, 40), "Game Over\n(Press 'Space' to Restart)");
        }
        else
        {
            GUI.DrawTexture(new Rect(Screen.width / 2 - 3, Screen.height / 2 - 3, 6, 6), crosshairTexture);
        }

        GUI.Box(new Rect(Screen.width / 2 - 50, 10, 100, 25), (enemiesToEliminate - enemiesEliminated).ToString());

        if (waitingForWave)
        {
            GUI.Box(new Rect(Screen.width / 2 - 125, Screen.height / 4 - 12, 250, 25), "Waiting for Wave " + waveNumber.ToString() + " (" + ((int)newWaveTimer).ToString() + " seconds left...)");
        }
    }

    public void EnemyEliminated(SC_NPCEnemy enemy)
    {
        enemiesEliminated++;

        if(enemiesToEliminate - enemiesEliminated <= 0)
        {
            //Start next wave
            newWaveTimer = 10;
            waitingForWave = true;
            waveNumber++;
        }
    }
}
  • צור סקריפט חדש, שם לו "SC_DamageReceiver" ואז הדבק בתוכו את הקוד למטה:

SC_DamageReceiver.cs

using UnityEngine;

public class SC_DamageReceiver : MonoBehaviour, IEntity
{
    //This script will keep track of player HP
    public float playerHP = 100;
    public SC_CharacterController playerController;
    public SC_WeaponManager weaponManager;

    public void ApplyDamage(float points)
    {
        playerHP -= points;

        if(playerHP <= 0)
        {
            //Player is dead
            playerController.canMove = false;
            playerHP = 0;
        }
    }
}
  • צרף סקריפט SC_NPCEnemy למופע חי של האויב (תבחין שהוא הוסיף רכיב נוסף בשם NavMesh Agent, הדרוש כדי לנווט ב-NavMesh)
  • הקצה את הקדם המופע המת שנוצר לאחרונה למשתנה Npc Dead Prefab
  • עבור ה-Fire Point, צור GameObject חדש, הזז אותו בתוך מופע האויב החי והצב אותו מעט לפני המופע, ואז הקצה אותו למשתנה Fire Point:

  • לבסוף, שמור את המופע החי ב-Prefab ומחק אותו מהסצנה.

הגדרת Enemy Spawner

כעת נעבור ל-SC_EnemySpawner. הסקריפט הזה יוליד אויבים בגלים וגם יציג קצת מידע על ממשק המשתמש על המסך, כמו Player HP, התחמושת הנוכחית, כמה אויבים נשארו בגל הנוכחי וכו'.

  • צור GameObject חדש ושם לו "_EnemySpawner"
  • צרף אליו את הסקריפט SC_EnemySpawner
  • הקצה את AI האויב החדש שנוצר למשתנה Enemy Prefab
  • הקצה את המרקם למטה למשתנה Crosshair Texture

  • צור כמה אובייקטי GameObjects חדשים והצב אותם סביב הסצנה ואז הקצה אותם למערך Spawn Points

אתה תבחין שנותר משתנה אחד אחרון להקצות שהוא המשתנה Player.

  • צרף סקריפט SC_DamageReceiver למופע של Player
  • שנה את תג המופע של Player ל "Player"
  • הקצה משתנים של בקר שחקן ו-Weapon Manager ב-SC_DamageReceiver

  • הקצה מופע Player למשתנה Player ב-SC_EnemySpawner

ולבסוף, עלינו לאפות את ה-NavMesh בסצנה שלנו כדי שה-AI של האויב יוכל לנווט.

כמו כן, אל תשכח לסמן כל אובייקט סטטי בסצנה כסטטי ניווט לפני אפיית NavMesh:

  • עבור לחלון NavMesh (חלון -> AI -> ניווט), לחץ על הכרטיסייה Bake ואז לחץ על כפתור Bake. לאחר אפיית ה-NavMesh הוא אמור להיראות בערך כך:

עכשיו הגיע הזמן ללחוץ על Play ולבדוק את זה:

Sharp Coder נגן וידאו

הכל עובד כמצופה!

מָקוֹר
SimpleFPS.unitypackage4.61 MB