Pentru miscarea si controlul caracterelor in 3D sunt posibile mai multe abordari.
CharacterController este o componenenta ce poate fi adaugata pentru a putea controla caracaterul. Aceasta componenta nu foloseste elemente de fizica in nici nu fel, dar vine atasat cu un collider de baza Capsule Collider
care nu permite intrarea in alte collidere. Acest lucru simplifica foarte mult scriptarea caracterului.
Character Controller pune la dispozitie doua metode pentru miscarea caracterului:
// Move forward / backward Vector3 forward = transform.TransformDirection(Vector3.forward); float curSpeed = speed * Input.GetAxis("Vertical"); controller.SimpleMove(forward * curSpeed);
moveDirection = new Vector3(Input.GetAxis("Horizontal"), 0.0f, Input.GetAxis("Vertical")); moveDirection = transform.TransformDirection(moveDirection); moveDirection = moveDirection * speed; if (Input.GetButton("Jump")) { moveDirection.y = jumpSpeed; } } // Apply gravity moveDirection.y = moveDirection.y - (gravity * Time.deltaTime); // Move the controller controller.Move(moveDirection * Time.deltaTime);
In cazul functiilor puse la dispozitie de Character Controller, miscarile sunt constranse de coliziuni in mod automat si este destul de permisiva (spre exemplu va putea urca autmat rampe sau scari).
Componenta de Rigidbody practic determina ca obiectul pe care e plasata sa se comporte ca un corp fizic, care interactioneaza cu mediul si asupra caruia se pot aplica forte fizice. Implicit, componenta Rigidbody este afectata de gravitatia definita ca prametru.
Exista 4 moduri in care se poate aplica o forta:
Pentru a adauga o forta exista de asemenea mai multe metode, dar cea standard este AddForce(Vector3 force, ForceMode mode = ForceMode.Force).
rb.AddForce(10.0f * Vector3.up); if (Input.GetButtonDown("Jump") && _isGrounded) { rb.AddForce(Vector3.up * Mathf.Sqrt(JumpHeight * -2f * Physics.gravity.y), ForceMode.VelocityChange); }
Pentru a misca un caracter folosind inputul, de regula se foloseste functia MovePosition. Aceasta functie va incerca sa mute caracterul la pozitia respectiva, tinand cont de coliziuni. De asemenea, nu va influienta cu nimic procesarile fizice in desfasurare aplicate asupra RigidBody-ului, comenzile de miscare ale caracterului fiind separate oarecum de mecanica de simulare a interactiunilor fizice.
Vector3 dirVector = new Vector3 (Input.GetAxis ("Horizontal"), 0, Input.GetAxis ("Vertical")).normalized; GetComponent <Rigidbody> ().MovePosition (transform.position + dirVector * Time.deltaTime);
Componenta Rigidbody este una foarte precisa din punct de vedere al coliziunilor, deci ofera mai mult control in acest sens, dar necesita si mai multa programare chiar si pentru lucruri simple, cum ar fi urcatul scarilor sau pe o rampa.
O mentiune importanta este ca Unity foloseste o separatie a procesarii functiilor de update in ceea ce priveste frame-ul. Astfel update-urile de procesare a fizicii sunt separate de update-urile de procesare a frame-ului:
O problema generala care apare la preluarea inputului este normalizarea.
Vector2 dirVector = new Vector2 (Input.GetAxis ("Horizontal"), Input.GetAxis ("Vertical"));
versus
Vector2 dirVector = new Vector2 (Input.GetAxis ("Horizontal"), Input.GetAxis ("Vertical")).normalized;
Daca inputul nu este normalizat, caracterul se va misca cu o viteza mai mare atunci cand se misca in fi
Pentru rotatia camerei exista mai multe variante, dar una dintre cele mai indiacate este sa retineti intotdeauna rotatia actuala a camerei, pentru a evita probleme legate de rotatii multiple compuse.
O varianta simpla:
private float yaw = 0.0f; private float pitch = 0.0f; void Update () { yaw += speedH * Input.GetAxis("Mouse X"); pitch -= speedV * Input.GetAxis("Mouse Y"); transform.eulerAngles = new Vector3(pitch, yaw, 0.0f); }
In spatiul 3D testarea selectarii sau tintirii asupra unui obiect se face in general printr-o tehnica numita Raycasting, care presupune trasarea unei raze de la sursa (in general camera) la destinatie. Acest lucru este util atat pentru object picking (selectarea unui obiect in spatiul 3D cu mouse-ul spre exemplu) cat si pentru tintirea si lansarea de proiectile.
Desi in cazul unui FPS, lansarea de proiectile se poate face si fizic (deci instantiez un proiectil care are damage, si ii imprim o forta cu care sa se deplaseze spre o directie), in general ea se simuleaza prin trasarea unei raze si detectia coliziuniii cu aceasta, si apoi aplicarea de efecte astfel incat impactul sa para real (proiectilul in general nu este vizibil ochiului uman).
Pentru trasarea unei raze de la camera la o tinta se poate folosi functia de Raycast din pachetul de Physics oferit de Unity. Functia Raycast accepta o multitudine de variante de paramtri, in functie nevoia de utilizare, parametrii principali fiind punctul de start si directia. Un exemplu pentru o camera FPS si o tinta intr-un anumit range se poate folosi urmatoarea secventa de cod:
RaycastHit hit; //daca am un obiect pe directia in range if(Physics.Raycast(fpsCam.transform.position, fpsCam.transform.forward, out hit, range)) { //accesez un script de damage pe targetul meu daca accesta exista Enemy orc = hit.transform.GetComponent<Enemy>(); if(orc!=null) { orc.TakeDamage(ammount); //TakeDamage este o functie publica in scriptul de Enemy enemyHealthBar.SetActive(true); //pot afisa si Healthbar-ul inamicului la plasarea tintei pe acesta } if(orc.rigidbody != null) { orc.rigidbody.addForce(-hit.normal * impactForce); //adauga o forta la impact } }
Asa cum se observa si in exemplul de mai sus, Racycast-urile se pot folosi in multe moduri. Cateva exemple:
Similar cu spatiul 2D, se pot folosi atat obiecte plasate in scena, cat si partea de Canvas, in ceea ce priveste interfata grafica.
Astfel, spre exemplu pentru un FPS single player, in general nu avem nevoie de caracter, ci doar de maini, sau eventual doar o arma animata. Pentru a controla usor acest aspect se poate importa un obiect texturat pentru arma, care se va atasa camerei 3D disponibile in scena si se va configura astfel incat sa arate cat mai natural.
Pentru a crea un crosshair sau o tinta se poata adauga un disc sau o imagine cu transparenta in centrul canvas-ului.
Bineinteles, se poate crea un crosshair animat si folosind 4 dreptunghiuri simple.
Pentru reprezentarea healthbar-ului sau a altor elemente de tip bara, se poate folosi un element de UI (gameobject) de tip Slider, asa cum este reprezentat in imaginea de mai sus.
GetComponent.<Slider>().value = myHealth;
Pentru crearea unui minimap se foloseste de regula o camera suplimentara, plasata top-down deasupra scenei.
Camera special creata va trebui sa afiseze scena intr-o textura. Pentru acest lucru vom folosi un asset de tip Render texture
(Assets → Create → Render Texture) pe care o vom atasa ca Target in camera nou creata.
Pasul urmator este sa afisam Minimap-ul. Pentru acest lucru, in canvas vom crea o imagine si vom lega textura in care salvam imaginile de la camera la aceasta.
Rezultatul poate fi ibunatatit prin plasarea acestuia intr-un loc potrivit adaugarea unei masti sau a unei borduri si asa mai departe.
Pentru a defini sau actualiza Layere se paote merge la Edit → Project Settings → Tags and Layers. Putem folosi Layere pentru a delimita diverse lucruri, de exemplu care obiecte se vad pe carema, pentru care se calculeaza coliziunea etc.
Putem simplifica geometria din minimap folosind Layere si primitive simplificate.
De exemplu se poata adauga un obiect copil pentru caracter sau obietele care vreau sa apara pe harta. Setam Layerul de Minimap pentru obiect.
Adaugam si cativa inamici cu alta culoare
Urmatorul pas este sa configuram camera principala astfel incat sa nu afiseze aceste primitive.
Apoi dezactivam toate celelalte layere si lasam doar Minimap pentru camera care face Render to Texture din setarea de Culling Mask
Rezultatul este simplificat ca geometrie si urmeaza pozitiile personajelor
Crearea animatiilor se poate face folosind utilitarul de animatie (Window > Animation), similar ca in spatiul 2D. Similar, se poate folosi Animator Controller pentru a gestiona starile (spre exemplu tras cu arma).
void Update() { if(Input.GetButtonDown("Fire1")) { //set animation state animator.SetInteger("state", STATE_SHOOTING); //raycast & everything else .. } }
Daca vreti sa aveti actiunea de Fire incontinuu, se poate folosi punctia de Input, GetButton()
void Update() { if(Input.GetButton("Fire1") && Time.time >= nextFire) { nextFire = Time.time + 1f/fireRate; //set animation state animator.SetInteger("state", STATE_SHOOTING); //raycast & everything else .. } }
Animatia caracterelor 3D, se face similar ca in 2D, fiind utilizata animatia bazata pe oase. Astfel, fiecare caracter 3D are definite oase (bones) care controleaza miscarea si animatia acestuia.
Crearea animatiilor se poate face folosind utilitarul de animatie (Window > Animation), similar ca in spatiul 2D. Similar, se poate folosi Animator Controller pentru a gestiona starile.
Ca notiune suplimentara, se pot defini stari de tranzitie/interpolare intre anumite animatii. Un exemplu comun este trecerea sau ajustarea animatiei de walk si run in functie de viteza curenta a personajului, sau a animatie de alergare stanga - fata - dreapta (daca sunt definite animatii diferite). Acest lucru se poate realiza folosind BlendTrees
(Create State > From New Blend Tree).
In cazul in care vrem sa combinam animatii putem folosi masti si niveluri de animatie.
Mastile ne permit sa facem discard la o portiune de animatie astfel incat sa preluam animatia doar pentru anumite parti de schelet.
In combinatie cu Layere de animatie in Unity putem combina diverse tipuri de animatii: de ex mers si tras in acelasi timp.
Pentru navigarea automata a personajelor pana la un punct pe harta, indiferent ca e vorba de un personaj controlat sau un agent inteligent, avem nevoie in primul rand de generarea unor zone in care acestea sa se poata misca. Aceste zone poarta numele de Navigation Mesh sau NavMesh, prescurtat. In Unity se pot genera automat acest NavMesh folosind utilitarul de Navigation Bake: Window > AI > Navigation > Bake.
De mentionat este ca un navmesh se genereaza folosind elementele statice ale scenei. Pentru a marca un obiect ca fiind static, trebuie bifat in inspector casuta de Static.
Mai mult, meniul de Navigation, permite detalierea fiecarui obiect cu privire la navigatie, astfel incat se poate specifica daca un obiect e static sau daca e Walkable sau nu.
Crearea (Bake) NavMesh-ului este statica, in sensul ca odata creata ea nu se mai modifica la runtime. Astfel, daca avem obiecte mobile, care influienteaza NavMesh-ul, nu le putem declara statice si nu le putem include in procesul de Bake. Pentru acestea exista o componenta Nav Mesh Obstacle
care altereaza dinamic NavMesh-ul.
Rezultatul este o zona in care agentii sau jucatorii pot naviga folosind functii puse la dispozitie de unity pentru deplasare.
Pentru a programa inamici sau agenti NPC (Non-Playable Character) se poate folosi aceeasi functionalitate de navigare automata (NavMesh) si componenta de tip NavMeshAgent, pentru navigatie, similar cu sectiunea de Navigare automata din laboratorul precedent.
Diferenta este ca acesti agenti vor raspunde automat la anumite evenimente:
Astfel, pentru inamici putem defini un controller cu un radius de actiune, si un gizmos pentru vizualizare usoara a acestuia in editor.
public class EnemyController : MonoBehaviour { public float radius = 2; void OnDrawGizmosSelected() { Gizmos.color = Color.red; Gizmos.DrawWireSphere(transform.position, radius); } }
Pentru ca un inamic sa se miste spre player, atunci cand player-ul intra in raza de actiune putem folosi componenta de NavMeshAgent
void Update() { //calculam distanta intre player si inamic float distance = Vector3.Distance(target.position, transform.position); if(distance <= radius) { //misca agentul pana la player agent.SetDestination(target.position); //in momentul in care intra in raza de atac, ataca } else { // agentul se misca in treaba lui / patruleaza etc. }
Mai departe, se poate folosi o alta distanta, pentru a determina raza de atac. Un inamic poate avea atac melee (de aproape) sau de la o anumita distanta.
public float attackRadius = 1; void Update() { //calculam distanta intre player si inamic float distance = Vector3.Distance(target.position, transform.position); if(distance <= radius) { //misca agentul pana la player agent.SetDestination(hit.point); //in momentul in care intra in raza de atac, ataca if(distance <= attack) { //ataca } }
O problema in activitatea agentilor este detectarea player-ului, in sensul de referinta. Astfel, avem mai multe variante:
public class PlayerManager : MonoBehaviour { public static PlayerManager instance; public GameObject player; void Awake() { instance = this; } }
Folosind varianta simpla cu singleton, putem lua pozitia player-ului de inters, similar cu laboratorul precedent:
target = PlayerManager.instance.player.transform;
//Roteste cu 90 grade void RotateN() { Vector3 currentRotation = transform.rotation; Vector3 wantedRotation = currentRotation * Quaternion.AngleAxis(-90, Vector3.up); transform.rotation = Quaternion.Slerp(currentRotation, wantedRotation, Time.deltaTime * rotationSpeed); } //Roteste inamicul cu fata catre player void FaceTarget () { Vector3 direction = (target.position - transform.position).normalized; Quaternion lookRotation = Quaternion.LookRotation(new Vector3(direction.x, 0, direction.z)); transform.rotation = Quaternion.Slerp(transform.rotation, lookRotation, Time.deltaTime * 5f); }
Interactiunea cu obiectele in spatiul 3D poate fi extrem de complexa, intrucat exista foarte multe forme de interactiuni: inamici, obiecte de pickup, deschidere de usi, activare de manivele etc. Fiecare dintre aceste interactiuni are specificul ei, dar abstractizand, putem deduce ca fiecare interactiune se intampla intr-o anumita raza si cu un anumit punct de interactiune. Pentru a defini usor aceste lucruri, putem crea o clasa generica denumita InteractionObject
cu o metoda abstracta (virtuala) ce defineste interactiunea in detaliu.
public class InteractionObject : MonoBehaviour { public float radius = 1f; public Transform interactionPoint; Transform interactionObject; bool done = false; //metoda abstracta, speficica fiecarui tip de interactiuni public virtual void Interaction () { } void Update () { float distance = Vector3.Distance(interactionObject.position, interactionPoint.position); if (distance <= radius && !done) // avem interactiune cu obiectul, pot sa afisez informatii: de ex "Press E to use" { done = true; Interaction(); } } } }
Astfel, toate obiectele ce vor avea interactiuni, vor mosteni aceasta clasa. Spre exemplu pentru un obiect de pickup putem avea urmatoarea secventa:
public class PickupObject : InteractionObject { public override void Interaction() { base.Interaction(); // se apeleaza metoda parinte, in caz ca avem ceva generic //mecanica ... //distrugem obiectul Destroy(gameObject); } }
Pentru a controla mai bine zona de actiune (radius) si punctul de interes pentru un obiect de interactiune (InteractionObject), se poate defini o functie de editor, atunci cand obiectul este selectat. In exemplul de mai jos, la selectarea obiectului se va afisa o sfera wireframe de culoare alba.
void OnDrawGizmosSelected () { Gizmos.color = Color.white; Gizmos.DrawWireSphere(interactionPoint.position, radius); }
Realizarea unui joc 3D FPS
Gasiti aici modul in care se poate adauga direct acest repository ca pachet: https://docs.unity3d.com/Manual/upm-ui-giturl.html