这两年房卡模式的麻将特别火,尤其是红中麻将,玩法简单刺激,适合亲友局。最近不少朋友问我镜像房卡麻将怎么做——就是那种一个玩家开房生成房号,其他人输入房号加入的模式,不需要匹配、不需要大厅,干净利落。
今天就把这套系统的核心代码分享出来,从后端到前端的关键逻辑都讲清楚。我尽量少废话,多贴代码,毕竟咱搞开发的看代码比看文字舒服。
一、房卡模式的核心设计思路
镜像麻将的精髓在于“房卡即房间”。玩家A创建房间后获得一个数字房号(比如6位数字),发给微信好友,好友在APP里输入房号直接进入,不需要通过大厅匹配。整个流程背后的技术点有三个:房间生命周期管理、房号生成与校验、断线重连的状态保持。
我用Go语言写服务端,WebSocket做长连接通信,房间状态全部存内存里(用sync.Map保证并发安全),定期把非活跃房间持久化到Redis防止服务器重启丢数据。客户端用LayaAir,方便以后出H5版本。
二、服务端核心代码实现
1. 房间管理器:创建房间与房号生成
房间号不能简单自增,容易被遍历。我用了一个6位随机数,生成后查重,循环直到不重复。同时房间对象用读写锁保护,因为在牌局中出牌、摸牌操作频繁,不能一把大锁卡住所有协程。
package room import ( "sync" "time" "math/rand" ) type Room struct { ID string // 6位房号 CreatorUID string // 房主ID Players [4]*Player // 固定4个座位 Status int // 0等待中 1游戏中 CreateTime time.Time mu sync.RWMutex Game *MahjongGame // 当前牌局对象 } type RoomManager struct { rooms sync.Map // map[string]*Room } // 创建新房间 func (rm *RoomManager) CreateRoom(uid string) (*Room, error) { roomID := generateRoomID() // 保证不重复 for { if _, ok := rm.rooms.Load(roomID); !ok { break } roomID = generateRoomID() } room := &Room{ ID: roomID, CreatorUID: uid, Status: 0, CreateTime: time.Now(), } room.Players[0] = &Player{UID: uid, SeatID: 0, Ready: true} rm.rooms.Store(roomID, room) return room, nil } // 生成6位房间号 func generateRoomID() string { const digits = "0123456789" b := make([]byte, 6) for i := range b { b[i] = digits[rand.Intn(len(digits))] } return string(b) } // 玩家加入房间 func (rm *RoomManager) JoinRoom(roomID, uid string) (*Room, int, error) { val, ok := rm.rooms.Load(roomID) if !ok { return nil, -1, errors.New("房间不存在") } room := val.(*Room) room.mu.Lock() defer room.mu.Unlock() if room.Status != 0 { return nil, -1, errors.New("游戏已开始,无法加入") } // 找空位 for i, p := range room.Players { if p == nil { room.Players[i] = &Player{UID: uid, SeatID: i} return room, i, nil } } return nil, -1, errors.New("房间已满") }
2. 红中麻将胡牌算法——癞子(万能牌)处理
红中麻将的特色是“红中”可以当作任何牌。这导致标准胡牌判定要加癞子处理逻辑。我写了一个通用的判断函数,先把手牌里所有红中剔除出来,剩余牌递归拆分成顺子、刻子、对子,缺的部分用红中补足。
// 手牌表示为牌值数组,红中定义为35 const HongZhong = 35 // 判断能否胡牌,返回true,同时返回胡牌牌型拆解 func CanWin(handTiles []int, laizi int) (bool, [][]int) { // 去掉癞子 normalTiles := []int{} laiziCount := 0 for _, t := range handTiles { if t == laizi { laiziCount++ } else { normalTiles = append(normalTiles, t) } } // 对普通牌排序 sort.Ints(normalTiles) // 尝试所有可能的将牌(对子) return tryWin(normalTiles, laiziCount) } func tryWin(tiles []int, laiziCount int) (bool, [][]int) { if len(tiles) == 0 { return laiziCount == 0, nil // 必须没有剩余癞子 } // 尝试取一个对子做将牌 for i := 0; i < len(tiles)-1; i++ { if tiles[i] == tiles[i+1] { newTiles := make([]int, len(tiles)) copy(newTiles, tiles) // 移除这对对子 newTiles = append(newTiles[:i], newTiles[i+2:]...) if ok, _ := checkMianZi(newTiles, laiziCount); ok { return true, nil } } } // 如果癞子足够,也可以拿癞子凑将牌 if laiziCount >= 2 { if ok, _ := checkMianZi(tiles, laiziCount-2); ok { return true, nil } } return false, nil } // 检查剩余牌和癞子能否全部组成面子(顺子或刻子) func checkMianZi(tiles []int, laizi int) (bool, [][]int) { if len(tiles) == 0 { return laizi == 0, nil } // 尝试用第一个牌组成刻子 count := 1 for count < len(tiles) && tiles[count] == tiles[0] { count++ } if count >= 3 { // 有刻子,移除这三个 newTiles := make([]int, len(tiles)) copy(newTiles, tiles) newTiles = append(newTiles[:0], newTiles[3:]...) if ok, _ := checkMianZi(newTiles, laizi); ok { return true, nil } } else if count == 2 && laizi >= 1 { // 两张加一个癞子组成刻子 newTiles := make([]int, len(tiles)) copy(newTiles, tiles) newTiles = append(newTiles[:0], newTiles[2:]...) if ok, _ := checkMianZi(newTiles, laizi-1); ok { return true, nil } } else if count == 1 && laizi >= 2 { // 一张加两个癞子组成刻子 newTiles := tiles[1:] if ok, _ := checkMianZi(newTiles, laizi-2); ok { return true, nil } } // 尝试组成顺子(仅数字牌且不能跨花色,这里简化判断) if tiles[0] < 30 { // 万字、条子、筒子 // 找 tiles[0]+1 和 tiles[0]+2 has2 := false has3 := false idx2, idx3 := -1, -1 for i := 1; i < len(tiles); i++ { if tiles[i] == tiles[0]+1 && !has2 { has2 = true idx2 = i } if tiles[i] == tiles[0]+2 && !has3 { has3 = true idx3 = i } } if has2 && has3 { newTiles := []int{} for i, t := range tiles { if i == 0 || i == idx2 || i == idx3 { continue } newTiles = append(newTiles, t) } if ok, _ := checkMianZi(newTiles, laizi); ok { return true, nil } } // 缺一张用癞子补的顺子情况,限于篇幅省略… } return false, nil }
这段代码虽然简化了花色边界和补癞子的所有组合,但已经把核心思路展示清楚了:先抽将,再递归拆面子。实际项目里还要完善补癞子的多种情况,建议大家自己补齐测试。
3. 游戏流程控制——摸牌、出牌、吃碰杠胡
服务端维护一个状态机:等待出牌→等待碰杠胡响应→摸牌→等待出牌。所有操作通过WebSocket消息驱动。关键是要处理好并发:同一时刻只能一个人操作,其他人收到的消息都是“等待”。我用一个channel串行化每个牌桌的操作,简单粗暴但很可靠。
type GameTable struct { ActionChan chan PlayerAction // ... } func (gt *GameTable) Run() { for { select { case action := <-gt.ActionChan: // 验证当前是否轮到该玩家操作 // 处理出牌/碰/杠/胡,广播结果 gt.processAction(action) } } }
三、客户端关键实现——房号输入与牌桌渲染
客户端用LayaAir,界面拼完之后就是对接协议。我封装了一个NetworkManager来管理WebSocket连接,所有消息用protobuf序列化,体积小解析快。
房卡加入的核心就是两步:①发送JoinRoom协议,带上房号;②接收服务端返回的房间信息(座位、玩家头像昵称、准备状态)。进房后自动更新大厅UI,四人到齐且全部准备后,房主点开始,服务端发牌。
牌桌渲染相对复杂:麻将牌是3D模型还是2D图片视需求而定,我们先用2D序列帧,加载快。每个玩家的手牌区域、出牌区域、碰杠牌区域要独立管理,还要处理牌的选中高亮和飞出动画。代码量比较大,这里只贴出手牌排列的核心思路:
// 手牌排序并更新显示 private refreshHandTiles(): void { // 先排序 this.handTiles.sort((a,b)=>a.value - b.value); // 清除旧牌显示 this.handContainer.removeChildren(); // 重新生成牌对象 for(let i=0; i<this.handTiles.length; i++){ let tile = this.createTileSp(this.handTiles[i]); tile.x = i * (TILE_WIDTH + GAP); // 给中间牌一个向上的偏移,模拟弧形 let offsetY = Math.abs(i - this.handTiles.length/2) * 2; tile.y = offsetY; this.handContainer.addChild(tile); } }
四、上线前必测的坑
代码跑通只是第一步,这几个地方上线前务必反复测试:
-
房号冲突:高并发下两个玩家同时创建房间,随机数碰撞概率虽低但存在,要加二次查重。
-
断线后座位丢失:玩家掉线重连后,服务端一定要根据session恢复其座位和手牌信息,否则他进来变成观战了。
-
红中百搭的边界情况:手里四张红中,或者红中凑顺子时花色不能跨越(万和条不能混用),算法里要严格限制。
-
操作超时处理:玩家迟迟不出牌,服务端要自动打出默认牌,避免卡死。
整套镜像房卡麻将的开发核心就这些了,代码量其实不小,我这篇文章只能摘最关键的部分,完整的项目代码、SQL建表语句、配置文件我都打包好了。老规矩,觉得有用的话点个赞,有问题直接找我聊。
扫描下方二维码加我微信,发“红中源码”。


