La schermata di gioco presenterà la nostra astronave in azione e controllata da un joystick virtuale. Abbiamo bisogno di un pulsante Ready che il giocatore deve premere per iniziare, e di un pulsante Game Over da visualizzare quando ha terminato tutte le sue vite. Questi pulsanti compariranno in trasparenza all'inizio del gioco e al momento del Game Over. Nel costruttore di PhGamePlayScreen
definiamo questi due oggetti usando tutte le informazioni delle lezioni precedenti:
private GameLevels gameLevels;
private boolean playerReady;
private GameObject readyButton;
private GameObject gameOverButton;
public PhGamePlayScreen(Game game, Context context, GL10 gl10) {
super(game, context, gl10);
readyButton = new GameObject(240, 400, 254, 254,
GameAtlas.getGameImage(256, 481, 254, 254),
GameObject.CollisionMask.NONE);
gameOverButton = new GameObject(240, 400, 254, 254,
GameAtlas.getGameImage(1, 481, 254, 254),
GameObject.CollisionMask.NONE);
readyButton.setActive(true);
gameOverButton.setActive(false);
playerReady = false;
gameLevels = new GameLevels(gl10);
}
Dopo aver definito gli oggetti fondamentali dello schermo di gioco, analizziamo cosa inserire nel metodo update()
.
if (gameOverButton.isActive() && gameOverButton.isTouched(touchX, touchY)) {
Options.addScore(gameLevels.getScore());
try {
Options.save();
} catch (ResourceException e) {
e.printStackTrace();
}
PhGameScoresScreen scores = new PhGameScoresScreen(game, context,
gl10);
game.setCurrentScreen(scores);
} else if (readyButton.isActive() && readyButton.isTouched(touchX, touchY)) {
playerReady = true;
readyButton.setActive(false);
}
In questa fase controlliamo se il pulsante Game Over è attivo e premuto dall'utente. Se sì, aggiorniamo il punteggio grazie alla classe Options
che ci permette di leggere e scrivere da un file. Infine navighiamo verso la schermata dei punteggi. Se, invece, il pulsante Ready è attivo e l'utente lo ha premuto, lo disattiviamo ed avviamo il gioco.
gameLevels.updateBackgrounds(deltaTime);
gameLevels.updateScore(deltaTime);
if (game.getState() == Game.State.Running && playerReady) {
gameLevels.updateJoystickAndPlayer(touchX, touchY, actionUp,
deltaTime);
gameLevels.updateEnemyAndExplosions(deltaTime);
gameLevels.checkCollisionShipFireAndEnemy();
gameLevels.checkCollisionShipAndEnemy();
}
Aggiorniamo quindi, tramite la classe GameLevels
(che approfondiremo a breve), lo sfondo ed il punteggio del giocatore. Successivamente aggiorniamo anche il controller del giocatore, verificando le collisioni tra il giocatore ed i nemici, ed aggiornando i nemici stessi, attivando le esplosioni se il fuoco del giocatore li ha colpiti.
gameLevels.updateShipHitted();
if (gameLevels.getLife() == 0) {
if (!gameOverButton.isActive()) {
gameOverButton.setActive(true);
playerReady = false;
}
gameLevels.updatePlayer(touchX, touchY, actionUp, deltaTime);
gameLevels.updateEnemyAndExplosions(deltaTime);
}
resetTouch();
Se il giocatore è stato colpito dobbiamo aggiornare il numero di vite ed eventualmente attivare un effetto di esplosione. Se il giocatore ha perso tutte le sue vite, si attiva il pulsante di Game Over ed il player verrà disattivato, pur continuando ad essere aggiornato insieme ai suoi nemici. All'interno del metodo present()
troviamo il seguente codice:
gl10.glClearColor(0, 0, 0, 1);
gl10.glClear(GL10.GL_COLOR_BUFFER_BIT);
gl10.glEnable(GL10.GL_BLEND);
gl10.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA);
gameLevels.drawBackgrounds(gl10);
gameLevels.drawEnemyAndExplosion(deltaTime, gl10);
gameLevels.drawJoystickAndPlayer(gl10);
gameLevels.drawScore(gl10);
Resettiamo il frame buffer, ed attiviamo l'alpha blending per la trasparenza degli oggetti. Proseguiamo disegnando lo sfondo di stelle che scorre, i nemici, il giocatore, il controller ed il punteggio.
if (readyButton.isActive()) {
readyButton.draw(gl10);
} else if (gameOverButton.isActive()) {
gameOverButton.draw(gl10);
}
gl10.glDisable(GL10.GL_BLEND);
Verifichiamo se dobbiamo disegnare un pulsante Ready o Game Over, disattivando infine l'alpha blending. Nei restanti metodi gestiamo il ciclo di vita dello screen rilasciando e recuperando le risorse:
@Override
public void pause() {
if (Options.enableMusic) {
gameLevels.getMusic().pause();
}
if (gameLevels.getLife() > 0) {
playerReady = false;
readyButton.setActive(true);
} else {
gameOverButton.setActive(false);
}
}
@Override
public void resume() {
GameAtlas.reloadAtlas();
GameBackgroundAtlas.reloadBackgrounds();
if (Options.enableMusic) {
gameLevels.getMusic().play();
}
}
@Override
public void dispose() {
if (Options.enableMusic) {
gameLevels.getMusic().dispose();
}
if (Options.enableSfx) {
gameLevels.getExplosionSfx().dispose();
gameLevels.getShipFireSfx().dispose();
}
GameAtlas.releaseAtlas();
GameBackgroundAtlas.releaseBackgrounds();
}
La classe GameLevels
, che abbiamo già visto nei precedenti frammenti di codice, rappresenta un livello di gioco. Essa, tra le altre cose, mantiene diversi pool di oggetti:
private ObjectPool<GameObject> explosions;
private ObjectPool<PhEnemyShip> enemyShipsType1;
private ObjectPool<PhEnemyShip> enemyShipsType2;
private ObjectPool<PhEnemyShip> enemyShipsType3;
private ObjectPool<PhMeteor> meteors;
private ObjectPool<PhMine> mines;
private ObjectPool<PhEnemyShipFire> enemyShipsFire;
ObjectPool
è una classe che utilizziamo per mantenere fisso il numero di oggetti di un particolare tipo. In tal modo, quando abbiamo bisogno di disegnare un'astronave aliena o un meteorite, non creiamo di volta in volta un nuovo oggetto, ma ne richiediamo uno esistente al pool. Facciamo inoltre in modo che nella schermata il numero di oggetti non superi mai una certa soglia, in modo da non esaurire il pool ed avere sempre un nuovo oggetto pronto all'uso. Ciò evita anche di appesantire il garbage collector.
La classe PhEnemyShip
realizza un'astronave aliena, gli oggetti meteors
e mines
rappresentano rispettivamente meteoriti e mine spaziali, mentre PhEnemyShipFire
è il fuoco di un'astronave aliena.
Il metodo initializeObjectsPool()
costruisce gli oggetti sfruttando quanto visto nella lezione precedente, e li inserisce nei vari pool. Oltre ai pool, manteniamo anche liste degli stessi oggetti, che denotano la loro appartenenza alla classe di oggetti attivi. Su questi ultimi saranno effettuati i test per le collisioni. Manteniamo anche liste di oggetti che devono essere rimossi dalla scena e riposti nel pool perchè non più attivi. Esempi di questo tipo sono le astronavi colpite o le animazioni di esplosioni al termine del loro effetto.
private ArrayList<PhEnemyShip> activeEnemyShips;
private ArrayList<PhMeteor> activeMeteors;
private ArrayList<PhMine> activeMines;
private ArrayList<PhEnemyShipFire> activeEnemyShipsFire;
private ArrayList<GameObject> activeExplosions;
private ArrayList<PhEnemyShip> mustEnemyRemove;
private ArrayList<PhMeteor> mustMeteorRemove;
private ArrayList<PhMine> mustMineRemove;
private ArrayList<PhEnemyShipFire> mustEnemyFireRemove;
private ArrayList<GameObject> mustExplosionRemove;
Per simulare l'effetto dello sfondo animato, utilizziamo due oggetti che scorrono uno di seguito all'altro:
private GameObject starFieldFrame1;
private GameObject starFieldFrame2;
Il metodo updateBackgrounds()
realizza questo piccolo trucco che da movimento agli oggetti della scena nel verso dello scorrimento, avendo come riferimento la risoluzione target (480x800):
public void updateBackgrounds(float deltaTime) {
starFieldFrame1.setY(starFieldFrame1.getY() - 50 * deltaTime);
starFieldFrame2.setY(starFieldFrame2.getY() - 50 * deltaTime);
if (starFieldFrame1.getY() <= -400) {
starFieldFrame1.setY(1200);
}
if (starFieldFrame2.getY() <= -400) {
starFieldFrame2.setY(1200);
}
}
Altro campo essenziale è quello del player, con gli oggetti che realizzano la nave del giocatore ed il singolo effetto di fuoco:
private PhSpaceShip ship;
private PhSpaceShipFire shipFire;
L'astronave può sparare un singolo colpo molto veloce, ma solo dopo che questo colpo è uscito dallo schermo oppure ha colpito un nemico esso diventa di nuovo disponibile, e l'astronave potrà sparare ancora. L'effetto è molto scorrevole e ci consente di risparmiare oggetti per questo semplice gioco.
Si noti che tutti gli oggetti che stiamo defininendo estendono GameObject
o DynamicGameObject
. Come esempio vediamo come istanziare l'astronave all'interno del metodo initializePlayer()
della classe GameLevels
:
ship = new PhSpaceShip(240, 400, 64, 64, GameAtlas.getGameImage(897,
129, 62, 62), GameObject.CollisionMask.RECTANGLE);
In questo caso abbiamo creato un oggetto dinamico in posizione iniziale (240, 400) nel mondo, di dimensione 64x64 pixel, applicando la porzione di texture con le coordinate pixel ricavate dall'Atlas, ed infine specificando una maschera di collisione rettangolare. Questa operazione viene fatta per ogni oggetto del gioco.
Il ciclo di vita di GameLevels
inizia con una serie di inizializzazioni:
public GameLevels(GL10 gl) {
initializeObjectsPool();
initializeSounds();
initializeActiveObjectsList();
initializeBackgrounds();
initializeJoystick(gl);
initializePlayer();
initializeRemoveList();
font = new PhGameFont();
simpleReflexAgent = new SimpleReflexAgent();
}
Qui vengono inizializzati i vari pool, effetti sonori e musica, oggetti attivi, sfondi, controller del giocatore, astronave del giocatore, liste di oggetti inattivi, font ed intelligenza artificiale delle astronavi aliene. La classe PhJoystick
realizza il controller del giocatore. In essa vengono utilizzati diversi GameObject
per comporre un oggetto più complesso. La classe mantiene lo stato della direzione di movimento e di fuoco dell'astronave. Possiamo inoltre notare come vengano usate le coordinate touch provenienti dalla GameView
, all'interno del metodo move()
per rispondere al movimento.
public class PhJoystick implements GameController{
private GameObject buttonUp;
private GameObject buttonDown;
private GameObject buttonRight;
private GameObject buttonLeft;
private GameObject buttonFire;
private Direction state;
private boolean fireState;
private GL10 gl10;
public PhJoystick(GL10 gl10) {
buttonUp = new GameObject(150, 150,90,90,GameAtlas.getGameImage(237, 338, 46, 46),GameObject.CollisionMask.NONE);
buttonDown = new GameObject(150,45,90,90,GameAtlas.getGameImage(235, 432, 46, 46),GameObject.CollisionMask.NONE);
buttonLeft = new GameObject(50, 95,90,90,GameAtlas.getGameImage(186, 384, 46, 46),GameObject.CollisionMask.NONE);
buttonRight = new GameObject(250, 95,90,90,GameAtlas.getGameImage(283, 384, 46, 46),GameObject.CollisionMask.NONE);
buttonFire = new GameObject(480-48, 94,90,90,GameAtlas.getGameImage(1, 385, 95, 95),GameObject.CollisionMask.NONE);
this.gl10=gl10;
this.state=Direction.STAND;
this.fireState=false;
}
@Override
public Direction move(float touchX, float touchY, boolean actionUp) {
if(buttonUp.isTouched(touchX, touchY)) {
state=Direction.UP;
} else if(buttonDown.isTouched(touchX, touchY)) {
state=Direction.DOWN;
} else if(buttonRight.isTouched(touchX, touchY)) {
state=Direction.RIGHT;
} else if(buttonLeft.isTouched(touchX, touchY)) {
state=Direction.LEFT;
}
return state;
}
@Override
public boolean fire(float touchX, float touchY) {
fireState=false;
if(buttonFire.isTouched(touchX, touchY)){
fireState=true;
}
return fireState;
}
public void draw() {
buttonFire.draw(gl10);
buttonDown.draw(gl10);
buttonUp.draw(gl10);
buttonLeft.draw(gl10);
buttonRight.draw(gl10);
}
}
Il metodo updateJoystickAndPlayer()
di GameLevels
utilizza questa classe per acquisire il movimento del giocatore ed aggiornare di conseguenza lo stato degli oggetti ship
e shipFire
. Il metodo updateEnemyAndExplosions()
mostra tutta la logica di gestione di nemici ed esplosioni. Questo metodo utilizza i vari pool ed altri metodi come makeTurbot()
, makeLineOblique()
, makeV()
per creare diverse configurazioni di attacco delle navi nemiche, oltre ad aggiornare le varie liste di oggetti attivi e da rimuovere.
La classe GameLevels
è inoltre dotata di una serie di metodi che ricorrono a draw
e drawAnimation
sui vari oggetti per il rendering della scena. Vediamo, ad esempio, il disegno del giocatore e del controller:
public void drawJoystickAndPlayer(GL10 gl) {
if (ship.isActive())
ship.draw(gl);
if (shipFire.isActive()) {
shipFire.draw(gl);
}
joystick.draw();
}
Il funzionamento del metodo è molto semplice: se l'oggetto della nave del giocatore è attivo, allora sarà disegnato, altrimenti no. Facciamo la stessa cosa per il fuoco dell'astronave e per il joystick.
Gestire le collisioni
I metodi checkCollisionShipFireAndEnemy()
e checkCollisionShipAndEnemy()
sono i rilevatori di collisioni. Il primo controlla se il fuoco del giocatore ha colpito un nemico, il secondo se é il giocatore ad essere colpito da un nemico. La rilevazione delle collisioni é realizzata con la classe CollisionManager
, che utilizza l'informazione della maschera di collisione impostata su un oggetto per utilizzare la metodologia opportuna di test. Come abbiamo visto, le nostre possibili maschere di collisione sono un rettangolo ed un cerchio,
quindi i test si riducono a 4 combinazioni. Il metodo checkCollision (GameObject object1, GameObject object2)
controlla la tipologia di maschera di entrambi gli oggetti ed applica l'algoritmo in grado di gestire la combinazione corrente sugli oggetti. Le collisioni vengono gestite con le seguenti metodologie matematiche:
-
Cerchio-Cerchio: si controlla la distanza tra i due centri del cerchio; se la distanza é maggiore della somma dei raggi, i due cerchi si intersecano e quindi abbiamo una collisione;
Figura 10. Collisioni Cerchio-Cerchio
-
Cerchio-Rettangolo: l'obiettivo è determinare il punto (x,y) dell'area del rettangolo più vicino al punto centro del cerchio. Successivamente si verifica se tale punto è contenuto nel cerchio. Si parte quindi con il determinare la x che cade nell'area del rettangolo più vicina alla x del centro del cerchio. Si fa la stessa cosa per la coordinata y, e si individua così il punto (x,y) dell’area del rettangolo più vicino al centro del cerchio. Se questo punto cade nell'area del cerchio allora è avvenuta una collisione;
Figura 11. Collisioni Cerchio-Rettangolo
-
Rettangolo-Rettangolo: si verifica se il lato sinistro del primo rettangolo é a sinistra del lato destro del secondo rettangolo; se sì, si verifica se il lato destro del primo rettangolo é a destra del lato sinistro del secondo rettangolo. Si fanno poi gli stessi test per i lati superiori e inferiori. Se hanno tutti esito positivo, allora é avvenuta una collisione.
Figura 12. Collisioni Rettangolo-Rettangolo