DevGang
Авторизоваться

Создание игры в Three.js 

Совсем недавно единственным способом создавать и развертывать игры было выбрать игровой движок, такой как Unity или Unreal, выучить язык, а затем упаковать свою игру и развернуть ее на выбранной вами платформе.

Мысль о попытке доставить игру пользователю через его браузер казалась невыполнимой задачей.

К счастью, благодаря достижениям в браузерных технологиях и аппаратному ускорению, которое становится доступным во всех популярных браузерах, повышению производительности JavaScript и постоянному увеличению доступной вычислительной мощности, создание интерактивных игр для браузеров становится все более и более распространенным явлением.

В этой статье мы рассмотрим, как можно создать игру с помощью Three.js. Вы можете исследовать это здесь, а также посмотреть видеоурок:

Но сначала давайте рассмотрим, что такое Three.js и почему это хороший выбор для разработки игр.

Что такое Three.js?

В описании проекта Three.js на GitHub Three.js метко описывается как «…простая в использовании, легкая, кросс-браузерная 3D-библиотека общего назначения».

Three.js позволяет нам, как разработчикам, относительно просто рисовать 3D-объекты и модели на экране. Без него нам пришлось бы взаимодействовать напрямую с WebGL, что, хотя и не невозможно, может сделать даже самый маленький проект по разработке игры занимающим невероятное количество времени.

Традиционно «движок игры» состоит из нескольких частей. Например, Unity и Unreal предоставляют способ рендеринга объектов на экран, а также множество других функций, таких как сеть, физика и так далее.

Однако Three.js более ограничен в своем подходе и не включает такие вещи, как физика или сеть. Но этот более простой подход означает, что его легче освоить и он более оптимизирован для того, чтобы делать то, что он делает лучше всего: рисовать объекты на экране.

В нем также есть отличный набор примеров, которые мы можем использовать, чтобы понять, как рисовать различные объекты на экране. Наконец, он предоставляет простой и нативный способ загрузки наших моделей в нашу сцену.

Three.js может быть привлекательным вариантом в качестве механизма разработки игр, если вы не хотите, чтобы вашим пользователям приходилось загружать приложение через магазин приложений или иметь какие-либо настройки для игры в вашу игру. Если ваша игра работает в браузере, то у вас самый низкий порог входа, что может быть только плюсом.

Использование Three.js для создания нашей игры

Сегодня мы пройдемся по Three.js, создав игру, в которой используются шейдеры, модели, анимация и игровая логика. То, что мы создадим, будет выглядеть так:

Концепция проста. Мы управляем ракетным кораблем, летящим по планете, и наша цель — собрать энергетические кристаллы. Нам также нужно управлять здоровьем нашего корабля, подбирая усиления щита и стараясь не повредить наш корабль слишком сильно, ударяя о камни на сцене.

В конце нашего прохождения ракетный корабль возвращается к базовому кораблю в небе, и если пользователь нажимает NEXT LEVEL, он получает еще один проход, на этот раз с более длинным путем, по которому должна пройти ракета.

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

Чтобы создать такую ​​игру, мы должны ответить на следующие вопросы:

  1. Как мы можем постоянно двигать ракетный корабль по водному пространству?
  2. Как мы можем обнаружить столкновения между ракетным кораблем и объектами?
  3. Как мы можем создать пользовательский интерфейс, который работает как на настольных, так и на мобильных устройствах?

К тому времени, когда мы создадим эту игру, мы преодолеем эти трудности.

Прежде чем мы начнем программировать, мы должны рассмотреть краткую теорию, в частности, касающуюся того, как мы будем создавать ощущение движения в игре.

Создание ощущения движения

Представьте на мгновение, что в реальной жизни вы управляете вертолетом и следуете за объектом на земле. Объект движется со скоростью, которая постепенно увеличивается. Чтобы не отставать, вы должны постепенно увеличивать скорость вертолета, в котором находитесь.

Если бы не было ограничений на скорость вертолета или объекта на земле, это продолжалось бы до тех пор, пока вы хотели бы не отставать от объекта на земле.

При создании игры, которая следует за объектом, как мы делаем в этом примере, может возникнуть соблазн применить ту же логику. То есть перемещать объект в мировом пространстве по мере его ускорения, и обновлять скорость камеры, которая следует за ним. Однако это представляет непосредственную проблему.

По сути, все, кто играет в эту игру, будут играть в нее на своих телефонах или настольных компьютерах. Это устройства с ограниченными ресурсами. Если мы попытаемся сгенерировать возможно неограниченное количество объектов при движении камеры, а затем переместим эту камеру, в конечном итоге мы израсходуем все доступные ресурсы, и вкладка браузера перестанет отвечать или выйдет из строя.

Нам также необходимо создать плоскость (плоский 2D-объект), представляющую океан. Когда мы делаем это, мы должны указать размеры океана.

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

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

Бесконечное движение в конечных пределах

Вместо того, чтобы бесконечно перемещать камеру в одном направлении, мы держим камеру неподвижной и перемещаем окружающую среду вокруг нее. Это имеет несколько преимуществ.

Во-первых, мы всегда знаем, где находится наш ракетный корабль, поскольку местонахождение ракеты не уходит вдаль; двигается только из стороны в сторону. Это позволяет нам легко определить, находятся ли объекты за камерой и могут ли они быть удалены со сцены для высвобождения ресурсов.

Другое преимущество заключается в том, что мы можем выбрать точку на расстоянии для создания объектов. Это означает, что по мере того, как объекты приближаются к игроку, новые предметы или объекты будут постоянно создаваться на расстоянии за пределами поля зрения игроков.

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

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

Это даст эффект того, что поверхность воды движется все быстрее и быстрее.

Теперь, когда мы решили, как мы будем двигать ракету вперед по сцене, давайте перейдем к настройке нашего проекта.

Конфигурация игрового проекта

Давайте начнем делать нашу игру! Первое, что нам нужно сделать, это настроить среду сборки. Для этого примера я решил использовать Typescript и Webpack. Эта статья не посвящена преимуществам этих технологий, поэтому я не буду вдаваться в подробности о них, за исключением краткого обзора.

Использование Webpack означает, что когда мы разрабатываем наш проект и сохраняем наши файлы, Webpack увидит, что наши файлы изменились, и автоматически перезагрузит наш браузер с сохраненными изменениями.

Это означает, что вам не нужно вручную обновлять браузер каждый раз, когда вы вносите изменения, что экономит много времени. Это также означает, что мы можем использовать такие плагины, как three-minifier, которые уменьшают размер нашего пакета при его развертывании.

Использование TypeScript в нашем примере означает, что наш проект будет иметь безопасность типов. Я нахожу это особенно полезным при работе с некоторыми внутренними типами Three.js, такими как Vector3 и Quaternions. Очень ценно знать, что я присваиваю переменной правильный тип значения.

Мы также будем использовать Materialize CSS для нашего пользовательского интерфейса. Для нескольких кнопок и карточек, которые мы будем использовать в качестве нашего пользовательского интерфейса, эта структура CSS значительно поможет.

Чтобы начать работу над нашим проектом, создайте новую папку. В папке создайте package.json и вставьте следующее содержимое:

{
  "dependencies": {
    "materialize-css": "^1.0.0",
    "nipplejs": "^0.9.0",
    "three": "^0.135.0"
  },
  "devDependencies": {
    "@types/three": "^0.135.0",
    "@yushijinhun/three-minifier-webpack": "^0.3.0",
    "clean-webpack-plugin": "^4.0.0",
    "copy-webpack-plugin": "^9.1.0",
    "html-webpack-plugin": "^5.5.0",
    "raw-loader": "^4.0.2",
    "ts-loader": "^9.2.5",
    "typescript": "^4.5.4",
    "webpack": "^5.51.1",
    "webpack-cli": "^4.8.0",
    "webpack-dev-server": "^4.0.0",
    "webpack-glsl-loader": "git+https://github.com/grieve/webpack-glsl-loader.git",
    "webpack-merge": "^5.8.0"
  },
  "scripts": {
    "dev": "webpack serve --config ./webpack.dev.js",
    "build": "webpack --config ./webpack.production.js"
  }
}

Затем в командном окне введите npm i для установки пакетов в новый проект.

Добавление файлов Webpack

Теперь нам нужно создать три файла: базовый файл конфигурации Webpack, а затем конфигурацию разработки и производства для нашего проекта.

Создайте файл [webpack.common.js] в папке вашего проекта и вставьте следующую конфигурацию:

const HtmlWebpackPlugin = require("html-webpack-plugin");
const CopyPlugin = require("copy-webpack-plugin");

module.exports = {
    plugins: [
        // Automatically creat an index.html with the right bundle name and references to our javascript.
        new HtmlWebpackPlugin({
            template: 'html/index.html'
        }),
        // Copy game assets from our static directory, to the webpack output
        new CopyPlugin({
            patterns: [
                {from: 'static', to: 'static'}
            ]
        }),
    ],
    // Entrypoint for our game
    entry: './game.ts',
    module: {
        rules: [
            {
                // Load our GLSL shaders in as text
                test: /.(glsl|vs|fs|vert|frag)$/, exclude: /node_modules/, use: ['raw-loader']
            },
            {
                // Process our typescript and use ts-loader to transpile it to Javascript
                test: /.tsx?$/,
                use: 'ts-loader',
                exclude: /node_modules/,
            }

        ],
    },
    resolve: {
        extensions: ['.tsx', '.ts', '.js'],
    },

}

Затем создайте файл [webpack.dev.js] и вставьте в него эти данные. Это настраивает функциональность горячей перезагрузки сервера разработки Webpack:

const { merge } = require('webpack-merge')
const common = require('./webpack.common.js')
const path = require('path');
module.exports = merge(common, {
    mode: 'development', // Don't minify the source
    devtool: 'eval-source-map', // Source map for easier development
    devServer: {
        static: {
            directory: path.join(__dirname, './dist'), // Serve static files from here
        },
        hot: true, // Reload our page when the code changes
    },
})

Наконец, создайте файл [webpack.production.js] и вставьте в него следующие данные:

const { merge } = require('webpack-merge')
const common = require('./webpack.common.js')
const path = require('path');
const ThreeMinifierPlugin = require("@yushijinhun/three-minifier-webpack");
const {CleanWebpackPlugin} = require("clean-webpack-plugin");
const threeMinifier = new ThreeMinifierPlugin();

module.exports = merge(common, {
    plugins: [
        threeMinifier, // Minifies our three.js code
        new CleanWebpackPlugin() // Cleans our 'dist' folder between builds
    ],
    resolve: {
        plugins: [
            threeMinifier.resolver,
        ]
    },
    mode: 'production', // Minify our output
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].[fullhash:8].js', // Our output will have a unique hash, which will force our clients to download updates if they become available later
        sourceMapFilename: '[name].[fullhash:8].map',
        chunkFilename: '[id].[fullhash:8].js'
    },
    optimization: {
        splitChunks: {
            chunks: 'all', // Split our code into smaller chunks to assist caching for our clients
        },
    },
})

Настройка среды TypeScript

Следующее, что нам нужно сделать, это настроить нашу среду TypeScript, чтобы мы могли использовать импорт из файлов JavaScript. Для этого создайте файл [tsconfig.json] и вставьте в него следующие данные:

{
    "compilerOptions": {
        "moduleResolution": "node",
        "strict": true,
        "allowJs": true,
        "checkJs": false,
        "target": "es2017",
      "module": "commonjs"

    },
    "include": ["**/*.ts"]
}

Теперь наша среда сборки настроена. Пришло время приступить к созданию красивой и правдоподобной сцены для навигации наших игроков.

Настройка игровой сцены

Наша сцена состоит из следующих элементов:

  1. Сама сцена (это то, к чему мы добавляем наши объекты, чтобы составить игровой мир)
  2. Небо
  3. Воды
  4. Фоновые объекты (камни, расположенные по обе стороны игровой площадки пользователя)
  5. Ракетный корабль
  6. Ряды, содержащие кристаллы, камни и щиты (называемые «рядами испытаний»).

Мы будем выполнять большую часть нашей работы в файле с именем game.ts, но мы также разобьем части нашей игры на отдельные файлы, чтобы не получить невероятно длинный файл. Теперь мы можем продолжить и создать файл game.ts.

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

Настройка игровой сцены

Первое, что нам нужно сделать, это создать файл Scene, чтобы Three.js что-то отображал. В наш game.tsмы добавим следующие строки, чтобы создать наш Scene и разместить PerspectiveCamera в сцене, чтобы мы могли видеть, что происходит.

Наконец, мы создадим ссылку для нашего рендерера, которую назначим позже:

export const scene = new Scene()
export const camera = new PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    2000
)

// Our three renderer
let renderer: WebGLRenderer;

Создание функции инициализации

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

Для этого давайте создадим функцию init и также поместим ее в наш game.ts. Эта функция init выполнит первоначальную настройку нашей сцены и запустится только один раз (при первой загрузке игры):

/// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/game.ts#L275-L279)
async function init() {
    renderer = new WebGLRenderer();
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.domElement);
}

Нам также потребуется использовать цикл рендеринга и анимации для нашей сцены. Нам понадобится цикл анимации для перемещения объектов на экране по мере необходимости, и нам понадобится цикл рендеринга для отрисовки новых кадров на экране.

Давайте продолжим и создадим функцию render в нашем файле game.ts. В начале эта функция будет выглядеть довольно голой, потому что она просто запрашивает кадр анимации, а затем визуализирует сцену.

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

// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/game.ts#L157)
const animate = () => {
    requestAnimationFrame(animate);
    renderer.render(scene, camera);
}

Итак, теперь у нас есть пустая сцена с камерой, но больше ничего. Давайте добавим немного воды в нашу сцену.

Создание воды для Scene

К счастью, в Three.js есть пример водного объекта, который мы можем использовать в нашей сцене. Он включает в себя отражения в реальном времени и выглядит неплохо; Вы можете проверить это здесь.

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

Мы делаем это потому, что если с течением времени мы смещаем нашу текстуру воды на возрастающую величину, это даст нам ощущение скорости.

Чтобы продемонстрировать, это начальная сцена нашей игры, но я увеличиваю смещение в каждом кадре. По мере увеличения смещения кажется, что скорость океана под нами увеличивается (хотя ракета на самом деле неподвижна).

Водный объект можно найти здесь, на GitHub Three.js. Единственное, что нам нужно сделать, это внести небольшое изменение, чтобы сделать это смещение управляемым из нашего цикла рендеринга (чтобы мы могли обновлять его с течением времени).

Первое, что мы сделаем, это возьмем копию примера Water.js из репозитория Three.js. Мы поместим этот файл в наш проект по адресу objects/water.js. Если мы откроем файл water.js примерно на полпути, мы начнем видеть что-то похожее на это:

Это шейдеры для нашего материала океана. Сами шейдеры выходят за рамки этой статьи, но, по сути, это инструкции, которые наша игра даст компьютерам наших пользователей о том, как рисовать этот конкретный объект.

У нас также есть наш код шейдера, написанный на языке шейдеров OpenGraph (GLSL), включенный в файл, который в остальном является JavaScript.

В этом нет ничего плохого, но если мы переместим этот код шейдера в отдельный файл, тогда мы сможем установить поддержку GLSL в выбранную нами IDE, и мы получим такие вещи, как раскраска синтаксиса и проверка, которые помогут нам настроить наш GLSL.

Чтобы разбить GLSL на отдельные файлы, давайте создадим каталог shader в нашем текущем каталоге objects, выберем содержимое нашего vertexShader и нашего fragmentShader и переместим их в файлы waterFragmentShader.glsl и waterVertexShader.glsl соответственно.

Вверху нашего файла [waterFragmentShader.glsl] у нас есть функция getNoise. По умолчанию это выглядит так:

vec4 getNoise( vec2 uv ) {
  vec2 uv0 = ( uv / 103.0 ) + vec2(time / 17.0, time / 29.0);
  vec2 uv1 = uv / 107.0-vec2( time / -19.0, time / 31.0 );
  vec2 uv2 = uv / vec2( 8907.0, 9803.0 ) + vec2( time / 101.0, time / 97.0 );
  vec2 uv3 = uv / vec2( 1091.0, 1027.0 ) - vec2( time / 109.0, time / -113.0 );
  vec4 noise = texture2D( normalSampler, uv0 ) +
   texture2D( normalSampler, uv1 ) +
   texture2D( normalSampler, uv2 ) +
   texture2D( normalSampler, uv3 );
  return noise * 0.5 - 1.0;
}

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

// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/objects/shaders/waterFragmentShader.glsl#L15-L32)

uniform float speed;

vec4 getNoise(vec2 uv) {
    float offset;
    if (speed == 0.0){
        offset = time / 10.0;
    }
    else {
        offset = speed;
    }
    vec2 uv3 = uv / vec2(50.0, 50.0) - vec2(speed / 1000.0, offset);
    vec2 uv0 = vec2(0, 0);
    vec2 uv1 = vec2(0, 0);
    vec2 uv2 = vec2(0, 0);
    vec4 noise = texture2D(normalSampler, uv0) +
    texture2D(normalSampler, uv1) +
    texture2D(normalSampler, uv2) +
    texture2D(normalSampler, uv3);
    return noise * 0.5 - 1.0;
}

Вы заметите, что мы включили новую переменную в этот GLSL-файл: переменную speed. Это переменная, которую мы будем обновлять, чтобы дать ощущение скорости.

В нашем game.ts теперь нам нужно настроить параметры воды. Вверху нашего файла добавьте следующие переменные:

// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/game.ts#L81-L98)

const waterGeometry = new PlaneGeometry(10000, 10000);

const water = new Water(
    waterGeometry,
    {
        textureWidth: 512,
        textureHeight: 512,
        waterNormals: new TextureLoader().load('static/normals/waternormals.jpeg', function (texture) {
            texture.wrapS = texture.wrapT = MirroredRepeatWrapping;
        }),
        sunDirection: new Vector3(),
        sunColor: 0xffffff,
        waterColor: 0x001e0f,
        distortionScale: 3.7,
        fog: scene.fog !== undefined
    }
);

Затем внутри нашей функции init мы должны настроить вращение и положение нашей водной плоскости, например так:

// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/game.ts#L364-L368)
// Water
water.rotation.x = -Math.PI / 2;
water.rotation.z = 0;
scene.add(water);

Это даст правильное вращение для океана.

Создание неба

Three.js поставляется с довольно убедительным небом, которое мы можем бесплатно использовать в нашем проекте. Вы можете увидеть пример этого на странице примеров Three.js здесь.

Добавить небо в наш проект довольно просто; нам просто нужно добавить небо в сцену, установить размер skybox, а затем установить некоторые параметры, которые управляют тем, как выглядит наше небо.

В нашей функции init, которую мы объявили, мы добавим небо в нашу сцену и настроим визуальные эффекты для неба:

// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/game.ts#L369-L398)
const sky = new Sky();
sky.scale.setScalar(10000); // Specify the dimensions of the skybox
scene.add(sky); // Add the sky to our scene

// Set up variables to control the look of the sky
const skyUniforms = sky.material.uniforms;
skyUniforms['turbidity'].value = 10;
skyUniforms['rayleigh'].value = 2;
skyUniforms['mieCoefficient'].value = 0.005;
skyUniforms['mieDirectionalG'].value = 0.8;

const parameters = {
    elevation: 3,
    azimuth: 115
};

const pmremGenerator = new PMREMGenerator(renderer);

const phi = MathUtils.degToRad(90 - parameters.elevation);
const theta = MathUtils.degToRad(parameters.azimuth);

sun.setFromSphericalCoords(1, phi, theta);

sky.material.uniforms['sunPosition'].value.copy(sun);
(water.material as ShaderMaterial).uniforms['sunDirection'].value.copy(sun).normalize();
scene.environment = pmremGenerator.fromScene(sky as any).texture;

(water.material as ShaderMaterial).uniforms['speed'].value = 0.0;

Финальная подготовка Scene

Последнее, что нам нужно сделать с нашей начальной инициализацией сцены, это добавить немного освещения и добавить нашу модель ракеты и модель нашего корабля-носителя:

// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/game.ts#L410-L420)
// Set the appropriate scale for our rocket
rocketModel.scale.set(0.3, 0.3, 0.3);
scene.add(rocketModel);
scene.add(mothershipModel);

// Set the scale and location for our mothership (above the player)
mothershipModel.position.y = 200;
mothershipModel.position.z = 100;
mothershipModel.scale.set(15,15,15);
sceneConfiguration.ready = true;

Теперь у нас есть сцена с красивой водой и ракетой. Но нам не хватает чего-то, что действительно могло бы превратить это в игру. Чтобы решить эту проблему, нам нужно построить некоторые основные параметры, чтобы управлять игрой и позволить игроку двигаться к определенным целям.

Вверху нашего файла game.ts мы добавим следующую переменную sceneConfiguration, которая поможет нам отслеживать объекты в нашей сцене:

// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/game.ts#L110-L143)
export const sceneConfiguration = {
    /// Whether the scene is ready (i.e.: All models have been loaded and can be used)
    ready: false,
    /// Whether the camera is moving from the beginning circular pattern to behind the ship
    cameraMovingToStartPosition: false,
    /// Whether the rocket is moving forward
    rocketMoving: false,
    // backgroundMoving: false,
    /// Collected game data
    data: {
        /// How many crystals the player has collected on this run
        crystalsCollected: 0,
        /// How many shields the player has collected on this run (can be as low as -5 if player hits rocks)
        shieldsCollected: 0,
    },
    /// The length of the current level, increases as levels go up
    courseLength: 500,
    /// How far the player is through the current level, initialises to zero.
    courseProgress: 0,
    /// Whether the level has finished
    levelOver: false,
    /// The current level, initialises to one.
    level: 1,
    /// Gives the completion amount of the course thus far, from 0.0 to 1.0.
    coursePercentComplete: () => (sceneConfiguration.courseProgress / sceneConfiguration.courseLength),
    /// Whether the start animation is playing (the circular camera movement while looking at the ship)
    cameraStartAnimationPlaying: false,
    /// How many 'background bits' are in the scene (the cliffs)
    backgroundBitCount: 0,
    /// How many 'challenge rows' are in the scene (the rows that have rocks, shields, or crystals in them).
    challengeRowCount: 0,
    /// The current speed of the ship
    speed: 0.0
}

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

Итак, нам нужно вернуть расположение нашей ракеты к началу и очистить все старые активы, которые использовались. Я добавил несколько комментариев, чтобы вы могли видеть, что делает каждая строка:

// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/game.ts#L519-L591)
export const sceneSetup = (level: number) => {
    // Remove all references to old "challenge rows" and background bits
    sceneConfiguration.challengeRowCount = 0;
    sceneConfiguration.backgroundBitCount = 0;

    // Reset the camera position back to slightly infront of the ship, for the start-up animation
    camera.position.z = 50;
    camera.position.y = 12;
    camera.position.x = 15;
    camera.rotation.y = 2.5;

    // Add the starter bay to the scene (the sandy shore with the rocks around it)
    scene.add(starterBay);

    // Set the starter bay position to be close to the ship
    starterBay.position.copy(new Vector3(10, 0, 120));

    // Rotate the rocket model back to the correct orientation to play the level
    rocketModel.rotation.x = Math.PI;
    rocketModel.rotation.z = Math.PI;

    // Set the location of the rocket model to be within the starter bay
    rocketModel.position.z = 70;
    rocketModel.position.y = 10;
    rocketModel.position.x = 0;

    // Remove any existing challenge rows from the scene
    challengeRows.forEach(x => {
        scene.remove(x.rowParent);
    });

    // Remove any existing environment bits from the scene
    environmentBits.forEach(x => {
        scene.remove(x);
    })

    // Setting the length of these arrays to zero clears the array of any values
    environmentBits.length = 0;
    challengeRows.length = 0;

    // Render some challenge rows and background bits into the distance
    for (let i = 0; i < 60; i++) {
        // debugger;
        addChallengeRow(sceneConfiguration.challengeRowCount++);
        addBackgroundBit(sceneConfiguration.backgroundBitCount++);
    }

    //Set the variables back to their beginning state

    // Indicates that the animation where the camera flies from the current position isn't playing
    sceneConfiguration.cameraStartAnimationPlaying = false;
    // The level isn't over (we just started it)
    sceneConfiguration.levelOver = false;
    // The rocket isn't flying away back to the mothership
    rocketModel.userData.flyingAway = false;
    // Resets the current progress of the course to 0, as we haven't yet started the level we're on
    sceneConfiguration.courseProgress = 0;
    // Sets the length of the course based on our current level
    sceneConfiguration.courseLength = 1000 * level;

    // Reset how many things we've collected in this level to zero
    sceneConfiguration.data.shieldsCollected = 0;
    sceneConfiguration.data.crystalsCollected = 0;

    // Updates the UI to show how many things we've collected to zero.
    crystalUiElement.innerText = String(sceneConfiguration.data.crystalsCollected);
    shieldUiElement.innerText = String(sceneConfiguration.data.shieldsCollected);

    // Sets the current level ID in the UI
    document.getElementById('levelIndicator')!.innerText = `LEVEL ${sceneConfiguration.level}`;
    // Indicates that the scene setup has completed, and the scene is now ready
    sceneConfiguration.ready = true;
}

Добавление игровой логики

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

  1. Клавиатуры (а именно левая и правая клавиши на клавиатуре)
  2. Сенсорные экраны (путем отображения джойстика на экране для маневрирования корабля слева направо)

Давайте настроим их сейчас.

Ввод с клавиатуры

Вверху нашего game.ts, мы добавим следующие переменные, чтобы отслеживать, были ли нажаты левые или правые клавиши на клавиатуре:

let leftPressed = false;
let rightPressed = false;

Затем внутри нашей функции init мы зарегистрируем события keydown и keyup для вызова функций onKeyDown и onKeyUp соответственно:

document.addEventListener('keydown', onKeyDown, false);
document.addEventListener('keyup', onKeyUp, false);

Наконец, для ввода с клавиатуры мы зарегистрируем, что делать при нажатии этих клавиш:

// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/game.ts#L500-L517)
function onKeyDown(event: KeyboardEvent) {
    console.log('keypress');
    let keyCode = event.which;
    if (keyCode == 37) { // Left arrow key
        leftPressed = true;
    } else if (keyCode == 39) { // Right arrow key
        rightPressed = true;
    }
}

function onKeyUp(event: KeyboardEvent) {
    let keyCode = event.which;
    if (keyCode == 37) { // Left arrow key
        leftPressed = false;
    } else if (keyCode == 39) { // Right arrow key
        rightPressed = false;
    }
}

Сенсорный ввод

У наших мобильных пользователей не будет клавиатуры для ввода данных, поэтому мы будем использовать nippleJS для создания джойстика на экране и использовать выходные данные джойстика для изменения положения ракеты на экране.

В рамках нашей функции init мы проверим, является ли устройство сенсорным, проверив, имеет ли оно ненулевое количество точек касания на экране. Если это так, мы создадим джойстик, но также установим движение ракеты на ноль, как только игрок отпустит управление джойстиком:

// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/game.ts#L280-L296)
if (isTouchDevice()) {
    // Get the area within the UI to use as our joystick
    let touchZone = document.getElementById('joystick-zone');

    if (touchZone != null) {
        // Create a Joystick Manager
        joystickManager = joystick.create({zone: document.getElementById('joystick-zone')!,})
        // Register what to do when the joystick moves
        joystickManager.on("move", (event, data) => {
            positionOffset = data.vector.x;
        })
        // When the joystick isn't being interacted with anymore, stop moving the rocket
        joystickManager.on('end', (event, data) => {
            positionOffset = 0.0;
        })
    }
}

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

// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/game.ts#L159-L170)
// If the left arrow is pressed, move the rocket to the left
if (leftPressed) {
    rocketModel.position.x -= 0.5;
}
// If the right arrow is pressed, move the rocket to the right
if (rightPressed) {
    rocketModel.position.x += 0.5;
}
// If the joystick is in use, update the current location of the rocket accordingly
rocketModel.position.x += positionOffset;
// Clamp the final position of the rocket to an allowable region
rocketModel.position.x = clamp(rocketModel.position.x, -20, 25);

Перемещение объектов в нашей сцене

Как мы уже говорили, ракетный корабль остается неподвижным в нашей сцене, а объекты движутся к нему. Скорость движения этих объектов постепенно увеличивается по мере того, как пользователь продолжает играть, что со временем увеличивает сложность уровня.

Все еще в нашем цикле анимации мы хотим постепенно перемещать эти объекты к игроку. Когда объекты покидают поле зрения игрока, мы хотим удалить их со сцены, чтобы не занимать ненужные ресурсы на компьютере игрока.

В нашем цикле рендеринга мы можем настроить эту функцию следующим образом:

// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/game.ts#L215-L252)
if (sceneConfiguration.rocketMoving) {
    // Detect if the rocket ship has collided with any of the objects within the scene
    detectCollisions();

    // Move the rocks towards the player
    for (let i = 0; i < environmentBits.length; i++) {
        let mesh = environmentBits[i];
        mesh.position.z += sceneConfiguration.speed;
    }

    // Move the challenge rows towards the player
    for (let i = 0; i < challengeRows.length; i++) {
        challengeRows[i].rowParent.position.z += sceneConfiguration.speed;
        // challengeRows[i].rowObjects.forEach(x => {
        //     x.position.z += speed;
        // })
    }

    // If the furtherest rock is less than a certain distance, create a new one on the horizon
    if ((!environmentBits.length || environmentBits[0].position.z > -1300) && !sceneConfiguration.levelOver) {
        addBackgroundBit(sceneConfiguration.backgroundBitCount++, true);
    }

    // If the furtherest challenge row is less than a certain distance, create a new one on the horizon
    if ((!challengeRows.length || challengeRows[0].rowParent.position.z > -1300) && !sceneConfiguration.levelOver) {
        addChallengeRow(sceneConfiguration.challengeRowCount++, true);
    }

    // If the starter bay hasn't already been removed from the scene, move it towards the player
    if (starterBay != null) {
        starterBay.position.z += sceneConfiguration.speed;
    }

    // If the starter bay is outside of the players' field of view, remove it from the scene
    if (starterBay.position.z > 200) {
        scene.remove(starterBay);
    }

Мы видим, что есть несколько функций, которые являются частью этого вызова:

  1. detectCollisions
  2. addBackgroundBit
  3. addChallengeRow

Давайте посмотрим, что эти функции делают в нашей игре.

detectCollisions

Обнаружение столкновений — важная часть нашей игры. Без него мы не узнаем, поразил ли наш ракетный корабль какую-либо из целей или он ударился о скалу и должен замедлиться. Вот почему мы хотим использовать обнаружение столкновений в нашей игре.

Обычно мы могли бы использовать физический движок для обнаружения столкновений между объектами в нашей сцене, но Three.js не имеет встроенного физического движка.

Однако это не означает, что для Three.js не существует физических движков. Конечно, да, но для наших нужд нам не нужно добавлять физический движок, чтобы проверить, не попала ли наша ракета в другой объект.

По сути, мы хотим ответить на вопрос: «Пересекается ли в данный момент моя модель ракеты с какими-либо другими моделями на экране?» Мы также должны реагировать определенным образом в зависимости от того, что было поражено.

Например, если наш игрок продолжает бить ракетой по камням, нам нужно закончить уровень, как только будет нанесен определенный урон.

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

Мы разместим этот код в нашем каталоге game в файле с именем collisionDetection.ts:

// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/game/collisionDetection.ts#L18-L87) 

export const detectCollisions = () => {
    // If the level is over, don't detect collisions
    if (sceneConfiguration.levelOver) return;
    // Using the dimensions of our rocket, create a box that is the width and height of our model
    // This box doesn't appear in the world, it's merely a set of coordinates that describe the box
    // in world space.
    const rocketBox = new Box3().setFromObject(rocketModel);
    // For every challange row that we have on the screen...
    challengeRows.forEach(x => {
        // ...update the global position matrix of the row, and its children.
        x.rowParent.updateMatrixWorld();
        // Next, for each object within each challenge row...
        x.rowParent.children.forEach(y => {
            y.children.forEach(z => {
                // ...create a box that is the width and height of the object
                const box = new Box3().setFromObject(z);
                // Check if the box with the obstacle overlaps (or intersects with) our rocket
                if (box.intersectsBox(rocketBox)) {
                    // If it does, get the center position of that box
                    let destructionPosition = box.getCenter(z.position);
                    // Queue up the destruction animation to play (the boxes flying out from the rocket)
                    playDestructionAnimation(destructionPosition);
                    // Remove the object that has been hit from the parent
                    // This removes the object from the scene
                    y.remove(z);
                    // Now, we check what it was that we hit, whether it was a rock, shield, or crystal
                    if (y.userData.objectType !== undefined) {
                        let type = y.userData.objectType as ObjectType;
                        switch (type) {
                            // If it was a rock...
                            case ObjectType.ROCK:
                                // ...remove one shield from the players' score
                                sceneConfiguration.data.shieldsCollected--;
                                // Update the UI with the new count of shields
                                shieldUiElement.innerText = String(sceneConfiguration.data.shieldsCollected);
                                // If the player has less than 0 shields...
                                if (sceneConfiguration.data.shieldsCollected <= 0) {
                                    // ...add the 'danger' CSS class to make the text red (if it's not already there)
                                    if (!shieldUiElement.classList.contains('danger')) {
                                        shieldUiElement.classList.add('danger');
                                    }
                                } else { //Otherwise, if it's more than 0 shields, remove the danger CSS class
                                    // so the text goes back to being white
                                    shieldUiElement.classList.remove('danger');
                                }

                                // If the ship has sustained too much damage, and has less than -5 shields...
                                if (sceneConfiguration.data.shieldsCollected <= -5) {
                                    // ...end the scene
                                    endLevel(true);
                                }
                                break;
                            // If it's a crystal...
                            case ObjectType.CRYSTAL:
                                // Update the UI with the new count of crystals, and increment the count of
                                // currently collected crystals
                                crystalUiElement.innerText = String(++sceneConfiguration.data.crystalsCollected);
                                break;
                            // If it's a shield...
                            case ObjectType.SHIELD_ITEM:
                                // Update the UI with the new count of shields, and increment the count of
                                // currently collected shields
                                shieldUiElement.innerText = String(++sceneConfiguration.data.shieldsCollected);
                                break;
                        }
                    }
                }
            });
        })
    });
}

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

Готовый результат будет выглядеть так.

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

// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/game/collisionDetection.ts#L89-L146)
const playDestructionAnimation = (spawnPosition: Vector3) => {

    // Create six boxes
    for (let i = 0; i < 6; i++) {
        // Our destruction 'bits' will be black, but have some transparency to them
        let destructionBit = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial({
            color: 'black',
            transparent: true,
            opacity: 0.4
        }));

        // Each destruction bit object within the scene will have a 'lifetime' property associated to it
        // This property is incremented every time a frame is drawn to the screen
        // Within our animate loop, we check if this is more than 500, and if it is, we remove the object
        destructionBit.userData.lifetime = 0;
        // Set the spawn position of the box
        destructionBit.position.set(spawnPosition.x, spawnPosition.y, spawnPosition.z);
        // Create an animation mixer for the object
        destructionBit.userData.mixer = new AnimationMixer(destructionBit);

        // Spawn the objects in a circle around the rocket
        let degrees = i / 45;

        // Work out where on the circle we should spawn this specific destruction bit
        let spawnX = Math.cos(radToDeg(degrees)) * 15;
        let spawnY = Math.sin(radToDeg(degrees)) * 15;

        // Create a VectorKeyFrameTrack that will animate this box from its starting position to the final
        // 'outward' position (so it looks like the boxes are exploding from the ship)
        let track = new VectorKeyframeTrack('.position', [0, 0.3], [
            rocketModel.position.x, // x 1
            rocketModel.position.y, // y 1
            rocketModel.position.z, // z 1
            rocketModel.position.x + spawnX, // x 2
            rocketModel.position.y, // y 2
            rocketModel.position.z + spawnY, // z 2
        ]);

        // Create an animation clip with our VectorKeyFrameTrack
        const animationClip = new AnimationClip('animateIn', 10, [track]);
        const animationAction = destructionBit.userData.mixer.clipAction(animationClip);

        // Only play the animation once
        animationAction.setLoop(LoopOnce, 1);

        // When complete, leave the objects in their final position (don't reset them to the starting position)
        animationAction.clampWhenFinished = true;
        // Play the animation
        animationAction.play();
        // Associate a Clock to the destruction bit. We use this within the render loop so ThreeJS knows how far
        // to move this object for this frame
        destructionBit.userData.clock = new Clock();
        // Add the destruction bit to the scene
        scene.add(destructionBit);

        // Add the destruction bit to an array, to keep track of them
        destructionBits.push(destructionBit);
    }

И это наше обнаружение столкновений, дополненное красивой анимацией, когда объект уничтожается.

addBackgroundBit

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

// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/game/objects.ts#L43-L60)
export const addBackgroundBit = (count: number, horizonSpawn: boolean = false) => {
    // If we're spawning on the horizon, always spawn at a position far away from the player
    // Otherwise, place the rocks at certain intervals into the distance-
    let zOffset = (horizonSpawn ? -1400 : -(60 * count));
    // Create a copy of our original rock model
    let thisRock = cliffsModel.clone();
    // Set the scale appropriately for the scene
    thisRock.scale.set(0.02, 0.02, 0.02);
    // If the row that we're adding is divisble by two, place the rock to the left of the user
    // otherwise, place it to the right of the user.
    thisRock.position.set(count % 2 == 0 ? 60 - Math.random() : -60 - Math.random(), 0, zOffset);
    // Rotate the rock to a better angle
    thisRock.rotation.set(MathUtils.degToRad(-90), 0, Math.random());
    // Finally, add the rock to the scene
    scene.add(thisRock);
    // Add the rock to the beginning of the environmentBits array to keep track of them (so we can clean up later)
    environmentBits.unshift(thisRock);// add to beginning of array
}

addChallengeRow

По мере развития нашей сцены мы также хотим добавить в сцену наши «строки испытаний». Это объекты, которые содержат камни, кристаллы или щиты. Каждый раз, когда создается один из этих новых рядов, мы случайным образом назначаем камни, кристаллы и щиты каждому ряду.

Итак, в приведенном выше примере к ячейкам 1, 2 и 4 ничего не добавлено, тогда как к ячейкам 3 и 5 добавлены кристалл и щит соответственно.

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

// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/game/objects.ts#L62-L92)
export const addChallengeRow = (count: number, horizonSpawn: boolean = false) => {
    // Work out how far away this challenge row should be
    let zOffset = (horizonSpawn ? -1400 : -(count * 60));
    // Create a Group for the objects. This will be the parent for these objects.
    let rowGroup = new Group();
    rowGroup.position.z = zOffset;
    for (let i = 0; i < 5; i++) {
        // Calculate a random number between 1 and 10
        const random = Math.random() * 10;
        // If it's less than 2, create a crystal
        if (random < 2) {
            let crystal = addCrystal(i);
            rowGroup.add(crystal);
        }
        // If it's less than 4, spawn a rock
        else if (random < 4) {
            let rock = addRock(i);
            rowGroup.add(rock);
        }
       // but if it's more than 9, spawn a shield
        else if (random > 9) {
            let shield = addShield(i);
            rowGroup.add(shield);
        }
    }
    // Add the row to the challengeRows array to keep track of it, and so we can clean them up later
    challengeRows.unshift({rowParent: rowGroup, index: sceneConfiguration.challengeRowCount++});
    // Finally add the row to the scene
    scene.add(rowGroup);
}

Функцию создания камня, кристалла и щита можно просмотреть по любой из этих ссылок.

Последние дополнения к нашему циклу рендеринга

Последнее, что нам нужно сделать в нашем цикле рендеринга, это следующее:

  1. Переместить обломки от собранных объектов к кораблю
  2. Если пользователь завершает уровень, показать анимацию «улета» и краткую информацию об уровне.
  3. Если ракета «улетает», настроить камеру так, чтобы она смотрела на ракету, чтобы пользователь мог видеть, как она летит к базовому кораблю.

Ближе к концу нашей функции рендеринга мы можем добавить следующий код для реализации этой функции:

// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/game.ts#L254-L270)

// Call the function to relocate the current bits on the screen and move them towards the rocket
// so it looks like the rocket is collecting them
moveCollectedBits();
// If the rockets progress equals the length of the course...
if (sceneConfiguration.courseProgress >= sceneConfiguration.courseLength) {
    // ...check that we haven't already started the level-end process
    if (!rocketModel.userData.flyingAway) {
        // ...and end the level
        endLevel(false);
    }
}
// If the level end-scene is playing...
if (rocketModel.userData.flyingAway) {
    // Rotate the camera to look at the rocket on it's return journey to the mothership
    camera.lookAt(rocketModel.position);
}

На этом наш цикл рендеринга завершен.

Создание пользовательского интерфейса игры

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

Это всего лишь простые HTML-элементы, которые мы программно показываем или скрываем в зависимости от того, что происходит в игре. Значок вопроса дает игроку некоторое представление о том, о чем игра, и содержит инструкции о том, как играть в игру. Он также включает (очень важно!) лицензии на наши модели.

И нажатие красной кнопки запускает игровой процесс. Обратите внимание, что когда мы нажимаем красную кнопку «Воспроизвести», камера перемещается и поворачивается за ракетой, подготавливая игрока к началу сцены.

В нашей функции сцены init мы регистрируем событие для обработчика этой кнопки onClick. Чтобы создать функции вращения и движения, нам нужно сделать следующее:

  1. Получить текущую позицию и поворот камеры
  2. Получить будущее положение и вращение, где мы хотим, чтобы камера была
  3. Создать KeyframeTrack для управления движениями и вращениями из обеих игровых позиций
  4. Назначить эти треки на микшер и начать их воспроизведение

Для этого мы добавим в нашу функцию init следующий код, например:

// Can be viewed [here](https://github.com/flutterfromscratch/threejs-rocket-game/blob/master/game.ts#L305-L421)

startGameButton.onclick = (event) => {
    // Indicate that the animation from the camera starting position to the rocket location is running
    sceneConfiguration.cameraStartAnimationPlaying = true;
    // Remove the red text on the shield item, if it existed from the last level
    shieldUiElement.classList.remove('danger');
    // Show the heads up display (that shows crystals collected, etc)
    document.getElementById('headsUpDisplay')!.classList.remove('hidden');

    // Create an animation mixer on the rocket model
    camera.userData.mixer = new AnimationMixer(camera);
    // Create an animation from the cameras' current position to behind the rocket
    let track = new VectorKeyframeTrack('.position', [0, 2], [
        camera.position.x, // x 1
        camera.position.y, // y 1
        camera.position.z, // z 1
        0, // x 2
        30, // y 2
        100, // z 2
    ], InterpolateSmooth);

    // Create a Quaternion rotation for the "forwards" position on the camera
    let identityRotation = new Quaternion().setFromAxisAngle(new Vector3(-1, 0, 0), .3);

    // Create an animation clip that begins with the cameras' current rotation, and ends on the camera being
    // rotated towards the game space
    let rotationClip = new QuaternionKeyframeTrack('.quaternion', [0, 2], [
        camera.quaternion.x, camera.quaternion.y, camera.quaternion.z, camera.quaternion.w,
        identityRotation.x, identityRotation.y, identityRotation.z, identityRotation.w
    ]);

    // Associate both KeyFrameTracks to an AnimationClip, so they both play at the same time
    const animationClip = new AnimationClip('animateIn', 4, [track, rotationClip]);
    const animationAction = camera.userData.mixer.clipAction(animationClip);
    animationAction.setLoop(LoopOnce, 1);
    animationAction.clampWhenFinished = true;

    camera.userData.clock = new Clock();
    camera.userData.mixer.addEventListener('finished', function () {
        // Make sure the camera is facing in the right direction
        camera.lookAt(new Vector3(0, -500, -1400));
        // Indicate that the rocket has begun moving
        sceneConfiguration.rocketMoving = true;
    });

    // Play the animation
    camera.userData.mixer.clipAction(animationClip).play();
    // Remove the "start panel" (containing the play buttons) from view
    startPanel.classList.add('hidden');
}

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

Вывод

Создание игры в Three.js дает вам доступ к невероятному количеству потенциальных клиентов. Поскольку люди могут играть в игру в своем браузере, ничего не загружая и не устанавливая на свои устройства, это становится весьма привлекательным способом разработки и распространения вашей игры.

Как мы видели, возможно создать привлекательный и интересный опыт для широкого круга пользователей. Итак, единственное, что вам нужно решить, это то, что вы будете создавать в Three.js?

Источник:

#JavaScript
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу