本教程将从零搭建一个完整的多人在线游戏服务器,涵盖游戏服务器的核心功能模块。我们将实现一个简化的多人对战游戏,包括登录、匹配、实时战斗和聊天功能。
项目架构
整体架构设计
客户端
↓ (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>
总结
本教程完整实现了一个多人在线游戏服务器,包括:
- 认证系统:注册、登录、Token 验证
- 房间系统:创建、加入、离开、房间列表
- 战斗系统:实时对战、技能、伤害计算
- 聊天系统:世界频道、私聊
- 网关服务:WebSocket 连接管理
- 客户端:完整的 HTML5 游戏客户端
扩展方向
- 添加数据库持久化
- 实现排行榜系统
- 添加匹配系统
- 支持更多战斗模式
- 实现公会系统
- 添加商城和支付
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。