Flutter Project : สร้างเกม Othello ด้วย Flutter
สวัสดีครับ บล็อกนี้จะบันทึกการทำเกม Othello ด้วย Flutter ครับ บล็อกนี้เป็นบล็อกบันทึกการทำเกมบล็อกที่ 6 แล้ว ที่ผมเขียนการทำบล็อกเหล่านี้ก็เพราะว่าอยากลองหาอะไรทำว่างๆ คลายเครียด ได้คิด logic ของเกม มันสนุกดี และเป็นการฝึก Flutter ไปด้วย ซึ่งจริงๆแล้วเกมที่ผ่านมาที่ทำก็เป็นเกมง่ายๆ หลักการคล้ายๆกันครับ ซึ่งเกม Othello ก็ไม่ยากเลย ใช้เวลาเขียนไม่กี่ชั่วโมงเท่านั้น มาดูว่าเป็นอย่างไรบ้าง
ก่อนหน้านี้เป็น เกม 2048 ครับ ใครสนใจตามไปอ่านได้
ประวัติของเกม Othello
เผื่อใครสงสัยแบบผมว่า ชื่อมันมาจากอะไร ผมก็อป wiki มาให้ละ
โอเทลโล่ เป็นหมากกระดานที่ถูกคิดค้นในราวปี ค.ศ. 1880 โดยชาวอังกฤษ นามว่า ลูอิส วอเตอร์แมน แรกเริ่มมีชื่อว่า รีเวอร์ซี่ (Reversi) ต่อมาในปี ค.ศ. 1898 บริษัท ราเวนส์เบอร์เกอร์ (Ravensburger) ประเทศเยอรมันได้ทำการซื้อลิขสิทธิ์ และทำการจำหน่ายหมากกระดานชนิดนี้เป็นครั้งแรก ในชื่อเกมว่า รีเวอร์ซี่
ต่อมาในราวปี ค.ศ. 1970 ชาวญี่ปุ่น โกโร่ ฮาเซกาว่า (Goro Hasegawa) ได้ทำการปรับเปลี่ยนกฎ และกติกาใหม่ โดยได้รับแรงบันดาลใจ มาจากหมากล้อม และละครที่สร้างจากบทประพันธ์ของ วิลเลี่ยม เช็คสเปียร์ (William Shakesphere) ที่มีเนื้อหาเกี่ยวกับโศกนาฏกรรมของคนดำ และคนขาวจึงเป็นที่มาของชื่อหมากกระดาน “โอเทลโล่” ตามชื่อของละครดังกล่าว
ในปี ค.ศ. 1973 โกโร่ ฮาเซกาว่า ได้ทำการจำหน่ายหมากกระดานโอเทลโล่ โดยใช้ชื่อ “โอเทลโล่” เป็นครั้งแรก จัดจำหน่ายโดยบริษัท ซึคุดะ ออริจินัล (Tsukada Original) หมากกระดานโอเทลโล่จึงเริ่มเผยแพร่ และเป็นที่นิยมตั้งแต่นั้นเป็นต้นมา
https://th.wikipedia.org/wiki/%E0%B9%82%E0%B8%AD%E0%B9%80%E0%B8%97%E0%B8%A5%E0%B9%82%E0%B8%A5%E0%B9%88
เริ่มต้น
New Flutter Project
เวลาเริ่มโปรเจคใหม่ ทำหน้า splash screen จะเป็นการสร้างขวัญกำลังใจ ฮ่าๆ
import 'package:flutter/material.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Othello', theme: ThemeData( primarySwatch: Colors.blue, ), home: MyHomePage(title: 'Othello'), ); } } 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( body: Container( color: Color(0xffecf0f1), child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text( 'Othello',style: TextStyle(fontSize: 32,fontWeight: FontWeight.bold,color: Color(0xff2c3e50)), ), Text( 'using Flutter',style: TextStyle(fontSize: 18,color: Color(0xff2c3e50)) ), ], ), )), ); } }
สร้างตาราง
เกมนี้เป็นเกมที่ใช้ตารางเป็นพื้นที่การเล่น ปกติจะใช้ตารางขนาด 8×8
กำหนดขนาดของบล็อกในตาราง
const double BLOCK_SIZE = 40;
วาดตาราง ผมก็ copy มาจากเกมก่อนๆ ฮ่าๆ
@override Widget build(BuildContext context) { return Scaffold( body: Container( color: Color(0xfffbf9f3), child: Center( child: Container( decoration: BoxDecoration( color: Color(0xff34495e), borderRadius: BorderRadius.circular(8), border: Border.all(width: 6, color: Color(0xff2c3e50))), child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: buildTable() )), )), ); } List<Row> buildTable() { List<Row> listRow = List(); for (int row = 0; row < 8; row++) { List<Widget> listCol = List(); for (int col = 0; col < 8; col++) { listCol.add(buildBlockUnit()); } Row rowWidget = Row(mainAxisSize: MainAxisSize.min, children: listCol); listRow.add(rowWidget); } return listRow; } Container buildBlockUnit() { return Container( decoration: BoxDecoration( color: Color(0xff27ae60), borderRadius: BorderRadius.circular(2), ), width: BLOCK_SIZE, height: BLOCK_SIZE, margin: EdgeInsets.all(2), ); }
ทีนี้ลองสมมุติ ว่ามีหมากสีขาวในตาราง โดยหมากจะเป็นวงกลม
ใน Flutter จะวาดวงกลม แค่ใช้ shape: BoxShape.circle ง่ายมากๆ
Container buildBlockUnit() { return Container( decoration: BoxDecoration( color: Color(0xff27ae60), borderRadius: BorderRadius.circular(2), ), width: BLOCK_SIZE, height: BLOCK_SIZE, margin: EdgeInsets.all(2), child: Center(child: buildItem()), ); } Widget buildItem(){ return Container(width: 30, height: 30, decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.white)); }
สร้าง BlockUnit
ต่อมาสร้างคลาสเพื่อให้ตารางเปลี่ยนค่าตามต้องการ แต่ละช่องชื่อว่า BlockUnit
class BlockUnit{ int value; BlockUnit({this.value = 0}); }
โดย BlockUnit มี 3 ค่า คือ ว่าง สีขาว สีดำ
const int ITEM_EMPTY = 0; const int ITEM_WHITE = 1; const int ITEM_BLACK = 2;
สร้าง BlockUnit เป็น List แบบ 2 มิติ ชื่อ table
กำหนดค่าเริ่มต้นใน initState ให้ตารางทุกช่องเป็น ค่าว่าง
List<List<BlockUnit>> table; @override void initState() { initTable(); super.initState(); } void initTable() { table = List(); for (int row = 0; row < 8; row++) { List<BlockUnit> list = List(); for (int col = 0; col < 8; col++) { list.add(BlockUnit(value: ITEM_EMPTY)); } table.add(list); } }
ปรับให้ตาราง เชื่อมกับตัวแปรใน state
Container buildBlockUnit(int row ,int col) { return Container( decoration: BoxDecoration( color: Color(0xff27ae60), borderRadius: BorderRadius.circular(2), ), width: BLOCK_SIZE, height: BLOCK_SIZE, margin: EdgeInsets.all(2), child: Center(child: buildItem(table[row][col])), ); } Widget buildItem(BlockUnit block){ if(block.value == ITEM_BLACK){ return Container(width: 30, height: 30, decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.black)); }else if(block.value == ITEM_WHITE){ return Container(width: 30, height: 30, decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.white)); } return Container(); }
ทีนี้ลองสุ่ม ค่าหมาก ขาว ดำ ลงไปในตาราง
void initTable() { table = List(); for (int row = 0; row < 8; row++) { List<BlockUnit> list = List(); for (int col = 0; col < 8; col++) { list.add(BlockUnit(value: randomItem())); } table.add(list); } } int randomItem(){ Random random = Random(); return random.nextInt(3); }
แสดงว่าตอนนี้ ตารางเชื่อมกับค่าตัวแปรใน state แล้ว
การเริ่มต้นของกระดาน
กำหนดตารางเริ่มต้นของเกม คือให้ทุกช่องว่างเปล่า
แล้วที่กลางตารางมีหมากขาว ดำ อันละ 2 ตัววางสลับกัน
@override void initState() { initTable(); initTableItems(); super.initState(); } void initTable() { table = List(); for (int row = 0; row < 8; row++) { List<BlockUnit> list = List(); for (int col = 0; col < 8; col++) { list.add(BlockUnit(value: ITEM_EMPTY)); } table.add(list); } } void initTableItems() { table[3][3].value = ITEM_WHITE; table[4][3].value = ITEM_BLACK; table[3][4].value = ITEM_BLACK; table[4][4].value = ITEM_WHITE; }
การเดินหมาก
ส่วนไฮไลด์ คือการทำส่วนการเดินหมาก
กำหนดให้เริ่มต้น คือ สีดำเดินก่อน
int currentTurn = ITEM_BLACK;
ใส่ GestureDetector ให้กับ BlockUnit เพื่อจะได้ดักการกดในช่องของตาราง
Widget buildBlockUnit(int row, int col) { return GestureDetector( onTap: () { setState(() { pasteItemToTable(row, col, currentTurn); }); }, child: Container( decoration: BoxDecoration( color: Color(0xff27ae60), borderRadius: BorderRadius.circular(2), ), width: BLOCK_SIZE, height: BLOCK_SIZE, margin: EdgeInsets.all(2), child: Center(child: buildItem(table[row][col])), )); }
โดยจะต้องกดที่ช่องว่างเท่านั้น เพราะเป็นการวางหมาก จากนั้นก็ให้เช็คเงื่อนไขตามกฏของเกม Othello
ตอนนี้ผมจะเช็คเฉพาะทางขวาก่อน กติกาเกม คือ ให้เปลี่ยนหมากสีของคนอื่นเป็นของเรา จนกว่าจะเจอหมากของเรา
ดังนั้น ไม่ใช่ช่องว่างทุกช่องที่เราวางได้ จะต้องเป็นช่องว่างที่วางแล้ว มีการเปลี่ยนหมากเป็นของเราเท่านั้น
ลูปเช็ค (ตอนนี้เฉพาะทางขวา) ถ้าเจอหมากของคนอื่นก็เก็บหมากไว้ใน list แต่ถ้าเจอเป็นของเราก็หยุดแค่นั้น แล้ว return list กลับไป แต่ถ้าเป็นช่องว่าง หรือสุดขอบก็ return list เปล่าๆ เมื่อทำเสร็จก็มาดูว่า list ผลลัพธ์ว่างเปล่าหรือไม่ ถ้าว่างแสดงว่าไม่มี หมากไหนที่เปลี่ยนเป็นของเราเลย หมากจะวางตรงนี้ไม่ได้ แต่ถ้า list ไม่ว่างก็เปลี่ยนหมากเป็นสีของเรา พร้อมกับเปลี่ยน turn
bool pasteItemToTable(int row, int col, int item) { if(table[row][col].value == ITEM_EMPTY){ List<Coordinate> listCoordinateRight = checkRight(row, col, item); if(listCoordinateRight.isNotEmpty){ table[row][col].value = item; inverseItemFromList(listCoordinateRight); currentTurn = inverseItem(currentTurn); return true; } } return false; } List<Coordinate> checkRight(int row, int col, int item) { List<Coordinate> list = List(); if (col + 1 < 8) { for (int c = col + 1; c < 8; c++) { if (table[row][c].value == item) { return list; } else if (table[row][c].value == ITEM_EMPTY) { return List(); } else { list.add(Coordinate(row: row, col: c)); } } } return List(); } void inverseItemFromList(List<Coordinate> list) { for (Coordinate c in list) { table[c.row][c.col].value = inverseItem(table[c.row][c.col].value); } } int inverseItem(int item) { if (item == ITEM_WHITE) { return ITEM_BLACK; } else if (item == ITEM_BLACK) { return ITEM_WHITE; } return item; }
เริ่มต้นสีดำวางก่อน และตอนนี้เช็คเงื่อนไขทางขวาเท่านั้น
ทำเช็คเงื่อนไขด้านล่างบ้าง ใช้หลักการเดิม
List<Coordinate> checkDown(int row, int col, int item) { List<Coordinate> list = List(); if (row + 1 < 8) { for (int r = row + 1; r < 8; r++) { if (table[r][col].value == item) { return list; }else if(table[r][col].value == ITEM_EMPTY ){ return List(); } else { list.add(Coordinate(row: r, col: col)); } } } return List(); }
ได้ผลลัพธ์ก็เอา list มารวมกัน
bool pasteItemToTable(int row, int col, int item) { List<Coordinate> listCoordinate = List(); listCoordinate.addAll(checkRight(row, col, item)); listCoordinate.addAll(checkDown(row, col, item)); if(listCoordinate.isNotEmpty){ table[row][col].value = item; inverseItemFromList(listCoordinate); currentTurn = inverseItem(currentTurn); return true; } return false; }
ดังนั้นเราต้องเช็คทั้งหมด 8 ทิศ
ซ้าย ขวา บน ล่าง
บนซ้าย บนขวา
ล่างซ้าย ล่างขวา
กรณีเช็คบนซ้าย
List<Coordinate> checkUpLeft(int row, int col, int item) { List<Coordinate> list = List(); if (row - 1 >= 0 && col - 1 >= 0) { int r = row - 1; int c = col - 1; while (r >= 0 && c >= 0) { if (table[r][c].value == item) { return list; } else if (table[r][c].value == ITEM_EMPTY) { return List(); } else { list.add(Coordinate(row: r, col: c)); } r--; c--; } } return List(); }
เมื่อเขียนครบทุกทิศทาง
bool pasteItemToTable(int row, int col, int item) { if (table[row][col].value == ITEM_EMPTY) { List<Coordinate> listCoordinate = List(); listCoordinate.addAll(checkRight(row, col, item)); listCoordinate.addAll(checkDown(row, col, item)); listCoordinate.addAll(checkLeft(row, col, item)); listCoordinate.addAll(checkUp(row, col, item)); listCoordinate.addAll(checkUpLeft(row, col, item)); listCoordinate.addAll(checkUpRight(row, col, item)); listCoordinate.addAll(checkDownLeft(row, col, item)); listCoordinate.addAll(checkDownRight(row, col, item)); if (listCoordinate.isNotEmpty) { table[row][col].value = item; inverseItemFromList(listCoordinate); currentTurn = inverseItem(currentTurn); return true; } } return false; }
ตอนนี้สลับกันเดินหมากได้แล้ว
สร้างเมนูเกม
เพิ่มเมนูเกมด้านบน โดยมีปุ่มเริ่มเกมใหม่ กับแสดงว่า Turn ปัจจุบันเป็นของหมากสีอะไร
ส่วน method เริ่มเกมใหม่ค่อยเขียนทีหลัง
Container buildMenu() { return Container( padding: EdgeInsets.only(top: 36, bottom: 12, left: 16, right: 16), color: Color(0xff34495e), child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ GestureDetector(onTap: () { // }, child: Container(constraints: BoxConstraints(minWidth: 120), decoration: BoxDecoration(color: Color(0xff27ae60), borderRadius: BorderRadius.circular(4)), padding: EdgeInsets.all(12), child: Column(children: <Widget>[ Text("New Game", style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white)) ]))), Expanded(child: Container()), Container(constraints: BoxConstraints(minWidth: 120), decoration: BoxDecoration(color: Color(0xffbbada0), borderRadius: BorderRadius.circular(4)), padding: EdgeInsets.all(8), child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text("TURN", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)), Container(margin: EdgeInsets.only(left: 8), child: buildItem(BlockUnit(value: currentTurn))) ])) ]), ); }
เพิ่มเมนูด้านบนของตารางเกม
@override Widget build(BuildContext context) { return Scaffold( body: Container( color: Color(0xffecf0f1), child: Column(children: <Widget>[ buildMenu(), ... ])), ); }
แสดงจำนวนหมาก
ต่อมาทำ แถบแสดงจำนวนหมาก
ประกาศตัวแปร count
int countItemWhite = 0; int countItemBlack= 0;
สร้างแท็บสำหรับแสดงจำนวนหมาก
Widget buildScoreTab() { return Row(mainAxisSize: MainAxisSize.max, children: <Widget>[ Expanded(child: Container(color: Color(0xff34495e), child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Container(padding: EdgeInsets.all(16), child: buildItem(BlockUnit(value: ITEM_WHITE))), Text("x $countItemWhite", style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold, color: Colors.white)) ]))), Expanded(child: Container(color: Color(0xffbdc3c7), child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Container(padding: EdgeInsets.all(16), child: buildItem(BlockUnit(value: ITEM_BLACK))), Text("x $countItemBlack", style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold, color: Colors.black)) ]))) ]); }
เอาไปวางไว้ด้านล่างของจอ
@override Widget build(BuildContext context) { return Scaffold( body: Container( color: Color(0xffecf0f1), child: Column(children: <Widget>[ buildMenu(), ... , buildScoreTab() ])), ); }
void updateCountItem(){ countItemBlack =0; countItemWhite =0; for(int row=0;row<8;row++) { for (int col = 0; col < 8; col++) { if(table[row][col].value == ITEM_BLACK){ countItemBlack++; }else if(table[row][col].value == ITEM_WHITE){ countItemWhite++; } } } }
bool pasteItemToTable(int row, int col, int item) { if (table[row][col].value == ITEM_EMPTY) { ... if (listCoordinate.isNotEmpty) { ... updateCountItem(); return true; } } return false; }
ปุ่มเริ่มเกมใหม่
ที่เหลือก็แค่ กหนดค่าเร่มต้นให้เกม เมื่อกดปุ่มเริ่มเกมใหม่
void restart() { setState(() { countItemWhite = 0; countItemBlack = 0; currentTurn = ITEM_BLACK; initTable(); initTableItems(); }); }
จบแล้ว
วันนี้สนุกแค่นี้ก่อนครับ สรุปเกม Othello ง่ายกว่าเกมทีผ่านมาเยอะเลย เพราะ copy โค้ดมา ไม่ใช่! เพราะกติกามันน้อย โปรเจคคลายเครียดครั้งหน้าจะทำเกมอะไร เดี๋ยวคิดดูอีกทีครับ
โค้ดอยู่ที่ Guthub นะ
https://github.com/benznest/othello-game-flutter