🎮 Membangun Game Pac-Man Klasik dengan Flutter: Belajar Sambil Seru-Seruan!

 🎮 Membangun Game Pac-Man Klasik dengan Flutter: Belajar Sambil Seru-Seruan!



 🚀 Pendahuluan


Siapa yang tidak kenal Pac-Man? Game klasik yang menemani masa kecil kita ini kini bisa kamu buat sendiri menggunakan Flutter— framework UI modern yang biasanya dipakai untuk membangun aplikasi mobile. Tapi siapa bilang Flutter cuma bisa buat aplikasi serius? Di artikel ini, saya akan menunjukkan bagaimana kita bisa membangun game Pac-Man sederhana lengkap dengan musuh, skor, dan tombol kontrol hanya dengan menggunakan Flutter native, tanpa package tambahan!


🔧 Apa yang Akan Kita Bangun?


Di game ini, kamu akan:


 Mengontrol Pac-Man menggunakan tombol atau keyboard.

 Mengumpulkan "pellet" untuk mendapatkan skor.

 Menghindari musuh (ghosts) yang mengejarmu secara otomatis.

 Menggunakan tombol Start, Pause, dan Restart.

 Melihat skor dan status game (Ready, Paused, Game Over) secara real-time.


📦 Kenapa Menggunakan Flutter?


Cepat dikembangkan: Flutter punya hot reload dan hot restart, sangat cocok untuk iterasi game kecil.

Portabel: Bisa dimainkan di Android, iOS, dan Web.

Tanpa library eksternal: Kode game ini murni Flutter tanpa dependency tambahan.


 🧱 Struktur Game


Game Pac-Man ini dibuat dengan struktur sederhana:


Maze (labirin) dibentuk menggunakan `List<List<int>>`, di mana angka:


   `1` = dinding

   `0` = jalur dengan pellet

   `2` = posisi awal player


Enum digunakan untuk mengelompokkan status dan jenis tile:


   `TileType` (wall, path, pellet)

   `GameStatus` (ready, running, paused, gameOver)


Pemain & Musuh: Diposisikan dengan koordinat grid (`Point<int>`) yang bisa berpindah tiap detik.


Timer digunakan untuk menggerakkan musuh setiap setengah detik.


CustomPaint menggambar maze, player, pellet, dan musuh.


 🎮 Kontrol Game


Start: Memulai permainan.


Pause / Resume: Menjeda atau melanjutkan game.


Restart: Mengulang permainan dari awal.


Keyboard Control: Gunakan tombol panah pada keyboard (di web/desktop) untuk mengontrol Pac-Man.


---


✨ Fitur Menarik


 👾 Musuh bergerak otomatis mengejar pemain dengan algoritma sederhana (semi-tracking).

 💡 Pellet tersebar di seluruh jalur dan hilang saat dimakan, menambah skor.

 💥 Game Over saat musuh menyentuh pemain.

 ⏸️ Pause / Resume tanpa kehilangan progres.

 📱 Responsive & ringan, cocok dimainkan di mobile atau web browser.


---


 📸 Tampilan


> 💡 UI sudah dibuat rapi dengan tombol kontrol di atas, lalu score dan status di bawahnya. Semua digambar dengan `CustomPainter` agar ringan dan fleksibel!


---


 🔤 Kode Lengkap


import 'dart:async';

import 'dart:math';


import 'package:flutter/material.dart';

import 'package:flutter/services.dart';


void main() {

  runApp(PacManGame());

}


class PacManGame extends StatelessWidget {

  @override

  Widget build(BuildContext context) {

    return MaterialApp(

      title: 'Pac-Man Enhanced',

      theme: ThemeData.dark().copyWith(

        scaffoldBackgroundColor: Colors.black,

        textTheme: TextTheme(

          bodyText2: TextStyle(color: Colors.white),

        ),

      ),

      home: GameScreen(),

      debugShowCheckedModeBanner: false,

    );

  }

}


enum TileType { wall, path, pellet }

enum GameStatus { ready, running, paused, gameOver }


class GameScreen extends StatefulWidget {

  @override

  _GameScreenState createState() => _GameScreenState();

}


class _GameScreenState extends State<GameScreen> {

  static const int rows = 21;

  static const int cols = 21;

  static const int tileSize = 24;


  final List<List<int>> mazeMap = [

    [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],

    [1,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,1],

    [1,0,1,1,1,1,0,1,0,1,1,1,0,1,0,1,1,1,1,0,1],

    [1,0,1,0,0,1,0,1,0,1,0,0,0,1,0,1,0,0,1,0,1],

    [1,0,1,0,0,1,0,1,0,1,0,1,0,1,0,1,0,0,1,0,1],

    [1,0,1,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,1,0,1],

    [1,0,0,0,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,1],

    [1,0,1,1,1,1,1,1,0,1,0,1,1,1,1,1,0,1,1,0,1],

    [1,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,1],

    [1,1,1,1,1,1,0,1,1,1,1,1,0,1,0,1,1,1,1,1,1],

    [1,0,0,0,0,1,0,0,0,0,2,0,0,1,0,0,0,0,0,0,1],

    [1,1,1,1,0,1,1,1,1,0,0,0,1,1,0,1,1,1,1,1,1],

    [1,0,0,1,0,0,0,0,1,0,1,0,0,0,0,1,0,0,0,0,1],

    [1,0,1,1,1,1,1,0,1,0,1,0,1,1,1,1,0,1,1,0,1],

    [1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,0,1],

    [1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1],

    [1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1],

    [1,0,1,0,1,1,1,1,1,1,0,1,1,1,1,0,1,1,1,0,1],

    [1,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,1],

    [1,0,0,0,1,1,1,1,0,1,1,1,1,0,1,1,1,0,0,0,1],

    [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],

  ];


  late List<List<TileType>> maze;


  Point<int> playerPos = Point(1, 1);

  List<Point<int>> enemies = [Point(19, 19), Point(19, 1), Point(1, 19), Point(10,10)];


  int score = 0;

  GameStatus gameStatus = GameStatus.ready;


  Timer? enemyTimer;


  FocusNode focusNode = FocusNode();


  @override

  void initState() {

    super.initState();

    resetGame();

  }


  void resetGame() {

    maze = List.generate(rows, (y) {

      return List.generate(cols, (x) {

        if (mazeMap[y][x] == 1) return TileType.wall;

        return TileType.pellet;

      });

    });


    playerPos = Point(1, 1);

    enemies = [Point(19, 19), Point(19, 1), Point(1, 19), Point(10, 10)];

    maze[playerPos.y][playerPos.x] = TileType.path;

    for (var e in enemies) {

      maze[e.y][e.x] = TileType.path;

    }


    score = 0;

    gameStatus = GameStatus.ready;

    enemyTimer?.cancel();


    setState(() {});

  }


  void startGame() {

    if (gameStatus == GameStatus.running) return;


    gameStatus = GameStatus.running;

    enemyTimer = Timer.periodic(Duration(milliseconds: 500), (_) {

      if (gameStatus == GameStatus.running) {

        moveEnemies();

        checkGameOver();

        setState(() {});

      }

    });

    setState(() {});

  }


  void pauseGame() {

    if (gameStatus == GameStatus.running) {

      gameStatus = GameStatus.paused;

    } else if (gameStatus == GameStatus.paused) {

      gameStatus = GameStatus.running;

    }

    setState(() {});

  }


  void moveEnemies() {

    List<Point<int>> newEnemyPositions = [];


    for (var enemyPos in enemies) {

      List<Point<int>> candidates = [];


      int dx = playerPos.x - enemyPos.x;

      int dy = playerPos.y - enemyPos.y;


      if (dx != 0) {

        int stepX = dx ~/ dx.abs();

        Point<int> nextPos = Point(enemyPos.x + stepX, enemyPos.y);

        if (canMove(nextPos) && !newEnemyPositions.contains(nextPos)) candidates.add(nextPos);

      }

      if (dy != 0) {

        int stepY = dy ~/ dy.abs();

        Point<int> nextPos = Point(enemyPos.x, enemyPos.y + stepY);

        if (canMove(nextPos) && !newEnemyPositions.contains(nextPos)) candidates.add(nextPos);

      }


      if (candidates.isEmpty) {

        List<Point<int>> possibleMoves = [

          Point(enemyPos.x + 1, enemyPos.y),

          Point(enemyPos.x - 1, enemyPos.y),

          Point(enemyPos.x, enemyPos.y + 1),

          Point(enemyPos.x, enemyPos.y - 1),

        ].where(canMove).where((p) => !newEnemyPositions.contains(p)).toList();


        if (possibleMoves.isNotEmpty) {

          newEnemyPositions.add(possibleMoves[Random().nextInt(possibleMoves.length)]);

        } else {

          newEnemyPositions.add(enemyPos);

        }

      } else {

        candidates.sort((a, b) {

          int distA = distance(a, playerPos);

          int distB = distance(b, playerPos);

          return distA.compareTo(distB);

        });

        newEnemyPositions.add(candidates.first);

      }

    }


    enemies = newEnemyPositions;

  }


  int distance(Point<int> a, Point<int> b) {

    return (a.x - b.x).abs() + (a.y - b.y).abs();

  }


  bool canMove(Point<int> p) {

    if (p.x < 0 || p.x >= cols || p.y < 0 || p.y >= rows) return false;

    return maze[p.y][p.x] != TileType.wall;

  }


  void movePlayer(int dx, int dy) {

    if (gameStatus != GameStatus.running) return;


    Point<int> newPos = Point(playerPos.x + dx, playerPos.y + dy);

    if (!canMove(newPos)) return;


    playerPos = newPos;


    if (maze[playerPos.y][playerPos.x] == TileType.pellet) {

      maze[playerPos.y][playerPos.x] = TileType.path;

      score += 10;

    }


    checkGameOver();

    setState(() {});

  }


  void checkGameOver() {

    for (var e in enemies) {

      if (e == playerPos) {

        gameStatus = GameStatus.gameOver;

        enemyTimer?.cancel();

        break;

      }

    }

  }


  @override

  Widget build(BuildContext context) {

    return Scaffold(

      appBar: AppBar(

        title: Text('Pac-Man Flutter'),

        centerTitle: true,

        backgroundColor: Colors.black87,

      ),

      body: RawKeyboardListener(

        autofocus: true,

        focusNode: focusNode,

        onKey: (event) {

          if (event is RawKeyDownEvent && gameStatus == GameStatus.running) {

            if (event.logicalKey == LogicalKeyboardKey.arrowUp) {

              movePlayer(0, -1);

            } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {

              movePlayer(0, 1);

            } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {

              movePlayer(-1, 0);

            } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {

              movePlayer(1, 0);

            }

          }

        },

        child: SingleChildScrollView(

          padding: EdgeInsets.symmetric(vertical: 16),

          child: Center(

            child: Column(

              mainAxisSize: MainAxisSize.min,

              children: [

                // Tombol Start/Pause/Restart di atas

                Row(

                  mainAxisAlignment: MainAxisAlignment.center,

                  children: [

                    ElevatedButton(

                      onPressed: (gameStatus == GameStatus.ready || gameStatus == GameStatus.paused)

                          ? startGame

                          : null,

                      style: ElevatedButton.styleFrom(backgroundColor: Colors.green),

                      child: Text('Start'),

                    ),

                    SizedBox(width: 12),

                    ElevatedButton(

                      onPressed: (gameStatus == GameStatus.running || gameStatus == GameStatus.paused)

                          ? pauseGame

                          : null,

                      style: ElevatedButton.styleFrom(backgroundColor: Colors.orange),

                      child: Text(gameStatus == GameStatus.paused ? 'Resume' : 'Pause'),

                    ),

                    SizedBox(width: 12),

                    ElevatedButton(

                      onPressed: () {

                        resetGame();

                      },

                      style: ElevatedButton.styleFrom(backgroundColor: Colors.red),

                      child: Text('Restart'),

                    ),

                  ],

                ),


                SizedBox(height: 12),


                // Score dan Status (Game Over, Paused, Ready) di atas maze

                Column(

                  children: [

                    Text(

                      'Score: $score',

                      style: TextStyle(fontSize: 22, color: Colors.yellowAccent, fontWeight: FontWeight.bold),

                    ),

                    SizedBox(height: 6),

                    if (gameStatus == GameStatus.ready) ...[

                      Text('Press Start to Play', style: TextStyle(fontSize: 18)),

                    ] else if (gameStatus == GameStatus.paused) ...[

                      Text('Paused', style: TextStyle(fontSize: 18, color: Colors.orangeAccent)),

                    ] else if (gameStatus == GameStatus.gameOver) ...[

                      Text('Game Over', style: TextStyle(fontSize: 24, color: Colors.redAccent, fontWeight: FontWeight.bold)),

                    ],

                  ],

                ),


                SizedBox(height: 20),


                Container(

                  width: cols * tileSize.toDouble(),

                  height: rows * tileSize.toDouble(),

                  decoration: BoxDecoration(

                    color: Colors.black,

                    border: Border.all(color: Colors.yellowAccent, width: 4),

                    boxShadow: [BoxShadow(color: Colors.yellowAccent.withOpacity(0.5), blurRadius: 15, spreadRadius: 1)],

                    borderRadius: BorderRadius.circular(12),

                  ),

                  child: CustomPaint(

                    painter: MazePainter(

                      maze: maze,

                      playerPos: playerPos,

                      enemies: enemies,

                      tileSize: tileSize,

                    ),

                  ),

                ),


                SizedBox(height: 30),


                Text('Use Arrow Keys to Move Pac-Man', style: TextStyle(color: Colors.white70)),

                SizedBox(height: 10),

                Text('Avoid the ghosts!', style: TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold)),

              ],

            ),

          ),

        ),

      ),

    );

  }

}


class MazePainter extends CustomPainter {

  final List<List<TileType>> maze;

  final Point<int> playerPos;

  final List<Point<int>> enemies;

  final int tileSize;


  MazePainter({

    required this.maze,

    required this.playerPos,

    required this.enemies,

    required this.tileSize,

  });


  @override

  void paint(Canvas canvas, Size size) {

    final paintWall = Paint()..color = Colors.blue[900]!;

    final paintPellet = Paint()..color = Colors.yellowAccent;

    final paintPath = Paint()..color = Colors.black;

    final paintPlayer = Paint()..color = Colors.yellow;

    final paintEnemy = Paint()..color = Colors.redAccent;


    for (int y = 0; y < maze.length; y++) {

      for (int x = 0; x < maze[y].length; x++) {

        Rect rect = Rect.fromLTWH(x * tileSize.toDouble(), y * tileSize.toDouble(), tileSize.toDouble(), tileSize.toDouble());


        if (maze[y][x] == TileType.wall) {

          canvas.drawRect(rect, paintWall);

        } else {

          canvas.drawRect(rect, paintPath);

          if (maze[y][x] == TileType.pellet) {

            canvas.drawCircle(rect.center, tileSize / 8, paintPellet);

          }

        }

      }

    }


    final playerCenter = Offset(playerPos.x * tileSize + tileSize / 2, playerPos.y * tileSize + tileSize / 2);

    final playerRadius = tileSize / 2 * 0.9;

    final mouthAngle = pi / 5;


    Path pacManPath = Path();

    pacManPath.moveTo(playerCenter.dx, playerCenter.dy);

    pacManPath.arcTo(

      Rect.fromCircle(center: playerCenter, radius: playerRadius),

      mouthAngle,

      2 * pi - 2 * mouthAngle,

      false,

    );

    pacManPath.close();

    canvas.drawPath(pacManPath, paintPlayer);


    for (var enemy in enemies) {

      final enemyRect = Rect.fromLTWH(enemy.x * tileSize.toDouble() + tileSize * 0.1, enemy.y * tileSize.toDouble() + tileSize * 0.1,

          tileSize * 0.8, tileSize * 0.8);

      canvas.drawRect(enemyRect, paintEnemy);


      final eyePaint = Paint()..color = Colors.white;

      final pupilPaint = Paint()..color = Colors.black;


      final leftEyeCenter = Offset(enemyRect.left + tileSize * 0.25, enemyRect.top + tileSize * 0.3);

      final rightEyeCenter = Offset(enemyRect.left + tileSize * 0.55, enemyRect.top + tileSize * 0.3);


      canvas.drawCircle(leftEyeCenter, tileSize * 0.1, eyePaint);

      canvas.drawCircle(rightEyeCenter, tileSize * 0.1, eyePaint);


      canvas.drawCircle(leftEyeCenter, tileSize * 0.05, pupilPaint);

      canvas.drawCircle(rightEyeCenter, tileSize * 0.05, pupilPaint);

    }

  }


  @override

  bool shouldRepaint(covariant MazePainter oldDelegate) {

    return true;

  }

}



---


 🤓 Apa yang Bisa Kita Pelajari?


Dengan membuat game ini, kamu akan memahami:


 Cara menggunakan `CustomPaint` dan `Canvas` di Flutter.

 Manajemen state sederhana untuk game.

 Algoritma gerak musuh dasar (AI sederhana).

 Penggunaan timer dan input keyboard.

 Membuat UI responsif untuk game.


---


 🎯 Tantangan Tambahan (Coba Sendiri!)


Jika kamu ingin mengembangkan lebih lanjut, coba tambahkan:


 🧠 AI yang lebih pintar untuk musuh.

 💣 Power-up atau bom untuk menghancurkan musuh.

 🎵 Suara saat makan pellet atau kalah.

 🌍 Level baru atau editor labirin.

 📱 Tombol arah on-screen untuk kontrol di smartphone.


---


 ✍️ Penutup


Membangun game seperti Pac-Man di Flutter bukan hanya menyenangkan, tapi juga mengajarkan kita banyak hal soal logika, rendering grafis, dan manajemen UI. Flutter bukan hanya untuk aplikasi serius — dia juga bisa jadi playground yang luar biasa untuk belajar dan bereksperimen.


---


📚 Ditulis oleh:


Nama: Sela Salafiah

Kelas: XII RPL 2

Proyek Pribadi Flutter Game Development


---

Komentar