RGB灯带做棋盘的双人五子棋游戏

发布时间 2023-06-27 14:23:48作者: 脏猫

这是我短学期的任务,做的简陋,勉强能实现主要功能,写了技术文档,记录一下。

一、 项目简介

本项目综合 arduino、flutter、node-red 以及 mqtt 协议开发一个可供电脑端和手机端共同游戏的五子棋游戏,并将游戏实时显示在 RGB 灯带组成的棋盘上。

二、 项目准备

所需的材料:ESP32 一个、WS2812 灯带共 225 个灯珠、杜邦线若干、5v 电源、变压箱、面包板。

所做的准备:搭建 MQTT 服务器、arduino 开发环境及 FastLED 库,Flutter 应用框架,node-red 节点开发环境。

三、 棋盘的制作


灯带走单总线协议,为了做成 15*15 的棋盘,需要在每 15 个灯珠处剪断,并焊接连接。并且每两行单独供电,防止走单总线串联而产生供电不足的问题,(如上图所示,第二条和第三条间只焊接数据线,供电线在第三条上连接面包板),面包板处供电连接变压箱,设定 5V,棋盘首部数据传输线连接 esp32 的 12 号引脚(可自行设定),esp32 由外部电源供电。

四、 Arduino 的开发

这里使用 ESP32 开发板,开发前请先下载并选用该开发板。

加载将使用的库:

  1. FastLED 库控制灯带的显示。
  2. WiFi 库将 ESP32 连接到热点。
  3. PubSubClient 库用于 mqtt 通信。

1. Wifi 的连接

#include<WiFi.h>//先导入库
const char* ssid = "xxxx";//WiFi的ssid和密码常量定义
const char* password = "xxxx";
void setup(){
    Serial.begin(115200);
 setup_wifi();//初始化函数调用启动wifi函数
}
void setup_wifi(){
    delay(10);
    Serial.println();
    Serial.print("Connecting to ");
    Serial.println(ssid);
    WiFi.begin(ssid,password);
    while(WiFi.status()!=WL_CONNECTED){
        delay(500);
        Serial.print(".");
    }
    Serial.println("");
    Serial.println("WiFi connected");//连接成功后显示IP信息
    Serial.println("IP address:");
    Serial.println(WiFi.localIP());
}

2. MQTT 服务器的连接

#include<PubSubClient.h>
const char* mqtt_server = "xxxx";
WiFiClient espClient;
PubSubClient client(espClient);
Void setup(){
    client.setServer(mqtt_server,1883);
    client.setCallback(callback);//设置回调函数,每次接收到信//息都执行callback函数
}
void reconnect(){
    while(!client.connected()){
        Serial.print("Attempting MQTT connection...");
        if(client.connect("ESP32Client")){
            Serial.println("connected");
            client.subscribe("player2");//订阅两个玩家的主题
        client.subscribe("player1");
        }else{
            Serial.print("failed,rc=");
            Serial.print(client.state());
            Serial.println(" try again in 5 seconds");
            delay(5000);
        }
    }
}
void loop() {
    if(!client.connected()){//客户端连接断开后执行重连
        reconnect();
    }
    client.loop();
}

3. 回调函数

回调函数中要根据接收到的消息来控制灯的显示。

#include<FastLED.h>
#define PIN 12//引脚
#define MAXLED 225//最大灯数
CRGB leds[MAXLED];//实例化
static int exist[225];//记录棋子信息,避免重复下棋
void setup(){
    FastLED.addLeds<WS2812,PIN, GRB>(leds, MAXLED);
}
void callback(char* topic,byte* payload,unsigned int length){
    int pos=0,j=0;  //pos代表棋子位置数字
    while(j<length){ //这一段的处理将字节类型的传入数据转化成int类型数字
        pos=pos*10+(int)((char)payload[j]-'0');
        Serial.print((char)payload[j]);
        j++;
    }
    if(pos==255){//设定玩家一赢时传入255
        fill_solid(leds,MAXLED, CRGB::Red);//将灯全部置为红色表面玩家一胜利
    }else if(pos==256){//玩家二赢时传入256
        fill_solid(leds,MAXLED, CRGB::Green);
    }else{
/*因为灯带数据传输的方向是一条线,所以会导致第15盏灯在第二行的最后一个位置,第二行的第一盏灯却是第29盏灯,这点在处理时必须注意逻辑*/
        if((pos/15)%2==1){
            pos=(pos/15+1)*15-1-(pos%15);
        }
//如果玩家传入的位置还未下子,就该点亮起红色且加入存在信息,再次下同样位置就不会执行
        if(strcmp(topic,"player1")==0&&exist[pos]==0){
            leds[pos]=CRGB(255,0,0);
            exist[pos]=1;
        }else if(strcmp(topic,"player2")==0&&exist[pos]==0){
            leds[pos]=CRGB(0,255,0);
            exist[pos]=1;
        }
    }
    FastLED.show();//输出灯的信息
    delay(1000);
}

全部代码如上,可借助串口监视器观察是否连接成功以及棋子的位置信息。

五、 AI-Node 上 Dashboard 的开发


棋盘节点接收 player2(手机端)的 mqtt 消息,并传出以 player1 作主题的 mqtt 消息。

这是最终做成的棋盘,点击格子下棋,player1 的棋子红色,player2 的棋子绿色。

1. 棋盘绘制

<div id="table_content" style="width: 550px; margin: 0 auto;"></div>

这是整体棋盘,设定了宽度,内部细节写在 script 标签里,目的是为了动态产生带独特 ID(位置信息)的表格元素。

<button
  type="submit"
  id="enda"
  style="width:100px;margin-left:350px;margin-top:50px;display:none"
  ng-click="send({payload:'255'})"
>
  结束游戏
</button>
<button
  type="submit"
  id="endb"
  style="width:100px;margin-left:350px;margin-top:50px;display:none"
  ng-click="send({payload:'256'})"
>
  结束游戏
</button>

两个结束按钮,当某一玩家获胜时出现对应按钮,用于宣告胜利,传给 esp32 胜利信息。通过 display:none 实现在游戏中隐藏,获胜时修改 display 值便可使其显现。

<script type="text/javascript">
var div1 = document.getElementById("table_content");
var code = '<table border=\"1px\"' + 'style=\"border-collapse: collapse;\">';
var i,j;
for (i=0;i < 15 ;i++ ){
    code+= "<tr>";
    for (j=0;j < 15 ;j++ ){
        var id;
        id = i*15+j;
//每个格子带上id值,单击发送数据即id值,同时调用clickBorder1函数,改变格子颜色。
        code += "<td width=\"30px\" height=\"30px\" id=\"" + id + "\"ng-click=\"send({payload:"+id+"})\" onclick='clickBorder1(" + id + ");'>";
        code += "</td>";
    }
    code+="</tr>";
}
    code += "</table>";
    div1.innerHTML = code;

将 html 标签写在 js 中,更加灵活便捷。

2. 落子函数

(以玩家 1 为例,玩家 2 同理)

var ids = new Array() //该数组记录棋格上有无棋子
function clickBorder1(id) {
  for (var i = 0; i < ids.length; i++) {
    if (ids[i] == id) {
      alert('此处已落子!')
      return
    }
  }
  document.getElementById(id).style.background = '#f00'
  document.getElementById(id).style.color = '#f00'
  document.getElementById(id).innerHTML = 'O'
  //用O和X表示棋子信息,方便判断胜利条件,设定字体颜色和背景相同即可。
  ids.push(id) //用ids数组记录棋格棋子有无
  iswina(Math.round(id / 15), Math.round(id % 15))
  //该函数判断是否胜利,参数为此棋子的行列位置。
}

3. 胜利判断

每次记录最后一个下的棋子的行列位置,通过判断其八个方向有无足够棋子达成五连珠来判断胜利。

function iswina(i, j) {
  var count = [0, 0, 0, 0, 0, 0, 0, 0] //各个方向已有相邻棋子数
  var state = [1, 1, 1, 1, 1, 1, 1, 1] //各个方向若无子或其他子就赋值为2终止该方向的判定。
  for (var step = 1; step < 5; step++) {
    //设定步长,最多四格
    if (state[0] == 1 && i - step >= 0 && j - step >= 0) {
      if (
        document.getElementById((i - step) * 15 + j - step).innerHTML == 'O'
      ) {
        count[0]++
      } else {
        state[0] = 2
      }
    } //左上
    if (state[1] == 1 && i - step >= 0) {
      if (document.getElementById((i - step) * 15 + j).innerHTML == 'O') {
        count[1]++
      } else {
        state[1] = 2
      }
    } //上
    if (state[2] == 1 && i - step >= 0 && j + step < 15) {
      if (
        document.getElementById((i - step) * 15 + j + step).innerHTML == 'O'
      ) {
        count[2]++
      } else {
        state[2] = 2
      }
    } //右上
    if (state[3] == 1 && j + step < 15) {
      if (document.getElementById(i * 15 + j + step).innerHTML == 'O') {
        count[3]++
      } else {
        state[3] = 2
      }
    } //右
    if (state[4] == 1 && i + step < 15 && j + step < 15) {
      if (
        document.getElementById((i + step) * 15 + j + step).innerHTML == 'O'
      ) {
        count[4]++
      } else {
        state[4] = 2
      }
    } //右下
    if (state[5] == 1 && i + step < 15) {
      if (document.getElementById((i + step) * 15 + j).innerHTML == 'O') {
        count[5]++
      } else {
        state[5] = 2
      }
    } //下
    if (state[6] == 1 && i + step < 15 && j - step >= 0) {
      if (
        document.getElementById((i + step) * 15 + j - step).innerHTML == 'O'
      ) {
        count[6]++
      } else {
        state[6] = 2
      }
    } //左下
    if (state[7] == 1 && j - step >= 0) {
      if (document.getElementById(i * 15 + j - step).innerHTML == 'O') {
        count[7]++
      } else {
        state[7] = 2
      }
    } //左
  }
  //同一条线加上该子,大于等于5个即胜利
  if (
    count[0] + count[4] + 1 >= 5 ||
    count[1] + count[5] + 1 >= 5 ||
    count[2] + count[6] + 1 >= 5 ||
    count[3] + count[7] + 1 >= 5
  ) {
    alert('五连珠,电脑胜')
    document.getElementById('enda').style.display = 'block'
    //跳出胜利信息,并出现结束游戏按钮
  }
  return
}

4. 作用域

;(function (scope) {
  scope.$watch('msg.payload', function (newVal, oldVal) {
    console.log('- Scope.msg -')
    console.dir(scope.msg)
    clickBorder2(scope.msg['payload'])
    //接收玩家二传入的信息
  })
})(scope)

六、 Flutter 应用的开发

1. mqtt 依赖

首先新建一个项目,在 pubspec.yaml 文件 dependencies 中加入 mqtt 应用依赖

mqtt_client: ^5.5.3

然后点击右上角 Package get 获取相关的依赖.

完成之后在 lib 目录下新建一个 package,并且新建一个 dart 文件 message.dart,在里面黏上下面的代码:

import 'package:mqtt_client/mqtt_client.dart' as mqtt;

class Message {
 final String topic;
 final String message;
 final mqtt.MqttQos qos;

 Message({this.topic, this.message, this.qos});
}

这个是一个 mqtt 消息的类,里面有一个消息的主题、内容和 Qos。

2. 导入相关库

import 'package:flutter/material.dart';
import 'package:mqtt_client/mqtt_client.dart' as mqtt;
import 'dart:async';
import 'models/message.dart';
import 'btnsingle.dart';//定义棋格类的文件,后面编写
void main() => runApp(MyApp());
//箭头函数运行Myapp()
class MyApp extends StatelessWidget{
@override
Widget build(BuildContext context) {
   return MaterialApp(
     debugShowCheckedModeBanner: false,
     title: 'Gomoku',
     home: MyHomePage(title:'Gomoku'),
   );
 }
}
class MyHomePage extends StatefulWidget{
 MyHomePage({Key key,this.title}) : super(key:key);
 final String title;
 @override
_MyHomePageState createState() => _MyHomePageState();
}
//创建了_MyHomePageState这个state,接下来编写其内容
class _MyHomePageState extends State<MyHomePage>{}

接下来的内容都写在_MyHomePageState 中

2. 定义棋格


与 dashboard 上每个格子带 id 相同,这里的每个格子也都有 id 值,在批量生成棋格的时候,我选择了二维数组,以行为单位,每行带 15 格,共 15 行。
单独写个 dart 文件来定义棋格类。

import 'package:flutter/material.dart';
int chose;
String come;
bool luozi;//是否有落子,用于判断玩家取消下子位置
class BtnSingle extends StatefulWidget {
  //传入参数isAnchoosed,如果为真,则代表玩家一的棋子
  //参数isChoosed如果为真,则代表玩家二的棋子
  BtnSingle({ Key key,this.id,this.isAnchoosed}) : super(key: key);
  final int id;
  bool isChoosed = false;
  final bool isAnchoosed ;
  @override
  _BtnSingle createState() => new _BtnSingle();
}
class _BtnSingle extends State<BtnSingle> {
  @override
  Widget build(BuildContext context) {
    return
      new Container(
        width: 26,
        height: 26,
        color: Color(0xFFFFFFFF),
        child:new FlatButton(
//棋格无人选中为白色,玩家二选中为绿色,玩家一所下为红色
        color: widget.isChoosed ? Color(0xFF00FF00):(widget.isAnchoosed?Color(0xFFFF0000):Color(0xFFFFFFFF)),
            onPressed: (){
                setState(() {
                    chose = widget.id;//获取该子位置
                    widget.isChoosed = !widget.isChoosed;//更改选中标记
                    if(widget.isChoosed) luozi=true;//如果当前选中状态,标记准备落子
                    else luozi=false;
                });
            },
              shape: RoundedRectangleBorder(
                  side: BorderSide(
                      color: Color(0xFF000000),
                      style: BorderStyle.solid,width: 1
                  )
              ),
            ),
      );
  }
}
//生成单行棋格,列表类型
//isAnchoosed是传入的bool类型参数,若为真代表则是player1所下,初始化都赋值为false
List<BtnSingle> initBtnSingle(int i){
  List<BtnSingle> listbtn = new List();
  int j;
  for(j=0;j<15;j++){
    listbtn.add(new BtnSingle(id: i*15+j,isAnchoosed:false));
  }
  return listbtn;
}
List<List<BtnSingle>> initBtnRow(){
  List<List<BtnSingle>> listrow = new List();
  for(int i=0;i<15;i++){
    listrow.add(initBtnSingle(i));
  }
  return listrow;
}

3. 生成_MyHomePageState 内棋盘

@override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: (
          Column(
            children: <Widget>[
//每一行都是列表,内有15个棋格
              new Row(
                children: list[0]
              ),
              ....//重复13个,只改变list后面数字就行
              new Row(
                children: list[14]
              ),
              new Wrap(
                children: <Widget>[
                  new Container(
                    padding: EdgeInsets.fromLTRB(75, 4, 20, 4),
                    child:
//确定按钮,触碰并不代表落子,为的是防止误触,只有在选中后再按确定才算落子。
                    RaisedButton(
                        child: Text('确定'),
                        onPressed: () {
                          if(luozi){//确定在此处下子
                            _pubMsg = chose.toString();
                            _pubMessage();
                          }
                        }
                    ),
                  ),
                  new Container(
                    padding: EdgeInsets.fromLTRB(20, 4, 100, 4),
//接收按钮启动监听,在游戏开始时按下
                    child:RaisedButton(
                      child: Text('接收'),
                      onPressed: (){
                        _subMessage();
                      },
                    )
                  )
                ],
              ),
                ],
              )
          )
      );
  }

4. 引入 mqtt 相关参数

String _pubTopic = 'player2';//作为player2发布消息
String _pubMsg;//发布消息的内容是字符串类型
String _subTopic = 'player1';//订阅player1的消息
bool _retainValue = false;
ScrollController subMsgScrollController = new ScrollController();
String broker = 'xxxx';//xxxx改为mqtt服务器地址
mqtt.MqttClient client;
mqtt.MqttConnectionState connectionState;
StreamSubscription subscription;
List<Message> messages = <Message>[];
@override
void initState(){
   super.initState();
   _connect();
}
//初始化函数,执行_connect()连接
void _connect() async{
    //client连接的初始化配置
    //默认端口1883,如果不是1883就采用
    //client = mqtt.MqttClient.withPort(broker, '',1883);
    client = mqtt.MqttClient(broker,'');
    client.logging(on: true);
    client.keepAlivePeriod = 30;
    client.onDisconnected = _onDisconnected;
    final mqtt.MqttConnectMessage connMess = mqtt.MqttConnectMessage()
        .withClientIdentifier('webberFlutter')//连接mqtt使用的id
        .startClean()
        .keepAliveFor(30)
        .withWillTopic('willtopic')
        .withWillMessage('My Will message')
        .withWillQos(mqtt.MqttQos.atLeastOnce);
    print('MQTT client connecting....');
    client.connectionMessage = connMess;
    try {
      await client.connect();
    } catch (e) {
      print(e);
      _disconnect();
    }
    if (client.connectionState == mqtt.MqttConnectionState.connected) {
      print('MQTT client connected');
      setState(() {
        connectionState = client.connectionState;
      });
    } else {
      print('ERROR: MQTT client connection failed - '
          'disconnecting, state is ${client.connectionState}');
      _disconnect();
    }
    subscription = client.updates.listen(_onMessage);
  }
//处理连接失败的情况
  void _disconnect() {
    client.disconnect();
    _onDisconnected();
  }
  void _onDisconnected() {
    setState(() {
      connectionState = client.connectionState;
      client = null;
      subscription.cancel();
      subscription = null;
    });
    print('MQTT client disconnected');
}
//_onMessage函数处理传入的信息
 void _onMessage(List<mqtt.MqttReceivedMessage> event) {
    print(event.length);
    print(event[0].topic);
    final mqtt.MqttPublishMessage recMess = event[0].payload as mqtt.MqttPublishMessage;
    final String message = mqtt.MqttPublishPayload.bytesToStringAsString(recMess.payload.message);
    print('MQTT message: topic is <${event[0].topic}>, '
        'payload is <-- ${message} -->');
    print(client.connectionState);
    setState(() {
        if(event[0].topic=='player1'){
          come = message;
          print(int.parse(come));
          for(int i=0;i<225;i++){
     if(int.parse(come)==list[i~/15][i%15].id&&!list[i~/15][i%15].isChoosed){
              BtnSingle btnSingle = new BtnSingle(id:int.parse(come) ,isAnchoosed: true);
              print(list[i~/15][i%15].id);
              print("i am here!");
              print(list[i~/15][i%15].isAnchoosed);
              list[i~/15][i%15] = btnSingle;
              print(list[i~/15][i%15].isAnchoosed);
//接收到玩家一棋子信息后,直接替换棋格,其isAnchoosed值为真,代表玩家一所下
            }
          }
        }
    });
  }
 void _subMessage(){
    //开始接收subtopic的submessage
      print("on sub message");
      if(connectionState == mqtt.MqttConnectionState.connected){
        setState(() {
          print('subscribe to ${_subTopic}');
          client.subscribe(_subTopic, mqtt.MqttQos.exactlyOnce);
        });
    }
  }
  void _pubMessage(){
    //发布消息
      final mqtt.MqttClientPayloadBuilder builder =
      mqtt.MqttClientPayloadBuilder();
      builder.addString(_pubMsg);
      print("pub message ${_pubTopic}:${_pubMsg}");
      client.publishMessage(
        _pubTopic,
        mqtt.MqttQos.values[0],
        builder.payload,
        retain: _retainValue,
      );
  }

至此 flutter 应用开发完毕。
启动 APP 和 node-red,给 esp32 通电并连接棋盘,保证 wifi 和 mqtt 连接都成功的情况下,便可以进行五子棋游戏。