Обучение
September 6, 2022

Игра Змейка на JavaScript

Первая версия змейки появилась в 1976 году, а последняя - сегодня.

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

У автора этой змейки есть две версии: в первом видео он делает змейку одним JS файлом, а во-втором видео уже на модулях с ООП (Объектно ориентированное программирование). Я сделал оба варианта, но именно второй мне захотелось разобрать по частям, чтоб лучше понять что к чему, т.к. он был намного менее понятен чем первый, там много классов и сложная модульная структура.

Гитхаб автора со змейкой: https://github.com/EpicLegend/snake2d-opp

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

Мой Гитхаб с моей змейкой: https://github.com/Buninman/Snake

Буду рад на указание ошибок, даже орфографических🙂

Основные изменения моей змейки:

  • Поменял дизайн игрового поля, добавил вниз блок с подсказкой;
  • Сделал счетчик рекорда, он запоминает наибольший накопленный результат;
  • Добавил возможность менять скорость змейки клавишами PgUp, PgDwn;
  • Теперь есть режим бога, в котором змейка не может себя съесть. Кнопка G;
  • Убрал возможность змейке разворачиваться на 180º и кусать себя. Больше она не умирает от случайного нажатия.

Тут можно поиграть в готовую змейку


Начало

Набросал схему устройства приложения, чтоб было понятнее что куда идет в плане импорта объектов. По такому порядку и пойдем:

  1. supportFunction.js
  2. config.js
  3. score.js
  4. canvas.js
  5. berry.js
  6. snake.js
  7. gameLoop.js
  8. game.js
  9. snake.html
  10. style.css

supportFunction.js

В модуле всего одна функция, подробнее о ней можно посмотреть в документации, она целиком оттуда.

export const getRandomInt = (min, max) =>
    Math.floor( Math.random() * (max - min) + min )
    
    // Math.random() возвращает псевдослучайное число
    // с плавающей запятой в диапазоне от 0 до менее 1
    // (включая 0, но не 1)
    
    // Math.floor() возвращает наибольшее целое число,
    // меньшее или равное заданному числу. Округляет

Эта функция возвращает случайное число между указанными значениями. Возвращаемое значение не меньше (и, возможно, равно) minи меньше (и не равно)max.

getRandomInt() вызывается в файле berry.js и имеет такой вид:

Для x: getRandomInt(0, this.canvas.element.width /
                       this.config.sizeCell) *
                       this.config.sizeCell
Для y: getRandomInt(0, this.canvas.element.height /
                       this.config.sizeCell) *
                       this.config.sizeCell

Соответственно на min передается 0, а на max передается:

Для x: ширинаПоля (688) / размерЯчейки (16) - это 43
Для y: высотаПоля (480) / размерЯчейки (16) - это 30

Таким образом, когда функция срабатывает, мы получаем число в диапазоне от 1 до 42 для x и от 1 до 49 для y.

config.js

Этот модуль претерпел самые большие изменения, вместо 8 строк кода, теперь тут 38.

Значения config.js используются в berry.js, snake.js и gameloop.js

export default class Config {
    constructor() {        
        this.speedBlock = document.querySelector('#speed')
        this.godModeColor = document.querySelector('.game-header')
        this.textBlock = document.querySelector('.keys-conf')
        // Ищем значения в HTML коде, по названиям классов и id
        // Тут мы ищем индикатор скорости, цвет плашки под ним
        // и блок текста с инструкцией
        
        this.step = 0
        this.maxStep = 6
        // step и maxStep нужны чтоб пропускать игровой цикл,
        // их использует функция в файле gameLoop.js.
        // Также maxStep регулирует скорость змейки,
        // чем выше значение тем реже перерисовывается змейка

        this.sizeCell = 16
        this.sizeBerry = this.sizeCell / 4
        // sizeCell это размер ячейки, а sizeBerry это размер ягодки
        
        this.godMode = false
        // Переменная отвечает за режим бога. Изначально он выключен

        this.colorLight = '#A55E00'        
        this.colorDark = '#5E3908'
        // Переменные с цветами змейки. Есть темный оттенок
        // и светлый, для головы змейки
        
        this.drawSpeed()
        // Метод отрисовывает значение скорости
    }
    
    speedLevel() {        
        return 10 - this.maxStep
        // Чтоб значения скорости были понятнее я сделал
        // функцию которая меняет порядок чисел, теперь 1
        // это самая медленная скорость, а 8 - самая быстрая
    }
    
    drawSpeed() {        
        this.speedBlock.innerHTML = this.speedLevel()
        // innerHTML обращается к значению в HTML
        // и меняет на текущую скорость змейки
    }
    
    drawGodMode() {        
        if (this.godMode) {  
        // Если режим бога сейчас true то: 
                
            this.colorLight = '#FA0556'
            this.colorDark = '#A00034'
            // Меняем цвета в переменных на красные.
            // Они также подхватываются змейкой
            
            this.textBlock.innerHTML = 'God Mode: ON'
            // Вместо инструкции пишем о включенном режиме
            
            this.godModeColor.style.backgroundColor = this.colorDark
            // style.backgroundColor получает значение цвета
            // параметра background в css-файле.
            // Тут мы меняем цвет плашки под счетом
            
        } else {
        // Если режим бога сейчас false:
        
            this.colorLight = '#A55E00'
            this.colorDark = '#5E3908'
            // Меняем цвета в переменных на оранжевые
            
            this.textBlock.innerHTML
                 ='Use the Arrows and PageUp/PageDown'
            // Пишем стандартную инструкцию внизу поля
            
            this.godModeColor.style.backgroundColor = this.colorDark
            // Меняем цвет плашки
        }
    }
}

🙁Не смог сделать так, чтоб ягодка меняла цвет вместе со змейкой. Сделал ягодку белой.

Свойства step и maxStep используются в модуле gameLoop.js, внутри функции animate():

animate() {
    requestAnimationFrame(this.animate)
    // Вызывает повторно функцию animate()
    // так образуется непрерывный цикл
        
    if (++this.config.step < this.config.maxStep) {
        return
        // Если ++step меньше maxStep, то ничего не делать.
        // Таким образом, надо 6 раз вызвать функцию,
        // чтоб змейка сделала 1 шаг
    }  
              
    this.config.step = 0
    // После движения змейки, счетчик возвращается на 0
    
    this.update()
    this.draw()
}
++ увеличивает значение на единицу и возвращает значение.

Оператор ++ будет прибавлять 1 к step до тех пор, пока на шестом вызове функции она не пройдет дальше. После функция обнуляет значение step.

score.js

Модуль отвечает за отрисовку очков за съеденные ягодки. Сюда же я добавил функцию drawRecord(), которая вызывается в случае смерти змейки сохраняет наш прогресс в виде рекорда.

Значение score.js используются в game.js

export default class Score {
    constructor(scoreBlock, recordBlock, score = 0) {
        this.scoreBlock = document.querySelector(scoreBlock)
        this.recordBlock = document.querySelector(recordBlock)
        // Ищем значения в HTML коде, по id. Тут мы ищем
        // счетчики очков и рекорда        
        this.score = score
        // Создает свойство со значением очков
        this.draw()
        // Вызывает метод объекта draw() при создании экземпляра
    }
    
    incScore() {
        this.score++
        // Добавляет 1 к счету за съеденную ягодку
        this.draw()
        // Запускает метод draw() который поменяет значение счетчика
    }

    setToZero() {
        this.score = 0
        // Обнуляет счетчик очков. Это нужно в случае сметри
        this.draw()
        // Запускает метод draw()
    }
    
    drawRecord() {
        if (this.score > this.record) {
        // Если значение очков больше чем текущий рекорд, то
            this.record = this.score
            // Меняем значение рекорда на текущее значение очков
            this.recordBlock.innerHTML = this.record
            // Меняем значение рекорда в HTML
        }    
    }
    
    draw() {        
        this.scoreBlock.innerHTML = this.score
        // innerHTML обращается к значению в HTML
        // и меняет значение счета в HTML
    }
}

canvas.js

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

Значение canvas.js используются в game.js

export default class Canvas {
    constructor(container) {
        this.element = document.createElement('canvas')
        // .createElement создает HTML-элемент <canvas> в HTML-файле

        this.context = this.element.getContext('2d')
        // .getContext('2d') указывает JS что мы хотим рисовать в 2д.
        // Нам становится доступен набор свойст и методов
        // таких как beginPath(), которым нарисована ягодка
        
        this.element.width = 688
        this.element.height = 800
        // Это высота и ширина 2д контекста, т.к. одна ячейка
        // у нас 16рх, то значения должны быть кратны 16рх
        
        container.appendChild(this.element)
        // Как я понял, .appendChild создает дочерний элемент.
        // Он помещает наш созданный 2д контекст (игровое поле)
        // в <canvas> внутри HTML-файла
    }
}

berry.js

Модуль отвечает за все что происходит с ягодкой.

Значение berry.js используются в game.js

import Config from './config.js'
import {getRandomInt} from './supportFunction.js'

export default class Berry {
    constructor(canvas) {
        this.x = 0
        this.y = 0
        // Это координаты ягодки        
        this.canvas = canvas
        // в Berry передаются параметры игрового поля,
        // нам нужны оттуда только высота и ширина
        this.config = new Config()
        // Передаем данные с Config, нам оттуда нужны размеры ячейки
        // и размер ягодки
        this.randomPosition()
        // Запускаем функцию получения рандомных кординат для ягодки
    }  
    
    draw(context) {
    // Функция рисования ягодки. Сюда передается наш 2д контекст
    // из класса Canvas
    
        context.beginPath()
        // Создает новый векторый контур. Начинаем рисование
        
        context.fillStyle = '#EBEBEB'
        // Заливка этого контура белым цветом. Если поменять ее
        // на динамичную переменную this.config.colorLight, то
        // цвет ягодки все равно не будет меняться
        // Я не понимаю почему🙁
        
        context.arc(this.x + (this.config.sizeCell / 2),
        // arc() создает векторную дугу. Замкнутая дуга это круг
        // В arc() нужно передать координаты x и y,
        // радиус, начальный угол и конечный угол
                    // Берем рандомную координату x, прибавляем
                    // к ней половину размера ячейки.
                    
                    this.y + (this.config.sizeCell / 2),
                    // Тоже самое для координаты y,
                    // Это дает нам положение центра ячейки
                    // Центр нашей ягодки
                    
                    this.config.sizeBerry, 0, 2 * Math.PI)
                    // Берем размер ягодки из Config, это радиус
                    // 0 - это начальный угол
                    // 2 * Math.PI - это конечный угол, такой
                    // параметр указан в учебнике по JS
                    
        context.fill()
        // Метод fill() создает заливку для нашей ягодки,
    }
    
    randomPosition() {
        this.x = getRandomInt(0, this.canvas.element.width / 
                                 this.config.sizeCell) * 
                                 this.config.sizeCell        
        this.y = getRandomInt(0, this.canvas.element.height / 
                                 this.config.sizeCell) *
                                 this.config.sizeCell
        // Функция получения рандомных координат, два раза
        // вызывает функцию генерации случайных чисел
        // и присваивает их x и y координатам
    }
}

Функция getRandomInt() расписана в модуле supportFunction.js

snake.js

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

Значение snake.js используются в game.js

import Config from './config.js'

export default class Snake {
    constructor() {
        this.config = new Config()
        // Передаем данные с Config, нам нужен размер ячейки
        this.x = 336
        this.y = 480
        // Начальные координаты змейки,
        // я сделал чтоб она появлялась внизу посередине
        this.dx = 0
        this.dy = -this.config.sizeCell
        // Начальное направление движения змейки,
        // по x - 0, а по y на -16рх, т.е. вверх.
        // Ось кординат начинается сверху слева
        // Ось y идет сверху вниз
        this.tails = []
        // Пустой массив для записи данных о хвосте змейки
        this.maxTails = 1
        // Параметр длины змейки. Я сделал по умолчанию 1,
        // т.е. только голова
        this.control()
        // Запускаем функцию отвечающую за упарвление змейкой        
    }
    
    update(berry, score, canvas) {
    // Функция обновления змейки. Сюда передаются данные ягодки,
    // счета и игрового поля
    
        this.x += this.dx
        this.y += this.dy
        // Прибавляем к координатам змейки размер движения.
        // Змейка перемещается путем прибавления или вычитания
        // размера ячейки от следующих координат положения головы
        
        if (this.x < 0) {
        // Если координата x меньше 0 то
            this.x = canvas.element.width - this.config.sizeCell
            // Присваиваем ей значение ширины игрового поля
            // минус одну ячейку.
            // Т.е. если змейка доходит до края, то
            // она появится с другой стороны
            
        } else if (this.x >= canvas.element.width) {
        // то, при условии, что координата x больше или равна
        // ширине игрового поля
                   this.x = 0
                   // Кордината x = 0, начало поля.
                   // Т.е. если змейка доходит до края, то
                   // она появится с другой стороны
        }

        if (this.y < 0) {
            this.y = canvas.element.height - this.config.sizeCell
        } else if (this.y >= canvas.element.height) {
                   this.y = 0
        // Логика с вертикальной осью такая же, но в расчет берем
        // высоту поля, а не ширину
        }
        
        this.tails.unshift({ x: this.x, y: this.y})
        // Метод .unshift() добавляет элемент в начало массива
        // с хостом. Добавляем туда объект с координатами x и y.
        // Это секция хвоста
        
        if (this.tails.length > this.maxTails) {
        // Если длина массива с хвостом больше чем параметр длинны
        // хвоста, то 
            this.tails.pop()
            // Удаляем последний элемент массива
            // Метод .pop() удаляет элемент в конце массива
        }
        
        this.tails.forEach((el, index) => {
        // Метод .forEach() перебирает все элементы массива,
        // все элементы змейки, и применяет к ним функцию.
        // В функцию передаются все элементы массива и их индексы
        
            if (el.x === berry.x && el.y === berry.y) {
            // Если координаты x и y в какой-то из секций змейки
            // совпадают с координатами ягоды, то
                this.maxTails++
                // Добавляем 1 к параметру длины змейки
                score.incScore()
                // Вызываем метод incScore(). Это метод нашего
                // объекта Score, он прибавляем 1 единичку к счету
                berry.randomPosition()
                // Вызываем метод randomPosition(). Это метод нашего
                // объекта Berry, он переносит ягодку на новые
                // случайные координаты
            }

            for (let i = index + 1; i < this.tails.length; i++) {
            // Цикл for запускает функцию пока условие верно.
            // Переменная i это индекс элементов массива, прибавляем
            // к ней 1, чтоб игнорировать элемент 0, это голова.
            // Цикл будет продолжаться пока i меньше длинны массива
            // Каждый цикл увеличиваем i на 1
            
                if ( el.x == this.tails[i].x &&
                     el.y == this.tails[i].y &&
                     !this.config.godMode) {
                // Если координаты x и y какого элемента равны
                // каким-то другим координатам из массива, кроме
                // головы, и если режим бога НЕ включен, то
                    this.death()
                    // Метод death() возвращает все параметры
                    // на начальные, перезапускает игру
                    score.drawRecord()
                    // Метод объекта Score, записывает наш счет
                    // в рекорд, если текущий рекорд меньше
                    score.setToZero()
                    // Обнуляет счетчик очков
                    berry.randomPosition()
                    // Вызываем метод randomPosition(). Это метод
                    // объекта Berry, он переносим ягодку на новые
                    // случайные координаты
                }            
            }        
        })    
    }
            
    draw(context) {
    // Функция рисования змейки. Сюда передается наш 2д контекст
    // из класса Canvas
    
        this.tails.forEach((el, index) => {
        // Метод .forEach() перебирает все элементы массива
            if (index == 0) {
            // Если индекс элемента массива равен 0,
                context.fillStyle = this.config.colorLight
                // то красим в яркий цвет. Это голова
            } else {
                context.fillStyle = this.config.colorDark
                // Иначе красим в темный цвет, это тело змейки
            }

            context.fillRect( el.x, el.y, this.config.sizeCell,
                                          this.config.sizeCell)
            // Метод fillRect() рисует прямоугольник по двум
            // координатам и двум величинам, это размер ячейки
        }) 
    }
        
    death() {
    // Метод death() возвращает все параметры
    // на начальные, перезапускает игру 
        this.x = 336
        this.y = 480
        // Ставит змейку на нужные координаты        
        this.dx = 0
        this.dy = -this.config.sizeCell
        // Выставляет напрвление движения 
        this.tails = []
        // Обнуляем массив хвостов
        this.maxTails = 1
        // Делает параметр колическва хвостов равным 1
    }
    
    control() {        
        document.addEventListener("keydown", e => {
        // Метод addEventListener() отслеживает события
        // Первый аргумен указывает на тип события - кнопка нажата.
        // Второй агрумент указывает на "слушателя", тот раздражитель
        // который должен это событие вызвать и ту функцию которое
        // это событие произведет. В нашем случае это будут кнопки
        // клавиатуры
        
            if (e.code == 'ArrowUp') {
            // Если код слушателя равен клавише "Стрелка вверх" то
            
                if (this.dx == 0 &&
                    this.dy == this.config.sizeCell) {
                    return
                    // Если сейчас прибавление координат идет по
                    // оси y, то ничего не делаем - return
                    // Эта проверка запрещает змейке начать
                    // двигаться в противоположную от текущей
                } else {
                    this.dx = 0
                    this.dy = -this.config.sizeCell
                    // Иначе начинаем отнимать координаты
                    // от текущего положения головы змейки
                    // по оси y. Это заставит змейку двигаться
                    // вверх от текущих координат y
                }
                
            } else if (e.code == 'ArrowLeft') {
                if (this.dx == this.config.sizeCell &&
                    this.dy == 0) {
                    return
                } else {
                    this.dx = -this.config.sizeCell
                    this.dy = 0
                    // Для движения влево все тоже самое,
                    // но начинаем отнимать размер ячейки от
                    // оси x, не трогая значение y
                }
            
            } else if (e.code == 'ArrowDown') {
                if (this.dx == 0 &&
                    this.dy == -this.config.sizeCell) {
                    return
                 } else {
                    this.dx = 0
                    this.dy = this.config.sizeCell
                }
            
            } else if (e.code == 'ArrowRight') {
                if (this.dx == -this.config.sizeCell &&
                    this.dy == 0) { 
                    return
                } else {
                    this.dx = this.config.sizeCell
                    this.dy = 0
                }
            
            }
            
            if (e.code == 'KeyG') {
            // Если код слушателя равен клавише "G", то
                if (this.config.godMode) {
                // Если параметр godMode равен true (включен)
                    this.config.godMode = false
                    // То выключаем его, делая false
                    this.config.drawGodMode()
                    // Запускаем функцию смены цветов и надписей
                    // Интерфейс и змейка становятся оранжевыми
                    
                } else {
                // Если параметр godMode равен false
                    this.config.godMode = true
                    // Включаем его, делая true
                    this.config.drawGodMode()
                    // Запускаем функцию смены цветов и надписей
                    // Интерфейс и змейка становятся красными
                }
            }        
        })    
    }
}

Занятные детали компьютерной логики:

  • Змейка никуда не движется и постоянно растет, каждый ход к ней прирастает новая голова и отваливаться одна клетка от хвоста.
  • Догнав ягодку у нас один раз НЕ отваливается хвост, так как к maxTails прибавляет 1.
  • Если выставить изначальное значение maxTails = 10, то можно заметить, что змейка НЕ появляется с хвостом длиной в 10 клеток, а просто 10 ходов у нее не укорачивается хвост.
  • Скорость змейки определяется количеством пустых вызовов функции animate(), зависящей от параметра maxStep чем больше мы будем вызывать функцию бесполезно, тем медленнее будет бежать змейка

gameLoop.js

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

Значение gameLoop.js используются в game.js

import Config from './config.js'

export default class GameLoop {
    constructor(update, draw) {
    // В конструктор передаются методы из класса Game
        this.update = update
        // update() отвечает за изменения в игре
        this.draw = draw
        // а draw() за отрисовку на экране
        
        this.config = new Config()
        // Передаем данные Config
        
        this.animate = this.animate.bind(this)
        // animate будет содержать метод animate().
        // Чтоб сохранить значение this, используем
        // метод bind()
        
        this.animate()
        // Запускаем метод animate() она отвечает
        // за непрерывное обновление игрового поля
        this.control()
        // Запускаем метод control() он отвечает
        // за нажатие клавиш
    }
    
    animate() {
    // Функция отвечает за непрерывный цикл внесения изменений
    // в игру
    
        requestAnimationFrame(this.animate)
        // Вызывает повторно функцию animate()
        // так образуется непрерывный цикл
        
        if (++this.config.step < this.config.maxStep) {
            return
            // Если ++step меньше maxStep, то ничего не делать
            // таким образом, надо 6 раз вызвать функцию,
            // чтоб змейка сделала 1 шаг
        }  
              
        this.config.step = 0
        // После движения змейки, счетчик возвращается на 0
    
        this.update()
        // Вызывается функция update(), которая обновляет 
        // данные по змейке, ягодке и очкам
        this.draw()
        // Вызывается функция, которая запускает draw() у ягодки
        // и змейки, предварительно отчистив игровое поле
    }
    
    control() {
    // Функция отвечает за изменение скорости змейки.
    // Чем выше значение maxStep, тем медленнее ползает змейка,
    // я выбрал диапазон от 2 до 9
    
        document.addEventListener('keydown', e => {
        // Метод addEventListener() отслеживает события
        // также как в snake.js
        
            if (e.code == 'PageUp') {
                if (this.config.maxStep > 2) {
                // Если maxStep больше 2, то мы можем позволить
                // себе уменьшить его значение
                    this.config.maxStep--
                    // Отнимаем 1 от maxStep
                    this.config.drawSpeed()
                    // Рисуем новое значение скорости
                }
            } else if (e.code == 'PageDown') {
                if (this.config.maxStep < 9) {
                // Если maxStep меньше 9, то мы можем позволить
                // себе увеличить его значение
                    this.config.maxStep++
                    // Прибавляем 1 к maxStep
                    this.config.drawSpeed()
                    // Рисуем новое значение скорости
                }
            }        
        })    
    }    
}

game.js

import Canvas from './canvas.js'
import Snake from './snake.js'
import Berry from './berry.js'
import Score from './score.js'
import GameLoop from './gameLoop.js'

class Game {
    constructor(container) {
        this.canvas = new Canvas(container)
        // Создается объект игрового поля,
        // со значением ссылки на класс в HTML-файле
        
        this.snake = new Snake()
        // Создается объект змейки

        this.berry = new Berry(this.canvas)
        // Объект ягодки
        
        this.score = new Score('#score', '#record', 0)
        new GameLoop(this.update.bind(this), this.draw.bind(this))
        // Создается объект GameLoop в аргументах которого передаютя
        // container.update где container это значение в HTML-файле,
        // а update это метод объекта.
        // .bind(this) это метод, который передает методу update()
        // нужное значение this, опять же значение в HTML-файле
    }
    
    update() {
        this.snake.update(this.berry, this.score, this.canvas)
        // Вызываем медод объекта Snake, передавая в него данные
        // ягодки, счета и игрового поля
    }
    
    draw() {
    // Функция отвечает за отрисовку всего на игровом поле
        this.canvas.context.clearRect(0, 0,
            this.canvas.element.width,
            this.canvas.element.height)
        // Метод .clearRect() очищает прямоугольную область пикселей.
        // аргументы 0, 0, это начальные координаты, а ширина и высота
        // игрового поля размер отчищаемой области
        
        this.snake.draw(this.canvas.context)
        this.berry.draw(this.canvas.context)
        // Вызываем методы draw() у змейки и у ягодки, передавая
        // туда свойства и функции 2D-контекста из Канваса
    }
}

new Game(document.querySelector('.canvas-wrapper'))
// Это старт игры, создается объект Game.
// querySelector ищет значение в HTML.
// У нас это пустой <div> с классом .canvas-wrapper

HTML & CSS

snake.html

<!DOCTYPE html>
<html lang="en">
<head>    
    <meta charset="UTF-8">
    <title>My Snake</title>
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport"
          content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="css/style.css">
    <link rel="apple-touch-icon" sizes="180x180" 
          href="img/apple-touch-icon.png">
    <link rel="icon" type="image/png" sizes="32x32" 
          href="img/favicon-32x32.png">
    <link rel="icon" type="image/png" sizes="16x16" 
          href="img/favicon-16x16.png">
</head>
<body>
    <div id="game">    
        <div class="game-header">
            
            <div class="game-score">                
                <span class="score-text">Speed:</span>
                <span class="score-count" id="speed" >0</span>
            </div>
            <div class="game-score">
                <span class="score-text">Score:</span>
                <span class="score-count" id="score" >0</span>
            </div>
            <div class="game-score">
                <span class="score-text">Record:</span>
                <span class="score-count" id="record" >0</span>
            </div>
            <!--
            Блоков game-score стало три, поэтому добавил
            уникальные id, по которым теперь ориенируется
            JavaScript вместо классов
            --->
        </div>        
        <div class="canvas-wrapper"></div>
        
        <div class="game-footer">            
            <div class="keys-conf">                
                <span>Use the WASD keys</span>            
            </div>        
        </div>
        <!--
        Новый блок game-footer отвечает за текст под игровым полем.
        Научил JS его менять в режиме бога
        --->
    </div>
    <script src="js/game.js" type="module"></script>
</body> 
</html>

style.css

body {
    margin: 0px;    
    font-family: sans-serif;
}
#game {
    background: #353336;    
    width: 100vw;    
    height: 100vh;
    display: flex;    
    flex-direction: column;    
    justify-content: center;    
    align-items: center;
}
#game-canvas {
    display: block;    
    border-radius: 5px; 
}
.game-header {    
    margin-bottom: 15px;    
    width: 708px;    
    height: 70px;    
    background: #5E3908;    
    display: flex;    
    align-items: center;    
    justify-content: space-between;    
    border-radius: 10px;
}
.game-score {
    padding: 10px 15px;    
    min-width: 160px;    
    height: 30px;    
    background: #121214;    
    border-radius: 5px;
    display: flex;    
    justify-content: center;    
    align-items: center;
}
.score-text {
    font-size: 2vmin;    
    color: #EBEBEB;
}
.score-count {
    font-size: 3vmin;
    padding-left: 6px;    
    color: #EBEBEB;    
    font-weight: bold;
}
.canvas-wrapper {
    background-color: #161618;    
    border-radius: 5px;    
    border: 10px solid #161618;
    background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg opacity='0.2'%3E%3Ccircle cx='8' cy='8' r='4' fill='%23525053'/%3E%3C/g%3E%3C/svg%3E%0A");
}
.game-footer {
    padding-top: 1vmin;    
    padding-bottom: 1vmin;
    display: flex;    
    justify-content: space-between;
}
.keys-conf {  
    width: 708px;    
    height: 30px;    
    display: flex;    
    justify-content: center;    
    align-items: center;
    font-size: 2vmin;    
    color: #797979;    
    font-weight: regular;
}

Основные изменения коснулись .game-header и .game-score, добавились классы .game-footer и .keys-conf.

Уверен как-то можно заменить уродскую url с svg-картинкой на нарисованную через css, если кто знает, напишите.

Наверх