📜 ⬆️ ⬇️

As I wrote browser 3D football. Part 1

Hi, Habr! I want to share a story about how I wrote browser-based 3D football. It all started with the fact that my husband loves football. Watching broadcasts, goes to the game, plays on the phone. And so, in order to make him a surprise, and also, in order to tear him away from the device with the game, I decided to write my own game.



Under the cut, I'll tell you how TypeScript and Three.js were friends, and what came of it.

A little about the choice of technology


I already had some experience with the Three.js library, so this time I decided to use it to work with 3D graphics.
')
TypeScript decided to use it because it’s just good.

Environment setup


A few words about setting up the environment. Directly to the development of the game itself is irrelevant, but, just in case, I will briefly describe the setup of the project assembly, it may be for someone and it will be useful.

First thing:

$ npm init 

initializes the npm package and creates the package.json file.

Package.json configures the scripts block, a set of scripts that can later be run like this:

 $ npm run <SCRIPT_NAME> 

Here is my set of scripts:

 "scripts": { "clean": "rm -rf ./tmp ./dist", "copy": "./bin/copy", "ts": "./node_modules/.bin/tsc", "requirejs": "./bin/requirejs", "js": "npm run ts && npm run requirejs", "css": "./bin/compile-css", "build": "npm run clean && npm run js && npm run css && npm run copy", "server": "./node_modules/.bin/http-server ./dist", "dev": "./bin/watcher & npm run server" } 

Respectively:


Multiple executables:

bin / compile-css - creates, if necessary, the directory dist / css and starts the compilation of stylus styles:

bin / compile-css
 #!/usr/bin/env bash if [ ! -d ./dist/css ]; then mkdir -p ./dist/css fi ./node_modules/.bin/stylus ./src/styles/index.styl -o ./dist/css/styles.css 


bin / copy - creates, if necessary, the necessary directories and copies dependencies from node_modules, html files and resources.

bin / copy
 #!/usr/bin/env bash cp ./src/*.html ./dist if [ ! -d ./dist/js/libs ]; then mkdir -p ./dist/js/libs fi if [ ! -d ./dist/js/libs/three/loaders ]; then mkdir -p ./dist/js/libs/three/loaders fi cp ./node_modules/three/build/three.js ./dist/js/libs/three.js cp -r ./node_modules/three/examples/js/loaders/sea3d ./dist/js/libs/three/loaders/sea3d cp -r ./node_modules/three/examples/js/loaders/TDSLoader.js ./dist/js/libs/three/loaders/TDSLoader.js cp -r ./src/resources ./dist/resources 


bin / requirejs - collects js files in one bundle.

bin / requirejs
 #!/usr/bin/env node const requirejs = require('requirejs'); const config = { baseUrl: "tmp/js", dir: "./dist/js", optimize: 'none', preserveLicenseComments: false, generateSourceMaps: false, wrap: { startFile: './node_modules/requirejs/require.js' }, modules: [ { name: 'football' } ] }; requirejs.optimize(config, function (results) { console.log(results); }); 


First problems


The first problems lurked already at the stage of installing dependencies and running compilation typescript.

Having installed the Three.js and TypeSript dependencies:

 $ npm install three --save $ npm install typescript --save-dev 

It seemed like a logical step to check if there are any ready taipings for Three.js. It turned out that there is - @ types / three . And I rushed to install them:

 $ npm install @types/three --save-dev 

However, as it turned out, these taipings turned out to be not quite high-quality, and when the compilation was launched they were immediately sprinkled with a lot of similar errors of the following type:

 $ npm run ts ... node_modules/@types/three/three-core.d.ts(1611,32): error TS2503: Cannot find namespace 'THREE'. 

Looking in node_modules/@types/three/index.d.ts I saw something like this structure:

 export * from "./three-core"; export * from "./three-canvasrenderer"; ... export * from "./three-vreffect"; export as namespace THREE; 

Those. it turns out that all internal descriptions are connected first, and then all this is declared by the THREE namespace and exported outside. But, at the same time, in the very first inclusion - in the three-core.d.ts space is already used THREE , which will be announced later.

How it worked for someone is unknown (someone after all is commited).

I had an assumption that the namespace had “retroactive effect” in some previous versions of typescript, and decided to abandon similar extravagance to the current version, but did not bring a consistent rollback to previous versions.

Then I decided to look where exactly THREE is used in three-core.d.ts and as it turned out all uses were concentrated in two adjacent methods:

 /** * Calls before rendering object */ onBeforeRender: (renderer: THREE.WebGLRenderer, scene: THREE.Scene, camera: THREE.Camera, geometry: THREE.Geometry | THREE.BufferGeometry, material: THREE.Material, group: THREE.Group) => void; /** * Calls after rendering object */ onAfterRender: (renderer: THREE.WebGLRenderer, scene: THREE.Scene, camera: THREE.Camera, geometry: THREE.Geometry | THREE.BufferGeometry, material: THREE.Material, group: THREE.Group) => void; 

At the same time, all types that were specified in the THREE namespace were described right there, in three-core.d.ts . This means that in order to use them you do not need a namespace or additional imports. Just removed THREE , launched the compilation again and - voila, the compilation was successful.

Light, camera, motor


The light source and camera are integral parts of any 3D scene. Which, of course, must also be created:

 import { Camera, Scene } from 'three'; export class App { protected scene: Scene; protected camera: Camera; constructor() { this.createScene(); this.createCamera(); this.createLight(); } protected createScene() { this.scene = new THREE.Scene(); } protected createCamera() { this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); } protected createLight() { const ambient = new THREE.AmbientLight(0xffffff); this.scene.add(ambient); } } 

You also need to create a canvas for drawing, add it to the document and stretch it to full screen:

 ... protected renderer: WebGLRenderer; ... protected createRenderer() { this.renderer = new THREE.WebGLRenderer(); this.updateRendererSize(); document.body.appendChild(this.renderer.domElement); } protected updateRendererSize() { this.renderer.setSize(window.innerWidth, window.innerHeight); } 

And call createRenderer in the constructor:

 ... constructor() { this.createRenderer(); } 

Well, the final touch of the starting scene setting is redrawing:

 constructor() { this.animate(); } protected animate() { window.requestAnimationFrame(() => this.animate()); this.renderer.render(this.scene, this.camera); } 

Playing field


Having prepared the scene, you can start adding objects related directly to football. And it seemed to me logical to start from the field.

The texture for the field without any problems was found on the Internet (which cannot be said about 3d-models, but more on that below):



field.ts :

 import { BASE_URL } from './const'; import { Scene, Texture } from 'three'; export const FIELD_WIDTH = 70; export const FIELD_HEIGHT = 15; export class Field { protected scene: Scene; constructor(scene: Scene) { this.scene = scene; const loader = new THREE.TextureLoader(); loader.load(`${ BASE_URL }/resources/textures/field.jpg`, (texture: Texture) => { const material = new THREE.MeshBasicMaterial({ map: texture }); const geometry = new THREE.PlaneGeometry(FIELD_HEIGHT, FIELD_WIDTH); const plane = new THREE.Mesh(geometry, material); plane.rotateX(-90 * Math.PI / 180); plane.rotateZ(90 * Math.PI / 180); this.scene.add(plane); }); } } 

As you can see, the texture is loaded first, then an object of the class PlaneGeometry is created , this texture is superimposed on it. After that, the object rotates around the X and Z axes slightly.

As a result, we get the following picture:


No football will work if there is no goal on the field. Therefore, I decided to use the next step to find a free 3D-model of a football goal on the Internet, create two-sided goal objects and add them to the stage. But here I was in for an unpleasant surprise, about which a slight lyrical digression would tell.

Lyrical digression


Suddenly for myself, I found out that finding a suitable 3D model is not a trivial exercise. Most of the good models were paid, and cost quite a lot (in my opinion) money. And in search of the unfortunate football goal was spent quite a lot of time. I, of course, do not call for the free distribution of everything and everything, but here in the field of software development there is a huge reservoir of free open source software, one github is worth something. Free audio, photos and many other file types are also usually easy to find. Perhaps in all these areas free analogs will somehow lose to commercial offers (and in some ways, by the way, they will win), but at least they are there, and it is not particularly difficult to find them. What can be said about the field of 3D-modeling.

Maybe I’m missing some detail, or I don’t know something about 3D modeling, that I would immediately dot the i and explain why there are so few free models, and those that are difficult to find and / or they are noticeably inferior in quality . I would be glad to hear an alternative point of view in the comments.

For the whole game, all I needed was to find models of the goal, players and the ball. And according to a rough estimate, 20-30% of the total time spent on development was spent searching for suitable models.

Like a ram on a new gate


But back to our sheep, more precisely to the gate. The necessary model was still found, which allowed to realize the class of the gate:

Gate.ts

 import { BASE_URL } from './const'; import { Mesh, Object3D } from 'three'; export class Gate extends FootballObject { protected mesh: Mesh; load() { return new Promise((resolve, reject) => { const loader = new THREE.TDSLoader(); loader.load(`${ BASE_URL }/resources/models/gate.3ds`, (object: Object3D) => { this.mesh = new THREE.Mesh((<Mesh> object.children[0]).geometry, new THREE.MeshBasicMaterial({color: 0xFFFFFF})); this.mesh.scale.set(.15, .15, .15); this.scene.add(this.mesh); resolve(); }); }); } } 

In order for the gate to fit us, we had to squeeze them a little, which happens in the line:

 this.mesh.scale.set(.15, .15, .15); 

A particularly attentive reader may notice that the Gate class is inherited from the FootballObject class, whose implementation was not cited. Immediately eliminate this blatant injustice.

Object.ts

 import { Mesh, Scene } from 'three'; export abstract class FootballObject { protected abstract mesh: Mesh; protected scene: Scene; constructor(scene: Scene) { this.scene = scene; } setPositionX(x: number) { this.mesh.position.x = x; } setPositionY(y: number) { this.mesh.position.y = y; } setPositionZ(z: number) { this.mesh.position.z = z; } getPositionX(): number { return this.mesh.position.x; } getPositionY(): number { return this.mesh.position.y; } getPositionZ(): number { return this.mesh.position.z; } setRotateX(angle: number) { this.mesh.rotateX(angle * Math.PI / 180); } setRotateY(angle: number) { this.mesh.rotateY(angle * Math.PI / 180); } setRotateZ(angle: number) { this.mesh.rotateZ(angle * Math.PI / 180); } } 

Subsequently, the Player (players) and Ball (ball) classes will also be inherited from FootballObject , which contains the implementation of methods for setting the position on the stage and rotating at a certain angle, given in degrees.

After that we need to create gate objects and place them along the necessary coordinates:

app.ts

 ... import { Field, FIELD_HEIGHT, FIELD_WIDTH } from './field'; import { Gate } from './gate'; class App { ... protected leftGate: Gate; protected rightGate: Gate; ... constructor() { ... this.createGates(); } ... protected createGates() { const DELTA_X = 2; this.leftGate = new Gate(this.scene); this.rightGate = new Gate(this.scene); this.leftGate.load() .then(() => { this.leftGate.setPositionX(- FIELD_WIDTH / 2 + DELTA_X); this.leftGate.setPositionY(2); this.leftGate.setRotateX(-90); this.leftGate.setRotateZ(180); }); this.rightGate.load() .then(() => { this.rightGate.setPositionX(FIELD_WIDTH / 2 - DELTA_X); this.rightGate.setPositionY(2); this.rightGate.setRotateX(-90); }); } } 

DELTA_X - some offset, which required to correct the coordinates of the gate, so that they stood clearly on the markings of the field.

As you can see, the left gate shifts to half the field in a negative direction (i.e., to the left), the right gate moves to the same half of the field in a positive direction (that is, to the right).
Both models rotate to get their natural position on the field.
The result of this is this picture:


Initially, I did not plan to stretch it into several articles, but somehow it turns out to be volumetric, therefore, perhaps, on this beautiful note I will complete the first part of the article about self-made football.

In the second part I will talk about the creation of teams and players, their placement on the field and strategies.

In order not to spoil and keep the intrigue to the end, I will lay out the source code and the demo in the last part of the article.

Thank you all for your attention!

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


All Articles