本系列将介绍简单游戏原型的创建,以展示如何在短时间内将一个想法变成一个最小的可行游戏。这些游戏将是克隆的,因此我们不必从头开始发明新的想法。
除了保持简单之外,我们还将为本系列设置一个设计约束来限制我们自己:只能渲染默认的立方体和世界空间。
一、最终效果演示
- 使用立方体建造竞技场、桨和球。
- 移动球和桨。
- 击球并得分。
- 让相机感受到冲击力。
- 为游戏赋予抽象的霓虹灯外观。
二、场景搭建
创建一个默认的立方体并将其变成预制件。删除它的Collider,因为我们不会依赖物理系统。使用其中四个一维缩放至 20 个单位的单元,形成围绕 XZ 平面上原点的 20×20 正方形区域的边界。由于立方体的厚度为 1 个单位,因此每个立方体都会沿适当的方向从原点移动 10.5 个单位。
为了使它更有趣一点,再添加四个放大到尺寸 2 的预制件实例,并使用它们来填充边界角。将它们的 Y 坐标设置为 0.5,以便所有底部对齐。还要调整主摄像头,使其显示整个竞技场的自上而下的视图。
竞技场需要有两个球拍。创建另一个默认立方体并将其变成一个新的预制件,同样没有碰撞器。将其比例设置为 (8, 1.1, 1.1) 并为其指定白色材质。将其两个实例添加到场景中,使它们重叠底部和顶部边界的中间,如从下面看到的。
我们最后需要的是一个球,它也是一个立方体。为此创建另一个立方体预制件(以防您稍后决定添加多个球)并为其指定黄色材质。将它的一个实例放在场景的中心。
三、实现逻辑
尽管我们的游戏非常简单,我们可以用一个脚本控制一切,但我们将逻辑上分割功能以使代码更易于理解。
3.1所需脚本
首先,我们有一个会移动的球,因此创建一个Ball脚本,并将其作为组件添加到球预制件中。
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; }
3.2基本数据
比赛的重点是控制球。球在场地周围沿直线移动,直到撞到某物。每个球员都尝试调整其球拍的位置,以便击中球并将其弹回另一侧。
3.2.1位置和速度
为了移动Ball需要跟踪其位置和速度。由于这实际上是一个 2D 游戏,因此我们将使用Vector2字段来实现。我们从恒定的 X 和 Y 速度开始,可通过单独的可序列化浮点字段进行配置。我使用 8 和 10 作为默认值。
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;
我们还为它提供了一个公共的StartNewGame方法,用于为新游戏进行设置。球从场地中心开始,更新其可视化以匹配,并使用配置好的速度。由于底部的挡板将由玩家控制,所以让速度的Y分量为负,以便球首先向玩家移动。
public void StartNewGame () { position = Vector2.zero; UpdateVisualization(); velocity = new Vector2(constantXSpeed, -constantYSpeed); }
现在Game脚本可以控制球了。当Game启动时,球应该开始新游戏;当Game更新时,球应该移动并更新其可视化效果。
void Awake () => ball.StartNewGame(); void Update () { ball.Move(); ball.UpdateVisualization(); }
3.2.2碰撞边界
目前我们有一个球,在进入游戏模式后开始移动,并且会一直移动,穿过底部边界并消失在视线之外。球并不知道场地的边界,我们也将保持这种状态。我们将为它添加两个公共方法,用于在给定边界的单一维度上强制进行反弹。我们简单地假设反弹请求是合适的。
当某个边界被穿越时,会发生反弹,这意味着球目前在该边界之外。这必须通过反射其轨迹来纠正。最终位置简单地等于边界的两倍减去当前位置。同时,该维度上的速度也会反向。这些反弹是完美的,因此其他维度上的位置和速度不会受到影响。创建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; }
一个合适的反弹发生在球的边缘触碰到边界时,而不是其中心。因此,我们需要知道球的大小,为此我们将添加一个配置字段,以范围extents来表示,默认设置为0.5,以匹配单位立方体的大小。
[SerializeField, Min(0f)] float constantXSpeed = 8f, constantYSpeed = 10f, extents = 0.5f;
球本身不会决定何时反弹,因此它的范围和位置必须是公开可访问的。为它们添加公开的属性。
public float Extents => extents; public Vector2 Position => position;
游戏还需要知道场地的范围,这可以是以原点为中心的任何矩形。为此,给它一个Vector2配置字段,默认设置为10×10。
[SerializeField, Min(0f)] Vector2 arenaExtents = new Vector2(10f, 10f);
我们首先从检查Y维度开始。为此创建一个BounceYIfNeeded方法。要检查的范围等于场地Y维度范围减去球的范围。如果球在负范围以下或在正范围以上,则它应该在相应的边界上反弹。在移动球和更新其可视化之间调用此方法。
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); } }
现在球被竞技场所容纳,从边缘反弹并且永远不会逃逸。
注意:在帧率下降的情况下,球有可能逃脱吗?
理论上是的。如果它移动得如此之快,以至于在同一个时间步长内应该在相同维度上反弹到相对的另一边,那么它会在一个帧内逃脱。然而,Unity的默认最大时间间隔是三分之一秒,这意味着在我们的竞技场中,这需要大于60的速度,这将会太快而无法进行游戏。
3.2.3移动球拍
我们还需要知道球拍的范围和速度,因此将它们的配置字段添加到Paddle,默认设置为 4 和 10。
public class Paddle : MonoBehaviour { [SerializeField, Min(0f)] float extents = 4f, speed = 10f; }
球拍也获得了一个公共的Move方法,这次带有目标和场地范围两个参数,都是X维度上的。首先让它获取其位置,然后限制X坐标以确保桨不会超过其应该移动的范围,然后设置其位置。
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和人类。让我们首先实现一个简单的AI控制器,通过创建一个AdjustByAI方法,该方法接受一个X位置和目标,并返回一个新的X。如果它在目标的左侧,它只会以最大速度向右移动,直到与目标匹配,否则它会以同样的方式向左移动。这是一个没有任何预测的简单的反应式AI,其难度仅取决于其速度。
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控制,并在Move方法中调用相应的方法来调整位置的X坐标。
[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。
3.3游戏逻辑
现在我们有了功能正常的球和球拍,我们可以制作一个可玩的游戏了。玩家尝试移动他们的桨板,使球反弹回场地的另一边。如果他们没能做到这一点,他们的对手就得一分。
3.3.1击球
在 Paddle 类中添加一个名为 HitBall 的方法,该方法在给定其 X 位置和范围的情况下,返回其当前位置是否击中了球。我们可以通过从球的位置中减去拍子的位置,然后将结果除以拍子加球的范围来检查这一点。如果拍子成功击中了球,那么结果是一个介于 -1 到 1 之间的击中因子。
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; }
如果球在被拍子击中后速度发生变化,那么我们简单的反弹代码就不够用了。我们需要将时间回溯到球反弹发生的那一刻,确定新的速度,然后将时间推进到当前时刻。
在 Ball 类中,将 constantXSpeed 常量重命名为 startSpeed,并添加一个可配置的 maxXSpeed,默认设置为 20。然后创建一个 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; }
为了找到球反弹的确切时刻,必须知道球的速度,因此为它添加一个公共的属性。
public Vector2 Velocity => velocity;
现在游戏在 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); }
BounceY 首先要做的是确定反弹发生了多久。这可以通过从球的 Y 位置中减去边界值,然后除以球的 Y 速度来找到。请注意,我们忽略了拍子比边界稍微厚一些这一事实,因为这只是为了避免在渲染时发生 Z 冲突的一个视觉效果。
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 轴反弹来避免,但仅当需要时这样做。为了支持这一改变,我们需要调整 BounceXIfNeeded 方法,以便通过参数提供它要检查的 X 位置。
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); } }
然后,我们还可以在 BounceY 方法中基于球将要击中 Y 边界的位置来调用 BounceXIfNeeded 方法。因此,我们只在 X 轴反弹发生在 Y 轴反弹之前时处理 X 轴反弹。之后,再次计算反弹的 X 位置,现在可能基于不同的球位置和速度。
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 轴速度是可变的。这意味着球从拍子到拍子总是花费相同的时间移动,但它可能会在水平方向上移动一小段距离或很远。
在我们的游戏中,如果拍子未能接住球,球仍然会在场地的边缘反弹,不会中断游戏玩法。让我们将这种行为保持为我们游戏的一个独特之处。
3.3.2得分
当防守方拍子未能接到球时,对手将获得一分。我们将在场地或竞技场上显示两名玩家的分数。为此,我们将通过GameObject/3D Object/Text - TextMeshPro来创建一个TextMeshPro文本游戏对象。
将文本转换为一个预制件(Prefab)。调整其RectTransform(矩形变换),使其宽度为20,高度为6,Y位置为-0.5,X旋转为90°。给其TextMeshPro组件设置初始文本为0,字体大小为72,并将其对齐方式设置为居中和中部。然后创建它的两个实例,Z位置分别为-5和5。
我们视玩家和其拍子为同一事物,因此拍子将通过可配置的TMPro.TextMeshPro字段来跟踪其分数文本的引用。
using TMPro; using UnityEngine; public class Paddle : MonoBehaviour { [SerializeField] TextMeshPro scoreText; … }
它还将跟踪自己的存储。给它一个私有的SetScore方法,该方法将用新的分数替换其当前分数,并更新其文本以匹配。这可以通过在文本组件上调用SetText方法,并将字符串"{0}"和分数作为参数来实现。
public void StartNewGame () { SetScore(0); } public bool ScorePoint (int pointsToWin) { SetScore(score + 1); return score >= pointsToWin; }
现在游戏也需要在两个拍子上调用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(); } }
3.3.3倒计时
我们不应该立即开始新游戏,而应该设置一个延迟,在此期间可以欣赏最终得分。此外,我们也应该延迟游戏的初始开始时间,以便玩家可以做好准备。创建一个新的文本实例,在竞技场中心显示倒计时,并将其字体大小减小到32,并将“GET READY”作为初始文本。
为倒计时文本和游戏新开始延迟时长提供游戏配置字段,其中延迟时长的最小值为1,默认值为3。同时,提供一个字段来跟踪直到新游戏开始的倒计时,并在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); }
如果倒计时到达零,就停用倒计时文本并开始新游戏,否则更新文本。但让我们只显示整数秒。我们可以通过对倒计时进行向上取整来实现这一点。为了使初始文本可见,只有当显示值小于配置的延迟时间时才更改它。如果延迟被设置为一个整数,那么“GET READY”文本将在第一秒内可见。
countdownUntilNewGame -= Time.deltaTime; if (countdownUntilNewGame <= 0f) { countdownText.gameObject.SetActive(false); StartNewGame(); } else { float displayValue = Mathf.Ceil(countdownUntilNewGame); if (displayValue < newGameDelay) { countdownText.SetText("{0}", displayValue); } }
当游戏未开始时,我们把球藏起来。由于在游戏开发过程中让球保持活跃状态很方便,我们给Ball赋予了一个Awake方法,使其在游戏开始时自动隐藏。然后在StartNewGame时再次激活它。同时,引入一个公开的EndGame方法,该方法将其X位置设置为状态的中心——这样AI就会在比赛之间将球拍移动到中间——然后使球消失。
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 方法,当玩家获胜时调用它,而不是立即开始新游戏。在这个方法中,重置倒计时,将倒计时文本设置为“GAME OVER”并激活它,同时也告知小球游戏已经结束。
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(); }
3.3.4随机性
目前我们已经有了一个功能基础的游戏,但让我们通过两种方式增加一些随机性来使游戏更加有趣。首先,不要总是以相同的X速度开始,给Ball一个可配置的最大起始X速度,默认设置为2,并在每局游戏开始时使用这个速度来随机化其速度。
[SerializeField, Min(0f)] float maxXSpeed = 20f, maxStartXSpeed = 2f, constantYSpeed = 10f, extents = 0.5f; … public void StartNewGame () { position = Vector2.zero; UpdateVisualization(); //velocity = new Vector2(startXSpeed, -constantYSpeed); velocity.x = Random.Range(-maxStartXSpeed, maxStartXSpeed); velocity.y = -constantYSpeed; gameObject.SetActive(true); }
第二,给挡板(Paddle)的AI一个目标偏移量,这样它就不会总是试图准确击中球的中心。为了控制这一点,引入一个可配置的最大目标偏移量,这个偏移量表示其范围的一个比例——类似于命中因子——默认设置为0.75。使用一个字段来跟踪其当前偏移量,并添加一个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;
…
}
3.3.5收缩球拍
作为我们游戏的一个最终特色,让我们在挡板每次得分时都缩小它的大小。这样就根据玩家接近胜利的程度创造了一个障碍。将其当前的范围转换为私有字段,并将最小值和最大值都设置为可配置的,两者默认都设置为4。引入一个SetExtents方法来替换当前的范围,同时也调整游戏对象的本地缩放比例以匹配。
[SerializeField, Min(0f)] float //extents = 4f, minExtents = 4f, maxExtents = 4f, speed = 10f, maxTargetingBias = 0.75f; … float extents, targetingBias; … void SetExtents (float newExtents) { extents = newExtents; Vector3 s = transform.localScale; s.x = 2f * newExtents; transform.localScale = s; }
在SetScore方法结束时设置范围,这取决于挡板离获胜有多近。这是通过根据新分数除以获胜所需分数再减一的结果,从最大范围插值到最小范围来完成的。为此添加所需的参数,该参数默认可以是大于1的任何值,以便在将其设置为零时能够正常工作。
public bool ScorePoint (int pointsToWin) { SetScore(score + 1, pointsToWin); return score >= pointsToWin; } … void SetScore (int newScore, float pointsToWin = 1000f) { score = newScore; scoreText.SetText("{0}", newScore); SetExtents(Mathf.Lerp(maxExtents, minExtents, newScore / (pointsToWin - 1f))); }
我们还应该在挡板唤醒时重置分数,以确保在游戏准备阶段其初始大小是正确的。
void Awake () { SetScore(0); }
让我们将底部挡板的最小尺寸设置为1,顶部挡板的最小尺寸设置为3.5,以便AI更容易地击中球。
未完待续……
本章介绍了游戏逻辑实现,下一章将介绍效果方面的内容,例如:相机抖动、粒子特效、高亮材质等等实现,同时也会给出项目文件。谢谢观看