心得之开发设计一个篮球共享计分器小程序

序言

作为一名篮球爱好者的程序员,在使用目前市面篮球计分器时,总觉得用的不顺手,市面基本都是单机模式,广告很多,也不便于核对和多人协同记录。因此,我决定自己设计并开发一款篮球共享计分器小程序,实现多人协同记录赛事,提供多种方便快捷的计分方式和统计数据效果。

上效果图:

由于上篇《开发一个题库系统App 和小程序的心得》已经叙述了很多资源方案,代码方案,部署方案,以及很多基础性的功能开发设计,此系统基本相同,不再赘述,直接讲解篮球共享计分器特有功能。

1. 功能目标

篮球共享计分器功能目标:

  1. 实现多人实时共享记录比赛
  2. 提供个人和团队数据和表现的统计数据
  3. 提供单机版/共享版/私密版三种模式记录比赛
  4. 提供简易/精准版模式选择
  5. 保存历史比赛数据,可用于文字直播。

1.1 功能列表

篮球共享计分器核心功能

  1. 实现常规简易单机版计分器
  2. 实现协同协同版计分器,协同实时切换简易版和精准版计分方式
  3. 实现团队对比数据,可视化图表展示和对比,以及完整文字记录
  4. 实现个人完整数据,以及个人完整文字记录
  5. 协同版区分公开和私密赛事,公开赛事可免登录参与,私密赛事支持多种精准权限控制记录比赛

1.2 功能截图

以下是App端的部分截图

以下是微信小程序端的部分截图

2. 实现方案

2.1 设计方案

2.1.1 数据库设计

赛事表

队员表

授权表

日志表

2.1.2 移动端开发-赛事列表和录入

赛事有三种添加方式:

  1. 已有比赛,可以选择复制一份,会复制赛事的队员和用户权限
  2. 录入比赛,自行录入比赛名称等信息,保存即可
  3. (需登录)添加授权码比赛,别人创建的私密比赛,分享的授权码,可以通过授权码进行添加

赛事录入细节:

  1. 长按赛事明细,可操作复制/编辑/授权/重置/取消/删除(可见操作按钮跟权限有关)
  2. 编辑赛事,可增加/修改/删除赛事双方队员:添加队员直接录入即可;搜索队员可查找有权限赛事的所有人员,因此同名队伍只需录入一次,下次直接搜索一次性添加即可;长按队员可编辑;删除按钮可删除。(赛事录入页面也可同样操作)

2.1.3 移动端开发-赛事授权(需登录)

赛事列表中,对于公开赛事,所有人都可见也都是管理员角色;对于私密赛事,长按明细,可选择授权操作。

有两种授权模式:

  1. 生成授权码,点击观众/主队管理员/客队管理员/管理员,可生成对应赛事授权码,其他人在赛事列表页面通过添加授权码比赛的方式即可获权
  2. 添加用户,点击添加用户,输入用户信息查找授权即可(为保证用户隐私,此处不支持模糊搜索)

授权细节说明:

  1. 赛事创建人是超级管理员,可以直接添加/修改/删除任何已授权用户
  2. 只能添加/修改/删除比自己低的权限,不会出现操作的权限超越操作人的权限的情况
  3. 可自己删除自己的授权,删除后赛事不可见

2.1.4 移动端开发-记录比赛

赛事记录方法:

  1. 点击【精准版】/【简易版】可以互相切换记录模式
  2. 点击【用时】,可查看实时比赛数据结果
  3. 点击【分享比赛】,可生成赛事二维码,让其他人参与
  4. 长按任意队员,可切换队员
  5. 长按日志明细,可作废或修改日志

赛事记录说明:

  1. 公开赛事记录,所有人都是管理员权限,都可以通过分享的二维码进行赛事记录。
  2. 私密赛事记录,扫码进来的默认赋权观众,需要进一步授权才可以记录比赛,否则只能观看。
  3. 私密赛事记录,管理员可以记录双方数据,主队管理员只能记录主队数据,客队管理员只能记录客队数据,观众只能观看。
  4. 加时赛说明,默认四节比赛,如果在第四节结束,两队分数一致,自动进入加时,否则比赛结束。

2.1.5 移动端开发-查看结果

查看结果有三种方式:

  1. 列表已经结束的比赛,点击查看即可查看比赛结果
  2. 记录比赛中,点击用时可以查看比赛结果
  3. 通过其他人分享的比赛结果二维码可以查看比赛结果

查看结果操作说明:

  1. 进入结果页面,可点击上方【*** 数据】切换页签,也可以左右滑动切换页签
  2. 在【**队 数据】页签,柱状图的"其他"表示整队的数据,也就是比赛记录时,选择了队伍,未选择具体人员
  3. 小程序图表可能出现空白,点一下图表内部,即可正常显示,图表组件在小程序上的层级Bug问题,暂时未修复

2.1.6 移动端开发-单机版计分器

操作说明:

  1. 点击【单机版】可清空重置数据
  2. 长按【犯规】或【队伍暂停】可减少次数
  3. 打开页面后可离线操作,离开页面或应用,数据缓存在本地,并不会消失,只能记录一个赛事,清空重置后,数据清除不可找回

2.2 开发方案

2.2.1 后端开发-框架Springboot

基于若依plus,在其基础上增加ruoyi-race模块,单独存放赛事相关功能,其中四个controller分别是:

Race:赛事功能

Log:日志功能

Member:成员功能

User:授权用户功能

Message:SSE发送消息功能(已弃用)

SSE:SSE连续功能(已弃用)

WxToken:获取小程序Token和生成小程序带参数二维码功能

SSE在使用时,微信小程序和APP不能很好的支持EventSource前端组件,长连接也存在兼容问题,效果都不好,所以弃用,使用WebSocket

在整合的框架中,使用的是Tio引擎的Websocket,增加两种赛事的消息类型,初始化消息和常规消息:

初始化消息:读取并回执当前某一赛事最新完整赛事数据

常规消息:发送记录赛事时的各种指令,更新赛事,记录日志,并回执更新后的完整赛事数据

2.2.2 移动端开发-框架Uniapp

核心在记录赛事使用websocket上,这里我们使用具体页面直连的方式,离开页面则关闭websocket,相关代码如下

复制代码
    onUnload() {
        this.timeClear()
        this.socket.close(true)
    },
    onShow() {
        this.diyApiGet('/race/race/getMoreByRaceId/' + this.setData.id).then(res => {
            this.setData = res.data;
            if(!this.userInfo || !this.userInfo.userId){
                // 公开赛事,无用户,随机生成一个
                this.userId = this.getRandom(2, 16);
            }else{
                this.userId = this.userInfo.userId;
            }
            // websocket非通讯状态,则重连
            // #ifdef APP-PLUS
            if(!this.socket){
                this.getMemberFirstList();
                this.getMemberSecondList();
                this.connectWebsocket(this.userId, this.setData.id);
            }
            // #endif
            // #ifndef APP-PLUS
            if(!this.socket || this.socket.getReadyState() != 1){
                this.getMemberFirstList();
                this.getMemberSecondList();
                this.connectWebsocket(this.userId, this.setData.id);
            }
            // #endif
        })
    },

连接websocket方法,未登录传入随机用户ID

复制代码
    connectWebsocket(userId, raceId){
            let url = globalData.wssUrl;
            let heartbeatTimeout = 50000; // 心跳超时时间,单位:毫秒
            let reconnInterval = 5000; // 重连间隔时间,单位:毫秒
            let binaryType = 'blob'; // 'blob' or 'arraybuffer';//arraybuffer是字节
            let paramStr = "app=urace&userId=" + userId + "&sessionId=" + raceId
            let param = "";
            this.socket = new TioSocket(url, paramStr, param, heartbeatTimeout, reconnInterval, binaryType);
            let ws = this.socket.connect(false);
            
            ws.onOpen((e) => {
                // 读取赛事初始信息
                uni.sendSocketMessage({
                    data: JSON.stringify({code:6, message: {raceId: raceId, userId: userId}})
                })
              this.socket.reset();
            })
            ws.onMessage((e) => {
                let message = JSONbig.parse(e.data);
                if(message.code == 2){
                    let data = JSONbig.parse(message.message.content)
                    if(data.msg){
                        this.msg(data.msg);
                        return;
                    }
                    if(data.data){
                        this.setData = data.data;
                        this.usedTime = data.data.usedTime || 0;
                        if(data.data.status == 1){
                            this.timeInterval();
                        }else{
                            this.timeClear();
                        }
                        if(data.data.status < 0){
                            this.logList = []
                        }
                        this.showTitle();
                    }
                    if(data.log){
                        if(data.log.logType == 99){
                            this.getMemberFirstList();
                            this.getMemberSecondList();
                            this.invalidMessageLog(data.log)
                        }else if(data.log.logType == 61){
                            this.moveMessageLog(data.log)
                        }else{
                            if(data.log.logType == 34 || data.log.logType == 41 || data.log.logType == 42 || data.log.logType == 43 
                                || data.log.logType == 81 || data.log.logType == 82 || data.log.logType == 83){
                                if(data.log.teamIndex == 1){
                                    this.getMemberFirstList();
                                }
                                if(data.log.teamIndex == 2){
                                    this.getMemberSecondList();
                                }
                            }
                            this.addMessageLog(data.log, true)
                        }
                    }
                }
                this.socket.reset();
            })
        },

赛事记录命令转译函数

复制代码
export function getLogText(log, firstTeamName, secondTeamName, status){
    let text = "";
    if(log.teamIndex == 1){
        text += " " + firstTeamName
    }
    if(log.teamIndex == 2){
        text += " " + secondTeamName
    }
    if(log.memberName){
        text += " " + log.memberName
    }
    switch(log.logType){
        case -10:
            text += "比赛尚未开始";
            break;
        case -1:
            text += "比赛进行中";
            break;
        case -2:
            text += "比赛暂停中";
            break;
        case -3:
            if(log.period == 2){
                text += "第一节已结束";
            }else if(log.period == 3){
                text += "第二节已结束";
            }else if(log.period == 4){
                text += "第三节已结束";
            }else if(log.period >= 5 && log.firstTeamScore != log.secondTeamScore){
                text += "比赛已结束";
            }else if(log.period == 5){
                text += "第四节已结束";
            }else if(log.period == 6){
                text += "加时赛1已结束";
            }else if(log.period == 7){
                text += "加时赛2已结束";
            }else if(log.period == 8){
                text += "加时赛3已结束";
            }else if(log.period == 9){
                text += "加时赛4已结束";
            }else if(log.period == 10){
                text += "加时赛5已结束";
            }else{
                text += "加时赛已结束";
            }
            break;
        case -9:
            text += "比赛已取消";
            break;
        case 1:
            text += "比赛开始";
            break;
        case 2:
            text += "裁判暂停";
            break;
        case 3:
            if(log.period == 1){
                text += "第一节比赛继续";
            }else if(log.period == 2){
                text += "第二节比赛继续";
            }else if(log.period == 3){
                text += "第三节比赛继续";
            }else if(log.period == 4){
                text += "第四节比赛继续";
            }else if(log.period == 5){
                text += "加时赛1继续";
            }else if(log.period == 6){
                text += "加时赛2继续";
            }else if(log.period == 7){
                text += "加时赛3继续";
            }else if(log.period == 8){
                text += "加时赛4继续";
            }else if(log.period == 9){
                text += "加时赛5继续";
            }else if(log.period == 10){
                text += "加时赛6继续";
            }else{
                text += "加时赛继续";
            }
            break;
        case 4:
            if(log.period == 2){
                text += "第一节结束";
            }else if(log.period == 3){
                text += "第二节结束";
            }else if(log.period == 4){
                text += "第三节结束";
            }else if(log.period >= 5 && log.firstTeamScore == log.secondTeamScore){
                text += "比赛结束";
            }else if(log.period == 5){
                text += "第四节结束";
            }else if(log.period == 6){
                text += "加时赛1结束";
            }else if(log.period == 7){
                text += "加时赛2结束";
            }else if(log.period == 8){
                text += "加时赛3结束";
            }else if(log.period == 9){
                text += "加时赛4结束";
            }else if(log.period == 10){
                text += "加时赛5结束";
            }else{
                text += "加时赛结束";
            }
            break;
        case 5:
            text += "比赛取消";
            break;
        case 6:
            text += " 请求暂停";
            break;
        case 11:
            text += " 罚篮命中,得1分"
            break;
        case 12:
            text += " 投篮命中,得2分"
            break;
        case 13:
            text += " 投篮命中,得2分,加罚1次"
            break;
        case 14:
            text += " 突破上篮,得2分"
            break;
        case 15:
            text += " 突破上篮,得2分,加罚1次"
            break;
        case 16:
            text += " 投篮命中,得3分"
            break;
        case 17:
            text += " 投篮命中,得3分,加罚1次"
            break;
        case 18:
            text += " 扣篮成功,得2分"
            break;
        case 19:
            text += " 扣篮成功,得2分,加罚1次"
            break;
        case 20:
            text += " 扣1分"
            break;
        case 21:
            text += " 罚篮不中"
            break;
        case 22:
            text += " 2分不中"
            break;
        case 23:
            text += " 2分不中,被犯规,罚球2次"
            break;
        case 24:
            text += " 3分不中"
            break;
        case 25:
            text += " 3分不中,被犯规,罚球3次"
            break;
        case 26:
            text += " 上篮失败"
            break;
        case 27:
            text += " 上篮失败,被犯规,罚球2次"
            break;
        case 28:
            text += " 扣篮失败"
            break;
        case 29:
            text += " 扣篮失败,被犯规,罚球2次"
            break;
        case 31:
            text += " 助攻一次"
            break;
        case 32:
            text += " 失误,丢失球权"
            break;
        case 33:
            text += " 抢断,获得球权"
            break;
        case 34:
            text += " 普通犯规"
            break;
        case 35:
            text += " 被普通犯规"
            break;
        case 36:
            text += " 投篮被犯规,开始罚球"
            break;
        case 37:
            text += " 盖帽"
            break;
        case 38:
            text += " 被盖帽"
            break;
        case 39:
            text += " 获得前场篮板"
            break;
        case 40:
            text += " 获得后场篮板"
            break;
        case 41:
            text += " 犯规,对手罚球"
            break;
        case 42:
            text += " 违体犯规"
            break;
        case 43:
            text += " 技术犯规"
            break;
        case 81:
            text += " 上场"
            break;
        case 82:
            text += " 下场," + log.nextMemberName + " 上场"
            break;
        case 83:
            text += " 下场休息"
            break;
        default:
            text += log.logType
    }
    return " " + text
}

3. 总结一下

篮球共享计分器,主要实现的是一个协同处理能力,此次小程序开发,不仅实现了App、小程序、H5的三端兼容,也实现了赛事数据的实时同步,经过测试,任意时间进入赛事的用户,都能保证比赛用时显示的同步,互相操作都能及时收到消息。

不足之处,图表使用的uchart工具,兼容性还有待研究,篮球规则懂的不全面,以及更多形式的赛事记录支持等。

以上分享心得只能描述个大概,个人文档和开发水平都有限,文档或有错误和不妥之处,欢迎指定!