使用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的灵活性和扩展性使得它非常适合开发各种类型的网络游戏服务器。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。