По всей видимости, я не самый правильный фронтедщик. Большую часть сознательной жизни я занимаюсь базами данных и немного бакендом. Любовь к настольным играм — вот что заставило меня связаться с web-разработкой. Новый, совершенно незнакомый мне язык программирования — JavaScript, я изучал по ходу дела. Допускаю, что многое из того что я делал, способно ужаснуть опытных web-разработчиков, но я стараюсь стать лучше. Этот текст не для тех, кто привык во всём следовать раз и навсегда написанным инструкциям. Но если вы любите экспериментировать, докапываться до каждой мелочи, открывать новое для себя, добро пожаловать под сень моего леса.
У меня были игры. Много игр, но настольные игры, за очень редким исключением — это то, во что интересно играть с кем-то. В этом и заключалась проблема. Для некоторых игр мне удалось сделать ботов, но играли они слабо, а в том, что касается Шахмат или Го, я не питал никаких иллюзий по поводу того, что мне удастся разработать бота, играть с которым будет интересно. Отчасти, это связано с низкой производительностью разработанной мной универсальной модели, но по большей части, я просто не очень силён в разработке ботов для настольных игр.
Радикальным решением стала бы разработка сетевой версии Dagaz, при помощи которой пользователи смогли бы играть друг с другом через Internet, но это подразумевало платный хостинг с полноценным бакендом и базой данных. Поскольку речь шла о хобби-проекте, я был не готов вкладывать в это деньги.
Так, волей-неволей, мне пришлось взяться за бакенд. Я выбрал Nest. Во первых, давно хотел попробовать TypeScript. Кроме того, все эти аннотации, максимально похожи на тот кровавый энтерпрайз, к которому я привык. Под капотом у Nest-а всё тот же Express, но код записывается лаконичнее — это удобно. Swagger прикручивается к проекту при помощи нескольких строк кода, а также всё новых и новых аннотаций. После этого, с REST API можно ознакамливаться так или даже так.
Отдельно стоит рассказать о базе данных. Вообще, я из тех людей, кому гораздо проще сразу писать SQL запрос, чем разбираться в премудростях какой-нибудь ORM. Но для этого проекта я сделал исключение. Просто потому что TypeORM создаёт все задекларированные таблички автоматически, а мне остаётся просто создать на сервере пустую базу данных. Разумеется, я выбрал PostgreSQL, но думаю, что переключиться на MySQL, при необходимости, будет не слишком сложно.
Следующая важная вещь из коробки — это аутентификация. Для того чтобы не гонять логин и пароль в открытом виде в каждом запросе, используем JWT. С точки зрения Nest-а, проверка авторизации пользователя, при выполнении запроса, сводится к использованию той или иной стратегии (все детали выполняемых проверок, разумеется, вынесены в сервис). Сами стратегии обёрнуты в guard-ы и могут использоваться вполне декларативно.
Здесь первая аннотация говорит о том, что для получения JWT используется базовая аутентификация. На выходе (если всё пойдёт успешно), получаем токен, который прикрепляем как bearer к запросам, использующим JWT-аутентификацию. Вторая аннотация определяет URL по которому должен выполняться POST-запрос и с этим связан один очень интересный момент.
Теперь, когда всё заработало, осталось разместить это на какой-то живой машине, доступной из Internet. Но перед этим, стоит озаботиться переходом от HTTP-протокола к HTTPS (очевидно, что передача логинов и паролей в открытом виде, при выполнении базовой аутентификации — это не самая удачная мысль). В принципе, Nest это тоже умеет, но гораздо проще, в смысле администрирования, установить на тот же сервер proxy Nginx. Конфигурация выглядит примерно вот так:
А что с играми? Если вы ещё не забыли, наша цель — связать имеющиеся игры с сервером (в рамках текущего проекта, чтобы получать от сервера сгенерированный бонус при выигрыше). В этом вполне может помочь jQuery (кстати, это одна из всего лишь трёх используемых мной сторонних библиотек, кроме неё я использую Underscore и в некоторых случаях Seedrandom). Фактически, вся интеграция свелась к переписыванию всего одного модуля.
Конечно, только этим дело не ограничивалось. Одним из требований отдела маркетинга была возможность запуска игр на мобильных устройствах. В этом, мне помог jonic (за что я очень ему благодарен). Было совершенно необходимо уметь масштабировать canvas, на котором происходит вся игра, по размерам экрана мобильного телефона или планшета. При этом, соотношение сторон должно было сохраняться, а в том случае если ширина canvas-а значительно превосходит высоту было крайне желательно попросить пользователя повернуть устройство должным образом.
Второй задачей стала интеграция отобранных отделом маркетинга игр в единое меню, с кратким описанием игр, страничками побед и поражений и навигацией между всеми этими страничками. Поскольку задача поддержания всего этого зоопарка в актуальном состоянии, сама по себе, способна свести с ума, Кирилл приспособил Gulp для автоматизированной сборки этой части сайта. Вот как всё выглядит в результате:
Вот так, при помощи Nest-а, jQuery, Nginx-а и небольшой доли везения нам удалось развернуть в Internet-е игровой сервер. Кому стало интересно, заходите ещё.
С чего всё началось
Фаза активного увлечения настольными играми началась для меня со знакомства с Zillions of Games. То что на компьютере можно играть в игры — новостью не было. Оригинальность этого проекта заключалась в использовании ZRF — Lisp-оподобного языка, предназначенного для быстрого описания разнообразных игр. К сожалению, я быстро осознал, что хотя простые игры делаются на ZRF просто, игры чуть сложнее быстро выливаются в тысячи строк малопонятного кода. Для примера, в ZRF нет самой обычной арифметики. В результате, целочисленные значения приходится собирать из битовых флагов.
В Axiom Development Kit — библиотеке разработанной Грегом Шмидтом, складывать числа было можно, но в качестве метаязыка использовался диалект Forth-а, что не делало программы более понятными. Само решение представляло собой, в некотором роде, хак использующий API, предназначенное для подключения к Zillions ботов. Кроме того, Axiom никак не решала фатальный недостаток Zillions — игры продолжали запускаться только под Windows и только на платной платформе с закрытым исходным кодом.
Знакомство с проектом Jocly подсказало мне возможное решение этой проблемы. Действительно, игра написанная на JavaScript могла запускаться в любом современном браузере, в том числе на мобильных платформах. С этого момента началась работа над Dagaz. Что-то я подсмотрел в Jocly, а что-то у Зейна Фишера. Также как Zillions, Dagaz использует ZRF (это позволяет разрабатывать прототипы новых игр очень быстро), но не напрямую (что оказалось невозможным из-за недостаточной производительности), а после компиляции специальной утилитой.
Да, читать это труднее, зато работает гораздо быстрее. Например, навигация — перемещение по доске, сводится к обычному арифметическому сложению. Есть ещё один момент. Некоторые правила, относящиеся не к перемещению отдельных фигур, а к игре в целом, невероятно трудно выразить на языке ZRF. Я и не пытаюсь. Для этого в Dagaz есть расширения. Например, в "Русских шашках", как и в большинстве других шашечных игр с «летающими» дамками, действует правило "Турецкого удара" — взятые фигуры убираются с доски только по завершении хода.
После того как список ходов сгенерирован базовой логикой, закодированной ZRF-описанием игры, он передаётся подключенным расширениям, способным выполнять сложные проверки, запрещать отдельные ходы, обогащать их дополнительными действиями или изменять порядок выполняемых действий, как в случае с deferred-captures, упомянутым выше. Такой гибридный подход серьёзным образом расширяет описательные возможности системы, без необходимости усложнения базового метаязыка ZRF.
Проект Dagaz постоянно развивается. Так, совсем недавно, мне удалось разработать принципиально новый модуль представления, предоставляющий функциональность недостижимую в рамках проекта ранее. Теперь canvas можно разделять на независимые регионы, отрисовавать перекрывающиеся всплывающие окна, выполнять прокрутку изображения, более гибко управлять анимацией перемещения фигур и пр. В комплект к новому представлению, пришлось разработать и новый контроллер, выгодно отличающийся от старого большей читабельностью кода.
Сама модель тоже меняется. Все эти длинные и непонятные столбцы команд стековой машины могут скоро уйти в прошлое.
Более того, такой подход позволит полностью отказаться как от Z2J (утилиты преобразования ZRF в JavaScript), так и от самого ZRF. Описание игры можно будет писать руками, сразу на JavaScript. Всё это позволит разрабатывать более сложные игры, ещё быстрее чем раньше.
И выглядит это как-то так
(define not-0?
(or (flag? $1-08)
(flag? $1-04)
(flag? $1-02)
(flag? $1-01)
)
)
(define inc
(if (flag? $1-01)
(set-flag $1-01 false)
(if (flag? $1-02)
(set-flag $1-02 false)
(if (flag? $1-04)
(set-flag $1-04 false)
(if (flag? $1-08)
(set-flag $1-08 false)
else
(set-flag $1-08 true)
)
else
(set-flag $1-04 true)
)
else
(set-flag $1-02 true)
)
else
(set-flag $1-01 true)
)
)
(define dec
(if (not-flag? $1-01)
(set-flag $1-01 true)
(if (not-flag? $1-02)
(set-flag $1-02 true)
(if (not-flag? $1-04)
(set-flag $1-04 true)
(if (not-flag? $1-08)
(set-flag $1-08 true)
else
(set-flag $1-08 false)
)
else
(set-flag $1-04 false)
)
else
(set-flag $1-02 false)
)
else
(set-flag $1-01 false)
)
)
(define sum
(while (not-0? $2)
(inc $1)
(dec $2)
)
)
В Axiom Development Kit — библиотеке разработанной Грегом Шмидтом, складывать числа было можно, но в качестве метаязыка использовался диалект Forth-а, что не делало программы более понятными. Само решение представляло собой, в некотором роде, хак использующий API, предназначенное для подключения к Zillions ботов. Кроме того, Axiom никак не решала фатальный недостаток Zillions — игры продолжали запускаться только под Windows и только на платной платформе с закрытым исходным кодом.
Знакомство с проектом Jocly подсказало мне возможное решение этой проблемы. Действительно, игра написанная на JavaScript могла запускаться в любом современном браузере, в том числе на мобильных платформах. С этого момента началась работа над Dagaz. Что-то я подсмотрел в Jocly, а что-то у Зейна Фишера. Также как Zillions, Dagaz использует ZRF (это позволяет разрабатывать прототипы новых игр очень быстро), но не напрямую (что оказалось невозможным из-за недостаточной производительности), а после компиляции специальной утилитой.
Вот это
(define checker-shift (
$1 (verify empty?)
(if (in-zone? promotion)
(add King)
else
add
)
))
(define checker-jump (
$1 (verify enemy?)
capture
$1 (verify empty?)
(if (in-zone? promotion)
(add-partial King continue-type)
else
(add-partial jump-type)
)
))
(define king-shift (
$1 (while empty?
add $1
)
))
(define king-jump (
$1 (while empty? $1)
(verify enemy?)
$1 (while empty?
mark
(while empty?
(opposite $1)
)
capture
back
(add-partial continue-type) $1
)
))
(define king-continue (
$1 (while empty?
$1 (verify not-last-from?)
)
(verify enemy?)
$1 (while empty?
mark
(while empty?
(opposite $1)
)
capture
back
(add-partial continue-type) $1
)
))
(game
(title "Russian Checkers")
(players White Black)
(turn-order White Black)
(move-priorities jump-type normal-type)
(board
(image "images/8x8.bmp")
(grid
(start-rectangle 2 2 52 52)
(dimensions
("a/b/c/d/e/f/g/h" (50 0)) ; files
("8/7/6/5/4/3/2/1" (0 50)) ; ranks
)
(directions (ne 1 -1) (nw -1 -1) (se 1 1) (sw -1 1))
)
(symmetry Black (nw se) (se nw) (ne sw) (sw ne))
(zone (name promotion) (players White)
(positions b8 d8 f8 h8)
)
(zone (name promotion) (players Black)
(positions a1 c1 e1 g1)
)
)
(piece
(name Man)
(image White "images/wman.bmp"
Black "images/bman.bmp")
(moves
(move-type jump-type)
(checker-jump nw) (checker-jump ne) (checker-jump sw) (checker-jump se)
(move-type normal-type)
(checker-shift nw) (checker-shift ne)
)
)
(piece
(name King)
(image White "images/wdamone.bmp"
Black "images/bdamone.bmp")
(moves
(move-type jump-type)
(king-jump nw) (king-jump ne) (king-jump sw) (king-jump se)
(move-type continue-type)
(king-continue nw) (king-continue ne)
(king-continue sw) (king-continue se)
(move-type normal-type)
(king-shift nw) (king-shift ne) (king-shift sw) (king-shift se)
)
)
(board-setup
(White (Man a1 c1 e1 g1 b2 d2 f2 h2 a3 c3 e3 g3) )
(Black (Man b8 d8 f8 h8 a7 c7 e7 g7 b6 d6 f6 h6) )
)
)
превращается в это
ZRF = {
JUMP: 0,
IF: 1,
FORK: 2,
FUNCTION: 3,
IN_ZONE: 4,
FLAG: 5,
SET_FLAG: 6,
POS_FLAG: 7,
SET_POS_FLAG: 8,
ATTR: 9,
SET_ATTR: 10,
PROMOTE: 11,
MODE: 12,
ON_BOARD_DIR: 13,
ON_BOARD_POS: 14,
PARAM: 15,
LITERAL: 16,
VERIFY: 20
};
Dagaz.Model.BuildDesign = function(design) {
design.checkVersion("z2j", "2");
design.checkVersion("animate-captures", "false");
design.checkVersion("smart-moves", "true");
design.checkVersion("show-hints", "false");
design.checkVersion("show-blink", "true");
design.checkVersion("deferred-captures", "true");
design.checkVersion("advisor-wait", "5");
design.addDirection("ne");
design.addDirection("se");
design.addDirection("sw");
design.addDirection("nw");
design.addPlayer("White", [2, 3, 0, 1]);
design.addPlayer("Black", [2, 3, 0, 1]);
design.addPosition("a8", [0, 9, 0, 0]);
design.addPosition("b8", [0, 9, 7, 0]);
design.addPosition("c8", [0, 9, 7, 0]);
design.addPosition("d8", [0, 9, 7, 0]);
design.addPosition("e8", [0, 9, 7, 0]);
design.addPosition("f8", [0, 9, 7, 0]);
design.addPosition("g8", [0, 9, 7, 0]);
design.addPosition("h8", [0, 0, 7, 0]);
design.addPosition("a7", [-7, 9, 0, 0]);
design.addPosition("b7", [-7, 9, 7, -9]);
design.addPosition("c7", [-7, 9, 7, -9]);
design.addPosition("d7", [-7, 9, 7, -9]);
design.addPosition("e7", [-7, 9, 7, -9]);
design.addPosition("f7", [-7, 9, 7, -9]);
design.addPosition("g7", [-7, 9, 7, -9]);
design.addPosition("h7", [0, 0, 7, -9]);
design.addPosition("a6", [-7, 9, 0, 0]);
design.addPosition("b6", [-7, 9, 7, -9]);
design.addPosition("c6", [-7, 9, 7, -9]);
design.addPosition("d6", [-7, 9, 7, -9]);
design.addPosition("e6", [-7, 9, 7, -9]);
design.addPosition("f6", [-7, 9, 7, -9]);
design.addPosition("g6", [-7, 9, 7, -9]);
design.addPosition("h6", [0, 0, 7, -9]);
design.addPosition("a5", [-7, 9, 0, 0]);
design.addPosition("b5", [-7, 9, 7, -9]);
design.addPosition("c5", [-7, 9, 7, -9]);
design.addPosition("d5", [-7, 9, 7, -9]);
design.addPosition("e5", [-7, 9, 7, -9]);
design.addPosition("f5", [-7, 9, 7, -9]);
design.addPosition("g5", [-7, 9, 7, -9]);
design.addPosition("h5", [0, 0, 7, -9]);
design.addPosition("a4", [-7, 9, 0, 0]);
design.addPosition("b4", [-7, 9, 7, -9]);
design.addPosition("c4", [-7, 9, 7, -9]);
design.addPosition("d4", [-7, 9, 7, -9]);
design.addPosition("e4", [-7, 9, 7, -9]);
design.addPosition("f4", [-7, 9, 7, -9]);
design.addPosition("g4", [-7, 9, 7, -9]);
design.addPosition("h4", [0, 0, 7, -9]);
design.addPosition("a3", [-7, 9, 0, 0]);
design.addPosition("b3", [-7, 9, 7, -9]);
design.addPosition("c3", [-7, 9, 7, -9]);
design.addPosition("d3", [-7, 9, 7, -9]);
design.addPosition("e3", [-7, 9, 7, -9]);
design.addPosition("f3", [-7, 9, 7, -9]);
design.addPosition("g3", [-7, 9, 7, -9]);
design.addPosition("h3", [0, 0, 7, -9]);
design.addPosition("a2", [-7, 9, 0, 0]);
design.addPosition("b2", [-7, 9, 7, -9]);
design.addPosition("c2", [-7, 9, 7, -9]);
design.addPosition("d2", [-7, 9, 7, -9]);
design.addPosition("e2", [-7, 9, 7, -9]);
design.addPosition("f2", [-7, 9, 7, -9]);
design.addPosition("g2", [-7, 9, 7, -9]);
design.addPosition("h2", [0, 0, 7, -9]);
design.addPosition("a1", [-7, 0, 0, 0]);
design.addPosition("b1", [-7, 0, 0, -9]);
design.addPosition("c1", [-7, 0, 0, -9]);
design.addPosition("d1", [-7, 0, 0, -9]);
design.addPosition("e1", [-7, 0, 0, -9]);
design.addPosition("f1", [-7, 0, 0, -9]);
design.addPosition("g1", [-7, 0, 0, -9]);
design.addPosition("h1", [0, 0, 0, -9]);
design.addZone("promotion", 1, [1, 3, 5, 7]);
design.addZone("promotion", 2, [56, 58, 60, 62]);
design.addZone("best", 1, [26, 21]);
design.addZone("best", 2, [37, 42]);
design.addCommand(0, ZRF.FUNCTION, 24); // from
design.addCommand(0, ZRF.PARAM, 0); // $1
design.addCommand(0, ZRF.FUNCTION, 22); // navigate
design.addCommand(0, ZRF.FUNCTION, 2); // enemy?
design.addCommand(0, ZRF.FUNCTION, 20); // verify
design.addCommand(0, ZRF.FUNCTION, 26); // capture
design.addCommand(0, ZRF.PARAM, 1); // $2
design.addCommand(0, ZRF.FUNCTION, 22); // navigate
design.addCommand(0, ZRF.FUNCTION, 1); // empty?
design.addCommand(0, ZRF.FUNCTION, 20); // verify
design.addCommand(0, ZRF.IN_ZONE, 0); // promotion
design.addCommand(0, ZRF.FUNCTION, 0); // not
design.addCommand(0, ZRF.IF, 5);
design.addCommand(0, ZRF.PROMOTE, 1); // King
design.addCommand(0, ZRF.MODE, 2); // continue-type
design.addCommand(0, ZRF.FUNCTION, 25); // to
design.addCommand(0, ZRF.JUMP, 3);
design.addCommand(0, ZRF.MODE, 0); // jump-type
design.addCommand(0, ZRF.FUNCTION, 25); // to
design.addCommand(0, ZRF.FUNCTION, 28); // end
design.addCommand(1, ZRF.FUNCTION, 24); // from
design.addCommand(1, ZRF.PARAM, 0); // $1
design.addCommand(1, ZRF.FUNCTION, 22); // navigate
design.addCommand(1, ZRF.FUNCTION, 1); // empty?
design.addCommand(1, ZRF.FUNCTION, 20); // verify
design.addCommand(1, ZRF.IN_ZONE, 0); // promotion
design.addCommand(1, ZRF.FUNCTION, 0); // not
design.addCommand(1, ZRF.IF, 4);
design.addCommand(1, ZRF.PROMOTE, 1); // King
design.addCommand(1, ZRF.FUNCTION, 25); // to
design.addCommand(1, ZRF.JUMP, 2);
design.addCommand(1, ZRF.FUNCTION, 25); // to
design.addCommand(1, ZRF.FUNCTION, 28); // end
design.addCommand(2, ZRF.FUNCTION, 24); // from
design.addCommand(2, ZRF.PARAM, 0); // $1
design.addCommand(2, ZRF.FUNCTION, 22); // navigate
design.addCommand(2, ZRF.FUNCTION, 1); // empty?
design.addCommand(2, ZRF.FUNCTION, 0); // not
design.addCommand(2, ZRF.IF, 4);
design.addCommand(2, ZRF.PARAM, 1); // $2
design.addCommand(2, ZRF.FUNCTION, 22); // navigate
design.addCommand(2, ZRF.JUMP, -5);
design.addCommand(2, ZRF.FUNCTION, 2); // enemy?
design.addCommand(2, ZRF.FUNCTION, 20); // verify
design.addCommand(2, ZRF.PARAM, 2); // $3
design.addCommand(2, ZRF.FUNCTION, 22); // navigate
design.addCommand(2, ZRF.FUNCTION, 1); // empty?
design.addCommand(2, ZRF.FUNCTION, 0); // not
design.addCommand(2, ZRF.IF, 18);
design.addCommand(2, ZRF.FUNCTION, 6); // mark
design.addCommand(2, ZRF.FUNCTION, 1); // empty?
design.addCommand(2, ZRF.FUNCTION, 0); // not
design.addCommand(2, ZRF.IF, 5);
design.addCommand(2, ZRF.PARAM, 3); // $4
design.addCommand(2, ZRF.FUNCTION, 23); // opposite
design.addCommand(2, ZRF.FUNCTION, 22); // navigate
design.addCommand(2, ZRF.JUMP, -6);
design.addCommand(2, ZRF.FUNCTION, 26); // capture
design.addCommand(2, ZRF.FUNCTION, 7); // back
design.addCommand(2, ZRF.FORK, 4);
design.addCommand(2, ZRF.MODE, 2); // continue-type
design.addCommand(2, ZRF.FUNCTION, 25); // to
design.addCommand(2, ZRF.FUNCTION, 28); // end
design.addCommand(2, ZRF.PARAM, 4); // $5
design.addCommand(2, ZRF.FUNCTION, 22); // navigate
design.addCommand(2, ZRF.JUMP, -19);
design.addCommand(2, ZRF.FUNCTION, 28); // end
design.addCommand(3, ZRF.FUNCTION, 24); // from
design.addCommand(3, ZRF.PARAM, 0); // $1
design.addCommand(3, ZRF.FUNCTION, 22); // navigate
design.addCommand(3, ZRF.FUNCTION, 1); // empty?
design.addCommand(3, ZRF.FUNCTION, 0); // not
design.addCommand(3, ZRF.IF, 7);
design.addCommand(3, ZRF.PARAM, 1); // $2
design.addCommand(3, ZRF.FUNCTION, 22); // navigate
design.addCommand(3, ZRF.FUNCTION, 4); // last-from?
design.addCommand(3, ZRF.FUNCTION, 0); // not
design.addCommand(3, ZRF.FUNCTION, 20); // verify
design.addCommand(3, ZRF.JUMP, -8);
design.addCommand(3, ZRF.FUNCTION, 2); // enemy?
design.addCommand(3, ZRF.FUNCTION, 20); // verify
design.addCommand(3, ZRF.PARAM, 2); // $3
design.addCommand(3, ZRF.FUNCTION, 22); // navigate
design.addCommand(3, ZRF.FUNCTION, 1); // empty?
design.addCommand(3, ZRF.FUNCTION, 0); // not
design.addCommand(3, ZRF.IF, 18);
design.addCommand(3, ZRF.FUNCTION, 6); // mark
design.addCommand(3, ZRF.FUNCTION, 1); // empty?
design.addCommand(3, ZRF.FUNCTION, 0); // not
design.addCommand(3, ZRF.IF, 5);
design.addCommand(3, ZRF.PARAM, 3); // $4
design.addCommand(3, ZRF.FUNCTION, 23); // opposite
design.addCommand(3, ZRF.FUNCTION, 22); // navigate
design.addCommand(3, ZRF.JUMP, -6);
design.addCommand(3, ZRF.FUNCTION, 26); // capture
design.addCommand(3, ZRF.FUNCTION, 7); // back
design.addCommand(3, ZRF.FORK, 4);
design.addCommand(3, ZRF.MODE, 2); // continue-type
design.addCommand(3, ZRF.FUNCTION, 25); // to
design.addCommand(3, ZRF.FUNCTION, 28); // end
design.addCommand(3, ZRF.PARAM, 4); // $5
design.addCommand(3, ZRF.FUNCTION, 22); // navigate
design.addCommand(3, ZRF.JUMP, -19);
design.addCommand(3, ZRF.FUNCTION, 28); // end
design.addCommand(4, ZRF.FUNCTION, 24); // from
design.addCommand(4, ZRF.PARAM, 0); // $1
design.addCommand(4, ZRF.FUNCTION, 22); // navigate
design.addCommand(4, ZRF.FUNCTION, 1); // empty?
design.addCommand(4, ZRF.FUNCTION, 0); // not
design.addCommand(4, ZRF.IF, 7);
design.addCommand(4, ZRF.FORK, 3);
design.addCommand(4, ZRF.FUNCTION, 25); // to
design.addCommand(4, ZRF.FUNCTION, 28); // end
design.addCommand(4, ZRF.PARAM, 1); // $2
design.addCommand(4, ZRF.FUNCTION, 22); // navigate
design.addCommand(4, ZRF.JUMP, -8);
design.addCommand(4, ZRF.FUNCTION, 28); // end
design.addPriority(0); // jump-type
design.addPriority(1); // normal-type
design.addPiece("Man", 0, 20);
design.addMove(0, 0, [3, 3], 0);
design.addMove(0, 0, [0, 0], 0);
design.addMove(0, 0, [2, 2], 0);
design.addMove(0, 0, [1, 1], 0);
design.addMove(0, 1, [3], 1);
design.addMove(0, 1, [0], 1);
design.addPiece("King", 1, 100);
design.addMove(1, 2, [3, 3, 3, 3, 3], 0, 10);
design.addMove(1, 2, [0, 0, 0, 0, 0], 0, 10);
design.addMove(1, 2, [2, 2, 2, 2, 2], 0, 10);
design.addMove(1, 2, [1, 1, 1, 1, 1], 0, 10);
design.addMove(1, 3, [3, 3, 3, 3, 3], 2, 10);
design.addMove(1, 3, [0, 0, 0, 0, 0], 2, 10);
design.addMove(1, 3, [2, 2, 2, 2, 2], 2, 10);
design.addMove(1, 3, [1, 1, 1, 1, 1], 2, 10);
design.addMove(1, 4, [3, 3], 1, 10);
design.addMove(1, 4, [0, 0], 1, 10);
design.addMove(1, 4, [2, 2], 1, 10);
design.addMove(1, 4, [1, 1], 1, 10);
design.setup("White", "Man", 56);
design.setup("White", "Man", 58);
design.setup("White", "Man", 60);
design.setup("White", "Man", 62);
design.setup("White", "Man", 49);
design.setup("White", "Man", 51);
design.setup("White", "Man", 53);
design.setup("White", "Man", 55);
design.setup("White", "Man", 40);
design.setup("White", "Man", 42);
design.setup("White", "Man", 44);
design.setup("White", "Man", 46);
design.setup("Black", "Man", 1);
design.setup("Black", "Man", 3);
design.setup("Black", "Man", 5);
design.setup("Black", "Man", 7);
design.setup("Black", "Man", 8);
design.setup("Black", "Man", 10);
design.setup("Black", "Man", 12);
design.setup("Black", "Man", 14);
design.setup("Black", "Man", 17);
design.setup("Black", "Man", 19);
design.setup("Black", "Man", 21);
design.setup("Black", "Man", 23);
}
Dagaz.View.configure = function(view) {
view.defBoard("Board");
view.defPiece("WhiteMan", "White Man");
view.defPiece("BlackMan", "Black Man");
view.defPiece("WhiteKing", "White King");
view.defPiece("BlackKing", "Black King");
view.defPosition("a8", 2, 2, 50, 50);
view.defPosition("b8", 52, 2, 50, 50);
view.defPosition("c8", 102, 2, 50, 50);
view.defPosition("d8", 152, 2, 50, 50);
view.defPosition("e8", 202, 2, 50, 50);
view.defPosition("f8", 252, 2, 50, 50);
view.defPosition("g8", 302, 2, 50, 50);
view.defPosition("h8", 352, 2, 50, 50);
view.defPosition("a7", 2, 52, 50, 50);
view.defPosition("b7", 52, 52, 50, 50);
view.defPosition("c7", 102, 52, 50, 50);
view.defPosition("d7", 152, 52, 50, 50);
view.defPosition("e7", 202, 52, 50, 50);
view.defPosition("f7", 252, 52, 50, 50);
view.defPosition("g7", 302, 52, 50, 50);
view.defPosition("h7", 352, 52, 50, 50);
view.defPosition("a6", 2, 102, 50, 50);
view.defPosition("b6", 52, 102, 50, 50);
view.defPosition("c6", 102, 102, 50, 50);
view.defPosition("d6", 152, 102, 50, 50);
view.defPosition("e6", 202, 102, 50, 50);
view.defPosition("f6", 252, 102, 50, 50);
view.defPosition("g6", 302, 102, 50, 50);
view.defPosition("h6", 352, 102, 50, 50);
view.defPosition("a5", 2, 152, 50, 50);
view.defPosition("b5", 52, 152, 50, 50);
view.defPosition("c5", 102, 152, 50, 50);
view.defPosition("d5", 152, 152, 50, 50);
view.defPosition("e5", 202, 152, 50, 50);
view.defPosition("f5", 252, 152, 50, 50);
view.defPosition("g5", 302, 152, 50, 50);
view.defPosition("h5", 352, 152, 50, 50);
view.defPosition("a4", 2, 202, 50, 50);
view.defPosition("b4", 52, 202, 50, 50);
view.defPosition("c4", 102, 202, 50, 50);
view.defPosition("d4", 152, 202, 50, 50);
view.defPosition("e4", 202, 202, 50, 50);
view.defPosition("f4", 252, 202, 50, 50);
view.defPosition("g4", 302, 202, 50, 50);
view.defPosition("h4", 352, 202, 50, 50);
view.defPosition("a3", 2, 252, 50, 50);
view.defPosition("b3", 52, 252, 50, 50);
view.defPosition("c3", 102, 252, 50, 50);
view.defPosition("d3", 152, 252, 50, 50);
view.defPosition("e3", 202, 252, 50, 50);
view.defPosition("f3", 252, 252, 50, 50);
view.defPosition("g3", 302, 252, 50, 50);
view.defPosition("h3", 352, 252, 50, 50);
view.defPosition("a2", 2, 302, 50, 50);
view.defPosition("b2", 52, 302, 50, 50);
view.defPosition("c2", 102, 302, 50, 50);
view.defPosition("d2", 152, 302, 50, 50);
view.defPosition("e2", 202, 302, 50, 50);
view.defPosition("f2", 252, 302, 50, 50);
view.defPosition("g2", 302, 302, 50, 50);
view.defPosition("h2", 352, 302, 50, 50);
view.defPosition("a1", 2, 352, 50, 50);
view.defPosition("b1", 52, 352, 50, 50);
view.defPosition("c1", 102, 352, 50, 50);
view.defPosition("d1", 152, 352, 50, 50);
view.defPosition("e1", 202, 352, 50, 50);
view.defPosition("f1", 252, 352, 50, 50);
view.defPosition("g1", 302, 352, 50, 50);
view.defPosition("h1", 352, 352, 50, 50);
}
Да, читать это труднее, зато работает гораздо быстрее. Например, навигация — перемещение по доске, сводится к обычному арифметическому сложению. Есть ещё один момент. Некоторые правила, относящиеся не к перемещению отдельных фигур, а к игре в целом, невероятно трудно выразить на языке ZRF. Я и не пытаюсь. Для этого в Dagaz есть расширения. Например, в "Русских шашках", как и в большинстве других шашечных игр с «летающими» дамками, действует правило "Турецкого удара" — взятые фигуры убираются с доски только по завершении хода.
Вот как это делается при помощи расширения
(function() {
Dagaz.Model.deferredStrike = true;
var checkVersion = Dagaz.Model.checkVersion;
Dagaz.Model.checkVersion = function(design, name, value) {
if (name != "deferred-captures") {
checkVersion(design, name, value);
}
}
var CheckInvariants = Dagaz.Model.CheckInvariants;
Dagaz.Model.CheckInvariants = function(board) {
_.chain(board.moves)
.filter(function(move) {
return move.actions.length > 0;
})
.each(function(move) {
var mx = _.chain(move.actions)
.map(function(action) {
return action[3];
}).max().value();
var actions = [];
_.each(move.actions, function(action) {
var pn = action[3];
if ((action[0] !== null) && (action[1] === null)) {
pn = mx;
}
actions.push([ action[0], action[1], action[2], pn ]);
});
move.actions = actions;
});
CheckInvariants(board);
}
})();
После того как список ходов сгенерирован базовой логикой, закодированной ZRF-описанием игры, он передаётся подключенным расширениям, способным выполнять сложные проверки, запрещать отдельные ходы, обогащать их дополнительными действиями или изменять порядок выполняемых действий, как в случае с deferred-captures, упомянутым выше. Такой гибридный подход серьёзным образом расширяет описательные возможности системы, без необходимости усложнения базового метаязыка ZRF.
Проект Dagaz постоянно развивается. Так, совсем недавно, мне удалось разработать принципиально новый модуль представления, предоставляющий функциональность недостижимую в рамках проекта ранее. Теперь canvas можно разделять на независимые регионы, отрисовавать перекрывающиеся всплывающие окна, выполнять прокрутку изображения, более гибко управлять анимацией перемещения фигур и пр. В комплект к новому представлению, пришлось разработать и новый контроллер, выгодно отличающийся от старого большей читабельностью кода.
Вот как выглядит описание игры в новом стиле
ZRF = {
JUMP: 0,
IF: 1,
FORK: 2,
FUNCTION: 3,
IN_ZONE: 4,
FLAG: 5,
SET_FLAG: 6,
POS_FLAG: 7,
SET_POS_FLAG: 8,
ATTR: 9,
SET_ATTR: 10,
PROMOTE: 11,
MODE: 12,
ON_BOARD_DIR: 13,
ON_BOARD_POS: 14,
PARAM: 15,
LITERAL: 16,
VERIFY: 20
};
Dagaz.Model.BuildDesign = function(design) {
design.checkVersion("z2j", "2");
design.checkVersion("smart-moves", "false");
design.checkVersion("show-blink", "false");
design.checkVersion("show-hints", "false");
design.checkVersion("show-captures", "false");
design.checkVersion("dtc-extension", "extended");
var g = design.addGrid();
g.addScale("A/B/C/D/E/F/G/H"); g.addScale("8/7/6/5/4/3/2/1");
g.addDirection("n",[ 0, -1]); g.addDirection("nw",[-1, -1]);
g.addDirection("e",[ 1, 0]); g.addDirection("ne",[ 1, -1]);
g.addDirection("w",[-1, 0]); g.addDirection("sw",[-1, 1]);
g.addDirection("s",[ 0, 1]); g.addDirection("se",[ 1, 1]);
design.addPlayer("White", [6, 7, 4, 5, 2, 3, 0, 1]);
design.addPlayer("Black", [6, 5, 2, 7, 4, 1, 0, 3]);
g.addPositions();
design.addPosition(["RWP", "RWN", "RWB", "RWR", "RWQ", "RWK", "RBP", "RBN", "RBB", "RBR", "RBQ", "RBK", "UP", "DN"]);
design.addZone("last-rank", 1, [0, 1, 2, 3, 4, 5, 6, 7]);
design.addZone("last-rank", 2, [56, 57, 58, 59, 60, 61, 62, 63]);
design.addZone("third-rank", 1, [40, 41, 42, 43, 44, 45, 46, 47]);
design.addZone("third-rank", 2, [16, 17, 18, 19, 20, 21, 22, 23]);
design.addCommand(0, ZRF.FUNCTION, 24); // from
design.addCommand(0, ZRF.PARAM, 0); // $1
design.addCommand(0, ZRF.FUNCTION, 22); // navigate
design.addCommand(0, ZRF.IN_ZONE, 1); // third-rank
design.addCommand(0, ZRF.FUNCTION, 0); // not
design.addCommand(0, ZRF.IF, 11);
design.addCommand(0, ZRF.FUNCTION, 2); // enemy?
design.addCommand(0, ZRF.FUNCTION, 0); // not
design.addCommand(0, ZRF.FUNCTION, 20); // verify
design.addCommand(0, ZRF.FORK, 3);
design.addCommand(0, ZRF.FUNCTION, 25); // to
design.addCommand(0, ZRF.FUNCTION, 28); // end
design.addCommand(0, ZRF.FUNCTION, 1); // empty?
design.addCommand(0, ZRF.FUNCTION, 20); // verify
design.addCommand(0, ZRF.PARAM, 1); // $2
design.addCommand(0, ZRF.FUNCTION, 22); // navigate
design.addCommand(0, ZRF.FUNCTION, 2); // enemy?
design.addCommand(0, ZRF.FUNCTION, 0); // not
design.addCommand(0, ZRF.FUNCTION, 20); // verify
design.addCommand(0, ZRF.FUNCTION, 25); // to
design.addCommand(0, ZRF.FUNCTION, 28); // end
design.addCommand(1, ZRF.FUNCTION, 24); // from
design.addCommand(1, ZRF.PARAM, 0); // $1
design.addCommand(1, ZRF.FUNCTION, 22); // navigate
design.addCommand(1, ZRF.FUNCTION, 2); // enemy?
design.addCommand(1, ZRF.FUNCTION, 20); // verify
design.addCommand(1, ZRF.FUNCTION, 25); // to
design.addCommand(1, ZRF.FUNCTION, 28); // end
design.addCommand(2, ZRF.FUNCTION, 24); // from
design.addCommand(2, ZRF.PARAM, 0); // $1
design.addCommand(2, ZRF.FUNCTION, 22); // navigate
design.addCommand(2, ZRF.FUNCTION, 2); // enemy?
design.addCommand(2, ZRF.FUNCTION, 20); // verify
design.addCommand(2, ZRF.FUNCTION, 5); // last-to?
design.addCommand(2, ZRF.FUNCTION, 20); // verify
design.addCommand(2, ZRF.LITERAL, 0); // Pawn
design.addCommand(2, ZRF.FUNCTION, 10); // piece?
design.addCommand(2, ZRF.FUNCTION, 20); // verify
design.addCommand(2, ZRF.FUNCTION, 26); // capture
design.addCommand(2, ZRF.PARAM, 1); // $2
design.addCommand(2, ZRF.FUNCTION, 22); // navigate
design.addCommand(2, ZRF.FUNCTION, 6); // mark
design.addCommand(2, ZRF.PARAM, 2); // $3
design.addCommand(2, ZRF.FUNCTION, 22); // navigate
design.addCommand(2, ZRF.FUNCTION, 4); // last-from?
design.addCommand(2, ZRF.FUNCTION, 20); // verify
design.addCommand(2, ZRF.FUNCTION, 7); // back
design.addCommand(2, ZRF.FUNCTION, 25); // to
design.addCommand(2, ZRF.FUNCTION, 28); // end
design.addCommand(3, ZRF.FUNCTION, 24); // from
design.addCommand(3, ZRF.IN_ZONE, 0); // last-rank
design.addCommand(3, ZRF.FUNCTION, 20); // verify
design.addCommand(3, ZRF.PARAM, 0); // $1
design.addCommand(3, ZRF.FUNCTION, 21); // position
design.addCommand(3, ZRF.ON_BOARD_DIR, 7); // name
design.addCommand(3, ZRF.FUNCTION, 0); // not
design.addCommand(3, ZRF.IF, 9);
design.addCommand(3, ZRF.FUNCTION, 1); // empty?
design.addCommand(3, ZRF.IF, 4);
design.addCommand(3, ZRF.FORK, 3);
design.addCommand(3, ZRF.FUNCTION, 25); // to
design.addCommand(3, ZRF.FUNCTION, 28); // end
design.addCommand(3, ZRF.PARAM, 1); // $2
design.addCommand(3, ZRF.FUNCTION, 22); // navigate
design.addCommand(3, ZRF.JUMP, -10);
design.addCommand(3, ZRF.FUNCTION, 1); // empty?
design.addCommand(3, ZRF.FUNCTION, 0); // not
design.addCommand(3, ZRF.FUNCTION, 20); // verify
design.addCommand(3, ZRF.FUNCTION, 25); // to
design.addCommand(3, ZRF.FUNCTION, 28); // end
design.addCommand(4, ZRF.FUNCTION, 24); // from
design.addCommand(4, ZRF.PARAM, 0); // $1
design.addCommand(4, ZRF.FUNCTION, 22); // navigate
design.addCommand(4, ZRF.PARAM, 1); // $2
design.addCommand(4, ZRF.FUNCTION, 22); // navigate
design.addCommand(4, ZRF.FUNCTION, 25); // to
design.addCommand(4, ZRF.FUNCTION, 28); // end
design.addCommand(5, ZRF.FUNCTION, 24); // from
design.addCommand(5, ZRF.PARAM, 0); // $1
design.addCommand(5, ZRF.FUNCTION, 22); // navigate
design.addCommand(5, ZRF.FUNCTION, 1); // empty?
design.addCommand(5, ZRF.FUNCTION, 0); // not
design.addCommand(5, ZRF.IF, 7);
design.addCommand(5, ZRF.FORK, 3);
design.addCommand(5, ZRF.FUNCTION, 25); // to
design.addCommand(5, ZRF.FUNCTION, 28); // end
design.addCommand(5, ZRF.PARAM, 1); // $2
design.addCommand(5, ZRF.FUNCTION, 22); // navigate
design.addCommand(5, ZRF.JUMP, -8);
design.addCommand(5, ZRF.FUNCTION, 25); // to
design.addCommand(5, ZRF.FUNCTION, 28); // end
design.addCommand(6, ZRF.FUNCTION, 24); // from
design.addCommand(6, ZRF.PARAM, 0); // $1
design.addCommand(6, ZRF.FUNCTION, 22); // navigate
design.addCommand(6, ZRF.FUNCTION, 25); // to
design.addCommand(6, ZRF.FUNCTION, 28); // end
design.addCommand(7, ZRF.FUNCTION, 24); // from
design.addCommand(7, ZRF.PARAM, 0); // $1
design.addCommand(7, ZRF.FUNCTION, 22); // navigate
design.addCommand(7, ZRF.FUNCTION, 1); // empty?
design.addCommand(7, ZRF.FUNCTION, 20); // verify
design.addCommand(7, ZRF.PARAM, 1); // $2
design.addCommand(7, ZRF.FUNCTION, 22); // navigate
design.addCommand(7, ZRF.FUNCTION, 1); // empty?
design.addCommand(7, ZRF.FUNCTION, 20); // verify
design.addCommand(7, ZRF.FUNCTION, 25); // to
design.addCommand(7, ZRF.PARAM, 2); // $3
design.addCommand(7, ZRF.FUNCTION, 22); // navigate
design.addCommand(7, ZRF.FUNCTION, 3); // friend?
design.addCommand(7, ZRF.FUNCTION, 20); // verify
design.addCommand(7, ZRF.LITERAL, 3); // Rook
design.addCommand(7, ZRF.FUNCTION, 10); // piece?
design.addCommand(7, ZRF.FUNCTION, 20); // verify
design.addCommand(7, ZRF.FUNCTION, 24); // from
design.addCommand(7, ZRF.PARAM, 3); // $4
design.addCommand(7, ZRF.FUNCTION, 22); // navigate
design.addCommand(7, ZRF.PARAM, 4); // $5
design.addCommand(7, ZRF.FUNCTION, 22); // navigate
design.addCommand(7, ZRF.FUNCTION, 25); // to
design.addCommand(7, ZRF.FUNCTION, 28); // end
design.addCommand(8, ZRF.FUNCTION, 24); // from
design.addCommand(8, ZRF.PARAM, 0); // $1
design.addCommand(8, ZRF.FUNCTION, 22); // navigate
design.addCommand(8, ZRF.FUNCTION, 1); // empty?
design.addCommand(8, ZRF.FUNCTION, 20); // verify
design.addCommand(8, ZRF.PARAM, 1); // $2
design.addCommand(8, ZRF.FUNCTION, 22); // navigate
design.addCommand(8, ZRF.FUNCTION, 1); // empty?
design.addCommand(8, ZRF.FUNCTION, 20); // verify
design.addCommand(8, ZRF.FUNCTION, 25); // to
design.addCommand(8, ZRF.PARAM, 2); // $3
design.addCommand(8, ZRF.FUNCTION, 22); // navigate
design.addCommand(8, ZRF.FUNCTION, 1); // empty?
design.addCommand(8, ZRF.FUNCTION, 20); // verify
design.addCommand(8, ZRF.PARAM, 3); // $4
design.addCommand(8, ZRF.FUNCTION, 22); // navigate
design.addCommand(8, ZRF.FUNCTION, 3); // friend?
design.addCommand(8, ZRF.FUNCTION, 20); // verify
design.addCommand(8, ZRF.LITERAL, 3); // Rook
design.addCommand(8, ZRF.FUNCTION, 10); // piece?
design.addCommand(8, ZRF.FUNCTION, 20); // verify
design.addCommand(8, ZRF.FUNCTION, 24); // from
design.addCommand(8, ZRF.PARAM, 4); // $5
design.addCommand(8, ZRF.FUNCTION, 22); // navigate
design.addCommand(8, ZRF.PARAM, 5); // $6
design.addCommand(8, ZRF.FUNCTION, 22); // navigate
design.addCommand(8, ZRF.PARAM, 6); // $7
design.addCommand(8, ZRF.FUNCTION, 22); // navigate
design.addCommand(8, ZRF.FUNCTION, 25); // to
design.addCommand(8, ZRF.FUNCTION, 28); // end
design.addPiece("Pawn", 0);
design.addMove(0, 0, [0, 0], 0);
design.addMove(0, 1, [1], 0);
design.addMove(0, 1, [3], 0);
design.addMove(0, 2, [2, 0, 0], 0);
design.addMove(0, 2, [4, 0, 0], 0);
design.addPiece("Knight", 1);
design.addMove(1, 4, [0, 1], 0);
design.addMove(1, 4, [0, 3], 0);
design.addMove(1, 4, [6, 5], 0);
design.addMove(1, 4, [6, 7], 0);
design.addMove(1, 4, [2, 3], 0);
design.addMove(1, 4, [2, 7], 0);
design.addMove(1, 4, [4, 1], 0);
design.addMove(1, 4, [4, 5], 0);
design.addPiece("Bishop", 2);
design.addMove(2, 5, [1, 1], 0);
design.addMove(2, 5, [3, 3], 0);
design.addMove(2, 5, [5, 5], 0);
design.addMove(2, 5, [7, 7], 0);
design.addPiece("Rook", 3);
design.addMove(3, 5, [0, 0], 0);
design.addMove(3, 5, [2, 2], 0);
design.addMove(3, 5, [4, 4], 0);
design.addMove(3, 5, [6, 6], 0);
design.addPiece("Queen", 4);
design.addMove(4, 5, [0, 0], 0);
design.addMove(4, 5, [1, 1], 0);
design.addMove(4, 5, [2, 2], 0);
design.addMove(4, 5, [3, 3], 0);
design.addMove(4, 5, [4, 4], 0);
design.addMove(4, 5, [5, 5], 0);
design.addMove(4, 5, [6, 6], 0);
design.addMove(4, 5, [7, 7], 0);
design.addPiece("King", 5);
design.addMove(5, 6, [0], 0);
design.addMove(5, 6, [1], 0);
design.addMove(5, 6, [2], 0);
design.addMove(5, 6, [3], 0);
design.addMove(5, 6, [4], 0);
design.addMove(5, 6, [5], 0);
design.addMove(5, 6, [6], 0);
design.addMove(5, 6, [7], 0);
design.addMove(5, 7, [2, 2, 2, 4, 4], 1);
design.addMove(5, 8, [4, 4, 4, 4, 2, 2, 2], 1);
design.addPiece("PawnR", 6);
design.addPiece("KnightR", 7);
design.addPiece("BishopR", 8);
design.addPiece("RookR", 9);
design.addPiece("QueenR", 10);
design.addPiece("KingR", 11);
design.setup("White", "Pawn", ["A2", "B2", "C2", "D2", "E2", "F2", "G2", "H2"]);
design.setup("White", "Knight", ["B1", "G1"]);
design.setup("White", "Bishop", ["C1", "F1"]);
design.setup("White", "Rook", ["A1", "H1"]);
design.setup("White", "Queen", ["D1"]);
design.setup("White", "King", ["E1"]);
design.setup("White", "PawnR", ["RWP"]);
design.setup("White", "KnightR", ["RWN"]);
design.setup("White", "BishopR", ["RWB"]);
design.setup("White", "RookR", ["RWR"]);
design.setup("White", "QueenR", ["RWQ"]);
design.setup("White", "KingR", ["RWK"]);
design.setup("Black", "Pawn", ["A7", "B7", "C7", "D7", "E7", "F7", "G7", "H7"]);
design.setup("Black", "Knight", ["B8", "G8"]);
design.setup("Black", "Bishop", ["C8", "F8"]);
design.setup("Black", "Rook", ["A8", "H8"]);
design.setup("Black", "Queen", ["D8"]);
design.setup("Black", "King", ["E8"]);
design.setup("Black", "PawnR", ["RBP"]);
design.setup("Black", "KnightR", ["RBN"]);
design.setup("Black", "BishopR", ["RBB"]);
design.setup("Black", "RookR", ["RBR"]);
design.setup("Black", "QueenR", ["RBQ"]);
design.setup("Black", "KingR", ["RBK"]);
}
Dagaz.View.configure = function(view) {
var b = view.root.addRegion(70, 0, 540, 540);
b.addBoard("WhiteBoard", [0]);
b.addBoard("BlackBoard", [1]);
var g = b.addGrid(31, 31, 89, 89);
g.addScale("A/B/C/D/E/F/G/H", 60, 0);
g.addScale("8/7/6/5/4/3/2/1", 0, 60);
g.addTurns(0, [0]);
g.addTurns(1, [1]);
var r = view.root.addRegion(630, 0, 120, 540);
r.addBoard("rpw", [0]);
r.addBoard("rpb", [1]);
r.addPosition("RWP", 1, 41, 58, 58, [0]);
r.addPosition("RWN", 1, 121, 58, 58, [0]);
r.addPosition("RWB", 1, 201, 58, 58, [0]);
r.addPosition("RWR", 1, 281, 58, 58, [0]);
r.addPosition("RWQ", 1, 361, 58, 58, [0]);
r.addPosition("RWK", 1, 441, 58, 58, [0]);
r.addPosition("RBP", 61, 41, 58, 58, [0]);
r.addPosition("RBN", 61, 121, 58, 58, [0]);
r.addPosition("RBB", 61, 201, 58, 58, [0]);
r.addPosition("RBR", 61, 281, 58, 58, [0]);
r.addPosition("RBQ", 61, 361, 58, 58, [0]);
r.addPosition("RBK", 61, 441, 58, 58, [0]);
r.addPosition("RBP", 1, 41, 58, 58, [1]);
r.addPosition("RBN", 1, 121, 58, 58, [1]);
r.addPosition("RBB", 1, 201, 58, 58, [1]);
r.addPosition("RBR", 1, 281, 58, 58, [1]);
r.addPosition("RBQ", 1, 361, 58, 58, [1]);
r.addPosition("RBK", 1, 441, 58, 58, [1]);
r.addPosition("RWP", 61, 41, 58, 58, [1]);
r.addPosition("RWN", 61, 121, 58, 58, [1]);
r.addPosition("RWB", 61, 201, 58, 58, [1]);
r.addPosition("RWR", 61, 281, 58, 58, [1]);
r.addPosition("RWQ", 61, 361, 58, 58, [1]);
r.addPosition("RWK", 61, 441, 58, 58, [1]);
var d = view.root.addRegion(770, 0, 120, 540, true, undefined, Dagaz.Model.drawDivision, Dagaz.Controller.eventDivision);
d.addBoard("div");
d.addPosition("UP", 1, 1, 120, 30);
d.addPosition("DN", 1, 510, 120, 30);
view.addPiece(["WhitePawn", "WhiteKnight", "WhiteBishop", "WhiteRook", "WhiteQueen", "WhiteKing"], Dagaz.View.drawPiece);
view.addPiece(["BlackPawn", "BlackKnight", "BlackBishop", "BlackRook", "BlackQueen", "BlackKing"], Dagaz.View.drawPiece);
view.addPiece(["WhitePawnR", "BlackPawnR", "WhiteKnightR", "BlackKnightR", "WhiteBishopR", "BlackBishopR", "WhiteRookR", "BlackRookR", "WhiteQueenR", "BlackQueenR", "WhiteKingR", "BlackKingR"], Dagaz.View.drawRes);
view.addPiece(["PawnWhite", "SmallPawnWhite", "KnightWhite", "SmallKnightWhite", "BishopWhite", "SmallBishopWhite", "RookWhite", "SmallRookWhite", "QueenWhite", "SmallQueenWhite", "KingWhite", "SmallKingWhite"]);
view.addPiece(["PawnBlack", "SmallPawnBlack", "KnightBlack", "SmallKnightBlack", "BishopBlack", "SmallBishopBlack", "RookBlack", "SmallRookBlack", "QueenBlack", "SmallQueenBlack", "KingBlack", "SmallKingBlack"]);
view.addPiece(["two", "d0", "d1", "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9", "plus", "minus", "question"]);
view.addPiece(["db", "dw", "ub", "uw"]);
}
Сама модель тоже меняется. Все эти длинные и непонятные столбцы команд стековой машины могут скоро уйти в прошлое.
Описание игры станет более читаемым
(function() {
var step = function(ctx, params) {
if (ctx.go(params, 0) && !ctx.isFriend()) {
ctx.end();
}
}
var pawnShift = function(ctx, params) {
if (ctx.go(params, 0) && ctx.isEmpty()) {
if (ctx.inZone(0)) {
ctx.promote(4);
}
ctx.end();
}
}
var pawnLeap = function(ctx, params) {
if (ctx.go(params, 0) && ctx.isEnemy()) {
if (ctx.inZone(0)) {
ctx.promote(4);
}
ctx.end();
}
}
var pawnJump = function(ctx, params) {
if (ctx.go(params, 0) &&
ctx.isEmpty() &&
ctx.inZone(1) &&
ctx.go(params, 0) &&
ctx.isEmpty()) {
ctx.end();
}
}
var enPassant = function(ctx, params) {
if (ctx.go(params, 0) &&
ctx.isEnemy() &&
ctx.isPiece(0)) {
ctx.capture();
if (ctx.go(params, 1)) {
ctx.put();
if (ctx.go(params, 1) &&
ctx.isLastFrom()) {
ctx.end();
}
}
}
}
var jump = function(ctx, params) {
if (ctx.go(params, 0) &&
ctx.go(params, 1) &&
!ctx.isFriend()) {
ctx.end();
}
}
var slide = function(ctx, params) {
while (ctx.go(params, 0)) {
if (ctx.isFriend()) break;
ctx.end();
if (!ctx.isEmpty()) break;
}
}
var O_O = function(ctx, params) {
if (ctx.go(params, 0) &&
ctx.isEmpty() &&
ctx.go(params, 0) &&
ctx.isEmpty()) {
ctx.put();
if (ctx.go(params, 0) &&
ctx.isFriend() &&
ctx.isPiece(1)) {
ctx.take();
if (ctx.go(params, 1) &&
ctx.go(params, 1)) {
ctx.end();
}
}
}
}
var O_O_O = function(ctx, params) {
if (ctx.go(params, 0) &&
ctx.isEmpty() &&
ctx.go(params, 0) &&
ctx.isEmpty()) {
ctx.put();
if (ctx.go(params, 0) &&
ctx.isEmpty() &&
ctx.go(params, 0) &&
ctx.isFriend() &&
ctx.isPiece(1)) {
ctx.take();
if (ctx.go(params, 1) &&
ctx.go(params, 1) &&
ctx.go(params, 1)) {
ctx.end();
}
}
}
}
games.model.BuildDesign = function(design) {
design.checkVersion("smart-moves", "false");
design.addDirection("w"); // 0
design.addDirection("e"); // 1
design.addDirection("s"); // 2
design.addDirection("ne"); // 3
design.addDirection("n"); // 4
design.addDirection("se"); // 5
design.addDirection("sw"); // 6
design.addDirection("nw"); // 7
design.addPlayer("White", [1, 0, 4, 6, 2, 7, 3, 5]);
design.addPlayer("Black", [0, 1, 4, 5, 2, 3, 7, 6]);
design.addPosition("a8", [0, 1, 8, 0, 0, 9, 0, 0]);
design.addPosition("b8", [-1, 1, 8, 0, 0, 9, 7, 0]);
design.addPosition("c8", [-1, 1, 8, 0, 0, 9, 7, 0]);
design.addPosition("d8", [-1, 1, 8, 0, 0, 9, 7, 0]);
design.addPosition("e8", [-1, 1, 8, 0, 0, 9, 7, 0]);
design.addPosition("f8", [-1, 1, 8, 0, 0, 9, 7, 0]);
design.addPosition("g8", [-1, 1, 8, 0, 0, 9, 7, 0]);
design.addPosition("h8", [-1, 0, 8, 0, 0, 0, 7, 0]);
design.addPosition("a7", [0, 1, 8, -7, -8, 9, 0, 0]);
design.addPosition("b7", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("c7", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("d7", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("e7", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("f7", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("g7", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("h7", [-1, 0, 8, 0, -8, 0, 7, -9]);
design.addPosition("a6", [0, 1, 8, -7, -8, 9, 0, 0]);
design.addPosition("b6", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("c6", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("d6", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("e6", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("f6", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("g6", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("h6", [-1, 0, 8, 0, -8, 0, 7, -9]);
design.addPosition("a5", [0, 1, 8, -7, -8, 9, 0, 0]);
design.addPosition("b5", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("c5", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("d5", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("e5", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("f5", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("g5", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("h5", [-1, 0, 8, 0, -8, 0, 7, -9]);
design.addPosition("a4", [0, 1, 8, -7, -8, 9, 0, 0]);
design.addPosition("b4", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("c4", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("d4", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("e4", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("f4", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("g4", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("h4", [-1, 0, 8, 0, -8, 0, 7, -9]);
design.addPosition("a3", [0, 1, 8, -7, -8, 9, 0, 0]);
design.addPosition("b3", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("c3", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("d3", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("e3", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("f3", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("g3", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("h3", [-1, 0, 8, 0, -8, 0, 7, -9]);
design.addPosition("a2", [0, 1, 8, -7, -8, 9, 0, 0]);
design.addPosition("b2", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("c2", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("d2", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("e2", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("f2", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("g2", [-1, 1, 8, -7, -8, 9, 7, -9]);
design.addPosition("h2", [-1, 0, 8, 0, -8, 0, 7, -9]);
design.addPosition("a1", [0, 1, 0, -7, -8, 0, 0, 0]);
design.addPosition("b1", [-1, 1, 0, -7, -8, 0, 0, -9]);
design.addPosition("c1", [-1, 1, 0, -7, -8, 0, 0, -9]);
design.addPosition("d1", [-1, 1, 0, -7, -8, 0, 0, -9]);
design.addPosition("e1", [-1, 1, 0, -7, -8, 0, 0, -9]);
design.addPosition("f1", [-1, 1, 0, -7, -8, 0, 0, -9]);
design.addPosition("g1", [-1, 1, 0, -7, -8, 0, 0, -9]);
design.addPosition("h1", [-1, 0, 0, 0, -8, 0, 0, -9]);
design.addZone("last-rank", 1, ["a8", "b8", "c8", "d8", "e8", "f8", "g8", "h8"]);
design.addZone("last-rank", 2, ["a1", "b1", "c1", "d1", "e1", "f1", "g1", "h1"]);
design.addZone("third-rank", 1, ["a3", "b3", "c3", "d3", "e3", "f3", "g3", "h3"]);
design.addZone("third-rank", 2, ["a6", "b6", "c6", "d6", "e6", "f6", "g6", "h6"]);
design.addPiece("Pawn", 0, 2);
design.addMove(0, pawnShift, [4], 0);
design.addMove(0, pawnJump, [4], 0);
design.addMove(0, pawnLeap, [7], 0);
design.addMove(0, pawnLeap, [3], 0);
design.addMove(0, enPassant, [1, 4], 0);
design.addMove(0, enPassant, [0, 4], 0);
design.addPiece("Rook", 1, 10);
design.addMove(1, slide, [4], 0);
design.addMove(1, slide, [2], 0);
design.addMove(1, slide, [0], 0);
design.addMove(1, slide, [1], 0);
design.addPiece("Knight", 2, 6);
design.addMove(2, jump, [4, 7], 0);
design.addMove(2, jump, [4, 3], 0);
design.addMove(2, jump, [2, 6], 0);
design.addMove(2, jump, [2, 5], 0);
design.addMove(2, jump, [0, 7], 0);
design.addMove(2, jump, [0, 6], 0);
design.addMove(2, jump, [1, 3], 0);
design.addMove(2, jump, [1, 5], 0);
design.addPiece("Bishop", 3, 6);
design.addMove(3, slide, [7], 0);
design.addMove(3, slide, [6], 0);
design.addMove(3, slide, [3], 0);
design.addMove(3, slide, [5], 0);
design.addPiece("Queen", 4, 18);
design.addMove(4, slide, [4], 0);
design.addMove(4, slide, [2], 0);
design.addMove(4, slide, [0], 0);
design.addMove(4, slide, [1], 0);
design.addMove(4, slide, [7], 0);
design.addMove(4, slide, [6], 0);
design.addMove(4, slide, [3], 0);
design.addMove(4, slide, [5], 0);
design.addPiece("King", 5, 1000);
design.addMove(5, step, [4], 0);
design.addMove(5, step, [2], 0);
design.addMove(5, step, [0], 0);
design.addMove(5, step, [1], 0);
design.addMove(5, step, [7], 0);
design.addMove(5, step, [6], 0);
design.addMove(5, step, [3], 0);
design.addMove(5, step, [5], 0);
design.addMove(5, O_O, [1, 0], 1);
design.addMove(5, O_O_O, [0, 1], 1);
design.setup("White", "Pawn", ["a2", "b2", "c2", "d2", "e2", "f2", "g2", "h2"]);
design.setup("White", "Rook", ["a1", "h1"]);
design.setup("White", "Knight", ["b1", "g1"]);
design.setup("White", "Bishop", ["c1", "f1"]);
design.setup("White", "Queen", ["d1"]);
design.setup("White", "King", ["e1"]);
design.setup("Black", "Pawn", ["a7", "b7", "c7", "d7", "e7", "f7", "g7", "h7"]);
design.setup("Black", "Rook", ["a8", "h8"]);
design.setup("Black", "Knight", ["b8", "g8"]);
design.setup("Black", "Bishop", ["c8", "f8"]);
design.setup("Black", "Queen", ["d8"]);
design.setup("Black", "King", ["e8"]);
}
})();
Более того, такой подход позволит полностью отказаться как от Z2J (утилиты преобразования ZRF в JavaScript), так и от самого ZRF. Описание игры можно будет писать руками, сразу на JavaScript. Всё это позволит разрабатывать более сложные игры, ещё быстрее чем раньше.
У меня были игры. Много игр, но настольные игры, за очень редким исключением — это то, во что интересно играть с кем-то. В этом и заключалась проблема. Для некоторых игр мне удалось сделать ботов, но играли они слабо, а в том, что касается Шахмат или Го, я не питал никаких иллюзий по поводу того, что мне удастся разработать бота, играть с которым будет интересно. Отчасти, это связано с низкой производительностью разработанной мной универсальной модели, но по большей части, я просто не очень силён в разработке ботов для настольных игр.
Радикальным решением стала бы разработка сетевой версии Dagaz, при помощи которой пользователи смогли бы играть друг с другом через Internet, но это подразумевало платный хостинг с полноценным бакендом и базой данных. Поскольку речь шла о хобби-проекте, я был не готов вкладывать в это деньги.
К счастью, появилась другая возможность
Дело в том, что моё начальство уже давно в курсе моего увлечения настольными играми. Иногда я провожу внутренние митапы на эту и другие темы, а недавно мы организовали небольшой, но самый настоящий турнир, по разработке ботов для игры в "Atari Go" среди сотрудников компании. И вот, буквально на днях, отдел маркетинга выступил с предложением о разработке платформы для повышения лояльности среди клиентов компании. Для меня это выглядело как публикация в Интернете нескольких, отобранных отделом маркетинга, игр и генерация «бонусов», для получения скидок, при победах игроков. Разумеется, меня такое предложение очень порадовало.
Так, волей-неволей, мне пришлось взяться за бакенд. Я выбрал Nest. Во первых, давно хотел попробовать TypeScript. Кроме того, все эти аннотации, максимально похожи на тот кровавый энтерпрайз, к которому я привык. Под капотом у Nest-а всё тот же Express, но код записывается лаконичнее — это удобно. Swagger прикручивается к проекту при помощи нескольких строк кода, а также всё новых и новых аннотаций. После этого, с REST API можно ознакамливаться так или даже так.
Отдельно стоит рассказать о базе данных. Вообще, я из тех людей, кому гораздо проще сразу писать SQL запрос, чем разбираться в премудростях какой-нибудь ORM. Но для этого проекта я сделал исключение. Просто потому что TypeORM создаёт все задекларированные таблички автоматически, а мне остаётся просто создать на сервере пустую базу данных. Разумеется, я выбрал PostgreSQL, но думаю, что переключиться на MySQL, при необходимости, будет не слишком сложно.
Подробнее про базу данных
Для меня, игровой сервер — это прежде всего база данных. В ней хранятся учётные данные пользователей, описания игр, данные по игровым сессиям, все выполненные игроками ходы, в общем, всё что нужно для организации игры по сети.
Параметры подключения к серверу конфигурируются в файле ormconfig.json в корне проекта. Помимо понятных вещей, типа логина и пароля, там определяется путь к каталогу, в котором описываются сущности базы данных (entities) и это именно то, за что мне нравится TypeORM. Дело в том, что мне нет необходимости писать SQL-скрипты для создания таблиц в базе данных. Достаточно создать по одному TypeScript-описанию на каждую таблицу.
После этого, структура таблиц будет автоматически синхронизироваться с описанием сущностей, при каждом запуске сервера (если конечно флаг synchronize в ormconfig.json установлен в true). Возможности аннотаций вполне достаточны для описания типа и размера столбцов, ограничений целостности, значений по умолчанию, в общем, всех необходимых деталей хранения данных.
Но структура таблиц — это только половина дела. Надо как-то заполнить справочники начальными значениями. Для этого, в TypeORM используются миграции. Миграция — это просто пара SQL-скриптов, используемых для наката и отката изменений в базе данных.
Выполняется миграция с консоли, после того как все таблицы созданы при первом запуске сервера. Поскольку в нашем проекте используется TypeScript, команда для запуска довольно заковыристая (и разумеется, предварительно должен быть установлен командный интерфейс TypeScript-а).
Все выполненные миграции фиксируются в специальной табличке в базе данных (по умолчанию, она называется migrations) и при необходимости могут быть отменены командой 'typeorm migration:revert'.
Параметры подключения к серверу конфигурируются в файле ormconfig.json в корне проекта. Помимо понятных вещей, типа логина и пароля, там определяется путь к каталогу, в котором описываются сущности базы данных (entities) и это именно то, за что мне нравится TypeORM. Дело в том, что мне нет необходимости писать SQL-скрипты для создания таблиц в базе данных. Достаточно создать по одному TypeScript-описанию на каждую таблицу.
Например такому
import { Entity, PrimaryGeneratedColumn, Column, Index, ManyToOne, JoinColumn, Check, Unique } from "typeorm";
import { users } from "./users";
import { game_sessions } from "./game_sessions";
import { game_results } from "./game_results";
@Entity()
@Unique(["session_id", "player_num"])
@Check(`"is_ai" in (0, 1)`)
export class user_games {
@PrimaryGeneratedColumn()
id: number;
@Index()
@Column({ nullable: false })
user_id: number;
@ManyToOne(type => users)
@JoinColumn({ name: "user_id" })
user: users;
@Index()
@Column({ nullable: false })
session_id: number;
@ManyToOne(type => game_sessions)
@JoinColumn({ name: "session_id" })
session: game_sessions;
@Index()
@Column({ nullable: true })
result_id: number;
@ManyToOne(type => game_results)
@JoinColumn({ name: "result_id" })
result: game_results;
@Column({ nullable: true })
score: number;
@Column()
player_num: number;
@Column({ default: 0 })
is_ai: number;
@Column({ nullable: true })
time_limit: number;
}
После этого, структура таблиц будет автоматически синхронизироваться с описанием сущностей, при каждом запуске сервера (если конечно флаг synchronize в ormconfig.json установлен в true). Возможности аннотаций вполне достаточны для описания типа и размера столбцов, ограничений целостности, значений по умолчанию, в общем, всех необходимых деталей хранения данных.
Но структура таблиц — это только половина дела. Надо как-то заполнить справочники начальными значениями. Для этого, в TypeORM используются миграции. Миграция — это просто пара SQL-скриптов, используемых для наката и отката изменений в базе данных.
Что-то в таком роде
import {MigrationInterface, QueryRunner} from "typeorm";
export class init1592210976213 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`insert into contact_types(id, name) values(1, 'EMail')`);
await queryRunner.query(`insert into token_types(id, name) values(1, 'Access')`);
await queryRunner.query(`insert into token_types(id, name) values(2, 'Refresh')`);
await queryRunner.query(`insert into game_results(id, name) values(1, 'Won')`);
await queryRunner.query(`insert into game_results(id, name) values(2, 'Lose')`);
await queryRunner.query(`insert into game_results(id, name) values(3, 'Draw')`);
await queryRunner.query(`insert into users(is_admin, login, pass) values(1, 'root', 'root')`);
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`delete from users`);
await queryRunner.query(`delete from game_statuses`);
await queryRunner.query(`delete from game_results`);
await queryRunner.query(`delete from token_types`);
await queryRunner.query(`delete from contact_types`);
await queryRunner.query(`delete from realms`);
}
}
Выполняется миграция с консоли, после того как все таблицы созданы при первом запуске сервера. Поскольку в нашем проекте используется TypeScript, команда для запуска довольно заковыристая (и разумеется, предварительно должен быть установлен командный интерфейс TypeScript-а).
npm install -g ts-node
ts-node ./node_modules/typeorm/cli.js migration:run
Все выполненные миграции фиксируются в специальной табличке в базе данных (по умолчанию, она называется migrations) и при необходимости могут быть отменены командой 'typeorm migration:revert'.
Следующая важная вещь из коробки — это аутентификация. Для того чтобы не гонять логин и пароль в открытом виде в каждом запросе, используем JWT. С точки зрения Nest-а, проверка авторизации пользователя, при выполнении запроса, сводится к использованию той или иной стратегии (все детали выполняемых проверок, разумеется, вынесены в сервис). Сами стратегии обёрнуты в guard-ы и могут использоваться вполне декларативно.
@UseGuards(LocalAuthGuard)
@Post('api/auth/login')
async login(@Request() req) {
const device: string = req.headers['x-forwarded-for'] ||
req.connection.remoteAddress;
const r = await this.authService.login(req.user, device);
return r;
}
Здесь первая аннотация говорит о том, что для получения JWT используется базовая аутентификация. На выходе (если всё пойдёт успешно), получаем токен, который прикрепляем как bearer к запросам, использующим JWT-аутентификацию. Вторая аннотация определяет URL по которому должен выполняться POST-запрос и с этим связан один очень интересный момент.
Суета вокруг роутинга
Дело в том, что помимо бакенда, обычно бывает ещё и фронтенд. Например, на Angular-е. В моём случае, это небольшая страничка, для активации операторами бонусов, особого интереса не представляющая. Важно здесь то, что фронтенд приложения умеют показывать различные странички, в зависимости от URL (это называется роутингом). То есть, у фронтенда роутинг свой, а у бакенда свой. Вопрос в том, как их подружить?
Проще всего, это делается в режиме отладки. Создаём файл с настройками, запускаем фронтенд командой 'ng serve --proxy-config proxy.conf.json' и отлаживаемся на здоровье (при условии, что бакенд тоже запущен, конечно). Все запросы, начинающиеся с префикса '/api' будут проксироваться на бакенд. Но держать два открытых TCP-порта в релизной сборке немного неудобно. Вот как это делается в Nest-е:
Для начала, учим бакенд отдавать статический контент. Корень сайта отображается на каталог public, то есть, файлы Nest будет искать именно там. Но что произойдёт, если файл найти не удастся? На этот случай, в проект добавлен перехватчик frontend.catch.ts, пересылающий все непонятные запросы в 'public/index.html', то есть собранному фронтенду с сохранением URL. Это немножко трудно уложить в голове, но именно благодаря этому работает SPA Angular-а.
Проще всего, это делается в режиме отладки. Создаём файл с настройками, запускаем фронтенд командой 'ng serve --proxy-config proxy.conf.json' и отлаживаемся на здоровье (при условии, что бакенд тоже запущен, конечно). Все запросы, начинающиеся с префикса '/api' будут проксироваться на бакенд. Но держать два открытых TCP-порта в релизной сборке немного неудобно. Вот как это делается в Nest-е:
main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { NestExpressApplication } from "@nestjs/platform-express";
import { join } from 'path';
import { NotFoundExceptionFilter } from './frontend.catch';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(
AppModule,
);
app.useStaticAssets(join(__dirname, '/../public'), {prefix: '/'});
app.setBaseViewsDir(join(__dirname, '/../public'));
app.useGlobalFilters(new NotFoundExceptionFilter());
app.enableCors();
await app.listen(3000);
}
bootstrap();
frontend.catch.ts
import { NotFoundException, Catch, ExceptionFilter, ArgumentsHost, HttpException } from "@nestjs/common";
import { resolve } from 'path';
@Catch(NotFoundException)
export class NotFoundExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
response.sendFile(resolve('public/index.html'));
}
}
Для начала, учим бакенд отдавать статический контент. Корень сайта отображается на каталог public, то есть, файлы Nest будет искать именно там. Но что произойдёт, если файл найти не удастся? На этот случай, в проект добавлен перехватчик frontend.catch.ts, пересылающий все непонятные запросы в 'public/index.html', то есть собранному фронтенду с сохранением URL. Это немножко трудно уложить в голове, но именно благодаря этому работает SPA Angular-а.
Теперь, когда всё заработало, осталось разместить это на какой-то живой машине, доступной из Internet. Но перед этим, стоит озаботиться переходом от HTTP-протокола к HTTPS (очевидно, что передача логинов и паролей в открытом виде, при выполнении базовой аутентификации — это не самая удачная мысль). В принципе, Nest это тоже умеет, но гораздо проще, в смысле администрирования, установить на тот же сервер proxy Nginx. Конфигурация выглядит примерно вот так:
server {
listen 80;
listen 443 ssl;
ssl_certificate /etc/nginx/ssl/certificate.crt;
ssl_certificate_key /etc/nginx/ssl/private.key;
ssl_verify_client off;
server_name games.dtco.ru;
location / {
index index.html;
proxy_pass http://localhost:3000/;
}
}
Здесь, правда, не обошлось без ложки дёгтя
Для того чтобы сервер работал сам по себе, в Linux системах его желательно оформлять как сервис. В моём случае, это вылилось вот в такой вот dagaz.service файл:
Замечаете неладное? Да-да, я запускаю его командой 'npm start', то есть, при каждом запуске собираю заново из TypeScript-а. Что меня заставило пойти на это? Начну издалека. В идеальном мире, мне следовало бы собрать сервер один раз и после этого запускать уже собранный. Примерно так:
Но я использую TypeORM, чтобы работать с базой данных. Для начала, мне не удалось прикрутить ormconfig.json, из которого берутся настройки подключения к собранному серверу. Само по себе, это не беда. Есть другой способ для передачи настроек.
Здесь есть нюанс. В entities я передаю две строки (это пути, по которым TypeORM ищет описания сущностей базы данных). Первая используется в режиме отладки, а по второй поиск идёт уже в собранной версии. В принципе, в каждой из них можно было оставить по одному расширению, просто я хотел показать и эту возможность. Файл ormconfig.json, после выполненных изменений, конечно убираем, он больше не нужен.
Ещё один добрый совет. Устанавливайте synchronize в false всегда, если только не собираетесь изменять структуру таблиц в базе данных. По причинам, о которых скажу чуть позже, со сборкой я возился на немного устаревшей версии проекта. Этого вполне хватило, чтобы снести в базе данных «ненужные» с точки зрения TypeORM поля. После чего, всё естественно сломалось. Не повторяйте моих ошибок.
Собранная версия запускается заметно быстрее (поскольку не надо каждый раз пересобирать TypeScript), но увы, воспользоваться этим я не смог.
При попытке сборки, ошибки компиляции летят откуда-то из недр @nestjs/swagger. Обновление пакета и TypeScript-а до последних версий не помогло. Если кто-то знает, как с этим бороться, скажите мне, я заинтригован. А сборка, о которой я писал выше, отлаживалась на копии проекта с полностью вырезанным Swagger-ом (и немного устаревшей, да). В результате, я не захотел отказываться от Swagger-а и запускаю сервис командой 'npm start'.
[Unit]
Description=Dagaz Server
After=network-online.target
[Service]
Restart=on-failure
Type=simple
User=dagaz
WorkingDirectory=/home/dagaz/Downloads/DagazServer-master
ExecStart=/usr/bin/npm start
[Install]
WantedBy=multi-user.target
Замечаете неладное? Да-да, я запускаю его командой 'npm start', то есть, при каждом запуске собираю заново из TypeScript-а. Что меня заставило пойти на это? Начну издалека. В идеальном мире, мне следовало бы собрать сервер один раз и после этого запускать уже собранный. Примерно так:
nest build
cd build
node main.js
Но я использую TypeORM, чтобы работать с базой данных. Для начала, мне не удалось прикрутить ormconfig.json, из которого берутся настройки подключения к собранному серверу. Само по себе, это не беда. Есть другой способ для передачи настроек.
database.provider.ts
import { createConnection } from 'typeorm';
import { dbOptions } from '../app.config';
export const databaseProviders = [
{
provide: 'DATABASE_CONNECTION',
useFactory: async () => await createConnection(
dbOptions
),
},
];
app.config.ts
import "reflect-metadata";
import { ConnectionOptions } from "typeorm";
export let dbOptions: ConnectionOptions = {
type: "postgres",
host: "127.0.0.1",
port: 5433,
username: "dagaz",
password: "dagaz",
database: "dagaz",
migrationsTableName: "migrations",
synchronize: false,
logging: false,
entities: [
"src/entity/**/*.{ts,js}",
"./entity/**/*.{ts,js}"
]
}
Здесь есть нюанс. В entities я передаю две строки (это пути, по которым TypeORM ищет описания сущностей базы данных). Первая используется в режиме отладки, а по второй поиск идёт уже в собранной версии. В принципе, в каждой из них можно было оставить по одному расширению, просто я хотел показать и эту возможность. Файл ormconfig.json, после выполненных изменений, конечно убираем, он больше не нужен.
Ещё один добрый совет. Устанавливайте synchronize в false всегда, если только не собираетесь изменять структуру таблиц в базе данных. По причинам, о которых скажу чуть позже, со сборкой я возился на немного устаревшей версии проекта. Этого вполне хватило, чтобы снести в базе данных «ненужные» с точки зрения TypeORM поля. После чего, всё естественно сломалось. Не повторяйте моих ошибок.
Собранная версия запускается заметно быстрее (поскольку не надо каждый раз пересобирать TypeScript), но увы, воспользоваться этим я не смог.
Возникла ещё одна проблема
C:\Users\User\dagaz-server>npm run build
> dagaz-server@0.0.1 prebuild C:\Users\User\dagaz-server
> rimraf dist
> dagaz-server@0.0.1 build C:\Users\User\dagaz-server
> tsc -p tsconfig.build.json
node_modules/@nestjs/swagger/dist/type-helpers/omit-type.helper.d.ts:2:90 - error TS1005: ',' expected.
2 export declare function OmitType<T, K extends keyof T>(classRef: Type<T>, keys: readonly K[]): Type<Omit<T, typeof keys[number]>>;
~
node_modules/@nestjs/swagger/dist/type-helpers/omit-type.helper.d.ts:2:91 - error TS1005: ',' expected.
2 export declare function OmitType<T, K extends keyof T>(classRef: Type<T>, keys: readonly K[]): Type<Omit<T, typeof keys[number]>>;
~
node_modules/@nestjs/swagger/dist/type-helpers/pick-type.helper.d.ts:2:90 - error TS1005: ',' expected.
2 export declare function PickType<T, K extends keyof T>(classRef: Type<T>, keys: readonly K[]): Type<Pick<T, typeof keys[number]>>;
~
node_modules/@nestjs/swagger/dist/type-helpers/pick-type.helper.d.ts:2:91 - error TS1005: ',' expected.
2 export declare function PickType<T, K extends keyof T>(classRef: Type<T>, keys: readonly K[]): Type<Pick<T, typeof keys[number]>>;
Found 4 errors.
npm ERR! code ELIFECYCLE
npm ERR! errno 2
npm ERR! dagaz-server@0.0.1 build: `tsc -p tsconfig.build.json`
npm ERR! Exit status 2
npm ERR!
npm ERR! Failed at the dagaz-server@0.0.1 build script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.
npm ERR! A complete log of this run can be found in:
npm ERR! C:\Users\User\AppData\Roaming\npm-cache\_logs\2020-10-21T09_33_58_253Z-debug.log
При попытке сборки, ошибки компиляции летят откуда-то из недр @nestjs/swagger. Обновление пакета и TypeScript-а до последних версий не помогло. Если кто-то знает, как с этим бороться, скажите мне, я заинтригован. А сборка, о которой я писал выше, отлаживалась на копии проекта с полностью вырезанным Swagger-ом (и немного устаревшей, да). В результате, я не захотел отказываться от Swagger-а и запускаю сервис командой 'npm start'.
А что с играми? Если вы ещё не забыли, наша цель — связать имеющиеся игры с сервером (в рамках текущего проекта, чтобы получать от сервера сгенерированный бонус при выигрыше). В этом вполне может помочь jQuery (кстати, это одна из всего лишь трёх используемых мной сторонних библиотек, кроме неё я использую Underscore и в некоторых случаях Seedrandom). Фактически, вся интеграция свелась к переписыванию всего одного модуля.
var SERVICE = "/api/";
var inProgress = false;
var auth = null;
...
var authorize = function() {
if (auth !== null) return;
inProgress = true;
$.ajax({
url: SERVICE + "auth/anonymous",
type: "GET",
dataType: "json",
success: function(data) {
auth = data.access_token;
console.log('Auth: Succeed ' + auth);
inProgress = false;
},
error: function() {
Dagaz.Controller.app.state = STATE.STOP;
alert('Auth: Error!');
},
statusCode: {
401: function() {
Dagaz.Controller.app.state = STATE.STOP;
alert('Auth: Bad User!');
},
500: function() {
Dagaz.Controller.app.state = STATE.STOP;
alert('Auth: Internal Error!');
}
}
});
}
...
App.prototype.exec = function() {
this.view.configure();
...
this.view.draw(this.canvas);
if (inProgress) return;
authorize();
...
}
В этом месте произошёл второй непонятный мне момент
Сразу после запуска, каждая игра выполняет несколько REST-запросов с JWT-аутентификацией к серверу. Проблема выглядела как «плавающий» сбой одного из таких запросов (в результате которого игра аварийно завершалась). Чтобы понять что происходит, пришлось собрать пакеты, летающие между Nginx-ом и Nest-ом.
Вот что получилось в итоге:
Запросы передаваемые через IPv4 обрабатывались нормально, а передаваемые через IPv6 — отбрасывались с ошибкой авторизации (403). Я облазил исходники Nest-а, но так и не понял, почему это происходит (если у кого-то есть мысли на этот счёт — буду рад выслушать). В результате, IPv6 на Loopback-е пришлось отключить:
и всё заработало.
tcpdump -vv -i any -s 4906 /var/log/dagaz.pcap
Вот что получилось в итоге:
Запросы передаваемые через IPv4 обрабатывались нормально, а передаваемые через IPv6 — отбрасывались с ошибкой авторизации (403). Я облазил исходники Nest-а, но так и не понял, почему это происходит (если у кого-то есть мысли на этот счёт — буду рад выслушать). В результате, IPv6 на Loopback-е пришлось отключить:
sysctl.conf
net.ipv6.conf.all.disable_ipv6=1
net.ipv6.conf.default.disable_ipv6=1
net.ipv6.conf.lo.disable_ipv6=1
и всё заработало.
Конечно, только этим дело не ограничивалось. Одним из требований отдела маркетинга была возможность запуска игр на мобильных устройствах. В этом, мне помог jonic (за что я очень ему благодарен). Было совершенно необходимо уметь масштабировать canvas, на котором происходит вся игра, по размерам экрана мобильного телефона или планшета. При этом, соотношение сторон должно было сохраняться, а в том случае если ширина canvas-а значительно превосходит высоту было крайне желательно попросить пользователя повернуть устройство должным образом.
Второй задачей стала интеграция отобранных отделом маркетинга игр в единое меню, с кратким описанием игр, страничками побед и поражений и навигацией между всеми этими страничками. Поскольку задача поддержания всего этого зоопарка в актуальном состоянии, сама по себе, способна свести с ума, Кирилл приспособил Gulp для автоматизированной сборки этой части сайта. Вот как всё выглядит в результате:
Вот так, при помощи Nest-а, jQuery, Nginx-а и небольшой доли везения нам удалось развернуть в Internet-е игровой сервер. Кому стало интересно, заходите ещё.