├── .gitignore ├── .eslintignore ├── img ├── fence.gif ├── tree.gif ├── clouds.png ├── user-1.jpg ├── user-2.png ├── user-3.png ├── wizard.gif ├── fireball.gif ├── icon-star.png ├── title-site.png ├── fireball-mask.png ├── wizard-reversed.gif ├── htmlacademy-logo.png └── wizard.svg ├── .eslintrc.yml ├── package.json ├── js ├── avatar.js ├── setup.js ├── main.js ├── api.js ├── user-modal.js ├── util.js ├── similar-list.js ├── user-form.js └── game.js ├── .editorconfig ├── Readme.md ├── pristine └── pristine.js ├── index.html └── css └── main.css /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | js/game.js 2 | pristine/pristine.js 3 | -------------------------------------------------------------------------------- /img/fence.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/code-and-magic-demo/HEAD/img/fence.gif -------------------------------------------------------------------------------- /img/tree.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/code-and-magic-demo/HEAD/img/tree.gif -------------------------------------------------------------------------------- /img/clouds.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/code-and-magic-demo/HEAD/img/clouds.png -------------------------------------------------------------------------------- /img/user-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/code-and-magic-demo/HEAD/img/user-1.jpg -------------------------------------------------------------------------------- /img/user-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/code-and-magic-demo/HEAD/img/user-2.png -------------------------------------------------------------------------------- /img/user-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/code-and-magic-demo/HEAD/img/user-3.png -------------------------------------------------------------------------------- /img/wizard.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/code-and-magic-demo/HEAD/img/wizard.gif -------------------------------------------------------------------------------- /img/fireball.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/code-and-magic-demo/HEAD/img/fireball.gif -------------------------------------------------------------------------------- /img/icon-star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/code-and-magic-demo/HEAD/img/icon-star.png -------------------------------------------------------------------------------- /img/title-site.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/code-and-magic-demo/HEAD/img/title-site.png -------------------------------------------------------------------------------- /img/fireball-mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/code-and-magic-demo/HEAD/img/fireball-mask.png -------------------------------------------------------------------------------- /img/wizard-reversed.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/code-and-magic-demo/HEAD/img/wizard-reversed.gif -------------------------------------------------------------------------------- /img/htmlacademy-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htmlacademy/code-and-magic-demo/HEAD/img/htmlacademy-logo.png -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | parserOptions: 2 | ecmaVersion: 2022 3 | sourceType: 'module' 4 | 5 | env: 6 | es2022: true 7 | browser: true 8 | 9 | extends: 'htmlacademy/vanilla' 10 | 11 | globals: 12 | Pristine: readonly 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "code-and-magic", 3 | "version": "29.0.0", 4 | "description": "Учебный проект «Код и Магия»", 5 | "scripts": { 6 | "lint": "eslint js/", 7 | "start": "browser-sync start --server --no-ui --files \"js/**/*.js\"" 8 | }, 9 | "author": "htmlacademy", 10 | "devDependencies": { 11 | "browser-sync": "2.29.3", 12 | "eslint": "8.42.0", 13 | "eslint-config-htmlacademy": "9.1.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /js/avatar.js: -------------------------------------------------------------------------------- 1 | const FILE_TYPES = ['jpg', 'jpeg', 'png']; 2 | 3 | const fileChooser = document.querySelector('.upload input[type=file]'); 4 | const preview = document.querySelector('.setup-user-pic'); 5 | 6 | fileChooser.addEventListener('change', () => { 7 | const file = fileChooser.files[0]; 8 | const fileName = file.name.toLowerCase(); 9 | 10 | const matches = FILE_TYPES.some((it) => fileName.endsWith(it)); 11 | 12 | if (matches) { 13 | preview.src = URL.createObjectURL(file); 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /js/setup.js: -------------------------------------------------------------------------------- 1 | import startGame from './game.js'; 2 | 3 | const FIREBALL_SIZE = 22; 4 | const WIZARD_WIDTH = 70; 5 | const WIZARD_SPEED = 3; 6 | 7 | const getFireballSpeed = (isMovingLeft) => isMovingLeft ? 2 : 5; 8 | 9 | const getWizardHeight = () => 1.337 * WIZARD_WIDTH; 10 | 11 | const getWizardX = (gameFieldWidth) => (gameFieldWidth - WIZARD_WIDTH) / 2; 12 | 13 | const getWizardY = (gameFieldHeight) => gameFieldHeight / 3; 14 | 15 | startGame( 16 | FIREBALL_SIZE, 17 | getFireballSpeed, 18 | WIZARD_WIDTH, 19 | WIZARD_SPEED, 20 | getWizardHeight, 21 | getWizardX, 22 | getWizardY, 23 | ); 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Файл с настройками для редактора. 2 | # 3 | # Если вы разрабатываете в редакторе от JetBrains, BBEdit, Coda или SourceLair 4 | # этот файл уже поддерживается и не нужно производить никаких дополнительных 5 | # действий. 6 | # 7 | # Если вы ведёте разработку в другом редакторе, зайдите 8 | # на http://editorconfig.org и в разделе «Download a Plugin» 9 | # скачайте дополнение для вашего редактора. 10 | 11 | root = true 12 | 13 | [*] 14 | charset = utf-8 15 | end_of_line = lf 16 | indent_size = 2 17 | indent_style = space 18 | insert_final_newline = true 19 | trim_trailing_whitespace = true 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /js/main.js: -------------------------------------------------------------------------------- 1 | import './setup.js'; 2 | import {closeUserModal} from './user-modal.js'; 3 | import {setUserFormSubmit, setEyesClick, setCoatClick} from './user-form.js'; 4 | import {renderSimilarList} from './similar-list.js'; 5 | import {getData} from './api.js'; 6 | import {showAlert, debounce} from './util.js'; 7 | import './avatar.js'; 8 | 9 | const RERENDER_DELAY = 500; 10 | 11 | getData() 12 | .then((wizards) => { 13 | renderSimilarList(wizards); 14 | setEyesClick(debounce( 15 | () => renderSimilarList(wizards), 16 | RERENDER_DELAY, 17 | )); 18 | setCoatClick(debounce( 19 | () => renderSimilarList(wizards), 20 | RERENDER_DELAY, 21 | )); 22 | }) 23 | .catch( 24 | (err) => { 25 | showAlert(err.message); 26 | } 27 | ); 28 | 29 | setUserFormSubmit(closeUserModal); 30 | -------------------------------------------------------------------------------- /js/api.js: -------------------------------------------------------------------------------- 1 | const BASE_URL = 'https://29.javascript.pages.academy/code-and-magick'; 2 | const Route = { 3 | GET_DATA: '/data', 4 | SEND_DATA: '/', 5 | }; 6 | const Method = { 7 | GET: 'GET', 8 | POST: 'POST', 9 | }; 10 | const ErrorText = { 11 | GET_DATA: 'Не удалось загрузить данные. Попробуйте обновить страницу', 12 | SEND_DATA: 'Не удалось отправить форму. Попробуйте ещё раз', 13 | }; 14 | 15 | const load = (route, errorText, method = Method.GET, body = null) => 16 | fetch(`${BASE_URL}${route}`, {method, body}) 17 | .then((response) => { 18 | if (!response.ok) { 19 | throw new Error(); 20 | } 21 | return response.json(); 22 | }) 23 | .catch(() => { 24 | throw new Error(errorText); 25 | }); 26 | 27 | const getData = () => load(Route.GET_DATA, ErrorText.GET_DATA); 28 | 29 | const sendData = (body) => load(Route.SEND_DATA, ErrorText.SEND_DATA, Method.POST, body); 30 | 31 | export {getData, sendData}; 32 | -------------------------------------------------------------------------------- /js/user-modal.js: -------------------------------------------------------------------------------- 1 | import {isEscapeKey, isEnterKey} from './util.js'; 2 | 3 | const userModalElement = document.querySelector('.setup'); 4 | const userModalOpenElement = document.querySelector('.setup-open'); 5 | const userModalCloseElement = userModalElement.querySelector('.setup-close'); 6 | 7 | const onDocumentKeydown = (evt) => { 8 | if (isEscapeKey(evt)) { 9 | evt.preventDefault(); 10 | closeUserModal(); 11 | } 12 | }; 13 | 14 | function openUserModal () { 15 | userModalElement.classList.remove('hidden'); 16 | document.addEventListener('keydown', onDocumentKeydown); 17 | } 18 | 19 | function closeUserModal () { 20 | userModalElement.classList.add('hidden'); 21 | document.removeEventListener('keydown', onDocumentKeydown); 22 | } 23 | 24 | userModalOpenElement.addEventListener('click', () => { 25 | openUserModal(); 26 | }); 27 | 28 | userModalOpenElement.addEventListener('keydown', (evt) => { 29 | if (isEnterKey(evt)) { 30 | openUserModal(); 31 | } 32 | }); 33 | 34 | userModalCloseElement.addEventListener('click', () => { 35 | closeUserModal(); 36 | }); 37 | 38 | userModalCloseElement.addEventListener('keydown', (evt) => { 39 | if (isEnterKey(evt)) { 40 | closeUserModal(); 41 | } 42 | }); 43 | 44 | export {openUserModal, closeUserModal}; 45 | -------------------------------------------------------------------------------- /js/util.js: -------------------------------------------------------------------------------- 1 | const ALERT_SHOW_TIME = 5000; 2 | 3 | const getRandomInteger = (a, b) => { 4 | const lower = Math.ceil(Math.min(a, b)); 5 | const upper = Math.floor(Math.max(a, b)); 6 | const result = Math.random() * (upper - lower + 1) + lower; 7 | return Math.floor(result); 8 | }; 9 | 10 | const getRandomArrayElement = (elements) => elements[getRandomInteger(0, elements.length - 1)]; 11 | 12 | const isEscapeKey = (evt) => evt.key === 'Escape'; 13 | 14 | const isEnterKey = (evt) => evt.key === 'Enter'; 15 | 16 | const showAlert = (message) => { 17 | const alertContainer = document.createElement('div'); 18 | alertContainer.style.zIndex = '100'; 19 | alertContainer.style.position = 'absolute'; 20 | alertContainer.style.left = '0'; 21 | alertContainer.style.top = '0'; 22 | alertContainer.style.right = '0'; 23 | alertContainer.style.padding = '10px 3px'; 24 | alertContainer.style.fontSize = '30px'; 25 | alertContainer.style.textAlign = 'center'; 26 | alertContainer.style.backgroundColor = 'red'; 27 | 28 | alertContainer.textContent = message; 29 | 30 | document.body.append(alertContainer); 31 | 32 | setTimeout(() => { 33 | alertContainer.remove(); 34 | }, ALERT_SHOW_TIME); 35 | }; 36 | 37 | const debounce = (callback, timeoutDelay) => { 38 | let timeoutId; 39 | return (...rest) => { 40 | clearTimeout(timeoutId); 41 | timeoutId = setTimeout(() => callback.apply(this, rest), timeoutDelay); 42 | }; 43 | }; 44 | 45 | export {getRandomArrayElement, isEscapeKey, isEnterKey, showAlert, debounce}; 46 | -------------------------------------------------------------------------------- /img/wizard.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 7 | 9 | 10 | 13 | 15 | 17 | 19 | 23 | 26 | 27 | 28 | 29 | 30 | 32 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /js/similar-list.js: -------------------------------------------------------------------------------- 1 | const SIMILAR_WIZARD_COUNT = 4; 2 | const DefaultColor = { 3 | COAT_COLOR: 'rgb(101, 137, 164)', 4 | EYES_COLOR: 'black', 5 | }; 6 | 7 | const similarElement = document.querySelector('.setup-similar'); 8 | const similarListElement = similarElement.querySelector('.setup-similar-list'); 9 | const similarWizardTemplate = document.querySelector('#similar-wizard-template') 10 | .content 11 | .querySelector('.setup-similar-item'); 12 | 13 | const getWizardRank = (wizard) => { 14 | const coatColorInput = document.querySelector('[name="coat-color"]'); 15 | const eyesColorInput = document.querySelector('[name="eyes-color"]'); 16 | 17 | let rank = 0; 18 | 19 | if (wizard.colorCoat === (coatColorInput.value || DefaultColor.COAT_COLOR)) { 20 | rank += 2; 21 | } 22 | if (wizard.colorEyes === (eyesColorInput.value || DefaultColor.EYES_COLOR)) { 23 | rank += 1; 24 | } 25 | 26 | return rank; 27 | }; 28 | 29 | const compareWizards = (wizardA, wizardB) => { 30 | const rankA = getWizardRank(wizardA); 31 | const rankB = getWizardRank(wizardB); 32 | 33 | return rankB - rankA; 34 | }; 35 | 36 | const renderSimilarList = (similarWizards) => { 37 | const similarListFragment = document.createDocumentFragment(); 38 | 39 | similarWizards 40 | .slice() 41 | .sort(compareWizards) 42 | .slice(0, SIMILAR_WIZARD_COUNT) 43 | .forEach(({name, colorCoat, colorEyes}) => { 44 | const wizardElement = similarWizardTemplate.cloneNode(true); 45 | wizardElement.querySelector('.setup-similar-label').textContent = name; 46 | wizardElement.querySelector('.wizard-coat').style.fill = colorCoat; 47 | wizardElement.querySelector('.wizard-eyes').style.fill = colorEyes; 48 | similarListFragment.appendChild(wizardElement); 49 | }); 50 | 51 | similarListElement.innerHTML = ''; 52 | similarListElement.appendChild(similarListFragment); 53 | similarElement.classList.remove('hidden'); 54 | }; 55 | 56 | export {renderSimilarList}; 57 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Код и Магия 2 | 3 | Учебный демо-проект от HTML Academy для 29 потока профессионального онлайн‑курса «JavaScript. Профессиональная разработка веб-интерфейсов». 4 | 5 | ### Как пользоваться репозиторием 6 | 7 | Первый вариант, это изучать коммиты [в веб-интерфейсе GitHub в master-ветке потока](https://github.com/htmlacademy/code-and-magic-demo). 8 | 9 | Второй вариант, изучать коммиты локально. Для этого нужно: 10 | 11 | 1. Склонировать репозиторий на свой компьютер. Именно склонировать, а не скачать архив. 12 | 2. Открыть папку репозитория в терминале, который поддерживает Git. 13 | 3. Убедиться, что ветка соответствует вашему потоку. 14 | 4. С помощью команды `git log --oneline` посмотреть список коммитов. Коммиты идут сверху вниз от новых к старым, выглядит это примерно вот так: 15 | ```bash 16 | c0ea9d8 1.2 Создаст функцию для генерации разметки меню WIP 17 | 1a34516 1.1 Подключит скрипт `src/main.js` к `public/index.html` 18 | 45f1ffe :hatching_chick: начальное состояние проекта 19 | ``` 20 | 5. Найти нужный коммит, скопировать его хэш (цифро-буквенный код в начале строки). 21 | 6. Встать на нужный коммит с помощью команды `git checkout хэш_коммита`. Например, вот так `git checkout c0ea9d8`. 22 | 7. Всё, изучайте код конкретного коммита. Чтобы вернуть всё как было, используйте команду `git checkout master`. 23 | 24 | > **Будьте внимательны**, если вы внесёте изменения в момент, когда изучаете коммиты, при попытке вернуться обратно, Git потребует от вас либо откатить изменения, либо закоммитить их. Пока вы не сделаете это, вернуться на master-ветку у вас не выйдет. 25 | 26 | ### Условные обозначения 27 | 28 | - Приписка `WIP` в названии коммита означает, что код в этом коммите может частично или полностью не работать, вызывать ошибки линтера, ломать сборку (`npm run build`) или не запускаться в режиме разработки (`npm run start`). Это нормально, потому что `WIP` — это аббревиатура `Work In Progress`, что дословно означает «работа в процессе». То есть такой коммит отражает некое промежуточное состояние нашего проекта. 29 | - Номер коммита `A. [B. ]C` расшифровывается, если не оговорено другое, следующим образом: 30 | 31 | - `A.` — номер модуля; 32 | - `[B. ]` — номер части домашнего задания. Квадратные скобки означают опциональность, потому что не все домашние задания даются в двух частях; 33 | - `C.` — порядковый номер. Исключительно для удобства. 34 | -------------------------------------------------------------------------------- /js/user-form.js: -------------------------------------------------------------------------------- 1 | import {showAlert, getRandomArrayElement} from './util.js'; 2 | import {sendData} from './api.js'; 3 | 4 | const Color = { 5 | FIREBALLS: [ 6 | '#ee4830', 7 | '#30a8ee', 8 | '#5ce6c0', 9 | '#e848d5', 10 | '#e6e848', 11 | ], 12 | EYES: [ 13 | 'black', 14 | 'red', 15 | 'blue', 16 | 'yellow', 17 | 'green', 18 | ], 19 | COATS: [ 20 | 'rgb(101, 137, 164)', 21 | 'rgb(241, 43, 107)', 22 | 'rgb(146, 100, 161)', 23 | 'rgb(56, 159, 117)', 24 | 'rgb(215, 210, 55)', 25 | 'rgb(0, 0, 0)', 26 | ], 27 | }; 28 | 29 | const SubmitButtonText = { 30 | IDLE: 'Сохранить', 31 | SENDING: 'Сохраняю...' 32 | }; 33 | 34 | const wizardForm = document.querySelector('.setup-wizard-form'); 35 | const fireballColorElement = wizardForm.querySelector('.setup-fireball-wrap'); 36 | const eyesColorElement = wizardForm.querySelector('.wizard-eyes'); 37 | const coatColorElement = wizardForm.querySelector('.wizard-coat'); 38 | const fireballColorInput = wizardForm.querySelector('[name="fireball-color"]'); 39 | const eyesColorInput = wizardForm.querySelector('[name="eyes-color"]'); 40 | const coatColorInput = wizardForm.querySelector('[name="coat-color"]'); 41 | const submitButton = wizardForm.querySelector('.setup-submit'); 42 | 43 | fireballColorElement.addEventListener('click', (evt) => { 44 | const randomColor = getRandomArrayElement(Color.FIREBALLS); 45 | evt.target.style.backgroundColor = randomColor; 46 | fireballColorInput.value = randomColor; 47 | }); 48 | 49 | const setEyesClick = (cb) => { 50 | eyesColorElement.addEventListener('click', (evt) => { 51 | const randomColor = getRandomArrayElement(Color.EYES); 52 | evt.target.style.fill = randomColor; 53 | eyesColorInput.value = randomColor; 54 | cb(); 55 | }); 56 | }; 57 | 58 | const setCoatClick = (cb) => { 59 | coatColorElement.addEventListener('click', (evt) => { 60 | const randomColor = getRandomArrayElement(Color.COATS); 61 | evt.target.style.fill = randomColor; 62 | coatColorInput.value = randomColor; 63 | cb(); 64 | }); 65 | }; 66 | 67 | const pristine = new Pristine(wizardForm, { 68 | classTo: 'setup-wizard-form__element', 69 | errorTextParent: 'setup-wizard-form__element', 70 | errorTextClass: 'setup-wizard-form__error-text', 71 | }); 72 | 73 | const blockSubmitButton = () => { 74 | submitButton.disabled = true; 75 | submitButton.textContent = SubmitButtonText.SENDING; 76 | }; 77 | 78 | const unblockSubmitButton = () => { 79 | submitButton.disabled = false; 80 | submitButton.textContent = SubmitButtonText.IDLE; 81 | }; 82 | 83 | const setUserFormSubmit = (onSuccess) => { 84 | wizardForm.addEventListener('submit', (evt) => { 85 | evt.preventDefault(); 86 | 87 | const isValid = pristine.validate(); 88 | if (isValid) { 89 | blockSubmitButton(); 90 | sendData(new FormData(evt.target)) 91 | .then(onSuccess) 92 | .catch( 93 | (err) => { 94 | showAlert(err.message); 95 | } 96 | ) 97 | .finally(unblockSubmitButton); 98 | } 99 | }); 100 | }; 101 | 102 | export {setUserFormSubmit, setEyesClick, setCoatClick}; 103 | -------------------------------------------------------------------------------- /pristine/pristine.js: -------------------------------------------------------------------------------- 1 | !function(e,r){"object"==typeof exports&&"undefined"!=typeof module?module.exports=r():"function"==typeof define&&define.amd?define(r):(e="undefined"!=typeof globalThis?globalThis:e||self).Pristine=r()}(this,(function(){"use strict";var e={en:{required:"This field is required",email:"This field requires a valid e-mail address",number:"This field requires a number",integer:"This field requires an integer value",url:"This field requires a valid website URL",tel:"This field requires a valid telephone number",maxlength:"This fields length must be < ${1}",minlength:"This fields length must be > ${1}",min:"Minimum value for this field is ${1}",max:"Maximum value for this field is ${1}",pattern:"Please match the requested format",equals:"The two fields do not match"}};function r(e){var r=arguments;return this.replace(/\${([^{}]*)}/g,(function(e,t){return r[t]}))}function t(e){return e.pristine.self.form.querySelectorAll('input[name="'+e.getAttribute("name")+'"]:checked').length}var n={classTo:"form-group",errorClass:"has-danger",successClass:"has-success",errorTextParent:"form-group",errorTextTag:"div",errorTextClass:"text-help"},i=["required","min","max","minlength","maxlength","pattern"],s=/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,a=/-message(?:-([a-z]{2}(?:_[A-Z]{2})?))?/,o="en",l={},u=function(e,r){r.name=e,void 0===r.priority&&(r.priority=1),l[e]=r};function f(t,s,u){var f=this;function c(e,r,t,n){var i=l[t];if(i&&(e.push(i),n)){var s="pattern"===t?[n]:n.split(",");s.unshift(null),r[t]=s}}function p(t){for(var n=[],i=!0,s=0;t.validators[s];s++){var a=t.validators[s],l=t.params[a.name]?t.params[a.name]:[];if(l[0]=t.input.value,!a.fn.apply(t.input,l)&&(i=!1,"function"==typeof a.msg?n.push(a.msg(t.input.value,l)):"string"==typeof a.msg?n.push(r.apply(a.msg,l)):a.msg===Object(a.msg)&&a.msg[o]?n.push(r.apply(a.msg[o],l)):t.messages[o]&&t.messages[o][a.name]?n.push(r.apply(t.messages[o][a.name],l)):e[o]&&e[o][a.name]&&n.push(r.apply(e[o][a.name],l)),!0===a.halt))break}return t.errors=n,i}function m(e){if(e.errorElements)return e.errorElements;var r=function(e,r){for(;(e=e.parentElement)&&!e.classList.contains(r););return e}(e.input,f.config.classTo),t=null,n=null;return(t=f.config.classTo===f.config.errorTextParent?r:r.querySelector("."+f.config.errorTextParent))&&((n=t.querySelector(".pristine-error"))||((n=document.createElement(f.config.errorTextTag)).className="pristine-error "+f.config.errorTextClass,t.appendChild(n),n.pristineDisplay=n.style.display)),e.errorElements=[r,n]}function d(e){var r=m(e),t=r[0],n=r[1];t&&(t.classList.remove(f.config.successClass),t.classList.add(f.config.errorClass)),n&&(n.innerHTML=e.errors.join("
"),n.style.display=n.pristineDisplay||"")}function h(e){var r=function(e){var r=m(e),t=r[0],n=r[1];return t&&(t.classList.remove(f.config.errorClass),t.classList.remove(f.config.successClass)),n&&(n.innerHTML="",n.style.display="none"),r}(e)[0];r&&r.classList.add(f.config.successClass)}return function(e,r,t){e.setAttribute("novalidate","true"),f.form=e,f.config=function(e,r){for(var t in r)t in e||(e[t]=r[t]);return e}(r||{},n),f.live=!(!1===t),f.fields=Array.from(e.querySelectorAll("input:not([type^=hidden]):not([type^=submit]), select, textarea")).map(function(e){var r=[],t={},n={};return[].forEach.call(e.attributes,(function(e){if(/^data-pristine-/.test(e.name)){var s=e.name.substr(14),o=s.match(a);if(null!==o){var l=void 0===o[1]?"en":o[1];return n.hasOwnProperty(l)||(n[l]={}),void(n[l][s.slice(0,s.length-o[0].length)]=e.value)}"type"===s&&(s=e.value),c(r,t,s,e.value)}else~i.indexOf(e.name)?c(r,t,e.name,e.value):"type"===e.name&&c(r,t,e.value)})),r.sort((function(e,r){return r.priority-e.priority})),f.live&&e.addEventListener(~["radio","checkbox"].indexOf(e.getAttribute("type"))?"change":"input",function(e){f.validate(e.target)}.bind(f)),e.pristine={input:e,validators:r,params:t,messages:n,self:f}}.bind(f))}(t,s,u),f.validate=function(e,r){r=e&&!0===r||!0===e;var t=f.fields;!0!==e&&!1!==e&&(e instanceof HTMLElement?t=[e.pristine]:(e instanceof NodeList||e instanceof(window.$||Array)||e instanceof Array)&&(t=Array.from(e).map((function(e){return e.pristine}))));for(var n=!0,i=0;t[i];i++){var s=t[i];p(s)?!r&&h(s):(n=!1,!r&&d(s))}return n},f.getErrors=function(e){if(!e){for(var r=[],t=0;t=parseInt(r)}}),u("maxlength",{fn:function(e,r){return!e||e.length<=parseInt(r)}}),u("min",{fn:function(e,r){return!e||("checkbox"===this.type?t(this)>=parseInt(r):parseFloat(e)>=parseFloat(r))}}),u("max",{fn:function(e,r){return!e||("checkbox"===this.type?t(this)<=parseInt(r):parseFloat(e)<=parseFloat(r))}}),u("pattern",{fn:function(e,r){var t=r.match(new RegExp("^/(.*?)/([gimy]*)$"));return!e||new RegExp(t[1],t[2]).test(e)}}),u("equals",{fn:function(e,r){var t=document.querySelector(r);return t&&(!e&&!t.value||t.value===e)}}),f.addValidator=function(e,r,t,n,i){u(e,{fn:r,msg:t,priority:n,halt:i})},f.addMessages=function(r,t){var n=e.hasOwnProperty(r)?e[r]:e[r]={};Object.keys(t).forEach((function(e,r){n[e]=t[e]}))},f.setLocale=function(e){o=e},f})); 2 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Код и Магия 9 | 10 | 11 | 12 | 13 |
14 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
44 |
45 |
46 | 47 |

48 | Code and Magick 49 | Code and magick 50 |

51 |

52 | Это игра, где главного героя, которым вам предстоит управлять и изменять заклинаниями окружающий мир зовут 53 | Пендальф Синий. 54 | Вместе с ним вас ждет увлекательное приключение… 55 |

56 | 57 |
58 | 59 |
60 | Button to open setup window 61 |
62 |
63 | 64 | 156 | 157 | 160 | 161 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | -------------------------------------------------------------------------------- /css/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | 6 | min-width: 1024px; 7 | margin: 0 auto; 8 | 9 | font: 20px "PT Mono"; 10 | color: #ffffff; 11 | 12 | background: #da641a; 13 | } 14 | 15 | h1, 16 | h2 { 17 | text-align: center; 18 | text-shadow: 5px 5px 0 rgba(0, 0, 0, 0.72); 19 | } 20 | 21 | button { 22 | margin-right: 20px; 23 | padding: 0 16px; 24 | 25 | font: bold 20px "PT Mono"; 26 | line-height: 54px; 27 | 28 | background: #ffffff; 29 | border: 0; 30 | } 31 | 32 | *[tabindex]:focus, button:focus, input:focus { 33 | outline: none; 34 | box-shadow: 0 0 10px #fff000; 35 | } 36 | 37 | form { 38 | margin-bottom: 0; 39 | } 40 | 41 | .hidden { 42 | display: none; 43 | } 44 | 45 | .section-title { 46 | position: relative; 47 | 48 | height: 52px; 49 | 50 | text-indent: -1000em; 51 | } 52 | 53 | .section-title img { 54 | position: absolute; 55 | top: 0; 56 | left: 50%; 57 | 58 | transform: translateX(-50%); 59 | } 60 | 61 | header { 62 | position: relative; 63 | 64 | padding-top: 80px; 65 | 66 | text-align: center; 67 | 68 | background: #2b5cfc; 69 | background: linear-gradient(180deg, #0000ff, #94c6fd); 70 | border-bottom: solid 8px #1cb34d; 71 | } 72 | 73 | .header-title { 74 | height: 101px; 75 | margin-bottom: 30px; 76 | } 77 | 78 | .header-clouds { 79 | position: absolute; 80 | top: 0; 81 | right: 0; 82 | left: 0; 83 | z-index: 0; 84 | 85 | height: 363px; 86 | 87 | background: url("../img/clouds.png") top center repeat-x; 88 | } 89 | 90 | .header-fence { 91 | position: absolute; 92 | bottom: 0; 93 | left: 50%; 94 | 95 | width: 91px; 96 | height: 48px; 97 | margin-left: -400px; 98 | 99 | background: url("../img/fence.gif") no-repeat; 100 | } 101 | 102 | .header-tree { 103 | position: absolute; 104 | bottom: 0; 105 | left: 50%; 106 | 107 | width: 120px; 108 | height: 137px; 109 | margin-left: 300px; 110 | 111 | background: url("../img/tree.gif") no-repeat; 112 | } 113 | 114 | .header-title { 115 | position: relative; 116 | z-index: 1; 117 | 118 | margin: 0 0 28px; 119 | padding: 0; 120 | 121 | font-size: 69px; 122 | } 123 | 124 | .header-description { 125 | position: relative; 126 | z-index: 1; 127 | 128 | width: 620px; 129 | margin: 0 auto; 130 | 131 | font-size: 20px; 132 | line-height: 1.5; 133 | } 134 | 135 | .demo { 136 | width: 700px; 137 | height: 300px; 138 | margin: 0 auto; 139 | } 140 | 141 | footer { 142 | font-size: 16px; 143 | line-height: 1.6; 144 | text-align: center; 145 | color: #000000; 146 | } 147 | 148 | footer a { 149 | color: #000000; 150 | } 151 | 152 | footer p { 153 | margin-bottom: 0; 154 | padding-bottom: 20px; 155 | } 156 | 157 | .footer-link { 158 | position: relative; 159 | 160 | display: block; 161 | width: 250px; 162 | margin: 0 auto; 163 | padding-top: 50px; 164 | } 165 | 166 | .footer-link:hover { 167 | opacity: 0.6; 168 | } 169 | 170 | .footer-link img { 171 | position: absolute; 172 | top: 0; 173 | left: 50%; 174 | 175 | transform: translateX(-50%); 176 | } 177 | 178 | .setup { 179 | width: 800px; 180 | 181 | position: absolute; 182 | left: 50%; 183 | top: 100px; 184 | transform: translateX(-50%); 185 | 186 | background: #89a1fd; 187 | box-shadow: 10px 10px 0 rgba(0, 0, 0, 0.25); 188 | box-sizing: border-box; 189 | color: #000; 190 | 191 | z-index: 3; 192 | } 193 | 194 | .setup-open-icon { 195 | position: absolute; 196 | top: 100px; 197 | right: 100px; 198 | 199 | cursor: pointer; 200 | 201 | z-index: 2; 202 | } 203 | 204 | .setup-open-icon:hover { 205 | box-shadow: 5px 5px 0 rgba(0, 0, 0, 0.5); 206 | } 207 | 208 | .setup-title { 209 | padding: 20px; 210 | } 211 | 212 | .setup-user { 213 | display: inline-block; 214 | } 215 | 216 | .setup-open-icon, 217 | .setup-user-pic { 218 | width: 40px; 219 | height: 40px; 220 | 221 | display: inline-block; 222 | 223 | margin-right: 5px; 224 | margin-top: -10px; 225 | border-radius: 40px; 226 | overflow: hidden; 227 | vertical-align: middle; 228 | } 229 | 230 | .setup-user-name { 231 | background: transparent; 232 | border: 0; 233 | color: #fff; 234 | 235 | font-family: 'PT Mono', serif; 236 | font-size: 30px; 237 | font-weight: 900; 238 | 239 | width: 500px; 240 | } 241 | 242 | .setup-user-name:valid { 243 | border: 0; 244 | } 245 | 246 | .setup-user-name:invalid { 247 | border: 2px solid red; 248 | } 249 | 250 | .setup-user-name:focus { 251 | border-bottom: solid 1px #000; 252 | outline: none; 253 | } 254 | 255 | .setup-close { 256 | float: right; 257 | 258 | top: 20px; 259 | right: 20px; 260 | 261 | font-size: 70px; 262 | color: #000; 263 | cursor: pointer; 264 | 265 | transform: translateY(-0.3em); 266 | } 267 | 268 | .setup-artifacts-shop { 269 | display: flex; 270 | flex-wrap: wrap; 271 | justify-content: flex-end; 272 | 273 | padding-top: 20px; 274 | padding-bottom: 20px; 275 | 276 | width: 716px; 277 | height: 65px; 278 | } 279 | 280 | .setup-player { 281 | padding-top: 20px; 282 | position: relative; 283 | 284 | height: 300px; 285 | min-height: 300px; 286 | } 287 | 288 | .setup-wizard { 289 | width: 250px; 290 | height: 250px; 291 | 292 | position: absolute; 293 | bottom: 0; 294 | left: 50px; 295 | } 296 | 297 | .setup-fireball-wrap { 298 | position: absolute; 299 | left: 300px; 300 | top: 0; 301 | 302 | width: 75px; 303 | height: 75px; 304 | 305 | background: #ee4830; 306 | } 307 | 308 | .setup-fireball { 309 | background: url('../img/fireball-mask.png'); 310 | background-size: 75px 75px; 311 | width: 75px; 312 | height: 75px; 313 | } 314 | 315 | .setup-artifacts { 316 | position: absolute; 317 | bottom: 0; 318 | right: 46px; 319 | 320 | display: flex; 321 | flex-wrap: wrap; 322 | justify-content: space-between; 323 | 324 | width: 260px; 325 | height: 260px; 326 | } 327 | 328 | .setup-artifacts-cell { 329 | width: 65px; 330 | height: 65px; 331 | 332 | background: rgba(0, 0, 0, 0.1); 333 | border: solid 1px rgba(255, 255, 255, 0.5); 334 | 335 | box-sizing: border-box; 336 | } 337 | 338 | .setup-artifacts-cell:last-child { 339 | border-right-width: 1px; 340 | } 341 | 342 | .setup-footer { 343 | border-top: 10px solid #1cb34d; 344 | padding-bottom: 20px; 345 | 346 | background-color: #da641a; 347 | } 348 | 349 | .setup-similar-title { 350 | color: #fff; 351 | 352 | margin-left: 20px; 353 | } 354 | 355 | .setup-similar-list { 356 | display: flex; 357 | flex-direction: row; 358 | justify-content: space-between; 359 | 360 | margin-left: 20px; 361 | margin-right: 20px; 362 | 363 | flex-wrap: nowrap; 364 | } 365 | 366 | .setup-similar-item { 367 | max-width: 150px; 368 | overflow: hidden; 369 | text-overflow: ellipsis; 370 | } 371 | 372 | .setup-similar-label { 373 | margin: 5px; 374 | 375 | color: #fff; 376 | 377 | } 378 | 379 | .setup-similar-content { 380 | position: relative; 381 | 382 | width: 150px; 383 | height: 150px; 384 | 385 | background-color: #414342; 386 | box-shadow: 5px 5px 0 rgba(0, 0, 0, 0.25); 387 | } 388 | 389 | .setup-similar-wizard { 390 | position: absolute; 391 | left: 50%; 392 | transform: translateX(-50%); 393 | bottom: 0; 394 | 395 | margin: 0 auto; 396 | 397 | width: 100px; 398 | } 399 | 400 | .setup-submit { 401 | display: block; 402 | margin: 20px auto 0; 403 | 404 | background: #1cb34d; 405 | color: #fff; 406 | } 407 | 408 | .setup-submit:disabled { 409 | opacity: 0.3; 410 | } 411 | 412 | .upload { 413 | position: relative; 414 | 415 | display: inline-block; 416 | 417 | box-sizing: border-box; 418 | 419 | width: 40px; 420 | min-width: 40px; 421 | max-width: 40px; 422 | height: 40px; 423 | } 424 | 425 | .upload .setup-user-pic { 426 | display: block; 427 | margin: 0; 428 | 429 | position: absolute; 430 | left: 0; 431 | top: 0; 432 | } 433 | 434 | .upload input { 435 | position: absolute; 436 | z-index: 0; 437 | opacity: 0; 438 | width:100%; 439 | height:100%; 440 | 441 | left: 0; 442 | top: 0; 443 | } 444 | 445 | .setup-wizard-form__element { 446 | position: relative; 447 | } 448 | 449 | .setup-wizard-form__error-text { 450 | position: absolute; 451 | top: 34px; 452 | left: 20px; 453 | color: #6d0505; 454 | text-align: center; 455 | } 456 | -------------------------------------------------------------------------------- /js/game.js: -------------------------------------------------------------------------------- 1 | export default function startGame ( 2 | fireballSize, 3 | getFireballSpeed, 4 | wizardWidth, 5 | wizardSpeed, 6 | getWizardHeight, 7 | getWizardX, 8 | getWizardY, 9 | ) { 10 | const GameConstants = { 11 | Fireball: { 12 | size: fireballSize || 24, 13 | speed: getFireballSpeed || function (movingLeft) { 14 | return movingLeft ? 2 : 5; 15 | }, 16 | }, 17 | Wizard: { 18 | speed: wizardSpeed || 2, 19 | width: wizardWidth || 61, 20 | getHeight: getWizardHeight || function (width) { 21 | return 1.377 * width; 22 | }, 23 | getX: getWizardX || function (width) { 24 | return width / 3; 25 | }, 26 | getY: getWizardY || function (height) { 27 | return height - 100; 28 | }, 29 | }, 30 | }; 31 | 32 | /** 33 | * @const 34 | * @type {number} 35 | */ 36 | const HEIGHT = 300; 37 | 38 | /** 39 | * @const 40 | * @type {number} 41 | */ 42 | const WIDTH = 700; 43 | 44 | /** 45 | * ID уровней. 46 | * @enum {number} 47 | */ 48 | const Level = { 49 | INTRO: 0, 50 | MOVE_LEFT: 1, 51 | MOVE_RIGHT: 2, 52 | LEVITATE: 3, 53 | HIT_THE_MARK: 4, 54 | }; 55 | 56 | const NAMES = ['Кекс', 'Катя', 'Игорь']; 57 | 58 | /** 59 | * Порядок прохождения уровней. 60 | * @type {Array.} 61 | */ 62 | const LevelSequence = [ 63 | Level.INTRO, 64 | ]; 65 | 66 | /** 67 | * Начальный уровень. 68 | * @type {Level} 69 | */ 70 | const INITIAL_LEVEL = LevelSequence[0]; 71 | 72 | /** 73 | * Допустимые виды объектов на карте. 74 | * @enum {number} 75 | */ 76 | const ObjectType = { 77 | ME: 0, 78 | FIREBALL: 1, 79 | }; 80 | 81 | /** 82 | * Допустимые состояния объектов. 83 | * @enum {number} 84 | */ 85 | const ObjectState = { 86 | OK: 0, 87 | DISPOSED: 1, 88 | }; 89 | 90 | /** 91 | * Коды направлений. 92 | * @enum {number} 93 | */ 94 | const Direction = { 95 | NULL: 0, 96 | LEFT: 1, 97 | RIGHT: 2, 98 | UP: 4, 99 | DOWN: 8, 100 | }; 101 | 102 | /** 103 | * Карта спрайтов игры. 104 | * @type {Object.} 105 | */ 106 | const SpriteMap = {}; 107 | const REVERSED = '-reversed'; 108 | 109 | SpriteMap[ObjectType.ME] = { 110 | width: 61, 111 | height: 84, 112 | url: 'img/wizard.gif', 113 | }; 114 | 115 | // TODO: Find a clever way 116 | SpriteMap[ObjectType.ME + REVERSED] = { 117 | width: 61, 118 | height: 84, 119 | url: 'img/wizard-reversed.gif', 120 | }; 121 | 122 | SpriteMap[ObjectType.FIREBALL] = { 123 | width: 24, 124 | height: 24, 125 | url: 'img/fireball.gif', 126 | }; 127 | 128 | /** 129 | * Правила перерисовки объектов в зависимости от состояния игры. 130 | * @type {Object.} 131 | */ 132 | const ObjectsBehaviour = {}; 133 | 134 | /** 135 | * Обновление движения мага. Движение мага зависит от нажатых в данный момент 136 | * стрелок. Маг может двигаться одновременно по горизонтали и по вертикали. 137 | * На движение мага влияет его пересечение с препятствиями. 138 | * @param {Object} object 139 | * @param {Object} state 140 | * @param {number} timeframe 141 | */ 142 | ObjectsBehaviour[ObjectType.ME] = function (object, state, timeframe) { 143 | // Пока зажата стрелка вверх, маг сначала поднимается, а потом левитирует 144 | // в воздухе на определенной высоте. 145 | // NB! Сложность заключается в том, что поведение описано в координатах 146 | // канваса, а не координатах, относительно нижней границы игры. 147 | if (state.keysPressed.UP && object.y > 0) { 148 | object.direction = object.direction & ~Direction.DOWN; 149 | object.direction = object.direction | Direction.UP; 150 | object.y -= object.speed * timeframe * 2; 151 | } 152 | 153 | // Если стрелка вверх не зажата, а маг находится в воздухе, он плавно 154 | // опускается на землю. 155 | if (!state.keysPressed.UP) { 156 | if (object.y < HEIGHT - object.height) { 157 | object.direction = object.direction & ~Direction.UP; 158 | object.direction = object.direction | Direction.DOWN; 159 | object.y += object.speed * timeframe / 3; 160 | } 161 | } 162 | 163 | // Если зажата стрелка влево, маг перемещается влево. 164 | if (state.keysPressed.LEFT) { 165 | object.direction = object.direction & ~Direction.RIGHT; 166 | object.direction = object.direction | Direction.LEFT; 167 | object.x -= object.speed * timeframe; 168 | } 169 | 170 | // Если зажата стрелка вправо, маг перемещается вправо. 171 | if (state.keysPressed.RIGHT) { 172 | object.direction = object.direction & ~Direction.LEFT; 173 | object.direction = object.direction | Direction.RIGHT; 174 | object.x += object.speed * timeframe; 175 | } 176 | 177 | // Ограничения по перемещению по полю. Маг не может выйти за пределы поля. 178 | if (object.y < 0) { 179 | object.y = 0; 180 | } 181 | 182 | if (object.y > HEIGHT - object.height) { 183 | object.y = HEIGHT - object.height; 184 | } 185 | 186 | if (object.x < 0) { 187 | object.x = 0; 188 | } 189 | 190 | if (object.x > WIDTH - object.width) { 191 | object.x = WIDTH - object.width; 192 | } 193 | }; 194 | 195 | /** 196 | * Обновление движения файрбола. Файрбол выпускается в определенном направлении 197 | * и после этого неуправляемо движется по прямой в заданном направлении. Если 198 | * он пролетает весь экран насквозь, он исчезает. 199 | * @param {Object} object 200 | * @param {Object} _state 201 | * @param {number} timeframe 202 | */ 203 | ObjectsBehaviour[ObjectType.FIREBALL] = function (object, _state, timeframe) { 204 | if (object.direction & Direction.LEFT) { 205 | object.x -= object.speed * timeframe; 206 | } 207 | 208 | if (object.direction & Direction.RIGHT) { 209 | object.x += object.speed * timeframe; 210 | } 211 | 212 | if (object.x < 0 || object.x > WIDTH) { 213 | object.state = ObjectState.DISPOSED; 214 | } 215 | }; 216 | 217 | /** 218 | * ID возможных ответов функций, проверяющих успех прохождения уровня. 219 | * CONTINUE говорит о том, что раунд не закончен и игру нужно продолжать, 220 | * WIN о том, что раунд выигран, FAIL — о поражении. PAUSE о том, что игру 221 | * нужно прервать. 222 | * @enum {number} 223 | */ 224 | const Verdict = { 225 | CONTINUE: 0, 226 | WIN: 1, 227 | FAIL: 2, 228 | PAUSE: 3, 229 | INTRO: 4, 230 | }; 231 | 232 | /** 233 | * Правила завершения уровня. Ключами служат ID уровней, значениями функции 234 | * принимающие на вход состояние уровня и возвращающие true, если раунд 235 | * можно завершать или false если нет. 236 | * @type {Object.} 237 | */ 238 | const LevelsRules = {}; 239 | 240 | /** 241 | * Уровень считается пройденным, если был выпущен файлболл и он улетел 242 | * за экран. 243 | * @param {Object} state 244 | * @return {Verdict} 245 | */ 246 | LevelsRules[Level.INTRO] = function (state) { 247 | const deletedFireballs = state.garbage.filter(function (object) { 248 | return object.type === ObjectType.FIREBALL; 249 | }); 250 | 251 | const fenceHit = deletedFireballs.filter(function (fireball) { 252 | // Did we hit the fence? 253 | return fireball.x < 10 && fireball.y > 240; 254 | })[0]; 255 | 256 | return fenceHit ? Verdict.WIN : Verdict.CONTINUE; 257 | }; 258 | 259 | /** 260 | * Начальные условия для уровней. 261 | * @enum {Object.} 262 | */ 263 | const LevelsInitialize = {}; 264 | 265 | /** 266 | * Первый уровень. 267 | * @param {Object} state 268 | * @return {Object} 269 | */ 270 | LevelsInitialize[Level.INTRO] = function (state) { 271 | state.objects.push( 272 | // Установка персонажа в начальное положение. Он стоит в крайнем левом 273 | // углу экрана, глядя вправо. Скорость перемещения персонажа на этом 274 | // уровне равна 2px за кадр. 275 | { 276 | direction: Direction.RIGHT, 277 | height: GameConstants.Wizard.getHeight(GameConstants.Wizard.width), 278 | speed: GameConstants.Wizard.speed, 279 | sprite: SpriteMap[ObjectType.ME], 280 | state: ObjectState.OK, 281 | type: ObjectType.ME, 282 | width: GameConstants.Wizard.width, 283 | x: GameConstants.Wizard.getX(WIDTH), 284 | y: GameConstants.Wizard.getY(HEIGHT), 285 | }, 286 | ); 287 | 288 | return state; 289 | }; 290 | 291 | /** 292 | * Конструктор объекта Game. Создает canvas, добавляет обработчики событий 293 | * и показывает приветственный экран. 294 | * @param {Element} container 295 | * @constructor 296 | */ 297 | const Game = function (container) { 298 | this.container = container; 299 | this.canvas = document.createElement('canvas'); 300 | this.canvas.width = container.clientWidth; 301 | this.canvas.height = container.clientHeight; 302 | this.container.appendChild(this.canvas); 303 | 304 | this.ctx = this.canvas.getContext('2d'); 305 | 306 | this._onKeyDown = this._onKeyDown.bind(this); 307 | this._onKeyUp = this._onKeyUp.bind(this); 308 | this._pauseListener = this._pauseListener.bind(this); 309 | 310 | this.setDeactivated(false); 311 | }; 312 | 313 | Game.prototype = { 314 | /** 315 | * Текущий уровень игры. 316 | * @type {Level} 317 | */ 318 | level: INITIAL_LEVEL, 319 | 320 | /** @param {boolean} deactivated */ 321 | setDeactivated: function (deactivated) { 322 | if (this._deactivated === deactivated) { 323 | return; 324 | } 325 | 326 | this._deactivated = deactivated; 327 | 328 | if (deactivated) { 329 | this._removeGameListeners(); 330 | } else { 331 | this._initializeGameListeners(); 332 | } 333 | }, 334 | 335 | /** 336 | * Состояние игры. Описывает местоположение всех объектов на игровой карте 337 | * и время проведенное на уровне и в игре. 338 | * @return {Object} 339 | */ 340 | getInitialState: function () { 341 | return { 342 | // Статус игры. Если CONTINUE, то игра продолжается. 343 | currentStatus: Verdict.CONTINUE, 344 | 345 | // Объекты, удаленные на последнем кадре. 346 | garbage: [], 347 | 348 | // Время с момента отрисовки предыдущего кадра. 349 | lastUpdated: null, 350 | 351 | // Состояние нажатых клавиш. 352 | keysPressed: { 353 | ESC: false, 354 | LEFT: false, 355 | RIGHT: false, 356 | SPACE: false, 357 | UP: false, 358 | }, 359 | 360 | // Время начала прохождения уровня. 361 | levelStartTime: null, 362 | 363 | // Все объекты на карте. 364 | objects: [], 365 | 366 | // Время начала прохождения игры. 367 | startTime: null, 368 | }; 369 | }, 370 | 371 | /** 372 | * Начальные проверки и запуск текущего уровня. 373 | * @param {boolean=} restart 374 | */ 375 | initializeLevelAndStart: function (restart) { 376 | restart = typeof restart === 'undefined' ? true : restart; 377 | 378 | if (restart || !this.state) { 379 | // При перезапуске уровня, происходит полная перезапись состояния 380 | // игры из изначального состояния. 381 | this.state = this.getInitialState(); 382 | this.state = LevelsInitialize[this.level](this.state); 383 | } else { 384 | // При продолжении уровня состояние сохраняется, кроме записи о том, 385 | // что состояние уровня изменилось с паузы на продолжение игры. 386 | this.state.currentStatus = Verdict.CONTINUE; 387 | } 388 | 389 | // Запись времени начала игры и времени начала уровня. 390 | this.state.levelStartTime = Date.now(); 391 | if (!this.state.startTime) { 392 | this.state.startTime = this.state.levelStartTime; 393 | } 394 | 395 | this._preloadImagesForLevel(function () { 396 | // Предварительная отрисовка игрового экрана. 397 | this.render(); 398 | 399 | // Установка обработчиков событий. 400 | this._initializeGameListeners(); 401 | 402 | // Запуск игрового цикла. 403 | this.update(); 404 | }.bind(this)); 405 | }, 406 | 407 | /** 408 | * Временная остановка игры. 409 | * @param {Verdict=} verdict 410 | */ 411 | pauseLevel: function (verdict) { 412 | if (verdict) { 413 | this.state.currentStatus = verdict; 414 | } 415 | 416 | this.state.keysPressed.ESC = false; 417 | this.state.lastUpdated = null; 418 | 419 | this._removeGameListeners(); 420 | window.addEventListener('keydown', this._pauseListener); 421 | 422 | this._drawPauseScreen(); 423 | }, 424 | 425 | /** 426 | * Обработчик событий клавиатуры во время паузы. 427 | * @param {KeyboardsEvent} evt 428 | * @private 429 | * @private 430 | */ 431 | _pauseListener: function (evt) { 432 | if (evt.keyCode === 32 && !this._deactivated) { 433 | evt.preventDefault(); 434 | const needToRestartTheGame = this.state.currentStatus === Verdict.WIN || 435 | this.state.currentStatus === Verdict.FAIL; 436 | this.initializeLevelAndStart(needToRestartTheGame); 437 | 438 | window.removeEventListener('keydown', this._pauseListener); 439 | } 440 | }, 441 | 442 | /** 443 | * Отрисовка экрана паузы. 444 | */ 445 | _drawPauseScreen: function () { 446 | let message; 447 | switch (this.state.currentStatus) { 448 | case Verdict.WIN: 449 | if (window.renderStatistics) { 450 | const statistics = this._generateStatistics(new Date() - this.state.startTime); 451 | const keys = this._schuffleArray(Object.keys(statistics)); 452 | window.renderStatistics(this.ctx, keys, keys.map(function (it) { 453 | return statistics[it]; 454 | })); 455 | return; 456 | } 457 | message = 'Вы победили Газебо!\nУра!'; 458 | break; 459 | case Verdict.FAIL: 460 | message = 'Вы проиграли!'; 461 | break; 462 | case Verdict.PAUSE: 463 | message = 'Игра на паузе!\nНажмите Пробел, чтобы продолжить'; 464 | break; 465 | case Verdict.INTRO: 466 | message = 'Добро пожаловать!\nНажмите Пробел для начала игры'; 467 | break; 468 | } 469 | 470 | this._drawMessage(message); 471 | }, 472 | 473 | _generateStatistics: function (time) { 474 | const generationIntervalSec = 3000; 475 | const minTimeInSec = 1000; 476 | 477 | const statistic = { 478 | 'Вы': time, 479 | }; 480 | 481 | for (let i = 0; i < NAMES.length; i++) { 482 | const diffTime = Math.random() * generationIntervalSec; 483 | let userTime = time + (diffTime - generationIntervalSec / 2); 484 | if (userTime < minTimeInSec) { 485 | userTime = minTimeInSec; 486 | } 487 | statistic[NAMES[i]] = userTime; 488 | } 489 | 490 | return statistic; 491 | }, 492 | 493 | _schuffleArray: function (array) { 494 | for (let i = array.length - 1; i > 0; i--) { 495 | const j = Math.floor(Math.random() * (i + 1)); 496 | const temp = array[i]; 497 | array[i] = array[j]; 498 | array[j] = temp; 499 | } 500 | return array; 501 | }, 502 | 503 | _drawMessage: function (message) { 504 | const ctx = this.ctx; 505 | 506 | const drawCloud = function (x, y, width, heigth) { 507 | const offset = 10; 508 | ctx.beginPath(); 509 | ctx.moveTo(x, y); 510 | ctx.lineTo(x + offset, y + heigth / 2); 511 | ctx.lineTo(x, y + heigth); 512 | ctx.lineTo(x + width / 2, y + heigth - offset); 513 | ctx.lineTo(x + width, y + heigth); 514 | ctx.lineTo(x + width - offset, y + heigth / 2); 515 | ctx.lineTo(x + width, y); 516 | ctx.lineTo(x + width / 2, y + offset); 517 | ctx.lineTo(x, y); 518 | ctx.stroke(); 519 | ctx.closePath(); 520 | ctx.fill(); 521 | }; 522 | 523 | ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; 524 | drawCloud(190, 40, 320, 100); 525 | 526 | ctx.fillStyle = 'rgba(256, 256, 256, 1.0)'; 527 | drawCloud(180, 30, 320, 100); 528 | 529 | ctx.fillStyle = '#000'; 530 | ctx.font = '16px PT Mono'; 531 | message.split('\n').forEach(function (line, i) { 532 | ctx.fillText(line, 200, 80 + 20 * i); 533 | }); 534 | }, 535 | 536 | /** 537 | * Предзагрузка необходимых изображений для уровня. 538 | * @param {function} callback 539 | * @private 540 | */ 541 | _preloadImagesForLevel: function (callback) { 542 | if (typeof this._imagesArePreloaded === 'undefined') { 543 | this._imagesArePreloaded = []; 544 | } 545 | 546 | if (this._imagesArePreloaded[this.level]) { 547 | callback(); 548 | return; 549 | } 550 | 551 | const keys = Object.keys(SpriteMap); 552 | let imagesToGo = keys.length; 553 | 554 | const self = this; 555 | 556 | const loadSprite = function (sprite) { 557 | const image = new Image(sprite.width, sprite.height); 558 | image.onload = function () { 559 | sprite.image = image; 560 | if (--imagesToGo === 0) { 561 | self._imagesArePreloaded[self.level] = true; 562 | callback(); 563 | } 564 | }; 565 | image.src = sprite.url; 566 | }; 567 | 568 | for (let i = 0; i < keys.length; i++) { 569 | loadSprite(SpriteMap[keys[i]]); 570 | } 571 | }, 572 | 573 | /** 574 | * Обновление статуса объектов на экране. Добавляет объекты, которые должны 575 | * появиться, выполняет проверку поведения всех объектов и удаляет те, которые 576 | * должны исчезнуть. 577 | * @param {number} delta Время, прошеднее с отрисовки прошлого кадра. 578 | */ 579 | updateObjects: function (delta) { 580 | // Персонаж. 581 | const me = this.state.objects.filter(function (object) { 582 | return object.type === ObjectType.ME; 583 | })[0]; 584 | 585 | // Добавляет на карту файрбол по нажатию на Shift. 586 | if (this.state.keysPressed.SHIFT) { 587 | this.state.objects.push({ 588 | direction: me.direction, 589 | height: GameConstants.Fireball.size, 590 | speed: GameConstants.Fireball.speed(me.direction & Direction.LEFT), 591 | sprite: SpriteMap[ObjectType.FIREBALL], 592 | type: ObjectType.FIREBALL, 593 | width: GameConstants.Fireball.size, 594 | x: me.direction & Direction.RIGHT ? me.x + me.width : me.x - GameConstants.Fireball.size, 595 | y: me.y + me.height / 2, 596 | }); 597 | 598 | this.state.keysPressed.SHIFT = false; 599 | } 600 | 601 | this.state.garbage = []; 602 | 603 | // Убирает в garbage не используемые на карте объекты. 604 | const remainingObjects = this.state.objects.filter(function (object) { 605 | ObjectsBehaviour[object.type](object, this.state, delta); 606 | 607 | if (object.state === ObjectState.DISPOSED) { 608 | this.state.garbage.push(object); 609 | return false; 610 | } 611 | 612 | return true; 613 | }, this); 614 | 615 | this.state.objects = remainingObjects; 616 | }, 617 | 618 | /** 619 | * Проверка статуса текущего уровня. 620 | */ 621 | checkStatus: function () { 622 | // Нет нужны запускать проверку, нужно ли останавливать уровень, если 623 | // заранее известно, что да. 624 | if (this.state.currentStatus !== Verdict.CONTINUE) { 625 | return; 626 | } 627 | 628 | if (!this.commonRules) { 629 | // Проверки, не зависящие от уровня, но влияющие на его состояние. 630 | this.commonRules = [ 631 | 632 | /** 633 | * Если персонаж мертв, игра прекращается. 634 | * @param {Object} state 635 | * @return {Verdict} 636 | */ 637 | function (state) { 638 | const me = state.objects.filter(function (object) { 639 | return object.type === ObjectType.ME; 640 | })[0]; 641 | 642 | return me.state === ObjectState.DISPOSED ? 643 | Verdict.FAIL : 644 | Verdict.CONTINUE; 645 | }, 646 | 647 | /** 648 | * Если нажата клавиша Esc игра ставится на паузу. 649 | * @param {Object} state 650 | * @return {Verdict} 651 | */ 652 | function (state) { 653 | return state.keysPressed.ESC ? Verdict.PAUSE : Verdict.CONTINUE; 654 | }, 655 | 656 | /** 657 | * Игра прекращается если игрок продолжает играть в нее два часа подряд. 658 | * @param {Object} state 659 | * @return {Verdict} 660 | */ 661 | function (state) { 662 | return Date.now() - state.startTime > 3 * 60 * 1000 ? 663 | Verdict.FAIL : 664 | Verdict.CONTINUE; 665 | }, 666 | ]; 667 | } 668 | 669 | // Проверка всех правил влияющих на уровень. Запускаем цикл проверок 670 | // по всем универсальным проверкам и проверкам конкретного уровня. 671 | // Цикл продолжается до тех пор, пока какая-либо из проверок не вернет 672 | // любое другое состояние кроме CONTINUE или пока не пройдут все 673 | // проверки. После этого состояние сохраняется. 674 | const allChecks = this.commonRules.concat(LevelsRules[this.level]); 675 | let currentCheck = Verdict.CONTINUE; 676 | let currentRule; 677 | 678 | while (currentCheck === Verdict.CONTINUE && allChecks.length) { 679 | currentRule = allChecks.shift(); 680 | currentCheck = currentRule(this.state); 681 | } 682 | 683 | this.state.currentStatus = currentCheck; 684 | }, 685 | 686 | /** 687 | * Принудительная установка состояния игры. Используется для изменения 688 | * состояния игры от внешних условий, например, когда необходимо остановить 689 | * игру, если она находится вне области видимости и установить вводный 690 | * экран. 691 | * @param {Verdict} status 692 | */ 693 | setGameStatus: function (status) { 694 | if (this.state.currentStatus !== status) { 695 | this.state.currentStatus = status; 696 | } 697 | }, 698 | 699 | /** 700 | * Отрисовка всех объектов на экране. 701 | */ 702 | render: function () { 703 | // Удаление всех отрисованных на странице элементов. 704 | this.ctx.clearRect(0, 0, WIDTH, HEIGHT); 705 | 706 | // Выставление всех элементов, оставшихся в this.state.objects согласно 707 | // их координатам и направлению. 708 | this.state.objects.forEach(function (object) { 709 | if (object.sprite) { 710 | const reversed = object.direction & Direction.LEFT; 711 | const sprite = SpriteMap[object.type + (reversed ? REVERSED : '')] || SpriteMap[object.type]; 712 | this.ctx.drawImage(sprite.image, object.x, object.y, object.width, object.height); 713 | } 714 | }, this); 715 | }, 716 | 717 | /** 718 | * Основной игровой цикл. Сначала проверяет состояние всех объектов игры 719 | * и обновляет их согласно правилам их поведения, а затем запускает 720 | * проверку текущего раунда. Рекурсивно продолжается до тех пор, пока 721 | * проверка не вернет состояние FAIL, WIN или PAUSE. 722 | */ 723 | update: function () { 724 | if (!this.state.lastUpdated) { 725 | this.state.lastUpdated = Date.now(); 726 | } 727 | 728 | const delta = (Date.now() - this.state.lastUpdated) / 10; 729 | this.updateObjects(delta); 730 | this.checkStatus(); 731 | 732 | switch (this.state.currentStatus) { 733 | case Verdict.CONTINUE: 734 | this.state.lastUpdated = Date.now(); 735 | this.render(); 736 | requestAnimationFrame(function () { 737 | this.update(); 738 | }.bind(this)); 739 | break; 740 | 741 | case Verdict.WIN: 742 | case Verdict.FAIL: 743 | case Verdict.PAUSE: 744 | case Verdict.INTRO: 745 | this.pauseLevel(); 746 | break; 747 | } 748 | }, 749 | 750 | /** 751 | * @param {KeyboardEvent} evt [description] 752 | * @private 753 | */ 754 | _onKeyDown: function (evt) { 755 | switch (evt.keyCode) { 756 | case 37: 757 | this.state.keysPressed.LEFT = true; 758 | break; 759 | case 39: 760 | this.state.keysPressed.RIGHT = true; 761 | break; 762 | case 38: 763 | this.state.keysPressed.UP = true; 764 | break; 765 | case 27: 766 | this.state.keysPressed.ESC = true; 767 | break; 768 | } 769 | 770 | if (evt.shiftKey) { 771 | this.state.keysPressed.SHIFT = true; 772 | } 773 | }, 774 | 775 | /** 776 | * @param {KeyboardEvent} evt [description] 777 | * @private 778 | */ 779 | _onKeyUp: function (evt) { 780 | switch (evt.keyCode) { 781 | case 37: 782 | this.state.keysPressed.LEFT = false; 783 | break; 784 | case 39: 785 | this.state.keysPressed.RIGHT = false; 786 | break; 787 | case 38: 788 | this.state.keysPressed.UP = false; 789 | break; 790 | case 27: 791 | this.state.keysPressed.ESC = false; 792 | break; 793 | } 794 | 795 | if (evt.shiftKey) { 796 | this.state.keysPressed.SHIFT = false; 797 | } 798 | }, 799 | 800 | /** @private */ 801 | _initializeGameListeners: function () { 802 | window.addEventListener('keydown', this._onKeyDown); 803 | window.addEventListener('keyup', this._onKeyUp); 804 | }, 805 | 806 | /** @private */ 807 | _removeGameListeners: function () { 808 | window.removeEventListener('keydown', this._onKeyDown); 809 | window.removeEventListener('keyup', this._onKeyUp); 810 | }, 811 | }; 812 | 813 | Game.Verdict = Verdict; 814 | 815 | const game = new Game(document.querySelector('.demo')); 816 | game.initializeLevelAndStart(); 817 | game.setGameStatus(Verdict.INTRO); 818 | 819 | return game; 820 | } 821 | --------------------------------------------------------------------------------