1
Watch
14
Star
6
Fork
3
Issue
chexiongsheng
chexiongsheng
pushedAt 1 year ago

chexiongsheng/puerts_fps_demo

这篇文章《制作简单FPS游戏》介绍了如何在UE下用蓝图制作一个简单的FPS游戏,本文按其步骤,用TypeScript实现了一遍,熟悉蓝图的同学可以通过两边对照,找到蓝图怎么换成TypeScript的感觉;不熟悉蓝图的同学也可以直接看本文。

起步入门

下载示例项目并解压。进入项目文件夹(BlockBreakerStarter),双击BlockBreaker.uproject打开项目,我们能看到以下场景:

绿色墙上包含着多个目标,当目标受到伤害时会变红。一旦血量值降为零,目标就会消失。红色按钮可以重置所有的目标。

TypeScript编程环境搭建

  • 下载(或clone)puerts
  • 拷贝unreal/Puerts目录到到BlockBreakerStarter/Plugins目录下;
  • 命令行进入BlockBreakerStarter/Plugins/Puerts,执行如下命令
node enable_puerts_module.js
  • 双击BlockBreaker.uproject打开项目,点击puerts的生成按钮,这步骤会生成UE API的TypeScript声明

  • 重启UE4编辑器

创建玩家角色

vscode打开BlockBreakerStarter目录,在TypeScript目录新建TS_Player.ts文件,输入如下代码:

import * as UE from 'ue'

class TS_Player extends UE.Character {
}

export default TS_Player;

这样就新建了个能在UE编辑器下使用的TypeScript类。
注意:要满足以下三点,一个类才能被UE编辑器使用:

  1. 这个类继承自UE的类或者另一继承UE的类;
  2. 类名和去掉.ts后缀的文件名相同;
  3. 把这个类export default。

Character本身是Pawn的一种,额外多了一些其他功能,比如CharacterMovement组件。

该组件会自动处理如走动跑跳等移动功能,我们只要简单调用对应函数就可以移动角色。我们也可以在该组件设置走路速度,起跳速度等变量。

在实现移动功能前,Character需要知道玩家的按键情况,所以我们先将移动映射到WASD键上。

创建移动映射

选择Edit\Project Settings,打开Input设置。

创建两个名为MoveForwardMoveRight轴映射。MoveForward控制前后移动,MoveRight控制左右移动。

]

对于MoveForward,将按键改为W,随后,创建多一个键位插槽,将其设置为S,并将Scale改为**-1.0**。

随后,我们会将Scale值跟角色朝向向量相乘,当Scale值是正数时,向量方向朝前,当Scale值是负数时,向量方向朝后。通过得出的向量结果,我们就可以让角色朝前朝后移动了。

接着,我们要对左右移动做同样的设置,将MoveRight设为D,新建键位插槽设为AScale值设为**-1.0**。

现在我们设置好了键位映射,就可以用它们来进行移动了。

实现移动

TS_Player输入MoveForwardMoveRight的处理代码:

class TS_Player extends UE.Character {
    MoveForward(axisValue: number): void {
        this.AddMovementInput(this.GetActorForwardVector(), axisValue, false);
    }

    MoveRight(axisValue: number): void {
        this.AddMovementInput(this.GetActorRightVector(), axisValue, false);
    }
}

代码解释:

  1. MoveForward回调的axisValue参数,当按下W时为1,当按下S时为**-1**,什么都不按,是0
  2. AddMovementInput函数将玩家朝向向量ScaleValue相乘,使得不同按键控制输出不同方向的向量。什么都不按,意味着向量并没有方向,角色原地不动
  3. CharacterMovement组件获得AddMovementInput节点的输出,驱动角色朝指定方向移动
  4. MoveRight类似,不通的是输入的方向,MoveForward用的是GetActorForwardVector,而MoveRight用的是GetActorRightVector

设置默认Pawn

保存TS_Player.ts,打开World Settings面板并找到Game Mode设置,将Default Pawn Class改为TS_Player

注意:如果你的主编辑器面板还没有World Settings面板,在Toolbar选择Settings\World Settings调出面板。

现在运行游戏你就能控制TS_Player了,按下Play并使用WSAD来进行移动。

我们接着创建输入映射来观察四周。

创建观察映射

打开Project Settings,再创建两个轴映射,分别命名为LookHorizontalLookVertical

LookHorizontal的键位改为Mouse X

这样当鼠标向滑动时会输出正数,反之亦然。

接着,将LookVertical的键位改为Mouse Y

这样当鼠标向上滑动时会输出正数,反之亦然。

现在,我们要写点逻辑来实现转动视角。

实现转动视角

如果一个Pawn上没有Camera组件,Unreal会自动为你创建一个摄像机。默认情况下,摄像机会使用控制器的旋转。

**注意:**如果你想了解更多关于控制器的内容,可以查看AI部分教程。

虽然控制器并没有物理实体,它仍旧有自己的旋转。这意味着我们可以让角色和摄像机面向不同方向。比如,在第三人称游戏里,角色和摄像机并不总是处于同一方向。

要在第一人称视角里转动摄像机,我们所要做的就是修改控制器的旋转。

TS_Player输入LookHorizontalLookVertical的处理代码:

class TS_Player extends UE.Character {
    // ... other code

    LookHorizontal(axisValue: number): void {
        this.AddControllerYawInput(axisValue);
    }

    LookVertical(axisValue: number): void {
        this.AddControllerPitchInput(axisValue * -1);
    }
}

 和之前左右移动类似,有点差异的是LookVertical会将axisValue乘以-1,如果不这么处理,视角上下移动和大多数人的习惯不太一致。

保存文件,按下Play运行游戏,使用鼠标来转动视角吧。

现在移动和视角转动都实现了,是时候搞把枪了!

创建枪支

创建枪支基类

在TypeScript目录下新建TS_BaseGun.ts,输入如下代码:

import * as UE from 'ue'
class TS_BaseGun extends UE.Actor {
    MaxBulletDistance: number;
    Damage: number;
    FireRate: number;
    GunMesh: UE.StaticMeshComponent;
}
export default TS_BaseGun;

 我们在TS_BaseGun类里头定义了几个number类型的变量,它们含义分别是:

  • **MaxBulletDistance:**子弹最远飞行距离
  • **Damage:**子弹伤害
  • **FireRate:**子弹发射间隔(秒)

注意:每个变量的默认值都是0,对本例来说没什么问题。然而,如果你希望新的枪支类有别的默认值,你需要在BP_BaseGun设置下。

GunMesh是StaticMeshComponent类型的变量,是枪支的外形,我们会在创建枪械子类时初始化它。

创建枪械子类

保存后,添加TS_Rifle.ts,输入如下代码:

import TS_BaseGun from './TS_BaseGun'
import * as UE from 'ue'

import './ObjectExt'

class TS_Rifle extends TS_BaseGun {
    Constructor() {
        this.MaxBulletDistance = 5000;
        this.Damage = 2;
        this.FireRate = 0.1;

        this.GunMesh = this.CreateDefaultSubobjectGeneric<UE.StaticMeshComponent>("GunMesh", UE.StaticMeshComponent.StaticClass());
        this.GunMesh.StaticMesh = UE.StaticMesh.Load("/Game/BlockBreaker/Meshes/SM_Rifle");
        this.RootComponent = this.GunMesh;
    }
}

export default TS_Rifle;

 代码多起来了,别慌,听我一一道来:

  1. 那几个number变量在TS_BaseGun的子类TS_Rifle的子类初始化,意味着来复枪每颗子弹能最远飞行5000单位的距离。如果子弹命中Actor,能对其造成2点伤害。当持续开火射击时,射击间隔不少于0.1秒。
  2. 我们通过CreateDefaultSubobjectGeneric,创建了个StaticMeshComponent对象,并加载它的StaticMesh属性

注:Object的CreateDefaultSubobject方法用于创建子对象,但鉴于该方法参数较多,而且返回的是基类Object,不便于使用,我们稍稍封装了一下(CreateDefaultSubobjectGeneric),封装的实现在ObjectExt.ts,代码如下

import * as UE from 'ue'

declare module "ue" {
    interface Object {
        CreateDefaultSubobjectGeneric<T extends UE.Object>(SubobjectFName: string, ReturnType: UE.Class) : T
    }
}

UE.Object.prototype.CreateDefaultSubobjectGeneric = function CreateDefaultSubobjectGeneric<T extends UE.Object>(SubobjectFName: string, ReturnType: UE.Class) : T {
    return this.CreateDefaultSubobject(SubobjectFName, ReturnType, ReturnType, /*bIsRequired =*/ true, /*bIsAbstract =*/ false, /*bTransient =*/ false) as T;
}

这把枪现在就完成了。

接着,我们要创建自己的摄像机组件了。这样能够更好地控制摄像机位置,我们还可以将枪支跟摄像机绑定在一起,这样枪支就能始终保持在摄像机的正面了。

创建摄像机

打开TS_Player.ts并新增几个变量

import * as UE from 'ue'
import TS_BaseGun from './TS_BaseGun'
import {$ref, $unref} from 'puerts' //和本节无关,但后面要用到
import './ObjectExt' 

class TS_Player extends UE.Character {
    FpsCamera: UE.CameraComponent;
    EquippedGun:TS_BaseGun;
    GunLocation:UE.SceneComponent;
    
    // other code...
}

export default TS_Player;

这几个变量的含义分别是

  • FpsCamera: 摄像机;
  • EquippedGun:枪的引用;
  • GunLocation:枪的位置;

添加构造函数,初始化FpsCamera,GunLocation:

class TS_Player extends UE.Character {
    // other code...

    Constructor() {
        let FpsCamera = this.CreateDefaultSubobjectGeneric<UE.CameraComponent>("FpsCamera", UE.CameraComponent.StaticClass());
        FpsCamera.SetupAttachment(this.CapsuleComponent, "FpsCamera");
        FpsCamera.K2_SetRelativeLocationAndRotation(new UE.Vector(0, 0, 90), undefined, false, $ref<UE.HitResult>(undefined), false);
        FpsCamera.bUsePawnControlRotation = true;
        this.FpsCamera = FpsCamera;

        this.GunLocation = this.CreateDefaultSubobjectGeneric<UE.SceneComponent>("GunLocation", UE.SceneComponent.StaticClass());
        this.GunLocation.SetupAttachment(this.FpsCamera, "GunLocation");
        this.GunLocation.K2_SetRelativeLocationAndRotation(new UE.Vector(30, 14, -12), new UE.Rotator(0, 95, 0), false, $ref<UE.HitResult>(undefined), false);
    }

    // other code...
}

代码解释

  1. 创建名为FpsCamera的相机,并attach到CapsuleComponent下
  2. 设置相机的位置
  3. 默认情况下,摄像机组件并不使用控制器的旋转。要修正这点,bUsePawnControlRotation设置为true
  4. 创建一个SceneComponent作为枪支的位置,将其attach到相机下
  5. 设置GunLocation的位置和旋转

生成并绑定枪支

TS_Player下添加ReceiveBeginPlay方法,这个函数会在游戏开始的时候被引擎调用,在该方法添加来复枪的生成和绑定逻辑

class TS_Player extends UE.Character {
    // other code...

    ReceiveBeginPlay(): void {
        let ucls  = UE.Class.Load("/Game/Blueprints/TypeScript/TS_Rifle.TS_Rifle_C");
        this.EquippedGun = UE.GameplayStatics.BeginDeferredActorSpawnFromClass(this, ucls, undefined, UE.ESpawnActorCollisionHandlingMethod.Undefined, this) as TS_BaseGun;
        UE.GameplayStatics.FinishSpawningActor(this.EquippedGun, undefined);

        this.EquippedGun.K2_AttachToComponent(this.GunLocation, undefined, UE.EAttachmentRule.SnapToTarget, UE.EAttachmentRule.SnapToTarget, UE.EAttachmentRule.SnapToTarget, true);
    }
}

保存后点击运行,这时可以看到我们有了枪。

现在有趣的地方来了:射击子弹!要检测子弹是否打中东西,我们要用上射线检测(line trace)

射击子弹

射线检测是一个包含开始点和结束点(两点成线)的函数,它会检测这条线上的每个点,看是否碰到其他物体。在游戏中,这是用于检测子弹是否打中东西的最普遍做法。

由于射击是属于枪支的特性,射击函数应该设计在枪支类里,而不是角色类。在TS_BaseGun类中添加创建名为Shoot的函数。

class TS_BaseGun extends UE.Actor {
    //other code

    //@no-blueprint
    Shoot(StartLocation: UE.Vector, EndLocation: UE.Vector): void {
        let hitResultOut = $ref<UE.HitResult>(undefined);
        if (UE.KismetSystemLibrary.LineTraceSingle(this, StartLocation, EndLocation, 0, false, undefined, 0, hitResultOut, true, undefined, undefined, 0)) {
            let hitResult = $unref(hitResultOut);
            UE.GameplayStatics.SpawnEmitterAtLocation(this, this.PS_BulletImpact, hitResult.Location, new UE.Rotator(0, 0, 0), new UE.Vector(1, 1, 1), true, UE.EPSCPoolMethod.AutoRelease, true);
        }
    }
}

代码解释

  1. 使用LineTraceByChannel函数来执行射线检测。这个节点会使用**可视力(Visibility)或者摄像机(Camera)**碰撞通道来进行碰撞检测。 
  2. 通过$ref创建引用类型,用于碰撞信息的输出
  3. 如果检测到碰撞到碰撞(LineTraceByChannel返回值为true),则使用SpawnEmitteratLocation函数在碰撞位置生成粒子特效PS_BulletImpact
  4. @no-blueprint告诉系统别生成对应的蓝图方法,因为这个方法我们只在TypeScript里头调用

调用射击函数

首先,我们需要创建射击的按键映射。点击Compile并打开Project Settings。创建一个新的Axis Mapping并命名为Shoot,将其按键设为Left Mouse Button,然后关闭Project Settings

打开TS_Player.ts,添加Shoot事件处理

class TS_Player extends UE.Character {
    //other code...

    Shoot(axisValue: number): void {
        if (axisValue == 1) { 
            let cameraLocation = this.FpsCamera.K2_GetComponentLocation();
            let endLocation = cameraLocation.op_Addition(this.FpsCamera.GetForwardVector().op_Multiply(this.EquippedGun.MaxBulletDistance));
            this.EquippedGun.Shoot(cameraLocation, endLocation); 
        }
    }
}

 代码解释

  1. 如果玩家按下了鼠标左键,则调用枪支的Shoot函数
  2. Shoot函数射线检测的起始点是相机的位置,终点= 相机位置 + 相机朝向 * 枪支射程

保存文件,按下Play运行游戏,按住鼠标左键开始发射子弹吧!

现在,枪支是每帧都在射击的,射速实在是有点太快了,所以下一步要降低枪支的开火速度。

降低开火速度

首先,我们需要一个变量检测玩家是否正在射击。打开TS**_Player创建boolean类型变量,命名为CanShoot**,将其默认值设为true。如果CanShoot等于true,说明玩家正在射击。

另外,将Shoot逻辑稍作改造

class TS_Player extends UE.Character {
    // other code ..
    CanShoot: boolean;

    Constructor() {
        // other code ..
        this.CanShoot = true;
    }

    // other code ..

    //@no-blueprint
    async AShoot(axisValue: number): Promise<void> {
        if (axisValue == 1 && this.CanShoot) {
            let cameraLocation = this.FpsCamera.K2_GetComponentLocation();
            let endLocation = cameraLocation.op_Addition(this.FpsCamera.GetForwardVector().op_Multiply(this.EquippedGun.MaxBulletDistance));
            this.EquippedGun.Shoot(cameraLocation, endLocation);
            this.CanShoot = false;
            await delay(this.EquippedGun.FireRate * 1000);//TODO: 支持Latent方法转async方法后,可以用KismetSystemLibrary.Delay
            this.CanShoot = true;
        }
    }

    Shoot(axisValue: number): void {
        this.AShoot(axisValue)
    }
}

 代码解释:

  1. 由于我们要用到异步的等待,我们把前面的Shoot逻辑移动到一个async版本的AShoot函数,添加@no-blueprint声明其只在TypeScript中使用
  2. 只有按下鼠标而且CanShoot变量为true时才允许射击
  3. 调用EquippedGun射击后,把CanShoot改为false,按枪支的射速延时后设置CanShoot为true

里头用到的delay函数时用setTimeout的简单封装,熟悉TypeScript的同学应该都知道怎么写。

保存后,按下Play运行游戏测试下枪支的射速吧!

实现受击

在Unreal里,每个Actor都能受击。然而,Actor要对受击伤害做出什么处理是可以自由定义的。

比如,当战斗中的游戏角色当受击时,会扣除血量。然而,像气球一类物体是没有血量概念的。取而代之的,我们会编写逻辑让气球在受击时爆炸。

打开TS_BaseGun.ts,在Shoot函数添加受击逻辑

class TS_BaseGun extends UE.Actor {
    // other code ..

    //@no-blueprint
    Shoot(StartLocation: UE.Vector, EndLocation: UE.Vector): void {
        let hitResultOut = $ref<UE.HitResult>(undefined);
        if (UE.KismetSystemLibrary.LineTraceSingle(this, StartLocation, EndLocation, 0, false, undefined, 0, hitResultOut, true, undefined, undefined, 0)) {
            let hitResult = $unref(hitResultOut);
            UE.GameplayStatics.SpawnEmitterAtLocation(this, this.PS_BulletImpact, hitResult.Location, new UE.Rotator(0, 0, 0), new UE.Vector(1, 1, 1), true, UE.EPSCPoolMethod.AutoRelease, true);
            if (hitResult.Actor) {
                UE.GameplayStatics.ApplyDamage(hitResult.Actor, this.Damage, undefined, undefined, undefined);
            }
        }
    }
}

新增的只是if (hitResult.Actor) {}这段,简单的对碰撞到的Actor调用下ApplyDamage,把枪支的伤害值传过去。

现在我们需要处理每种Actor对于受击伤害的反馈。这部分内容原来的蓝图教程很简单,只是简单调用了下封装好的逻辑,我就不改造成TypeScript了,保留原文,有兴趣的同学可以继续实现;要改造需要用TypeScript实现其例子已经封装好的逻辑,而且要把地图里头的绿墙上方块,红色按钮都换成TypeScript模块。

处理受击

首先,我们需要处理目标获得伤害数据,打开BP_Target并创建Event AnyDamage事件节点,这个节点会在受到伤害且其数值不为零时触发执行。

随后,调用TakeDamage函数并连接Damage引脚。这个函数会将目标的Health变量减去Damage数值,并更新目标的颜色。

现在,当目标受到伤害时,它就会扣除血量了。点击Compile并关闭BP_Target

接着,我们需要处理按钮对伤害的反馈。打开BP_ResetButton并创建Event AnyDamage。随后,调用ResetTargets函数。

这个函数会在按钮受击时调用并重置所有目标的状态。点击Compile并关闭BP_ResetButton

按下Play运行游戏开始射击目标。如果你想要重置所有目标,就朝按钮射击。

后续学习

虽然本篇教程中所制作是一个非常简单的FPS游戏,你可以在此基础上进一步扩展,试着创建更多具有不用射速和伤害的枪械,也可以尝试添加装弹功能!

相关代码

ucloud ads