Привет! Хочу поделиться историей о том, как я браузерный 3D-футбол писала. Началось всё с того, что мой муж любит футбол. Смотрит трансляции, ходит на игры, играет на телефоне. И вот, чтобы сделать ему сюрприз, а также, чтобы хоть ненадолго оторвать от девайса с игрой, решила написать свою игру.
Под катом я расскажу как дружила 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 стилей:
#!/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 файлы и ресурсы.
#!/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 файлы в один бандл.
#!/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 — некоторое смещение, на которое потребовалось скорректировать координаты ворот, чтобы они встали чётко на разметку поля.
Как видно левые ворота сдвигаются на половину поля в отрицательную сторону (т. е. влево), правые ворота — на ту же половину поля в положительную сторону (т.е. вправо).
Обе модели вращаются, чтобы получить своё естественно положение на поле.
Результатом этого становится вот такая картина:
Изначально не планировала растягивать это на несколько статей, но как-то оно получается объемно, поэтому, пожалуй, на этой прекрасной ноте завершу первую часть статьи о «самопальном» футболе.
Во второй части расскажу о создании команд и игроков, их расстановке на поле, стратегии и игровой механике, а также про интерфейс управления.
Чтобы не спойлерить и сохранить интригу до конца, выложу исходники и демку в последней части статьи.
Всем спасибо за внимание!
Автор: Катя
Вот это хардкор