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:
- clean - cleansing rebuilt files
- copy - copy required files
- ts - typescript compilation
- requirejs - build requirejs
- js - run two previous commands in sequence
- css - compile css
- build- complete build
- server - start http server to return statics
- dev - run in dev mode (tracking changes + http server)
Multiple executables:
bin / compile-css - creates, if necessary, the directory dist / css and starts the compilation of stylus styles:
bin / copy - creates, if necessary, the necessary directories and copies dependencies from node_modules, html files and 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:
onBeforeRender: (renderer: THREE.WebGLRenderer, scene: THREE.Scene, camera: THREE.Camera, geometry: THREE.Geometry | THREE.BufferGeometry, material: THREE.Material, group: THREE.Group) => void; 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()
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!