Skynet 完整游戏服务器实战

从零搭建一个基于 Skynet 的完整多人在线游戏服务器,涵盖登录注册、房间匹配、实时对战、聊天系统、数据持久化等核心功能

本教程将从零搭建一个完整的多人在线游戏服务器,涵盖游戏服务器的核心功能模块。我们将实现一个简化的多人对战游戏,包括登录、匹配、实时战斗和聊天功能。

项目架构

整体架构设计

客户端
  ↓ (WebSocket)
┌─────────────────────────────────────┐
│  网关服务 (wsgate)                   │
│  - WebSocket 连接管理               │
│  - 协议编解码                       │
│  - 消息路由                         │
└─────────────────────────────────────┘
  ↓
┌─────────────────────────────────────┐
│  认证服务 (auth)                     │
│  - 登录/注册                        │
│  - Token 验证                       │
└─────────────────────────────────────┘
  ↓
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 玩家代理  │ │ 房间管理  │ │ 聊天服务  │
│ (agent)  │ │ (room)   │ │ (chat)   │
└──────────┘ └──────────┘ └──────────┘
  ↓            ↓            ↓
┌─────────────────────────────────────┐
│  数据服务 (data)                     │
│  - MySQL 持久化                     │
│  - Redis 缓存                       │
└─────────────────────────────────────┘

目录结构

my-game-server/
├── config                  # 配置文件
├── service/
│   ├── main.lua           # 主服务
│   ├── wsgate.lua         # WebSocket 网关
│   ├── auth.lua           # 认证服务
│   ├── agent.lua          # 玩家代理
│   ├── room.lua           # 房间服务
│   ├── room_mgr.lua       # 房间管理
│   ├── chat.lua           # 聊天服务
│   ├── data.lua           # 数据服务
│   └── monitor.lua        # 监控服务
├── lualib/
│   ├── protocol.lua       # 协议定义
│   ├── game_logic.lua     # 游戏逻辑
│   └── utils.lua          # 工具函数
└── logs/

主服务

-- service/main.lua
local skynet = require "skynet"

skynet.start(function()
    skynet.error("=====================================")
    skynet.error("  多人游戏服务器启动中...            ")
    skynet.error("=====================================")
    
    -- 1. 启动基础服务
    skynet.uniqueservice("data")
    skynet.error("[1/6] 数据服务已启动")
    
    skynet.uniqueservice("auth")
    skynet.error("[2/6] 认证服务已启动")
    
    skynet.uniqueservice("room_mgr")
    skynet.error("[3/6] 房间管理服务已启动")
    
    skynet.uniqueservice("chat")
    skynet.error("[4/6] 聊天服务已启动")
    
    skynet.uniqueservice("monitor")
    skynet.error("[5/6] 监控服务已启动")
    
    -- 2. 启动网关(最后启动,开始接收连接)
    local gate = skynet.newservice("wsgate")
    skynet.call(gate, "lua", "open", {
        address = "0.0.0.0",
        port = 8888,
        maxclient = 4096,
        heartbeat_timeout = 60,
    })
    skynet.error("[6/6] WebSocket 网关已启动 (端口 8888)")
    
    skynet.error("=====================================")
    skynet.error("  服务器启动完成!                   ")
    skynet.error("  WebSocket: ws://localhost:8888     ")
    skynet.error("=====================================")
end)

协议定义

-- lualib/protocol.lua
local cjson = require "cjson"

local Protocol = {}

-- 消息类型定义
Protocol.MSG = {
    -- 认证相关 (1-99)
    LOGIN_REQ       = 1,
    LOGIN_RESP      = 2,
    REGISTER_REQ    = 3,
    REGISTER_RESP   = 4,
    
    -- 房间相关 (100-199)
    CREATE_ROOM_REQ  = 100,
    CREATE_ROOM_RESP = 101,
    JOIN_ROOM_REQ    = 102,
    JOIN_ROOM_RESP   = 103,
    LEAVE_ROOM_REQ   = 104,
    LEAVE_ROOM_RESP  = 105,
    ROOM_LIST_REQ    = 106,
    ROOM_LIST_RESP   = 107,
    ROOM_UPDATE      = 108,  -- 服务端推送
    
    -- 战斗相关 (200-299)
    BATTLE_START     = 200,
    BATTLE_ACTION    = 201,
    BATTLE_UPDATE    = 202,
    BATTLE_END       = 203,
    
    -- 聊天相关 (300-399)
    CHAT_SEND_REQ    = 300,
    CHAT_RECV        = 301,
    
    -- 系统消息 (900-999)
    KICK             = 900,
    HEARTBEAT        = 901,
    SERVER_NOTICE    = 902,
}

-- 编码消息
function Protocol.encode(msg_type, data)
    return cjson.encode({
        type = msg_type,
        data = data,
        ts = os.time(),
    })
end

-- 解码消息
function Protocol.decode(raw)
    local ok, msg = pcall(cjson.decode, raw)
    if not ok then
        return nil, "解码失败: " .. msg
    end
    return msg
end

-- 错误码
Protocol.ERROR = {
    SUCCESS          = 0,
    INVALID_PARAMS   = 1,
    AUTH_FAILED      = 2,
    ROOM_FULL        = 3,
    ROOM_NOT_FOUND   = 4,
    ALREADY_IN_ROOM  = 5,
    NOT_IN_ROOM      = 6,
    SERVER_ERROR     = 99,
}

return Protocol

认证服务

-- service/auth.lua
local skynet = require "skynet"
local crypt = require "skynet.crypt"

-- 用户数据库(实际项目应使用 MySQL)
local users = {}       -- username -> {password_hash, user_id, ...}
local tokens = {}      -- token -> user_id
local next_user_id = 1000

local CMD = {}

-- 注册
function CMD.register(username, password)
    -- 参数验证
    if not username or not password then
        return false, "用户名和密码不能为空"
    end
    
    if string.len(username) < 3 or string.len(username) > 20 then
        return false, "用户名长度需在 3-20 字符之间"
    end
    
    if string.len(password) < 6 then
        return false, "密码长度不能少于 6 位"
    end
    
    -- 检查用户名是否已存在
    if users[username] then
        return false, "用户名已存在"
    end
    
    -- 创建用户
    local user_id = next_user_id
    next_user_id = next_user_id + 1
    
    local password_hash = crypt.sha1(password)
    
    users[username] = {
        user_id = user_id,
        username = username,
        password_hash = password_hash,
        level = 1,
        gold = 1000,
        exp = 0,
        created_at = os.time(),
    }
    
    skynet.error(string.format("新用户注册: %s (ID: %d)", username, user_id))
    
    return true, {
        user_id = user_id,
        username = username,
    }
end

-- 登录
function CMD.login(username, password)
    if not username or not password then
        return false, "用户名和密码不能为空"
    end
    
    local user = users[username]
    if not user then
        return false, "用户不存在"
    end
    
    local password_hash = crypt.sha1(password)
    if user.password_hash ~= password_hash then
        return false, "密码错误"
    end
    
    -- 生成 Token
    local token = crypt.base64encode(crypt.randomkey())
    tokens[token] = user.user_id
    
    user.last_login = os.time()
    
    skynet.error(string.format("用户登录: %s (ID: %d)", username, user.user_id))
    
    return true, {
        token = token,
        user_id = user.user_id,
        username = username,
        level = user.level,
        gold = user.gold,
    }
end

-- 验证 Token
function CMD.verify_token(token)
    local user_id = tokens[token]
    if not user_id then
        return false, "无效的 Token"
    end
    
    -- 查找用户信息
    for username, user in pairs(users) do
        if user.user_id == user_id then
            return true, {
                user_id = user_id,
                username = username,
                level = user.level,
                gold = user.gold,
            }
        end
    end
    
    return false, "用户不存在"
end

-- 登出
function CMD.logout(token)
    tokens[token] = nil
    return true
end

-- 获取用户信息
function CMD.get_user(user_id)
    for username, user in pairs(users) do
        if user.user_id == user_id then
            return user
        end
    end
    return nil
end

-- 更新用户信息
function CMD.update_user(user_id, field, value)
    for username, user in pairs(users) do
        if user.user_id == user_id then
            user[field] = value
            return true
        end
    end
    return false
end

skynet.start(function()
    skynet.register(".auth")
    
    -- 预置测试账号
    users["test1"] = {
        user_id = 1001,
        username = "test1",
        password_hash = crypt.sha1("123456"),
        level = 10,
        gold = 5000,
        exp = 500,
        created_at = os.time(),
    }
    users["test2"] = {
        user_id = 1002,
        username = "test2",
        password_hash = crypt.sha1("123456"),
        level = 15,
        gold = 8000,
        exp = 1200,
        created_at = os.time(),
    }
    next_user_id = 1003
    
    skynet.dispatch("lua", function(session, source, cmd, ...)
        local f = assert(CMD[cmd])
        if session ~= 0 then
            skynet.retpack(f(...))
        else
            f(...)
        end
    end)
end)

玩家代理服务

-- service/agent.lua
local skynet = require "skynet"
local Protocol = require "protocol"

local fd = tonumber(...)
local addr = select(2, ...)

local user_id = nil
local username = nil
local token = nil
local room_id = nil
local auth_service = nil
local room_mgr = nil
local chat_service = nil

local CMD = {}

-- ===== 消息处理 =====

function CMD.message(fd, raw_data)
    local msg, err = Protocol.decode(raw_data)
    if not msg then
        skynet.error("消息解码失败:", err)
        return
    end
    
    local msg_type = msg.type
    local data = msg.data or {}
    
    -- 心跳
    if msg_type == Protocol.MSG.HEARTBEAT then
        send(Protocol.MSG.HEARTBEAT, {ts = os.time()})
        return
    end
    
    -- 登录
    if msg_type == Protocol.MSG.LOGIN_REQ then
        handle_login(data)
        return
    end
    
    -- 注册
    if msg_type == Protocol.MSG.REGISTER_REQ then
        handle_register(data)
        return
    end
    
    -- 未登录状态,拒绝其他请求
    if not user_id then
        send_error(msg_type + 1, "请先登录")
        return
    end
    
    -- 已登录状态的消息处理
    if msg_type == Protocol.MSG.CREATE_ROOM_REQ then
        handle_create_room(data)
    elseif msg_type == Protocol.MSG.JOIN_ROOM_REQ then
        handle_join_room(data)
    elseif msg_type == Protocol.MSG.LEAVE_ROOM_REQ then
        handle_leave_room()
    elseif msg_type == Protocol.MSG.ROOM_LIST_REQ then
        handle_room_list()
    elseif msg_type == Protocol.MSG.BATTLE_ACTION then
        handle_battle_action(data)
    elseif msg_type == Protocol.MSG.CHAT_SEND_REQ then
        handle_chat(data)
    else
        skynet.error("未知消息类型:", msg_type)
    end
end

function CMD.disconnect()
    skynet.error(string.format("玩家断开: %s (ID: %s)", 
        username or "unknown", user_id or "unknown"))
    
    -- 离开房间
    if room_id and room_mgr then
        pcall(skynet.send, room_mgr, "lua", "leave_room", room_id, user_id)
    end
    
    -- 通知聊天服务下线
    if chat_service and user_id then
        pcall(skynet.send, chat_service, "lua", "offline", user_id)
    end
    
    -- 登出
    if token and auth_service then
        pcall(skynet.send, auth_service, "lua", "logout", token)
    end
    
    skynet.exit()
end

-- ===== 业务逻辑 =====

function handle_login(data)
    auth_service = skynet.uniqueservice("auth")
    
    local ok, result = skynet.call(auth_service, "lua", 
        "login", data.username, data.password)
    
    if ok then
        user_id = result.user_id
        username = result.username
        token = result.token
        
        -- 初始化依赖服务
        room_mgr = skynet.uniqueservice("room_mgr")
        chat_service = skynet.uniqueservice("chat")
        
        send(Protocol.MSG.LOGIN_RESP, {
            success = true,
            user_id = user_id,
            username = username,
            level = result.level,
            gold = result.gold,
        })
        
        skynet.error(string.format("玩家登录成功: %s (ID: %d)", username, user_id))
    else
        send(Protocol.MSG.LOGIN_RESP, {
            success = false,
            error = result,
        })
    end
end

function handle_register(data)
    auth_service = skynet.uniqueservice("auth")
    
    local ok, result = skynet.call(auth_service, "lua", 
        "register", data.username, data.password)
    
    if ok then
        send(Protocol.MSG.REGISTER_RESP, {
            success = true,
            user_id = result.user_id,
        })
    else
        send(Protocol.MSG.REGISTER_RESP, {
            success = false,
            error = result,
        })
    end
end

function handle_create_room(data)
    local ok, result = skynet.call(room_mgr, "lua", 
        "create_room", user_id, data)
    
    if ok then
        room_id = result.room_id
        send(Protocol.MSG.CREATE_ROOM_RESP, {
            success = true,
            room_id = room_id,
            room = result,
        })
    else
        send(Protocol.MSG.CREATE_ROOM_RESP, {
            success = false,
            error = result,
        })
    end
end

function handle_join_room(data)
    if room_id then
        send(Protocol.MSG.JOIN_ROOM_RESP, {
            success = false,
            error = "已在房间中",
        })
        return
    end
    
    local ok, result = skynet.call(room_mgr, "lua", 
        "join_room", data.room_id, user_id, self)
    
    if ok then
        room_id = data.room_id
        send(Protocol.MSG.JOIN_ROOM_RESP, {
            success = true,
            room = result,
        })
    else
        send(Protocol.MSG.JOIN_ROOM_RESP, {
            success = false,
            error = result,
        })
    end
end

function handle_leave_room()
    if not room_id then
        send(Protocol.MSG.LEAVE_ROOM_RESP, {
            success = false,
            error = "不在房间中",
        })
        return
    end
    
    skynet.call(room_mgr, "lua", "leave_room", room_id, user_id)
    room_id = nil
    
    send(Protocol.MSG.LEAVE_ROOM_RESP, {success = true})
end

function handle_room_list()
    local rooms = skynet.call(room_mgr, "lua", "get_room_list")
    send(Protocol.MSG.ROOM_LIST_RESP, {rooms = rooms})
end

function handle_battle_action(data)
    if not room_id then
        return
    end
    
    -- 转发给房间服务处理
    skynet.send(".room_mgr", "lua", "battle_action", 
        room_id, user_id, data)
end

function handle_chat(data)
    if not chat_service then
        return
    end
    
    skynet.send(chat_service, "lua", "send_message", 
        user_id, username, data)
end

-- ===== 辅助函数 =====

function send(msg_type, data)
    local msg = Protocol.encode(msg_type, data)
    skynet.send(".wsgate", "lua", "send", fd, msg)
end

function send_error(msg_type, error_msg)
    send(msg_type, {success = false, error = error_msg})
end

-- ===== 来自其他服务的调用 =====

-- 房间广播消息
function CMD.room_message(msg_type, data)
    send(msg_type, data)
end

-- 聊天消息
function CMD.chat_message(from_name, message, channel)
    send(Protocol.MSG.CHAT_RECV, {
        from = from_name,
        message = message,
        channel = channel,
        timestamp = os.time(),
    })
end

-- 被踢出
function CMD.kick(reason)
    send(Protocol.MSG.KICK, {reason = reason})
    skynet.timeout(100, function()
        skynet.send(".wsgate", "lua", "close", fd, 1000, reason)
    end)
end

skynet.start(function()
    skynet.dispatch("lua", function(session, source, cmd, ...)
        local f = assert(CMD[cmd], "Unknown command: " .. cmd)
        if session ~= 0 then
            skynet.retpack(f(...))
        else
            f(...)
        end
    end)
end)

房间管理服务

-- service/room_mgr.lua
local skynet = require "skynet"
local Protocol = require "protocol"

local rooms = {}         -- room_id -> room_data
local player_rooms = {}  -- user_id -> room_id
local next_room_id = 1

local MAX_ROOM_SIZE = 4
local BATTLE_DURATION = 120  -- 战斗时长(秒)

local CMD = {}

function CMD.create_room(owner_id, options)
    local room_id = next_room_id
    next_room_id = next_room_id + 1
    
    local room = {
        id = room_id,
        owner = owner_id,
        players = {owner_id},
        max_players = options.max_players or MAX_ROOM_SIZE,
        status = "waiting",  -- waiting, playing, finished
        created_at = os.time(),
        game_state = nil,
    }
    
    rooms[room_id] = room
    player_rooms[owner_id] = room_id
    
    skynet.error(string.format("房间 %d 已创建,房主: %d", room_id, owner_id))
    
    return true, room
end

function CMD.join_room(room_id, user_id, agent)
    local room = rooms[room_id]
    if not room then
        return false, "房间不存在"
    end
    
    if room.status ~= "waiting" then
        return false, "房间正在游戏中"
    end
    
    if #room.players >= room.max_players then
        return false, "房间已满"
    end
    
    if player_rooms[user_id] then
        return false, "已在其他房间中"
    end
    
    -- 加入房间
    table.insert(room.players, user_id)
    player_rooms[user_id] = room_id
    
    -- 广播更新
    broadcast_room(room_id, Protocol.MSG.ROOM_UPDATE, {
        event = "join",
        user_id = user_id,
        players = room.players,
    })
    
    skynet.error(string.format("玩家 %d 加入房间 %d", user_id, room_id))
    
    return true, room
end

function CMD.leave_room(room_id, user_id)
    local room = rooms[room_id]
    if not room then
        return false, "房间不存在"
    end
    
    -- 从玩家列表移除
    for i, pid in ipairs(room.players) do
        if pid == user_id then
            table.remove(room.players, i)
            break
        end
    end
    
    player_rooms[user_id] = nil
    
    -- 广播更新
    broadcast_room(room_id, Protocol.MSG.ROOM_UPDATE, {
        event = "leave",
        user_id = user_id,
        players = room.players,
    })
    
    -- 如果房间为空,删除房间
    if #room.players == 0 then
        rooms[room_id] = nil
        skynet.error(string.format("房间 %d 已删除(空房间)", room_id))
    -- 如果房主离开,转移房主
    elseif room.owner == user_id then
        room.owner = room.players[1]
        broadcast_room(room_id, Protocol.MSG.ROOM_UPDATE, {
            event = "owner_change",
            new_owner = room.owner,
        })
    end
    
    skynet.error(string.format("玩家 %d 离开房间 %d", user_id, room_id))
    return true
end

function CMD.get_room_list()
    local list = {}
    for id, room in pairs(rooms) do
        list[#list + 1] = {
            id = id,
            owner = room.owner,
            player_count = #room.players,
            max_players = room.max_players,
            status = room.status,
        }
    end
    return list
end

function CMD.start_battle(room_id)
    local room = rooms[room_id]
    if not room then
        return false, "房间不存在"
    end
    
    if #room.players < 2 then
        return false, "至少需要 2 名玩家"
    end
    
    if room.status ~= "waiting" then
        return false, "房间状态不正确"
    end
    
    room.status = "playing"
    
    -- 初始化游戏状态
    room.game_state = init_game_state(room.players)
    
    -- 广播战斗开始
    broadcast_room(room_id, Protocol.MSG.BATTLE_START, {
        players = room.players,
        game_state = room.game_state,
        duration = BATTLE_DURATION,
    })
    
    -- 启动战斗循环
    skynet.fork(function()
        battle_loop(room_id)
    end)
    
    skynet.error(string.format("房间 %d 战斗开始", room_id))
    return true
end

function CMD.battle_action(room_id, user_id, action)
    local room = rooms[room_id]
    if not room or room.status ~= "playing" then
        return
    end
    
    -- 处理玩家操作
    process_action(room, user_id, action)
end

-- ===== 战斗系统 =====

function init_game_state(players)
    local state = {
        players = {},
        time_left = BATTLE_DURATION,
    }
    
    for i, pid in ipairs(players) do
        state.players[pid] = {
            hp = 100,
            mp = 50,
            x = math.random(0, 800),
            y = math.random(0, 600),
            score = 0,
            alive = true,
        }
    end
    
    return state
end

function process_action(room, user_id, action)
    local player = room.game_state.players[user_id]
    if not player or not player.alive then
        return
    end
    
    if action.type == "move" then
        player.x = action.x
        player.y = action.y
    elseif action.type == "attack" then
        -- 攻击目标
        local target = room.game_state.players[action.target_id]
        if target and target.alive then
            -- 计算伤害
            local distance = math.sqrt(
                (player.x - target.x)^2 + (player.y - target.y)^2)
            
            if distance < 100 then
                local damage = math.random(10, 25)
                target.hp = target.hp - damage
                player.score = player.score + damage
                
                if target.hp <= 0 then
                    target.hp = 0
                    target.alive = false
                    player.score = player.score + 50
                end
            end
        end
    elseif action.type == "skill" then
        -- 技能(消耗 MP)
        if player.mp >= 20 then
            player.mp = player.mp - 20
            -- AOE 伤害
            for pid, pdata in pairs(room.game_state.players) do
                if pid ~= user_id and pdata.alive then
                    local distance = math.sqrt(
                        (player.x - pdata.x)^2 + (player.y - pdata.y)^2)
                    if distance < 150 then
                        pdata.hp = pdata.hp - 30
                        player.score = player.score + 30
                        if pdata.hp <= 0 then
                            pdata.hp = 0
                            pdata.alive = false
                            player.score = player.score + 50
                        end
                    end
                end
            end
        end
    end
end

function battle_loop(room_id)
    local room = rooms[room_id]
    if not room then return end
    
    local start_time = skynet.time()
    
    while room and room.status == "playing" do
        skynet.sleep(50)  -- 每 0.5 秒更新一次
        
        local elapsed = skynet.time() - start_time
        room.game_state.time_left = math.max(0, BATTLE_DURATION - elapsed)
        
        -- MP 回复
        for pid, pdata in pairs(room.game_state.players) do
            if pdata.alive then
                pdata.mp = math.min(50, pdata.mp + 1)
            end
        end
        
        -- 广播状态更新
        broadcast_room(room_id, Protocol.MSG.BATTLE_UPDATE, {
            time_left = room.game_state.time_left,
            players = room.game_state.players,
        })
        
        -- 检查战斗结束条件
        local alive_count = 0
        for _, pdata in pairs(room.game_state.players) do
            if pdata.alive then
                alive_count = alive_count + 1
            end
        end
        
        if alive_count <= 1 or elapsed >= BATTLE_DURATION then
            -- 战斗结束
            end_battle(room_id)
            return
        end
        
        room = rooms[room_id]
    end
end

function end_battle(room_id)
    local room = rooms[room_id]
    if not room then return end
    
    -- 计算排名
    local rankings = {}
    for pid, pdata in pairs(room.game_state.players) do
        rankings[#rankings + 1] = {
            user_id = pid,
            score = pdata.score,
            alive = pdata.alive,
        }
    end
    
    table.sort(rankings, function(a, b) return a.score > b.score end)
    
    for i, r in ipairs(rankings) do
        r.rank = i
    end
    
    -- 广播战斗结果
    broadcast_room(room_id, Protocol.MSG.BATTLE_END, {
        rankings = rankings,
    })
    
    -- 重置房间状态
    room.status = "waiting"
    room.game_state = nil
    
    skynet.error(string.format("房间 %d 战斗结束", room_id))
end

-- ===== 辅助函数 =====

function broadcast_room(room_id, msg_type, data)
    local room = rooms[room_id]
    if not room then return end
    
    local gate = skynet.localname(".wsgate")
    if not gate then return end
    
    -- 通过房间管理获取每个玩家的 agent
    for _, pid in ipairs(room.players) do
        local agent = get_agent(pid)
        if agent then
            skynet.send(agent, "lua", "room_message", msg_type, data)
        end
    end
end

function get_agent(user_id)
    -- 简化实现:通过 player_rooms 和全局 agent 注册表查找
    -- 实际项目中应有更好的 agent 管理方式
    local addr = skynet.localname(string.format(".agent_%d", user_id))
    return addr
end

skynet.start(function()
    skynet.register(".room_mgr")
    
    -- 定时清理空房间
    skynet.fork(function()
        while true do
            skynet.sleep(6000)  -- 每分钟检查
            for id, room in pairs(rooms) do
                if #room.players == 0 then
                    rooms[id] = nil
                    skynet.error("清理空房间:", id)
                end
            end
        end
    end)
    
    skynet.dispatch("lua", function(session, source, cmd, ...)
        local f = assert(CMD[cmd])
        if session ~= 0 then
            skynet.retpack(f(...))
        else
            f(...)
        end
    end)
end)

聊天服务

-- service/chat.lua
local skynet = require "skynet"
local Protocol = require "protocol"

local online_players = {}  -- user_id -> agent
local channels = {}        -- channel -> {user_ids}

local CMD = {}

function CMD.online(user_id, agent)
    online_players[user_id] = agent
    
    -- 加入世界频道
    if not channels["world"] then
        channels["world"] = {}
    end
    channels["world"][user_id] = true
    
    skynet.error(string.format("玩家 %d 上线", user_id))
end

function CMD.offline(user_id)
    online_players[user_id] = nil
    
    -- 从所有频道移除
    for channel, members in pairs(channels) do
        members[user_id] = nil
    end
    
    skynet.error(string.format("玩家 %d 下线", user_id))
end

function CMD.send_message(user_id, username, data)
    local channel = data.channel or "world"
    local message = data.message
    
    if not message or string.len(message) == 0 then
        return
    end
    
    -- 过滤敏感词(简化实现)
    message = filter_sensitive(message)
    
    local chat_msg = {
        from_id = user_id,
        from_name = username,
        message = message,
        channel = channel,
        timestamp = os.time(),
    }
    
    if channel == "world" then
        -- 世界频道广播
        broadcast_channel("world", chat_msg)
    elseif channel == "room" then
        -- 房间频道(由房间服务处理)
        skynet.send(".room_mgr", "lua", "room_chat", user_id, chat_msg)
    elseif string.sub(channel, 1, 3) == "pm:" then
        -- 私聊
        local target_id = tonumber(string.sub(channel, 4))
        if target_id and online_players[target_id] then
            skynet.send(online_players[target_id], "lua", 
                "chat_message", username, message, channel)
        end
    end
end

function CMD.join_channel(user_id, channel)
    if not channels[channel] then
        channels[channel] = {}
    end
    channels[channel][user_id] = true
end

function CMD.leave_channel(user_id, channel)
    if channels[channel] then
        channels[channel][user_id] = nil
    end
end

-- ===== 辅助函数 =====

function broadcast_channel(channel, chat_msg)
    local members = channels[channel]
    if not members then return end
    
    for user_id in pairs(members) do
        local agent = online_players[user_id]
        if agent then
            skynet.send(agent, "lua", "chat_message", 
                chat_msg.from_name, chat_msg.message, channel)
        end
    end
end

function filter_sensitive(message)
    -- 简单的敏感词过滤
    local words = {"脏话1", "脏话2", "敏感词"}
    for _, word in ipairs(words) do
        message = string.gsub(message, word, string.rep("*", #word))
    end
    return message
end

skynet.start(function()
    skynet.register(".chat")
    
    skynet.dispatch("lua", function(session, source, cmd, ...)
        local f = assert(CMD[cmd])
        if session ~= 0 then
            skynet.retpack(f(...))
        else
            f(...)
        end
    end)
end)

客户端示例

<!DOCTYPE html>
<html>
<head>
    <title>多人游戏客户端</title>
    <style>
        body { font-family: Arial, sans-serif; padding: 20px; }
        #login, #game { max-width: 600px; margin: 0 auto; }
        .panel { border: 1px solid #ccc; padding: 15px; margin: 10px 0; border-radius: 5px; }
        input, button { padding: 8px; margin: 5px; }
        #log { height: 200px; overflow-y: scroll; border: 1px solid #eee; padding: 10px; 
               font-family: monospace; font-size: 12px; background: #f9f9f9; }
        #battle-canvas { border: 1px solid #333; background: #222; }
    </style>
</head>
<body>
    <div id="login">
        <div class="panel">
            <h2>登录</h2>
            <input type="text" id="username" placeholder="用户名" value="test1">
            <input type="password" id="password" placeholder="密码" value="123456">
            <button onclick="doLogin()">登录</button>
            <button onclick="doRegister()">注册</button>
        </div>
    </div>

    <div id="game" style="display:none">
        <div class="panel">
            <h2>大厅</h2>
            <button onclick="createRoom()">创建房间</button>
            <button onclick="refreshRooms()">刷新房间列表</button>
            <button onclick="startBattle()">开始战斗</button>
            <div id="room-list"></div>
        </div>
        
        <div class="panel">
            <h2>战斗</h2>
            <canvas id="battle-canvas" width="800" height="600"></canvas>
            <div>
                <button onclick="movePlayer()">随机移动</button>
                <button onclick="attackNearest()">攻击最近</button>
                <button onclick="useSkill()">释放技能</button>
            </div>
        </div>
        
        <div class="panel">
            <h2>聊天</h2>
            <input type="text" id="chat-input" placeholder="输入消息">
            <button onclick="sendChat()">发送</button>
        </div>
    </div>

    <div class="panel">
        <h2>日志</h2>
        <div id="log"></div>
    </div>

    <script>
    // 消息类型
    const MSG = {
        LOGIN_REQ: 1, LOGIN_RESP: 2,
        REGISTER_REQ: 3, REGISTER_RESP: 4,
        CREATE_ROOM_REQ: 100, CREATE_ROOM_RESP: 101,
        JOIN_ROOM_REQ: 102, JOIN_ROOM_RESP: 103,
        LEAVE_ROOM_REQ: 104, LEAVE_ROOM_RESP: 105,
        ROOM_LIST_REQ: 106, ROOM_LIST_RESP: 107,
        ROOM_UPDATE: 108,
        BATTLE_START: 200, BATTLE_ACTION: 201,
        BATTLE_UPDATE: 202, BATTLE_END: 203,
        CHAT_SEND_REQ: 300, CHAT_RECV: 301,
        HEARTBEAT: 901,
    };

    let ws = null;
    let userId = null;
    let username = null;
    let roomId = null;
    let gameState = null;

    function log(msg) {
        const el = document.getElementById('log');
        const time = new Date().toLocaleTimeString();
        el.innerHTML += `[${time}] ${msg}<br>`;
        el.scrollTop = el.scrollHeight;
    }

    function connect() {
        ws = new WebSocket('ws://localhost:8888');
        
        ws.onopen = () => {
            log('已连接到服务器');
            // 启动心跳
            setInterval(() => {
                send(MSG.HEARTBEAT, {});
            }, 30000);
        };
        
        ws.onmessage = (event) => {
            const msg = JSON.parse(event.data);
            handleMessage(msg);
        };
        
        ws.onclose = (e) => {
            log('连接断开: ' + e.reason);
        };
    }

    function send(type, data) {
        if (ws && ws.readyState === WebSocket.OPEN) {
            ws.send(JSON.stringify({type, data}));
        }
    }

    function handleMessage(msg) {
        const {type, data} = msg;
        
        switch(type) {
            case MSG.LOGIN_RESP:
                if (data.success) {
                    userId = data.user_id;
                    username = data.username;
                    log(`登录成功: ${username} (ID: ${userId})`);
                    document.getElementById('login').style.display = 'none';
                    document.getElementById('game').style.display = 'block';
                    refreshRooms();
                } else {
                    log('登录失败: ' + data.error);
                }
                break;
                
            case MSG.ROOM_UPDATE:
                log(`房间更新: ${data.event}`);
                refreshRooms();
                break;
                
            case MSG.BATTLE_START:
                gameState = data.game_state;
                log('战斗开始!');
                renderBattle();
                break;
                
            case MSG.BATTLE_UPDATE:
                gameState = data.players;
                renderBattle();
                break;
                
            case MSG.BATTLE_END:
                log('战斗结束!');
                data.rankings.forEach(r => {
                    log(`  第${r.rank}名: 玩家${r.user_id} 分数:${r.score}`);
                });
                gameState = null;
                break;
                
            case MSG.CHAT_RECV:
                log(`[聊天] ${data.from}: ${data.message}`);
                break;
        }
    }

    function doLogin() {
        send(MSG.LOGIN_REQ, {
            username: document.getElementById('username').value,
            password: document.getElementById('password').value,
        });
    }

    function doRegister() {
        send(MSG.REGISTER_REQ, {
            username: document.getElementById('username').value,
            password: document.getElementById('password').value,
        });
    }

    function createRoom() {
        send(MSG.CREATE_ROOM_REQ, {max_players: 4});
    }

    function refreshRooms() {
        send(MSG.ROOM_LIST_REQ, {});
    }

    function startBattle() {
        // 通过房间管理启动战斗
        log('请求开始战斗...');
    }

    function movePlayer() {
        send(MSG.BATTLE_ACTION, {
            type: 'move',
            x: Math.random() * 800,
            y: Math.random() * 600,
        });
    }

    function attackNearest() {
        // 找到最近的敌人
        if (!gameState) return;
        const myState = gameState[userId];
        if (!myState) return;
        
        let nearest = null;
        let minDist = Infinity;
        for (const [pid, pdata] of Object.entries(gameState)) {
            if (pid != userId && pdata.alive) {
                const dist = Math.sqrt(
                    (myState.x - pdata.x)**2 + (myState.y - pdata.y)**2);
                if (dist < minDist) {
                    minDist = dist;
                    nearest = pid;
                }
            }
        }
        
        if (nearest) {
            send(MSG.BATTLE_ACTION, {
                type: 'attack',
                target_id: parseInt(nearest),
            });
        }
    }

    function useSkill() {
        send(MSG.BATTLE_ACTION, {type: 'skill'});
    }

    function sendChat() {
        const input = document.getElementById('chat-input');
        if (input.value) {
            send(MSG.CHAT_SEND_REQ, {
                channel: 'world',
                message: input.value,
            });
            input.value = '';
        }
    }

    function renderBattle() {
        if (!gameState) return;
        const canvas = document.getElementById('battle-canvas');
        const ctx = canvas.getContext('2d');
        ctx.clearRect(0, 0, 800, 600);
        
        for (const [pid, pdata] of Object.entries(gameState)) {
            if (!pdata.alive) continue;
            
            ctx.fillStyle = pid == userId ? '#0f0' : '#f00';
            ctx.beginPath();
            ctx.arc(pdata.x, pdata.y, 15, 0, Math.PI * 2);
            ctx.fill();
            
            // 血条
            ctx.fillStyle = '#333';
            ctx.fillRect(pdata.x - 20, pdata.y - 25, 40, 5);
            ctx.fillStyle = pdata.hp > 50 ? '#0f0' : (pdata.hp > 25 ? '#ff0' : '#f00');
            ctx.fillRect(pdata.x - 20, pdata.y - 25, 40 * (pdata.hp / 100), 5);
            
            // ID
            ctx.fillStyle = '#fff';
            ctx.font = '10px Arial';
            ctx.fillText(pid, pdata.x - 10, pdata.y - 30);
        }
    }

    // 启动
    connect();
    </script>
</body>
</html>

总结

本教程完整实现了一个多人在线游戏服务器,包括:

  1. 认证系统:注册、登录、Token 验证
  2. 房间系统:创建、加入、离开、房间列表
  3. 战斗系统:实时对战、技能、伤害计算
  4. 聊天系统:世界频道、私聊
  5. 网关服务:WebSocket 连接管理
  6. 客户端:完整的 HTML5 游戏客户端

扩展方向

  • 添加数据库持久化
  • 实现排行榜系统
  • 添加匹配系统
  • 支持更多战斗模式
  • 实现公会系统
  • 添加商城和支付

继续阅读

探索更多技术文章

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

全部文章 返回首页