Привет, Хабр! Хочу поделиться историей о том, как я браузерный 3D-футбол писала. Началось всё с того, что мой муж любит футбол. Смотрит трансляции, ходит на игры, играет на телефоне. И вот, чтобы сделать ему сюрприз, а также, чтобы хоть ненадолго оторвать от девайса с игрой, решила написать свою игру.

Под катом я расскажу как дружила TypeScript и Three.js и что из этого получилось.
Я уже имела некоторый опыт работы с библиотекой Three.js, поэтому и на этот раз решила воспользоваться ею для работы с 3D-графикой.
TypeScript решила использовать потому что он просто хорош.
Пара слов о настройке среды. Непосредственно к разработке самой игры это не имеет отношения, но, на всякий случай, бегло опишу настройку сборки проекта, может быть для кого-то и это окажется полезным.
Первым делом:
инициализирует npm пакет и создаёт файл package.json.
В package.json настраивается блок scripts — набор скриптов, которые впоследствии могут быть запущены таким образом:
Вот мой набор скриптов:
Соответственно:
Несколько исполняемых файлов:
bin/compile-css — создаёт при необходимости директорию dist/css и запускает компиляцию stylus стилей:
bin/copy — создаёт при необходимости нужные директории и копирует зависим��сти из node_modules, html файлы и ресурсы.
bin/requirejs — собирает js файлы в один бандл.
Первые проблемы подстерегали уже на этапе установки зависимостей и запуска компиляции typescript.
Установив в зависимости Three.js и TypeSript:
Казалось логичным шагом проверить нет ли готовых тайпингов для Three.js. Оказалось, что есть — @types/three. И я устремилась их устанавливать:
Однако, как выяснилось, тайпинги эти оказались не вполне качественными и при запуске компиляции тут же посыпали множеством однотипных ошибок примерно такого вида:
Заглянув в node_modules/@types/three/index.d.ts увидела примерно такую структуру:
Т.е. получается, что сначала подключаются все внутренние описания, а потом всё это объявляется пространством имен THREE и экспортится наружу. Но, в то же время, в самом первом включении — в three-core.d.ts уже используется пространство THREE, которое будет объявлено позже.
Как это у кого-то работало неизвестно (кто-то ведь всё это закоммитил).
Было у меня предположение, что пространство имен имело «обратную силу» в каких-нибудь предыдущих версиях typescript, а к актуальной версии от подобных экстравагантностей решили отказаться, но последовательный откат к предыдущим версиям результатов не принёс.
Тогда я решила посмотреть где же именно используется THREE в three-core.d.ts и как выяснилось все использования были сосредоточены в двух соседствующих методах:
При этом все типы, которые указывались в пространстве имен THREE были описаны прямо тут же, в three-core.d.ts. Это означает, что для того, чтобы их использовать не нужно ни пространство имён, ни дополнительных импортов. Просто убрала THREE, запустила компиляцию снова и — вуаля, компиляция завершилась успешно.
Свет, камера,
Источник света и камера — это неотъемлемые части любой 3D сцены. Которую, естественно, тоже необходимо создать:
Также необходимо создать канвас для отрисовки, добавить его в документ и растянуть на весь экран:
И вызывать createRenderer в кострукторе:
Ну и последний штрих стартовой настройки сцены — перерисовка:
Подготовив сцену, можно начать добавлять объекты, связанные непосредственно с футболом. И мне показалось логичным начать именно с поля.
Текстура для поля без особых проблем нашлась в интернете (чего нельзя сказать о 3d-моделях, но об этом ниже):

field.ts:
Как видно, сначала загружается текстура, затем создается объект класса PlaneGeometry, на него накладывается эта текстура. После чего объект немного вращается вокруг осей X и Z.
В результате получаем такую картину:

Никакого футбола не получится, если на поле не будет ворот. Поэтому я решила следующим шагом найти в интернете бесплатную 3D-модель футбольных ворот, создать объекты ворот в количестве двух штук и добавить их на сцену. Но тут меня ждал неприятный сюрприз, о котором поведает небольшое лирическое отступление.
Внезапно для себя, выяснила, что найти подходящую 3D-модель — занятие отнюдь нетривиальное. Большинство годных моделей оказались платными, причём стоили довольно немалых (на мой взгляд) денег. И на поиски несчастных футбольных ворот было потрачено довольно таки немало времени. Я, конечно, не призываю к бесплатному распространению всего и вся, но вот в области разработки софта есть огромный пласт бесплатного ПО с открытым кодом, один github чего стоит. Бесплатные аудио, фото и многие другие типы файлов тоже, как правило, не составляет труда найти. Возможно во всех этих областях бесплатные аналоги будут в чём-то проигрывать коммерческим предложениям (а в чём-то, между прочим, будут и выигрывать), но они хотя бы есть, и найти их не составляет особо труда. Чего нельзя сказать об области 3D-моделирования.
Возможно, я упускаю какую-то деталь, или что-то о 3D-моделировании мне неизвестно, что сразу бы расставило все точки над i и объяснило почему так мало бесплатных моделей, а те что есть сложно найти и/или они заметно уступают в качестве. Буду рада услышать альтернативную точку зрения в комментариях.
Для всей игры мне всего и требовалось, что найти модели ворот, игроков и мяча. И по грубой оценке на поиски подходящих моделей было потрачено 20-30% от всего времени, потраченного на разработку.
Но вернёмся к нашим баранам, точнее к воротам. Необходимая модель была всё-таки найдена, что позволило реализовать класс ворот:
Gate.ts
Чтобы ворота нам подошли по размеру пришлось их немного сжать, что и происходит в строке:
Особо внимательный читатель может заметить, что класс Gate наследуется от класса FootballObject, реализация которого не приводилась. Немедленно устраним эту вопиющую несправедливость.
Object.ts
Впоследствии классы Player (игроки) и Ball (мяч) также будут отнаследованы от FootballObject, который содержит реализацию методов для установки позиции на сцене и вращения на определённый угол, заданный в градусах.
После чего нам остаётся создать объекты ворот и разместить их по нужным координатам:
app.ts
DELTA_X — некоторое смещение, на которое потребовалось скорректировать координаты ворот, чтобы они встали чётко на разметку поля.
Как видно левые ворота сдвигаются на половину поля в отрицательную сторону (т. е. влево), правые ворота — на ту же половину поля в положительную сторону (т.е. вправо).
Обе модели вращаются, чтобы получить своё естественно положение на поле.
Результатом этого становится вот такая картина:

Изначально не планировала растягивать это на несколько статей, но как-то оно получается объемно, поэтому, пожалуй, на этой прекрасной ноте завершу первую часть статьи о «самопальном» футболе.
Во второй части расскажу о создании команд и игроков, их расстановке на поле и стратегии.
Чтобы не спойлерить и сохранить интригу до конца, выложу исходники и демку в последней части статьи.
Всем спасибо за внимание!

Под катом я расскажу как дружила TypeScript и Three.js и что из этого получилось.
Немного о выборе технологий
Я уже имела некоторый опыт работы с библиотекой Three.js, поэтому и на этот раз решила воспользоваться ею для работы с 3D-графикой.
TypeScript решила использовать потому что он просто хорош.
Настройка среды
Пара слов о настройке среды. Непосредственно к разработке самой игры это не имеет отношения, но, на всякий случай, бегло опишу настройку сборки проекта, может быть для кого-то и это окажется полезным.
Первым делом:
$ npm init
инициализирует npm пакет и создаёт файл package.json.
В package.json настраивается блок scripts — набор скриптов, которые впоследствии могут быть запущены таким образом:
$ npm run <SCRIPT_NAME>
Вот мой набор скриптов:
"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" }
Соответственно:
- clean — очищение пересобираемых файлов
- copy — копирование необходимых файлов
- ts — компиляция typescript'a
- requirejs — сборка requirejs'ом
- js — запуск двух предыдущих команд последовательно
- css — компиляции css
- build- полная сборка
- server — запуск http сервера для отдачи статики
- dev — запус в dev режиме (отслеживание изменний + http сервер)
Несколько исполняемых файлов:
bin/compile-css — создаёт при необходимости директорию dist/css и запускает компиляцию stylus стилей:
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 — создаёт при необходимости нужные директории и копирует зависим��сти из node_modules, html файлы и ресурсы.
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 — собирает js файлы в один бандл.
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); });
Первые проблемы
Первые проблемы подстерегали уже на этапе установки зависимостей и запуска компиляции typescript.
Установив в зависимости Three.js и TypeSript:
$ npm install three --save $ npm install typescript --save-dev
Казалось логичным шагом проверить нет ли готовых тайпингов для Three.js. Оказалось, что есть — @types/three. И я устремилась их устанавливать:
$ npm install @types/three --save-dev
Однако, как выяснилось, тайпинги эти оказались не вполне качественными и при запуске компиляции тут же посыпали множеством однотипных ошибок примерно такого вида:
$ npm run ts ... node_modules/@types/three/three-core.d.ts(1611,32): error TS2503: Cannot find namespace 'THREE'.
Заглянув в node_modules/@types/three/index.d.ts увидела примерно такую структуру:
export * from "./three-core"; export * from "./three-canvasrenderer"; ... export * from "./three-vreffect"; export as namespace THREE;
Т.е. получается, что сначала подключаются все внутренние описания, а потом всё это объявляется пространством имен THREE и экспортится наружу. Но, в то же время, в самом первом включении — в three-core.d.ts уже используется пространство THREE, которое будет объявлено позже.
Как это у кого-то работало неизвестно (кто-то ведь всё это закоммитил).
Было у меня предположение, что пространство имен имело «обратную силу» в каких-нибудь предыдущих версиях typescript, а к актуальной версии от подобных экстравагантностей решили отказаться, но последовательный откат к предыдущим версиям результатов не принёс.
Тогда я решила посмотреть где же именно используется THREE в three-core.d.ts и как выяснилось все использования были сосредоточены в двух соседствующих методах:
/** * 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;
При этом все типы, которые указывались в пространстве имен THREE были описаны прямо тут же, в three-core.d.ts. Это означает, что для того, чтобы их использовать не нужно ни пространство имён, ни дополнительных импортов. Просто убрала THREE, запустила компиляцию снова и — вуаля, компиляция завершилась успешно.
Свет, камера, мотор
Источник света и камера — это неотъемлемые части любой 3D сцены. Которую, естественно, тоже необходимо создать:
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); } }
Также необходимо создать канвас для отрисовки, добавить его в документ и растянуть на весь экран:
... 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); }
И вызывать createRenderer в кострукторе:
... constructor() { this.createRenderer(); }
Ну и последний штрих стартовой настройки сцены — перерисовка:
constructor() { this.animate(); } protected animate() { window.requestAnimationFrame(() => this.animate()); this.renderer.render(this.scene, this.camera); }
Игровое поле
Подготовив сцену, можно начать добавлять объекты, связанные непосредственно с футболом. И мне показалось логичным начать именно с поля.
Текстура для поля без особых проблем нашлась в интернете (чего нельзя сказать о 3d-моделях, но об этом ниже):

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); }); } }
Как видно, сначала загружается текстура, затем создается объект класса PlaneGeometry, на него накладывается эта текстура. После чего объект немного вращается вокруг осей X и Z.
В результате получаем такую картину:

Никакого футбола не получится, если на поле не будет ворот. Поэтому я решила следующим шагом найти в интернете бесплатную 3D-модель футбольных ворот, создать объекты ворот в количестве двух штук и добавить их на сцену. Но тут меня ждал неприятный сюрприз, о котором поведает небольшое лирическое отступление.
Лирическое отступление
Внезапно для себя, выяснила, что найти подходящую 3D-модель — занятие отнюдь нетривиальное. Большинство годных моделей оказались платными, причём стоили довольно немалых (на мой взгляд) денег. И на поиски несчастных футбольных ворот было потрачено довольно таки немало времени. Я, конечно, не призываю к бесплатному распространению всего и вся, но вот в области разработки софта есть огромный пласт бесплатного ПО с открытым кодом, один github чего стоит. Бесплатные аудио, фото и многие другие типы файлов тоже, как правило, не составляет труда найти. Возможно во всех этих областях бесплатные аналоги будут в чём-то проигрывать коммерческим предложениям (а в чём-то, между прочим, будут и выигрывать), но они хотя бы есть, и найти их не составляет особо труда. Чего нельзя сказать об области 3D-моделирования.
Возможно, я упускаю какую-то деталь, или что-то о 3D-моделировании мне неизвестно, что сразу бы расставило все точки над i и объяснило почему так мало бесплатных моделей, а те что есть сложно найти и/или они заметно уступают в качестве. Буду рада услышать альтернативную точку зрения в комментариях.
Для всей игры мне всего и требовалось, что найти модели ворот, игроков и мяча. И по грубой оценке на поиски подходящих моделей было потрачено 20-30% от всего времени, потраченного на разработку.
Как баран на новые ворота
Но вернёмся к нашим баранам, точнее к воротам. Необходимая модель была всё-таки найдена, что позволило реализовать класс ворот:
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(); }); }); } }
Чтобы ворота нам подошли по размеру пришлось их немного сжать, что и происходит в строке:
this.mesh.scale.set(.15, .15, .15);
Особо внимательный читатель может заметить, что класс Gate наследуется от класса FootballObject, реализация которого не приводилась. Немедленно устраним эту вопиющую несправедливость.
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); } }
Впоследствии классы Player (игроки) и Ball (мяч) также будут отнаследованы от FootballObject, который содержит реализацию методов для установки позиции на сцене и вращения на определённый угол, заданный в градусах.
После чего нам остаётся создать объекты ворот и разместить их по нужным координатам:
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 — некоторое смещение, на которое потребовалось скорректировать координаты ворот, чтобы они встали чётко на разметку поля.
Как видно левые ворота сдвигаются на половину поля в отрицательную сторону (т. е. влево), правые ворота — на ту же половину поля в положительную сторону (т.е. вправо).
Обе модели вращаются, чтобы получить своё естественно положение на поле.
Результатом этого становится вот такая картина:

Изначально не планировала растягивать это на несколько статей, но как-то оно получается объемно, поэтому, пожалуй, на этой прекрасной ноте завершу первую часть статьи о «самопальном» футболе.
Во второй части расскажу о создании команд и игроков, их расстановке на поле и стратегии.
Чтобы не спойлерить и сохранить интригу до конца, выложу исходники и демку в последней части статьи.
Всем спасибо за внимание!
