diff --git a/.editorconfig b/.editorconfig index e951c9d7..83e830ef 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,3 +6,6 @@ indent_style = tab # IDE0160: Convert to file-scoped namespace csharp_style_namespace_declarations = file_scoped:warning + +# IDE0042: Deconstruct variable declaration +csharp_style_deconstructed_variable_declaration = false \ No newline at end of file diff --git a/.github/workflows/Checkers Build.yml b/.github/workflows/Checkers Build.yml new file mode 100644 index 00000000..ce2b4242 --- /dev/null +++ b/.github/workflows/Checkers Build.yml @@ -0,0 +1,24 @@ +name: Checkers Build +on: + push: + paths: + - 'Projects/Checkers/**' + branches: + - main + pull_request: + paths: + - 'Projects/Checkers/**' + branches: + - main + workflow_dispatch: +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: setup dotnet + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 6.0.x + - name: dotnet build + run: dotnet build "Projects\Checkers\Checkers.csproj" --configuration Release diff --git a/.vscode/launch.json b/.vscode/launch.json index ff6845ec..cef7bba1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -362,6 +362,16 @@ "console": "externalTerminal", "stopAtEntry": false, }, + { + "name": "Checkers", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "Build Checkers", + "program": "${workspaceFolder}/Projects/Checkers/bin/Debug/Checkers.dll", + "cwd": "${workspaceFolder}/Projects/Checkers/bin/Debug", + "console": "externalTerminal", + "stopAtEntry": false, + }, { "name": "Blackjack", "type": "coreclr", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 2aa4809e..42cd0319 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,6 +2,19 @@ "version": "2.0.0", "tasks": [ + { + "label": "Build Checkers", + "command": "dotnet", + "type": "process", + "args": + [ + "build", + "${workspaceFolder}/Projects/Checkers/Checkers.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary", + ], + "problemMatcher": "$msCompile", + }, { "label": "Build Duck Hunt", "command": "dotnet", diff --git a/Projects/Checkers/Board.cs b/Projects/Checkers/Board.cs new file mode 100644 index 00000000..36887fd6 --- /dev/null +++ b/Projects/Checkers/Board.cs @@ -0,0 +1,171 @@ +namespace Checkers; + +public class Board +{ + public List Pieces { get; } + + public Piece? Aggressor { get; set; } + + public Piece? this[int x, int y] => + Pieces.FirstOrDefault(piece => piece.X == x && piece.Y == y); + + public Board() + { + Aggressor = null; + Pieces = new List + { + new() { NotationPosition ="A3", Color = Black}, + new() { NotationPosition ="A1", Color = Black}, + new() { NotationPosition ="B2", Color = Black}, + new() { NotationPosition ="C3", Color = Black}, + new() { NotationPosition ="C1", Color = Black}, + new() { NotationPosition ="D2", Color = Black}, + new() { NotationPosition ="E3", Color = Black}, + new() { NotationPosition ="E1", Color = Black}, + new() { NotationPosition ="F2", Color = Black}, + new() { NotationPosition ="G3", Color = Black}, + new() { NotationPosition ="G1", Color = Black}, + new() { NotationPosition ="H2", Color = Black}, + + new() { NotationPosition ="A7", Color = White}, + new() { NotationPosition ="B8", Color = White}, + new() { NotationPosition ="B6", Color = White}, + new() { NotationPosition ="C7", Color = White}, + new() { NotationPosition ="D8", Color = White}, + new() { NotationPosition ="D6", Color = White}, + new() { NotationPosition ="E7", Color = White}, + new() { NotationPosition ="F8", Color = White}, + new() { NotationPosition ="F6", Color = White}, + new() { NotationPosition ="G7", Color = White}, + new() { NotationPosition ="H8", Color = White}, + new() { NotationPosition ="H6", Color = White} + }; + } + + public static string ToPositionNotationString(int x, int y) + { + if (!IsValidPosition(x, y)) throw new ArgumentException("Not a valid position!"); + return $"{(char)('A' + x)}{y + 1}"; + } + + public static (int X, int Y) ParsePositionNotation(string notation) + { + if (notation is null) throw new ArgumentNullException(nameof(notation)); + notation = notation.Trim().ToUpper(); + if (notation.Length is not 2 || + notation[0] < 'A' || 'H' < notation[0] || + notation[1] < '1' || '8' < notation[1]) + throw new FormatException($@"{nameof(notation)} ""{notation}"" is not valid"); + return (notation[0] - 'A', notation[1] - '1'); + } + + public static bool IsValidPosition(int x, int y) => + 0 <= x && x < 8 && + 0 <= y && y < 8; + + public (Piece A, Piece B) GetClosestRivalPieces(PieceColor priorityColor) + { + double minDistanceSquared = double.MaxValue; + (Piece A, Piece B) closestRivals = (null!, null!); + foreach (Piece a in Pieces.Where(piece => piece.Color == priorityColor)) + { + foreach (Piece b in Pieces.Where(piece => piece.Color != priorityColor)) + { + (int X, int Y) vector = (a.X - b.X, a.Y - b.Y); + double distanceSquared = vector.X * vector.X + vector.Y * vector.Y; + if (distanceSquared < minDistanceSquared) + { + minDistanceSquared = distanceSquared; + closestRivals = (a, b); + } + } + } + return closestRivals; + } + + public List GetPossibleMoves(PieceColor color) + { + List moves = new(); + if (Aggressor is not null) + { + if (Aggressor.Color != color) + { + throw new Exception($"{nameof(Aggressor)} is not null && {nameof(Aggressor)}.{nameof(Aggressor.Color)} != {nameof(color)}"); + } + moves.AddRange(GetPossibleMoves(Aggressor).Where(move => move.PieceToCapture is not null)); + } + else + { + foreach (Piece piece in Pieces.Where(piece => piece.Color == color)) + { + moves.AddRange(GetPossibleMoves(piece)); + } + } + return moves.Any(move => move.PieceToCapture is not null) + ? moves.Where(move => move.PieceToCapture is not null).ToList() + : moves; + } + + public List GetPossibleMoves(Piece piece) + { + List moves = new(); + ValidateDiagonalMove(-1, -1); + ValidateDiagonalMove(-1, 1); + ValidateDiagonalMove( 1, -1); + ValidateDiagonalMove( 1, 1); + return moves.Any(move => move.PieceToCapture is not null) + ? moves.Where(move => move.PieceToCapture is not null).ToList() + : moves; + + void ValidateDiagonalMove(int dx, int dy) + { + if (!piece.Promoted && piece.Color is Black && dy is -1) return; + if (!piece.Promoted && piece.Color is White && dy is 1) return; + (int X, int Y) target = (piece.X + dx, piece.Y + dy); + if (!IsValidPosition(target.X, target.Y)) return; + PieceColor? targetColor = this[target.X, target.Y]?.Color; + if (targetColor is null) + { + if (!IsValidPosition(target.X, target.Y)) return; + Move newMove = new(piece, target); + moves.Add(newMove); + } + else if (targetColor != piece.Color) + { + (int X, int Y) jump = (piece.X + 2 * dx, piece.Y + 2 * dy); + if (!IsValidPosition(jump.X, jump.Y)) return; + PieceColor? jumpColor = this[jump.X, jump.Y]?.Color; + if (jumpColor is not null) return; + Move attack = new(piece, jump, this[target.X, target.Y]); + moves.Add(attack); + } + } + } + + /// Returns a if -> is valid or null if not. + public Move? ValidateMove(PieceColor color, (int X, int Y) from, (int X, int Y) to) + { + Piece? piece = this[from.X, from.Y]; + if (piece is null) + { + return null; + } + foreach (Move move in GetPossibleMoves(color)) + { + if ((move.PieceToMove.X, move.PieceToMove.Y) == from && move.To == to) + { + return move; + } + } + return null; + } + + public static bool IsTowards(Move move, Piece piece) + { + (int Dx, int Dy) a = (move.PieceToMove.X - piece.X, move.PieceToMove.Y - piece.Y); + int a_distanceSquared = a.Dx * a.Dx + a.Dy * a.Dy; + (int Dx, int Dy) b = (move.To.X - piece.X, move.To.Y - piece.Y); + int b_distanceSquared = b.Dx * b.Dx + b.Dy * b.Dy; + return b_distanceSquared < a_distanceSquared; + } +} diff --git a/Projects/Checkers/Checkers.csproj b/Projects/Checkers/Checkers.csproj new file mode 100644 index 00000000..89193e26 --- /dev/null +++ b/Projects/Checkers/Checkers.csproj @@ -0,0 +1,8 @@ + + + Exe + net6.0 + disable + enable + + diff --git a/Projects/Checkers/Game.cs b/Projects/Checkers/Game.cs new file mode 100644 index 00000000..f6675c1c --- /dev/null +++ b/Projects/Checkers/Game.cs @@ -0,0 +1,68 @@ +namespace Checkers; + +public class Game +{ + private const int PiecesPerColor = 12; + + public PieceColor Turn { get; private set; } + public Board Board { get; } + public PieceColor? Winner { get; private set; } + public List Players { get; } + + public Game(int humanPlayerCount) + { + if (humanPlayerCount < 0 || 2 < humanPlayerCount) throw new ArgumentOutOfRangeException(nameof(humanPlayerCount)); + Board = new Board(); + Players = new() + { + new Player(humanPlayerCount >= 1, Black), + new Player(humanPlayerCount >= 2, White), + }; + Turn = Black; + Winner = null; + } + + public void PerformMove(Move move) + { + (move.PieceToMove.X, move.PieceToMove.Y) = move.To; + if ((move.PieceToMove.Color is Black && move.To.Y is 7) || + (move.PieceToMove.Color is White && move.To.Y is 0)) + { + move.PieceToMove.Promoted = true; + } + if (move.PieceToCapture is not null) + { + Board.Pieces.Remove(move.PieceToCapture); + } + if (move.PieceToCapture is not null && + Board.GetPossibleMoves(move.PieceToMove).Any(m => m.PieceToCapture is not null)) + { + Board.Aggressor = move.PieceToMove; + } + else + { + Board.Aggressor = null; + Turn = Turn is Black ? White : Black; + } + CheckForWinner(); + } + + public void CheckForWinner() + { + if (!Board.Pieces.Any(piece => piece.Color is Black)) + { + Winner = White; + } + if (!Board.Pieces.Any(piece => piece.Color is White)) + { + Winner = Black; + } + if (Winner is null && Board.GetPossibleMoves(Turn).Count is 0) + { + Winner = Turn is Black ? White : Black; + } + } + + public int TakenCount(PieceColor colour) => + PiecesPerColor - Board.Pieces.Count(piece => piece.Color == colour); +} diff --git a/Projects/Checkers/Move.cs b/Projects/Checkers/Move.cs new file mode 100644 index 00000000..f5eb9d7f --- /dev/null +++ b/Projects/Checkers/Move.cs @@ -0,0 +1,17 @@ +namespace Checkers; + +public class Move +{ + public Piece PieceToMove { get; set; } + + public (int X, int Y) To { get; set; } + + public Piece? PieceToCapture { get; set; } + + public Move(Piece pieceToMove, (int X, int Y) to, Piece? pieceToCapture = null) + { + PieceToMove = pieceToMove; + To = to; + PieceToCapture = pieceToCapture; + } +} diff --git a/Projects/Checkers/Piece.cs b/Projects/Checkers/Piece.cs new file mode 100644 index 00000000..a189ccbc --- /dev/null +++ b/Projects/Checkers/Piece.cs @@ -0,0 +1,18 @@ +namespace Checkers; + +public class Piece +{ + public int X { get; set; } + + public int Y { get; set; } + + public string NotationPosition + { + get => Board.ToPositionNotationString(X, Y); + set => (X, Y) = Board.ParsePositionNotation(value); + } + + public PieceColor Color { get; set; } + + public bool Promoted { get; set; } +} diff --git a/Projects/Checkers/PieceColor.cs b/Projects/Checkers/PieceColor.cs new file mode 100644 index 00000000..b3d48ef3 --- /dev/null +++ b/Projects/Checkers/PieceColor.cs @@ -0,0 +1,7 @@ +namespace Checkers; + +public enum PieceColor +{ + Black = 1, + White = 2, +} diff --git a/Projects/Checkers/Player.cs b/Projects/Checkers/Player.cs new file mode 100644 index 00000000..f5a3471a --- /dev/null +++ b/Projects/Checkers/Player.cs @@ -0,0 +1,13 @@ +namespace Checkers; + +public class Player +{ + public bool IsHuman { get; } + public PieceColor Color { get; } + + public Player(bool isHuman, PieceColor color) + { + IsHuman = isHuman; + Color = color; + } +} diff --git a/Projects/Checkers/Program.cs b/Projects/Checkers/Program.cs new file mode 100644 index 00000000..26e65d84 --- /dev/null +++ b/Projects/Checkers/Program.cs @@ -0,0 +1,223 @@ +Encoding encoding = Console.OutputEncoding; + +try +{ + Console.OutputEncoding = Encoding.UTF8; + Game game = ShowIntroScreenAndGetOption(); + Console.Clear(); + RunGameLoop(game); + RenderGameState(game, promptPressKey: true); + Console.ReadKey(true); +} +finally +{ + Console.OutputEncoding = encoding; + Console.CursorVisible = true; + Console.Clear(); + Console.Write("Checkers was closed."); +} + +Game ShowIntroScreenAndGetOption() +{ + Console.Clear(); + Console.WriteLine(); + Console.WriteLine(" Checkers"); + Console.WriteLine(); + Console.WriteLine(" Checkers is played on an 8x8 board between two sides commonly known as black"); + Console.WriteLine(" and white. The objective is simple - capture all your opponent's pieces. An"); + Console.WriteLine(" alternative way to win is to trap your opponent so that they have no valid"); + Console.WriteLine(" moves left."); + Console.WriteLine(); + Console.WriteLine(" Black starts first and players take it in turns to move their pieces forward"); + Console.WriteLine(" across the board diagonally. Should a piece reach the other side of the board"); + Console.WriteLine(" the piece becomes a king and can then move diagonally backwards as well as"); + Console.WriteLine(" forwards."); + Console.WriteLine(); + Console.WriteLine(" Pieces are captured by jumping over them diagonally. More than one enemy piece"); + Console.WriteLine(" can be captured in the same turn by the same piece. If you can capture a piece"); + Console.WriteLine(" you must capture a piece."); + Console.WriteLine(); + Console.WriteLine(" Moves are selected with the arrow keys. Use the [enter] button to select the"); + Console.WriteLine(" from and to squares. Invalid moves are ignored."); + Console.WriteLine(); + Console.WriteLine(" Press a number key to choose number of human players:"); + Console.WriteLine(" [0] Black (computer) vs White (computer)"); + Console.WriteLine(" [1] Black (human) vs White (computer)"); + Console.Write(" [2] Black (human) vs White (human)"); + + int? humanPlayerCount = null; + while (humanPlayerCount is null) + { + Console.CursorVisible = false; + switch (Console.ReadKey(true).Key) + { + case ConsoleKey.D0 or ConsoleKey.NumPad0: humanPlayerCount = 0; break; + case ConsoleKey.D1 or ConsoleKey.NumPad1: humanPlayerCount = 1; break; + case ConsoleKey.D2 or ConsoleKey.NumPad2: humanPlayerCount = 2; break; + } + } + return new Game(humanPlayerCount.Value); +} + +void RunGameLoop(Game game) +{ + while (game.Winner is null) + { + Player currentPlayer = game.Players.First(player => player.Color == game.Turn); + if (currentPlayer.IsHuman) + { + while (game.Turn == currentPlayer.Color) + { + (int X, int Y)? selectionStart = null; + (int X, int Y)? from = game.Board.Aggressor is not null ? (game.Board.Aggressor.X, game.Board.Aggressor.Y) : null; + List moves = game.Board.GetPossibleMoves(game.Turn); + if (moves.Select(move => move.PieceToMove).Distinct().Count() is 1) + { + Move must = moves.First(); + from = (must.PieceToMove.X, must.PieceToMove.Y); + selectionStart = must.To; + } + while (from is null) + { + from = HumanMoveSelection(game); + selectionStart = from; + } + (int X, int Y)? to = HumanMoveSelection(game, selectionStart: selectionStart, from: from); + Piece? piece = null; + piece = game.Board[from.Value.X, from.Value.Y]; + if (piece is null || piece.Color != game.Turn) + { + from = null; + to = null; + } + if (from is not null && to is not null) + { + Move? move = game.Board.ValidateMove(game.Turn, from.Value, to.Value); + if (move is not null && + (game.Board.Aggressor is null || move.PieceToMove == game.Board.Aggressor)) + { + game.PerformMove(move); + } + } + } + } + else + { + List moves = game.Board.GetPossibleMoves(game.Turn); + List captures = moves.Where(move => move.PieceToCapture is not null).ToList(); + if (captures.Count > 0) + { + game.PerformMove(captures[Random.Shared.Next(captures.Count)]); + } + else if(!game.Board.Pieces.Any(piece => piece.Color == game.Turn && !piece.Promoted)) + { + var (a, b) = game.Board.GetClosestRivalPieces(game.Turn); + Move? priorityMove = moves.FirstOrDefault(move => move.PieceToMove == a && Board.IsTowards(move, b)); + if (priorityMove is not null) + { + game.PerformMove(priorityMove); + } + else + { + game.PerformMove(moves[Random.Shared.Next(moves.Count)]); + } + } + else + { + game.PerformMove(moves[Random.Shared.Next(moves.Count)]); + } + } + + RenderGameState(game, playerMoved: currentPlayer, promptPressKey: true); + Console.ReadKey(true); + } +} + +void RenderGameState(Game game, Player? playerMoved = null, (int X, int Y)? selection = null, (int X, int Y)? from = null, bool promptPressKey = false) +{ + const char BlackPiece = '○'; + const char BlackKing = '☺'; + const char WhitePiece = '◙'; + const char WhiteKing = '☻'; + const char Vacant = '·'; + + Console.CursorVisible = false; + Console.SetCursorPosition(0, 0); + StringBuilder sb = new(); + sb.AppendLine(); + sb.AppendLine(" Checkers"); + sb.AppendLine(); + sb.AppendLine($" ╔═══════════════════╗"); + sb.AppendLine($" 8 ║ {B(0, 7)} {B(1, 7)} {B(2, 7)} {B(3, 7)} {B(4, 7)} {B(5, 7)} {B(6, 7)} {B(7, 7)} ║ {BlackPiece} = Black"); + sb.AppendLine($" 7 ║ {B(0, 6)} {B(1, 6)} {B(2, 6)} {B(3, 6)} {B(4, 6)} {B(5, 6)} {B(6, 6)} {B(7, 6)} ║ {BlackKing} = Black King"); + sb.AppendLine($" 6 ║ {B(0, 5)} {B(1, 5)} {B(2, 5)} {B(3, 5)} {B(4, 5)} {B(5, 5)} {B(6, 5)} {B(7, 5)} ║ {WhitePiece} = White"); + sb.AppendLine($" 5 ║ {B(0, 4)} {B(1, 4)} {B(2, 4)} {B(3, 4)} {B(4, 4)} {B(5, 4)} {B(6, 4)} {B(7, 4)} ║ {WhiteKing} = White King"); + sb.AppendLine($" 4 ║ {B(0, 3)} {B(1, 3)} {B(2, 3)} {B(3, 3)} {B(4, 3)} {B(5, 3)} {B(6, 3)} {B(7, 3)} ║"); + sb.AppendLine($" 3 ║ {B(0, 2)} {B(1, 2)} {B(2, 2)} {B(3, 2)} {B(4, 2)} {B(5, 2)} {B(6, 2)} {B(7, 2)} ║ Taken:"); + sb.AppendLine($" 2 ║ {B(0, 1)} {B(1, 1)} {B(2, 1)} {B(3, 1)} {B(4, 1)} {B(5, 1)} {B(6, 1)} {B(7, 1)} ║ {game.TakenCount(White),2} x {WhitePiece}"); + sb.AppendLine($" 1 ║ {B(0, 0)} {B(1, 0)} {B(2, 0)} {B(3, 0)} {B(4, 0)} {B(5, 0)} {B(6, 0)} {B(7, 0)} ║ {game.TakenCount(Black),2} x {BlackPiece}"); + sb.AppendLine($" ╚═══════════════════╝"); + sb.AppendLine($" A B C D E F G H"); + sb.AppendLine(); + if (selection is not null) + { + sb.Replace(" $ ", $"[{ToChar(game.Board[selection.Value.X, selection.Value.Y])}]"); + } + if (from is not null) + { + char fromChar = ToChar(game.Board[from.Value.X, from.Value.Y]); + sb.Replace(" @ ", $"<{fromChar}>"); + sb.Replace("@ ", $"{fromChar}>"); + sb.Replace(" @", $"<{fromChar}"); + } + PieceColor? wc = game.Winner; + PieceColor? mc = playerMoved?.Color; + PieceColor? tc = game.Turn; + // Note: these strings need to match in length + // so they overwrite each other. + string w = $" *** {wc} wins ***"; + string m = $" {mc} moved "; + string t = $" {tc}'s turn "; + sb.AppendLine( + game.Winner is not null ? w : + playerMoved is not null ? m : + t); + string p = " Press any key to continue..."; + string s = " "; + sb.AppendLine(promptPressKey ? p : s); + Console.Write(sb); + + char B(int x, int y) => + (x, y) == selection ? '$' : + (x, y) == from ? '@' : + ToChar(game.Board[x, y]); + + static char ToChar(Piece? piece) => + piece is null ? Vacant : + (piece.Color, piece.Promoted) switch + { + (Black, false) => BlackPiece, + (Black, true) => BlackKing, + (White, false) => WhitePiece, + (White, true) => WhiteKing, + _ => throw new NotImplementedException(), + }; +} + +(int X, int Y)? HumanMoveSelection(Game game, (int X, int y)? selectionStart = null, (int X, int Y)? from = null) +{ + (int X, int Y) selection = selectionStart ?? (3, 3); + while (true) + { + RenderGameState(game, selection: selection, from: from); + switch (Console.ReadKey(true).Key) + { + case ConsoleKey.DownArrow: selection.Y = Math.Max(0, selection.Y - 1); break; + case ConsoleKey.UpArrow: selection.Y = Math.Min(7, selection.Y + 1); break; + case ConsoleKey.LeftArrow: selection.X = Math.Max(0, selection.X - 1); break; + case ConsoleKey.RightArrow: selection.X = Math.Min(7, selection.X + 1); break; + case ConsoleKey.Enter: return selection; + case ConsoleKey.Escape: return null; + } + } +} diff --git a/Projects/Checkers/README.md b/Projects/Checkers/README.md new file mode 100644 index 00000000..03f30dce --- /dev/null +++ b/Projects/Checkers/README.md @@ -0,0 +1,62 @@ +

+ Checkers +

+ +

+ flat + Language C# + Target Framework + Build + Discord + +

+ +> **Note** This game was a *[Community Contribution](https://github.com/ZacharyPatten/dotnet-console-games/pull/40)! + +Checkers is played on an 8x8 board between two sides commonly known as black +and white. The objective is simple - capture all your opponent's pieces. An +alternative way to win is to trap your opponent so that they have no valid +moves left. + +Black starts first and players take it in turns to move their pieces forward +across the board diagonally. Should a piece reach the other side of the board +the piece becomes a king and can then move diagonally backwards as well as +forwards. + +Pieces are captured by jumping over them diagonally. More than one enemy piece +can be captured in the same turn by the same piece. If you can capture a piece +you must capture a piece. + +Moves are selected with the arrow keys. Use the [enter] button to select the +from and to squares. Invalid moves are ignored. + +``` + ╔═══════════════════╗ + 8 ║ . ◙ . ◙ . ◙ . ◙ ║ ○ = Black + 7 ║ ◙ . ◙ . ◙ . ◙ . ║ ☺ = Black King + 6 ║ . ◙ . ◙ . . . ◙ ║ ◙ = White + 5 ║ . . . . . . ◙ . ║ ☻ = White King + 4 ║ . . . ○ . . . . ║ + 3 ║ ○ . ○ . . . ○ . ║ Taken: + 2 ║ . ○ . ○ . ○ . ○ ║ 0 x ◙ + 1 ║ ○ . ○ . ○ . ○ . ║ 0 x ○ + ╚═══════════════════╝ + A B C D E F G H +``` + +## Input + +- `0`, `1`, `2`: select number of human players in menu +- `↑`, `↓`, `←`, `→`: move selection +- `enter`: confirm +- `escape`: cancel current move + +

+ You can play this game in your browser: +
+ + Play Now + +
+ Hosted On GitHub Pages +

\ No newline at end of file diff --git a/Projects/Checkers/_using.cs b/Projects/Checkers/_using.cs new file mode 100644 index 00000000..30586f36 --- /dev/null +++ b/Projects/Checkers/_using.cs @@ -0,0 +1,12 @@ +global using System; +global using System.Collections.Generic; +global using System.Linq; +global using System.Text; +global using Checkers; +global using static _using; + +public static class _using +{ + public const PieceColor Black = PieceColor.Black; + public const PieceColor White = PieceColor.White; +} diff --git a/Projects/Website/BlazorConsole.cs b/Projects/Website/BlazorConsole.cs index f1460067..9850b002 100644 --- a/Projects/Website/BlazorConsole.cs +++ b/Projects/Website/BlazorConsole.cs @@ -39,6 +39,8 @@ public struct Pixel public int _windowHeight = 35; public int _windowWidth = 80; + public Encoding OutputEncoding; + public int WindowHeight { get => _windowHeight; diff --git a/Projects/Website/Games/Checkers/Board.cs b/Projects/Website/Games/Checkers/Board.cs new file mode 100644 index 00000000..1c8f0673 --- /dev/null +++ b/Projects/Website/Games/Checkers/Board.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using static Website.Games.Checkers._using; + +namespace Website.Games.Checkers; + +public class Board +{ + public List Pieces { get; } + + public Piece? Aggressor { get; set; } + + public Piece? this[int x, int y] => + Pieces.FirstOrDefault(piece => piece.X == x && piece.Y == y); + + public Board() + { + Aggressor = null; + Pieces = new List + { + new() { NotationPosition ="A3", Color = Black}, + new() { NotationPosition ="A1", Color = Black}, + new() { NotationPosition ="B2", Color = Black}, + new() { NotationPosition ="C3", Color = Black}, + new() { NotationPosition ="C1", Color = Black}, + new() { NotationPosition ="D2", Color = Black}, + new() { NotationPosition ="E3", Color = Black}, + new() { NotationPosition ="E1", Color = Black}, + new() { NotationPosition ="F2", Color = Black}, + new() { NotationPosition ="G3", Color = Black}, + new() { NotationPosition ="G1", Color = Black}, + new() { NotationPosition ="H2", Color = Black}, + + new() { NotationPosition ="A7", Color = White}, + new() { NotationPosition ="B8", Color = White}, + new() { NotationPosition ="B6", Color = White}, + new() { NotationPosition ="C7", Color = White}, + new() { NotationPosition ="D8", Color = White}, + new() { NotationPosition ="D6", Color = White}, + new() { NotationPosition ="E7", Color = White}, + new() { NotationPosition ="F8", Color = White}, + new() { NotationPosition ="F6", Color = White}, + new() { NotationPosition ="G7", Color = White}, + new() { NotationPosition ="H8", Color = White}, + new() { NotationPosition ="H6", Color = White} + }; + } + + public static string ToPositionNotationString(int x, int y) + { + if (!IsValidPosition(x, y)) throw new ArgumentException("Not a valid position!"); + return $"{(char)('A' + x)}{y + 1}"; + } + + public static (int X, int Y) ParsePositionNotation(string notation) + { + if (notation is null) throw new ArgumentNullException(nameof(notation)); + notation = notation.Trim().ToUpper(); + if (notation.Length is not 2 || + notation[0] < 'A' || 'H' < notation[0] || + notation[1] < '1' || '8' < notation[1]) + throw new FormatException($@"{nameof(notation)} ""{notation}"" is not valid"); + return (notation[0] - 'A', notation[1] - '1'); + } + + public static bool IsValidPosition(int x, int y) => + 0 <= x && x < 8 && + 0 <= y && y < 8; + + public (Piece A, Piece B) GetClosestRivalPieces(PieceColor priorityColor) + { + double minDistanceSquared = double.MaxValue; + (Piece A, Piece B) closestRivals = (null!, null!); + foreach (Piece a in Pieces.Where(piece => piece.Color == priorityColor)) + { + foreach (Piece b in Pieces.Where(piece => piece.Color != priorityColor)) + { + (int X, int Y) vector = (a.X - b.X, a.Y - b.Y); + double distanceSquared = vector.X * vector.X + vector.Y * vector.Y; + if (distanceSquared < minDistanceSquared) + { + minDistanceSquared = distanceSquared; + closestRivals = (a, b); + } + } + } + return closestRivals; + } + + public List GetPossibleMoves(PieceColor color) + { + List moves = new(); + if (Aggressor is not null) + { + if (Aggressor.Color != color) + { + throw new Exception($"{nameof(Aggressor)} is not null && {nameof(Aggressor)}.{nameof(Aggressor.Color)} != {nameof(color)}"); + } + moves.AddRange(GetPossibleMoves(Aggressor).Where(move => move.PieceToCapture is not null)); + } + else + { + foreach (Piece piece in Pieces.Where(piece => piece.Color == color)) + { + moves.AddRange(GetPossibleMoves(piece)); + } + } + return moves.Any(move => move.PieceToCapture is not null) + ? moves.Where(move => move.PieceToCapture is not null).ToList() + : moves; + } + + public List GetPossibleMoves(Piece piece) + { + List moves = new(); + ValidateDiagonalMove(-1, -1); + ValidateDiagonalMove(-1, 1); + ValidateDiagonalMove( 1, -1); + ValidateDiagonalMove( 1, 1); + return moves.Any(move => move.PieceToCapture is not null) + ? moves.Where(move => move.PieceToCapture is not null).ToList() + : moves; + + void ValidateDiagonalMove(int dx, int dy) + { + if (!piece.Promoted && piece.Color is Black && dy is -1) return; + if (!piece.Promoted && piece.Color is White && dy is 1) return; + (int X, int Y) target = (piece.X + dx, piece.Y + dy); + if (!IsValidPosition(target.X, target.Y)) return; + PieceColor? targetColor = this[target.X, target.Y]?.Color; + if (targetColor is null) + { + if (!IsValidPosition(target.X, target.Y)) return; + Move newMove = new(piece, target); + moves.Add(newMove); + } + else if (targetColor != piece.Color) + { + (int X, int Y) jump = (piece.X + 2 * dx, piece.Y + 2 * dy); + if (!IsValidPosition(jump.X, jump.Y)) return; + PieceColor? jumpColor = this[jump.X, jump.Y]?.Color; + if (jumpColor is not null) return; + Move attack = new(piece, jump, this[target.X, target.Y]); + moves.Add(attack); + } + } + } + + /// Returns a if -> is valid or null if not. + public Move? ValidateMove(PieceColor color, (int X, int Y) from, (int X, int Y) to) + { + Piece? piece = this[from.X, from.Y]; + if (piece is null) + { + return null; + } + foreach (Move move in GetPossibleMoves(color)) + { + if ((move.PieceToMove.X, move.PieceToMove.Y) == from && move.To == to) + { + return move; + } + } + return null; + } + + public static bool IsTowards(Move move, Piece piece) + { + (int Dx, int Dy) a = (move.PieceToMove.X - piece.X, move.PieceToMove.Y - piece.Y); + int a_distanceSquared = a.Dx * a.Dx + a.Dy * a.Dy; + (int Dx, int Dy) b = (move.To.X - piece.X, move.To.Y - piece.Y); + int b_distanceSquared = b.Dx * b.Dx + b.Dy * b.Dy; + return b_distanceSquared < a_distanceSquared; + } +} diff --git a/Projects/Website/Games/Checkers/Checkers.cs b/Projects/Website/Games/Checkers/Checkers.cs new file mode 100644 index 00000000..e036cb32 --- /dev/null +++ b/Projects/Website/Games/Checkers/Checkers.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using static Website.Games.Checkers._using; + +namespace Website.Games.Checkers; + +public class Checkers +{ + public readonly BlazorConsole Console = new(); + + public async Task Run() + { + + Encoding encoding = Console.OutputEncoding; + + try + { + Console.OutputEncoding = Encoding.UTF8; + Game game = await ShowIntroScreenAndGetOption(); + await Console.Clear(); + await RunGameLoop(game); + await RenderGameState(game, promptPressKey: true); + await Console.ReadKey(true); + } + finally + { + Console.OutputEncoding = encoding; + Console.CursorVisible = true; + await Console.Clear(); + await Console.Write("Checkers was closed."); + } + + async Task ShowIntroScreenAndGetOption() + { + await Console.Clear(); + await Console.WriteLine(); + await Console.WriteLine(" Checkers"); + await Console.WriteLine(); + await Console.WriteLine(" Checkers is played on an 8x8 board between two sides commonly known as black"); + await Console.WriteLine(" and white. The objective is simple - capture all your opponent's pieces. An"); + await Console.WriteLine(" alternative way to win is to trap your opponent so that they have no valid"); + await Console.WriteLine(" moves left."); + await Console.WriteLine(); + await Console.WriteLine(" Black starts first and players take it in turns to move their pieces forward"); + await Console.WriteLine(" across the board diagonally. Should a piece reach the other side of the board"); + await Console.WriteLine(" the piece becomes a king and can then move diagonally backwards as well as"); + await Console.WriteLine(" forwards."); + await Console.WriteLine(); + await Console.WriteLine(" Pieces are captured by jumping over them diagonally. More than one enemy piece"); + await Console.WriteLine(" can be captured in the same turn by the same piece. If you can capture a piece"); + await Console.WriteLine(" you must capture a piece."); + await Console.WriteLine(); + await Console.WriteLine(" Moves are selected with the arrow keys. Use the [enter] button to select the"); + await Console.WriteLine(" from and to squares. Invalid moves are ignored."); + await Console.WriteLine(); + await Console.WriteLine(" Press a number key to choose number of human players:"); + await Console.WriteLine(" [0] Black (computer) vs White (computer)"); + await Console.WriteLine(" [1] Black (human) vs White (computer)"); + await Console.Write(" [2] Black (human) vs White (human)"); + + int? humanPlayerCount = null; + while (humanPlayerCount is null) + { + Console.CursorVisible = false; + switch ((await Console.ReadKey(true)).Key) + { + case ConsoleKey.D0 or ConsoleKey.NumPad0: humanPlayerCount = 0; break; + case ConsoleKey.D1 or ConsoleKey.NumPad1: humanPlayerCount = 1; break; + case ConsoleKey.D2 or ConsoleKey.NumPad2: humanPlayerCount = 2; break; + } + } + return new Game(humanPlayerCount.Value); + } + + async Task RunGameLoop(Game game) + { + while (game.Winner is null) + { + Player currentPlayer = game.Players.First(player => player.Color == game.Turn); + if (currentPlayer.IsHuman) + { + while (game.Turn == currentPlayer.Color) + { + (int X, int Y)? selectionStart = null; + (int X, int Y)? from = game.Board.Aggressor is not null ? (game.Board.Aggressor.X, game.Board.Aggressor.Y) : null; + List moves = game.Board.GetPossibleMoves(game.Turn); + if (moves.Select(move => move.PieceToMove).Distinct().Count() is 1) + { + Move must = moves.First(); + from = (must.PieceToMove.X, must.PieceToMove.Y); + selectionStart = must.To; + } + while (from is null) + { + from = await HumanMoveSelection(game); + selectionStart = from; + } + (int X, int Y)? to = await HumanMoveSelection(game, selectionStart: selectionStart, from: from); + Piece? piece = null; + piece = game.Board[from.Value.X, from.Value.Y]; + if (piece is null || piece.Color != game.Turn) + { + from = null; + to = null; + } + if (from is not null && to is not null) + { + Move? move = game.Board.ValidateMove(game.Turn, from.Value, to.Value); + if (move is not null && + (game.Board.Aggressor is null || move.PieceToMove == game.Board.Aggressor)) + { + game.PerformMove(move); + } + } + } + } + else + { + List moves = game.Board.GetPossibleMoves(game.Turn); + List captures = moves.Where(move => move.PieceToCapture is not null).ToList(); + if (captures.Count > 0) + { + game.PerformMove(captures[Random.Shared.Next(captures.Count)]); + } + else if (!game.Board.Pieces.Any(piece => piece.Color == game.Turn && !piece.Promoted)) + { + var (a, b) = game.Board.GetClosestRivalPieces(game.Turn); + Move? priorityMove = moves.FirstOrDefault(move => move.PieceToMove == a && Board.IsTowards(move, b)); + if (priorityMove is not null) + { + game.PerformMove(priorityMove); + } + else + { + game.PerformMove(moves[Random.Shared.Next(moves.Count)]); + } + } + else + { + game.PerformMove(moves[Random.Shared.Next(moves.Count)]); + } + } + + await RenderGameState(game, playerMoved: currentPlayer, promptPressKey: true); + await Console.ReadKey(true); + } + } + + async Task RenderGameState(Game game, Player? playerMoved = null, (int X, int Y)? selection = null, (int X, int Y)? from = null, bool promptPressKey = false) + { + const char BlackPiece = '○'; + const char BlackKing = '☺'; + const char WhitePiece = '◙'; + const char WhiteKing = '☻'; + const char Vacant = '·'; + + Console.CursorVisible = false; + await Console.SetCursorPosition(0, 0); + StringBuilder sb = new(); + sb.AppendLine(); + sb.AppendLine(" Checkers"); + sb.AppendLine(); + sb.AppendLine($" ╔═══════════════════╗"); + sb.AppendLine($" 8 ║ {B(0, 7)} {B(1, 7)} {B(2, 7)} {B(3, 7)} {B(4, 7)} {B(5, 7)} {B(6, 7)} {B(7, 7)} ║ {BlackPiece} = Black"); + sb.AppendLine($" 7 ║ {B(0, 6)} {B(1, 6)} {B(2, 6)} {B(3, 6)} {B(4, 6)} {B(5, 6)} {B(6, 6)} {B(7, 6)} ║ {BlackKing} = Black King"); + sb.AppendLine($" 6 ║ {B(0, 5)} {B(1, 5)} {B(2, 5)} {B(3, 5)} {B(4, 5)} {B(5, 5)} {B(6, 5)} {B(7, 5)} ║ {WhitePiece} = White"); + sb.AppendLine($" 5 ║ {B(0, 4)} {B(1, 4)} {B(2, 4)} {B(3, 4)} {B(4, 4)} {B(5, 4)} {B(6, 4)} {B(7, 4)} ║ {WhiteKing} = White King"); + sb.AppendLine($" 4 ║ {B(0, 3)} {B(1, 3)} {B(2, 3)} {B(3, 3)} {B(4, 3)} {B(5, 3)} {B(6, 3)} {B(7, 3)} ║"); + sb.AppendLine($" 3 ║ {B(0, 2)} {B(1, 2)} {B(2, 2)} {B(3, 2)} {B(4, 2)} {B(5, 2)} {B(6, 2)} {B(7, 2)} ║ Taken:"); + sb.AppendLine($" 2 ║ {B(0, 1)} {B(1, 1)} {B(2, 1)} {B(3, 1)} {B(4, 1)} {B(5, 1)} {B(6, 1)} {B(7, 1)} ║ {game.TakenCount(White),2} x {WhitePiece}"); + sb.AppendLine($" 1 ║ {B(0, 0)} {B(1, 0)} {B(2, 0)} {B(3, 0)} {B(4, 0)} {B(5, 0)} {B(6, 0)} {B(7, 0)} ║ {game.TakenCount(Black),2} x {BlackPiece}"); + sb.AppendLine($" ╚═══════════════════╝"); + sb.AppendLine($" A B C D E F G H"); + sb.AppendLine(); + if (selection is not null) + { + sb.Replace(" $ ", $"[{ToChar(game.Board[selection.Value.X, selection.Value.Y])}]"); + } + if (from is not null) + { + char fromChar = ToChar(game.Board[from.Value.X, from.Value.Y]); + sb.Replace(" @ ", $"<{fromChar}>"); + sb.Replace("@ ", $"{fromChar}>"); + sb.Replace(" @", $"<{fromChar}"); + } + PieceColor? wc = game.Winner; + PieceColor? mc = playerMoved?.Color; + PieceColor? tc = game.Turn; + // Note: these strings need to match in length + // so they overwrite each other. + string w = $" *** {wc} wins ***"; + string m = $" {mc} moved "; + string t = $" {tc}'s turn "; + sb.AppendLine( + game.Winner is not null ? w : + playerMoved is not null ? m : + t); + string p = " Press any key to continue..."; + string s = " "; + sb.AppendLine(promptPressKey ? p : s); + await Console.Write(sb); + + char B(int x, int y) => + (x, y) == selection ? '$' : + (x, y) == from ? '@' : + ToChar(game.Board[x, y]); + + static char ToChar(Piece? piece) => + piece is null ? Vacant : + (piece.Color, piece.Promoted) switch + { + (Black, false) => BlackPiece, + (Black, true) => BlackKing, + (White, false) => WhitePiece, + (White, true) => WhiteKing, + _ => throw new NotImplementedException(), + }; + } + + async Task<(int X, int Y)?> HumanMoveSelection(Game game, (int X, int y)? selectionStart = null, (int X, int Y)? from = null) + { + (int X, int Y) selection = selectionStart ?? (3, 3); + while (true) + { + await RenderGameState(game, selection: selection, from: from); + switch ((await Console.ReadKey(true)).Key) + { + case ConsoleKey.DownArrow: selection.Y = Math.Max(0, selection.Y - 1); break; + case ConsoleKey.UpArrow: selection.Y = Math.Min(7, selection.Y + 1); break; + case ConsoleKey.LeftArrow: selection.X = Math.Max(0, selection.X - 1); break; + case ConsoleKey.RightArrow: selection.X = Math.Min(7, selection.X + 1); break; + case ConsoleKey.Enter: return selection; + case ConsoleKey.Escape: return null; + } + } + } + + } +} diff --git a/Projects/Website/Games/Checkers/Game.cs b/Projects/Website/Games/Checkers/Game.cs new file mode 100644 index 00000000..22e02456 --- /dev/null +++ b/Projects/Website/Games/Checkers/Game.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using static Website.Games.Checkers._using; + +namespace Website.Games.Checkers; + +public class Game +{ + private const int PiecesPerColor = 12; + + public PieceColor Turn { get; private set; } + public Board Board { get; } + public PieceColor? Winner { get; private set; } + public List Players { get; } + + public Game(int humanPlayerCount) + { + if (humanPlayerCount < 0 || 2 < humanPlayerCount) throw new ArgumentOutOfRangeException(nameof(humanPlayerCount)); + Board = new Board(); + Players = new() + { + new Player(humanPlayerCount >= 1, Black), + new Player(humanPlayerCount >= 2, White), + }; + Turn = Black; + Winner = null; + } + + public void PerformMove(Move move) + { + (move.PieceToMove.X, move.PieceToMove.Y) = move.To; + if ((move.PieceToMove.Color is Black && move.To.Y is 7) || + (move.PieceToMove.Color is White && move.To.Y is 0)) + { + move.PieceToMove.Promoted = true; + } + if (move.PieceToCapture is not null) + { + Board.Pieces.Remove(move.PieceToCapture); + } + if (move.PieceToCapture is not null && + Board.GetPossibleMoves(move.PieceToMove).Any(m => m.PieceToCapture is not null)) + { + Board.Aggressor = move.PieceToMove; + } + else + { + Board.Aggressor = null; + Turn = Turn is Black ? White : Black; + } + CheckForWinner(); + } + + public void CheckForWinner() + { + if (!Board.Pieces.Any(piece => piece.Color is Black)) + { + Winner = White; + } + if (!Board.Pieces.Any(piece => piece.Color is White)) + { + Winner = Black; + } + if (Winner is null && Board.GetPossibleMoves(Turn).Count is 0) + { + Winner = Turn is Black ? White : Black; + } + } + + public int TakenCount(PieceColor colour) => + PiecesPerColor - Board.Pieces.Count(piece => piece.Color == colour); +} diff --git a/Projects/Website/Games/Checkers/Move.cs b/Projects/Website/Games/Checkers/Move.cs new file mode 100644 index 00000000..a2555d4c --- /dev/null +++ b/Projects/Website/Games/Checkers/Move.cs @@ -0,0 +1,17 @@ +namespace Website.Games.Checkers; + +public class Move +{ + public Piece PieceToMove { get; set; } + + public (int X, int Y) To { get; set; } + + public Piece? PieceToCapture { get; set; } + + public Move(Piece pieceToMove, (int X, int Y) to, Piece? pieceToCapture = null) + { + PieceToMove = pieceToMove; + To = to; + PieceToCapture = pieceToCapture; + } +} diff --git a/Projects/Website/Games/Checkers/Piece.cs b/Projects/Website/Games/Checkers/Piece.cs new file mode 100644 index 00000000..30c842d6 --- /dev/null +++ b/Projects/Website/Games/Checkers/Piece.cs @@ -0,0 +1,18 @@ +namespace Website.Games.Checkers; + +public class Piece +{ + public int X { get; set; } + + public int Y { get; set; } + + public string NotationPosition + { + get => Board.ToPositionNotationString(X, Y); + set => (X, Y) = Board.ParsePositionNotation(value); + } + + public PieceColor Color { get; set; } + + public bool Promoted { get; set; } +} diff --git a/Projects/Website/Games/Checkers/PieceColor.cs b/Projects/Website/Games/Checkers/PieceColor.cs new file mode 100644 index 00000000..a8c57337 --- /dev/null +++ b/Projects/Website/Games/Checkers/PieceColor.cs @@ -0,0 +1,7 @@ +namespace Website.Games.Checkers; + +public enum PieceColor +{ + Black = 1, + White = 2, +} diff --git a/Projects/Website/Games/Checkers/Player.cs b/Projects/Website/Games/Checkers/Player.cs new file mode 100644 index 00000000..765ce744 --- /dev/null +++ b/Projects/Website/Games/Checkers/Player.cs @@ -0,0 +1,13 @@ +namespace Website.Games.Checkers; + +public class Player +{ + public bool IsHuman { get; } + public PieceColor Color { get; } + + public Player(bool isHuman, PieceColor color) + { + IsHuman = isHuman; + Color = color; + } +} diff --git a/Projects/Website/Games/Checkers/_using.cs b/Projects/Website/Games/Checkers/_using.cs new file mode 100644 index 00000000..3d47339b --- /dev/null +++ b/Projects/Website/Games/Checkers/_using.cs @@ -0,0 +1,7 @@ +namespace Website.Games.Checkers; + +public static class _using +{ + public const PieceColor Black = PieceColor.Black; + public const PieceColor White = PieceColor.White; +} diff --git a/Projects/Website/Pages/Checkers.razor b/Projects/Website/Pages/Checkers.razor new file mode 100644 index 00000000..2cbc8ade --- /dev/null +++ b/Projects/Website/Pages/Checkers.razor @@ -0,0 +1,55 @@ +@using System + +@page "/Checkers" + +Checkers + +

Checkers

+ + + Go To Readme + + +
+
+
+			@Console.State
+		
+
+
+ + + + + + + + + +
+
+ + + + + +@code +{ + Games.Checkers.Checkers Game; + BlazorConsole Console; + + public Checkers() + { + Game = new(); + Console = Game.Console; + Console.WindowWidth = 82; + Console.WindowHeight = 25; + Console.StateHasChanged = StateHasChanged; + } + + protected override void OnInitialized() => InvokeAsync(Game.Run); +} diff --git a/Projects/Website/Shared/NavMenu.razor b/Projects/Website/Shared/NavMenu.razor index 06d216f6..6f6699f2 100644 --- a/Projects/Website/Shared/NavMenu.razor +++ b/Projects/Website/Shared/NavMenu.razor @@ -178,6 +178,11 @@ Battleship +