Пишем тренажер слепой печати на чистом JavaScript: Часть 2

от автора

Здравствуйте! В преддверии старта курса «Fullstack разработчик JavaScript» один из наших авторов решил поделиться своим опытом создания тренажера для слепой печати. А мы же, в свою очередь, хотим показать данный материал вам и сегодня делимся заключительной его частью.


Первую часть можно почитать здесь

Всем привет! Продолжаем писать тренажер слепой печати на нативном JavaScript. В прошлой части мы с вами сделали основную логику приложения, в котором по нажатию на клавишу Enter загружался первый уровень из двадцати символов, которые при изучении метода слепой печати стоит изучать одними из первых (J, K, F, D). Рабочий варинат первой версии можно посмотреть здесь. Однако у нас есть еще несколько задач по улучшению приложения.

Давайте сформулируем ТЗ, что бы понять, чего именно мы хотим достичь:

«В начале игры, после того как пользователь нажал приглашающий Enter, который анимировано двигается слева-направо, загружаются последовательно уровни с первого по третий. По нажатию на правильную кнопку играется положительный игровой сигнал, в случае ошибки играется отрицательный. Первый и второй уровень имеют обычные символы, третий уровень бонусный, в котором символы специально для программистов (скорее шуточный). В конце уровня у нас появляется окно результатов, которое пишется и берется из localStorage, в данном окне отображается количество ошибок игрока и время окончания игрового сеанса. В случае слишком большого числа ошибок проигрывается звук проигрыша, в случае успешного перехода на следующий уровень — мажорная мелодия».

Окей, теперь у нас есть ТЗ, начнем программировать. Очевидно, что наша кодовая база значительно увеличится, а значит, для того, чтобы справиться с большим количеством кода и импортом необходимых библиотек, нам потребуется webpack. Но давайте начнем с верстки и посмотрим какие изменения произошли. Содержимое head я приводить не буду, потому что единственное, что там поменялось — это то, что теперь javascript мы подтягиваем с обработанного вебпаком dist/code.js, а в остальном все осталось по прежнему.

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

<body class="has-background-black-bis">  <div class="modal">     <div class="modal-background has-background-link"></div>     <div class="modal-content has-background-white">         <h3 class="is-size-4"> Количество ошибок </h3>         <table class="table">             <thead>               <tr>                 <th> Дата игры</th>                 <th> Количество ошибок</th>               </tr>             </thead>             <tbody>               <tr class="target_error">               	<!-- сюда мы будем писать результаты -->             </tr>             </tbody>         </table>     <div>            </div>     <button class="modal-close is-large" aria-label="close"></button> </div> </div>  

Конечно факт того, что модальное окно у нас будет сразу на странице, вызывает физическую боль у любителей SPA — фреймворков (ведь они бы хотели все это аккуратно разложить по компонентам). Но я не захотел расписывать генерацию этого контента в JS. Давайте посмотрим на следующую часть нашего html:

    <section class="hero is-primary is-large">         <div class="hero-head container">             <h1 class="label is-size-4 has-text-white promo"> Тренажер печати 3000</h1>              <!-- поменялось название -->             <h3 class="label is-size-4 has-text-danger has-text-centered name-level"></h3>             <div class="error-panel is-hidden">                  <!-- теперь наша панель ошибок изначально скрыта -->                 <progress id="prog" class="progress is-danger" value="0" max="20"> </progress>             </div>         </div>         <div class="hero-body has-background-black-bis main-board">             <div id="columns">                 <h3 class="label is-size-2 has-text-white  has-text-centered begin anim-elem">Press Enter to Start</h3>                 <div class="buttons columns is-half is-centered">                     <!-- и здесь у нас будет происходить небольшая отрисовка-->                 </div>             </div>         </div>     </section> </body> 

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

Вот-вот приступим к JS, только я еще добавлю чуть-чуть CSS, чтобы немножко подправить то, что не удалось сделать с помощью Bulma:

body{     max-height:40vh !important; } .promo{     margin-top: 1rem; } 

Окей, js. Я написал максимально простой конфиг вебпака. От того, что находится на первой странице документации webpack, он отличается только наличием слежения за изменениями в файлах. По большей части он мне нужен для того, чтобы пользоваться импортами в основном файле index.js, и в итоге иметь минифицированный файл:

const path = require("path");  module.exports = {   entry: './js/index.js',   output: {     filename: 'code.js',     path: path.resolve(__dirname, 'dist'),     },   watch: true, }  

Отлично, а теперь мы можем перейти к структуре js. Для анимаций я решил воспользоваться anime.js — хоть и хорошо понимаю, что такой объем анимации, который нам понадобится, можно в css сделать за 10 строчек. Возможно в будущем мы добавим еще анимаций, поэтому я притащил целый anime.es.js. Кроме того, я вынес в отдельный файл функцию генерации рандома — чисто ради удобства:

export default function getRandomInt(max) {     return Math.floor(Math.random() * Math.floor(max)); } 

Дальше я решил вынести в отдельный файл функцию showResult, которая занималась отрисовкой результатов, после того, как пользователь смог пройти всю игру. Это довольная универсальная функция, которая первым аргументом на вход принимает элемент, в который будет записана информация из localStorage и только что полученная, а вторым — информацию, которую нужно записать (в нашем случае это количество ошибок игрока). Кроме того, я реализовал сортировку ключей из localStorage, для того, чтобы данные в таблице были сортированы по времени, когда они были созданы. Во второй части функции происходит отрисовка в таблице данные предыдущих попыток игрока. Однако меньше слов, больше кода:

export default function showResult(target_El, content){     localStorage.setItem(+new Date, content);        (function drawOnLoad() {         let temp_arr = [];         for (let i = 0; i < localStorage.length; i++) {              temp_arr.push(+localStorage.key(i));         }         temp_arr.sort();         for(let i = 0; i< temp_arr.length; i++){             let item_time = new Date(temp_arr[i]);             target_El.insertAdjacentHTML('afterend',              `<th>${item_time.getDate()} / ${item_time.getMonth()}  ${item_time.getHours()} : ${item_time.getMinutes()} </th>             <th> ${localStorage.getItem(String(temp_arr[i]))}</th>             `);         }     })(); } 

Отлично, с дополнительными файлами покончено, наконец-то мы можем перейти к основному файлу. Сначала сделаем импорты:

  import anime from "./anime.es";   import getRandomInt from "./random";   import showResult from "./showResult"; 

Библиотеки получены. Давайте с самого начала сделаем анимацию нашей приглашающей надписи:

anime({   targets: ".anim-elem",   translateX: [-50, 50],   easing: "linear",   direction: "alternate",   duration: 1000,   loop: true }); 

Супер! Наконец-то само приложение. Я решил переложить все данные в json, чтобы изобразить работу сервера, которые может генерировать такую игру, к примеру, на венгерском, русском или упрощенном китайском — короче говоря, с любым набором символов (но до этого ещё конечно расти и расти). После получения данных по fetch асинхронно вызывается сама функция, которая даст старт нашей игре. JSON я залил на gist.github (можно посмотреть здесь )

function get_data() {   fetch(     // "не буду приводить длинную ссылку, здесь файл json"   )     // я переложил информацию в json файл - это показалось мне более логичным,     // если кто-то захочет замасштабировать эту игру на сервер     .then(res => res.json())     .then(data => {       //асинхронно вызываю функцию основной игры, когда загрузится необходимая информация       read_data(data);     })     .catch(err => {       console.warn("произошла ошибка");       console.warn(err.name);       //мастерская обработка ошибок     }); }  get_data(); // логично это обернуть в самовызывающийся модуль, но... я просто не буду этого делать 

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

var number_of_level = 0; // по дефолту нас будет запускаться уровень номер 0   var error_sound = new Audio("sounds/error_sound.wav");   var fail_sound = new Audio("sounds/fail_sound.wav"); // играется при проигрыше   var press_sound = new Audio("sounds/press_sound.wav");   var succes_sound = new Audio("sounds/succes_sound.wav"); // думаю из названий все в принципе понятно. Вот этот играется при переходе на следующий уровень 

8-битные звуки я нашел в бесплатных банках в интернете. Теперь давайте посмотрим на элементы, которые я получаю из DOM — дерева:

  let modal = document.querySelector(".modal"); // наше модальное окно с результатами   var target_error = document.querySelector(".target_error"); // элемент в которое мы будем писать историю наших ошибок   let error_panel = document.querySelector(".error-panel"); //панель прогресса ошибок пользователя   let begin = document.querySelector(".begin"); // здесь у нас надпись, которая приглашает пользователя нажать enter для начала игры. Во время игры должна пропасть   let progress = document.getElementById("prog"); // здесь прогресс ошибок пользователя   let buttons = document.querySelector(".buttons"); // элемент в который мы будем писать наши буковки   let name_level = document.querySelector(".name-level"); //сюда мы будем писать название нашего уровня   let modal_close = document.querySelector(".modal-close"); // кнопка, при клике на которую у нас будет у нас будет закрываться модальной окно с результатами  document.addEventListener("keydown", StartGame, {     once: true     //благодаря once у нас отрисовка вызывается только один раз при загрузке страницы     //это было уже в прошлой части, так что объяснить не буду   }); 

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

  function StartGame(e) {     if (e.key == "Enter") {       error_panel.classList.remove("is-hidden"); //делаем видимой нашу панель ошибок       press_sound.play();       begin.remove(); // удаляем приглашающую надпись       mainGame(); // основная функция игры     }   } 

Отлично, дальше идет отрисовка букв. Все отличие от прошлой версии состоит в том, что данные мы берем теперь из JSON объекта:

  function drawBoard(info) {     let str_arr = info.level_info[number_of_level].symbols; // здесь скорее всего как-то шустро и быстро использовать деструктуризацию, благо мы собираем проект в webpack, но....     name_level.innerHTML = info.level_info[number_of_level].name_level;     let col_arr = info.symbol_colors;     for (let i = 0; i < 20; i++) { // рисуем по прежнему 20 букв       let rand = getRandomInt(str_arr.length);       buttons.insertAdjacentHTML(         "afterbegin",         `<button class='game-button button                      is-large ${col_arr[rand]}' id='${str_arr[rand]}'>                         ${str_arr[rand]}</button>`       );     }   } 

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

  function mainGame() {     drawBoard(information);     document.addEventListener("keydown", press); 

Дальше пойдет последняя в нашем коде функция press, в которой у нас определяется проигрыш и выигрыш. Если пользователь набрал слишком много ошибок, нужно сообщить ему, что он проиграл, и проиграть мелодию, которая отвечает за «фиаско». Успешный конец уровня у нас случается, если переменная count_right набирает значение, равное количеству знаков, которое мы генерируем (20). Я отдаю себе отчет, что переход на следующий уровень можно сделать, когда длина массива elements_arr у нас станет равна 0, но пока у нас вот такое решение. В случае, если пользователь успешно прошел все три уровня, показывается доска результатов с количеством ошибок:

  var errors_count = 0;   var count_right = 0;    function press(e) {     let elements_arr = document.querySelectorAll(".game-button"); // выбираем массив всех созданных элементов     if (e.key == elements_arr[0].id) {       // здесь можно выбирать и по querySelector, но тогда код будет длиннее       elements_arr[0].remove();       count_right++; //  считаем правильные ответы       press_sound.play();     } else {       errors_count++; // считаем ошибки       error_sound.play();       progress.value = errors_count; // увеличиваем наш счетчик ошибок       if (errors_count > 20) {         // если пользователь допустит ошибок больше чем у нас букв, игра закончится         fail_sound.play(); // играет звук фиаско         name_level.innerHTML = 'Ты проиграл!';          setTimeout(() => {           window.location.reload(); // и страницы возвращается на первую страницу         }, 2500);       }     }     if (count_right == 20) {        count_right = 0;       number_of_level++;       if (number_of_level == 3) { //пока у нас будет только 3 левела         modal.classList.add("is-active"); //включается модалка с таблицей результатов          showResult(target_error, errors_count);         modal_close.onclick = async function() {         modal.classList.remove("is-active");         window.location.reload(); //и, когда пользователь закрывает модальное окно, страница автоматически перезагружается         };       }       succes_sound.play();       mainGame();     }   } } // это кстати скобка закрывающей асинхронной функции, я не ошибся 

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

Есть ещё много чего, что можно было бы добавить в приложение — это какие-нибудь забавные анимации букв, музыка на заднем фоне (или можно было бы сделать так, чтобы при печати издавались разные звуки, чтобы форсить пользователя печатать в определенном ритме). Можно сделать прокрутку букв и скоростной режим — чтобы была возможность печатать на время и фиксировать достигнутую точность. Также хотелось бы, чтобы при старте уровня давался обратный отсчет, чтобы игрок мог правильно расположить руки на клавиатуре и приготовиться. Короче говоря, идей масса (как и очень напрашивающийся перевод этого приложения на React или Vue), и я надеюсь, что хабровчане тоже что-нибудь посоветуют. Всем спасибо за внимание!

Ну а мы ждем всех желающих на курсе!


ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/481020/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *