雖然說這個系列到目前為止的主軸是介紹一些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 }
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,作為Pacman
和Ghost
的parent class。_nextPosition
,可以根據position
和direction
計算newPosition
。tryMove
則是使Character
實際上在地圖中移動的函數,接收目前的地圖和移動的方向,判斷新位置是不是牆壁,最後回傳移動有沒有成功。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;
}
}
facing
,用來代表小精靈面向哪一個方向。tryMove
中,我們先呼叫super.tryMove
來嘗試移動。-1
。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
的移動比較特殊,目前設定是會不斷朝同一個方向前進,直到撞牆就隨機轉向。heading
來表示目前前進的方向。move
中,以目前的heading
呼叫tryMove
,若撞牆就改變方向,直到成功為止。終於進入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移動時就會更新畫面。pacman
,ghosts
,currentMap
以外,還有Timer
用來控制Ghosts的移動速度,以及gameOver
用來表示遊戲結束。initState
中呼叫_startGame
函數,用來初始化所有state。_buildGameDisplay
用來顯示遊戲畫面,_buildGameInfo
用來顯示分數和開始按鈕,_buildGameControl
用來顯示方向鍵。 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.generate
和List.from
。Timer.periodic
來讓每個ghost每秒鐘移動一次。_checkGameOver
檢查遊戲是否結束。 void _checkGameOver() {
gameOver = ghosts.any((it) => it.position == pacman.position);
if (gameOver) timer.cancel();
}
timer
取消,ghosts停止移動。 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()
,重新開始遊戲。 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
。 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
來建立整個遊戲地圖。crossAxisCount
設為columns
,然後用List.generate
來產生總共rows * columns
個cell。index % columns
、index ~/ columns
來計算目前這個cell的x, y座標。最後一步了!加油!
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
來旋轉。-1
,回傳空的Container
。大功告成了!是不是其實也滿簡單的?大部份的程式碼應該都挺直白的,可能只有最一開始移動的部份須要稍微理解一下。程式碼和遊戲本身都還有很多可以改進的地方,例如切分Widget,加強Ghost AI,增加大力丸等等。有興趣的人可以自己嘗試看看,然後也別忘了把你的曠世鉅作分享到Flutter Taipei!