skynet开发一个猜数字游戏
一、游戏简介
猜数字游戏目的是掌握 actor 模型开发思路。
规则:
- 满三个人开始游戏,游戏开始后不能退出,直到游戏结束。
- 系统会随机生成1-100 之间的数字,玩家依次猜规则内的数字。
- 玩家猜测正确,那么该玩家就输了;如果猜测错误,游戏继续。
- 直到有玩家猜测成功,游戏结束。
游戏设计时,首先是简单可用,然后持续优化,而不是一开始就过度优化。
二、接口设计和实现
skynet 中,从 actor 底层看是通过消息进行通信;从 actor 应用层看是通过 api 来进行通信。
遵循接口隔离原则:
- 不应该强迫客户依赖于他们不用的方法。
- 从安全封装的角度出发,只暴露客户需要的接口。
- 服务间不依赖彼此的实现。
2.1、agent服务接口
agent服务主要是用户。具有如下功能:
- login:实现登录功能;断线重连。
- ready:准备,转发到大厅,加入匹配队列。
- guess:猜测数字,转发到房间。
- help:列出所有操作说明。
- quit:退出。
local skynet = require "skynet"
local socket = require "skynet.socket"
local tunpack = table.unpack
local tconcat = table.concat
local select = select
local clientfd, addr = ...
clientfd = tonumber(clientfd)
local hall
local function read_table(result)
local reply = {}
for i = 1, #result, 2 do reply[result[i]] = result[i + 1] end
return reply
end
-- 读取redis的相关信息
local rds = setmetatable({0}, {
__index = function (t, k)
if k == "hgetall" then
t[k] = function (red, ...)
return read_table(skynet.call(red[1], "lua", k, ...))
end
else
t[k] = function (red, ...)
return skynet.call(red[1], "lua", k, ...)
end
end
return t[k]
end
})
local client = {fd = clientfd}
local CMD = {}
local function client_quit()
skynet.call(hall, "lua", "offline", client.name)
if client.isgame and client.isgame > 0 then
skynet.call(client.isgame, "lua", "offline", client.name)
end
skynet.fork(skynet.exit) --强制关闭进程,退出
end
-- 发送信息
local function sendto(arg)
-- local ret = tconcat({"fd:", clientfd, arg}, " ")
-- socket.write(clientfd, ret .. "\n")
socket.write(clientfd, arg .. "\r\n")
end
-- 用户登录
function CMD.login(name, password)
if not name and not password then
sendto("没有设置用户名或者密码")
client_quit()
return
end
local ok = rds:exists("role:"..name)
if not ok then
local score = 1000
-- 满足条件唤醒协程,不满足条件挂起协程
rds:hmset("role:"..name, tunpack({
"name", name,
"password", password,
"score", score,
"isgame", 0,
}))
client.name = name
client.password = password
client.score = score
client.isgame = 0
client.agent = skynet.self()
else
local dbs = rds:hgetall("role:"..name)
if dbs.password ~= password then
sendto("密码错误,请重新输入密码")
return
end
client = dbs
client.fd = clientfd
client.isgame = tonumber(client.isgame) or 0
client.agent = skynet.self()
end
if client.isgame > 0 then
ok = pcall(skynet.call, client.isgame, "lua", "online", client)
if not ok then
client.isgame = 0
sendto("请准备开始游戏。。。")
end
else
sendto("请准备开始游戏。。。")
end
end
function CMD.ready()
if not client.name then
sendto("请先登陆")
return
end
if client.isgame and client.isgame > 0 then
sendto("在游戏中,不能准备")
return
end
local ok, msg = skynet.call(hall, "lua", "ready", client) --发起一个远程调用,调用hall服务的ready
if not ok then
sendto(msg)
return
end
client.isgame = ok
rds:hset("role:"..client.name, "isgame", ok)
end
function CMD.guess(number)
if not client.name then
sendto("错误:请先登陆")
return
end
if not client.isgame or client.isgame == 0 then
sendto("错误:没有在游戏中,请先准备")
return
end
local numb = math.tointeger(number)
if not numb then
sendto("错误:猜测时需要提供一个整数而不是 "..number)
return
end
skynet.send(client.isgame, "lua", "guess", client.name, numb)
end
local function game_over()
client.isgame = 0
rds:hset("role:"..client.name, "isgame", 0)
end
function CMD.help()
local params = tconcat({
"*规则*:猜数字游戏,由系统随机1-100数字,猜中输,未猜中赢。",
"help: 显示所有可输入的命令;",
"login: 登陆,需要输入用户名和密码;",
"ready: 准备,加入游戏队列,满员自动开始游戏;",
"guess: 猜数字,只能猜1~100之间的数字;",
"quit: 退出",
}, "\r\n")
socket.write(clientfd, params .. "\r\n")
end
function CMD.quit()
client_quit()
end
--处理数据接受
local function process_socket_events()
while true do
local data = socket.readline(clientfd)-- "\n" read = 0,telnet的分隔符是\n
if not data then
print("断开网络 "..clientfd)
client_quit()
return
end
-- 开始解析数据包
local pms = {}
for pm in string.gmatch(data, "%w+") do
pms[#pms+1] = pm
end
if not next(pms) then
sendto("error[format], recv data")
goto __continue__
end
-- 分发命令
local cmd = pms[1]
if not CMD[cmd] then
sendto(cmd.." 该命令不存在")
CMD.help()
goto __continue__
end
skynet.fork(CMD[cmd], select(2, tunpack(pms)))
::__continue__::
end
end
-- 开始agent服务
skynet.start(function ()
print("recv a connection:", clientfd, addr)
rds[1] = skynet.uniqueservice("redis") --进入redis服务
hall = skynet.uniqueservice("hall") -- 进入hall服务
socket.start(clientfd) -- 绑定 clientfd agent 网络消息
skynet.fork(process_socket_events) --创建协程,处理数据接受
skynet.dispatch("lua", function (_, _, cmd, ...)
if cmd == "game_over" then
game_over()
end
end)
end)
2.2、room服务接口
room服务是一个游戏空间。具有如下功能:
- start:初始化房间。
- online:用户上线,如果用户在游戏中,告知游戏进度。
- offline:用户下线,通知房间内其他用户。
- guess:猜测数字,推动游戏进程。
local skynet = require "skynet"
local socket = require "skynet.socket"
local CMD = {}
local roles = {}
local redisd
local game = {
random_value = 0,
user_turn = 0,
up_limit = 100,
down_limit = 1,
turns = {},
}
local function sendto(clientfd, arg)
socket.write(clientfd, arg .. "\r\n")
end
local function broadcast(msg)
for _, role in pairs(roles) do
if role.isonline > 0 then
sendto(role.fd, msg)
end
end
end
function CMD.start(members)
for _, role in ipairs(members) do
role.isonline = 1
roles[role.name] = role
game.turns[#game.turns+1] = role.name
end
game.random_value = math.random(1, 100)
broadcast(("房间:%d 系统已经随机一个数字"):format(skynet.self()))
local rv = math.random(1, 1500)
if rv <= 500 then
game.user_turn = 1
elseif rv <= 1000 then
game.user_turn = 2
else
game.user_turn = 3
end
local name = game.turns[game.user_turn]
broadcast(("请玩家%s开始猜数字"):format(name))
end
function CMD.offline(name)
if roles[name] then
roles[name].isonline = 0
broadcast(("%s 玩家已经掉线,请求呼叫他上线"):format(name))
end
skynet.retpack()
end
function CMD.online(client)
local name = client.name
if roles[name] then
roles[name] = client
roles[name].isonline = 1
broadcast(("%s 玩家已经上线"):format(name))
sendto(client.fd, ("范围变为 [%d - %d], 接下来由 %s 来操作"):format(game.down_limit, game.up_limit, game.turns[game.user_turn]))
end
skynet.retpack()
end
local function game_over()
for _, role in pairs(roles) do
if role.isonline == 0 then
skynet.call(redisd, "hset", "role:"..role.name, "isgame", 0)
else
skynet.send(role.agent, "lua", "game_over")
sendto(role.fd, "离开房间")
end
end
skynet.fork(skynet.exit)
end
function CMD.guess(name, val)
local role = assert(roles[name])
if game.turns[game.user_turn] ~= name then
sendto(role.fd, ("错误:还没轮到你操作,现在由 %s 来操作"):format(game.turns[game.user_turn]))
return
end
if not val or val < game.down_limit or val > game.up_limit then
sendto(role.fd, ("错误:请输入[%d - %d]之间的数字"):format(game.down_limit, game.up_limit))
return
end
game.user_turn = game.user_turn % 3+1
local next = game.turns[game.user_turn]
if val == game.random_value then
broadcast(("游戏结束,%s猜中了数字%d,输了"):format(name, val))
game_over()
return
end
if val < game.random_value then
game.down_limit = val+1
if game.down_limit == game.up_limit then
broadcast(("游戏结束,只剩下一个数字%d %s输了"):format(val+1, next))
game_over()
return
end
broadcast(("%s输入的数字太小,范围变为 [%d - %d], 接下来由 %s 来操作"):format(name, game.down_limit, game.up_limit, next))
return
end
if val > game.random_value then
game.up_limit = val-1
if game.down_limit == game.up_limit then
broadcast(("游戏结束,只剩下一个数字%d %s输了"):format(val-1, next))
game_over()
return
end
broadcast(("%s输入的数字太大,范围变为 [%d - %d], 接下来由 %s 来操作"):format(name, game.down_limit, game.up_limit, next))
return
end
end
skynet.start(function ()
math.randomseed(math.tointeger(skynet.time()*100), skynet.self()) --生成随机数
redisd = skynet.uniqueservice("redis") --进入redis服务
skynet.dispatch("lua", function (_, _, cmd, ...)
local func = CMD[cmd]
if not func then
return
end
func(...)
end)
end)
2.3、hall服务接口
hall服务类似《斗地主》的大厅。具有如下功能:
- ready:加入匹配队列。
- offline:用户掉线,需要从匹配队列移除用户。
local skynet = require "skynet"
local queue = require "skynet.queue"
local socket = require "skynet.socket"
local cs = queue()
local tinsert = table.insert
local tremove = table.remove
-- local tconcat = table.concat
local CMD = {}
local queues = {}
local resps = {}
local function sendto(clientfd, arg)
-- local ret = tconcat({"fd:", clientfd, arg}, " ")
-- socket.write(clientfd, ret .. "\n")
socket.write(clientfd, arg .. "\r\n")
end
function CMD.ready(client)
if not client or not client.name then
return skynet.retpack(false, "准备:非法操作")
end
if resps[client.name] then
return skynet.retpack(false, "重复准备")
end
tinsert(queues, 1, client)
resps[client.name] = skynet.response()
if #queues >= 3 then
local roomd = skynet.newservice("room")
local members = {tremove(queues), tremove(queues), tremove(queues)}
for i=1, 3 do
local cli = members[i]
resps[cli.name](true, roomd)
resps[cli.name] = nil
end
skynet.send(roomd, "lua", "start", members)
return
end
sendto(client.fd, "等待其他玩家加入")
end
function CMD.offline(name)
for pos, client in ipairs(queues) do
if client.name == name then
tremove(queues, pos)
break
end
end
if resps[name] then
resps[name](true, false, "退出")
resps[name] = nil
end
skynet.retpack()
end
skynet.start(function ()
-- 消息路由
skynet.dispatch("lua", function(session, address, cmd, ...)
local func = CMD[cmd]
if not func then
skynet.retpack({ok = false, msg = "非法操作"})
return
end
cs(func, ...) --开始执行
end)
end)
2.4、redis服务
用于保存玩家名称、密码、分数、游戏状态等信息。
开启redis服务,redis的键值对、数据结构操作在agent服务进行。
local skynet = require "skynet.manager"
local redis = require "skynet.db.redis"
-- 连接redis
skynet.start(function ()
local rds = redis.connect({
host = "127.0.0.1",
port = 6379,
db = 0,
-- auth = "123456",
})
skynet.dispatch("lua", function (session, address, cmd, ...)
skynet.retpack( rds[cmd:lower()](rds, ...) )
end)
end)
2.5、gate服务接口
gate服务是网关,主要处理网络连接,也是游戏的入口函数文件。具有如下功能:
- 绑定网络连接,推送信息到agent服务。
- 进入redis服务。
实现:
main.lua
local skynet = require "skynet"
local socket = require "skynet.socket"
local function accept(clientfd,addr)
skynet.newservice("agent",clientfd,addr)--创建一个agent服务(lua虚拟机)
end
skynet.start(function()
-- body
local listenfd=socket.listen("0.0.0.0",8888)
skynet.uniqueservice("redis")
skynet.uniqueservice("hall")
socket.start(listenfd,accept) --绑定listenfd到accept函数
end)
三、编写skynet的config文件
编写的game代码放在app目录下。
thread=4 --工作线程
logger=nil
harbor=0
start="main" -- 启动第一个服务
lua_path="./skynet/lualib/?.lua;".."./skynet/lualib/?/init.lua;".."./lualib/?.lua;"
luaservice="./skynet/service/?.lua;./app/?.lua"
lualoader="./skynet/lualib/loader.lua"
cpath="./skynet/cservice/?.so"
lua_cpath="./skynet/luaclib/?.so"
四、游戏演示
(1)服务端:
先启动 redis,然后启动 skynet。
redis-server redis.conf
./skynet/skynet config
(2)客户端:使用telnet。
telnet <IP> 8888
五、总结
- 这些服务接口还可以进一步进行优化,比如 agent 服务可以不要实时创建而是采用预先创建、如果某个服务相对简单,可以创建固定数量。
- 如果是万人同时在线游戏,agent、room 需要预先分配,长时间运行会让服务内存膨胀,同时也会造成 luagc 负担会加重。