Линии

Пример игры можно посмотреть в интернете (например здесь https://www.min2win.ru/game/lines-98.html)

Игра линии представляет собой классическую игру по составлению разноцветных шариков в ряд, чтобы получилась линия длиной 3 или 5 шариков (в разных версиях игры по разному). На каждом шагу пользователь выбирает 1 шарик и выбирает куда его переместить, но перемещать можно только в те ячейки до куда существует безпрепятственный путь для шарика. После хода игрока дополнительно появляются еще три новых шарика. Если была составлена линия из шариков одинакового цвета длиной 3/5 то она будет удалена с поля.

При решении задачи решается ряд подзадач:

  1. поиск пути при движении шарика в новую позицию

  2. поиск свободного места для генерация новых шариков в случайном месте 

  3. проверка на наличие линий который можно удалить

  4. определение условий проигрыша

 

Для решения этой задачи, разобьем ее на подводящие подзадачи.

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);
    }

}
Шрифт Press Start 2P можно скачать здесь с архива шрифтов google

Попробуем сознательно проиграть игру и увидим следующую надпись

Для продолжения игры давайте добавим небольшую проверку в начала хенделра 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. Переделать отрисовку шариков на тайлсет. Для этого сделайте скриншот с оригинальной игры и создайте тайлсет. После этого замените вывод всех шариков тайлами из тайлсета