Jan 1, 0001
34 mins read
enum EFishState {
None,
Swim, //正常游泳期
Jump, //跳跃动画期
QTEJump, //QTE跳跃
QTEFail, //QTE失败
InAir, //滑行期
AirToSwim, //窗口期
Hit, //受击动画到入水期
}
onCreateNodeData是在PostCreateGameDataByFlowNodeSAction这个action执行时调用的函数,也就是当注册FishParkourData之前调用这个函数,在这个函数中绑定了蓝图中的委托(OnBeginOverlap、OnInputJumpStart、OnFallingInWater)并且更新了UI,调用了一个setParkourSplinePointInfo( params.splineSpawnerId, selfActor.ParkourMovement);函数,设置组件的setActive(true),其中params是通过如下得到。
let params = findObjectParam(
action.params,
SettingFishParkourNodeCreateParam
);
//Define.ts
export class SettingFishParkourNodeCreateParam extends SettingNodeParam {
public splineSpawnerId: string = "";
}
这个函数在Util.ts工具类中,存入样条线全部的点。
export function setParkourSplinePointInfo(spawnerId: string, parkourMovementComponent: UE.ParkourMovementComponent) {
let splinePoints = findSplinePointsBySpawnerId(spawnerId); // 返回splinePoints数组
F.assert(splinePoints);
let pointTransforms = UE.NewArray(UE.Transform);
for (let info of splinePoints) { // 从该数组拿到每个点
let transform = new UE.Transform();
transform.SetLocation(new UE.Vector(info.x, info.y, info.z));
transform.SetRotation(new UE.Rotator(info.pitch, info.yaw, info.roll).Quaternion()); // 将欧拉角转换为四元数
pointTransforms.Add(transform); // 添加到pointTransforms中
}
parkourMovementComponent.SetSplinePointInfo(pointTransforms); // 调用cpp中组件的函数将pointTeansforms赋值
}
其中findSplinePointsBySpawnerId是在SplinePointSystem.ts中定义的。这个系统中关联的数据是SplinePointData,其中包含一个Map<string, ISplinePoints>splinePoints,这个ISplinePoints是包含着splinePoints数组、spawnerId和isAutoSpawn。所以这个函数会返回ISplinePoints的splinePoints数组。
ISplinePoints
|__splinePoints:ITransformData[]
|__SpawnerId:string
|__isAutoSpawn:boolean
onBBValueChanged这个函数是在FlowBlackboardValueChangedEvent触发时调用的,也就是当黑板键值改变会判断键是否为bFishEnterWaterFall,代表飞鱼是否进入瀑布,如果进入瀑布event.newValue=true,那么就设置bCanCtrl=false飞鱼不可以控制,否则可以控制。这个bCanCtrl在蓝图中是可以控制是否能跳跃和移动的开关。
setState会先调用canSwitchToState判断是否能够切换到目标状态canSwitchToState会返回一个bool值用来判断是否可以切换状态,比如说我要切换到InAir状态,那么就一定得是从Jump状态或者QTEJump状态。之后setState然后会执行exitState退出当前状态(如果当前状态是在空中(要从空中状态切换出去)那么bFalling=false,动画蓝图中的bInAir=false,这个bInAir会在enterInAirState中变为true),exitState逻辑是根据不同的状态选择要执行的逻辑,比如说我现在要切换状态到Jump那么就会SetState(Jump)然后进入到这个函数里执行this.enterJumpState(oldStatus);七个状态分别对应七个函数。
beginOverlap是当飞鱼撞到障碍物时被调用,如果otherActor的tag时Obstacle那么就销毁otherActor并setState(Hit)。这个tag是在地图中的actor中设置的。
onInputJump是在跳跃键被触发时调用,如果是swim和AirToSwim状态就setState(Jump)直接return,否则调用onPlayPose(播放摇摆尾巴动画)。这也是为什么canSwitchToState中判断了状态切换条件仍然需要在onInputJump中判断一次。
private onInputJump() {
let fishComponent = FishParkourSingletonData.getSingleton();
// 因为需要调用onPlayPose所以要再判断一次
if (
fishComponent.fishState === EFishState.Swim ||
fishComponent.fishState === EFishState.AirToSwim
) {
this.setState(EFishState.Jump);
return;
}
this.onPlayPose();
}
onPlayPose中如果在空中并且不在播放动画,那么播放摇摆尾巴,并且启用定时器延时1s(动画播放时间)记录timerId,存储到data中。这是为了防止其他动画打断它(因为受击或者跳跃也会播放动画),在动画播放结束后定时器会清空Timer并设置bPlayingPose为false。
if (
fishComponent.fishState === EFishState.InAir &&
!fishComponent.bPlayingPose
) {
this.playMontageFish(FishTailing_Montage_Id);
let timerId = addTimer(
fishComponent.id,
(component: Readonly<FishParkourSingletonData>) => {
this.modify(component, (v) => {
v.poseTimer = undefined;
v.bPlayingPose = false;
});
},
1000
);
this.modify(fishComponent, (v) => {
v.poseTimer = timerId;
v.bPlayingPose = true;
});
}
如果进入到setState(Jump)那就会调用enterJumpState
设置CharacterMovement.JumpZVelocity=对应情况的速度,然后调用selfActor.Jump(),播放跳跃动画。
这个函数会被调用三次,分别在enterHitState、enterQTEJumpState、enterJumpState,作用是移除windowTimer定时器并设置windowTimer=undefined、windowTimer=false。
this.cancelWindowTimer();
let timerId = addTimer(
fishComponent.id,
(component: Readonly<FishParkourSingletonData>) => {
this.setState(EFishState.InAir); // 0.2秒后切换到InAir状态 并置空jumpAnimTimer
this.modify(component, (v) => {
v.jumpAnimTimer = undefined;
});
},
200
);
this.modify(fishComponent, (v) => {
v.jumpAnimTimer = timerId;
});
也就是在按下跳跃键0.2秒会触发enterInAirState函数,在这个函数内部会将bInAir = true(为了切换动画机的状态),将bFalling = true。总而言之bFalling代表着是否下落,在空中时会变为true;在从空中状态切换到其他状态时exitStatus变为false。
BP_PlayerFish是飞鱼的蓝图,事件图表中绑定了OnBeginOverlap、OnInputJumpStart回调函数(ts),以及EventTick每帧调用TickCheckJumpDistoWater和ChangeMeshAngle两个蓝图中创建的函数。
其中TickCheckJumpDistoWater逻辑:如果bFalling=true(上一帧在下落)并且bReduceSpeed=false(没有在减速)那么就再判断当前帧是否在下落,如果没有在下落,那就把bFalling置为false,并且CallOnFallingInWater(bFalling状态从isFalling到!IsFalling的切换判断为刚好入水)。
if (bAutoMove){
SelfPawn->AddMovementInput(SelfPawn->GetActorForwardVector(), bReduceSpeed ? ReduceSpeedScale : 1);
}
其中bAutoMove含义是:是否能自动移动,当落水时onFallingInWater和QTE失败时enterQTEFailState会设置为true,而enterQTEJumpState会设置为false。
总而言之在受击和QTE跳跃时会减速,并不会进入到蓝图中的TickCheckJumpDistoWater逻辑。
这个函数是进入到AirToSwim落水窗口期的函数,它是通过蓝图中的TickCheckJumpDisToWater来一直判断是否进入窗口期,然后Call On Falling in Water。
这个函数主要做了以下事情:
判断是否在快落地时按下跳跃键(如果按下会播放摇摆尾动作bPlayPose=true)是无法触发窗口期,就只能切换成Swim状态。
然后设置一个定时器,在窗口时间WindowTime结束后将Timer置空bInJumpWindow=false。
切换到AirToSwim状态,目前该状态没有写逻辑,主要是做一些播放特效。另外就是为了在下次按下跳跃键进入跳跃逻辑时会执行else分支(暴击次数和不同的跳跃高度)。
private onFallingInWater() {
let fishComponent = FishParkourSingletonData.getSingleton();
fishComponent.selfActor.ParkourMovement.bAutoMove = true;
if (fishComponent.bPlayingPose) { // bPlayingPose是在已经跳跃时按下跳跃键的时候会为true 这个时候是不能触发窗口期的!
this.setState(EFishState.Swim);
} else {
let timerId = addTimer(
fishComponent.id,
(component: Readonly<FishParkourSingletonData>) => {
this.modify(component, (v) => {
v.windowTimer = undefined;
v.bInJumpWindow = false;
});
this.setState(EFishState.Swim);
},
fishComponent.selfActor.WindowTime * 1000
);
this.modify(fishComponent, (v) => {
v.windowTimer = timerId;
});
this.setState(EFishState.AirToSwim); // 转换到窗口期状态 之后再次跳跃会在enterJumpState里判断是否当前状态是AirToSwim
}
}
ChangeMeshAngle是为了改变飞鱼在向上坡游动时的角色旋转方向,characterMovement中的currentfloor能够获取角色当前脚下的地面信息,从中取到CurrentFloorHitResult中的ImpactNormal能够拿到地面的法线方向,然后和(0,0,1)进行一个点乘会得到法线与Z轴的夹角余弦=》如果地面完全水平则点乘结果是1,如果是垂直墙壁则是0,然后将结果求ACOSd(反余弦)将余弦值转换成角度会得到一个度数。所以**点积常用来求角度的余弦值而叉积常用来求法线方向、判断左右位置关系。**之后就可以SetRelativeRotation传入坡度角度的负数。
为什么要解决重力的问题?
因为飞鱼向坡上游动时,跳跃会跳不高,是因为当飞鱼在平地时起跳力是完全对抗重力的,但是在坡面时起跳方向是坡面的法线方向,分量会浪费在水平方向上,所以会跳不高。
CharacterMovement中有一个GravityScale是重力缩放因子,会影响Z方向的加速度,=1时是正常重力,=0是漂浮。当角色走在不同坡度的地面时,不可能用同样的重力,否则会不自然,所以需要将刚才求到的坡度角度Sin值,Sin(AngleDeg)越大坡面越陡,用1-Sin(AngleDeg)=》坡面越陡越接近0,之后×BaseGravityScale得到实际的重力。
float AngleDeg = FMath::Acosd(Normal · UpVector);
float Factor = 1 - FMath::Sin(AngleDeg);
GravityScale = BaseGravityScale * Factor;
onQTEResult是通过QTEResultEvent这个event触发,当action.bSucceed=true时setState(QTEJump)否则setState(QTEFail)QTE失败。
在enterQTEJumpState中减速,并且通过qteCount判断第几次QTE跳跃来设置不同的跳跃高度。
和跳跃一样播放跳跃蒙太奇,然后添加定时器SetState(InAir) 和jumpAnimTimer=undefined,如果被打断(Hit)的话可以清空,如果没被打断依然会通过定时器设置undefined.
hit中先会减速然后取消播放跳跃(QTE跳跃和普通跳跃)和在空中跳跃摇摆尾的动作的定时器。而WindowTimer是落水时赋值封装在cancelWindowTimer中,Jump、QUEJump和Hit中调用。
if (fishComponent.jumpAnimTimer) {
removeTimer(fishComponent.jumpAnimTimer);
this.modify(fishComponent, (v) => {
v.jumpAnimTimer = undefined;
});
}
if (fishComponent.poseTimer) {
removeTimer(fishComponent.poseTimer);
this.modify(fishComponent, (v) => {
v.bPlayingPose = false;
v.poseTimer = undefined;
});
}
this.cancelWindowTimer();
ParkourMovementComponent是一个移动组件继承UActorComponent,用于处理飞鱼沿着样条线移动。通过TickComponent来实现每一帧向前移动,并平滑飞鱼的转向RInterp。在BeginPlay中先SetComponentTickEnable(false),因为要等待TS中的onCreateNodeData逻辑都执行完了(之前有说明该函数)再开始Tick逻辑(通过SetActive)。还有一个FindRotationClosestToWorldLocation倒序遍历样条线上的点,然后找到离飞鱼当前位置最近的点,返回该点的Rotator。
所以总流程是如下:
飞鱼沿着样条线自动向前移动,玩家可操控左右和跳跃键来躲避瀑布的碎石,其中在窗口期跳跃会触发暴击跳跃;进入瀑布区域玩家失去控制权,只接受来自QTE跳跃机制。目前无论玩家QTE成功与失败都会触发跳跃,失败会额外触发受击效果。
如何判断入水窗口期?
通过在BP中Tick检查上一帧与该帧的isFalling状态是否不同,即上一帧isFalling=true而下一帧是false,则调用入水窗口期的回调函数,设置飞鱼状态为窗口期。在指定时间后更改状态为游泳状态,如果在更改状态之前触发了跳跃,即从窗口期—->跳跃,则会增加暴击次数,并且更改跳跃速度。
如何进入QTE跳跃?
QTETrigger会被种在场景中,通过流程图飞鱼进入该Trigger会触发QTE系统的StartQTE(OperationStartQTE)对应FlowOperationStartQTENodeSystem.ts,其中PostStartFlowNodeSAction节点开始后会触发onExecute函数,在这个函数中会调用startQTE(node.id, node.qteTemplateId); qteTemplateId在xls表中对应clickQTE,而startQTE在QTESystem中实现。
startQTE中主要做了通过changeInput清空当前输入,然后绑定xls表中对应的输入映射,然后加载输入映射资源到增强输入系统,之后_QTEStartAction广播会被QTEParentSystem接收到,根据QTE限制的时间启动倒计时定时器,当时间结束后会触发onTmeEnd函数表示QTE失败,之后会调整全局时间流速。
那么QTE成功是被谁通知的呢?通过子系统ClickQTESystem,如下代码是子系统中的onBindCallBack,当_QTEBindIAAction时会被触发。
// 回调绑定函数 当玩家触发按键时会调用该函数
let handle = selfCharacter.BindInputActionCallBack(action.inputAction, UE.ETriggerEvent.Started, toManualReleaseDelegate((ActionValue: UE.InputActionValue, ElapsedTime: number, TriggeredTime: number, SourceAction: $Nullable<UE.InputAction>) => {
if (data.bSucceed) {
return;
} // 已经成功了就直接返回 防止反复触发
this.modify(data, (v) => {
v.bSucceed = true;
});
_QTESuccessAction.do(parent); // 广播QTE成功
data.button1.PlayAnimation(data.button1.AnimOnSuccess, 0, 1);
}));
所以在QTE创建时,QTEParent系统中bindInputCallBack会被调用去绑定按键,广播_QTEBindIAAction被子系统接收到onBindCallBack,子系统调用玩家的c++中BindInputActionCallBack函数去监听是否按下按键,如果按下就广播_QTESuccessAction,这个行为会被QTEParent系统接收到,最终广播_QTEResultEvent。
@D.on(QTEParentData)
protected onSuccess(action: _QTESuccessAction) {
let data = action.getGameData<QTEParentData>();
this.onFinish(data, true);
}
private onTimeEnd(data: Readonly<QTEParentData>) {
this.onFinish(data, false);
}
private onFinish(data: Readonly<QTEParentData>, bSuccess: boolean) {
_QTEResultEvent.dispatch(data.id, bSuccess);
}
而_QTEResultEvent又会被飞鱼系统监听到,如果action.bSucceed = true切换到QTEJump状态。否则切换到QTEFail状态(减速,向后退,播放手机动画)。
Sharing is caring!