【Unity小游戏】游戏开发案例-Unity打造畅玩无阻的小游戏(上)

击球方阵

乒乓克隆

  • 使用立方体建造竞技场、球拍和球。
  • 移动球和球拍。
  • 击球并得分。
  • 让相机感受到冲击力。
  • 给游戏一个抽象的霓虹灯外观。

这是有关基础游戏的系列教程中的第一个教程。在其中,我们将创建一个简单的 Pong 克隆。

本教程是使用 Unity 2021.3.16f1 制作的。

在方形竞技场中用球拍击打方球

本系列将涵盖简单游戏基础游戏的创建,以展示如何在短时间内将想法转变为最小的工作游戏。这些游戏将是克隆的,所以我们不必从头开始发明一个新想法,但我们会以某种方式偏离标准。

除了保持简单之外,我们还将为这个系列设置一个设计约束来限制自己:我们只能渲染默认的立方体和世界空间文本,仅此而已。另外,我不包括声音。

本系列假定您至少已经完成了基础知识系列,以及您选择的更多系列,以便您熟悉 Unity 中的工作和编程。我不会像其他系列那样详细地展示每个步骤,假设您可以自己创建游戏对象并在检查器中连接内容而无需屏幕截图。我也不会解释基本的数学或运动定律。

游戏场景

我们将在本教程中克隆的游戏是 Pong,这是乒乓球或网球的非常抽象的表示形式。你可以克隆 Pong 的想法,但不要给它一个类似的名字,以免会要求你把它拿下来。所以我们把我们的游戏命名为击球方阵,因为它都是正方形。

我们将从新 3D 项目的默认示例场景开始,尽管重命名为 .我们将使用的唯一包是您选择的编辑器集成包(在我的例子中)及其依赖项。这些包的依赖项是 Burst, Core RP Library, Custom NUnit, Mathematics, Seacher, Shader Graph, Test Framework 和 Unity UI。

创建渲染/URP 资源(使用通用渲染器)资源,并将其用于项目设置中的图形/可编程渲染管线设置和质量/渲染管线资源。

竞技场

创建默认多维数据集并将其转换为预制件。移除它的对撞机,因为我们不会依赖物理系统。使用其中四个在一个维度上缩放到 20 个单位,以形成 XZ 平面上原点周围 20×20 个正方形区域的边界。由于立方体的厚度为一个单位,因此每个立方体在适当的方向上从原点移动 10.5 个单位。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-f0XUScMH-1682677243478)(null)]

为了使它更有趣一些,请再添加四个放大到大小 2 的预制件实例,并使用它们来填充边界角。将其 Y 坐标设置为 0.5,以便所有底部对齐。同时调整主摄像头,使其显示整个竞技场的自上而下视图。
请添加图片描述

竞技场需要有两个桨(板)。创建另一个默认多维数据集并将其转换为新的预制件,同样没有碰撞体。将其比例设置为 (8, 1.1, 1.1) 并为其提供白色材质。将它的两个实例添加到场景中,以便它们与底部和顶部边界的中间重叠,如上所示。
请添加图片描述

我们最不需要的是一个球,它也将是一个立方体。为此创建另一个立方体预制件(以防您以后决定添加多个球),并为其提供黄色材质。将它的实例放在场景的中心。

请添加图片描述

组件

虽然我们的游戏非常简单,可以用一个脚本控制一切,但我们会在逻辑上拆分功能,以使代码更易于理解。我们现在将创建明显的组件类型,稍后再填充它们。

首先,我们有一个移动的球,因此创建一个扩展类并将其作为组件添加到球预制件中。**Ball**``MonoBehaviour

using UnityEngine;

public class Ball : MonoBehaviour {}

其次,我们有会尝试击球的球拍,因此请为它们创建一个组件类型并将其添加到球拍预制件中。**Paddle**

using UnityEngine;

public class Paddle : MonoBehaviour {}

第三,我们需要一个控制游戏循环并与球和球拍通信的组件。只需命名它,给它配置字段以连接球和两个球拍,将其附加到场景中的空游戏对象,然后连接起来。**Game**

using UnityEngine;

public class Game : MonoBehaviour
{
[SerializeField]
Ball ball;

[SerializeField]
Paddle bottomPaddle, topPaddle;
}

控制球

比赛的重点在于控制球。球在竞技场上直线移动,直到击中某物。每个球员都试图定位其球拍,使其击中球并将其弹回另一侧。

位置和速度

为了移动,需要跟踪它的位置和速度。由于这实际上是一个 2D 游戏,我们将为此使用字段,其中 2D Y 维度表示 3D Z 维度。我们从恒定的 X 和 Y 速度开始,可通过单独的可序列化浮点字段进行配置。我使用 8 和 10 作为默认值。**Ball**``Vector2

public class Ball : MonoBehaviour
{
[SerializeField, Min(0f)]
float
constantXSpeed = 8f,
constantYSpeed = 10f;

Vector2 position, velocity;
}

我们最终可能会在每次更新时对球的位置和速度进行各种调整,所以我们不要一直设置它。相反,请为此创建一个公共方法。Transform.localPosition``UpdateVisualization

public void UpdateVisualization () =>
transform.localPosition = new Vector3(position.x, 0f, position.y);

我们不会让球不会自行移动,而是通过公共方法让它执行标准运动。Move

public void Move () => position += velocity * Time.deltaTime;

我们还为它提供了一个公共方法,为新游戏做好准备。球从竞技场的中心开始,更新其可视化效果以匹配,并使用配置的速度。由于底部球拍将由玩家控制,因此将速度的 Y 分量设置为负数,使其首先向玩家移动。StartNewGame

public void StartNewGame ()
{
position = Vector2.zero;
UpdateVisualization();
velocity = new Vector2(constantXSpeed, -constantYSpeed);
}

现在可以控制球了。至少,当它唤醒球时,球应该开始一个新的游戏,当它更新时,球应该移动,然后更新它的可视化。**Game**

void Awake () => ball.StartNewGame();

void Update ()
{
ball.Move();
ball.UpdateVisualization();
}

跳出边界

此时,我们有一个球在进入游戏模式后开始移动,并且继续前进,穿过底部边界并消失在视野之外。 不直接知道竞技场的边界,我们会保持这种状态。相反,我们将向其添加两个公共方法,这些方法强制在给定边界的单个维度中反弹。我们只是假设退回请求是合适的。**Ball**

当某个边界被越过时,就会发生反弹,这意味着球目前超出了边界。这必须通过反映其轨迹来纠正。最终位置只是等于边界减去当前位置的两倍。此外,该维度中的速度会翻转。这些反弹是完美的,因此另一个维度的位置和速度不受影响。创建和实现此目的的方法。BounceX``BounceY

public void BounceX (float boundary)
{
position.x = 2f * boundary - position.x;
velocity.x = -velocity.x;
}

public void BounceY (float boundary)
{
position.y = 2f * boundary - position.y;
velocity.y = -velocity.y;
}

当球的边缘接触边界而不是其中心时,就会发生适当的反弹。因此,我们需要知道球的大小,为此我们将添加一个以范围表示的配置字段,默认情况下设置为 0.5,与单位立方体匹配。

[SerializeField, Min(0f)]
float
constantXSpeed = 8f,
constantYSpeed = 10f,
extents = 0.5f;

球本身不会决定何时反弹,因此其范围和位置必须可公开访问。为此添加 getter 属性。

public float Extents => extents;

public Vector2 Position => position;

**Game**还需要知道竞技场的范围,可以是以原点为中心的任何矩形。为此指定一个配置字段,默认情况下设置为 10×10。Vector2

[SerializeField, Min(0f)]
Vector2 arenaExtents = new Vector2(10f, 10f);

我们首先检查 Y 维度。为此创建一个方法。要检查的范围等于竞技场 Y 范围减去球范围。如果球低于负范围或高于正范围,则它应该从适当的边界反弹。在移动球和更新其可视化效果之间调用此方法。BounceYIfNeeded

void Update ()
{
ball.Move();
BounceYIfNeeded();
ball.UpdateVisualization();
}

void BounceYIfNeeded ()
{
float yExtents = arenaExtents.y - ball.Extents;
if (ball.Position.y < -yExtents)
{
ball.BounceY(-yExtents);
}
else if (ball.Position.y > yExtents)
{
ball.BounceY(yExtents);
}
}

球现在从底部和顶部边缘反弹。要同时从左右边缘反弹,请以相同的方式创建一个方法,但针对 X 维度并在 .BounceXIfNeeded``BounceYIfNeeded

void Update ()
{
ball.Move();
BounceYIfNeeded();
BounceXIfNeeded();
ball.UpdateVisualization();
}

void BounceXIfNeeded ()
{
float xExtents = arenaExtents.x - ball.Extents;
if (ball.Position.x < -xExtents)
{
ball.BounceX(-xExtents);
}
else if (ball.Position.x > xExtents)
{
ball.BounceX(xExtents);
}
}

球现在被竞技场所包含,从边缘反弹,永远不会逃脱。

移动桨

我们还需要知道桨的范围和速度,因此将它们的配置字段添加到 ,默认情况下设置为 4 和 10。**Paddle**

public class Paddle : MonoBehaviour
{
[SerializeField, Min(0f)]
float
extents = 4f,
speed = 10f;
}

**Paddle**还获取一个公共方法,这次使用目标和竞技场范围的参数,两者都在 X 维度中。让它最初获得位置,夹紧 X 坐标,使桨不能移动超过应有的位置,然后设置其位置。Move

public void Move (float target, float arenaExtents)
{
Vector3 p = transform.localPosition;
float limit = arenaExtents - extents;
p.x = Mathf.Clamp(p.x, -limit, limit);
transform.localPosition = p;
}

球拍应该由玩家控制,但有两种玩家:人工智能和人类。让我们首先实现一个简单的 AI 控制器,方法是创建一个获取 X 位置和目标并返回新 X 的方法。如果它在目标的左侧,它只是以最大速度向右移动,直到它与目标匹配,否则它以相同的方式向左移动。这是一个没有任何预测的愚蠢反应式 AI,它的难度只取决于它的速度。AdjustByAI

float AdjustByAI (float x, float target)
{
if (x < target)
{
return Mathf.Min(x + speed * Time.deltaTime, target);
}
return Mathf.Max(x - speed * Time.deltaTime, target);
}

对于人类玩家,我们创建了一个不需要目标的方法,只需根据按下的箭头键向左或向右移动。如果同时按下两者,它将不会移动。AdjustByPlayer

float AdjustByPlayer (float x)
{
bool goRight = Input.GetKey(KeyCode.RightArrow);
bool goLeft = Input.GetKey(KeyCode.LeftArrow);
if (goRight && !goLeft)
{
return x + speed * Time.deltaTime;
}
else if (goLeft && !goRight)
{
return x - speed * Time.deltaTime;
}
return x;
}

现在添加一个切换开关来确定球拍是否由 AI 控制,并调用适当的方法来调整位置的 X 坐标。Move

[SerializeField]
bool isAI;

…

public void Move (float target, float arenaExtents)
{
Vector3 p = transform.localPosition;
p.x = isAI ? AdjustByAI(p.x, target) : AdjustByPlayer(p.x);
float limit = arenaExtents - extents;
p.x = Mathf.Clamp(p.x, -limit, limit);
transform.localPosition = p;
}

在 的开头移动两个桨。**Game**.Update

void Update ()
{
bottomPaddle.Move(ball.Position.x, arenaExtents.x);
topPaddle.Move(ball.Position.x, arenaExtents.x);
ball.Move();
BounceYIfNeeded();
BounceXIfNeeded();
ball.UpdateVisualization();
}

桨现在要么响应箭头键,要么自行移动。启用顶部桨的 AI,并将其速度降低到 5,因此很容易被击败。请注意,您可以在玩游戏时随时启用或禁用 AI。

玩游戏

现在我们有了功能性球和球拍,我们可以制作一个可玩的游戏。玩家尝试移动他们的球拍,以便他们将球弹回竞技场的另一侧。如果他们没有做到这一点,他们的对手就会得分。

击球

添加一个方法,该方法返回它是否在其当前位置击中球,给定其 X 位置和范围。我们可以通过从球中减去球拍位置,然后将其除以球拍加球范围来检查这一点。结果是一个命中系数,如果球拍成功击中球,则在 -1-1 范围内的某个地方。HitBall``**Paddle**

public bool HitBall (float ballX, float ballExtents)
{
float hitFactor =
(ballX - transform.localPosition.x) /
(extents + ballExtents);
return -1f <= hitFactor && hitFactor <= 1f;
}

命中因子本身也很有用,因为它描述了球相对于球拍中心和范围的击球位置。在乒乓球中,这决定了球从球拍上反弹的角度。因此,让我们通过输出参数使其可用。

public bool HitBall (float ballX, float ballExtents, out float hitFactor)
{
hitFactor =
(ballX - transform.localPosition.x) /
(extents + ballExtents);
return -1f <= hitFactor && hitFactor <= 1f;
}

如果球在被球拍击中后速度发生变化,那么我们简单的弹跳代码是不够的。我们必须将时间倒退到反弹发生的那一刻,确定新的速度,并将时间向前移动到当前时刻。

在 中,重命名并添加一个可配置的 ,默认情况下设置为 20。然后创建一个覆盖其当前方法的方法,给定起始位置和速度系数。新速度成为按因子缩放的最大速度,然后确定具有给定时间增量的新位置。**Ball**``constantXSpeed``startSpeed``maxXSpeed``SetXPositionAndSpeed

[SerializeField, Min(0f)]
float
maxXSpeed = 20f,
startXSpeed = 8f,
constantYSpeed = 10f,
extents = 0.5f;

…

public void StartNewGame ()
{
position = Vector2.zero;
UpdateVisualization();
velocity = new Vector2(startXSpeed, -constantYSpeed);
}

public void SetXPositionAndSpeed (float start, float speedFactor, float deltaTime)
{
velocity.x = maxXSpeed * speedFactor;
position.x = start + velocity.x * deltaTime;
}

要找到反弹的确切时刻,必须知道球的速度,因此请为其添加公共 getter 属性。

public Vector2 Velocity => velocity;

**Game**现在在 Y 维度上弹跳时有更多的工作要做。因此,我们将首先调用一个新方法,而不是直接调用,其中包含防守桨的参数。ball.Bounce``**Game**.BounceY

void BounceYIfNeeded ()
{
float yExtents = arenaExtents.y - ball.Extents;
if (ball.Position.y < -yExtents)
{
BounceY(-yExtents, bottomPaddle);
}
else if (ball.Position.y > yExtents)
{
BounceY(yExtents, topPaddle);
}
}

void BounceY (float boundary, Paddle defender)
{
ball.BounceY(boundary);
}

必须做的第一件事是确定反弹发生的时间。这是通过从球的 Y 位置减去边界并将其除以球的 Y 速度来发现的。请注意,我们忽略了球拍比边界粗一点,因为这只是一个视觉上的东西,以避免渲染时 Z 战斗。BounceY

float durationAfterBounce = (ball.Position.y - boundary) / ball.Velocity.y;

ball.BounceY(boundary);

接下来,计算反弹发生时球的 X 位置。

float durationAfterBounce = (ball.Position.y - boundary) / ball.Velocity.y;
float bounceX = ball.Position.x - ball.Velocity.x * durationAfterBounce;

之后我们执行原始的 Y 弹跳,然后检查防守球拍是否击中球。如果是这样,请根据弹跳 X 位置、命中系数以及它发生的时间设置球的 X 位置和速度。

ball.BounceY(boundary);

if (defender.HitBall(bounceX, ball.Extents, out float hitFactor))
{
ball.SetXPositionAndSpeed(bounceX, hitFactor, durationAfterBounce);
}

在这一点上,我们必须考虑在两个维度上都发生反弹的可能性。在这种情况下,反弹的 X 位置可能最终位于竞技场之外。这可以通过先执行 X 反弹来防止,但仅在需要时。为了支持此更改,因此它检查的 X 位置是通过参数提供的。BounceXIfNeeded

void Update ()
{
…
BounceXIfNeeded(ball.Position.x);
ball.UpdateVisualization();
}

void BounceXIfNeeded (float x)
{
float xExtents = arenaExtents.x - ball.Extents;
if (x < -xExtents)
{
ball.BounceX(-xExtents);
}
else if (x > xExtents)
{
ball.BounceX(xExtents);
}
}

然后,我们还可以根据它到达 Y 边界的位置调用 in。因此,我们只处理 X 反弹发生在 Y 反弹之前。之后再次计算反弹 X 位置,现在可能基于不同的球位置和速度。BounceXIfNeeded``BounceY

float durationAfterBounce = (ball.Position.y - boundary) / ball.Velocity.y;
float bounceX = ball.Position.x - ball.Velocity.x * durationAfterBounce;

BounceXIfNeeded(bounceX);
bounceX = ball.Position.x - ball.Velocity.x * durationAfterBounce;
ball.BounceY(boundary);

接下来,球的速度会根据它击中球拍的位置而变化。它的 Y 速度始终保持不变,而它的 X 速度是可变的。这意味着从一个桨移动到另一个桨总是需要相同的时间,但它可能会横向移动一点或很多。Pong 的球的行为方式相同。

与乒乓球不同的是,在我们的游戏中,当球拍错过球拍时,球仍然会从竞技场的边缘反弹,而在乒乓球中会触发新一轮。我们的游戏只是不间断地进行,不会中断游戏玩法。让我们将这种行为作为我们游戏的独特怪癖。

得分点

当防守球拍错过球时,对手得分。我们将在地板或竞技场上显示两名球员的得分。为此创建一个文本游戏对象,通过 。这将触发一个弹出窗口,我们从中选择选项 .

将文本转换为预制件。调整其宽度为 20,高度为 6,Y 位置为 −0.5,X 旋转为 90°。为其组件指定起始文本 0、字体大小 72,并将其对齐方式设置为居中和中间。然后创建它的两个实例,Z 位置为 −5 和 5。RectTransform``TextMeshPro
请添加图片描述

我们认为玩家和它的球拍是一回事,因此将通过可配置的字段跟踪对其分数文本的引用。**Paddle**``TMPro.TextMeshPro

using TMPro;
using UnityEngine;

public class Paddle : MonoBehaviour
{
[SerializeField]
TextMeshPro scoreText;

…
}

它还将跟踪自己的商店。给它一个私有方法,用一个新的分数替换它当前的分数,并更新它的文本以匹配。这可以通过使用字符串和分数作为参数调用文本组件来完成。SetScore``SetText``"{0}"

int score;

…

void SetScore (int newScore)
{
score = newScore;
scoreText.SetText("{0}", newScore);
}

若要开始新游戏,请引入将分数设置为零的公共方法。此外,添加一个公共方法,该方法递增分数并返回这是否会导致玩家获胜。为了确定,给它一个获胜所需积分的参数。StartNewGame``ScorePoint

public void StartNewGame ()
{
SetScore(0);
}

public bool ScorePoint (int pointsToWin)
{
SetScore(score + 1);
return score >= pointsToWin;
}

**Game**现在也必须在两个桨上调用,所以让我们给它自己的方法来传递消息,它在 .StartNewGame``StartNewGame``Awake

void Awake () => StartNewGame();

void StartNewGame ()
{
ball.StartNewGame();
bottomPaddle.StartNewGame();
topPaddle.StartNewGame();
}

使获胜的积分数量可配置,最小为 2,默认值为 3。然后将攻击者球拍作为第三个参数添加到其中,如果防守方没有击中球,则让它调用它。如果这导致攻击者获胜,则开始新游戏。BounceY``ScorePoint

[SerializeField, Min(2)]
int pointsToWin = 3;

…

void BounceYIfNeeded ()
{
float yExtents = arenaExtents.y - ball.Extents;
if (ball.Position.y < -yExtents)
{
BounceY(-yExtents, bottomPaddle, topPaddle);
}
else if (ball.Position.y > yExtents)
{
BounceY(yExtents, topPaddle, bottomPaddle);
}
}

void BounceY (float boundary, Paddle defender, Paddle attacker)
{
…

if (defender.HitBall(bounceX, ball.Extents, out float hitFactor))
{
ball.SetXPositionAndSpeed(bounceX, hitFactor, durationAfterBounce);
}
else if (attacker.ScorePoint(pointsToWin))
{
StartNewGame();
}
}

新游戏倒计时

与其立即开始新游戏,不如引入延迟,在此期间可以欣赏最终分数。让我们也推迟游戏的初始开始,以便玩家可以做好准备。创建一个新的文本实例以在竞技场中心显示倒计时,其字体大小减小到 32 并作为其初始文本。

请添加图片描述

为倒计时文本和新的游戏延迟持续时间提供配置字段,最小值为 1,默认值为 3。还要给它一个字段来跟踪新游戏之前的倒计时,并将其设置为延迟持续时间,而不是立即开始新游戏。**Game**``Awake

using TMPro;
using UnityEngine;

public class Game : MonoBehaviour
{
…

[SerializeField]
TextMeshPro countdownText;

[SerializeField, Min(1f)]
float newGameDelay = 3f;

float countdownUntilNewGame;

void Awake () => countdownUntilNewGame = newGameDelay;

我们仍然总是移动球拍,这样玩家就可以在倒计时期间进入位置。将所有其他代码移动到一个新方法,我们仅在倒计时为零或更小时才调用该方法。否则我们调用 ,一种减少倒计时并更新其文本的新方法。Update``UpdateGame``UpdateCountdown

void Update ()
{
bottomPaddle.Move(ball.Position.x, arenaExtents.x);
topPaddle.Move(ball.Position.x, arenaExtents.x);

if (countdownUntilNewGame <= 0f)
{
UpdateGame();
}
else
{
UpdateCountdown();
}
}

void UpdateGame ()
{
ball.Move();
BounceYIfNeeded();
BounceXIfNeeded(ball.Position.x);
ball.UpdateVisualization();
}

void UpdateCountdown ()
{
countdownUntilNewGame -= Time.deltaTime;
countdownText.SetText("{0}", countdownUntilNewGame);
}

如果倒计时达到零,请停用倒计时文本并开始新游戏,否则更新文本。但是,让我们只显示整秒钟。我们可以通过倒计时的上限来做到这一点。要使初始文本可见,请仅在显示值小于配置的延迟时才更改它。如果延迟设置为整数,则文本将在第一秒内可见。

countdownUntilNewGame -= Time.deltaTime;
if (countdownUntilNewGame <= 0f)
{
countdownText.gameObject.SetActive(false);
StartNewGame();
}
else
{
float displayValue = Mathf.Ceil(countdownUntilNewGame);
if (displayValue < newGameDelay)
{
countdownText.SetText("{0}", displayValue);
}
}

让我们在没有比赛进行时隐藏球。由于在开发过程中让球在场景中处于活动状态很方便,因此我们给出了一种自行停用的方法。然后在 结束时再次激活它。此外,还引入了一种公共方法,该方法将其 X 位置设置为状态的中心——因此 AI 将在游戏之间将其球拍移动到中间——并自行停用。**Ball**``Awake``StartNewGame``EndGame

void Awake () => gameObject.SetActive(false);

public void StartNewGame ()
{
position = Vector2.zero;
UpdateVisualization();
velocity = new Vector2(startXSpeed, -constantYSpeed);
gameObject.SetActive(true);
}

public void EndGame ()
{
position.x = 0f;
gameObject.SetActive(false);
}

也给出一个方法,当玩家获胜时调用,而不是立即开始新游戏。在其中,重置倒计时,将倒计时文本设置为并激活它,并告诉球游戏结束。**Game**``EndGame

void BounceY (float boundary, Paddle defender, Paddle attacker)
{
…

if (defender.HitBall(bounceX, ball.Extents, out float hitFactor))
{
ball.SetXPositionAndSpeed(bounceX, hitFactor, durationAfterBounce);
}
else if (attacker.ScorePoint(pointsToWin))
{
EndGame();
}
}

void EndGame ()
{
countdownUntilNewGame = newGameDelay;
countdownText.SetText("GAME OVER");
countdownText.gameObject.SetActive(true);
ball.EndGame();
}

随机性

在这一点上,我们有一个最小的功能游戏,但让我们通过以两种不同的方式添加一些随机性来让它更有趣。首先,不要总是以相同的 X 速度开始,而是将可配置的最大启动 X 速度默认设置为 2,并在每场比赛开始时使用它来随机化其速度。**Ball**

[SerializeField, Min(0f)]
float
maxXSpeed = 20f,
maxStartXSpeed = 2f,
constantYSpeed = 10f,
extents = 0.5f;

…

public void StartNewGame ()
{
position = Vector2.zero;
UpdateVisualization();

velocity.x = Random.Range(-maxStartXSpeed, maxStartXSpeed);
velocity.y = -constantYSpeed;
gameObject.SetActive(true);
}

其次,给 AI 一个目标偏差,这样它就不会总是试图将球击到它的确切中心。为了控制这一点,引入了一个可配置的最大定位偏差,表示其范围的一小部分(类似于命中因子),默认情况下设置为 0.75。使用字段跟踪其当前偏差,并添加随机化的方法。**Paddle**``ChangeTargetingBias

[SerializeField, Min(0f)]
float
extents = 4f,
speed = 10f,
maxTargetingBias = 0.75f;

…

float targetingBias;

…

void ChangeTargetingBias () =>
targetingBias = Random.Range(-maxTargetingBias, maxTargetingBias);

目标偏差会改变每个新游戏以及球拍试图击球的时间。

public void StartNewGame ()
{
SetScore(0);
ChangeTargetingBias();
}

public bool HitBall (float ballX, float ballExtents, out float hitFactor)
{
ChangeTargetingBias();
…
}

要应用偏差,请在移动球拍之前将其添加到目标中。AdjustByAI

float AdjustByAI (float x, float target)
{
target += targetingBias * extents;
…
}

Unity小游戏开发实战:从零开始打造你的自己的游戏世界(上篇)
【Unity小游戏】游戏开发案例-Unity打造畅玩无阻的小游戏(下)