но не предусмотрено, что при повороте позиция меняется не сразу
А надо, чтоб сразу. Игрок, нажимая на клавишу должен видеть реакцию игры немедленно, иначе он будет считать, что кнопки плохо нажимаются, или игра тупит, и вообще сломает клавиатуру об монитор.
Сама змейка, еда, а также разметка прописываются в Canvas
Вам не нужен здесь Canvas. Холст нужен тогда, когда вам надо работать с координатами для рисования. В данной игре вам достаточно отобразить игровое поле-матрицу, где клетки просто идут друг за другом, никакой работы с координатам не требуется. А далее просто менять состояние каждой клетки.
есть концы разметки, нет границ, при утыкании в эту неполную клетку игра резонно считает, что пользователь проиграл
Перемешивание состояния графики и игровой логики вообще ни к чему хорошему не приведет. Игровая логика должна быть игровой логикой, интерфейс игры - интерфейсом игры. Логика говорит интерфейсу, что в ней поменялось, интерфейс отображает. Юзер нажал кнопку, интерфейс сказал логике, что что-то надо при этом сделать или поменять.
Движение пытался исправлять добавлением Thread.Sleep()
Thread.Sleep(), выполняемый в UI потоке намертво вешает приложение, пока действует. Вам нужно немного асинхронного программирования здесь. Но! Никогда не синхронизируйте логику разных частей приложения с помощью ожидания по времени - это плохая практика. Другими словами - костыль. Есть события, есть колбэки, есть асинхронное ожидание, куча инструментов, чтобы не делать Sleep(t).
Однако, везде испытал неудачу.
У вас графика управляет игрой. Или как говорят "хвост виляет собакой". Вы пытаетесь сделать очень сложное дело, заставить пиксели влиять на игровую логику, когда должно быть все в точности наоборот.
Давайте попробуем переписать игру так, чтобы "собака виляла хвостом". Ну и познакомимся с WPF.
Как написать игру "Змейка" в WPF и выжить

Предполагаю, что вы еще только начали осваивать WPF, решил написать вам змейку с использованием популярных практик, используемых именно в WPF.
Дело в том, что в WPF в общем понимании код не сваливается в кучу в класс окна, да и даже в Winforms это делают только новички. Я объясню кратко, что и как, не углубляясь в мелкие детали, но если интересно, в сети очень много написано про то, что я сейчас расскажу.
Приготовьтесь, сейчас будет много информации, но я выбрал только самые основы. Чтобы попробовать игру, показанную ниже, создайте новый .NET Core 3.1 или .NET 5 WPF проект.
Структура приложения
Код логически разделяется на 3 слоя:
- внутренняя логика приложения (Model)
- интерфейс (View)
- логика для взаимодействия с интерфейсом (ViewModel).
Все вместе это выглядит как Model-View-ViewModel, или сокращенно MVVM - самый удобный и популярный шаблон проектирования приложения, используемый в WPF. Грубо говоря, это точка опоры для того, чтобы всё в приложении не смешалось в кучу, чтобы не застрять в состоянии "не знаю, что теперь делать, придётся все переписывать".
Базовые технологии
Привязка данных
Самая основная технология, без которой в WPF написать что-то вменяемое очень сложно - привязка данных. А конкретно привязка данных к интерфейсу. Она по сути переворачивает работу связки интерфейс-код с ног на голову.
Новички же как делают - дают контролам имена x:Name, и обновляют контролы, присваивая свойствам данные. Это называется прямая ручная работа с интерфейсом. Но разработчики Microsoft не для этого делали WPF, и не сильно старались сделать этот способ работы с интерфейсом удобным. Как следствие, он очень неудобен, намного хуже, чем Winforms.
"С ног на голову" переворачивает это все привязка именно потому, что вы грубо говоря сообщаете контролу, где брать данные, и он сам обновляется, когда обновляются данные. То есть не вы даете контролам данные, а они сами в себя их затягивают, вы только в XAML указываете Binding - откуда тянуть, а в коде с помощью OnPropertyChanged() - когда тянуть.
Имена контролам за ненадобностью давать больше не нужно. Отвечает за эту магию класс Binding. Механически, чтобы Binding узнал, что данные поменялись, он подписывается на событие PropertyChanged в классе, который вы установили в DataContext контрола. Сам же DataContext наследуется между контролами, поэтому его можно назначить окну, и все заработает.
Чтобы использовать PropertyChanged вызовы к коде, нужно реализовать специально предназначенный для этого интерфейс INotifyPropertyChanged. Реалзиция выглядит вот так:
NotifyPropertyChanged.cs
public class NotifyPropertyChanged : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
Каждый класс создается в отдельном файле проекта. Классы можно добавить в Visual Studio кликнув правой кнопкой по решению в проводнике решения и выбрать Добавить -> Класс, и вставить туда код.
Теперь можно наследоваться от этого класса, и вызывать OnPropertyChanged() там, где нужно сообщить интерфейсу, что свойство ViewModel обновилось.
Команды
Так как код в классе окна больше писать не нужно, обработчики событий использовать в MVVM неудобно, что же делать, если нажал кнопку? - вызвать команду. А команду можно привязать как и любые данные к кнопке через Binding, да и не только к кнопке, ниже вы в этом убедитесь. Чтобы удобно использовать команды, есть вот такой вспомогательный класс:
RelayCommand.cs
public class RelayCommand : ICommand
{
private readonly Action<object> _execute;
private readonly Func<object, bool> _canExecute;
public event EventHandler CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
public RelayCommand(Action<object> execute, Func<object, bool> canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}
public bool CanExecute(object parameter) => _canExecute == null || _canExecute(parameter);
public void Execute(object parameter) => _execute(parameter);
}
Так же, добавьте этот класс в проект.
Не забывайте подключать пространства имён в тех местах, где студия подсвечивает вам ошибки. Чтобы сделать это легко и быстро, поставьте курсор на подчеркнутое и нажмите Ctrl+., из появившегося меню, выберите вариант с using, и студия сама добавит вам нужное пространство имен в начале кода.
Игра "Змейка"
Я начну с самых глубин игровой логики, и постепенно поднимусь к интерфейсу по слоям MVVM.
Как можно представить игровое поле? Самый очевидный ответ - с помощью двумерного массива. А что может быть в ячейке этого массива? Змейка, пусто, еда или например стена. Описать в коде такое просто - энумератором.
Второй энумератор, который потребуется - это направление движения змейки. И еще структура "коодинаты" для удобной работы с координатами ячеек на игровом поле.
Data.cs
public enum CellState
{
Empty,
Snake,
Food
}
public enum Direction
{
Right,
Down,
Left,
Up
}
public struct Coords
{
public int X { get; }
public int Y { get; }
public Coords(int x, int y)
{
X = x;
Y = y;
}
}
Для удобства самые мелкие кусочки кода с энумераторами, я помещаю в файл Data.cs, но профессиональные разработчики на полном серьёзе каждую структурную единицу кода кладут в отдельный файл. В больших проектах так делать реально удобнее.
Кстати, я не буду делать двумерный массив для игрового поля, а буду делать массив массивов, или список списков, что в данном контексте - одно и то же. Игровое поле будет выглядеть так:
public List<List<Cell>> Arena { get; }
А ячейка игрового поля - так:
Data.cs
public class Cell : NotifyPropertyChanged
{
private CellState _state;
public CellState State
{
get => _state;
set
{
_state = value;
OnPropertyChanged();
}
}
}
Вот класс, который создает новое игровое поле и содержит основную логику игры:
Game.cs
public class Game
{
private const int _delay = 300;
private readonly MainViewModel _viewModel; // ссылка на вью-модель
private readonly Snake _snake; // Змейка
private readonly Food _food; // Еда
private Direction _direction; // Куда змейка ползет
private CancellationTokenSource _cts = null; // Щтука, чтобы останавливать игру (ставить на паузу)
private bool _addDelay; // добавить паузу после смены напраления
public List<List<Cell>> Arena { get; } // игровое поле
public Direction Direction
{
get => _direction;
set
{
// Вот это не дает змейке ползти назад.
if (value != _direction && (int)value % 2 != (int)_direction % 2)
{
_direction = value;
_addDelay = true;
Update(); // немедленная реакция на нажатие
}
}
}
// создание новой игры
public Game(MainViewModel viewModel)
{
_viewModel = viewModel;
int width = 40;
int height = 30;
Arena = new List<List<Cell>>();
for (int i = 0; i < height; i++)
{
List<Cell> row = new List<Cell>();
for (int j = 0; j < width; j++)
{
row.Add(new Cell());
}
Arena.Add(row);
}
_food = new Food(Arena, 10, 2);
_snake = new Snake(this, _food, new Coords(Arena[0].Count / 2, Arena.Count / 2), 1, Direction.Right);
}
// запустить игру
public void Start()
{
if (_cts == null)
Run();
}
// остановить игру
public void Stop()
{
_cts?.Cancel();
}
// а здесь змейка в цикле ползает, обратите внимание на то что метод "async" - асинхронный
private async void Run()
{
using (_cts = new CancellationTokenSource())
{
try
{
while (true) // повторять, пока не надоест
{
if (_snake.Died) // если змейка умерла
{
_viewModel.EndGame(); // Game Over
break;
}
else
Update(); // Обновить игровое состояние
await Task.Delay(_delay, _cts.Token); // а вот единственная асинхронная операция
if (_addDelay)
{
_addDelay = false;
await Task.Delay(_delay / 2, _cts.Token);
}
}
}
catch (OperationCanceledException) { } // была остановка?
catch (Exception ex) // была другая ошибка?
{
MessageBox.Show(ex.Message);
}
}
_cts = null;
}
// начисляет 10 очков за каждую найденную еду
public void GiveScore()
{
_viewModel.Score += 10;
}
// обновляет игровое состояние
public void Update()
{
_snake.Move(Direction); // двинуть змейку
_food.Update(); // попросить еду добавиться на поле
}
}
Сама змейка - это как отдельная боевая единица, вы можете при таком построении приложения даже 2 змейки на поле выпустить, одной например может управлять игрок, другой компьютер. Здесь - на что фантазии хватит.
Snake.cs
public class Snake
{
private readonly Queue<Coords> _tail; // хвост змейки
private readonly Food _food; // змейка взаимодействует с едой
private readonly Game _game; // там есть игровое поле
private Coords _head; // голова змейки
public int _length; // длина змейки без учета головы
public bool Died { get; private set; } // показывает, жива ли змейка
public Coords Head // голова
{
get => _head;
private set
{
_head = value;
// отобразить голову на арене
_game.Arena[value.Y][value.X].State = CellState.Snake;
}
}
// создать новую змейку
public Snake(Game game, Food food, Coords head, int length, Direction direction)
{
_game = game;
_food = food;
_tail = new Queue<Coords>();
Head = head;
_length = length;
while (_tail.Count < _length)
Move(direction); // проползти немного, чтобы змейка была во всю длину на поле
}
// ползти на одну клетку в направлении direction
public void Move(Direction direction)
{
Coords coords = Head;
switch (direction)
{
case Direction.Right:
coords = new Coords(coords.X + 1, coords.Y);
break;
case Direction.Down:
coords = new Coords(coords.X, coords.Y + 1);
break;
case Direction.Left:
coords = new Coords(coords.X - 1, coords.Y);
break;
case Direction.Up:
coords = new Coords(coords.X, coords.Y - 1);
break;
}
if (!CheckMove(coords)) // а можно ли туда ползти?
return;
_tail.Enqueue(Head); // старая голова стала началом хвоста
Head = coords; // новая голова
while (_tail.Count > _length) // если хвост длиннее, чем нужно
{
Coords tail = _tail.Dequeue(); // отрезать клетку от хваоста
_game.Arena[tail.Y][tail.X].State = CellState.Empty; // и отобразить это на игровом поле
}
}
// проверка следующего хода
private bool CheckMove(Coords coords)
{
// если выползем за пределы или врежемся в себя
if (coords.X >= _game.Arena[0].Count || coords.X < 0 || coords.Y >= _game.Arena.Count || coords.Y < 0 || _game.Arena[coords.Y][coords.X].State == CellState.Snake)
Died = true; // то умрём
else
if (_game.Arena[coords.Y][coords.X].State == CellState.Food) // иначе может попасться еда
{
_food.FoodCount--; // сказать еде, что ее стало меньше
_length++; // вырастить хвост
_game.GiveScore(); // дать игроку очки
}
return !Died;
}
}
Еда отвечает за количество себя на поле, частоту появления ну и рисует себя, когда надо в случайной клетке поля.
Food.cs
public class Food
{
private readonly int _foodDelay; // задержка между появлением еды в игровых ходах
private readonly int _maxFood; // максимальное количество еды на поле
private readonly Random _rnd; // генератор случайных чисел
public readonly List<List<Cell>> _arena; // ссылка на игровое поле
private int tick; // сколько ходов прошло с момента последнего появления еды
public int FoodCount { get; set; } // сколько сейчас еды на поле
public Food(List<List<Cell>> arena, int foodDelay, int maxFood)
{
_rnd = new Random();
_arena = arena;
_foodDelay = foodDelay;
_maxFood = maxFood;
}
// добавить еду
public void Update()
{
if (tick >= _foodDelay && FoodCount < _maxFood)
{
tick = 0;
while (true)
{
Coords coords = new Coords(_rnd.Next(_arena[0].Count), _rnd.Next(_arena.Count)); // выбрать случайную клетку
if (_arena[coords.Y][coords.X].State == CellState.Empty) // если там пусто
{
_arena[coords.Y][coords.X].State = CellState.Food; // нарисовать еду
FoodCount++; // учесть, еды стало больше
break;
}
}
}
else
tick++; // +1 ход не было еды
}
}
ViewModel содержит в себе логику приложения, не отвечающую за игру, но отвечающую за взаимодействие с интерфейсом. Точнее наоборот, за взаимодействие интерфейса с вью-моделью, то есть предоставляет ему все необходимое, чтобы игра работала.
MainViewModel.cs
public class MainViewModel : NotifyPropertyChanged
{
private int _score;
private int _highScore;
private List<List<Cell>> _arena;
private Game _game;
private bool _gameRunning;
private bool _gameOver;
private ICommand _moveCommand;
private ICommand _startCommand;
public int Score // Очки
{
get => _score;
set
{
_score = value;
OnPropertyChanged();
}
}
public int HighScore // Лучший результат
{
get => _highScore;
set
{
_highScore = value;
OnPropertyChanged();
}
}
public List<List<Cell>> Arena // Ссылка на игровое поле для интерфейса
{
get => _arena;
set
{
_arena = value;
OnPropertyChanged();
}
}
public bool GameRunning // Сейчас игра играет?
{
get => _gameRunning;
set
{
_gameRunning = value;
OnPropertyChanged();
}
}
public bool GameOver // Игра проиграна?
{
get => _gameOver;
set
{
_gameOver = value;
OnPropertyChanged();
}
}
// Начать игру, сюда привязана кнопка "Start/Pause" в интерфейсе и клавиша Пробел
public ICommand StartCommand => _startCommand ??= new RelayCommand(parameter =>
{
if (!GameRunning)
{
if (GameOver)
NewGame();
else
{
GameRunning = true;
_game.Start();
}
}
else
{
GameRunning = false;
_game.Stop();
}
});
// Сюда привязаны стрелки и WASD
public ICommand MoveCommand => _moveCommand ??= new RelayCommand(parameter =>
{
if (GameRunning && Enum.TryParse(parameter.ToString(), out Direction direction))
{
_game.Direction = direction;
}
});
// Змейка умерла, закончить игру
public void EndGame()
{
GameRunning = false;
GameOver = true;
if (HighScore < Score)
HighScore = Score;
}
// Создать новую игру
private void NewGame()
{
if (GameRunning)
_game.Stop();
GameOver = false;
Score = 0;
_game = new Game(this);
Arena = _game.Arena;
}
// Здесь всё начинается
public MainViewModel()
{
NewGame();
}
}
Чтобы подключить ViewModel к окну, надо как я выше писал, задать DataContext, вот кстати, весь код окна:
MainWindow.xaml.cs
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new MainViewModel();
}
}
Интерфеейс змейки содержит много для вас нового, но я уверен, вы разберетесь. Вообще XAML не такой страшный, как кажется, и в нем просто удобнее создавать интерфейсы, чем в коде C#. Не зря же Microsoft решили сделать именно так.
MainWindow.xaml
<Window x:Class="WpfApp1.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:local="clr-namespace:WpfApp1"
mc:Ignorable="d"
Title="MainWindow" d:DataContext="{d:DesignInstance local:MainViewModel, IsDesignTimeCreatable=True}"
SizeToContent="WidthAndHeight" ResizeMode="CanMinimize" SnapsToDevicePixels="True">
<Window.InputBindings>
<KeyBinding Key="Right" Command="{Binding MoveCommand}" CommandParameter="Right"/>
<KeyBinding Key="Down" Command="{Binding MoveCommand}" CommandParameter="Down"/>
<KeyBinding Key="Left" Command="{Binding MoveCommand}" CommandParameter="Left"/>
<KeyBinding Key="Up" Command="{Binding MoveCommand}" CommandParameter="Up"/>
<KeyBinding Key="D" Command="{Binding MoveCommand}" CommandParameter="Right"/>
<KeyBinding Key="S" Command="{Binding MoveCommand}" CommandParameter="Down"/>
<KeyBinding Key="A" Command="{Binding MoveCommand}" CommandParameter="Left"/>
<KeyBinding Key="W" Command="{Binding MoveCommand}" CommandParameter="Up"/>
<KeyBinding Key="Space" Command="{Binding StartCommand}"/>
</Window.InputBindings>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition/>
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal">
<TextBlock Margin="5" Text="Score:" FontSize="20"/>
<TextBlock Margin="5" Text="{Binding Score}" FontSize="20" FontWeight="Bold"/>
<TextBlock Margin="5" Text="High Score:" FontSize="20"/>
<TextBlock Margin="5" Text="{Binding HighScore}" FontSize="20" FontWeight="Bold"/>
<Button Margin="5" Padding="15,0" Command="{Binding StartCommand}">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Content" Value="Start"/>
<Style.Triggers>
<DataTrigger Binding="{Binding GameRunning}" Value="True">
<Setter Property="Content" Value="Pause"/>
</DataTrigger>
<DataTrigger Binding="{Binding GameOver}" Value="True">
<Setter Property="Content" Value="New game"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</StackPanel>
<ItemsControl Grid.Row="1" Background="AliceBlue" Margin="5" ItemsSource="{Binding Arena}" HorizontalAlignment="Center" VerticalAlignment="Center">
<ItemsControl.ItemTemplate>
<DataTemplate>
<ItemsControl ItemsSource="{Binding}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Width="18" Height="18" Margin="1" BorderThickness="1">
<Border.Style>
<Style TargetType="Border">
<Setter Property="BorderBrush" Value="LightBlue"/>
<Style.Triggers>
<DataTrigger Binding="{Binding State}" Value="Snake">
<Setter Property="BorderBrush" Value="Blue"/>
<Setter Property="Background" Value="DodgerBlue"/>
</DataTrigger>
<DataTrigger Binding="{Binding State}" Value="Food">
<Setter Property="BorderBrush" Value="Red"/>
<Setter Property="Background" Value="LightPink"/>
<Setter Property="CornerRadius" Value="9"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel Orientation="Vertical"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<Border Grid.Row="1" HorizontalAlignment="Center" VerticalAlignment="Center" Background="White" BorderBrush="LightGray" BorderThickness="1">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Visibility" Value="Hidden"/>
<Style.Triggers>
<DataTrigger Binding="{Binding GameOver}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel>
<TextBlock Margin="20,10" Text="GAME OVER" FontSize="30" FontWeight="Bold"/>
<TextBlock Margin="20,10" Text="{Binding Score, StringFormat=Score: {0}}" FontSize="20" HorizontalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
</Window>
Вот они, все привязки данных здесь. Я показываю контролам - где брать данные, а в C# коде вообще не думаю о контролах. Это и называется разделение приложения на слои, с которых я начал этот рассказ.
Вот и всё, змейка готова.

Из меня, кстати, не очень хороший игрок. Приятного кодинга!
Обновлено
Немного сделал комфортнее управление, теперь реже проигрываю. А так же если игра закончена, написал на кнопке "New game".
Архив с решением - https://yadi.sk/d/M97soEdRyU3gMA
Thread.Sleep— это не лучшая идея. – VladD Dec 21 '20 at 17:20