СоХабр закрыт.
С 13.05.2019 изменения постов больше не отслеживаются, и новые посты не сохраняются.
Прошлым летом я публиковал статью о моем небольшом учебном проекте-игре "Слова из слова", написанном на JavaScript. Время идет и, я надеюсь, идет не напрасно. Постепенно набираясь знаний, я решил расширить идею и начать создание некого подобия интернет-площадки, которая объединит тематические игры со словами на одном ресурсе. Под катом ссылка на рабочий прототип проекта.
Игровой сайт «Игры со словами» представляет собой платформу для размещения игр соответствующей тематики.
Зарегистрированные игроки имеют доступ к имеющимся на ресурсе играм, а также участвуют в рейтинге, формируемом на основе игрового уровня пользователя. Опыт набирается в результате прохождения различных игр и выполнения определенных задач.
Ссылка на репозиторий GitHub: https://github.com/Ghivan/wordsgames
Ссылка на рабочий прототип платформы: https://wordsgames.by/login/
Фронтэнд — Typescript, SCSS, Bootstrap, JQuery.
Бэкэнд — PHP 7, MySQL.
База данных состоит из шести таблиц:
Таблицы 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 ;
За соединение с базой отвечает статический класс:
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);
}
}
}
Вот, собственно, и вкратце о моем проекте. Если кого-то заинтересуют подробности, с радостью отвечу. Буду благодарен за любые конструктивные замечания, а еще больше за совет, что и где нужно подучить, чтобы можно было превратить прототип платформы в полноценный работоспособный проект.
@Гуманитарий, который хочет стать технарем
комментарии (0)