web analytics

Flutter Project : สร้างเกม Sudoku ด้วย Flutter

cover

สวัสดีครับ จากบล็อกตอนที่แล้ว ผมได้เขียนเกมงู เขียนด้วย Flutter  ครับ โดยผมจะพยายามทำโปรเจค Flutter แบบเบาๆเล่นๆ สนุกๆคลายเครียดครับ เป็นการเรียนรู้ flutter ไปด้วย บล็อกนี้ถึงคิวของเกม Sudoku ครับ เพราะอยากลองทำโปรเจคเกี่ยวกับ Draggable (การลาก-วาง) ครับ ซึ่งผมก็ได้ศึกษาแล้วก็เขียนบล็อกเรื่อง draggable ไปแล้ว แต่เพื่อให้เข้าใจมากขึ้น เลยอยากลองนำมาใช้กับแอปจริงๆสักหน่อย ก็เลยทำโปรเจคนี้ แล้วก็เขียนบันทึกการทำแอปนี้ไปด้วยครับ

สรุปการทำ Draggable แบบพื้นฐานใน Flutter

Flutter Code : การทำลาก-วาง (Draggable) ใน Flutter

 

เกมงู  (The Snake Game) ด้วย Flutter ก่อนหน้านี้

Flutter Project : ทำเกมงู (Snake Game) ด้วย Flutter

 

เริ่มต้น

มาคิดคร่าวๆ เกี่ยวกับเกมก่อนครับ โดยเกม Sudoku นี้ ผมจะทำขนาด 9×9 โดยแบ่งย่อยเป็น 9 ส่วน โดยแต่ละส่วนจะมีขนาด 3×3 ซึ่งมันก็คือตารางแบบมาตรฐานที่เล่นกันบ่อยๆ

วิธีการที่ผมเลือกใช้คือ ผมจะไม่ใช้ array 2 มิติ ที่เก็บข้อมูลแบบ 9×9
ครั้งนี้ผมจะเก็บแบบ 4 มิติ คือ 3×3 ซ้อนกันเป็นตาราง 3×3 อีกที ที่ทำแบบนี้เพราะว่าจะได้แบ่งเขตแบบเท่าๆกันออกเป็น 9 ส่วน
แต่ละส่วนเรียกว่า SubTable ใน subTable แบ่งเป็นช่องๆ ที่เก็บตัวเลข ขอเรียกว่า channel โดยจะมีขนาด 3×3 ทั้งหมดทั้งมวลรวมกันเป็น Table

อันนี้คือแบบที่คิดเอาไว้ ไม่รู้คิดถูกคิดผิด เดี๋ยวมาดูกัน ฮ่าๆ

 

1

 

สร้างตาราง Sudoku

เตรียมตัวแปรค่าคงที่ เอาไว้ โดยจะเน้น subTable เป็นหลัก

const int COUNT_ROW_SUB_TABLE = 3;
const int COUNT_COL_SUB_TABLE = 3;

 

สร้างคลาส Table กับ SubTable เพื่อเก็บข้อมูล
จากนั้นเขียยน method กำหนดค่าเริ่มต้น
Table กำหนด SubTable 3×3
แล้วใน SubTable กำหนด Channel 3×3 โดย channel แต่ละมีค่า 0-9 (0 คือไม่ไม่มีค่า)
ซึ่งตอนนี้ผมขอกำหนดเป็นค่า col เพราะเดี๋ยวจะลองเอาค่ามาแสดงในตาราง

class SudokuSubTable {
  List<List<int>> subTable;

  init() {
    subTable = List();
    for (int row = 1; row <= COUNT_ROW_SUB_TABLE; row++) {
      List<int> list = List();
      for (int col = 1; col <= COUNT_COL_SUB_TABLE; col++) {
        list.add(col);
      }
      subTable.add(list);
    }
  }
}

class SudokuTable {
  List<List<SudokuSubTable>> table;

  init() {
    table = List();
    for (int row = 1; row <= COUNT_ROW_SUB_TABLE; row++) {
      List<SudokuSubTable> list = List();
      for (int col = 1; col <= COUNT_COL_SUB_TABLE; col++) {
        SudokuSubTable subTable = SudokuSubTable();
        subTable.init();
        list.add(subTable);
      }
      table.add(list);
    }
  }
}

 

เตรีมมค่าสีต่างๆ

  Color colorBorderTable = Colors.white;
  Color colorBackgroundApp = Colors.blue[100];
  Color colorBackgroundChannelEmpty1 = Color(0xffe3e3e3);
  Color colorBackgroundNumberTab = Colors.white;
  Color colorTextNumber = Colors.white;
  Color colorBackgroundChannelValue = Colors.blue[700];

 

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

  SudokuTable sudokuTable;

 

กำหนดค่าเริ่มต้นให้กับ Instance  ของ Table

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

  void initSudokuTable() {
    sudokuTable = SudokuTable();
    sudokuTable.init();
  }

 

ที build ก็วาด widget เป็นลักษณะคล้ายตารางตามที่ออกแบบไว้
คือแบ่งเป็น 9 ส่วนใหญ่ๆ

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: Container(
            constraints: BoxConstraints.expand(),
            color: colorBackgroundApp,
            child: Column(children: <Widget>[
              Expanded(
                  child: Center(
                      child: Column(
                          mainAxisSize: MainAxisSize.min,
                          children: <Widget>[
                    Container(
                      decoration: BoxDecoration(
                          color: colorBorderTable,
                          borderRadius: BorderRadius.all(Radius.circular(8))),
                      padding: EdgeInsets.all(6),
                      child: Column(
                          mainAxisSize: MainAxisSize.min,
                          mainAxisAlignment: MainAxisAlignment.center,
                          children: <Widget>[
                            Row(
                              mainAxisSize: MainAxisSize.min,
                              mainAxisAlignment: MainAxisAlignment.center,
                              children: <Widget>[
                                buildSubTable(sudokuTable.table[0][0],
                                    colorBackgroundChannelEmpty1),
                                buildSubTable(sudokuTable.table[0][1],
                                    colorBackgroundChannelEmpty2),
                                buildSubTable(sudokuTable.table[0][2],
                                    colorBackgroundChannelEmpty1),
                              ],
                            ),
                            Row(
                              mainAxisSize: MainAxisSize.min,
                              mainAxisAlignment: MainAxisAlignment.center,
                              children: <Widget>[
                                buildSubTable(sudokuTable.table[1][0],
                                    colorBackgroundChannelEmpty2),
                                buildSubTable(sudokuTable.table[1][1],
                                    colorBackgroundChannelEmpty1),
                                buildSubTable(sudokuTable.table[1][2],
                                    colorBackgroundChannelEmpty2),
                              ],
                            ),
                            Row(
                              mainAxisSize: MainAxisSize.min,
                              mainAxisAlignment: MainAxisAlignment.center,
                              children: <Widget>[
                                buildSubTable(sudokuTable.table[2][0],
                                    colorBackgroundChannelEmpty1),
                                buildSubTable(sudokuTable.table[2][1],
                                    colorBackgroundChannelEmpty2),
                                buildSubTable(sudokuTable.table[2][2],
                                    colorBackgroundChannelEmpty1),
                              ],
                            )
                          ]),
                    )
                  ]))),
            ])));
  }

 

แต่ละส่วนของ Table ก็วาด 3 แถว

  Container buildSubTable(SudokuSubTable subTable, Color color) {
    return Container(
        padding: EdgeInsets.all(1),
        child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
          Row(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.center,
              children: buildRowChannel(subTable.subTable[0], 0, color)),
          Row(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.center,
              children: buildRowChannel(subTable.subTable[1], 1, color)),
          Row(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.center,
              children: buildRowChannel(subTable.subTable[2], 2, color))
        ]));
  }

 

แต่ละแถววาด 3 column และแต่ละ column ขนาด 40×40

  List<Widget> buildRowChannel(
      List<int> dataRowChanel, int rowChannel, Color color) {
    List<Widget> listWidget = List();
    for (int col = 0; col < 3; col++) {
      Widget widget = buildChannel(dataRowChanel[col], color);
      listWidget.add(widget);
    }
    return listWidget;
  }
  Widget buildChannel(int value, Color color) => GestureDetector(
          onTap: () {},
          child: Container(
            margin: EdgeInsets.all(1),
            width: 40,
            height: 40,
            decoration: BoxDecoration(
                color: colorBackgroundChannelEmpty1,
                borderRadius: BorderRadius.all(Radius.circular(4))),
          ));

ได้ตารางแล้ว แบ่งเป็น 9 ส่วนแบบง่ายๆ

9

 

ทำแท็บตัวเลข

ขั้นตอนถัดมา มาทำแท็บตัวเลขด้านล่าง เพื่อจะได้สามารถลากตัวเลขไปใสในตาราง sudoku ได้
สร้าง method สำหรับ build widget แสดงแท็บตัวเลข 1-9 เรียงในแนวนอน

  List<Widget> buildNumberListTab() {
    List<Widget> listWidget = List();
    for (int i = 1; i <= 9; i++) {
      Widget widget = Container(
          padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8),
          margin: EdgeInsets.symmetric(horizontal: 4),
          decoration: BoxDecoration(
              color: colorBackgroundChannelValue,
              borderRadius: BorderRadius.all(Radius.circular(8))),
          child: Text("$i",
              style: TextStyle(
                  fontSize: 24,
                  color: colorTextNumber,
                  fontWeight: FontWeight.w900)));
      listWidget.add(widget);
    }
    return listWidget;
  }

 

เอาไปแท็บไปไว้ด้านล่างของตาราง

  @override
  Widget build(BuildContext context) {
              ...
              ,
              Container(
                  padding: EdgeInsets.all(12),
                  color: colorBackgroundNumberTab,
                  child: Row(
                      mainAxisAlignment: MainAxisAlignment.center,
                      crossAxisAlignment: CrossAxisAlignment.center,
                      children: buildNumberListTab()))
            ])));
  }

8

 

แสดงค่าตัวเลขลงในช่อง

ทีนี้ มาเขียนส่วนการแสดงค่าของตัวแปร table ลงใน Widget
โดยแต่ละช่องมันก็คือ channel ก็แค่เพิ่ม Text เข้าไปใน Container ของมัน
โดยแยกเป็น 2 กรณีคือ กรณีค่าเป็น 0 จะแสดงพื้นเปล่าๆแทน ถ้ามีค่าก็แสดงตัวเลข

  Widget buildChannel(int value, Color color){
    if(value == 0){
      return GestureDetector(
          onTap: () {},
          child: Container(
            margin: EdgeInsets.all(1),
            width: 40,
            height: 40,
            decoration: BoxDecoration(
                color: colorBackgroundSubTable1,
                borderRadius: BorderRadius.all(Radius.circular(4))),
          ));
    }else{
      return GestureDetector(
          onTap: () {},
          child: Container(
            margin: EdgeInsets.all(1),
            width: 40,
            height: 40,
            decoration: BoxDecoration(
                color: colorBackgroundNumberBox,
                borderRadius: BorderRadius.all(Radius.circular(4))),
            child: Center(child:Text(value.toString(),
                style: TextStyle(color: Colors.white, fontSize: 20,fontWeight: FontWeight.w900))),
          ));
    }
  }

10

 

ลองเขียนให้ทำการสุ่มเลข 0-9 ลงใน table เพื่อให้เห็นว่า ค่า 0 จะแสดงช่องว่าง

class SudokuSubTable {
  List<List<int>> subTable;

  init() {
    subTable = List();
    for (int row = 1; row <= COUNT_ROW_SUB_TABLE; row++) {
      List<int> list = List();
      for (int col = 1; col <= COUNT_COL_SUB_TABLE; col++) {
        list.add(randomNumber());
      }
      subTable.add(list);
    }
  }

  int randomNumber() {
    Random r = Random();
    return r.nextInt(10);
  }
}

11

 

เพิ่ม Draggable ให้กับตัวเลขในแท็บ

มาถึงส่วนยากของเกม คือส่วนของการลากตัวเลขไปใส่ในตาราง จริงๆก็ไม่ยากถ้าทำด้วย Flutter นะ
เริ่มจากใส่ Draggable ให้กับ Container ทั้ง 9 ตัวของแท็บตัวเลข ซึ่งเจ้า Draggable มันต้องกำหนด
child คือ Widget ที่แสดงตอนยังไม่ได้ทำการลาก
feedback คือ Widget ขณะกำลังลาก
ซึ่งทั้งสองอย่าง ในกรณีที่เราทำอยู่ มันแสดงผลเหมือนกัน ดังนั้น เราก็แยกออกมาเป็น method

  List<Widget> buildNumberListTab() {
    List<Widget> listWidget = List();
    for (int i = 1; i <= 9; i++) {
      Widget widget = buildNumberBoxWithDraggable(i);
      listWidget.add(widget);
    }
    return listWidget;
  }

  Widget buildNumberBoxWithDraggable(int i) {
    return Draggable(
      child: buildNumberBox(i),
      feedback:
          Material(type: MaterialType.transparency, child: buildNumberBox(i)),
      data: i,
    );
  }

  Container buildNumberBox(int i) {
    return Container(
        padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8),
        margin: EdgeInsets.symmetric(horizontal: 4),
        decoration: BoxDecoration(
            color: colorBackgroundChannelValue,
            borderRadius: BorderRadius.all(Radius.circular(8))),
        child: Text("$i",
            style: TextStyle(
                fontSize: 24,
                color: colorTextNumber,
                fontWeight: FontWeight.w900)));
  }
}

 

ทีนี้จะสามารถลาก ตัวเลขจากแท็บด้านล่างได้แล้ว

a1

 

แต่ยังไม่สามารถนำค่ามาใส่ในตารางได้ นั่นก็เพราะเรายังไม่ได้ผูก Target กับ Channel ในตารางนั่นเอง
ดังนั้นเราจะต้องทำการใส่ Target ให้กับ channel ทั้งหมด

ขอเริ่มจากล้างค่าในตารางออกให้หมด เป็นค่า 0

class SudokuSubTable {
  List<List<int>> subTable;

  init() {
    subTable = List();
    for (int row = 1; row <= COUNT_ROW_SUB_TABLE; row++) {
      List<int> list = List();
      for (int col = 1; col <= COUNT_COL_SUB_TABLE; col++) {
        list.add(0);
      }
      subTable.add(list);
    }
  }

8

 

ต่อมาก็ใส่ DragTarget ให้กับ Container แต่ละ channel
การใช้งาน DragTarget คือ กำหนด
builder = Widget ที่จะแสดงตามปกติ หรือขณะมีอะไรผ่าน เช็คได้ด้วย candidateData โดยกรณีของเราคือแสดงช่องว่างเปล่าๆปกติ
onWillAccept = เช็ค data ว่าใช่ที่ต้องการหรือไม่ กรณีของเรา ข้อมูลคือตัวเลข 1-9
onAccept = เมื่อ data เป็นตามที่ต้องการแล้วให้ทำอะไร กรณีของเรา คือเปลี่ยนค่าในตาราง table เป็นข้อมูลที่ส่งมา

คำถามคือ จะอัพเดทข้อมูลใน table ยังไง ในเมื่อ method นี้ ไม่รู้อะไรเลยนอกจาก ตัวเลขและสี

  Widget buildChannel(int value, Color color) {
    if (value == 0) {
      return DragTarget(builder: (BuildContext context, List<int> candidateData, List<dynamic> rejectedData){
        return Container(
            margin: EdgeInsets.all(1),
            width: 40,
            height: 40,
            decoration: BoxDecoration(
                color: colorBackgroundChannelEmpty1,
                borderRadius: BorderRadius.all(Radius.circular(4))),
          );
      },onWillAccept: (data){
        return data >= 0 && data <= 9;
      },onAccept: (data){
        // ???? 
      });
    }

 

มันก็แก้ได้หลายวิธีนะ เช่นส่งค่าตำแหน่งเข้ามาใน channel นี้เลย แต่ทว่าผมใช้การเก็บข้อมูลแบบ 4 มิติ
ถ้าส่งเข้ามาก็ดูจะยุ่งยากไปหน่อย ผมขอเลือกใช้วิธีทำ mehod callback ละกัน

 callback  Widget buildChannel(
       int value, Color color, Function(int) onNumberAccept) {
      ...
      , onAccept: (data) {
        onNumberAccept(data);
      });
    }
    ...
}

 

จากนั้น พอได้ callback มาแล้ว ที่ buildRowChannel มีข้อมูลเพียงพอจะเข้าถึงตำแหน่งใน table ได้แล้ว ซึ่งต้องใช้ 4 ค่า พร้อมกับทำใน setState

  List<Widget> buildRowChannel(
      SudokuSubTable subTable, int rowChannel, Color color) {
    List<int> dataRowChanel = subTable.subTable[rowChannel];
    List<Widget> listWidget = List();
    for (int col = 0; col < 3; col++) {
      Widget widget =
          buildChannel(rowChannel, dataRowChanel[col], color, (data) {
        setState(() {
          // update table with data.
          sudokuTable.table[subTable.indexRowInTable][subTable.indexColInTable]
              .subTable[rowChannel][col] = data;
        });
      });
      listWidget.add(widget);
    }
    return listWidget;
  }

 

ตอนนี้สามารถ ลากตัวเลขในแท็บมาใส่ในตารางได้แล้ว

a2

 

เพิ่ม Draggable ให้กับตัวเลขในตาราง

ตอนนี้เราลากตัวเลขในแท็บมาใส่ในตารางได้ แต่ยังลากตัวเลขในตารางไปในตำแหน่งอื่นๆ เพื่อย้ายตำแหน่งไม่ได้
วิธีการ คือ ใส่ Draggable ให้กับ Channel ในกรณีที่มันมีค่า > 0 ดังนั้นพอมันมีค่าใน channel มันก็จะลากได้

  Widget buildChannel(
      int rowChannel, int value, Color color, Function(int) onNumberAccept) {
    if (value == 0) {
      ...
    } else {
      return Draggable(
          child: buildChannelValue(value),
          feedback:  Material(type: MaterialType.transparency, child: buildChannelValue(value)),
          childWhenDragging:buildChannelEmpty());
    }
  }

  Container buildChannelEmpty() {
    return Container(
      margin: EdgeInsets.all(1),
      width: 40,
      height: 40,
      decoration: BoxDecoration(
          color: colorBackgroundChannelEmpty1,
          borderRadius: BorderRadius.all(Radius.circular(4))),
    );
  }

 

ลากได้แล้ว แต่ยังย้ายไม่ได้ เพราะยังไม่ได้ผูกกับ Target นั่นเอง

a3

 

วิธีการผูกกับ Target นั้นง่ายนิดเดียว มันคือการเพิ่ม data ลงไป แค่นี้!!
เพราะว่า channel เปล่าๆมันมี DragTarget ที่เราทำไว้สำหรับรอตัวเลขจากแท็บอยู่แล้ว
ซึ่งเงื่อนไขที่มันจะยอมรับคือตัวเลข 1-9 ซึ่งกรณีนี้ก็คือ 1-9 ดังนั้นก็ใช้ร่วมกันได้เลย ง่ายจัง

  Widget buildChannel(
      int rowChannel, int value, Color color, Function(int) onNumberAccept) {
    if (value == 0) {
      ...
    } else {
      return Draggable(
          child: buildChannelValue(value),
          feedback:  Material(type: MaterialType.transparency, child: buildChannelValue(value)),
          childWhenDragging:buildChannelEmpty(),
          data: value);
    }
  }

 

ตอนนี้ย้ายเลขในตารางได้แล้ว แต่ตำแหน่งเดิมของมันยังมีค่าเดิมค้างอยู่ ยังไม่ได้ถูกลบออกไป

a4

 

วิธีการคือต้องไปกำหนดค่า 0 ให้กับตำแหน่งเดิมใน table
ก็ทำแบบเดียวกับตอน numberAccept คือเพิ่ม callback สำหรับลบ

โดยจะเรียกเมื่อ onDragComplete พูดง่ายๆก็คือพอลากแล้ววางเสร็จ (วางแล้วถูก Target Accept ด้วยนะ)

  Widget buildChannel(int rowChannel, int value, Color color,
      {Function(int) onNumberAccept, Function() onRemove}) {
    if (value == 0) {
       ...
    } else {
      return Draggable(
        child: buildChannelValue(value),
        feedback: Material(
            type: MaterialType.transparency, child: buildChannelValue(value)),
        childWhenDragging: buildChannelEmpty(),
        onDragCompleted: () {
          onRemove();
        },
        data: value,
      );
    }
  }

 

ที่ buildRowChannel พอ onRemove ถูกเรียกก็ทำการกำหนดค่า 0 ให้กับ table ในตำแหน่งที่ถูกลากออกไป

  List<Widget> buildRowChannel(
      SudokuSubTable subTable, int rowChannel, Color color) {
    List<int> dataRowChanel = subTable.subTable[rowChannel];
    List<Widget> listWidget = List();
    for (int col = 0; col < 3; col++) {
      Widget widget = buildChannel(rowChannel, dataRowChanel[col], color,
          onNumberAccept: (data) {
        setState(() {
          sudokuTable.table[subTable.indexRowInTable][subTable.indexColInTable]
              .subTable[rowChannel][col] = data;
        });
      }, onRemove: () {
        setState(() {
          sudokuTable.table[subTable.indexRowInTable][subTable.indexColInTable]
              .subTable[rowChannel][col] = VALUE_NONE;  // Add here
        });
      });
      listWidget.add(widget);
    }
    return listWidget;
  }

 

ทีนี้ การลากตัวเลขของเราในตารางก็ลื่นใหลแล้ว

a5

 

ทำ Draggable สำหรับลบตัวเลข

ต่อมาทำ ให้สามารถลบตัวเลขออกจากตารางได้ โดยจะลบเมื่อลากแล้วไปวางนอกตาราง
วิธีการ คือ เรียก onRemove ที่ทำไว้ก่อนหน้านี้ ที่ onDraggableCanceled จบเกม ง่ายโครต

  Widget buildChannel(int rowChannel, int value, Color color,
      {Function(int) onNumberAccept, Function() onRemove}) {
    if (value == 0) {
       ...
    } else {
      return Draggable(
        ...
        onDraggableCanceled: (v, o) {
          onRemove();
        },
        data: value,
      );
    }
  }

a1

 

กำหนดค่าเริ่มต้นตาราง Sudoku

มาลองนำข้อมูลจากเกมจริงๆมาใช้บ้าง โดยปกติเกมจะมีตัวเลขเริ่มต้นมาให้ซึ่ง ตัวเลขนี้จะขยับไม่ได้

พอถึงตรงนี้แสดงว่า channel เราจะเก็บแค่ค่า int ไม่ได้แล้ว มันต้องมากกว่านั้น
ผมเลย สร้าง class SudokuChannel ขึ้นมา

class SudokuChannel {
  bool enableMove;
  int value;

  SudokuChannel({this.value, this.enableMove = true});
}

 

ดังนั้น SubTable จะเก็บค่าเป็น List<List<SudokuChannel>> แทน
แล้วก็เดี๋ยวเราต้องเพิ่มค่าเลขตอนเริ่มเกมลงใน SubTable ก็เขียน method setValue เตรียมไว้

lass SudokuSubTable {
  int indexRowInTable;
  int indexColInTable;
  List<List<SudokuChannel>> subTable;

  SudokuSubTable({this.indexRowInTable, this.indexColInTable});

  init() {
    subTable = List();
    for (int row = 1; row <= COUNT_ROW_SUB_TABLE; row++) {
      List<SudokuChannel> list = List();
      for (int col = 1; col <= COUNT_COL_SUB_TABLE; col++) {
        list.add(SudokuChannel(value: 0));
      }
      subTable.add(list);
    }
  }

  setValue({int row = 0, int col = 0, int value = 0, bool enableMove = true}) {
    subTable[row][col] = SudokuChannel(value: value, enableMove: enableMove);
  }

  int randomNumber() {
    Random r = Random();
    return r.nextInt(10);
  }
}

 

ตัวเลที่เกมเริ่มมาให้จะขยับไม่ได้ ดังนั้นสีของ channel ก็ควรจะต่างกับ channel ปกติหน่อยนึง

  Widget buildChannelValueFixed(int value) {
    return Container(
      margin: EdgeInsets.all(1),
      width: 40,
      height: 40,
      decoration: BoxDecoration(
          color: colorBackgroundChannelValueFixed,
          borderRadius: BorderRadius.all(Radius.circular(4))),
      child: Center(
          child: Text(value.toString(),
              style: TextStyle(
                  color: Colors.white,
                  fontSize: 20,
                  fontWeight: FontWeight.w900))),
    );
  }

 

วิธีการเช็คว่า channel นี้ขยับหรือสามารถลากได้มัย ก็แค่เช็คจาก enableMove

  Widget buildChannel(int rowChannel, SudokuChannel channel, Color color,
      {Function(int) onNumberAccept, Function() onRemove}) {
    if (channel.value == 0) {
      return DragTarget(
          ...
    } else {
      if (channel.enableMove) {
        return Draggable(
          ...
      } else {
        return buildChannelValueFixed(channel.value);
      }
    }
  }

 

หลายจุดที่มีการกำหนดค่าใหม่ลงใน channel จะใช้แบบ int เฉยๆไม่ได้แล้ว ต้องใช้แบบ object Channel แทน
นี่แหละคือผลของการไม่วางแผน 5555

 List<Widget> buildRowChannel(SudokuSubTable subTable, int rowChannel, Color color) {
    ...
        buildChannel(rowChannel, dataRowChanel[col], color,
          onNumberAccept: (data) {
            setState(() {
              sudokuTable.table[subTable.indexRowInTable][subTable.indexColInTable]
                  .subTable[rowChannel][col] = SudokuChannel(value: data);
            });
          }, onRemove: () {
            setState(() {
              sudokuTable.table[subTable.indexRowInTable][subTable.indexColInTable]
                  .subTable[rowChannel][col] = SudokuChannel();
            });
          });
     ...
  }

 

ต่อมาผมก็ไปหา ตารางเกม Sudoku จริงๆมา แบบนี้ จะลองเพิ่มตัวเลขเริ่มเกมลงไปในตาราง

13

 

วิธีการเพิ่มค่าลงไปที่ตารางสามารถใช้วิธี setValue ตรงๆไปที่ table ซึ่งก็ต้องระบุตำแหน่งที่ต้องการ จริงๆจะทำเป็นตัวแปร array แล้วเขียน function เพื่อ loop ค่าก็ได้ ยิ่งถ้าใช้การเก็บแบบ 9×9 ก็คงจะง่ายกว่า
อันนี้คือข้อเสียของ การเก็บข้อมูลแบบ 4 มิติ คือมันยุ่งยากกว่า

void initTableFixed() {
    SudokuSubTable subTableLeftTop = sudokuTable.table[0][0];
    subTableLeftTop.setValue(row: 0, col: 2, value: 5, enableMove: false);
    subTableLeftTop.setValue(row: 1, col: 0, value: 1, enableMove: false);
    subTableLeftTop.setValue(row: 1, col: 2, value: 2, enableMove: false);

    SudokuSubTable subTableTop = sudokuTable.table[0][1];
    subTableTop.setValue(row: 0, col: 0, value: 9, enableMove: false);
    subTableTop.setValue(row: 1, col: 1, value: 6, enableMove: false);
    subTableTop.setValue(row: 1, col: 2, value: 8, enableMove: false);
    subTableTop.setValue(row: 2, col: 0, value: 2, enableMove: false);

    SudokuSubTable subTableRightTop = sudokuTable.table[0][2];
    subTableRightTop.setValue(row: 0, col: 1, value: 1, enableMove: false);
    subTableRightTop.setValue(row: 1, col: 0, value: 4, enableMove: false);
    subTableRightTop.setValue(row: 2, col: 0, value: 7, enableMove: false);

    SudokuSubTable subTableLeft= sudokuTable.table[1][0];
    subTableLeft.setValue(row: 0, col: 0, value: 2, enableMove: false);
    subTableLeft.setValue(row: 0, col: 1, value: 1, enableMove: false);
    subTableLeft.setValue(row: 1, col: 1, value: 4, enableMove: false);
    subTableLeft.setValue(row: 1, col: 2, value: 8, enableMove: false);
    subTableLeft.setValue(row: 2, col: 1, value: 5, enableMove: false);

    SudokuSubTable subTableCenter= sudokuTable.table[1][1];
    subTableCenter.setValue(row: 0, col: 0, value: 8, enableMove: false);
    subTableCenter.setValue(row: 0, col: 2, value: 6, enableMove: false);
    subTableCenter.setValue(row: 1, col: 1, value: 9, enableMove: false);
    subTableCenter.setValue(row: 2, col: 0, value: 4, enableMove: false);
    subTableCenter.setValue(row: 2, col: 2, value: 3, enableMove: false);

    SudokuSubTable subTableRight = sudokuTable.table[1][2];
    subTableRight.setValue(row: 0, col: 1, value: 3, enableMove: false);
    subTableRight.setValue(row: 1, col: 0, value: 6, enableMove: false);
    subTableRight.setValue(row: 1, col: 1, value: 5, enableMove: false);
    subTableRight.setValue(row: 2, col: 1, value: 7, enableMove: false);
    subTableRight.setValue(row: 2, col: 2, value: 8, enableMove: false);

    SudokuSubTable subTableBottomLeft = sudokuTable.table[2][0];
    subTableBottomLeft.setValue(row: 0, col: 2, value: 4, enableMove: false);
    subTableBottomLeft.setValue(row: 1, col: 2, value: 3, enableMove: false);
    subTableBottomLeft.setValue(row: 2, col: 1, value: 6, enableMove: false);

    SudokuSubTable subTableBottom = sudokuTable.table[2][1];
    subTableBottom.setValue(row: 0, col: 2, value: 2, enableMove: false);
    subTableBottom.setValue(row: 1, col: 0, value: 6, enableMove: false);
    subTableBottom.setValue(row: 1, col: 1, value: 8, enableMove: false);
    subTableBottom.setValue(row: 2, col: 2, value: 4, enableMove: false);

    SudokuSubTable subTableBottomRight = sudokuTable.table[2][2];
    subTableBottomRight.setValue(row: 1, col: 0, value: 5, enableMove: false);
    subTableBottomRight.setValue(row: 1, col: 2, value: 1, enableMove: false);
    subTableBottomRight.setValue(row: 2, col: 0, value: 3, enableMove: false);
  }

 

เสร็จแล้วก็ init ใน initState()

  @override
  void initState() {
    initSudokuTable();
    initTableFixed();
    super.initState();
  }

 

มาแล้ว ตัวเลขเริ่มเกมที่ Fix กำหนดให้ขยับไม่ได้

14

 

ลองลากตัวเลขจากแท็บมาวาง ก็วางในตารางได้ปกติ

a2

 

เพิ่ม Draggable สำหรับทับช่องค่าเดิม

ปัญหายังมีต่อเนื่อง ตอนนี้เราไม่สามารถลากค่าในช่อง channel นึงไปทับช่อง channel นึงที่มีค่าอยู่แล้วได้ นั่นก็เพราะว่า channel ที่มีค่าอยู่มีแต่ Draggable ไม่มี DargTarget

เราก็แค่เพิ่ม DragTarget ครอบ Draggable อีกทีนึง นั่นก็หมายความว่าในกรณีนี้ มันสามารถลากก็ได้ แล้วก็เป็น target ได้ด้วยนั่นเอง

  Widget buildChannel(int rowChannel, SudokuChannel channel, Color color,
      {Function(int) onNumberAccept, Function() onRemove}) {
    if (channel.value == 0) {
      ...
    } else {
      if (channel.enableMove) {
        return DragTarget(builder: (BuildContext context, List<int> candidateData,
            List<dynamic> rejectedData) {
          return Draggable(
            child: buildChannelValue(channel.value),
            feedback: Material(
                type: MaterialType.transparency, child: buildChannelValue(channel.value)),
            childWhenDragging: buildChannelEmpty(),
            onDragCompleted: () {
              onRemove();
            },
            onDraggableCanceled: (v, o) {
              onRemove();
            },
            data: channel.value,
          );
        }, onWillAccept: (data) {
          return data >= 0 && data <= 9;
        }, onAccept: (data) {
          onNumberAccept(data);
        });
      } else {
        ...
      }
    }
  }

 

ลองลากค่าไป จะสามารถทับค่าอื่นได้แล้ว

a3

 

ทำเมนูเริ่มเกมใหม่

ทีนี้มาทำเมนู สำหรับ restart เริ่มเกมสักหน่อย โดยเมนูจะอยู่ด้านบน

  Container buildMenu() {
    return Container(
              padding: EdgeInsets.only(top: 30,bottom: 8,right: 16,left: 16),
              constraints: BoxConstraints.expand(height: 100),
              color: Colors.white,
              child: Row(mainAxisAlignment: MainAxisAlignment.center,
                  children: <Widget>[
                    Text("SUDOKU",
                        style: TextStyle(
                            color: Colors.blue[700],
                            fontSize: 30,
                            fontWeight: FontWeight.bold)),
                    Expanded(child: Container()),
                    FlatButton(color: Colors.blue[700], child: Text("New Game",
                        style: TextStyle(
                            color: Colors.white,
                            fontSize: 18)),
                      onPressed: () {
                        //
                      },)
                  ]),
            );
  }

เอาเมนูใส่ด้านบนของตาราง

 @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: Container(
            constraints: BoxConstraints.expand(),
            color: colorBackgroundApp,
            child: Column(children: <Widget>[
              buildMenu(),
              Container(height: 8,color: Colors.blue[300]),
              ...

 

ดูดีขึ้นมานิดนึง

15

ปุ่ม New game กดแล้วจะเริ่มเกมใหม่

        FlatButton(
          color: Colors.blue[700],
          child: Text("New Game",
              style: TextStyle(color: Colors.white, fontSize: 18)),
          onPressed: () {
            restart();
          },

การเริ่มเกมใหม่ก็แค่ initTable ใหม่เท่านั้นเอง

void restart() {
  setState(() {
    initSudokuTable();
    initTableFixed();
  });
}

a9

 

 

ทำ Hover ขณะลากเมื่อตัวเลขมีค่าขัดกัน

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

เพิ่ม enableWarning ให้กับ class SudokuChannel

class SudokuChannel {
  bool enableMove;
  bool enableWarning;
  int value;

  SudokuChannel(
      {this.value = 0, this.enableMove = true, this.enableWarning = false});
}

 

แล้วเพื่อให้แอปรู้ว่า ตอนนี้เรากำลังอยู่ใน mode ขณะลากเพื่อแสดง channel ได้ถูกต้อง

bool conflictMode = false;

 

เพิ่ม callback 2 อันให้กับ channel คือ onHover , onHoverEnd
และเรียก onHover  ที่ onWillAccept ของ DragTarget กรณีที่เป็นช่องว่างเท่านั้น
ส่วน onHoverEnd เรียกที่ onLeave
สรุปก็คือตอนเราลากตัวเลขผ่านช่องว่าง มันจะเข้าไปที่ onWillAccept แล้วเรียก onHover แล้วพอเราลากผ่านช่องว่างไป มันจะเข้า onLeave แล้วไปเรียก onHoverEnd

  Widget buildChannel(int rowChannel, SudokuChannel channel, Color color,
      {Function(int) onNumberAccept,
      Function() onRemove,
      Function(int) onHover,
      Function onHoverEnd}) {
    if (channel.value == 0) {
      return DragTarget(builder: (BuildContext context, List<int> candidateData,
          List<dynamic> rejectedData) {
        print("candidateData = "+candidateData.toString());
        return buildChannelEmpty();
      }, onWillAccept: (data) {
        bool accept = data >= 0 && data <= 9;
        if (accept) {
          if (!conflictMode) {
            onHover(data);
          }
        }
        return accept;
      }, onAccept: (data) {
        onNumberAccept(data);
        onHoverEnd();
      }, onLeave: (data) {
        onHoverEnd();
      });
    } else {
      if (channel.enableMove) {
        return DragTarget(builder: (BuildContext context,
            List<int> candidateData, List<dynamic> rejectedData) {
          return Draggable(
            child: buildChannelValue(channel),
            feedback: Material(
                type: MaterialType.transparency,
                child: buildChannelValue(channel)),
            childWhenDragging: buildChannelEmpty(),
            onDragCompleted: () {
              onRemove();
            },
            onDraggableCanceled: (v, o) {
              onRemove();
            },
            data: channel.value,
          );
        }, onWillAccept: (data) {
          return data >= 0 && data <= 9;
        }, onAccept: (data) {
          onNumberAccept(data);
        });
      } else {
        return buildChannelValueFixed(channel);
      }
    }
  }

 

ทีนี้ก็มาเขียน onHover , onHoverEnd ให้มันอัพเดทค่าของ enableWarning ใน table

  List<Widget> buildRowChannel(
      SudokuSubTable subTable, int rowChannel, Color color) {
    List<SudokuChannel> dataRowChanel = subTable.subTable[rowChannel];
    List<Widget> listWidget = List();
    for (int col = 0; col < 3; col++) {
      Widget widget = buildChannel(rowChannel, dataRowChanel[col], color,
          onNumberAccept: (data) {
        setState(() {
          sudokuTable.table[subTable.indexRowInTable][subTable.indexColInTable]
              .subTable[rowChannel][col] = SudokuChannel(value: data);
        });
      }, onRemove: () {
        setState(() {
          sudokuTable.table[subTable.indexRowInTable][subTable.indexColInTable]
              .subTable[rowChannel][col] = SudokuChannel();
        });
      }, onHover: (value) {
            setState(() {
             // update sudoku table about warning channel.
            });
      }, onHoverEnd: () {
        // clear all warning channel.
      });
      listWidget.add(widget);
    }
    return listWidget;
  }

เขียน method สำหรับทั้งสองกรณี
onHover จะแสดง warning อัพเดทเฉพาะ channel ที่มีค่าตรงกับ ตัวที่เรากำลังลากอยู่ เช็คเฉพาะแนวตั้งกับแนวนอนของ channel ที่ลากอยู่เท่านั้น (Conflict Mode = true)
onHoverEnd คืนค่า enableWarning เป็น false ให้กับทุก channel หมด  (Conflict Mode = false)

  void showWaringConflictChannel(int rowSubTable, int colSubTable,
      int rowChannel, int colChannel, int value) {
    // Check horizontal
    for (int i = 0; i < COUNT_ROW_SUB_TABLE; i++) {
      for (int j = 0; j < COUNT_ROW_SUB_TABLE; j++) {
        SudokuChannel channel =
            sudokuTable.table[rowSubTable][i].subTable[rowChannel][j];
        sudokuTable.table[rowSubTable][i].subTable[rowChannel][j]
            .enableWarning = channel.value == value;
        print(""+channel.value.toString());
      }
    }

    // Check vertical
    for (int i = 0; i < COUNT_COL_SUB_TABLE; i++) {
      for (int j = 0; j < COUNT_COL_SUB_TABLE; j++) {
        SudokuChannel channel =
            sudokuTable.table[i][colSubTable].subTable[j][colChannel];
        sudokuTable.table[i][colSubTable].subTable[j][colChannel]
            .enableWarning = channel.value == value;
        print(""+channel.value.toString());
      }
    }

    conflictMode = true;
  }

  void clearWaringConflictChannel() {
    // Check horizontal

    for (int i = 0; i < COUNT_ROW_SUB_TABLE; i++) {
      for (int j = 0; j < COUNT_ROW_SUB_TABLE; j++) {
        for (int k = 0; k < COUNT_ROW_SUB_TABLE; k++) {
          for (int m = 0; m< COUNT_ROW_SUB_TABLE; m++) {
            sudokuTable.table[i][j].subTable[k][m].enableWarning = false;
          }
        }
      }
    }

    conflictMode = false;
  }

 

ทีนี้ก็กำหนด method ให้กับ callback ทั้งสองตัว

      }, onHover: (value) {
            setState(() {
              showWaringConflictChannel(subTable.indexRowInTable,
                  subTable.indexColInTable, rowChannel, col, value);
            });
      }, onHoverEnd: () {
        clearWaringConflictChannel();
      });

 

สร้าง method สำหรับค่าสี กรณี warning

  Color getColorIfWarning(SudokuChannel channel,Color colorDefault) {
    if (channel.enableWarning) {
      return Colors.pink[400];
    }
    return colorDefault;
  }
  Widget buildChannelValue(SudokuChannel channel) {
    return Container(
      margin: EdgeInsets.all(1),
      width: 40,
      height: 40,
      decoration: BoxDecoration(
          color: getColorIfWarning(channel,colorBackgroundChannelValue),
          borderRadius: BorderRadius.all(Radius.circular(4))),
      child: Center(
          child: Text(channel.value.toString(),
              style: TextStyle(
                  color: Colors.white,
                  fontSize: 20,
                  fontWeight: FontWeight.w900))),
    );
  }

 

ตอนนี้ Hover ทำงานแล้ว เมื่อลากผ่านช่องว่างก็จะ warning channel ที่มีค่าเหมือนกันในแถวและหลัก
แต่ว่า พอลากไปแล้ววางนอกตางราง ค่า warning ไม่ถูกลบออก

a7

 

ดังนั้นนอกจากต้อง เคลีย enableWarning = false ทั้ง table ตอน onHoverEnd แล้ว
ต้องทำกรณีที่ลากแล้วไปปล่อยนอกตาราง หรือลากแล้ววางด้วย ก็คือกรณี onDragEnd ให้เคลียเหมือนกัน

  Widget buildNumberBoxWithDraggable(int i) {
    return Draggable(
      child: buildNumberBox(i),
      feedback:
          Material(type: MaterialType.transparency, child: buildNumberBox(i)),
      data: i,
      onDragEnd: (d){
        setState(() {
          clearWaringConflictChannel();
        });
      },
    );
  }

 

แก้ปัญหาไปอีก 1 อย่าง

a8

 

ทำ Responsive

ทำต่ออีกนิด มาลองเล่น responsive หน่อยละกันครับ
ปัญหาต่อมาคือ เกมตอนนี้ไม่รองรับหน้าจอขนาดอื่นๆ

เช่นเอาไปรันบนหน้าจอที่เล็กกว่า ก็จะมีส่วนที่เกินขอบจออกไป นั่นก็เพราะเราไป กำหนดค่าความกว้างไว้แบบคงที่ คือ 40

16

 

พอไปรันบน Tablet ก็มีปัญหา คือ ตารางมันเล็กไม่เหมาะกับขนาดจอ ตัวหนังสือก็เล็กด้วย

22

 

 

ดังนั้นเราจะมาทำ responsive กัน ให้แอปรองรับทั้งหน้าจอขนาดต่างๆ
เริ่มจากประกาศตัวแปรใน State ที่เก็บ size , fontscale

double channelSize = 0;
double fontScale = 1;

 

จากนั้นก็คำนวณ size ของ channel จากขนาดหน้าจอ ซึ่งผมเอาขนาดของจอด้านที่น้อยที่สุดมา หาร 9 เพราะ มันมี 9 ช่อง แล้วลบด้วยค่า padding ขอบต่างๆอีกนิดหน่อย แล้วแต่ความสวยงาม

@override
  Widget build(BuildContext context) {
    Size size= MediaQuery
        .of(context)
        .size;
    double shortestSize = size.shortestSide;
    double width = size.width;
    double height = size.height;

    // Tablet case
    if (shortestSize >= 600) {
      fontScale = 1.7;
      if (width > height) { // Tablet landscape
        channelSize = shortestSize / 9 - 30;
      }else{ // tablet portrait
        channelSize = shortestSize / 9 - 10;
      }
    } else { // phone case (portrait only)
      fontScale = 1;
      channelSize = shortestSize / 9 - 10;
    }
   
    ...

 

แล้วก็เอา channelSize , fontScale ไปกำหหนดให้กับ Container ใน channel

  Widget buildChannelValue(SudokuChannel channel) {
    return Container(
      margin: EdgeInsets.all(1),
      width: channelSize,
      height: channelSize,
      decoration: BoxDecoration(
          color: getColorIfWarning(channel, colorBackgroundChannelValue),
          borderRadius: BorderRadius.all(Radius.circular(4))),
      child: Center(
          child: Text(channel.value.toString(),
              textScaleFactor: fontScale,
              style: TextStyle(
                  color: Colors.white,
                  fontSize: 20,
                  fontWeight: FontWeight.w900))),
    );
  }

 

 

ได้แล้วลองรันในจอขนาดเล็กก็ใช้งานได้แล้ว

17

 

ลองรันบน Tablet ก็แสดงผลแบบถูกต้องมากขึ้น แต่ถ้าจะเอาแบบสวยๆก็คงต้องไปกำหนด พวก padding margin ต่างๆสำหรับแต่ละขนาดหน้าจอ

แต่นี่งานหยาบเอาแค่นี้พอก่อน ฮ่าๆ

23a

 

จบแล้ว

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

หวังว่าบันทึกการทำแอปนี้ของผมจะมีประโยชน์กับผู้อ่านนะครับ

 

โค้ดทั้งหมดอยู่ที่ Github

https://github.com/benznest/sudoku_game_flutter