Pull to refresh

Как я браузерный 3D-футбол писала. Часть 1

Reading time8 min
Views21K
Привет, Хабр! Хочу поделиться историей о том, как я браузерный 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 стилей:

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 — некоторое смещение, на которое потребовалось скорректировать координаты ворот, чтобы они встали чётко на разметку поля.

Как видно левые ворота сдвигаются на половину поля в отрицательную сторону (т. е. влево), правые ворота — на ту же половину поля в положительную сторону (т.е. вправо).
Обе модели вращаются, чтобы получить своё естественно положение на поле.
Результатом этого становится вот такая картина:


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

Во второй части расскажу о создании команд и игроков, их расстановке на поле и стратегии.

Чтобы не спойлерить и сохранить интригу до конца, выложу исходники и демку в последней части статьи.

Всем спасибо за внимание!
Tags:
Hubs:
Total votes 60: ↑56 and ↓4+52
Comments19

Articles