Jan 1, 0001

34 mins read

FishParkoutSystem.ts

enum EFishState {
    None,
    Swim, //正常游泳期
    Jump, //跳跃动画期
    QTEJump, //QTE跳跃
    QTEFail, //QTE失败
    InAir, //滑行期
    AirToSwim, //窗口期
    Hit, //受击动画到入水期
}

onCreateNodeData

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

onBBValueChanged这个函数是在FlowBlackboardValueChangedEvent触发时调用的,也就是当黑板键值改变会判断键是否为bFishEnterWaterFall,代表飞鱼是否进入瀑布,如果进入瀑布event.newValue=true,那么就设置bCanCtrl=false飞鱼不可以控制,否则可以控制。这个bCanCtrl在蓝图中是可以控制是否能跳跃和移动的开关。

setState | canSwitchToState

setState会先调用canSwitchToState判断是否能够切换到目标状态canSwitchToState会返回一个bool值用来判断是否可以切换状态,比如说我要切换到InAir状态,那么就一定得是从Jump状态或者QTEJump状态。之后setState然后会执行exitState退出当前状态(如果当前状态是在空中(要从空中状态切换出去)那么bFalling=false,动画蓝图中的bInAir=false,这个bInAir会在enterInAirState中变为true),exitState逻辑是根据不同的状态选择要执行的逻辑,比如说我现在要切换状态到Jump那么就会SetState(Jump)然后进入到这个函数里执行this.enterJumpState(oldStatus);七个状态分别对应七个函数。

beginOverlap

beginOverlap是当飞鱼撞到障碍物时被调用,如果otherActor的tag时Obstacle那么就销毁otherActor并setState(Hit)。这个tag是在地图中的actor中设置的。

onInputJump

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();
}

onPlayingPose

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;
    });
}

enterJumpState

如果进入到setState(Jump)那就会调用enterJumpState

  • 如果在swim状态则跳跃速度是NormalJumpZVelocity
  • 如果是AirToSwim状态则分为两种情况:
    • 达到最大暴击次数 则CriticalJumpZVelocity 并且criticalNumber = 0
    • 未达到最大暴击次数 则ContJumpZVelocity 并且criticalNumber ++

设置CharacterMovement.JumpZVelocity=对应情况的速度,然后调用selfActor.Jump(),播放跳跃动画。

cancelWindowTimer

这个函数会被调用三次,分别在enterHitStateenterQTEJumpStateenterJumpState,作用是移除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每帧调用TickCheckJumpDistoWaterChangeMeshAngle两个蓝图中创建的函数。

BP-TickCheckJumpDistoWater

其中TickCheckJumpDistoWater逻辑:如果bFalling=true(上一帧在下落)并且bReduceSpeed=false(没有在减速)那么就再判断当前帧是否在下落,如果没有在下落,那就把bFalling置为false,并且CallOnFallingInWater(bFalling状态从isFalling到!IsFalling的切换判断为刚好入水)。

  • bReduceSpeed是在enterQTEJumpState时开始时变为True,然后经过很短的时间后变为False,是因为要减速有一个起跳的放慢的感觉,在ParkourMovementComponent中的tickcomponent如下:
if (bAutoMove){
    SelfPawn->AddMovementInput(SelfPawn->GetActorForwardVector(), bReduceSpeed ? ReduceSpeedScale : 1);
}

其中bAutoMove含义是:是否能自动移动,当落水时onFallingInWater和QTE失败时enterQTEFailState会设置为true,而enterQTEJumpState会设置为false。

  • bReduceSpeed在enterHitState也会变为true,结束后变为false。因为受击也需要减速。

总而言之在受击和QTE跳跃时会减速,并不会进入到蓝图中的TickCheckJumpDistoWater逻辑。

onFallingInWater

这个函数是进入到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
    }
}

BP-ChangeMeshAngle

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

onQTEResult是通过QTEResultEvent这个event触发,当action.bSucceed=true时setState(QTEJump)否则setState(QTEFail)QTE失败。

enterQTEJumpState

在enterQTEJumpState中减速,并且通过qteCount判断第几次QTE跳跃来设置不同的跳跃高度。

和跳跃一样播放跳跃蒙太奇,然后添加定时器SetState(InAir) 和jumpAnimTimer=undefined,如果被打断(Hit)的话可以清空,如果没被打断依然会通过定时器设置undefined.

enterHitState

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

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!