第十一章:L2JMobius学习 – 角色移动讲解

在上一个章节中,我们已经完成了角色进入游戏世界的讲解。接下来,我们讲解角色是如何移动的?网络游戏和单机游戏很大的区别在于,网络游戏的很多逻辑都是在服务器端实现的,然后将逻辑处理结果发送给客户端就可以了。对于像角色移动这样的简单逻辑,同样也需要在服务器端实现。虽然客户端也有自己的移动代码,但是需要跟服务器端保持一致。毕竟,这个涉及到“移动速度”,“路径查找”等等问题。传统的PC网络,都是鼠标点击选择移动目标点,然后角色自动(AI)移动到目标点。这个过程的实现,就是路程=速度*时间。客户端和服务器端都会按照这个公式进行计算,但是为了防止客户端作弊,游戏角色在什么时间点移动到了那里,是由服务器端决定,然后同步给客户端的。接下来,我们具体讲解这个过程的代码实现。首先是,鼠标点击目标点,向服务器端发送MoveBackwardToLocation数据包,这个数据包能够读取的数据如下所示

// 目标点坐标
private int _targetX;
private int _targetY;
private int _targetZ;
// 当前点坐标
private int _originX;
private int _originY;
private int _originZ;
// 是否按键移动
private int _movementMode;

接下来,我们继续查看run方法,里面有很多不能移动的检查,我们不详细介绍了。

final Player player = client.getPlayer();
player.getAI().setIntention(CtrlIntention.AI_INTENTION_MOVE_TO, new Location(_targetX, _targetY, _targetZ));

上面的代码才是重点。我们发现了代码调用了Player类的getAI方法,也就是获取到PlayerAI对象,我们上一个章节介绍过,它的继承关系如下:

PlayerAI -> CreatureAI -> AbstractAI -> Ctrl

虽然调用了PlayerAI的setIntention方法,但是这个方法实际是在父类AbstractAI里面的,参数为一个枚举类型(名称来看就是移动的意思)和移动到的目标点坐标。在AbstractAI类的setIntention方法中,我们会根据“不同的行为”执行不同的逻辑方法。这个“不同的行为”类似于Unity的状态机,它是一个枚举类型,例如:CtrlIntention.AI_INTENTION_MOVE_TO,以下是CtrlIntention枚举中定义的“行为状态”,如下所示

// 休闲状态
AI_INTENTION_IDLE,
// 巡逻状态
AI_INTENTION_ACTIVE,
// 休息状态
AI_INTENTION_REST,
// 普通攻击状态
AI_INTENTION_ATTACK,
// 技能攻击状态
AI_INTENTION_CAST,
// 移动状态
AI_INTENTION_MOVE_TO,
// 跟随状态
AI_INTENTION_FOLLOW,
// 拾取物品状态
AI_INTENTION_PICK_UP,
// 移动后交互(NPC)状态
AI_INTENTION_INTERACT,
// 船上移动
AI_INTENTION_MOVE_TO_IN_A_BOAT

熟悉完这些“行为状态”之后,我们就继续查看当指定“行为状态”是CtrlIntention.AI_INTENTION_MOVE_TO 的时候,如何处理呢?

onIntentionMoveTo((Location) arg0);
break;

继续调用onIntentionMoveTo方法,参数为目标点。该方法位于CreatureAI类中。这个CreatureAI类封装的内容非常多,重点的代码如下

changeIntention(AI_INTENTION_MOVE_TO, pos, null);
clientStopAutoAttack();

moveTo(pos.getX(), pos.getY(), pos.getZ());

首先是修改当前角色的状态为AI_INTENTION_MOVE_TO,如果角色处于攻击状态的话,则要停止攻击。然后调用moveTo方法移动到目标点。这个moveTo方法在AbstractAI类中

// 调用 Creature 类的 moveToLocation 方法
_accessor.moveTo(x, y, z);

// 调用 Creature 类的 broadcastMoveToLocation 方法
_actor.broadcastMoveToLocation();

上面代码中的注释,说的已经非常清除了,都是调用Creature 类里面的方法。另外在说一下,对于角色移动来讲,不管是玩家还是NPC,都是需要的。因此,这个角色移动的所有逻辑基本的都可以在Creature 类和CreatureAI类中完成。对于玩家角色来讲,Player类和PlayerAI 类都继承上面的两个类,而Npc和NpcWalkerAI/AttackableAI 也是继承上面的两个类。大家都继承的话,那么里面的方法就共享了,也就共享角色移动的代码逻辑了。后期我们讲解怪物巡逻的时候,就会用到角色移动的代码了。我们回到AbstractAI类的moveTo方法,里面重要的两句代码已经展示出了了。后者_actor.broadcastMoveToLocation();就是向客户端发送MoveToLocation数据包。这个数据包非常简单,里面就是坐标信息,主要目的就是检查一下而已。客户端收到这个数据包之后,就可以移动了。我们重点查看前面一句代码Creature 类的 moveToLocation 方法。这个方法非常的复杂,我们简单介绍一下。

// 获取角色的移动速度
final float speed = getStat().getMoveSpeed();

// 移动目标点
int x = xValue;
int y = yValue;
int z = zValue;
int offset = offsetValue;

// 当前角色位置
final int curX = getX();
final int curY = getY();
final int curZ = getZ();

// 计算移动总距离(路程)
double dx = (x - curX);
double dy = (y - curY);
double dz = (z - curZ);
double distance = Math.hypot(dx, dy);

// 计算sin和cos,计算角色旋转角度_heading
double cos = dx / distance;
double sin = dy / distance;

// 构建移动数据对象
final MoveData m = new MoveData();
m._xDestination = x;
m._yDestination = y;
m._zDestination = z; 

// 计算一共需要花费多长时间到达目标点
final int ticksToMove = 1 + (int) ((GameTimeTaskManager.TICKS_PER_SECOND * distance) / speed);

// 移动开始时间
m._moveStartTime = GameTimeTaskManager.getInstance().getGameTicks();

// 移动数据对象赋值给 _move 类成员变量持有
// 后面可以直接从  _move 中获取
_move = m;

// 将当前角色放入到 移动任务管理器 中
MovementTaskManager.getInstance().registerMovingObject(this);

虽然代码很多,但是逻辑还是比较清晰的。客户端与服务器端的角色移动同步,实际就是角色坐标点的同步。因为路程和速度已经确定了,那么总的时间也就确定了。当某一个时间的点的时候,角色必定移动到该时间点对应的位置上。时间是客户端和服务器端唯一能够准确同步的信息,因此,服务端按照时间点来确定角色的位置,是不会产生遗漏问题的。接下来,我们来看看“移动任务管理器” MovementTaskManager吧。在这个类中,有一个集合:Set<Set<Creature>> POOLS ,里面存储了“移动”的角色。上面的registerMovingObject方法就是将当前“移动”角色放入到这个集合中。然后,定时执行一个Movement线程任务,代码如下:

ThreadPool.scheduleAtFixedRate(new Movement(pool), TASK_DELAY, TASK_DELAY);

这个Movement线程任务也非常简单,就是从集合中获取角色Creature类,然后调用它的updatePosition方法。如果移动完毕(到达目标点),就把当前角色移除集合。注意,我们的Movement线程任务是定时执行的,每隔0.1秒就会执行一次哦。那么,我们重点就回到Creature类的updatePosition方法了。

// 获取移动数据对象(移动前放入的)
final MoveData m = _move;

// 上一次移动后的位置坐标
m._xAccurate = getX();
m._yAccurate = getY();

// 当前游戏时间
final int gameTicks = GameTimeTaskManager.getInstance().getGameTicks();

// 剩余的路径坐标,剩余的路程(还有多长距离到达目标点)
double dx = m._xDestination - m._xAccurate;
double dy = m._yDestination - m._yAccurate;
double dz = m._zDestination - zPrev;

// 剩余的路程(还有多长距离到达目标点)
double delta = (dx * dx) + (dy * dy);
delta = Math.sqrt(delta);

// 本次时间段行走的路程
final double distPassed;
// 本次时间段行走的路程 = 速度 * 当前时间 - 上一次时间
distPassed = (_stat.getMoveSpeed() * (gameTicks - m._moveTimestamp)) / GameTimeTaskManager.TICKS_PER_SECOND;

// 百分比,本次行走路程,剩余总路程
double distFraction = Double.MAX_VALUE;
// 本次时间段行走的路程,以及剩余的总路程,做一个百分比
distFraction = distPassed / delta;

// 本次时间段行走的路程 超过 剩余的总路程
if (distFraction > 1)
// 直接设置目标点为角色的当前位置坐标
super.setXYZ(m._xDestination, m._yDestination, m._zDestination);

// 没有到达目标点
// 上一次坐标 加上 本次行走的距离,就是新的坐标
m._xAccurate += dx * distFraction;
m._yAccurate += dy * distFraction;
super.setXYZ((int) m._xAccurate, (int) m._yAccurate, zPrev + (int) ((dz * distFraction) + 0.5));


// 更新一下时间点,为下一次计算做准备,这个非常重要
// 路程计算公式中的时间:gameTicks - m._moveTimestamp
m._moveTimestamp = gameTicks;

// 到达目标点之后,更新周围游戏对象
if (distFraction > 1){
	getKnownList().updateKnownObjects();
	ThreadPool.execute(() -> getAI().notifyEvent(CtrlEvent.EVT_ARRIVED));
	return true;
}

上面的代码基本上比较清晰的。至于坐标Z轴的数值则是根据地图数据(GeoEngine)计算而来,这里我们不介绍。其实,我们发现这个updatePosition方法就是根据移动速度和时间来更新角色的位置坐标:super.setXYZ(x,y,z)。这个方法就在父类WorldObject中,它的主要功能就是更新玩家的位置坐标和瓦片地图。这个我们之前就介绍过了。

扫描二维码关注公众号,回复: 15690561 查看本文章

接下来,我们总结一下上面的整体流程:

第一,客户端发送MoveBackwardToLocation数据包

第二,调用PlayerAI(AbstractAI)的 setIntention方法,执行“MOVE_TO”状态

第三,调用PlayerAI(CreatureAI)的 onIntentionMoveTo 方法

第四,调用PlayerAI(AbstractAI)的 moveTo 方法

第五, 调用Player(Creature)的 moveToLocation 方法和 broadcastMoveToLocation 方法(发送MoveToLocation数据包)

第六,调用 移动任务管理器 注册当前 Player,定时调用 Creature 类的 updatePosition 方法更新当前 Player的位置坐标。

虽然我们的实例对象是Player和PlayerAI,但是代码都集中在了Creature和CreatureAI/ AbstractAI里面。这些角色移动的代码是共享出来的,后期的NPC移动也是这些代码。

到这里,角色的移动还没有结束。我们只是讲解了服务器端的角色移动逻辑代码,但是客户端呢?客户端向服务器端发送MoveBackwardToLocation数据包,然后服务器端返回MoveToLocation数据包,客户端就开始进行角色移动了。那么客户端的角色移动如何同步服务器端的角色移动呢?非常简单,客户端会定时向服务器发送ValidatePosition数据包,从名称来看,就是检查位置的。这个数据包处理非常简单,就是将服务器端的位置发给客户端,返回的数据包是ValidateLocation,我们来看看。

// 读取坐标和旋转
_objectId = creature.getObjectId();
_x = creature.getX();
_y = creature.getY();
_z = creature.getZ();
_heading = creature.getHeading();

// 返回坐标和旋转
ServerPackets.VALIDATE_LOCATION.writeId(this);
writeInt(_objectId);
writeInt(_x);
writeInt(_y);
writeInt(_z);
writeInt(_heading);

关于角色的移动,我们就讲解到这里。下一章节,我们介绍NPC的刷新。

本章节涉及的内容均已上传百度网盘:

https://pan.baidu.com/s/1XdlcCFPvXnzfwFoVK7Sn7Q?pwd=avd4

欢迎加企鹅交流裙:874700842(裙文件里面也可以下载所有内容)。

猜你喜欢

转载自blog.csdn.net/konkon2012/article/details/131678901