Игра «Слова из слова». Продолжение

от автора

Прошлым летом я публиковал статью о моем небольшом учебном проекте-игре "Слова из слова", написанном на JavaScript. Время идет и, я надеюсь, идет не напрасно. Постепенно набираясь знаний, я решил расширить идею и начать создание некого подобия интернет-площадки, которая объединит тематические игры со словами на одном ресурсе. Под катом ссылка на рабочий прототип проекта.
wordsgames.by

О проекте

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

Ссылка на репозиторий GitHub: https://github.com/Ghivan/wordsgames
Ссылка на рабочий прототип платформы: https://wordsgames.by/login/

Инструменты создания

Фронтэнд — Typescript, SCSS, Bootstrap, JQuery.
Бэкэнд — PHP 7, MySQL.

База данных

Схема базы данных

database schema

База данных состоит из шести таблиц:

  1. Глобальные таблицы ресурса:
    • dictionary — толковый словарь;
    • players — данные о зарегистрированных игроках;
    • games — описание игр;
  2. Таблицы игры "Слова из слова":
    • wfw_levels — информация об этапах;
    • wfw_scoreTable — очки (в будущем также и достижения) игрока ;
    • wfw_levelsPassed — информация о прохождении этапов.

Таблицы players, wfw_levelsPassed и wfw_scoreTable связаны через id игрока. Таблицы wfw_levels и wfw_levelsPassed связаны через поле word (сделано для того, чтобы можно было менять порядок уровня на обновляя записи о прохождении).
У каждого игрока есть игровой уровень (дань RPG), общий для ресурса. Количество опыта, необходимое для перехода на следующий уровень, рассчитывается по формуле геометрической прогрессии. За определение уровня игрока отвечает пользовательская функция в СУБД, а данные обновляются посредством триггера.

Функция и триггер

DELIMITER $$  CREATE FUNCTION `countExp`(`lvl` INT) RETURNS int(11)     NO SQL     SQL SECURITY INVOKER     COMMENT 'Подсчитывает необходимое количество опыта для уровня' BEGIN   DECLARE exp int; SET exp = FLOOR(1000 * (POW(1.1, lvl) - 1)); RETURN exp; END$$  DELIMITER ; -- Триггер, определяющий уровень игрока: DELIMITER // CREATE TRIGGER `lvlCount` BEFORE UPDATE ON `players`  FOR EACH ROW BEGIN IF (NEW.exp <> OLD.exp AND NEW.exp > 0) THEN IF NEW.exp >= countExp(NEW.`level`) THEN WHILE NEW.exp >= countExp(NEW.`level`)   DO     set NEW.level = NEW.level + 1;   END WHILE; ELSEIF  NEW.exp < countExp(NEW.`level` - 1) THEN   WHILE NEW.exp < countExp(NEW.`level` - 1)   DO     set NEW.level = NEW.level - 1;   END WHILE; END IF; END IF; END // DELIMITER ;

Соединение с базой данных из PHP

За соединение с базой отвечает статический класс:

Класс DB

class DB {     private static $dbc = null;      protected static function getConnection()     {          if (!self::$dbc){             try {                 self::$dbc = new PDO("mysql:host=".HOST.";dbname=".DB_NAME.";charset=UTF8", DB_USER, DB_PASSWORD);             } catch (Throwable $e) {                 ErrorLogger::logException($e);                 return null;             }         }         return self::$dbc;     } }

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

Получение информации об играх

class DBGamesGlobalInfo extends DB {     private static $queries = array(         'globalInfo' => 'SELECT id, name, rules, status, author, path  FROM games'     );      static function getGlobalInfo(){         try {             $stmt = parent::getConnection()->prepare(self::$queries['globalInfo']);             if (!$stmt->execute()){                 ErrorLogger::logFailedDBRequest($stmt->errorInfo(), $stmt->queryString,__LINE__, __FILE__);                 $message = 'Ошибка запроса информации об играх';                 throw new Exception($message);             }             return $stmt->fetchAll(PDO::FETCH_ASSOC);         } catch (Throwable $e){             ErrorLogger::logException($e);             return null;         }     } }

Авторизация

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

Авторизация

class Authorization {     public static function check(){         if (session_status() !== PHP_SESSION_ACTIVE){             session_start();         }         return (isset($_SESSION['pl_id'])) ? true : false;     }     public static function logIn($playerId){         if (!defined('LOGIN_SCRIPT') || LOGIN_SCRIPT !== '/login/server_scenarios/index.php') return false;          if (session_status() !== PHP_SESSION_ACTIVE){             session_start();         }         $_SESSION['pl_id'] = $playerId;         return (isset($_SESSION['pl_id'])) ? true : false;     }      public static function logOut(){         if (session_status() !== PHP_SESSION_ACTIVE){             session_start();         }         unset($_SESSION['pl_id']);         unset($_SESSION['cur_level']);     }      public static function getAuthorizedPlayerId(){         if (session_status() !== PHP_SESSION_ACTIVE){             session_start();         }         return isset($_SESSION['pl_id']) ? $_SESSION['pl_id'] : null;     } }

Игра "Слова из слова"

Кратко напомню правила: Необходимо составлять слова из показанного на экране слова. Слово должно быть нарицательным именем существительным в единственном числе. Уменьшительно-ласкательные формы, а также сокращения не принимаются. Минимальная длина слова — 3 буквы. Для перехода на следующий этап необходимо отгадать не менее 30% вариантов слов текущего.

Вид игрового поля

Word from word

Взаимодействие с сервером происходит посредством Ajax.

Скелет игрового поля до инициализации

<div class="row">         <div class="col-xs-4 col-sm-3 player-info">             <img src="/_app_files/players_avatars/no_avatar.png" alt="Ваш аватар" class="img-responsive img-circle center-block avatar" id="userAvatar">             <h2 id="userLoginLabel">Игрок</h2>             <p class="link-to-cabinet"><a href="/cabinet/">Вернуться в личный кабинет</a></p>              <div class="tablescore">                 <div>Этап: <span id="level-number">0</span></div>                 <div>Очки: <span id="score-value">0</span></div>             </div>              <div class="progress">                 <span>Слов отгадано: <span id="found-words-number">0</span>/<span id="total-words-number">0</span></span>                 <div id="user-progress-bar" class="progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="0"> </div>             </div>              <div class="level-map-box">                 <div class="label label-info">Карта этапов <span class="glyphicon glyphicon-triangle-bottom"></span></div>                 <div class="btn-group-sm" id="level-buttons-container"></div>             </div>              <div class="tips">                 <div class="label label-info">Подсказки <span class="glyphicon glyphicon-triangle-bottom"></span></div>                 <div class="btn-group-sm">                     <a class="btn">                         <img id="word-definition-tip" title="Показать определение неотгаданного слова." alt="Показать определение неотгаданного слова." src="images/tips/definition_gray.png" draggable="false">                     </a>                     <a class="btn">                         <img id="hole-word-tip" title="Показать неотгаданное слово целиком." alt="Показать неотгаданное слово целиком." src="images/tips/word_gray.png" draggable="false">                     </a>                 </div>             </div>          </div>          <div class="col-xs-8 col-sm-9 gamefield">             <div id="missions-icon" class="row missions">                  <img id="mission1-icon" src="images/missions/incomplete.png" alt="Первая звезда" title="Отгадать больше 40% слов">                 <img id="mission2-icon" src="images/missions/incomplete.png" alt="Вторая звезда">                 <img id="mission3-icon" src="images/missions/incomplete.png" alt="Третья звезда" title="Отгадать 100% слов">             </div>              <div id="help-button"><a href="#help-box" data-toggle="modal"><span class="glyphicon glyphicon-question-sign"></span></a> </div>             <!--Блок помощи-->             <div id="help-box" class="modal fade">                  <!-- Модальное окно -->                 <div class="modal-dialog">                      <!--Все содержимое модального окна -->                     <div class="modal-content">                          <!-- Заголовок модального окна -->                         <div class="modal-header">                             <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>                             <h4 class="modal-title">Помощь</h4>                         </div>                          <!-- Основное содержимое мод                         ального окна -->                         <div class="modal-body">                             <!-- Текст правил -->                         </div>                      </div>                     <!--Конец всего содержимого модального окна -->                  </div>                 <!--Конец модального окна -->              </div>             <!--Конец блока помощи-->              <div id="user-input-word" class="row"></div>              <div id="user-input-controls-btn" class="row">                 <div id="clear-letter-btn">Стереть букву</div>                 <div id="clear-word-btn">Стереть все слово</div>             </div>              <div id="level-main-word" class="row"></div>              <div id="user-found-words-box" class="row"></div>         </div>     </div>     <!--окно сообщений-->     <div id="message-modal-box" class="modal fade">          <!-- Модальное окно -->         <div class="modal-dialog">              <!--Все содержимое модального окна -->             <div class="modal-content">                 <div class="modal-header">                     <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>                     <h4 id="message-modal-header">Подсказка</h4>                 </div>                  <!-- Основное содержимое модального окна -->                 <div class="modal-body">                      <p id="message-modal-content"></p>                  </div>              </div>             <!--Конец всего содержимого модального окна -->          </div>         <!--Конец модального окна -->      </div>

Далее подгружаем данные о прогрессе пользователя.

Клиентский код инициализации

class Controller{     private model: Model;     private view: View;     private freezeState: boolean = false;      constructor(){         this.view = new View();         this.model = new Model(this.onReceiveInitialData.bind(this), this.onError.bind(this));          $(document).on('tipClick',this.useTip.bind(this));         $(document).on('lvlBtnClick',this.changeLevel.bind(this));         $(document).on('letterClick', this.onLetterClick.bind(this));         $(document).on('foundWordClick',this.getWordDefinition.bind(this));         $(document).on('keydown', this.keyControls.bind(this));          $('#clear-letter-btn').on('click', this.removeLastLetter.bind(this));         $('#clear-word-btn').on('click', this.clearUserInput.bind(this));     } .......... }  class Model{     readonly tipsCost = {         holeWord: 250,         wordDefinition: 100     };     private login: string;     private avatar: string;     private level: number;     private totalLevelsNumber: number;     private levelsPassedNumber: number;     private levelWord: string;     private wordVariants: Array<string>;     private foundWords: Array<string>;     private score: number;     private userWord: string = '';     private missions: {         1: boolean,         2: boolean,         3: boolean     };     private missionUnique;     private dictionary = {};      constructor(success: ()=> any, error: (message: string)=>any, lvl?: number){         let that = this;         $.ajax({             url: 'server_scenarios/index.php',             type: 'post',             data: {                 'action': 'getInitialInfo',                 'lvl' : (lvl) ? lvl : null             },             success: function (data) {                 if (data.state){                     that.initialize(data);                     success();                 } else {                     error(data.message);                 }             },             error: function(){                 error('Ошибка соединения с сервером');             }         })     }      public initialize(data: ServerAnswerInitialData){         this.login = data.login;         this.avatar = data.avatar;         this.level = parseInt(data.level);         this.totalLevelsNumber = parseInt(data.totalLevelsNumber);         this.levelsPassedNumber = parseInt(data.levelsPassedNumber);         this.levelWord = data.levelWord;         this.wordVariants = data.wordVariants;         this.foundWords = data.foundWords;         this.score = parseInt(data.score);         this.missions = data.missions;         this.missionUnique = data.missionUnique;     } ........ }  class View{     private playerInfo: PlayerInfo;     private gamefield: Gamefield;     public loader: Loader;     constructor(){         this.loader = new Loader();         this.playerInfo = new PlayerInfo();         this.gamefield = new Gamefield();     }      public initializePlayerInfoBox(data: UserInfoData){         this.playerInfo.setNewAvatar(data.avatar);         this.playerInfo.setLoginLabel(data.login);         this.playerInfo.setLevelLabel(data.level.toString());         this.playerInfo.setScoreLabel(data.score);         this.playerInfo.setFoundWordsLabel(data.foundWordsNumber.toString());         this.playerInfo.setTotalWordsLabel(data.totalWordsNumber.toString());         this.playerInfo.setProgressBar(data.foundWordsNumber, data.totalWordsNumber);         this.playerInfo.createLevelMap(data.totalLevelsNumber, data.level, data.levelsPassedNumber);         if (data.tipsState.wordDefinition){             this.playerInfo.enableTip('wordDefinition');         }         if (data.tipsState.holeWord){             this.playerInfo.enableTip('holeWord');         }     }      public initializeGameField(data: GamefieldData){         for (let prop in data.missions){             if (data.missions.hasOwnProperty(prop)){                 (data.missions[prop]) ? this.showCompleteMissionStateIcon(parseInt(prop)): this.showIncompleteMissionStateIcon(parseInt(prop));             }         }         this.updateUserInputWord();         this.gamefield.printMainWordLetters(data.levelMainWord);          this.gamefield.clearFoundWordsBox();         if (data.foundWords){             for (let i = 0; i < data.foundWords.length; i++){                 this.addFoundWord(data.foundWords[i]);             }         }         this.gamefield.setUniqueMissionTitle(data.missionUnique);     } .... }

Серверный код инициализации

if (!defined('PLAYER_ID') || !defined('CURRENT_LEVEL')) exit(); header('Content-Type: application/json');  $playerGlobalInfo = DBPlayerGlobalInfo::getGlobalInfo(PLAYER_ID); $playerProgressInfo = DBPlayerProgress::getProgressOnLvl(PLAYER_ID, CURRENT_LEVEL); $levelInfo = DBGameInfo::getLevelInfo(CURRENT_LEVEL);  echo json_encode(array(     'state' => true,     'login' => $playerGlobalInfo['login'],     'avatar' => file_exists($_SERVER['DOCUMENT_ROOT'] . $playerGlobalInfo['avatar']) ? $playerGlobalInfo['avatar'] : '/_app_files/players_avatars/no_avatar.png',     'level' => CURRENT_LEVEL,     'totalLevelsNumber' => DBGameInfo::getLevelsQuantity(),     'levelsPassedNumber' => DBPlayerProgress::getPassedLvlQuantity(PLAYER_ID),     'levelWord' => $levelInfo['word'],     'wordVariants' => $levelInfo['wordVariants'],     'foundWords' => (empty($playerProgressInfo['foundWords'])) ? array() : $playerProgressInfo['foundWords'],     'score' => DBPlayerProgress::getScore(PLAYER_ID),     'missions' => array(         1 => (boolean) $playerProgressInfo['star1status'],         2 => (boolean) $playerProgressInfo['star2status'],         3 => (boolean) $playerProgressInfo['star3status']     ),     'missionUnique' => $levelInfo['missionUnique'] ));

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

Проверка слова, вводимого игроком

Слово уровня представляет собой набор div’ов с одной буквой внутри. По нажатию на букву выполняется проверка активности буквы, если она не выбрана, то добавляется к набираемому слову и, как только длина слова станет больше либо равна, то оно отправляется на проверку наличия в словаре. Далее, когда проверка пройдена успешна, слова отправляется на сервер для подсчета очков и занесения в базу данных.
В случае, когда буква была выбрана, то проверяется ее положение в слове. Если крайняя — стирается, если нет — ничего не происходит.
Обработчик клика приведен ниже.

Обработчика клика по букве

onLetterClick(e: CustomEvent): void{         if (this.freezeState) return;         this.freeze();         let letter = e.detail,             userWord = this.model.getUserInputWord();         if (!letter.hasClass('active')){             userWord += letter.text();             letter.data('order', userWord.length);             this.view.setActiveLetterState(letter);             this.view.updateUserInputWord(userWord);             this.model.updateUserInputWord(userWord);             if (userWord.length >= 3){                 this.model.checkUserWord(userWord, this.onAlreadyFoundWord.bind(this), this.onNewFoundWord.bind(this), );             }         } else {             if (letter.data().order === userWord.length){                 letter.data('order', 0);                 userWord = userWord.substr(0, userWord.length-1);                 this.view.removeActiveLetterState(letter);                 this.view.updateUserInputWord(userWord);                 this.model.updateUserInputWord(userWord);             }         }     }

Отправка найденного слова на сервер

checkUserWord(word: string, onAlreadyFound: (word) => any, onNewFound:(data: ServerAnswerCheckWord)=>any){         let model = this;          if (this.foundWords.indexOf(word) > -1){             onAlreadyFound(word);             return;         }         if (this.wordVariants.indexOf(word) > -1){             $.ajax({                 url: 'server_scenarios/index.php',                 type: 'post',                 data: {                     'action': 'checkWord',                     'userWord' : word                 },                 success: function (data) {                     if (data.state){                         model.score = data.score;                         model.foundWords = data.foundWords;                         let level_status = false;                         if ((data.lvl_status) && (model.totalLevelsNumber >= (model.level + 1))){                             level_status = true;                         }                          onNewFound({                             word: data.word,                             score: data.score,                             experience: data.experience,                             points: data.points,                             missions: data.missions,                             foundWordsNumber: data.foundWords.length,                             lvl_status: level_status                         });                     }                 }             })         }     }

Серверный код обработки найденного слова

if (!defined('PLAYER_ID') || !defined('CURRENT_LEVEL') || empty($_POST['userWord'])) exit; header('Content-Type: application/json');  $checker = new AddingWordChecker(PLAYER_ID,CURRENT_LEVEL,$_POST['userWord']);  echo json_encode(     $checker->getChangedData() );  class AddingWordChecker {     const POINTS_PER_LETTER = 4;     const EXPERIENCE_PER_WORD = 1;      const POINTS_FOR_LEVEL_COMPLETE = 150;     const EXPERIENCE_FOR_LEVEL_COMPLETE = 20;      const POINTS_FOR_FIRST_STAR = 1000;     const EXPERIENCE_FOR_FIRST_STAR = 50;      const POINTS_FOR_SECOND_STAR = 500;     const EXPERIENCE_FOR_SECOND_STAR = 30;      const POINTS_FOR_THIRD_STAR = 10000;     const EXPERIENCE_FOR_THIRD_STAR = 250;      const PERCENT_FOUND_FOR_LEVEL_COMPLETE = 0.3;     const PERCENT_FOUND_FOR_FIRST_STAR = 0.4;     const PERCENT_FOUND_FOR_THIRD_STAR = 1;      private $playerId;     private $state = false;     private $gameLevel;     private $levelStatus;     private $wordToCheck;     private $wordVariants;     private $foundWords;     private $star1status;     private $star2status;     private $star3status;     private $changedData = array();      function __construct($playerId, $gameLevel, $wordToCheck)     {         $wordToCheck = strip_tags($wordToCheck);          $this->playerId = $playerId;         $this->gameLevel = $gameLevel;         $this->wordVariants = DBGameInfo::getWordVariantsOnLvl(CURRENT_LEVEL);          $playerProgress = DBPlayerProgress::getProgressOnLvl($playerId, $gameLevel);         $this->levelStatus = $playerProgress['lvl_status'];         $this->foundWords = (empty($playerProgress['foundWords'])) ? array()  : $playerProgress['foundWords'];         $this->star1status = $playerProgress['star1status'];         $this->star2status = $playerProgress['star2status'];         $this->star3status = $playerProgress['star3status'];          $this->wordToCheck = $wordToCheck;          if (!$this->checkWord()){             $this->changedData['state']  = false;             $this->changedData['message'] = 'Неверное слово';             return;         }          $this->addWord();     }      public function getChangedData(){         $this->changedData['state'] = $this->state;         $this->changedData['score'] = DBPlayerProgress::getScore($this->playerId);         $this->changedData['word'] = $this->wordToCheck;         return $this->changedData;     }      private function checkWord(){         if ((array_search($this->wordToCheck, $this->foundWords) !== false) ||             (array_search($this->wordToCheck, $this->wordVariants) === false)) {             $this->state = false;         } else {             $this->state = true;         }         return $this->state;     }      private function addWord(){         if (!$this->state) return;          array_push($this->foundWords, $this->wordToCheck);         DBPlayerProgress::updateFoundWords($this->playerId, $this->gameLevel, $this->foundWords);         $this->changedData['foundWords'] = $this->foundWords;          $this->calculatePointsForWordLength();         $this->checkLvlStatus();         $this->checkMissions();         DBPlayerProgress::augmentScore($this->playerId, $this->changedData['points']);         DBPlayerGlobalInfo::augmentExperience($this->playerId, $this->changedData['experience']);     }      private function addPoints($points){         if (isset($this->changedData['points'])){             $this->changedData['points'] += $points;         } else {             $this->changedData['points'] = $points;         }     }      private function addExperience($experience){         if (isset($this->changedData['experience'])){             $this->changedData['experience'] += $experience;         } else {             $this->changedData['experience'] = $experience;         }     }      private function calculatePointsForWordLength(){         $wordLength = mb_strlen($this->wordToCheck);         $points = $wordLength * $this::POINTS_PER_LETTER;         $experience = $this::EXPERIENCE_PER_WORD;         switch ($wordLength){             case (($wordLength > 3) && ($wordLength <= 5)):                 $points *= 1.1;                 $experience *= 2;                 break;             case (($wordLength > 5) && ($wordLength <= 7)):                 $points *= 1.2;                 $experience *= 3;                 break;             case (($wordLength > 7) && ($wordLength <= 9)):                 $points *= 1.3;                 $experience *= 4;                 break;             case ($wordLength >= 10):                 $points *= 2;                 $experience *= 10;                 break;         }          $this->addPoints(floor($points));         $this->addExperience(floor($experience));     }      private function checkLvlStatus(){         if ((!$this->levelStatus) &&             (count($this->foundWords) >= count($this->wordVariants)* $this::PERCENT_FOUND_FOR_LEVEL_COMPLETE)){             $this->levelStatus = true;             DBPlayerProgress::completeLevel($this->playerId, $this->gameLevel);             $this->changedData['lvl_status'] = $this->levelStatus;              $this->addPoints($this::POINTS_FOR_LEVEL_COMPLETE);             $this->addExperience($this::EXPERIENCE_FOR_LEVEL_COMPLETE);         }     }      private function checkMissions(){         $this->changedData['missions'] = array();         $this->checkFirstStarMission();         $this->checkSecondStarMission();         $this->checkThirdStarMission();     }      private function checkFirstStarMission(){         if ($this->star1status) return;         if (count($this->foundWords) >= count($this->wordVariants)* $this::PERCENT_FOUND_FOR_FIRST_STAR){             $this->star1status = true;             DBPlayerProgress::setCompleteStatusOnMission($this->playerId, $this->gameLevel, 1);             $this->changedData['missions']['star1status'] = $this->star1status;              $this->addPoints($this::POINTS_FOR_FIRST_STAR);             $this->addExperience($this::EXPERIENCE_FOR_FIRST_STAR);         }     }      private function checkSecondStarMission(){         if ($this->star2status) return;         $uniqueMission = DBGameInfo::getUniqueMission($this->gameLevel);         $letter = array_keys($uniqueMission)[0];         $quantity = array_values($uniqueMission)[0];         $pattern = '/^'.$letter.'+/u';         $matches = preg_grep($pattern, $this->foundWords);         if (count($matches) >= $quantity){             $this->star2status = true;             DBPlayerProgress::setCompleteStatusOnMission($this->playerId, $this->gameLevel, 2);             $this->changedData['missions']['star2status'] = $this->star2status;              $this->addPoints($this::POINTS_FOR_SECOND_STAR);             $this->addExperience($this::EXPERIENCE_FOR_SECOND_STAR);         }     }      private function checkThirdStarMission(){         if ($this->star3status) return;         if (count($this->foundWords) >= count($this->wordVariants)* $this::PERCENT_FOUND_FOR_THIRD_STAR){             $this->star3status = true;             DBPlayerProgress::setCompleteStatusOnMission($this->playerId, $this->gameLevel, 3);             $this->changedData['missions']['star3status'] = $this->star3status;              $this->addPoints($this::POINTS_FOR_THIRD_STAR);             $this->addExperience($this::EXPERIENCE_FOR_THIRD_STAR);         }     } } 

Заключение

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

@Гуманитарий, который хочет стать технарем

ссылка на оригинал статьи https://habrahabr.ru/post/324282/


Комментарии

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

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