Линии

Пример игры можно посмотреть в интернете (например здесь https://www.min2win.ru/game/lines-98.html)
Игра линии представляет собой классическую игру по составлению разноцветных шариков в ряд, чтобы получилась линия длиной 3 или 5 шариков (в разных версиях игры по разному). На каждом шагу пользователь выбирает 1 шарик и выбирает куда его переместить, но перемещать можно только в те ячейки до куда существует безпрепятственный путь для шарика. После хода игрока дополнительно появляются еще три новых шарика. Если была составлена линия из шариков одинакового цвета длиной 3/5 то она будет удалена с поля.
При решении задачи решается ряд подзадач:
поиск пути при движении шарика в новую позицию
поиск свободного места для генерация новых шариков в случайном месте
проверка на наличие линий который можно удалить
определение условий проигрыша
Для решения этой задачи, разобьем ее на подводящие подзадачи.
1. Отрисовка поля
2. Выбор и телепортация шарика в пустую клетку (без поиска пути)
3. Удаление линий (3 в линию по вертикали, диагонали и горизонтали)
4. Добавим проверку на проигрыш и перезапуск игры
5. Добавим поиск пути шарика
6. Добавим анимацию перехода шарика вдоль пути
Отрисовка поля и шариков
Создадим новый проект с анимацией
Сперва объявим массив хранящий сцену
int[,] map = new int[9,9]; // сцена, где 0 -пусто, [1;7] - цвет шарика
//дополнительные переменные
List<Point> path = new List<Point>(); //путь шарика
// массив для хранения волны
int[,] wave = new int[9, 9];
// очки
int scores = 0;
// рекорд очков
int maxScores = 0;
// генератор случайных чисел
Random rand = new Random();
Проинициализируем все необходимые вещи в конструкторе формы:
public Form1()
{
InitializeComponent();
DoubleBuffered = true;
System.Windows.Forms.Timer timer = new System.Windows.Forms.Timer();
timer.Interval = 10;
timer.Start();
timer.Tick += Timer_Tick;
Paint += Form1_Paint;
ClientSize = new Size(CellSize * 9 + 1, CellSize * 9 + 1);
MouseUp += Form1_MouseUp;
MaximizeBox = false;
StartPosition = FormStartPosition.CenterScreen;
FormBorderStyle = FormBorderStyle.FixedSingle;
ResetGame();
}
Обьявим функцию сброса игры (пригодится в дальнейшем):
private void ResetGame()
{
gameOver = false;
map = new int[9, 9];
for (int i = 0; i < 3; i++)
{
NewBall();
}
}
Напишем функцию установки нового шарика в случайную клетку:
public void NewBall()
{
int x = 0;
int y = 0;
do
{
x = rand.Next(map.GetLength(0));
y = rand.Next(map.GetLength(1));
} while (map[x, y] != 0);
map[x, y] = rand.Next(colors.Length) + 1;
}
Создадим функцию отрисовки сцены, и отрисуем ее.
//размер ячейки поля в пикселях
const int CellSize = 50;
// массив цветов шариков
Color[] colors = new[] { Color.Red, Color.Green, Color.Orange, Color.Blue, Color.Yellow, Color.Violet, Color.LimeGreen };
// функция отрисовки карты: сетки и шариков
public void DrawMap(Graphics gr)
{
int ballGap = 3; // зазор шаров в клетки
for (int i = 0; i < 9; i++)
{
for (int j = 0; j < 9; j++)
{
// рисуем клетку
gr.DrawRectangle(Pens.White, i * CellSize, j * CellSize, CellSize, CellSize);
if (map[i, j] > 0) // если есть шар в клетке, то рисуем
gr.FillEllipse(new SolidBrush(colors[map[i, j] - 1]), i * CellSize + ballGap, j * CellSize + ballGap, CellSize - ballGap * 2, CellSize - ballGap * 2);
}
}
}
Чтобы проверить корректность отрисовки, заполним случайными значениями map и запутим. Увидим такую каритнку:

Обработка нажатия мышки
Нажатие мышки должно работать по следующему алгоритмы:
1. Если шарик не выбран и мы нажали на шарик, то выбрать шарик (запомнить его. в дальнешейм вырбранный шарик можно отрисосывать каким-то другим образом чтобы пользователь наглядно видел как шарик выбран)
2. Если шарик был выбран ранее, и нажали на другой шарик, то выбрать вновь нажатый шарик
3. Если шарик был выбран ранее и нажали на пустую клетку, то построить путь до нажатой клектки и дальше возможны два варианта,
3.1 Если путь существует, то переместить шарик в новую клетку, сгенерировать новые шарики (3 штуки или меньше если клеток не осталось), проверить условия проигрыша (если свободных клеток не осталось то перевести состояние игры в GameOver (Проигрыш)
3.2 Если пути не существует, то ничего не делать. Переход не возможнен
Для начала давайте решим упрощенную задачу, и будем просто телепортировать шарик в новую пустую позицию без проверки наличия пути перехода.
Давайте опишем это все в коде
private void Form1_MouseUp(object? sender, MouseEventArgs e)
{
var cursor = e.Location;
int row = (cursor.Y) / CellSize;
int column = cursor.X / CellSize;
if (map[column, row] != 0)
selectedBall = new Point(column, row);
if (selectedBall != null && map[column, row] == 0)
{
//move ball to new position
map[column, row] = map[selectedBall.Value.X, selectedBall.Value.Y];
map[selectedBall.Value.X, selectedBall.Value.Y] = 0;
selectedBall = null;
//generate new balls
var freeQty = FreeCells();
if (freeQty <= 3) //gameOver
gameOver = true;
for (int i = 0; i < Math.Min(freeQty, 3); i++)
NewBall();
}
}
Опишем функцию проверки свободных клеток:
public int FreeCells()
{
int ret = 0;
for (int i = 0; i < map.GetLength(0); i++)
{
for (int j = 0; j < map.GetLength(1); j++)
{
if (map[i, j] == 0)
ret++;
}
}
return ret;
}
Запустим и убедимся, что теперь мы можем выбирать шарик и перемешать его в свободные клетки. Теперь давайте добавим проверку что в линии находится 3 или более шариков одинакового цвета и ее можно удалить.
Добавим удаление линий
Создадим функцию, которая будет помечать шарики готовые для удаления (которые входят в какиую-либо из линий одинакового цвета требуемой длины)
const int BallInLine = 3;
private void CheckLines()
{
bool[,] deleteMap = new bool[9, 9];
//find all 5-len lines
//check rows
for (int i = 0; i < 9; i++)
{
int accum = 0;
int startColor = 0;
for (int j = 0; j < 9; j++)
{
if (map[i, j] != startColor)
{
accum = 0;
startColor = map[i, j];
}
if (startColor != 0)
accum++;
if (accum >= BallInLine)
{
//remove line
for (int k = 0; k < accum; k++)
{
//map[i, j - k] = 0;
deleteMap[i, j - k] = true;
}
}
}
}
По аналогии добавьте поиск для столбцов.
Тепер добавим происк по диагонали:
//check diags 1
for (int j = 0; j < 9; j++)
{
for (int i = 0; i < 9; i++)
{
int accum = 0;
int startColor = 0;
for (int q = 0; q < 9; q++)
{
var column = i + q;
var row = j + q;
if (column >= 9 || row >= 9)
break;
if (map[i + q, j + q] != startColor)
{
accum = 0;
startColor = map[i + q, j + q];
}
if (startColor != 0)
accum++;
if (accum >= BallInLine)
{
//remove line
for (int k = 0; k < accum; k++)
{
deleteMap[i + k, j + k] = true;
}
}
}
}
}
Самостоятельно добавьте проверку обратных диагоналей по аналогии.
В конце функции нужно удалить все помеченные к удалению шарики с карты
//remove deleted
for (int i = 0; i < 9; i++)
{
for (int j = 0; j < 9; j++)
{
if (deleteMap[i, j])
{
// за каждый удаленный шарик добавляем 1 очко
scores++;
map[i, j] = 0;
}
}
}
// обновим рекорд если требуется
maxScores = Math.Max(maxScores, scores);
Для того чтобы линии стали удаляться будем вызывать проверку удаления линия в момент телепортации шарика в новое место:
private void Form1_MouseUp(object? sender, MouseEventArgs e)
{
var cursor = e.Location;
int row = (cursor.Y) / CellSize;
int column = cursor.X / CellSize;
if (map[column, row] != 0)
selectedBall = new Point(column, row);
if (selectedBall != null && map[column, row] == 0)
{
//move ball to new position
map[column, row] = map[selectedBall.Value.X, selectedBall.Value.Y];
map[selectedBall.Value.X, selectedBall.Value.Y] = 0;
selectedBall = null;
//check lines
CheckLines();
//generate new balls
var freeQty = FreeCells();
if (freeQty <= 3) //gameOver
gameOver = true;
for (int i = 0; i < Math.Min(freeQty, 3); i++)
NewBall();
}
}
Запустим и проверим результат

Поиск пути шарика
Теперь когда мы сделали упрощенную версию игры (без поиска пути шарика), давайте добавим поиск пути. По аналогии с задачей в лабиринте сделаем волновой поиск.
private void BuildWave(int ballX, int ballY)
{
wave = new int[9, 9];
Queue<(Point, int)> q = new Queue<(Point, int)>();
q.Enqueue(new(new Point(ballX, ballY), 1));
bool first = true;
while (q.Any())
{
var deq = q.Dequeue();
var p = deq.Item1;
if (p.X < 0 || p.Y < 0 || p.X >= map.GetLength(0) || p.Y >= map.GetLength(1))
continue;
if (!first && map[p.X, p.Y] != 0)
continue;
first = false;
if (wave[p.X, p.Y] != 0)
continue;
int level = deq.Item2;
wave[p.X, p.Y] = level;
q.Enqueue((new Point(p.X + 1, p.Y), level + 1));
q.Enqueue((new Point(p.X - 1, p.Y), level + 1));
q.Enqueue((new Point(p.X, p.Y + 1), level + 1));
q.Enqueue((new Point(p.X, p.Y - 1), level + 1));
}
}
//если вышли за пределы карты, то пропускаем эту клетку
if (p.X < 0 || p.Y < 0 || p.X >= map.GetLength(0) || p.Y >= map.GetLength(1))
continue;
Попробуем отрисовать метки и убедимся что волна строится правильно
Game Over
Добавим отрисовку надписи конца игры:
private void Form1_Paint(object? sender, PaintEventArgs e)
{
var gr = e.Graphics;
gr.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
gr.Clear(Color.Black);
gr.ResetTransform();
DrawMap(gr);
if (gameOver)
{
var text = "GAME OVER";
var font = new Font("Press Start 2P", 24);
var ms = gr.MeasureString(text, font);
gr.FillRectangle(new SolidBrush(Color.FromArgb(220, Color.Black)), 0, 0, ClientSize.Width, ClientSize.Height);
gr.DrawString(text, font, Brushes.DeepPink, ClientSize.Width / 2 - ms.Width / 2, ClientSize.Height / 2 - ms.Height / 2);
}
}
Попробуем сознательно проиграть игру и увидим следующую надпись

Для продолжения игры давайте добавим небольшую проверку в начала хенделра MouseUp
private void Form1_MouseUp(object? sender, MouseEventArgs e)
{
if (gameOver) // если игра в состоянии GameOver и нажали кнопку мыши
{ // то сбросить игру
ResetGame();
return;
}
// .....
Теперь при проигрыше достаточно нажать кнопкой мыши в любом месте игрового окна и игра сброситься и начнется с начала.
Добавляем анимацию
Добавим анимацию перехода шарика по пути. Для того чтобы это сделать введем третье состояние в машину состояний игры: движение шарика. Поскольку теперь у нас больше чем два состояния в игре, то переменную gameOver можно удалить и ввести state
enum GameState
{
Idle, BallMove, GameOver
}
GameState state = GameState.Idle;
Таже введем функцию обновления сцены где будем смещать шарик по пути если происходит анимация. Также по аналогии с предыдущими уроками добавим задержку анимации (например в 100мс)
DateTime lastMove = new DateTime() ;
const int MoveAnimatinDelayMs = 100;
private void UpdateScene()
{
if (state != GameState.BallMove)
return;
if (DateTime.Now.Subtract(lastMove).TotalMilliseconds < MoveAnimatinDelayMs)
return;
lastMove = DateTime.Now;
if (!path.Any())
{
selectedBall = null;
//check lines
CheckLines();
//generate new balls
var freeQty = FreeCells();
if (freeQty <= 3) //gameOver
state = GameState.GameOver;
for (int i = 0; i < Math.Min(freeQty, 3); i++)
{
NewBall();
}
state = GameState.Idle;
return;
}
var nextCell = path.First();
path.RemoveAt(0);
//move ball to new position
map[nextCell.X, nextCell.Y] = map[selectedBall.Value.X, selectedBall.Value.Y];
map[selectedBall.Value.X, selectedBall.Value.Y] = 0;
selectedBall = nextCell;
}
Запустим проверим как теперь будет двигаться шарик:

Полный код примера можно посмотреть здесь
Задания:
1. Попробовать выводить псевдо-объемные шарики используя радиальный градиант (смотри здесь)
2. Добавить анимацию удаления шариков (например плавное уменьшение и исчезновение)
3. Добавить анимацию выбранного щарика (подумайте нужно ли вводить для этого новое сосятоние в машину состояний или нет)
4. Переделать отрисовку шариков на тайлсет. Для этого сделайте скриншот с оригинальной игры и создайте тайлсет. После этого замените вывод всех шариков тайлами из тайлсета