
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
GLSurfaceViewe 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.