目标效果
要点分析
游戏物体
操纵球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的脚本代码:
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;
}
}
效果:
这种方式的优点就是实现简便,但缺点是不好控制Ball的落地点,容易出现球飞出屏幕或过早落地的情况,以及不好计算动态得分等。
所以我又想了另一种实现方式,自己手动模拟抛物线运动。
在每次切换状态后,先确定一个目标点(在横向轴做一些偏移,但前进轴距离固定(根据方向取0或10)),然后根据球的当前位置使球做抛物线运动并能精准落在目标点上。
实现逻辑:抛物线运动分为水平和垂直两个方向的运动,如果水平方向的运动和垂直方向的运动所用时间相同,即Ball在水平方向到达目标点时垂直方向也正好落地,那么球也就正好落在目标点上了。所以可以先固定Ball的水平方向的速度,每次切换状态时,根据Ball的当前坐标和目标点坐标的水平距离计算出到达所需时间,然后根据这个时间和Ball的当前高度与目标点的垂直距离,计算出Ball的上抛的初速度。接下来每帧根据两个方向的速度做水平位移和竖直位移,然后让竖直方向的速度减去重力加速度deltaTime即可。
其中,水平运动的所需时间为
$$t = vs$$
竖直上抛运动的位移公式为
$$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代码:
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脚本来控制游戏规则与流程,代码如下:
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); //重新加载本场景
}
}
最终效果:
UE4实现
用UE4实现在逻辑上也差不多,就是实操不同,大致用c++、类蓝图以及关卡蓝图制作,主要讲下制作流程,就不多介绍了。
关卡蓝图
在进入该关卡时,设置主摄像机,监听鼠标点击事件,显示鼠标指针,初始化一下GameController。
Ball
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,其它类也是如此。

TrapBall
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:

GameController
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:

GameOver:
SetScore:
实现效果: