Карточная игра на JavaScript и Canvas, или персональный Лас-Вегас. Часть 1
Для работы я, как всегда, не использовал никакие сторонние движки и библиотеки, даже jQuery мне не понадобилось. Только функции ванильного JavaScript, включая средства работы с холстом (canvas). Холст в игре — основа для вывода всей игровой графики. В WebGL, на этот раз, не было необходимости, поэтому зоопарк поддерживаемых браузеров расширился. Средой программирования, как обычно, стал продвинутый блокнот. Игра получилась объемом 3,8 Мб, из которых 3 Мб — это семь карт спрайтов в формате png. Запускается игра по html-файлу. Сервер на PHP. В случае выбора однопользовательской игры (то есть, с ИИ), запросы к серверу не отправляются и все расчеты ведутся на клиенте. Диздок не писал — он не нужен хипстерам.
Далее — занимательная геометрия и программирование, а во второй части будет рассказ о размещении игры в социальных сетях и в магазине.
GUI
Написание кода графической части не отняло у меня слишком много времени, так как для своих предыдущих игр я уже разработал движок меню на JavaScript на теге canvas с подгрузкой спрайтов из форматов jpg и png, или, иначе говоря, GUI. Достаточно было лишь взять тот код и определить в качестве экранных кнопок при помощи ассоциативного массива все активные области, то есть, карты и кнопки меню. Ну и еще набросать сами спрайты, то есть, создать дизайн. Основное время ушло, собственно, на описание игровой логики всех пяти игр. И чуть позже — на создание многопользовательской версии под две социальные сети и магазин Windows.
Впрочем, я немного слукавил насчет того, что мое canvas-меню так уж прямо полностью готово. Его нельзя просто так взять и… В общем, оно (говнокод) далеко от совершенства и весьма ограничено в своих возможностях. Но главное, мне захотелось, чтобы карты не лежали скучной прямой линией, а выкладывались бы веселым веером, независимо от их количества. Однако, мое меню не поддерживало поворот «кнопок». Поэтому настало время запрограммировать это безобразие раз и навсегда. Для начала.
Добро пожаловать в Вегас
Я решил, что изгиб будет задаваться неким параметром d, который обозначает, насколько центр линии карт выдвигается вверх.
Итак, дано: количество карт (n), ширина прямоугольника (w), ограниченного центрами крайних, скучных, «неизогнутых» карт, и смещение середины линейки карт вверх (d), придающее им веселья. Требуется найти точки центров и углы поворота каждой карты так, чтобы получился веер, как на картинке. Сложно сказать, насколько хорош мой метод, но это то, что мне удалось вспомнить из геометрии. Писать будем на JavaScript.
var L=0, h=0, dx=0, hx=0, hy=0;
//Находим неизвестные стороны треугольника (1..3..n), вокруг которого будет описана окружность
//Обозначим переменной a равные стороны 1..3 и 3..n
var a = Math.sqrt ( d*d+(w*w/4) );
//Найдем радиус R описанной окружности по формуле
var p = 0.5*(a+w+a);
var R = a*w*a / ( 4*Math.sqrt( p*(p-a)*(p-w)*(p-a) ) );
//Угол L0 для отклонения первой и последней карт от горизонтальной оси будет равен
var L0 = Math.asin( 1 - (d/R) );
//Угол dL, на который будут отступать карты друг от друга по окружности
var dL = ( Math.PI-(2*L0) ) / (n-1);
//Переберем в цикле все карты
for (var i=0; i
Вегас уже где-то близко.
Код для отображения всех спрайтов на холсте по сформированному массиву точек и углов тривиален и использует только стандартные функции работы с canvas. Схема простая: нужно сохранить состояние canvas, переместиться туда, где будет находиться центр спрайта, повернуть canvas на требуемый угол, отобразить спрайт, а затем восстановить состояние canvas. Кстати, половинную ширину (w2) и высоту (h2) спрайта не обязательно вычислять каждый раз, их можно предопределить. В общем, примерно так:
var w2 = Math.floor(spriteW/2), h2 = Math.floor(spriteH/2);
var x = spriteX+w2, y = spriteY+h2;
ctx.save();
ctx.translate(x, y);
ctx.rotate(spriteL - Math.PI/2);
ctx.drawImage(spriteMapImage, spriteMapX, spriteMapY, spriteW, spriteH, -w2, -h2, spriteW, spriteH);
ctx.restore();
Ура! Теперь карты вкладываются веером, ну прямо, как в Вегасе — в кино видел. Недостаток или, точнее, недоработка пока заключается в том, что, хотя спрайты карт-кнопок теперь и располагаются под углом, но кликабельные области все равно остаются не повернутыми, пусть даже при этом правильно смещенными. То есть, у них cо спрайтами совпадают только центры. Однако, для данной игры этого вполне достаточно. Угол отклонения карт будет довольно мал, и вряд ли случайно кликнешь по соседней карте. Здесь, если и будут подобные круги из карт, как на этой картинке, то не кликабельные, а просто для эффекта. Для карт игроков будут изгибы гораздо меньше.
Всегда есть, к чему стремиться. Можно в обработчике указателя мыши для проверки вхождения в область клика считать синусы и косинусы, чтобы точнее позиционироваться по наклоненной кнопке. Либо можно поворачивать canvas при движении мыши. Но все это будет, как мне показалось, сильно и необоснованно нагружать процессор. Ведь, нужно будет при каждом движении мыши перебирать синусы-косинусы всех «экранных кнопок», чтобы определять вхождение указателя в какую-либо область клика. Можно, конечно, отрабатывать углы только при событии клика, но тогда не будет такого красивого изменения вида указателя при вхождении и выходе из кликабельной области. Я еще подумаю над тем, как можно решить данную задачу. А пока двигаемся дальше.
AI
Прочитав данный подзаголовок, можно было подумать, что вот он, наконец-то, изобретен искусственный интеллект. Свершилось то, к чему так долго стремились все светлые умы цивилизации. Однако, разочарую. В данном случае под пафосной аббревиатурой AI скрывается обычный генератор случайных чисел. Ладно, шучу, не совсем обычный. Минимальная логика у нашего игрового ИИ (Иван Иваныча) все же есть. Иван Иваныч — серьезный мужик. Логика будет еще дополняться, но даже сейчас Иван Иваныч частенько обыгрывает меня, например, в мою же игру, наглец. В покер так я вообще почти всегда проигрываю. Но, возможно, я не умею в него играть. А вы говорите, искусственного интеллекта не существует. Нет, он явно что-то замышляет… Я опасаюсь, как бы случайно не создать Sky-Net.
Анимация карт
Сделаем из игры конфетку. Без анимации карт все работает как-то резко и некрасиво. Добавим еще один графический слой (читай — прозрачный холст, canvas) поверх основного и будем на нем устраивать «заезды» карт между колодой и столом, столом и игроками, колодой и игроками. С вращением, песнями и плясками. Карта, которую требуется анимировать, будет просто выноситься на внешний холст и там уже беспрепятственно двигаться, не опасаясь оказаться слоном в посудной лавке, чтобы случайно не стереть собой соседей и фон. А в конце пути она будет падать обратно на нижний холст и далее вести себя прилично. Анимацию можно и отключить через игровое меню, если она не нужна. Обработчики кликов, кстати, повесим сразу на внешний холст.
Движение карты по холсту будем задавать следующими входными параметрами: скоростью V и точками, начальной (X0; Y0) и конечной (X, Y). При движении будем отрисовывать спрайт в позиции, в которой он должен оказаться по истечении времени ti, прошедшего с момента начала движения. И предварительно, конечно же, стирать спрайт со старой позиции. Вызывать следующую отрисовку будем сразу после того, как завершится предыдущая. Таким образом, обеспечим настолько плавное движение, насколько это позволит устройство, на котором будет выполняться код. А привязка ко времени сделает движение в целом равномерным, независимо от возможных кратковременных тормозов браузера. Принцип расчета такой:
В начале функции вызова движения определим некоторые параметры для движения
//Запомним начальное время
var t0 = Date.now() / 100;
//Полные перемещения по горизонтальной и вертикальной осям
var Sx = X - X0;
var Sy = Y - Y0;
//Общее расстояние находим по формуле
var S = Math.sqrt (Sx*Sx + Sy*Sy);
//Время, прошедшее с момента начала движения (ti), относится к общему времени движения (t), как относятся соответствующие расстояния по оси, например, x, то есть, ti / t = dxi / Sx. Из этого следует, что приращение координаты для горизонтальной оси dxi = Sx * ti / t, или dxi = Sx * ti * V / S. Скорость задается как параметр движения, а расстояния мы уже вычислили. Аналогично – для вертикальной оси.
//Выведем для этой анимации некие константы-коэффициенты, которые следуют из формулы выше и которые будут использоваться для расчета горизонтальной и вертикальной координат в цикле, чтобы не вычислять эти значения при каждой итерации. То есть, пока возьмем без времени ti
var constX = V * Sx / S;
var constY = V * Sy / S;
А в цикле движения будем каждый раз рассчитывать координаты уже по времени
//Время, прошедшее с начала движения, равно
var ti = Date.now()/100 - t0;
//Текущие координаты (Xi;Yi) в зависимости от этого времени
var Xi = constX * ti + X0;
var Yi = constY * ti + Y0;
И, наконец, эти координаты будем проверять на принадлежность к отрезку движения. То есть, Xi должно лежать между X0 и X, а Yi — между Y0 и Y. Пока выполняется данное условие, отрисовываем спрайт в (Xi; Yi) и зацикливаем функцию движения. Далее код, думаю, понятен, и нет смысла его подробно расписывать. Весь принцип я объяснил.
Теперь касательно угла поворота спрайта (ai). Если заданные в качестве входных параметров начальный угол (a0) и конечный угол (a) различаются, то в функции движения вычисляем текущий угол ai следующим образом. Приращение угла, то есть, (ai) относится к приращению пути, например, по горизонтальной оси (которое у нас равно (Xi — X0)), как общий угол поворота (a — a0) относится к общему пути по горизонтальной оси (Sx), то есть:
ai / (Xi — X0) = (a-a0) / Sx
//И туда, где мы рассчитываем текущие координаты спрайта в цикле, добавим также расчет также текущего угла поворота, не забывая прибавить начальный угол (a).
var ai = (a-a0) * Math.abs (Xi - X0) / Math.abs(Sx) + a;
//Можно вынести за скобки цикла постоянную величину (a-a0) / Math.abs(Sx);
Отрисовываем спрайт стандартным drawImage с предварительным поворотом холста при помощи стандартных же функций JS translate и rotate, здесь, думаю, все понятно.
Да, этот алгоритм не учитывает некоторые частные случаи. Когда, например, расстояние равно 0, то возникает ошибка деления на 0. Но мы не будем задавать картам нулевое движение, зачем бы нам это? Поэтому и делать проверку на такие крайности в данном случае излишне.
Игры
Игры в комплекте следующие. «Три мешка» — моя авторская игра. «Подкидной дурак», «Покер Техасский Холдем». Это те, что открыты изначально. Теперь закрытые: «Чешский дурак» и «Три палки». Открываются они, если выиграть достаточное количество монет: для открытия первой закрытой игры у игрока должно быть 700 монет, второй — 900 монет. Изначально игроку дается 650 монет. Если же проигрывать, то игры, наоборот, будут закрываться вплоть до самой первой. Монеты можно выиграть у других игроков в открытых играх или приобрести. В однопользовательской версии монеты убраны совсем, а доступны только три игры из пяти.
Отдельно про покер
Я раньше никогда не играл в покер, но слышал, что это популярная игра и по ней даже проводят мировые турниры. Я посмотрел один такой, а также пару фильмов про карточных шулеров. Как когда я делал «Морской бой», я смотрел сериал про пиратов. Новичку сложно запомнить все покерные комбинации, а тем более, по ходу игры сразу представлять все возможные варианты. Поэтому я добавил экран, который можно вызвать во время своего хода и посмотреть все возможные на данный момент для себя комбинации. И уже в зависимости от этого сделать тот или иной ход. Этот алгоритм использует и Иван Иваныч (Искусственный Интеллект) для оценки своих шансов, поэтому писать данную функцию в любом случае пришлось бы. Вот, как в итоге это выглядит. Яркие карты — это те, которые есть на столе и на руках. Затемненные — это те, которых пока нет, но они еще могут прийти из колоды. А, если все 5 карт перевернуты, то данная комбинация уже невозможна ни при каких условиях. Зеленой точкой помечаются собранные комбинации: чем ближе к номеру один, тем, соответственно, лучше.
Для покера я просто, ради прикола, также предусмотрел такой вариант, когда в игре участвует только ИИ, без людей. Получилось что-то вроде экранной заставки, где компьютер сам с собой бесконечно играет в покер.
Дизайн
Дизайн — это не моя стихия. В процессе создания игры он, как обычно, много раз переделывался и корректировался. В итоге, к моменту релиза игры я остановился на внезапно пришедшем мне в голову кабацком черно-красном варианте с расположением главного меню на мастях. Я долго думал, какой из двух видов оставить, а потом решил, что пусть будут оба с переключением между ними.
Английский язык
Опыт локализации предыдущих игр подсказывал мне, насколько муторно потом выдергивать из скриптов все кириллические строки и заменять их ссылками. Поэтому я добавлял их в языковой файл прямо по ходу, а в скрипты основной программы вставлял сразу ссылки (с комментариями). Cначала я заполнял только русскоязычный файл, а затем сделал копию и перевел его на английский. Языковой файл подключается динамически, в зависимости от выбранного языка, и представляет собой массив строк с определенным именем. Как обычно, простой скрипт:
var el=document.createElement("script"); el.type="text/javascript";
el.src="lng/lng_"+lng+".js"; el.async=true;
document.getElementsByTagName("head")[0].appendChild(el);
Здесь переменная lng ранее читается из параметров командной строки. Функция применения языка запускается по готовности html-документа. А во всех функциях, где присутствуют текстовые строки, уже заданы ссылки на элементы массива языкового файла с фразами. В верстке самой html-страницы никаких фраз нет, ведь, по сути, документ представляет собой просто два канваса (canvas), наложенные один на другой, да пустой блочный элемент для вывода правил игры. Правила также располагаются в «языковом» файле.
Те фразы, которые были «захардкорены» в изображениях — например, подписи на кнопках, вынесены в отдельные изображения в формате png, соответственно, для русского и английского языков. А фоны — например, сам игровой стол, подложи для меню и прочие спрайты без надписей, располагаются в общем файле.
Вот пример русскоязычной локализации и общего для русского и английского языков фона (в уменьшенном виде):
«Русскоязычная» карта спрайтов
Общая карта спрайтов (фонов) для всех языков
Подложки для главного меню слишком большие, поэтому они располагаются еще в одном, дополнительном файле. Они также общие для всех языков, а «кнопки» для них уже включены в обе «языковые» карты.
Звуки
Звуки для игры были частично сгенерированы при помощи программы-синтезатора, частично взяты из бесплатных ассетов и подключены тегом audio. Здесь рассказывать особо не о чем. Был только один неприятный момент, связанный с тем, что если не закончилось воспроизведение текущего звука, то новый звук пропускается и не воспроизводится совсем. Поэтому пришлось сокращать звуки, чтобы они не «перекрывали» друг друга в некоторых ситуациях.
Промежуточный итог
В следующей статье расскажу об опыте размещения универсального Windows-приложения в магазине Windows Store, о создании классического (десктопного) приложения под Windows из html-js файлов, а также о многопользовательской версии для социальных сетей ВКонтакте и Фейсбук. На данный момент игра доступна пока только в Windows Store в виде однопользовательского UWP-приложения x86 и x64 под Windows 8.1 — 10. Планирую также сделать сборки под Android и iOs. А также, возможно, выложить просто zip-архив для запуска однопользовательской версии игры по index.html для всех операционных систем.
Вообще, архив с html-js файлами мне кажется самым оптимальным вариантом для распространения подобных приложений, так как нет необходимости что-то собирать при помощи «тяжелых» средств разработки, нет необходимости создавать инсталлятор, нет необходимости включать в сборку какие-либо библиотеки, html можно открыть в любой ОС при помощи любого бразузера на выбор и использовать тот, в каком приложение будет работать лучше всего. Жаль, что магазины приложений не позволяют распространять приложения таким способом, а операционные системы не устанавливают и не создают ярлыки для html, как, например, это делают с исполняемыми файлами. Разве что, Windows 8.1 — 10 как-то движется в этом направлении, собранный пакет для распространения под них занимает всего 4,4 Мб. Производители браузеров могли бы тоже повернуться лицом к html-js приложениям и предусмотреть какой-нибудь режим запуска без показа интерфейса браузера, как это делается, например, в node-webkit, когда используется браузерный движок. Тогда бы не пришлось, например, в сборку «классического» приложения включать движок и «тащить» этот движок с каждым таким приложением. Ведь, потенциал у веб-приложений есть: доступна 3D графика WebGL, байт-код и скоро, возможно, браузерный код еще больше приблизится по скорости к нативному. Но обо всем этом — в следующей части. А пока я пишу сервер.