iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 23
0
Mobile Development

Why Flutter why? 從表層到底層,從如何到為何。系列 第 23

days[22] = "如何做一個Pacman遊戲?"

雖然說這個系列到目前為止的主軸是介紹一些Flutter底層實作,或是一些設計模式、觀念、函式庫的分析等等,其實最初的目標就是只希望可以寫一些沒有被寫過一百遍的東西,例如如何架設環境、如何串接Firebase...,不過寫到第23天也真的是越來越沒梗了。剛好上週末參加Flutter Taipei試著做了一個Pacman遊戲,覺得還蠻有趣的,所以今天我們就轉換一下心情,一起來做個Pacman吧!我想這應該不算是被寫過一百次的題目吧?

首先是這次的範例程式碼,直接在DartPad上就可以玩了,記得調整右邊UI的比例,因為我沒有特別處理這部份。

地圖

const originalMap = [
  [1,1,1,1,1,1,1,1,1,1,1,],
  [1,0,0,0,0,0,0,0,0,0,1,],
  [1,0,1,0,1,0,1,0,1,0,1,],
  [1,0,1,0,1,1,1,0,1,0,1,],
  [1,0,1,0,0,0,0,0,1,0,1,],
  [1,0,1,0,1,0,1,0,1,0,1,],
  [1,0,0,0,1,0,1,0,0,0,1,],
  [1,1,1,1,1,0,1,1,1,1,1,],
  [0,0,0,0,0,0,0,0,0,0,0,],
  [1,1,1,1,1,0,1,1,1,1,1,],
  [1,0,0,0,1,0,1,0,0,0,1,],
  [1,0,1,0,1,0,1,0,1,0,1,],
  [1,0,1,0,0,0,0,0,1,0,1,],
  [1,0,1,0,1,1,1,0,1,0,1,],
  [1,0,1,0,1,0,1,0,1,0,1,],
  [1,0,0,0,0,0,0,0,0,0,1,],
  [1,1,1,1,1,1,1,1,1,1,1,],
];

一開始我們很直覺的把遊戲地圖轉成一個2D int array,1代表牆壁,0代表豆子,我們之後會用-1代表空地。當然我們也可以把它們用enum來表示,不過這樣地圖就沒那麼一目瞭然了。

方向

enum Direction { LEFT, UP, DOWN, RIGHT }
  • 接下來我們定義一個方向的enum,這會被用在Pacman圖示的方向、Ghost移動方向,還有遊戲的方向鍵上。

角色

class Character {
  Character(this.position);

  Point position;

  static Point _nextPosition(Point position, Direction direction) {
    Point newPosition = position;
    switch (direction) {
      case Direction.UP: newPosition += Point(0, -1); break;
      case Direction.DOWN: newPosition += Point(0, 1); break;
      case Direction.LEFT: newPosition += Point(-1, 0); break;
      case Direction.RIGHT: newPosition += Point(1, 0); break;
    }
    return newPosition;
  }

  bool tryMove(List<List<int>> map, Direction direction) {
    final nextPosition = _nextPosition(position, direction);
    if (map[nextPosition.y][nextPosition.x] == 1)
      return false;

    position = nextPosition;
    return true;
  }
}

  • 我們建立Character class,作為PacmanGhost的parent class。
  • 立一個static函數_nextPosition,可以根據positiondirection計算newPosition
  • tryMove則是使Character實際上在地圖中移動的函數,接收目前的地圖和移動的方向,判斷新位置是不是牆壁,最後回傳移動有沒有成功。

Pacman

class Pacman extends Character {
  Pacman(Point position) : super(position);

  Direction facing = Direction.RIGHT;

  @override
  bool tryMove(List<List<int>> map, Direction direction) {
    final moved = super.tryMove(map, direction);
    if (moved) {
      facing = direction;
      map[position.y][position.x] = -1;
    }
    return moved;
  }
}
  • Pacman首先多了一個state facing,用來代表小精靈面向哪一個方向。
  • tryMove中,我們先呼叫super.tryMove來嘗試移動。
  • 如果移動成功,就讓小精靈轉向,並把豆子吃掉,也就是把地圖上該位置設成-1

Ghost

class Ghost extends Character {
  Ghost(Point position) : super(position);

  Direction heading = Direction.UP;

  void move(List<List<int>> map) {
    while (!tryMove(map, heading)) {
      heading = Direction.values[Random().nextInt(3)];
    }
  }
}
  • Ghost的移動比較特殊,目前設定是會不斷朝同一個方向前進,直到撞牆就隨機轉向。
  • 宣告state heading來表示目前前進的方向。
  • move中,以目前的heading呼叫tryMove,若撞牆就改變方向,直到成功為止。

App基本結構

終於進入Flutter的部份了,先來看看整個App的基本結構

void main() {
  runApp(MaterialApp(
    home: PacManScreen(),
  ));
}

class PacManScreen extends StatefulWidget {
  @override
  _PacManScreenState createState() => _PacManScreenState();
}

class _PacManScreenState extends State<PacManScreen> {
  Pacman pacman;
  List<Ghost> ghosts;
  List<List<int>> currentMap;
  Timer timer;
  bool gameOver;

  @override
  void initState() {
    super.initState();
    _startGame();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: Column(
        children: [
          Expanded(child: Container(
            color: Colors.black,
            child: _buildGameDisplay(),
          )),
          _buildGameInfo(),
          _buildGameControl(),
        ],
      ),
    );
  }
  
  ...
}
  • PacmanScreen是個StatefulWidget,每當Pacman或Ghost移動時就會更新畫面。
  • 我們的state除了有明顯的pacmanghostscurrentMap以外,還有Timer用來控制Ghosts的移動速度,以及gameOver用來表示遊戲結束。
  • 我們在initState中呼叫_startGame函數,用來初始化所有state。
  • 整個UI包含三個區塊:_buildGameDisplay用來顯示遊戲畫面,_buildGameInfo用來顯示分數和開始按鈕,_buildGameControl用來顯示方向鍵。

_startGame()

  void _startGame() {
    setState(() {
      gameOver = false;
      pacman = Pacman(Point(1, 1));
      ghosts = [
        Ghost(Point(7,1)),
        Ghost(Point(1, 15)),
        Ghost(Point(9, 13)),
      ];
      
      currentMap = List.generate(originalMap.length, (i) => List.from(originalMap[i]));
      
      timer?.cancel();
      timer = Timer.periodic(Duration(seconds: 1), (timer) {
        setState(() {
          ghosts.forEach((it) => it.move(currentMap));
          _checkGameOver();
        });
      });
    });
  }
  • 這裡我們須要把originalMap的設定複製一份到currentMap,因此必須使用List.generateList.from
  • 我們使用Timer.periodic來讓每個ghost每秒鐘移動一次。
  • ghosts移動後呼叫_checkGameOver檢查遊戲是否結束。

_checkGameOver()

  void _checkGameOver() {
    gameOver = ghosts.any((it) => it.position == pacman.position);
    if (gameOver) timer.cancel();
  }
  • 簡單的判斷有沒有任何Ghost和Pacman位置重疊
  • 如果有的話將timer取消,ghosts停止移動。

_buildGameInfo()

  Row _buildGameInfo() {
    final score = currentMap.map((row) => row.where((it) => it == -1).length).fold(0, (a, b) => a+b);
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        FlatButton(
          child: Text("START"),
          onPressed: () => _startGame(),
          color: Colors.green,
        ),
        Text("SCORE: $score"),
      ],
    );
  }
  • 我們從currentMap的每一個row中,用row.where((it) => it == -1).length取得豆子被吃掉的數量,然後用fold加總起來,就得到目前的score
  • 用一個FlatButton來呼叫_startGame(),重新開始遊戲。

_buildGameControl()

  Row _buildGameControl() {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: Direction.values.map(_buildKey).toList(),
    );
  }

  Widget _buildKey(Direction direction) {
    IconData icon;
    switch(direction) {
      case Direction.RIGHT: icon = Icons.arrow_forward; break;
      case Direction.LEFT: icon = Icons.arrow_back; break;
      case Direction.UP: icon = Icons.arrow_upward; break;
      case Direction.DOWN: icon = Icons.arrow_downward; break;
    }
    return GestureDetector(
      onTap: () {
        if (gameOver) return;
        setState(() {
          pacman.tryMove(currentMap, direction);
          _checkGameOver();
        });
      },
      child: Padding(
        padding: EdgeInsets.all(8),
        child: Icon(icon),
      ),
    );
  }
  • 我們把Direction enum 的值map成每個方向鍵的Widget,並放進Row中。
  • _buildKey中根據方向決定Icon。
  • 方向鍵被點擊時,在onTap中嘗試移動pacman
  • 移動後再次檢查遊戲是否結束。

_buildGameDisplay()

  Widget _buildGameDisplay() {
    if (gameOver) {
      return Center(child: Text("GAME OVER!", style: TextStyle(color: Colors.red)));
    }

    final rows = currentMap.length;
    final columns = currentMap[0].length;
    return GridView.count(
      crossAxisCount: columns,
      mainAxisSpacing: 2,
      crossAxisSpacing: 2,
      childAspectRatio: 1.1,
      children: List.generate(rows * columns, (index) {
        final position = Point(index % columns, index ~/ columns);
        final cellValue = currentMap[position.y][position.x];
        return _buildCell(position, cellValue);
      }),
    );
  }
  • 首先判斷是否gameOver,直接回傳置中紅字"GAME OVER!"。
  • 接下來使用GridView來建立整個遊戲地圖。
  • 我們將GridView的crossAxisCount設為columns,然後用List.generate來產生總共rows * columns個cell。
  • 在generator中,用index % columnsindex ~/ columns來計算目前這個cell的x, y座標。
  • 透過x, y座標取出這個cell的state(1, 0, -1)。

_buildCell

最後一步了!加油!

  Widget _buildCell(Point<int> position, int cell) {
    if (ghosts.any((ghost) => ghost.position == position)) {
      return Image.network("https://i.imgur.com/1uneU2Q.png");
    }

    if (pacman.position == position) {
      return RotatedBox(
        quarterTurns: [Direction.RIGHT, Direction.DOWN, Direction.LEFT, Direction.UP,].indexOf(pacman.facing),
        child: Image.network("https://i.imgur.com/8foCiWg.png"),
      );
    }

    switch (cell) {
      case -1: return Container();
      case 0: return Padding(
        padding: const EdgeInsets.all(12.0),
        child: Container(
          decoration: BoxDecoration(
            color: Colors.yellow,
            borderRadius: BorderRadius.circular(16),
          ),
        ),
      );
      case 1: return Container(
        decoration: BoxDecoration(
          color: Colors.blue,
          borderRadius: BorderRadius.circular(8)
        ),
      );
    }

    return Container();
  }
  • 如果有任何ghost在這個位置,回傳ghost Image
  • 如果pacman在這個位置,回傳pacman Image,記得根據pacman的facing來旋轉。
  • 如果cell state是-1,回傳空的Container
  • 如果cell state是0,回傳豆子的Widget,這裡我只是簡單的用yellow Container+circular border來製造黃色圓形,然後用Padding把它縮小。
  • 如果cell state是1,回傳牆壁的Widget。

結語

大功告成了!是不是其實也滿簡單的?大部份的程式碼應該都挺直白的,可能只有最一開始移動的部份須要稍微理解一下。程式碼和遊戲本身都還有很多可以改進的地方,例如切分Widget,加強Ghost AI,增加大力丸等等。有興趣的人可以自己嘗試看看,然後也別忘了把你的曠世鉅作分享到Flutter Taipei


上一篇
days[21] = "Layout是怎麼運作的?"
下一篇
days[23] = "Flutter Web是怎麼運作的?(上)"
系列文
Why Flutter why? 從表層到底層,從如何到為何。30

尚未有邦友留言

立即登入留言