1. 背景
随着前段时间微信的更新,小程序的热度又上了一个台阶;最近我发现微信亲戚群里面充斥着一个叫《成语猜猜看》的小游戏。本着学习研究的目的,我对这个小游戏进行了一些探索。
2. 玩法介绍
游戏玩法非常简单:根据图片的提示,从下面的散落的汉字中选出4个,组成正确的成语即可。
游戏分了很多等级,每个等级对应了若干关卡,随着等级的提高,关卡的数量也相应提高,当前是【御史】第27关。
3. 简单的分析
要实现这个小游戏本身很简单。
我的思路是:
- 首先后端存有相当数量的成语,作为题目,并且每个成语都对应一个图片;
- 后端提供一个获取题目的接口。
参数:当前关卡;
返回:(1)数组:当前关卡的备选答案(散落的汉字)(2)提示的图片 - 后端提供一个提交答案的接口。
参数:当前关卡,答案
返回:答案是否正确 - 前端根据根据当前的关卡获取题目,用户点选了4个汉字之后,提交答案。
于是游戏的主体就实现了。
4. 一些实践
(1)尝试抓取数据包
既然小游戏涉及了前后端的交互,那么我们就可以通过抓取数据包来分析。
基于以往对微信小程序开发的经验来看,微信限制了小程序的网络请求必须使用https协议,因此我使用了 Fildder 并做了相关配置之后,来抓取手机微信的https请求。
然而让我感到意外的是,在确保了 Fiddler 可以侦听到手机的 https 请求的情况下,我没有发现任何疑似成语猜猜看小游戏发出的数据包,包括了WebSocket。
难道这竟是个单机游戏?!!!
可是单击游戏要怎么有效得存储用户的游戏进度、金币数量这些敏感信息呢?
(2)小程序源码的获取
前段时间听说由于微信的漏洞,可以通过构造 url 获得任意微信小程序的源码,但是现在这个漏洞已经修复了,这个方法看来行不通。
换一个思路
在微信小程序的开发文档上看到过一句话:微信在运行小程序前,将小程序的包下载到手机里。
换句话说,这个小游戏的包,就在我手机里面,不需要再去想办法下载;通过在网上查找资料,最终确定了文件的位置:/data/data/com.tencent.mm/MicroMsg/ae7bf444d1f1cd061ed448cc1d581daa/appbrand/pkg/
,文件包的格式为.wxapkg
,所幸也有网友提供了解析该文件的方法:unpack wxapkg
于是便得到了小游戏的源码
源码的结构大致如下:
首先将源代码格式化一下,不然没法看。
经过简要的分析发现游戏的主逻辑都在app-service.js
这个文件里面,下面主要分析分析这个文件:
打开这个文件,首先映入眼帘的就是“相当数量的成语”,等级、成语解释、成语对应的图片连接,即图中的LEVEL_NAMES, ALL_IDIOM
以及后面的几个数组。
验证了之前的猜想:这果然是个单击游戏。
那么他就只能将用户数据保存在本地了,看另一段代码:
App({
globalData: {
userInfo: "",
PASS_LEVELS: "PassLevels",
CURRENT_LEVELS: "CurrentLevels",
TOTAL_POINT: "TotalPoint",
LAST_SIGNIN: "LastSignin",
TOTAL_SIGNIN_COUNT: "TotalSigninCount",
SHARE_TIME: "ShareTime",
SHARE_COUNT: "ShareCount"
},
onLaunch: function () {
"" == wx.getStorageSync(this.globalData.PASS_LEVELS) && wx.setStorageSync(this.globalData.PASS_LEVELS, 1),
"" == wx.getStorageSync(this.globalData.CURRENT_LEVELS) && wx.setStorageSync(this.globalData.CURRENT_LEVELS,
1), "" == wx.getStorageSync(this.globalData.TOTAL_POINT) && wx.setStorageSync(this.globalData.TOTAL_POINT,
300), "" == wx.getStorageSync(this.globalData.LAST_SIGNIN) && wx.setStorageSync(this.globalData.LAST_SIGNIN,
0), "" == wx.getStorageSync(this.globalData.TOTAL_SIGNIN_COUNT) && wx.setStorageSync(this.globalData.TOTAL_SIGNIN_COUNT,
0), "" == wx.getStorageSync(this.globalData.SHARE_TIME) && wx.setStorageSync(this.globalData.SHARE_TIME,
0), "" == wx.getStorageSync(this.globalData.SHARE_COUNT) && wx.setStorageSync(this.globalData.SHARE_COUNT,
0)
},
getLevel: function (t) {
this.gloalData.level
}
});
看到 globalData
中各个字段和名称以及 wx.getStorageSync
方法,一切都明朗起来了:小游戏通过微信的接口,将用户数据存储在本地,下下次启动时在读取回来,以继续游戏。
(3)从源码中能得到什么?
- 所有关卡的答案 :
ALL_IDIOMS
,第level
关的答案为ALL_IDIOMS[level-1]
- 如何通过用户的等级和当前等级的关数,计算总关数level。
比如御史第27关,总关数是多少呢,这其中的计算规则又是什么?看下面的代码:
onLoad: function () {
this.data.cur_level = Number(wx.getStorageSync(getApp().globalData.CURRENT_LEVELS));
var a = Number(wx.getStorageSync(getApp().globalData.TOTAL_POINT));
this.data.cur_level < 1 && (this.data.cur_level = 1);
var e = "http://xcxcy.oss-cn-hangzhou.aliyuncs.com/cycck/res/obj_" + this.data.cur_level + ".jpg";
this.data.cur_level > 501 && (e = "http://cyktc.oss-cn-beijing.aliyuncs.com/cyimg_rename/" + t.PIC[this.data
.cur_level - 502]);
var s;
for (s = 1; s < 15 && !(this.data.cur_level < (2 * s + 1) * (2 * s + 1)); s++);
var o = "http://cyktc.oss-cn-beijing.aliyuncs.com/level/level_" + s + ".png";
this.getRandomArr();
for (var r = [], n = 0; n < 4; n++) r[n] = 24;
for (var i = [], n = 0; n < 24; n++) i[n] = !0;
var l = !1;
try {
var c = wx.getSystemInfoSync();
console.log(c.model), -1 != c.model.search("iPhone") || (l = !0)
} catch (t) {}
this.data.cur_level < 30 && (l = !1), this.setData({
ans: r,
array_show: i,
main_img_url: e,
level_icon_url: o,
total_point: a,
pointAdd: l
})
},
该段代码应该是做了游戏开始时的初始化工作,其中就包含了将当前总关数,转换为等级 + 当前关数的方法,重点关注这个循环:
for (s = 1; s < 15 && !(this.data.cur_level < (2 * s + 1) * (2 * s + 1)); s++);
var o = "http://cyktc.oss-cn-beijing.aliyuncs.com/level/level_" + s + ".png";
合理猜测this.data.cur_level
为当前的总关数,而s代表的用户当前的等级,我们看看当s取不同值时,下面的url是什么:
通过这个循环得到了换算的方法:
总过关数 = (2*当前等级 - 1)^2 + 当前等级的关数
,比如御史第27关:
LEVEL_NAMES = ["学童", "童生", "秀才", "举人", "贡士", "进士", "翰林", "侍郎", "尚书", "大学士", "御史", "丞相", "太子少师", "太子太师"]
御史在LEVEL_NAMES中为第11项,所以当前等级=11
总过关数 = (2*11 - 1)^2 + 27 = 468
那么该关的答案为
ALL_IDIOMS[468-1] = 斗折蛇行
5. 一个自动答题的脚本
到目前为止,我们可以做到通过当前的等级以及关数,计算出答案。如果更进一步,如何实现一个自动答题的脚本?
思路:
- 首先手动指定起始关卡:
grade(等级)
和cur_level(当前等级的关数)
。例如御史27关,grade = 御史
,cur_level = 27
- 通过
grade
和cur_level
计算出当前的总关数level
,公式为:
level = (2*grade_index - 1)^2 + cur_level
其中grade_index
为grade
在LEVEL_NAMES
数组中的下标。例如御史第27关:level = (2 * 11 - 1)^2 + 27 = 468
- 通过
level
得到答案ans
,ans = ALL_IDIOMS[level - 1]
,那么御史27关答案为ALL_IDIOMS[468-1] = 斗折蛇行
- 在散落的汉字中找出答案,并依次点击,就可通过该关卡。
- 进入下一关,
level = level + 1
, 跳到第 3 步。
现在关键在于实现第 4 步。
第 4 步怎么做:
(1)利用 adb 相关命令获取手机截图,模拟点击等操作;
(2)使用 opencv 识别出截图中文字,从而判断如何点击;
抄起 Python 开始干
(1)首先引入 LEVEL_NAMES 和 ALL_IDIOMS
新建文件all_idioms.py
,将这两个数组拷贝进来(这里ALL_IDIOMS中省略了大部分内容)
# 等级计算: 总过关数 - (2*(当前等级-1) + 1)^2 = 当前等级的关数
LEVEL_NAMES = ["学童", "童生", "秀才", "举人", "贡士", "进士", "翰林", "侍郎", "尚书", "大学士", "御史", "丞相", "太子少师", "太子太师"]
ALL_IDIOMS = [
"浓眉大眼","一本正经","长话短说","五颜六色","因小失大","一心两用","历历在目","羊入虎口","欺上瞒下","一日三秋"......]
(2)新建程序的主脚本 hick_idioms.py
def main():
start_level_name = input("起始等级: ")
start_cur_level = input("起始关: ")
# 获得起始的关卡
start_level = getTotalLevel(start_level_name, int(start_cur_level))
while True:
#随便点击一个位置,这是因为每过一关以后会弹出一个对话框,点击一次是为了消除对话框
tap(200, 500)
time.sleep(1)
# 打印出这一关的答案
print(ALL_IDIOMS[start_level])
# 根据这一关的答案,生成用于匹配的图片模板,即将四个字转换成图片。
generateTemplate(ALL_IDIOMS[start_level])
#依次次匹配这四个字
for i in range(4):
refreshPic()
img_rgb = cv2.imread("screen.png")
img_gray = cv2.cvtColor(img_rgb, cv2.COLOR_BGR2GRAY)
template = cv2.imread('%d.png' % i, 0)
w, h = template.shape[::-1]
res = cv2.matchTemplate(img_gray, template, cv2.TM_CCOEFF_NORMED)
loc = np.where(res >= 0.6)
pts = list(zip(*loc[::-1]))
#如果匹配到了多个点,则随机选取一个(这是为了后面的纠错,若每次都选取固定的点,程序会陷入死循环)
pt = pts[random.randint(0,len(pts)-1)]
tap(pt[0], pt[1] + 1320)
# 判断是否成功
if isSuccess():
#成功了进入下一关
start_level = start_level + 1
else:
#失败了则撤回已经点选的答案,重新开始这一关,因为前面随机选取答案的机制,第一次没选对,第二次就有可能会选对......
tap(325,1175)
tap(425,1175)
tap(550,1175)
tap(675,1175)
if __name__ == '__main__':
main()
程序中还有的其他函数实现没有给出,但不难实现。