Flutter Project : สร้างเกมงู (Snake Game) ด้วย Flutter
สวัสดีครับ จากบล็อกตอนที่แล้ว ผมได้เขียนเกม OX ด้วย Flutter ไปแล้ว วันนี้นึกสนุกอยากทำเกมงูขึ้นมา อยู่ๆทุกอย่างก็เข้ามาในหัวทันทีทันใด ในเมื่อคิดแล้วก็ทำเลยละกัน เป็นที่มาของบันทึกบล็อกนี้ครับ เกมงู เขียนด้วย Flutter แบบเบาๆครับ
เกม OX ก่อนหน้านี้
เริ่มต้น
สร้างโปรเจคเปล่าๆขึ้นมา เตรียมพร้อม
import 'package:flutter/material.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( title: 'Snake Game', theme: ThemeData( primarySwatch: Colors.blue, ), home: MyHomePage(title: 'Snake Game'), ); } } class MyHomePage extends StatefulWidget { MyHomePage({Key key, this.title}) : super(key: key); final String title; @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Container( color: Colors.blue[50], child: Center( child: Text( "Hello Flutter :)", style: TextStyle(fontSize: 24,fontWeight: FontWeight.bold),)))); } }
สร้างหน้าจอ
ผมขอเรียกพื้นที่ ที่ให้เจ้างูเลื้อยไปมา ว่า Map นะครับ คิดว่าขนาดน่าจะประมาณ 36×48 ช่อง ขนาดช่องละ 10×10 ครับ ไว้เดี๋ยวค่อยปรับขนาดทีหลัง
class _MyHomePageState extends State<MyHomePage> { static const int COUNT_ROW = 48; static const int COUNT_COL = 36; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Container( color: Colors.blue[50], child: Center( child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: Colors.blue[800]), padding: EdgeInsets.all(8), margin: EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: buildMap()))))); } List<Widget> buildRowMap(int row) { List<Widget> listWidget = List(); for (int i = 0; i < COUNT_COL; i++) { listWidget.add(buildMapUnit(row, i)); } return listWidget; } List<Widget> buildMap() { List<Widget> listWidget = List(); for (int i = 0; i < COUNT_ROW; i++) { listWidget.add(Row(children: buildRowMap(i))); } return listWidget; } Container buildMapUnit(int row, int col) => Container( width: 10, height: 10, padding: EdgeInsets.all(0), margin: EdgeInsets.all(0), color: Colors.pink[50], ); }
เพื่อความสวยงามใส่ขอบให้เรียบร้อย
เริ่มเกม
เกมงูนั้นเป็นเกมที่รันไปเรื่อยๆจนกว่าจะจบ งูจะเลื้อยไปเรื่อยๆในทิศทางที่กำหนด ดังนั้นมันเลยต้องเป็นแอปที่ทำงานตลอดเวลา
นั่นก็คือ Timer
Timer timer;
ประกาศตัวแปรเกี่ยวกับ map แล้วก็ตำแหน่งของหัวงู เพราะหัวงูต้องว่งไปตามทิศนั้นๆ
โดยตัวแปร map จะเก็บค่าว่าแต่ละช่องเป็นตัวงู หรือพื้นที่ว่าง
int rowSnakeHead = 0; int colSnakeHead = 0; List<List<int>> map = List();
initState ก็ทำการกำหนด Map ให้พร้อม โดยกำหนด 0 คือ พื้นที่ว่าง ส่วน 1 คือเป็นตัวงู
แล้วก็กำหนดให้ timer รันทุกๆ 0.5 วินาที โดยทุกๆรอบจะรันคำสั่ง method ชื่อว่า process
@override void initState() { initMap(); timer = Timer.periodic(Duration(milliseconds: 500), (Timer t) => process()); super.initState(); } void initMap() { map = List(); for (int row = 0; row < COUNT_ROW; row++) { List<int> rowMap = List(); for (int col = 0; col < COUNT_COL; col++) { rowMap.add(0); } map.add(rowMap); } } @override void dispose() { timer?.cancel(); super.dispose(); }
ที่ process() จะทำการ setState ใหม่ โดยตอนนี้ผมจะทำให้ เกม สมมุติ มีงูอยู่แถวนึงแล้ววิ่งไปทางขวา
process() { setState(() { map[rowSnakeHead][colSnakeHead] = 1; colSnakeHead++; if (colSnakeHead >= COUNT_COL) { colSnakeHead = 0; rowSnakeHead++; } if (rowSnakeHead >= COUNT_ROW) { rowSnakeHead = 0; colSnakeHead = 0; } }); }
สร้าง method เพื่อระบุสีให้กับแต่ละช่องใน map
Color getColorBackground(int row, int col) { if (map[row][col] == 1) { return Colors.pink[500]; } else { return Colors.pink[100]; } }
map แต่ละช่อง ผมเรียกว่า Map Unit ซึ่งมันก็คือ container ขนาด 10×10
Container buildMapUnit(int row, int col) => Container( width: 10, height: 10, padding: EdgeInsets.all(0), margin: EdgeInsets.all(0), color: getColorBackground(row, col), );
รองรัน
ทำตัวงู
ดูเหมือนว่าเรื่องของตำแหน่งงูเป็นเรื่องหลักๆของเกม ดังนั้นเลยคิดว่าควรทำ class ขึ้นมาเพื่อเก็บตำแหน่งของแต่ละส่วนของตัวงูดีกว่า ให้ชือ class ว่า SnakePart
class SnakePart { int row; int col; SnakePart({this.row, this.col}); }
ประกาศตัวแปร list เก็บ SnakePart
List<SnakePart> snake;
ที่ initState กำหนด ว่างูจะเริ่มต้นอยู่ตรงไหนบ้าง โดยผมกำหนดให้งูเริ่มจากบนขวา มีขนาด 4 ช่อง
@override void initState() { initMap(); initSnake(); timer = Timer.periodic(Duration(milliseconds: 500), (Timer t) => process()); super.initState(); } void initSnake() { snake = List(); snake.add(SnakePart(row: 0, col: 3)); // head of snake. snake.add(SnakePart(row: 0, col: 2)); snake.add(SnakePart(row: 0, col: 1)); snake.add(SnakePart(row: 0, col: 0)); // tail of snake. }
แล้วก็มีเรื่องของทิศทางที่งูเลื้อยด้วย มี 4 ทิศดังนี้ กำหนดให้เริ่มหันหัวไปทางขวา
static const int DIRECTION_TOP= 0; static const int DIRECTION_LEFT = 1; static const int DIRECTION_RIGHT = 2; static const int DIRECTION_BOTTOM = 3; int currentSnakeDirection = DIRECTION_RIGHT;
ต่อมา จากเดิม 0 คือที่ว่าง 1 คือตัวงู คิดว่าเพื่อความเข้าใจง่ายขึ้น จึงควรสร้างตัวแปรแบบนี้ดีกว่า
static const int GROUND = 0; static const int SNAKE = 1;
เช่น initMap จะได้แบบนี้
void initMap() { map = List(); for (int row = 0; row < COUNT_ROW; row++) { List<int> rowMap = List(); for (int col = 0; col < COUNT_COL; col++) { rowMap.add(GROUND); } map.add(rowMap); } }
ตอนเรียกกำหนดสีของแต่ละช่องก็จะอ่านง่ายขึ้นด้วย
Color getColorBackground(int row, int col) { if (map[row][col] == SNAKE) { return Colors.pink[500]; } else { return Colors.pink[100]; } }
ทีนี้ต้องมาเปลี่ยน logic ของ process ให้งูมันเลื้อย ไปตามทิศทางของ direction
process() { setState(() { // }); }
วิธีการก็คือ เช็คจาก direction ก่อน เช่นไปทางขวา ก็ทำการเพิ่มหัวงูทางขวาเข้ามาใน ชิ้นส่วนของงู
กำหนดค่าช่องทางขวาของหัวงูเป็น SNAKE กำหนดส่วนหางงูเป็น GROUND
process() { setState(() { if (currentSnakeDirection == DIRECTION_RIGHT) { SnakePart snakeHead = snake.first; if (snakeHead.col + 1 < COUNT_COL) { SnakePart snakeNewHead = SnakePart(row: snakeHead.row, col: snakeHead.col + 1); snake.insert(0, snakeNewHead); map[snakeNewHead.row][snakeNewHead.col] = SNAKE; SnakePart snakeTail = snake.last; map[snakeTail.row][snakeTail.col] = GROUND; snake.removeLast(); }else{ // Head of Snake attack the wall. } } }); }
จะได้แบบนี้
ทีนี้ก็ทำให้ครบทั้งหมด 4 ด้าน โดยเพื่อความอ่านง่ายก็แยกการทำงานของแต่ละด้านออกเป็น method 4 ตัว
void moveSnakeToRight() { SnakePart snakeHead = snake.first; if (snakeHead.col + 1 < COUNT_COL) { SnakePart snakeNewHead = SnakePart(row: snakeHead.row, col: snakeHead.col + 1); snake.insert(0, snakeNewHead); map[snakeNewHead.row][snakeNewHead.col] = SNAKE; SnakePart snakeTail = snake.last; map[snakeTail.row][snakeTail.col] = GROUND; snake.removeLast(); } else { // Head of Snake attack the wall. } } void moveSnakeToLeft() { SnakePart snakeHead = snake.first; if (snakeHead.col - 1 >= 0) { SnakePart snakeNewHead = SnakePart(row: snakeHead.row, col: snakeHead.col - 1); snake.insert(0, snakeNewHead); map[snakeNewHead.row][snakeNewHead.col] = SNAKE; SnakePart snakeTail = snake.last; map[snakeTail.row][snakeTail.col] = GROUND; snake.removeLast(); } else { // Head of Snake attack the wall. } } void moveSnakeToTop() { SnakePart snakeHead = snake.first; if (snakeHead.row - 1 >= 0) { SnakePart snakeNewHead = SnakePart(row: snakeHead.row - 1, col: snakeHead.col); snake.insert(0, snakeNewHead); map[snakeNewHead.row][snakeNewHead.col] = SNAKE; SnakePart snakeTail = snake.last; map[snakeTail.row][snakeTail.col] = GROUND; snake.removeLast(); } else { // Head of Snake attack the wall. } } void moveSnakeToBottom() { SnakePart snakeHead = snake.first; if (snakeHead.row + 1 < COUNT_ROW) { SnakePart snakeNewHead = SnakePart(row: snakeHead.row + 1, col: snakeHead.col); snake.insert(0, snakeNewHead); map[snakeNewHead.row][snakeNewHead.col] = SNAKE; SnakePart snakeTail = snake.last; map[snakeTail.row][snakeTail.col] = GROUND; snake.removeLast(); } else { // Head of Snake attack the wall. } }
ตอน process ก็จะได้แบบนี้
process() { setState(() { if (currentSnakeDirection == DIRECTION_RIGHT) { moveSnakeToRight(); } else if (currentSnakeDirection == DIRECTION_LEFT) { moveSnakeToLeft(); }else if (currentSnakeDirection == DIRECTION_TOP) { moveSnakeToTop(); }else if (currentSnakeDirection == DIRECTION_BOTTOM) { moveSnakeToBottom(); } }); }
ทำปุ่มควบคุมงู
ตอนนี้งูยังควบคุมไม่ได้ เลยต้องทำปุ่มบังคับ ให้มันเลี้ยวไปตามที่เราต้องการ
คิดๆไว้ประมาณนี้
ทำปุ่มควบคุม ตามแบบ
Container buildControlButton() { return Container( padding: EdgeInsets.all(8), color: Colors.blue[200], child: Row( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Container( color: Colors.white, child: Icon(Icons.keyboard_arrow_left, size: 48), ), Container( color: Colors.white, child: Column( mainAxisSize: MainAxisSize.min, children: <Widget>[ Container( color: Colors.white, child: Icon(Icons.keyboard_arrow_up, size: 48), ), Container( color: Colors.white, child: Icon(Icons.keyboard_arrow_down, size: 48), ) ], )), Container( color: Colors.white, child: Icon(Icons.keyboard_arrow_right, size: 48), ) ]), ); }
เอาแท็บปุ่มควบคุมใส่ไว้ด้านล่างของจอ
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Column(children: <Widget>[ Expanded( child: Container( color: Colors.blue[50], child: Center( child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: Colors.blue[800]), padding: EdgeInsets.all(8), margin: EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: buildMap()))))), buildControlButton() ])); }
เพื่อความสวยงาม ใส่ขอบให้มนขึ้น
... Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.only( topLeft: Radius.circular(8), topRight: Radius.circular(8))), child: Icon(Icons.keyboard_arrow_up, size: 48), )
ควบคุมงู
ทำปุ่มแล้ว ก็เพิ่ม event ให้กับปุ่ม นั่นก็คือ GestureDetector
โดยกำหนด state ใหม่ให้กับ currentSnakeDirection นั่นเอง ทำให้ครบทั้ง 4 ปุ่ม
Container buildControlButton() { return Container( padding: EdgeInsets.all(8), color: Colors.blue[200], child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ GestureDetector( onTap: () { setState(() { currentSnakeDirection = DIRECTION_TOP; }); }, child: Container( ... )), Container( child: Column( mainAxisSize: MainAxisSize.min, children: <Widget>[ GestureDetector( onTap: () { setState(() { currentSnakeDirection = DIRECTION_TOP; }); }, child: Container( ... )), GestureDetector( onTap: () { setState(() { currentSnakeDirection = DIRECTION_BOTTOM; }); }, child: Container( ... )) ], )), GestureDetector( onTap: () { setState(() { currentSnakeDirection = DIRECTION_RIGHT; }); }, child: Container( ... ) ]), ); }
ทีนี้จะเห็นว่าโค้ดส่วนของปุ่ม มันซ้ำซ้อนกันเยอะมาก เพราะมี 4 ปุ่ม แต่ละปุ่มก็ทำงานลักษณะเดียวกัน ต่างแค่หน้าตานิดหน่อย ผมจึงรวบให้เหลือ method เดียว
GestureDetector buildControlDirectionButton(IconData icon, int direction) { return GestureDetector( onTap: () { setState(() { currentSnakeDirection = direction; }); }, child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.only( topLeft: direction == DIRECTION_TOP || direction == DIRECTION_LEFT ? Radius.circular(8) : Radius.circular(0), topRight: direction == DIRECTION_TOP || direction == DIRECTION_RIGHT ? Radius.circular(8) : Radius.circular(0), bottomLeft: direction == DIRECTION_LEFT || direction == DIRECTION_BOTTOM ? Radius.circular(8) : Radius.circular(0), bottomRight: direction == DIRECTION_RIGHT || direction == DIRECTION_BOTTOM ? Radius.circular(8) : Radius.circular(0))), child: Icon(icon, size: 48), )); }
แถบควบคุมก็จะเหลือแค่นี้ อ่านง่ายขึ้น
Container buildControlButton() { return Container( padding: EdgeInsets.all(8), color: Colors.blue[200], child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ buildControlDirectionButton(Icons.keyboard_arrow_left, DIRECTION_LEFT), Container( child: Column( mainAxisSize: MainAxisSize.min, children: <Widget>[ buildControlDirectionButton( Icons.keyboard_arrow_up, DIRECTION_TOP), buildControlDirectionButton( Icons.keyboard_arrow_down, DIRECTION_BOTTOM), ], )), buildControlDirectionButton( Icons.keyboard_arrow_right, DIRECTION_RIGHT), ]), ); }
ลองรัน งูเลื้อยได้แล้ว แล้วก็ควบคุมให้เลี้ยวได้ด้วย
สุ่มอาหารลงในหน้าจอ
ต่อมาก็ มาทำให้สุ่มอาหาร ให้งูเลื้อยมากิน แล้วจะตัวยาวขึ้น
ประกาศตัวแปร FEED แล้วก็ MAX_COUNT_FEED
static const int GROUND = 0; static const int SNAKE = 1; static const int FEED = 2; static const int MAX_COUNT_FEED = 4;
คลาสของอาหาร
class Feed { int row; int col; Feed({this.row, this.col}); }
ประกาศ list ที่เก็บตำแหน่งของ อาหารบน map
List<Feed> listFeed;
กำหนดค่าเริ่มต้นให้ อาหาร โดยเริ่มต้นจะยังไม่มี
void initFeed() { listFeed = List(); }
ที่ initState ก็ initFeed() ด้วย
@override void initState() { initMap(); initSnake(); initFeed(); timer = Timer.periodic(Duration(milliseconds: 500), (Timer t) => process()); super.initState(); }
เพิ่มให้ map unit วาดสีของอาหาร เป็นสีเขียว จะได้ต่างจากอันอื่น
Color getColorBackground(int row, int col) { if (map[row][col] == SNAKE) { return Colors.pink[500]; } else if (map[row][col] == GROUND) { return Colors.pink[100]; } else if (map[row][col] == FEED){ return Colors.green[600]; } }
สร้าง method สุ่ม อาหารลงใน map โดยจะสุ่มลง เฉพาะตำแหน่งที่เป็นพื้นที่ว่างเท่านั้น
randomFeedOnMap() { if (listFeed.length < MAX_COUNT_FEED) { Random rd = Random(); int rowFeed = rd.nextInt(COUNT_ROW); int colFeed = rd.nextInt(COUNT_COL); if (map[rowFeed][colFeed] == GROUND) { Feed feed = Feed(row: rowFeed, col: colFeed); listFeed.add(feed); map[rowFeed][colFeed] = FEED; } } }
โดยการสุ่มอาหาร จะใส่ไว้ที่ process() ดังนั้นมันจะสุ่มมาเรื่อยๆ จนกว่าจะเต็ม MAX ที่กำหนด
process() { setState(() { ... randomFeedOnMap(); }); }
ทำให้งูกินอาหารได้
พองูเลื้อยมากินอาหารแล้ว อาหารจะต้องหายไป พร้อมกับงูตัวยาวขึ้น
วิธีการก็คือ เช็คว่าหัวงูปัจจุบัน มันเป็นตำแหน่งเดียวกับอาหารหรือไม่
โดย getFeedEatenBySnake() จะ return อาหารที่ถูกงูกิน
Feed getFeedEatenBySnake() { SnakePart snakeHead = snake.first; for (Feed feed in listFeed) { if (snakeHead.row == feed.row && snakeHead.col == feed.col) { return feed; } } return null; } void removeFeedOnMap(Feed feed) { listFeed.removeWhere((f) { return f.row == feed.row && f.col == feed.col; }); }
ทีนี้ ตอนงูกินอาหาร หางมันจะต้องงอกเพิ่ม ซึ่งปกติ กรณีที่ไม่มีอาหาร หางมันจะถูกเปลี่ยนค่าเป็น GROUND
เราก็แค่เปลี่ยนค่ากลับมาเป็น SNAKE โดย method move จะ return ตำแหน่งหางกลับมา
SnakePart moveSnakeToRight() { ... if (snakeHead.col + 1 < COUNT_COL) { ... return snakeTail; } else { // Head of Snake attack the wall. } }
แล้วก็เอาหางมาเปลี่ยนค่ากลับเป็น SNAKE
void eatFeed(SnakePart snakeTail, Feed feed) { map[snakeTail.row][snakeTail.col] = SNAKE; map[feed.row][feed.col] = SNAKE; snake.add(snakeTail); removeFeedOnMap(feed); }
สรุป process()
process() { if (playing) { setState(() { SnakePart snakeTailRemoved; if (currentSnakeDirection == DIRECTION_RIGHT) { snakeTailRemoved = moveSnakeToRight(); } else if (currentSnakeDirection == DIRECTION_LEFT) { snakeTailRemoved = moveSnakeToLeft(); } else if (currentSnakeDirection == DIRECTION_TOP) { snakeTailRemoved = moveSnakeToTop(); } else if (currentSnakeDirection == DIRECTION_BOTTOM) { snakeTailRemoved = moveSnakeToBottom(); } randomFeedOnMap(); Feed feed = getFeedEatenBySnake(); if (feed != null) { eatFeed(snakeTailRemoved, feed); } }); } }
มาลองเล่นกัน
เพื่อความสนุกก็เพิ่ม ให้อาหารสุ่มมากที่สุด 20 อัน งูเร็วขึ้นโดยเคลื่อนที่ทุกๆ 0.2 วินาที
static const int MAX_COUNT_FEED = 20; static const int SPEED_SNAKE = 200;
กำหนดค่าใหม่ให้ timer
@override void initState() { ... timer = Timer.periodic(Duration(milliseconds: SPEED_SNAKE), (Timer t) => process()); super.initState(); }
ทำแต้มคะแนน
ต่อมา ทำตัวหนังสือแสดงแต้มคะแนน แล้วก็พองูกินอาหารก็ +10
ประกาศตัวแปรคะแนน
int score = 0;
เพิ่ม Widget สำหรับแสดงคะแนนด้านบนของ map
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Column(children: <Widget>[ Expanded( child: Container( color: Colors.blue[50], child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text( "Score : $score", style: TextStyle( fontSize: 28, color: Colors.blue[700], fontWeight: FontWeight.bold), ), buildMapContainer() ]))), buildControlButton() ])); } Container buildMapContainer() { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: Colors.blue[800]), padding: EdgeInsets.all(8), margin: EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: buildMap())); }
เพิ่มให้ตอนงูกินอาหาร คะแนน +10
void eatFeed(SnakePart snakeTail, Feed feed) { score += 10; ... }
ปรับแต่งอาหาร
เพื่อความสวยงามมาตกแต่งเรื่องความสวยงามนิดนึง
ก่อนอื่นเปลี่ยนให้อาหารเป็นกลมๆ แทนเหลี่ยมๆ
Container buildMapUnit(int row, int col) { if (map[row][col] == GROUND) { return Container( width: 10, height: 10, color: getColorBackground(row, col), ); } else if (map[row][col] == SNAKE) { return Container( width: 10, height: 10, color: getColorBackground(row, col), ); } else if (map[row][col] == FEED) { return Container( width: 10, height: 10, color: Colors.pink[100], foregroundDecoration: BoxDecoration( shape: BoxShape.circle, color: getColorBackground(row, col)), ); } }
ปรับแต่งตัวงู
ตัวงูยังเป้นเหลี่ยมๆ ซึ่งไม่สวยเท่าไหร่ เลยคิดว่าน่าจะทำให้หัวกับบหางงูเป็นขอบมนน่าจะดีกว่า
โดยแยก method การวาด map unit ออกมาเป็นกรณีๆ
Container buildMapUnit(int row, int col) { if (map[row][col] == GROUND) { ... } else if (map[row][col] == SNAKE) { SnakePart snakeHead = snake.first; SnakePart snakeTail = snake.last; if (row == snakeHead.row && col == snakeHead.col) { return buildSnakeHead(snakeHead); } else if (row == snakeTail.row && col == snakeTail.col) { return buildSnakeTail(snakeTail); } else { return Container( width: 10, height: 10, color: getColorBackground(row, col), ); } } else if (map[row][col] == FEED) { ... } }
กรณี หัวงูก็ง่ายหน่อย คือสามารถคำนวณจาก ทิศทางของงูได้นั่นเอง
เช่น ถ้างูกำลังไปทางขวา หัวของงูจะขอบมน เฉพาะบนขวา กับล่างขวานั่นเอง
ก็ทำให้ครบทุกด้าน
Container buildSnakeHead(SnakePart snakeHead) { return Container( width: 10, height: 10, foregroundDecoration: BoxDecoration( borderRadius: BorderRadius.only( topLeft: currentSnakeDirection == DIRECTION_TOP || currentSnakeDirection == DIRECTION_LEFT ? Radius.circular(12) : Radius.circular(0), topRight: currentSnakeDirection == DIRECTION_TOP || currentSnakeDirection == DIRECTION_RIGHT ? Radius.circular(12) : Radius.circular(0), bottomLeft: currentSnakeDirection == DIRECTION_LEFT || currentSnakeDirection == DIRECTION_BOTTOM ? Radius.circular(12) : Radius.circular(0), bottomRight: currentSnakeDirection == DIRECTION_RIGHT || currentSnakeDirection == DIRECTION_BOTTOM ? Radius.circular(12) : Radius.circular(0)), color: getColorBackground(snakeHead.row, snakeHead.col), ), color: Colors.pink[100], ); }
ที่ยากหน่อยคือส่วนหาง ซึ่งมันไม่สามารถคำนวณจากทิศทางของงูได้ แต่ต้องคำนวณจากท่อนรองสุดท้ายของงู
พูดไปก็คิดไม่ออก ผมเลยต้องวาดออกมาเป็นรูป 4 กรณี
รวมแล้วจะได้ประมาณนี้
Container buildSnakeTail(SnakePart snakeTail) { SnakePart snakeBeforeTail = snake[snake.length - 2]; return Container( width: 10, height: 10, foregroundDecoration: BoxDecoration( borderRadius: BorderRadius.only( topLeft: (snakeBeforeTail.row == snakeTail.row && snakeBeforeTail.col > snakeTail.col) || (snakeBeforeTail.col == snakeTail.col && snakeBeforeTail.row > snakeTail.row) ? Radius.circular(12) : Radius.circular(0), topRight: (snakeBeforeTail.col == snakeTail.col && snakeBeforeTail.row > snakeTail.row) || (snakeBeforeTail.row == snakeTail.row && snakeBeforeTail.col < snakeTail.col) ? Radius.circular(12) : Radius.circular(0), bottomLeft: (snakeBeforeTail.row == snakeTail.row && snakeBeforeTail.col > snakeTail.col) || (snakeBeforeTail.col == snakeTail.col && snakeBeforeTail.row < snakeTail.row) ? Radius.circular(12) : Radius.circular(0), bottomRight: (snakeBeforeTail.col == snakeTail.col && snakeBeforeTail.row < snakeTail.row) || (snakeBeforeTail.row == snakeTail.row && snakeBeforeTail.col < snakeTail.col) ? Radius.circular(12) : Radius.circular(0)), color: getColorBackground(snakeTail.row, snakeTail.col), ), color: Colors.pink[100], ); }
ได้แล้ว
ไหนก็ไหนๆแล้ว เพิ่มหัวงูให้มีสีแตกต่างจากตัวงูหน่อยนึงละกัน
Color colorSnakeHead = Colors.pink[900];
Container buildSnakeHead(SnakePart snakeHead) { return Container( ... color: colorSnakeHead, ), color: Colors.pink[100], ); }
งูชนกำแพง
วิธีจบเกมแบบนึง คือ การที่งูหัวชนกำแพงหรือขอบด้านข้าง แต่บางเกมผมก็เคยเห็นมีแบบให้งูทะลุไปโผล่อีกด้านได้ ไว้เดี๋ยวค่อยทำนะ ตอนนี้กำหนดให้งูชนกำแพง คือจบเกม
ประกาศตัวแปรใน State เก็บค่าว่าเกมจบหรือยัง
bool playing = true;
ทำ method สำหรับจบเกม แล้วแสดง dialog Game Over
void gameOver(){ playing = false; showGameOverDialog(); } void showGameOverDialog() { // flutter defined function showDialog( context: context, builder: (BuildContext context) { // return object of type Dialog return AlertDialog( content: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[ Text("Game Over ):", style: TextStyle( fontSize: 32, color: Colors.pink[800], fontWeight: FontWeight.bold)), RaisedButton(padding: EdgeInsets.symmetric(horizontal: 18,vertical: 6), color: Colors.yellow[800], child: Text("Play again", style: TextStyle( fontSize: 22, color: Colors.white, fontWeight: FontWeight.bold)), onPressed: () { Navigator.of(context).pop(); restart(); }, ) ]) ); }, ); }
แล้วก็ทำปุ่ม Play again พอกดแล้วจะ restart เกม เราก็แค่ set เป็นค่าเริ่มต้นให้หมด
void restart() { setState(() { initMap(); initSnake(); initFeed(); playing =true; score = 0; currentSnakeDirection = DIRECTION_RIGHT; }); }
งูชนตัวเอง
อีกแบบ คือ การที่งูวิ่งไปชนหางตัวเอง ก็จบเกมเหมือนกัน ซึ่งตอนนี้ยังไม่ได้เขียนรองรับกรณีนี้
ถ้ารันก็จะเป็นแบบนี้
วิธีการป้องกันก็คือตอนที่งู ขยับไปในทิศทางนั้น เราจะต้องรู้ตำแหน่งหัวใหม่ใน unit map เราก็แค่เอาตำแหน่งนั้นไปเช็คว่ามันคือชิ้นส่วนของงูหรือไม่ ถ้าใช่ ก็คือ Game Over
SnakePart moveSnakeToRight() { SnakePart snakeHead = snake.first; if (snakeHead.col + 1 < COUNT_COL) { SnakePart snakeNewHead = SnakePart(row: snakeHead.row, col: snakeHead.col + 1); if( map[snakeNewHead.row][snakeNewHead.col] == SNAKE){ gameOver(); return null; } ...
ป้องกันการกดย้อนทาง
อีกปัญหาคือการกดในทิศที่ย้อนหาตัวเอง ซึ่งมันไม่ควรจะทำได้
วิธีการก็แค่ไปดัก ตอนกดปุ่ม ถ้าเป็นทิศตรงข้ามกับทิศปัจจุบันก็ไม่ต้องทำอะไร
GestureDetector buildControlDirectionButton(IconData icon, int direction) { return GestureDetector( onTap: () { if (currentSnakeDirection == DIRECTION_TOP && direction == DIRECTION_BOTTOM) { return; } if (currentSnakeDirection == DIRECTION_BOTTOM && direction == DIRECTION_TOP) { return; } if (currentSnakeDirection == DIRECTION_RIGHT && direction == DIRECTION_LEFT) { return; } if (currentSnakeDirection == DIRECTION_LEFT && direction == DIRECTION_RIGHT) { return; } ...
โหมดไม่มีกำแพง
ประกาศตัวแปร
bool enableWall = false;
ที่ method การเคลื่อนที่ของงู จะมีส่วนที่เช็คแนวขอบนอก Map อยู่
ปกติ ถ้าออกนอก map จะ game over แต่ถ้าโหมดไม่มีกำแพง ก็แค่ย้ายหัวไปอยู่ฝั่งตรงข้ามแทน
SnakePart moveSnakeToBottom() { SnakePart snakeHead = snake.first; if (snakeHead.row + 1 < COUNT_ROW) { ... } else { // Head of Snake attack the wall. if(enableWall) { gameOver(); }else{ moveSnakeToCrossTop(snakeHead.col); } } }
ย้ายหัวงูไปโผล่ด้านตรงข้าม เช่นชนขอบล่างก็ไปโผล่ด้านบนนั่นเอง
SnakePart moveSnakeToCrossTop(int col) { SnakePart snakeNewHead = SnakePart(row: 0, col: col); snake.insert(0, snakeNewHead); map[snakeNewHead.row][snakeNewHead.col] = SNAKE; SnakePart snakeTail = snake.last; map[snakeTail.row][snakeTail.col] = GROUND; snake.removeLast(); return snakeTail; }
ลองรัน
จบแล้ว
ขอจบเท่านี้ก่อนครับ ทำไปทำมา ก็เยอะอยู่เหมือนกันน่ะเนี่ย หวังว่าจะมีประโยชน์ครับ สำหรับใครที่กำลังหัด Flutter บอกเลยว่าสนุกมาก เกมงูนี้ผมเขียนเล่นๆแค่ไม่ถึงครึ่งวันเท่านั้น
โค้ดอยู่บน Github แล้วนะ
https://github.com/benznest/snake_game_flutter
สำหรับโปรเจคเล่นๆครั้งหน้าจะเป็นอะไรนั้นก็ยังไม่รู้เหมือนกัน