Введение
Разработка мобильных приложений как написание электронных книг — автор может быстро получить результат, отклики, доход, известность. Современный рынок мобильных устройств полон различными аппаратами всевозможных форм-факторов. Программировать для платформ, которые легко могут уместиться в кармане весьма интересно и каждый может себя попробовать в этом амплуа.
Лаборатория которой руководил автор на протяжении 5 лет занималась разработкой различных приложений для самых популярных за последнее пятилетие операционных систем носимых устройств — Android, IOS, Windows Phone. Хотя сегодня платформа корпорации Microsoft уже мало используется, но возможность практически без дополнительных затрат со стороны программиста (в случае если Visual Studio уже установлено) разрабатывать эти самые приложения, выкладывать их в магазины при этом не неся затрат на тиражирование, продажу, экспозицию и другие накладные расходы вызывает неподдельный интерес среди любой среды, где собираются единомышленники по созданию кода.
Данная книга посвящена разработке приложений именно под платформу от Microsoft и является первой в цикле, которые автор намерен опубликовать.
У читателя предполагается опыт программирования на объектно-ориентированном языке, желательно опыт на C#.
По мнению автора нет ничего более увлекательного для программиста, чем разработка игрового приложения — именно так можно заинтересовать аудиторию и постараться окунуть ее в «бездну программирования». Далее по тексту используется собирательное понятие игра, как отражение разрабатываемых мобильных приложений (соотвественно приложения и классы именуются Game).
Автор хотел бы выразить огромную благодарность Сыровацкой Е. С. и Вавиличеву А. В., которые проверяли тексты программ из книги, принимали непосредственное участие в тестировании приложений и внимание к работе.
Работа с XNA
Начальные теоретические сведения
Для разработки приложений и игр для платформы Windows Phone чаще всего используется язык программирования C#. Для написания основной логики используются:
////////////////////////////////////////////////////////////////////////////
if (условие1) // условие содержит логическое выражение
{
// Действия, которые надо выполнить, если условие1 выполняется
}
elseif (условие2) // Не обязательно
{
// Действия, которые надо выполнить, если условие1 не выполняется, но выполняется условие2
}
else// Не обязательно
{
// Действия, которые надо выполнить, если условия 1 и 2 не выполняются
}
////////////////////////////////////////////////////////////////////////////
switch (значение1)
{
case значение2:
// Действия, которые надо выполнить, если значения 1 и 2 равны
break;
case значение3:
// Действия, которые надо выполнить, если значения 1 и 3 равны
break;
<…>
}
////////////////////////////////////////////////////////////////////////////
тип [] mas1 = new тип [число элементов]; // Объявления одномерного массива
тип [,] mas2 = new тип [число строк, число столбцов]; // и двумерного массива
////////////////////////////////////////////////////////////////////////////
foreach (тип новая_переменная in mas1) // Цикл по всем элементам
{
// Действия для каждого объекта из mas1, где под объектом подразумевается новая_переменная
}
////////////////////////////////////////////////////////////////////////////
for (i = начальное_значение; i <= Конечное_значение; i++) // Цикл
{
// Тело цикла
}
////////////////////////////////////////////////////////////////////////////
While (условие) // Выполнять цикл пока условие = true
{
// Тело цикла
}
////////////////////////////////////////////////////////////////////////////
Random rand = new Random (); // Создает переменную rand для работы со случайными числами
// Присваивает переменной случайное значение от 0 до максимального значения:
имя_переменной = rand.Next (максимальное_значение);
//Присваивает переменной случайное значение от минимального значения до максимального значения:
имя_переменной = rand.Next (минимальное_значение, максимальное_значение);
////////////////////////////////////////////////////////////////////////////
Разработка игр с использованием XNA
В отличие от разработки приложений для Windows Phone, для создания игр целесообразно использовать набор инструментов XNA. При разработке игры на платформе Silverlight, что используется для создания приложений, может возникнуть трудность с отображением большого количества элементов на экране, а именно долгая отрисовка и как следствие подвисание самой игры, поэтому для создания игр для Windows Phone в SDK включена возможность использования XNA.
Окно создания проекта представлено на рисунке 1.
Во вкладке «Обозреватель решений» показанны все файлы, включенные в проект, работать предстоит с файлом Game1.cs, в нем располагается основная логика игры (рис.2).
Background.png и PhoneGameThump.png являются иконками игры, которые отображаются в меню смартфона, их необходимо заменить на свои файлы с теми же названиями и размерами изображений.
В папке GameContent необходимо расположить весь контент игры: текстуры, звуки, шрифты и другое. Они помещаются в папку контента и добавляются в проект при нажатии правой копкой мыши в обозревателе решений по папке контента и выборе действия "Добавить существующий элемент" (рис 3):
Изначально файл Game1.cs содержит несколько стандартных и необходимых методов:
public Game1()
{
// Здесь указываются ориентация экрана, частота обновления,
// разрешение и сенсорные жесты, которые будут использованы в игре
}
protected override void LoadContent()
{
// Здесь загружается весь контент, необходимый в игре
}
protected override void Update(GameTime gameTime)
{
// Здесь располагается логика, выполняемая при обновлении экрана
base.Update(gameTime);
}
protected override void Draw(GameTime gameTime)
{
// Здесь производится отрисовка графического контента
base.Draw(gameTime);
}
Вывод текста и графики в XNA
Для того чтобы вывести текст на экран, необходимо добавить в папку с контектом файл с названием *.spritefont, где * - название шрифта, этот файл содержит следующие строки:
<?xml version="1.0" encoding="utf-8"?>
<XnaContent xmlns:Graphics="Microsoft.Xna.Framework.Content.Pipeline.Graphics">
<Asset Type= " Graphics: FontDescription»>
<FontName>Segoe UI</FontName>
<Size>15</Size>
<Spacing>0</Spacing>
<UseKerning>true</UseKerning>
<Style>Bold</Style>
<CharacterRegions>
<CharacterRegion>
<Start> </Start>
<End>~</End>
</CharacterRegion>
<CharacterRegion>
<Start>А</Start>
<End>я</End>
</CharacterRegion>
</CharacterRegions>
</Asset>
</XnaContent>
FontName – в этих тегах заключено имя шрифта, Microsoft не рекомендует использовать какие-либо специфические шрифты, одни из рекомендуемых – шрифты Segoe разных разновидностей.
Size – определяет размер шрифта.
Style – определяет стиль шрифта (Regular, Bold, Italic).
CharacterRegion – определяет символы, которые можно использовать, в данном случае это русские и английские буквы, цифры и некоторые специальные символы.
После добавления этого файла в проект, его необходимо объявить в коде:
SpriteFont Название_переменной; // Создаем переменную в начале класса Game1
В методе LoadContent() прописываем следующее:
Название_переменной = Content.Load<SpriteFont>("*"); //где * - название файла
В методе Draw(GameTime gameTime) выводим текст на экран:
spriteBatch.DrawString(Название_переменной, "Текст, который необходимо вывести", new Vector2(координата_x, координата_y), Color.Цвет);
Вывод изображения аналогичен выводу текста:
Texture2D Название_переменной; // Создаем переменную в начале класса Game1
В методе LoadContent() прописываем следующее:
название_переменной = Content.Load<Texture2D>("*"); // где * - название файла
В методе Draw(GameTime gameTime) рисуем текстуру:
spriteBatch.Draw(название_переменной, new Rectangle(координата_x, координата_y, ширина, высота), Color.White);
Обработка нажатий
Нажатия на экран
Современные мобильные телефоны в большинстве случаев оснащены большим сенсорным экраном и минимальным количеством аппаратных кнопок, поэтому и организация взаимодействия с играми построена на считывании жестов с экрана.
В методе Game1() прописываются жесты, что могут быть использованные в игре:
public Game1()
{
<...>
TouchPanel.EnabledGestures = GestureType.Tap | GestureType.FreeDrag;
<...>
}
Все возможные жесты можно посмотреть в подсказке, всплывающей при вводе" GestureType". Наиболее часто используемые из них, это Tap — нажатие, FreeDrag — перетягивание, Hold — долгое нажатие, DoubleTap — двойное нажатие. Так же можно обработать и действия при отсутствии жестов.
В логике игры размещается следующее:
while (TouchPanel.IsGestureAvailable)
{
// Считывание жеста
GestureSample gesture = TouchPanel.ReadGesture();
// Координаты касания и другие необходимые параметры
int tapY = (int)gesture.Position.Y;
int tapX = (int)gesture.Position.X;
<...>
switch (gesture.GestureType)
{
// Если жест является нажатием:
case GestureType.Tap:
<...>
break;
<…>
// Если жест является перетягиванием:
case GestureType.FreeDrag:
<...>
break;
}
}
Нажатия на аппаратные кнопки
Обработка нажатий на аппаратные кнопки тоже важна, однако в приложении запрещено использовать их для нестандартных функций (например, аппаратную кнопку назад для установления паузы).
Одним из требований к приложениям и играм является то, что необходимо программировать действия для аппаратной кнопки назад таким образом, что после нажатия на нее показывается предыдущий модуль игры (не относится к игровым уровням), пример:
Меню – Список уровней – Уровень 1 – Уровень 2
Из любого уровня переход назад осуществляется в Список уровней, даже если новый уровень запускался после предыдущего, из Списка уровней соответственно в Меню, из Меню же происходит выход из приложения:
if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
{
if (Mode == GameMode.Menu)
{
this. Exit ();
}
else if (Mode == GameMode.Levels)
{
Mode = GameMode.Menu;
}
else if (Mode == GameMode.Level_1)
{
Mode = GameMode. Levels;
}
else if (Mode == GameMode.Level_2)
{
Mode = GameMode.Levels;
}
}
Создание игровых модулей рассмотрено в соответсвующем разделе.
Работа с таймером
Некоторые игры требуют работу со временем и выводом некоторого таймера на экран. Пример:
Необходимые переменные:
// Время игры, промежуточное время
int time, tempTime;
// Время в представлении ЧЧ:ММ:СС
string timeString;
В методе Update(GameTime gameTime) указываем:
if (tempTime < (int)gameTime.TotalGameTime.TotalSeconds)
{
time++;
}
// Приведение времени в формат ЧЧ:ММ:СС
GetTime(time);
tempTime = (int)gameTime.TotalGameTime.TotalSeconds;
Создаем метод GetTime(int timeTemp):
public void GetTime(int timeTemp)
{
if (timeTemp/ 3600 < 10)
{
timeString = "0" + (timeTemp/ 3600).ToString() + ":";
}
else if (timeTemp/ 3600 >= 10)
{
timeString = (timeTemp/ 3600).ToString() + ":";
}
else if (timeTemp/ 3600 < 1)
{
timeString = "00:";
}
if ((timeTemp% 3600) / 60 < 10)
{
timeString = timeString + "0" + ((timeTemp% 3600) / 60).ToString() + ":";
}
else if ((timeTemp% 3600) / 60 >= 10)
{
timeString = timeString + ((timeTemp% 3600) / 60).ToString() + ":";
}
else if ((timeTemp% 3600) / 60 < 1)
{
timeString = timeString + "00:";
}
if ((timeTemp% 3600) % 60 < 10)
{
timeString = timeString + "0" + ((timeTemp% 3600) % 60).ToString();
}
else if ((timeTemp% 3600) % 60 >= 10)
{
timeString = timeString + ((timeTemp% 3600) % 60).ToString ();
}
else if ((timeTemp% 3600) % 60 < 1)
{
timeString = timeString + "00";
}
}
В методе Draw(GameTime gameTime) выводим время на экран:
spriteBatch. DrawString (Шрифт, timeString, new Vector2 (координата_x, координата_y), Color. Цвет);
Пример создания игры
Для примера рассмотрим создание игры судоку. Из контента использованы только текстуры (рис 4 — 9):
Для генерации игрового поля была использована следующая последовательность действий:
Первая строка заполняется случайными неповторяющимися числами от 1 до 9. Далее для каждой следующей строки происходит особая перестановка элементов из первой строки такая, что судоку имеет решение и является верным. Затем в каждом блоке из трех строк (1—3, 4—6, 7—9) происходит случайная перестановка двух строк, если программа выбрала одинаковую строку, то перестановка не происходит, тоже самое делается и с столбцами. Далее переставляем местами 2 случайных блока по горизонтали и по вертикали, по тому же принципу как и строки со столбцами. Затем удаляются лишние клетки. В итоге получается уникальное судоку.
Ниже приведен код из файла Game1:
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.GamerServices;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Microsoft.Xna.Framework.Input.Touch;
using Microsoft.Xna.Framework.Media;
using System.IO.IsolatedStorage;
using System.IO;
namespace Sudoku
{
// Класс Field
public class Field
{
// Квадрат
public short Square {get; set;}
// Значение
public short Value {get; set;}
// Стандартное
public bool Standard {get; set;}
// Повтор
public bool Repeat {get; set;}
}
public class Game1: Microsoft.Xna.Framework.Game
{
GraphicsDeviceManager graphics;
SpriteBatch spriteBatch;
// Объявление переменных
// Текстуры:
Texture2D gameField;
Texture2D doublePoint;
Texture2D standartNumbers, errorNumbers;
Texture2D menuUpdate, menuRecord;
Texture2D [] numbersTexture = new Texture2D [10];
// Массив элементов поля
Field [,] field = new Field [9,9];
// Массив чисел
short [] numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9};
// Время текущей игры, рекордное время
int time, tempTime, recordTime;
// Выбранная цифра
byte chouseNum;
// Время в представлении ЧЧ: ММ: СС
string timeString;
bool menuUp, menuRec;
public Game1 ()
{
graphics = new GraphicsDeviceManager (this);
Content.RootDirectory = " Content ";
// Ориектация экрана
graphics.SupportedOrientations = DisplayOrientation. Portrait;
// Разрешение экрана
graphics.PreferredBackBufferWidth = 480;
graphics.PreferredBackBufferHeight = 800;
// На полный экран
graphics.IsFullScreen = true;
// Частота кадра на Windows Phone по умолчанию — 30 кадров в секунду.
TargetElapsedTime = TimeSpan.FromTicks (333333);
// Дополнительный заряд аккумулятора заблокирован.
InactiveSleepTime = TimeSpan.FromSeconds (1);
// Тип жестов — нажатие
TouchPanel. EnabledGestures = GestureType. Tap;
}
protected override void Initialize ()
{
base.Initialize ();
}
// Загрузка контента
protected override void LoadContent ()
{
spriteBatch = new SpriteBatch (GraphicsDevice);
// Загрузка текстур — фон, двоеточие, выделение стандартных и выбранных чисел, выделение ошибок,
// сообщение об обновлении и информация о рекорде
gameField = Content. Load <Texture2D> («GameField»);
doublePoint = Content. Load <Texture2D> («d»);
standartNumbers = Content. Load <Texture2D> («s»);
errorNumbers = Content. Load <Texture2D> («error»);
menuUpdate = Content. Load <Texture2D> («MenuUpdate»);
menuRecord = Content. Load <Texture2D> («MenuRecord»);
// Загрузка текстур цифр
for (short i = 0; i <10; i++)
{
numbersTexture [i] = Content.Load<Texture2D>(i.ToString ());
}
// Созданние массиива объектов класса Field
for (short i = 0; i <9; i++)
{
for (short j = 0; j <9; j++)
{
field [i, j] = new Field ();
// Определение принадлежности к одному из 9 квадратов
if (i <3 && j <3)
{
field [i, j].Square = 1;
}
else if (i <3 && j <6)
{
field [i, j].Square = 2;
}
else if (i <3 && j <9)
{
field [i, j].Square = 3;
}
else if (i <6 && j <3)
{
field [i, j].Square = 4;
}
else if (i <6 && j <6)
{
field [i, j].Square = 5;
}
else if (i <6 && j <9)
{
field [i, j].Square = 6;
}
else if (i <9 && j <3)
{
field [i, j].Square = 7;
}
else if (i <9 && j <6)
{
field [i, j].Square = 8;
}
else if (i <9 && j <9)
{
field [i, j].Square = 9;
}
}
}
// Чтение сохраненных данных
ReadData ();
// Поиск повторов
SearchRepeats ();
}
// Генерация поля
public void Generate ()
{
for (short i = 0; i <9; i++)
{
for (short j = 0; j <9; j++)
{
field [i, j].Standard = false;
field [i, j].Repeat = false;
}
}
time = 0;
Random rand = new Random ();
numbers = new short [9] {1, 2, 3, 4, 5, 6, 7, 8, 9};
// Рандомное формирование первой стороки
for (short i = 0; i <9; i++)
{
short tempRand = (short)(rand.Next (45689) % (9 — i));
field [0, i].Value = numbers [tempRand];
for (short j = tempRand; j <9 — i — 1; j++)
{
numbers [j] = numbers [j +1];
}
}
// Формирование строк согласно перестановкам
numbers = new short [9] {8, 3, 4, 6, 7, 0, 5, 1, 2};
// Формирование второй строки
for (short i = 0; i <9; i++)
{
field [1, numbers[i]].Value = field [0, i].Value;
}
numbers = new short [9] {5, 6, 8, 2, 0, 7, 1, 3, 4};
// Формирование третьей строки
for (short i = 0; i <9; i++)
{
field [2, numbers[i]].Value = field [0, i].Value;
}
numbers = new short [9] {2, 8, 6, 4, 3, 1, 7, 0, 5};
// Формирование четвертой строки
for (short i = 0; i <9; i++)
{
field [3, numbers[i]].Value = field [0, i].Value;
}
numbers = new short [9] {7, 5, 3, 1, 2, 6, 4, 8, 0};
// Формирование пятой строки
for (short i = 0; i <9; i++)
{
field [4, numbers[i]].Value = field [0, i].Value;
}
numbers = new short [9] {4, 2, 1, 7, 8, 3, 0, 5, 6};
// Формирование шестой строки
for (short i = 0; i <9; i++)
{
field [5, numbers[i]].Value = field [0, i].Value;
}
numbers = new short [9] {6, 0, 5, 8, 1, 4, 3, 2, 7};
// Формирование седьмой строки
for (short i = 0; i <9; i++)
{
field [6, numbers[i]].Value = field [0, i].Value;
}
numbers = new short [9] {3, 4, 7, 0, 5, 8, 2, 6, 1};
// Формирование восьмой строки
for (short i = 0; i <9; i++)
{
field [7, numbers[i]].Value = field [0, i].Value;
}
numbers = new short [9] {1, 7, 0, 5, 6, 2, 8, 4, 3};
// Формирование девятой строки
for (short i = 0; i <9; i++)
{
field [8, numbers[i]].Value = field [0, i].Value;
}
// Переменивание строк/столбцов/регионов
MixHorizontalLines ();
MixVerticalLines ();
MixHorizontalRegions ();
MixVerticalRegions ();
// Освобождение клеток на поле
DeleteNumbersFromField ();
}
// Перестановка строк/столбцов в каждом регионе (блок из трех строк/столбцов)
public void MixHorizontalLines ()
{
Random rand = new Random ();
short line1, line2;
//Переставляем две строки в первой тройке
line1 = (short)(rand.Next (12432) % 3);
line2 = (short)(rand.Next (87457) % 3);
ExchangeValuesOfHorizontalLines (line1, line2);
//Переставляем две строки во второй тройке
line1 = (short)(rand.Next (12432) % 3 +3);
line2 = (short)(rand.Next (87457) % 3 +3);
ExchangeValuesOfHorizontalLines (line1, line2);
//Переставляем две строки в третьей тройке
line1 = (short)(rand.Next (12432) % 3 +6);
line2 = (short)(rand.Next (87457) % 3 +6);
ExchangeValuesOfHorizontalLines (line1, line2);
}
public void ExchangeValuesOfHorizontalLines (short line1, short line2)
{
if (line1!= line2)
{
short lineTemp;
for (short i = 0; i <9; i++)
{
lineTemp = field [line1, i].Value;
field [line1, i].Value = field [line2, i].Value;
field [line2, i].Value = lineTemp;
}
}
}
public void MixVerticalLines ()
{
Random rand = new Random ();
short line1, line2;
//Переставляем две строки в первой тройке
line1 = (short)(rand.Next (12432) % 3);
line2 = (short)(rand.Next (87457) % 3);
ExchangeValuesOfVerticalLines (line1, line2);
//Переставляем две строки во второй тройке
line1 = (short)(rand.Next (12432) % 3 +3);
line2 = (short)(rand.Next (87457) % 3 +3);
ExchangeValuesOfVerticalLines (line1, line2);
//Переставляем две строки в третьей тройке
line1 = (short)(rand.Next (12432) % 3 +6);
line2 = (short)(rand.Next (87457) % 3 +6);
ExchangeValuesOfVerticalLines (line1, line2);
}
public void ExchangeValuesOfVerticalLines (short line1, short line2)
{
if (line1!= line2)
{
short lineTemp;
for (short i = 0; i <9; i++)
{
lineTemp = field [i, line1].Value;
field [i, line1].Value = field [i, line2].Value;
field [i, line2].Value = lineTemp;
}
}
}
// Перестановка регионов (блок из трех строк/столбцов)
public void MixVerticalRegions ()
{
Random rand = new Random ();
short Region1, Region2;
//Переставляем две строки в первой тройке
Region1 = (short)(rand.Next (7654) % 3);
Region2 = (short)(rand.Next (45545) % 3);
ExchangeValuesOfVerticalRegions (Region1, Region2);
}
public void ExchangeValuesOfVerticalRegions (short Region1, short Region2)
{
if (Region1!= Region2)
{
short lineTemp1;
short lineTemp2;
short lineTemp3;
for (short i = 0; i <9; i++)
{
lineTemp1 = field [i, Region1 * 3].Value;
lineTemp2 = field [i, Region1 * 3 + 1].Value;
lineTemp3 = field [i, Region1 * 3 + 2].Value;
field [i, Region1 * 3].Value = field [i, Region2 * 3].Value;
field [i, Region1 * 3 + 1].Value = field [i, Region2 * 3 + 1].Value;
field [i, Region1 * 3 + 2].Value = field [i, Region2 * 3 + 2].Value;
field [i, Region1 * 3].Value = lineTemp1;
field [i, Region1 * 3 + 1].Value = lineTemp2;
field [i, Region1 * 3 + 2].Value = lineTemp3;
}
}
}
public void MixHorizontalRegions ()
{
Random rand = new Random ();
short Region1, Region2;
//Переставляем две строки в первой тройке
Region1 = (short)(rand.Next (7654) % 3);
Region2 = (short)(rand.Next (45545) % 3);
ExchangeValuesOfHorizontalRegions (Region1, Region2);
}
public void ExchangeValuesOfHorizontalRegions (short Region1, short Region2)
{
if (Region1!= Region2)
{
short lineTemp1;
short lineTemp2;
short lineTemp3;
for (short i = 0; i <9; i++)
{
lineTemp1 = field [Region1 * 3, i].Value;
lineTemp2 = field [Region1 * 3 +1, i].Value;
lineTemp3 = field [Region1 * 3 +2, i].Value;
field [Region1 * 3, i].Value = field [i, Region2 * 3].Value;
field [Region1 * 3 +1, i].Value = field [i, Region2 * 3 + 1].Value;
field [Region1 * 3 +2, i].Value = field [i, Region2 * 3 + 2].Value;
field [Region1 * 3, i].Value = lineTemp1;
field [Region1 * 3 +1, i].Value = lineTemp2;
field [Region1 * 3 +2, i].Value = lineTemp3;
}
}
}
// Освобождение клеток на поле
public void DeleteNumbersFromField ()
{
Random rand = new Random ();
for (short k = 0; k <9; k++)
{
short [] randString = new short [9];
numbers = new short [9] {0, 1, 2, 3, 4, 5, 6, 7, 8};
for (short i = 0; i <9; i++)
{
short temp = (short)(rand.Next (45689 * (i +1) — 6) % (9 — i));
randString [i] = numbers [temp];
for (short j = temp; j <9 — i — 1; j++)
{
numbers [j] = numbers [j +1];
}
}
short tempRand = (short)(rand.Next (4, 7));
for (short t = 0; t <tempRand; t++)
{
field [k, randString[t]].Value = 0;
}
for (short t = tempRand; t <9; t++)
{
field [k, randString[t]].Standard = true;
}
}
}
// Проверка условий победы
public bool Win ()
{
bool flag = true;
short full = 0;
SearchRepeats ();
// Подсчет заполненных клеток
for (short i = 0; i <9; i++)
{
for (short j = 0; j <9; j++)
{
if (field [i, j].Value!= 0)
{
full++;
}
}
}
Бесплатный фрагмент закончился.
Купите книгу, чтобы продолжить чтение.