如何使用skynet框架实现一个简单的多人在线游戏服务器

使用skynet框架实现一个简单的多人在线游戏服务器可以分为以下几个步骤: 1.环境搭建 首先,需要安装skynet框架。这通常包括安装lua环境和skynet本身。你可以从skynet的GitHub仓库克隆代码并按照README进行编译安装。

使用skynet框架实现一个简单的多人在线游戏服务器可以分为以下几个步骤:

1. 环境搭建

首先,需要安装skynet框架。这通常包括安装lua环境和skynet本身。你可以从skynet的GitHub仓库克隆代码并按照README进行编译安装。

  • 安装必要的工具,例如在CentOS上使用yum install -y git gcc readline-devel autoconf安装git、gcc等。
  • 下载skynet源码并编译:git clone https://github.com/cloudwu/skynet.git 然后 make linux 或 make macosx 根据系统选择相应的命令。

2. 基础配置

创建一个配置文件来定制skynet的行为(例如config),设置线程数、日志路径、服务启动脚本等:

thread = 4
logger = nil
logpath = "."
harbor = 1
start = "main" -- 指定启动脚本

3. 编写启动脚本

创建main.lua作为服务器的入口点。在这个脚本中,你可以启动监听网络连接的服务和其他游戏逻辑服务。

local skynet = require "skynet"

skynet.start(function()
    skynet.error("Server is starting...")

    -- 启动网络服务以接受客户端连接
    local gate = skynet.newservice("gate")

    -- 启动游戏逻辑服务
    local game = skynet.newservice("gamelogic")

    -- 其他初始化代码...
end)

4. 网络服务

创建一个gate.lua服务来处理客户端的连接、消息接收和转发。使用skynet.socket来监听端口和接受连接。

local skynet = require "skynet"
local socket = require "skynet.socket"

skynet.start(function()
    local gate = {}

    local function on_connect(fd, addr)
        skynet.error("New connection from " .. addr)
        socket.start(fd)
        -- 这里可以添加代码来将客户端连接转发到游戏逻辑服务
    end

    local function accept()
        local fd = socket.listen("0.0.0.0", 8888)
        skynet.error("Listen on " .. fd)
        socket.start(fd, on_connect)
    end

    skynet.dispatch("lua", function(_,_, command, ...)
        if command == "accept" then
            accept()
        else
            skynet.ret(skynet.pack(false, "Unknown command"))
        end
    end)
end)

5. 游戏逻辑服务

创建gamelogic.lua服务来处理游戏逻辑,如玩家移动、战斗等。这个服务可以接收来自gate服务转发的消息,并处理它们。

local skynet = require "skynet"

skynet.start(function()
    local function on_message(session, source, message)
        -- 处理从gate服务转发过来的消息
        skynet.error("Received message: " .. message)
        -- 这里添加游戏逻辑处理代码
    end

    skynet.dispatch("lua", on_message)
end)

6. 客户端消息处理

在gate.lua中,添加代码来接收客户端消息并将其转发到gamelogic服务。

-- 在gate.lua中添加
local function on_message(fd, data)
    local cmd, msg = skynet.unpack(data)
    -- 转发消息到gamelogic服务
    skynet.send(game, "lua", cmd, msg)
end

-- 修改on_connect函数来读取和转发消息
local function on_connect(fd, addr)
    -- ...
    socket.read(fd, on_message) -- 读取消息并调用on_message处理
end

7. 广播消息

在游戏中,你可能需要广播消息给所有客户端。可以在gamelogic服务中实现这个功能。

-- 在gamelogic.lua中添加
local clients = {}

local function add_client(fd)
    clients[fd] = true
end

local function remove_client(fd)
    clients[fd] = nil
end

local function broadcast(message)
    for fd in pairs(clients) do
        socket.write(fd, skynet.pack(message))
    end
end

8. 自定义二进制协议设计

游戏服务器通常采用二进制协议而非文本协议,以减少带宽和解析开销。一个典型的消息格式如下:

┌─────────┬─────────┬─────────┬────────┐
│ Length  │  Cmd    │  Ver    │  Body  │
│ 2 bytes │ 2 bytes │ 1 byte  │  N B   │
└─────────┴─────────┴─────────┴────────┘
-- proto.lua 协议编解码模块
local proto = {}

function proto.pack(cmd, body)
    local body_data = body or ""
    local len = 2 + 1 + #body_data  -- cmd(2) + ver(1) + body
    local header = string.pack(">I2 I2 B", len, cmd, 1)  -- 大端序
    return header .. body_data
end

function proto.unpack(data)
    if #data < 5 then
        return nil, "数据不足"
    end
    local len, cmd, ver = string.unpack(">I2 I2 B", data)
    if #data < len + 2 then
        return nil, "等待更多数据"
    end
    local body = data:sub(6, 5 + len - 3)
    return {cmd = cmd, ver = ver, body = body}, data:sub(len + 3)
end

return proto

消息协议定义示例

-- msgdef.lua 消息定义
local CMD = {
    LOGIN_REQ       = 1001,
    LOGIN_RSP       = 1002,
    HEARTBEAT_REQ   = 2001,
    HEARTBEAT_RSP   = 2002,
    PLAYER_MOVE     = 3001,
    PLAYER_ATTACK   = 3002,
    SCENE_ENTER     = 4001,
    SCENE_LEAVE     = 4002,
    BROADCAST       = 5001,
}

return CMD

9. 心跳与断线重连机制

心跳机制是维持长连接和及时检测客户端异常断开的关键:

-- heartbeat.lua 心跳服务
local skynet = require "skynet"
local CMD = require "msgdef"

skynet.start(function()
    local sessions = {}  -- fd -> {last_heartbeat, user_id}
    
    -- 定时检查超时
    skynet.fork(function()
        while true do
            skynet.sleep(500)  -- 5秒检查一次
            local now = skynet.time()
            for fd, session in pairs(sessions) do
                if now - session.last_heartbeat > 30 then
                    skynet.error("心跳超时, 断开连接: fd=" .. fd)
                    -- 通知 gate 关闭连接
                    skynet.send("gate", "lua", "close", fd)
                    -- 通知游戏逻辑服务玩家下线
                    skynet.send("gamelogic", "lua", "offline", session.user_id)
                    sessions[fd] = nil
                end
            end
        end
    end)
    
    skynet.dispatch("lua", function(_, _, command, ...)
        if command == "register" then
            local fd, user_id = ...
            sessions[fd] = {last_heartbeat = skynet.time(), user_id = user_id}
        elseif command == "heartbeat" then
            local fd = ...
            if sessions[fd] then
                sessions[fd].last_heartbeat = skynet.time()
                -- 回复心跳
                local proto = require "proto"
                local data = proto.pack(CMD.HEARTBEAT_RSP, "")
                skynet.send("gate", "lua", "send", fd, data)
            end
        elseif command == "unregister" then
            local fd = ...
            sessions[fd] = nil
        end
    end)
end)

断线重连状态恢复

-- 在 gamelogic.lua 中添加重连逻辑
local player_states = {}  -- user_id -> player_state

local function on_reconnect(user_id, fd)
    local state = player_states[user_id]
    if not state then
        -- 首次登录
        state = {
            user_id = user_id,
            fd = fd,
            pos = {x = 0, y = 0, z = 0},
            hp = 100,
            online = true,
            last_login = skynet.time(),
        }
        player_states[user_id] = state
    else
        -- 断线重连,更新 fd 和状态
        state.fd = fd
        state.online = true
        state.reconnect_count = (state.reconnect_count or 0) + 1
        skynet.error("玩家重连: " .. user_id .. " 第" .. state.reconnect_count .. "次")
    end
    
    -- 推送当前场景状态给客户端
    local scene_state = build_scene_state(state)
    send_to_client(fd, CMD.SCENE_ENTER, scene_state)
    return state
end

10. 玩家状态管理与持久化

状态机设计

                    ┌──────────────┐
         登录成功   │   离线        │
        ┌──────────►│  (Offline)   │◄──────────┐
        │           └──────┬───────┘           │
        │                  │ 连接断开           │
        │                  ▼                   │
┌───────┴──────┐   ┌──────────────┐    ┌──────┴───────┐
│   登录中      │   │   在线        │    │   战斗中      │
│(Authenticating)│  │  (Online)    ├───►│  (InBattle)  │
└──────────────┘   └──────┬───────┘    └──────┬───────┘
                          │ 进入副本/匹配      │ 战斗结束
                          ▼                   │
                   ┌──────────────┐           │
                   │   匹配中      │───────────┘
                   │ (Matching)   │
                   └──────────────┘

Redis 数据缓存层

-- redis_mgr.lua Redis 管理模块
local skynet = require "skynet"
local redis = require "skynet.db.redis"

local db

local function init()
    db = redis.connect({
        host = "127.0.0.1",
        port = 6379,
        db = 0,
    })
end

local M = {}

-- 使用 Hash 存储玩家基础数据
function M.save_player(user_id, data)
    local key = "player:" .. user_id
    db:hset(key, "name", data.name)
    db:hset(key, "level", data.level)
    db:hset(key, "exp", data.exp)
    db:hset(key, "gold", data.gold)
    db:expire(key, 86400)  -- 24小时过期
end

function M.load_player(user_id)
    local key = "player:" .. user_id
    return db:hgetall(key)
end

-- 在线状态集合
function M.set_online(user_id, server_id)
    db:zadd("online_players", skynet.time(), user_id)
    db:hdel("player:" .. user_id, "offline_time")
end

function M.set_offline(user_id)
    db:hdel("online_players", user_id)
    db:hset("player:" .. user_id, "offline_time", skynet.time())
end

init()
return M

MySQL 持久化(异步写入)

-- db_mgr.lua 数据库管理
local skynet = require "skynet"
local mysql = require "skynet.db.mysql"

local db
local dirty_players = {}  -- 待写入队列

skynet.start(function()
    db = mysql.connect({
        host = "127.0.0.1",
        port = 3306,
        database = "game_db",
        user = "game_user",
        password = "password",
        max_packet_size = 1024 * 1024,
    })
    
    -- 每30秒批量持久化
    skynet.fork(function()
        while true do
            skynet.sleep(3000)  -- 30秒
            if next(dirty_players) then
                local batch = {}
                for user_id, data in pairs(dirty_players) do
                    table.insert(batch, {
                        user_id = user_id,
                        name = data.name,
                        level = data.level,
                        exp = data.exp,
                        pos_x = data.pos.x,
                        pos_y = data.pos.y,
                        pos_z = data.pos.z,
                    })
                    dirty_players[user_id] = nil
                end
                
                -- 批量更新
                for _, player in ipairs(batch) do
                    db:query(string.format(
                        "INSERT INTO players (user_id, name, level, exp, pos_x, pos_y, pos_z) "
                        .. "VALUES (%d, '%s', %d, %d, %f, %f, %f) "
                        .. "ON DUPLICATE KEY UPDATE "
                        .. "name=VALUES(name), level=VALUES(level), exp=VALUES(exp), "
                        .. "pos_x=VALUES(pos_x), pos_y=VALUES(pos_y), pos_z=VALUES(pos_z)",
                        player.user_id, player.name, player.level, player.exp,
                        player.pos_x, player.pos_y, player.pos_z
                    ))
                end
                skynet.error("批量持久化完成: " .. #batch .. " 个玩家")
            end
        end
    end)
end)

11. 房间/匹配系统

-- room_mgr.lua 房间管理
local skynet = require "skynet"

local rooms = {}       -- room_id -> room_info
local player_room = {} -- user_id -> room_id
local room_counter = 0

local M = {}

function M.create_room(creator_id, config)
    room_counter = room_counter + 1
    local room_id = room_counter
    
    rooms[room_id] = {
        id = room_id,
        creator = creator_id,
        players = {[creator_id] = {ready = false, team = 1}},
        max_players = config.max_players or 4,
        status = "waiting",  -- waiting, playing, ended
        create_time = skynet.time(),
    }
    player_room[creator_id] = room_id
    
    skynet.error("创建房间: " .. room_id .. " 创建者: " .. creator_id)
    return room_id
end

function M.join_room(room_id, user_id)
    local room = rooms[room_id]
    if not room then
        return false, "房间不存在"
    end
    if room.status ~= "waiting" then
        return false, "房间已开始"
    end
    if table_count(room.players) >= room.max_players then
        return false, "房间已满"
    end
    
    room.players[user_id] = {ready = false, team = 2}
    player_room[user_id] = room_id
    
    -- 广播其他玩家
    M.broadcast_to_room(room_id, "player_join", {user_id = user_id})
    return true
end

function M.player_ready(room_id, user_id)
    local room = rooms[room_id]
    if not room then return false end
    room.players[user_id].ready = true
    
    -- 检查是否全部准备
    local all_ready = true
    for _, p in pairs(room.players) do
        if not p.ready then all_ready = false end
    end
    
    if all_ready and table_count(room.players) >= 2 then
        room.status = "playing"
        M.broadcast_to_room(room_id, "game_start", {})
        -- 启动游戏逻辑
        skynet.send("gamelogic", "lua", "start_game", room_id, room.players)
    end
    return true
end

function M.broadcast_to_room(room_id, cmd, data)
    local room = rooms[room_id]
    if not room then return end
    
    for user_id, _ in pairs(room.players) do
        local state = player_states[user_id]
        if state and state.online then
            send_to_client(state.fd, cmd, data)
        end
    end
end

function table_count(t)
    local c = 0
    for _ in pairs(t) do c = c + 1 end
    return c
end

return M

12. AOI 视野同步与兴趣管理

AOI(Area of Interest)是大规模 MMO 中减少广播开销的核心机制:

-- aoi.lua 九宫格 AOI 实现
local skynet = require "skynet"

local GRID_SIZE = 100  -- 每个格子 100x100 单位
local VISION_RANGE = 2  -- 视野范围(格子数)
local grids = {}  -- grid_key -> {user_id -> true}
local player_grid = {}  -- user_id -> grid_key

local function get_grid_key(x, z)
    local gx = math.floor(x / GRID_SIZE)
    local gz = math.floor(z / GRID_SIZE)
    return gx .. "," .. gz
end

local function get_around_grids(gx, gz)
    local around = {}
    for dx = -VISION_RANGE, VISION_RANGE do
        for dz = -VISION_RANGE, VISION_RANGE do
            table.insert(around, (gx + dx) .. "," .. (gz + dz))
        end
    end
    return around
end

local M = {}

-- 玩家移动时更新 AOI
function M.on_move(user_id, old_x, old_z, new_x, new_z)
    local old_key = get_grid_key(old_x, old_z)
    local new_key = get_grid_key(new_x, new_z)
    
    if old_key == new_key then return end
    
    -- 离开旧格子
    if grids[old_key] then
        grids[old_key][user_id] = nil
        if not next(grids[old_key]) then
            grids[old_key] = nil
        end
    end
    
    -- 进入新格子
    grids[new_key] = grids[new_key] or {}
    grids[new_key][user_id] = true
    player_grid[user_id] = new_key
    
    -- 计算视野变化并推送
    local old_gx, old_gz = string.match(old_key, "(-?%d+),(-?%d+)")
    local new_gx, new_gz = string.match(new_key, "(-?%d+),(-?%d+)")
    old_gx, old_gz = tonumber(old_gx), tonumber(old_gz)
    new_gx, new_gz = tonumber(new_gx), tonumber(new_gz)
    
    -- 离队通知
    for _, gk in ipairs(get_around_grids(old_gx, old_gz)) do
        if grids[gk] then
            for uid, _ in pairs(grids[gk]) do
                if uid ~= user_id then
                    send_to_client_by_uid(uid, "player_leave", {user_id = user_id})
                end
            end
        end
    end
    
    -- 入队通知
    for _, gk in ipairs(get_around_grids(new_gx, new_gz)) do
        if grids[gk] then
            for uid, _ in pairs(grids[gk]) do
                if uid ~= user_id then
                    send_to_client_by_uid(uid, "player_enter", {user_id = user_id, x = new_x, z = new_z})
                    send_to_client_by_uid(user_id, "player_enter", {user_id = uid})
                end
            end
        end
    end
end

-- 获取玩家视野内的所有玩家
function M.get_vision_players(user_id)
    local grid_key = player_grid[user_id]
    if not grid_key then return {} end
    
    local gx, gz = string.match(grid_key, "(-?%d+),(-?%d+)")
    local result = {}
    for _, gk in ipairs(get_around_grids(tonumber(gx), tonumber(gz))) do
        if grids[gk] then
            for uid, _ in pairs(grids[gk]) do
                if uid ~= user_id then
                    table.insert(result, uid)
                end
            end
        end
    end
    return result
end

return M

13. 启动和测试

启动skynet服务器并使用配置文件:

./skynet/skynet examples/config

然后,使用客户端连接到服务器并测试多人在线功能。

性能压测建议

# 使用 skynet 自带的 benchmark 工具
# 1. 连接压测:模拟 1000 并发连接
# 2. 消息压测:每连接每秒发送 20 条消息
# 3. AOI 压测:100 玩家在 1000x1000 场景中随机移动

# 监控指标
- CPU 使用率 < 70%(多核充分利用)
- 内存 RSS < 2GB
- 网络 I/O < 1Gbps
- 消息延迟 P99 < 50ms
- 断线重连成功率 > 99.9%

14. 扩展和优化

根据游戏需求,你可以添加更多的服务和功能,如玩家数据库管理、游戏状态同步、战斗系统等。同时,注意优化网络通信和游戏逻辑的性能。

性能优化策略

优化项方案效果
消息序列化protobuf 替代 JSON减少 50%+ 传输体积
AOI 算法十字链表 / R-Tree支持万人同屏
热更新skynet 代码热替换不停服更新
连接池MySQL/Redis 连接复用减少连接开销
分区分服按地域/等级分流水平扩展
状态快照定时序列化到磁盘容错恢复

Skynet 核心特性速查

服务启动: skynet.newservice(name, ...)
消息发送: skynet.send(addr, type, ...)
消息调用: skynet.call(addr, type, ...)
定时器:   skynet.timeout(ti, func) / skynet.sleep(ti)
多线程:   skynet.fork(func)
唯一服务: skynet.uniqueservice(name)
集群:     cluster.call(node, addr, ...)

这只是一个基础的框架,具体实现时需要根据你的游戏需求进行调整和扩展。skynet的灵活性和扩展性使得它非常适合开发各种类型的网络游戏服务器。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页