移动

2018/08/28 Mmo-Game

mmo游戏里移动的一些设计和思考

目录

腾讯学院移动教程

1:游戏里的三个世界

2:有哪些行为需要在三个世界之间进行同步?

1.移动

2.技能

3.聊天

3:移动(2d)实现方式

一:用移动向量来实现移动同步

客户端和服务器以相同的向量在奔跑,所以它们的位置始终是相同的。

什么时候客户端需要同步到服务端:

玩家发生转向、变速、停止

网络延迟高的时候会发生什么?

本地到服务器的延迟(也称为上行延迟):

例如本地玩家突然转向,由于发生延迟,转向的移动向量还没来得及发往服务器,服务器还是以上一次移动向量在运行,如上图服务器虚线部分代表在这个延迟时间内移动的距离,移动完虚线的距离后又收到客户端发送的向上移动向量。这个时候怎么办?

主要两个方案:

1.回溯(也就是服务器之间拖拽)

客户端发送向上移动向量时,还会带POS值,然后服务器判断服务器玩家当前的POS值是否相等,不对的话,服务器拉回,在进行向上移动向量。

2.加固定时长

其实他不是按照这个移动向量一直往前走,而是固定的只会让他走比如0.5秒,走0.5秒之后,如果没有接下来的包来,那么这个人就会停下来,这样就会保证我们两个的位置始终相同。但是有缺点,本地世界不管有没有发生转向都需要定时上报我的移动包。

服务器到第三方(下行延迟)

因为发生延迟,所以第三方同样是以之前的移动向量运行,同样上图第三方虚线代表延迟时间内走的路。问题来了,第三方是没有办法把这个人直接拉回到原来的Pos坐标的,否则看起来特别怪异!

那延迟收到移动包怎么解决:

1.运动补偿

第三方走到这里就走到这里了,也就是延迟时间内沿着虚线移动到当前位置不变,然后有个补偿过去,所以第三方时间的位置可能会和本地世界和服务器世界有些位置是不一样的,第三方世界采用加速方案,快速切换过去,在切换的过程中往往速度比实际快的多,其实是一个追赶的过程。

所以第三方时间的位置往往和服务器或者本地世界位置有一些偏差,我们有时候会发现,明明自己的客户端技能打中的那个人,但是对方却没有扣血,这就是由于第三方世界和服务器位置有些偏差,第三方世界需要追赶,所以路径有时候和它真实的路径不一样。

2.加固定时长

如果在移动过程中发生丢包了会怎么样?

本地发送服务器移动包丢失了,服务器收到下一个移动包,下一个移动包的位置跟当前位置相差太多的话,我们服务器往往采用直接拒绝当前的这个移动包,同时下发一个我服务器的移动包告诉本地我的位置在哪,本地会直接将人拖拽到当前位置。

这就是为什么,我们在游戏有的时候跑着跑着突然被拉回去,就是这个原因。

二:用路点表示法来实现移动同步,客户端和移动端都以相同的路径上的路点移动那么也能保证同步

如上图,其实还需要一个当前的点,然后才是x1,y1。。。

如何实现同步?

例如,当客户端走到x1和y1位置后,上行,发送包上行包到服务器,服务器收到一个上行包会加一个开始时间戳t1(也就是收到的时间记上去),然后,每隔一段时间(一般来说是0.5秒,间隔时间越短越精确)

计算行走记录,根据距离计算出在第几个折现上,按照比例计算出在该折现上的位置。

发生网络延迟时如何处理?

上行延迟

路点是有一个终点的,如果发生网络延迟,它不会像向量同步那样一直走的,路点同步服务器如果遇到终点那么玩家就会停下来。

如果本地世界客户端当前的点跟服务器的点相差太大,那么服务器会拒绝请求,进行服务器拖拽。

如果说相差不大,一般服务器会有一个容忍的距离,在这个容忍的距离中,服务器将人的位置直接射到客户端上行的位置,然后沿着这条路径再走。

下行延迟

采用运动补偿

总结

以上,2d中的游戏大多采用上面两种方式。另外,在实际上往往没有这么多折点,上传的路点一般就两点,起点和终点。

我们项目服务器, 网络延迟高时采用的是回溯(也就是服务器直接拖拽)。

4:移动(3d)实现方式

简单说一下3d地形:

3d场景在服务器上是一个一个的格子,大多数游戏,客户端是3d但服务器一般是2d
Grid就是将地图从二维平面上划分为一系类50*50的网格
体素-Voxel:

以grid为单位自下往上做CT扫描,如果射线碰到阻挡物称为体素,每一个体素是用XY还有一个层来定位的

每个体素包含它在第几层,还有它上沿和下沿的高度也就是这个物体的厚度

行走的需要判断Grid与Grid之间高差和容纳判断

轻功只需要校验容纳判定

我们mmo游戏里移动设计概要

1.简单设计思路

单点和点组

移动同步分为单点和点组两种协议,点组在客户端定点寻路用,单点为视野内无阻挡点击和摇杆移动用。

移动贴墙

对于移动贴墙时,按策划意图,不做移动速度分量,保持原速绕墙。

主客户端面向墙壁,其他客户端面向自身移动方向,与客户端沟通后,客户端表示主客户端的部分由客户端组自行处理。

服务器可认为没有人物移动时还需区分移动朝向和面向不同这事儿。

校验

摇杆移动时客户端预估一个大约2秒后的抵达位置发到服务器,包括阻挡也是客户端自行判定,服务器只做校验。

修正

移动时客户端发当前坐标和朝向到服务器,朝向直接信客户端(如需可以有改变朝向协议),坐标如与服务器差距较大,则拉回服务器坐标。

如偏差不大,信客户端,并服务器赋值为客户端坐标,并记录修正向量,每次修正都做向量叠加,当该向量距离超限时,判定为客户端非法,按此距离与单位移动速度计算出额定移动时间,将此客户端原地定身该时间。(定身,解定,为特殊协议,与战斗定身buff无关)

2.疾跑系统

1.只要玩家移动了,那么服务端就会尝试开启疾跑

// 单位移动组消息
message CSUnitMove{//20200
	required int32 index = 1; //单位序号(0:主角,1:宠物,2:傀儡,3:运镖车)
	required int32 nowX = 2; //当前x坐标
	required int32 nowY = 3; //当前y坐标
	required int32 nowZ = 4; //当前z坐标
	repeated int32 values = 5; //x,y,z坐标依次
	optional int32 dummyObjectId = 6; // 傀儡移动专用:傀儡的ObjectId
	optional bool isSyncY = 7; // 是否同步y
	optional int32 targetDistance = 8; // 目标距离 (若此距离小于配置距离,本次寻路不疾跑)
	optional bool nearBlock = 9; // 顶着墙行走(true为顶着墙行走,保持跑步状态)
}

消息体

/** 客户端移动到(组) */
private void doMoveToList(int nowX, int nowY, int nowZ, List<Integer> moveList, boolean syncY,boolean fallDown, int targetDistance,boolean isNearBlock)
{
    ...
    ...
    ...
    BPLog.BP_LOGIC.info("移动目标距离----:{}", targetDistance);
    if (targetDistance > 0 && targetDistance <= DictGameConfig.getFindPathNotGallopMaxDistance())
    {
        // 距离太近,疾跑关闭
        findPathCloseGallop = true;
    }
    else
    {
        if (findPathCloseGallop)
        {
            findPathCloseGallop = false;
        }
    }

    if(!isMoving())
    {
        //开始行走事件  尝试疾跑、移动是否打断技能和buff
        onStartMove(true);
    }
    ...
    ...
}

当移动目标距离targetDistance过近,那么关闭疾跑状态设置为true。

这里的targetDistance是直接点击地图寻路,才会有值。否则,通过轮盘单点移动客户端上发的targetDistance的值都为0。

onStartMove开始移动事件,调用尝试开启疾跑

/**
* 尝试开启疾跑计时
* @return
*/
public boolean tryPrepareGallop(boolean checkMoveingState)
{
    // 距离太近,疾跑关闭
    if (findPathCloseGallop)
    {
        return false;
    }

    ...
    ...

    //开启疾跑倒计时时间,tick会计算时间是否到达
    this.gallopSwitchRemian = DictGameConfig.getGallopBootMillis();

	return true;
}

尝试开启疾跑计时

@Override
public void tick(int delay)
{
    if(moveState == MoveStateEnum.STAND)
    {
        tickGallopOff(delay);
    }
    else if(moveState == MoveStateEnum.MOVING)
    {
        tickGallopStart(delay);
    }
}

MoveModule类下根据不同的状态进行tick疾跑关闭和开启检查,tick时间到达那么会进行疾跑的关闭或开启

/** 疾跑开启检查 */
private void tickGallopStart(int delay)
{
    ...
    ...
    gallopSwitchRemian -= delay;
    if(gallopSwitchRemian < 0)
    {
        // 区域规划 禁止疾跑区域中不触发疾跑
        if (getCharacter().getScene().getSceneGirdModule().isGallopForbiddenArea(getCharacter().getX(), getCharacter().getZ()))
        {
            gallopSwitchRemian = 0;
            return ;
        }

        //时间到了,开启疾跑
        startGallop();
    }
    ...
    ...
}

这里开启检查,除了判断时间是否到达,还会根据后台地图信息判断当前区域是否可以疾跑。

/**
* 开启疾跑
*/
public void startGallop()
{
    this.gallopSwitchRemian = -1;

    if(this.moveAttitudeEnum == MoveAttitudeEnum.GALLOP)
    {
        return;
    }

    //设置移动姿态为--疾跑
    changeMoveAttitude(MoveAttitudeEnum.GALLOP);
    //增加速度
    getCharacter().getAttributeLogic().addOneAttribute(AttributeTypeEnum.MOVE_SPEED_C, DictGameConfig.getGallopMoveSpeedCAdd());

}

开启疾跑需要设置移动姿态为疾跑,并且增加移动速度属性。关闭疾跑类型,设置其他移动姿态,减少速度。

2.客户端向服务端发送停止协议

// 单位停止消息
message CSUnitStop{//20202
	required int32 index = 1; //单位序号(0:主角,1:宠物,2:傀儡)
	required int32 nowX = 2; //当前x坐标
	required int32 nowY = 3; //当前y坐标
	required int32 nowZ = 4; //当前z坐标
	required int32 rotation = 5;//客户端朝向(0-360)
	optional int32 slideId = 6; // 滑行ID
	optional int32 dummyObjectId = 7; // 傀儡移动专用:傀儡的ObjectId
}

注意这里传滑行ID

/** 客户端停止 */
public void clientStopMove(int nowX,int nowY,int nowZ,int rotation,int slideId, boolean noticeSelf)
{
    ...
    ...
    // 关闭疾跑
    offGallop();

    // 调用滑行停止逻辑
    stopMoveWithSlide(slideId, noticeSelf);
    ...
    ...

}

关闭疾跑,同时调用滑行停止逻辑。

3.跳跃系统

1.开始跳跃

// 开始跳跃
message CSUnitJumpStart {
	required int32 rotation = 1; // 朝向,传0->360
	required int32 posX = 2; // 当前坐标x
	required int32 posY = 3; // 当前坐标y
	required int32 posZ = 4; // 当前坐标z
	required int32 jumpId = 5; // 跳跃id
	required int32 jumpSpeed = 6; // 跳跃速度
}

当前坐标,朝向,跳跃速度,跳跃id(停止时滑行用)

/**
* 开始跳跃
*/
@PacketHandler(PacketIDConst.CSUnitJumpStart)
public void handleCSUnitJumpStart(BPAOI.CSUnitJumpStart message, Actor actor)
{
    int result = actor.getMoveModule().clientJumpStart(actor, message.getPosX(), message.getPosY(), message.getPosZ(),
            message.getRotation(), message.getJumpId(), message.getJumpSpeed());
    BPAOI.SCUnitJumpStart.Builder builder = BPAOI.SCUnitJumpStart.newBuilder();
    builder.setResult(result);
    if (result < 0)
    {
        builder.setPosX(actor.getX()).setPosY(actor.getY()).setPosZ(actor.getZ()).setRotation(actor.getRotation());
    }
    actor.sendPacket(PacketIDConst.SCUnitJumpStart, builder.build());
}
```java

如果跳跃当中玩家没有转向那么前端通过这个协议发起跳跃。

/**
* 跳跃开始
*
* @param actor 玩家
* @param posX 坐标x
* @param posY 坐标y
* @param posZ 坐标z
* @param rotation 方向
* @param jumpId 跳跃id
* @param jumpSpeed 跳跃速度
* @return
*/
public int clientJumpStart(Actor actor, int posX, int posY, int posZ, int rotation, int jumpId, int jumpSpeed)
{
    ...
    ...
    jumpStartPosX = posX;
    jumpStartPosY = posY;
    jumpStartPosZ = posZ;
    //最大跳跃时间(服务端也会做个预防,增加最大跳跃时间,时间到了强制落地)
    jumpRemainTime = DictGameConfig.getJumpMaxTime();
    //当前时间
    jumpStartTime = getCharacter().getNowTickTime();

    // 起点 (x,y,z,rotation,jumpId,jumpSpeed,jumpRemain,jumpPart)
    // 添加到数组里
    addJumpPos(posX, posY, posZ, rotation, jumpId, jumpSpeed, 0, 0, 1);

    actor.setPos(posX, posY, posZ);
    actor.setRotation(rotation);

    //记录跳跃之前的移动状态 (之前是疾跑,跳跃落地后还是疾跑)
    jumpBeforeMoveState = moveState;
    //状态改为跳跃
    moveState = MoveStateEnum.JUMP;

    //广播通知周围玩家
    BPAOI.SCUnitJumpStartNotice.Builder builder = BPAOI.SCUnitJumpStartNotice.newBuilder();
    BPStruct.PBJumpStartData.Builder dataBuilder = getJumpStartBuilder();
    dataBuilder.setPosX(posX).setPosY(posY).setPosZ(posZ).setRotation(rotation).setJumpId(jumpId).setJumpSpeed(jumpSpeed);
    builder.setObjectID(actor.getObjectID()).setData(dataBuilder.build());
    actor.radioMessageWithoutSelf(PacketIDConst.SCUnitJumpStartNotice, builder.build());
    ...
    ...
}

如果跳跃当中玩家没有转向那么调用的是clientJumpStart()方法。

检验当前是否已经在跳跃中。服务端tick一个最大的跳跃时间,时间到达会强制落地。

跳跃点记录到数组里(队伍跟随用)。

记录跳跃之前的移动状态 (之前是疾跑,跳跃落地后还是疾跑)。

设置当前移动状态为跳跃状态。

服务端不用改变移动速度,因为跳跃是上下的速度,客户端发过来跳跃速度,只是广播下给周围玩家。

待做,这里需要做一个跳跃高度的校验和跳跃速度校验!!!!!!!!

2.跳跃落地

1.强制跳跃落地
private void tickJump(int delay)
{
    if (jumpRemainTime < 0)
    {
        return;
    }

    jumpRemainTime -= delay;

    if (jumpRemainTime < 0)
    {
        forceJumpFall();
    }
}

服务端维护的最大跳跃时间到达,强制进行跳跃落地。

2.客户端跳跃结束向服务端发起落地
/**
* 跳跃落地
*/
@PacketHandler(PacketIDConst.CSUnitJumpFall)
public void handleCSUnitJumpFall(BPAOI.CSUnitJumpFall message, Actor actor)
{
    actor.getMoveModule().clientJumpFall(actor, message.getPosX(), message.getPosY(), message.getPosZ(), message.getRotation(), false);
}

客户端跳跃结束向服务端发起落地

3.落地方法处理
/**
* 跳跃落地
*
* @param actor 玩家
* @param posX 坐标x
* @param posY 坐标y
* @param posZ 坐标z
* @param rotation 方向
* @param force 是否强制
*/
public void clientJumpFall(Actor actor, int posX, int posY, int posZ, int rotation, boolean force)
{
    ...
    ...
    // 校验位置
    boolean checkPos = actor.getScene().getSceneGirdModule().checkWalkableForClientMove(posX, posZ);
    if (!checkPos)
    {
        BPLog.BP_LOGIC.error("【移动】跳跃落地位置非法,posX:{} posY:{} posZ:{}", posX, posY, posZ);
        return;
    }

    // todo 这里直接信任客户端,可能会比较容易出bug,应该增加服务器的校验。例如:之前点和客户端发来的点做一次差值比较距离,
    // todo 距离过大服务器直接拉回 待做---可以考虑调用通用的方法checkClientNowPosition();

    actor.setPos(posX, posY, posZ);
    actor.setRotation(rotation);

    ...
    ...

    //设置回之前的移动状态,之前如果是疾跑那么跳跃结束后还是疾跑状态
    moveState = jumpBeforeMoveState;
	jumpBeforeMoveState = null;

    ...
    ...
    广播周围玩家
    ...
    ...
}

落地点坐标校验,状态重置为之前的移动状态。

3.跳跃转向(同步位置和方向)

// 跳跃转向(位置同步)
message CSUnitJumpRedirect{
	required int32 rotation = 1; // 朝向,传0->360
	required int32 posX = 2; // 当前的坐标x
	required int32 posY = 3; // 当前的坐标y
	required int32 posZ = 4; // 当前的坐标z
	required int32 part = 5; // 跳跃部分序号
	required int32 remain = 6; // 剩余时间
	required int32 jumpSpeed = 7; // 跳跃速度
}
/**
* 跳跃转向(同步位置和方向)
*/
@PacketHandler(PacketIDConst.CSUnitJumpRedirect)
public void handleCSUnitJumpRedirect(BPAOI.CSUnitJumpRedirect message, Actor actor)
{
    actor.getMoveModule().clientJumpRedirect(actor, message.getPosX(), message.getPosY(), message.getPosZ(), message.getRotation(), message.getPart(), message.getRemain(), message.getJumpSpeed());
}

如果玩家在跳跃的过程中,方向发生改变,调用该方法。

/**
* 跳跃转向(同步位置和方向)
*
* @param actor 玩家
* @param posX 坐标x
* @param posY 坐标y
* @param posZ 坐标z
* @param rotation 方向
* @param part 序号
* @param remain 剩余时间
* @param jumpSpeed 跳跃速度
*/
public void clientJumpRedirect(Actor actor, int posX, int posY, int posZ, int rotation, int part, int remain, int jumpSpeed)
{
    ...
    ...
    // 跳跃过程中不能超过最大高度
    if (posY - jumpStartPosY > DictGameConfig.getJumpHeightMax())
    {
        posY = jumpStartPosY + DictGameConfig.getJumpHeightMax();
    }

    // todo 这里直接信任客户端,可能会比较容易出bug,应该增加服务器的校验。例如:之前点和客户端发来的点做一次差值比较距离,
    // todo 距离过大服务器直接拉回 待做---可以考虑调用通用的方法checkClientNowPosition();
    ...
    ...
}

这里做了高度校验,还有其它一些待做的校验。

3.游泳系统

1.玩家进入水域

1.Actor下进入场景方法(进入场景就走包括断线重连)
@Override
public void onEnterScene(AbstractBPScene scene)
{
    ...
    ...
    getMoveModule().onEnterScene();
    ...
    ...
}
public void onEnterScene()
{
    // 检测开启 = 开启检测开关 && 不是单机副本
    isOpenCheck = GameConstant.openMoveCheck && !getCharacter().getScene().isClientControlAI();

    // 游泳区域
    if (character.getScene().getSceneGirdModule().getWaterAreaStatus(character.getX(), character.getZ()) == WaterAreaChecker.WATER_AREA_VALUE_DEEP)
    {
        onEnterSwimArea();
    }
}

检测是否进入游泳区域

/**
* 进入游泳区
*/
public void onEnterSwimArea()
{
    if (getCharacter().getObjectType() == ObjectTypeEnum.ACTOR)
    {
        Actor actor = (Actor) getCharacter();

        // 给玩家下马
        actor.getActorRideModule().rideBeDown();

        // 游泳检查变身是否解除
        actor.getTransfigureModule().onStartSwim();

        // 关闭疾跑
        offGallop();

        changeMoveAttitude(MoveAttitudeEnum.SWIM);

        // 切换至游泳速度
        character.getMoveModule().makeUseSpeed();
    }
}

进入游泳区,相关处理,改变移动姿态为游泳,切换至游泳速度。

这里调用了makeUseSpeed()就切换成游泳速度了,我们看下具体逻辑。

/**
* 处理移动速度相关变量
*/
public void makeUseSpeed()
{
    useMoveSpeed = getCharacter().getMoveAttribute();
}

这里的getMoveAttribute()方法最终调用是子类的方法

@Override
public int getMoveAttribute()
{
    if (master != null && master.getMoveModule().isSwimming())
    {
        return getAttributeLogic().getAttribute(AttributeTypeEnum.SWIM_MOVE_SPEED);
    }
    return getAttributeLogic().getAttribute(AttributeTypeEnum.MOVE_SPEED);
}

这里如果当前是游泳姿态,那么调用的是-SWIM_MOVE_SPEED(196)游泳移动速度

2.Actor下坐标改变方法onPosSeted(),注意坐标需要改变也就是玩家移动,地图传送不算移动。
@Override
protected void onPosSeted(int x, int y,int z)
{
    ...
    ...
    if (getMoveModule().isSwimming())
    {
        if (waterAreaStatus == WaterAreaChecker.WATER_AREA_VALUE_NONE)
        {
            getMoveModule().onLeaveSwimArea();
        }
    }
    ...
    ...
}

2.玩家离开水域

1.坐标改变事件回调(Actor下),地图传送也可能坐标改变
@Override
protected void onPosSeted(int x, int y,int z)
{
    ...
    ...
    if (getMoveModule().isSwimming())
    {
        if (waterAreaStatus == WaterAreaChecker.WATER_AREA_VALUE_NONE)
        {
            getMoveModule().onLeaveSwimArea();
        }
    }
    ...
    ...
}
/**
* 离开游泳区
*/
public void onLeaveSwimArea()
{
    if (getCharacter().getObjectType() == ObjectTypeEnum.ACTOR)
    {
        changeMoveAttitude(MoveAttitudeEnum.RUN);

        // 切换至陆地速度
        makeUseSpeed();

        offGallop();

        // 打断技能(水中和陆地的技能互斥)
        getCharacter().getSkillModule().breakSkill();

        tryPrepareGallop(false);
    }
}

设置姿态为行走,切换至陆地速度,打断技能…

这里我们要知道,玩家第一次进入场景(进入新场景或断线重连),只需要判断是否在水域即可,所以这里进场景不用再判断是否离开水域。

也就是只有在坐标改变方法里判断onPosSeted()是否离开水域。

3.Actor下onPosSeted()里进入和离开水域判断

@Override
protected void onPosSeted(int x, int y,int z)
{
    ...
    ...
    byte waterAreaStatus = scene.getSceneGirdModule().getWaterAreaStatus(x, z);

    if (getMoveModule().isSwimming())
    {
        if (waterAreaStatus == WaterAreaChecker.WATER_AREA_VALUE_NONE)
        {
            getMoveModule().onLeaveSwimArea();
        }
    }
    else
    {
        if (waterAreaStatus == WaterAreaChecker.WATER_AREA_VALUE_DEEP)
        {
            getMoveModule().onEnterSwimArea();
        }
    }
    ...
    ...
}

坐标改变,进入和离开水域判断

4.移动

1.是否开启移动检测

/**
    * 客户端移动
    * @param nowX
    * @param nowY
    * @param nowZ
    * @param moveList       路径
    * @param syncY          是否需要同步y
    * @param fallDown       是否在下落
    * @param targetDistance
    * @param isNearBlock    是否是顶墙行走
    */
public void clientMoveToList(int nowX, int nowY, int nowZ, List<Integer> moveList, boolean syncY,boolean fallDown, int targetDistance,boolean isNearBlock)
{
    // 采样抽查路径点阻挡计数
    if(isOpenCheck)
    {


        // 20190522 改成每次客户端的发移动都检测
        this.needSampleCheckPos = true;
    }

    doMoveToList(nowX,nowY,nowZ,moveList, syncY,fallDown, targetDistance,isNearBlock);
}

如果isOpenCheck等于true那么开启移动检测,什么时候设置为true?

public void onEnterScene()
{
    // 检测开启 = 开启检测开关 && 不是单机副本
    isOpenCheck = GameConstant.openMoveCheck && !getCharacter().getScene().isClientControlAI();

}

进入场景时判断当前场景是否默认开启检测和是否单机本

2.单点和点组

// 单位移动组消息
message CSUnitMove{//20200
	required int32 index = 1; //单位序号(0:主角,1:宠物,2:傀儡,3:运镖车)
	required int32 nowX = 2; //当前x坐标
	required int32 nowY = 3; //当前y坐标
	required int32 nowZ = 4; //当前z坐标
	repeated int32 values = 5; //x,y,z坐标依次
	optional int32 dummyObjectId = 6; // 傀儡移动专用:傀儡的ObjectId
	optional bool isSyncY = 7; // 是否同步y
	optional int32 targetDistance = 8; // 目标距离 (若此距离小于配置距离,本次寻路不疾跑)
	optional bool nearBlock = 9; // 顶着墙行走(true为顶着墙行走,保持跑步状态)
}
1.单点

视野内无阻挡点击和摇杆移动用,摇杆移动时客户端预估一个大约2秒后的抵达位置发到服务器,包括阻挡也是客户端自行判定,服务器只做校验。

2.点组

在客户端定点寻路用。客户端发上来的路径组点是怎么选取的,肯定是有筛选的,不然超过服务端配置的最大路径长度不合法。

猜想,如果距离超长,客户端应该会分多次点组请求上来?服务端配置的最大路径长度校验依据是什么?

3.普通移动(不是顶墙)

1.客户端发起移动
/** 客户端移动到(组) */
private void doMoveToList(int nowX, int nowY, int nowZ, List<Integer> moveList, boolean syncY,boolean fallDown, int targetDistance,boolean isNearBlock)
{
    ...
    ...
    // 路径点过长,则截取前边合法部分
    int movePathSize = moveList.size();
    if(movePathSize > CLT_MOVE_PATH_VALUE_MAX)
    {
        moveList = moveList.subList(0,CLT_MOVE_PATH_VALUE_MAX);
    }

    ...
     //当前点坐标距离 校验 大于容错值后拉回
    if (mdx + mdy > checkValue + distanceCompensate)
    {
        BPLog.BP_SCENE.warn("【移动拉回】 单位被拉回by距离(xz): [objectID] {} [checkValue] {} [xDiff] {} [zDiff] {} [serverNowPos] ({},{},{}) [clientNowPos] ({},{},{})",
                character.getObjectID(), checkValue, mdx, mdy, character.getX(), character.getY(), character.getZ(), nowX, nowY, nowZ);

        moveBack(false);

        return BPErrorCodeEnum.MOVE_OUT_OF_MAX_VALIDATE_RANGE;
    }


    if(needSampleCheckPos)
    {
        // 检验终点是否可走
        if(checkPositionWalkable(moveList.get(size - 3),moveList.get(size - 2),moveList.get(size - 1),fallDown) < 0)
        {
            return;
        }

        // 检验当前点是否可走
        if(checkClientNowPosition(nowX,nowY,nowZ,-1,fallDown) < 0)
        {
            return;
        }
    }
    ...

    moveArr.resetQuick();

    if (nowX != character.getX() || nowZ != character.getZ())
    {
        moveArr.add(nowX);
        moveArr.add(nowY);
        moveArr.add(nowZ);
    }

    for (int i = 0; i < size; i++)
    {
        int value = moveList.get(i);
        moveArr.add(value);
    }

    ....

    // 设置移动状态
    setMoveState(MoveStateEnum.MOVING);

    ...
    // 设置客户端停止协议检测状态
	setClientStopCheckState();

    ...
    //广播周围玩家
    sendClientMove(moveArr, syncY, false);
    ...

    // 触发格子移动
	toMoveGrid();
    ...
    ...
}

当前点坐标距离 校验 大于容错值后拉回, 这个也叫回溯(也就是服务器之间拖拽)。

后期上线,玩家当前移动时突然发生了转向,由于网络延迟导致客户端上发的包服务器接收时间延长,服务器继续按照之前的移动向量移动了一定距离,

客户端上发的路径点就和服务器当前的路径点不一致,那么服务器会采用回溯也就是直接拖拽然后下发告诉客户端。

检验终点是否可走, 检验当前点是否可走。并做距离校验,超过容忍距离服务端拉回。

客户端发送路径上来,服务端做一些校验,是否3的倍数,是否路径组长度过长,

然后判断客户端发上来的当前x、y、z坐标是否和服务器当前相等,如果不相等也装进服务端缓存的数组moveArr。

设置移动状态为移动,在tick的时候会根据状态tickMove。

设置客户端停止协议检测状态,客户发送停止移动用到。

广播周围玩家

// 单位移动组消息
message SCUnitMove{//20201
	required int32 objectId = 1; //实例ID
	repeated int32 values = 2; //x,z坐标依次
	optional bool nearBlock = 3; // 顶着墙行走(true为顶着墙行走,保持跑步状态)
}

触发格子移动,设置下一个移动格子坐标并计算相关单位速度向量,tickMove会使用到。

2.触发格子移动toMoveGrid()
/** 每步移动 */
private void toMoveGrid()
{
    ...
    else
    {
        gridMoveTo(moveArr.get(0),moveArr.get(1),moveArr.get(2));
    }
    ...
}
/** 移动到 */
private void gridMoveTo(int x,int y,int z)
{
    ...
    ...
    gridMoveTargetX = x;
    gridMoveTargetY = y;
    gridMoveTargetZ = z;

    //角度单位分向量,单位速度向量
    countUseMoveSpeedXYZ();

    //计算朝向调用 faceTo(x, z);
    getCharacter().onGriMoveTo(x,y,z);

    //推送格子移动,广播下一个移动的格子坐标
    sendGridMove(x,y,z);
    ...
    ...
}

设置下一个移动格子坐标,计算角度单位分向量,单位速度向量,计算朝向,广播格子移动。

//角度单位分向量,单位速度向量
public void countUseMoveSpeedXYZ()
{
    AbstractCharacter character=getCharacter();

    //当前值local
    float x = character.getFX();
    float y = character.getFY();
    float z = character.getFZ();

    //目标值local
    float mx = gridMoveTargetX;
    float my = gridMoveTargetY;
    float mz = gridMoveTargetZ;

    //差值
    float dx = mx - x;
    float dy = my - y;
    float dz = mz - z;

    //距离平方
    double mDisSq = dx * dx + dz * dz;
    //距离
    float mDis = (float) Math.sqrt(mDisSq);

    // 单位方向向量(向量长度 / 模 , 也就是两点之间的差值除以两点之前的距离)
    useMoveNormalVectorX = dx / mDis;
    useMoveNormalVectorY = dy / mDis;
    useMoveNormalVectorZ = dz / mDis;

    if(mDis > 0)
    {
        // 单位速度向量
        useMoveSpeedX = useMoveNormalVectorX * useMoveSpeedF;
        useMoveSpeedY = useMoveNormalVectorY * useMoveSpeedF;
        useMoveSpeedZ = useMoveNormalVectorZ * useMoveSpeedF;
    }
}

计算角度单位分向量,单位速度向量。

所以从这里我们可以看出,我们游戏采用移动向量来实现移动同步。

客户端和服务器以相同的向量在奔跑,所以它们的位置始终是相同的。

3.服务端tick移动状态-tickMove
/** 执行移动 */
private void tickMove(int delay)
{
    ...
    ...
    if (gridMoveTargetX < 0)
    {
        return;
    }

    if(moveArr.isEmpty())
    {
        if(!isNearBlockMove)
        {
            ....
        }
        return;
    }

    AbstractCharacter character = getCharacter();
    
    //当前的float坐标
    float sx = character.getFX();
    float sy = character.getFY();
    float sz = character.getFZ();

    //目标坐标
    float mx = gridMoveTargetX;
    float my = gridMoveTargetY;
    float mz = gridMoveTargetZ;

    //差值
    float dx = mx - sx;
    float dz = mz - sz;

    //距离平方
    float mDisSq = dx * dx + dz * dz;

    //时间*速度=移动距离
    float dis = delay * useMoveSpeedF;

    //移动距离加上服务器缓存的之前移动距离
    dis += saveDistance;

    //当前移动距离的平方
    float disSq = dis * dis;

    //是否到达
    boolean reach = false;

    float x;
    float y;
    float z;

    //到达(当前移动距离平方大于等于玩家距离目标格子距离平方)
    if (disSq >= mDisSq)
    {
        //玩家距离目标格子距离
        float mDis = (float) Math.sqrt(mDisSq);

        //缓存多走的移动距离,因为过了一个tick时间,玩家从开始移动的距离超出到目标点的距离。
        //我们缓存多行走的距离,下一次tick到下一个目标格子需要加上多行走的距离。
        saveDistance = dis - mDis;
        x = mx;
        y = my;
        z = mz;

        reach = true;
    }
    else
    {
        //未到达
        //当前生物移动到的x坐标 = 当前生物坐标 + x速度向量乘以时间
        x = sx + useMoveSpeedX * delay;
        y = sy + useMoveSpeedY * delay;
        z = sz + useMoveSpeedZ * delay;

        //上一个tick到达格子点时行走的距离已超出,所以要加上上一次多走的距离
        if (saveDistance > 0)
        {
            //距离 * 单位向量 = 移动的坐标点
            //加上当前生物移动到的x坐标
            x += saveDistance * useMoveNormalVectorX;
            y += saveDistance * useMoveNormalVectorY;
            z += saveDistance * useMoveNormalVectorZ;
        }

        //多走的距离设置为0
        saveDistance = 0;
    }
    //直接set当前坐标
    character.setPos(x, y, z);

    if (reach)
    {
        // 用完这个点,先从数组里将点删除
        if (moveArr.size() >= 3)
        {
            moveArr.remove(0, 3);
        }

        //每步移动,取下一个移动坐标点x,y,z
        //gridMoveTargetX不小于0,继续tick
        toMoveGrid();
    }

    ...
    ...
}

这里要特别注意saveDistance这个缓存变量,缓存多走的移动距离,因为过了一个tick时间,玩家从开始移动的距离超出到目标点的距离。

我们缓存多行走的距离,下一次tick到下一个目标格子需要加上多行走的距离。

其它的注释上写的很清楚了,看注释。

主要就是计算经过一个tick时间当前生物移动的距离是否到达目标点,不管到达不到打,都会通过character.setPos(x, y, z),更新当前

生物移动到的坐标点。如果到达目标点,将当前目标点先从数组里将点删除取下一个目标点继续tick执行。

4.character.setPos(x, y, z)本次改变坐标,现在y坐标也传入
/**
* 本次改变坐标是否设置脏标记
* 即是否发送坐标变换的广播
* @param x
* @param y
* @param z
* @param isSetDirty 坐标是否改变
*/
private void toSetPos(float x, float y, float z, boolean isSetDirty)
{
    ...
    ...

    //场景像素模块处理判断是否被挤走
    int result = scene.getScenePixelModule().setObjectPosition2(this, (int)x, (int)y, (int)z, isBePush());

    ...
    //服务端拉回之前的距离
    if (result == BPErrorCodeEnum.SCENE_PIXEL_BE_PUSH_BPOBJECT)
    {
        BPAOI.SCUnitSpecialMove.Builder builder = BPAOI.SCUnitSpecialMove.newBuilder();
        builder.setTargetX((int) this.x);
        builder.setTargetY((int) this.y);
        builder.setTargetZ((int) this.z);
        builder.setRotation(getRotation());
        builder.setMoveType(SpecialMoveType.TRANSPORT_PUSH.getIndex());
        builder.setObjectId(getObjectID());
        radioMessageWithoutSelf(PacketIDConst.SCUnitSpecialMove, builder.build());
    }

    ...
    this.x=x;
    this.y=y;
    this.z=z;

    //坐标改变事件
    onPosSeted((int)x,(int)y,(int)z);

    if (isSetDirty)
    {
        posDirty=true;
    }
    ...
    ...
}

判断要设置的坐标点是否有其他生物会挤走当前对象,如果挤走拉回之前的位置。

设置坐标。

设置isSetDirty坐标改变为true,tick状态执行视野更新。

5.tick坐标改变isSetDirty执行视野更新
public void refreshPos()
{
    if(posDirty)
    {
        posDirty=false;
    
        AbstractBPScene scene=getScene();
    
        if(scene!=null)
        {
            scene.getAOIModule().unitUpdate(this);
        }
    }
}

执行视野更新

public void unitUpdate(BPObject unit,int x,int y)
{
    //当前y坐标其实传入的是z坐标
    if(openCheck)
    {
        if(!units.contains(unit))
        {
            throwError("单位不存在:"+unit.getObjectID());
            return;
        }
    }

    //获取当前坐标所在的灯塔位置(第几个)(九宫格内,每个格子有一个灯塔)
    int tx = x / towerSize;
    int ty = y / towerSize;

    if (tx < 0)
    {
        tx = 0;
    }
    if (tx > tXMax)
    {
        tx = tXMax;
    }

    if (ty < 0)
    {
        ty = 0;
    }
    if (ty > tYMax)
    {
        ty = tYMax;
    }

    //原先所在的灯塔位置(九宫格内,每个格子有一个灯塔)
    int otx = unit.getTowerX();
    int oty = unit.getTowerY();

    //需要(现在所处的灯塔和之前所处的灯塔位置不一样,执行视野更新)
    if (tx != otx || ty != oty)
    {
        toUnitUpdate(unit,otx,oty,tx,ty);
    }
}

现在所处的灯塔和之前所处的灯塔位置不一样,执行视野更新。

4.顶墙移动

主客户端面向墙壁,其他客户端面向自身移动方向,与客户端沟通后,客户端表示主客户端的部分由客户端组自行处理。

服务器可认为没有人物移动时还需区分移动朝向和面向不同这事儿。

客户端发上来的移动目标值和生物当前值相等,所以一些速度分量计算为0。

/** 客户端移动到(组) */
private void doMoveToList(int nowX, int nowY, int nowZ, List<Integer> moveList, boolean syncY,boolean fallDown, int targetDistance,boolean isNearBlock)
{
    ...
    // 设置顶墙移动状态(一定要在设置移动状态后再设置)
	setNearBlockFlag(isNearBlock);
    ...
    ...
}
/**
* 设置 顶墙移动状态
* @param nearBlockFlag
*/
private void setNearBlockFlag(boolean nearBlockFlag)
{
    if(nearBlockFlag)
    {
        this.isNearBlockMove = true;
        this.nearBlockMoveRemain = NEAR_BLOCK_REMAIN_ALL;
    }
    else
    {
        this.isNearBlockMove = false;
        this.nearBlockMoveRemain = -1;
    }
}

设置顶墙移动状态。

private void tickNearBlock(int delay)
{
    if(!isNearBlockMove)
    {
        return;
    }

    this.nearBlockMoveRemain -= delay;

    if(this.nearBlockMoveRemain < 0)
    {
        isNearBlockMove = false;

        if(isMoving())
        {
            // 停止移动
            onGridMoveFinish();
        }
    }
}

tick顶墙状态,其实就是客户端直面墙壁,所以服务端来看就是原地不动,停止移动。

5.移动贴墙时

对于移动贴墙时,按策划意图,不做移动速度分量,保持原速绕墙即可。

就是绕着墙壁行走。

6.停止移动

// 单位停止消息
message CSUnitStop{//20202
	required int32 index = 1; //单位序号(0:主角,1:宠物,2:傀儡)
	required int32 nowX = 2; //当前x坐标
	required int32 nowY = 3; //当前y坐标
	required int32 nowZ = 4; //当前z坐标
	required int32 rotation = 5;//客户端朝向(0-360)
	optional int32 slideId = 6; // 滑行ID
	optional int32 dummyObjectId = 7; // 傀儡移动专用:傀儡的ObjectId
}

客户端主要发送当前坐标,朝向,滑行id到服务端。

/** 客户端停止 */
public void clientStopMove(int nowX,int nowY,int nowZ,int rotation,int slideId, boolean noticeSelf)
{
    ...
    ...
    校验点是否合法
    ...
    ...
    // 清空路径补偿
    saveDistance = 0;

    ...
    // 设置位置和朝向
	character.setPos(nowX,nowY,nowZ);
	character.setRotation(MapUtils.rotationCut(rotation));

    ...
    // 调用滑行停止逻辑,并设置状态为stop
	stopMoveWithSlide(slideId, noticeSelf);
    ...
}

基本上相似,主要是直接设置坐标点,朝向,停止状态。

然后tick停止移动状态。

Search

    Table of Contents