Продолжение статьи по быстрой разработке гипермедиа-ориентированного веб-приложения с HTMX 2.0.

Продолжение первой части

В пред идущей части вы познакомились с гипермедийными системами, начали создавать новое приложение — настольную онлайн игру «Шпион» и добавили первый гипермедийный элемент на страницу ожидания игроков. В этой части мы продолжим работу над этим приложением.

Рефакторинг

Перед добавлением следующих гипермедийных элементов нам следует провести небольшой рефакторинг. После проведения рефакторинга облегчается добавление новой функциональности.

По соглашению MVC контроллеры должны только обрабатывать входящие запросы, оперировать моделями и выбирать представление для показа пользователя. А наш пример нарушает это соглашение. Код серверной части гипермедийных систем как правило следует выносить в обычные гипермедийные модели, а визуальным представлениям мы будем передавать не примитивные типы данных, а веб модели, наполненные готовыми данными. А сами контроллеры не должны взаимодействовать напрямую с моделями из репозитория или доменными моделями.

Гипермедийная модель ожидания участников игры

Создайте в папке Web новую папку Hypermedia. В этой папке будут находится гипермедийные модели поддержки работы гипермедийных элементов на визуальных представлениях. Другими словами серверный код гипермедийных систем. Создайте в этой папке новый класс WaitHypermedia и наполните его содержимым из листинга ниже.

Листинг: Гипермедийная модель WaitHypermedia

using System.Linq;
using System.Web;
using SpyOnlineGame.Data;
using SpyOnlineGame.Models;
using SpyOnlineGame.Web.Models;

namespace SpyOnlineGame.Web.Hypermedia
{
    public class WaitHypermedia
    {
        private readonly HttpRequestBase _request;
        private readonly int _id;
        private readonly Player _current;
        
        public bool IsHtmx => _request.Headers.AllKeys.Contains("hx-request");

        public bool IsNotFound => _current is null;

        public WaitHypermedia(HttpRequestBase request, int id)
        {
            _request = request;
            _id = id;

            _current = PlayersRepository.GetById(_id);
        }

        public WaitWebModel Model()
        {
            return new WaitWebModel
            {
                Id = _id,
                Current = _current ?? new Player(),
                All = PlayersRepository.All,
            };
        }
    }
}

Упрощение метода действия контроллера

Теперь можно упростить и метод Index контроллера Wait. Вся логика работы выносится из метода действия в гипермедийную модель, а в методе действия остается только логика, характерная для контроллера.

Листинг: Контроллер Wait с упрощенным методом

using System.Web.Mvc;
using SpyOnlineGame.Web.Hypermedia; // Добавить

namespace SpyOnlineGame.Controllers
{
    public class WaitController : Controller
    {
        public ActionResult Index(int id)
        {
            var hypermedia = new WaitHypermedia(Request, id); // Добавить
            if (hypermedia.IsNotFound) 
              return new HttpNotFoundResult(); // Добавить

            if (hypermedia.IsHtmx) // Добавить
            {
                return PartialView("Partial/WaitPartial", 
                  hypermedia.Model()); // Добавить
            }
            return View(hypermedia.Model()); // Добавить
        }
    }
}

Вначале выполнения этого метода мы создаем гипермедийную модель. Потом граничным оператором проверяет,  смогла ли найти гипермедийная модель текущего пользователя. И в конце в зависимости от природы запроса возвращает либо частичное представление, либо обычное.

Таким рефакторингом мы никоим образом не нарушаем соглашения MVC. Наоборот, мы их подчеркиваем. Метод действия контроллера работает с моделью и выбирает какое визуальное представление возвращать в ответ на запрос. В гипермедийной модели будет сконцентрирована гипермедийная логика. В веб модели - логика, связанная с визуальным представлением.

Очень важно понимание, что гипермедийная модель этого примера, метод действия контроллера, гипермедийный элемент визуального представления и коммуникация между ними - это есть одна гипермедийная система.

После проведения этого рефакторинга можно приступить к добавлению остальных гипермедийных систем на страницу ожидания регистрации игроков. Мы не будем выносить бизнес логику в доменную модель из гипермедийной модели ради упрощения примера, так как в данном примере наша цель прежде всего познакомится с гипермедийными системами.

Интерактивность страницы ожидания регистрации игроков

Теперь мы займемся добавлением остальных интерактивных гипермедийных элементов страницы ожидания. Очень важным считаю перед добавлением всякой новой функциональности в приложение проводить рефакторинг тех мест приложения, где предполагается внести изменения. Это правило предотвращает загнивание кода и дает на какое-то время нам буст к производительности. 

Обновление по необходимости элементов на странице

Сейчас происходит обновление списка зарегистрированных пользователей на странице браузера у каждого зарегистрированного участника каждую секунду. Более логично обновлять только тогда, когда кто-либо еще зарегистрировался в качестве игрока. Добавим обновление по необходимости.

Первым делом добавим в модель пользователя новое свойство необходимости обновления игрока. Добавьте в класс новое свойство, выделенное жирным шрифтом на листинга ниже.

Листинг: Дополненная модель участника Player

namespace SpyOnlineGame.Models
{
    public class Player
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public bool IsNeedUpdate { get; set; } // Добавить
    }
}

Откорректируйте код репозитория участников PlayersRepository, чтобы при добавлении нового или удалении любого участника флаг необходимости обновления поднимался. Проведите изменения, указанные в следующем листинге.

Листинг: Откорректированный репозитория участников PlayersRepository

…
        public static int Add(Player player)
        {
            player.Id = _lastId++;
            _players.Add(player);
            IsNeedAllUpdate(); // Добавить
            return player.Id;
        }

        public static void Remove(int id)
        {
            var deleted = GetById(id);
            if (deleted is null) return;
            _players.Remove(deleted);
            IsNeedAllUpdate() // Добавить
        }

        public static void IsNeedAllUpdate() // Добавить метод
        {
            foreach (var each in All) each.IsNeedUpdate = true; // Добавить
        }
    }
}

После этого добавьте новый метод в гипермедийную модель ожидания участников, который в качестве результата будет возвращать признак, что модель уже содержит старые, измененные данные. Нужно еще добавить новое свойство, которое будет инкапсулировать логику условия граничного оператора из метода действия контроллера. Добавьте метод и свойство, представленный в следующем листинге.

Листинг: Новый метод в гипермедийной модели WaitHypermedia

…
        public bool IsNotFound => _current is null;

        public bool IsNoContent => IsHtmx && HasOldData(); // Добавить

        public WaitHypermedia(HttpRequestBase request, int id)
        {
…
        }

        public bool HasOldData() // Добавить метод
        {
            if (_current?.IsNeedUpdate != true) return true; // Добавить
            _current.IsNeedUpdate = false; // Добавить
            return false; // Добавить
        }

        public WaitWebModel Model()
        {
…
        }
…

Этот метод должен только один раз разрешать обновление списка пользователей на странице у каждого участника, а после этого — снова запрещать. Но разрешать только после, например, регистрации еще одного участника игры.

Осталось только добавить новый граничный оператор в метод Index контроллера Wait, как в листинге ниже.

Листинг: Новый граничный оператор в методе Index класса WaitController

…
        public ActionResult Index(int id)
        {
            var hypermedia = new WaitHypermedia(Request, id);
            if (hypermedia.IsNotFound) return new HttpNotFoundResult();
            if (hypermedia.IsNoContent) // Добавить блок
              return new HttpStatusCodeResult(HttpStatusCode.NoContent); 

            if (hypermedia.IsHtmx)
            {
                return PartialView("Partial/WaitPartial", hypermedia.Model());
            }
            return View(hypermedia.Model());
        }
…

Объектом HttpStatusCodeResult с параметром NoContent мы возвращаем в качестве результата код 204. Гипермедийный элемент при получении такого кода не производит никаких действий и таблица участников игры не обновляется.

Добавление статуса готовности игроков к игре.

Добавьте признак готовности игрока к игре в модель Player. Этот флаг сигнализирует о том, что участник игры готов к началу игры. Необходимо добавить отображение готовности всех игроков и кнопку смены статуса готовности.

Листинг: Модель участника Player с новым признаком готовности

namespace SpyOnlineGame.Models
{
    public class Player
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public bool IsReady { get; set; } // Добавить
        public bool IsNeedUpdate { get; set; }
    }
}

Измените частичное визуальное представление отображения зарегистрированных игроков. Добавим отображение готовности игрока к игре в новой колонке таблицы. Дополнительно будем производить стилизацию ячейки готовности на основе значения статуса готовности.

Листинг: Частичное визуальное представление Wait/WaitPartial.cshtml с таблицей готовности

@model WaitWebModel

<p>Все зарегистрированные игроки:</p>
<table class="table table-striped table-bordered">
    <thead>
        <tr><th>Id</th><th>Имя</th><th>Готовность</th></tr> // Изменить
    </thead>
    <tbody>
    @foreach (var each in Model.All)
    {
        <tr>
            <th scope="row" class="align-middle">@each.Id</th>
            <td class="align-middle">@each.Name</td>
            <td class="align-middle text-white 
              @(each.IsReady ? "bg-success" : "bg-danger")"> // Добавить блок
                @(each.IsReady ? "Готов" : "Не готов") 
            </td>
        </tr>
    }
    </tbody>
</table>

Теперь нужно добавить в гипермедийную модель метод смены статуса игрока. Добавим новый метод в гипермедийную модель согласно следующему листингу.

Листинг: Гипермедийная модель WaitHypermedia с новым методом

…
        public bool HasOldData()
        {
            if (_current?.IsNeedUpdate != true) return true;
            _current.IsNeedUpdate = false;
            return false;
        }

        public void SwitchReady() // Добавить новый метод
        {
            if (_current is null) return;
            _current.IsReady = !_current.IsReady;
            PlayersRepository.IsNeedAllUpdate();
        }
…

Этот метод меняет признак готовности игрока на противоположный и обновляет страницы у всех участников. Прежде чем добавить кнопку на визуальное представление, нужно добавить новый метод действия в контроллер Wait. 

Листинг: Новый метод смены готовности в контроллере Wait

…
        public ActionResult Index(int id)
        {
…
        }

        public ActionResult SwitchReady(int id) // Добавить новый метод
        {
            var hypermedia = new WaitHypermedia(Request, id);
            hypermedia.SwitchReady();
            return Index(id);
        }
…

Этот метод действия, как вы видите, вызывает метод смены готовности у гипермедийной модели ожидания участников. А в качестве результата возвращает визуальное представление метода Index. Мы так сделали для того, чтобы после смены статуса готовности участника игры он сразу-же увидел изменение своего статуса. Осталось только добавить стилизованную кнопку смены готовности игрока в частичное визуальное представление.

Листинг: Частичное визуальное представление Wait/WaitPartial.cshtml с кнопкой готовности

@model WaitWebModel

<div class="my-1"> // Добавить весь новый блок
    <p class="form-label">Ваша готовность:</p>
    <button class="btn @(Model.Current.IsReady ? "btn-success" : "btn-danger")"
            hx-get="@Url.Action("SwitchReady", new { Model.Id })"
            hx-target="#wait">
        @(Model.Current.IsReady ? "Готов" : "Не готов")
    </button>
</div>

<p>Все зарегистрированные игроки:</p>
…

Теперь после запуска приложения в нескольких браузерах вы можете увидеть что после изменения статуса готовности любого участника игры все игроки сразу увидят изменение.

Смена имени участника

Мы добавим интерактивную возможность сменить свое имя уже зарегистрированному в игре участнику Мы добавим эту возможность в виде текстового поля прямо на страницу ожидания участников игры. Пользователь меняет текст в поле — меняется его имя у всех участников игры. С помощью гипермедийных систем такие, как и многие другие возможности очень легко реализуются.

Первым делом добавим новый метод смены имени в гипермедийную модель ожидания участиков игры.

Листинг: Гипермедийная модель WaitHypermedia с новым методом смены имени

…
        public void SwitchReady()
        {
…
        }

        public void SetName(string name) // Добавить новый метод
        {
            if (_current is null || _current.Name == name) return;
            _current.Name = name;
            PlayersRepository.IsNeedAllUpdate();
        }
…

Затем добавим новый метод смены имени зарегистрированного участника игры в контроллер Wait.

Листинг: Новый метод смены имени зарегистрированного участника игры в контроллере Wait

…
        public ActionResult SwitchReady(int id)
        {
…
        }

        public void SetName(int id, string name) // Добавить новый метод
        {
            var hypermedia = new WaitHypermedia(Request, id);
            hypermedia.SetName(name);
        }
…

Осталось добавить новый гипермедийный элемент на визуальное представление Index.

Листинг: Визуальное представление Wait/Index.cshtml с текстовым полем смены имени

…
<h4>@ViewBag.Title</h4>
<p>Ожидание окончания регистрации всех участников игры.</p>

<div class="my-1"> // Добавить весь новый блок
    <label class="form-label">Ваше имя:</label>
    <input type="text" name="name" class="form-control"
           hx-get="@Url.Action("SetName", new { Model.Id })"
           hx-trigger="change, keyup delay:500ms changed"
           value="@Model.Current.Name" />
</div>
…

У этого нового элемента триггер выполнения мы установили в keyup changed — от отжатия клавиши после ввода нового символа с задержкой delay:500ms в 500 миллисекунд. При срабатывании этого триггера будет произведен запрос по указанному адресу. Особенность в том, что мы указали атрибут id запроса, а название второго атрибута - name - будет автоматически подставлено исходя из имени метода, а значение будет взято из текстового поля.

Запустите приложение на выполнение и проверьте возможность смены имени.

Кнопка выхода из игры

Нужно добавить метод выхода из игры в гипермедийную модель участника игры. Вызовом этого метода участник удаляет себя из списка зарегистрированных игроков.

Листинг: Гипермедийная модель ожидания игроков WaitHypermedia с новым методом

…
        public void SetName(string name)
        {
…
        }

        public void Logout() // Добавить новый метод
        {
            PlayersRepository.Remove(_id);
        }
…

После этого нужно добавить кнопку на визуальное представление и добавить метод действия в контроллер Wait. Добавьте новый метод действия в контроллер Wait, как в листинге ниже.

Листинг: Новый метод действия выхода из игры в контроллере Wait

…
        public void SetName(int id, string name)
        {
…
        }
 
        public ActionResult Logout(int id) // Добавить новый метод
        {
            var hypermedia = new WaitHypermedia(Request, id);
            hypermedia.Logout();

            return RedirectToAction("Index", "Home");
        }
…

И в конце добавим на верхнюю часть страницы ожидания регистрации игроков кнопку выхода.

Листинг: Визуальное представление с кнопкой выхода Wait/Index.cshtml

@model WaitWebModel
@{
    ViewBag.Title = "Ожидание";
}

@Html.ActionLink("Выход", "Logout", new { Model.Id }, 
    new { @class="btn btn-warning my-1" }) // Добавить

<h4>@ViewBag.Title</h4>
<p>Ожидание окончания регистрации всех участников игры.</p>
…

Теперь вы можете запустить приложение и проверить работоспособность кнопки. При нажатии этой кнопки происходит перенаправление на главную страницу игры и все участники видят изменения в списке участников.

Кнопки исключения игроков из списка

Добавим возможность исключения уже зарегистрированных лишних игроков. В репозитории пользователей у нас уже есть метод удаления игроков. Этот метод будет использовать гипермедийная модель. Займемся ее модификацией. Добавьте в эту модель новый метод, представленный на листинге ниже. Расположите этот метод рядом с методом Logout — это два метода, выполняющие схожие функции.

Листинг: Гипермедийная модель ожидания игроков WaitHypermedia с новым методом

…
        public void Kick(int playerId) // Добавить новый метод
        {
            PlayersRepository.Remove(playerId);
        }

        public void Logout()
        {
            PlayersRepository.Remove(_id);
        }
…

Теперь добавим новый метод в контроллер Wait. Этот метод будет производить вызов метода гипермедийной модели. Еще нам нужно модифицировать метод Index, чтобы теперь он исключенных игроков из списка участников перенаправлял на стартовую страницу приложения.

Листинг: Новый метод действия исключения игрока в контроллере Wait

…
        public ActionResult Index(int id)
        {
            var hypermedia = new WaitHypermedia(Request, id);
            if (hypermedia.IsNotFound) // Добавить весь блок
            {
                if (!hypermedia.IsHtmx) return RedirectToAction("Index", "Home");
                Response.Headers.Add("hx-redirect", Url.Action("Index", "Home"));
            }
            if (hypermedia.IsNoContent) 
                return new HttpStatusCodeResult(HttpStatusCode.NoContent);

            if (hypermedia.IsHtmx)
            {
                return PartialView("Partial/WaitPartial", hypermedia.Model());
            }
            return View(hypermedia.Model());
        }
…
        public ActionResult Kick(int id, int playerId) // Добавить новый метод
        {
            var hypermedia = new WaitHypermedia(Request, id);
            hypermedia.Kick(playerId);
            return Index(id);
        }

        public ActionResult Logout(int id)
        {
…
        }
…

В качестве результата выполнения метод действия Kick будет вызывать метод Index чтобы вернуть актуальное частичное представление с измененным списком пользователей. А в методе Index теперь в том случае, если участник с указанным идентификатором отсутствует, теперь будем перенаправлять на стартовую страницу. В этом примере перенаправление на другую страницу работает теперь с использованием гипермедийной системы. Так как страница исключаемого участника каждую секунду обновляется, то вызовом этого метода будет произведена переадресация сразу после того, как его исключили.

Осталось только модифицировать таблицу в визуальном частичном представлении. В эту таблицу добавьте одну новую колонку и кнопку возможности исключения игроков из списка.

Листинг: Частичное представление Wait/WaitPartial.cshtml с кнопками исключения игроков

@model WaitWebModel

<button class="btn @(Model.Current.IsReady ? "btn-success" : "btn-danger")"
        hx-get="@Url.Action("SwitchReady", new { Model.Id })"
        hx-target="#wait">
    @(Model.Current.IsReady ? "Готов" : "Не готов")
</button>

<p>Все зарегистрированные игроки:</p>
<table class="table table-striped table-bordered">
    <thead>
        <tr><th>Id</th><th>Имя</th><th>Готовность</th>
          <th class="col-1"></th></tr> // Изменить
    </thead>
    <tbody>
    @foreach (var each in Model.All)
    {
        <tr>
            <th scope="row" class="align-middle">@each.Id</th>
            <td class="align-middle">@each.Name</td>
            <td class="align-middle text-white 
              @(each.IsReady ? "bg-success" : "bg-danger")">
                @(each.IsReady ? "Готов" : "Не готов")
            </td>
            <td> // Добавить весь новый блок
                @if (each.Id != Model.Id)
                {
                    <button class="btn btn-warning"
                            hx-get="@Url.Action("Kick", 
                              new { Model.Id, playerId = each.Id })"
                            hx-target="#wait">
                        Выгнать
                    </button>
                }
            </td>
        </tr>
    }
    </tbody>
</table>

Если вы запустите на выполнение приложение, то можете проверить возможность исключения игроков из списка зарегистрированных игроков.

На следующем большом скриншоте отображается работа функционала исключения игрока из списка игроков. На двух верхних окнах — окна двух игроков изначально. А ниже, под красной полоской, показаны окна участников после нажатия кнопки «Выгнать» в окне первого игрока. В результате в окне браузера второго игрока стала отображаться главная страница приложения с формой регистрации. А в левом окне первого игрока теперь в списке зарегистрированных пользователей показан только один игрок вместо двух.

Гипермедийные системы позволяют очень легко добавлять такие сложные интерактивные поведения на страницы веб‑приложения.

Начало игры

Осталось добавить последнюю интерактивную функциональность на нашу страницу ожидания регистрации игроков. Это кнопка начала игры. Она должна быть доступной только тогда, когда число зарегистрированных участников игры больше трех и при этом они все готовы к игре. Но осложняется все тем, что после нажатия кнопки начала игры всех зарегистрированных и готовых к игре игроков нужно одномоментно перенаправить на страницу игры. А всем другим игрокам необходимо запретить регистрацию. В этом приложении специально ради упрощения примера мы не реализуем игровые сессии. Сессия будет только одна, она же и последняя. То есть для начала новой игры нужно перезапускать приложение, а после окончания игры приложение будет бесполезным. Это специальная условность только ради максимального упрощения.

Добавьте новое свойство в модель данных участника - признак его участия в игре.

Листинг: Модель участника Player с признаком участия в игре

namespace SpyOnlineGame.Models
{
    public class Player
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public bool IsReady { get; set; }
        public bool IsPlay { get; set; } // Добавить
        public bool IsNeedUpdate { get; set; }
    }
}

Модифицируйте вебмодель ожидания регистрации игроков, чтобы теперь она могла передавать в визуальное представление не список всех пользователей, но список ожидающих и список уже играющих игроков. Еще добавьте признак того, что можно начинать игру. А игру можно начинать только если количество участников больше или равно трем и все они готовы к игре.

Листинг: Модифицированная вебмодель WaitWebModel

using System;
using System.Collections.Generic;
using SpyOnlineGame.Models;

namespace SpyOnlineGame.Web.Models
{
    public class WaitWebModel
    {
        public int Id { get; set; }
        public Player Current { get; set; }
        public IEnumerable<Player> All { get; set; } = Array.Empty<Player>();
        public bool IsMayBeStart { get; set; } // Добавить
    }
}

Далее нам придется изменить гипермедийную модель ожидания игроков WaitHypermedia. В эту модель нужно добавить новый метод начала игры, признак начала игры и модифицировать метод Model(). Произведите изменения, согласно следующему листингу.

Листинг: Модифицированная гипермедийная модель WaitHypermedia

using System.Linq;
using System.Web;
using SpyOnlineGame.Data;
using SpyOnlineGame.Models;
using SpyOnlineGame.Web.Models;

namespace SpyOnlineGame.Web.Hypermedia
{
    public class WaitHypermedia
    {
        private readonly HttpRequestBase _request;
        private readonly int _id;
        private readonly Player _current;
        
        public bool IsHtmx => _request.Headers.AllKeys.Contains("hx-request");

        public bool IsNotFound => _current is null;

        public bool IsNoContent => IsHtmx && HasOldData();

        public bool IsMayBeStart => PlayersRepository.All.Count() >= 3 && 
            PlayersRepository.All.All(x => x.IsReady) 
            && !IsGameStarted; // Добавить

        public bool IsGameStarted =>
            PlayersRepository.All.Any(p => p.IsPlay); // Добавить

        public WaitHypermedia(HttpRequestBase request, int id)
        {
            _request = request;
            _id = id;

            _current = PlayersRepository.GetById(_id);
        }

        public bool HasOldData()
        {
            if (_current?.IsNeedUpdate != true) return true;
            _current.IsNeedUpdate = false;
            return false;
        }

        public void SwitchReady()
        {
            if (_current is null) return;
            _current.IsReady = !_current.IsReady;
            PlayersRepository.IsNeedAllUpdate();
        }

        public void SetName(string name)
        {
            if (_current is null || _current.Name == name) return;
            _current.Name = name;
            PlayersRepository.IsNeedAllUpdate();
        }

        public void Kick(int playerId)
        {
            PlayersRepository.Remove(playerId);
        }

        public void Logout()
        {
            PlayersRepository.Remove(_id);
        }

        public void Start() // Добавить новый метод
        {
            if (!IsMayBeStart) return;
            foreach (var each in PlayersRepository.All)
            {
                each.IsPlay = true;
            }
            PlayersRepository.IsNeedAllUpdate();
        }

        public WaitWebModel Model()
        {
            return new WaitWebModel
            {
                Id = _id,
                Current = _current ?? new Player(),
                All = PlayersRepository.All,
                IsMayBeStart = IsMayBeStart, // Добавить
            };
        }
    }
}

Теперь модифицируйте частичное визуальное представление WaitPartial.cshtml для того, чтобы оно отображало кнопку начала игры.

Листинг: Модифицированное частичное представление Wait/WaitPartial.cshtml

@model WaitWebModel

<div class="my-1">
    <p class="form-label">Ваша готовность:</p>
    <button class="btn @(Model.Current.IsReady ? "btn-success" : "btn-danger")"
            hx-get="@Url.Action("SwitchReady", new { Model.Id })"
            hx-target="#wait">
        @(Model.Current.IsReady ? "Готов" : "Не готов")
    </button>
</div>

<p>Все зарегистрированные игроки:</p>
<table class="table table-striped table-bordered">
    <thead>
        <tr><th>Id</th><th>Имя</th><th>Готовность</th>
          <th class="col-1"></th></tr>
    </thead>
    <tbody>
        @foreach (var each in Model.All)
        {
            <tr>
                <th scope="row" class="align-middle">@each.Id</th>
                <td class="align-middle">@each.Name</td>
                <td class="align-middle text-white @(each.IsReady 
                  ? "bg-success" : "bg-danger")">
                    @(each.IsReady ? "Готов" : "Не готов")
                </td>
                <td>
                    @if (each.Id != Model.Id)
                    {
                        <button class="btn btn-warning"
                                hx-get="@Url.Action("Kick", 
                                new { Model.Id, playerId = each.Id })"
                                hx-target="#wait">
                            Выгнать
                        </button>
                    }
                </td>
            </tr>
        }
    </tbody>
</table>

@if (Model.IsMayBeStart) // Добавить весь новый блок
{
    <button class="btn btn-success"
            hx-get="@Url.Action("Start", new { Model.Id })"
            hx-swap="none">Начать игру</button>
}

И в самую последнюю очередь нужно произвести изменения в контроллере Wait. В него нужно добавить метод старта игры и в методе Index добавить перенаправление на другой контроллер при старте игры.

Листинг: Измененный контроллер Wait

using System.Net;
using System.Web.Mvc;
using SpyOnlineGame.Web.Hypermedia;

namespace SpyOnlineGame.Controllers
{
    public class WaitController : Controller
    {
        public ActionResult Index(int id)
        {
            var hypermedia = new WaitHypermedia(Request, id);
            if (hypermedia.IsNotFound)
            {
                if (!hypermedia.IsHtmx) return RedirectToAction("Index", "Home");
                Response.Headers.Add("hx-redirect", Url.Action("Index", "Home"));
            }
            if (hypermedia.IsGameStarted) // Добавить весь блок
            {
                Response.Headers.Add("hx-redirect", 
                  Url.Action("Index", "Game", new { id }));
            }
            if (hypermedia.IsNoContent) 
                return new HttpStatusCodeResult(HttpStatusCode.NoContent);

            if (hypermedia.IsHtmx)
            {
                return PartialView("Partial/WaitPartial", hypermedia.Model());
            }
            return View(hypermedia.Model());
        }
…
        public ActionResult Logout(int id)
        {
            var hypermedia = new WaitHypermedia(Request, id);
            hypermedia.Logout();

            return RedirectToAction("Index", "Home");
        }

        public void Start(int id) // Добавить новый метод
        {
            var hypermedia = new WaitHypermedia(Request, id);
            hypermedia.Start();
        }
    }
}

Теперь можно запустить приложение и проверить запуск игры. Нужно открыть две дополнительные вкладки браузера, зарегистрироваться в игре и включить состояние готовности. После нажатия кнопки начала игры произойдет перенаправление всех трех вкладок на несуществующий пока контроллер игры Game.

Метод Index() граничным оператором проверки статуса запущенности игры перенаправляет на метод действия Index() контроллера Game. Такого контроллера и метода еще не существует. Добавьте его и наполните содержимым из следующего листинга.

Листинг: Контроллер игры Game

using System.Web.Mvc;

namespace SpyOnlineGame.Controllers
{
    public class GameController : Controller
    {
        public ActionResult Index(int id)
        {
            return View();
        }
    }
}

Добавьте новое визуальное представление с пустым содержимым для отображения пустой страницы игры.

Листинг: Визуальное представление без наполнения Views/Game/Index.cshtml

@{
    ViewBag.Title = "Игра";
    Layout = "~/Views/Shared/_GameLayout.cshtml";
}

<h4>@ViewBag.Title</h4>

Далее добавим запрет регистрации новых участников, когда игра уже запущена. Для этого добавим новый граничный оператор в метод Registration() контроллера Home. При наличии хотя-бы одного пользователя в репозитории с флагом участия в игре мы будем производить перенаправление на главную страницу игры. Это запретит дальнейшую регистрацию игроков.

Листинг: Измененный метод действия Registration() контроллера Home

…
       [HttpPost]
       public ActionResult Registration(RegistrationWebModel model)
       {
           if (PlayersRepository.All.Any(p => p.IsPlay)) // Добавить
             return RedirectToAction("Index"); // Добавить
           var player = model.Map();
           var id = PlayersRepository.Add(player);
           return RedirectToAction("Index", "Wait", new { id });
       }
…

Запустите приложение в режиме отладки, откройте еще два дополнительных окна и зарегистрируйте три участника игры. После установки всех трех участников в статус готовности «Готов» появится кнопка начала игры. После нажатия по этой кнопке будет произведено перенаправление всех трех участников игры на страницу игры.

На следующем скриншоте в верхней части показаны три запущенных окна приложения до запуска игры. А в нижней части окна — те же самые окна после нажатия кнопки «Начать игру».

Мы успешно завершили реализацию страницы ожидания регистрации участников. Можно приступать к реализации следующей страницы, с функционалом самой игры.

Страница игры

Приступим к основной части приложения. Начнем реализацию страницы игры с самого простого — отображения имени участника. Потом добавим начальную инициализацию игры. Затем постепенно будем наполнять эту страницу интерактивными игровыми элементами.

Создайте новую веб модель игры с названием GameWebModel. В этой модели будут передаваться данные для визуального представления страницы игры. Наполните ее содержимым следующего листинга.

Листинг: Первоначальная вебмодель GameWebModel

using SpyOnlineGame.Models;

namespace SpyOnlineGame.Web.Models
{
    public class GameWebModel
    {
        public int Id { get; set; }
        public Player Current { get; set; }
    }
}

Далее создайте новую гипер медийную модель игры GameHypermedia. В эту гипермедийную модель будет вынесена вся логика из метода действия контроллера. Она очень сильно будет похожа на другую гипермедийную модель. Мы не будем проводить рефакторинг гипермедийных моделей и формирование абстрактной базовой гипермедийной модели ввиду нецелесообразности такого рефакторинга. Наполните класс гипермедийной модели содержимым следующего листинга.

Листинг: Гипермедийная модель GameHypermedia

using System.Linq;
using System.Web;
using SpyOnlineGame.Data;
using SpyOnlineGame.Models;
using SpyOnlineGame.Web.Models;

namespace SpyOnlineGame.Web.Hypermedia
{
    public class GameHypermedia
    {
        private readonly HttpRequestBase _request;
        private readonly int _id;
        private readonly Player _current;

        public bool IsHtmx => _request.Headers.AllKeys.Contains("hx-request");

        public bool IsNotFound => _current is null;

        public GameHypermedia(HttpRequestBase request, int id)
        {
            _request = request;
            _id = id;

            _current = PlayersRepository.GetById(_id);
        }

        public GameWebModel Model()
        {
            return new GameWebModel
            {
                Id = _id,
                Current = _current ?? new Player(),
            };
        }
    }
}

Теперь изменим содержимое метода действия контроллера Game, для того, чтобы он передавал полезные данные в визуальное представление, а также возвращал частичное визуальное представление с таблицей голосования в случае гипермедийного запроса. Добавьте новое содержимое согласно следующего листинга.

Листинг: Измененный контроллер игры Game

using System.Web.Mvc;

namespace SpyOnlineGame.Controllers
{
    public class GameController : Controller
    {
        public ActionResult Index(int id) // полностью заменить содержимое
        {
            var hypermedia = new GameHypermedia(Request, id);
            if (hypermedia.IsNotFound)
            {
                if (!hypermedia.IsHtmx) return RedirectToAction("Index", "Home");
                Response.Headers.Add("hx-redirect", Url.Action("Index", "Home"));
            }

            if (hypermedia.IsHtmx)
            {
                return PartialView("Partial/VotingPartial", hypermedia.Model());
            }
            return View(hypermedia.Model());
        }
    }
}

Создайте новое частичное представление голосования Views/Game/Partial/VotingPartial.cshtml и наполните его пустым содержимым. Позже наполним его содержимым.

Листинг: Частичное визуальное представление Views/Game/Partial/VotingPartial.cshtml

@model GameWebModel

<h6>Открытое голосование за определение шпиона</h6>

Измените визуальное представление страницы игры как на следующем листинге.

Листинг: Визуальное представление Views/Game/Index.cshtml

@model GameWebModel // Добавить
@{
    ViewBag.Title = "Игра";
    Layout = "~/Views/Shared/_GameLayout.cshtml";
}

<h4>@ViewBag.Title</h4>

<p>Ваше имя: <strong>@Model.Current.Name</strong></p> // Добавить

@Html.Partial("Partial/VotingPartial", Model) // Добавить

Пока это визуальное представление ничего не отображает полезного, кроме имени текущего участника. Однако это основная самая объемная часть приложения. Далее нам нужно добавить функциональность инициализации игры.

Инициализация игры

Эта функциональность срабатывает только один раз после запуска со страницы ожидания регистрации участников. Ради упрощения этого примера мы не будем реализовывать сброс игры. Главная цель статьи - ознакомиться с гипер медийными системами. Поэтому сконцентрируемся прежде всего на интерактивных визуальных элементах страниц. Однако перед добавлением следующих гипермедийных систем нам нужно обязательно создать логику начальной инициализации игры. Без корректно инициализированной игры двигаться дальше не имеет смысла.

Для начала создадим источник мест. Назначение этого источника — случайным образом выбирать какое‑либо место из списка доступных. Создайте новый класс LocationsSource в папке Models и наполните его содержимым из следующего листинга.

Листинг: Источник списка доступных мест LocationsSource

using System;

namespace SpyOnlineGame.Models
{
    public static class LocationsSource
    {
        private static Random _rand = new Random();

        private static string[] _locations =
        {
            "Банк",
            "Казино",
            "Больница",
            "Офис",
            "Казино",
        };

        public static string GetRandomLocation()
        {
            var locationNum = _rand.Next(_locations.Length);

            return _locations[locationNum];
        }
    }
}

Можете добавить произвольное количество своих уникальных мест. Количество мест в этом классе, как вы видите, не ограничено, главное чтобы про них мог догадаться шпион.

Добавьте поле для хранения назначенной роли игроку в класс Player как на следующем листинге. В процессе инициализации игры нужно одному из участников назначить роль шпиона. Этому участнику нельзя показывать скрытно выбранную случайное место. О месте должны знать только мирные игроки.

Создайте новый перечислимый тип RoleCode в папку Models. Наполните его содержимым из следующего листинга.

Листинг: Роли игроков RoleCode

namespace SpyOnlineGame.Models
{
    public enum RoleCode
    {
        Honest,
        Spy,
    }
}

Добавьте использование этого нового типа в классе участника игры Player.

Листинг: Модель участника игры Player

namespace SpyOnlineGame.Models
{
    public class Player
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public bool IsReady { get; set; }
        public bool IsPlay { get; set; }
        public RoleCode Role { get; set; } // Добавить
        public bool IsNeedUpdate { get; set; }
    }
}

В вебмодель игры добавьте новые свойства для передачи в визуальное представления сведений о выбранном месте и о том, кто первым задает вопрос.

Листинг: Веб модель игры GameWebModel с новыми свойствами

using SpyOnlineGame.Models;

namespace SpyOnlineGame.Web.Models
{
    public class GameWebModel
    {
        public int Id { get; set; }
        public Player Current { get; set; }
        public string Location { get; set; } // Добавить
        public string FirstName { get; set; } // Добавить
    }
}

В гипермедийной модели GameHypermedia нужно добавить прежде всего новый метод инициализации новой игры. Дополнительно нужно добавить еще одно полезное свойство — признак необходимости запуска игры.

Листинг: Обновленная гипермедийная модель GameHypermedia

using System;
using System.Linq;
using System.Web;
using SpyOnlineGame.Data;
using SpyOnlineGame.Models;
using SpyOnlineGame.Web.Models;

namespace SpyOnlineGame.Web.Hypermedia
{
    public class GameHypermedia
    {
        private static string _location; // Добавить
        private static string _firstName; // Добавить
        private readonly Random _rand = new Random(); // Добавить
        private readonly HttpRequestBase _request;
        private readonly int _id;
        private readonly Player _current;

        public bool IsHtmx => _request.Headers.AllKeys.Contains("hx-request");
        public bool IsNotFound => _current is null;

        public bool IsNeedInit => string.IsNullOrEmpty(_location) &&
          PlayersRepository.All.Any(p => p.IsPlay); // Добавить

        public GameHypermedia(HttpRequestBase request, int id)
        {
            _request = request;
            _id = id;
            _current = PlayersRepository.GetById(_id);
        }

        public void Init() // Добавить метод
        {
            if (!IsNeedInit) return;
            var all = PlayersRepository.All.ToArray();
            var firstNum = _rand.Next(all.Length);
            _firstName = all[firstNum].Name;
            _location = LocationsSource.GetRandomLocation();
            var spyNum = _rand.Next(all.Length);
            all[spyNum].Role = RoleCode.Spy;
        }

        public GameWebModel Model()
        {
            return new GameWebModel
            {
                Id = _id,
                Current = _current ?? new Player(),
                Location = _location, // Добавить
                FirstName = _firstName, // Добавить
            };
        }
    }
}

В метод действия Index() контроллера Game нужно добавить граничный оператор проверки необходимости запуска игры, как на следующем скриншоте. В случае необходимости запуска — нужно вызвать метод инициализации игры.

Листинг: Обновленный метод Index() контроллера Game

…
        public ActionResult Index(int id)
        {
            var hypermedia = new GameHypermedia(Request, id);
            if (hypermedia.IsNotFound)
            {
                if (!hypermedia.IsHtmx) return RedirectToAction("Index", "Home");
                Response.Headers.Add("hx-redirect", Url.Action("Index", "Home"));
            }
            if (hypermedia.IsNeedInit) hypermedia.Init(); // Добавить

            if (hypermedia.IsHtmx)
            {
                return PartialView("Partial/VotingPartial", hypermedia.Model());
            }
            return View(hypermedia.Model());
        }
…

В этом методе только один раз производится инициализация игры. Она производится как только любой первый участник перейдет на страницу игры со страницы ожидания игроков. 

Завершим добавление этой новой функциональности отображения информации о игре на странице игры. После этого можно будет запустить приложение и проверить правильность инициализации игры. Отредактируйте визуальное представление игры как на следующем листинге.

Листинг: Визуальное представление Views/Game/Index.cshtml

@model GameWebModel
@{
    ViewBag.Title = "Игра";
    Layout = "~/Views/Shared/_GameLayout.cshtml";
}

<h4>@ViewBag.Title</h4>

<p>Ваше имя: <strong>@Model.Current.Name</strong></p>

<div class="my-1"> // Добавить новый блок
    <span>Загадано место: <strong>@Model.Location</strong>.</span>
</div>

<div class="my-1"> // Добавить новый блок
    @if (Model.Current.Name == Model.FirstName)
    {
        <p>Вы <strong>первым</strong> задаете вопрос любому другому игроку.</p>
    }
    else
    {
          Первым вопрос задаёт <p><strong>@Model.FirstName</strong>.</p>
    }
</div>

@Html.Partial("Partial/VotingPartial", Model)

Теперь запустите приложение в режиме отладки. Запустите ещё два окна браузера, зарегистрируйте сразу трех пользователей, установите статус готовности и запустите игру. Вы должны увидеть представленный на следующем скриншоте результат. Должно отображаться случайно выбранное место и информация о том, кто первым задает вопрос. Каждому участнику игры должна быть назначена роль - шпион должен быть только один.

Если все в порядке с инициализацией игры, то можно переходить к наполнению этой страницы интерактивными игровыми элементами.

Завершение второй части

На этом заканчивается вторая часть моей статьи. В этой части вы провели рефакторинг, наполнили интерактивными элементами страницу ожидания регистрации участников игры и создали страницу самой игры. В следующей части мы займемся наполнением этой страницы интерактивными элементами и завершим разработку этого небольшого игрового приложения.

Комментарии (0)