1 Version 3.0主要内容
1.1 需求分析要点
对于该黄金点游戏的微信小程序开发,我们根据前阶段讨论的初步需求和设计思路,从工程技术和能力实现上思考,继续细化,精准需求。
- 微信小程序用户扫码后自动登录,进入游戏主界面可以有创建房间、加入房间两种选择。
- 创建房间:该玩家成为房主,进行房间名命名和输入数字时长设置,之后进入输入数字界面。
加入房间:该玩家成为普通玩家,进入后输入某个指定的已创建的房间名称,之后进入输入数字界面。 - 玩家在指定时间内,输入自己的数字并点击提交,当等待时间结束之时,则系统会自动计算该房间内所有玩家得分和获胜玩家,从而在界面上进行显示。
- 当一轮游戏结束后,玩家可以直接在同一房间内发起下一轮游戏,与其他玩家继续围绕黄金点不断博弈,也可以选择退出房间,结束游戏。
- 在小程序主菜单的玩家个人界面,可以查看个人的历史记录,可以选择数字历史和获胜历史选项卡,进行查看对应内容的统计图表,这样便可以对于过去游戏中的数学博弈原理有所明晰理解。
1.2 游戏设计流程
如下图所示,我们根据进一步的需求分析,将本流程图进行了修改和完善,力求较好地呈现该游戏小程序的使用功能,便于后续的构建编码等等步骤。
1.3 工具环境
本小程序开发使用微信开发者工具稳定版 [Stable Build](1.03),同时利用小程序云开发功能构建数据库。
云开发:使用云开发开发微信小程序,无需搭建服务器,即可使用云端能力。该云开发功能提供完整的原生云端支持和微信服务支持,弱化后端和运维概念,无需搭建服务器,使用平台提供的 API 进行核心业务开发,即可实现快速上线和迭代,同时这一能力,同开发者已经使用的云服务相互兼容,并不互斥。
数据库:云开发提供了一个 JSON 数据库,数据库中的每条记录都是一个 JSON 格式的对象。一个数据库可以有多个集合(相当于关系型数据中的表),集合可看做一个 JSON 数组,数组中的每个对象就是一条记录,记录的格式是 JSON 对象。其中关系型数据中的行(row)对应这里的记录(record / doc),列(column)对应这里的字段(field)。
同时由于小程序支持简洁的组件化编程,开发者可以将页面内的功能模块抽象成自定义组件,以便在不同的页面中重复使用;也可以将复杂的页面拆分成多个低耦合的模块,有助于代码维护。这里我们决定借助Vant Weapp和Apache ECharts两个组件进行设计,其中前者是轻量、可靠的小程序 UI 组件库,提供按钮、图标、反馈、加载、滑动等等功能的组件,后者则是用于快速开发图表,满足各种可视化需求。
1.4 数据库设计
通过需求分析和设计,以及上述的流程图,我们建立了三张表进行数据库操作,由于JSON格式阅读性较差,我们先简单使用关系数据库进行展示,注意下面划线的为关系的主码。
- rooms(, round_num, room_master, time_limit)
- games( , number, golden_point, score)
- users( , total_score, total_round, win_round, lose_round, user_name)
如下图所示为该关系数据库的E-R图。
在该微信小程序中,我们将其改变成云开发所提供的JSON数据库,下面仅展示JSON格式的rooms数据。(由于openid与用户安全有关,故在此用*隐去)
{
"_id":"2a7b532a5fbbc1ef004aa7ea279ffe72",
"time_limit":10000.0,
"_openid":"o*******************************",
"room_id":"aaa101",
"room_master":"o*******************************",
"round_num":4.0
}
{
"_id":"2a7b532a5fbbc260004aabd65a08880c",
"_openid":"o*******************************",
"room_id":"aaa102",
"room_master":"o*******************************",
"round_num":4.0,
"time_limit":5000.0
}
在rooms房间信息中,我们用字符串room_id, room_master来记录房间创建者设置的房间名称和自己的账号,用数字round_id来记录房间中游戏进行的最大轮数,用数字time_limit来创建者设置的输入时间时长。
我们注意到这里,每条记录都会有一个 _id 字段和_openid字段,其中_id字段用以唯一标志一条记录、_openid 字段用以标志记录的创建者,即小程序的用户。小程序在管理端(控制台和云函数)中创建的不会有 _openid 字段,它是属于管理员创建的记录。而开发者可以自定义 _id,但不可自定义和修改 _openid 。总之,_openid 是在该小程序文档创建时由系统根据小程序用户默认创建的,我们由此可以使用其来标识和定位每条记录。
2 Version 3.0程序实现
2.1 程序目录结构
如上图所示,该小程序包含一个描述整体程序的app和多个描述各自页面的page。小程序主体部分由三个文件组成,包括app.js, app.json, app.wxss,必须放在项目的根目录下,而每个page由四个文件组成,分别是js, json, wxss, wxml格式的文件。
2.2 整体程序app
2.2.1 app.js——小程序逻辑
在 app.js 中可以调用 App 方法注册小程序,绑定生命周期回调函数、错误监听和页面不存在监听函数等。我们在整个小程序只有一个 App 实例,是全部页面共享的,其它程序通过 getApp 方法获取到全局唯一的 App 实例,获取App上的数据或调用在 App 上的函数。
//app.js
App({
globalData: {
userInfo: null,
openID: '',
roomID: ''
},
onLaunch: function () {
// 登录(无用)
wx.login({
success: res => {
// 微信禁止将开发者密码放置在前端代码,只能通过后端调用(或云函数)获取实现
}
})
// 初始化云函数
wx.cloud.init({
env: 'base-lcg',
traceUser: true
})
// 获取用户信息
wx.getSetting({
success: res => {
if (res.authSetting['scope.userInfo']) {
// 已经授权,可以直接调用 getUserInfo 获取头像昵称,不会弹框
wx.getUserInfo({
success: res => {
// 可以将 res 发送给后台解码出 unionId
this.globalData.userInfo = res.userInfo
// 由于 getUserInfo 是网络请求,可能会在 Page.onLoad 之后才返回
// 所以此处加入 callback 以防止这种情况
if (this.userInfoReadyCallback) {
this.userInfoReadyCallback(res)
}
}
})
}
}
})
// 调用云函数login获取openid
wx.cloud.callFunction({
name: 'login',
data: {},
success: res => {
this.globalData.openID = res.result.openid
console.log('[云函数] [login] user openid: ', res.result.openid)
const db = wx.cloud.database()
db.collection('users').where({
user_id: this.globalData.openID
}).get({
success: res => {
if (res.data.length === 0) {
wx.showLoading({
title: '正在注册',
})
db.collection('users').add({
data: {
user_id: this.globalData.openID,
user_name: this.globalData.userInfo.nickName,
total_score: 0,
total_round: 0,
win_round: 0,
lose_round: 0
},
success: res => {
wx.hideLoading()
wx.showToast({
title: '注册成功',
})
},
fail: err => {
wx.showToast({
icon: 'none',
title: '注册失败',
})
}
})
} else {
wx.showToast({
icon: "none",
title: '欢迎回来',
})
}
},
fail: err => {
wx.showToast({
icon: 'none',
title: '访问数据库失败',
})
}
})
},
fail: err => {
console.error('[云函数] [login]调用失败', err)
}
})
}
})
2.2.2 app.json——公共配置
本app.json 文件用来对微信小程序进行全局配置,决定页面文件的路径、窗口表现、设置网络超时时间、设置多 tab 等。这里主要包含我们编写的6个页面、界面底部的2个滑动窗口。
{
"pages": [
"pages/index/index",
"pages/user/user",
"pages/history/history",
"pages/create/create",
"pages/join/join",
"pages/game/game"
],
"window": {
"backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#eee",
"navigationBarTitleText": "黄金点小游戏",
"navigationBarTextStyle": "black"
},
"tabBar": {
"list": [
{
"pagePath": "pages/index/index",
"text": "游戏",
"iconPath": "images/game.png",
"selectedIconPath": "images/game.png"
},
{
"pagePath": "pages/user/user",
"text": "我的",
"iconPath": "images/mine.png",
"selectedIconPath": "images/mine.png"
}
],
"backgroundColor": "#eeeeee"
},
"networkTimeout": {
"request": 10000,
"downloadFile": 10000
},
"style": "v2",
"sitemapLocation": "sitemap.json"
}
2.2.3 app.wxss——公共样式表
WXSS 本质上是一套样式语言,用于描述 WXML 的组件样式。WXSS 用来决定 WXML 的组件应该怎么显示。在这里,我们定义在 app.wxss 中的样式为全局样式,作用于每一个页面。在 page 的 wxss 文件中定义的样式为局部样式,只作用在对应的页面,并会覆盖 app.wxss 中相同的选择器。
注意到以下代码中的rpx
,这个是微信小程序中独有的,rpx即responsive pixel,它可以根据屏幕宽度进行自适应。在开发中,我们规定屏幕宽为750rpx,且统一用 iPhone6 作为视觉稿的标准,在 iPhone6 上,屏幕宽度为375px,共有750个物理像素,则750rpx = 375px = 750物理像素,1rpx = 0.5px = 1物理像素。理解好这里的单元大小,才能更有益于我们后期对于布局样式的修改和优化。
/**app.wxss**/
.container {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding-top: 300rpx;
padding-bottom: 500rpx;
box-sizing: border-box;
}
2.3 页面文件index
2.3.1 index.js
该部分主要用于首页的功能,包括自动登录获取用户信息,提供多个页面跳转的入口等等。在js文件中不仅要处理自动登录获取用户信息并存入数据库users,还应该提供用户下次登录后自动查询比对数据库信息,确定是否为同一用户,便于后期数据的记录和存储。
const db = wx.cloud.database()
db.collection('users').where({
user_id: app.globalData.openID
}).get({
success: res => {
const _id = res.data[0].openID
db.collection('users').doc(_id).update({
data: {
user_name: this.userInfo.nickName
}
})
}
})
上图所示,在开始使用数据库 API 进行增删改查操作之前,需要先获取数据库的引用,即const db = wx.cloud.database()
。随后为关于数据库的查询和更新操作函数。
查询上,我们通过在该记录的引用调用get
方法来获取这个记录的数据,同时通过调用集合上的where
方法可以指定查询条件,where
方法接收的是一个对象参数,该对象中每个字段和它的值构成一个需满足的匹配条件,各个字段间的关系是 与 的关系,即需同时满足这些匹配条件,最终结果返回满足指定查询条件的记录。更新上,我们则使用 update
方法来局部更新一个记录,局部更新意味着只有指定的字段会得到更新,而其他字段不会受影响。
2.3.2 index.wxss
这里为该首页页面的格式,这里每一部分都与index.wxml文件中的每一项相互对应,依次指出其位置、大小、颜色、字体等等属性。
/* pages/index/index.wxss */
.index-container {
background-image: url("https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1605542397779&di=3ac2e7a46a54870515bce98ccec09cf2&imgtype=0&src=http%3A%2F%2Fb-ssl.duitang.com%2Fuploads%2Fitem%2F201806%2F12%2F20180612132316_EhGwk.gif");
background-position: center;
}
.title {
margin-bottom: 20px;
color: brown;
}
.title-text {
font-size: 100rpx;
font-weight: bolder;
font-family: 'Courier New', Courier, monospace;
font-style: italic;
}
.userinfo {
display: flex;
flex-direction: column;
align-items: center;
}
.userinfo-avatar {
width: 90px;
height: 90px;
margin: 10px;
border-radius: 10%;
}
.userinfo-nickname {
color: #555;
font-size: 15px;
}
.index-btn {
margin-top: 60rpx;
}
2.3.3 index.wxml
WXML是小程序框架设计的一套标签语言,结合基础组件、事件系统,可以构建出页面的结构。该部分包含了玩家登录后用户名的数据绑定,信息列表的渲染等等实现部分。
Golden Point
{{userInfo.nickName}}
创建房间
加入房间
2.3.4 index页面实现效果
当用户扫描二维码之后便可以进入该页面,这时系统会自动登录并获取用户账号信息,玩家可以选择创建房间,或者加入房间。下方为主菜单的“游戏”和“我的”两栏,便于用户进行快速选择和使用相应功能。
2.4 页面文件create和join
这两个文件分别对应于首页的两个按钮组件所发生的事件,create页面用于选择创建房间后进入的设置界面,join页面用于选择加入房间后进入的界面。
2.4.1 create.wxml
该程序主要实现创建房间的布局和操作,包括输入房间名、设定对局时限两个部分,具体的动作函数对应于create.js文件中。
确定
2.4.2 create.js
我们在js文件中实现房间名的输入和时间时长的设定规范,其中最关键的部分则是关于云函数实现数据库rooms的插入新房间记录,并按表格的属性依次进行初始化。注意到,在插入房间数据时,会提前查找比对数据库表中已有的记录,如若有相同名的房间,则不允许新建。本次迭代版本目前暂时不支持重新创建一个同过去名称相同的房间和销毁过去的某个指定房间。
// pages/create/create.js
const app = getApp()
Page({
/**
* 页面的初始数据
*/
data: {
roomID: '',
timeLimit: '2:30',
minMin: 0,
maxMin: 59,
filter(type, options) {
if (type==='minute') {
return options.filter(options => options%5 === 0)
}
return options
}
},
onChangeRoom: function(event) {
this.setData({
roomID: event.detail
})
},
onInput: function(event) {
this.setData({
timeLimit: event.detail
})
},
bindCreateOk: function () {
if (this.data.roomID == '') {
wx.showToast({
icon: 'none',
title: '房间名不能为空',
})
}
else if (this.data.timeLimit == '00:00') {
wx.showToast({
icon: 'none',
title: '对局时限不能为0',
})
}
else {
wx.showLoading({
title: '正在创建房间',
})
var minute = parseInt(this.data.timeLimit.slice(0, 2), 10)
var second = parseInt(this.data.timeLimit.slice(3, 5), 10)
var timeLimit = (minute * 60 + second) * 1000
const db = wx.cloud.database()
db.collection('rooms').where({
room_id: this.data.roomID
}).get({
success: res => {
if (res.data.length != 0) {
wx.hideLoading()
wx.showToast({
icon: 'none',
title: '房间名已存在',
})
} else {
db.collection('rooms').add({
data: {
room_id: this.data.roomID,
room_master: app.globalData.openID,
time_limit: timeLimit,
round_num: 1
},
success: res => {
wx.hideLoading()
app.globalData.roomID = this.data.roomID
wx.showToast({
title: '创建房间成功',
})
setTimeout(
function () {
wx.navigateTo({
url: '../game/game',
})
}, 1000
)
},
fail: err => {
wx.showToast({
icon: 'none',
title: '创建房间失败',
})
}
})
}
},
fail: err => {
wx.showToast({
icon: 'none',
title: '创建房间失败',
})
}
})
}
}
})
2.4.3 create页面实现效果
创建房间页面如上图所示,可以首先输入房间名,支持任何字符串、数字等符号,接着设定对局时限,通过滑动选择组件进行选择分与秒,点击确定后则会创建新的房间记录到数据库rooms表中。
当房间的名称已经出现在之前的游戏中时,我们会提示“房间名已存在”,此时玩家可以选择换一个名称进行新建,也可以退出创建房间模式,进入加入房间模式,从而进行输入过去的历史房间名,继续游戏局数。
2.4.4 join页面实现效果
由于加入房间的join页面实现方式与创建房间的相似程度较高,故在此不详细展开解释源代码。下面为js文件中对数据库查询的关键部分代码。
const db = wx.cloud.database()
db.collection('rooms').where({
room_id: this.data.roomID
}).get({
success: res => {
if (res.data.length != 0) {
wx.hideLoading()
app.globalData.roomID = this.data.roomID
wx.showToast({
title: '加入房间成功',
})
setTimeout(
function () {
wx.navigateTo({
url: '../game/game',
})
}, 1000
)
}
else {
wx.hideLoading()
wx.showToast({
icon: 'none',
title: '房间名不存在',
})
}
},
fail: err => {
wx.showToast({
icon: 'none',
title: '加入房间失败',
})
}
})
2.5 页面文件game
2.5.1 game.js
本js文件主要用于实现黄金点游戏的数字计算和判断得分,也是本游戏逻辑的实现根本。通过借鉴前几次迭代开发的游戏版本中的逻辑实现,同时配合云数据库的查询和更新记录操作,最终得以实现游戏的功能。下面为程序中的onFinished
函数,包含了游戏逻辑实现和数据库操作。
onFinished: function () {
wx.showLoading({
title: '计算结果',
})
const db = wx.cloud.database()
db.collection('rooms').where({
room_id: app.globalData.roomID
}).get({
success: res => {
db.collection('games').where({
room_id: res.data[0].room_id,
round_num: res.data[0].round_num,
}).get({
success: res => {
var gp = 0;
var score = 0;
var _id = ''
var max_openid = '';
var min_openid = ''
for (var i = 0; i < res.data.length; i++) {
gp += res.data[i].number
if (res.data[i].user_id === app.globalData.openID) {
_id = res.data[i]._id
}
}
gp = gp / res.data.length * 0.618;
console.log("golden_point: ", gp)
var MIN = 0x3f3f3f3f;
var MAX = 0
for (var i = 0; i < res.data.length; i++) {
if (Math.abs(res.data[i].number - gp) < MIN) {
MIN = Math.abs(res.data[i].number - gp)
min_openid = res.data[i].user_id
}
if (Math.abs(res.data[i].number - gp) > MAX) {
MAX = Math.abs(res.data[i].number - gp)
max_openid = res.data[i].user_id
}
}
console.log("max, min: ", max_openid, min_openid)
if (app.globalData.openID === max_openid) score = -2
if (app.globalData.openID === min_openid) score = res.data.length
db.collection('games').doc(_id).update({
data: {
golden_point: gp,
score: score
},
success: _res => {
db.collection('users').where({
user_id: app.globalData.openID
}).get({
success: res => {
const _id = res.data[0]._id
db.collection('users').doc(_id).update({
data: {
total_round: res.data[0].total_round + 1,
total_score: res.data[0].total_score + score,
win_round: res.data[0].win_round + Number(score > 0),
lose_round: res.data[0].lose_round + Number(score < 0)
}
})
}
})
wx.hideLoading()
wx.showToast({
icon: 'none',
title: '本局得分:' + score,
duration: 5000
})
}
})
}
})
}
})
},
2.5.2 game页面实现效果
上图中,上方设置的倒计时即为前面步骤中所设置的时长,一旦进入该页面则自动开始计时,以增强游戏玩家的强烈紧迫感和刺激性。玩家在指定时间内输入1-1000以内的数字,并点击确认按钮,系统即可成功提交并存入数据库用于计算。
若玩家输入非数字格式或者超过1-1000范围的数字,则会出现提示,导致无法提交。
玩家确认之后,等待至倒计时停止后,此时房间内所有玩家均已输完数字并提交至数据库,此时系统会读取数据库数据并计算判断黄金点的距离,由此显示玩家的得分。
玩家在本轮游戏结束后,可以点击再来一局按钮,此时界面会再次跳转至新的输入数字界面,开启新的一轮。当然,也可以选择返回退出游戏,并不影响该房间内其他玩家的游戏进行。
2.6 页面文件user
本页面用于显示玩家个人的信息和历史,包括用户名的显示、历史对局、TODO、关于我们、当前版本,在这里我们也在页面上方插入了一个消息滚动条,增加游戏程序界面的美观程度。
2.7 页面文件history
本历史页面用于查询当前游戏玩家账户的数字历史和获胜历史,数字历史使用折线图统计并显示玩家在每一间房间中所玩的数字和黄金点,而获胜历史使用扇形图统计并显示玩家在过去累计获胜、失败、平分的局数百分比。
2.7.1 history.wxml
我们采用Tab布局,使用左右两个滑块进行图表的展示和切换。
数字历史
获胜历史
2.7.2 history.js
在js文件中,对于每一个统计图的实现,我们按照查询条件的要求将数据库中的相关数据获取,将数据放入option中,使用echarts组件进行相应的绘制和格式调整。下面为绘制饼图展示获胜历史的主要函数initPieChart
,包含数据库的查询选择、图表数据的装入option
进行渲染。
// 饼图
function initPieChart(canvas, width, height, dpr) {
const chart = echarts.init(canvas, null, {
width: width,
height: height,
devicePixelRatio: dpr // 像素
});
console.log(chart);
canvas.setChart(chart);
const db = wx.cloud.database({});
const cont = db.collection('users');
var win = 0;
var lose = 0;
var draw = 0;
cont.where({
user_id: app.globalData.openID,
}).get({success(res) {
console.log(res.data)
win = res.data[0].win_round;
lose = res.data[0].lose_round;
draw = res.data[0].total_round-win-lose;
var option = {
backgroundColor: "#ffffff",
color: ["#37A2DA", "#32C5E9", "#67E0E3", "#91F2DE", "#FFDB5C", "#FF9F7F"],
series: [{
label: {
normal: {
formatter: '{b}: {d}%',
fontSize: 18
}
},
type: 'pie',
center: ['50%', '50%'],
radius: ['40%', '60%'],
data: [{
value: win,
name: '胜利'
}, {
value: lose,
name: '失败'
}, {
value: draw,
name: '平分'
}],
hoverAnimation: true
}]
};
chart.setOption(option);
return chart;
}
})
}
2.7.3 history页面实现效果
该页面上方有两个选项卡,可以滑动选择要查看的类型。下图是数字历史的折线统计图展示。其中每个房间绘制两条线,一条NUM为用户曾输入的数字,一条GP为该局对应的黄金点值,X轴为游戏的局数,本图包含aaa101房间和aaa102房间。
下图是获胜历史的扇形统计图展示,可以查看到对应的百分比。
3 开发总结与后续完善
本次软件开发从最开始对于迭代版本微信小程序平台的选取、小程序的需求工程和设计工程,到关系数据库和云数据库的设计开发、界面组件的选择实现,最后到程序众多文件的编码实现,这一步一步走来近乎于软件工程中严格的沟通、策划、建模、构建、部署五个步骤。只有真正当我们从零开始不断迭代开发之时,才能具体深刻地感知到软件工程中的各种理论知识运用。
在这开发过程中,有十分繁琐细致的需求设计,需要我们循序渐进地站在用户角度去思考,去想法设法地构建各种界面场景,并结合我们实际的技术和工具进行优化和更改初步设计;也有严密严谨的数据库设计,从数据库开发人员的角度,思索游戏中要建立的关系表数目、表中的各属性和唯一候选码,要尽力减少数据的冗余和依赖;更有每个界面的布局、格式、颜色的选择和设计,从玩家角度来增加游戏的体验感和沉浸感,确保游戏的流畅顺利进行。
对于本游戏小程序的下一步,一方面,我们需要进一步对各个页面布局进行修改和优化,包括字体、图片、背景、图表的选择和设计,从而形成一套本游戏的整体风格,提供玩家游戏的舒适感。借助于UI设计的三个黄金原则,把控制权交给用户,减轻用户的记忆负担,保持界面一致,我们可以继续设计和评估本游戏的界面。
另一方面,我们可以继续优化游戏的逻辑功能,包括游戏房间的设置、数字输入的方式、倒计时功能的完善等等,迭代开发就需要我们不断细化、变更需求设计,以到达更加完美的游戏小程序要求。