Skynet游戏开发实战:打造交互式猜数字游戏

一、游戏简介

猜数字游戏目的是掌握 actor 模型开发思路。

规则:

  1. 满三个人开始游戏,游戏开始后不能退出,直到游戏结束。
  2. 系统会随机生成1-100 之间的数字,玩家依次猜规则内的数字。
  3. 玩家猜测正确,那么该玩家就输了;如果猜测错误,游戏继续。
  4. 直到有玩家猜测成功,游戏结束。
server
TCP
TCP
TCP
hall
agent
gate
agent
agent
room
client
client
client

游戏设计时,首先是简单可用,然后持续优化,而不是一开始就过度优化。

二、接口设计和实现

skynet 中,从 actor 底层看是通过消息进行通信;从 actor 应用层看是通过 api 来进行通信。

遵循接口隔离原则:

  1. 不应该强迫客户依赖于他们不用的方法。
  2. 从安全封装的角度出发,只暴露客户需要的接口。
  3. 服务间不依赖彼此的实现。

2.1、agent服务接口

agent服务主要是用户。具有如下功能:

  1. login:实现登录功能;断线重连。
  2. ready:准备,转发到大厅,加入匹配队列。
  3. guess:猜测数字,转发到房间。
  4. help:列出所有操作说明。
  5. 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服务是一个游戏空间。具有如下功能:

  1. start:初始化房间。
  2. online:用户上线,如果用户在游戏中,告知游戏进度。
  3. offline:用户下线,通知房间内其他用户。
  4. 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服务类似《斗地主》的大厅。具有如下功能:

  1. ready:加入匹配队列。
  2. 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服务是网关,主要处理网络连接,也是游戏的入口函数文件。具有如下功能:

  1. 绑定网络连接,推送信息到agent服务。
  2. 进入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

guess_game

五、总结

  1. 这些服务接口还可以进一步进行优化,比如 agent 服务可以不要实时创建而是采用预先创建、如果某个服务相对简单,可以创建固定数量。
  2. 如果是万人同时在线游戏,agent、room 需要预先分配,长时间运行会让服务内存膨胀,同时也会造成 luagc 负担会加重。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Lion Long

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值