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

击球方阵

乒乓克隆

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

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

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

沿上文内容

收缩桨

作为我们游戏的最后一个行为,让我们在每次得分时缩小。这根据玩家离获胜的接近程度创建让分盘。将其当前范围转换为私有字段,并使其最小值和最大值可配置,默认情况下均设置为 4。引入了一种替换当前范围的方法,该方法还可以调整游戏对象的局部比例以匹配。**Paddle**``SetExtents

[SerializeField, Min(0f)]
float

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;
}

根据距离赢得划桨的接近程度,在 的末尾设置范围。这是通过根据新分数除以获胜所需的分数减去 1 从最大范围到最小范围插值来完成的。为此添加所需的参数,默认情况下,该参数可以是大于 1 的任何值,当它设置为零时。SetScore

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

活跃的相机

随着游戏玩法的完成,让我们看看我们是否可以使游戏的呈现更加有趣。当球击中某物时,给人一种沉重的冲击感的简单方法是摇晃相机。它模拟了球员如何感觉球击中竞技场的两侧。为了进一步增加沉浸感,我们从自上而下切换到透视视图。将相机的位置设置为 (0, 20, -19),将其 X 旋转设置为 50。

从玩家的角度显示竞技场

推挤和推搡

要控制相机的行为,请创建组件类型。该相机可以在给定的脉冲下在 XZ 平面上推来推去,或者在 Y 维度上被推挤。这是通过给相机一个 3D 速度来实现的,该速度在 中应用,因此在所有推挤和推动帧完成后。**LivelyCamera**``LateUpdate

推挤是通过公共方法完成的,该方法通过可配置的强度增加 Y 速度,默认情况下设置为 40。推动是通过公共方法完成的,该方法带有 2D 脉冲参数,该参数被添加到速度中,由可配置的推动强度因子缩放,默认情况下设置为 1。JostleY``PushXZ

using UnityEngine;

public class LivelyCamera : MonoBehaviour
{
[SerializeField, Min(0f)]
float
jostleStrength = 40f,
pushtrength = 1f;

Vector3 velocity;

public void JostleY () => velocity.y += jostleStrength;

public void PushXZ (Vector2 impulse)
{
velocity.x += pushStrength * impulse.x;
velocity.z += pushStrength * impulse.y;
}

void LateUpdate ()
{
transform.localPosition += velocity * Time.deltaTime;
}
}

将此组件添加到主摄像机,然后提供一个配置字段并将其连接到摄像机。**Game**

[SerializeField]
LivelyCamera livelyCamera;

当在 中检测到反弹时,在执行反弹之前,使用球的速度作为脉冲进行调用。BounceXIfNeeded``PushXZ

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

在执行 Y 退回之前,在 中执行相同的操作。此外,在得分时推挤相机。BounceY

void BounceY (float boundary, Paddle defender, Paddle attacker)
{
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;
livelyCamera.PushXZ(ball.Velocity);
ball.BounceY(boundary);

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

弹簧和阻尼

摄像机现在被推来推去,但它的速度仍然存在,所以竞技场很快就会消失在视野之外。为了回弹,我们使用简单的弹簧机制将其固定到初始位置。为其提供默认设置为 100 的可配置弹簧强度,默认将其阻尼强度设置为 10。还要给它一个锚点位置,并在它唤醒时将其设置为相机的位置。**LivelyCamera**

[SerializeField, Min(0f)]
float
springStrength = 100f,
dampingStrength = 10f,
jostleStrength = 40f,
pushStrength = 1f;

Vector3 anchorPosition, velocity;

void Awake () => anchorPosition = transform.localPosition;

我们通过使用由弹簧强度缩放的相机的当前位移作为加速度来实现弹簧。我们还通过等于阻尼强度成比例的当前速度的负加速度来减慢运动速度。

void LateUpdate ()
{
Vector3 displacement = anchorPosition - transform.localPosition;
Vector3 acceleration = springStrength * displacement - dampingStrength * velocity;
velocity += acceleration * Time.deltaTime;
transform.localPosition += velocity * Time.deltaTime;
}

生动的相机,具有高帧率。

增量时间最大值

我们简单的弹簧规则只有在帧速率足够高的情况下才能表现良好。它抗拒推搡,将相机拉回其锚点,但可能会导致一些超调,并且在静止之前可能会摆动一下。但是,如果帧速率太低,过冲最终可能会夸大其动量,并且可能会失控,加速而不是减慢。这个问题可以通过强制非常低的帧速率来证明,通过添加方法。稍后必须将其设置回零才能删除限制,因为此设置是永久性的。Application.targetFrameRate = 5;``Awake

当帧速率足够高时,不会出现此问题。因此,我们可以通过强制执行较小的时间增量来避免它。我们可以通过移动相机来做到这一点。但是,由于这强制执行精确的时间增量,这将导致微卡顿,因为相机可能无法在每帧更新相同的次数时更新,这非常明显,因为它会影响整个视图的运动。此外,它还限制了相机运动的有效帧速率。FixedUpdate

一个简单的解决方案是强制实施最大时间增量,但不是最小时间增量。为此添加一个可配置的最大值,默认情况下设置为 六十分之一秒。然后将代码从移动到具有时间增量作为参数的新方法。使用最大增量调用的次数与当前帧的增量相适应的次数一样多,然后再次调用剩余的增量。**LivelyCamera**``LateUpdate``TimeStep``LateUpdate``TimeStep

[SerializeField, Min(0f)]
float
springStrength = 100f,
dampingStrength = 10f,
jostleStrength = 40f,
pushStrength = 1f,
maxDeltaTime = 1f / 60f;

…

void LateUpdate ()
{
float dt = Time.deltaTime;
while (dt > maxDeltaTime)
{
TimeStep(maxDeltaTime);
dt -= maxDeltaTime;
}
TimeStep(dt);
}

void TimeStep (float dt)
{
Vector3 displacement = anchorPosition - transform.localPosition;
Vector3 acceleration = springStrength * displacement - dampingStrength * velocity;
velocity += acceleration * dt;
transform.localPosition += velocity * dt;
}

视觉

最后一步是稍微改善我们游戏的视觉效果。我们将选择一个简单的发光霓虹灯外观。

首先让一切都变暗,简单地关闭定向灯。同时将相机设置为纯色背景。然后启用摄像机的选项并将其设置为 FXAA。

保持环境照明完好无损,即使黑色背景没有意义,因此仍然有一些照明使竞技场可见。

为什么使用 FXAA 而不是 MSAA?
我们将使用高强度HDR颜色。URP的FXAA可以处理这些问题,但其MSAA不能。

发光球

球将是我们唯一的光源。为其提供子点光源,将其颜色设置为黄色,强度设置为 20,范围设置为 100。

以球为光源的黑暗视觉效果

球应该发光。使用 HDR 颜色属性为其创建无光照着色器图。使用它来创建具有高强度黄色的材质,并将其分配给球预制件。

为了使它看起来发光,我们需要应用一个泛光后期效果。通过以下方式创建全局卷。选择它并为其创建新的卷配置文件。为 添加覆盖,设置为 1 并启用。此外,将 set 的替代添加到 ACES。

调整 URP 资源,使其设置为 HDR。我们也可以设置为 .这样可以防止 Unity 不必要地每帧更新体积数据,因为我们从不更改它。

发光球

反弹粒子

球现在看起来像一个高能立方体。为了加强这个想法,让我们在它反弹时出现火花。创建一个使用顶点颜色乘以强度属性的无光照着色器图。将其表面类型设置为透明,将其混合模式设置为加法,以便始终变亮。为其创建强度设置为 10 的粒子材质。

 粒子的着色器图

                                   粒子的着色器图(上图)

在原点创建粒子系统游戏对象。禁用和 .设置为常量范围 0.5–1、 2–4 和 0.5。将其更改为 和 。

将模块的两个速率设置为零。

默认情况下,该模块设置为圆锥体,我们保留该圆锥体,但将其设置为 45,设置为 0.5。

启用模块,颜色渐变从黄色变为红色,其 alpha 在两端设置为零,在 255% 时设置为 10%。

启用模块,线性曲线从 1 下降到零。

仅使用立方体的约束也适用于粒子,因此将模块的 设置为 ,默认情况下使用立方体。让它使用我们的颗粒材料。

这个粒子系统不是球本身的一部分,但需要一个可配置的参考才能生成粒子,以及一个配置选项来控制每次弹跳生成多少粒子,默认情况下设置为 20。**Ball**

[SerializeField]
ParticleSystem bounceParticleSystem;

[SerializeField]
int bounceParticleEmission = 20;

通过调用反弹粒子系统,创建一种处理发射反弹粒子的方法。发射锥必须正确定位和旋转,因此请为方法提供 X 和 Z 位置以及 Y 旋转作为参数。使用这些来调整系统的形状模块。Emit

void EmitBounceParticles (float x, float z, float rotation)
{
ParticleSystem.ShapeModule shape = bounceParticleSystem.shape;
shape.position = new Vector3(x, 0f, z);
shape.rotation = new Vector3(0f, rotation, 0f);
bounceParticleSystem.Emit(bounceParticleEmission);
}

在适当的参数中调用该方法。边界是相应维度中的位置。第二个位置可以通过将球的位置倒回反弹的时刻来找到。旋转取决于反弹尺寸以及边界是负还是正。BounceX``BounceY

public void BounceX (float boundary)
{
float durationAfterBounce = (position.x - boundary) / velocity.x;
position.x = 2f * boundary - position.x;
velocity.x = -velocity.x;
EmitBounceParticles(
boundary,
position.y - velocity.y * durationAfterBounce,
boundary < 0f ? 90f : 270f
);
}

public void BounceY (float boundary)
{
float durationAfterBounce = (position.y - boundary) / velocity.y;
position.y = 2f * boundary - position.y;
velocity.y = -velocity.y;
EmitBounceParticles(
position.x - velocity.x * durationAfterBounce,
boundary,
boundary < 0f ? 0f : 180f
);
}

请添加图片描述

                                      反弹粒子

启动粒子

让我们在比赛开始时出现球时让火花飞溅。将现有粒子系统转换为预制件,并使用位于新起始粒子系统的原点的第二个实例。将其增加到 0.5–1.5,使其持续时间更长,并将其设置为球体。

为其添加一个配置字段,以及一个用于开始时生成的粒子数量的字段,默认情况下设置为 100。在开始新游戏时发射这些粒子。**Ball**

[SerializeField]
ParticleSystem bounceParticleSystem, startParticleSystem;

[SerializeField]
int
bounceParticleEmission = 20,
startParticleEmission = 100;

…

public void StartNewGame ()
{
…
startParticleSystem.Emit(startParticleEmission);
}

起始粒子。

尾迹粒子

第三个也是最后一个粒子效果将是球留下的痕迹。创建粒子系统预制件的另一个实例,这次使用 和 启用。将其设置为 1–1.25 并设置为零。将其更改为从其音量发出的框。要使其在移动时发射粒子,请设置为 2。

还要为此系统添加一个配置字段,以便在更新其可视化效果时将其位置与球的位置同步。我们不会让步道系统成为球的子项,以便在比赛结束时球被停用后它可以保持可见,否则尾迹会立即消失。**Ball**

[SerializeField]
ParticleSystem bounceParticleSystem, startParticleSystem, trailParticleSystem;

…

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

请添加图片描述

                                           尾迹粒子

这是有效的,除了在每场比赛结束时和开始时,轨迹效果也会跟随球传送。我们可以通过做两件事来避免这种情况。首先,我们必须在游戏结束时关闭发射,并在新游戏开始时打开它。这是通过设置发射模块的属性来完成的,因此让我们为此添加一个方便的方法。

public void StartNewGame ()
{
…
SetTrailEmission(true);
}

public void EndGame ()
{
…
SetTrailEmission(false);
}

…

void SetTrailEmission (bool enabled)
{
ParticleSystem.EmissionModule emission = trailParticleSystem.emission;
emission.enabled = enabled;
}

其次,粒子系统记住了它的旧位置。为了清除它以避免在新游戏开始时显示传送的轨迹,我们必须调用它。Play

SetTrailEmission(true);
trailParticleSystem.Play();

这意味着我们可以恢复为禁用,因为我们现在明确播放它。

反应表面

球和它的粒子并不是唯一可以发光的东西。让我们让它的表面也可以通过暂时发光来对被击中做出反应。创建一个具有 、HDR 和 a 属性的光照着色器图,默认值为 −1000。

它的发射颜色取决于最后一次命中发生的时间。它将在命中时全力以赴,并在下一秒线性淡出。这可以通过从当前时间中减去上次命中的时间,从 1 中减去该时间,使其饱和并使用它来缩放发射颜色来完成。

反应表面的着色器图

                                   反应表面的着色器图

使用此着色器图创建材质并将其用于划色预制件。使用白色作为基色,使用高强度白色作为发射色。

唤醒时检索材质实例,并在成功时更新其上次命中时间。这将使球拍在设法击球时发光。**Paddle**``HitBall

static readonly int timeOfLastHitId = Shader.PropertyToID("_TimeOfLastHit");

…

Material paddleMaterial;

void Awake ()
{
paddleMaterial = GetComponent<MeshRenderer>().material;
SetScore(0);
}

…

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

bool success = -1f <= hitFactor && hitFactor <= 1f;
if (success)
{
paddleMaterial.SetFloat(timeOfLastHitId, Time.time);
}
return success;
}

请添加图片描述

                                             反应桨

让我们更进一步,让作为对手进球的竞技场边界在得分时发光。创建另一种反应性曲面材质,其颜色设置为中灰色,并将其用于竞技场边界预制件。然后为其目标提供可配置的参考,以及可配置的 HDR 目标颜色。**Paddle**``MeshRenderer

当球拍唤醒并将材质的发射颜色设置为目标颜色时,检索其目标材质的实例。设置得分时的最后命中时间。

static readonly int
emissionColorId = Shader.PropertyToID("_EmissionColor"),
timeOfLastHitId = Shader.PropertyToID("_TimeOfLastHit");

[SerializeField]
TextMeshPro scoreText;

[SerializeField]
MeshRenderer goalRenderer;

[SerializeField, ColorUsage(true, true)]
Color goalColor = Color.white;

…

Material goalMaterial, paddleMaterial;

void Awake ()
{
goalMaterial = goalRenderer.material;
goalMaterial.SetColor(emissionColorId, goalColor);
paddleMaterial = GetComponent<MeshRenderer>().material;
SetScore(0);
}

…

public bool ScorePoint (int pointsToWin)
{
goalMaterial.SetFloat(timeOfLastHitId, Time.time);
SetScore(score + 1, pointsToWin);
return score >= pointsToWin;
}

将拨片连接到相应的渲染。使用高强度绿色作为底部玩家颜色,使用高强度红色作为顶部 AI 颜色。

反应性目标。

彩色文本

我们通过为文本着色并使其发光来结束。首先将文本预制件的默认字体材质颜色设置为高强度黄色。

发光的文本

                                        发光的文本

我们将使用进球颜色来显示比分,但有一个变化。我们将从零开始的黑色,因此分数最初在黑色背景上是不可见的。一旦分数颜色等于获胜的分数,它们将达到其全部强度。

在这种情况下,通过文本的属性检索材质实例,其表面颜色着色器属性名为 。fontMaterial

static readonly int
emissionColorId = Shader.PropertyToID("_EmissionColor"),
faceColorId = Shader.PropertyToID("_FaceColor"),
timeOfLastHitId = Shader.PropertyToID("_TimeOfLastHit");

…

Material goalMaterial, paddleMaterial, scoreMaterial;

void Awake ()
{
goalMaterial = goalRenderer.material;
goalMaterial.SetColor(emissionColorId, goalColor);
paddleMaterial = GetComponent<MeshRenderer>().material;
scoreMaterial = scoreText.fontMaterial;
SetScore(0);
}

…

void SetScore (int newScore, float pointsToWin = 1000f)
{
score = newScore;
scoreText.SetText("{0}", newScore);
scoreMaterial.SetColor(faceColorId, goalColor * (newScore / pointsToWin));
SetExtents(Mathf.Lerp(maxExtents, minExtents, newScore / (pointsToWin - 1f)));
}
                                       改变颜色的分数

请注意,默认配置使一旦您发现 AI 的弱点就很容易击败它。这便于开发,但应进行调整以提供所需的挑战级别。
(完结)

猜你喜欢

转载自blog.csdn.net/weixin_72715182/article/details/130482071