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

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

f0637d8f2d014d66aa0546640c39d9dd.png

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

Итак, предыдущая часть закончилась тем, что у нас было футбольное поле и ворота. Этого категорически недостаточно для полноценного футбольного действа — нужны люди футболисты. С этого и начнём, но сначала работа на ошибками.

Работа над ошибками


В комментариях к первой части справедливо отмечалось, что имеет место некоторое нарушение перспективы. Я тоже это замечала, но списала это на несовершенность самого Three.js.
Однако, поигравшись с параметрами метода THREE.PerspectiveCamera удалось всё-таки добиться удобоваримого результата.

До:


this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);


59cfa2b3d9c2c710984892.png

После:


this.camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);


59cfa2b3e3ab2349973821.png

Т.е. проблема была в первом аргументе, который отвечает за вертикальное поле зрения камеры и задаётся в градусах. Но хочу заметить, что вызов этого метода скопирован из официального туториала по Three.js — Creating a scene, в котором также передаётся 75.

Футболист — Статика


Но вернёмся всё-таки к созданию футболистов. В предыдущей части был описан абстрактный класс FootballObject. Именно от него мы и отнаследуем класс Player:

Player.ts

import { Field } from './field';
import { FootballObject } from './object';
import { BASE_URL } from './const';
import { Mesh, MeshBasicMaterial, Scene, Texture } from 'three';

export enum PlayerType {
    DEFENDER,
    MIDFIELDER,
    FORWARD
}

export interface IPlayerOptions {
    isCpu: boolean;
}

const MIN_SPEED = 0.03;
const MAX_SPEED = 0.07;

export class Player extends FootballObject {

    protected options: IPlayerOptions;
    protected mesh: Mesh;
    protected type: PlayerType;
    protected startX: number;
    protected startZ: number;
    protected targetX: number;
    protected targetZ: number;
    protected speed: number;

    public isActive = true;
    public isRun = false;
    public isCurrent = false;

    static ready: Promise;
    static mesh: Mesh;

    constructor(scene: Scene, options: IPlayerOptions) {
        super(scene);

        this.options = options;
        this.speed = (Math.random() * (MAX_SPEED - MIN_SPEED) + MIN_SPEED);
    }

    static init(scene: Scene): Promise {
        if (!Player.ready) {
            Player.ready = new Promise((resolve, reject) => {
                const loader = new THREE.SEA3D({
                    autoPlay: true,
                    container: scene,
                    multiplier: .6
                });

                loader.onComplete = () => {
                    const mesh: Mesh = loader.getMesh('Player');
                    const hat = loader.getMesh('Hat');

                    if (hat) {
                        hat.visible = false;
                    }

                    mesh.scale.set(.02, .02, .02);
                    mesh.position.y = 2;
                    mesh.visible = false;

                    Player.mesh = mesh;

                    resolve();
                };
                loader.load(`${ BASE_URL }/resources/models/player1.sea`);
            });
        }

        return Player.ready;
    }

}

Первым делом объявляются типы футболистов — PlayerType:

  • DEFENDER — защитник
  • MIDFIELDER — полузащитник
  • FORWARD — нападающий

Далее определяется интерфейс для опций, в данном случае состоящий из единственного поля — IsCpu — является ли данный игрок участником команды под управлением компьютера.

Немного о членах класса Player:

  • startX, startZ, targetX, targetZ — определяют координаты старта и цели соответственно при движении, инициированном методом moveTo (о нём чуть позже)
  • isActive — активность, позволяет «выключить» игрока
  • isRun — находится ли игрок в состоянии бега
  • isCurrent — является ли игрок текущим активным игроком своей команды (в случае с командой, за которую играет пользователь — является ли игрок именно тем, кем в настоящий момент управляет пользователь с клавиатуры)

Здесь есть два интересных момента:

Момент первый — это то, что загрузка модели вынесена в статический метод. Изначально для каждого игрока я загружала свою собственную модель, ибо она всё равно кешировалась и накладных расходов в виде походов по сети для каждого игрока не было. И в том, что на каждый игрока инстанцировалась своя модель не было ничего особо страшного, кроме того, что потреблялось больше памяти, но с этим вполне можно было жить. Однако, по неясным для меня причинам, каждый следующий создаваемый экземпляр класса Player всё больше «обесцвечивал» все инстансы. Да так, что после создания всех экземпляров они становились абсолютно белыми, как будто и нет у них никакой текстуры:

59d039ba6b700996587435.png

Пробовала разные варианты загрузки, разные способы установки текстур, всё тщетно. И тут вспомнила, что где-то читала о методе оптимизации работы с моделей в случае многократного использования на сцене. Там рекомендовалось не загружать модель каждый раз, а клонировать, что позволяет уменьшить объём выделяемой под хранение моделей памяти. Стала смотреть есть ли такая возможность в Three.js и выяснилось, что есть — Mesh (см. метод clone).
Добавляем нашему Player'у метод clone:

clone() {      
    this.mesh = Player.mesh.clone();
    this.scene.add(this.mesh);
}


И, о чудо, модели перестали «обесцвечиваться» — проблема решена.

Момент второй — глядя на этот код

const hat = loader.getMesh('Hat');

if (hat) {
    hat.visible = false;
}

вы можете подумать: «hat? Шляпа? Какая ещё шляпа?». И будете правы, шляпа тут вообще не к месту. Но я вам объясню откуда она здесь появилась. Ещё в первой части этой статьи из меня излилось немного праведного гнева по поводу сложности поиска 3D-моделей. В частности модель футболиста я искала очень долго, находила в форматах, неподдерживаемых Three.js, искала конвертер, конвертировала, загружала на сцену и тут выяснялось, что толи в процессе конвертации, толи ещё где модель «побилась» и теперь это скорее не футболист, а огромный богомол.
Радости моей не было предела, когда подходящая модель была найдена, да не где-нибудь, а в разделе с примерами самой Three.js — loader/sea3d/skinning. Это значило, что модель гарантировано, без регистрации и смс можно будет загрузить на сцену Three.js и не получить артефакты, генерируемые конвертерами. Но при всём при этом был и один немаловажный нюанс — футболист был в огромной соломенной шляпе:

59d0409058c07685002568.png

К счастью, как видно из кода выше, шляпу удалось скрыть и получился обычный футболист.

Для того, чтобы команды различались цветом игроку нужно уметь устанавливать текстуру, отличную от той, что загружается вместе с моделью. Для этих целей нам послужит метод setTexture:

setTexture(textureName: string) {
    const loader = new THREE.TextureLoader();

    loader.load(`${ BASE_URL }/resources/textures/${textureName}`, (texture: Texture) => {
        this.mesh.material = this.mesh.material.clone();
        texture.flipY = false;
        ( this.mesh.material).map = texture;
    });
}

Футболист — Динамика


Заставим наших футболистов бегать! Эту фразу мог бы произнести тренер сборной сборной России по футболу, но произношу её я. Поэтому мои футболисты будут бегать гарантировано, а вот с нашей сборной не всё так однозначно.
В моём случае все футболисты делятся на два типа:

  • футболист, которым управляет пользователь
  • все остальные футболисты


С футболистом, которым управляет пользователь всё понятно — он перемещается туда и тогда куда и когда это ему укажет пользователь. Если пользователь бездействует, то и футболист стоит на месте.
Остальным же футболистам необходимо указывать куда и когда им следует перемещаться. Реализовано это двумя методами: moveTo и animate. Первый сохраняет координаты точки, в которую футболисту надлежит переместиться, второй реализует это перемещение и вызывается вместе с перерисовкой сцены.

moveTo(x: number, z: number) {
    this.startX = this.mesh.position.x;
    this.startZ = this.mesh.position.z;
    this.targetX = x;
    this.targetZ = z;
    this.isRun = true;
}

animate(options: any) {
    if (this.isCurrent && this.isRun && !this.options.isCpu) {
        this.run();
    } else if (this.isRun) {
        const distanceX = this.targetX - this.startX;
        const distanceZ = this.targetZ - this.startZ;
        const newX = this.mesh.position.x + this.speed * (distanceX > 0 ? 1 : -1);
        const newZ = this.mesh.position.z + this.speed * (distanceZ > 0 ? 1 : -1);

        let isRun = false;

        if (Field.isInsideByX(newX) && ((distanceX > 0 && this.mesh.position.x < this.targetX) || (distanceX < 0 && this.mesh.position.x > this.targetX))) {
            this.mesh.position.x = newX;
            isRun = true;
        }

        if (Field.isInsideByZ(newZ) && ((distanceZ > 0 && this.mesh.position.z < this.targetZ) || (distanceZ < 0 && this.mesh.position.z > this.targetZ))) {
            this.mesh.position.z = newZ;
            isRun = true;
        }

        this.isRun = isRun;
        this.run();
    } else if (!options.isStarted) {
        this.idleStatic();
    } else {
        this.idleDynamic();
    }
}

В animate рассчитываются координаты для текущей итерации перерисовки с учётом скорости футболиста, проверяется не выходит ли футболист за пределы поля и запускается необходимая анимация — бег или ожидание.

Команда, без которой нам не жить


Теперь у нас есть футболисты и мы наконец-то можем собрать из них комаду:

import { Player, PlayerType } from './player';
import { FIELD_WIDTH, FIELD_HEIGHT } from './field';
import { Utils } from './utils';
import { Scene } from 'three';
import { FootballObject } from './object';

export class Team {

    protected scene: Scene;
    protected options: ITeamOptions;
    protected players: Player[] = [];
    protected currentPlayer: Player;
    protected score = 0;

    withBall = false;

}

Массив players будет содержать всех игроков команды.
currentPlayer — ссылка на текущего активного игрока.
score — количество голов, забитых командой.
withBall — флаг, определяющий владеет ли команда мячом.

Наши команды играют по схеме 4–4–2:

protected getPlayersType(): PlayerType[] {
    return [
        PlayerType.DEFENDER,
        PlayerType.DEFENDER,
        PlayerType.DEFENDER,
        PlayerType.DEFENDER,
        PlayerType.MIDFIELDER,
        PlayerType.MIDFIELDER,
        PlayerType.MIDFIELDER,
        PlayerType.MIDFIELDER,
        PlayerType.FORWARD,
        PlayerType.FORWARD
    ]
}

т.е. 4 защитника, 4 полузащитника и 2 нападающих.

Теперь заполним нашу команду футболистами:

createPlayers() {
    return new Promise((resolve, reject) => {
        Player.init(this.scene)
            .then(() => {
                const types: PlayerType[] = this.getPlayersType();
                let promises = [];

                for (let i = 0; i < 10; i++) {
                    let player = new Player(this.scene, {
                        isCpu: this.options.isCpu
                    });

                    const promise = player.clone()
                        .then(() => {
                            if (this.options.side === 'left') {
                                player.setRotateY(90);
                            } else {
                                player.setRotateY(-90);
                            }
                            player.setType(types[i]);
                            player.show();

                            this.players.push(player);
                        });

                    promises.push(promise);
                }

                Promise.all(promises)
                    .then(() => this.setStartPositions());

                resolve();
            });
    });
}

Здесь можно отметить, что в зависимости от стороны на поле, занимаемой командой (left/right передаётся в опциях), футболисты разворачиваются на 90 или на -90 градусов чтобы быть повёрнутыми лицом к воротам противника.
По завершению создания всех экземпляров вызывается метод setStartPositions, который расставляет футболистов по стартовым позициям.

setStartPositions() {
    const startPositions = this.getStartPositions();

    this.players.forEach((item: Player, index: number) => {
        item.isRun = false;

        if (startPositions[index]) {
            item.setPositionX(startPositions[index].x);
            item.setPositionZ(startPositions[index].z);
        }
    })
}

protected getStartPositions() {
    const halfFieldWidth = FIELD_WIDTH / 2;
    const halfFieldHeight = FIELD_HEIGHT / 2;

    if (this.options.side === 'left') {
        return [
            {
                x: this.getRandomPosition(- halfFieldWidth * .6),
                z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .1)
            },
            {
                x: this.getRandomPosition(- halfFieldWidth * .6),
                z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .4)
            },
            {
                x: this.getRandomPosition(- halfFieldWidth * .6),
                z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .7)
            },
            {
                x: this.getRandomPosition(- halfFieldWidth * .6),
                z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .9)
            },

            {
                x: this.getRandomPosition(- halfFieldWidth * .4),
                z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .1)
            },
            {
                x: this.getRandomPosition(- halfFieldWidth * .4),
                z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .4)
            },
            {
                x: this.getRandomPosition(- halfFieldWidth * .4),
                z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .7)
            },
            {
                x: this.getRandomPosition(- halfFieldWidth * .4),
                z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .9)
            },

            {
                x: this.getRandomPosition(- halfFieldWidth * .2),
                z: 0
            },
            {
                x: 0,
                z: 0
            }
        ];
    } else {
        return [
            {
                x: this.getRandomPosition(halfFieldWidth * .6),
                z: - halfFieldHeight + FIELD_HEIGHT * .1
            },
            {
                x: this.getRandomPosition(halfFieldWidth * .6),
                z: - halfFieldHeight + FIELD_HEIGHT * .4
            },
            {
                x: this.getRandomPosition(halfFieldWidth * .6),
                z: - halfFieldHeight + FIELD_HEIGHT * .7
            },
            {
                x: this.getRandomPosition(halfFieldWidth * .6),
                z: - halfFieldHeight + FIELD_HEIGHT * .9
            },

            {
                x: this.getRandomPosition(halfFieldWidth * .4),
                z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .1)
            },
            {
                x: this.getRandomPosition(halfFieldWidth * .4),
                z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .4)
            },
            {
                x: this.getRandomPosition(halfFieldWidth * .4),
                z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .7)
            },
            {
                x: this.getRandomPosition(halfFieldWidth * .4),
                z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .9)
            },

            {
                x: this.getRandomPosition(halfFieldWidth * .2),
                z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .3)
            },
            {
                x: this.getRandomPosition(halfFieldWidth * .2),
                z: this.getRandomPosition(- halfFieldHeight + FIELD_HEIGHT * .7)
            },
        ];
    }
}

Как можно заметить, в определение положения на поле используется элемент случайности, реализованный методом getRandomPosition:

protected getRandomPosition(n: number, size?: number): number {
    size = size || 2;
    const min = n - size;
    const max = n + size;

    return Math.random() * (max - min) + min;
}

protected getRandomPositionX(x: number, size?: number) {
    let position = this.getRandomPosition(x, size);

    position = Math.min(position, FIELD_WIDTH / 2);
    position = Math.max(position, - FIELD_WIDTH / 2);

    return position;
}

protected getRandomPositionZ(z: number, size?: number) {
    let position = this.getRandomPosition(z, size);

    position = Math.min(position, FIELD_HEIGHT / 2);
    position = Math.max(position, - FIELD_HEIGHT / 2);

    return position;
}

Методы getRandomPositionX и getRandomPositionZ возвращают позицию с элементом случайности для координат x и z с учётом размеров поля и будут использованы позднее.

Кроме этого на будущее пригодятся вот эти два метода:

getNearestPlayer(point: FootballObject): Player {
    let min: number = Infinity,
        nearest: Player = null;

    this.players.forEach((item: Player) => {
        if (item !== point && item.isActive) {
            const distance = Utils.getDistance(item, point);

            if (distance < min) {
                min = distance;
                nearest = item;
            }
        }
    });

    return nearest;
}

getNearestForwardPlayer(point: FootballObject): Player {
    let min: number = Infinity,
        nearest: Player = null;

    this.players.forEach((item: Player) => {
        if (item !== point && item.isActive && item.getPositionX() > point.getPositionX()) {
            const distance = Utils.getDistance(item, point);

            if (distance < min) {
                min = distance;
                nearest = item;
            }
        }
    });

    return nearest || this.getNearestPlayer(point);
}

getNearestPlayer — возвращает футболиста, находящегося ближе всех к заданной точке.
getNearestForwardPlayer — делает то же самое, но с одним нюансом — поиск ближайшего футболиста осуществляется среди тех из них, координата по X, которых превышает координату по X заданной точки. Этот метод пригодится при поиске футболиста, которому следует отдать пас. При этом если такой футболист не будет найден (т.е. заданная точка по координате X превышает всех футболистов команды), то будет найден просто ближайший футболист, даже если его координата X меньше, чем у заданной точки.
Для определения расстояния используется метод Utils.getDistance:

static getDistance(obj1: FootballObject, obj2: FootballObject): number {
    if (obj1 && obj2) {
        const distanceX = obj1.getPositionX() - obj2.getPositionX();
        const distanceZ = obj1.getPositionZ() - obj2.getPositionZ();

        return Math.sqrt(distanceX * distanceX + distanceZ * distanceZ);
    }
}

Лучшая защита — нападение


Пришло время задуматься о стратегии команды. Дабы не усложнять, я решила, что мои команды будут уметь только нападать или защищаться, т.е. будут иметь две стратегии — defense и attack. Когда одна команда с мячом — она атакует, а команда противника защищается, и наоборот. Каждый из типов футболистов при этом должны стремиться в определённую точку поля (не без элемента случайности, естественно).
Например, когда команда атакует, то защитники стремятся к центру поля, полузащитники к середине половины поля противника (¾ поля), нападающие ближе к воротам противника.
И примерно в таких же пропорциях футболисты отходят на свою половину поля при защите: защитники стремятся к своим воротам, полузащитники к середине своей половины поля (¼ поля), нападающие остаются в центре поля.
Эта логика реализована в методе setStrategy класса Team:

setStrategy(strategy: string) {
    const isLeft = this.options.side === 'left';
    const RND_SIZE = 4;

    switch (strategy) {
        case 'defense':
            this.players
                .filter((item: Player) => item.getType() === PlayerType.FORWARD && item !== this.currentPlayer)
                .forEach((item: Player) => {
                    item.moveTo(this.getRandomPositionX(0, RND_SIZE), this.getRandomPositionZ(item.getPositionZ(), RND_SIZE));
                });
            this.players
                .filter((item: Player) => item.getType() === PlayerType.MIDFIELDER && item !== this.currentPlayer)
                .forEach((item: Player) => {
                    item.moveTo(this.getRandomPositionX(((isLeft ? - FIELD_WIDTH : FIELD_WIDTH) / 2) * .4, RND_SIZE), this.getRandomPositionZ(item.getPositionZ(), RND_SIZE));
                });
            this.players
                .filter((item: Player) => item.getType() === PlayerType.DEFENDER && item !== this.currentPlayer)
                .forEach((item: Player) => {
                    item.moveTo(this.getRandomPositionX(((isLeft ? - FIELD_WIDTH : FIELD_WIDTH) / 2) * .6, RND_SIZE), this.getRandomPositionZ(item.getPositionZ(), RND_SIZE));
                });
            break;
        case 'attack':
            this.players
                .filter((item: Player) => item.getType() === PlayerType.FORWARD && item !== this.currentPlayer)
                .forEach((item: Player) => {
                    item.moveTo(this.getRandomPositionX(((isLeft ? FIELD_WIDTH : - FIELD_WIDTH) / 2) * .7, RND_SIZE), this.getRandomPositionZ(item.getPositionZ(), RND_SIZE));
                });
            this.players
                .filter((item: Player) => item.getType() === PlayerType.MIDFIELDER && item !== this.currentPlayer)
                .forEach((item: Player) => {
                    item.moveTo(this.getRandomPositionX(((isLeft ? FIELD_WIDTH : - FIELD_WIDTH) / 2) * .5, RND_SIZE), this.getRandomPositionZ(item.getPositionZ(), RND_SIZE));
                });
            this.players
                .filter((item: Player) => item.getType() === PlayerType.DEFENDER && item !== this.currentPlayer)
                .forEach((item: Player) => {
                    item.moveTo(this.getRandomPositionX(0, RND_SIZE), this.getRandomPositionZ(item.getPositionZ(), RND_SIZE));
                });
            break;
    }
}

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

Всем спасибо за внимание!

© Habrahabr.ru