https://github.com/zsiothsu/Pigtail
一、原型设计
https://www.wulihub.com.cn/gc/qGzDEb/index.html
[2.2.1]提供此次结对作业的设计说明,要求:(16分)
1. 这部分的首行原型作品的链接(1分)
2. 文字准确(4分)
3. 逻辑清晰(4分)
4. 说明你所采用的原型开发工具(1分)
- 原型模型必须采用专用的原型模型设计工具实现:如Axure Rp、Balsamiq Mockup、Prototype Composer、GUI Design Studio、Adobe设计组件等等。
答:原型开发工具:Axure Rp9
5. 图文并茂(多贴原型图)(6分)
-
主页面
-
帮助/规则
-
人人对战界面
-
联机对战界面
-
人机对战界面
-
返回桌面
[2.2.2]遇到的困难及解决方法:(4分)
- 困难描述(1分)
答:首先是没有合适、精美的元件库方便进行设计。
其次是初次使用Axure Rp系列原型设计,对原型设计的了解不够,也不懂得应该怎么设计交互。
在设计连接对方服务器地址界面时,涉及动态面板的操作也不太熟悉,走了一些弯路。
最后是找原型设计托管平台,一直找不到合适对接的平台用来托管,比如Axhub平台只能进行平台设计和支持Axure Rp8.0团队版的文件上传,AxureShare平台访问速度慢,只能支持RP格式等等。 - 解决过程(2分)
答:在淘宝上和知乎上找了很多元件库,方便了原型设计。为了解决软件操作不熟悉的问题,在B站上学习了相关的实战教程,一边实操一边摸索。后来浮窗的设计确实有点问题,虽然多走了一点弯路,但是也完美实现了。
原型设计托管平台是在知乎上找到了Wulihub文件托管平台,可以上传zip压缩文件,就可以进行在线演示,速度也是比较快的。 - 有何收获(1分)
答:原型设计很有意思,也了解到产品经理的职责,需要先设计出逻辑框架,然后团队才能明确能否实现。操作过程中遇到困难,但一一解决后看到成果成就感满满。
二、原型设计实现
类设计
本项目主要分为以下几个模块:
- OneGame、OneGame_Simple:游戏机制的实现
- Player:玩家的实现,包括储存个人信息以及执行玩家的动作
- AI:基础AI的实现,帮助玩家出牌
- LocalServer:依照课程提供的 API 仿造出的一个本地服务端,负责单人游戏以及本地开服联机
各个模块的关系框图如下:
模块及网络接口的实现
OneGame
OneGame
拥有两个实现
OneGame
用于服务器支持游戏运行,拥有一局游戏的完整信息OneGame_Simple
用于客户端根据返回值last_code
来更新游戏,是一个不完全的游戏体。
类结构
OneGame
的部分结构:主要由卡牌信息和游戏控制信息组成,开始游戏之后,只需通过operate()
函数来执行玩家的步骤
/* /app/src/main/java/com/emmm/poke/server/LocalServer.java */
class OneGame {
/* cards */
private Stack<String> card_group;
public Stack<String> card_placement;
public Vector<String> card_at_p1;
public Vector<String> card_at_p2;
/* game status information */
public GameStatus gameStatus;
public int turn;
public int winner;
/**
* lock
* reentrant lock for multithreaded query,
* for ensuring only one player getting the
* first hand
*/
Lock turn_lock = new ReentrantLock();
/**
* do an operation: put a card or turn over from card group
*
* @param host player order, 0: host 1:guest
* @param order own order.
* @param op game operation
* @param card card put or turned over
* @return Tuple.first: isSuccess
* Tuple.second: code likes '0 0 H7'
* Tuple.third: full log message
*/
public Tuple<Boolean, String, String> operate(int host, int order, GameOperation op, String card) {}
}
游戏流程
游戏流程每个人的实现都差不多,所以就不详细展开了,这里贴出游戏流程的结构。
/* /app/src/main/java/com/emmm/poke/server/LocalServer.java */
if (op == GameOperation.turnOver) {
/* 翻牌的处理 */
/* 卡牌组空了 */
if (this.card_group.empty()) {
/* 游戏结束 */
}
} else {
/* 打牌的处理 */
}
回合轮转(关键)
回合轮转听起来不像是很难的代码,实现确实也只有几行,但确实是游戏开始最关键的步骤,需要考虑以下几点
- 玩家到齐后:第一个判断是否轮到自己的或者第一个操作的玩家设为游戏的先手
- 两个玩家可能同时向服务器请求先手
- 服务器要支持自己和自己联机
思考一下以上问题,处理的方式就很明显了:加上一个锁
其中一段回合轮转的代码
/* /app/src/main/java/com/emmm/poke/server/LocalServer.java */
public boolean getTurn(int id) {
/* 加锁防止同时先手 */
turn_lock.lock();
boolean ret = false;
/* 游戏正在进行 */
if (this.gameStatus == GameStatus.PLAYING)
/* 正常返回轮次 */
ret = (this.turn == 0 ? (this.host_id == id) : (this.guest_id == id));
/* 游戏刚刚开始 */
else if (this.gameStatus == GameStatus.READY) {
/* if player is first hand, set turn for this player */
this.turn = id == host_id ? 0 : 1;
/* 返回true, 并且设置游戏开始 */
this.gameStatus = GameStatus.PLAYING;
ret = true;
}
turn_lock.unlock();
return ret;
}
LocalServer
至于为什么要特地写一个本地的服务器而不是用课程提供的实践平台,原因倒也很明显,不连校园网就没法调试啊,然后就写了一个和实践平台API一样的本地服务器。
而且参考了一下游戏 Minecraft,这个游戏的单人模式其实也是在本地开启了一个服务端,然后客户端连接本地服务器进行游戏。而且更重要的是,本地开服顺便实现了局域网联机的功能,其它客户端只要访问这台设备就可以进行双人游戏了,突破人人对战在同一个设备的限制。
LocalServer
使用的框架是 NanoHttpd , 可以实现回复 HTTP 请求,和进行 uri 的静态路由,做一个游戏服务器很合适
类结构
基础框架继承了RouterNanoHTTPD
, 实现以下几个类进行数据处理
LoginHandler
处理登录操作,api:/api/user/login/
GameControlHandler
创建游戏和获取游戏上步操作, api:/api/game/
,/api/game/:uuid/last/
GameHandler
单局游戏的操作和加入,以及获取游戏详细信息, api:/api/game/:uuid
indexHandler
获取服务器当前公开的游戏的列表 api:/api/game/index/
开启了全局的服务器,并且维护玩家列表和游戏列表。
/* /app/src/main/java/com/emmm/poke/server/LocalServer.java */
public class LocalServer extends RouterNanoHTTPD {
public static HashMap<String, OneGame> gameList = new HashMap<>();
public static HashMap<String, Integer> playerList = new HashMap<>();
/* global game server */
public static LocalServer server = new LocalServer(9000);
/** LoginHandler
* Handler for api "/api/user/login/"
*/
public static class LoginHandler extends DefaultHandler {}
/** GameControlHandler
* Including create a new game and get last operation.
* For api "/api/game/"
*/
public static class GameControlHandler extends DefaultHandler {}
/** GameHandler
* Game process handler
* Handler for api "/api/game/:uuid"
*/
public static class GameHandler extends DefaultHandler {}
/** indexHandler
* Handler for api "/api/game/index/"
* List all public games on server
*/
public static class indexHandler extends DefaultHandler {}
public static class _404Handler extends DefaultHandler {}
/**
* add routers for apis
*/
public void addMappings() {
addRoute("/api/user/login/", LoginHandler.class);
addRoute("/api/game/", GameControlHandler.class);
addRoute("/api/game/index/", indexHandler.class);
setNotFoundHandler(_404Handler.class);
}
}
服务端网络接口(nanohttpd)
以执行玩家操作的api为例
请求示例(这里用curl调试)
curl -X PUT -H'Authorization:**************************' -H'Content-Type:application/json' -d'{"type": 0}' 'http://127.0.0.1:9000/api/game/1234567890123456'
返回示例
{
"code": 200,
"data": {
"last_code": "1 0 D10",
"last_msg": "2P 从<牌库>翻开了一张 D10"
},
"msg": "操作成功"
}
- 获取session头部
String token = session.getHeaders().get("authorization");
- 获取游戏uuid, uri为
/api/game/:uuid
String baseUri = uriResource.getUri();
String[] uriSq = baseUri.trim().split("/");
String uuid = uriSq[2];
- 执行完毕后返回 JSON 数据
JSONObject res = new JSONObject();
JSONObject data = new JSONObject();
String last_code = opres.third;
data.put("last_code", opres.second);
data.put("last_msg", last_code);
res.put("code", 200);
res.put("data", data);
res.put("msg", "操作成功");
return NanoHTTPD.newFixedLengthResponse(Response.Status.OK, getMimeType(), res.toString());
Player、AI
客户端使用了 OkHttpClient,使用起来比java自带的http客户端稳定。
网络接口实现(okhttp)
以操作卡牌为例
下面是部分代码, 因为安卓访问网络必须用非UI线程,所以单独开了一个线程给http请求
/* /app/src/main/java/com/emmm/poke/client/Player.java */
/* 请求url */
String url = "http://" + server_game_ip + ":" + server_game_port + "/api/game/" + uuid;
String urlParam = op == GameOperation.putCard ?
"{\"type\":1, \"card\":\"" + card + "\"}" :
"{\"type\":0}";
MediaType JSON = MediaType.parse("application/json; charset=utf-8");
/* 执行http请求 */
OkHttpClient client = new OkHttpClient();
RequestBody body = RequestBody.create(JSON, urlParam);
Request request = new Request.Builder()
.url(url)
.addHeader("Authorization", token)
.addHeader("Content-Type", "application/json")
.put(body)
.build();
Response response = client.newCall(request).execute();
/* 解析返回的JSON数据 */
if (response.isSuccessful() || response.code() == 403) {
String res = response.body().string();
JSONObject json = new JSONObject(res);
JSONObject data = json.getJSONObject("data");
String last_code = data.getString("last_code");
String last_msg = data.getString("last_msg");
ret_value = new Tuple<Boolean, String, String>(response.code() == 200, last_code, last_msg);
} else {
ret_value = new Tuple<Boolean, String, String>(false, "请求超时", "");
}
AI实现
上图为一次两个AI交战的结果,对我一个人类来说很恐怖的是两个AI结束时的手牌都非常少。
这里编写的AI没有什么特别的功能,因为笔者自己也没有什么好的策略能赢得这个游戏,所以只加入了一条规则:实际上是一个贪心算法
假设牌堆里最多的花色为S,且放置区的顶部花色不为S,自己的手牌刚好有S花色的牌,那就打出这张牌
为了应对和上条规则一样的AI,当最大概率翻出的花色和放置区顶部的牌花色一致,且手里有其它花色的牌,那就打出一张非此花色的牌
其余情况直接翻牌
这条规则能做到尽量消耗自己的手牌,且让对手下一次翻牌且吃牌的概率最大。如果是我自己打牌的话大概率也会这样做,只是我没法记住牌组中现在还有多少什么样的牌
/* src/main/java/com/emmm/poke/client/AI.java */
public void active() throws InterruptedException {
/* 判断放置区是否为空 */
char top_card = player.game.card_placement.empty()
? 'N'
: player.game.card_placement.peek().charAt(0);
/* 获取牌组中最多的花色 */
Pair<Double, Character> most_type_in_group = new Pair<Double, Character>(0.0, 'N');
Pair<Double, Character> get_chance_S = new Pair<Double, Character>((double) player.game.rest_S / player.game.card_group, 'S');
if (get_chance_S.first > most_type_in_group.first) most_type_in_group = get_chance_S;
// 以下省略
/* 获取自己各花色卡牌数量 */
int player_own_S = player.game.own_S;
// 以下省略
Vector<String> own_card = player.host == 0 ? player.game.card_at_p1 : player.game.card_at_p2;
boolean status = false;
/*
假设牌堆里最多的花色为S,且放置区的顶部花色不为S,自己的手牌刚好有S花色的牌,那就打出这张牌
*/
if ((top_card != 'S' && most_type_in_group.second == 'S' && player_own_S > 0)
|| (top_card != 'H' && most_type_in_group.second == 'H' && player_own_H > 0)
|| (top_card != 'C' && most_type_in_group.second == 'C' && player_own_C > 0)
|| (top_card != 'D' && most_type_in_group.second == 'D' && player_own_D > 0)
) {
String card = null;
for (String c : own_card) {
if(c.charAt(0) == most_type_in_group.second) {
card = c;
player.operate_update(GameOperation.putCard, card);
status = true;
break;
}
}
}
/* 如果最大概率翻出的花色和放置区顶部的牌花色一致且手里有其它花色的牌,那就打出一张非此花色的牌 */
if(!status && top_card == most_type_in_group.second) {
String card = null;
for (String c : own_card) {
if(c.charAt(0) != most_type_in_group.second) {
card = c;
player.operate_update(GameOperation.putCard, card);
status = true;
break;
}
}
}
/* 否则就翻牌 */
if(!status) {
player.operate_update(GameOperation.turnOver, null);
}
}
如图是AI的一次交战,可以看到运用以上策略,打出最有把握的一张牌之后,如果对手没法招架,往往对手在下一次翻牌时就会吃牌。可以说是性价比最高
实现中遇到的问题
nanohttpd
NanoHTTPD 添加静态路由时调用的是以下函数
addRoute("/api/user/login/", LoginHandler.class);
该函数的原型是:
public void addRoute(String url, Class<?> handler, Object... initParameter) {
router.addRoute(url, 100, handler, initParameter);
}
可以看到,传入的handler类在库中的类型是Class<?>
,所以是一个类型,而不是某个已经实例化的对象,在 NanoHTTPD 库中该 Handler 被用以下代码实例化:
Object object = handler.newInstance();
比较棘手的是,我的Handler类一开始是定义为 LocalServer 的内部非静态类,而Server一开始也不是全局静态变量,导致 object 实例化时一直失败,因为不能使用newInstance()
实例化一个没有父实例基础的内部非静态类。
google了半天也没有办法让其在Server实例的基础上实例化 Handler。
最后解决办法是 Server 直接作为全局静态变量运行, 而内部 Handler 类也全部改为 static 的静态内部类。才解决了用 class.newInstance()
实例化的问题。耗时一个晚上,期间甚至想过改库的代码,但是要修改的地方太多,最后就妥协了。
服务端返回值
当最后一张牌被对手翻开,游戏随之结束。然而客户端向服务器请求最后一次操作时,返回的是这样的结果:
{"code":400,"data":{"err_msg":"对局已结束"},"msg":"参数错误"}
这是一个令人哭笑不得的结果,对方已经结束了游戏,而自己只能根据之前的记录,来算出被对手翻开的最后一张牌到底是什么。
所以最后在getlast方法下加了一个判断语句,来为游戏收尾,这才结束了游戏。
if (json.getInt("code") == 400) {
Log.v("code", "400end");
if(game.card_group == 1) {
int base = 0;
if(game.rest_S == 1) base = 0;
else if(game.rest_H == 1) base = 13;
else if(game.rest_C == 1) base = 26;
else if(game.rest_D ==1 ) base = 39;
/* 找出那张不在任何牌堆的牌,作为最后一张牌被对手翻开 */
for(int i = base ; i < base + 13;i++) {
String final_card = OneGame_Simple.card_new[i];
if(!game.card_at_p1.contains(final_card) &&
!game.card_at_p2.contains(final_card) &&
!game.card_placement.contains(final_card)) {
game.operate((host+1) % 2, host, GameOperation.turnOver, final_card);
break;
}
}
}
ret_value = true;
}
性能分析
如图为按照 total Time 排序的性能分析图
客户端耗时排序
- getLast
- login
- createGame
- joinGame
- operate
服务端耗时排序
- GameHandler POST joinGame (NanoHttpd Request Processor(#4))
- GameHandler PUT operate (NanoHttpd Request Processor(#8))
- LoginHandler POST login (NanoHttpd Request Processor(#1 #2))
- GameControlHandler POST createGame (NanoHttpd Request Processor(#3))
- GameControlHandler GET getLast (NanoHttpd Request Processor(#9))
如图是单用户调用的操作的耗时, 可以看到一次操作调用的getlast消耗了 3 次 getlast 的时间,大概是判断是否到自己的轮次就消耗了两次 getlast。
new Thread(new Runnable() {
@Override
public void run() {
while (true) {
try {
while (!player.getLast()) {
Thread.sleep(50);
}
player.ai.active();
if(player.isGameOver()) break;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
MainActivity.p1 = true;
}
}, "P1").start();
其中 player.ai.active() 包含了一次operate操作和一次getlast操作。
因为判断是否到自己的轮次用了while自旋等待,所以是比较消耗cpu资源的。如果把 while 中线程sleep 的时间从 50 改为 100 , 那就能减少一次无意义的 getlast。
Git 记录
任务进度条
第N周 | 新增代码(行) | 累计代码 | 本周学习耗时(小时) | 累计学习耗时(小时) | 重要成长 |
---|---|---|---|---|---|
1 | 1253 | 1253 | 41 | 41 | 初步学习了如何开发安卓应用和服务端设计 |
2 | 4473 | 5726 | 40 | 81 | 学习了多线程和多进程的异步操作 |
评价队友
姓名 | 学号 |
---|---|
许嘉滨 | 1111900828 |
布置的任务都能及时完成,完成度很高。但由于作业有难度,加上之前都没有安卓开发的经验,所以一些任务实现上遇到了困难,需要加强沟通吧,可以少走一些弯路。
姓名 | 学号 |
---|---|
林经纬 | 081900321 |
技术走在前面,耐心并且有恒心,有规划地一步一个脚印。
大佬直接把我带飞了!虽然很不想给神仙舍友拖后腿,但还是给他拖了后腿/sigh..一定要努力赶上滨哥的步伐!
团队分工
学号 | 姓名 | 博客链接 | 分工 |
---|---|---|---|
111900828 | 许嘉滨 | https://www.cnblogs.com/chipenhsiao/p/15434174.html | 后端开发 |
081900321 | 林经纬 | https://www.cnblogs.com/Pothoy/p/15438112.html | 前端设计 |
三、心得
姓名 | 学号 |
---|---|
许嘉滨 | 1111900828 |
可能是自己能力太差了,加上也是第一次开发安卓端的软件和第一次调N线程的代码,坑一掉就是两三天,结果最后发现都是一两句语句写错,就这一两句可以消耗我整个晚上来查错。然后两三天就布置一次的数据库的作业也被我直接鸽掉了,我图个啥呢,可能是我本来就不想写数据库的作业。
比如服务端的 OneGame 类,本来翻牌的 code 是 0,打牌的 code 是 1,然后我一不小心都写了 0,给自己埋了一大坑。因为对自己写过的代码比较自信,AI 写出来时发现全在翻牌,一张牌也没打出,也没怀疑过是自己写的服务端的问题。等到我发现服务端 code 全是 0 时,那一瞬间自己智商--,我为什么不先用老师给的服务端试一下。
想起来了调试硬件的痛苦,要么代码问题,要么排母松了,要么焊点虚焊,要么芯片直接拿到了次品,要么做完才发现从理论上计算,这个项目就是不可能达到指定要求的。
现在知道软件一样难调试。
可能就是想证明这么简单的软件我怎么可能写不出来,然后越写越觉得自己什么都不会。但还是挺过来了就是。毕竟我还是写出来了。
姓名 | 学号 |
---|---|
林经纬 | 081900321 |
完全没开发过项目,这次的软工实战实在是心有余而力不足,一边看B站视频从零起步JavaScript,一边掉头发。还好有一个神仙室友各方面素养都!很!优!秀!和他在一个宿舍我学到了很多东西!
在他的带领下,我从以为前端就是原型设计,到亲手设计一款app的样式,了解开发一款app的流程,知道前端和后端对接的方式不只有通过接口,还有镶嵌代码的方式,以及移动端app设计使用的Andriod Studio开发工具。
完成项目之后,我真的很佩服他的毅力和坚持,因为我不会写python和js+css +html,所以我只能在心里默默支持他,然后如果他需要帮助,我就顶上。事实证明,他能力很优秀,把我带飞了,长大我也要像他一样优秀/XD。
给我感触很深的就是,掌握技能,是一件很厉害的事情,完成项目,真的成就感满满,以前没有想过两个人就能写好这样一个卡牌游戏的app,这是我没有踏入过的世界,感觉一下子打开了。