护城河第2部分-制作多人地下城爬行者

这篇文章涵盖了使用城堡的Moat框架创建基本的多人冒险游戏。 源代码在此处可用,如果您已下载城堡应用程序,则可以在此处播放。 我将从更高层次上讨论代码,因此您应该阅读具体内容的源代码。

希望您已经阅读了上一篇文章,并且知道如何在共享的世界中建立动员球员。 现在,让我们添加一些敌人和物体,以便为玩家提供一些不错的旧PVE地牢动作。

首先,在serverInitWorld中 ,我从房间的网格中创建一个迷宫,每个房间都有一个或多个进入相邻房间的“门”。 如果有兴趣,请查看lib / maze_gen.lua中的迷宫算法。

 函数DGame:serverInitWorld() 
serverCreateMaze(10,10);
结束

serverCreateMaze函数调用mazegen算法,并将墙壁和敌人添加到场景中。 这是添加墙的示例函数:

 函数addWall(x,y,w,h,isDoor) 
如果(isDoor)返回结束;

DGame:spawn(GameEntities.Wall,
x,y,w,h
);
结束

这是一些将怪物添加到房间的代码。

  -以1个单位裁剪边界以忽略墙 
当地roomArea = {
x = x + 1.0,
y = y + 1.0,
w = roomSize-2.0,
h =房间大小-2.0
}

-产生一个怪物敌人并提供房间区域信息
DGame:spawn(GameEntities.Monster,centerX,centerY,1.0、1.0,{
健康= 5
searchArea = roomArea
});

在生成的最后一个参数中,我们可以添加任何其他元数据,例如health和searchArea ,这与怪物实体的可见性区域相对应,在该区域中它将搜索附近的玩家进行攻击。 在addHazards函数中,您可以看到更多向房间添加不同敌人类型的示例。

客户端和服务器上都会调用worldUpdate函数,以每个时钟滴答更新一次场景中的所有非玩家实体。 典型的实现为每种类型调用update函数:

  -更新非玩家实体 
函数DGame:worldUpdate(dt)
  -获取当前帧的刻度(时间索引) 
本地刻度= DGame:getTick();
DGame:eachEntityOfType(GameEntities.Spinner,updateSpinner,tick);
DGame:eachEntityOfType(GameEntities.Monster,updateMonster,tick);
DGame:eachEntityOfType(GameEntities.Eye,updateEye,tick);
DGame:eachEntityOfType(GameEntities.EyeBullet,updateEyeBullet);
DGame:eachEntityOfType(GameEntities.Chest,updateChest,tick);
DGame:eachEntityOfType(GameEntities.IceBullet,updateIceBullet,tick);

结束

这是updateMonster的实现

 函数updateMonster(monster,tick) 

如果(monster.freezeCounter和monster.freezeCounter> 0),则
monster.freezeCounter = monster.freezeCounter-1;
返回;
结束

本地最近播放器= findNearestPlayer(monster,monster.searchArea);

本地oldX,oldY = monster.x,monster.y;

如果(closestPlayer)然后
本地dx =最近播放器.x-monster.x;
本地dy =最近播放器.y-monster.y;
dx,dy = DGame.Utils.normalize(dx,dy);
本地x = monster.x + dx * GameConstants.MonsterSpeed;
局部y = monster.y + dy * GameConstants.MonsterSpeed;
DGame:moveEntity(monster,x,y);
结束

DGame:eachOverlapping(怪物,函数(实体)

如果(entity.type == GameEntities.Wall),则
monster.x,monster.y = oldX,oldY;
DGame:moveEntity(monter);
结束

结束);

结束

稍后我将介绍freezeCounter,这是为了允许玩家冻结带有冰咒的怪物。 该函数的其余部分调用findNearestPlayer,确定怪物向玩家移动的方向。 如果怪物撞到墙壁,它将移回其先前位置。 findNearestPlayer函数再次调用eachOverlapping ,但是这次使用搜索区域,定义了x,y,w和h的任何表,使播放器保持最小距离。

 函数findNearestPlayer(entity,searchArea) 
本地最近播放器=零;
本地最近播放器距离= 100;

DGame:eachOverlapping(searchArea,function(foundEntity)

如果(foundEntity.type == GameEntities.Player)然后
本地距离= DGame.Utils.distance(entity,foundEntity);
如果(距离<最近播放器距离)
最近播放器= foundEntity;
最近播放器距离=距离;
结束
结束

结束);

返回最近的播放器;
结束

使用护城河的好处之一是,我们可以像编写单人游戏一样编写代码-但是在幕后,发生了很多魔术,包括在一个平台上多次调用更新调用当客户端将其所有实体与服务器上的对等实体同步并重新应用帧以赶上本地价格时,就会发生倒带。 (这可能会使播放声音文件成为问题,因为它们可能会被先前的帧调用并一次播放太多。提供了playSound函数来帮助限制更新函数中调用的声音效果。)重要的是,这还意味着客户端可以做出预测关于与服务器不同的实体状态。 因此,服务器上的怪物可能会追逐玩家A,而客户端上的怪物会追逐玩家B,因为客户端总是总是领先于服务器一点,并且并不完全了解其他玩家的状态。

一个简单的解决方案是让一个实体的状态纯粹是时间的函数,从而使客户端可以轻松地完美预测其状态。 在这里,我有一个旋转式的敌人(想想来自Mario的幽灵轮),它会随着时间推移而绕圈移动。

 函数updateSpinner(spinner,tick) 

本地t =(tick * GameConstants.TickInterval)* spinner.spinDir;

局部x,y = math.sin(t + spinner.angle)* spinner.radius + spinner.centerX,math.cos(t + spinner.angle)* spinner.radius + spinner.centerY;

DGame:moveEntity(spinner,x,y);

结束

我们可以相信,客户总是能看到这样一个实体的正确状态。 无论世界状况如何,客户端与服务器之间的时间差异如何。

另一个常见的解决方案是让怪物提前决定在不久的将来将采取何种行动。 我们可以更改怪物的更新功能,使其看起来像这样:

  -每2秒决定一次(120分钟) 
如果(tick%120 == 0)然后

monster.nextMove = {
开始=刻度+ 30,
结束=刻度+ 90,
startX = monster.x,startY = monster.y,
endX = monster.x + dx,startY = monster.y + dy
}
 结束 
  -进行插值 
本地nextMove = monster.nextMove;
如果(nextMove并勾选> nextMove.start并勾选<nextMove.end),则
本地t =(tick-nextMove.start)/(nextMove.end-nextMove.start)
本地nx = Moat.Utils.lerp(nextMove.startX,nextMove.endX,t);
本地ny = Moat.Utils.lerp(nextMove.startY,nextMove.endY,t);
DGame:moveEntity(monster,nx,ny);
结束

大多数PVE游戏都使用类似上述的方法,以便服务器实体可以在提交承诺之前广播其意图,从而使所有客户端都能进入同一页面。

还有其他NPC类型,包括射出球的眼睛和产生宝藏以供玩家收集的胸部。 对于宝物箱,由于它随机地产生黄金,因此客户端根本无法预测,因此客户端应仅等待服务器将黄金实体发送过来。 因此,我们将防止在客户端上运行以下代码:

 函数updateChest(胸部,勾号) 
  -客户不应该更新胸部 
如果(DGame.isClient)
返回
结束

如果(tick%1000 == 0)然后

本地goldCount = 0;
DGame:eachOverlapping(chest.searchArea,function(entity)
如果(entity.type == GameEntities.Gold),则
goldCount = goldCount + 1;
结束
结束);

如果(goldCount <4)然后

本地x = Chest.searchArea.x +(chest.searchArea.w-1)* math.random();
局部y = Chest.searchArea.y +(chest.searchArea.h-1)* math.random();

DGame:spawn(GameEntities.Gold,x,y,1,1);
结束

结束
 结束 

接下来,让我们的玩家向敌人开火。 返回我们的playerUpdate函数的输入处理程序。 我们将添加以下几行

  -处理流冰 
如果(input.mx)然后
本地dx,dy = DGame.Utils.normalize(input.mx,input.my);
  DGame:spawn(GameEntities.IceBullet,player.x,player.y,1、1,{ 
dx = dx,
dy = dy
});
结束

在客户端上调用spawn时 ,在幕后会生成一个临时实体,该实体将持续到服务器及时赶上客户端为止。 此时,临时实体将被删除,并由服务器副本替换或完全删除,具体取决于服务器是否也确定生成有效。

updateIceBullet中,项目符号将使用为持续120个滴答声的FrozenCounter派生折磨一个实体。

 如果(hitEntity.type == GameEntities.Monster或hitEntity.type == GameEntities.Eye),则 
  hitEntity.freezeCounter = 120; 
hitOnce = true;
DGame:despawn(子弹);
 结束 

我要指出的最后一件事是lib / sprite.lua中称为Sprite的渲染库。 您可以将此库用于像素艺术风格的游戏。 使用Sprite可以轻松设置摄像机,将世界裁剪到客户端可见范围,在像素和世界坐标之间转换以及绘制平铺矩形。

  -绘制具有给定图像的实体 
Sprite.drawEntity(iceBulletEntity,Sprite.images.ice);