Fai uno sparatutto al neon con jME HUD e buchi neri

Finora, in questa serie sulla creazione di un gioco ispirato a Geometry Wars in jMonkeyEngine, abbiamo implementato la maggior parte del gameplay e dell'audio. In questa parte, finiremo il gameplay aggiungendo buchi neri e aggiungeremo alcune UI per visualizzare il punteggio dei giocatori.


Panoramica

Ecco a cosa stiamo lavorando per l'intera serie:


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


Oltre a modificare le classi esistenti, ne aggiungeremo due nuove:

  • BlackHoleControl: Inutile dire che questo gestirà il comportamento dei nostri buchi neri.
  • Hud: Qui archiviamo e visualizziamo il punteggio dei giocatori, le vite e altri elementi dell'interfaccia utente.

Iniziamo con i buchi neri.


Buchi neri

Il buco nero è uno dei nemici più interessanti in Geometry Wars. In MonkeyBlaster, il nostro clone, è particolarmente interessante una volta che aggiungiamo effetti particellari e la griglia di orditura nei prossimi due capitoli.

Funzionalità di base

I buchi neri attireranno le particelle del giocatore, i nemici vicini e (dopo il prossimo tutorial), ma respingeranno i proiettili.

Ci sono molte funzioni possibili che possiamo usare per attrazione o repulsione. Il più semplice è usare una forza costante, in modo che il buco nero tiri con la stessa forza indipendentemente dalla distanza dell'oggetto. Un'altra possibilità è di aumentare linearmente la forza da zero, a una certa distanza massima, a piena forza, per oggetti direttamente sopra il buco nero. E se vorremmo modellare la gravità in modo più realistico, possiamo usare il quadrato inverso della distanza, il che significa che la forza di gravità è proporzionale a 1 / (distanza * distanza).

In realtà utilizzeremo ognuna di queste tre funzioni per gestire oggetti diversi. I proiettili saranno respinti con una forza costante, i nemici e la nave del giocatore saranno attratti con una forza lineare, e le particelle useranno una funzione inversa quadrata.

Implementazione

Inizieremo generando i nostri buchi neri. Per raggiungere questo abbiamo bisogno di un altro varibale in MonkeyBlasterMain:

 spawn lungo privatoCooldownBlackHole;

Quindi dobbiamo dichiarare un nodo per i buchi neri; chiamiamolo blackHoleNode. Puoi dichiararlo e inizializzarlo proprio come abbiamo fatto noi enemyNode nel tutorial precedente.

Creeremo anche un nuovo metodo, spawnBlackHoles, che chiamiamo subito dopo spawnEnemies nel simpleUpdate (float tpf). La generazione reale è piuttosto simile ai nemici che generano:

 private void spawnBlackHoles () if (blackHoleNode.getQuantity () < 2)  if (System.currentTimeMillis() - spawnCooldownBlackHole > 10f) spawnCooldownBlackHole = System.currentTimeMillis (); if (new Random (). nextInt (1000) == 0) createBlackHole (); 

La creazione del buco nero segue anche la nostra procedura standard:

 private void createBlackHole () Spazial blackHole = getSpatial ("Buco nero"); blackHole.setLocalTranslation (getSpawnPosition ()); blackHole.addControl (new BlackHoleControl ()); blackHole.setUserData ( "attivo", false); blackHoleNode.attachChild (blackhole); 

Ancora una volta, cariciamo lo spazio, impostiamo la sua posizione, aggiungiamo un controllo, lo impostiamo su non attivo e infine lo colleghiamo al nodo appropriato. Quando dai un'occhiata BlackHoleControl, noterai che non è molto diverso neanche.

Implementeremo l'attrazione e la repulsione dopo, in MonkeyBlasterMain, ma c'è una cosa che dobbiamo affrontare ora. Dal momento che il buco nero è un nemico forte, non vogliamo che vada giù facilmente. Pertanto, aggiungiamo una variabile, hitpoints, al BlackHoleControl, e impostare il suo valore iniziale su 10 in modo che morirà dopo dieci colpi.

 public class BlackHoleControl estende AbstractControl private long spawnTime; hitpoint privati ​​int; public BlackHoleControl () spawnTime = System.currentTimeMillis (); hitpoints = 10;  @Override protected void controlUpdate (float tpf) if ((Boolean) spatial.getUserData ("active")) // useremo questo punto più tardi ... else // gestirà lo "attivo" -status long dif = 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 ("Black Hole"); pic.getMaterial () setColor ( "Colore", colore).;  @Override protected void controlRender (RenderManager rm, ViewPort vp)  public void wasShot () hitpoints--;  public booleano isDead () return hitpoint <= 0;  

Abbiamo quasi finito con il codice di base per i buchi neri. Prima di implementare la gravità, dobbiamo occuparci delle collisioni.

Quando il giocatore o un nemico arriva troppo vicino al buco nero, morirà. Ma quando un proiettile riesce a colpirlo, il buco nero perderà un hitpoint.

Dai un'occhiata al seguente codice. Appartiene a handleCollisions (). È praticamente la stessa di tutte le altre collisioni:

 // c'è qualcosa che si scontra con un buco nero? per (i = 0; i 

Bene, ora puoi uccidere il buco nero, ma questo non è l'unico momento in cui dovrebbe sparire. Ogni volta che il giocatore muore, tutti i nemici spariscono e così dovrebbe essere il buco nero. Per gestire questo, basta aggiungere la seguente riga al nostro killPlayer () metodo:

 blackHoleNode.detachAllChildren ();

Ora è il momento di implementare le cose interessanti. Creeremo un altro metodo, handleGravity (float tpf). Basta chiamarlo con gli altri metodi in simplueUpdate (float tpf).

In questo metodo, controlliamo tutte le entità (giocatori, proiettili e nemici) per vedere se sono vicino a un buco nero, diciamo entro 250 pixel e, se lo sono, applichiamo l'effetto appropriato:

 private void handleGravity (float tpf) for (int i = 0; i 

Per verificare se due entità si trovano a una certa distanza l'una dall'altra, creiamo un metodo chiamato isNearby () che confronta le posizioni dei due spaziali:

 private boolean isNearby (Spatial a, Spatial b, float distance) Vector3f pos1 = a.getLocalTranslation (); Vector3f pos2 = b.getLocalTranslation (); ritorno pos1.distanceSquared (pos2) <= distance * distance; 

Ora che abbiamo controllato ogni entità, se è attiva e entro la distanza specificata di un buco nero, possiamo finalmente applicare l'effetto della gravità. Per fare ciò, faremo uso dei controlli: creiamo un metodo in ogni controllo, chiamato applyGravity (gravità Vector3f).

Diamo un'occhiata a ciascuno di essi:

PlayerControl:

 public void applyGravity (gravità Vector3f) spatial.move (gravità); 

BulletControl:

 public void applyGravity (gravità Vector3f) direction.addLocal (gravità); 

SeekerControl e WandererControl:

 public void applyGravity (Vector3f gravity) velocity.addLocal (gravità); 

E ora torniamo alla classe principale, MonkeyBlasterMain. Ti darò il metodo prima e spiegherò i passaggi sottostanti:

 private void applyGravity (Spazial blackHole, Spatial target, float tpf) Vector3f difference = blackHole.getLocalTranslation (). sottrarre (target.getLocalTranslation ()); Vector3f gravity = difference.normalize (). MultLocal (tpf); float distance = difference.length (); if (target.getName (). equals ("Player")) gravity.multLocal (250f / distance); target.getControl (PlayerControl.class) .applyGravity (gravity.mult (80F));  else if (target.getName (). equals ("Bullet")) gravity.multLocal (250f / distance); target.getControl (BulletControl.class) .applyGravity (gravity.mult (-0.8f));  else if (target.getName (). equals ("Seeker")) target.getControl (SeekerControl.class) .applyGravity (gravity.mult (150000));  else if (target.getName (). equals ("Wanderer")) target.getControl (WandererControl.class) .applyGravity (gravity.mult (150000)); 

La prima cosa che facciamo è calcolare il Vettore tra il buco nero e il bersaglio. Successivamente, calcoliamo la forza gravitazionale. La cosa importante da notare è che, ancora una volta, moltiplichiamo la forza per il tempo trascorso dall'ultimo aggiornamento, TPF, per ottenere lo stesso effetto con ogni frame rate. Infine, calcoliamo la distanza tra il bersaglio e il buco nero.

Per ogni tipo di obiettivo, dobbiamo applicare la forza in un modo leggermente diverso. Per il giocatore e per i proiettili, la forza diventa più forte quanto più sono vicini al buco nero:

 gravity.multLocal (250f / distanza);

I proiettili devono essere respinti; ecco perché moltiplichiamo la loro forza gravitazionale di un numero negativo.

Cercatori e Erranti semplicemente ottengono una forza applicata che è sempre la stessa, indipendentemente dalla loro distanza dal buco nero.

Ora abbiamo finito con l'implementazione dei buchi neri. Aggiungiamo alcuni fantastici effetti nei prossimi capitoli, ma per ora puoi testarlo!

Mancia: Si noti che questo è il tuo gioco; sentiti libero di modificare qualsiasi parametro che ti piace! Puoi cambiare l'area di effetto per il buco nero, la velocità dei nemici o del giocatore ... Queste cose hanno un enorme effetto sul gameplay. A volte vale la pena giocare un po 'con i valori.

Il display Head-Up

Ci sono alcune informazioni che devono essere monitorate e visualizzate dal giocatore. Ecco a cosa serve l'HUD (Head-Up Display). Vogliamo monitorare le vite dei giocatori, il moltiplicatore del punteggio attuale e, naturalmente, il punteggio stesso, e mostrare tutto questo al giocatore.

Quando il giocatore segna 2.000 punti (o 4.000, o 6.000, o ...) il giocatore otterrà un'altra vita. Inoltre, vogliamo salvare il punteggio dopo ogni partita e confrontarlo con il punteggio attuale. Il moltiplicatore aumenta ogni volta che il giocatore uccide un nemico e salta a uno quando il giocatore non uccide nulla in un dato momento.

Creeremo una nuova classe per tutto ciò, chiamato Hud. Nel Hud all'inizio abbiamo alcune cose da inizializzare:

 public class Hud private AssetManager assetManager; guiNode del nodo privato; private int screenWidth, screenHeight; private final int fontSize = 30; private final int multiplierExpiryTime = 2000; private final int maxMultiplier = 25; int vita pubblica; punteggio pubblico int; moltiplicatore pubblico int; private long multiplierActivationTime; private int scoreForExtraLife; privato BitmapFont guiFont; BitmapText privato livesText; BitmapText privato scoreText; BitmapText privato multiplierText; nodo privato gameOverNode; public Hud (AssetManager assetManager, Node guiNode, int screenWidth, int screenHeight) this.assetManager = assetManager; this.guiNode = guiNode; this.screenWidth = screenWidth; this.screenHeight = screenHeight; SetupText (); 

Sono un sacco di variabili, ma la maggior parte di esse è piuttosto auto-esplicativa. Abbiamo bisogno di avere un riferimento al AssetManager caricare il testo, al guiNode per aggiungerlo alla scena e così via.

Successivamente, ci sono alcune variabili che dobbiamo monitorare continuamente, come il moltiplicatore, il suo tempo di scadenza, il moltiplicatore massimo possibile e la vita del giocatore.

E finalmente ne abbiamo alcuni BitmapText oggetti, che memorizzano il testo attuale e lo visualizzano sullo schermo. Questo testo è impostato nel metodo SetupText (), che viene chiamato alla fine del costruttore.

 private void setupText () guiFont = assetManager.loadFont ("Interface / Fonts / Default.fnt"); livesText = new BitmapText (guiFont, false); livesText.setLocalTranslation (30, ScreenHeight-30,0); livesText.setSize (fontSize); livesText.setText ("Vite:" + vive); guiNode.attachChild (livesText); scoreText = new BitmapText (guiFont, true); scoreText.setLocalTranslation (screenWidth - 200, screenHeight-30,0); scoreText.setSize (fontSize); scoreText.setText ("Punteggio:" + punteggio); guiNode.attachChild (scoreText); multiplierText = new BitmapText (guiFont, true); multiplierText.setLocalTranslation (screenwidth-200, ScreenHeight-100,0); multiplierText.setSize (fontSize); multiplierText.setText ("Moltiplicatore:" + vive); guiNode.attachChild (multiplierText); 

Per caricare il testo, dobbiamo prima caricare il carattere. Nel nostro esempio utilizziamo un carattere predefinito fornito con jMonkeyEngine.

Mancia: Naturalmente, puoi creare i tuoi font personali, metterli da qualche parte nel risorse directory-preferibilmente attività / Interfaccia-e caricali. Se vuoi saperne di più, consulta questo tutorial sul caricamento dei caratteri in jME.

Successivamente, avremo bisogno di un metodo per ripristinare tutti i valori in modo che possiamo ricominciare da capo se il giocatore muore troppe volte:

 public void reset () score = 0; moltiplicatore = 1; vite = 4; multiplierActivationTime = System.currentTimeMillis (); scoreForExtraLife = 2000; updateHUD (); 

Reimpostare i valori è semplice, ma dobbiamo anche applicare i cambiamenti delle variabili all'HUD. Lo facciamo in un metodo separato:

 private void updateHUD () livesText.setText ("Vite:" + lives); scoreText.setText ("Punteggio:" + punteggio); multiplierText.setText ("Moltiplicatore:" + moltiplicatore); 

Durante la battaglia, il giocatore guadagna punti e perde vite. Chiameremo questi metodi da MonkeyBlasterMain:

 public void addPoints (int basePoints) score + = basePoints * moltiplicatore; if (score> = scoreForExtraLife) scoreForExtraLife + = 2000; vive ++;  increaseMultiplier (); updateHUD ();  private void increaseMultiplier () multiplierActivationTime = System.currentTimeMillis (); se (moltiplicatore < maxMultiplier)  multiplier++;   public boolean removeLife()  if (lives == 0) return false; lives--; updateHUD(); return true; 

Concetti notevoli in questi metodi sono:

  • Ogni volta che aggiungiamo punti, controlliamo se abbiamo già raggiunto il punteggio necessario per ottenere una vita extra.
  • Ogni volta che aggiungiamo punti, dobbiamo anche aumentare il moltiplicatore chiamando un metodo separato.
  • Ogni volta che aumentiamo il moltiplicatore, dobbiamo essere consapevoli del massimo moltiplicatore possibile e non andare oltre.
  • Ogni volta che il giocatore colpisce un nemico, dobbiamo resettare il multiplierActivationTime.
  • Quando il giocatore non ha più vite da rimuovere, noi ritorniamo falso in modo che la classe principale possa agire di conseguenza.

Ci sono due cose che dobbiamo gestire.

Per prima cosa, dobbiamo resettare il moltiplicatore se il giocatore non uccide nulla per un po '. Implementeremo un aggiornare() metodo che controlla se è il momento di farlo:

 public void update () if (moltiplicatore> 1) if (System.currentTimeMillis () - multiplierActivationTime> multiplierExpiryTime) moltiplicatore = 1; multiplierActivationTime = System.currentTimeMillis (); updateHUD (); 

L'ultima cosa di cui dobbiamo occuparci è terminare il gioco. Quando il giocatore ha esaurito tutte le loro vite, il gioco è finito e il punteggio finale dovrebbe essere visualizzato al centro dello schermo. Dobbiamo anche verificare se il punteggio più alto corrente è inferiore al punteggio attuale del giocatore e, in tal caso, salvare il punteggio attuale come nuovo punteggio elevato. (Si noti che è necessario creare un file highscore.txt prima, o non sarai in grado di caricare un punteggio.)

È così che finiamo il gioco Hud:

 public void endGame () // init gameOverNode gameOverNode = new Node (); gameOverNode.setLocalTranslation (screenWidth / 2 - 180, screenHeight / 2 + 100,0); guiNode.attachChild (gameOverNode); // controlla highscore int highscore = loadHighscore (); if (score> highscore) saveHighscore (); // init e visualizza testo BitmapText gameOverText = new BitmapText (guiFont, false); gameOverText.setLocalTranslation (0,0,0); gameOverText.setSize (fontSize); gameOverText.setText ("Game Over"); gameOverNode.attachChild (gameOverText); BitmapText yourScoreText = new BitmapText (guiFont, false); yourScoreText.setLocalTranslation (0, -50,0); yourScoreText.setSize (fontSize); yourScoreText.setText ("Il tuo punteggio:" + punteggio); gameOverNode.attachChild (yourScoreText); BitmapText highscoreText = new BitmapText (guiFont, false); highscoreText.setLocalTranslation (0, -100,0); highscoreText.setSize (fontSize); highscoreText.setText ("Highscore:" + highscore); gameOverNode.attachChild (highscoreText); 

Infine, abbiamo bisogno di due ultimi metodi: loadHighscore () e saveHighscore ():

 private int loadHighscore () try FileReader fileReader = new FileReader (new File ("highscore.txt")); Lettore BufferedReader = new BufferedReader (fileReader); String line = reader.readLine (); return Integer.valueOf (line);  catch (FileNotFoundException e) e.printStackTrace ();  catch (IOException e) e.printStackTrace (); restituisce 0;  private void saveHighscore () try FileWriter writer = new FileWriter (new File ("highscore.txt"), false); writer.write (punteggio + System.getProperty ( "line.separator")); writer.close ();  catch (IOException e) e.printStackTrace ();
Mancia: Come avrai notato, non ho usato il assetManager caricare e salvare il testo. Lo abbiamo usato per caricare tutti i suoni e la grafica, e il corretto modo jME per caricare e salvare i testi in realtà sta usando il assetManager per questo, ma dal momento che non supporta il caricamento di file di testo da solo, avremmo bisogno di registrare un TextLoader con il assetManager. Puoi farlo se vuoi, ma in questo tutorial mi sono attenuto al modo Java predefinito di caricare e salvare testo, per motivi di semplicità.

Ora abbiamo una grande classe che gestirà tutti i nostri problemi relativi all'HUD. L'unica cosa che dobbiamo fare ora è aggiungerla al gioco.

Dobbiamo dichiarare l'oggetto all'inizio:

 Hud privato hud;

... inizializzalo simpleInitApp ():

 hud = new Hud (assetManager, guiNode, settings.getWidth (), settings.getHeight ()); hud.reset ();

... aggiorna l'HUD in simpleUpdate (float tpf) (indipendentemente dal fatto che il giocatore sia vivo):

 hud.update ();

... aggiungi punti quando il giocatore colpisce i nemici (in checkCollisions ()):

 // aggiungi punti a seconda del tipo di nemico se (enemyNode.getChild (i) .getName (). equals ("Seeker")) hud.addPoints (2);  else if (enemyNode.getChild (i) .getName (). equals ("Wanderer")) hud.addPoints (1); 
Attento! Devi aggiungere i punti prima distacchi i nemici dalla scena, o ti imbatterai in problemi con enemyNode.getChild (i).

... e rimuovi le vite quando il giocatore muore (in killPlayer ()):

 if (! hud.removeLife ()) hud.endGame (); gameOver = true; 

Potresti aver notato che abbiamo introdotto anche una nuova variabile, gioco finito. Lo imposteremo falso all'inizio:

 private boolean gameOver = false;

Il giocatore non dovrebbe spawnare più una volta che il gioco è finito, quindi aggiungiamo questa condizione a simpleUpdate (float tpf)

  else if (System.currentTimeMillis () - (Long) player.getUserData ("dieTime")> 4000f &&! gameOver) 

Ora puoi iniziare il gioco e controllare se ti sei perso qualcosa! E il tuo gioco ha un nuovo obiettivo: battere il punteggio più alto. ti auguro buona fortuna!

Cursore personalizzato

Dato che abbiamo un gioco 2D, c'è ancora una cosa da aggiungere per perfezionare il nostro HUD: un cursore del mouse personalizzato.
Non è niente di speciale; basta inserire questa linea simpleInitApp ():

 inputManager.setMouseCursor ((JmeCursor) assetManager.loadAsset ("Textures / Pointer.ico"));

Conclusione

Il gameplay ora è completamente finito. Nelle restanti due parti di questa serie, aggiungeremo alcuni interessanti effetti grafici. Questo renderà il gioco leggermente più difficile, dal momento che i nemici potrebbero non essere più facili da individuare!