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

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.

Hit the square ball with the racket in the square arena

This series will cover the creation of a simple game base game to show how you can turn an idea into a minimal working game in a short amount of time. The games will be clones, so we don't have to invent a new idea from scratch, but we will deviate from the standard in some way.

In addition to keeping it simple, we'll limit ourselves by setting a design constraint for this series: we can only render the default cube and world space text, and that's it. Also, I'm not including the sound.

This series assumes that you have completed at least the Basics series, and additional series of your choice so that you are familiar with working and programming in Unity. I won't show each step in detail like other series, assuming you can create the game objects yourself and connect things in the inspector without taking screenshots. I also won't explain basic mathematics or laws of motion.

game scene

The game we will clone in this tutorial is Pong, which is a very abstract representation of table tennis or tennis. You can clone the Pong idea, but don't give it a similar name lest you be asked to take it down. So we named our game Batting Square because it's all square.

We'll start with the default example scene for a new 3D project, albeit renamed. The only packages we'll use are the Editor Integration Package of your choice (in my case) and its dependencies. The dependencies of these packages are Burst, Core RP Library, Custom NUnit, Mathematics, Seacher, Shader Graph, Test Framework and Unity UI.

Create a Render/URP Resource (Use Universal Renderer) resource and use it with the Graphics/Programmable Render Pipeline settings and Quality/Render Pipeline resource in the project settings.

arena

Create a default cube and convert it to a prefab. Remove its collider since we won't rely on the physics system. Use four of these scaled to 20 units in one dimension to form the boundaries of a 20×20 square area around the origin on the XZ plane. Since the cubes are one unit thick, each cube moves 10.5 units from the origin in the appropriate direction.
[The external link image transfer failed. The source site may have an anti-leeching mechanism. It is recommended to save the image and upload it directly (img-f0XUScMH-1682677243478) (null)]

To make it a little more interesting, add four more instances of the prefab enlarged to size 2 and use them to fill the border corners. Set its Y coordinate to 0.5 so that all bottoms are aligned. Also adjust the main camera so that it shows a top-down view of the entire arena.
Please add image description

The arena requires two paddles (boards). Create another default cube and convert it to a new prefab, again without collider. Set its scale to (8, 1.1, 1.1) and give it a white material. Add two instances of it to the scene so that they overlap the middle of the bottom and top borders, as shown above.
Please add image description

The last thing we need is a ball, it will also be a cube. Create another cube prefab for this (in case you decide to add more balls later) and give it a yellow material. Place its instance in the center of the scene.

Please add image description

components

While our game is simple enough to control everything with a single script, we will logically split functionality to make the code easier to understand. We will create the obvious component types now and populate them later.

First, we have a moving ball, so create an extension class and add it as a component to the ball prefab.**Ball**``MonoBehaviour

using UnityEngine;

public class Ball : MonoBehaviour {}

Second, we have rackets that will try to hit the ball, so create a component type for them and add it to the racket prefab.**Paddle**

using UnityEngine;

public class Paddle : MonoBehaviour {}

Third, we need a component that controls the game loop and communicates with the ball and racket. Just name it, give it the configuration fields to connect the ball and the two rackets, attach it to an empty game object in the scene, and connect it.**Game**

using UnityEngine;

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

[SerializeField]
Paddle bottomPaddle, topPaddle;
}

Control the ball

The whole point of the game is to control the ball. The ball moves in a straight line around the arena until it hits something. Each player attempts to position his racket so that it hits the ball and bounces it back to the other side.

position and speed

In order to move, its position and speed need to be tracked. Since this is actually a 2D game, we will use fields for this, where the 2D Y dimension represents the 3D Z dimension. We start with constant X and Y velocities, configurable via separate serializable float fields. I use 8 and 10 as default values.**Ball**``Vector2

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

Vector2 position, velocity;
}

We'll probably end up making various adjustments to the ball's position and speed on every update, so let's not set it all the time. Instead, create a public method for this.Transform.localPosition``UpdateVisualization

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

Instead of making the ball not move on its own, we'll make it perform standard motion through public methods.Move

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

We also provide a public method for it, ready for new games. The ball starts in the center of the arena, updating its visualization to match, using the configured velocity. Since the bottom racket will be controlled by the player, set the Y component of the speed to be negative so that it moves towards the player first.StartNewGame

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

Now you can control the ball. At least, when it wakes up the ball, it should start a new game, when it updates, the ball should move, and then update its visualization.**Game**

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

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

beyond the boundaries

At this point, we have a ball that starts moving after entering game mode, and continues forward, crossing the bottom border and disappearing out of view. The boundaries of the arena are not directly known and we will keep it that way. Instead, we'll add two public methods to it that force the bounce in a single dimension given the bounds. We just assume that returning the request is appropriate.**Ball**

A bounce occurs when a certain boundary is crossed, meaning the ball is now outside the boundary. This must be corrected by reflecting its trajectory. The final position is simply equal to the bounds minus twice the current position. Additionally, the speed in this dimension flips. These bounces are perfect so the position and velocity in the other dimension are not affected. Create and implement methods for this purpose.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;
}

Proper bounce occurs when the edge of the ball touches the boundary rather than its center. Therefore, we need to know the size of the ball, and to do this we will add a configuration field expressed as a range, which by default is set to 0.5, matching the unit cube.

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

The ball itself does not determine when it bounces, so its range and location must be publicly accessible. Add a getter property for this.

public float Extents => extents;

public Vector2 Position => position;

**Game**You also need to know the extent of the arena, which can be any rectangle centered at the origin. Specify a configuration field for this, which is set to 10×10 by default.Vector2

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

We first examine the Y dimension. Create a method for this. The range to check is equal to the arena Y range minus the ball range. If the ball is below the negative range or above the positive range, it should bounce from the appropriate boundary. This method is called between moving the ball and updating its visualization.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);
}
}

The ball now bounces off the bottom and top edges. To bounce from the left and right edges simultaneously, create a method the same way, but for the X dimension and in .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);
}
}

The ball is now contained by the arena, bouncing off the edge and never escaping.

moving paddle

We also need to know the range and speed of the paddles, so add their configuration fields to, which are set to 4 and 10 by default.**Paddle**

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

**Paddle**Also gets a public method, this time taking parameters for the target and arena scope, both in the X dimension. Let it get its position initially, clamp the X coordinate so the paddle can't move any further than it should, and then set its position.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;
}

The racket is supposed to be controlled by the player, but there are two kinds of players: artificial intelligence and humans. Let's start by implementing a simple AI controller by creating a method that gets the X position and target and returns the new X. If it's to the left of the target, it just moves right at maximum speed until it matches the target, otherwise it moves left the same way. It's a stupid reactive AI without any predictions, its difficulty is only determined by its speed.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);
}

For human players, we created a method that doesn't require a target and simply moves left or right based on the arrow keys pressed. If you press both at the same time, it won't move.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;
}

Now add a toggle switch to determine if the racket is controlled by the AI, and call the appropriate method to adjust the X coordinate of the position.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;
}

Move both paddles at the beginning.**Game**.Update

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

The paddle now either responds to the arrow keys or moves on its own. Enables the top paddle's AI and reduces its speed to 5 so it is easily defeated. Note that you can enable or disable the AI ​​at any time while playing the game.

play games

Now that we have a functional ball and racket, we can make a playable game. Players try to move their paddles so that they bounce the ball to the other side of the arena. If they don't do that, their opponents will score.

batting

Add a method that returns whether it hits the ball at its current location, given its X position and range. We can check this by subtracting the racket position from the ball and dividing it by the racket plus ball range. The result is a hit coefficient, somewhere in the -1-1 range if the racket successfully hits the ball.HitBall``**Paddle**

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

The hit factor itself is also useful because it describes the ball's impact position relative to the racket's center and range. In table tennis, this determines the angle at which the ball bounces off the racket. So let's make it available via the output parameter.

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

If the ball changes speed after being hit by the racket, then our simple bounce code is not enough. We must rewind time to the moment the rebound occurred, determine the new velocity, and move time forward to the current moment.

In, rename and add a configurable, set to 20 by default. Then create a method that overrides its current method, given a starting position and velocity coefficient. The new velocity becomes the maximum velocity scaled by the factor, and the new position with the given time increment is then determined.**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;
}

To find the exact moment of the bounce, you must know the speed of the ball, so add a public getter property to it.

public Vector2 Velocity => velocity;

**Game**Now there's more work to do when bouncing in the Y dimension. So instead of calling it directly, we will first call a new method with the parameters of the defending paddle.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);
}

The first thing that must be done is to determine when the rebound occurred. This is found by subtracting the bounds from the ball's Y position and dividing it by the ball's Y velocity. Note that we ignored the racket being a bit thicker than the border as this is just a visual thing to avoid Z battles when rendering.BounceY

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

ball.BounceY(boundary);

Next, calculate the X position of the ball when the bounce occurs.

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

After that we perform the original Y bounce and then check if the defensive paddle hits the ball. If so, set the ball's X position and speed based on the bounce X position, hit factor, and when it occurs.

ball.BounceY(boundary);

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

At this point, we must consider the possibility of a rebound occurring in both dimensions. In this case, the bounce's X position may end up outside the arena. This can be prevented by performing an X bounce first, but only if needed. To support this change, the X position it checks is provided via a parameter.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);
}
}

Then we can also call in based on where it reaches the Y boundary. Therefore, we only deal with X bounces occurring before Y bounces. The bounce X position is then calculated again, now possibly based on a different ball position and speed.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);

Next, the speed of the ball changes depending on where it hits the racket. Its Y speed remains constant, while its X speed is variable. This means that moving from one paddle to the other always takes the same amount of time, but it may move sideways a little or a lot. Pong's ball behaves the same way.

Unlike table tennis, where when the racket misses the ball still bounces off the edge of the arena, a new round is triggered in our game. Our game just goes on uninterrupted with no interruption to the gameplay. Let's make this behavior a unique quirk of our game.

score points

When the defensive racket misses the ball, the opponent scores. We will display the scores of both players on the floor or in the arena. Create a text game object for this, via . This will trigger a pop-up window from which we select options.

Convert text to prefab. Adjust its width to 20, height to 6, Y position to −0.5, and X rotation to 90°. Give its component a starting text of 0, a font size of 72, and set its alignment to Center and Middle. Then create two instances of it with Z positions −5 and 5.RectTransform``TextMeshPro
Please add image description

We consider a player and its racket to be the same thing, so references to its score text will be tracked via a configurable field.**Paddle**``TMPro.TextMeshPro

using TMPro;
using UnityEngine;

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

…
}

It will also track its own store. Give it a private method that replaces its current score with a new score and updates its text to match. This can be done by calling the text component with a string and a score as parameters.SetScore``SetText``"{0}"

int score;

…

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

To start a new game, introduce a public method that sets the score to zero. Additionally, add a public method that increments the score and returns whether this results in the player winning. To be sure, give it a parameter of the points needed to win.StartNewGame``ScorePoint

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

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

**Game**Now it has to be called on both paddles as well, so let's give it its own method to pass the message, which it does in .StartNewGame``StartNewGame``Awake

void Awake () => StartNewGame();

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

Makes the number of points awarded for winning configurable, with a minimum of 2 and a default of 3. Then add the attacker's racket to it as the third parameter and have it call it if the defender doesn't hit the ball. If this results in the attacker winning, a new game is started.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();
}
}

New game countdown

Rather than starting a new game immediately, a delay can be introduced during which the final score can be appreciated. Let's also delay the initial start of the game so players can prepare. Create a new text instance to display the countdown in the center of the arena, with its font size reduced to 32 as its initial text.

Please add image description

Provides configuration fields for the countdown text and the new game delay duration, with a minimum value of 1 and a default value of 3. Also give it a field to track the countdown before the new game and set it to a delay duration instead of starting the new game immediately.**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;

We still always move the racket so players can get into position during the countdown. Move all other code to a new method that we only call when the countdown is zero or less. Otherwise we call, a new method that decrements the countdown and updates its text.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);
}

If the countdown reaches zero, deactivate the countdown text and start a new game, otherwise update the text. But let's just show the whole second. We can do this with a cap on the countdown. To make the initial text visible, change it only if the display value is less than the configured delay. If the delay is set to an integer, the text will be visible for the first second.

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

Let's hide the ball when there's no play going on. Since it's convenient to have the ball active in the scene during development, we've included a way to deactivate it on its own. Then activate it again at the end. Additionally, a public method has been introduced that sets its X position to the center of the state - so the AI ​​will move its racket to the middle between games - and deactivates itself.**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);
}

Also gives a method to call when a player wins, rather than starting a new game immediately. In it, reset the countdown, set the countdown text to and activate it, and tell the ball that the game is over.**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();
}

randomness

At this point, we have a minimally functional game, but let's make it more interesting by adding some randomness in two different ways. First, instead of always starting with the same X speed, default the configurable maximum starting X speed to 2 and use it at the start of each game to randomize its speed.**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);
}

Second, give the AI ​​a target bias so that it doesn't always try to hit the ball to its exact center. To control this, a configurable maximum positioning bias is introduced, representing a fraction of its range (similar to a hit factor), which is set to 0.75 by default. Use fields to track its current bias and add methods for randomization.**Paddle**``ChangeTargetingBias

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

…

float targetingBias;

…

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

Target deviation changes with each new game and when the racket attempts to hit the ball.

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

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

To apply bias, add it to the target before moving the racket.AdjustByAI

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

Unity small game development practice: build your own game world from scratch (Part 1)
[Unity small game] Game development case - Unity creates unobstructed small games (Part 2)

Guess you like

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