web analytics

Flutter Project : สร้างเกมงู (Snake Game) ด้วย Flutter

cove

สวัสดีครับ จากบล็อกตอนที่แล้ว ผมได้เขียนเกม OX ด้วย Flutter ไปแล้ว วันนี้นึกสนุกอยากทำเกมงูขึ้นมา อยู่ๆทุกอย่างก็เข้ามาในหัวทันทีทันใด ในเมื่อคิดแล้วก็ทำเลยละกัน เป็นที่มาของบันทึกบล็อกนี้ครับ เกมงู เขียนด้วย Flutter แบบเบาๆครับ

เกม OX ก่อนหน้านี้

Flutter Code : ทำเกม OX (Tic-Tac-Toe) ด้วย Flutter

 

เริ่มต้น

สร้างโปรเจคเปล่าๆขึ้นมา เตรียมพร้อม

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),))));
  }
}

1

 

สร้างหน้าจอ

ผมขอเรียกพื้นที่ ที่ให้เจ้างูเลื้อยไปมา ว่า 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],
      );
}

 

เพื่อความสวยงามใส่ขอบให้เรียบร้อย

2

 

เริ่มเกม

เกมงูนั้นเป็นเกมที่รันไปเรื่อยๆจนกว่าจะจบ งูจะเลื้อยไปเรื่อยๆในทิศทางที่กำหนด ดังนั้นมันเลยต้องเป็นแอปที่ทำงานตลอดเวลา
นั่นก็คือ 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),
      );

 

รองรัน

a1

 

ทำตัวงู

ดูเหมือนว่าเรื่องของตำแหน่งงูเป็นเรื่องหลักๆของเกม ดังนั้นเลยคิดว่าควรทำ 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.
        }
      }
    });
  }

 

จะได้แบบนี้

a2

 

ทีนี้ก็ทำให้ครบทั้งหมด 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();
      }
    });
  }

ทำปุ่มควบคุมงู

ตอนนี้งูยังควบคุมไม่ได้ เลยต้องทำปุ่มบังคับ ให้มันเลี้ยวไปตามที่เราต้องการ
คิดๆไว้ประมาณนี้

13

 

ทำปุ่มควบคุม ตามแบบ

  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()
        ]));
  }

3

 

เพื่อความสวยงาม ใส่ขอบให้มนขึ้น

              ...
              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),
                )

4

 

ควบคุมงู

ทำปุ่มแล้ว ก็เพิ่ม 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),
      ]),
    );
  }

 

ลองรัน งูเลื้อยได้แล้ว แล้วก็ควบคุมให้เลี้ยวได้ด้วย

a3

 

สุ่มอาหารลงในหน้าจอ

ต่อมาก็ มาทำให้สุ่มอาหาร ให้งูเลื้อยมากิน แล้วจะตัวยาวขึ้น
ประกาศตัวแปร 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();
    });
  }

4

 

ทำให้งูกินอาหารได้

พองูเลื้อยมากินอาหารแล้ว อาหารจะต้องหายไป พร้อมกับงูตัวยาวขึ้น
วิธีการก็คือ เช็คว่าหัวงูปัจจุบัน มันเป็นตำแหน่งเดียวกับอาหารหรือไม่
โดย 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();
  }

a4

 

ทำแต้มคะแนน

ต่อมา ทำตัวหนังสือแสดงแต้มคะแนน แล้วก็พองูกินอาหารก็ +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;
    ...
  }

a5

 

ปรับแต่งอาหาร

เพื่อความสวยงามมาตกแต่งเรื่องความสวยงามนิดนึง
ก่อนอื่นเปลี่ยนให้อาหารเป็นกลมๆ แทนเหลี่ยมๆ

  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)),
      );
    }
  }

5

 

ปรับแต่งตัวงู

ตัวงูยังเป้นเหลี่ยมๆ ซึ่งไม่สวยเท่าไหร่ เลยคิดว่าน่าจะทำให้หัวกับบหางงูเป็นขอบมนน่าจะดีกว่า
โดยแยก 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 กรณี

9 10

11 12

รวมแล้วจะได้ประมาณนี้

  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],
    );
  }

ได้แล้ว

6 7

 

ไหนก็ไหนๆแล้ว เพิ่มหัวงูให้มีสีแตกต่างจากตัวงูหน่อยนึงละกัน

Color colorSnakeHead = Colors.pink[900];
  Container buildSnakeHead(SnakePart snakeHead) {
    return Container(
        ...
        color: colorSnakeHead,
      ),
      color: Colors.pink[100],
    );
  }

8

 

งูชนกำแพง

วิธีจบเกมแบบนึง คือ การที่งูหัวชนกำแพงหรือขอบด้านข้าง แต่บางเกมผมก็เคยเห็นมีแบบให้งูทะลุไปโผล่อีกด้านได้ ไว้เดี๋ยวค่อยทำนะ ตอนนี้กำหนดให้งูชนกำแพง คือจบเกม

ประกาศตัวแปรใน 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;
    });
  }

a7

 

 

งูชนตัวเอง

อีกแบบ คือ การที่งูวิ่งไปชนหางตัวเอง ก็จบเกมเหมือนกัน ซึ่งตอนนี้ยังไม่ได้เขียนรองรับกรณีนี้
ถ้ารันก็จะเป็นแบบนี้

a6

 

วิธีการป้องกันก็คือตอนที่งู ขยับไปในทิศทางนั้น เราจะต้องรู้ตำแหน่งหัวใหม่ใน 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;
      }

      ...

a8

 

ป้องกันการกดย้อนทาง

อีกปัญหาคือการกดในทิศที่ย้อนหาตัวเอง ซึ่งมันไม่ควรจะทำได้

a9

 

วิธีการก็แค่ไปดัก ตอนกดปุ่ม ถ้าเป็นทิศตรงข้ามกับทิศปัจจุบันก็ไม่ต้องทำอะไร

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;
          }

          ...

a10

 

 

โหมดไม่มีกำแพง

ประกาศตัวแปร

 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;
  }

ลองรัน

a11 a12

 

จบแล้ว

ขอจบเท่านี้ก่อนครับ ทำไปทำมา ก็เยอะอยู่เหมือนกันน่ะเนี่ย หวังว่าจะมีประโยชน์ครับ สำหรับใครที่กำลังหัด Flutter บอกเลยว่าสนุกมาก เกมงูนี้ผมเขียนเล่นๆแค่ไม่ถึงครึ่งวันเท่านั้น

โค้ดอยู่บน Github แล้วนะ

https://github.com/benznest/snake_game_flutter

สำหรับโปรเจคเล่นๆครั้งหน้าจะเป็นอะไรนั้นก็ยังไม่รู้เหมือนกัน