⬆️ ⬇️

As I wrote browser 3D football. Part 2

Hi, Habr!



That was the continuation of my story about writing browser 3D football. I apologize for the long break, the blame for the work, the production of borscht and other edibles for your beloved husband, the repair and everything else. But the article itself will not write and do not read. Therefore, all interested and have not forgotten about the first part - you are welcome under the cat.





')

Just in case, the link to the first part - As I wrote browser-based 3D football. Part 1



So, the previous part ended with the fact that we had a football field and gate. This is absolutely not enough for a full-fledged football event - people need football players. Let's start with this, but first work with mistakes.



Bug work



In the comments to the first part, it was rightly noted that there is some violation of perspective. I noticed it too, but I blamed it on the imperfection of Three.js itself.

However, having played 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 given in degrees. But I want to note that the call to this method is copied from the official tutorial on Three.js - Creating a scene , in which 75 is also passed.



Football Player - Static



But let's get back to the creation of football players. In the previous section, the abstract class FootballObject was described. It is from him that we will inherit the Player class:



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


First of all, the types of football players are announced - PlayerType :







Next, an interface for options is defined, in this case consisting of a single field - IsCpu - whether this player is a member of a computer-controlled team.



A bit about the members of the class Player :





There are two interesting points here:



The first moment is that the model loading is rendered into a 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 hikes on the network for each player. And the fact that its own model was instantiated for each player was not particularly scary, except that it consumed more memory, but it was quite possible to live with it. However, for reasons that are unclear to me, each next created instance of the Player class increasingly "discolored" all instances. So much so that after creating all the instances, they became completely white, as if they had no texture:







I tried different boot options, different ways to set textures, all in vain. And then I remembered that I had read somewhere about the method of optimizing work with models in case of repeated use on the stage. It was recommended not to load the model every time, but to clone it, which allows to reduce the amount of memory models allocated for storage. I started to see if there is such an opportunity in Three.js and it turned out that there is - Mesh (see the clone method) .

Add our player 's clone method:



 clone() { this.mesh = Player.mesh.clone(); this.scene.add(this.mesh); } 


And, lo and behold, the models ceased to “bleach” - the problem is solved.



The second moment - looking at this code



 const hat = loader.getMesh('Hat'); if (hat) { hat.visible = false; } 


you might think, “hat? Hat? What else is a 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. Even in the first part of this article, a bit of righteous anger poured out of me about the difficulty of finding 3D models. In particular, I looked for a football player model for a very long time, found it in formats that were not supported by Three.js, searched for a converter, converted it, loaded it onto the stage, and then it turned out that either the conversion process or the model “beat” and now it’s probably not football player, and a huge mantis.



My joy knew no bounds when a suitable model was found, and 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 you can upload to the Three.js scene and not get the artifacts generated by the converters. But with all of this, there was one important nuance - the football player was wearing a huge straw hat:







Fortunately, as can be seen from the code above, the hat was hidden and turned out to be an ordinary football player.



In order for teams to differ in color, the player needs to be able to set a texture that is different from the one that is 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; (<MeshBasicMaterial> this.mesh.material).map = texture; }); } 


Football Player - Dynamics



Let's make our footballers run! This phrase could say the coach of the Russian national football team, but I pronounce it. Therefore, my players will run guaranteed, but with our team is not so simple.



In my case, all the players are divided into two types:





With a player who is controlled by the user, everything is clear - he moves there and then where and when the user indicates it. If the user is inactive, then the football player is standing still.



The rest of the players need to specify where and when they should move. It is implemented by two methods: moveTo and animate . The first saves the coordinates of the point to which the player must move, the second realizes 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, coordinates are calculated for the current iteration of the redraw, taking into account the speed of the player, it is checked whether the player is out of the field and the necessary animation is started - running or waiting.



Team, without which we can not live



Now we have the players and we can finally collect 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 if 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 footballers:



 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 of the field occupied by the team (the left / right is passed in options), the players turn 90 or -90 degrees to face to the opponent's gates.



Upon completion of the creation of all instances, the setStartPositions method is called , which places the players on the 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, in determining the position on the field, an element of chance is used, implemented by the getRandomPosition method:



 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 an element of randomness for the x and z coordinates, taking into account the size of the field, and will be used later.



In addition, these two methods will be useful 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 football player who is closest to the specified point.

getNearestForwardPlayer - does the same thing, but with one nuance - the nearest player is searched for among them, the coordinate on X, which exceeds the coordinate on X of the 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., a given point along the X coordinate exceeds all players of the team), then the nearest player will be found, even if its 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 offense.



It's time to think about the strategy of the team. In order not to complicate things, I decided that my teams would 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 enemy team defends itself, and vice versa. At the same time, each of the types of football players must strive to a certain point of the field (not without an element of chance, naturally).



For example, when a team attacks, defenders tend to the center of the field, midfielders to the middle of the middle 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 when defending: the defenders strive for their goal, the midfielders by 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 team class setStrategy method:



 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 "self-made" football.



In the third (and final) part I will talk about the game mechanics, the control interface, as well as add the ball and begin to score goals.



And I remind you that in order not to spoil and keep the intrigue to the end, I’ll post the source code and demo in the last part of the article.



Thank you all for your attention!

Source: https://habr.com/ru/post/339028/



All Articles