Tetris Game

Преди около седмица раздадоха първите отборни проекти в академията, чиято цел беше да представим дотук наученото в курса по обектно ориентирано програмиране. Съвсем естествено ние решихме, че трябва да правим игра и се спряхме на тетрис, реализиран с WinForms. В тази статия ще разкажа за играта и ключовите моменти в кода. Сорсът, както винаги – в гитхъб.

Gameplay

Tetris2                     Tetris1                  Tetris3

Общо взето класическият лейаут на играта. Имаме игрално поле, опашка с фигурите, които ще паднат след текущата. В дясно се показват точките и нивото на трудност на което сте в момента. Извън режим на игра могат да се проверяват най-високите точки до момента. Ако се постигне нов по-голям резултат играчът може да запише името си. Фигурките се движат със стрелките и се въртят с натискане на space. Има възможност за пауза, чрез натискане на бутона P или Esc.

Shapes and Blocks

Блокчетата(Block.cs) са най-малката строителна единица. Всяко си знае координатите, цвета и формата(правоъгълник с равни страни, вече съм говорил на тази тема). Може да се нарисува, мести и да провери, дали на дадено място до него в полето е празно. Фигурите (Shape.cs) съдържат лист от блокчета. Навсякъде в играта работим с фигура и когато местим в дадена посока тя се обръща към всички квадратчета в нея. Ротацията(Rotate()) обаче е малко по-сложна. Не е случайно, че всяка фигура има полета row и col. Те са за обозначаване на нейният център, който е точката на въртене. Намираме разликата между координатите на центъра на фигурата и всяко блокче. След това с тяхна помощ изчисляваме новите координати за блокчето.


 int deltaRow = block.Row - this.row;
 int deltaCol = block.Col - this.col;
 int newDeltaRow = deltaCol;
 int newDeltaCol = 1 - (deltaRow - (this.formSize - 2));
 block.Move(this.row + newDeltaRow, this.col + newDeltaCol);

Collision System

mete

В предишната игра, за която блогнах, за колизии се проверяваше чрез IntersectsWith() метода в Rectangle. Тетрисът обаче освен за колизии, проверява и за пълни редове. Ако трябва да сравняваме блокчетата всяко с всяко за колизия или ред ще имаме адски неефективен алгоритъм. Решението(Grid.cs) е да използваме матрица в която да се движат фигурите и спрямо нея да проверяваме за колизия или ред. Докато все още фигурата пада в матрицата се проверява дали на следващият ход ще опре дъното и това става много бързо, защото всяко блокче си знае координатите и те съвпадат с индексацията в матрицата. След като дадената фигура падне, нейните блокчета се референцират от матрицата и се взима нова фигура. Сега проверката за пълен ред става много лесно – обхождаме двумерният масив и ако в реда няма нито един null – имаме победител. Смъкването на блокчетата след това с един ред надолу става по същият начин, като започнем от махнатият ред и продължим до 0, увеличаваме координатата за ред с 1. За да не предаваме като аргумент Grid-а или да пазим референция към него във всяко блокче, когато правим проверки се използва GridManager.cs, той се явява посредник между блокчетата във фигурата и grid(матрицата).

Score

Точкуването(Score.cs) се смята по формулата LinesDestroyed * GridManager.Width * Level. Където Width е броят блокчета на ред. В типичен oldschool стил се запазват само топ 3 резултатите. След като играчът “умре” се проверява, дали точките му са сред 3-те най-добри и ако е така се добавя, като последният се изгубва. Данните се пазят в криптиран вид във файл, като след излизането от игра могат да се проверят. ScoreEntry е структура, която пази името и точките.

Game Pipeline

Може би най-интересната част от програмата е там, където всичко по-горе се управлява(TetrisForm.cs). Да го нарека енджин би било доста силно казано, затова му викам пайплайн – начинът по който върви програмата. Имаме таймер, който е отговорен за смъкването на фигурите. В оставащият прозорец от време(между тиковете) играчът може да движи фигурата накъдето си поиска. По този начин се постига класическото за тетриса плъзване на фигурата под друга в последният момент:

private void GameTimer_Tick(object sender, EventArgs e)
{
    if (!shape.MoveDown())
    {
        grid.PlaceShapeBlocksInGrid(shape.GetBlocks);
        shape = shapeQueue.NextShape();
        if (!shape.IsShapePossible())
        {
            GameTimer.Stop();
            Score.CheckIfHighscore();
            btnRestart.Visible = true;
            btnHighScores.Visible = true;
        }
        if (stopwatch.Elapsed.Seconds > 30)
            RiseLevel();
        Score.Update(lblScoreAmount);
        QueuePanel.Refresh(); //forces repaint
    }

this.Refresh(); //forces repaint
}

MoveDown() връща булев резултат и знаем дали фигурата е мръднала надолу или не може. Ако не може – слагаме блокчетата й в grid-a и вземаме нова фигура. След това проверяваме, дали може да бъде поставен новият шейп, ако не – значи gameover и проверяваме дали резултата е в highscore и показваме бутоните. Също така има пуснат и друг таймер, който увеличава нивото на всеки 30 секунди. Накрая ъпдейтваме точките, защото може да е разрушен ред и рефрешваме панела, където се визуализира опашката.

Като стана дума за опашката от чакащи фигури, не му е тук мястото, но иначе ще забравя да го напиша. Имаше проблем с визуализацията й при различните резолюции – появяваше се не където трябва. Този проблем беше решен с добавянето на допълнителен панел, който е позициониран релативно спрямо главният, а фигурите вътре пак си се позиционират абсолютно, но този път спрямо по-малкият панел.

Увеличаването на нивото става, чрез намаляване интервала в GameTimer:


 private void RiseLevel()
 {
     if (GameTimer.Interval > 60)
     {
        GameTimer.Interval -= 20;
        Score.Level++;
     }
     stopwatch.Restart();
     lblLevel.Text = Score.Level + " Level";
 }

Колкото по-малък е интервала, толкова по-бързо ще се придвижва фигурата надолу и съответно нивото на трудност се увеличава.

За да е по-респонсив играта в метода за input TetrisForm_KeyDown() след натискане на копче се вика this.Refresh(). Това прерисува фигурата веднага, а не на следващият тик в таймера. Ако го махнете ще видите как има закъснение между вашите действия и фигурата – тя се движи, но се вижда с малко закъснение. Това допълнително прерисуване обаче има своята цена.

Problems

35uldy

Графичните способности на WinForms определено не предвиждат да се използват за игри и при многократно ъпдейтване на UI компонентите се получава примигване. Ако махнем рефреша в input-а почти не се получава, но тогава имаме адски дървена игра, което пък тотално убива геймплея, особено при по-високите нива, когато времето за реакция е много по-малко. На места има coupling, който не измислих как да избегнем – примерно, когато блокчето проверява дали дадено място в grid-a е свободно. Първоначалният вариант беше по-лош – всяко блокче пазеше референция към grid, но и със статичен клас не изглежда много добре. На много места кода може да се оптимизира, така че да се спестят излишни проверки или операции, примерно RiseLevel() може да ъпдейтва лейбъла за ниво, само когато то се увеличи, а не всеки път, когато метода се извика. При ротация(Rotate()) смятаме 2 пъти завъртането – първият път се проверява дали всички кубчета могат да бъдат завъртени, а после отново, за да се завъртят. ShapeQueue може да пази фигурите във фиксиран масив, защото те винаги са 3 и никога няма да станат повече.

Всеки код има своите недостатъци и можем много да научим от зле написаният, затова реших да добавя по-изпъкващите в този тетрис, вместо да не ги спомена или да ги поправя.

Posted in C#, Programming Tagged with: , , , , , , , , , ,

Leave a Reply

Your email address will not be published.