diff --git a/.github/workflows/Tetris Build.yml b/.github/workflows/Tetris Build.yml
new file mode 100644
index 00000000..a2ced14f
--- /dev/null
+++ b/.github/workflows/Tetris Build.yml
@@ -0,0 +1,20 @@
+name: Tetris Build
+on:
+ push:
+ paths:
+ - 'Projects/Tetris/**'
+ - '!**.md'
+ pull_request:
+ paths:
+ - 'Projects/Tetris/**'
+ - '!**.md'
+ workflow_dispatch:
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-dotnet@v3
+ with:
+ dotnet-version: 7.0.x
+ - run: dotnet build "Projects\Tetris\Tetris.csproj" --configuration Release
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 476816fe..f22e1c47 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -462,6 +462,16 @@
"console": "externalTerminal",
"stopAtEntry": false,
},
+ {
+ "name": "Tetris",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "Build Tetris",
+ "program": "${workspaceFolder}/Projects/Tetris/bin/Debug/Tetris.dll",
+ "cwd": "${workspaceFolder}/Projects/Tetris/bin/Debug",
+ "console": "externalTerminal",
+ "stopAtEntry": false,
+ },
{
"name": "Role Playing Game",
"type": "coreclr",
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 2f312b30..f66b39b7 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -639,6 +639,19 @@
],
"problemMatcher": "$msCompile",
},
+ {
+ "label": "Build Tetris",
+ "command": "dotnet",
+ "type": "process",
+ "args":
+ [
+ "build",
+ "${workspaceFolder}/Projects/Tetris/Tetris.csproj",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary",
+ ],
+ "problemMatcher": "$msCompile",
+ },
{
"label": "Build Solution",
"command": "dotnet",
diff --git a/Projects/Tetris/Program.cs b/Projects/Tetris/Program.cs
new file mode 100644
index 00000000..05646a0e
--- /dev/null
+++ b/Projects/Tetris/Program.cs
@@ -0,0 +1,858 @@
+using System;
+using System.Diagnostics;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+
+#region Constants
+
+string[] emptyField = new string[42];
+emptyField[0] = "╭──────────────────────────────╮";
+for (int i = 1; i < 41; i++)
+{
+ emptyField[i] = "│ │";
+}
+emptyField[^1] = "╰──────────────────────────────╯";
+
+string[] nextTetrominoBorder = new[]
+{
+ "╭─────────╮",
+ "│ │",
+ "│ │",
+ "│ │",
+ "│ │",
+ "│ │",
+ "│ │",
+ "│ │",
+ "│ │",
+ "╰─────────╯"
+};
+
+string[] scoreBorder = new[]
+{
+ "╭─────────╮",
+ "│ │",
+ "╰─────────╯"
+};
+
+string[] pauseRender = new[]
+{
+ "█████╗ ███╗ ██╗██╗█████╗█████╗",
+ "██╔██║██╔██╗██║██║██╔══╝██╔══╝",
+ "█████║█████║██║██║ ███╗ █████╗",
+ "██╔══╝██╔██║██║██║ ██╗██╔══╝",
+ "██║ ██║██║█████║█████║█████╗",
+ "╚═╝ ╚═╝╚═╝╚════╝╚════╝╚════╝",
+};
+
+string[][] tetrominos = new[]
+{
+ new[]{
+ "╭─╮",
+ "╰─╯",
+ "x─╮",
+ "╰─╯",
+ "╭─╮",
+ "╰─╯",
+ "╭─╮",
+ "╰─╯"
+ },
+ new[]{
+ "╭─╮ ",
+ "╰─╯ ",
+ "╭─╮x─╮╭─╮",
+ "╰─╯╰─╯╰─╯"
+ },
+ new[]{
+ " ╭─╮",
+ " ╰─╯",
+ "╭─╮x─╮╭─╮",
+ "╰─╯╰─╯╰─╯"
+ },
+ new[]{
+ "╭─╮╭─╮",
+ "╰─╯╰─╯",
+ "x─╮╭─╮",
+ "╰─╯╰─╯"
+ },
+ new[]{
+ " ╭─╮╭─╮",
+ " ╰─╯╰─╯",
+ "╭─╮x─╮ ",
+ "╰─╯╰─╯ "
+ },
+ new[]{
+ " ╭─╮ ",
+ " ╰─╯ ",
+ "╭─╮x─╮╭─╮",
+ "╰─╯╰─╯╰─╯"
+ },
+ new[]{
+ "╭─╮╭─╮ ",
+ "╰─╯╰─╯ ",
+ " x─╮╭─╮",
+ " ╰─╯╰─╯"
+ },
+};
+
+const int borderSize = 1;
+
+int initialX = (emptyField[0].Length / 2) - 3;
+int initialY = 1;
+
+int consoleWidthMin = 44;
+int consoleHeightMin = 43;
+
+#endregion
+
+Stopwatch timer = new();
+bool closeRequested = false;
+bool gameOver;
+int score = 0;
+TimeSpan fallSpeed;
+string[] field;
+Tetromino tetromino;
+int consoleWidth = Console.WindowWidth;
+int consoleHeight = Console.WindowHeight;
+bool consoleTooSmallScreen = false;
+
+Console.OutputEncoding = Encoding.UTF8;
+while (!closeRequested)
+{
+ Console.Clear();
+ Console.Write("""
+
+ ██████╗█████╗██████╗█████╗ ██╗█████╗
+ ╚═██╔═╝██╔══╝╚═██╔═╝██╔═██╗██║██╔══╝
+ ██║ █████╗ ██║ █████╔╝██║ ███╗
+ ██║ ██╔══╝ ██║ ██╔═██╗██║ ██╗
+ ██║ █████╗ ██║ ██║ ██║██║█████║
+ ╚═╝ ╚════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚════╝
+
+ Controls:
+
+ [A] or [←] move left
+ [D] or [→] move right
+ [S] or [↓] fall faster
+ [Q] spin left
+ [E] spin right
+ [Spacebar] drop
+ [P] pause and resume
+ [Escape] close game
+ [Enter] start game
+ """);
+ bool mainMenuScreen = true;
+ while (!closeRequested && mainMenuScreen)
+ {
+ Console.CursorVisible = false;
+ switch (Console.ReadKey(true).Key)
+ {
+ case ConsoleKey.Enter: mainMenuScreen = false; break;
+ case ConsoleKey.Escape: closeRequested = true; break;
+ }
+ }
+ Initialize();
+ Console.Clear();
+ DrawFrame();
+ while (!closeRequested && !gameOver)
+ {
+ // if user changed the size of the console, we need to clear the console
+ if (consoleWidth != Console.WindowWidth || consoleHeight != Console.WindowHeight)
+ {
+ consoleWidth = Console.WindowWidth;
+ consoleHeight = Console.WindowHeight;
+ if (!consoleTooSmallScreen)
+ {
+ Console.Clear();
+ DrawFrame();
+ }
+ else
+ {
+ consoleTooSmallScreen = false;
+ }
+ }
+
+ // if the console isn't big enough to render the game, pause the game and tell the user
+ if (consoleWidth < consoleWidthMin || consoleHeight < consoleHeightMin)
+ {
+ if (!consoleTooSmallScreen)
+ {
+ Console.Clear();
+ Console.Write($"Please increase size of console to at least {consoleWidthMin}x{consoleHeightMin}. Current size is {consoleWidth}x{consoleHeight}.");
+ timer.Stop();
+ consoleTooSmallScreen = true;
+ }
+ }
+ else if (consoleTooSmallScreen)
+ {
+ consoleTooSmallScreen = false;
+ Console.Clear();
+ DrawFrame();
+ }
+
+ HandlePlayerInput();
+ if (closeRequested || gameOver)
+ {
+ break;
+ }
+ if (timer.IsRunning && timer.Elapsed > fallSpeed)
+ {
+ TetrominoFall();
+ if (closeRequested || gameOver)
+ {
+ break;
+ }
+ DrawFrame();
+ }
+ }
+ if (closeRequested)
+ {
+ break;
+ }
+ Console.Clear();
+ Console.Write($"""
+
+ ██████╗ █████╗ ██ ██╗█████╗
+ ██╔════╝ ██╔══██╗███ ███║██╔══╝
+ ██║ ███╗███████║██╔██═██║█████╗
+ ██║ ██║██╔══██║██║ ██║██╔══╝
+ ╚██████╔╝██║ ██║██║ ██║█████╗
+ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚════╝
+ ██████╗██╗ ██╗█████╗█████╗
+ ██ ██║██║ ██║██╔══╝██╔═██╗
+ ██ ██║██║ ██║█████╗█████╔╝
+ ██ ██║╚██╗██╔╝██╔══╝██╔═██╗
+ ██████║ ╚███╔╝ █████╗██║ ██║
+ ╚═════╝ ╚══╝ ╚════╝╚═╝ ╚═╝
+
+ Final Score: {score}
+
+ [Enter] return to menu
+ [Escape] close game
+ """);
+ Console.CursorVisible = false;
+ bool gameOverScreen = true;
+ while (!closeRequested && gameOverScreen)
+ {
+ Console.CursorVisible = false;
+ switch (Console.ReadKey(true).Key)
+ {
+ case ConsoleKey.Enter: gameOverScreen = false; break;
+ case ConsoleKey.Escape: closeRequested = true; break;
+ }
+ }
+}
+Console.Clear();
+Console.WriteLine("Tetris was closed.");
+Console.CursorVisible = true;
+
+void Initialize()
+{
+ gameOver = false;
+ score = 0;
+ field = emptyField[..];
+ initialX = (field[0].Length / 2) - 3;
+ initialY = 1;
+ tetromino = new()
+ {
+ Shape = tetrominos[Random.Shared.Next(0, tetrominos.Length)],
+ Next = tetrominos[Random.Shared.Next(0, tetrominos.Length)],
+ X = initialX,
+ Y = initialY
+ };
+ fallSpeed = GetFallSpeed();
+ timer.Restart();
+}
+
+void HandlePlayerInput()
+{
+ while (Console.KeyAvailable && !closeRequested)
+ {
+ switch (Console.ReadKey(true).Key)
+ {
+ case ConsoleKey.A or ConsoleKey.LeftArrow:
+ if (timer.IsRunning && !Collision(Direction.Left))
+ {
+ tetromino.X -= 3;
+ }
+ DrawFrame();
+ break;
+ case ConsoleKey.D or ConsoleKey.RightArrow:
+ if (timer.IsRunning && !Collision(Direction.Right))
+ {
+ tetromino.X += 3;
+ }
+ DrawFrame();
+ break;
+ case ConsoleKey.S or ConsoleKey.DownArrow:
+ if (timer.IsRunning)
+ {
+ TetrominoFall();
+ }
+ break;
+ case ConsoleKey.E:
+ if (timer.IsRunning)
+ {
+ TetrominoSpin(Direction.Right);
+ DrawFrame();
+ }
+ break;
+ case ConsoleKey.Q:
+ if (timer.IsRunning)
+ {
+ TetrominoSpin(Direction.Left);
+ DrawFrame();
+ }
+ break;
+ case ConsoleKey.P:
+ if (timer.IsRunning)
+ {
+ timer.Stop();
+ DrawFrame();
+ }
+ else if (!consoleTooSmallScreen)
+ {
+ timer.Start();
+ DrawFrame();
+ }
+ break;
+ case ConsoleKey.Spacebar:
+ if (timer.IsRunning)
+ {
+ HardDrop();
+ }
+ break;
+ case ConsoleKey.Escape:
+ closeRequested = true;
+ return;
+ }
+ }
+}
+
+void DrawFrame()
+{
+ bool collision = false;
+ char[][] frame = new char[field.Length][];
+
+ // Field
+ for (int y = 0; y < field.Length; y++)
+ {
+ frame[y] = field[y].ToCharArray();
+ }
+
+ // Tetromino
+ for (int y = 0; y < tetromino.Shape.Length && !collision; y++)
+ {
+ for (int x = 0; x < tetromino.Shape[y].Length; x++)
+ {
+ int tY = tetromino.Y + y;
+ int tX = tetromino.X + x;
+ char charToReplace = field[tY][tX];
+ char charTetromino = tetromino.Shape[y][x];
+ if (charTetromino is ' ')
+ {
+ continue;
+ }
+ if (charToReplace is not ' ')
+ {
+ collision = true;
+ break;
+ }
+ if (charTetromino is 'x')
+ {
+ charTetromino = '╭';
+ }
+ frame[tY][tX] = charTetromino;
+ }
+ }
+
+ // Draw Preview
+ for (int yField = field.Length - tetromino.Shape.Length - borderSize; yField >= 0; yField -= 2)
+ {
+ if (CollisionBottom(yField, tetromino.Y, tetromino.Shape))
+ {
+ continue;
+ }
+ for (int y = 0; y < tetromino.Shape.Length && !collision; y++)
+ {
+ for (int x = 0; x < tetromino.Shape[y].Length; x++)
+ {
+ int tY = yField + y;
+ if (tetromino.Y + tetromino.Shape.Length > tY)
+ {
+ continue;
+ }
+ int tX = tetromino.X + x;
+ char charToReplace = field[tY][tX];
+ char charTetromino = tetromino.Shape[y][x];
+ if (charTetromino is ' ')
+ {
+ continue;
+ }
+ if (charToReplace is not ' ')
+ {
+ collision = true;
+ break;
+ }
+ frame[tY][tX] = '•';
+ }
+ }
+ break;
+ }
+
+ // Next
+ for (int y = 0; y < nextTetrominoBorder.Length; y++)
+ {
+ frame[y] = frame[y].Concat(nextTetrominoBorder[y]).ToArray();
+ }
+ for (int y = 0; y < tetromino.Next.Length; y++)
+ {
+ for (int x = 0; x < tetromino.Next[y].Length; x++)
+ {
+ int tY = y + borderSize;
+ int tX = field[y].Length + x + borderSize;
+ char charTetromino = tetromino.Next[y][x];
+ if (charTetromino is 'x')
+ {
+ charTetromino = '╭';
+ }
+ frame[tY][tX] = charTetromino;
+ }
+ }
+
+ // Score
+ for (int y = 0; y < scoreBorder.Length; y++)
+ {
+ int sY = nextTetrominoBorder.Length + y;
+ frame[sY] = frame[sY].Concat(scoreBorder[y]).ToArray();
+ }
+ char[] scoreRender = score.ToString(CultureInfo.InvariantCulture).ToCharArray();
+ for (int scoreX = scoreRender.Length - 1; scoreX >= 0; scoreX--)
+ {
+ int sY = nextTetrominoBorder.Length + borderSize;
+ int sX = frame[sY].Length - (scoreRender.Length - scoreX) - borderSize;
+ frame[sY][sX] = scoreRender[scoreX];
+ }
+
+ // Pause
+ if (!timer.IsRunning)
+ {
+ for (int y = 0; y < pauseRender.Length; y++)
+ {
+ int fY = (field.Length / 2) + y - pauseRender.Length;
+ for (int x = 0; x < pauseRender[y].Length; x++)
+ {
+ int fX = x + borderSize;
+
+ if (x >= field[fY].Length) break;
+
+ frame[fY][fX] = pauseRender[y][x];
+ }
+ }
+ }
+
+ StringBuilder render = new();
+ for (int y = 0; y < frame.Length; y++)
+ {
+ render.AppendLine(new string(frame[y]));
+ }
+ Console.SetCursorPosition(0, 0);
+ Console.Write(render);
+ Console.CursorVisible = false;
+}
+
+char[][] DrawLastFrame(int yS)
+{
+ bool collision = false;
+ int yScope = yS - 2;
+ int xScope = tetromino.X;
+ char[][] frame = new char[field.Length][];
+ for (int y = 0; y < field.Length; y++)
+ {
+ frame[y] = field[y].ToCharArray();
+ }
+ for (int y = 0; y < tetromino.Shape.Length && !collision; y++)
+ {
+ for (int x = 0; x < tetromino.Shape[y].Length; x++)
+ {
+ int tY = yScope + y;
+ int tX = xScope + x;
+ char charToReplace = field[tY][tX];
+ char charTetromino = tetromino.Shape[y][x];
+ if (charTetromino is ' ')
+ {
+ continue;
+ }
+ if (charToReplace is not ' ')
+ {
+ collision = true;
+ break;
+ }
+ if (charTetromino is 'x')
+ {
+ charTetromino = '╭';
+ }
+ frame[tY][tX] = charTetromino;
+ }
+ }
+ return frame;
+}
+
+bool Collision(Direction direction)
+{
+ int xNew = tetromino.X;
+ bool collision = false;
+ switch (direction)
+ {
+ case Direction.Right:
+ xNew += 3;
+ if (xNew + tetromino.Shape[0].Length > field[0].Length - borderSize)
+ {
+ collision = true;
+ }
+ break;
+ case Direction.Left:
+ xNew -= 3;
+ if (xNew < borderSize)
+ {
+ collision = true;
+ }
+ break;
+ case Direction.None:
+ break;
+ }
+ if (collision)
+ {
+ return collision;
+ }
+ for (int y = 0; y < tetromino.Shape.Length && !collision; y++)
+ {
+ for (int x = 0; x < tetromino.Shape[y].Length; x++)
+ {
+ int tY = tetromino.Y + y;
+ int tX = xNew + x;
+ char charToReplace = field[tY][tX];
+ char charTetromino = tetromino.Shape[y][x];
+ if (charTetromino is ' ')
+ {
+ continue;
+ }
+ if (charToReplace is not ' ')
+ {
+ collision = true;
+ break;
+ }
+ }
+ }
+ return collision;
+}
+
+bool CollisionBottom(int initY, int yScope, string[] shape)
+{
+ int xNew = tetromino.X;
+ for (int yUpper = initY; yUpper >= yScope; yUpper -= 2)
+ {
+ for (int y = shape.Length - 1; y >= 0; y -= 2)
+ {
+ for (int x = 0; x < shape[y].Length; x++)
+ {
+ int tY = yUpper + y;
+ int tX = xNew + x;
+ char charToReplace = field[tY][tX];
+ char charTetromino = shape[y][x];
+ if (charTetromino is ' ')
+ {
+ continue;
+ }
+ if (charToReplace is not ' ')
+ {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+}
+
+TimeSpan GetFallSpeed() =>
+ TimeSpan.FromMilliseconds(
+ score switch
+ {
+ > 162 => 100,
+ > 144 => 200,
+ > 126 => 300,
+ > 108 => 400,
+ > 090 => 500,
+ > 072 => 600,
+ > 054 => 700,
+ > 036 => 800,
+ > 018 => 900,
+ _ => 1000,
+ });
+
+void TetrominoFall()
+{
+ int yAfterFall = tetromino.Y;
+ bool collision = false;
+
+ if (tetromino.Y + tetromino.Shape.Length + 2 > field.Length)
+ {
+ yAfterFall = field.Length - tetromino.Shape.Length + 1;
+ }
+ else
+ {
+ yAfterFall += 2;
+ }
+
+ // Y Collision
+ for (int xCollision = 0; xCollision < tetromino.Shape[0].Length;)
+ {
+ for (int yCollision = tetromino.Shape.Length - 1; yCollision >= 0; yCollision -= 2)
+ {
+ char exist = tetromino.Shape[yCollision][xCollision];
+ if (exist is ' ')
+ {
+ continue;
+ }
+ char[] lineYC = field[yAfterFall + yCollision - 1].ToCharArray();
+ if (tetromino.X + xCollision < 0 || tetromino.X + xCollision > lineYC.Length)
+ {
+ continue;
+ }
+ if (lineYC[tetromino.X + xCollision] is not ' ' or '│')
+ {
+ char[][] lastFrame = DrawLastFrame(yAfterFall);
+ for (int y = 0; y < lastFrame.Length; y++)
+ {
+ field[y] = new string(lastFrame[y]);
+ }
+ tetromino.X = initialX;
+ tetromino.Y = initialY;
+ tetromino.Shape = tetromino.Next;
+ tetromino.Next = tetrominos[Random.Shared.Next(0, tetrominos.Length)];
+ xCollision = tetromino.Shape[0].Length;
+ collision = true;
+ break;
+ }
+ }
+ xCollision += 3;
+ }
+
+ if (!collision)
+ {
+ tetromino.Y = yAfterFall;
+ }
+
+ // Clean Lines
+ int clearedLines = 0;
+ for (int lineIndex = field.Length - 1; lineIndex >= 0; lineIndex--)
+ {
+ string line = field[lineIndex];
+ bool notCompleted = line.Any(e => e is ' ');
+ if (lineIndex is 0 || lineIndex == field.Length - 1)
+ {
+ continue;
+ }
+ if (!notCompleted)
+ {
+ field[lineIndex] = "│ │";
+ clearedLines++;
+ for (int lineM = lineIndex; lineM >= 1; lineM--)
+ {
+ if (field[lineM - 1] is "╭──────────────────────────────╮")
+ {
+ field[lineM] = "│ │";
+ continue;
+ }
+ field[lineM] = field[lineM - 1];
+ }
+ lineIndex++;
+ }
+ }
+ clearedLines /= 2;
+ if (clearedLines > 0)
+ {
+ int value = clearedLines switch
+ {
+ 1 => 1,
+ 2 => 3,
+ 3 => 6,
+ 4 => 9,
+ _ => throw new NotImplementedException(),
+ };
+ score += value;
+ fallSpeed = GetFallSpeed();
+ }
+ if (Collision(Direction.None))
+ {
+ gameOver = true;
+ }
+ else
+ {
+ DrawFrame();
+ timer.Restart();
+ }
+}
+
+void HardDrop()
+{
+ int y = tetromino.Y;
+ int x = tetromino.X;
+ for (int yField = field.Length - tetromino.Shape.Length - borderSize; yField >= 0; yField -= 2)
+ {
+ if (CollisionBottom(yField, y, tetromino.Shape))
+ {
+ continue;
+ }
+ tetromino.Y = yField;
+ break;
+ }
+ DrawFrame();
+ timer.Restart();
+}
+
+void TetrominoSpin(Direction spinDirection)
+{
+ int yScope = tetromino.Y;
+ int xScope = tetromino.X;
+ string[] newShape = new string[tetromino.Shape[0].Length / 3 * 2];
+ int newY = 0;
+ int rowEven = 0;
+ int rowOdd = 1;
+
+ // Turn
+ for (int y = 0; y < tetromino.Shape.Length;)
+ {
+ switch (spinDirection)
+ {
+ case Direction.Right:
+ SpinRight(newShape, tetromino.Shape, ref newY, rowEven, rowOdd, y);
+ break;
+ case Direction.Left:
+ SpinLeft(newShape, tetromino.Shape, ref newY, rowEven, rowOdd, y);
+ break;
+ }
+ newY = 0;
+ rowEven += 2;
+ rowOdd += 2;
+ y += 2;
+ }
+
+ // Old Pivot
+ (int y, int x) offsetOP = (0, 0);
+ for (int y = 0; y < tetromino.Shape.Length; y += 2)
+ {
+ for (int x = 0; x < tetromino.Shape[y].Length; x += 3)
+ {
+ if (tetromino.Shape[y][x] is 'x')
+ {
+ offsetOP = (y / 2, x / 3);
+ y = tetromino.Shape.Length;
+ break;
+ }
+ }
+ }
+
+ // New Pivot
+ (int y, int x) offsetNP = (0, 0);
+ for (int y = 0; y < newShape.Length; y += 2)
+ {
+ for (int x = 0; x < newShape[y].Length; x += 3)
+ {
+ if (newShape[y][x] is 'x')
+ {
+ offsetNP = (y / 2, x / 3);
+ y = newShape.Length;
+ break;
+ }
+ }
+ }
+
+ yScope += (offsetOP.y - offsetNP.y) * 2;
+ xScope += (offsetOP.x - offsetNP.x) * 3;
+
+ // Tetromino Square(O) special case
+ if (newShape.Length / 2 == newShape[0].Length / 3)
+ {
+ yScope = tetromino.Y;
+ xScope = tetromino.X;
+ }
+ // Tetromino I special case
+ else if (newShape.Length is 8 && newShape[0].Length is 3 && offsetNP.y is 2)
+ {
+ newShape[2] = "x─╮";
+ newShape[4] = "╭─╮";
+ yScope += 2;
+ }
+
+ if (xScope < 1 || yScope < 1)
+ {
+ return;
+ }
+
+ // Verified Collision
+ for (int y = 0; y < newShape.Length - 1; y++)
+ {
+ for (int x = 0; x < newShape[y].Length; x++)
+ {
+ if (newShape[y][x] is ' ')
+ {
+ continue;
+ }
+ char c = field[yScope + y][xScope + x];
+ if (c is not ' ')
+ {
+ return;
+ }
+ }
+ }
+ tetromino.Y = yScope;
+ tetromino.X = xScope;
+ tetromino.Shape = newShape;
+}
+
+void SpinLeft(string[] newShape, string[] shape, ref int newY, int rowEven, int rowOdd, int y)
+{
+ for (int x = shape[y].Length - 1; x >= 0; x -= 3)
+ {
+ for (int xS = 2; xS >= 0; xS--)
+ {
+ newShape[newY] += shape[rowEven][x - xS];
+ newShape[newY + 1] += shape[rowOdd][x - xS];
+ }
+ newY += 2;
+ }
+}
+
+void SpinRight(string[] newShape, string[] shape, ref int newY, int rowEven, int rowOdd, int y)
+{
+ for (int x = 2; x < shape[y].Length; x += 3)
+ {
+ if (newShape[newY] is null)
+ {
+ newShape[newY] = "";
+ newShape[newY + 1] = "";
+ }
+ for (int xS = 0; xS <= 2; xS++)
+ {
+ newShape[newY] = newShape[newY].Insert(0, shape[rowEven][x - xS].ToString(CultureInfo.InvariantCulture));
+ newShape[newY + 1] = newShape[newY + 1].Insert(0, shape[rowOdd][x - xS].ToString(CultureInfo.InvariantCulture));
+ }
+ newY += 2;
+ }
+}
+
+class Tetromino
+{
+ public required string[] Shape { get; set; }
+ public required string[] Next { get; set; }
+ public int X { get; set; }
+ public int Y { get; set; }
+}
+
+enum Direction
+{
+ None,
+ Right,
+ Left,
+}
diff --git a/Projects/Tetris/README.md b/Projects/Tetris/README.md
new file mode 100644
index 00000000..7cac7dd6
--- /dev/null
+++ b/Projects/Tetris/README.md
@@ -0,0 +1,92 @@
+
+ Tetris
+
+
+
+
+
+
+
+
+
+
+
+
+ You can play this game in your browser:
+
+
+
+
+
+ Hosted On GitHub Pages
+
+
+> **Note** This game was a *[Community Contribution](https://github.com/dotnet/dotnet-console-games/pull/89)!
+
+Well, just tetris!
+
+```
+╭──────────────────────────────╮╭─────────╮
+│ ││ ╭─╮ │
+│ ││ ╰─╯ │
+│ ││╭─╮╭─╮╭─╮│
+│ ││╰─╯╰─╯╰─╯│
+│ ││ │
+│ ││ │
+│ ││ │
+│ ││ │
+│ │╰─────────╯
+│ │╭─────────╮
+│ ││ 12│
+│ │╰─────────╯
+│ ╭─╮ │
+│ ╰─╯ │
+│ ╭─╮ │
+│ ╰─╯ │
+│ ╭─╮ │
+│ ╰─╯ │
+│ ╭─╮ │
+│ ╰─╯ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ │
+│ ••• │
+│ ••• │
+│ ╭─╮╭─╮╭─╮••• │
+│ ╰─╯╰─╯╰─╯••• │
+│ ╭─╮╭─╮╭─╮╭─╮••• │
+│ ╰─╯╰─╯╰─╯╰─╯••• │
+│ ╭─╮╭─╮╭─╮╭─╮╭─╮•••╭─╮ ╭─╮│
+│ ╰─╯╰─╯╰─╯╰─╯╰─╯•••╰─╯ ╰─╯│
+╰──────────────────────────────╯
+```
+
+## Input
+
+|Key|Action|
+|---|---|
+| `←` or `A` | Move Left |
+| `→` or `D` | Move Right |
+| `↓` or `S` | Fall Faster |
+| `Q` | Spin Left |
+| `E` | Spin Right |
+| `P` | Pause or Resume |
+| `Enter` | Confirm |
+| `Escape` | Close Game |
+
+## Downloads
+
+[win-x64](https://github.com/dotnet/dotnet-console-games/raw/binaries/win-x64/Tetris.exe)
+
+[linux-x64](https://github.com/dotnet/dotnet-console-games/raw/binaries/linux-x64/Tetris)
+
+[osx-x64](https://github.com/dotnet/dotnet-console-games/raw/binaries/osx-x64/Tetris)
diff --git a/Projects/Tetris/Tetris.csproj b/Projects/Tetris/Tetris.csproj
new file mode 100644
index 00000000..0e17b8ef
--- /dev/null
+++ b/Projects/Tetris/Tetris.csproj
@@ -0,0 +1,8 @@
+
+
+ Exe
+ net7.0
+ disable
+ enable
+
+
diff --git a/Projects/Website/Games/Tetris/Tetris.cs b/Projects/Website/Games/Tetris/Tetris.cs
new file mode 100644
index 00000000..1597199d
--- /dev/null
+++ b/Projects/Website/Games/Tetris/Tetris.cs
@@ -0,0 +1,871 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Diagnostics;
+using System.Text;
+using System.Linq;
+using System.Globalization;
+
+namespace Website.Games.Tetris;
+
+public class Tetris
+{
+ public readonly BlazorConsole Console = new();
+
+ public async Task Run()
+ {
+ #region Constants
+
+ string[] emptyField = new string[42];
+ emptyField[0] = "╭──────────────────────────────╮";
+ for (int i = 1; i < 41; i++)
+ {
+ emptyField[i] = "│ │";
+ }
+ emptyField[^1] = "╰──────────────────────────────╯";
+
+ string[] nextTetrominoBorder = new[]
+ {
+ "╭─────────╮",
+ "│ │",
+ "│ │",
+ "│ │",
+ "│ │",
+ "│ │",
+ "│ │",
+ "│ │",
+ "│ │",
+ "╰─────────╯"
+ };
+
+ string[] scoreBorder = new[]
+ {
+ "╭─────────╮",
+ "│ │",
+ "╰─────────╯"
+ };
+
+ string[] pauseRender = new[]
+ {
+ "█████╗ ███╗ ██╗██╗█████╗█████╗",
+ "██╔██║██╔██╗██║██║██╔══╝██╔══╝",
+ "█████║█████║██║██║ ███╗ █████╗",
+ "██╔══╝██╔██║██║██║ ██╗██╔══╝",
+ "██║ ██║██║█████║█████║█████╗",
+ "╚═╝ ╚═╝╚═╝╚════╝╚════╝╚════╝",
+ };
+
+ string[][] tetrominos = new[]
+ {
+ new[]{
+ "╭─╮",
+ "╰─╯",
+ "x─╮",
+ "╰─╯",
+ "╭─╮",
+ "╰─╯",
+ "╭─╮",
+ "╰─╯"
+ },
+ new[]{
+ "╭─╮ ",
+ "╰─╯ ",
+ "╭─╮x─╮╭─╮",
+ "╰─╯╰─╯╰─╯"
+ },
+ new[]{
+ " ╭─╮",
+ " ╰─╯",
+ "╭─╮x─╮╭─╮",
+ "╰─╯╰─╯╰─╯"
+ },
+ new[]{
+ "╭─╮╭─╮",
+ "╰─╯╰─╯",
+ "x─╮╭─╮",
+ "╰─╯╰─╯"
+ },
+ new[]{
+ " ╭─╮╭─╮",
+ " ╰─╯╰─╯",
+ "╭─╮x─╮ ",
+ "╰─╯╰─╯ "
+ },
+ new[]{
+ " ╭─╮ ",
+ " ╰─╯ ",
+ "╭─╮x─╮╭─╮",
+ "╰─╯╰─╯╰─╯"
+ },
+ new[]{
+ "╭─╮╭─╮ ",
+ "╰─╯╰─╯ ",
+ " x─╮╭─╮",
+ " ╰─╯╰─╯"
+ },
+ };
+
+ const int borderSize = 1;
+
+ int initialX = (emptyField[0].Length / 2) - 3;
+ int initialY = 1;
+
+ int consoleWidthMin = 44;
+ int consoleHeightMin = 43;
+
+ #endregion
+
+ Stopwatch timer = new();
+ bool closeRequested = false;
+ bool gameOver;
+ int score = 0;
+ TimeSpan fallSpeed;
+ string[] field;
+ Tetromino tetromino;
+ int consoleWidth = Console.WindowWidth;
+ int consoleHeight = Console.WindowHeight;
+ bool consoleTooSmallScreen = false;
+
+ Console.OutputEncoding = Encoding.UTF8;
+ while (!closeRequested)
+ {
+ await Console.Clear();
+ await Console.Write("""
+
+ ██████╗█████╗██████╗█████╗ ██╗█████╗
+ ╚═██╔═╝██╔══╝╚═██╔═╝██╔═██╗██║██╔══╝
+ ██║ █████╗ ██║ █████╔╝██║ ███╗
+ ██║ ██╔══╝ ██║ ██╔═██╗██║ ██╗
+ ██║ █████╗ ██║ ██║ ██║██║█████║
+ ╚═╝ ╚════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚════╝
+
+ Controls:
+
+ [A] or [←] move left
+ [D] or [→] move right
+ [S] or [↓] fall faster
+ [Q] spin left
+ [E] spin right
+ [Spacebar] drop
+ [P] pause and resume
+ [Escape] close game
+ [Enter] start game
+ """);
+ bool mainMenuScreen = true;
+ while (!closeRequested && mainMenuScreen)
+ {
+ Console.CursorVisible = false;
+ switch ((await Console.ReadKey(true)).Key)
+ {
+ case ConsoleKey.Enter: mainMenuScreen = false; break;
+ case ConsoleKey.Escape: closeRequested = true; break;
+ }
+ }
+ Initialize();
+ await Console.Clear();
+ await DrawFrame();
+ while (!closeRequested && !gameOver)
+ {
+ // if user changed the size of the console, we need to clear the console
+ if (consoleWidth != Console.WindowWidth || consoleHeight != Console.WindowHeight)
+ {
+ consoleWidth = Console.WindowWidth;
+ consoleHeight = Console.WindowHeight;
+ if (!consoleTooSmallScreen)
+ {
+ await Console.Clear();
+ await DrawFrame();
+ }
+ else
+ {
+ consoleTooSmallScreen = false;
+ }
+ }
+
+ // if the console isn't big enough to render the game, pause the game and tell the user
+ if (consoleWidth < consoleWidthMin || consoleHeight < consoleHeightMin)
+ {
+ if (!consoleTooSmallScreen)
+ {
+ await Console.Clear();
+ await Console.Write($"Please increase size of console to at least {consoleWidthMin}x{consoleHeightMin}. Current size is {consoleWidth}x{consoleHeight}.");
+ timer.Stop();
+ consoleTooSmallScreen = true;
+ }
+ }
+ else if (consoleTooSmallScreen)
+ {
+ consoleTooSmallScreen = false;
+ await Console.Clear();
+ await DrawFrame();
+ }
+
+ await HandlePlayerInput();
+ if (closeRequested || gameOver)
+ {
+ break;
+ }
+ if (timer.IsRunning && timer.Elapsed > fallSpeed)
+ {
+ await TetrominoFall();
+ if (closeRequested || gameOver)
+ {
+ break;
+ }
+ await DrawFrame();
+ }
+ }
+ if (closeRequested)
+ {
+ break;
+ }
+ await Console.Clear();
+ await Console.Write($"""
+
+ ██████╗ █████╗ ██ ██╗█████╗
+ ██╔════╝ ██╔══██╗███ ███║██╔══╝
+ ██║ ███╗███████║██╔██═██║█████╗
+ ██║ ██║██╔══██║██║ ██║██╔══╝
+ ╚██████╔╝██║ ██║██║ ██║█████╗
+ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚════╝
+ ██████╗██╗ ██╗█████╗█████╗
+ ██ ██║██║ ██║██╔══╝██╔═██╗
+ ██ ██║██║ ██║█████╗█████╔╝
+ ██ ██║╚██╗██╔╝██╔══╝██╔═██╗
+ ██████║ ╚███╔╝ █████╗██║ ██║
+ ╚═════╝ ╚══╝ ╚════╝╚═╝ ╚═╝
+
+ Final Score: {score}
+
+ [Enter] return to menu
+ [Escape] close game
+ """);
+ Console.CursorVisible = false;
+ bool gameOverScreen = true;
+ while (!closeRequested && gameOverScreen)
+ {
+ Console.CursorVisible = false;
+ switch ((await Console.ReadKey(true)).Key)
+ {
+ case ConsoleKey.Enter: gameOverScreen = false; break;
+ case ConsoleKey.Escape: closeRequested = true; break;
+ }
+ }
+ }
+ await Console.Clear();
+ await Console.WriteLine("Tetris was closed.");
+ Console.CursorVisible = true;
+ await Console.Refresh();
+
+ void Initialize()
+ {
+ gameOver = false;
+ score = 0;
+ field = emptyField[..];
+ initialX = (field[0].Length / 2) - 3;
+ initialY = 1;
+ tetromino = new()
+ {
+ Shape = tetrominos[Random.Shared.Next(0, tetrominos.Length)],
+ Next = tetrominos[Random.Shared.Next(0, tetrominos.Length)],
+ X = initialX,
+ Y = initialY
+ };
+ fallSpeed = GetFallSpeed();
+ timer.Restart();
+ }
+
+ async Task HandlePlayerInput()
+ {
+ while ((await Console.KeyAvailable()) && !closeRequested)
+ {
+ switch ((await Console.ReadKey(true)).Key)
+ {
+ case ConsoleKey.A or ConsoleKey.LeftArrow:
+ if (timer.IsRunning && !Collision(Direction.Left))
+ {
+ tetromino.X -= 3;
+ }
+ await DrawFrame();
+ break;
+ case ConsoleKey.D or ConsoleKey.RightArrow:
+ if (timer.IsRunning && !Collision(Direction.Right))
+ {
+ tetromino.X += 3;
+ }
+ await DrawFrame();
+ break;
+ case ConsoleKey.S or ConsoleKey.DownArrow:
+ if (timer.IsRunning)
+ {
+ await TetrominoFall();
+ }
+ break;
+ case ConsoleKey.E:
+ if (timer.IsRunning)
+ {
+ TetrominoSpin(Direction.Right);
+ await DrawFrame();
+ }
+ break;
+ case ConsoleKey.Q:
+ if (timer.IsRunning)
+ {
+ TetrominoSpin(Direction.Left);
+ await DrawFrame();
+ }
+ break;
+ case ConsoleKey.P:
+ if (timer.IsRunning)
+ {
+ timer.Stop();
+ await DrawFrame();
+ }
+ else if (!consoleTooSmallScreen)
+ {
+ timer.Start();
+ await DrawFrame();
+ }
+ break;
+ case ConsoleKey.Spacebar:
+ if (timer.IsRunning)
+ {
+ await HardDrop();
+ }
+ break;
+ case ConsoleKey.Escape:
+ closeRequested = true;
+ return;
+ }
+ }
+ }
+
+ async Task DrawFrame()
+ {
+ bool collision = false;
+ char[][] frame = new char[field.Length][];
+
+ // Field
+ for (int y = 0; y < field.Length; y++)
+ {
+ frame[y] = field[y].ToCharArray();
+ }
+
+ // Tetromino
+ for (int y = 0; y < tetromino.Shape.Length && !collision; y++)
+ {
+ for (int x = 0; x < tetromino.Shape[y].Length; x++)
+ {
+ int tY = tetromino.Y + y;
+ int tX = tetromino.X + x;
+ char charToReplace = field[tY][tX];
+ char charTetromino = tetromino.Shape[y][x];
+ if (charTetromino is ' ')
+ {
+ continue;
+ }
+ if (charToReplace is not ' ')
+ {
+ collision = true;
+ break;
+ }
+ if (charTetromino is 'x')
+ {
+ charTetromino = '╭';
+ }
+ frame[tY][tX] = charTetromino;
+ }
+ }
+
+ // Draw Preview
+ for (int yField = field.Length - tetromino.Shape.Length - borderSize; yField >= 0; yField -= 2)
+ {
+ if (CollisionBottom(yField, tetromino.Y, tetromino.Shape))
+ {
+ continue;
+ }
+ for (int y = 0; y < tetromino.Shape.Length && !collision; y++)
+ {
+ for (int x = 0; x < tetromino.Shape[y].Length; x++)
+ {
+ int tY = yField + y;
+ if (tetromino.Y + tetromino.Shape.Length > tY)
+ {
+ continue;
+ }
+ int tX = tetromino.X + x;
+ char charToReplace = field[tY][tX];
+ char charTetromino = tetromino.Shape[y][x];
+ if (charTetromino is ' ')
+ {
+ continue;
+ }
+ if (charToReplace is not ' ')
+ {
+ collision = true;
+ break;
+ }
+ frame[tY][tX] = '•';
+ }
+ }
+ break;
+ }
+
+ // Next
+ for (int y = 0; y < nextTetrominoBorder.Length; y++)
+ {
+ frame[y] = frame[y].Concat(nextTetrominoBorder[y]).ToArray();
+ }
+ for (int y = 0; y < tetromino.Next.Length; y++)
+ {
+ for (int x = 0; x < tetromino.Next[y].Length; x++)
+ {
+ int tY = y + borderSize;
+ int tX = field[y].Length + x + borderSize;
+ char charTetromino = tetromino.Next[y][x];
+ if (charTetromino is 'x')
+ {
+ charTetromino = '╭';
+ }
+ frame[tY][tX] = charTetromino;
+ }
+ }
+
+ // Score
+ for (int y = 0; y < scoreBorder.Length; y++)
+ {
+ int sY = nextTetrominoBorder.Length + y;
+ frame[sY] = frame[sY].Concat(scoreBorder[y]).ToArray();
+ }
+ char[] scoreRender = score.ToString(CultureInfo.InvariantCulture).ToCharArray();
+ for (int scoreX = scoreRender.Length - 1; scoreX >= 0; scoreX--)
+ {
+ int sY = nextTetrominoBorder.Length + borderSize;
+ int sX = frame[sY].Length - (scoreRender.Length - scoreX) - borderSize;
+ frame[sY][sX] = scoreRender[scoreX];
+ }
+
+ // Pause
+ if (!timer.IsRunning)
+ {
+ for (int y = 0; y < pauseRender.Length; y++)
+ {
+ int fY = (field.Length / 2) + y - pauseRender.Length;
+ for (int x = 0; x < pauseRender[y].Length; x++)
+ {
+ int fX = x + borderSize;
+
+ if (x >= field[fY].Length) break;
+
+ frame[fY][fX] = pauseRender[y][x];
+ }
+ }
+ }
+
+ StringBuilder render = new();
+ for (int y = 0; y < frame.Length; y++)
+ {
+ render.AppendLine(new string(frame[y]));
+ }
+ await Console.SetCursorPosition(0, 0);
+ await Console.Write(render);
+ Console.CursorVisible = false;
+ }
+
+ char[][] DrawLastFrame(int yS)
+ {
+ bool collision = false;
+ int yScope = yS - 2;
+ int xScope = tetromino.X;
+ char[][] frame = new char[field.Length][];
+ for (int y = 0; y < field.Length; y++)
+ {
+ frame[y] = field[y].ToCharArray();
+ }
+ for (int y = 0; y < tetromino.Shape.Length && !collision; y++)
+ {
+ for (int x = 0; x < tetromino.Shape[y].Length; x++)
+ {
+ int tY = yScope + y;
+ int tX = xScope + x;
+ char charToReplace = field[tY][tX];
+ char charTetromino = tetromino.Shape[y][x];
+ if (charTetromino is ' ')
+ {
+ continue;
+ }
+ if (charToReplace is not ' ')
+ {
+ collision = true;
+ break;
+ }
+ if (charTetromino is 'x')
+ {
+ charTetromino = '╭';
+ }
+ frame[tY][tX] = charTetromino;
+ }
+ }
+ return frame;
+ }
+
+ bool Collision(Direction direction)
+ {
+ int xNew = tetromino.X;
+ bool collision = false;
+ switch (direction)
+ {
+ case Direction.Right:
+ xNew += 3;
+ if (xNew + tetromino.Shape[0].Length > field[0].Length - borderSize)
+ {
+ collision = true;
+ }
+ break;
+ case Direction.Left:
+ xNew -= 3;
+ if (xNew < borderSize)
+ {
+ collision = true;
+ }
+ break;
+ case Direction.None:
+ break;
+ }
+ if (collision)
+ {
+ return collision;
+ }
+ for (int y = 0; y < tetromino.Shape.Length && !collision; y++)
+ {
+ for (int x = 0; x < tetromino.Shape[y].Length; x++)
+ {
+ int tY = tetromino.Y + y;
+ int tX = xNew + x;
+ char charToReplace = field[tY][tX];
+ char charTetromino = tetromino.Shape[y][x];
+ if (charTetromino is ' ')
+ {
+ continue;
+ }
+ if (charToReplace is not ' ')
+ {
+ collision = true;
+ break;
+ }
+ }
+ }
+ return collision;
+ }
+
+ bool CollisionBottom(int initY, int yScope, string[] shape)
+ {
+ int xNew = tetromino.X;
+ for (int yUpper = initY; yUpper >= yScope; yUpper -= 2)
+ {
+ for (int y = shape.Length - 1; y >= 0; y -= 2)
+ {
+ for (int x = 0; x < shape[y].Length; x++)
+ {
+ int tY = yUpper + y;
+ int tX = xNew + x;
+ char charToReplace = field[tY][tX];
+ char charTetromino = shape[y][x];
+ if (charTetromino is ' ')
+ {
+ continue;
+ }
+ if (charToReplace is not ' ')
+ {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ TimeSpan GetFallSpeed() =>
+ TimeSpan.FromMilliseconds(
+ score switch
+ {
+ > 162 => 100,
+ > 144 => 200,
+ > 126 => 300,
+ > 108 => 400,
+ > 090 => 500,
+ > 072 => 600,
+ > 054 => 700,
+ > 036 => 800,
+ > 018 => 900,
+ _ => 1000,
+ });
+
+ async Task TetrominoFall()
+ {
+ int yAfterFall = tetromino.Y;
+ bool collision = false;
+
+ if (tetromino.Y + tetromino.Shape.Length + 2 > field.Length)
+ {
+ yAfterFall = field.Length - tetromino.Shape.Length + 1;
+ }
+ else
+ {
+ yAfterFall += 2;
+ }
+
+ // Y Collision
+ for (int xCollision = 0; xCollision < tetromino.Shape[0].Length;)
+ {
+ for (int yCollision = tetromino.Shape.Length - 1; yCollision >= 0; yCollision -= 2)
+ {
+ char exist = tetromino.Shape[yCollision][xCollision];
+ if (exist is ' ')
+ {
+ continue;
+ }
+ char[] lineYC = field[yAfterFall + yCollision - 1].ToCharArray();
+ if (tetromino.X + xCollision < 0 || tetromino.X + xCollision > lineYC.Length)
+ {
+ continue;
+ }
+ if (lineYC[tetromino.X + xCollision] is not ' ' or '│')
+ {
+ char[][] lastFrame = DrawLastFrame(yAfterFall);
+ for (int y = 0; y < lastFrame.Length; y++)
+ {
+ field[y] = new string(lastFrame[y]);
+ }
+ tetromino.X = initialX;
+ tetromino.Y = initialY;
+ tetromino.Shape = tetromino.Next;
+ tetromino.Next = tetrominos[Random.Shared.Next(0, tetrominos.Length)];
+ xCollision = tetromino.Shape[0].Length;
+ collision = true;
+ break;
+ }
+ }
+ xCollision += 3;
+ }
+
+ if (!collision)
+ {
+ tetromino.Y = yAfterFall;
+ }
+
+ // Clean Lines
+ int clearedLines = 0;
+ for (int lineIndex = field.Length - 1; lineIndex >= 0; lineIndex--)
+ {
+ string line = field[lineIndex];
+ bool notCompleted = line.Any(e => e is ' ');
+ if (lineIndex is 0 || lineIndex == field.Length - 1)
+ {
+ continue;
+ }
+ if (!notCompleted)
+ {
+ field[lineIndex] = "│ │";
+ clearedLines++;
+ for (int lineM = lineIndex; lineM >= 1; lineM--)
+ {
+ if (field[lineM - 1] is "╭──────────────────────────────╮")
+ {
+ field[lineM] = "│ │";
+ continue;
+ }
+ field[lineM] = field[lineM - 1];
+ }
+ lineIndex++;
+ }
+ }
+ clearedLines /= 2;
+ if (clearedLines > 0)
+ {
+ int value = clearedLines switch
+ {
+ 1 => 1,
+ 2 => 3,
+ 3 => 6,
+ 4 => 9,
+ _ => throw new NotImplementedException(),
+ };
+ score += value;
+ fallSpeed = GetFallSpeed();
+ }
+ if (Collision(Direction.None))
+ {
+ gameOver = true;
+ }
+ else
+ {
+ await DrawFrame();
+ timer.Restart();
+ }
+ }
+
+ async Task HardDrop()
+ {
+ int y = tetromino.Y;
+ int x = tetromino.X;
+ for (int yField = field.Length - tetromino.Shape.Length - borderSize; yField >= 0; yField -= 2)
+ {
+ if (CollisionBottom(yField, y, tetromino.Shape))
+ {
+ continue;
+ }
+ tetromino.Y = yField;
+ break;
+ }
+ await DrawFrame();
+ timer.Restart();
+ }
+
+ void TetrominoSpin(Direction spinDirection)
+ {
+ int yScope = tetromino.Y;
+ int xScope = tetromino.X;
+ string[] newShape = new string[tetromino.Shape[0].Length / 3 * 2];
+ int newY = 0;
+ int rowEven = 0;
+ int rowOdd = 1;
+
+ // Turn
+ for (int y = 0; y < tetromino.Shape.Length;)
+ {
+ switch (spinDirection)
+ {
+ case Direction.Right:
+ SpinRight(newShape, tetromino.Shape, ref newY, rowEven, rowOdd, y);
+ break;
+ case Direction.Left:
+ SpinLeft(newShape, tetromino.Shape, ref newY, rowEven, rowOdd, y);
+ break;
+ }
+ newY = 0;
+ rowEven += 2;
+ rowOdd += 2;
+ y += 2;
+ }
+
+ // Old Pivot
+ (int y, int x) offsetOP = (0, 0);
+ for (int y = 0; y < tetromino.Shape.Length; y += 2)
+ {
+ for (int x = 0; x < tetromino.Shape[y].Length; x += 3)
+ {
+ if (tetromino.Shape[y][x] is 'x')
+ {
+ offsetOP = (y / 2, x / 3);
+ y = tetromino.Shape.Length;
+ break;
+ }
+ }
+ }
+
+ // New Pivot
+ (int y, int x) offsetNP = (0, 0);
+ for (int y = 0; y < newShape.Length; y += 2)
+ {
+ for (int x = 0; x < newShape[y].Length; x += 3)
+ {
+ if (newShape[y][x] is 'x')
+ {
+ offsetNP = (y / 2, x / 3);
+ y = newShape.Length;
+ break;
+ }
+ }
+ }
+
+ yScope += (offsetOP.y - offsetNP.y) * 2;
+ xScope += (offsetOP.x - offsetNP.x) * 3;
+
+ // Tetromino Square(O) special case
+ if (newShape.Length / 2 == newShape[0].Length / 3)
+ {
+ yScope = tetromino.Y;
+ xScope = tetromino.X;
+ }
+ // Tetromino I special case
+ else if (newShape.Length is 8 && newShape[0].Length is 3 && offsetNP.y is 2)
+ {
+ newShape[2] = "x─╮";
+ newShape[4] = "╭─╮";
+ yScope += 2;
+ }
+
+ if (xScope < 1 || yScope < 1)
+ {
+ return;
+ }
+
+ // Verified Collision
+ for (int y = 0; y < newShape.Length - 1; y++)
+ {
+ for (int x = 0; x < newShape[y].Length; x++)
+ {
+ if (newShape[y][x] is ' ')
+ {
+ continue;
+ }
+ char c = field[yScope + y][xScope + x];
+ if (c is not ' ')
+ {
+ return;
+ }
+ }
+ }
+ tetromino.Y = yScope;
+ tetromino.X = xScope;
+ tetromino.Shape = newShape;
+ }
+
+ void SpinLeft(string[] newShape, string[] shape, ref int newY, int rowEven, int rowOdd, int y)
+ {
+ for (int x = shape[y].Length - 1; x >= 0; x -= 3)
+ {
+ for (int xS = 2; xS >= 0; xS--)
+ {
+ newShape[newY] += shape[rowEven][x - xS];
+ newShape[newY + 1] += shape[rowOdd][x - xS];
+ }
+ newY += 2;
+ }
+ }
+
+ void SpinRight(string[] newShape, string[] shape, ref int newY, int rowEven, int rowOdd, int y)
+ {
+ for (int x = 2; x < shape[y].Length; x += 3)
+ {
+ if (newShape[newY] is null)
+ {
+ newShape[newY] = "";
+ newShape[newY + 1] = "";
+ }
+ for (int xS = 0; xS <= 2; xS++)
+ {
+ newShape[newY] = newShape[newY].Insert(0, shape[rowEven][x - xS].ToString(CultureInfo.InvariantCulture));
+ newShape[newY + 1] = newShape[newY + 1].Insert(0, shape[rowOdd][x - xS].ToString(CultureInfo.InvariantCulture));
+ }
+ newY += 2;
+ }
+ }
+ }
+
+ class Tetromino
+ {
+ public required string[] Shape { get; set; }
+ public required string[] Next { get; set; }
+ public int X { get; set; }
+ public int Y { get; set; }
+ }
+
+ enum Direction
+ {
+ None,
+ Right,
+ Left,
+ }
+}
diff --git a/Projects/Website/Pages/Tetris.razor b/Projects/Website/Pages/Tetris.razor
new file mode 100644
index 00000000..6a0de575
--- /dev/null
+++ b/Projects/Website/Pages/Tetris.razor
@@ -0,0 +1,54 @@
+@using System
+
+@page "/Tetris"
+
+Tetris
+
+Tetris
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⌨ Keyboard input is supported if you click on the game.
+
+
+
+ ↻ You can restart the game by refreshing the page.
+
+
+@code
+{
+ Games.Tetris.Tetris Game;
+ BlazorConsole Console;
+
+ public Tetris()
+ {
+ Game = new();
+ Console = Game.Console;
+ Console.WindowWidth = 45;
+ Console.WindowHeight = 44;
+ Console.TriggerRefresh = StateHasChanged;
+ }
+
+ protected override void OnInitialized() => InvokeAsync(Game.Run);
+}
diff --git a/Projects/Website/Shared/NavMenu.razor b/Projects/Website/Shared/NavMenu.razor
index b2c080a1..db4f7628 100644
--- a/Projects/Website/Shared/NavMenu.razor
+++ b/Projects/Website/Shared/NavMenu.razor
@@ -233,6 +233,11 @@
Gravity
+
+
+ Tetris
+
+
Role Playing Game
diff --git a/README.md b/README.md
index 05a836ef..f001b076 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,7 @@
|[Maze](Projects/Maze)|5|[![Play Now](.github/resources/play-badge.svg)](https://dotnet.github.io/dotnet-console-games/Maze) [![Status](https://github.com/dotnet/dotnet-console-games/workflows/Maze%20Build/badge.svg)](https://github.com/dotnet/dotnet-console-games/actions)|
|[PacMan](Projects/PacMan)|5|[![Play Now](.github/resources/play-badge.svg)](https://dotnet.github.io/dotnet-console-games/PacMan) [![Status](https://github.com/dotnet/dotnet-console-games/workflows/PacMan%20Build/badge.svg)](https://github.com/dotnet/dotnet-console-games/actions)|
|[Gravity](Projects/Gravity)|5|[![Play Now](.github/resources/play-badge.svg)](https://dotnet.github.io/dotnet-console-games/Gravity) [![Status](https://github.com/dotnet/dotnet-console-games/workflows/Gravity%20Build/badge.svg)](https://github.com/dotnet/dotnet-console-games/actions)|
+|[Tetris](Projects/Tetris)|5|[![Play Now](.github/resources/play-badge.svg)](https://dotnet.github.io/dotnet-console-games/Tetris) [![Status](https://github.com/dotnet/dotnet-console-games/workflows/Tetris%20Build/badge.svg)](https://github.com/dotnet/dotnet-console-games/actions)
*_[Community Contribution](https://github.com/dotnet/dotnet-console-games/pull/89)_|
|[Role Playing Game](Projects/Role%20Playing%20Game)|6|[![Play Now](.github/resources/play-badge.svg)](https://dotnet.github.io/dotnet-console-games/Role%20Playing%20Game) [![Status](https://github.com/dotnet/dotnet-console-games/workflows/Role%20Playing%20Game%20Build/badge.svg)](https://github.com/dotnet/dotnet-console-games/actions)|
|[Console Monsters](Projects/Console%20Monsters)|7|[![Play Now](.github/resources/play-badge.svg)](https://dotnet.github.io/dotnet-console-games/Console%20Monsters) [![Status](https://github.com/dotnet/dotnet-console-games/workflows/Console%20Monsters%20Build/badge.svg)](https://github.com/dotnet/dotnet-console-games/actions)
*_Community Collaboration_
[![Warning](https://raw.githubusercontent.com/dotnet/dotnet-console-games/main/.github/resources/warning-icon.svg)](#) _Work In Progress_|
|[Shmup](Projects/Shmup)|?|[![Play Now](.github/resources/play-badge.svg)](https://dotnet.github.io/dotnet-console-games/Shmup) [![Status](https://github.com/dotnet/dotnet-console-games/workflows/Shmup%20Build/badge.svg)](https://github.com/dotnet/dotnet-console-games/actions)
[![Warning](https://raw.githubusercontent.com/dotnet/dotnet-console-games/main/.github/resources/warning-icon.svg)](#) _Work In Progress_ & _Only Supported On Windows OS (+WEB)_|
diff --git a/dotnet-console-games.sln b/dotnet-console-games.sln
index 1cd68ef3..92b38d84 100644
--- a/dotnet-console-games.sln
+++ b/dotnet-console-games.sln
@@ -103,6 +103,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shmup", "Projects\Shmup\Shm
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Clicker", "Projects\Clicker\Clicker.csproj", "{C408B9C3-5F16-4F0A-B0D0-F39A6F7F0B72}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tetris", "Projects\Tetris\Tetris.csproj", "{4E9F6AA3-7E12-4555-95C2-6D90C8CD3DBB}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -309,6 +311,10 @@ Global
{C408B9C3-5F16-4F0A-B0D0-F39A6F7F0B72}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C408B9C3-5F16-4F0A-B0D0-F39A6F7F0B72}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C408B9C3-5F16-4F0A-B0D0-F39A6F7F0B72}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4E9F6AA3-7E12-4555-95C2-6D90C8CD3DBB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4E9F6AA3-7E12-4555-95C2-6D90C8CD3DBB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4E9F6AA3-7E12-4555-95C2-6D90C8CD3DBB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4E9F6AA3-7E12-4555-95C2-6D90C8CD3DBB}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/dotnet-console-games.slnf b/dotnet-console-games.slnf
index 4cfb281f..e6060bff 100644
--- a/dotnet-console-games.slnf
+++ b/dotnet-console-games.slnf
@@ -42,6 +42,7 @@
"Projects\\Snake\\Snake.csproj",
"Projects\\Sudoku\\Sudoku.csproj",
"Projects\\Tanks\\Tanks.csproj",
+ "Projects\\Tetris\\Tetris.csproj",
"Projects\\Tic Tac Toe\\Tic Tac Toe.csproj",
"Projects\\Tents\\Tents.csproj",
"Projects\\Tower Of Hanoi\\Tower Of Hanoi.csproj",
@@ -50,7 +51,7 @@
"Projects\\Whack A Mole\\Whack A Mole.csproj",
"Projects\\Wordle\\Wordle.csproj",
"Projects\\Wumpus World\\Wumpus World.csproj",
- "Projects\\Yahtzee\\Yahtzee.csproj"
+ "Projects\\Yahtzee\\Yahtzee.csproj",
]
}
}
\ No newline at end of file