pac-man android tutorial con OpenGL ES e Java

Mini guida: creare Pac-Man su Android con OpenGL ES 2.0 e Java

In questa pagina realizziamo anzitutto una mini app Pac-Man 2D single-player per dispositivi mobili, un vero e proprio pac-man android tutorial, disegnata con OpenGL ES 2.0 e Java. L’obiettivo principale è avere un progetto minimo ma completo: labirinto, Pac-Man, fantasmi, shader basilari per cerchi e rettangoli colorati, logica di movimento e collisioni.

OpenGL ES 2.0 è lo standard grafico per dispositivi mobili a basso consumo energetico. A differenza delle API di alto livello come Canvas, permette di sfruttare la GPU direttamente tramite shader GLSL, garantendo fluidità anche su hardware modesto e un controllo granulare su ogni pixel disegnato. Inoltre, è la tecnologia alla base di questo pac-man android tutorial. Per maggiori dettagli, consulta la documentazione ufficiale OpenGL ES.

Prerequisiti per questo pac-man android tutorial

  • Android Studio (API 18+ consigliata)
  • Conoscenze base di Java e Android (Activity, View)
  • Nozioni minime di OpenGL ES 2.0 (shader, draw call)

Struttura del progetto pac-man android

  • MainActivity: crea la GLSurfaceView e avvia il gioco
  • GameView: estende GLSurfaceView, inoltra input touch
  • GameRenderer: implementa GLSurfaceView.Renderer, game loop, disegno
  • Maze: definisce la griglia del labirinto, pallini e power-up
  • PacMan: stato del giocatore, movimento e animazione bocca
  • Ghost: logica dei fantasmi con modalità chase/scatter/frightened
  • ShaderUtils: compilazione/link dei programmi shader (cerchi/rettangoli)

Le classi comunicano in modo lineare: MainActivity crea GameView, che a sua volta istanzia GameRenderer. Ad ogni frame, onDrawFrame() aggiorna lo stato del gioco (pacMan, ghost, maze) e successivamente disegna tutti gli elementi. In particolare, l’input touch risale da GameView al renderer tramite queueEvent, garantendo così la sincronizzazione con il thread OpenGL.

MainActivity.java (setup GLSurfaceView)

package com.example.pacmanopengl;

import android.app.Activity;
import android.opengl.GLSurfaceView;
import android.os.Bundle;

public class MainActivity extends Activity {
    private GameView gameView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        gameView = new GameView(this);
        setContentView(gameView);
    }

    @Override
    protected void onPause() {
        super.onPause();
        gameView.onPause();
    }

    @Override
    protected void onResume() {
        super.onResume();
        gameView.onResume();
    }
}

GameView.java (GLSurfaceView + input touch)

package com.example.pacmanopengl;

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);

        // Usa OpenGL ES 2.0
        setEGLContextClientVersion(2);

        renderer = new GameRenderer(context);
        setRenderer(renderer);

        // Rende costantemente i frame per avere un game loop semplice
        setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (event.getAction() == MotionEvent.ACTION_DOWN ||
                event.getAction() == MotionEvent.ACTION_MOVE) {
            final float x = event.getX();
            final float y = event.getY();

            queueEvent(new Runnable() {
                @Override
                public void run() {
                    renderer.handleTouch(x, y, getWidth(), getHeight());
                }
            });
        }
        return true;
    }
}

RENDERMODE_CONTINUOUSLY fa sì che onDrawFrame() venga chiamato continuamente, comportandosi come un game loop. L’uso di queueEvent() è fondamentale: infatti, l’interfaccia touch gira sul Thread Android in Android Studio, mentre il rendering avviene su un thread separato; pertanto, senza questa sincronizzazione si andrebbero a leggere dati in race condition.

Maze.java (labirinto, pallini, power-up)

package com.example.pacmanopengl;

public class Maze {

    public static final int EMPTY = 0;
    public static final int WALL = 1;
    public static final int DOT = 2;
    public static final int POWERUP = 3;

    private final int rows;
    private final int cols;
    private final int[][] grid;

    // Coordinate di spawn
    public int pacStartRow = 17, pacStartCol = 13;
    public int ghostStartRow = 11, ghostStartCol = 13;

    public Maze() {
        // Labirinto molto semplificato 21x27 (colonne x righe)
        rows = 21;
        cols = 21;
        grid = new int[rows][cols];
        initMaze();
    }

    private void initMaze() {
        // Bordo esterno di muri e interno con pallini
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                if (r == 0 || c == 0 || r == rows - 1 || c == cols - 1) {
                    grid[r][c] = WALL;
                } else {
                    grid[r][c] = DOT;
                }
            }
        }
        // Qualche muro interno grezzo
        for (int c = 3; c < cols - 3; c++) {
            grid[5][c] = WALL;
            grid[15][c] = WALL;
        }
        for (int r = 3; r < rows - 3; r++) {
            grid[r][7] = WALL;
            grid[r][13] = WALL;
        }
        // Power-up agli angoli interni
        grid[2][2] = POWERUP;
        grid[2][cols - 3] = POWERUP;
        grid[rows - 3][2] = POWERUP;
        grid[rows - 3][cols - 3] = POWERUP;
    }

    public int getRows() {
        return rows;
    }

    public int getCols() {
        return cols;
    }

    public int getCell(int row, int col) {
        if (row < 0 || col < 0 || row >= rows || col >= cols) return WALL;
        return grid[row][col];
    }

    public void eatDot(int row, int col) {
        if (grid[row][col] == DOT || grid[row][col] == POWERUP) {
            grid[row][col] = EMPTY;
        }
    }

    public boolean isWall(int row, int col) {
        return getCell(row, col) == WALL;
    }
}


PacMan.java (movimento, animazione, input touch)

package com.example.pacmanopengl;

public class PacMan {

    public float row, col; // posizione in coordinate di cella
    public int dirRow = 0, dirCol = 0; // direzione corrente
    public int nextDirRow = 0, nextDirCol = 0; // direzione desiderata

    private float speed = 5.0f; // celle al secondo
    private float mouthPhase = 0f; // per animazione bocca

    public PacMan(float startRow, float startCol) {
        this.row = startRow;
        this.col = startCol;
    }

    public void update(float deltaTime, Maze maze) {
        mouthPhase += deltaTime * 6f; // animazione veloce
        // Tenta di cambiare direzione se possibile (solo su centro cella)
        if (isCenteredInCell()) {
            if (canMove(nextDirRow, nextDirCol, maze)) {
                dirRow = nextDirRow;
                dirCol = nextDirCol;
            }
        }
        // Muovi Pac-Man
        float newRow = row + dirRow * speed * deltaTime;
        float newCol = col + dirCol * speed * deltaTime;

        if (!isBlocked(newRow, newCol, maze)) {
            row = newRow;
            col = newCol;
        }

        // Mangia eventuali pallini nella cella attuale
        int cellRow = Math.round(row);
        int cellCol = Math.round(col);
        maze.eatDot(cellRow, cellCol);
    }

    private boolean isBlocked(float newRow, float newCol, Maze maze) {
        int r = Math.round(newRow);
        int c = Math.round(newCol);
        return maze.isWall(r, c);
    }

    private boolean canMove(int dRow, int dCol, Maze maze) {
        if (dRow == 0 && dCol == 0) return false;
        int targetRow = Math.round(row) + dRow;
        int targetCol = Math.round(col) + dCol;
        return !maze.isWall(targetRow, targetCol);
    }

    private boolean isCenteredInCell() {
        return Math.abs(row - Math.round(row)) < 0.05f &&
                Math.abs(col - Math.round(col)) < 0.05f;
    }

    // Gestione input: swipe semplice basato sulla posizione touch
    public void handleTouchDirection(float nx, float ny) {
        // (0,0) in alto a sinistra, normalizzato 0..1
        // Decidi direzione in base al vettore dal centro schermo
        float cx = 0.5f;
        float cy = 0.5f;
        float dx = nx - cx;
        float dy = ny - cy;

        if (Math.abs(dx) > Math.abs(dy)) {
            // movimento orizzontale
            nextDirRow = 0;
            nextDirCol = dx > 0 ? 1 : -1;
        } else {
            // movimento verticale
            nextDirRow = dy > 0 ? 1 : -1;
            nextDirCol = 0;
        }
    }

    public float getMouthOpenAmount() {
        // Valore 0..1 per apertura bocca (usato dallo shader)
        return (float) (0.5f + 0.5f * Math.sin(mouthPhase));
    }
}


Ghost.java (chase/scatter/frightened semplificato)

package com.example.pacmanopengl;

public class Ghost {

    public enum Mode {
        CHASE,
        SCATTER,
        FRIGHTENED
    }

    public float row, col;
    public int dirRow = 0, dirCol = 1;
    public Mode mode = Mode.CHASE;

    private float speed = 4.0f;
    private float modeTimer = 0f;

    // Corner di destinazione in modalità SCATTER
    private int scatterRow, scatterCol;

    public Ghost(float startRow, float startCol, int scatterRow, int scatterCol) {
        this.row = startRow;
        this.col = startCol;
        this.scatterRow = scatterRow;
        this.scatterCol = scatterCol;
    }

    public void update(float deltaTime, Maze maze, PacMan pacMan, boolean powerActive) {
        modeTimer += deltaTime;

        // Semplice alternanza CHASE/SCATTER quando non c'è power-up
        if (!powerActive) {
            if (modeTimer > 7f) {
                mode = (mode == Mode.CHASE) ? Mode.SCATTER : Mode.CHASE;
                modeTimer = 0f;
            }
        } else {
            mode = Mode.FRIGHTENED;
            modeTimer = 0f;
        }

        float targetRow, targetCol;
        switch (mode) {
            case SCATTER:
                targetRow = scatterRow;
                targetCol = scatterCol;
                break;
            case FRIGHTENED:
                // In frightened ci si allontana da Pac-Man
                targetRow = row + (row - pacMan.row);
                targetCol = col + (col - pacMan.col);
                break;
            case CHASE:
            default:
                targetRow = pacMan.row;
                targetCol = pacMan.col;
                break;
        }

        chooseDirection(targetRow, targetCol, maze);

        float newRow = row + dirRow * speed * deltaTime;
        float newCol = col + dirCol * speed * deltaTime;
        if (!isBlocked(newRow, newCol, maze)) {
            row = newRow;
            col = newCol;
        }
    }

    private void chooseDirection(float targetRow, float targetCol, Maze maze) {
        // Pathfinding estremamente semplice: prova le 4 direzioni e scegli quella che minimizza la distanza al target
        int[] dRows = { -1, 1, 0, 0 };
        int[] dCols = { 0, 0, -1, 1 };
        float bestDist = Float.MAX_VALUE;
        int bestDirIndex = 0;
        for (int i = 0; i < 4; i++) {
            int nr = Math.round(row) + dRows[i];
            int nc = Math.round(col) + dCols[i];
            if (maze.isWall(nr, nc)) continue;
            float dr = targetRow - nr;
            float dc = targetCol - nc;
            float dist = dr * dr + dc * dc;
            if (dist < bestDist) {
                bestDist = dist;
                bestDirIndex = i;
            }
        }
        dirRow = dRows[bestDirIndex];
        dirCol = dCols[bestDirIndex];
    }

    private boolean isBlocked(float newRow, float newCol, Maze maze) {
        int r = Math.round(newRow);
        int c = Math.round(newCol);
        return maze.isWall(r, c);
    }
}


Le tre modalità del fantasma simulano il comportamento classico di Pac-Man. In CHASE il fantasma insegue direttamente Pac-Man minimizzando la distanza. La modalità SCATTER, invece, lo indirizza verso un angolo fisso del labirinto, concedendo così una pausa al giocatore. Infine, quando si attiva FRIGHTENED grazie ai power-up, la logica si inverte: in questo modo il fantasma si allontana da Pac-Man e cambia colore in blu come segnale visivo.

ShaderUtils.java (shader cerchi/rettangoli)

package com.example.pacmanopengl;

import android.opengl.GLES20;

public class ShaderUtils {

    // Vertex shader: passa le coordinate 2D nello spazio -1..1
    public static final String VERTEX_SHADER =
            "attribute vec2 aPosition;\n" +
            "uniform vec2 uResolution;\n" +
            "varying vec2 vPos;\n" +
            "void main() {\n" +
            "  // Coordinate NDC direttamente da aPosition (-1..1)\n" +
            "  vPos = aPosition;\n" +
            "  gl_Position = vec4(aPosition, 0.0, 1.0);\n" +
            "}\n";

    // Fragment shader: disegna rettangoli pieni o cerchi ritagliando un quad
    public static final String FRAGMENT_SHADER =
            "precision mediump float;\n" +
            "varying vec2 vPos;\n" +
            "uniform vec4 uColor;\n" +
            "uniform int uShape; // 0 = rect, 1 = circle\n" +
            "uniform vec2 uCenter; // centro cerchio in NDC\n" +
            "uniform float uRadius; // raggio cerchio in NDC\n" +
            "void main() {\n" +
            "  if (uShape == 0) {\n" +
            "    gl_FragColor = uColor;\n" +
            "  } else {\n" +
            "    float d = distance(vPos, uCenter);\n" +
            "    if (d <= uRadius) {\n" +
            "      gl_FragColor = uColor;\n" +
            "    } else {\n" +
            "      discard;\n" +
            "    }\n" +
            "  }\n" +
            "}\n";

    public static int compileShader(int type, String source) {
        int shader = GLES20.glCreateShader(type);
        GLES20.glShaderSource(shader, source);
        GLES20.glCompileShader(shader);

        int[] compiled = new int[1];
        GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0);
        if (compiled[0] == 0) {
            String log = GLES20.glGetShaderInfoLog(shader);
            GLES20.glDeleteShader(shader);
            throw new RuntimeException("Errore compilazione shader: " + log);
        }
        return shader;
    }

    public static int createProgram(String vertexSrc, String fragmentSrc) {
        int vs = compileShader(GLES20.GL_VERTEX_SHADER, vertexSrc);
        int fs = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSrc);
        int program = GLES20.glCreateProgram();
        GLES20.glAttachShader(program, vs);
        GLES20.glAttachShader(program, fs);
        GLES20.glLinkProgram(program);

        int[] linked = new int[1];
        GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linked, 0);
        if (linked[0] == 0) {
            String log = GLES20.glGetProgramInfoLog(program);
            GLES20.glDeleteProgram(program);
            throw new RuntimeException("Errore link programma: " + log);
        }
        return program;
    }
}


GameRenderer.java (game loop, disegno, logica di gioco)

package com.example.pacmanopengl;

import android.content.Context;
import android.opengl.GLES20;
import android.opengl.GLSurfaceView;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;

import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;

public class GameRenderer implements GLSurfaceView.Renderer {

    private final Context context;

    private Maze maze;
    private PacMan pacMan;
    private Ghost ghost;

    private int program;
    private int aPositionLoc;
    private int uColorLoc;
    private int uShapeLoc;
    private int uCenterLoc;
    private int uRadiusLoc;

    private FloatBuffer quadBuffer;

    private long lastTimeNs;

    private boolean powerActive = false;
    private float powerTimer = 0f;

    private int viewWidth, viewHeight;

    public GameRenderer(Context context) {
        this.context = context;
    }

    @Override
    public void onSurfaceCreated(GL10 gl, EGLConfig config) {
        GLES20.glClearColor(0f, 0f, 0f, 1f);

        maze = new Maze();
        pacMan = new PacMan(maze.pacStartRow, maze.pacStartCol);
        ghost = new Ghost(maze.ghostStartRow, maze.ghostStartCol, 1, maze.getCols() - 2);

        program = ShaderUtils.createProgram(ShaderUtils.VERTEX_SHADER, ShaderUtils.FRAGMENT_SHADER);
        GLES20.glUseProgram(program);

        aPositionLoc = GLES20.glGetAttribLocation(program, "aPosition");
        uColorLoc = GLES20.glGetUniformLocation(program, "uColor");
        uShapeLoc = GLES20.glGetUniformLocation(program, "uShape");
        uCenterLoc = GLES20.glGetUniformLocation(program, "uCenter");
        uRadiusLoc = GLES20.glGetUniformLocation(program, "uRadius");

        // Quad in NDC che copre l'intero schermo, useremo trasformazioni per posizionare gli oggetti
        float[] quad = {
                -1f, -1f,
                1f, -1f,
                -1f, 1f,
                1f, 1f
        };
        quadBuffer = ByteBuffer.allocateDirect(quad.length * 4)
                .order(ByteOrder.nativeOrder())
                .asFloatBuffer();
        quadBuffer.put(quad).position(0);

        lastTimeNs = System.nanoTime();
    }

    @Override
    public void onSurfaceChanged(GL10 gl, int width, int height) {
        viewWidth = width;
        viewHeight = height;
        GLES20.glViewport(0, 0, width, height);
    }

    @Override
    public void onDrawFrame(GL10 gl) {
        long now = System.nanoTime();
        float deltaTime = (now - lastTimeNs) / 1_000_000_000f;
        lastTimeNs = now;

        updateGame(deltaTime);

        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
        GLES20.glUseProgram(program);

        GLES20.glEnableVertexAttribArray(aPositionLoc);
        GLES20.glVertexAttribPointer(aPositionLoc, 2, GLES20.GL_FLOAT, false, 0, quadBuffer);

        drawMaze();
        drawPacMan();
        drawGhost();

        GLES20.glDisableVertexAttribArray(aPositionLoc);
    }

    private void updateGame(float deltaTime) {
        if (powerActive) {
            powerTimer -= deltaTime;
            if (powerTimer <= 0f) {
                powerActive = false;
            }
        }

        pacMan.update(deltaTime, maze);

        // Se Pac-Man ha mangiato un power-up, attiviamo lo stato frightened
        int cellRow = Math.round(pacMan.row);
        int cellCol = Math.round(pacMan.col);
        if (maze.getCell(cellRow, cellCol) == Maze.POWERUP) {
            powerActive = true;
            powerTimer = 7f;
        }

        ghost.update(deltaTime, maze, pacMan, powerActive);

        // Collisione Pac-Man / Ghost (stessa cella)
        int ghostRow = Math.round(ghost.row);
        int ghostCol = Math.round(ghost.col);
        if (ghostRow == cellRow && ghostCol == cellCol) {
            if (powerActive) {
                // Fantasma mangiato: rispawn al centro
                ghost.row = maze.ghostStartRow;
                ghost.col = maze.ghostStartCol;
                powerActive = false;
            } else {
                // Pac-Man morto: risetta posizione di partenza
                pacMan.row = maze.pacStartRow;
                pacMan.col = maze.pacStartCol;
            }
        }
    }

    // Conversione cella -> NDC (-1..1)
    private float cellToNdcX(float col) {
        return (col / maze.getCols()) * 2f - 1f;
    }

    private float cellToNdcY(float row) {
        // Invertiamo l'asse Y per avere riga 0 in alto
        return 1f - (row / maze.getRows()) * 2f;
    }

    private void drawMaze() {
        for (int r = 0; r < maze.getRows(); r++) {
            for (int c = 0; c < maze.getCols(); c++) {
                int cell = maze.getCell(r, c);
                if (cell == Maze.WALL) {
                    // Muri come rettangoli blu
                    drawCellRect(r, c, 0.0f, 0.0f, 1.0f, 1.0f);
                } else if (cell == Maze.DOT) {
                    // Pallini come piccoli cerchi gialli
                    drawCellCircle(r, c, 1.0f, 1.0f, 0.0f, 1.0f, 0.02f);
                } else if (cell == Maze.POWERUP) {
                    // Power-up più grandi
                    drawCellCircle(r, c, 1.0f, 0.5f, 0.0f, 1.0f, 0.05f);
                }
            }
        }
    }

    private void drawPacMan() {
        float x = cellToNdcX(pacMan.col);
        float y = cellToNdcY(pacMan.row);
        float radius = 1.0f / Math.max(maze.getCols(), maze.getRows());

        // Colore giallo
        GLES20.glUniform4f(uColorLoc, 1.0f, 1.0f, 0.0f, 1.0f);
        GLES20.glUniform1i(uShapeLoc, 1);
        GLES20.glUniform2f(uCenterLoc, x, y);
        GLES20.glUniform1f(uRadiusLoc, radius * 0.9f);

        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
    }

    private void drawGhost() {
        float x = cellToNdcX(ghost.col);
        float y = cellToNdcY(ghost.row);
        float radius = 1.0f / Math.max(maze.getCols(), maze.getRows());

        if (ghost.mode == Ghost.Mode.FRIGHTENED) {
            // blu
            GLES20.glUniform4f(uColorLoc, 0.0f, 0.0f, 1.0f, 1.0f);
        } else {
            // rosso
            GLES20.glUniform4f(uColorLoc, 1.0f, 0.0f, 0.0f, 1.0f);
        }

        GLES20.glUniform1i(uShapeLoc, 1);
        GLES20.glUniform2f(uCenterLoc, x, y);
        GLES20.glUniform1f(uRadiusLoc, radius * 0.9f);

        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
    }

    private void drawCellRect(int row, int col, float r, float g, float b, float a) {
        float cx = cellToNdcX(col + 0.5f);
        float cy = cellToNdcY(row + 0.5f);
        float halfWidth = 1.0f / maze.getCols();
        float halfHeight = 1.0f / maze.getRows();

        // Usiamo il quad unitario e approssimiamo lo scaling modificando il raggio del cerchio max
        GLES20.glUniform4f(uColorLoc, r, g, b, a);
        GLES20.glUniform1i(uShapeLoc, 0);
        GLES20.glUniform2f(uCenterLoc, cx, cy); // non usato per rect
        GLES20.glUniform1f(uRadiusLoc, 1.0f); // non usato per rect

        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
    }

    private void drawCellCircle(int row, int col, float r, float g, float b, float a, float scale) {
        float cx = cellToNdcX(col + 0.5f);
        float cy = cellToNdcY(row + 0.5f);
        float radius = (1.0f / Math.max(maze.getCols(), maze.getRows())) * scale * 2.0f;

        GLES20.glUniform4f(uColorLoc, r, g, b, a);
        GLES20.glUniform1i(uShapeLoc, 1);
        GLES20.glUniform2f(uCenterLoc, cx, cy);
        GLES20.glUniform1f(uRadiusLoc, radius);

        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
    }

    // Input touch da GameView
    public void handleTouch(float x, float y, int width, int height) {
        float nx = x / (float) width;
        float ny = y / (float) height;
        pacMan.handleTouchDirection(nx, ny);
    }
}


Logica di gioco del pac-man android tutorial

Questa mini app fornisce un ciclo di gioco base: Pac-Man si muove nel labirinto mangiando pallini, i fantasmi lo inseguono con un pathfinding molto semplice e i power-up attivano la modalità frightened. I shader GLSL, dal canto loro, disegnano cerchi (Pac-Man, fantasmi, pallini) e rettangoli (muri) a partire da un quad 2D. Come prossimi passi, puoi aggiungere più fantasmi con comportamenti diversi, punteggi, vite multiple, UI completa e una mappa più fedele all’originale. Inoltre, vale la pena implementare ottimizzazioni come batching delle draw call, VBO e texture. In particolare, per progetti più ambiziosi, vale la pena esplorare texture atlases per sprite animati, instanced rendering per ridurre le draw call dei muri, e l’uso di Vertex Buffer Objects (VBO) per non ricaricare i dati geometrici a ogni frame. Puoi approfondire questi argomenti nel nostro Tutorial sui Giochi Android in Java.