实战-球球大作战-战斗流程
战斗流程
游戏玩法
战场中包含球和食物这两种对象,每个玩家控制一个球,其中黑色小圆代表食物。当玩家进入战斗时,场景中会添加一个代表玩家的小球,它出生在随机的位置上,
默认是很小尺寸(半径)。玩家可以控制球移动的速度,比如设置为(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(...)