Прошлым летом я публиковал статью о моем небольшом учебном проекте-игре "Слова из слова", написанном на 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);
}
}
}
Заключение
Вот, собственно, и вкратце о моем проекте. Если кого-то заинтересуют подробности, с радостью отвечу. Буду благодарен за любые конструктивные замечания, а еще больше за совет, что и где нужно подучить, чтобы можно было превратить прототип платформы в полноценный работоспособный проект.
@Гуманитарий, который хочет стать технарем