Guida rapida: sviluppare Pong su Android con OpenGL ES e Java
Pong è uno dei giochi più semplici e didattici per imparare le basi della grafica 2D. Su Android, usare OpenGL ES 2.0 ti permette di avere pieno controllo sul rendering, imparando concetti riutilizzabili in giochi più complessi.
In questa mini guida creiamo una versione giocabile di Pong (single player vs CPU) in Java puro, usando GLSurfaceView e un renderer personalizzato.
1. Prerequisiti
- Android Studio (versione recente)
- Android SDK >= 21 (Android 5.0)
- Conoscenza base di Java e cicli di gioco
- Dispositivo o emulatore con supporto OpenGL ES 2.0 (praticamente tutti i device moderni)
2. Setup del progetto e configurazione GLSurfaceView
Crea un nuovo progetto Empty Activity. Nel layout principale sostituisci il TextView con un GLSurfaceView personalizzato o, più semplicemente, useremo il layout in Java creando la vista a runtime.
Esempio di layout XML (opzionale) in activity_main.xml:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.opengl.GLSurfaceView
android:id="@+id/glSurfaceView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
In MainActivity.java collegheremo il GLSurfaceView al nostro GameRenderer e abiliteremo OpenGL ES 2.0.
3. Componenti base: GLSurfaceView e Renderer
GLSurfaceView è una vista specializzata che gestisce il contesto OpenGL. GameRenderer implementa GLSurfaceView.Renderer ed è responsabile del ciclo di rendering: inizializzazione, gestione delle dimensioni e disegno di ogni frame.
Le tre funzioni principali del renderer:
onSurfaceCreated(): inizializza lo stato OpenGL, colori di sfondo, ecc.onSurfaceChanged(): reagisce a cambi di dimensione (rotazione, resize).onDrawFrame(): viene chiamata ogni frame; qui aggiorniamo logica di gioco e disegniamo la scena.
4. Codice: Renderer, Paddle, Ball, MainActivity e GameLoop
Qui sotto trovi una mini app completa. I file Java principali sono:
MainActivity.java: crea ilGLSurfaceView, gestisce il touch.GameRenderer.java: implementaGLSurfaceView.Renderer, game loop e disegno.Paddle.java: rappresenta la racchetta (giocatore e CPU).Ball.java: rappresenta la pallina, movimento e collisioni.
Il game loop è integrato in onDrawFrame(): ad ogni chiamata aggiorniamo le posizioni in base al tempo trascorso e ridisegniamo.
MainActivity.java
package com.example.pongopengl;
import android.app.Activity;
import android.opengl.GLSurfaceView;
import android.os.Bundle;
import android.view.MotionEvent;
import android.view.View;
public class MainActivity extends Activity implements View.OnTouchListener {
private GLSurfaceView glSurfaceView;
private GameRenderer renderer;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
glSurfaceView = new GLSurfaceView(this);
// Dichiarare che vogliamo usare OpenGL ES 2.0
glSurfaceView.setEGLContextClientVersion(2);
renderer = new GameRenderer(this);
glSurfaceView.setRenderer(renderer);
// Render continui per avere un game loop semplice
glSurfaceView.setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);
glSurfaceView.setOnTouchListener(this);
setContentView(glSurfaceView);
}
@Override
protected void onPause() {
super.onPause();
glSurfaceView.onPause();
}
@Override
protected void onResume() {
super.onResume();
glSurfaceView.onResume();
}
@Override
public boolean onTouch(View v, MotionEvent event) {
// Usiamo la posizione X del tocco per muovere la racchetta del giocatore
if (event != null) {
final float x = event.getX();
// Inviamo l'evento al renderer sul thread GL
glSurfaceView.queueEvent(new Runnable() {
@Override
public void run() {
renderer.onTouch(x);
}
});
return true;
}
return false;
}
}
Paddle.java
package com.example.pongopengl;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import javax.microedition.khronos.opengles.GL10;
public class Paddle {
// Coordinate del rettangolo (due triangoli) in coordinate normalizzate
private FloatBuffer vertexBuffer;
private float width;
private float height;
// Posizione centrale
public float x;
public float y;
// Velocità usata per l'IA o per eventuali animazioni
public float speed = 1.5f;
// Colore RGBA
private float[] color = {1f, 1f, 1f, 1f};
public Paddle(float width, float height, float startX, float startY) {
this.width = width;
this.height = height;
this.x = startX;
this.y = startY;
updateBuffer();
}
private void updateBuffer() {
float left = x - width / 2f;
float right = x + width / 2f;
float top = y + height / 2f;
float bottom = y - height / 2f;
float[] vertices = {
left, top,
left, bottom,
right, bottom,
left, top,
right, bottom,
right, top
};
ByteBuffer bb = ByteBuffer.allocateDirect(vertices.length * 4);
bb.order(ByteOrder.nativeOrder());
vertexBuffer = bb.asFloatBuffer();
vertexBuffer.put(vertices);
vertexBuffer.position(0);
}
public void setPosition(float x, float y) {
this.x = x;
this.y = y;
updateBuffer();
}
public void draw(GL10 gl) {
// Abilita array di vertici
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glVertexPointer(2, GL10.GL_FLOAT, 0, vertexBuffer);
// Imposta colore
gl.glColor4f(color[0], color[1], color[2], color[3]);
// Disegna 6 vertici (2 triangoli)
gl.glDrawArrays(GL10.GL_TRIANGLES, 0, 6);
gl.glDisableClientState(GL10.GL_VERTEX_ARRAY);
}
// Controllo collisione con la pallina (asse Y dipende da renderer)
public boolean collidesWith(Ball ball) {
float left = x - width / 2f;
float right = x + width / 2f;
float top = y + height / 2f;
float bottom = y - height / 2f;
float bx = ball.x;
float by = ball.y;
float br = ball.radius;
return bx + br > left && bx - br < right &&
by + br > bottom && by - br < top;
}
}
Ball.java
package com.example.pongopengl;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import javax.microedition.khronos.opengles.GL10;
public class Ball {
public float x;
public float y;
public float vx;
public float vy;
public float radius;
private FloatBuffer vertexBuffer;
private int segments = 32;
private float[] color = {1f, 1f, 1f, 1f};
public Ball(float radius, float startX, float startY, float vx, float vy) {
this.radius = radius;
this.x = startX;
this.y = startY;
this.vx = vx;
this.vy = vy;
buildCircle();
}
private void buildCircle() {
// Creiamo un triangolo a ventaglio per il cerchio
float[] vertices = new float[(segments + 2) * 2];
vertices[0] = 0f;
vertices[1] = 0f;
for (int i = 0; i <= segments; i++) {
double angle = 2.0 * Math.PI * i / segments;
float dx = (float) Math.cos(angle) * radius;
float dy = (float) Math.sin(angle) * radius;
vertices[(i + 1) * 2] = dx;
vertices[(i + 1) * 2 + 1] = dy;
}
ByteBuffer bb = ByteBuffer.allocateDirect(vertices.length * 4);
bb.order(ByteOrder.nativeOrder());
vertexBuffer = bb.asFloatBuffer();
vertexBuffer.put(vertices);
vertexBuffer.position(0);
}
public void update(float dt) {
// Aggiorna posizione in base alla velocità e al delta time
x += vx * dt;
y += vy * dt;
}
public void bounceHorizontal() {
vx = -vx;
}
public void bounceVertical() {
vy = -vy;
}
public void draw(GL10 gl) {
gl.glPushMatrix();
// Trasla la matrice alla posizione della pallina
gl.glTranslatef(x, y, 0f);
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glVertexPointer(2, GL10.GL_FLOAT, 0, vertexBuffer);
gl.glColor4f(color[0], color[1], color[2], color[3]);
gl.glDrawArrays(GL10.GL_TRIANGLE_FAN, 0, segments + 2);
gl.glDisableClientState(GL10.GL_VERTEX_ARRAY);
gl.glPopMatrix();
}
}
GameRenderer.java
package com.example.pongopengl;
import android.content.Context;
import android.opengl.GLSurfaceView;
import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;
public class GameRenderer implements GLSurfaceView.Renderer {
private Context context;
private Paddle playerPaddle;
private Paddle cpuPaddle;
private Ball ball;
// Limiti del mondo in coordinate normalizzate
// Useremo -1 .. 1 per X e Y
private float worldLeft = -1f;
private float worldRight = 1f;
private float worldTop = 1f;
private float worldBottom = -1f;
private long lastTimeNs;
public GameRenderer(Context context) {
this.context = context;
}
@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
// Colore di sfondo nero
gl.glClearColor(0f, 0f, 0f, 1f);
// Abilita il 2D senza profondità
gl.glDisable(GL10.GL_DEPTH_TEST);
// Inizializza oggetti di gioco
float paddleWidth = 0.4f;
float paddleHeight = 0.05f;
playerPaddle = new Paddle(paddleWidth, paddleHeight, 0f, -0.9f);
cpuPaddle = new Paddle(paddleWidth, paddleHeight, 0f, 0.9f);
ball = new Ball(0.04f, 0f, 0f, 0.6f, 0.8f);
lastTimeNs = System.nanoTime();
}
@Override
public void onSurfaceChanged(GL10 gl, int width, int height) {
gl.glViewport(0, 0, width, height);
// Impostiamo una proiezione ortografica semplice da -1 a 1
gl.glMatrixMode(GL10.GL_PROJECTION);
gl.glLoadIdentity();
gl.glOrthof(worldLeft, worldRight, worldBottom, worldTop, -1f, 1f);
gl.glMatrixMode(GL10.GL_MODELVIEW);
gl.glLoadIdentity();
}
@Override
public void onDrawFrame(GL10 gl) {
long now = System.nanoTime();
float dt = (now - lastTimeNs) / 1_000_000_000.0f; // secondi
lastTimeNs = now;
update(dt);
gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
gl.glLoadIdentity();
// Disegna oggetti di gioco
playerPaddle.draw(gl);
cpuPaddle.draw(gl);
ball.draw(gl);
}
private void update(float dt) {
// Aggiorna la pallina
ball.update(dt);
// Rimbalzi sui bordi sinistro e destro
if (ball.x - ball.radius < worldLeft) {
ball.x = worldLeft + ball.radius;
ball.bounceHorizontal();
} else if (ball.x + ball.radius > worldRight) {
ball.x = worldRight - ball.radius;
ball.bounceHorizontal();
}
// Controllo collisione con racchette
if (playerPaddle.collidesWith(ball) && ball.vy < 0) {
ball.bounceVertical();
// Aggiungi un po' di variazione in base alla posizione di impatto
ball.vx += (ball.x - playerPaddle.x) * 0.5f;
}
if (cpuPaddle.collidesWith(ball) && ball.vy > 0) {
ball.bounceVertical();
ball.vx += (ball.x - cpuPaddle.x) * 0.5f;
}
// Rimbalzo su bordi alto/basso (reset se esce per semplicità)
if (ball.y - ball.radius < worldBottom) {
resetBall(-1);
} else if (ball.y + ball.radius > worldTop) {
resetBall(1);
}
// IA semplice: la CPU segue la X della pallina
float targetX = ball.x;
float dx = targetX - cpuPaddle.x;
float maxMove = cpuPaddle.speed * dt;
if (Math.abs(dx) > maxMove) {
dx = Math.signum(dx) * maxMove;
}
float newCpuX = cpuPaddle.x + dx;
newCpuX = clamp(newCpuX, worldLeft + 0.2f, worldRight - 0.2f);
cpuPaddle.setPosition(newCpuX, cpuPaddle.y);
}
private void resetBall(int direction) {
ball.x = 0f;
ball.y = 0f;
ball.vx = 0.6f * direction;
ball.vy = 0.8f * direction;
}
private float clamp(float v, float min, float max) {
return Math.max(min, Math.min(max, v));
}
// Chiamato dalla MainActivity quando l'utente tocca lo schermo
public void onTouch(float screenX) {
// Convertiamo la coordinata di schermo in mondo [-1, 1]
// Qui assumiamo una trasformazione semplice: normalizziamo rispetto alla larghezza.
// Per semplicità, supponiamo che la larghezza dello schermo sia mappata in [-1, 1].
// In un progetto reale, passeresti anche la larghezza dello schermo
// o useresti un tocco relativo (0..1). Qui semplifichiamo:
float normalizedX = (screenX / 1080f) * 2f - 1f; // 1080 come esempio
normalizedX = clamp(normalizedX, worldLeft + 0.2f, worldRight - 0.2f);
playerPaddle.setPosition(normalizedX, playerPaddle.y);
}
}
Nota: per una gestione precisa del touch, passa la larghezza reale dello schermo al renderer o calcola normalizedX usando un fattore derivato in onSurfaceChanged().
5. Mini app completa e prossimi passi
Con questi quattro file Java (MainActivity, GameRenderer, Paddle, Ball) hai una versione funzionante di Pong single player contro una semplice IA. Puoi estendere il progetto aggiungendo punteggi, suoni, menu, livelli di difficoltà e migliorando la fisica della pallina.
Come prossimo passo, prova a:
- Implementare un sistema di punteggio e reset della partita.
- Aggiungere effetti grafici (colori, linee centrali, animazioni).
- Ottimizzare il controllo touch (drag continuo, multi-touch).
- Portare la logica su OpenGL ES 2.0 con shader (vertex/fragment) per avere più controllo sul rendering.