2

Делаю первые шаги в C#. Начал с консольного приложения, где реализуется квест: текст и меню. Выбираешь стрелками клавиатуры вариант, нажимаешь enter, переходишь к следующей менюшке с выбором, сопровождающийся комментарием. Возник вопрос можно ли перенести такую историю на WPF? Или это крайне трудоемко - постоянно работать с элементами меню? Прикрепляю пример

  • Перезалейте скрин с замазанной нецензурщиной. – aepot Apr 11 '21 at 19:57
  • 1
    Возник вопрос можно ли перенести такую историю на WPF? Да. Или это крайне трудоемко Мне например легче лёгкого, а как вам - не знаю. Начните учить матчасть, ключевые слова: "привязка данных", "MVVM в WPF", "ООП", "SOLID". Разработка приложений с графическим интерфейсом сильно отличается от консоли, но то что принципы используются другие в разработке - совершенно не значит, что одно сложнее или проще другого. Если вам скучно читать, и нужно сразу в бой, вот, разберитесь как это работает. – aepot Apr 11 '21 at 20:06

1 Answers1

4

Ну например так.

Будем писать сразу на MVVM. Поскольку ваша игра — походовая стратегия, то естественно моделировать состояние игры как стейт-машину. Стейты и таблица переходов у нас, разумеется, не меняются в течение игры (то есть immutable). Это даст такую структуру.

class GameStepVM
{
    public GameStepVM(string description, IReadOnlyList<GameStepOptionVM> options)
    {
        Description = description;
        Options = options;
    }
public string Description { get; }
public IReadOnlyList&lt;GameStepOptionVM&gt; Options { get; }

}

class GameStepOptionVM { public GameStepOptionVM(string text) => Text = text;

public string Text { get; }

}

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

GameStepVM описывает одно состояние игры, GameStepOptionVM — возможные переходы из него.

Сама логика игры умещается в класс GameVM. Поскольку это класс содержит изменяемые данные, нам необходимо будет реализовать INotifyPropertyChanged, а для этого имеет смысл воспользоваться универсальной заготовкой:

public class VM : INotifyPropertyChanged
{
    protected bool Set<T>(ref T field, T value,
                          [CallerMemberName] string propertyName = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value))
            return false;
    field = value;
    RaisePropertyChanged(propertyName);
    return true;
}

protected void RaisePropertyChanged([CallerMemberName] string propertyName = null) =&gt;
    PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));

public event PropertyChangedEventHandler PropertyChanged;

}

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

class GameVM : VM
{
    public GameVM(ImmutableDictionary<GameStepOptionVM, GameStepVM> transitions,
                  GameStepVM initialStep)
    {
        Transitions = transitions;
        currentStep = initialStep;
        MakeStepCommand = new RelayCommand<GameStepOptionVM>(MakeStep);
    }
readonly ImmutableDictionary&lt;GameStepOptionVM, GameStepVM&gt; Transitions;

GameStepVM currentStep;
public GameStepVM CurrentStep
{
    get =&gt; currentStep;
    private set =&gt; Set(ref currentStep, value);
}

public ICommand MakeStepCommand { get; }

void MakeStep(GameStepOptionVM option)
{
    CurrentStep = Transitions[option];
    // advance time, etc.
}

}

Команду смены состояния я публикую как ICommand, а не публичный метод, имея в виду, что вызываться это будет из UI.

Построение графа состояний мы делегировали классу Initializer, который у меня создаёт всё вручную, а у вас, вероятно, должен быть читать данные из ресурсов (например, из файла):

static class Initializer
{
    public static (ImmutableDictionary<GameStepOptionVM, GameStepVM> transitions, GameStepVM initialStep) CreateGraph()
    {
        // load steps; this should be perhaps done from a file
        var steps = new List<GameStepVM>()
        {
            new GameStepVM("Понедельник, 8 утра. Невыспавшийся ты притащил своё тело на пару по Английскому в великий и могучий Гидрак",
                    new[]
                    {
                        new GameStepOptionVM("Ботать"),
                        new GameStepOptionVM("Спать на задней парте"),
                        new GameStepOptionVM("Зарубиться в 2048 на Xiaomi"),
                        new GameStepOptionVM("Сходить ешё за кофе"),
                        new GameStepOptionVM("Да пошло оно к ху@м! (отчислиться)"),
                    }),
            new GameStepVM("Вы отчислены",
                    new[]
                    {
                        new GameStepOptionVM("Наслаждаться жизнью, готовиться в армию")
                    })
        };
        var transitions = new Dictionary<GameStepOptionVM, GameStepVM>()
        {
            { steps[0].Options[0], steps[0] }, // in step 0, option 0 -> step 0
            { steps[0].Options[1], steps[1] }, // etc.
            { steps[0].Options[2], steps[1] },
            { steps[0].Options[3], steps[1] },
            { steps[0].Options[4], steps[1] },
            { steps[1].Options[0], null }
        };
        return (transitions.ToImmutableDictionary(), steps[0]);
    }
}

Остальную бизнес-логику типа подсчёта времени легко дописать самостоятельно в классе GameVM.


С логикой всё, переходим к UI.

Отображение одного шага удобно сделать в виде UserControl'а. Что нам понадобится на вход? GameStep и команда перехода. Отлично, пишем UserControl Нужные параметры положим в виде DependencyProperty.

public partial class GameStepPresentation : UserControl
{
    public GameStepPresentation()
    {
        InitializeComponent();
    }
public object Step
{
    get { return (object)GetValue(StepProperty); }
    set { SetValue(StepProperty, value); }
}

public static readonly DependencyProperty StepProperty =
    DependencyProperty.Register(
        &quot;Step&quot;, typeof(object), typeof(GameStepPresentation));

public ICommand TransitionCommand
{
    get { return (ICommand)GetValue(TransitionCommandProperty); }
    set { SetValue(TransitionCommandProperty, value); }
}

public static readonly DependencyProperty TransitionCommandProperty =
    DependencyProperty.Register(
        &quot;TransitionCommand&quot;, typeof(ICommand), typeof(GameStepPresentation));

}

Сам UI простой: сверху описание (я отдал под него много места в расчёте на то, что будет нужно ещё что-то добавлять), внизу кнопки с опциями (переходами). Чтобы разместить не известное заранее количество кнопок, используется, как обычно, ItemsControl.

<UserControl x:Class="TestApp.GameStepPresentation"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:TestApp"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">
    <Grid DataContext="{Binding Step, RelativeSource={RelativeSource FindAncestor, AncestorType=UserControl}}"
          d:DataContext="{d:DesignInstance Type=local:GameStepVM}">
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
    &lt;TextBlock Text=&quot;{Binding Description}&quot; TextWrapping=&quot;Wrap&quot; Grid.Row=&quot;0&quot;/&gt;

    &lt;ItemsControl ItemsSource=&quot;{Binding Options}&quot; Grid.Row=&quot;1&quot;&gt;
        &lt;ItemsControl.ItemTemplate&gt;
            &lt;DataTemplate DataType=&quot;local:GameStepOptionVM&quot;&gt;
                &lt;Button Height=&quot;30&quot; Content=&quot;{Binding Text}&quot;
                        Command=&quot;{Binding TransitionCommand, RelativeSource={RelativeSource FindAncestor, AncestorType=UserControl}}&quot;
                        CommandParameter=&quot;{Binding}&quot;/&gt;
            &lt;/DataTemplate&gt;
        &lt;/ItemsControl.ItemTemplate&gt;
    &lt;/ItemsControl&gt;
&lt;/Grid&gt;

</UserControl>

Далее, главное окно. В нём лишь одна хитрость: когда текущий Step равен null, игра окончена, и мы выводим об этом радостное сообщение на весь экран.

<Window x:Class="testApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
        xmlns:local="clr-namespace:SOWpfFx01"
        mc:Ignorable="d"
        d:DataContext="{d:DesignData Type=local:GameVM, IsDesignTimeCreatable=False}"
        Title="Test" Height="680" Width="800">
    <Grid>
        <local:GameStepPresentation Step="{Binding CurrentStep}"
                                    TransitionCommand="{Binding MakeStepCommand}"/>
    &lt;!-- game over message --&gt;
    &lt;TextBlock FontSize=&quot;24&quot; Text=&quot;Game over&quot;
               HorizontalAlignment=&quot;Center&quot; VerticalAlignment=&quot;Center&quot;&gt;
        &lt;TextBlock.Style&gt;
            &lt;Style TargetType=&quot;TextBlock&quot;&gt;
                &lt;Setter Property=&quot;Visibility&quot; Value=&quot;Collapsed&quot;/&gt;
                &lt;Style.Triggers&gt;
                    &lt;DataTrigger Binding=&quot;{Binding CurrentStep}&quot; Value=&quot;{x:Null}&quot;&gt;
                        &lt;Setter Property=&quot;Visibility&quot; Value=&quot;Visible&quot;/&gt;
                    &lt;/DataTrigger&gt;
                &lt;/Style.Triggers&gt;
            &lt;/Style&gt;
        &lt;/TextBlock.Style&gt;
    &lt;/TextBlock&gt;
&lt;/Grid&gt;

</Window>

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

Ну и класс App, который будет создавать VM и «подвязывать» DataContext:

<Application x:Class="TestApp.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
</Application>

(Обратите внимание, что я убрал стандартный StartupUri).

public partial class App : Application
{
    protected override void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);
        var (transitions, initialStep) = Initializer.CreateGraph();
        var w = new MainWindow() { DataContext = new GameVM(transitions, initialStep) };
        w.Show();
    }
}

Да, ещё вам понадобится стандартная реализация RelayCommand. Например, такая:

public class RelayCommand<T> : ICommand
{
    public RelayCommand(Action<T> onExecute) { OnExecute = onExecute; }
public event EventHandler CanExecuteChanged;
public bool CanExecute(object parameter) =&gt; true;
public void Execute(object parameter) =&gt; OnExecute((T)parameter);

readonly Action&lt;T&gt; OnExecute;

}

Вот что получилось:

простая анимашка, хотя дизайн ну такой себе

Я не выставлял красоту наподобие шрифтов, маргинов и выравнивания, это остаётся тоже вам.

VladD
  • 206,799
  • Команда разбросана по коду, метод+поле+свойство+конструктор - 4 части. А если таких команд пара десятков? Предлагаю собрать покучнее в поле+свойство public ICommand MakeStepCommand => makeStepCommand ??= new RelayCommand<GameStepOptionVM>(option => { CurrentStep = Transitions[option]; });. А так сделано добротно, мне только кажется, что с юзерконтролом слегка перегнул, можно было не пугать так автора (но я бы еще и в ControlTemplate разметку юзерконтролу засунул, чтобы ReletiveSource TemplatedParent юзать). – aepot Apr 12 '21 at 07:39
  • @aepot: С командой не скомпилируется вроде? По поводу отдельного поля, часто внутри класса нужен настоящий тип. Но в принципе поле в даннопм случае можно выкинуть. – VladD Apr 12 '21 at 07:55
  • Только не говорите, что у вас .NET Framework 4.x, а так скомпилится легко. Никто против поля ничего и не говорил. Я предлагал метод засунуть в лямбду, а инициализацию из конструктора унести в ленивую. – aepot Apr 12 '21 at 07:57
  • А, там ленивая инстанциация. Ну, мне больше нравится, когда в конструкторе явно устанавливается значение свойства, тогда у меня так честный readonly. Ну и заменять метод на лямбду нехорошо, т. к. это сейчас метод однострочный, а в него явно ещё придётся добавлять обработку текущего времени, номера хода и всё такое. – VladD Apr 12 '21 at 08:05
  • Если автор минуса будет любезен объяснить свой минус, я, вероятно, смогу улучшить свой ответ. – VladD Apr 12 '21 at 08:08
  • По командам, ну если хочется жосского ридонли с конструктором xD, то тогда всё ок. – aepot Apr 12 '21 at 08:15
  • @aepot: У меня в голове простая политика: либо жёсткое readonly, либо INPC. Позволяет ничего не забыть на уровне компилятора. То есть я понимаю, что lazy — тоже вариант, но сознательно избегаю его. Наверное, это небольшой догматизм, ну, в больших проектах такой догматизм может быть и полезен. – VladD Apr 12 '21 at 09:05