كيفية إنشاء لعبة FPS بدعم الذكاء الاصطناعي في Unity
لعبة إطلاق النار من منظور الشخص الأول (FPS) هي نوع فرعي من ألعاب إطلاق النار حيث يتم التحكم في اللاعب من منظور الشخص الأول.
لإنشاء لعبة FPS في Unity، سنحتاج إلى وحدة تحكم اللاعب ومجموعة من العناصر (الأسلحة في هذه الحالة) والأعداء.
الخطوة 1: إنشاء وحدة تحكم المشغل
سنقوم هنا بإنشاء وحدة تحكم سيستخدمها لاعبنا.
- قم بإنشاء كائن لعبة جديد (كائن اللعبة -> إنشاء فارغ) وقم بتسميته "Player"
- قم بإنشاء كبسولة جديدة (كائن اللعبة -> كائن ثلاثي الأبعاد -> كبسولة) وحركها داخل الكائن "Player"
- قم بإزالة مكون مصادم الكبسولة من الكبسولة وقم بتغيير موضعه إلى (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"
- قم بتحريك مدير الأسلحة داخل الكاميرا الرئيسية للاعب وقم بتغيير موضعه إلى (0، 0، 0)
- قم بإرفاق البرنامج النصي SC_WeaponManager إلى "WeaponManager"
- قم بتعيين الكاميرا الرئيسية لمتغير Player Camera في SC_WeaponManager
إعداد بندقية
- قم بسحب وإسقاط نموذج بندقيتك في المشهد (أو ببساطة قم بإنشاء مكعب وقم بتمديده إذا لم يكن لديك نموذج بعد).
- قم بقياس النموذج بحيث يكون حجمه متناسبًا مع كبسولة اللاعب
في حالتي، سأستخدم نموذج بندقية مصنوع خصيصًا (BERGARA BA13):
- قم بإنشاء GameObject جديد وقم بتسميته "Rifle" ثم قم بتحريك نموذج البندقية بداخله
- انقل الكائن "Rifle" داخل الكائن "WeaponManager" وضعه أمام الكاميرا هكذا:
لإصلاح لقطة الكائن، ما عليك سوى تغيير مستوى القطع القريب للكاميرا إلى شيء أصغر (في حالتي قمت بتعيينه على 0.15):
أفضل بكثير.
- قم بإرفاق البرنامج النصي SC_Weapon بكائن Rifle (ستلاحظ أنه أضاف أيضًا مكون مصدر الصوت، وهذا ضروري لتشغيل النار وإعادة تحميل الصوتيات).
كما ترون، لدى SC_Weapon 4 متغيرات لتعيينها. يمكنك تعيين متغيرات الصوت Fire وReload audio على الفور إذا كان لديك مقاطع صوتية مناسبة في مشروعك.
سيتم شرح متغير Bullet Prefab لاحقًا في هذا البرنامج التعليمي.
في الوقت الحالي، سنقوم فقط بتعيين متغير نقطة النار:
- قم بإنشاء GameObject جديد، وأعد تسميته إلى "FirePoint" وانقله داخل Rifle Object. ضعه أمام البرميل مباشرة أو بداخله قليلاً، مثل هذا:
- قم بتعيين تحويل FirePoint إلى متغير نقطة النار في SC_Weapon
- قم بتعيين بندقية لمتغير سلاح ثانوي في البرنامج النصي SC_WeaponManager
إعداد مدفع رشاش
- قم بتكرار كائن البندقية وأعد تسميته إلى Submachinegun
- استبدل نموذج البندقية بداخله بنموذج مختلف (في حالتي سأستخدم الطراز المخصص من TAVOR X95)
- حرك تحويل Fire Point حتى يناسب النموذج الجديد
- قم بتعيين Submachinegun لمتغير السلاح الأساسي في البرنامج النصي SC_WeaponManager
إعداد رصاصة الجاهزة
سيتم إنتاج الرصاصة الجاهزة وفقًا لمعدل إطلاق النار للسلاح وستستخدم Raycast لاكتشاف ما إذا كانت قد أصابت شيئًا ما وأحدثت ضررًا.
- قم بإنشاء GameObject جديد وقم بتسميته "Bullet"
- أضف مكون Trail Renderer إليه وقم بتغيير متغير الوقت الخاص به إلى 0.1.
- اضبط منحنى العرض على قيمة أقل (على سبيل المثال، البداية 0.1 النهاية 0)، لإضافة مسار ذي مظهر مدبب
- قم بإنشاء مادة جديدة وقم بتسميتها Bullet_trail_material وقم بتغيير التظليل الخاص بها إلى Particles/Additive
- قم بتعيين مادة تم إنشاؤها حديثًا إلى Trail Renderer
- قم بتغيير لون Trail Renderer إلى شيء مختلف (على سبيل المثال، البداية: برتقالي ساطع، نهاية: برتقالي داكن)
- احفظ كائن التعداد النقطي في Prefab واحذفه من المشهد.
- قم بتعيين مبنى جاهز تم إنشاؤه حديثًا (اسحب وأفلت من عرض المشروع) لمتغير Rifle وSubmachinegun Bullet Prefab
مدفع رشاش:
بندقية:
الأسلحة جاهزة الآن.
الخطوة 3: إنشاء الذكاء الاصطناعي للعدو
سيكون الأعداء عبارة عن مكعبات بسيطة تتبع اللاعب وتهاجم بمجرد اقترابهم بدرجة كافية. سوف يهاجمون على شكل موجات، مع وجود المزيد من الأعداء في كل موجة للقضاء عليهم.
إعداد الذكاء الاصطناعي للعدو
لقد قمت أدناه بإنشاء نسختين مختلفتين من المكعب (الشكل الأيسر مخصص للمثال الحي وسيتم نشر الشكل الأيمن بمجرد مقتل العدو):
- قم بإضافة مكون Rigidbody إلى كل من المثيلات الميتة والحية
- احفظ المثيل الميت في المبنى الجاهز واحذفه من المشهد.
الآن، سيحتاج المثيل الحي إلى مكونين إضافيين حتى يتمكن من التنقل في مستوى اللعبة وإلحاق الضرر باللاعب.
- أنشئ سكريبت جديد وسميه "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
- بالنسبة لنقطة النار، قم بإنشاء كائن GameObject جديد، وحركه داخل مثيل العدو الحي وضعه أمام المثيل قليلًا، ثم قم بتعيينه لمتغير Fire Point:
- أخيرًا، احفظ المثيل الحي في Prefab واحذفه من Scene.
إعداد مولد العدو
الآن دعنا ننتقل إلى SC_EnemySpawner. سينتج هذا البرنامج النصي أعداء في موجات وسيُظهر أيضًا بعض معلومات واجهة المستخدم على الشاشة، مثل صحة اللاعب والذخيرة الحالية وعدد الأعداء المتبقين في الموجة الحالية وما إلى ذلك.
- قم بإنشاء GameObject جديد وقم بتسميته "_EnemySpawner"
- قم بإرفاق البرنامج النصي SC_EnemySpawner به
- قم بتعيين الذكاء الاصطناعي للعدو الذي تم إنشاؤه حديثًا إلى متغير Enemy Prefab
- قم بتعيين النسيج أدناه لمتغير Crosshair Texture
- قم بإنشاء اثنين من كائنات اللعبة الجديدة ووضعهما حول المشهد ثم قم بتعيينهما في مصفوفة Spawn Points
ستلاحظ أن هناك متغيرًا أخيرًا متبقيًا لتعيينه وهو متغير Player.
- قم بإرفاق البرنامج النصي SC_DamageReceiver بمثيل المشغل
- قم بتغيير علامة مثيل المشغل إلى "Player"
- قم بتعيين متغيرات وحدة التحكم في المشغل ومدير الأسلحة في SC_DamageReceiver
- قم بتعيين مثيل Player لمتغير Player في SC_EnemySpawner
وأخيرًا، يتعين علينا إنشاء NavMesh في مشهدنا حتى يتمكن الذكاء الاصطناعي للعدو من التنقل.
لا تنس أيضًا وضع علامة على كل كائن ثابت في المشهد باعتباره التنقل الثابت قبل إنشاء NavMesh:
- انتقل إلى نافذة NavMesh (نافذة -> AI -> التنقل)، وانقر على علامة التبويب "خبز" ثم انقر على زر "خبز". بعد خبز NavMesh، يجب أن يبدو كما يلي:
حان الوقت الآن للضغط على "تشغيل" واختباره:
كل شيء يعمل كما هو متوقع!