Advertisement
  1. Game Development
  2. Programming

Creare un Neon Vector Shooter in jMonkeyEngine: Nemici e Suono

Scroll to top
Read Time: 16 min
This post is part of a series called Cross-Platform Vector Shooter: jMonkeyEngine.
Make a Neon Vector Shooter in jMonkeyEngine: The Basics
Make a Neon Vector Shooter With jME: HUD and Black Holes

() translation by (you can also view the original English article)

Nella prima parte di questa serie sulla crazione di un gioco ispirato a Geometry-Wars con jMonkeyEngine, abbiamo implementato l'astronave del giocatore e la abbiamo fatta muovere e sparare. Questa volta, aggiungeremo i nemici e gli effetti sonori.


Panoramica

Ecco a cosa lavoreremo per tutta la serie:

... sd ecco a cosa arriveremo alla fine di questa parte:

Per implementare le nuove caratteristiche, avremo bisogno di alcune nuove classi.

  • SeekerControl: Questa è una classe per il comportamento del "nemico ricercatore".
  • WandererControl: Anche questa è una classe per il comportamento, questa volta per il "nemico nomade".
  • Sound: Gestiremo il caricamento e la riproduzione di effetti sonori e musica con questa classe.

Come si può immaginare, aggiungeremo due tipi di nemici. Il primo si chiama seeker (cercatore); inseguirà attivamente il giocatore fino a quando non muore. L'altro, il wanderer (viandante, che vagabonda), si aggira intorno allo schermo in maniera casuale.


Aggiunta dei nemici

Creeremo i nemici in posizioni casuali sullo schermo. Al fine di dare al giocatore un certo tempo per reagire, il nemico non sarà attivo immediatamente, bensì si esaurirà lentamente. Dopo che è sbiadito completamente, inizierà a muoversi nel mondo. Quando si scontra con il giocatore, il giocatore muore; quando si scontra con un proiettile, muore lui.

Generazione dei nemici

Prima di tutto, abbiamo bisogno di creare alcune variabili nuove nella classe MonkeyBlasterMain:

1
2
    private long enemySpawnCooldown;
3
    private float enemySpawnChance = 80;
4
5
    private Node enemyNode;

Arriveremo ad usare i primi due abbastanza presto. Prima, dobbiamo inizializzare enemyNode nella simpleInitApp():

1
2
// set up the enemyNode

3
enemyNode = new Node("enemies");
4
guiNode.attachChild(enemyNode);

Okay, ora andiamo sul codice di generazione dei nemici: sovrascriviamo simpleUpdate(float tpf). Questo metodo viene chiamato dal motore più e più volte, e semplicemente continua la sua funzione di produrre nemici fintanto che il giocatore è vivo. (Avevamo già impostato i dati utente alive a true nell'ultimo tutorial.)

1
2
    @Override
3
    public void simpleUpdate(float tpf) {
4
        if ((Boolean) player.getUserData("alive")) {
5
            spawnEnemies();
6
        }
7
    }

E questo è come generiamo i nemici:

1
2
    private void spawnEnemies() {
3
        if (System.currentTimeMillis() - enemySpawnCooldown >= 17) {
4
            enemySpawnCooldown = System.currentTimeMillis();
5
6
            if (enemyNode.getQuantity() < 50) {
7
                if (new Random().nextInt((int) enemySpawnChance) == 0) {
8
                    createSeeker();
9
                }
10
                if (new Random().nextInt((int) enemySpawnChance) == 0) {
11
                    createWanderer();
12
                }
13
            }
14
            //increase Spawn Time

15
            if (enemySpawnChance >= 1.1f) {
16
                enemySpawnChance -= 0.005f;
17
            }
18
        }
19
    }

Non lasciatevi confondere dalla variabile enemySpawnCooldown. Non è lì per fare generare nemici con una frequenza discreta di 17ms, sarebbe troppo breve come intervallo.

enemySpawnCooldown è in realtà lì per garantire che la quantità di nuovi nemici sia la stessa su ogni macchina. Su computer più veloci, simpleUpdate(float tpf) viene chiamata molto più spesso rispetto a quelli più lenti. Con questa variabile, controlliamo circa ogni 17ms, se dobbiamo generare nuovi nemici.
Ma vogliamo generare nemici ogni 17ms? Noi in realtà vogliamo generarli casualmente, quindi introduciamo un'istruzione if:

1
2
if (new Random().nextInt((int) enemySpawnChance) == 0) {

Minore è il valore di enemySpawnChance, più è probabile che un nuovo nemico si riproduca in questo intervallo di 17ms e quindi più nemici che il giocatore dovrà affrontare. Ecco perché sottraiamo un pò di enemySpawnChance ad ogni tick (n.d.a. unità che indica un singolo frame aggiornato): significa che il gioco otterrà più difficile nel corso del tempo.

Creare seekers e wanderers è simile alla creazione di qualsiasi altro oggetto:

1
2
    private void createSeeker() {
3
        Spatial seeker = getSpatial("Seeker");
4
        seeker.setLocalTranslation(getSpawnPosition());
5
        seeker.addControl(new SeekerControl(player));
6
        seeker.setUserData("active",false);
7
        enemyNode.attachChild(seeker);
8
    }
9
10
    private void createWanderer() {
11
        Spatial wanderer = getSpatial("Wanderer");
12
        wanderer.setLocalTranslation(getSpawnPosition());
13
        wanderer.addControl(new WandererControl());
14
        wanderer.setUserData("active",false);
15
        enemyNode.attachChild(wanderer);
16
    }

Creiamo uno spatial, lo muoviamo, aggiungiamo un controllo personalizzato, lo impostiamo come non-attivo, e lo attribuiamo al nostro enemyNode. Cosa? Perché non è attivo? Questo perché non vogliamo che il nemico inizi ad inseguire il giocatore non appena si genera; vogliamo dare al giocatore un pò di tempo per reagire.

Prima di addentrarci nei controlli, abbiamo bisogno di implementare il metodo di getSpawnPosition(). Il nemico deve generarsi in modo casuale, ma non proprio accanto al giocatore:

1
2
    private Vector3f getSpawnPosition() {
3
        Vector3f pos;
4
        do {
5
            pos = new Vector3f(new Random().nextInt(settings.getWidth()), new Random().nextInt(settings.getHeight()),0);
6
        } while (pos.distanceSquared(player.getLocalTranslation()) < 8000);
7
        return pos;
8
    }

Calcoliamo una nuova posizione casuale pos. Se è troppo vicino al giocatore, si calcola una nuova posizione e ripetiamo il tutto finché non è ad una distanza decente.

Ora abbiamo solo bisogno che i nemici si attivino iniziando a muoversi. Lo faremo nei loro controlli.

Controllo del comportamento dei Nemici

Ce ne occuperemo prima con il SeekerControl:

1
2
public class SeekerControl extends AbstractControl {
3
    private Spatial player;
4
    private Vector3f velocity;
5
    private long spawnTime;
6
7
    public SeekerControl(Spatial player) {
8
        this.player = player;
9
        velocity = new Vector3f(0,0,0);
10
        spawnTime = System.currentTimeMillis();
11
    }
12
13
    @Override
14
    protected void controlUpdate(float tpf) {
15
        if ((Boolean) spatial.getUserData("active")) {
16
            //translate the seeker

17
            Vector3f playerDirection = player.getLocalTranslation().subtract(spatial.getLocalTranslation());
18
            playerDirection.normalizeLocal();
19
            playerDirection.multLocal(1000f);
20
            velocity.addLocal(playerDirection);
21
            velocity.multLocal(0.8f);
22
            spatial.move(velocity.mult(tpf*0.1f));
23
24
            // rotate the seeker

25
            if (velocity != Vector3f.ZERO) {
26
                spatial.rotateUpTo(velocity.normalize());
27
                spatial.rotate(0,0,FastMath.PI/2f);
28
            }
29
        } else {
30
            // handle the "active"-status

31
            long dif = System.currentTimeMillis() - spawnTime;
32
            if (dif >= 1000f) {
33
                spatial.setUserData("active",true);
34
            }
35
36
            ColorRGBA color = new ColorRGBA(1,1,1,dif/1000f);
37
            Node spatialNode = (Node) spatial;
38
            Picture pic = (Picture) spatialNode.getChild("Seeker");
39
            pic.getMaterial().setColor("Color",color);
40
        }
41
    }
42
43
    @Override
44
    protected void controlRender(RenderManager rm, ViewPort vp) {}
45
}

Concentriamoci sucontrolUpdate(float tpf):

In primo luogo, abbiamo bisogno di verificare se il nemico è attivo. Se non lo è, dobbiamo dissolverlo in ingresso lentamente (n.d.a. "fade out"=dissolvenza, "fade in"=assolvenza ma non si usa).
Poi controlliamo il tempo trascorso da quando abbiamo generato il nemico e, se è abbastanza a lungo, lo impostiamo come attivo.

Indipendentemente dal fatto che è stato attivato, abbiamo bisogno di regolarne il colore. La variabile locale spatial contiene lo spaziale a cui il controllo è stato attaccato, ma dovreste ricordare che non abbiamo agganciato il controllo all'immagine attuale, l'immagine è un figlio del nodo a cui abbiamo allegato il controllo. (Se non sapete di cosa sto parlando, date un'occhiata al metodo di getSpatial (String name) che abbiamo implementato nello scorso tutorial.)

Così; otteniamo l'immagine come figlio di spatial, otteniamo il suo materiale ed impostiamo il suo colore al valore appropriato. Niente di speciale, una volta che vi sarete abituati agli spatials, materiali e nodi.

Info: Vi chiederete perché abbiamo impostato il colore del materiale a bianco. (I valori RGB sono tutti 1 nel codice). Non vogliamo un un nemico giallo e uno rosso?
E' perché il materiale mescola il colore del materiale stesso con i colori della texture, quindi se vogliamo visualizzare la trama del nemico così com'è, abbiamo bisogno di mescolarlo ad uno sfondo bianco.

Ora abbiamo bisogno di dare un'occhiata a quello che facciamo quando il nemico è attivo. Questo controllo è chiamato SeekerControl per un motivo: vogliamo che i nemici con questo controllo collegato inseguano il giocatore.

A questo fine, calcoliamo la direzione dal seeker al giocatore e aggiungiamo questo valore alla velocità. Dopo di che, riduciamo la sua velocità del 80% in modo che non cresca all'infinito e spostiamo il seeker di conseguenza.

La rotazione non è niente di speciale: se il seeker non è ancora in piedi, lo ruotiamo nella direzione del giocatore. Quindi lo ruotarlo un pò di più, perché il seeker nel Seeker.png non è rivolto verso l'alto, ma a destra.

Info: Il metodo rotateUpTo(Vector3f direction) di Spatial ruota uno spaziale in modo che il suo asse y punti nella direzione data.

Questo per il primo nemico. Il codice del secondo nemico, il wanderer (errante, viandante), non è molto diverso:

1
2
public class WandererControl extends AbstractControl {
3
    private int screenWidth, screenHeight;
4
5
    private Vector3f velocity;
6
    private float directionAngle;
7
    private long spawnTime;
8
9
    public WandererControl(int screenWidth, int screenHeight) {
10
        this.screenWidth = screenWidth;
11
        this.screenHeight = screenHeight;
12
13
        velocity = new Vector3f();
14
        directionAngle = new Random().nextFloat() * FastMath.PI * 2f;
15
        spawnTime = System.currentTimeMillis();
16
    }
17
18
    @Override
19
    protected void controlUpdate(float tpf) {
20
        if ((Boolean) spatial.getUserData("active")) {
21
            // translate the wanderer

22
23
            // change the directionAngle a bit

24
            directionAngle += (new Random().nextFloat() * 20f - 10f) * tpf;
25
            System.out.println(directionAngle);
26
            Vector3f directionVector = MonkeyBlasterMain.getVectorFromAngle(directionAngle);
27
            directionVector.multLocal(1000f);
28
            velocity.addLocal(directionVector);
29
30
            // decrease the velocity a bit and move the wanderer

31
            velocity.multLocal(0.8f);
32
            spatial.move(velocity.mult(tpf*0.1f));
33
34
            // make the wanderer bounce off the screen borders

35
            Vector3f loc = spatial.getLocalTranslation();
36
            if (loc.x  screenWidth || loc.y > screenHeight) {
37
                Vector3f newDirectionVector = new Vector3f(screenWidth/2, screenHeight/2,0).subtract(loc);
38
                directionAngle = MonkeyBlasterMain.getAngleFromVector(newDirectionVector);
39
            }
40
41
            // rotate the wanderer

42
            spatial.rotate(0,0,tpf*2);
43
        } else {
44
            // handle the "active"-status

45
            long dif = System.currentTimeMillis() - spawnTime;
46
            if (dif >= 1000f) {
47
                spatial.setUserData("active",true);
48
            }
49
50
            ColorRGBA color = new ColorRGBA(1,1,1,dif/1000f);
51
            Node spatialNode = (Node) spatial;
52
            Picture pic = (Picture) spatialNode.getChild("Wanderer");
53
            pic.getMaterial().setColor("Color",color);
54
        }
55
    }
56
57
    @Override
58
    protected void controlRender(RenderManager rm, ViewPort vp) {}
59
}

Prima la roba facile; la dissolvenza in ingresso del nemico è la stesso del controllo del seeker (ricercatore). Nel costruttore, abbiamo scelto una direzione casuale per il wanderer, verso cui volerà una volta attivato.

Suggerimento: Se si dispone di più di due nemici, o semplicemente desiderate strutturare il gioco in modo più pulito, si potrebbe aggiungere un terzo controllo: EnemyControl che gestirebbe tutto quello quello che i nemici hanno in comune: il movimento del nemico, la dissolvenza in ingresso, l'attivazione ...

Ora le principali differenze:

Quando il nemico è attivo, prima cambiamo un pò la sua direzione, in modo che il wanderer non si muova in linea retta per tutto il tempo. Lo facciamo cambiando directAngle un pò e aggiungendo la directionVector alla velocity. Poi applichiamo la velocità, proprio come facciamo nel SeekerControl.

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

Infine, ruotiamo un pò il wanderer. Solo perché un nemico ruotante è più carino.

Ora che abbiamo finito l'implementazione dei due nemici, è possibile avviare il gioco e giocarci un pò. Ti dà un pò l'idea di come sarà il gioco, anche se ancora non possiamo uccidere i nemici e loro non possono uccidere te. Aggiungiamo il prossimo.

Rilevamento Collisioni

Per rendere i nemici capaci di uccidere il giocatore, abbiamo bisogno di sapere se collidono tra loro. Per questo aggiungeremo un nuovo metodo, handleCollisions, chiamato nel simpleUpdate(float tpf):

1
2
    @Override
3
    public void simpleUpdate(float tpf) {
4
        if ((Boolean) player.getUserData("alive")) {
5
            spawnEnemies();
6
            handleCollisions();
7
        }
8
    }

E ora il metodo:

1
2
    private void handleCollisions() {
3
        // should the player die?

4
        for (int i=0; i<enemyNode.getQuantity(); i++) {
5
            if ((Boolean) enemyNode.getChild(i).getUserData("active")) {
6
                if (checkCollision(player,enemyNode.getChild(i))) {
7
                    killPlayer();
8
                }
9
            }
10
        }
11
    }

Dobbiamo iterare tutti i nemici avendo contato i figli del nodo e raggiungendoli uno ad uno. Dobbiamo solo controllare se il nemico uccide il giocatore quando il nemico stesso è attivo. Se non lo è, non ci interessa. Quindi, se è attivo, controlliamo se il giocatore e il nemico si scontrano. Questo lo facciamo in un altro metodo, checkCollision(Spatial a, Spatial b):

1
2
    private boolean checkCollision(Spatial a, Spatial b) {
3
        float distance = a.getLocalTranslation().distance(b.getLocalTranslation());
4
        float maxDistance =  (Float)a.getUserData("radius") + (Float)b.getUserData("radius");
5
        return distance <= maxDistance;
6
    }

Il concetto è abbastanza semplice: in primo luogo, si calcola la distanza tra i due spatials. Abbiamo bisogno di sapere quanto vicino devono essere i due spatials per considerarli in collisione, quindi otteniamo i raggi di ogni spaziali e li sommiamo. (Abbiamo impostato tra i dati utente "radius" nel getSpatial (String name) nel precedente tutorial.) Quindi, se la distanza effettiva è inferiore o uguale a questa distanza massima, il metodo restituisce true, il che significa che si sono scontrati.

E adesso? Abbiamo bisogno di uccidere il giocatore. Creiamo un altro metodo:

1
2
    private void killPlayer() {
3
        player.removeFromParent();
4
        player.getControl(PlayerControl.class).reset();
5
        player.setUserData("alive", false);
6
        player.setUserData("dieTime", System.currentTimeMillis());
7
        enemyNode.detachAllChildren();
8
        }
9
    }

In primo luogo, rimuoviamo il player dal suo nodo padre, questo lo rimuove automaticamente dalla scena. Quindi, abbiamo bisogno di ripristinare il movimento nel PlayerControl, altrimenti, il giocatore potrebbe ancora muoversi quando si rigenera di nuovo.

Quindi impostiamo il dato utente alive su false e creariamo un nuovo dato utente dieTime. (Ne abbiamo bisogno per il respawn del giocatore quando è morto)

Infine, stacchiamo tutti i nemici, poiché per il giocatore sarebbe difficile combattere subito con nemici già esistenti appena viene rigenerato.

Oltre ai già citati respawning, cerchiamo di gestire il prossimo. Ci sarà, ancora una volta, da modificare il metodo simpleUpdate(float tpf):

1
2
    @Override
3
    public void simpleUpdate(float tpf) {
4
        if ((Boolean) player.getUserData("alive")) {
5
            spawnEnemies();
6
            handleCollisions();
7
        } else if (System.currentTimeMillis() - (Long) player.getUserData("dieTime") > 4000f && !gameOver) {
8
            // spawn player
9
            player.setLocalTranslation(500,500,0);
10
            guiNode.attachChild(player);
11
            player.setUserData("alive",true);
12
        }
13
    }

Quindi, se il giocatore non è vivo ed è rimasto morto abbastanza a lungo, impostiamo la sua posizione al centro dello schermo, lo aggiungiamo alla scena, e, infine, impostiamo nuovamente il suo dato utente (userdata) alive a true!

Ora potrebbe essere un buon momento per avviare il gioco e testare le nostre nuove funzionalità. Per circa venti secondi avrete difficoltà, perché lo sparo è inutile, quindi cerchiamo di fare qualcosa a riguardo.

Al fine di rendere i proiettili capaci di uccidere i nemici, aggiungeremo del codice del handleCollisions():

1
2
        //should an enemy die?

3
        int i=0;
4
        while (i < enemyNode.getQuantity()) {
5
            int j=0;
6
            while (j < bulletNode.getQuantity()) {
7
                if (checkCollision(enemyNode.getChild(i),bulletNode.getChild(j))) {
8
                    enemyNode.detachChildAt(i);
9
                    bulletNode.detachChildAt(j);
10
                    break;
11
                }
12
                j++;
13
            }
14
            i++;
15
        }

La procedura per uccidere i nemici è più o meno la stesso che per uccidere il giocatore; iterare tutti i nemici e tutti i proiettili, verificare se si scontrano e, se lo fanno, li sganciamo entrambe.

Ora eseguiamo il gioco e vediamo quanto lontano andiamo!

Info: Iterare ogni nemico confrontando la sua posizione con la posizione di ogni proiettile è un pessimo modo per verificare la presenza di collisioni. Per semplicità va bene per questo esempio, ma in un vero e proprio gioco avremmo dovuto implementare algoritmi migliori, come il rilevamento di collisioni quadtree. Fortunatamente, jMonkeyEngine utilizza il motore fisico Bullet, così ogni volta che avremo della fisica 3D complessa, non ce ne dovremo preoccupare molto.

Con il gameplay principale abbiamo finito. Andremo ad implementare i buchi neri, a visualizzare il punteggio e la vita del giocatore, poi per rendere il gioco più divertente ed emozionante aggiungeremo alcuni effetti sonori ed una grafica migliore. Queste ultime cose le otterremo con un filtro di trasformazione bloom post processing, alcuni effetti particellari ed un effetto al background molto bello.

Prima di considerare questa parte della serie finita, aggiungeremo un pò di audio e l'effetto bloom.


Riproduzione di Suoni e Musica

Per l'audio del vostro gioco creeremo una nuova classe, chiamata semplicemente Sound:

1
2
public class Sound {
3
    private AudioNode music;
4
    private AudioNode[] shots;
5
    private AudioNode[] explosions;
6
    private AudioNode[] spawns;
7
8
    private AssetManager assetManager;
9
10
    public Sound(AssetManager assetManager) {
11
        this.assetManager = assetManager;
12
        shots = new AudioNode[4];
13
        explosions = new AudioNode[8];
14
        spawns = new AudioNode[8];
15
16
        loadSounds();
17
    }
18
19
    private void loadSounds() {
20
        music = new AudioNode(assetManager,"Sounds/Music.ogg");
21
        music.setPositional(false);
22
        music.setReverbEnabled(false);
23
        music.setLooping(true);
24
25
        for (int i=0; i<shots.length; i++) {
26
            shots[i] = new AudioNode(assetManager,"Sounds/shoot-0"+(i+1)+".wav");
27
            shots[i].setPositional(false);
28
            shots[i].setReverbEnabled(false);
29
            shots[i].setLooping(false);
30
        }
31
32
        for (int i=0; i<explosions.length; i++) {
33
            explosions[i]  = new AudioNode(assetManager,"Sounds/explosion-0"+(i+1)+".wav");
34
            explosions[i].setPositional(false);
35
            explosions[i].setReverbEnabled(false);
36
            explosions[i].setLooping(false);
37
        }
38
39
        for (int i=0; i<spawns.length; i++) {
40
            spawns[i]  = new AudioNode(assetManager,"Sounds/spawn-0"+(i+1)+".wav");
41
            spawns[i].setPositional(false);
42
            spawns[i].setReverbEnabled(false);
43
            spawns[i].setLooping(false);
44
        }
45
    }
46
47
    public void startMusic() {
48
        music.play();
49
    }
50
51
    public void shoot() {
52
        shots[new Random().nextInt(shots.length)].playInstance();
53
    }
54
55
    public void explosion() {
56
        explosions[new Random().nextInt(explosions.length)].playInstance();
57
    }
58
59
    public void spawn() {
60
        spawns[new Random().nextInt(spawns.length)].playInstance();
61
    }
62
}

Qui cominciamo impostando la variabile AudioNode e inizializzando gli array.

Quindi, carichiamo i suoni, facendo la stessa cosa più o meno per ogni suono. Creiamo un nuovo AudioNode, con l'aiuto del assetManager. Poi, lo impostiamo non posizionale e disabilitiamo il riverbero. (Non abbiamo bisogno che il suono sia posizionale perché non abbiamo uscita stereo nel nostro gioco 2D, anche se sarebbe possibile implementarlo, se vi piace.) La disattivazione del riverbero rende la riproduzione il suono così come è nel file; se attivo potremmo fare suonare a jME l'audio come se ci trovassimo in una grotta o in un sotterraneo, Dopo di che, abbiamo impostato il looping a true per la musica e a false per qualsiasi altro suono.

Suonare i suoni è piuttosto semplice: ci basta chiamare soundX.play().

Info: quando semplicemente chiamiamo play() su qualche suono, questo riproduce il suono. Ma a volte vogliamo giocare lo stesso suono due volte o anche più volte contemporaneamente. Questo è ciò che playInstance() fa: crea una nuova istanza per ogni suono in modo che lo si possa suonare lo stesso più volte allo stesso tempo.

Lascio il resto del lavoro a voi: è necessario chiamare startMusic, shoot(), explosion() (per uccidere i nemici), e spawn() nei punti appropriati della nostra classe principale MonkeyBlasterMain().

Quando avrete finito, vedrete che il gioco è ora molto più divertente; quei pochi effetti sonori aggiungono davvero molto all'atmosfera. Ma cerchiamo di pulire un pò di più la grafica.


L'aggiunta del filtro Bloom post-processing

L'attivazione del boom è molto semplice in jMonkeyEngine, poiché tutto il codice e gli shader necessari sono già implementati. Basta proseguire ed incollare queste righe in simpleInitApp():

1
2
        FilterPostProcessor fpp=new FilterPostProcessor(assetManager);
3
        BloomFilter bloom=new BloomFilter();
4
        bloom.setBloomIntensity(2f);
5
        bloom.setExposurePower(2);
6
        bloom.setExposureCutOff(0f);
7
        bloom.setBlurScale(1.5f);
8
        fpp.addFilter(bloom);
9
        guiViewPort.addProcessor(fpp);
10
        guiViewPort.setClearColor(true);

Ho configurato il BloomFilter un pò; se volete sapere che cosa fanno queste impostazioni, dovreste dare un'occhiata al tutorial jME sul bloom.


Conclusioni

Congratulazioni per aver finito la seconda parte. Ci sono ancora altre tre parti da affrontare, quindi non distraetevi giocando troppo! La prossima volta aggiungeremo l'interfaccia grafica e i buchi neri.

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Game Development tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.