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

О проекте
Игровой сайт «Игры со словами» представляет собой платформу для размещения игр соответствующей тематики.
Зарегистрированные игроки имеют доступ к имеющимся на ресурсе играм, а также участвуют в рейтинге, формируемом на основе игрового уровня пользователя. Опыт набирается в результате прохождения различных игр и выполнения определенных задач.
Ссылка на репозиторий GitHub: https://github.com/Ghivan/wordsgames
Ссылка на рабочий прототип платформы: https://wordsgames.by/login/
Инструменты создания
Фронтэнд — Typescript, SCSS, Bootstrap, JQuery.
Бэкэнд — PHP 7, MySQL.
База данных

База данных состоит из шести таблиц:
- Глобальные таблицы ресурса:
- dictionary — толковый словарь;
- players — данные о зарегистрированных игроках;
- games — описание игр;
- Таблицы игры "Слова из слова":
- 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
За соединение с базой отвечает статический класс:
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% вариантов слов текущего.

Взаимодействие с сервером происходит посредством 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/
Добавить комментарий