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

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

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

Интерактивность игровой страницы

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

Рефакторинг веб моделей

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

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

Листинг: Веб модель участника игры PlayerWebModel

using SpyOnlineGame.Models;

namespace SpyOnlineGame.Web.Models
{
    public class PlayerWebModel
    {
        public static PlayerWebModel Default => new PlayerWebModel();


        public static PlayerWebModel Create(Player current)
        {
            return new PlayerWebModel
            {
                Id = current.Id,
                Name = current.Name,
                IsReady = current.IsReady,
                Role = current.Role,
            };
        }

        public int Id { get; private set; }
        public string Name { get; private set; }
        public bool IsReady { get; private set; }
        public RoleCode Role { get; private set; }
    }
}

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

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

Листинг: Веб модель ожидания участников WaitWebModel

using System;
using System.Collections.Generic;
using System.Linq; // Добавить
using SpyOnlineGame.Data; // Добавить
using SpyOnlineGame.Models;

namespace SpyOnlineGame.Web.Models
{
    public class WaitWebModel
    {
        public static WaitWebModel Create(int id, Player current, 
          bool isMayBeStart) // Добавить новый метод
        {
            return new WaitWebModel
            {
                Id = id,
                Current = PlayerWebModel.Create(current) 
                  ?? PlayerWebModel.Default,
                All = PlayersRepository.All.Select(PlayerWebModel.Create),
                IsMayBeStart = isMayBeStart,
            };
        }

        public int Id { get; private set; } // Изменить
        public PlayerWebModel Current { get; private set; } // Изменить
        public IEnumerable<PlayerWebModel> All { get; private set; } 
          = Array.Empty<PlayerWebModel>(); // Изменить
        public bool IsMayBeStart { get; private set; } // Изменить
    }
}

Затем проведите подобные изменения и веб модели игры GameWebModel.

Листинг: Веб модель ожидания участников GameWebModel

using SpyOnlineGame.Models;

namespace SpyOnlineGame.Web.Models
{
    public class GameWebModel
    {
        public static GameWebModel Create(int id, Player current, 
          string location, string firstName) // Добавить новый метод
        {
            return new GameWebModel
            {
                Id = id,
                Current = PlayerWebModel.Create(current) 
                  ?? PlayerWebModel.Default,
                Location = location,
                FirstName = firstName,
            };
        }

        public int Id { get; private set; } // Изменить
        public PlayerWebModel Current { get; private set; } // Изменить
        public string Location { get; private set; } // Изменить
        public string FirstName { get; private set; } // Изменить
    }
}

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

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

…
        public void Start()
        {
            if (!IsMayBeStart) return;
            foreach (var each in PlayersRepository.All)
            {
                each.IsPlay = true;
            }
            PlayersRepository.IsNeedAllUpdate();
        }

        public WaitWebModel Model() =>
            WaitWebModel.Create(_id, _current, IsMayBeStart); // Изменить
…

По аналогии с этой моделью, также снимите ответственность с гипермедийной модели игры GameHypermedia, как в следующем листинге.

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

…
        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() =>
           GameWebModel.Create(_id, _current, _location, _firstName); // Изменить
…

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

Закрытый показ загаданного места

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

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

Листинг: Вебмодель отображения места LocationWebModel

using SpyOnlineGame.Models;

namespace SpyOnlineGame.Web.Models
{
    public class LocationWebModel
    {
        public static LocationWebModel Create(int id, Player current, 
          string location, bool isShow)
        {
            return new LocationWebModel
            {
                Id = id,
                Role = current?.Role ?? RoleCode.Honest,
                Location = location,
                IsShow = isShow,
            };
        }

        public int Id { get; set; }
        public RoleCode Role { get; set; }
        public string Location { get; set; }
        public bool IsShow { get; set; }
    }
}

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

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

using SpyOnlineGame.Models;

namespace SpyOnlineGame.Web.Models
{
    public class GameWebModel
    {
        public static GameWebModel Create(int id, Player current, 
          string firstName) // Изменить
        {
            return new GameWebModel
            {
                Id = id,
                Current = PlayerWebModel.Create(current) 
                  ?? PlayerWebModel.Default,
                // Удалить
                FirstName = firstName,
            };
        }

        public int Id { get; private set; }
        public PlayerWebModel Current { get; private set; }
        // Удалить
        public string FirstName { get; private set; }
    }
}

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

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

…
        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 LocationWebModel Location(bool isShow) =>
            LocationWebModel.Create(_id, _current, _location, !isShow); // Замена

        public GameWebModel Model() =>
            GameWebModel.Create(_id, _current, _firstName); // Замена
…

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

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

…
        public ActionResult Index(int id)
        {
…
        }

        public ActionResult Location(int id, bool isShow) // Добавить метод
        {
            var hypermedia = new GameHypermedia(Request, id);
            var model = hypermedia.Location(isShow);
            return PartialView("Partial/LocationPartial", model);
        }
…

Этот новый метод действия контроллера Game в качестве результата выполнения возвращает частичное представление которое мы еще не создали. Создайте новое частичное представление View/Game/Partial/LocationPartial.cshtml и наполните его содержимым следующего листинга.

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

@using SpyOnlineGame.Models
@model LocationWebModel

@if (Model.IsShow)
{
    <p>Загадано место: <strong>@(Model.Role == RoleCode.Spy 
        ? "Вы шпион" : Model.Location)</strong>.</p>
}
else
{
    <p>Держите место в тайне от другого игрока. Возможно он шпион.</p>
}

<button class="btn btn-dark"
        hx-get="@Url.Action("Location", new { Model.Id, Model.IsShow })"
        hx-target="#location">
    @(Model.IsShow ? "Скрыть" : "Показать")
</button>

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

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

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

<h4>@ViewBag.Title</h4>

<div class="my-1">
    <p>Ваше имя: <strong>@Model.Current.Name</strong></p>
</div>

<div id="location" class="my-1"> // Добавить весь новый блок
    @Html.Partial("Partial/LocationPartial", 
    new LocationWebModel { Id = Model.Id })
</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)

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

Таблица голосования

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

Листинг: Модель участника игры 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 int VotePlayerId { get; set; } // Добавить
        public bool IsVoted { get; set; } // Добавить
        public bool IsNeedUpdate { get; set; }
    }
}

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

Листинг: Измененная веб модель участника игры PlayerWebModel

using SpyOnlineGame.Data;
using SpyOnlineGame.Models;

namespace SpyOnlineGame.Web.Models
{
    public class PlayerWebModel
    {
        public static PlayerWebModel Default => new PlayerWebModel();
        public static PlayerWebModel Create(Player current)
        {
            return new PlayerWebModel
            {
                Id = current.Id,
                Name = current.Name,
                IsReady = current.IsReady,
                Role = current.Role,
                VotePlayerId = current.VotePlayerId, // Добавить
                VotePlayerName = PlayersRepository.GetById(current.VotePlayerId)?
                  .Name ?? "Нет", // Добавить
                IsVoted = current.IsVoted, // Добавить
            };
        }

        public int Id { get; private set; }
        public string Name { get; private set; }
        public bool IsReady { get; private set; }
        public RoleCode Role { get; private set; }
        public int VotePlayerId { get; set; } // Добавить
        public string VotePlayerName { get; set; } // Добавить
        public bool IsVoted { get; set; } // Добавить
    }
}

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

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

using SpyOnlineGame.Models;
using System.Collections.Generic; // Добавить
using System; // Добавить
using System.Linq; // Добавить
using SpyOnlineGame.Data; // Добавить

namespace SpyOnlineGame.Web.Models
{
    public class GameWebModel
    {
        public static GameWebModel Create(int id, Player current, 
          string firstName)
        {
            var all = PlayersRepository.All.Where(p => p.IsPlay)
              .Select(PlayerWebModel.Create); // Добавить

            return new GameWebModel
            {
                Id = id,
                Current = PlayerWebModel.Create(current) 
                  ?? PlayerWebModel.Default,
                FirstName = firstName,
                All = all, // Добавить
            };
        }

        public int Id { get; private set; }
        public PlayerWebModel Current { get; private set; }
        public string FirstName { get; private set; }
        public IEnumerable<PlayerWebModel> All { get; set; } 
          = Array.Empty<PlayerWebModel>(); // Добавить
    }
}

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

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

@model GameWebModel

<h6>Открытое голосование за определение шпиона</h6>
<table class="table table-striped table-bordered"> // Добавить новую таблицу
    <thead>
        <tr>
            <th>Игрок:</th>
            @foreach (var each in Model.All)
            {
                <th>@each.Name</th>
            }
        </tr>
    </thead>
    <tbody>
        <tr>
            <th scope="row" class="align-middle">Голос:</th>
            @foreach (var each in Model.All)
            {
                if (each.Id == Model.Id)
                {
                    <td>Список голосования</td>
                }
                else
                {
                    <td class="align-middle">@each.VotePlayerName</td>
                }
            }
        </tr>
    </tbody>
</table>

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

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

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

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

Раскрывающийся список голосования

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

Добавьте новый класс вспомогательных инструментов с названием GameHelpers в новую папку Common.

Листинг: Вспомогательный класс Common/GameHelpers

using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
using SpyOnlineGame.Data;
using SpyOnlineGame.Models;
using SpyOnlineGame.Web.Models;

namespace SpyOnlineGame.Common
{
    public static class GameHelpers
    {
        public static IEnumerable<SelectListItem> CreateVariantsForDropDown(
            this Player current, int id)
        {
            var othersLivesPlayer = CreateAllPlayers().Where(p => p.Id != id);
            var variants = new List<SelectListItem> { new SelectListItem
                { Value = "0", Text = "Сомневаюсь" } };
            variants.AddRange(othersLivesPlayer.Select(p =>
                new SelectListItem
                {
                    Value = p.Id.ToString(),
                    Text = p.Name,
                    Selected = p.Id == current.VotePlayerId,
                }));
            return variants;
        }

        public static bool CheckMayBeVote(int id)
        {
            return MaxVoteCount(id) >= CountOfLivesPlayers() - 1;
        }

        public static int MaxVoteCount(int id)
        {
            var grouped = GroupedByVotePlayers(id);
            if (!grouped.Any()) return 0;
            return grouped.Select(p => p.Count()).Max();
        }

        public static IGrouping<int, PlayerWebModel>[] GroupedByVotePlayers(
          int id)
        {
            var actualPlayers = CreateAllPlayers().Where(p => p.VotePlayerId != 0
              && p.VotePlayerId != id);
            var result = actualPlayers.GroupBy(p => p.VotePlayerId).ToArray();
            return result;
        }

        public static int CountOfLivesPlayers()
        {
            return CreateAllPlayers().Count(p => !p.IsVoted);
        }

        public static IEnumerable<PlayerWebModel> CreateAllPlayers()
        {
            return PlayersRepository.All.Where(p => p.IsPlay)
                .Select(PlayerWebModel.Create);
        }
    }
}

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

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

Листинг: Измененная веб модель игры GameWebModel

using SpyOnlineGame.Models;
using System.Collections.Generic;
using System;
using System.Web.Mvc; // Добавить
using SpyOnlineGame.Data;

namespace SpyOnlineGame.Web.Models
{
    public class GameWebModel
    {
        public static GameWebModel Create(int id, Player current, 
          string firstName)
        {
            return new GameWebModel
            {
                Id = id,
                Current = PlayerWebModel.Create(current) 
                          ?? PlayerWebModel.Default,
                FirstName = firstName,
                All = GameHelpers.CreateAllPlayers(), // Изменить
             PlayersVariants = current.CreateVariantsForDropDown(id), // Изменить
                IsMayBeVote = GameHelpers.CheckMayBeVote(id), // Изменить
            };
        }

        public int Id { get; private set; }
        public PlayerWebModel Current { get; private set; }
        public string FirstName { get; private set; }
        public IEnumerable<PlayerWebModel> All { get; private set; } 
            = Array.Empty<PlayerWebModel>();
        public IEnumerable<SelectListItem> PlayersVariants { get; private set; } 
            = Array.Empty<SelectListItem>(); // Добавить
        public bool IsMayBeVote { get; private set; } // Добавить
    }
}

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

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

…
        public bool IsNotFound => _current is null;

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

        public bool IsNeedInit => 
            string.IsNullOrEmpty(_location) && PlayersRepository.All.Any(p => p.IsPlay);

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

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

        public void Init()
        {
…
        }

        public void Select(int votePlayerId) // Добавить новый метод
        {
            _current.VotePlayerId = votePlayerId;
            PlayersRepository.IsNeedAllUpdate();
        }

        public LocationWebModel Location(bool isShow) =>
            LocationWebModel.Create(_id, _current, _location, !isShow);

        public GameWebModel Model() =>
            GameWebModel.Create(_id, _current, _firstName);
    }
}

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

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

…
        public ActionResult Index(int id)
        {
            var hypermedia = new GameHypermedia(Request, id);
            if (hypermedia.IsNotFound)
            {
                return RedirectToAction("Index", "Home");
            }
            if (hypermedia.IsNeedInit) hypermedia.Init();
            if (hypermedia.IsNoContent) // Добавить
              return new HttpStatusCodeResult(HttpStatusCode.NoContent); // Добавить

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

        public ActionResult Location(int id, bool isShow)
        {
…
        }

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

Теперь изменим визуальное представление страницы игры Views/Game/Index.cshtml. Замените простое отображение частичного представления гипермедийным элементом.

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

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

<div id="voting" class="my-1"
     hx-get="@Url.Action("Index", "Game", new { Model.Id })"
     hx-trigger="every 1s"> // Заменить новым блоком
    @Html.Partial("Partial/VotingPartial", Model)
</div>

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

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

@model GameWebModel

<h6>Открытое голосование за определение шпиона</h6>
<table class="table table-striped table-bordered">
    <thead>
        <tr>
            <th>Игрок:</th>
            @foreach (var each in Model.All)
            {
                <th>@each.Name</th>
            }
        </tr>
    </thead>
    <tbody>
        <tr>
            <th scope="row" class="align-middle">Голос:</th>
            @foreach (var each in Model.All)
            {
                if (each.Id == Model.Id)
                {
                    <td>
                        // Добавить следую новый блок вместо текста
                        @Html.DropDownList("VotePlayerId", Model.PlayersVariants,
                          null, new
                        {
                            @class = "form-select",
                            hx_get = Url.Action("Select", "Game", 
                              new { Model.Id }),
                            hx_target = "#voting",
                        }) 
                    </td>
                }
                else
                {
                    <td class="align-middle">@each.VotePlayerName</td>
                }
            }
        </tr>
    </tbody>
</table>

@if (Model.IsMayBeVote) // Добавить новый блок
{
    <button class="btn btn-primary mt-2">
        Подтвердить голосование
    </button>
}

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

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

Кнопка подтверждения голосования

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

Листинг: Вспомогательный класс GameHelpers

using System.Linq;
using SpyOnlineGame.Data;

namespace SpyOnlineGame.Common
{
    public static class VotedHelpers
    {
        public static int? GetVotedPlayerId()
        {
            var grouped = GameHelpers.GroupedByVotePlayers(0);
            var result = grouped
                .Select(p => new { p.Key, count = p.Count() })
                .OrderByDescending(p => p.count)
                .FirstOrDefault()?.Key;
            return result;
        }

        public static void VotedOfPlayer(int? votePlayerId)
        {
            if (votePlayerId == null) return;
            var votedPlayer = PlayersRepository.GetById((int)votePlayerId);
            votedPlayer.IsVoted = true;
            foreach (var each in PlayersRepository.All)
            {
                each.VotePlayerId = 0;
            }
            PlayersRepository.IsNeedAllUpdate();
        }
    }
}

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

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

…
        public void Select(int votePlayerId)
        {
            _current.VotePlayerId = votePlayerId;
            PlayersRepository.IsNeedAllUpdate();
        }

        public void Confirm() // Новый метод
        {
            if (!GameHelpers.CheckMayBeVote(_id)) return;
            var votePlayerId = VotedHelpers.GetVotedPlayerId();
            VotedHelpers.VotedOfPlayer(votePlayerId);
        }

        public LocationWebModel Location(bool isShow) =>
            LocationWebModel.Create(_id, _current, _location, !isShow);

        public GameWebModel Model() =>
            GameWebModel.Create(_id, _current, _firstName);
…

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

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

…
        public ActionResult Select(int id, int votePlayerId)
        {
            var hypermedia = new GameHypermedia(Request, id);
            hypermedia.Select(votePlayerId);
            return Index(id);
        }

        public void Confirm(int id) // Новый метод
        {
            var hypermedia = new GameHypermedia(Request, id);
            hypermedia.Confirm();
        }
…

Теперь наконец, Теперь наконец, мы можем перейти к частичному визуальному представлению голосования за определение шпиона Views/Game/Partial/VotingPartial.cshtml. Полностью замените его содержимым из следующего следующего листинга, так как изменения слишком обширные.

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

@using SpyOnlineGame.Models
@model GameWebModel

@if (Model.Current.IsVoted)
{
    <h6 class="bg-dark text-light p-2">Вы были убиты и теперь можете 
      только наблюдать</h6>
}
else
{
    <h6>Открытое голосование за определение шпиона</h6>
}
<div class="table-responsive">
    <table class="table table-striped table-bordered">
        <thead>
            <tr>
                <th>Игрок:</th>
                @foreach (var each in Model.All)
                {
                    if (each.IsVoted)
                    {
                        <th class="bg-dark text-light">@each.Name</th>
                    }
                    else
                    {
                        <th>@each.Name</th>
                    }
                }
            </tr>
        </thead>
        <tbody>
            <tr>
                <th scope="row" class="align-middle">Голос:</th>
                @foreach (var each in Model.All)
                {
                    if (each.IsVoted)
                    {
                        if (each.Role == RoleCode.Spy)
                        {
                            <td class="bg-danger text-light align-middle">
                              Шпион</td>
                        }
                        else
                        {
                            <td class="bg-success text-light align-middle">
                              Мирный</td>
                        }
                    }
                    else
                    {
                        if (each.Id == Model.Id)
                        {
                            <td>
                                @Html.DropDownList("VotePlayerId", 
                                  Model.PlayersVariants,
                                    null, new
                                    {
                                        @class = "form-select",
                                        hx_get = Url.Action("Select", "Game",
                                            new { Model.Id }),
                                        hx_target = "#voting",
                                    })
                            </td>
                        }
                        else
                        {
                            <td class="align-middle">@each.VotePlayerName</td>
                        }
                    }
                }
            </tr>
        </tbody>
    </table>
</div>

@if (Model.IsMayBeVote && !Model.Current.IsVoted)
{
    <button class="btn btn-primary mt-2"
            hx-get="@Url.Action("Confirm", new { Model.Id })">
        Уничтожить шпиона
    </button>
}

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

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

Убедитесь, что голосование успешно работает. Нам осталось только добавить конец игры.

Конец игры

По правилам конец игры наступает в одном из двух случаев:

  • В случае победы мирных - когда шпион вычислен и успешно устранен.

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

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

Листинг: Вспомогательный класс RulesHelpers

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

namespace SpyOnlineGame.Common
{
    public static class RulesHelpers
    {
        public static bool CheckEndGame()
        {
            var lives = CreateLivesPlayers().ToArray();
            var spyCount = lives.Count(p => p.Role == RoleCode.Spy);
            var honestCount = lives.Count(p => p.Role == RoleCode.Honest);
            return spyCount == 0 || honestCount <= spyCount;
        }

        private static IEnumerable<PlayerWebModel> CreateLivesPlayers()
        {
            return GameHelpers.CreateAllPlayers().Where(p => !p.IsVoted);
        }
    }
}

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

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

…
    public class GameHypermedia
    {
        private static string _location;
        private static string _firstName;
        private static bool _isEndGame; // Добавить
        private readonly Random _rand = new Random();
        private readonly HttpRequestBase _request;
        private readonly int _id;
        private readonly Player _current;
…
        public bool IsNeedInit => string.IsNullOrEmpty(_location) 
          && PlayersRepository.All.Any(p => p.IsPlay);

        public bool IsEndGame => _isEndGame; // Добавить

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

            _current = PlayersRepository.GetById(_id);
        }
…
        public void Select(int votePlayerId)
        {
            _current.VotePlayerId = votePlayerId;
            PlayersRepository.IsNeedAllUpdate();
        }

        public void Confirm()
        {
            if (!GameHelpers.CheckMayBeVote(_id)) return;
            var votePlayerId = VotedHelpers.GetVotedPlayerId();
            VotedHelpers.VotedOfPlayer(votePlayerId);
            if (RulesHelpers.CheckEndGame()) // Добавить
            {
                _isEndGame = true; // Добавить
            }
        }
…

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

Листинг: Перенаправление в методе контроллере 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.IsEndGame) // Добавить блок кода
            {
                if (!hypermedia.IsHtmx) return RedirectToAction("Index", "End", 
                    new { id });
                Response.Headers.Add("hx-redirect", Url.Action("Index", "End", 
                    new { id }));
            }
            if (hypermedia.IsNoContent)
                return new HttpStatusCodeResult(HttpStatusCode.NoContent);

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

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

Листинг: Контроллер окончания игры End

using System.Web.Mvc;

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

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

@{
    ViewBag.Title = "Конец игры";
}

<h2>Конец игры</h2>

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

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

Результаты игры

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

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

Листинг: Веб модель окончания игры EndWebModel

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

namespace SpyOnlineGame.Web.Models
{
    public class EndWebModel
    {
        public static EndWebModel Create(Player current)
        {
            var honestPlayers = GameHelpers.CreateAllPlayers()
              .Where(p => p.Role == RoleCode.Honest);
            var spyPlayer = GameHelpers.CreateAllPlayers()
              .First(p => p.Role == RoleCode.Spy);
            var isWinOfHonestPlayers = spyPlayer.IsVoted;
            var isCurrentWin = current.Role == RoleCode.Honest &&
              isWinOfHonestPlayers || current.Role == RoleCode.Spy &&
              !isWinOfHonestPlayers;
            return new EndWebModel
            {
                IsWinOfHonestPlayers = isWinOfHonestPlayers,
                Current = PlayerWebModel.Create(current) ??
                  PlayerWebModel.Default,
                IsCurrentWin = isCurrentWin,
                HonestPlayers = honestPlayers,
                SpyPlayer = spyPlayer,
            };
        }
        // Победа мирных
        public bool IsWinOfHonestPlayers { get; private set; }
        // Текущий игрок
        public PlayerWebModel Current { get; private set; }
        // Текущий игрок - в команде победителей
        public bool IsCurrentWin { get; private set; }
        // Команда мирных игроков
        public IEnumerable<PlayerWebModel> HonestPlayers { get; private set; } =
          Array.Empty<PlayerWebModel>();
        // Шпион
        public PlayerWebModel SpyPlayer { get; private set; }
    }
}

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

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

Листинг: Веб сервис окончания игры EndWebService

using SpyOnlineGame.Data;
using SpyOnlineGame.Models;
using SpyOnlineGame.Web.Models;

namespace SpyOnlineGame.Web.Services
{
    public class EndWebService
    {
        private readonly Player _current;

        public EndWebService(int id)
        {
            _current = PlayersRepository.GetById(id);
        }

        public EndWebModel Model() =>
            EndWebModel.Create(_current);
    }
}

Произведите изменения в главном методе действия контролера окончания игры End, как в следующем листинге.

Листинг: Контроллер окончания игры End

using System.Web.Mvc;

namespace SpyOnlineGame.Controllers
{
    public class EndController : Controller
    {
        public ActionResult Index(int id)
        {
            var webService = new EndWebService(id); // Добавить
            return View(webService.Model()); // Добавить
        }
    }
}

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

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

@model EndWebModel
@{
    ViewBag.Title = Model.IsCurrentWin ? "Победа" : "Проигрыш";
}

<div class="my-4 p-5 text-light rounded @(Model.IsCurrentWin 
    ? Model.IsWinOfHonestPlayers ? "bg-success" 
    : "bg-danger" : "bg-secondary")">
    <h1 class="text-center">@ViewBag.Title</h1>
    
    <p>Ваше имя: <strong>@Model.Current.Name</strong></p>

    @if (Model.IsWinOfHonestPlayers)
    {
        <p>Победа мирных.</p>
    }
    else
    {
        <p>Победа шпиона.</p>
    }
    @if (Model.IsCurrentWin)
    {
        <p>Вы выиграли в этой игре!</p>
    }
    else
    {
        <p>К сожалению, вы проиграли.</p>
    }

    <h5 class="mt-2">Мирные игроки:</h5>
    <ol class="list-group list-group-numbered">
        @foreach (var each in Model.HonestPlayers)
        {
            <li class="list-group-item">@each.Name</li>
        }
    </ol>
    
    <h5 class="mt-2">Шпион:</h5>
    <ol class="list-group list-group-numbered">
        <li class="list-group-item">@Model.SpyPlayer.Name</li>
    </ol>
</div>

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

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

Мы завершили создание настольной онлайн игры “Шпион”. Приложение содержит множество гипермедийных элементов и может быть легко расширено новым.

Продолжение

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

  1. Добавить отображение правил игры на страницах ожидания и самой игры. Это можно реализовать в виде гипермедийной кнопки, клик по которой поочередно отображает/скрывает рядом на странице правила настольной игры “Шпион”.

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

  3. Добавить на страницу игры таймер времени, по истечении которого шпионы побеждают.

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

Заключение

В этой статье мы создали новый обучающий MVC проект на устаревшей платформе ASP.NET MVC 5 и использовали его для построения простой настольной игры. Теперь вы имеете первое представление о гипермедийных системах на основе библиотеки Htmx.js. Также вы теперь вы имеете начальные знания про добавление интерактивности в веб-приложение с помощью гипермедийных систем.

Однако ради упрощения материала статьи я решил не рассматривать многие интересные аспекты использования гипермедийных систем. Не была рассмотрена валидация формы ввода с помощью гипермедийной системы. Ради упрощения материала был использован очень простой способ связи между браузером и сервером - пулинг, предполагающий регулярную отправку запросов на сервер. Ради упрощения примера решено было бизнес-логику не выносить в доменные модели приложения, а оставить в веб и гипермедийных моделях и сервисах. Из-за дополнительной ненужной сложности было решено не добавлять контейнер внедрения зависимостей в проект на устаревшей платформе ASP.NET MVC 5.

За более полными теоретическими сведениями по гипермедийным системам вам следует обратиться к первоисточнику по этой тематике. Это книга Карсона Гросса “Hypermedia-разработка. Htmx и Hyperview” с примерами на на языке программирования Python и веб фреймворке Flask. 

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

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


  1. jpegqs
    21.09.2024 02:38

    @moderator Разберитесь там уже, почему у некоторых авторов статьи дублируются по два три раза, и отхватывают за это минусов. Это они что-то неправильно делают или ваш сайт глючит.

    https://habr.com/ru/articles/844984
    https://habr.com/ru/articles/844964
    https://habr.com/ru/articles/844932


    1. ColdPhoenix
      21.09.2024 02:38

      Это 3 разные части.

      Разве что кат одинаковый у человека(на мобильной версии его нет).


      1. kanadeiar Автор
        21.09.2024 02:38

        Я немного подредактировал названия, рисунки и аннотации к статьям. Надеюсь что так вам удобнее будет ориентироваться в частях моей статьи.


    1. kanadeiar Автор
      21.09.2024 02:38

      Это моя одна небольшая статья, но к сожалению пришлось разделить ее на три равных части. Я в аннотации к каждой статье подробно написал об этом.

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


      1. jpegqs
        21.09.2024 02:38

        Тогда минусы вам заслуженные, не надо так делать. Три огромные статьи на 100500 экранов. Опубликуйте первую часть, дождитесь реакции, только тогда публикуйте следующие, если будет запрос (можно опрос добавить).


        1. kanadeiar Автор
          21.09.2024 02:38

          Да, заслуженные, может быть. Я считаю свою статью небольшой (всего-то полтора часа времени), но просто с большим количеством скриншотов и листингов.


          1. jpegqs
            21.09.2024 02:38

            Полтора часа это очень много.