Игровое меню
В этом руководстве строятся меню небольшой игры: главное меню (Играть, Настройки, Выход), меню паузы (Продолжить, Настройки, В главное меню) и страница настроек, где игрок меняет размер интерфейса. Настройки следуют правилу для всего, что меняет вид экрана: пока игрок двигает ползунок, ничего не применяется, Применить фиксирует изменение, а неподтверждённое в течение десяти секунд изменение откатывается — неудачный размер не оставит интерфейс непригодным.
Вам понадобится игра с подключённым IcyUI и одним экраном: сначала пройдите Начало работы и Первый экран. Меню — это страницы одного Frame, который переходит между ними и хранит историю возвратов; см. Вкладки и навигация.
1. Опишите страницы в разметке
Каждая страница — файл в папке Menus/ проекта игры с Copy to Output Directory = «Copy if newer»:
<ItemGroup>
<None Include="Menus\*.xml" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
Главное меню — Page, кнопки которой запускают команды модели представления:
<!-- Menus/MainMenu.xml. The data context is the MenuViewModel. KeepAlive lets Back return to this page. -->
<Page KeepAlive="True">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Orientation="Vertical">
<TextBlock FontSize="32" Margin="0,0,0,24" HorizontalAlignment="Center" Text="{Binding Title}" />
<Button x:Name="PlayButton" Padding="24,8" Margin="0,0,0,8" Command="{Binding PlayCommand}">Play</Button>
<Button Padding="24,8" Margin="0,0,0,8" Command="{Binding SettingsCommand}">Settings</Button>
<Button Padding="24,8" Command="{Binding QuitCommand}">Quit</Button>
</StackPanel>
</Page>
KeepAlive заставляет фрейм сохранять страницу, когда игрок открывает «Настройки», поэтому «Назад» возвращает на ту же
страницу. Меню паузы устроено так же:
<!-- Menus/PauseMenu.xml. The same data context as the main menu. -->
<Page KeepAlive="True">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Orientation="Vertical">
<TextBlock FontSize="32" Margin="0,0,0,24" HorizontalAlignment="Center">Paused</TextBlock>
<Button x:Name="ResumeButton" Padding="24,8" Margin="0,0,0,8" Command="{Binding ResumeCommand}">Resume</Button>
<Button Padding="24,8" Margin="0,0,0,8" Command="{Binding SettingsCommand}">Settings</Button>
<Button x:Name="QuitToMenuButton" Padding="24,8" Command="{Binding QuitToMenuCommand}">Quit to menu</Button>
</StackPanel>
</Page>
2. Напишите модели представления
Страницами управляют две модели представления. MenuViewModel хранит команды. Запуск, продолжение и выход — дело игры,
поэтому это события; открытие настроек и возврат назад принадлежат самому меню и используют
Navigation фрейма:
/// <summary>
/// The data behind every menu page: the commands the pages bind to. Starting, resuming and quitting are the game's
/// business, so they're events; opening Settings and going back are the menu's own.
/// </summary>
public sealed class MenuViewModel : ObservableObject
{
private readonly Frame frame;
public MenuViewModel(Frame frame, SettingsViewModel settings)
{
this.frame = frame;
Settings = settings;
settings.BackRequested += (_, _) => GoBack();
PlayCommand = new RelayCommand(() => PlayRequested?.Invoke(this, EventArgs.Empty));
ResumeCommand = new RelayCommand(() => ResumeRequested?.Invoke(this, EventArgs.Empty));
QuitToMenuCommand = new RelayCommand(() => QuitToMenuRequested?.Invoke(this, EventArgs.Empty));
QuitCommand = new RelayCommand(() => QuitRequested?.Invoke(this, EventArgs.Empty));
SettingsCommand = new RelayCommand(OpenSettings);
}
public event EventHandler? PlayRequested;
public event EventHandler? ResumeRequested;
public event EventHandler? QuitToMenuRequested;
public event EventHandler? QuitRequested;
public string Title => "My Game";
public SettingsViewModel Settings { get; }
public ICommand PlayCommand { get; }
public ICommand ResumeCommand { get; }
public ICommand QuitToMenuCommand { get; }
public ICommand QuitCommand { get; }
public ICommand SettingsCommand { get; }
public bool IsOpen => frame.IsVisible;
public void ShowMainMenu() => Show("Menus/MainMenu.xml");
public void ShowPauseMenu() => Show("Menus/PauseMenu.xml");
/// <summary>Hides the menu, dropping any change that was never confirmed.</summary>
public void Close()
{
Settings.Revert();
frame.Navigation.Clear();
frame.IsVisible = false;
}
/// <summary>Goes back one page, dropping an unconfirmed change. Returns false on the first page.</summary>
public bool GoBack()
{
Settings.Revert();
return frame.Navigation.TryNavigateBack() != null;
}
private void OpenSettings()
{
// Bindings follow their data context, not what is nested inside it: the page binds to the settings itself.
if (frame.Navigation.Navigate("Menus/Settings.xml") is { } page)
page.DataContext = Settings;
}
private void Show(string page)
{
Settings.Revert();
frame.Navigation.Clear();
frame.IsVisible = true;
frame.Navigation.Navigate(page);
}
}
ShowMainMenu и ShowPauseMenu очищают историю фрейма и переходят на страницу по её пути;
Navigate(string) загружает разметку через конвейер ресурсов.
3. Добавьте страницу настроек с «Применить» и «Отмена»
Страница настроек правит черновик: ползунок двусторонне привязан к DraftScale, и ничего больше не меняется до
нажатия «Применить».
<!-- Menus/xml. The data context is the SettingsViewModel: the menu sets it on the page when it opens. -->
<Page>
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Orientation="Vertical" Width="320">
<TextBlock FontSize="28" Margin="0,0,0,16">Settings</TextBlock>
<TextBlock Margin="0,0,0,4">UI size</TextBlock>
<Slider x:Name="UiSize" Minimum="0.5" Maximum="2" Margin="0,0,0,8" Value="{Binding DraftScale, Mode=TwoWay}" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<Button x:Name="ApplyButton" Padding="16,6" Margin="0,0,8,0" Command="{Binding ApplyCommand}">Apply</Button>
<Button x:Name="CancelButton" Padding="16,6" Command="{Binding CancelCommand}">Cancel</Button>
</StackPanel>
<!-- Shown for ten seconds after Apply; the change reverts unless the player keeps it. -->
<StackPanel x:Name="ConfirmPanel" Orientation="Vertical" Margin="0,0,0,16" IsVisible="{Binding IsConfirming}">
<TextBlock Text="{Binding CountdownText}" Margin="0,0,0,4" />
<ProgressBar Minimum="0" Maximum="1" Height="6" Margin="0,0,0,8" Value="{Binding RevertProgress}" />
<StackPanel Orientation="Horizontal">
<Button x:Name="KeepButton" Padding="16,6" Margin="0,0,8,0" Command="{Binding KeepCommand}">Keep</Button>
<Button x:Name="RevertButton" Padding="16,6" Command="{Binding RevertCommand}">Revert</Button>
</StackPanel>
</StackPanel>
<Button x:Name="BackButton" Padding="16,6" HorizontalAlignment="Left" Command="{Binding BackCommand}">Back</Button>
</StackPanel>
</Page>
Эта страница привязана непосредственно к SettingsViewModel, а не через MenuViewModel.Settings. Привязка следует за
своим контекстом данных и не идёт дальше по пути, поэтому OpenSettings в модели представления выше при переходе
устанавливает DataContext страницы в объект настроек. См. Привязка данных.
Модель представления настроек применяет черновик к UserScale и возвращает прежнее значение, если оно не подтверждено вовремя:
/// <summary>
/// The settings page's data. The slider edits a draft; Apply commits it, and unless the player confirms within ten
/// seconds, the previous size comes back, so a bad size can never leave the UI unusable.
/// </summary>
public sealed class SettingsViewModel : ObservableObject
{
private const float RevertSeconds = 10;
private readonly ScalingConfiguration scaling;
private float draftScale;
private float previousScale;
private float remaining;
private bool isConfirming;
public SettingsViewModel(ScalingConfiguration scaling)
{
this.scaling = scaling;
draftScale = scaling.UserScale;
ApplyCommand = new RelayCommand(Apply);
CancelCommand = new RelayCommand(Cancel);
KeepCommand = new RelayCommand(Keep);
RevertCommand = new RelayCommand(Revert);
BackCommand = new RelayCommand(() => BackRequested?.Invoke(this, EventArgs.Empty));
}
/// <summary>Raised when the player presses Back; the menu goes to the previous page.</summary>
public event EventHandler? BackRequested;
/// <summary>The slider's value. Nothing changes on screen until Apply.</summary>
public float DraftScale
{
get => draftScale;
set => SetProperty(ref draftScale, value);
}
/// <summary>True while an applied change waits for the player to keep or revert it.</summary>
public bool IsConfirming
{
get => isConfirming;
private set => SetProperty(ref isConfirming, value);
}
/// <summary>The time left, from 1 down to 0, for the countdown bar.</summary>
public float RevertProgress => remaining / RevertSeconds;
public string CountdownText => $"Keep this size? Reverting in {MathF.Ceiling(remaining)} s";
public ICommand ApplyCommand { get; }
public ICommand CancelCommand { get; }
public ICommand KeepCommand { get; }
public ICommand RevertCommand { get; }
public ICommand BackCommand { get; }
/// <summary>Advances the countdown. Call it every frame with the frame time, even while the game is paused.</summary>
public void Tick(float elapsedSeconds)
{
if (!IsConfirming)
return;
remaining -= elapsedSeconds;
if (remaining <= 0)
Revert();
else
OnCountdownChanged();
}
/// <summary>Puts the previous size back, if a change is waiting for confirmation.</summary>
public void Revert()
{
if (!IsConfirming)
return;
scaling.UserScale = previousScale;
DraftScale = previousScale;
Finish();
}
private void Apply()
{
if (IsConfirming)
return;
previousScale = scaling.UserScale;
scaling.UserScale = DraftScale;
remaining = RevertSeconds;
IsConfirming = true;
OnCountdownChanged();
}
private void Keep() => Finish();
private void Cancel() => DraftScale = scaling.UserScale;
private void Finish()
{
remaining = 0;
IsConfirming = false;
OnCountdownChanged();
}
private void OnCountdownChanged()
{
OnPropertyChanged(nameof(RevertProgress));
OnPropertyChanged(nameof(CountdownText));
}
}
Обратный отсчёт — обычный код: Tick принимает время кадра. Вызывайте его каждый кадр, в том числе на паузе, потому что
отсчёт важен именно тогда, когда игрок находится в меню паузы. Уход со страницы или закрытие меню тоже откатывает
неподтверждённое изменение.
4. Покажите меню и поставьте игру на паузу
MenuController связывает части: добавляет фрейм на главный Canvas, подключает события и обрабатывает Escape через
KeyGesture. Escape ставит игру на паузу во время игры; в меню возвращает на страницу назад; на
первой странице меню паузы продолжает игру.
/// <summary>
/// Owns the menu and the game's play/pause state. Engine-neutral: the game's update loop calls <see cref="Update"/>
/// and checks <see cref="IsPaused"/>.
/// </summary>
public sealed class MenuController
{
public MenuController(IIcyHost ui)
{
Frame = new Frame
{
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
};
ui.MainCanvas.Add(Frame);
Menu = new MenuViewModel(Frame, new SettingsViewModel(ui.Configuration.Scaling));
Frame.DataContext = Menu;
Menu.PlayRequested += (_, _) => StartPlaying();
Menu.ResumeRequested += (_, _) => Resume();
Menu.QuitToMenuRequested += (_, _) => QuitToMenu();
Menu.QuitRequested += (_, _) => QuitRequested?.Invoke(this, EventArgs.Empty);
ui.Configuration.Input.Events.RegisterCommand(new RelayCommand(HandleEscape), new KeyGesture(Keys.Escape));
Menu.ShowMainMenu();
}
/// <summary>Raised when the player chooses Quit; the game exits.</summary>
public event EventHandler? QuitRequested;
public Frame Frame { get; }
public MenuViewModel Menu { get; }
public bool IsPlaying { get; private set; }
/// <summary>True while the pause menu is open: gameplay should skip its update.</summary>
public bool IsPaused { get; private set; }
/// <summary>Call every frame with the frame time. The Settings countdown runs even while paused.</summary>
public void Update(float elapsedSeconds) => Menu.Settings.Tick(elapsedSeconds);
/// <summary>Escape: pause during play, go back a page in a menu, or resume from the pause menu's first page.</summary>
public void HandleEscape()
{
if (!Menu.IsOpen)
{
if (IsPlaying)
Pause();
}
else if (!Menu.GoBack() && IsPaused)
{
Resume();
}
}
private void StartPlaying()
{
IsPlaying = true;
IsPaused = false;
Menu.Close();
}
private void Pause()
{
IsPaused = true;
Menu.ShowPauseMenu();
}
private void Resume()
{
IsPaused = false;
Menu.Close();
}
private void QuitToMenu()
{
IsPlaying = false;
IsPaused = false;
Menu.ShowMainMenu();
}
}
В нём нет ссылок на движок, поэтому тот же код работает на обоих. Различается игровой цикл, который вызывает Update и
пропускает игровой процесс, пока установлен IsPaused:
using Icy.Stride.Configuration;
using Stride.Engine;
/// <summary>Add this script to an entity in your scene: it shows the main menu and pauses gameplay behind the pause menu.</summary>
public class MenuScript : SyncScript
{
private MenuController menu = null!;
public override void Start()
{
StrideIcyHost ui = Game.UseIcyUI();
menu = new MenuController(ui);
menu.QuitRequested += (_, _) => ((Game)Game).Exit();
}
public override void Update()
{
// UI time: the Settings countdown must keep running while the game is paused.
menu.Update((float)Game.UpdateTime.Elapsed.TotalSeconds);
if (!menu.IsPlaying || menu.IsPaused)
return;
// Your gameplay update goes here.
}
}
«Пауза» — ваш флаг, а не флаг движка: IcyUI продолжает рисовать и анимировать меню, ведь это отдельный слой от игрового процесса. Пока флаг установлен, пропускайте собственные обновления (движение, таймеры, ИИ).
Что вы построили
Главное меню, меню паузы и страницу настроек в разметке, в одном фрейме с историей возвратов, и контроллер, который запускает игру, ставит её на паузу и продолжает. Размер интерфейса следует схеме «Применить» и «Отмена», а неподтверждённое изменение откатывается через десять секунд, что бы игрок ни делал дальше.
Примечание
Кнопки X и Y геймпада вызывают события «назад» и «вперёд», но Frame пока на них не реагирует, поэтому на каждой
странице есть кнопка «Назад». Escape (и B, закрывающая диалоги) тоже сам по себе не возвращает на страницу назад:
это делает HandleEscape из этого руководства, для клавиатуры.
Дальше
- Ввод, фокус и навигация: как меню управляется с клавиатуры или геймпада.
- Масштабирование: что умножает
UserScaleи другие настройки масштаба. - Привязка данных: как страницы следуют за своей моделью представления.
- Свои темы: дайте кнопкам и спискам меню цвета вашей игры.