游戏原型系列:(1)乒乓广场

本系列将介绍简单游戏原型的创建,以展示如何在短时间内将一个想法变成一个最小的可行游戏。这些游戏将是克隆的,因此我们不必从头开始发明新的想法。

除了保持简单之外,我们还将为本系列设置一个设计约束来限制我们自己:只能渲染默认的立方体和世界空间。

一、最终效果演示

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

二、场景搭建

创建一个默认的立方体并将其变成预制件。删除它的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碰撞边界

目前我们有一个球,在进入游戏模式后开始移动,并且会一直移动,穿过底部边界并消失在视线之外。球并不知道场地的边界,我们也将保持这种状态。我们将为它添加两个公共方法,用于在给定边界的单一维度上强制进行反弹。我们简单地假设反弹请求是合适的。

当某个边界被穿越时,会发生反弹,这意味着球目前在该边界之外。这必须通过反射其轨迹来纠正。最终位置简单地等于边界的两倍减去当前位置。同时,该维度上的速度也会反向。这些反弹是完美的,因此其他维度上的位置和速度不会受到影响。创建BounceXBounceY方法来实现这一点。

	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更容易地击中球。

未完待续……

本章介绍了游戏逻辑实现,下一章将介绍效果方面的内容,例如:相机抖动、粒子特效、高亮材质等等实现,同时也会给出项目文件。谢谢观看

  • 17
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值