Maak een Neon Vector Shooter in jMonkeyEngine The Basics

In deze tutorialserie zal ik uitleggen hoe je een game maakt die is geïnspireerd door Geometry Wars, met behulp van de jMonkeyEngine. De jMonkeyEngine (kortweg "jME") is een open source 3D Java-game-engine - lees meer op hun website of in onze gids over het leren van jMonkeyEngine.

Hoewel de jMonkeyEngine intrinsiek een 3D-game-engine is, is het ook mogelijk om er 2D-games mee te maken.

gerelateerde berichten
Deze tutorialserie is gebaseerd op de serie van Michael Hoffman en legt uit hoe je hetzelfde spel in XNA kunt maken:
  • Maak een Neon Vector Shooter in XNA

De vijf hoofdstukken van de tutorial zijn gewijd aan bepaalde onderdelen van het spel:

  1. Initialiseer de 2D-scène, laad en toon enkele grafische afbeeldingen, invoer verwerken.
  2. Voeg vijanden, botsingen en geluidseffecten toe.
  3. Voeg de GUI en zwarte gaten toe.
  4. Voeg een aantal spectaculaire deeltjeseffecten toe.
  5. Voeg het kromme achtergrondraster toe.

Als een kleine visuele voorproefje, dit is het uiteindelijke resultaat van onze inspanningen:


... En hier zijn onze resultaten na dit eerste hoofdstuk:


De muziek en geluidseffecten die je in deze video's kunt horen, zijn gemaakt door RetroModular en je kunt lezen hoe hij dit heeft gedaan.

De sprites zijn van Jacob Zinman-Jeanes, onze residente Tuts + -ontwerper. Alle illustraties zijn te vinden in de zip-download van het bronbestand.


Het lettertype is Nova Square, door Wojciech Kalinowski.

De tutorial is ontworpen om je te helpen de basis van de jMonkeyEngine te leren en je eerste game ermee te maken. Hoewel we zullen profiteren van de functies van de engine, zullen we geen gecompliceerde tools gebruiken om de prestaties te verbeteren. Wanneer er een meer geavanceerd hulpmiddel is om een ​​functie te implementeren, zal ik linken naar de juiste jME-tutorials, maar ik hou me aan de eenvoudige manier in de zelfstudie. Als je meer naar jME kijkt, kun je later je versie van MonkeyBlaster verder bouwen en verbeteren.

Daar gaan we!


Overzicht

Het eerste hoofdstuk bevat het laden van de benodigde afbeeldingen, verwerken van invoer en het verplaatsen en schieten van de speler.

Om dit te bereiken, hebben we drie klassen nodig:

  • MonkeyBlasterMain: Onze hoofdklasse met de gamelus en het basisspel.
  • PlayerControl: Deze klasse bepaalt hoe de speler zich gedraagt.
  • BulletControl: Gelijkaardig aan het bovenstaande, definieert dit het gedrag voor onze kogels.

In de loop van de tutorial gooien we de algemene gameplay-code erin MonkeyBlasterMain en beheer de objecten op het scherm voornamelijk via besturingselementen en andere klassen. Speciale functies, zoals geluid, hebben ook hun eigen klassen.


Het schip van de speler laden

Als je de jME SDK nog niet hebt gedownload, is het hoog tijd! Je vindt het op de startpagina van jMonkeyEngine.

Maak een nieuw project in de jME SDK. Het genereert automatisch de hoofdklasse, die er ongeveer zo uitziet:

pakket monkeyblaster; import com.jme3.app.SimpleApplication; import com.jme3.renderer.RenderManager; openbare klasse MonkeyBlasterMain breidt SimpleApplication uit public static void main (String [] args) Hoofd-app = nieuwe hoofd (); app.start ();  @Override public void simpleInitApp ()  @Override public void simpleUpdate (float tpf)  @Override public void simpleRender (RenderManager rm) 

We beginnen met het overnemen simpleInitApp (). Deze methode wordt aangeroepen wanneer de toepassing start. Dit is de plaats om alle componenten in te stellen:

 @Override public void simpleInitApp () // setup-camera voor 2D-games cam.setParallelProjection (true); cam.setLocation (nieuwe Vector3f (0,0,0.5f)); getFlyByCamera () setEnabled (false).; // zet weergave van statistieken uit (u kunt het laten staan ​​als u dat wilt) setDisplayStatView (false); setDisplayFps (false); 

Eerst moeten we de camera een beetje aanpassen, omdat jME in feite een 3D-game-engine is. De statistiekenweergave in de tweede alinea kan heel interessant zijn, maar zo schakel je het uit.

Wanneer je het spel nu start, kun je ... niets zien.

Nou, we moeten de speler in het spel laden! We maken een kleine methode om het laden van onze entiteiten aan te pakken:

 private Spatial getSpatial (String name) Node node = new Node (name); // load picture Picture pic = new Afbeelding (naam); Texture2D tex = (Texture2D) assetManager.loadTexture ("Textures /" + name + ". Png"); pic.setTexture (assetManager, tex, true); / / aanpassen afbeelding zweven breedte = tex.getImage (). getWidth (); vlotterhoogte = tex.getImage (). getHeight (); pic.setWidth (breedte); pic.setHeight (hoogte); pic.move (-breedte / 2f -Hoogte / 2f, 0); // voeg een artikel toe aan de afbeelding Materiaal picMat = nieuw Materiaal (assetManager, "Common / MatDefs / Gui / Gui.j3md"); . PicMat.getAdditionalRenderState () setBlendMode (BlendMode.AlphaAdditive); node.setMaterial (picMat); // stel de straal in van de ruimtelijke // (alleen de breedte gebruikt als een eenvoudige benadering) node.setUserData ("radius", width / 2); // voeg de afbeelding toe aan het knooppunt en retourneer het node.attachChild (foto); return node; 

Aan het begin maken we een knooppunt dat onze foto zal bevatten.

Tip: De jME-scènegrafiek bestaat uit ruimtelijke denkers (knooppunten, afbeeldingen, geometrieën, enzovoort). Telkens wanneer u een ruimtelijke iets toevoegt aan de guiNode, het wordt zichtbaar in de scène. We zullen de gebruiken guiNode omdat we een 2D-spel maken. U kunt spatials koppelen aan andere spatials en daarom uw scène organiseren. Om een ​​echte meester van de scènegrafiek te worden, raad ik deze jME-scène grafiek-zelfstudie aan.

Nadat het knooppunt is gemaakt, laden we de afbeelding en passen we de juiste textuur toe. De juiste maat op de foto toepassen is mooi om te begrijpen, maar waarom moeten we hem verplaatsen?

Wanneer u een afbeelding in jME laadt, bevindt het rotatiepunt zich niet in het midden, maar in een hoek van de afbeelding. Maar we kunnen de afbeelding met de helft van de breedte naar links en de helft van de hoogte omhoog verplaatsen en deze aan een ander knooppunt toevoegen. Wanneer we vervolgens het bovenliggende knooppunt roteren, wordt de afbeelding zelf rond het midden geroteerd.

De volgende stap is het toevoegen van een materiaal aan de foto. Een materiaal bepaalt hoe de afbeelding wordt weergegeven. In dit voorbeeld gebruiken we het standaard GUI-materiaal en stellen we het BlendMode naar AlphaAdditive. Dit betekent dat overlappende transparante delen van meerdere afbeeldingen helderder worden. Dit zal later nuttig zijn om explosies 'glanzender' te maken.

Ten slotte voegen we onze afbeelding toe aan het knooppunt en retourneren het.

Nu moeten we de speler toevoegen aan de guiNode. We breiden uit simpleInitApp een beetje meer:

// de speler-speler instellen = getSpatial ("Player"); player.setUserData ( "leven", true); player.move (settings.getWidth () / 2, settings.getHeight () / 2, 0); guiNode.attachChild (speler);

In het kort: we laden de speler, configureren enkele gegevens, verplaatsen deze naar het midden van het scherm en koppelen deze aan de guiNode om het te laten weergeven.

Gebruikersgegevens is eenvoudigweg een aantal gegevens die u aan elke ruimtelijke kunt koppelen. In dit geval voegen we een Booleaanse waarde toe en noemen we deze levend, zodat we kunnen opzoeken of de speler nog leeft. We zullen dat later gebruiken.

Voer nu het programma uit! Je zou de speler in het midden moeten kunnen zien. Op het moment is het behoorlijk saai, dat geef ik toe. Dus laten we wat actie toevoegen!


Omgang met invoer en verplaatsing van de speler

De invoer van jMonkeyEngine is vrij eenvoudig als je het eenmaal hebt gedaan. We beginnen met het implementeren van een Action Listener:

openbare klasse MonkeyBlasterMain breidt SimpleApplication implementeert ActionListener 

Voor elke sleutel voegen we nu de invoer-toewijzing en de luisteraar toe simpleInitApp ():

 inputManager.addMapping ("left", nieuwe KeyTrigger (KeyInput.KEY_LEFT)); inputManager.addMapping ("right", nieuwe KeyTrigger (KeyInput.KEY_RIGHT)); inputManager.addMapping ("omhoog", nieuwe KeyTrigger (KeyInput.KEY_UP)); inputManager.addMapping ("down", nieuwe KeyTrigger (KeyInput.KEY_DOWN)); inputManager.addMapping ("return", nieuwe KeyTrigger (KeyInput.KEY_RETURN)); inputManager.addListener (this, "left"); inputManager.addListener (this, "right"); inputManager.addListener (this, "up"); inputManager.addListener (this, "down"); inputManager.addListener (this, "return");

Wanneer een van die toetsen wordt ingedrukt of losgelaten, is de methode OnAction wordt genoemd. Voordat we ingaan op wat we eigenlijk moeten doen do wanneer een toets wordt ingedrukt, moeten we een controle toevoegen aan onze speler.

info: Besturingselementen vertegenwoordigen bepaald gedrag van objecten in de scène. U kunt bijvoorbeeld een toevoegen FightControl en een IdleControl naar een vijandelijke AI. Afhankelijk van de situatie kunt u besturingselementen in- en uitschakelen of koppelen en ontkoppelen.

Onze PlayerControl zorgt er eenvoudigweg voor dat de speler wordt verplaatst wanneer een toets wordt ingedrukt, draait hem in de juiste richting en zorgt ervoor dat de speler het scherm niet verlaat.

Alsjeblieft:

public class PlayerControl breidt AbstractControl uit private int screenWidth, screenHeight; // is de speler momenteel in beweging? public boolean omhoog, omlaag, links, rechts; // snelheid van de speler private float speed = 800f; // lastRotation van de speler private float lastRotation; openbare PlayerControl (int width, int height) this.screenWidth = width; this.screenHeight = height;  @Override protected void controlUpdate (float tpf) // verplaats de speler in een bepaalde richting // als hij niet uit het scherm is als (up) if (spatial.getLocalTranslation (). Y < screenHeight - (Float)spatial.getUserData("radius"))  spatial.move(0,tpf*speed,0);  spatial.rotate(0,0,-lastRotation + FastMath.PI/2); lastRotation=FastMath.PI/2;  else if (down)  if (spatial.getLocalTranslation().y > (Float) spatial.getUserData ("radius")) spatial.move (0, tpf * -snelheid, 0);  spatial.rotate (0,0, -lastRotation + FastMath.PI * 1.5f); lastRotation = FastMath.PI * 1.5F;  else if (links) if (spatial.getLocalTranslation (). x> (Float) spatial.getUserData ("radius")) spatial.move (tpf * -snelheid, 0,0);  spatial.rotate (0,0, -lastRotation + FastMath.PI); lastRotation = FastMath.PI;  else if (right) if (spatial.getLocalTranslation (). x < screenWidth - (Float)spatial.getUserData("radius"))  spatial.move(tpf*speed,0,0);  spatial.rotate(0,0,-lastRotation + 0); lastRotation=0;   @Override protected void controlRender(RenderManager rm, ViewPort vp)  // reset the moving values (i.e. for spawning) public void reset()  up = false; down = false; left = false; right = false;  

Oke; laten we nu de code stuk voor stuk bekijken.

 privé int scherm Breedte, scherm Hoogte; // is de speler momenteel in beweging? public boolean omhoog, omlaag, links, rechts; // snelheid van de speler private float speed = 800f; // lastRotation van de speler private float lastRotation; openbare PlayerControl (int width, int height) this.screenWidth = width; this.screenHeight = height; 

Eerst initialiseren we een aantal variabelen, definiëren we in welke richting en hoe snel de speler beweegt en hoe ver hij geroteerd is. Vervolgens hebben we de screenWidth en screenHeight, welke we nodig zullen hebben in de volgende grote methode.

controlUpdate (float tpf) wordt automatisch door jME opgeroepen voor elke updatecyclus. De variabele tpf geeft de tijd aan sinds de laatste update. Dit is nodig om de snelheid te regelen: als sommige computers twee keer zo lang duren om een ​​update als anderen te berekenen, moet de speler twee keer zo ver gaan in een enkele update op die computers.

Nu naar de eerste als uitspraak:

 if (up) if (spatial.getLocalTranslation (). y < screenHeight - (Float)spatial.getUserData("radius"))  spatial.move(0,tpf*speed,0); 

We controleren of de speler omhoog gaat en zo ja, we controleren of deze verder kan gaan. Als het ver genoeg van de grens verwijderd is, verplaatsen we het eenvoudig een beetje.

Nu op de rotatie:

 spatial.rotate (0,0, -lastRotation + FastMath.PI / 2); lastRotation = FastMath.PI / 2;

We draaien de speler terug lastRotation om zijn oorspronkelijke richting te zien. Vanuit deze richting kunnen we de speler draaien in de richting waarin we willen dat hij ernaar kijkt. Ten slotte besparen we de daadwerkelijke rotatie.

We gebruiken dezelfde soort logica voor alle vier de richtingen. De reset () methode is hier alleen om alle waarden weer in te stellen op nul, voor gebruik bij het uit elkaar zetten van de speler.

Dus we hebben eindelijk de controle over onze speler. Het is tijd om het toe te voegen aan het werkelijke ruimtelijke. Voeg eenvoudig de volgende regel toe aan de simpleInitApp () methode:

player.addControl (nieuwe PlayerControl (settings.getWidth (), settings.getHeight ()));

Het object instellingen is inbegrepen in de klas SimpleApplication. Het bevat gegevens over de weergave-instellingen van het spel.

Als we het spel nu starten, gebeurt er nog steeds niets. We moeten het programma vertellen wat te doen wanneer een van de toegewezen toetsen wordt ingedrukt. Om dit te doen, overschrijven we de OnAction methode:

 public void onAction (String naam, boolean isPressed, float tpf) if ((Boolean) player.getUserData ("alive")) if (name.equals ("up")) player.getControl (PlayerControl.class). omhoog = is ingedrukt;  else if (name.equals ("down")) player.getControl (PlayerControl.class) .down = isPressed;  else if (name.equals ("left")) player.getControl (PlayerControl.class) .left = isPressed;  else if (name.equals ("right")) player.getControl (PlayerControl.class). right = isPressed; 

Voor elke ingedrukte toets, vertellen we het PlayerControl de nieuwe status van de sleutel. Nu is het eindelijk tijd om onze game te starten en iets op het scherm te zien bewegen!

Als je blij bent dat je de basisprincipes van invoer- en gedragsbeheer begrijpt, is het tijd om hetzelfde opnieuw te doen - deze keer voor de kogels.


Enkele Bullet-actie toevoegen

Als we er wat van willen hebben echt actie gaande is, moeten we een aantal vijanden kunnen neerschieten. We zullen dezelfde basisprocedure volgen als in de vorige stap: input beheren, wat kogels maken en er een gedrag aan toevoegen.

Om muisinvoer te verwerken, zullen we een andere luisteraar implementeren:

openbare klasse MonkeyBlasterMain breidt SimpleApplication implementeert ActionListener, AnalogListener 

Voordat er iets gebeurt, moeten we de toewijzing en de luisteraar toevoegen zoals we de vorige keer deden. We doen dat in de simpleInitApp () methode, naast de andere input-initialisatie:

 inputManager.addMapping ("mousePick", nieuwe MouseButtonTrigger (MouseInput.BUTTON_LEFT)); inputManager.addListener (dit, "mousePick");

Telkens wanneer we met de muis klikken, is de methode onAnalog wordt gebeld. Voordat we aan de daadwerkelijke schietpartij beginnen, moeten we een kleine hulpmethode implementeren, Vector3f getAimDirection (), wat ons de richting geeft om op te schieten door de positie van de speler af te trekken van die van de muis:

 private Vector3f getAimDirection () Vector2f mouse = inputManager.getCursorPosition (); Vector3f playerPos = player.getLocalTranslation (); Vector3f dif = nieuwe Vector3f (mouse.x-playerPos.x, mouse.y-playerPos.y, 0); return dif.normalizeLocal (); 
Tip: Bij het koppelen van objecten aan de guiNode, hun lokale vertaaleenheden zijn gelijk aan één pixel. Dit maakt het voor ons gemakkelijk om de richting te berekenen, omdat de cursorpositie ook wordt gespecificeerd in pixeleenheden.

Nu we een richting hebben om op te schieten, laten we de daadwerkelijke schietactie uitvoeren:

 public void onAnalog (Stringnaam, floatwaarde, float tpf) if ((Boolean) player.getUserData ("alive")) if (naam.equals ("mousePick")) // shoot Bullet if (System.currentTimeMillis () - bulletCooldown> 83f) bulletCooldown = System.currentTimeMillis (); Vector3f-doel = getAimDirection (); Vector3f offset = nieuwe Vector3f (aim.y / 3, -aim.x / 3,0); // init bullet 1 Spatial bullet = getSpatial ("Bullet"); Vector3f finalOffset = aim.add (offset) .mult (30); Vector3f trans = player.getLocalTranslation (). Add (finalOffset); bullet.setLocalTranslation (trans); bullet.addControl (nieuwe BulletControl (doel, settings.getWidth (), settings.getHeight ())); bulletNode.attachChild (kogel); // init bullet 2 Spatial bullet2 = getSpatial ("Bullet"); finalOffset = aim.add (offset.negate ()). mult (30); trans = player.getLocalTranslation (). add (finalOffset); bullet2.setLocalTranslation (trans); bullet2.addControl (nieuwe BulletControl (doel, settings.getWidth (), settings.getHeight ())); bulletNode.attachChild (bullet2); 

Oké, dus laten we dit eens doornemen:

 if (System.currentTimeMillis () - bulletCooldown> 83f) bulletCooldown = System.currentTimeMillis (); Vector3f-doel = getAimDirection (); Vector3f offset = nieuwe Vector3f (aim.y / 3, -aim.x / 3,0);

Als de speler in leven is en op de muisknop is geklikt, controleert onze code eerst of de laatste opname minstens 83 ms geleden is gemaakt (bulletCooldown is een lange variabele die we aan het begin van de les initialiseren). Als dat zo is, mogen we fotograferen en berekenen we de juiste richting voor richten en de offset.

// init bullet 1 Spatial bullet = getSpatial ("Bullet"); Vector3f finalOffset = aim.add (offset) .mult (30); Vector3f trans = player.getLocalTranslation (). Add (finalOffset); bullet.setLocalTranslation (trans); bullet.addControl (nieuwe BulletControl (doel, settings.getWidth (), settings.getHeight ())); bulletNode.attachChild (kogel); // init bullet 2 Spatial bullet2 = getSpatial ("Bullet"); finalOffset = aim.add (offset.negate ()). mult (30); trans = player.getLocalTranslation (). add (finalOffset); bullet2.setLocalTranslation (trans); bullet2.addControl (nieuwe BulletControl (doel, settings.getWidth (), settings.getHeight ())); bulletNode.attachChild (bullet2);

We willen twee kogels spawnen, de een naast de ander, dus we zullen een beetje verschil moeten toevoegen aan elk van hen. Een geschikte offset is orthogonaal ten opzichte van de doelrichting, wat eenvoudig kan worden bereikt door de X en Y waarden en negeert een ervan. De tweede zal simpelweg een ontkenning zijn van de eerste.

// init bullet 1 Spatial bullet = getSpatial ("Bullet"); Vector3f finalOffset = aim.add (offset) .mult (30); Vector3f trans = player.getLocalTranslation (). Add (finalOffset); bullet.setLocalTranslation (trans); bullet.addControl (nieuwe BulletControl (doel, settings.getWidth (), settings.getHeight ())); bulletNode.attachChild (kogel); // init bullet 2 Spatial bullet2 = getSpatial ("Bullet"); finalOffset = aim.add (offset.negate ()). mult (30); trans = player.getLocalTranslation (). add (finalOffset); bullet2.setLocalTranslation (trans); bullet2.addControl (nieuwe BulletControl (doel, settings.getWidth (), settings.getHeight ())); bulletNode.attachChild (bullet2);

De rest lijkt redelijk bekend: we initialiseren de kogel door de onze te gebruiken getSpatial methode vanaf het begin. Vervolgens vertalen we het naar de juiste plaats en koppelen het aan het knooppunt. Maar wacht, welk knooppunt?

We zullen onze entiteiten in specifieke knooppunten organiseren, dus het is logisch om een ​​knooppunt te maken waar we al onze kogels aan kunnen bevestigen. Om de kinderen van dat knooppunt weer te geven, moeten we het koppelen aan het guiNode.

De initialisatie in simpleInitApp () is vrij eenvoudig:

// setup the bulletNode bulletNode = new Node ("bullets"); guiNode.attachChild (bulletNode);

Als je doorgaat en de game start, kun je de kogels zien verschijnen, maar ze bewegen niet! Als je jezelf wilt testen, stop dan met lezen en denk zelf na wat we moeten doen om ze te laten bewegen.

...

Heb je het uitgevonden?

We moeten een controle toevoegen aan elke kogel die voor de beweging zorgt. Om dit te doen, zullen we een andere klasse aanmaken genaamd BulletControl:

public class BulletControl breidt AbstractControl uit private int screenWidth, screenHeight; privé zweeftijd = 1100f; openbare Vector3f-richting; privé float rotatie; public BulletControl (Vector3f direction, int screenWidth, int screenHeight) this.direction = direction; this.screenWidth = screenWidth; this.screenHeight = screenHeight;  @Override protected void controlUpdate (float tpf) // verplaatsing spatial.move (direction.mult (snelheid * tpf)); // rotation float actualRotation = MonkeyBlasterMain.getAngleFromVector (direction); if (actualRotation! = rotatie) spatial.rotate (0,0, actualRotation - rotation); rotatie = werkelijke rotatie;  // grenzen controleren Vector3f loc = spatial.getLocalTranslation (); if (loc.x> screenWidth || loc.y> screenHeight || loc.x < 0 || loc.y < 0)  spatial.removeFromParent();   @Override protected void controlRender(RenderManager rm, ViewPort vp)  

Een snelle blik op de structuur van de klas laat zien dat deze behoorlijk lijkt op de PlayerControl klasse. Het belangrijkste verschil is dat we geen sleutels hebben om gecontroleerd te worden, en we hebben wel een richting variabel. We verplaatsen de kogel eenvoudig in zijn richting en draaien deze dienovereenkomstig.

 Vector3f loc = spatial.getLocalTranslation (); if (loc.x> screenWidth || loc.y> screenHeight || loc.x < 0 || loc.y < 0)  spatial.removeFromParent(); 

In het laatste blok controleren we of het opsommingsteken zich buiten de grenzen van het scherm bevindt en, als dit het geval is, verwijderen we het van het bovenliggende knooppunt, waardoor het object wordt verwijderd.

Mogelijk hebt u deze methode-oproep gepakt:

MonkeyBlasterMain.getAngleFromVector (richting);

Het verwijst naar een korte statische wiskundige helpermethode in de hoofdklasse. Ik heb twee van hen gemaakt, waarvan er één een hoek in een vector in 2D-ruimte omzet en de ander deze terug in een hoekwaarde omzet.

 public static float getAngleFromVector (Vector3f vec) Vector2f vec2 = new Vector2f (vec.x, vec.y); retourneer vec2.getAngle ();  public static Vector3f getVectorFromAngle (float angle) return new Vector3f (FastMath.cos (angle), FastMath.sin (angle), 0); 
Tip: Als je je behoorlijk in de war voelt door al die vectorbewerkingen, doe jezelf dan een plezier en verdiep je in een aantal tutorials over vector wiskunde. Het is essentieel in zowel 2D- als 3D-ruimte. Terwijl je bezig bent, moet je ook het verschil tussen graden en radialen opzoeken. En als je meer wilt gaan doen aan 3D-gameprogrammering, zijn quaternions ook geweldig ...

Nu terug naar het hoofdoverzicht: we hebben een inputlistener gemaakt, twee kogels geïnitialiseerd en een gemaakt BulletControl klasse. Het enige dat overblijft is om een ​​toe te voegen BulletControl naar elke bullet bij het initialiseren:

bullet.addControl (nieuwe BulletControl (doel, settings.getWidth (), settings.getHeight ()));

Nu is het spel veel leuker!



Conclusie

Hoewel het niet bepaald een uitdaging is om rond te vliegen en wat kogels te schieten, kun je dat tenminste do iets. Maar wanhoop niet - na de volgende tutorial zult u moeite hebben om aan de groeiende hordes vijanden te ontsnappen!