App Tutorial

Gioco Pong su Android con OpenGL ES – Guida Completa in Java

Sviluppare Pong per Android con OpenGL ES 2.0 e Java

In questa guida completa vedremo come sviluppare una versione semplificata ma completa del classico gioco Pong per Android, utilizzando OpenGL ES 2.0 e Java. L’obiettivo è fornire un punto di partenza solido per sviluppatori Android che vogliono imparare le basi della grafica 2D con OpenGL ES, la struttura di un game loop e la gestione dell’input touch.

1. Introduzione: perché Pong è un ottimo progetto didattico

Pong, pubblicato originariamente nel 1972, è uno dei primi videogiochi arcade della storia. Il gameplay è estremamente semplice: due racchette che si muovono verticalmente ai bordi dello schermo e una palla che rimbalza tra loro. Proprio questa semplicità lo rende un progetto ideale per imparare:

  • come strutturare un game loop su Android;
  • come disegnare primitive 2D con OpenGL ES 2.0;
  • come gestire input touch per controllare la racchetta del giocatore;
  • come implementare una semplice IA per l’avversario;
  • come organizzare il codice in classi responsabili (renderer, entità di gioco, utility).

Alla fine di questa guida avrai un prototipo giocabile di Pong e una base solida su cui costruire giochi 2D più complessi.

2. Cosa serve per iniziare

Per seguire questa guida ti servono i seguenti prerequisiti:

  • Android Studio (versione recente, ad esempio Flamingo o successiva);
  • SDK minimo: API 21 (Android 5.0) o superiore;
  • Conoscenza di base di Java e dei concetti base di Android (Activity, ciclo di vita, viste).

Quando crei il progetto in Android Studio, scegli un Empty Activity e assicurati che il Minimum SDK sia almeno 21.

2.1 Dipendenze Gradle

OpenGL ES 2.0 è già integrato nell’SDK Android tramite la classe android.opengl.GLES20, quindi non servono librerie esterne. Verifica soltanto che nel tuo AndroidManifest.xml sia dichiarato il requisito di OpenGL ES 2.0:

```xml
<uses-feature
    android:glEsVersion="0x00020000"
    android:required="true" />
```

Nel file build.gradle (Module: app) assicurati solo di avere il plugin Android classico e il supporto Java 8 o superiore:

```gradle
android {
    compileSdkVersion 34
    defaultConfig {
        applicationId "com.example.pong"
        minSdkVersion 21
        targetSdkVersion 34
        versionCode 1
        versionName "1.0"
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}
```

3. Cos’è OpenGL ES su Android

OpenGL ES (Open Graphics Library for Embedded Systems) è una API grafica pensata per dispositivi mobili e embedded. Su Android, la versione 2.0 introduce il modello programmabile basato su shader (vertex e fragment shader), che consente un controllo fine sul rendering.

3.1 GLSurfaceView e Renderer

Per usare OpenGL ES in modo semplice su Android, si utilizza la coppia GLSurfaceView + GLSurfaceView.Renderer:

  • GLSurfaceView è una vista specializzata che contiene una superficie OpenGL;
  • Renderer è un’interfaccia che fornisce i callback per gestire il ciclo di vita del rendering: onSurfaceCreated, onSurfaceChanged, onDrawFrame.

3.2 Coordinate NDC e VBO

OpenGL ES 2.0 lavora in uno spazio di coordinate chiamato NDC (Normalized Device Coordinates), in cui:

  • x va da -1.0 a +1.0 (sinistra → destra)
  • y va da -1.0 a +1.0 (basso → alto)

Per disegnare oggetti usiamo array di vertici (float) memorizzati in FloatBuffer e, opzionalmente, in VBO (Vertex Buffer Object) sul lato GPU. In questa guida useremo principalmente FloatBuffer diretti, più facili da capire all’inizio.

3.3 Configurazione base di GLSurfaceView (Java)

Un esempio minimale di configurazione di un GLSurfaceView con un renderer in Java può essere il seguente:

```java
public class SimpleGLView extends GLSurfaceView {
    public SimpleGLView(Context context) {
        super(context);
        // Indichiamo che vogliamo usare OpenGL ES 2.0
        setEGLContextClientVersion(2);
        // Impostiamo il renderer personalizzato
        setRenderer(new SimpleRenderer());
        // Render continuo (adatto per i giochi)
        setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);
    }
    private static class SimpleRenderer implements GLSurfaceView.Renderer {
        @Override
        public void onSurfaceCreated(GL10 gl, EGLConfig config) {
            // Imposta il colore di sfondo (RGBA)
            GLES20.glClearColor(0f, 0f, 0f, 1f);
        }
        @Override
        public void onSurfaceChanged(GL10 gl, int width, int height) {
            // Definisce l'area di disegno
            GLES20.glViewport(0, 0, width, height);
        }
        @Override
        public void onDrawFrame(GL10 gl) {
            // Pulisce il buffer colore
            GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
        }
    }
}
```

4. Architettura del progetto Pong

Organizzeremo il progetto in diverse classi per mantenere il codice leggibile e manutenibile:

  • MainActivity.java: Activity principale, imposta la finestra a schermo intero e mostra la GameView.
  • GameView.java: estende GLSurfaceView, configura OpenGL ES 2.0, collega il GameRenderer e gestisce l’input touch del giocatore.
  • GameRenderer.java: implementa GLSurfaceView.Renderer, contiene il game loop, aggiorna logica e disegna la scena.
  • Ball.java: rappresenta la palla (posizione, velocità, disegno, aggiornamento e rimbalzi).
  • Paddle.java: rappresenta una racchetta (posizione, dimensioni, disegno, collisione con la palla).
  • ShaderUtils.java: classe di utilità per compilare e collegare shader OpenGL ES 2.0.

Vediamo ora l’implementazione passo per passo di ciascuna classe.

5. Implementazione passo per passo

5.a GameView.java

GameView estende GLSurfaceView, imposta il renderer e gestisce il tocco del dito sullo schermo per muovere la racchetta del giocatore.

```java
package com.example.pong;
import android.content.Context;
import android.opengl.GLSurfaceView;
import android.view.MotionEvent;
public class GameView extends GLSurfaceView {
    private final GameRenderer renderer;
    public GameView(Context context) {
        super(context);
        // Usiamo OpenGL ES 2.0
        setEGLContextClientVersion(2);
        renderer = new GameRenderer(context);
        setRenderer(renderer);
        // Render continuo per avere un game loop costante
        setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);
    }
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // Otteniamo la coordinata Y del tocco per muovere la racchetta del giocatore
        float y = event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_MOVE:
                // Passiamo il valore normalizzato al renderer
                renderer.onPlayerTouch(y, getHeight());
                return true;
        }
        return super.onTouchEvent(event);
    }
}
```

5.b GameRenderer.java

GameRenderer implementa l’interfaccia GLSurfaceView.Renderer. Qui gestiamo:

  • inizializzazione di OpenGL ES (colore di sfondo, shader, ecc.);
  • creazione di Ball e Paddle;
  • aggiornamento logico ad ogni frame (movimento, collisioni, punteggio);
  • disegno degli oggetti.
```java
package com.example.pong;
import android.content.Context;
import android.opengl.GLES20;
import android.opengl.GLSurfaceView;
import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;
public class GameRenderer implements GLSurfaceView.Renderer {
    private final Context context;
    // Entità di gioco
    private Ball ball;
    private Paddle playerPaddle;
    private Paddle aiPaddle;
    // Dimensioni dello schermo in NDC
    private float aspectRatio = 1.0f;
    // Punteggi
    private int playerScore = 0;
    private int aiScore = 0;
    // Velocità massima dell'IA
    private static final float AI_SPEED = 0.02f;
    // Posizione desiderata della racchetta del giocatore (in NDC)
    private float playerTargetY = 0f;
    public GameRenderer(Context context) {
        this.context = context;
    }
    @Override
    public void onSurfaceCreated(GL10 gl, EGLConfig config) {
        // Impostiamo il colore di sfondo (nero)
        GLES20.glClearColor(0f, 0f, 0f, 1f);
        // Abilitiamo il blending per eventuale trasparenza (non strettamente necessario qui)
        GLES20.glEnable(GLES20.GL_BLEND);
        GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA);
        // Inizializziamo entità di gioco con coordinate NDC
        // Racchette: un rettangolo stretto in orizzontale e alto in verticale
        float paddleWidth = 0.03f;
        float paddleHeight = 0.3f;
        playerPaddle = new Paddle(-0.9f, 0f, paddleWidth, paddleHeight); // lato sinistro
        aiPaddle = new Paddle(0.9f, 0f, paddleWidth, paddleHeight);      // lato destro
        // Palla al centro con velocità iniziale
        ball = new Ball(0f, 0f, 0.04f, 0.02f, 0.03f);
    }
    @Override
    public void onSurfaceChanged(GL10 gl, int width, int height) {
        // Aggiorniamo il viewport
        GLES20.glViewport(0, 0, width, height);
        // Calcoliamo l'aspect ratio (larghezza / altezza)
        aspectRatio = (float) width / (float) height;
        // Aggiorniamo le entità se necessario (in questo caso usiamo NDC, quindi non serve molto)
    }
    @Override
    public void onDrawFrame(GL10 gl) {
        // Puliamo lo schermo
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
        // Aggiorniamo logica di gioco
        updateGame();
        // Disegniamo gli oggetti
        ball.draw();
        playerPaddle.draw();
        aiPaddle.draw();
    }
    private void updateGame() {
        // Aggiorniamo posizione racchetta giocatore verso il target
        updatePlayerPaddle();
        // Aggiorniamo IA
        updateAI();
        // Aggiorniamo palla
        ball.update();
        // Controlliamo collisioni con le racchette
        if (playerPaddle.checkCollision(ball)) {
            ball.bounceX();
        }
        if (aiPaddle.checkCollision(ball)) {
            ball.bounceX();
        }
        // Controlliamo se la palla esce dallo schermo ai lati (punto)
        if (ball.getX() < -1.1f) { // punto per l'IA
            aiScore++;
            resetBall(false);
        } else if (ball.getX() > 1.1f) { // punto per il giocatore
            playerScore++;
            resetBall(true);
        }
    }
    private void updatePlayerPaddle() {
        // Muoviamo dolcemente la racchetta verso la posizione target
        float dy = playerTargetY - playerPaddle.getCenterY();
        float maxStep = 0.08f; // velocità massima della racchetta del giocatore
        if (Math.abs(dy) > maxStep) {
            dy = Math.signum(dy) * maxStep;
        }
        playerPaddle.moveBy(0f, dy);
    }
    private void updateAI() {
        // IA molto semplice: muove la racchetta verso la palla con velocità limitata
        float targetY = ball.getY();
        float dy = targetY - aiPaddle.getCenterY();
        if (Math.abs(dy) > AI_SPEED) {
            dy = Math.signum(dy) * AI_SPEED;
        }
        aiPaddle.moveBy(0f, dy);
    }
    private void resetBall(boolean playerScored) {
        // Rimettiamo la palla al centro e invertiamo la direzione orizzontale a seconda di chi ha segnato
        float direction = playerScored ? -1f : 1f;
        ball.reset(0f, 0f, direction);
    }
    /**
     * Metodo chiamato da GameView quando il giocatore tocca lo schermo.
     * @param touchY coordinata Y in pixel
     * @param viewHeight altezza della vista in pixel
     */
    public void onPlayerTouch(float touchY, int viewHeight) {
        // Normalizziamo touchY (0 in alto, viewHeight in basso) in coordinate NDC (-1 in basso, +1 in alto)
        float normalizedY = 1f - 2f * (touchY / (float) viewHeight);
        playerTargetY = normalizedY;
    }
    public int getPlayerScore() {
        return playerScore;
    }
    public int getAiScore() {
        return aiScore;
    }
}
```

5.c Ball.java

Ball gestisce posizione, velocità, logica di rimbalzo sui bordi e disegno di un quadrato che rappresenta la palla. Usiamo un semplice shader che colora il quadrato di bianco.

```java
package com.example.pong;
import android.opengl.GLES20;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
public class Ball {
    private float x;
    private float y;
    private float radius;
    private float vx;
    private float vy;
    private final FloatBuffer vertexBuffer;
    private final int program;
    // Attributi shader
    private int positionHandle;
    private int colorHandle;
    private int offsetHandle;
    // Coordinate di un quadrato centrato in (0,0)
    private static final float[] VERTICES = {
            -1f, -1f,
             1f, -1f,
            -1f,  1f,
             1f,  1f
    };
    public Ball(float x, float y, float radius, float vx, float vy) {
        this.x = x;
        this.y = y;
        this.radius = radius;
        this.vx = vx;
        this.vy = vy;
        // Creiamo il buffer dei vertici (quadrato unitario, scalato nello shader)
        ByteBuffer bb = ByteBuffer.allocateDirect(VERTICES.length * 4);
        bb.order(ByteOrder.nativeOrder());
        vertexBuffer = bb.asFloatBuffer();
        vertexBuffer.put(VERTICES);
        vertexBuffer.position(0);
        // Creiamo gli shader
        String vertexShaderCode =
                "attribute vec2 a_Position;\n" +
                "uniform vec2 u_Offset;\n" +
                "uniform float u_Radius;\n" +
                "void main() {\n" +
                "    vec2 scaledPosition = a_Position * u_Radius;\n" +
                "    gl_Position = vec4(scaledPosition + u_Offset, 0.0, 1.0);\n" +
                "}";
        String fragmentShaderCode =
                "precision mediump float;\n" +
                "uniform vec4 u_Color;\n" +
                "void main() {\n" +
                "    gl_FragColor = u_Color;\n" +
                "}";
        int vertexShader = ShaderUtils.compileVertexShader(vertexShaderCode);
        int fragmentShader = ShaderUtils.compileFragmentShader(fragmentShaderCode);
        program = ShaderUtils.linkProgram(vertexShader, fragmentShader);
    }
    public void draw() {
        GLES20.glUseProgram(program);
        // Otteniamo gli handle
        positionHandle = GLES20.glGetAttribLocation(program, "a_Position");
        colorHandle = GLES20.glGetUniformLocation(program, "u_Color");
        offsetHandle = GLES20.glGetUniformLocation(program, "u_Offset");
        int radiusHandle = GLES20.glGetUniformLocation(program, "u_Radius");
        // Passiamo i dati dei vertici
        GLES20.glEnableVertexAttribArray(positionHandle);
        GLES20.glVertexAttribPointer(positionHandle, 2, GLES20.GL_FLOAT, false, 0, vertexBuffer);
        // Colore bianco
        GLES20.glUniform4f(colorHandle, 1f, 1f, 1f, 1f);
        // Offset (posizione della palla)
        GLES20.glUniform2f(offsetHandle, x, y);
        // Raggio
        GLES20.glUniform1f(radiusHandle, radius);
        // Disegniamo il quadrato come strip di triangoli
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
        GLES20.glDisableVertexAttribArray(positionHandle);
    }
    public void update() {
        // Aggiorniamo la posizione
        x += vx;
        y += vy;
        // Rimbalzo sui bordi superiore e inferiore dello schermo (NDC -1..1)
        if (y + radius > 1f) {
            y = 1f - radius;
            vy = -vy;
        } else if (y - radius < -1f) {
            y = -1f + radius;
            vy = -vy;
        }
    }
    public void bounceX() {
        vx = -vx;
    }
    public float getX() {
        return x;
    }
    public float getY() {
        return y;
    }
    public float getRadius() {
        return radius;
    }
    public void reset(float startX, float startY, float direction) {
        this.x = startX;
        this.y = startY;
        this.vx = Math.abs(vx) * direction; // mantiene la velocità ma cambia direzione
        this.vy = Math.abs(vy) * (Math.random() > 0.5 ? 1f : -1f);
    }
}
```

5.d Paddle.java

Paddle rappresenta una racchetta. Anche qui utilizziamo un semplice quadrato allungato e lo disegniamo con OpenGL ES 2.0. Implementiamo anche un semplice controllo di collisione AABB (Axis-Aligned Bounding Box) con la palla.

```java
package com.example.pong;
import android.opengl.GLES20;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
public class Paddle {
    private float centerX;
    private float centerY;
    private float halfWidth;
    private float halfHeight;
    private final FloatBuffer vertexBuffer;
    private final int program;
    private int positionHandle;
    private int colorHandle;
    private int offsetHandle;
    private int scaleHandle;
    // Vertici di un quadrato unitario centrato in (0,0)
    private static final float[] VERTICES = {
            -1f, -1f,
             1f, -1f,
            -1f,  1f,
             1f,  1f
    };
    public Paddle(float centerX, float centerY, float width, float height) {
        this.centerX = centerX;
        this.centerY = centerY;
        this.halfWidth = width;
        this.halfHeight = height;
        ByteBuffer bb = ByteBuffer.allocateDirect(VERTICES.length * 4);
        bb.order(ByteOrder.nativeOrder());
        vertexBuffer = bb.asFloatBuffer();
        vertexBuffer.put(VERTICES);
        vertexBuffer.position(0);
        String vertexShaderCode =
                "attribute vec2 a_Position;\n" +
                "uniform vec2 u_Offset;\n" +
                "uniform vec2 u_Scale;\n" +
                "void main() {\n" +
                "    vec2 scaled = a_Position * u_Scale;\n" +
                "    gl_Position = vec4(scaled + u_Offset, 0.0, 1.0);\n" +
                "}";
        String fragmentShaderCode =
                "precision mediump float;\n" +
                "uniform vec4 u_Color;\n" +
                "void main() {\n" +
                "    gl_FragColor = u_Color;\n" +
                "}";
        int vertexShader = ShaderUtils.compileVertexShader(vertexShaderCode);
        int fragmentShader = ShaderUtils.compileFragmentShader(fragmentShaderCode);
        program = ShaderUtils.linkProgram(vertexShader, fragmentShader);
    }
    public void draw() {
        GLES20.glUseProgram(program);
        positionHandle = GLES20.glGetAttribLocation(program, "a_Position");
        offsetHandle = GLES20.glGetUniformLocation(program, "u_Offset");
        scaleHandle = GLES20.glGetUniformLocation(program, "u_Scale");
        colorHandle = GLES20.glGetUniformLocation(program, "u_Color");
        GLES20.glEnableVertexAttribArray(positionHandle);
        GLES20.glVertexAttribPointer(positionHandle, 2, GLES20.GL_FLOAT, false, 0, vertexBuffer);
        // Colore bianco
        GLES20.glUniform4f(colorHandle, 1f, 1f, 1f, 1f);
        // Offset (posizione della racchetta)
        GLES20.glUniform2f(offsetHandle, centerX, centerY);
        // Scala (mezze dimensioni)
        GLES20.glUniform2f(scaleHandle, halfWidth, halfHeight);
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
        GLES20.glDisableVertexAttribArray(positionHandle);
    }
    public boolean checkCollision(Ball ball) {
        float ballX = ball.getX();
        float ballY = ball.getY();
        float r = ball.getRadius();
        float left = centerX - halfWidth;
        float right = centerX + halfWidth;
        float bottom = centerY - halfHeight;
        float top = centerY + halfHeight;
        // Collisione AABB semplice
        boolean collisionX = ballX + r > left && ballX - r < right;
        boolean collisionY = ballY + r > bottom && ballY - r < top;
        return collisionX && collisionY;
    }
    public void moveBy(float dx, float dy) {
        centerX += dx;
        centerY += dy;
        // Manteniamo la racchetta nello schermo (limiti NDC)
        if (centerY + halfHeight > 1f) {
            centerY = 1f - halfHeight;
        } else if (centerY - halfHeight < -1f) {
            centerY = -1f + halfHeight;
        }
    }
    public float getCenterY() {
        return centerY;
    }
}
```

5.e ShaderUtils.java

ShaderUtils racchiude la logica per compilare e collegare shader GLSL, restituendo un int che rappresenta il programma OpenGL ES 2.0.

```java
package com.example.pong;
import android.opengl.GLES20;
import android.util.Log;
public class ShaderUtils {
    private static final String TAG = "ShaderUtils";
    public static int compileVertexShader(String shaderCode) {
        return compileShader(GLES20.GL_VERTEX_SHADER, shaderCode);
    }
    public static int compileFragmentShader(String shaderCode) {
        return compileShader(GLES20.GL_FRAGMENT_SHADER, shaderCode);
    }
    private static int compileShader(int type, String shaderCode) {
        final int shaderId = GLES20.glCreateShader(type);
        if (shaderId == 0) {
            Log.e(TAG, "Impossibile creare shader");
            return 0;
        }
        GLES20.glShaderSource(shaderId, shaderCode);
        GLES20.glCompileShader(shaderId);
        final int[] compileStatus = new int[1];
        GLES20.glGetShaderiv(shaderId, GLES20.GL_COMPILE_STATUS, compileStatus, 0);
        if (compileStatus[0] == 0) {
            Log.e(TAG, "Errore compilazione shader: " + GLES20.glGetShaderInfoLog(shaderId));
            GLES20.glDeleteShader(shaderId);
            return 0;
        }
        return shaderId;
    }
    public static int linkProgram(int vertexShaderId, int fragmentShaderId) {
        final int programId = GLES20.glCreateProgram();
        if (programId == 0) {
            Log.e(TAG, "Impossibile creare programma");
            return 0;
        }
        GLES20.glAttachShader(programId, vertexShaderId);
        GLES20.glAttachShader(programId, fragmentShaderId);
        GLES20.glLinkProgram(programId);
        final int[] linkStatus = new int[1];
        GLES20.glGetProgramiv(programId, GLES20.GL_LINK_STATUS, linkStatus, 0);
        if (linkStatus[0] == 0) {
            Log.e(TAG, "Errore link programma: " + GLES20.glGetProgramInfoLog(programId));
            GLES20.glDeleteProgram(programId);
            return 0;
        }
        return programId;
    }
}
```

5.f MainActivity.java

MainActivity imposta l’app a schermo intero e mostra la GameView come contenuto principale.

```java
package com.example.pong;
import android.app.Activity;
import android.os.Bundle;
import android.view.Window;
import android.view.WindowManager;
public class MainActivity extends Activity {
    private GameView gameView;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // Schermo intero senza title bar
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
                WindowManager.LayoutParams.FLAG_FULLSCREEN);
        gameView = new GameView(this);
        setContentView(gameView);
    }
    @Override
    protected void onPause() {
        super.onPause();
        gameView.onPause();
    }
    @Override
    protected void onResume() {
        super.onResume();
        gameView.onResume();
    }
}
```

6. Gestione del punteggio

Nel codice del GameRenderer abbiamo introdotto due variabili playerScore e aiScore che vengono aggiornate ogni volta che la palla esce dai bordi sinistro o destro dello schermo. Per visualizzare il punteggio ci sono due approcci principali:

  • usare un overlay Canvas sopra la superficie OpenGL;
  • usare una TextView sovrapposta tramite layout XML.

6.1 TextView sovrapposta (approccio consigliato)

Il modo più semplice e flessibile è creare un layout XML contenente un FrameLayout con dentro la GameView e una TextView sopra di essa. Ad esempio in activity_main.xml:

```xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <com.example.pong.GameView
        android:id="@+id/game_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
    <TextView
        android:id="@+id/score_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="top|center_horizontal"
        android:textColor="#FFFFFF"
        android:textSize="24sp"
        android:text="0 : 0" />
</FrameLayout>
```

In MainActivity puoi quindi instanziare la GameView dal layout e aggiornare periodicamente la TextView (ad esempio usando un Handler o sfruttando un callback dal renderer). Qui un esempio semplice con Handler:

```java
public class MainActivity extends Activity {
    private GameView gameView;
    private TextView scoreText;
    private Handler handler = new Handler(Looper.getMainLooper());
    private final Runnable scoreUpdater = new Runnable() {
        @Override
        public void run() {
            if (gameView != null) {
                GameRenderer renderer = gameView.getRenderer();
                if (renderer != null) {
                    int player = renderer.getPlayerScore();
                    int ai = renderer.getAiScore();
                    scoreText.setText(player + " : " + ai);
                }
            }
            handler.postDelayed(this, 200); // aggiorna ogni 200 ms
        }
    };
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(Window.FEATURE_NO_TITLE);
        getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
                WindowManager.LayoutParams.FLAG_FULLSCREEN);
        setContentView(R.layout.activity_main);
        gameView = findViewById(R.id.game_view);
        scoreText = findViewById(R.id.score_text);
        handler.post(scoreUpdater);
    }
    public GameRenderer getRenderer() {
        return gameView != null ? gameView.getRenderer() : null;
    }
}
```

Naturalmente dovrai aggiungere un metodo getRenderer() in GameView che restituisca l’istanza del renderer.

7. IA avversaria semplice

L’IA implementata nel metodo updateAI() di GameRenderer è volutamente semplice: la racchetta dell’avversario si limita a seguire la posizione verticale della palla, con una velocità massima per evitare movimenti istantanei e rendere il gioco più umano.

```java
private void updateAI() {
    // Target verticale: la Y della palla
    float targetY = ball.getY();
    // Differenza tra target e posizione attuale
    float dy = targetY - aiPaddle.getCenterY();
    // Limitiamo la velocità
    if (Math.abs(dy) > AI_SPEED) {
        dy = Math.signum(dy) * AI_SPEED;
    }
    // Muoviamo la racchetta
    aiPaddle.moveBy(0f, dy);
}
```

Per rendere il gioco più interessante puoi:

  • aggiungere un fattore di errore (offset casuale rispetto alla posizione della palla);
  • ridurre la velocità dell’IA quando il giocatore ha pochi punti di svantaggio;
  • aumentare la velocità dell’IA con l’aumentare del punteggio del giocatore.

8. Ottimizzazioni e suggerimenti

Una volta che il prototipo di Pong è funzionante, puoi iniziare a ottimizzarlo per ottenere un framerate stabile (idealmente 60 FPS) e ridurre l’uso della memoria.

  • Usa FloatBuffer diretti: come abbiamo fatto, crea i buffer con ByteBuffer.allocateDirect(...) e order(ByteOrder.nativeOrder()). Questo permette alla JVM di allocare memoria nativa, più efficiente per la GPU.
  • Evita allocazioni nel game loop: non creare nuovi oggetti dentro onDrawFrame(), update() o altri metodi chiamati ad ogni frame. Riutilizza array e oggetti esistenti.
  • Batching e VBO: per giochi più complessi con molti oggetti, valuta l’uso di VBO (Vertex Buffer Object) e di tecniche di batching per ridurre le chiamate a glDrawArrays.
  • Profilazione: usa gli strumenti di Android Studio (Profiler) per monitorare FPS, CPU e memoria, identificando colli di bottiglia.
  • Delta time: per un movimento più fluido e indipendente dal frame rate, puoi usare un delta time (tempo trascorso tra un frame e l’altro) nei calcoli di update della fisica, invece di usare velocità “per frame”.

9. Conclusione e passi successivi

In questa guida abbiamo visto come creare, passo per passo, una versione base di Pong per Android utilizzando OpenGL ES 2.0 e Java. Hai imparato a:

  • configurare un GLSurfaceView e un Renderer;
  • disegnare primitive 2D tramite shader e FloatBuffer;
  • implementare un semplice game loop e la logica di gioco (movimento, rimbalzi, collisioni);
  • gestire l’input touch per controllare la racchetta del giocatore;
  • aggiungere una semplice IA avversaria e un sistema di punteggio.

Per continuare ad ampliare le tue competenze, puoi esplorare:

  • Effetti sonori con SoundPool o altre librerie audio, per rimbalzi e punti segnati;
  • Fisica migliorata: angoli di rimbalzo dipendenti dal punto di impatto sulla racchetta, attrito, accelerazioni progressive della palla;
  • Grafica avanzata: texture, interpolazioni, animazioni, sfondi dinamici;
  • Multiplayer locale (sullo stesso dispositivo) o online (via rete), con sincronizzazione dello stato di gioco;
  • migrazione a Kotlin e utilizzo di API più moderne mantenendo la stessa architettura.

Partendo da questo Pong, hai tutte le basi per costruire il tuo piccolo motore 2D personalizzato su Android con OpenGL ES.