[Unity Mini Game] Game Development Case-Unity Creates Unhindered Mini Games (Part 2)

batting square

ping pong clone

  • Use cubes to build the arena, racket and ball.
  • Move the ball and racket.
  • Hit the ball and score runs.
  • Let the camera feel the impact.
  • Gives the game an abstract neon look.

This is the first in a series of tutorials on the basic game. In it, we'll create a simple Pong clone.

This tutorial was made using Unity 2021.3.16f1.

Along with the above content

retracting paddle

As the final act of our game, let's zoom out every time we score. This creates a handicap based on how close a player is to winning. Converts its current scope to a private field and makes its minimum and maximum values ​​configurable, both set to 4 by default. Introduced a method to replace the current extent, which also adjusts the game object's local scale to match.**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;
}

Set the range at the end of the stroke based on how close you are to winning the stroke. This is done by interpolating from the maximum range to the minimum range based on the new score divided by the score required to win minus 1. Add the required parameter for this, which can be any value greater than 1 by default when it is set to zero.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)));
}

We should also reset the score when the racket wakes up so that its initial size is correct in the preparation phase of the game.

void Awake ()
{
SetScore(0);
}

Let's use at least 1 for the bottom racket and 3.5 for the top racket to make it easier for the AI ​​to hit the ball.

active camera

With the gameplay complete, let's see if we can make the presentation of the game more interesting. An easy way to give the feeling of a heavy impact when the ball hits something is to shake the camera. It simulates how players feel when the ball hits the sides of the arena. To further increase immersion, we switch from top-down to perspective view. Set the camera's position to (0, 20, -19) and its X rotation to 50.

Shows the arena from the player's perspective

pushing and shoving

To control the camera's behavior, create a component type. The camera can be pushed around in the XZ plane at a given pulse, or pushed around in the Y dimension. This is achieved by giving the camera a 3D speed which is applied in so after all the jostling and pushing frames are completed.**LivelyCamera**``LateUpdate

Pushing is done via a public method that increases the Y speed by a configurable strength, set to 40 by default. Pushing is done via a public method with a 2D pulse parameter which is added to the velocity, scaled by a configurable push strength factor, set to 1 by default.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;
}
}

Add this component to the main camera, then provide a configuration field and connect it to the camera.**Game**

[SerializeField]
LivelyCamera livelyCamera;

When a bounce is detected in , it is called using the ball's speed as a pulse before executing the bounce.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);
}
}

Do the same in before executing the Y return. Also, push the camera while scoring.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();
}
}
}

springs and dampers

The camera is now pushed around, but its speed remains so the arena quickly disappears from view. To spring back we use a simple spring mechanism to hold it in its original position. Gives it a configurable spring strength with a default setting of 100, and a default damping strength of 10. Also give it an anchor position and set it to the camera's position when it wakes up.**LivelyCamera**

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

Vector3 anchorPosition, velocity;

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

We implement the spring by using the current displacement of the camera as the acceleration, scaled by the strength of the spring. We also slow down the movement by a negative acceleration equal to the current velocity proportional to the damping strength.

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

Vivid camera with high frame rate.

Maximum delta time

Our simple spring rule only performs well if the frame rate is high enough. It resists pushing and pulling the camera back to its anchor point, but may cause some overshoot and may wiggle a bit before coming to rest. However, if the frame rate is too low, overshoot can end up exaggerating its momentum, and it can spin out of control, accelerating instead of slowing down. This problem can be demonstrated by forcing a very low frame rate, via the add method. It must be set back to zero later to remove the limit, as this setting is permanent.Application.targetFrameRate = 5;``Awake

When the frame rate is high enough, this problem does not occur. Therefore, we can avoid it by enforcing smaller time increments. We can do this by moving the camera. However, since this enforces precise time increments, this will result in micro-stuttering as the camera may not update the same number of times per frame, which is very noticeable as it affects the motion of the entire view. Additionally, it limits the effective frame rate of camera motion.FixedUpdate

A simple solution is to enforce a maximum time increment, but not a minimum time increment. Adds a configurable maximum value for this, set to one sixtieth of a second by default. Then move the code from to a new method that has a time delta as a parameter. Call with the maximum increment as many times as the current frame's increments fit, then call again for the remaining increments.**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;
}

Vision

The final step is to slightly improve the visuals of our game. We'll choose a simple glowing neon look.

Start by darkening everything and simply turning off the directional lights. Also set the camera to a solid color background. Then enable the camera's options and set it to FXAA.

Keeping the environment lighting intact even though the black background doesn't make sense, so there is still some lighting to make the arena visible.

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

Glowing ball

The ball will be our only source of light. Give it a sub-point light, set its color to yellow, intensity to 20, and range to 100.

Dark visual effect using ball as light source

The ball should glow. Create an unlit shader map for it using HDR color properties. Use this to create a material with a high intensity yellow color and assign it to the ball prefab.

To make it look glowy, we need to apply a bloom post effect. Create a global volume in the following way. Select it and create a new volume profile for it. To add overrides, set to 1 and enable. Additionally, an override for set was added to ACES.

Adjust the URP resource so it is set to HDR. We can also set to . This prevents Unity from unnecessarily updating the volume data every frame since we never change it.

Glowing ball

bounce particles

The ball now looks like a high-energy cube. To reinforce this idea, let's have a spark when it bounces. Create an unlit shader graph that uses the vertex color multiplied by the intensity attribute. Set its Surface Type to Transparent and its Blending Mode to Additive so it's always light. Create a particle material for it with a strength set to 10.

Shader graph for particles

                                   粒子的着色器图(上图)

Create a particle system game object at the origin. Disabled and set to constant ranges 0.5–1, 2–4, and 0.5. Change it to and.

Set both rates of the module to zero.

By default the module is set to a cone, we keep that cone but set it to 45 and set it to 0.5.

With the module enabled, the color gradient goes from yellow to red, with its alpha set to zero at both ends and to 10% at 255%.

With the module enabled, the linear curve decreases from 1 to zero.

Constraints that only use cubes also apply to particles, so set the module to use cubes by default. Let it use our granular materials.

This particle system is not part of the ball itself, but requires a configurable reference to generate particles from, and a configuration option to control how many particles are generated per bounce, which is set to 20 by default.**Ball**

[SerializeField]
ParticleSystem bounceParticleSystem;

[SerializeField]
int bounceParticleEmission = 20;

Create a way to handle emitting bounced particles by calling the bounce particle system. The emission cone must be positioned and rotated correctly, so provide the X and Z position and Y rotation as parameters to the method. Use these to adjust the system's shape module.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);
}

Call this method with appropriate parameters. Boundaries are positions in corresponding dimensions. The second position can be found by reversing the position of the ball back to the moment of the bounce. The rotation depends on the bounce size and whether the bounds are negative or positive.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
);
}

Please add image description

                                      反弹粒子

Start particles

Let's make the sparks fly when the ball appears at the start of the game. Convert the existing particle system to a prefab and use a second instance located at the origin of the new starting particle system. Increase it to 0.5–1.5 to make it last longer and set it to a sphere.

Add a configuration field to it, as well as a field for the number of particles to spawn at the start, which is set to 100 by default. These particles are emitted when starting a new game.**Ball**

[SerializeField]
ParticleSystem bounceParticleSystem, startParticleSystem;

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

…

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

starting particle.

trail particles

The third and final particle effect will be the trail left by the ball. Create another instance of the Particle System prefab, this time using and enabling it. Set it to 1–1.25 and set it to zero. Change it to a box that emits from its volume. To make it emit particles when moving, set to 2.

Also add a configuration field for this system to synchronize its position with the ball's position when updating its visualization. We will not make the trail system a child of the ball so that it can remain visible after the ball is deactivated at the end of the game, otherwise the wake will disappear immediately.**Ball**

[SerializeField]
ParticleSystem bounceParticleSystem, startParticleSystem, trailParticleSystem;

…

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

Please add image description

                                           尾迹粒子

This works, except that at the end and beginning of each game, a trajectory effect will also follow the ball as it travels. We can avoid this by doing two things. First we have to turn off launch when the game ends and turn it on when a new game starts. This is done by setting the properties of the emitting module, so let's add a convenience method for this.

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

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

…

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

Second, the particle system remembers its old position. In order to clear it to avoid showing the teleported trail at the start of a new game, we have to call it.Play

SetTrailEmission(true);
trailParticleSystem.Play();

This means we can revert to disabled since we're now explicitly playing it.

reaction surface

The ball and its particles aren't the only things that can emit light. Let's also make its surface react to being hit by temporarily glowing. Creates a lighting shader map with HDR and a properties, with a default value of −1000.

The color of its emission depends on when the last hit occurred. It will go full blast on hit and fade out linearly the next second. This can be done by subtracting the time of the last hit from the current time, subtracting that time from 1, saturating it and using that to scale the emission color.

Shader graph for reactive surfaces

                                   反应表面的着色器图

Use this shader map to create a material and use it with the shading prefab. Use white as the base color and high-intensity white as the emissive color.

Retrieve the material instance on wakeup and update its last hit time on success. This will make the racket shine when it manages to hit the ball.**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;
}

Please add image description

                                             反应桨

Let's take it a step further and let the arena borders that serve as opponents' goals shine when they are scored. Create another reactive surface material with its color set to medium gray and use it for the arena border prefab. It then provides a configurable reference for its target, as well as a configurable HDR target color.**Paddle**``MeshRenderer

When Racket wakes up and sets the material's emission color to the target color, retrieves an instance of its target material. Set the last hit time when scoring.

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

Connect the pick to the corresponding render. Use high intensity green as the bottom player color and high intensity red as the top AI color.

Reactive goals.

Colored text

We finish by coloring the text and making it glow. Start by setting the text prefab's default font material color to high-intensity yellow.

glowing text

                                        发光的文本

We will use the goal colors to display the score, but with a twist. We'll start with black from zero, so the fraction won't be visible initially on the black background. Once the score color equals the winning score, they will reach their full strength.

In this case, the material instance is retrieved via the text's properties, whose Surface Color shader property is named.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)));
}
                                       改变颜色的分数

Note that the default configuration makes it easy to defeat the AI ​​once you discover its weakness. This facilitates development, but should be adjusted to provide the desired level of challenge.
(end)

Guess you like

Origin blog.csdn.net/weixin_72715182/article/details/130482071