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,這是我沒有踏入過的世界,感覺一下子打開了。