Текстовая игра на Blazor с нуля (+сорсы +демо)

  • Tutorial
Идея о том, что можно писать веб приложение без JavaScript интересна многим, а особенно тем кто начинал свой путь в программирование с серверного языка. Внедрение Web Assembly может (наконец-то) позволить это полноценно реализовать. Писать всю логику приложения на одном языке — звучит довольно заманчиво. Тем более, если этот код компилируется в бинарник, а не в промежуточный язык.

Эту технологию хотелось бы увидеть в не типичных веб-приложениях. Например, как это — писать что-то более интерактивное на Web Assembly (Blazor). Предлагаю попробовать создать простую текстовую интерактивную игру. В игре есть возможность создать свой вопрос и отвечать на вопросы других игроков, набирая очки за правильные ответы. Всё на одной странице и с помощью Blazor.



Результат:

Github
Demo

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

Приятно видеть, что технология уже рабочая и можно даже попробовать поиграть (или посмотреть код) в что-то конкретное, например — шахматы, астероиды, и даже Diablo.
Но пока не попробуешь сам — не поймешь. Для того чтобы создать новый проект на Blazor, воспользуемся IDE или же напишем в консоле:

dotnet new blazorserver -o BlazorGame

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

Изначальная структура проекта проста, а именно 3 папки: Data, Pages, Shared. Для моделей, компонент и общих элементов соответственно.

Создание модели и сервиса


Удалим шаблонные классы из папки Data. И добавим туда новую модель для задания игры:

public class QuizItem
    {
        public Guid Id { get; set; }
        public string Question { get; set; }
        public string Answer { get; set; }
        public int Score { get; set; }
    }

А также примитивный сервис для заданий:

public class QuizService
    {
        private static readonly List<QuizItem> QuizItems;

        static QuizService()
        {
            QuizItems = new List<QuizItem> {
                new QuizItem
                {
                    Question = "4 + 7 = ?",
                    Answer = "11",
                    Score = 1
                },
                new QuizItem
                {
                    Question = "Where is the code of this application hosted?",
                    Answer = "Github",
                    Score = 5
                }
            };
        }

        public Task<List<QuizItem>> GetQuizesAsync()
        {
            return Task.FromResult(QuizItems);
        }
    }

Сервис зарегистрируем в ConfigureServices для того, чтобы достать его при необходимости, где потребуется.

Github commit #1

Создание интерфейса пользователя


Так как у нас есть базовая логика, можем приступить к UI. Как и в большинстве других UI фреймворков — Blazor использует компоненты в виде XML, из которого автоматические создается HTML для браузера. Переместимся в папку Pages, в которой поменяем страницу с примером (Index) на страницу для просмотра задания:

@page "/"

@using BlazorGame.Data
@inject QuizService QuizService

<p>Your current score is @currentScore</p>

@if (quiz == null)
{
    <p><em>Loading...</em></p>
}
else
{
    @foreach (var quizItem in quiz)
    {
        <section>
            <h3>@quizItem.Question</h3>
            <div>
                <input type="text" @oninput="@((eventArgs) => CheckAnswer(eventArgs.Value.ToString(), quizItem.Id))" />
            </div>
        </section>
    }
}

@code {
    List<QuizItem> quiz;
    int currentScore = 0;

    protected override async Task OnInitializedAsync()
    {
        quiz = await QuizService.GetQuizesAsync();
    }

    void CheckAnswer(string answer, Guid id)
    {
        var quizItem = quiz.SingleOrDefault(q => q.Id == id);
        var dbAnswer = quizItem?.Answer.ToLower();
        if (!string.IsNullOrEmpty(dbAnswer) && answer.ToLower().Contains(dbAnswer))
        {
            currentScore++;
            quiz.Remove(quizItem);
        }
    }
}

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

Github commit #2

Передача данных между компонентами


Сделаем базовый рефакторинг и разделим наш код на 2 компоненты:

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

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

@page "/"

@using BlazorGame.Data
@inject QuizService QuizService

<p>Your current score is <b>@state.CurrentScore</b></p>

@if (quiz == null)
{
    <p><em>Loading...</em></p>
}
else
{
    @foreach (var quizItem in quiz)
    {
        <QuizViewer Item="@quizItem" State="@state" OnScoreChanged="ScoreChanged" />
    }
}

@code {
    List<QuizItem> quiz;
    UserState state = new UserState();

    protected override async Task OnInitializedAsync()
    {
        quiz = await QuizService.GetQuizesAsync(state.UserId);
    }

    public async void ScoreChanged(int score)
    {
        state.CurrentScore = score;
    }
}


@using BlazorGame.Data
@inject QuizService QuizService

<section style="@style" >
    <h3>@Item.Question</h3>
    <div>
        <input type="text" @oninput="@((eventArgs) => CheckAnswer(eventArgs.Value.ToString(), Item.Id))" />
    </div>
</section>

@code{
    [Parameter]
    public QuizItem Item { get; set; }

    [Parameter]
    public UserState State { get; set; }

    [Parameter]
    public EventCallback<int> OnScoreChanged { get; set; }

    string style = "";

    async void CheckAnswer(string answer, Guid quizItemId)
    {
        var dbAnswer = Item?.Answer.ToLower();
        if (!string.IsNullOrEmpty(dbAnswer) && answer.ToLower().Contains(dbAnswer))
        {
            await OnScoreChanged.InvokeAsync(++State.CurrentScore);

            await QuizService.MarkAsDoneAsync(State.UserId, quizItemId);
            style = "display:none";
        }
    }
}

Github commit #3

Управление состоянием


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

Тут есть несколько нюансов. Во-первых, придется выключить серверный пре-рендеринг, потому как Local Storage не доступен на этом этапе. А во-вторых, причина первого пункта в том, что под капотом эта библиотека использует вызовы JavaScript. На сколько я понимаю это связано с тем, что на данный момент не готово решение как из Blazor достучаться до Local Storage через Web Assembly.

Обойдемся пока простым вариантом и будем хранить и читать идентификатор пользователя примерно следующим образом:

state.UserId = await ProtectedLocalStorage.GetAsync<Guid>("userId");

Github commit #4

Добавим социализации


Чтобы выглядело повеселей, добавим возможность пользователю самому создавать задание вопрос-ответ. Для этого воспользуемся компонентой фреймворка EditForm.

<EditForm Model="@quizItem" OnValidSubmit="CreateNewItem">
        <DataAnnotationsValidator />
        <ValidationSummary />

        <div class="form-group">
            <label>You can create your own question here.</label>
            <InputText class="form-control" id="question" @bind-Value="quizItem.Question" placeholder="Question" />
        </div>
        <div class="form-group">
            <InputText class="form-control" id="answer" @bind-Value="quizItem.Answer" placeholder="Answer" />
        </div>

        <button type="submit" class="btn btn-primary">Create</button>
    </EditForm>

Github commit #5

Для того, чтобы все это работало — добавим базу данных и логику для её использования. Здесь ничего нового. Код такой же как и для любого .NET Core приложения.

Github commit #6

Следующий шаг — несколько изменений в пользовательском интерфейсе.

Github commit #7

Ну и для полноты проверки фреймворка — добавим простой чат.

Выводы


Все это выглядит довольно интересно. С одной стороны не привычно. С другой же, похоже, что работает все довольно слажено. Кроме того, я ожидал увидеть банальные возможности писать простые скрипты на C# вместо JavaScript, а оказалось что функциональности очень много и разной (серверный и клиентский хостинг, взаимодействие с JS interop, возможность часть приложения писать с помощью Blazor, а часть как-то по-старому, использование .NET framework внутри браузера). В результате получилось, что Blazor позволяет создавать приложения где много инструментов для интерактива. Возможно из всего этого когда-то получится Веб 3.0.

Результат:

Github
Demo
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 23

    0
    Добрый день. У вас есть два бага:
      0
      Спасибо за замечания.
      1. Возможность писать ответы с лишними символами сделана специально, чтобы легче было отвечать на вопросы и не было проблем с тем, что где-то лишний пробел или ответ более общий.
      2. По тому, что текст остается в поле для ответа после правильного ответа — пока не решил как это красиво пофиксить. Возможно это даже баг самого Blazor, так как текст, фактически перебрасывается в другую компоненту.
        0
        С первым согласен, удобнее для пользователя, но с некоторым вопросами могут быть странности.
        Со вторым странно. Надо будет глянуть исходники.
        0
        Хабр не даёт редактировать сообщение, поэтому оформлю ответом.
        1) Не очищаются поля ввода при переключении на следующий вопрос.
        2) Ответ учитывается несмотря на лишние символы. Предположим есть вопрос, на который правильным ответом будет «Java», но пользователь сначала введя «Script», а потом перейдя в начало поля и введя «Java» получит балл, несмотря на то что ввёл неправильный ответ «JavaScript».
        –1
        Я таки продам гараж в Астрахани
          +1
          А где в этой штуке проходит граница фронт/бэк. Как сказать «Вот тут счетчик локально увеличь» или «Вот тут локально увеличь и отошли на сервер результаты»
            0
            Я тоже думал об этом, пока пробовал разные варианты имплементации. Пока не сформулировал для себя четкий ответ.
              0
              Ну так расскажи о разных=) я не знаком ни с C# ни с Razor, но выглядит интересно.
              В одном месте ты вызываешь
              _hubConnection.SendAsync("SendMessage", message, userId);

              а в другом
              await QuizService.CreateQuizItemAsync(State.UserId, quizItem);


              я правильно понмиаю, что оба метода вызывают код на бэкенде, но в первом случае ты делаешь это явно, а во втором случае, приложение само понимает, что ты вызываешь код из cs файла и потому надо отправить соответствующий запрос в бэкэнд?
                0
                Есть разные возможности писать код, для интерактива с сервером (через веб-сокеты или что-то еще). Сначала можно было писать только низкоуровневый код, где надо было «вручную» открывать конекшн, слать, закрывать — много кода.

                Поотом придумали фрейворк для этого (SignalR), в нём надо создать Хаб (клас, который удобно работает с конекшенами) и с клиента довольно просто слать что-то в этот хаб, или же ожидать от него сообщения. (Можно через C#, JS, ..). Пример такого подхода — как раз строка с _hubConnection.

                А теперь придумали еще Blazor, который еще более высокоуровневый и сам умеет создавать для себя свой хаб и держать его открытыми. А в коде надо просто написать вызов метода из класса, а Blazor уже сам решает — надо ли для этого идти на сервер. Как именно он это делает я еще сам не до конца разобрался. Возможно, просто все вызовы к класам или к зарегистрированным сервисам автоматически идут на сервер (если Blazor с серверным хостингом).
              +1
              Тоже интересуют этот момент. Заставлять пользователей грузить 1.5Мб WebAssembly как-то не очень хочется. Но и ходить на сервер при каждом нажатии кнопки — тоже такое себе.
                0
                1.5Мб — звучит не очень тяжеловесно. Вообще, подход с серверным хостингом — да, звучит не типично. Но это же только один из вариантов как можно использовать Blazor. Для приложений типа онлайн-игр, например это стандарт.
                  0
                  А где например онлайн-игры сделанные на Blazor, а не на Unity?
                    0
                    Про 1.5Мб ты бы закинул майлру =) Поржем вместе.

                    Для онлайн игр еще рано, только зарелизилось.
              –1
              Вот ничего интересного не узнал. Мне бы было интересно, в чем преимущество Blazor перед SPA фрейворками (Angular, React, Vue)? Минусы, плюсы. Статья из ряда, я попрыбывал, посмотрите.
                +2

                Как минимум в том, что C# разработчику не нужно разбираться в SPA фрейворке (Angular, React, Vue)

                  +2
                  Самое главное преимущество это конечно нормальный язык с типами, ООП и статическим анализом, а не JavaScript.
                    0
                    Например использовать одни и то же библиотеки на бэке и фронте, не надо будет отдельно описывать модели на js
                    0

                    А причем здесь WebAssembly? Открываю демку с веб-инспектором. Вижу 200 Кб Javascript который обрабатывает все события на сервере через веб-сокеты. WebAssembly здесь не пахнет.

                      0
                      Да, вы правы. В этой хостинг модели все работает через сервер. Приложение на wasm будет во много схоже. Как будет время — сделаю миграцию и отпишусь :)
                        0
                        Переделал ;)
                        habr.com/ru/post/507620
                        blazorgame.azurewebsites.net
                        Теперь настоящий WebAssembly
                        0

                        Что нашёл инженер на свалке?

                        0

                        спасибо огромное! как раз изучал эту тему, а тут ваша статья!

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

                        Самое читаемое