As I wrote browser-based 3D football. Part 2
Hello, Habr!
So the continuation of my story about writing browser-based 3D football arrived in time. I apologize for the long break, the fault is the work, the production of borscht and other things edible for my beloved husband, the burdens of repair and all the rest. But the article will not write and read itself. Therefore, all interested and not yet forgotten about the first part - you are welcome to cat.

Just in case, the link to the first part - As I wrote 3D browser-based soccer. Part 1
So, the previous part ended with the fact that we had a football field and a goal. This is categorically not enough for a full-fledged football action -people need football players. We will start from this, but first work on errors.
In the comments to the first part, it was rightly noted that there was some violation of perspective. I also noticed this, but attributed it to the imperfection of Three.js itself.
However, after playing with the parameters of the THREE.PerspectiveCamera method, we managed to achieve a digestible result.


Those. The problem was in the first argument, which is responsible for the vertical field of view of the camera and is set in degrees. But I want to note that the call to this method is copied from the official Three.js tutorial - Creating a scene , in which 75 is also passed.
But back to the creation of football players. In the previous part, the abstract FootballObject class was described . It is from him that we will inherit the class Player :
Player.ts
The first thing that types of players are announced is PlayerType :
Next, the interface for options is determined, in this case consisting of a single field - IsCpu - whether this player is a member of a team controlled by a computer.
A bit about the members of the Player class :
There are two interesting points here:
The first moment is that the model is loaded into the static method. Initially, for each player, I downloaded my own model, because it was still cached and there was no overhead in the form of network trips for each player. And the fact that each player instantiated their own model was nothing particularly scary, except that it consumed more memory, but it was quite possible to live with it. However, for reasons that are not clear to me, each successive instance of the Player class created increasingly discolored all instances. Yes, so that after creating all the copies, they became completely white, as if they had no texture:

I tried different download options, different ways to install textures, all in vain. And then I remembered that somewhere I read about the method of optimizing the work with models in case of repeated use on the stage. It was recommended there not to load the model every time, but to clone, which allows reducing the amount of memory allocated for storing models. I began to look if there was such an opportunity in Three.js and it turned out that there was - Mesh (see the clone method) .
Add to our Player 's clone method:
And, lo and behold, the models ceased to “discolor” - the problem was solved.
The second moment - looking at this code
you might think: "hat? Hat? What hat? ” And you will be right, the hat is out of place here at all. But I will explain to you where she came from here. Even in the first part of this article, a little righteous anger poured out from me about the difficulty of finding 3D models. In particular, I searched for a model of a football player for a very long time, found in formats unsupported by Three.js, looked for a converter, converted, uploaded to the stage, and it turned out that it was either in the process of conversion, or else where the model was “broken” and now it’s probably not soccer player, and a huge mantis.
My joy knew no bounds when a suitable model was found, but not somewhere, but in the section with examples of Three.js itself - loader / sea3d / skinning. This meant that the model is guaranteed, without registration and SMS it will be possible to upload to the Three.js scene and not receive artifacts generated by the converters. But for all this, there was one important nuance - the player was in a huge straw hat:

Fortunately, as can be seen from the code above, the hat was hidden and an ordinary football player turned out.
In order for the teams to differ in color, the player needs to be able to set a texture different from the one loaded with the model. For these purposes, we will use the setTexture method :
Let's make our players run! This phrase could be spoken by the coach of the Russian national football team, but I pronounce it. Therefore, my players will run guaranteed, but with our team it is not so simple.
In my case, all football players are divided into two types:
With a football player controlled by the user, everything is clear - he moves there and then where and when the user tells him. If the user is inactive, then the footballer stands still.
The rest of the players need to indicate where and when they should move. This is implemented by two methods: moveTo and animate . The first saves the coordinates of the point at which the player needs to move, the second implements this movement and is called along with the redrawing of the scene.
In animate, the coordinates for the current iteration of the redraw are calculated taking into account the speed of the player, it checks to see if the player is out of the field and the necessary animation starts - running or waiting.
Now we have players and we can finally assemble a team from them:
The players array will contain all the players on the team.
currentPlayer - link to the current active player.
score - the number of goals scored by the team.
withBall - a flag that determines whether the team owns the ball.
Our teams play according to the 4-4-2 scheme:
those. 4 defenders, 4 midfielders and 2 forwards.
Now fill our team with football players:
Here it can be noted that depending on the side on the field occupied by the team (left / right is passed in the options), the players turn around 90 or -90 degrees to be turned facing the opponent’s goal.
Upon completion of the creation of all instances, the setStartPositions method is called , which places the players in their starting positions.
As you can see, the element of randomness implemented by the getRandomPosition method is used to determine the position on the field :
The getRandomPositionX and getRandomPositionZ methods return a position with a random element for x and z coordinates, taking into account the size of the field and will be used later.
In addition, these two methods will come in handy for the future:
getNearestPlayer - returns the player who is closest to the given point.
getNearestForwardPlayer - does the same thing, but with one caveat - the closest player is searched for among those with an X coordinate that exceeds the X coordinate of a given point. This method is useful when looking for a football player who should pass. Moreover, if such a player is not found (i.e., the given point on the X coordinate exceeds all the players on the team), then the nearest player will be found, even if his X coordinate is less than that of the given point.
To determine the distance, use the Utils.getDistance method :
It's time to think about the team strategy. In order not to complicate, I decided that my teams will only be able to attack or defend, i.e. will have two strategies - defense and attack . When one team with the ball - it attacks, and the opposing team defends, and vice versa. At the same time, each type of football player should strive to a certain point in the field (not without an element of chance, of course).
For example, when a team attacks, then the defenders tend to the center of the field, midfielders to the middle of half of the opponent’s field (3/4 of the field), attacking closer to the opponent’s goal.
And in approximately the same proportions, the players retreat to their half of the field during defense: the defenders strive for their goal, the midfielders in the middle of their half of the field (1/4 of the field), the attackers remain in the center of the field.
This logic is implemented in the setStrategy method of the Team class :
On this, perhaps, I will complete the second part of the article on "home-made" football.
In the third (and final) part I will talk about game mechanics, the control interface, and also add the ball and start scoring goals.
And I remind you that in order not to spoil and keep the intrigue to the end, I will post the source and demo in the last part of the article.
Thank you all for your attention!
So the continuation of my story about writing browser-based 3D football arrived in time. I apologize for the long break, the fault is the work, the production of borscht and other things edible for my beloved husband, the burdens of repair and all the rest. But the article will not write and read itself. Therefore, all interested and not yet forgotten about the first part - you are welcome to cat.

Just in case, the link to the first part - As I wrote 3D browser-based soccer. Part 1
So, the previous part ended with the fact that we had a football field and a goal. This is categorically not enough for a full-fledged football action -
Error handling
In the comments to the first part, it was rightly noted that there was some violation of perspective. I also noticed this, but attributed it to the imperfection of Three.js itself.
However, after playing with the parameters of the THREE.PerspectiveCamera method, we managed to achieve a digestible result.
Before:
this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);

After:
this.camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);

Those. The problem was in the first argument, which is responsible for the vertical field of view of the camera and is set in degrees. But I want to note that the call to this method is copied from the official Three.js tutorial - Creating a scene , in which 75 is also passed.
Soccer Player - Statics
But back to the creation of football players. In the previous part, the abstract FootballObject class was described . It is from him that we will inherit the class Player :
Player.ts
import { Field } from './field';
import { FootballObject } from './object';
import { BASE_URL } from './const';
import { Mesh, MeshBasicMaterial, Scene, Texture } from 'three';
export enum PlayerType {
DEFENDER,
MIDFIELDER,
FORWARD
}
export interface IPlayerOptions {
isCpu: boolean;
}
const MIN_SPEED = 0.03;
const MAX_SPEED = 0.07;
export class Player extends FootballObject {
protected options: IPlayerOptions;
protected mesh: Mesh;
protected type: PlayerType;
protected startX: number;
protected startZ: number;
protected targetX: number;
protected targetZ: number;
protected speed: number;
public isActive = true;
public isRun = false;
public isCurrent = false;
static ready: Promise;
static mesh: Mesh;
constructor(scene: Scene, options: IPlayerOptions) {
super(scene);
this.options = options;
this.speed = (Math.random() * (MAX_SPEED - MIN_SPEED) + MIN_SPEED);
}
static init(scene: Scene): Promise {
if (!Player.ready) {
Player.ready = new Promise((resolve, reject) => {
const loader = new THREE.SEA3D({
autoPlay: true,
container: scene,
multiplier: .6
});
loader.onComplete = () => {
const mesh: Mesh = loader.getMesh('Player');
const hat = loader.getMesh('Hat');
if (hat) {
hat.visible = false;
}
mesh.scale.set(.02, .02, .02);
mesh.position.y = 2;
mesh.visible = false;
Player.mesh = mesh;
resolve();
};
loader.load(`${ BASE_URL }/resources/models/player1.sea`);
});
}
return Player.ready;
}
}
The first thing that types of players are announced is PlayerType :
- DEFENDER - Defender
- MIDFIELDER - Midfielder
- FORWARD - FORWARD
Next, the interface for options is determined, in this case consisting of a single field - IsCpu - whether this player is a member of a team controlled by a computer.
A bit about the members of the Player class :
- startX, startZ, targetX, targetZ - determine the coordinates of the start and target, respectively, when the movement is initiated by the moveTo method (more about that later)
- isActive - activity, allows you to "turn off" the player
- isRun - whether the player is in a running state
- isCurrent - whether the player is the current active player of his team (in the case of the team the user is playing for, is the player exactly who the user is currently controlling from the keyboard)
There are two interesting points here:
The first moment is that the model is loaded into the static method. Initially, for each player, I downloaded my own model, because it was still cached and there was no overhead in the form of network trips for each player. And the fact that each player instantiated their own model was nothing particularly scary, except that it consumed more memory, but it was quite possible to live with it. However, for reasons that are not clear to me, each successive instance of the Player class created increasingly discolored all instances. Yes, so that after creating all the copies, they became completely white, as if they had no texture:

I tried different download options, different ways to install textures, all in vain. And then I remembered that somewhere I read about the method of optimizing the work with models in case of repeated use on the stage. It was recommended there not to load the model every time, but to clone, which allows reducing the amount of memory allocated for storing models. I began to look if there was such an opportunity in Three.js and it turned out that there was - Mesh (see the clone method) .
Add to our Player 's clone method:
clone() {
this.mesh = Player.mesh.clone();
this.scene.add(this.mesh);
}
And, lo and behold, the models ceased to “discolor” - the problem was solved.
The second moment - looking at this code
const hat = loader.getMesh('Hat');
if (hat) {
hat.visible = false;
}
you might think: "hat? Hat? What hat? ” And you will be right, the hat is out of place here at all. But I will explain to you where she came from here. Even in the first part of this article, a little righteous anger poured out from me about the difficulty of finding 3D models. In particular, I searched for a model of a football player for a very long time, found in formats unsupported by Three.js, looked for a converter, converted, uploaded to the stage, and it turned out that it was either in the process of conversion, or else where the model was “broken” and now it’s probably not soccer player, and a huge mantis.
My joy knew no bounds when a suitable model was found, but not somewhere, but in the section with examples of Three.js itself - loader / sea3d / skinning. This meant that the model is guaranteed, without registration and SMS it will be possible to upload to the Three.js scene and not receive artifacts generated by the converters. But for all this, there was one important nuance - the player was in a huge straw hat:

Fortunately, as can be seen from the code above, the hat was hidden and an ordinary football player turned out.
In order for the teams to differ in color, the player needs to be able to set a texture different from the one loaded with the model. For these purposes, we will use the setTexture method :
setTexture(textureName: string) {
const loader = new THREE.TextureLoader();
loader.load(`${ BASE_URL }/resources/textures/${textureName}`, (texture: Texture) => {
this.mesh.material = this.mesh.material.clone();
texture.flipY = false;
( this.mesh.material).map = texture;
});
}
Soccer Player - Dynamics
Let's make our players run! This phrase could be spoken by the coach of the Russian national football team, but I pronounce it. Therefore, my players will run guaranteed, but with our team it is not so simple.
In my case, all football players are divided into two types:
- user controlled soccer player
- all other football players
With a football player controlled by the user, everything is clear - he moves there and then where and when the user tells him. If the user is inactive, then the footballer stands still.
The rest of the players need to indicate where and when they should move. This is implemented by two methods: moveTo and animate . The first saves the coordinates of the point at which the player needs to move, the second implements this movement and is called along with the redrawing of the scene.
moveTo(x: number, z: number) {
this.startX = this.mesh.position.x;
this.startZ = this.mesh.position.z;
this.targetX = x;
this.targetZ = z;
this.isRun = true;
}
animate(options: any) {
if (this.isCurrent && this.isRun && !this.options.isCpu) {
this.run();
} else if (this.isRun) {
const distanceX = this.targetX - this.startX;
const distanceZ = this.targetZ - this.startZ;
const newX = this.mesh.position.x + this.speed * (distanceX > 0 ? 1 : -1);
const newZ = this.mesh.position.z + this.speed * (distanceZ > 0 ? 1 : -1);
let isRun = false;
if (Field.isInsideByX(newX) && ((distanceX > 0 && this.mesh.position.x < this.targetX) || (distanceX < 0 && this.mesh.position.x > this.targetX))) {
this.mesh.position.x = newX;
isRun = true;
}
if (Field.isInsideByZ(newZ) && ((distanceZ > 0 && this.mesh.position.z < this.targetZ) || (distanceZ < 0 && this.mesh.position.z > this.targetZ))) {
this.mesh.position.z = newZ;
isRun = true;
}
this.isRun = isRun;
this.run();
} else if (!options.isStarted) {
this.idleStatic();
} else {
this.idleDynamic();
}
}
In animate, the coordinates for the current iteration of the redraw are calculated taking into account the speed of the player, it checks to see if the player is out of the field and the necessary animation starts - running or waiting.
A team we can't live without
Now we have players and we can finally assemble a team from them:
import { Player, PlayerType } from './player';
import { FIELD_WIDTH, FIELD_HEIGHT } from './field';
import { Utils } from './utils';
import { Scene } from 'three';
import { FootballObject } from './object';
export class Team {
protected scene: Scene;
protected options: ITeamOptions;
protected players: Player[] = [];
protected currentPlayer: Player;
protected score = 0;
withBall = false;
}
The players array will contain all the players on the team.
currentPlayer - link to the current active player.
score - the number of goals scored by the team.
withBall - a flag that determines whether the team owns the ball.
Our teams play according to the 4-4-2 scheme:
protected getPlayersType(): PlayerType[] {
return [
PlayerType.DEFENDER,
PlayerType.DEFENDER,
PlayerType.DEFENDER,
PlayerType.DEFENDER,
PlayerType.MIDFIELDER,
PlayerType.MIDFIELDER,
PlayerType.MIDFIELDER,
PlayerType.MIDFIELDER,
PlayerType.FORWARD,
PlayerType.FORWARD
]
}
those. 4 defenders, 4 midfielders and 2 forwards.
Now fill our team with football players:
createPlayers() {
return new Promise((resolve, reject) => {
Player.init(this.scene)
.then(() => {
const types: PlayerType[] = this.getPlayersType();
let promises = [];
for (let i = 0; i < 10; i++) {
let player = new Player(this.scene, {
isCpu: this.options.isCpu
});
const promise = player.clone()
.then(() => {
if (this.options.side === 'left') {
player.setRotateY(90);
} else {
player.setRotateY(-90);
}
player.setType(types[i]);
player.show();
this.players.push(player);
});
promises.push(promise);
}
Promise.all(promises)
.then(() => this.setStartPositions());
resolve();
});
});
}
Here it can be noted that depending on the side on the field occupied by the team (left / right is passed in the options), the players turn around 90 or -90 degrees to be turned facing the opponent’s goal.
Upon completion of the creation of all instances, the setStartPositions method is called , which places the players in their starting positions.
setStartPositions() {
const startPositions = this.getStartPositions();
this.players.forEach((item: Player, index: number) => {
item.isRun = false;
if (startPositions[index]) {
item.setPositionX(startPositions[index].x);
item.setPositionZ(startPositions[index].z);
}
})
}
protected getStartPositions() {
const halfFieldWidth = FIELD_WIDTH / 2;
const halfFieldHeight = FIELD_HEIGHT / 2;
if (this.options.side === 'left') {
return [
{
x: this.getRandomPosition(- halfFieldWidth * .6),
z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .1)
},
{
x: this.getRandomPosition(- halfFieldWidth * .6),
z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .4)
},
{
x: this.getRandomPosition(- halfFieldWidth * .6),
z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .7)
},
{
x: this.getRandomPosition(- halfFieldWidth * .6),
z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .9)
},
{
x: this.getRandomPosition(- halfFieldWidth * .4),
z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .1)
},
{
x: this.getRandomPosition(- halfFieldWidth * .4),
z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .4)
},
{
x: this.getRandomPosition(- halfFieldWidth * .4),
z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .7)
},
{
x: this.getRandomPosition(- halfFieldWidth * .4),
z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .9)
},
{
x: this.getRandomPosition(- halfFieldWidth * .2),
z: 0
},
{
x: 0,
z: 0
}
];
} else {
return [
{
x: this.getRandomPosition(halfFieldWidth * .6),
z: - halfFieldHeight + FIELD_HEIGHT * .1
},
{
x: this.getRandomPosition(halfFieldWidth * .6),
z: - halfFieldHeight + FIELD_HEIGHT * .4
},
{
x: this.getRandomPosition(halfFieldWidth * .6),
z: - halfFieldHeight + FIELD_HEIGHT * .7
},
{
x: this.getRandomPosition(halfFieldWidth * .6),
z: - halfFieldHeight + FIELD_HEIGHT * .9
},
{
x: this.getRandomPosition(halfFieldWidth * .4),
z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .1)
},
{
x: this.getRandomPosition(halfFieldWidth * .4),
z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .4)
},
{
x: this.getRandomPosition(halfFieldWidth * .4),
z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .7)
},
{
x: this.getRandomPosition(halfFieldWidth * .4),
z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .9)
},
{
x: this.getRandomPosition(halfFieldWidth * .2),
z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .3)
},
{
x: this.getRandomPosition(halfFieldWidth * .2),
z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .7)
},
];
}
}
As you can see, the element of randomness implemented by the getRandomPosition method is used to determine the position on the field :
protected getRandomPosition(n: number, size?: number): number {
size = size || 2;
const min = n - size;
const max = n + size;
return Math.random() * (max - min) + min;
}
protected getRandomPositionX(x: number, size?: number) {
let position = this.getRandomPosition(x, size);
position = Math.min(position, FIELD_WIDTH / 2);
position = Math.max(position, - FIELD_WIDTH / 2);
return position;
}
protected getRandomPositionZ(z: number, size?: number) {
let position = this.getRandomPosition(z, size);
position = Math.min(position, FIELD_HEIGHT / 2);
position = Math.max(position, - FIELD_HEIGHT / 2);
return position;
}
The getRandomPositionX and getRandomPositionZ methods return a position with a random element for x and z coordinates, taking into account the size of the field and will be used later.
In addition, these two methods will come in handy for the future:
getNearestPlayer(point: FootballObject): Player {
let min: number = Infinity,
nearest: Player = null;
this.players.forEach((item: Player) => {
if (item !== point && item.isActive) {
const distance = Utils.getDistance(item, point);
if (distance < min) {
min = distance;
nearest = item;
}
}
});
return nearest;
}
getNearestForwardPlayer(point: FootballObject): Player {
let min: number = Infinity,
nearest: Player = null;
this.players.forEach((item: Player) => {
if (item !== point && item.isActive && item.getPositionX() > point.getPositionX()) {
const distance = Utils.getDistance(item, point);
if (distance < min) {
min = distance;
nearest = item;
}
}
});
return nearest || this.getNearestPlayer(point);
}
getNearestPlayer - returns the player who is closest to the given point.
getNearestForwardPlayer - does the same thing, but with one caveat - the closest player is searched for among those with an X coordinate that exceeds the X coordinate of a given point. This method is useful when looking for a football player who should pass. Moreover, if such a player is not found (i.e., the given point on the X coordinate exceeds all the players on the team), then the nearest player will be found, even if his X coordinate is less than that of the given point.
To determine the distance, use the Utils.getDistance method :
static getDistance(obj1: FootballObject, obj2: FootballObject): number {
if (obj1 && obj2) {
const distanceX = obj1.getPositionX() - obj2.getPositionX();
const distanceZ = obj1.getPositionZ() - obj2.getPositionZ();
return Math.sqrt(distanceX * distanceX + distanceZ * distanceZ);
}
}
The best defense is attack
It's time to think about the team strategy. In order not to complicate, I decided that my teams will only be able to attack or defend, i.e. will have two strategies - defense and attack . When one team with the ball - it attacks, and the opposing team defends, and vice versa. At the same time, each type of football player should strive to a certain point in the field (not without an element of chance, of course).
For example, when a team attacks, then the defenders tend to the center of the field, midfielders to the middle of half of the opponent’s field (3/4 of the field), attacking closer to the opponent’s goal.
And in approximately the same proportions, the players retreat to their half of the field during defense: the defenders strive for their goal, the midfielders in the middle of their half of the field (1/4 of the field), the attackers remain in the center of the field.
This logic is implemented in the setStrategy method of the Team class :
setStrategy(strategy: string) {
const isLeft = this.options.side === 'left';
const RND_SIZE = 4;
switch (strategy) {
case 'defense':
this.players
.filter((item: Player) => item.getType() === PlayerType.FORWARD && item !== this.currentPlayer)
.forEach((item: Player) => {
item.moveTo(this.getRandomPositionX(0, RND_SIZE), this.getRandomPositionZ(item.getPositionZ(), RND_SIZE));
});
this.players
.filter((item: Player) => item.getType() === PlayerType.MIDFIELDER && item !== this.currentPlayer)
.forEach((item: Player) => {
item.moveTo(this.getRandomPositionX(((isLeft ? - FIELD_WIDTH : FIELD_WIDTH) / 2) * .4, RND_SIZE), this.getRandomPositionZ(item.getPositionZ(), RND_SIZE));
});
this.players
.filter((item: Player) => item.getType() === PlayerType.DEFENDER && item !== this.currentPlayer)
.forEach((item: Player) => {
item.moveTo(this.getRandomPositionX(((isLeft ? - FIELD_WIDTH : FIELD_WIDTH) / 2) * .6, RND_SIZE), this.getRandomPositionZ(item.getPositionZ(), RND_SIZE));
});
break;
case 'attack':
this.players
.filter((item: Player) => item.getType() === PlayerType.FORWARD && item !== this.currentPlayer)
.forEach((item: Player) => {
item.moveTo(this.getRandomPositionX(((isLeft ? FIELD_WIDTH : - FIELD_WIDTH) / 2) * .7, RND_SIZE), this.getRandomPositionZ(item.getPositionZ(), RND_SIZE));
});
this.players
.filter((item: Player) => item.getType() === PlayerType.MIDFIELDER && item !== this.currentPlayer)
.forEach((item: Player) => {
item.moveTo(this.getRandomPositionX(((isLeft ? FIELD_WIDTH : - FIELD_WIDTH) / 2) * .5, RND_SIZE), this.getRandomPositionZ(item.getPositionZ(), RND_SIZE));
});
this.players
.filter((item: Player) => item.getType() === PlayerType.DEFENDER && item !== this.currentPlayer)
.forEach((item: Player) => {
item.moveTo(this.getRandomPositionX(0, RND_SIZE), this.getRandomPositionZ(item.getPositionZ(), RND_SIZE));
});
break;
}
}
On this, perhaps, I will complete the second part of the article on "home-made" football.
In the third (and final) part I will talk about game mechanics, the control interface, and also add the ball and start scoring goals.
And I remind you that in order not to spoil and keep the intrigue to the end, I will post the source and demo in the last part of the article.
Thank you all for your attention!