
簡介在游戲開發中客戶端架構設計決定了項目的可維護性與擴展性。以Unity和C#為技術棧紙牌類游戲作為典型的實時交互應用其核心在于將復雜的UI狀態、網絡消息與邏輯層解耦。通過分析一套完整的Unity紙牌游戲客戶端源碼可以深入理解消息分發機制、事件驅動模型、以及UI與邏輯分離的工程實踐。從數據序列化、粘包拆包處理到UI拖拽優化這些基礎技術點共同支撐起流暢的玩家體驗。掌握這些原理不僅有助于開發棋牌游戲也能遷移到其他聯機游戲項目中。以“撲克手云”為例拆解其模塊劃分與核心實現為開發者提供可參考的客戶端架構范本。 最近在拆一套“撲克手云”的Unity紙牌游戲客戶端源碼拆完以后最大感受是一個項目能不能被稱為“優秀源碼”跟它用了多少高級技巧沒關系更多是看代碼結構能不能讓人在一個月以后還能看懂換個人接手能不能改得動。這套項目的架構屬于“簡單但規整”那一類Unity C#核心圍繞牌桌場景、手牌管理、出牌邏輯、網絡消息收發和UI狀態同步來展開。如果你項目空窗期想找個完整的棋牌類客戶端例子或者想學習怎么從0搭一套游戲客戶端這套源碼值得花一個周末仔細過一遍。先說這套源碼能解決什么問題。第一它告訴你紙牌類游戲客戶端的通用模塊長什么樣第二它給了一套比較清爽的C#分層方式第三它把“服務端下發消息客戶端響應刷新UI”這個高頻場景做得直接明了。不是那種堆了一堆類、改一個功能牽連五個腳本的寫法讀起來負擔比較小。下面按我實際拆解的順序來寫。1. 項目整體設計與模塊劃分1.1 為什么選Unity加C#做紙牌客戶端棋牌類項目普遍有一個特點玩法邏輯不算復雜但UI狀態特別多。手牌要排序、出牌要拖拽、倒計時要跳、聊天消息要滾動、牌局結果要彈窗這些如果全部用原生代碼寫界面工程量不小。Unity在2D UI上的成熟度很高UGUI配合Animator做紙牌類交互非常順手所以“撲克手云”選Unity屬于最自然的路線沒必要為炫技去換引擎。C#這個語言也比較劃算。它的類型系統讓手牌這類數據不容易在傳輸過程中被改出奇怪的值委托和事件又可以很自然地處理“用戶點了一下牌”“收到服務端通知”這類回調邏輯。源碼里大量用到了事件和委托這正好是對C#核心特性的一次實戰示范比如出牌區點擊回調、房間狀態變更通知。有C#基礎的人讀起來會覺得很親切剛好還能看看委托在實際業務里怎么用而不是停留在語法層面。1.2 客戶端源碼的模塊邊界打開項目以后目錄劃分很清晰大致是GameEntry程序入口負責初始化SDK、加載全局配置Net網絡層封裝連接、消息編碼、消息分發UI所有UGUI界面一個界面一個Prefab對應一個ControllerGameLogic純邏輯層主要放手牌、牌型、局內狀態Data數據表、玩家信息、協議對象Tool公共工具方法。這套劃分不是花架子它最大的好處是UI和邏輯被隔開了。我在實際項目里見過很多把出牌規則寫在按鈕點擊事件里的寫法當時改著方便后期加了機器人、加了重連以后就是災難。“撲克手云”把出牌邏輯放在GameLogic里UI層只負責把玩家的點擊轉換成一句RequestPlayCards(cardIds)這樣多端復用和測試都會容易很多。1.3 客戶端和服務端的接口約定客戶端源碼要跑起來理解網絡協議這塊繞不開。項目用的是自定義二進制協議包頭是消息長度加消息ID包體是JSON字符串。雖然JSON串在性能上不如直接二進制序列化但勝在調試方便人在斷點里直接能看到可讀數據。這套源碼把消息分發做成了注冊表風格每個消息ID對應一個Handler收到包后按ID分發不搞一堆if else。public class MsgDispatcher { private Dictionaryint, Actionbyte[] _handlers new Dictionaryint, Actionbyte[](); public void Register(int msgId, Actionbyte[] handler) { _handlers[msgId] handler; } public void Dispatch(int msgId, byte[] body) { if (_handlers.TryGetValue(msgId, out var handler)) { handler(body); } } }這里要理解一件事紙牌游戲服務端是權威的客戶端不能自己決定“我出了這手牌就真出了”。客戶端做的是把請求發出去等服務端返回結果再更新界面這樣能避免很多不同步問題。在這套源碼里點出牌后本地會先進入灰色等待狀態而不是立刻把牌打出去這是對的做法。很多新手寫單機玩法寫習慣了總想本地立刻響應放到聯機環境里就容易出邏輯漏洞。2. 核心玩法與UI交互細節2.1 牌桌場景與UI層級設計牌桌場景在紙牌項目里是整個客戶端的臉面。“撲克手云”的場景結構并不復雜但層級設計值得說說。最底層是背景和桌面裝飾中間層是玩家信息區和手牌區最上層是操作按鈕區、倒計時、彈窗、飄字提示。這樣的層疊關系保證了任何情況下操作按鈕都不會被手牌擋住彈窗出現時又天然蓋住全局。UGUI的層級對性能也有影響。像牌桌這種經常有牌進出的場景如果每個牌對象都是一個帶大圖集的Image再疊加各種描邊、陰影特效重建網格的開銷會很高。這套源碼的處理方式是把靜態背景和動態牌面分開背景永遠不參與層級重建只有手牌和出牌區頻繁動這樣Draw Call控制起來就簡單得多。2.2 手牌拖拽與點擊出牌手牌交互有幾個細節處理不好會非常難受。最典型的是“拖拽靈敏度”用戶按下鼠標后手指稍微動了幾個像素到底算點擊還是拖拽源碼里給出的方案是記錄按下位置位移超過閾值才進入拖拽狀態否則等抬起時按點擊處理。private const float DragThreshold 20f; private Vector2 _downPos; private bool _isDragging; public void OnPointerDown(PointerEventData eventData) { _isDragging false; _downPos eventData.position; } public void OnDrag(PointerEventData eventData) { if (Vector2.Distance(eventData.position, _downPos) DragThreshold) { _isDragging true; } if (_isDragging) { transform.position eventData.position; } } public void OnPointerUp(PointerEventData eventData) { if (!_isDragging) { // 處理點擊出牌 GameEvents.RequestPlay(CurrentCardId); } }這個邏輯看起來簡單但難在工程化。卡牌對象本身要接收UGUI的Pointer事件Image身上必須勾選RaycastTarget同時手牌區域和出牌區的高層容器不能把這個事件吞掉。源碼里專門在牌桌畫布上掛了自研的CardRaycastFilter處理哪些區域能響應鼠標、哪些區域要穿透這樣拖拽到出牌區外松手不會誤觸發。2.3 牌型計算邏輯牌型計算是棋牌項目里最容易寫亂的模塊因為玩法規則多分支判斷很容易堆成屎山。“撲克手云”的寫法是把牌型枚舉和判斷方法分開每種牌型一個判定函數統一收進一個靜態類。public static class CardTypeChecker { public static CardType Check(ListCardData cards) { if (cards null || cards.Count 0) return CardType.None; cards.Sort((a, b) a.Rank.CompareTo(b.Rank)); if (IsStraightFlush(cards)) return CardType.StraightFlush; if (IsFourOfKind(cards)) return CardType.FourOfKind; if (IsFullHouse(cards)) return CardType.FullHouse; if (IsFlush(cards)) return CardType.Flush; if (IsStraight(cards)) return CardType.Straight; if (IsThreeOfKind(cards)) return CardType.ThreeOfKind; if (IsTwoPair(cards)) return CardType.TwoPair; if (IsOnePair(cards)) return CardType.OnePair; return CardType.HighCard; } }這種寫法看著繁瑣但后續擴展規則很方便。比如要加“同花順大于四條”還是“紅桃同花大于黑桃同花”只需要在判斷函數里調大小關系不用動調用方。另外要注意玩牌規則里2和A的大小在不同玩法里不一樣所以源碼里沒有硬編碼int rank而是抽象出一個RankRule接口把“A最大還是2最大”這種差異留給配置去決定。這個設計很聰明同一個客戶端可以適配多種玩法。2.4 動畫與狀態反饋紙牌項目動畫多處理不好會顯得很硬。“撲克手云”里出牌、棄牌、收牌的動畫不是每個單獨做邏輯而是統一走一個CardAnimCtrl用DOTween批量控制位置和旋轉。比如出牌時卡片從手牌區飛到出牌區同時做一次翻轉和放大動畫時長固定180毫秒這樣手感和節奏是穩定的。動畫狀態和游戲狀態需要同步。“撲克手云”的做法是在動畫播放期間把操作鎖住等動畫回調結束再恢復輸入。這個細節很重要否則用戶瘋狂點屏幕可能觸發好幾輪出牌請求會造成服務端數據錯亂。我在別的項目里就踩過這個坑最后不得不在請求層加一個節流閥其實源頭就是動畫期間沒鎖輸入。private IEnumerator PlayCardCoroutine(CardView view, Vector2 targetPos) { _inputLocked true; view.rectTransform.DOMove(targetPos, 0.18f).SetEase(Ease.OutCubic); yield return new WaitForSeconds(0.18f); _inputLocked false; }3. 客戶端核心功能實現3.1 MonoBehaviour腳本架構與對象生命周期看這套源碼的方便之處在于整個客戶端沒有把邏輯全部塞進MonoBehaviour里。MonoBehaviour一方面方便在Inspector里拖引用另一方面生命周期和場景綁得太死場景切掉對象就沒了。“撲克手云”把大部分數據和邏輯放在純C#類中MonoBehaviour只做視圖組件負責監聽UI事件和驅動表現。舉個例子RoomModel是純C#類保存房間號、玩家列表、當前局狀態、手牌數據RoomView是MonoBehaviour只負責把RoomModel的數據刷新到UI上。當網絡層收到“玩家加入”消息后直接改RoomModel再通過事件通知RoomView刷新二者解耦。這樣做有一個明顯好處斷線重連的時候RoomModel可以直接恢復不需要重新等場景加載。測試也方便單測直接new一個RoomModel給邏輯注入數據不依賴Unity的播放模式跑得也快。3.2 數據序列化與消息分發客戶端網絡層最容易忽略的點是粘包和拆包。“撲克手云”在Socket接收數據時維護了一個緩沖區每次收到數據先檢查包頭里的長度字段夠長才解析出完整消息體否則繼續等下一包。這個處理是網絡層的基本功但很多初學者寫網絡都會漏。public class PacketParser { private byte[] _buffer new byte[8192]; private int _offset 0; public void Append(byte[] data) { Array.Copy(data, 0, _buffer, _offset, data.Length); _offset data.Length; while (_offset 8) { int len BitConverter.ToInt32(_buffer, 0); if (_offset len 8) break; int msgId BitConverter.ToInt32(_buffer, 4); byte[] body new byte[len]; Array.Copy(_buffer, 8, body, 0, len); _msgDispatcher.Dispatch(msgId, body); _offset - len 8; Array.Copy(_buffer, len 8, _buffer, 0, _offset); } } }這套邏輯里解析的時候用的是大端還是小端要看服務端的約定源碼注釋里寫得很清楚服務端是C#的BitConverter默認小端所以客戶端也用小端省了一次字節序轉換。如果對接的服務端是Java或者Go那就要注意字節序否則會出現消息頭解析錯誤。3.3 資源加載與配置表Unity項目的資源管理最容易讓人頭疼的是引用滿天飛。“撲克手云”沒有用Addressables這類重量級方案而是用Unity原生的Resources.Load加一層緩存。項目不大牌面、背景、特效這些資源加起來也就二三十個Resources目錄管理完全夠用。但代碼里有個經驗值得學所有資源路徑不是硬編碼到業務腳本里的而是統一在一個ResPath靜態類中定義常量。這樣以后要改成AssetBundle或者Addressables只改加載方法不用滿項目找字符串。配置表方面項目使用ScriptableObject來存卡牌的基礎數值比如花色、點數、特效綁定等。用ScriptableObject的好處是改配置不需要重新打客戶端包策劃同學直接在編輯器里改資產就行。而且它天然支持Unity的Inspector編輯不容易寫錯字段名。3.4 跨平臺分辨率和輸入適配紙牌客戶端一般會同時面向PC和手機分辨率適配不能只做一個設計尺寸然后拉伸。“撲克手云”用的是Unity UGUI的CanvasScaler模式設為Scale With Screen Size參考分辨率1280x720。雖然PC和iPhone屏幕比例不同但通過Match Width Or Height的中間值讓UI在豎屏和橫屏下都能保持不錯的位置。不過在開發機測試時要特別注意的一件事是不同平臺上EventSystem的行為有一點點差異鼠標點擊和觸摸點擊雖然都走IPointerClickHandler但在某些安卓機型上會有點擊位置偏移。源碼里沒有硬編碼屏幕像素而是始終用RectTransformUtility.ScreenPointToLocalPointInRectangle做坐標轉換這個細節對手機端體驗影響很大。4. 實戰中遇到的坑和優化記錄4.1 導入源碼以后報一堆錯拿到Unity項目源碼第一件事不是看代碼而是打開Project Window確認Assets目錄結構和包管理器里的依賴。這個項目的坑在于它用了DOTween如果你本地沒有安裝導入后會有幾十個找不到命名空間的報錯。處理方案是先去Package Manager或者從官方網站導入DOTween插件再等IDE重新編譯。還有一類報錯是版本問題。Unity每年更新都會廢棄一些API比如老的OnGUI寫法、UnityWebRequest的舊接口導入到2022以后會報過時警告。大部分警告不影響運行但如果你使用的是2021以上的版本建議把項目先把API升級到對應版本否則有些打包配置會變得不可控。4.2 UI拖拽事件被遮擋我在跑“撲克手云”的時候曾經遇到一個問題拖動一張牌到出牌區上方明明位置已經進入目標區域卻不觸發高亮。排查后發現出牌區的碰撞檢測區域被它下面一個透明的全屏Image擋掉了。這個Image本身是用于控制點擊穿透的但因為RaycastTarget沒有關閉導致事件被它截胡。解決辦法是給透明Image關閉RaycastTarget或者根據事件狀態動態切換RaycatTarget。這也是UGUI一個問題反復出現的根源很多人不知道RaycastTarget不光是決定這個UI能不能點到還會直接影響整個UI事件系統的性能。像這種全屏透明層開著射線檢測等于每幀都多做了大量UI射線檢測白白消耗性能。4.3 GC與卡頓優化紙牌項目的GC壓力主要來自字符串拼接和JSON解析。“撲克手云”在收到網絡消息時會做一次JsonUtility.FromJson如果在Update里隨手Debug.Log拼接消息內容很容易產生大量瞬時垃圾。源碼的做法是所有日志走自定義LogUtil發布版本直接禁掉Debug避免不必要的字符串分配。手牌列表頻繁增刪也會造成GC。源碼里沒有在主線程用List.RemoveAt到處刪除而是先把要刪除的索引收集起來最后統一批量移除同時復用卡牌對應的CardView對象。這個優化策略在老手機上效果很明顯真機測試幀率從37幀提回到60幀左右卡頓感基本消失。4.4 升級Unity版本以后UI錯位很多項目源碼能跑但一升級Unity版本就會出現UI錯位或字體發虛。“撲克手云”原來在Unity 2019上開發升級到2022以后Text組件的渲染方式變了部分場景里的中文字體無法顯示。原因是舊項目用的是動態字體字體文件是“Dynamic”模式而新版本對動態字體的默認回退邏輯做了調整導致字體丟失。解決方式有兩個一是把字體改成Legacy模式并手動指定字體資產二是用新的TextMeshPro重做一遍UI組件。這套系統里統一換了TMP并做了字體資產重新生成順便把各種字號也統一成了TMP的樣式表反而比原來更好維護了。5. 從源碼里透出來的工程習慣5.1 命名、注釋和提交習慣看源碼過程中我挺感慨的是它的一致性。私有字段統一用_camelCase公共屬性統一用PascalCase消息類名后面統一掛Msg后綴事件名統一帶On前綴。這些約定雖然不復雜但能堅持到上百個腳本都遵守是團隊協作的底線。很多項目代碼亂不是大家不懂規范而是沒在編碼規范里落到強制規則。注釋這塊源碼沒有每行都注釋而是在關鍵節點上寫“為什么這么寫”。比如出牌動畫那里注釋不是寫“播放動畫”而是寫“動畫期間必須鎖輸入否則用戶連續點擊會發出多個請求”這種注釋才有價值。我在整理項目時也習慣把“為什么”寫清楚比把“是什么”寫清楚更重要。5.2 給熱更新和混淆留后路棋牌類游戲在國內上線基本躲不開熱更新和代碼混淆。雖然“撲克手云”本身沒有接熱更新框架但它的代碼分層方式讓熱更新接入成本很低。只要把GameLogic和Net層做成程序集打成一個DLL或者進AssetBundleUI層繼續留在主工程就能實現邏輯熱更。代碼混淆這個點做棋牌的人應該早有耳聞客戶端如果明文發布很容易被逆向看到協議和邏輯。“撲克手云”源碼沒有做混淆但它的協議本身就帶了一個token字段每次連接由服務端下發客戶端后續消息都要帶上這個臨時token過期后就失效一定程度防止了簡單重放攻擊。如果要上生產建議配合商用混淆工具把關鍵字符串和邏輯混淆掉。5.3 后面可以怎么擴展這套源碼雖然已經完整但要變成能上線的產品還有幾個地方可以擴展。第一是增加牌局錄像功能。現在源碼里沒有保存對局記錄如果要做回放需要在服務端留一份對局事件流客戶端只負責播放事件。這其實不難因為客戶端本身就依賴事件驅動把網絡消息順便寫進列表就能得到回放數據。第二是增加機器人和托管出牌。源碼里的GameLogic是純邏輯層加入AI出牌只要實現一個IPlayerStrategy接口給機器人掛一個自動出牌策略就行。UI層幾乎不用改。第三是增加語音聊天和表情互動。紙牌游戲需要強互動源碼目前只有文字聊天。接入語音SDK時注意把語音錄制和播放的代碼也放到相對獨立的模塊不要和牌局邏輯混在一起否則后期排查問題會很折磨。最后再說一點實際體會這個項目我前后大概花了一個通宵加一個下午梳理完。從最開始看網絡層、理解協議到后來把手牌管理邏輯過了一遍最大的收獲其實不是某個技術點而是明白了“客戶端的價值在于把不確定的網絡狀態轉化成流暢的界面反饋”。你要協調網絡延遲、輸入響應、動畫時長、資源加載這些矛盾哪塊沒處理好玩家體驗都上不來。如果你正在學Unity和C#而且手頭缺一個完整的客戶端項目做參照去把“撲克手云”這類源碼從頭到尾過一遍比看十篇教程都好用。重點看三樣東西消息分發怎么設計、UI和邏輯怎么解耦、出牌判斷怎么擴展。把這三塊吃透再去做聯機游戲項目思路會清楚很多。還有一個容易被忽略的細節就是一定要養成看Console日志的習慣很多問題不是代碼寫錯了而是場景里面某個組件沒掛對日志會直接告訴你。祝你把這套源碼啃下來以后能寫出比它更順手的棋牌客戶端。本文還有配套的精品資源點擊獲取