🦀 Actix 游戏配对秒开:FIFO 队列自动撮合,2 人房秒进零等待

Photos provided by Unsplash OR Pexels

Actix 游戏服务端进阶:添加配对匹配(Matchmaking)逻辑实战指南

引言与背景

在上篇《Actix 实战游戏服务端开发指南》中,我们构建了一个基础的四子棋(Four in a Row)游戏服务器,支持 WebSocket 实时通信、玩家会话和游戏房间。但缺少**自动配对匹配(Matchmaking)**逻辑:玩家连接后如何自动排队、配对、进入房间?

本指南专注于添加 Matchmaking 逻辑,从用户实战角度提供完整、可直接复制的代码集成。设计一个全局 Matchmaker Actor,使用 FIFO 队列管理等待玩家,当达到 2 人时自动创建游戏房间、通知玩家加入。这适用于 2 人对战游戏(如四子棋、国际象棋),易扩展到技能匹配、超时 solo 等高级特性。

为什么用 Actor 实现 Matchmaking?

  • 并发安全:队列操作原子,无锁。
  • 异步通知:自动广播“匹配成功”事件。
  • 容错:Supervisor 包装,重启不丢队列(持久化可选)。
  • 性能:O(1) 入队/出队,支撑 10k+ 并发玩家。

集成流程:玩家 WS 连接 → PlayerSession 自动入队 → Matchmaker 配对 → 创建 GameRoom → 玩家收到“joined_room”事件 → 开始游戏。

1. 设计剖析

1.1 核心组件

  • Matchmaker Actor:单例,全局共享(AppData<Addr>)。
    • waiting: VecDeque<Addr<PlayerSession>>:等待队列(FIFO 公平)。
    • rooms: HashMap<Uuid, Addr<GameRoom>>:活跃房间注册(可选,用于查询)。
  • 新消息
    • EnqueuePlayer(Addr<PlayerSession>):入队。
    • JoinedRoom(Addr<GameRoom>):通知玩家加入房间。
    • AddPlayer(Addr<PlayerSession>):房间添加玩家。
    • PlayerLeft(Uuid):玩家断开,清理。
  • 流程图
    WS 连接 → PlayerSession.started() → do_send(EnqueuePlayer)
    
    Matchmaker.try_match() → if >=2: pop2 → create GameRoom → do_send(JoinedRoom/AddPlayer)
    
    PlayerSession.room = Some(room) → 客户端收到 "joined_room" → 发送 Move

1.2 高级扩展点

  • 技能匹配:队列按 MMR(Match Making Rating)分桶(Vec<VecDeque>)。
  • 超时ctx.run_interval 每 30s 检查队列,solo 或取消。
  • 全链路追踪:EnqueuePlayer 带 trace_id: Uuid(用 player_id)。
  • 持久化:队列存 Redis,断线恢复。

2. 完整代码集成(直接复制到上篇项目)

2.1 更新 Cargo.toml(无新依赖)

已有的 actix-web-actorsuuid 等足够。

2.2 新增/更新 messages.rs

use actix::prelude::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use std::collections::HashMap;  // 如需 rooms

// 原有:Join, Move, Broadcast...

#[derive(Message)]
#[rtype(result = "()")]
pub struct EnqueuePlayer(pub Addr<PlayerSession>);

#[derive(Message)]
#[rtype(result = "()")]
pub struct JoinedRoom(pub Addr<GameRoom>);

#[derive(Message)]
#[rtype(result = "()")]
pub struct AddPlayer(pub Addr<PlayerSession>);

#[derive(Message)]
#[rtype(result = "()")]
pub struct PlayerLeft(pub Uuid);  // 玩家 ID

2.3 更新 actors/player.rs(PlayerSession)

use actix::prelude::*;
use actix_web_actors::ws;
use std::time::{Duration, Instant};
use uuid::Uuid;

pub struct PlayerSession {
    pub id: Uuid,
    pub hb: Instant,
    pub room: Option<Addr<GameRoom>>,
    pub matchmaker: Addr<Matchmaker>,  // 新增
}

impl Actor for PlayerSession {
    type Context = ws::WebsocketContext<Self>;

    fn started(&mut self, ctx: &mut Self::Context) {
        self.hb = Instant::now();
        // ... 原有心跳代码
        // 新增:自动入队匹配
        self.matchmaker.do_send(EnqueuePlayer(ctx.address()));
    }

    fn stopping(&mut self, _: &mut Self::Context) -> Running {
        // 通知房间/队列玩家离开
        if let Some(room) = &self.room {
            room.do_send(PlayerLeft(self.id));
        }
        Running::Stop
    }
}

impl Handler<JoinedRoom> for PlayerSession {
    type Result = ();

    fn handle(&mut self, msg: JoinedRoom, ctx: &mut Self::Context) -> Self::Result {
        self.room = Some(msg.0.clone());
        // 通知客户端
        let event = serde_json::json!({ "event": "joined_room", "room_id": self.id });  // 实际用 room.id
        ctx.text(serde_json::to_string(&event).unwrap());
    }
}

// 原有 StreamHandler, Handler<Broadcast>...

2.4 新建 actors/matchmaker.rs

use actix::prelude::*;
use std::collections::{VecDeque, HashMap};
use uuid::Uuid;

use crate::actors::player::PlayerSession;
use crate::actors::game::GameRoom;  // 假设路径
use crate::messages::{EnqueuePlayer, JoinedRoom, AddPlayer};

pub struct Matchmaker {
    waiting: VecDeque<Addr<PlayerSession>>,
    rooms: HashMap<Uuid, Addr<GameRoom>>,  // 可选:房间注册
}

impl Default for Matchmaker {
    fn default() -> Self {
        Self {
            waiting: VecDeque::new(),
            rooms: HashMap::new(),
        }
    }
}

impl Actor for Matchmaker {
    type Context = Context<Self>;
}

impl Handler<EnqueuePlayer> for Matchmaker {
    type Result = ();

    fn handle(&mut self, msg: EnqueuePlayer, ctx: &mut Context<Self>) -> Self::Result {
        println!("玩家 {} 入队,队列长度:{}", msg.0.recipient().to_string(), self.waiting.len() + 1);
        self.waiting.push_back(msg.0);
        self.try_match(ctx);
    }
}

impl Matchmaker {
    fn try_match(&mut self, _ctx: &mut Context<Self>) {
        if self.waiting.len() >= 2 {
            let p1 = self.waiting.pop_front().unwrap();
            let p2 = self.waiting.pop_front().unwrap();
            let room_id = Uuid::new_v4();
            let room = GameRoom::new(room_id).start();  // 假设有 new 方法初始化 board/turn

            self.rooms.insert(room_id, room.clone());

            // 通知玩家加入
            p1.do_send(JoinedRoom(room.clone()));
            p2.do_send(JoinedRoom(room.clone()));

            // 添加到房间
            room.do_send(AddPlayer(p1));
            room.do_send(AddPlayer(p2));

            println!("匹配成功!房间 {} 创建,玩家:{:?} vs {:?}", room_id, p1, p2);
        }
    }
}

2.5 更新 actors/game.rs(GameRoom)

// 在 GameRoom 添加
impl Handler<AddPlayer> for GameRoom {
    type Result = ();

    fn handle(&mut self, msg: AddPlayer, _ctx: &mut Context<Self>) -> Self::Result {
        self.players.push(msg.0);
        if self.players.len() == 2 {
            // 游戏开始:广播 "game_start"
            let start_event = Broadcast {
                event: "game_start".to_string(),
                data: serde_json::json!({ "turn": self.turn }),
            };
            for player in &self.players {
                player.do_send(start_event.clone());
            }
        }
    }
}

impl Handler<PlayerLeft> for GameRoom {
    type Result = ();

    fn handle(&mut self, msg: PlayerLeft, _ctx: &mut Context<Self>) -> Self::Result {
        // 移除玩家,检查游戏结束等
        self.players.retain(|p| /* 比较 id */ false);
        if self.players.len() < 2 {
            // 广播 "game_cancel" 或结束房间
        }
    }
}

2.6 更新 main.rs & ws_route

use actix_web::{web, App, HttpServer, HttpRequest, HttpResponse};

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let matchmaker = Matchmaker::default().start();  // 启动 Matchmaker

    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(matchmaker.clone()))  // 共享 Addr
            .route("/ws", web::get().to(ws_route))
    })
    .bind(("0.0.0.0", 8080))?
    .run()
    .await
}

async fn ws_route(
    req: HttpRequest,
    stream: web::Payload,
    data: web::Data<Addr<Matchmaker>>,
) -> Result<HttpResponse, actix_web::Error> {
    let player_id = Uuid::new_v4();
    let matchmaker = data.get_ref().clone();

    ws::start(
        PlayerSession {
            id: player_id,
            hb: Instant::now(),
            room: None,
            matchmaker,
        },
        &req,
        stream,
    )
}

2.7 测试与运行

  • cargo run
  • 打开 2 个浏览器/客户端连接 ws://localhost:8080/ws
  • 自动匹配:日志显示“匹配成功”,客户端收到 "joined_room""game_start"
  • 发送 Move:正常游戏。

3. 最佳实践总结

特性实践益处
公平性FIFO VecDeque先到先配,无饥饿
性能do_send 火并忘,O(1) 操作10k+ QPS
容错Supervisor::start(|_Matchmaker)
扩展分桶匹配:waiting: HashMap<u8, VecDeque> (skill/10)MMR 匹配
监控tracing::info!(trace_id = %player_id, “匹配 {}“)全链路
安全限流:if waiting.len() > 1000 { 拒绝 }防 DDoS

坑点避免

  • Addr 必须 Clone + Send。
  • 断线清理:stopping() 发送 PlayerLeft。
  • 生产:队列持久化(Redis),分布式(上篇 Raft)。

4. 参考资料

集成完成!现在你的服务器支持自动匹配。如果需要技能匹配、Redis 持久或分布式 matchmaking,告诉我进一步优化~

版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)