实战-球球大作战-战斗流程

战斗流程

游戏玩法

战场中包含球和食物这两种对象,每个玩家控制一个球,其中黑色小圆代表食物。当玩家进入战斗时,场景中会添加一个代表玩家的小球,它出生在随机的位置上,

默认是很小尺寸(半径)。玩家可以控制球移动的速度,比如设置为(1,0)会一直向右走,直到设置为(0,0)才停下来。场景中会随机生成一些食物,遍布各处,当小球碰(吃)到食物时,

食物消失,球的尺寸增长。

 

协议

1、进入战场

当玩家点击“开始比赛”按钮时,客户端发送enter协议,服务端会做出能不能进入战斗的判定,并且将agent和某个scene关联起来。

 

如果成功进入战场,服务端会回应成功信息,且向同战场的其他玩家广播有人进入的消息。广播的消息包含三个参数,分别是刚进入的玩家id、它的坐标和尺寸。

如果进入失败,例如玩家已经在战斗中,会回应失败信息,如“您已在战场中,不能重复进入"。

 

2、战场信息

进入战场后,客户端需要显示战场中的球和食物。服务端会发送balist和foodlist协议。以ballist为例,它依次包含了各个球的信息,每个球包含4个参数,分别是玩家id、x坐标、y坐标和尺寸。服务端生成食物时,会给每个食物一个一id。在食物信息协议

foodlist中每个食物会包含id、x坐标、y坐标这3个参数。

 

3、生成食物

战场会随机生成一些食物。服务端会广播addfood协议,此协议包含新生成食物的id、x坐标、y坐标。

 

4、移动

当需要改变移动方向时,客户端会发送shift协议,设置小球的x方向速度和y方向速度。所有游戏逻辑由服务端判定。

每当小球的位置发生变化时(每隔一小段时间),服务端就会广播move协议(见图3-40),更新小球的坐标。

move协议是发送频率最高的协议,假设服务端每0.2秒更新一次小球位置,战场上有10个小球,那么每个客户端每秒将收到50条move协议。

 

说明:每个客户端每秒将收到50条move协议,这个频率非常高,但有不少
优化方法,比如可以将多个小球的位置信息合并成一条协议或使用A01算法做优化。

 

5、吃食物

当小球吞下食物时,服务端会广播eat协议,此协议的参数包含玩家id、被吃掉的食物id和玩家的新尺寸。

6、离开

当玩家掉线时(离开战场),服务端会广播leave协议,告诉战场中每一位玩家,有人离开了,

 

完整的《球球大作战》还包含玩家间的碰撞、球分裂、排行榜等功能。这些功能不算复杂,写法和“生成食物”“吞下小球”很相似。

 

代码实现

场景服务会处理绝大部分的游戏逻辑。新建空服务service/scene/init.lua(代码略),开始编写相关代码。

1、Ball类

场景中包含小球和食物这两种对象,先看看小球的实现。定义如代码所示的balls表和ball类,balls表会以玩家id为索引,保存战场中各个小球的信息。

小球与玩家关联,它会记录玩家id(playerid)、代理服务(agent)的id、代理服务所在的节点(node);每个球都包含x坐标、y坐标和尺寸这三种属性(x,ysize),以及移动速度speedx和speedy。

玩家进入战场会新建ball对象,并为其赋予随机的坐标。

service/scene/init.lua

local balls = {} -- [player_id] = ball

-- 球
function ball()
    local m = {
        player_id = nil,
        node = nil,
        agent = nil,
        x = math.random(0, 100),
        y = math.random(0, 100),
        size = 2,
        speedx = 0,
        speedy = 0,
    }
    return m
end

展示了ball类一些属性的含义。

定义如代码所示的辅助方法balllist msg,它会收集战场中的所有小球并构建ballist协议。

service/scene/init.lua

local function balllist_msg()
    local msg = {"balllist"}
    for i,v in pairs(balls) do
        table.insert(msg, v.playerid)
        table.insert(msg, v.x)
        table.insert(msg, v.playerid)
        table.insert(msg, v.y)
        table.insert(msg, v.size)
    end
    return msg
end

 

2、Foot类

食物类food包含id、x坐标、y坐标这三种属性;表foods会以食物id为索引,保存战场中各食物的信息。

为给食物赋予唯一id,定义变量food maxid,其初始值为0,每创建一个食物,给food maxid加1。

变量food count用于记录战场中食物数量,以限制食物总量。

service/scene/init.lua

local foods = {} -- [id] = food
local food_maxid = 0
local food_count = 0

-- 食物
function food()
    local m = {
        id = nil,
        x = math.random(0, 100),
        y = math.random(0, 100)
    }
    return m
end

定义如代码所示的辅助方法foodlist msg,它会收集战场中的所有食物,并构建foodlist协议。

service/scene/init.lua

-- 食物列表
local function foodlist_msg()
    local msg = {"foodlist"}
    for i,v in parirs(foods) do
        table.insert(msg, v.id)
        table.insert(msg, v.x)
        table.insert(msg, v.y)
    end
    return msg
end

 

进入战斗

下图展示了进入战斗的流程,agent收到enter协议(开始比赛,图中阶阶段1)后,随机选择一个scene服务,给它发送enter消息(稍后实现,见图中阶段2)。

scene和客户端的所有交互,都以agent作为中介。

现在看看scene服务的内容,定义如代码所示的enter远程调用,参数playerid指玩家id;参数agent和node指玩家对应的代理服务id及其所在的节点;参数source是消息的发送方,它等同于agent.
这段代码实现了如下几项功能:
1)判定能否进入战斗场景:如果玩家已在战场内,不可再次进入,返回失败信息(false)。

2)创建ball对象:创建玩家对应的ball对象,并给各个属性赋值。

3)向战场内的其他玩家广播enter协议,说明新的玩家到来(broadcast方法稍后实现)

4)将ball对象存入balls表

5)向玩家回应成功进入的信息(enter协议),此处使用's.send(...,"send"....,”向agent发送消息,agent相关处理会稍后实现:

6)向玩家发送战场信息(涉及ballist协议和foodlist协议)

service/scene/init.lua

-- 进入
s.resp.enter = function(source, player_id, node, agent)
    if balls[player_id] then
        return false
    end
    local b = ball()
    b.player_id = player_id
    b.node = node
    b.agent = agent
    balls[player_id] = b

    -- 广播
    local entermsg = {"enter", player_id, b.x, b.y, b.size}
    broadcast(entermsg)
    -- 回应
    local ret_msg = {"enter", 0, "进入成功"}
    -- 发战场信息
    s.send(b.node, b.agent, "send", balllist_msg())
    s.send(b.node, b.agent, "send", foodlist_msg())
    return true
end

定义如代码所示的辅助方法broadcast,用于广播协议。它会遍历balls表,把消息发送给每个玩家。

service/scene/init.lua

-- 广播消息
function broadcast(msg)
    for i,v in pairs(balls) do
        s.send(v.node, v.agent, "send", msg)
    end
end

 

退出战斗

当玩家掉线时,agent会远程调用scene服务的leave方法(稍后实现)。实现如代码所示的leave远程调用,它会删除与玩家对应的小球(设置balls列表对应id为空),并广播leave协议。

service/scene/init.lua

-- 退出
s.resp.leave = function(source, player_id)
    if not balls[player_id] then
        return false
    end
    balls[player_id] = nil

    local leave_msg = {"leave", player_id}
    broadcast(leave_msg)
end

 

操作移动

当玩家要改变移动方向时,客户端会发送shift协议,经由agent转发(稍后实现),调用scene的shift方法。实现如代码的shift远程调用,它根据参数playerid找到与玩家对应的小球,并设置它的速度。

service/scene/init.lua

-- 改变速度
s.resp.shift = function(source, player_id, x, y)
    local b = balls[player_id]
    if not b then
        return false
    end 
    b.speedx = x
    b.speedy = y
end

 

主循环

《球球大作战》是一款服务端运算的游戏,一般会使用主循环程序结构,让服务端处理战斗逻辑。

如图所示,图中的balls和foods代表服务端的状态,在循环中执行“食物生成”“位置更新”和“碰撞检测”等功能,从而改变服务端的状态。

scene启动后,会开启定时器,每隔一段时间(0.2秒)执行一次循环,在循环中会处理食物生成、位置更新等功能。

定义如代码所示的update方法,通过某种机制(稍后实现)让它每隔一小段时间被调用一次。

参数frame代表当前的帧数,每一次执行update,frame加1。

其中的food update、move update和eat update分别实现“食物生成”“位置更新”和“碰撞检测”的功能,稍后实现。

service/scene/init.lua

-- 更新逻辑
function update()
    food_update()
    move_update()
    eat_update()
    -- 碰撞略
    -- 分裂略
end

现在思考一个问题,怎样开启稳定的定时器?可以开启一个死循环协程,协程中调用update,最后用skynet.sleep让它等待一小段时间。

定义如代码所示的服务初始化方法init,它会调用skynet.fork开启一个协程,协程的代码位于匿名函数中。

每帧等待的时间=运行的帧数*间隔帧-(结束时间-开始时间)

service/scene/init.lua

-- 初始化
s.init = function()
    skynet.fork(function()
        -- 保持帧率执行
        local stime = skynet.now()
        local frame = 0
        while true do
            frame = frame + 1
            local ok, err = pcall(update, frame)
            if not ok then
                skynet.error(err)
            end
            local etime = skynet.now()
            local wait_time = frame * 20 - (etime - stime)
            -- 补帧
            if wait_time <= 0 then
                wait_time = 2
            end
            skynet.sleep(wait_time)
        end
    end)
end

pcall是为安全调用update而引入的。

waittime代表每次循环后需等待的时间。由于程序有可能卡住,我们很难保证每隔0.2秒调用一次update”是精确的。

update方法也需要一定的执行时间,等待时间waittime的实际值应为0.2减去执行时间,见图的左侧,图中update前的竖直黑线代表update的执行时间。

若某次执行时间超过间隔(如图的右侧第0.2秒执行的update),则程序需要加快执行,只能给很短的间隔时间。

使得运行较长时间后,最终会在第N秒执行Nx5次update。

 

移动逻辑

服务端要处理的第一项业务功能是球的移动,现在实现move_update方法。由于主循环会每隔0.2秒调用一次move update,

因此它只需遍历场景中的所有球,根据“路程=速度x时间”计算出每个球的新位置,再广播move协议通知所有客户端即可。

service/scene/init.lua

-- 移动更新
function move_update()
    for i,v in pairs(balls) do
        v.x = v.speedx * 0.2
        v.y = v.speedy * 0.2
        if v.speedx ~= 0 or v.speedy ~= 0 then
            local msg = {"move", v.player_id, v.x, v.y}
            broadcast(msg)
        end
    end
end

 

生成食物

服务端会每隔一小段时间放置一个新食物,定义如代码所示的food update方法来实现该功能,这段代码做了如下几件事情。

  • 判断食物总量:场景中最多能有50个食物,多了就不再生成。
  • 控制生成时间:计算一个0到100的随机数,只有大于等于98才往下执行,即往下执行的概率是1/50。由于主循环每0.2秒调用一次food update,因此平均下来每10秒会生成一个食物。
  • 生成食物:创建food类型对象f,把它添加到foods列表中,并广播addfood协议。生成食物时,会更新食物总量food count和食物最大标识food maxid。

service/scene/init.lua

-- 食物更新
function food_update()
    if food_count > 50 then
        return
    end

    if math.random(1, 100) < 98 then
        return
    end

    food_maxid = food_maxid + 1
    food_count = food_count + 1
    local f = food()
    f.id = food_maxid
    foods[f.id] = f

    local msg = {"addfood", f.id, f.x, f.y}
    broadcast(msg)
end

 

吞下食物

编写吃食物的eat update方法,如代码所示,它会遍历所有的球和食物,并根据两点间距离公式判断小球是否和食物发生了碰撞。

如果发生碰撞,即视为吞下食物,服务端会广播eat协议,并让食物消失(设置foods对应值为nil)

service/scene/init.lua

-- 吃食物
function eat_update()
    for pid, b in pairs(balls) do
        for fid, f in pairs(foods) do
            if (b.x-f.x)^2 + (b.y-f.y)^2 < b.size^2 then
                b.size = b.size + 1
                food_count = food_count - 1
                local msg = {"eat", b.player_id, fid, b.size}
                broadcast(msg)
                foods[fid] = nil
            end
        end
    end
end

代码中变量名的含义如下:

  • pid:即playerid,指遍历到的小球对应的玩家id。
  • b:遍历到的ball对象。
  • fid:遍历到的食物id。
  • f:遍历到的food对象。

说明:本章的场景服务代码更多的是为了演示如何使用框架,没有很多性
能考究。比如在代码中,双重嵌套for循环的计算量较大。在实际项目中,往
往会使用一些简化的计算方法(后面的章节会有简单的描述)。

以上,完成了场景服务的所有代码。

 

完整代码:

service/scene/init.lua

local skynet = require "skynet"
local s = require "service"

skynet.error("scene-init.lua")

local balls = {} -- [player_id] = ball
local foods = {} -- [id] = food
local food_maxid = 0
local food_count = 0

-- 球
function ball()
    local m = {
        player_id = nil,
        node = nil,
        agent = nil,
        x = math.random(0, 100),
        y = math.random(0, 100),
        size = 2,
        speedx = 0,
        speedy = 0,
    }
    return m
end

-- 球列表
local function balllist_msg()
    local msg = {"balllist"}
    for i,v in pairs(balls) do
        table.insert(msg, v.playerid)
        table.insert(msg, v.x)
        table.insert(msg, v.playerid)
        table.insert(msg, v.y)
        table.insert(msg, v.size)
    end
    return msg
end

-- 食物
function food()
    local m = {
        id = nil,
        x = math.random(0, 100),
        y = math.random(0, 100)
    }
    return m
end

-- 食物列表
local function foodlist_msg()
    local msg = {"foodlist"}
    for i,v in pairs(foods) do
        table.insert(msg, v.id)
        table.insert(msg, v.x)
        table.insert(msg, v.y)
    end
    return msg
end

-- 广播消息
function broadcast(msg)
    for i,v in pairs(balls) do
        s.send(v.node, v.agent, "send", msg)
    end
end

-- 初始化
s.init = function()
    skynet.error("scene init.lua init")
    skynet.fork(function()
        -- 保持帧率执行
        local stime = skynet.now()
        local frame = 0
        while true do
            frame = frame + 1
            local ok, err = pcall(update, frame)
            if not ok then
                skynet.error(err)
            end
            local etime = skynet.now()
            local wait_time = frame * 20 - (etime - stime)
            -- 补帧
            if wait_time <= 0 then
                wait_time = 2
            end
            skynet.sleep(wait_time)
        end
    end)
end

-- 进入
s.resp.enter = function(source, player_id, node, agent)
    skynet.error("scene-enter player="..player_id.." node="..node.." agent="..agent)
    if balls[player_id] then
        skynet.error("scene-enter player="..player_id.." is fail")
        return false
    end
    local b = ball()
    b.player_id = player_id
    b.node = node
    b.agent = agent
    balls[player_id] = b

    -- 广播
    local enter_msg = {"enter", player_id, b.x, b.y, b.size}
    broadcast(enter_msg)
    -- 回应
    local ret_msg = {"enter", 0, "进入成功"}
    -- 发战场信息
    s.send(b.node, b.agent, "send", balllist_msg())
    s.send(b.node, b.agent, "send", foodlist_msg())
    return true
end

-- 退出
s.resp.leave = function(source, player_id)
    if not balls[player_id] then
        return false
    end
    balls[player_id] = nil

    local leave_msg = {"leave", player_id}
    broadcast(leave_msg)
end

-- 改变速度
s.resp.shift = function(source, player_id, x, y)
    local b = balls[player_id]
    if not b then
        return false
    end 
    b.speedx = x
    b.speedy = y
end

-- 食物更新
function food_update()
    if food_count > 50 then
        return
    end

    if math.random(1, 100) < 98 then
        return
    end

    food_maxid = food_maxid + 1
    food_count = food_count + 1
    local f = food()
    f.id = food_maxid
    foods[f.id] = f

    local msg = {"addfood", f.id, f.x, f.y}
    broadcast(msg)
end

-- 移动更新
function move_update()
    for i,v in pairs(balls) do
        v.x = v.speedx * 0.2
        v.y = v.speedy * 0.2
        if v.speedx ~= 0 or v.speedy ~= 0 then
            local msg = {"move", v.player_id, v.x, v.y}
            broadcast(msg)
        end
    end
end

-- 吃食物
function eat_update()
    for pid, b in pairs(balls) do
        for fid, f in pairs(foods) do
            if (b.x-f.x)^2 + (b.y-f.y)^2 < b.size^2 then
                b.size = b.size + 1
                food_count = food_count - 1
                local msg = {"eat", b.player_id, fid, b.size}
                broadcast(msg)
                foods[fid] = nil
            end
        end
    end
end

-- 更新逻辑
function update()
    food_update()
    move_update()
    eat_update()
    -- 碰撞略
    -- 分裂略
end

s.start(...)

 

posted @ 2024-03-29 11:44  独一无二~  阅读(181)  评论(0)    收藏  举报