結對編程作業


https://github.com/trainKing-star/PigGame

姓名 學號 博客鏈接 項目類型
周浩東 031902332 結對編程作業 單人項目

項目已部署在雲服務器,電腦點擊訪問試玩,不過服務器帶寬小、作品需要加載的資源太大,初次加載會有需要游覽器緩存資源,可能存在一些bug

一、原型設計

1. 原型作品

設計靈感來自NS版世界游戲大全51豬尾巴游戲,以這個游戲的UI界面為原型,詳情見B站視頻鏈接
各個頁面使用簡潔風格,游戲間使用大量的動畫增強交互感,頁面有設置背景音樂,因此決定設計內容:

  • 項目以Web頁面形式開發
  • 游戲界面向B站視頻中演示UI靠齊,力求還原
  • 游戲界面、游戲元素都有背景音樂或者音效
  • 游戲元素響應鼠標事件,能響應動畫

2. 原型開發工具

  • 在線摳圖工具:搞定摳圖
  • 在線PS:搞定
  • 圖像超分:Waifu2x-Extension-GUI

3. 開發原型界面

(1)主界面

主界面邏輯:

  • 用戶進入頁面,頁面背景為黑色,等待4s后頁面內容全部加載,設置有動畫
    • 4s內界面逐漸從黑色變為設置的顏色,透明度從0逐漸到1過度
    • 4s內頁面牌堆開始加載並逐步演示牌堆動畫
    • 4s后如頁面全部元素加載完畢,中間的牌堆和豬豬設置有連續的上下浮動動畫
  • 頁面設置有背景音樂,當音樂文件加載完畢便開始循環播放音樂,音樂選擇了輕松愉悅的純音樂《菊次郎的夏天》
  • 頁面右下方的選項按鈕,設置有當獲取到鼠標訪問時會有變色效果

(2)游戲界面

游戲界面邏輯:

  • 用戶進入頁面,頁面背景為黑色,等待4s后頁面內容全部加載,設置有動畫
    • 4s內界面逐漸從黑色變為設置的顏色,透明度從0逐漸到1過度並且過程中背景會逐漸被拉近
    • 4s內頁面牌堆開始加載,並會在游戲背景中按順便排成一個扇形
    • 4s后如頁面全部元素加載完畢,默認時左下角的玩家先手,左下角玩家頭像會變大並且開始有規律的閃爍
  • 頁面設置有背景音樂,元素也有特有音效
  • 在出牌階段,出牌的玩家頭像會變大並且有規律的閃爍
  • 扇形牌堆可以響應鼠標事件,當鼠標移動到牌上,牌會提高Z軸並且視圖變大,點擊牌,牌將進行動畫移動到扇形中間區域
  • 用戶打出的牌與翻開的牌堆頂部牌同花色時,翻開牌堆的牌將消失並且會進行一系列的動畫出現在對應玩家的手牌區域
  • 用戶點擊用戶的手牌區域將動畫彈出用戶的手牌,如果是自己的手牌則在自己的出牌階段可以從手牌打出,打出時牌會從手牌消失並且動畫出現在翻過牌堆,再次點擊手牌區將會把彈出的手牌彈回手牌區

(3)游戲結束界面

結束界面邏輯:

  • 當扇形牌堆最后一張牌被打出,進入游戲結束界面,一層透明淡黑色背景覆蓋原頁面
  • 首先出現兩個玩家的手牌數量對比,如果兩者手牌不相等則逐漸出現對應的勝利圖案,其間有勝利的音效,一段時間后回到主界面

4. 困難及解決方法

問題 解決方案
UI原型來自B站視頻,沒有圖片、音頻等資源 通過在線PS、摳圖,圖像超分、圖片修正等工具和技術獲取到想要的所有資源
撲克牌本身的對象集合以及翻轉、排序難以自主實現 github上選定了一個開源第三方js包,富有一些想要的功能,這也是我選擇web端開發的重要原因
原生js實現起來復雜 原生js和js框架Jquery結合使用
原生js實現動畫困難 使用CSS3和HTML5動畫進行開發
B站UI原型為3D版本,本人技術上沒有頭緒 改成2D版本

5. 收獲

  • HTML、CSS、Javascript啥都能做
  • 互聯網時代啥都有,在線PS、視頻編輯、音頻編輯,圖像超分、圖像修正等,只有你想不到沒有它做不到
  • 技術上有困難、遇事不決,找Github,啥都有
  • 框架越來越簡化開發,項目邏輯比技術更重要

二、原型設計實現

1. 前端代碼實現思路

項目中有實現由人機對戰、AI對戰、本地玩家對戰四個對局類型,四個對局類型延用一套游戲邏輯

本地玩家對戰是原始的模板,人機對戰和AI對戰在模板上基於編寫的后端接口,請求后端服務進行AI對戰響應

  • 通過HTML進行頁面基本布局
  • 通過CSS編寫動畫,添加樣式以及設置元素位置信息
  • 通過Javascript加載牌堆,編寫內部游戲邏輯,編寫定時函數以及動畫加載
  • 通過Python編寫AI模型、加載AI模型、后端接口等服務
  • 單人項目方便調試調優,進行前后端不分離開發,由后端渲染前端界面響應資源請求

前端開發采用到的開發第三方包:

  • deck-of-cards:撲克牌包,擁有初始化一副撲克牌並且設置有幾種撲克牌事件,如翻轉、扇形、排序、打亂等
  • Reflection.js:生成圖片倒影,首頁的小豬倒影用它來實現
  • jPlayer:背景音樂第三方包,通過這個包可以設置web前端音樂或者音效
  • Jquery:js框架,簡化開發

(1)前端首頁

主要部分通過Javascript加載牌堆並進行牌堆出現動畫並加載背景音樂

// 首頁js代碼
$(document).ready(function(){
    //初始化系統信息
    var $container = document.getElementById('container');
    // 初始化牌堆
    var deck = Deck();
    // 綁定標簽
    deck.mount($container);
    // 牌堆牌面翻轉
    deck.flip();
    // 牌堆出現效果
    deck.intro();
    // 加載背景音樂,循環執行
    background_index();
    setInterval(background_index, 154 * 1000);

    // 加載背景音樂
    function background_index(){
        let playerc = $("#jplayer");
        if (playerc.data().jPlayer && playerc.data().jPlayer.internal.ready === true) {
            playerc.jPlayer("setMedia", {
                mp3: "static/static/background_index.mp3"
            }).jPlayer("play");//jPlayer("play") 用來自動播放
        }else {
            playerc.jPlayer({
                ready: function() {
                    $(this).jPlayer("setMedia", {
                        mp3: "static/static/background_index.mp3" //同上
                    }).jPlayer("play");//同上
                },
                swfPath: "static/node_modules/jPlayer/dist/jplayer/jquery.jplayer.swf",
                supplied: "mp3"
            });
        }
    }
});

(2)前端本地玩家對戰

內部邏輯代碼通過Javascript加載牌堆並進行牌堆出現動畫並加載背景音樂,CSS負責編寫動畫內容提供給js代碼調用

/*CSS動畫資源代碼實例,之后大多以此為模板*/
@keyframes body {
    0% {
        opacity: 0;
        transform: scale(1);
        background:url(../static/background.png) no-repeat;
        width:100%;
        height:100%;
        background-size:100% 100%;
        filter:progid:DXImageTransform.Microsoft.AlphaImageLoader(src='bg-login.png',sizingMethod=scale);
    }
    100% {
        opacity: 1;
        transform: scale(1.2);
        background:url(../static/background.png) no-repeat;
        width:100%;
        height:100%;
        background-size:100% 100%;
        filter:progid:DXImageTransform.Microsoft.AlphaImageLoader(src='bg-login.png',sizingMethod=scale);
    }
}

.main{
    animation-name: body;
    animation-duration: 4s;
    animation-fill-mode: forwards;
    display: flex;
    justify-content: space-between;
}

本地玩家對戰中核心的代碼是玩家的鼠標事件、牌堆的初始化、牌堆與手牌的交互、手牌觸發區的交換實現

  • 頁面加載時,牌堆開始加載並展開成扇形
  • 頁面首先初始化對局信息,開啟對於牌堆的鼠標事件,響應用戶的操作,牌堆按使用和未使用分成兩部分
  • 開始收牌時,進行牌堆和手牌的轉化,共有三個牌堆,主牌堆和兩個玩家的專屬牌堆
// 以下為游戲初始化的信息
// 主牌堆
let deck_container = init("container");
// 兩個玩家的專屬牌堆
let player1 = null, player2 = null;
// 牌堆亂序並且展開成扇形
deck_container.shuffle();
deck_container.fan();
// 對局信息,記錄上一步的信息和當前對局的玩家回合
let record = 0, record_suit = null;
let GAME = 0;

// 主牌堆初始化
function init(label) {
    // 獲取標簽
    let $label = document.getElementById(label);
    // 綁定標簽
    let deck = Deck();
    deck.mount($label);
    return deck
}
// 主牌堆、兩個玩家的鼠標事件,三個牌堆的鼠標事件實現類似以玩家一的鼠標事件作為例子
// 玩家鼠標點擊事件
function p1_mouseenter(card){
    // 鼠標移到牌上將會z軸上升並且變大
    let vw = $(window).width();
    let origin = card.currentTarget.style.transform;
    card.currentTarget.style["z-index"] = parseInt(card.currentTarget.style["z-index"]) + 200;
    card.currentTarget.style.transform = origin + "perspective(" + vw * 0.8 + "px) translateZ(" + vw * 0.10 + "px)";
    event.stopPropagation()
}
function p1_mouseleave(card){
    // 鼠標移出牌上將會z軸下降並且變回原大小
    let origin = card.currentTarget.style.transform.split(" ");
    origin = origin.slice(0, origin.length - 2).join(" ");
    card.currentTarget.style["z-index"] = parseInt(card.currentTarget.style["z-index"]) - 200;
    card.currentTarget.style.transform = origin;
    event.stopPropagation()
}
let chick_mp1 = 0;
function p1_mousedown(){
    // 鼠標點擊事件,信號量參數判斷是不是可以行動
    if(GAME === 1 || chick_mp1 === 1) return;
    chick_mp1 = 1;
    // 加載點擊音效
    background_move();
    let t = $(this);
    // 遍歷玩家牌堆,獲取到鼠標點擊的牌並進行響應的操作
    player1.cards.forEach(function (card) {
        // 獲取到鼠標點擊的牌
        if (card.$el.className === t.attr("class")) {
            card.unmount();
            let label = null;
            deck_container.cards.forEach(function (c) {
                if(c.i === card.i){
                    label = c;
                    return false
                }
            });
            // 主牌堆出現一張已使用的牌
            label.mount($("#container .deck")[0]);
            label.$el.style["z-index"] = parseInt(label.$el.style["z-index"]) + 200;
            // 點擊事件結束,邏輯判斷並加載對應動畫,判斷是否結束游戲
            function f() {
                let end = 1;
                deck_container.cards.forEach(function (card){
                    if(card.$el.className === "card")  {
                        end = 0;
                        return true;
                    }
                    if(card.$el.style["z-index"] >= 200){
                        if(record_suit==null) record_suit = card;
                        else if(card.i !== record_suit.i && card.suit === record_suit.suit){
                            // 是否滿足收牌條件,進行玩家收牌
                            if(GAME === 0) player1 = player_one_init("player1");
                            else player2 = player_one_init("player2");
                            record_suit = null;
                        }
                        // z軸回歸,讓新打出牌置於久牌頂
                        else record_suit = card;
                        card.$el.style["z-index"] = record;
                        record++;
                        c_click = 0;
                        // 下一回合相應的玩家動畫
                        if (GAME === 0 ) {
                            GAME = 1;
                            action_add("div.bgImg2.footer.player2");
                            action_remove("div.bgImg1.header.player1");
                        }
                        else {
                            GAME = 0;
                            action_remove("div.bgImg2.footer.player2");
                            action_add("div.bgImg1.header.player1");
                        }
                        return false;
                    }
                });

                // 游戲結束
                if(end === 1) {
                    $(".end").css("display", "");
                    let p1 = 0;
                    // 玩家的手牌計算
                    player1.cards.forEach(function (card){
                        if(card.$root != null) p1++;
                    });
                    let p2 = 0;
                    player2.cards.forEach(function (card){
                        if(card.$root != null) p2++;
                    });
                    // 顯示玩家手牌數
                    $("p.text1").text(p1);
                    $("p.text2").text(p2);
                    // 跳轉回首頁
                    function index() {
                        window.location.href = "http://127.0.0.1:5000";
                    }
                    // 加載結束動畫
                    function s1() {
                        $(".success1").css("display", "");
                        $(".photo").css("display", "none");
                    }
                    function s2() {
                        $(".success2").css("display", "");
                        $(".photo").css("display", "none");
                    }
                    // 定時器設定,保持函數有序執行
                    if(p1>p2) setInterval(s2, 2000);
                    else if(p1<p2) setInterval(s1, 2000);
                    setInterval(index, 5000);
                    return false;
                }
                chick_mp1 = 0;
            }
            setTimeout(f, 2000);
            return false;
        }
    });
    // 禁止事件向上冒泡
    event.stopPropagation();
}
// 觸發區鼠標事件
let click_f = 0;
// 加載尾部觸發區動畫並且進行主牌堆的事件禁止和開啟
$("#footer").mousedown(function (){
    if(click_f === 0){
        // 加載動畫
        $("#player1").css({
            "animation-name": "player1_change_end",
            "animation-duration":"2s",
            "animation-fill-mode":"forwards"
        });
        // 禁止主牌堆的事件開啟
        $("#container .card").off("mouseenter", c_seen);
        $("#container .card").off("mouseleave", c_leave);
        $("#container .card").off("mouseup", mouseup);
        click_f = 1;
    }
    else if(click_f === 1){
        // 加載動畫
        $("#player1").css({
            "animation-name": "player1_change_start",
            "animation-duration":"2s",
            "animation-fill-mode":"forwards"
        });
        // 開啟主牌堆的事件
        click_f = 0;
        $("#container .card").on("mouseenter", c_seen);
        $("#container .card").on("mouseleave", c_leave);
        $("#container .card").on("mouseup", mouseup);
    }
});
let click_h = 0;
// 加載頭部觸發區動畫並且進行主牌堆的事件禁止和開啟
$("#header").mousedown(function (){
    if(click_h === 0){
        // 加載動畫
        $("#player2").css({
            "animation-name": "player2_change_end",
            "animation-duration":"2s",
            "animation-fill-mode":"forwards"
        });
        // 禁止主牌堆事件
        click_h = 1;
        $("#container .card").off("mouseenter", c_seen);
        $("#container .card").off("mouseleave", c_leave);
        $("#container .card").off("mouseup", mouseup);
    }
    else if(click_h === 1){
        // 加載動畫
        $("#player2").css({
            "animation-name": "player2_change_start",
            "animation-duration":"2s",
            "animation-fill-mode":"forwards"
        });
        // 開啟主牌堆事件
        click_h = 0;
        $("#container .card").on("mouseenter", c_seen);
        $("#container .card").on("mouseleave", c_leave);
        $("#container .card").on("mouseup", mouseup);
    }
});

(3)前端人機對戰和AI對戰

人機對戰、AI對戰繼承了玩家對戰的代碼模板,添加了前端向后端請求AI接口,處理響應信息並針對AI請求對玩家對戰的模板進行了更改

  • 前端向后端請求接口,使用了ajax進行請求
  • 針對AI操作,取消一個玩家的鼠標事件,改為自動執行函數
// AI行為請求和AI鼠標點擊事件函數
function AI_action() {
    // 初始化傳入請求的字典
    let data_dict = {
        "pokers_total":0, "pokers_0":0, "pokers_1":0, "pokers_2":0, "pokers_3":0,
        "used_total":0, "used_0":0, "used_1":0, "used_2":0, "used_3":0, "used_head":0,
        "player_one_total":0, "player_one_0":0, "player_one_1":0, "player_one_2":0, "player_one_3":0,
        "player_two_total":0, "player_two_0":0, "player_two_1":0, "player_two_2":0, "player_two_3":0
    };
	// 計算主牌堆數據
    deck_container.cards.forEach(function (card) {
        if(card.$el.style["z-index"] >= 200) {
            data_dict["used_head"] = card.suit;
        }
        if(card.$root && card.$el.className === "card"){
            data_dict["pokers_total"] += 1;
            data_dict["pokers_" + card.suit] += 1;
        }
        else if(card.$root && card.$el.id === "show"){
            data_dict["used_total"] += 1;
            data_dict["used_" + card.suit] += 1;
        }
    });
	// 計算玩家一數據
    if(player1){
        player1.cards.forEach(function (card) {
            if(card.$root){
                data_dict["player_one_total"] += 1;
                data_dict["player_one_" + card.suit] += 1;
            }
        });
    }
	// 計算玩家二數據
    if(player2){
        player2.cards.forEach(function (card) {
            if(card.$root){
                data_dict["player_two_total"] += 1;
                data_dict["player_two_" + card.suit] += 1;
            }
        });
    }

	// 進行ajax post請求並傳入json數據
    $.ajax({
      type: 'POST',
      url: "http://127.0.0.1:5000/play",
      contentType: "application/json",
      data: JSON.stringify(data_dict),
      success: success,
      dataType: "json"
    });
	// 請求成功回調函數
    function success(data, textStatus, jqXHR) {
        let action = data["action"];
        if(action === 0 || player2 == null || data_dict["player_two_total"] === 0) {
            let array = new Array();
            // 獲取主牌堆位置信息
            deck_container.cards.forEach(function (card) {
                if(card.$root && card.$el.className === "card"){
                    array.push(card.pos);
                }
            });
            // 隨機選擇一個節點
            let index = Math.floor(Math.random() * array.length);
            let z = deck_container.cards[array[index]].$el;
			// 對這個節點進行動畫更新
            let vw = $(window).width();
            let origin = z.style["transform"];
            z.style["z-index"] = parseInt(z.style["z-index"]) + 200;
            z.style["transform"] = origin + "perspective(" + vw * 0.8 + "px) translateZ(" + vw * 0.10 + "px)";
            // 讓這個節點執行屬於這個節點的鼠標事件
            AI_mouseup(z);
        }
        else{
            // 針對請求的數據進行額外處理
            if(data_dict["player_two_" + (action - 1)] === 0){
                let array = new Array();
                player2.cards.forEach(function (card) {
                    if(card.$root && card.$el.className !== "card"){
                        array.push(card.pos);
                    }
                });
                let index = Math.floor(Math.random() * array.length);
                let z = player2.cards[array[index]].$el;
                // 執行屬於這個節點的點擊事件
                p2_mousedown(z);
                return false;
            }
            player2.cards.forEach(function (card) {
                if(card.$root && card.$el.className !== "card" && card.suit === (action - 1)){
                    p2_mousedown(card.$el);
                    return false;
                }
            });

        }
    }
}
// AI主動執行的點擊事件
function AI_mouseup(t){
    background_move();
    // 關閉主牌堆事件
    $("#container .card").off("mouseenter");
    $("#container .card").off("mouseleave");
    $("#container .card").off("mouseup");
    // z節點牌翻面
    deck_container.cards.forEach(function (card){
        if(card.$el.style["z-index"] === t.style["z-index"]){
            card.setSide('front');
        }
    });

    t.id = "show";
    function f() {
        let end = 1;
        deck_container.cards.forEach(function (card){
            if(card.$el.className === "card")  {
                end = 0;
                return true;
            }
            // 獲取已使用的牌頂牌,並進行判斷
            if(card.$el.style["z-index"] >= 200){
                if(record_suit==null) record_suit = card;
                else if(card.i !== record_suit.i && card.suit === record_suit.suit){
                    // 進行牌堆和玩家手牌的初始化
                    if(GAME === 0) player1 = player_one_init("player1");
                    else player2 = player_one_init("player2");
                    record_suit = null;
                }
                // 讓新牌置於牌堆頂
                else record_suit = card;
                card.$el.style["z-index"] = record;
                record++;
                return false;
            }
        });
        
        // 結束動畫
        if(end === 1) {
            $(".end").css("display", "");
            let p1 = 0;
            player1.cards.forEach(function (card){
                if(card.$root) p1++;
            });
            let p2 = 0;
            player2.cards.forEach(function (card){
                if(card.$root) p2++;
            });
            $("p.text1").text(p1);
            $("p.text2").text(p2);
            function index() {
                window.location.href = "index.html";
            }
            function s1() {
                $(".success1").css("display", "");
                $(".photo").css("display", "none");
            }
            function s2() {
                $(".success2").css("display", "");
                $(".photo").css("display", "none");
            }
            background_get_success();
            if(p1>p2) setInterval(s2, 2000);
            else if(p1<p2) setInterval(s1, 2000);
            setInterval(index, 5000);
            return;
        }

        c_click = 0;
        if (GAME === 0 ) {
            GAME = 1;
            action_add("div.bgImg2.footer.player2");
            action_remove("div.bgImg1.header.player1");
        }
        else {
            GAME = 0;
            action_remove("div.bgImg2.footer.player2");
            action_add("div.bgImg1.header.player1");
        }
		// 開啟主牌堆事件
        $("#container .card").on("mouseenter", c_seen);
        $("#container .card").on("mouseleave", c_leave);
        $("#container .card").on("mouseup", mouseup);
    }
    setTimeout(f, 2000);
}

2. 后端代碼實現思路

后端代碼包括AI模型開發、Web前端渲染、后端接口等,后端開發采用Python的Flask框架,AI開發采用Pytorch框架,以下為使用的主要使用的第三方包

  • Flask:Python的Web后端框架,簡單快捷方便
  • Pytorch:深度學習框架,同樣簡單快捷高效
  • Transformers:Hugging Face開發的自然語言處理框架,簡化自然語言模型開發
  • Pandas:Python的一個數據分析包,處理數據很方便
  • Flask-Socketio:flask集成socket長連接功能

(1)后端AI實現

AI實現思路:

  • 編寫豬尾巴游戲的游戲邏輯,使用隨機數參與游戲選擇,記錄下每局勝利玩家的對局數據,用作AI訓練
    • 為了方便,我指定只獲取玩家一勝利時候的對局數據
    • 重復獲取了一萬場勝利場數,總共獲取到六十多萬對局決策數據
class Record:
    """
    數據生成過程中的記錄類,運行過程中記錄下勝利場數中玩家的每一步操作
    """

    def __init__(self):
        """
        初始數據定義
        """
        self.data = pd.DataFrame(columns=["pokers_total", "pokers_0", "pokers_1", "pokers_2", "pokers_3",
                                          "used_total", "used_0", "used_1", "used_2", "used_3", "used_head",
                                          "player_one_total", "player_one_0", "player_one_1", "player_one_2",
                                          "player_one_3",
                                          "player_two_total", "player_two_0", "player_two_1", "player_two_2",
                                          "player_two_3",
                                          "label", "number"])

    def transform(self, pokers, used, pokers_one, pokers_two, label):
        """
        將輸入數據轉換為記錄
        :param pokers: 主體撲克牌集合
        :param used: 被使用但沒有被玩家收集的撲克牌集合
        :param pokers_one:玩家一的手牌集合
        :param pokers_two:玩家二的手牌集合
        :param label:玩家的選擇,出手牌或者翻牌堆
        """
        self.data = self.data.append({
            "pokers_total": self.get_poker_len(pokers),
            "pokers_0": pokers["0"],
            "pokers_1": pokers["1"],
            "pokers_2": pokers["2"],
            "pokers_3": pokers["3"],
            "used_total": self.get_poker_len(used),
            "used_0": used["0"],
            "used_1": used["1"],
            "used_2": used["2"],
            "used_3": used["3"],
            "used_head": used["head"],
            "player_one_total": self.get_poker_len(pokers_one),
            "player_one_0": pokers_one["0"],
            "player_one_1": pokers_one["1"],
            "player_one_2": pokers_one["2"],
            "player_one_3": pokers_one["3"],
            "player_two_total": self.get_poker_len(pokers_two),
            "player_two_0": pokers_two["0"],
            "player_two_1": pokers_two["1"],
            "player_two_2": pokers_two["2"],
            "player_two_3": pokers_two["3"],
            "label": label
        }, ignore_index=True)
        self.data["number"] = self.data["pokers_total"] + self.data["used_total"] \
                              + self.data["player_one_total"] + self.data["player_two_total"]

    def to_csv(self):
        """
        重復行刪除,nan行填充,寫入文件
        :return:
        """
        self.data = self.data.drop_duplicates()
        self.data = self.data.fillna(0)
        self.data.to_csv("data.csv", index=False)

    def get_poker_len(self, poker):
        """
        返回輸入的撲克牌集合的牌數
        :param poker:輸入的撲克牌集合
        :return: 撲克牌的有效數量
        """
        len = 0
        for k, v in poker.items():
            if k == 'head':
                continue
            len += v
        return len


class Game:
    """
    游戲對局類,通過隨機模擬出真實對局的情況
    """

    def __init__(self, recode_csv):
        """
        初始化參數
        :param recode_csv:輸入的記錄類實例
        """
        # 主體牌堆
        self.pokers = {"0": 13, "1": 13, "2": 13, "3": 13}
        # 已使用牌堆
        self.used = {"0": 0, "1": 0, "2": 0, "3": 0, "head": None}
        # 玩家一手牌
        self.player_one_pokers = {"0": 0, "1": 0, "2": 0, "3": 0}
        # 玩家二手牌
        self.player_two_pokers = {"0": 0, "1": 0, "2": 0, "3": 0}
        # 玩家回合
        self.LEADER = 0
        # 對局記錄
        self.recode = []
        # 全局勝利對局記錄
        self.csv_recode = recode_csv

    def start(self):
        """
        游戲開始類
        :return:返回游戲局是否是設置玩家勝利
        """
        while True:
            # 游戲結束判斷
            if self.game_over():
                # 是否是指定玩家勝利
                if self.get_poker_len(self.player_one_pokers) >= self.get_poker_len(self.player_two_pokers):
                    return 0
                # 寫入記錄類
                for r in self.recode:
                    self.csv_recode.transform(r[0], r[1], r[2], r[3], r[4])
                return 1
            # 游戲主體過程
            self.select()

    def record_list(self, label):
        """
        通過深拷貝復制對象,寫入記錄類實例
        :param label: 玩家操作,出手牌或者翻牌堆
        """
        self.recode.append((copy.deepcopy(self.pokers),
                            copy.deepcopy(self.used),
                            copy.deepcopy(self.player_one_pokers),
                            copy.deepcopy(self.player_two_pokers),
                            label))

    def select(self):
        """
        玩家主體游戲過程
        """
        # 判斷是否有能力出手牌,沒有就翻牌堆
        if self.LEADER == 0 and self.get_poker_len(self.player_one_pokers) == 0:
            self.record_list(0)
            self.select_poker(self.pokers)
            return
        elif self.LEADER == 1 and self.get_poker_len(self.player_two_pokers) == 0:
            self.select_poker(self.pokers)
            return

        random.seed(int(round(time.time() * 1000000)))
        domain = random.randint(0, 1)
        # 隨機過程模擬用戶是出手牌還是翻牌堆
        if domain == 0:
            self.record_list(0)
            self.select_poker(self.pokers)
        elif self.LEADER == 0:
            self.select_poker(self.player_one_pokers, 1)
        elif self.LEADER == 1:
            self.select_poker(self.player_two_pokers)

    def select_poker(self, pokers, main=0):
        """
        從輸入的撲克牌集合中按照規則隨機收取牌
        :param pokers: 輸入的撲克牌集合
        :param main: 是否是指定玩家出手牌
        """
        # 隨機從撲克牌集合出牌
        while True:
            random.seed(int(round(time.time() * 1000000)))
            index = random.randint(0, 3)
            if pokers[str(index)] == 0:
                return 0
            if main == 1 and self.LEADER == 0:
                # 是指定玩家出手牌,記錄
                self.record_list(index + 1)
            # 更新牌堆首牌信息
            pre_head = self.used["head"]
            pokers[str(index)] -= 1
            # 更新已使用牌堆信息
            self.enter_used(str(index))
            break

        if (index + 1) == pre_head:
            # 出牌與過去的牌堆首牌同花色,玩家收牌
            self.collect_card()

        if self.LEADER == 0:
            self.LEADER = 1
        elif self.LEADER == 1:
            self.LEADER = 0
        return 1

    def game_over(self):
        """
        游戲結束
        :return: 是否游戲結束
        """
        # 獲取主體牌堆有效牌數
        poker_len = self.get_poker_len(self.pokers)
        # 牌堆無牌則游戲結束
        if poker_len <= 0:
            return True
        return False

    def enter_used(self, index):
        """
        更新已使用牌堆信息
        :param index: 花色編號
        """
        self.used[index] += 1
        self.used["head"] = int(index) + 1

    def clean_used(self):
        """
        清空已使用牌堆
        """
        self.used = {"0": 0, "1": 0, "2": 0, "3": 0, "head": None}

    def collect_card(self):
        """
        玩家收牌
        """
        if self.LEADER == 0:
            play = self.player_one_pokers
        else:
            play = self.player_two_pokers
        # 玩家手牌加入已使用牌堆所有牌
        for k, v in self.used.items():
            if k == "head":
                continue
            play[k] += v
        self.clean_used()

    def get_poker_len(self, poker):
        """
        獲取輸入牌堆有效牌數
        :param poker: 輸入牌堆
        :return: 牌堆有效牌數
        """
        len = 0
        for k, v in poker.items():
            len += v
        return len


if __name__ == "__main__":
    csv_recode = Record()
    total_num = 0
    # 獲取一萬條勝利數據
    while True:
        game = Game(csv_recode)
        total_num += game.start()
        if total_num > 1e4:
            break
    csv_recode.to_csv()
  • 編寫AI模型,AI模型我采用一種簡單的神經網絡模型,並針對模型的層數和參數進行測試,最終使用了一個四層的神經網絡模型
class MyModel(nn.Module):
    """
    簡單神經網絡模型
    """
    def __init__(self, input_size, num_labels, device):
        """
        模型初始化
        :param input_size:輸入尺寸
        :param num_labels: 輸出標簽數
        :param device: 在哪種設備運行
        """
        super(MyModel, self).__init__()
        self.device = device
        # 交叉熵損失函數
        self.criterion = nn.CrossEntropyLoss()
        # 網絡模型
        self.start = nn.Sequential(
            nn.Linear(input_size, 32),
            nn.ReLU(),
            nn.Linear(32, 64),
            nn.ReLU(),
            nn.Linear(64, 32),
            nn.ReLU(),
            nn.Linear(32, num_labels)
        )

    def forward(self, data, label=None):
        """
        模型執行函數
        :param data:輸入數據
        :param label: 數據對應的標簽,None就是測試節點
        :return: 損失和標簽預測分數 或 標簽預測分數
        """
        probabilities = self.start(data)
        if label is not None:
            loss = self.criterion(probabilities, label)
            return loss, probabilities
        return probabilities
  • 編寫訓練代碼,設置優化算法、參數等信息,保存訓練結果
def main(train_file, target_dir,
         input_size=21,
         num_labels=5,
         epochs=100,
         batch_size=512,
         lr=2e-03,
         patience=10,
         max_grad_norm=10.0,
         checkpoint=None):
    """
    訓練函數
    :param train_file: 訓練數據集文件
    :param target_dir: 輸出文件目錄
    :param input_size: 出入尺寸
    :param num_labels: 輸出標簽數
    :param epochs: 執行迭代數
    :param batch_size: 輸入數據批量大小
    :param lr: 學習率
    :param patience: 忍耐次數,達到忍耐次數退出循環
    :param max_grad_norm: 參數裁剪力度
    :param checkpoint: 模型參數檢查點路徑
    """
    device = torch.device("cuda")
    print(20 * "=", "准備訓練 ", 20 * "=")
    # 保存模型的路徑
    if not os.path.exists(target_dir):
        os.makedirs(target_dir)
    # -------------------- 數據加載 ------------------- #
    print("\t* 開始分割數據集...")
    word_train, word_dev, label_train, label_dev = load_dev("train", train_file)
    print("\t* 加載訓練數據...")
    train_data = DataPrecessForSentence(word_train, label_train)
    train_loader = DataLoader(train_data, shuffle=True, batch_size=batch_size)
    print("\t* 加載驗證數據...")
    dev_data = DataPrecessForSentence(word_dev, label_dev)
    dev_loader = DataLoader(dev_data, shuffle=False, batch_size=batch_size)
    # -------------------- 模型定義 ------------------- #
    print("\t* 建立模型 分類:{}".format(num_labels))
    model = MyModel(input_size=input_size, num_labels=num_labels, device=device).to(device)
    # -------------------- 預訓練  ------------------- #
    # 待優化的參數
    param_optimizer = list(model.named_parameters())
    no_decay = ['bias', 'LayerNorm.bias', 'LayerNorm.weight']
    optimizer_grouped_parameters = [
        {
            'params': [p for n, p in param_optimizer if not any(nd in n for nd in no_decay)],
            'weight_decay': 0.01
        },
        {
            'params': [p for n, p in param_optimizer if any(nd in n for nd in no_decay)],
            'weight_decay': 0.0
        }
    ]
    # 設置優化函數AdamW
    optimizer = AdamW(optimizer_grouped_parameters, lr=lr)
    # 設置學習率策略
    scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="max",
                                                           factor=0.85, patience=0)
    best_score = 0.0
    start_epoch = 1
    # 損失曲線繪制的數據
    epochs_count = []
    train_losses = []
    valid_losses = []
    # 如果將一個檢查點作為參數,則從檢查點繼續訓練
    if checkpoint:
        checkpoint = torch.load(checkpoint)
        start_epoch = checkpoint["epoch"] + 1
        best_score = checkpoint["best_score"]
        print("\t* 訓練將繼續在 epoch 的現有模型上進行 {}...".format(start_epoch))
        model.load_state_dict(checkpoint["model"])
        optimizer.load_state_dict(checkpoint["optimizer"])
        epochs_count = checkpoint["epochs_count"]
        train_losses = checkpoint["train_losses"]
        valid_losses = checkpoint["valid_losses"]
    # 在開始(或恢復)訓練之前計算損失和准確度
    _, valid_loss, valid_accuracy = validate(model, dev_loader)
    print("\t* 訓練之前的驗證集loss: {:.4f}, accuracy: {:.4f}%".format(valid_loss, (valid_accuracy * 100)))
    # -------------------- 訓練迭代 ------------------- #
    print("\n", 20 * "=", "訓練模型時設備: {}".format(device), 20 * "=")
    patience_counter = 0
    for epoch in range(start_epoch, epochs + 1):
        epochs_count.append(epoch)
        print("* 訓練epoch {}:".format(epoch))
        epoch_time, epoch_loss, epoch_accuracy = train(model, train_loader, optimizer, max_grad_norm)
        train_losses.append(epoch_loss)
        print("-> 訓練time: {:.4f}s, loss = {:.4f}, accuracy: {:.4f}%"
              .format(epoch_time, epoch_loss, (epoch_accuracy * 100)))
        print("* 驗證epoch {}:".format(epoch))
        epoch_time, epoch_loss, epoch_accuracy = validate(model, dev_loader)
        valid_losses.append(epoch_loss)
        print("-> 驗證time: {:.4f}s, loss: {:.4f}, accuracy: {:.4f}%\n"
              .format(epoch_time, epoch_loss, (epoch_accuracy * 100)))
        # 使用調度器更新優化器的學習率
        scheduler.step(epoch_accuracy)
        # 早期驗證精度上停止
        if epoch_accuracy < best_score:
            patience_counter += 1
        else:
            best_score = epoch_accuracy
            patience_counter = 0
            torch.save({"epoch": epoch,
                        "model": model.state_dict(),
                        "best_score": best_score,
                        "optimizer": optimizer.state_dict(),
                        "epochs_count": epochs_count,
                        "train_losses": train_losses,
                        "valid_losses": valid_losses},
                       os.path.join(target_dir, "best.pth.tar"))
        if patience_counter >= patience:
            print("-> 提前停止:達到限制極限,停止...")
            break

最后出現的問題是用戶不出牌的標簽占了全部標簽的60%以上,導致訓練的結構過擬合偏向不出牌,我重新拆分了數據集使得每一個分類都平衡在5.3萬數據,因此更新后總數據量為26.3萬,最終訓練結果為接近30%的正確率

(2)后端接口實現

后端接口包括兩個部分,此部分使用Flask開發:

  • 前后端不分離開發,因此存在一個渲染前端項目的接口
  • 請求AI服務接口
  • websocket服務接口,用於在線對戰,不過在線對戰停工了
app = Flask(__name__)
# 允許跨域
cors = CORS(app)
# 允許socket
socket = SocketIO(app, cors_allowed_origins="*")
# 加載模型
model = load_model("models/best.pth.tar")
# 因為python的特性,所有這個a是線程安全的
client = 0


@socket.on('connect', namespace='/game')
def connect():
    """
    socket連接函數,連接大於兩個就會自動斷開新連接
    """
    global client
    if client > 2:
        socket.emit("disconnect")
    print("進入連接")


@socket.on('join', namespace='/game')
def join(data):
    """
    響應客戶端的加入房間請求
    :param data: 請求數據
    """
    global client
    # 抽取出房間號
    room = data["room"]
    join_room(room)
    # 客戶端數+1
    client += 1
    if client == 2:
        # 發送初始化信息到客戶端
        socket.emit("init", 1, namespace="/game")
        # 發送開始對局信息到客戶端
        socket.emit("start", broadcast=True, room=room, namespace="/game")
    else:
        # 發送初始化信息到客戶端
        socket.emit("init", 0, namespace="/game")


@socket.on('disconnect', namespace='/game')
def disconnect():
    """
    客戶端連接斷開
    """
    # 客戶端-1
    global client
    client -= 1


@app.route("/", methods=["GET"])
def index():
    """
    渲染HTML主頁
    """
    return render_template("index.html")


@app.route("/play", methods=["POST"])
def play():
    """
    接收AI玩家的操作請求
    """
    # 處理傳入的數據
    dict_json = request.data
    json_dict = json.loads(dict_json)
    # 使用模型得到數據並且返回處理信息
    result = use_model(model=model, json_dict=json_dict)
    return jsonify({"action": result})

對於AI接口,封裝了將瀏覽器請求信息轉化成模型可輸入信息的過程,相關代碼如下:

def load_model(checkpoint_file,
               input_size=21,
               num_labels=5,
               device="cpu"):
    """
    加載模型
    :param checkpoint_file: 模型參數檢查點路徑
    :param input_size: 輸入尺寸
    :param num_labels: 輸出標簽數
    :param device: 使用設備
    :return: 加載參數后的可用模型
    """
    model = MyModel(input_size=input_size, num_labels=num_labels, device=device).to(device)
    checkpoint = torch.load(checkpoint_file, map_location=device)
    model.load_state_dict(checkpoint["model"])
    return model


def use_model(model, json_dict):
    """
    使用模型進行預測
    :param model: 輸入可用的模型
    :param json_dict: 輸入的字典數據
    :return:
    """
    # 字典數據轉化為列表
    data_list = json_transform_data(json_dict)
    # 轉化為模型可用數據並輸入模型
    data = torch.tensor(data_list, dtype=torch.float32).reshape(1, -1)
    output = model(data).argmax(dim=1).item()
    if output != 0 and json_dict["player_one_" + str(output - 1)] == 0:
        return 0
    return output


def json_transform_data(data):
    """
    將輸入的字典信息轉為列表數據
    :param data: 輸入的字典
    :return: 可用列表數據
    """
    data_list = [
        data["pokers_total"],
        data["pokers_0"],
        data["pokers_1"],
        data["pokers_2"],
        data["pokers_3"],

        data["used_total"],
        data["used_0"],
        data["used_1"],
        data["used_2"],
        data["used_3"],
        data["used_head"],

        data["player_one_total"],
        data["player_one_0"],
        data["player_one_1"],
        data["player_one_2"],
        data["player_one_3"],

        data["player_two_total"],
        data["player_two_0"],
        data["player_two_1"],
        data["player_two_2"],
        data["player_two_3"]
    ]
    return data_list

(3)后端在線AI實現

在線AI對戰通過Python的requests包進行遠程接口請求,通過本次作業提供的API實現了在線AI對戰,不過在和室友的對局中發現,通過隨機獲取到的數據,既是數據量很大但仍然沒有好效果,因此編寫了實時模型訓練微調的功能。

在每一場對局的結束,會將勝利玩家的對局數據寫入本地CSV文件中,用於訓練微調

def login(student_id, password):
    """
    用戶登錄接口
    :param student_id:學號
    :param password: 密碼
    :return: token
    """
    data = {"student_id": student_id, "password": password}
    result = requests.post(url="http://172.17.173.97:8080/api/user/login", data=data)
    output = json.loads(result.content)
    if result.status_code == 200 and output["status"] == 200:
        print("登錄成功")
    else:
        print("登錄失敗")
    return output["data"]["token"]


def create_root(token, private=True):
    """
    創建房間
    :param token: 登錄簽名
    :param private: 是否公開
    :return: 房間id
    """
    data = {"private": private}
    header = {"Authorization": "Bearer " + token}
    result = requests.post(url="http://172.17.173.97:9000/api/game", data=data, headers=header)
    output = json.loads(result.content)
    if result.status_code == 200 and output["code"] == 200:
        print("創建房間成功,房間號{}".format(output["data"]["uuid"]))
    else:
        print("創建房間失敗")
    return output["data"]["uuid"]


def join_root(token, room_id):
    """
    加入房間
    :param token: 用戶簽名
    :param room_id: 房間id
    :return: 是否加入房間成功
    """
    header = {"Authorization": "Bearer " + token}
    result = requests.post(url="http://172.17.173.97:9000/api/game/" + room_id, headers=header)
    output = json.loads(result.content)
    if result.status_code == 200 and output["code"] == 200:
        print("加入房間成功")
        return True
    print("加入房間失敗")
    return False


def emit_action(token, room_id, type, card=None):
    """
    提交用戶的操作到服務器
    :param token: 用戶簽名
    :param room_id: 房間id
    :param type: 抽牌或出手牌
    :param card: 卡牌名
    :return: 打牌字符串或者布爾值
    """
    header = {"Authorization": "Bearer " + token}
    data = None
    if type == 0:
        data = {"type": type}
    elif type == 1 and card is not None:
        data = {"type": type, "card": card}
    else:
        print({"type": type, "card": card}, "出現錯誤")

    result = requests.put(url="http://172.17.173.97:9000/api/game/" + room_id, data=data, headers=header)
    output = json.loads(result.content)
    if result.status_code == 200 and output["code"] == 200:
        last_code = output["data"]["last_code"].split(" ")
        return last_code
    print("[錯誤]:", output)
    return False


def get_last(token, room_id):
    """
    獲取上一步
    :param token: 用戶簽名
    :param room_id: 房間id
    :return: 上一步操作接口的信息
    """
    header = {"Authorization": "Bearer " + token}
    while True:
        result = requests.get(url="http://172.17.173.97:9000/api/game/{}/last".format(room_id), headers=header)
        output = json.loads(result.content)
        if result.status_code == 200 and output["code"] == 200:
            last_code = output["data"]["last_code"].split(" ")
            your_true = output["data"]["your_turn"]
            last_msg = output["data"]["last_msg"]
            return last_code, your_true, last_msg
        elif output["code"] == 403:
            print(output["data"]["err_msg"])
            time.sleep(1)
            continue
        elif output["code"] == 400:
            print(output["data"]["err_msg"])
            winner(token, room_id)
            return None, None, None


def handle_response(response, player, used):
    """
    控制牌堆的變化,包括清空、增加、收牌
    :param response: 接口的返回信息
    :param player: 玩家的手牌
    :param used: 已使用的牌堆
    """
    head = {"S":1, "H":2, "C":3, "D":4}

    if response[1] == "1":
        player[response[2][0]].remove(response[2])

    used[response[2][0]].append(response[2])
    if used["head"] == 0:
        used["head"] = head[response[2][0]]
    elif used["head"] == head[response[2][0]]:
        for k, v in player.items():
            player[k].extend(used[k])
            used[k] = []
            used["head"] = 0
    else:
        used["head"] = head[response[2][0]]


def get_poker_len(poker):
    """
    返回輸入的撲克牌集合的牌數
    :param poker:輸入的撲克牌集合
    :return: 撲克牌的有效數量
    """
    length = 0
    for k, v in poker.items():
        if k == 'head':
            continue
        length += len(v)
    return length

def transform_use_model(used, player_one, player_two, model, csv_one, csv_two, r):
    """
    將輸入轉化為可以被模型接收的輸入
    :param used: 已使用的牌堆
    :param player_one: 玩家一的手牌
    :param player_two: 玩家二的手牌
    :param model: AI模型
    :param csv_one: 玩家一代表的CSV文件,用戶對戰結束記錄勝利一方的數據進行實時訓練
    :param csv_two: 玩家二代表的CSV文件,用戶對戰結束記錄勝利一方的數據進行實時訓練
    :param r: 代表玩家一回合還是玩家二回合
    :return: type和card
    """
    data = {
            "pokers_total": 52 - get_poker_len(used) - get_poker_len(player_one) - get_poker_len(player_two),
            "pokers_0": 13 - len(used["S"]) - len(player_one["S"]) - len(player_two["S"]),
            "pokers_1": 13 - len(used["H"]) - len(player_one["H"]) - len(player_two["H"]),
            "pokers_2": 13 - len(used["C"]) - len(player_one["C"]) - len(player_two["C"]),
            "pokers_3": 13 - len(used["D"]) - len(player_one["D"]) - len(player_two["D"]),
            "used_total": get_poker_len(used),
            "used_0": len(used["S"]),
            "used_1": len(used["H"]),
            "used_2": len(used["C"]),
            "used_3": len(used["D"]),
            "used_head": used["head"],
            "player_one_total": get_poker_len(player_one),
            "player_one_0": len(player_one["S"]),
            "player_one_1": len(player_one["H"]),
            "player_one_2": len(player_one["C"]),
            "player_one_3": len(player_one["D"]),
            "player_two_total": get_poker_len(player_two),
            "player_two_0": len(player_two["S"]),
            "player_two_1": len(player_two["H"]),
            "player_two_2": len(player_two["C"]),
            "player_two_3": len(player_two["D"])
        }
    action = use_model(model, data)
    data["label"] = action
    if r == 1:
        csv_one.data = csv_one.data.append(data, ignore_index=True)
    else:
        csv_two.data = csv_two.data.append(data, ignore_index=True)

    init = ["S", "H", "C", "D"]
    head = {"S": 1, "H": 2, "C": 3, "D": 4}
    if action != 0:
        label = init[action - 1]
        if len(player_one[label]) == 0 and get_poker_len(player_one) != 0:
            for k, v in player_one.items():
                if len(v) != 0 and head[k] != used["head"]:
                    element = random.choice(v)
                    return 1, element
            return 0, None
        elif action != used["head"]:
            v = player_one[label]
            element = random.choice(v)
            return 1, element
        return 0, None
    else:
        return 0, None

def winner(token, room_id):
    """
    獲取勝利者接口
    :param token: 用戶簽名
    :param room_id: 房間id
    :return: 勝利者
    """
    header = {"Authorization": "Bearer " + token}
    result = requests.get(url="http://172.17.173.97:9000/api/game/{}".format(room_id), headers=header)
    output = json.loads(result.content)
    if result.status_code == 200 and output["code"] == 200:
        win = output["data"]["winner"]
        if win == 0:
            print("1P勝利了!")
        else:
            print("2P勝利了!")
        return win
    else:
        print("未知錯誤")


def run(token, room_id, used, player_one, player_two, model, csv_one, csv_two):
    """
    游戲執行程序
    :param token: 用戶簽名
    :param room_id: 房間id
    :param used: 已使用的牌堆
    :param player_one: 玩家一的牌堆
    :param player_two: 玩家二的牌堆
    :param model: AI模型
    :param csv_one: 玩家一對應的CSV文件
    :param csv_two: 玩家二對應的CSV文件
    """
    # 循環直到游戲結束
    while True:
        pre_response = None
        r = None
        # 循環請求上一步的接口,等待另一個玩家完成操作
        while True:
            response, your_true, last_msg = get_last(token, room_id)
            # 游戲結束返回
            if response is None and your_true is None and last_msg is None:
                return
            # 游戲剛開始如果是自己的回合就跳出,不是就下一次循環
            if len(response) == 1 and your_true:
                break
            elif len(response) == 1 and not your_true:
                continue
            # 作用是多次循環同樣的請求時,只有第一次請求會更新所有的牌堆
            if response[2] == pre_response:
                continue
            if pre_response is None:
                pre_response = response[2]

            # 更新對應玩家的牌堆,是自己的回合就跳出,不是就下一次循環
            if your_true:
                print(last_msg)
                r = 0
                handle_response(response, player_two, used)
                break
            elif not your_true:
                print(last_msg)
                r = 1
                handle_response(response, player_one, used)

        # 通過模型轉化為type和card
        type, card = transform_use_model(used, player_one, player_two, model, csv_one, csv_two, r)
        # 執行自己的操作請求
        response = emit_action(token, room_id, type=type, card=card)
        # 游戲結束,跳出循環
        if not response:
            print("游戲結束,退出")
            break


def to_csv(csv):
    """
    將玩家對應的CSV內容寫入文件中
    :param csv: 玩家的CSV文件內存
    """
    # 讀取總數據集
    data = pd.read_csv("../data/play.csv")
    # 鏈接數據集
    data = pd.concat([data, csv], axis=0)
    # 丟棄重復行
    data = data.drop_duplicates()
    # nan填充為0
    data = data.fillna(0)
    # 重新寫入文件
    data.to_csv("../data/play.csv", index=False)


if __name__ == "__main__":
    student_id = input("請輸入你的學號:")
    password = input("請輸入你的密碼:")
    # 玩家一牌堆
    player_one = {"S": [], "H": [], "C": [], "D": []}
    # 玩家二牌堆
    player_two = {"S": [], "H": [], "C": [], "D": []}
    # 已使用的牌堆
    used = {"S": [], "H": [], "C": [], "D": [], "head": 0}
    # 加載模型
    model = load_model("../models/origin.tar")
    # 初始化CSV記錄類
    csv_one = Record()
    csv_two = Record()
    # 登錄
    token = login(student_id, password)
    # 創建房間
    room_id = create_root(token)
    # 執行游戲
    run(token, room_id, used, player_one, player_two, model, csv_one, csv_two)
    # 獲取勝利結果
    win = winner(token, room_id)
    # 根據勝利結果,將勝利玩家的每步操作寫入數據集
    if win == 0:
        to_csv(csv_one.data)
    else:
        to_csv(csv_two.data)
    # 進行微調
    main("../data/play.csv", "../models", checkpoint="../models/origin.tar", epochs=1)

3. 性能分析

因為前端使用HTML、CSS、JavaScript編寫沒有找到相關的性能分析工具或者測試工具,選擇前后端不分離開發,因此這次的性能分析主要分析后端方面,后端方面性能既是包括了渲染前端頁面、響應前端資源以及響應AI請求等功能

選擇使用了在線AI對戰游戲類型,讓兩個AI進行對戰直至游戲結束,獲取整個游戲從開始到結束的性能分析

(1)性能分析圖

(2)性能分析

由圖分析,性能影響主要在請求和響應服務,項目中其他函數沒有明顯影響性能,分析大概會影響性能的原因

  • 前端后端不分離,前端請求資源,有些資源過大導致響應緩慢,可以考慮使用較小的資源或者調低資源的質量
  • 性能中使用有很多wait等待函數,猜想可能和前端有使用大量的定時器有關
  • 其他方面無明顯影響性能,本次主要的AI模型采用簡單神經網絡,模型很小,參數比較少因此響應也比較快

4. 單元測試

因為Web前端用原生語言編寫,不能像Python、JAVA一樣運行,暫時沒有找到測試前端的工具,因此本次編寫針對后端AI接口的單元測試

通過獲取程序運行時多次請求的輸入值以及響應的輸出值作為單元測試的原始數據,單元測試時將原始數據輸入模型得到目標輸出,判斷目標輸出是否與原始輸出相同,測試模型是否還穩定

from AI.utils import use_model, load_model

def test_model():
    """
    針對AI接口的單元測試,測試模型是否還穩定
    """
    json_dict = [
        {'pokers_total': 51, 'pokers_0': 12, 'pokers_1': 13, 'pokers_2': 13, 'pokers_3': 13, 'used_total': 1,
         'used_0': 1, 'used_1': 0, 'used_2': 0, 'used_3': 0, 'used_head': 0, 'player_one_total': 0, 'player_one_0': 0,
         'player_one_1': 0, 'player_one_2': 0, 'player_one_3': 0, 'player_two_total': 0, 'player_two_0': 0,
         'player_two_1': 0, 'player_two_2': 0, 'player_two_3': 0},
        {'pokers_total': 50, 'pokers_0': 11, 'pokers_1': 13, 'pokers_2': 13, 'pokers_3': 13, 'used_total': 0,
         'used_0': 0, 'used_1': 0, 'used_2': 0, 'used_3': 0, 'used_head': 0, 'player_one_total': 0, 'player_one_0': 0,
         'player_one_1': 0, 'player_one_2': 0, 'player_one_3': 0, 'player_two_total': 2, 'player_two_0': 2,
         'player_two_1': 0, 'player_two_2': 0, 'player_two_3': 0},
        {'pokers_total': 49, 'pokers_0': 10, 'pokers_1': 13, 'pokers_2': 13, 'pokers_3': 13, 'used_total': 1,
         'used_0': 1, 'used_1': 0, 'used_2': 0, 'used_3': 0, 'used_head': 0, 'player_one_total': 0, 'player_one_0': 0,
         'player_one_1': 0, 'player_one_2': 0, 'player_one_3': 0, 'player_two_total': 2, 'player_two_0': 2,
         'player_two_1': 0, 'player_two_2': 0, 'player_two_3': 0},
        {'pokers_total': 41, 'pokers_0': 8, 'pokers_1': 12, 'pokers_2': 10, 'pokers_3': 11, 'used_total': 0,
         'used_0': 0, 'used_1': 0, 'used_2': 0, 'used_3': 0, 'used_head': 0, 'player_one_total': 11, 'player_one_0': 5,
         'player_one_1': 1, 'player_one_2': 3, 'player_one_3': 2, 'player_two_total': 2, 'player_two_0': 2,
         'player_two_1': 0, 'player_two_2': 0, 'player_two_3': 0},
        {'pokers_total': 41, 'pokers_0': 8, 'pokers_1': 12, 'pokers_2': 10, 'pokers_3': 11, 'used_total': 1,
         'used_0': 1, 'used_1': 0, 'used_2': 0, 'used_3': 0, 'used_head': 0, 'player_one_total': 11, 'player_one_0': 5,
         'player_one_1': 1, 'player_one_2': 3, 'player_one_3': 2, 'player_two_total': 1, 'player_two_0': 1,
         'player_two_1': 0, 'player_two_2': 0, 'player_two_3': 0},
        {'pokers_total': 41, 'pokers_0': 8, 'pokers_1': 12, 'pokers_2': 10, 'pokers_3': 11, 'used_total': 1,
         'used_0': 1, 'used_1': 0, 'used_2': 0, 'used_3': 0, 'used_head': 0, 'player_one_total': 10, 'player_one_0': 4,
         'player_one_1': 1, 'player_one_2': 3, 'player_one_3': 2, 'player_two_total': 1, 'player_two_0': 1,
         'player_two_1': 0, 'player_two_2': 0, 'player_two_3': 0}
    ]

    origin = [
        {'action': 0},
        {'action': 0},
        {'action': 0},
        {'action': 1},
        {'action': 1},
        {'action': 1}
    ]
    # 加載模型
    model = load_model("../models/best.pth.tar")
    for index in range(len(json_dict)):
        result = use_model(model=model, json_dict=json_dict[index])
        # 判斷模型輸出是否符合原模型輸出
        assert result == origin[index]["action"]

本次單元測試的覆蓋率如下,因為只是針對AI接口的單元測試,所以訓練時的代碼並沒有被覆蓋,前端方面的內容同樣也沒有被覆蓋

5. Github代碼簽入記錄

6. 困難及解決方法

問題 解決方案
前端撲克牌亂序后之前寫的按編號處理失效 重新選擇一個指標作為新的編號進行處理
原生js編寫動畫困難 使用CSS進行動畫編寫,js調用CSS動畫
前端代碼中定時器太多,出現類似死鎖、讀寫沖突等情況 通過設置類似信號量機制的方法,同步數據
前端修改代碼后,頁面渲染不及時導致后續獲取的數據出錯 設置定時器,進行一段事件等待
前端某些元素難以設置有序的進行移動、出現、消失多個元素的聯合動畫 放棄這個想法,設置成在一個元素中的動畫
AI訓練沒有訓練數據 通過模擬游戲過程,使用隨機數進行勝利對局數據獲取
AI模型訓練的效果不是很好 增加數據預處理,例如進行數據歸一化,修改網絡結構多次嘗試選個最優的
在線對戰涉及的改動太多,事情太多 放棄在線對戰

7. 收獲

  • 熟悉了HTML、CSS、JS的配合,做了一個老早就想做但是都沒時間做的動畫項目
  • 首次通過AI訓練游戲數據,雖然效果不是很好但勉強可用
  • 體驗了許多線程死鎖、讀寫沖突等的BUG,豐富了作為計算機專業的煩惱體驗
  • 一個人開發了項目,完成了之前的一個小小念想進行前后端的全棧開發
  • 找到了許許多多的在線工具,互聯網上啥都有
  • 找到了一些其他神奇的第三方包
  • 總之很肝、很痛苦、很耗時,但滿足了一個想法,不過下次拒絕這么肝

8. 評價我的隊友

單人項目沒有隊友,這兩分是不是可以全給我了?

老實說一個人開發效率很快,我懂我要做什么、我會什么、我多久可以做完,因此整個項目都按照我的想法大部分要求都能快速完成,但果然還是ddl的時候才開始做,最后還有在線對戰還沒有完成。一個人做我可以做到足夠滿意,但沒有做到很滿意,不過我覺得多加一個人可能也不能夠把在線對戰做出來吧,在我的項目里要涉及更改的代碼有點多。

下次我一定要找好多個隊友,躺着做項目,絕不熬夜絕不肝

9. PSP和學習進度條

PSP2.1 Personal Software Process Stages 預估耗時(分鍾) 實際耗時(分鍾)
Planning 計划 10 10
· Estimate · 估計這個任務需要多少時間 10 10
Development 開發 1180 945
· Analysis · 需求分析 (包括學習新技術) 360 200
· Design Spec · 生成設計文檔 20 30
· Design Review · 設計復審 10 10
· Coding Standard · 代碼規范 (為目前的開發制定合適的規范) 10 15
· Design · 具體設計 60 70
· Coding · 具體編碼 600 500
· Code Review · 代碼復審 60 70
· Test · 測試(自我測試,修改代碼,提交修改) 60 50
Reporting 報告 100 120
· Test Repor · 測試報告 60 80
· Size Measurement · 計算工作量 10 10
· Postmortem & Process Improvement Plan · 事后總結, 並提出過程改進計划 30 30
· 合計 1290 1075
N周 新增代碼(行) 累計代碼(行) 本周學習耗時(小時) 累計學習耗時(小時) 重要成長
1 1500 1500 24 24 復習HTML、CSS、js、Jquery,編寫動畫以及前端游戲邏輯
2 880 2380 24 48 首次訓練游戲AI,調整網絡結構,進行前后端不分離開發

三、心得

本次項目我重新拾起好久都沒動過的前端,因為之前學習的時候就吃了很多BUG,所以這次我直接使用Flex響應式布局,拒絕使用float屬性果然BUG少了,開發流暢了,人也清爽了。同樣之前踩坑這次直接使用js和Juqery,動畫絕不死磕js選擇使用CSS的動畫屬性實現,舒服多了。

我剛開始的時候是在想一個人組隊,隨便做做就行了因為這幾周我也很忙,要做的事情很多,還有就是那時候沒有一個UI參考和一些把最核心復雜的東西解決的工具或包,做不出我心目中的好東西,所以我不想浪費時間反而自己做了個垃圾項目,明明都准備擺爛了、放棄了、躺着了,卻在github上找抄襲目標的時候,發現了一個撲克牌的第三方包deck-of-cards,直接把我項目中最復雜最核心的撲克牌事件集合解決了......不久后又又發現一個豬尾巴小游戲的視頻,UI精美、簡約......懂了,看來這次不能如願擺爛,燃起來了!

基於deck-of-cards和視頻UI我找到在線PS、摳圖等工具,從原始視頻上獲取資源並通過其他的AI模型獲取到了高清的SVG資源,白嫖黨一路白嫖獲取了所有想要的資源,再次感嘆科技的強大,人工智能真的改變了很多東西。

前端開發過程中最讓我耗時和痛苦的莫過於讀寫沖突和死鎖問題,大量動畫和定時器的應用造成了大量的問題,為了解決這些問題又設置了大量的定時器,最終還是通過信號量機制設置一些變量用於控制函數功能才得以解決,這時候我體會到了js語言的麻煩,即使使用了很好的IDE依然沒有完整的代碼補全、函數補全功能並且模塊化還賊麻煩,懷戀VUE可惜這次沒有決定使用VUE,果然Python才是天下第一的語言!

上一周一整周全在寫前端的東西,也熬了一整天周的夜十分痛苦,有違我的生活習慣,執着了,發現我還是能寫些高級一點的頁面的。項目中我原本打算使用響應式開發,但有兩個前端東西最后我沒有做成響應,一個是首頁豬的倒影、一個deck包的撲克牌,即使他們有自己寫了一些響應式的東西但都是初次加載的時候固定了,只有再次讓他們初始化或者刷新才能改變,其他的響應式做得差不多,打算在手機上也能訪問。最后部署后發現,服務器帶寬太小加載資源的速度太慢,手機上就更慢了並且手機上的觸屏不被判斷為鼠標事件!!!豬和撲克牌都太大了,手機上玩還任重道遠。

后端方面主要就是AI開發,原本打算使用循環神經網絡,但存在一些問題最后不想解決就直接使用了簡單的神經網路實現。獲取數據采用的是模擬游戲隨機獲取操作實現,用上了華為雲服務器因為隨着數據不斷增多所以會越來越慢,為了不弄壞我的電腦選擇上華為雲用了上次白嫖的代金券,但也用了一個晚上才獲取到一萬局的勝利數據,最后獲取到了六十萬的操作數據,不清楚pandas是不是把記錄的數據寫入了內存,應該是吧,六十萬也才100M以下。

接下來是本次項目可能改進的地方,當然寫的時候我改不動了,立個flag下次一定:

  • 前端模塊化,將重復的代碼抽離,高內聚低耦合,進行代碼復用
  • AI換成循環神經網絡,因為打牌對局本身就有時序性,可以試試理論上會不會有准確率的提升
  • 通過socket進行在線對戰
  • 編寫更多的單元測試
  • 編寫更多的異常處理,因為項目中有很多地方需要數據一致性,所以會需求很多的異常處理
  • 優化部署在服務器上的速度,不過我感覺只能通過換個更大的服務器實現,改變資源大小可以優化,因為我提供的資源全是高清資源
  • 修復前端代碼里面的讀寫沖突和線程死鎖問題

綜合來說這次項目我很滿意,做了一直想做的東西,很滿意了大三這一年還能自己全棧做個東西,雖然之前我想做個記錄我個人生活的,以后給小孩子看。下次軟工實踐我的工作很專一,這次23點后,什么事情都與我無瓜,我只想睡覺。最后的最后,我還是想要一次難得的團隊經歷。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM