Fai uno sparatutto al neon in jMonkeyEngine nemici e suoni

Nella prima parte di questa serie sulla creazione di un gioco ispirato a Geometry Wars in jMonkeyEngine, abbiamo implementato la nave del giocatore e l'abbiamo lasciata muovere e sparare. Questa volta, aggiungeremo i nemici e gli effetti sonori.


Panoramica

Ecco a cosa stiamo lavorando per l'intera serie:


... ed ecco cosa avremo alla fine di questa parte:


Avremo bisogno di alcune nuove classi per implementare le nuove funzionalità:

  • SeekerControl: Questa è una classe di comportamento per il nemico ricercatore.
  • WandererControl: Questa è anche una classe di comportamento, questa volta per il nemico errante.
  • Suono: Gestiremo il caricamento e la riproduzione di effetti sonori e musica con questo.

Come avrai intuito, aggiungeremo due tipi di nemici. Il primo è chiamato a cercatore; inseguirà attivamente il giocatore fino alla sua morte. L'altro, il vagabondo, si aggira intorno allo schermo in modo casuale.


Aggiungere nemici

Genereremo i nemici in posizioni casuali sullo schermo. Per dare al giocatore un po 'di tempo per reagire, il nemico non sarà attivo immediatamente, ma piuttosto si affievolirà lentamente. Dopo che è completamente sbiadito, inizierà a muoversi attraverso il mondo. Quando entra in collisione con il giocatore, il giocatore muore; quando si scontra con un proiettile, muore da solo.

Nemici generati

Prima di tutto, dobbiamo creare alcune nuove variabili nel MonkeyBlasterMain classe:

 private long enemySpawnCooldown; private float enemySpawnChance = 80; nodo privato enemyNode;

Presto useremo i primi due abbastanza presto. Prima di ciò, dobbiamo inizializzare il enemyNode nel simpleInitApp ():

 // imposta il Nodo nemico enemyNode = new Node ("enemy"); guiNode.attachChild (enemyNode);

Ok, ora passiamo al vero codice di spawning: scavalcheremo simpleUpdate (float tpf). Questo metodo viene chiamato dal motore più e più volte e continua a chiamare la funzione di spawning del nemico fintanto che il giocatore è vivo. (Abbiamo già impostato i dati utente vivo a vero nell'ultimo tutorial.)

 @Override public void simpleUpdate (float tpf) if ((Boolean) player.getUserData ("alive")) spawnEnemies (); 

E questo è il modo in cui generiamo i nemici:

 private void spawnEnemies () if (System.currentTimeMillis () - enemySpawnCooldown> = 17) enemySpawnCooldown = System.currentTimeMillis (); if (enemyNode.getQuantity () < 50)  if (new Random().nextInt((int) enemySpawnChance) == 0)  createSeeker();  if (new Random().nextInt((int) enemySpawnChance) == 0)  createWanderer();   //increase Spawn Time if (enemySpawnChance >= 1.1f) enemySpawnChance - = 0.005f; 

Non farti confondere dal enemySpawnCooldown variabile. Non è lì per far apparire i nemici a una frequenza decente: 17ms sarebbe troppo breve di un intervallo.

enemySpawnCooldown è in realtà lì per garantire che la quantità di nuovi nemici sia la stessa su ogni macchina. Sui computer più veloci, simpleUpdate (float tpf) viene chiamato molto più spesso rispetto a quelli più lenti. Con questa variabile controlliamo ogni 17ms se dovessimo generare nuovi nemici.
Ma vogliamo generarli ogni 17ms? In realtà vogliamo che si generino a intervalli casuali, quindi presentiamo un Se dichiarazione:

 if (new Random (). nextInt ((int) enemySpawnChance) == 0) 

Più piccolo è il valore di enemySpawnChance, più è probabile che un nuovo nemico si riproduca in questo intervallo di 17ms, e quindi più nemici dovranno affrontare il giocatore. Ecco perché sottraiamo un po 'di enemySpawnChance ogni segno di spunta: significa che il gioco diventerà più difficile nel tempo.

La creazione di cercatori e vagabondi è simile alla creazione di qualsiasi altro oggetto:

 private void createSeeker () Spatial seeker = getSpatial ("Seeker"); seeker.setLocalTranslation (getSpawnPosition ()); seeker.addControl (new SeekerControl (player)); seeker.setUserData ( "attivo", false); enemyNode.attachChild (ricercatore);  private void createWanderer () Spatial wanderer = getSpatial ("Wanderer"); wanderer.setLocalTranslation (getSpawnPosition ()); wanderer.addControl (new WandererControl ()); wanderer.setUserData ( "attivo", false); enemyNode.attachChild (wanderer); 

Creiamo lo spazio, lo spostiamo, aggiungiamo un controllo personalizzato, lo impostiamo inattivo e lo colleghiamo al nostro enemyNode. Che cosa? Perché non attivo? Questo perché non vogliamo che il nemico inizi a inseguire il giocatore non appena viene generato; vogliamo dare al giocatore un po 'di tempo per reagire.

Prima di entrare nei controlli, dobbiamo implementare il metodo getSpawnPosition (). Il nemico dovrebbe generarsi in modo casuale, ma non vicino al giocatore:

 private Vector3f getSpawnPosition () Vector3f pos; do pos = new Vector3f (new Random (). nextInt (settings.getWidth ()), new Random (). nextInt (settings.getHeight ()), 0);  while (pos.distanceSquared (player.getLocalTranslation ()) < 8000); return pos; 

Calcoliamo una nuova posizione casuale pos. Se è troppo vicino al giocatore, calcoliamo una nuova posizione e ripetiamo fino a una distanza decente.

Ora abbiamo solo bisogno di rendere i nemici attivi e iniziare a muoversi. Lo faremo nei loro controlli.

Controllo del comportamento dei nemici

Ci occuperemo del SeekerControl primo:

 public class SeekerControl estende AbstractControl private Spatial player; velocità privata Vector3f; privato lungo spawnTime; public SeekerControl (Spatial player) this.player = player; velocity = new Vector3f (0,0,0); spawnTime = System.currentTimeMillis ();  @Override protected void controlUpdate (float tpf) if ((Boolean) spatial.getUserData ("active")) // traduce il seeker Vector3f playerDirection = player.getLocalTranslation (). Sottrarre (spatial.getLocalTranslation ()); playerDirection.normalizeLocal (); playerDirection.multLocal (1000F); velocity.addLocal (playerDirection); velocity.multLocal (0.8f); spatial.move (velocity.mult (TPF * 0.1f)); // ruota il seeker se (velocity! = Vector3f.ZERO) spatial.rotateUpTo (velocity.normalize ()); spatial.rotate (0,0, FastMath.PI / 2f);  else // gestisce lo script "active" -status long = System.currentTimeMillis () - spawnTime; if (dif> = 1000f) spatial.setUserData ("active", true);  ColorRGBA color = new ColorRGBA (1,1,1, dif / 1000f); Node spatialNode = (Node) spaziale; Picture pic = (Immagine) spatialNode.getChild ("Seeker"); pic.getMaterial () setColor ( "Colore", colore).;  @Override protected void controlRender (RenderManager rm, ViewPort vp) 

Concentriamoci su controlUpdate (float tpf):

Per prima cosa, dobbiamo controllare se il nemico è attivo. Se non lo è, dobbiamo sbiadirlo lentamente.
Controlliamo quindi il tempo trascorso da quando abbiamo generato il nemico e, se è abbastanza lungo, lo rendiamo attivo.

Indipendentemente dal fatto che lo abbiamo appena impostato, dobbiamo regolarne il colore. La variabile locale spaziale contiene lo spazio a cui è stato assegnato il controllo, ma è possibile ricordare che non abbiamo applicato il controllo all'immagine reale: l'immagine è figlia del nodo a cui è stato collegato il controllo. (Se non sai di cosa sto parlando, dai un'occhiata al metodo getSpatial (nome stringa) abbiamo implementato l'ultimo tutorial.)

Così; abbiamo la foto di un bambino di spaziale, prendi il suo materiale e imposta il suo colore al valore appropriato. Niente di speciale una volta che sei abituato a spazi, materiali e nodi.

Informazioni: Potresti chiederti perché abbiamo impostato il colore del materiale sul bianco. (I valori RGB sono tutti 1 nel nostro codice). Non vogliamo un nemico giallo e uno rosso?
È perché il materiale mescola il colore del materiale con i colori della trama, quindi se vogliamo mostrare la trama del nemico così com'è, dobbiamo mescolarlo con il bianco.

Ora dobbiamo dare un'occhiata a ciò che facciamo quando il nemico è attivo. Questo controllo è chiamato SeekerControl per una ragione: vogliamo che i nemici con questo controllo attaccato seguano il giocatore.

Per riuscirci, calcoliamo la direzione dal ricercatore al giocatore e aggiungiamo questo valore alla velocità. Dopodiché, riduciamo la velocità dell'80% in modo che non possa crescere all'infinito e spostare il ricercatore di conseguenza.

La rotazione non è nulla di speciale: se il ricercatore non sta fermo, lo ruotiamo nella direzione del giocatore. Quindi lo ruotiamo un po 'di più perché il ricercatore Seeker.png non è rivolto verso l'alto, ma a destra.

Informazioni: Il rotateUpTo (direzione Vector3f) metodo di Spaziale ruota uno spazio in modo che il suo asse y punti nella direzione data.

Quindi quello fu il primo nemico. Il codice del secondo nemico, il vagabondo, non è molto diverso:

 public class WandererControl estende AbstractControl private int screenWidth, screenHeight; velocità privata Vector3f; direzione float privataAngle; privato lungo spawnTime; public WandererControl (int screenWidth, int screenHeight) this.screenWidth = screenWidth; this.screenHeight = screenHeight; velocity = new Vector3f (); directionAngle = new Random (). nextFloat () * FastMath.PI * 2f; spawnTime = System.currentTimeMillis ();  @Override protected void controlUpdate (float tpf) if ((Boolean) spatial.getUserData ("active")) // traduce il wanderer // cambia la direzioneAngle un po 'directionAngle + = (new Random (). NextFloat () * 20f - 10f) * tpf; System.out.println (directionAngle); Vector3f directionVector = MonkeyBlasterMain.getVectorFromAngle (directionAngle); directionVector.multLocal (1000F); velocity.addLocal (directionVector); // diminuisce la velocità un po 'e muove il vagabondo velocity.multLocal (0.8f); spatial.move (velocity.mult (TPF * 0.1f)); // far rimbalzare il vagabondo dai bordi dello schermo Vector3f loc = spatial.getLocalTranslation (); if (loc.x screenWidth || loc.y> screenHeight) Vector3f newDirectionVector = new Vector3f (screenWidth / 2, screenHeight / 2,0) .subtract (loc); directionAngle = MonkeyBlasterMain.getAngleFromVector (newDirectionVector);  // ruota il vagabondo spazial.rotate (0,0, tpf * 2);  else // gestisce lo script "active" -status long = System.currentTimeMillis () - spawnTime; if (dif> = 1000f) spatial.setUserData ("active", true);  ColorRGBA color = new ColorRGBA (1,1,1, dif / 1000f); Node spatialNode = (Node) spaziale; Picture pic = (Immagine) spatialNode.getChild ("Wanderer"); pic.getMaterial () setColor ( "Colore", colore).;  @Override protected void controlRender (RenderManager rm, ViewPort vp) 

Le cose facili prima: svanire il nemico è lo stesso del controllo dei cercatori. Nel costruttore, scegliamo una direzione casuale per il vagabondo, in cui volerà una volta attivato.

Mancia: Se hai più di due nemici o vuoi semplicemente strutturare il gioco in modo più pulito, puoi aggiungere un terzo controllo: EnemyControl Gestirebbe tutto ciò che tutti i nemici avevano in comune: spostare il nemico, dissolverlo, renderlo attivo ...

Ora alle principali differenze:

Quando il nemico è attivo, prima cambiamo direzione un po ', in modo che il vagabondo non si muova in linea retta per tutto il tempo. Lo facciamo cambiando il nostro directionAngle un po 'e aggiungendo il directionVector al velocità. Applichiamo quindi la velocità proprio come facciamo noi SeekerControl.

Dobbiamo verificare se il vagabondo è al di fuori dei confini dello schermo e, in caso affermativo, cambiamo il directionAngle ad una direzione più appropriata in modo che venga applicata nel prossimo aggiornamento.

Infine, ruotiamo un po 'il vagabondo. Questo è solo perché un nemico che gira sembra più fresco.

Ora che abbiamo completato l'implementazione di entrambi i nemici, puoi iniziare il gioco e giocare un po '. Ti dà una piccola occhiata a come giocherà il gioco, anche se non puoi uccidere i nemici e neanche loro possono ucciderti. Aggiungiamolo dopo.

Rilevamento collisione

Per fare in modo che i nemici uccidano il giocatore, dobbiamo sapere se si scontrano. Per questo, aggiungeremo un nuovo metodo, handleCollisions, chiamato simpleUpdate (float tpf):

 @Override public void simpleUpdate (float tpf) if ((Boolean) player.getUserData ("alive")) spawnEnemies (); handleCollisions (); 

E ora il metodo attuale:

 private void handleCollisions () // il giocatore dovrebbe morire? per (int i = 0; i 

Scorriamo tutti i nemici raccogliendo la quantità dei bambini del nodo e ottenendone ognuno di essi. Inoltre, dobbiamo solo controllare se il nemico uccide il giocatore quando il nemico è effettivamente attivo. Se non lo è, non dobbiamo preoccuparcene. Quindi, se è attivo, controlliamo se il giocatore e il nemico si scontrano. Lo facciamo con un altro metodo, checkCollisoin (Spatial a, Spaziale b):

 private boolean checkCollision (Spatial a, Spatial b) float distance = a.getLocalTranslation (). distance (b.getLocalTranslation ()); float maxDistance = (Float) a.getUserData ("radius") + (Float) b.getUserData ("radius"); ritorno a distanza <= maxDistance; 

Il concetto è piuttosto semplice: in primo luogo, calcoliamo la distanza tra le due aree. Successivamente, abbiamo bisogno di sapere quanto devono essere vicine le due spaziali per poter essere considerate in collisione, in modo da ottenere il raggio di ogni spaziale e aggiungerle. (Abbiamo impostato il raggio "dati utente" in getSpatial (nome stringa) nel precedente tutorial.) Quindi, se la distanza effettiva è inferiore o uguale a questa distanza massima, il metodo ritorna vero, il che significa che sono entrati in collisione.

E adesso? Dobbiamo uccidere il giocatore. Creiamo un altro metodo:

 private void killPlayer () player.removeFromParent (); Player.getControl (PlayerControl.class) .reset (); player.setUserData ("alive", false); player.setUserData ("dieTime", System.currentTimeMillis ()); enemyNode.detachAllChildren (); 

Per prima cosa, separiamo il giocatore dal suo nodo genitore, che lo rimuove automaticamente dalla scena. Quindi, dobbiamo resettare il movimento dentro PlayerControl-altrimenti, il giocatore potrebbe ancora muoversi quando si genera nuovamente.

Quindi impostiamo i dati utente vivo a falso e creare un nuovo dato utente dieTime. (Avremo bisogno di ricreare il giocatore quando è morto.)

Alla fine, separiamo tutti i nemici, poiché il giocatore avrebbe difficoltà a combattere i nemici già esistenti nel momento in cui viene generato.

Abbiamo già menzionato il respawning, quindi gestiamolo dopo. Cambierà, ancora una volta, il simpleUpdate (float tpf) metodo:

 @Override public void simpleUpdate (float tpf) if ((Boolean) player.getUserData ("alive")) spawnEnemies (); handleCollisions ();  else if (System.currentTimeMillis () - (Long) player.getUserData ("dieTime")> 4000f &&! gameOver) // spawn player player.setLocalTranslation (500,500,0); guiNode.attachChild (giocatore); player.setUserData ( "vivo", true); 

Quindi, se il giocatore non è vivo ed è morto abbastanza a lungo, abbiamo impostato la sua posizione al centro dello schermo, lo abbiamo aggiunto alla scena e infine impostato i suoi dati utente vivo a vero ancora!

Ora potrebbe essere un buon momento per iniziare il gioco e testare le nostre nuove funzionalità. Avrai difficoltà a durare più di venti secondi, però, perché la tua pistola è inutile, quindi facciamo qualcosa al riguardo.

Per fare in modo che i proiettili uccidano i nemici, aggiungeremo del codice al handleCollisions () metodo:

 // dovrebbe morire un nemico? int i = 0; mentre io < enemyNode.getQuantity())  int j=0; while (j < bulletNode.getQuantity())  if (checkCollision(enemyNode.getChild(i),bulletNode.getChild(j)))  enemyNode.detachChildAt(i); bulletNode.detachChildAt(j); break;  j++;  i++; 

La procedura per uccidere i nemici è praticamente la stessa di uccidere il giocatore; iteriamo attraverso tutti i nemici e tutti i proiettili, controlliamo se si scontrano e, se lo fanno, li separiamo entrambi.

Adesso fai il gioco e guarda fino a dove vai!

Informazioni: Muovere attraverso ogni nemico e confrontare la sua posizione con la posizione di ciascun proiettile è un pessimo modo di controllare le collisioni. Va bene in questo esempio per semplicità, ma in a vero gioco si dovrebbe implementare algoritmi migliori per farlo, come il rilevamento di collisione quadriponte. Fortunatamente, jMonkeyEngine utilizza il motore fisico di Bullet, quindi ogni volta che hai complicato la fisica 3D, non devi preoccuparti di questo.

Ora abbiamo finito con il gameplay principale. Stiamo ancora implementando buchi neri e mostreremo il punteggio e le vite del giocatore, e per rendere il gioco più divertente ed emozionante aggiungeremo effetti sonori e grafica migliore. Quest'ultimo sarà raggiunto attraverso il filtro post processing, alcuni effetti particellari e un fantastico effetto di sfondo.

Prima di considerare questa parte della serie terminata, aggiungeremo audio e l'effetto bloom.


Riproduzione di suoni e musica

Per un po 'di audio al nostro gioco creeremo una nuova classe, semplicemente chiamata Suono:

 public class Sound musica AudioNode privata; colpi privati ​​AudioNode []; esplosioni private AudioNode []; private AudioNode [] spawns; AssetManager privato assetManager; public Sound (AssetManager assetManager) this.assetManager = assetManager; shots = new AudioNode [4]; explosions = new AudioNode [8]; spawn = nuovo AudioNode [8]; loadSounds ();  private void loadSounds () music = new AudioNode (assetManager, "Sounds / Music.ogg"); music.setPositional (false); music.setReverbEnabled (false); music.setLooping (true); per (int i = 0; i 

Qui, iniziamo impostando il necessario AudioNode variabili e inizializzare gli array.

Successivamente, carichiamo i suoni, e per ogni suono facciamo praticamente la stessa cosa. Creiamo un nuovo AudioNode, con l'aiuto di assetManager. Quindi, non lo impostiamo e disabilitiamo il riverbero. (Non abbiamo bisogno che il suono sia posizionale perché non abbiamo un'uscita stereo nel nostro gioco 2D, sebbene tu possa implementarlo se ti piace.) Disattivare il riverbero fa suonare il suono proprio come è nell'audio reale file; se l'avessimo abilitato, potremmo fare in modo che jME suoni l'audio come se fossimo in una grotta o in un dungeon, per esempio. Successivamente, impostiamo il ciclo su vero per la musica e per falso per qualsiasi altro suono.

Suonare i suoni è piuttosto semplice: chiamiamo semplicemente soundX.play ().

Informazioni: Quando chiami semplicemente giocare() su alcuni suoni, suona solo il suono. Ma a volte vogliamo suonare lo stesso suono due volte o anche più volte contemporaneamente. Questo è ciò che playInstance () è lì per: crea una nuova istanza per ogni suono in modo che possiamo suonare lo stesso suono più volte allo stesso tempo.

Lascerò il resto del lavoro a te: devi chiamare startMusic, sparare(), esplosione() (per i nemici che muoiono), e spawn () nei posti appropriati della nostra classe principale MonkeyBlasterMain ().

Quando avrai finito, vedrai che il gioco ora è molto più divertente; quei pochi effetti sonori aggiungono davvero all'atmosfera. Ma lucidiamo anche la grafica.


Aggiunta del filtro Bloom Post-Processing

Abilitare la fioritura è molto semplice in jMonkeyEngine, poiché tutti i codici e gli shader necessari sono già stati implementati per te. Basta andare avanti e incollare queste righe in simpleInitApp ():

 FilterPostProcessor fpp = new FilterPostProcessor (assetManager); BloomFilter bloom = new BloomFilter (); bloom.setBloomIntensity (2f); bloom.setExposurePower (2); bloom.setExposureCutOff (0f); bloom.setBlurScale (1.5f); fpp.addFilter (bloom); guiViewPort.addProcessor (FPP); guiViewPort.setClearColor (true);

Ho configurato il BloomFilter un po; se vuoi sapere a cosa servono tutte queste impostazioni, dovresti dare un'occhiata al tutorial di jME su bloom.


Conclusione

Congratulazioni per aver terminato la seconda parte. Ci sono altre tre parti da fare, quindi non distrarti giocando troppo a lungo! La prossima volta aggiungeremo la GUI e i buchi neri.