כיצד ליצור 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):
- צור GameObject חדש ושם לו "Rifle" ואז הזיז את דגם הרובה בתוכו
- הזז את האובייקט "Rifle" בתוך האובייקט "WeaponManager" והצב אותו מול המצלמה כך:
כדי לתקן את חיתוך האובייקט, פשוט שנה את מישור הגזירה הקרוב של המצלמה למשהו קטן יותר (במקרה שלי הגדרתי אותו ל-0.15):
הרבה יותר טוב.
- צרף סקריפט 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)
- הזיזו טרנספורמציה של Fire Point עד שתתאים לדגם החדש
- הקצה 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 ולבדוק את זה:
הכל עובד כמצופה!