目标效果

要点分析

游戏物体

操纵球Ball一个
陷阱球TrapBall一个
矩形Floor平面一个

游戏逻辑

正常流程

​ 当第一次点击Ball时游戏开始,点击Ball后Ball进入前进状态,前进状态在触碰地面后进入返回状态,返回状态中当Ball向下坠落时鼠标点击Ball可再次进入前进状态并加得分(得分根据前进距离取5~10分之间)且刷新得分UI,如此反复。

​ 在得分大于15分后TrapBall启用并开始循环做顺时针圆周运动。

死亡事件

  • Ball在返回状态中如果触碰Floor则游戏结束。

  • 当点击TrapBall后游戏结束。

重新开始

当死亡时,进入总结面板,点击重新开始按钮后重新加载游戏场景。

细节部分
Ball的落地点每次都是不固定的,会有一定偏移。但要保证是在一定范围内偏移,不能让Ball掉出Floor。

实现逻辑

Ball
Ball在游戏过程中一共分为三种状态,分别为wait、forward、back。wait为原地等待,forward为朝前做抛物线运动,back为朝摄像机做抛物运动,
当 (被点击 + 状态=back + 向下坠落) 都成立时,就加分。
当 (被点击 + 状态=wait或back + 垂直速度不大于0)|(与Floor发生碰撞 + 状态=forward)成立时就切换状态。
当 (与Floor发生碰撞 + 状态=back)成立时就调用GameOver方法进入游戏结束阶段。

TrapBall
TrapBall在游戏过程中分为两种状态,wait和run。wait为原地等待,run为做圆周运动。默认为wait,当得分score>=15时就进入run状态。当 被点击时就调用GameOver。

UI
右上角得分UI:当每次加分时,刷新一次。
游戏结束面板:当游戏结束时,用得分填充score的text文本,当点击重新开始按钮时,重新加载该场景。

实现

在用UE4制作之前,我先用Unity3d制作了一份以明确大致制作流程。所以先说一下unity3d版本的制作过程。

Unity3d实现

Ball
对于Ball的抛物线运动我想到了两种解决方案。
一种是直接给Ball附上rigidbody组件,然后每次切换状态就赋给Ball一个新的有方向的力即可。
这种解决方案下Ball的脚本代码:

Ball的脚本代码

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class BallScript : MonoBehaviour
{
    public enum State{ forward ,back,wait}
    private State state;
    private GameController gc;
    private Rigidbody rb;
    private static float moveA = 0.1f;
    private float distance = 10;        //终点距离起点范围
    // Start is called before the first frame update

    private void Awake()
    {
        gc = FindObjectOfType<GameController>();
        rb = GetComponent<Rigidbody>();
    }
    void Start()
    {
        state = State.wait;
    }
    private void OnMouseDown()
    {
        if(state==State.back && rb.velocity.y < 0)
        {
            gc.AddScore(5);
        }
        TurnState();
    }
    private void OnCollisionEnter(Collision collision)
    {
        if (state == State.wait) return;
        if(state == State.back)
        {
            gc.GameOver();
        }
        else
        {
            TurnState();
        }
    }
    /// <summary>
    /// 改变Ball的状态
    /// </summary>
    private void TurnState()
    {
        if (state == State.forward) state = State.back;
        else state = State.forward;         //如果是wait或back状态就进入forward状态
        rb.velocity = GetVelocity();
    }
    private Vector3 GetVelocity()
    {
        Vector3 baseVec = new Vector3(0,1,1);
        float power;
        if (state == State.back)
        {
            baseVec.z = -1;
            power = transform.position.z * 0.9f;
        }
        else
            power = 5f;
        baseVec.x = Random.Range(transform.position.x<-2.5f?moveA:-moveA, transform.position.x > 2.5f ?  -moveA:moveA);
        baseVec.y += Random.Range(-moveA, moveA);
        baseVec *= power;
        return baseVec;
    }
}

效果:
GIF.gif

这种方式的优点就是实现简便,但缺点是不好控制Ball的落地点,容易出现球飞出屏幕或过早落地的情况,以及不好计算动态得分等。

所以我又想了另一种实现方式,自己手动模拟抛物线运动。
在每次切换状态后,先确定一个目标点(在横向轴做一些偏移,但前进轴距离固定(根据方向取0或10)),然后根据球的当前位置使球做抛物线运动并能精准落在目标点上。
实现逻辑:抛物线运动分为水平和垂直两个方向的运动,如果水平方向的运动和垂直方向的运动所用时间相同,即Ball在水平方向到达目标点时垂直方向也正好落地,那么球也就正好落在目标点上了。所以可以先固定Ball的水平方向的速度,每次切换状态时,根据Ball的当前坐标和目标点坐标的水平距离计算出到达所需时间,然后根据这个时间和Ball的当前高度与目标点的垂直距离,计算出Ball的上抛的初速度。接下来每帧根据两个方向的速度做水平位移和竖直位移,然后让竖直方向的速度减去重力加速度deltaTime即可。
其中,水平运动的所需时间为
$$t = v
s$$
竖直上抛运动的位移公式为
$$h = v_0t - \frac{1}{2}gt^2$$
转换得
$$v_0 = \frac{h+\frac{1}{2}gt^2}{t}$$

这种解决方案既节省性能又解决了上一种方案存在的问题,这种方式的Ball的部分脚本代码如下:

public enum State { wait,forward,back};
public State state;                                                     //状态            
private const float G = 10f;                                       //重力加速度
public const float EndZ = 10f;                                   //forward时目标点的z轴分量
public const float BeginZ = 0f;                               //back时目标点的z轴分量
public float RandX = 0.5f;                          //随机左右偏移范围
public float rad;                                           //与目标点水平夹角
public Vector3 goalPos = Vector3.zero;      //目标点
public float speedUp;                               //垂直方向的速度
public float speedForward = 10f;             //水平方向的速度

//物理帧运动
void FixedUpdate()
{
    if (state == State.wait) return;
    Vector3 pos = transform.position;
    pos.y += speedUp * Time.fixedDeltaTime;
    float s = speedForward * Time.deltaTime;
    float xAdd = Mathf.Sin(rad) * s;
    float zAdd = Mathf.Cos(rad) * s;
    pos.z += state == State.forward ? zAdd : -zAdd;
    pos.x += pos.x>goalPos.x?-xAdd:xAdd;
    transform.position = pos;
    speedUp -= G * Time.deltaTime;
}

//切换状态
void SwitchState()
{
    if (state == State.forward) state = State.back;
    else state = State.forward;
    SetGoal();
}
//设置目标点
void SetGoal()
{
    if (state == State.wait) return;
    goalPos.z = state == State.forward ? EndZ : BeginZ;             //根据方向取Z轴分量
    goalPos.x = Random.Range(-RandX, RandX);                        //在范围内左右偏移
    Vector3 pos = transform.position;
    //计算水平位移
    float distance = Mathf.Sqrt(Mathf.Pow(pos.x - goalPos.x, 2) + Mathf.Pow(pos.z - goalPos.z, 2));
    //计算到达所需时间
    float totalTime = distance / speedForward;
    //计算上抛初速度
    speedUp = (-Mathf.Abs(pos.y - goalPos.y) + G / 2 * totalTime * totalTime) / totalTime;     
    //提前计算好x轴偏移夹角,后面水平运动时就不用重复计算了
    rad = Mathf.Atan(Mathf.Abs(pos.x - goalPos.x) / Mathf.Abs(pos.z - goalPos.z));
}

TrapBall
TrapBall只要在run时做圆周运动即可,圆的参数方程为:
$x = a+r*cos(\theta)$
$y = b+r*sin(\theta)$
TrapBall代码:

TrapBallScript

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ErrorBallScript : MonoBehaviour
{
    private Vector3 Point = new Vector3(0, -1, 0);      //圆心
    private float r = 2.5f;       //半径
    private float cycleTime = 3f;        //2秒/周期
    private float addAngle;                 //每秒加多少弧度
    private float angle = -Mathf.PI;    //当前弧度
    private Vector3 pos;
    private GameController gc;
    // Start is called before the first frame update
    private void Awake()
    {
        gc = FindObjectOfType<GameController>();
    }
    void Start()
    {
        addAngle = 2 * Mathf.PI / cycleTime;
        pos = transform.position;
    }

    private void FixedUpdate()
    {
        angle -= addAngle * Time.fixedDeltaTime;
        pos.x = Point.x + r * Mathf.Cos(angle);
        pos.y = Point.y + r * Mathf.Sin(angle);

        transform.position = pos;
    }
    private void OnMouseDown()
    {
        gc.GameOver();
    }
}

GameController
另外还需要一个GameController脚本来控制游戏规则与流程,代码如下:

GameController

public class GameController : MonoBehaviour
{
    private int score{ get;set; }
    public Text ScoreText;
    public Text GameOverScoreText;
    public GameObject GameOverPanel;
    private const int StartTrapBallScore = 15;       //启用TrapBall的阈值分
    public GameObject TrapBall;

    // Start is called before the first frame update
    void Start()
    {
        score = 0;
        Time.timeScale = 1f;
    }
    public void AddScore(int addScore)
    {
        score += addScore;
        RefreshScoreText();
        //当得分大于等于StartErrorBallScore时,ErrorBall开始启用
        if (score >= StartTrapBallScore)
            TrapBall.SetActive(true);
    }
    public void GameOver()
    {
        Time.timeScale = 0f;
        GameOverPanel.SetActive(true);
        GameOverScoreText.text = score.ToString();
    }
    private void RefreshScoreText()
    {
        ScoreText.text = score.ToString();
    }
    public void Restart()
    {
        SceneManager.LoadSceneAsync(SceneManager.GetActiveScene().buildIndex);       //重新加载本场景
    }

}

最终效果:
Unity3dFinal.gif

UE4实现

用UE4实现在逻辑上也差不多,就是实操不同,大致用c++、类蓝图以及关卡蓝图制作,主要讲下制作流程,就不多介绍了。

关卡蓝图
在进入该关卡时,设置主摄像机,监听鼠标点击事件,显示鼠标指针,初始化一下GameController。
Snipaste_2020-12-13_16-39-03.jpg

Ball

Ball.h & Ball.cpp


h:

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Ball.generated.h"

UCLASS()
class STARTBALL_API ABall : public AActor
{
    GENERATED_BODY()

public: 
    // Sets default values for this actor's properties
    ABall();
    enum State { wait,forward, back};
    UPROPERTY(VisibleAnywhere)
        UStaticMeshComponent* VisualMesh;
    UPROPERTY(BlueprintReadWrite, Category = "myCategory")
        int state;
    UPROPERTY(VisibleAnywhere)
        float speedUp;                              //上方向的速度
    float speedForward = 500.0f;            //前进方向的速度(单位秒)
    UPROPERTY(BlueprintReadWrite, Category = "myCategory")
        int score = 0;
    UPROPERTY(BlueprintReadOnly, Category = "myCategory")
        float EndX = 1000.0f;           //前进时的目标x
private:
    //模拟抛物线运动
    const float G =     1000.0f;                    //重力加速度
    const float BeginX = 0.0f;              //回来时的目标x
    FVector goalPos;                            //目标坐标
    int RandY = 100;                            //随机偏移范围            
    float rad;                                  //与目标点的水平夹角弧度

protected:
    // Called when the game starts or when spawned
    virtual void BeginPlay() override;

public: 
    // Called every frame
    virtual void Tick(float DeltaTime) override;
    UFUNCTION(BlueprintCallable, Category = "BPFunc_Lib")
    void SwitchState();     //改变状态
    void SetGoal();             //设置目标
    UFUNCTION(BlueprintCallable, Category = "BPFunc_Lib")
    bool IsDown();              //是否为下降状态
    UFUNCTION(BlueprintCallable, Category = "BPFunc_Lib")
    bool IsBack();
    UFUNCTION(BlueprintCallable, Category = "BPFunc_Lib")
    bool IsWait();
};

cpp:

// Fill out your copyright notice in the Description page of Project Settings.

#include "Ball.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/StaticMesh.h"
#include "UObject/ConstructorHelpers.h"
#include<ctime>
#include<cstdlib>

// Sets default values
ABall::ABall()
{
    srand(signed int(time(0)));
    goalPos.Y = 0;
    // Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
    PrimaryActorTick.bCanEverTick = true;

    VisualMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
    VisualMesh->SetupAttachment(RootComponent);
}

// Called when the game starts or when spawned
void ABall::BeginPlay()
{
    Super::BeginPlay();
    state = State::wait;
}

// Called every frame
void ABall::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
    //移动
    if (state == State::wait) return;
    FVector pos = GetActorLocation();
    pos.Z += speedUp * DeltaTime;
    float s = speedForward * DeltaTime;     //水平位移
    float xAdd = FMath::Sin(rad)*s;
    float yAdd = FMath::Cos(rad)*s;
    pos.X += state == State::forward ? xAdd : -xAdd;
    pos.Y += pos.Y > goalPos.Y ? -yAdd : yAdd;
    SetActorLocation(pos);
    speedUp -= G * DeltaTime;
    if (pos.Z <= 50) { pos.Z = 50.1f; SetActorLocation(pos); SwitchState(); }               //自动切换,测试
}


void ABall::SwitchState() {
    if (state == State::forward)state = State::back;
    else state = State::forward;
    SetGoal();
}

void ABall::SetGoal() {
    if (state == State::wait)return;
    goalPos.X = state == State::forward ? EndX : BeginX;
    goalPos.Y = rand() % (2 * RandY) - RandY;
    FVector pos = GetActorLocation();
    float distance = FMath::Sqrt(FMath::Pow(pos.Y - goalPos.Y, 2) + FMath::Pow(pos.X - goalPos.X, 2));      //距离目标点的平面距离
    float totalTime = distance / speedForward;      //到达目标点所需时间
    speedUp = (-FMath::Abs(pos.Z - goalPos.Z) + G / 2 * totalTime * totalTime) / totalTime;

    rad = FMath::Atan(FMath::Abs(pos.X - goalPos.X) / FMath::Abs(pos.Y - goalPos.Y));
}

bool ABall::IsBack() {
    return state == State::back;
}
bool ABall::IsWait() {
    return state == State::wait;
}
bool ABall::IsDown() {
    return speedUp <= 0;
}


然后以Ball类作为父类创建类蓝图BP_Ball,其它类也是如此。
Snipaste_2020-12-13_16-51-57.jpg

TrapBall

TrapBall.h & TrapBall.cpp


h:

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "TrapBall.generated.h"

UCLASS()
class STARTBALL_API ATrapBall : public AActor
{
    GENERATED_BODY()

public: 
    // Sets default values for this actor's properties
    ATrapBall();
    enum State{wait,run};
    State state;
    UPROPERTY(VisibleAnywhere)
    UStaticMeshComponent* VisualMesh;
    FVector pos;
    float r = 200;                  //半径
    FVector* point;             //圆心
    float angle = -180;             //当前旋转角度
    float zTime = 2;            //转一圈需要的时间
protected:
    // Called when the game starts or when spawned
    virtual void BeginPlay() override;

public: 
    // Called every frame
    virtual void Tick(float DeltaTime) override;
    UFUNCTION(BlueprintCallable, Category = "BPFunc_Lib")
    void Run();
};

cpp:

// Fill out your copyright notice in the Description page of Project Settings.

#include "TrapBall.h"
#include "Components/StaticMeshComponent.h"
#include "Engine/StaticMesh.h"
// Sets default values
ATrapBall::ATrapBall()
{
    // Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
    PrimaryActorTick.bCanEverTick = true;
    VisualMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
    VisualMesh->SetupAttachment(RootComponent);
    point = new FVector(0, 0, -60);
}
// Called when the game starts or when spawned
void ATrapBall::BeginPlay()
{
    Super::BeginPlay();
    state = State::wait;
}

// Called every frame
void ATrapBall::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
    if (state == State::wait)return;
    angle += DeltaTime / zTime * 360;
    pos.Z = point->Z + r * FMath::Cos(FMath::DegreesToRadians(angle));
    pos.Y = point->Y + r * FMath::Sin(FMath::DegreesToRadians(angle));
    SetActorLocation(pos);
}
void ATrapBall::Run() {
    state = State::run;
}


BP_TrapBall:
Snipaste_2020-12-13_16-54-24.jpg

GameController

GameController.h & GameController.cpp


h:

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MyGameController.generated.h"

UCLASS()
class STARTBALL_API AMyGameController : public AActor
{
    GENERATED_BODY()

public: 
    // Sets default values for this actor's properties
    AMyGameController();
    UPROPERTY(BlueprintReadOnly, Category = "myCategory")
        int score;
    const static int runTrapBallScore = 15;
protected:
    // Called when the game starts or when spawned
    virtual void BeginPlay() override;

public: 
    // Called every frame
    virtual void Tick(float DeltaTime) override;
    UFUNCTION(BlueprintCallable, Category = "BPFunc_Lib")
    void AddStore(int EndX,FVector pos);                //根据Ball在点击时的当前坐标加分
    UFUNCTION(BlueprintCallable, Category = "BPFunc_Lib")
        bool AlreadyRunTrapBall();
};

cpp:

// Fill out your copyright notice in the Description page of Project Settings.

#include "MyGameController.h"

// Sets default values
AMyGameController::AMyGameController()
{
    // Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
    PrimaryActorTick.bCanEverTick = true;

}

// Called when the game starts or when spawned
void AMyGameController::BeginPlay()
{
    Super::BeginPlay();

}

// Called every frame
void AMyGameController::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

}
//Ball:上升速度,终点X轴分量,坐标
void AMyGameController::AddStore(const int EndX,FVector pos) {
    score += (EndX - pos.X) / EndX * 10;
}
bool AMyGameController::AlreadyRunTrapBall() {
    return score >= runTrapBallScore;
}


BP_MyGameController本事没有事件函数,但提供了一些蓝图函数。
Init:
Snipaste_2020-12-13_16-56-36.jpg

GameOver:
Snipaste_2020-12-13_16-56-56.jpg

SetScore:
Snipaste_2020-12-13_16-57-24.jpg

实现效果:

下载连接

Unity3d版本
UE4版本

最后修改:2021 年 06 月 20 日
如果觉得我的文章对你有用,请随意赞赏