commit 42fcaf86317ca0faac844a100792f35b474c2df3 Author: Heller Date: Wed Jun 17 19:02:07 2026 +0000 feat: initial project structure with Snake.Core, CLI and Avalonia skeleton diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1056ada --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +*.userprefs + +## Mono auto generated files +mono_crash.* + +## Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +## Visual Studio cache/options directory +.vs/ + +## MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +## NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +## .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +## NuGet Packages +*.nupkg +*.snupkg +**/[Pp]ackages/* +!**/[Pp]ackages/build/ + +## Rider +.idea/ +*.sln.iml + +## VS Code +.vscode/ + +## OS generated files +.DS_Store +Thumbs.db + +## Avalonia +*.DotSettings.user diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..2b44a5d --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,8 @@ + + + net8.0 + enable + enable + latest + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a46273f --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Snake (C#) + +Классическая игра «Змейка» на .NET 8 с разделением логики и интерфейсов. + +## Структура решения + +| Проект | Назначение | +|--------|------------| +| `Snake.Core` | Игровая логика: поле, змейка, еда, движение, коллизии | +| `Snake.CLI` | Консольный интерфейс | +| `Snake.Avalonia` | Графический интерфейс (Avalonia UI) | + +## Требования + +- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) + +## Запуск + +```bash +# Консольная версия +dotnet run --project Snake.CLI + +# Avalonia UI (требуется графическая среда) +dotnet run --project Snake.Avalonia +``` + +## Git Flow + +Проект использует [Git Flow](https://nvie.com/posts/a-successful-git-branching-model/): + +| Ветка | Назначение | +|-------|------------| +| `main` | Стабильные релизы | +| `develop` | Интеграционная ветка для текущей разработки | +| `feature/*` | Новые функции (ветвление от `develop`) | +| `release/*` | Подготовка релиза (ветвление от `develop`) | +| `hotfix/*` | Срочные исправления в production (ветвление от `main`) | + +### Типичный workflow + +```bash +# Начать новую функцию +git checkout develop +git checkout -b feature/my-feature + +# Завершить функцию +git checkout develop +git merge --no-ff feature/my-feature +git branch -d feature/my-feature + +# Релиз +git checkout -b release/1.0.0 develop +# ... финальные правки, тег версии ... +git checkout main +git merge --no-ff release/1.0.0 +git tag -a v1.0.0 +git checkout develop +git merge --no-ff release/1.0.0 +``` + +## Игровая логика (Snake.Core) + +- `Board` — размеры поля и проверка границ +- `Snake` — сегменты тела, направление, движение +- `Food` — случайное размещение еды на свободных клетках +- `SnakeGame` — игровой цикл, счёт, коллизии со стенами и собой diff --git a/Snake.Avalonia/App.axaml b/Snake.Avalonia/App.axaml new file mode 100644 index 0000000..2e46121 --- /dev/null +++ b/Snake.Avalonia/App.axaml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/Snake.Avalonia/App.axaml.cs b/Snake.Avalonia/App.axaml.cs new file mode 100644 index 0000000..cf7026b --- /dev/null +++ b/Snake.Avalonia/App.axaml.cs @@ -0,0 +1,23 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace Snake.Avalonia; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow(); + } + + base.OnFrameworkInitializationCompleted(); + } +} \ No newline at end of file diff --git a/Snake.Avalonia/MainWindow.axaml b/Snake.Avalonia/MainWindow.axaml new file mode 100644 index 0000000..55047b6 --- /dev/null +++ b/Snake.Avalonia/MainWindow.axaml @@ -0,0 +1,10 @@ + + + diff --git a/Snake.Avalonia/MainWindow.axaml.cs b/Snake.Avalonia/MainWindow.axaml.cs new file mode 100644 index 0000000..e6b141b --- /dev/null +++ b/Snake.Avalonia/MainWindow.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Snake.Avalonia; + +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/Snake.Avalonia/Program.cs b/Snake.Avalonia/Program.cs new file mode 100644 index 0000000..a0cb932 --- /dev/null +++ b/Snake.Avalonia/Program.cs @@ -0,0 +1,24 @@ +using Avalonia; +using System; + +namespace Snake.Avalonia; + +class Program +{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() +#if DEBUG + .WithDeveloperTools() +#endif + .WithInterFont() + .LogToTrace(); +} diff --git a/Snake.Avalonia/Snake.Avalonia.csproj b/Snake.Avalonia/Snake.Avalonia.csproj new file mode 100644 index 0000000..2f6df16 --- /dev/null +++ b/Snake.Avalonia/Snake.Avalonia.csproj @@ -0,0 +1,24 @@ + + + WinExe + net8.0 + enable + app.manifest + true + + + + + + + + + None + All + + + + + + + diff --git a/Snake.Avalonia/Views/GameView.axaml b/Snake.Avalonia/Views/GameView.axaml new file mode 100644 index 0000000..e4ac10a --- /dev/null +++ b/Snake.Avalonia/Views/GameView.axaml @@ -0,0 +1,7 @@ + + + diff --git a/Snake.Avalonia/Views/GameView.axaml.cs b/Snake.Avalonia/Views/GameView.axaml.cs new file mode 100644 index 0000000..df8fddc --- /dev/null +++ b/Snake.Avalonia/Views/GameView.axaml.cs @@ -0,0 +1,15 @@ +using Avalonia.Controls; + +namespace Snake.Avalonia.Views; + +/// +/// Placeholder for the Avalonia game view. +/// Wire up here for rendering and input. +/// +public partial class GameView : UserControl +{ + public GameView() + { + InitializeComponent(); + } +} diff --git a/Snake.Avalonia/app.manifest b/Snake.Avalonia/app.manifest new file mode 100644 index 0000000..4bc76ae --- /dev/null +++ b/Snake.Avalonia/app.manifest @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/Snake.CLI/ConsoleRenderer.cs b/Snake.CLI/ConsoleRenderer.cs new file mode 100644 index 0000000..41386d7 --- /dev/null +++ b/Snake.CLI/ConsoleRenderer.cs @@ -0,0 +1,50 @@ +using Snake.Core; + +namespace Snake.CLI; + +internal static class ConsoleRenderer +{ + public static void Render(SnakeGame game) + { + Console.Clear(); + Console.WriteLine($"Score: {game.Score}"); + + if (game.IsGameOver) + Console.WriteLine("Game Over! Press R to restart or Q to quit."); + + var board = game.Board; + var snakeCells = game.Snake.Segments.ToHashSet(); + var food = game.Food.Position; + + Console.Write('+'); + Console.Write(new string('-', board.Width * 2)); + Console.WriteLine('+'); + + for (var y = 0; y < board.Height; y++) + { + Console.Write('|'); + + for (var x = 0; x < board.Width; x++) + { + var position = new Position(x, y); + var symbol = position == food + ? '@' + : position == game.Snake.Head + ? 'O' + : snakeCells.Contains(position) + ? 'o' + : ' '; + + Console.Write(symbol); + Console.Write(' '); + } + + Console.WriteLine('|'); + } + + Console.Write('+'); + Console.Write(new string('-', board.Width * 2)); + Console.WriteLine('+'); + Console.WriteLine("Controls: Arrow keys to move, R to restart, Q to quit."); + } +} diff --git a/Snake.CLI/Program.cs b/Snake.CLI/Program.cs new file mode 100644 index 0000000..a5bb0fb --- /dev/null +++ b/Snake.CLI/Program.cs @@ -0,0 +1,44 @@ +using Snake.CLI; +using Snake.Core; + +const int boardWidth = 20; +const int boardHeight = 15; +const int tickMs = 120; + +SnakeGame game = new(boardWidth, boardHeight); +ConsoleRenderer.Render(game); + +while (true) +{ + if (Console.KeyAvailable) + { + var key = Console.ReadKey(intercept: true).Key; + + switch (key) + { + case ConsoleKey.UpArrow: + game.SetDirection(Direction.Up); + break; + case ConsoleKey.DownArrow: + game.SetDirection(Direction.Down); + break; + case ConsoleKey.LeftArrow: + game.SetDirection(Direction.Left); + break; + case ConsoleKey.RightArrow: + game.SetDirection(Direction.Right); + break; + case ConsoleKey.Q: + return; + case ConsoleKey.R when game.IsGameOver: + game = new SnakeGame(boardWidth, boardHeight); + break; + } + } + + if (!game.IsGameOver) + game.Tick(); + + ConsoleRenderer.Render(game); + Thread.Sleep(tickMs); +} diff --git a/Snake.CLI/Snake.CLI.csproj b/Snake.CLI/Snake.CLI.csproj new file mode 100644 index 0000000..e72c09a --- /dev/null +++ b/Snake.CLI/Snake.CLI.csproj @@ -0,0 +1,14 @@ + + + + + + + + Exe + net8.0 + enable + enable + + + diff --git a/Snake.Core/Board.cs b/Snake.Core/Board.cs new file mode 100644 index 0000000..91ddab0 --- /dev/null +++ b/Snake.Core/Board.cs @@ -0,0 +1,24 @@ +namespace Snake.Core; + +public sealed class Board +{ + public Board(int width, int height) + { + if (width < 2) + throw new ArgumentOutOfRangeException(nameof(width), "Width must be at least 2."); + + if (height < 2) + throw new ArgumentOutOfRangeException(nameof(height), "Height must be at least 2."); + + Width = width; + Height = height; + } + + public int Width { get; } + + public int Height { get; } + + public bool IsWithinBounds(Position position) => + position.X >= 0 && position.X < Width && + position.Y >= 0 && position.Y < Height; +} diff --git a/Snake.Core/Direction.cs b/Snake.Core/Direction.cs new file mode 100644 index 0000000..82c9e98 --- /dev/null +++ b/Snake.Core/Direction.cs @@ -0,0 +1,9 @@ +namespace Snake.Core; + +public enum Direction +{ + Up, + Down, + Left, + Right +} diff --git a/Snake.Core/Food.cs b/Snake.Core/Food.cs new file mode 100644 index 0000000..3a7c8be --- /dev/null +++ b/Snake.Core/Food.cs @@ -0,0 +1,38 @@ +namespace Snake.Core; + +public sealed class Food +{ + private readonly Random _random; + + public Food(Board board, IEnumerable occupiedPositions, Random? random = null) + { + _random = random ?? Random.Shared; + Board = board; + Respawn(occupiedPositions); + } + + public Board Board { get; } + + public Position Position { get; private set; } + + public void Respawn(IEnumerable occupiedPositions) + { + var occupied = occupiedPositions.ToHashSet(); + var freeCells = new List(); + + for (var y = 0; y < Board.Height; y++) + { + for (var x = 0; x < Board.Width; x++) + { + var position = new Position(x, y); + if (!occupied.Contains(position)) + freeCells.Add(position); + } + } + + if (freeCells.Count == 0) + throw new InvalidOperationException("No free cells available for food."); + + Position = freeCells[_random.Next(freeCells.Count)]; + } +} diff --git a/Snake.Core/GameTickResult.cs b/Snake.Core/GameTickResult.cs new file mode 100644 index 0000000..3a8cb9f --- /dev/null +++ b/Snake.Core/GameTickResult.cs @@ -0,0 +1,8 @@ +namespace Snake.Core; + +public enum GameTickResult +{ + Moved, + AteFood, + GameOver +} diff --git a/Snake.Core/Position.cs b/Snake.Core/Position.cs new file mode 100644 index 0000000..7eaf886 --- /dev/null +++ b/Snake.Core/Position.cs @@ -0,0 +1,3 @@ +namespace Snake.Core; + +public readonly record struct Position(int X, int Y); diff --git a/Snake.Core/Snake.Core.csproj b/Snake.Core/Snake.Core.csproj new file mode 100644 index 0000000..bb23fb7 --- /dev/null +++ b/Snake.Core/Snake.Core.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/Snake.Core/Snake.cs b/Snake.Core/Snake.cs new file mode 100644 index 0000000..e88c5bc --- /dev/null +++ b/Snake.Core/Snake.cs @@ -0,0 +1,80 @@ +namespace Snake.Core; + +public sealed class Snake +{ + private readonly LinkedList _segments = new(); + + public Snake(Position start, int length, Direction direction) + { + if (length < 1) + throw new ArgumentOutOfRangeException(nameof(length), "Length must be at least 1."); + + Direction = direction; + + var offset = DirectionToOffset(direction); + for (var i = 0; i < length; i++) + { + _segments.AddLast(new Position( + start.X - offset.X * i, + start.Y - offset.Y * i)); + } + } + + public Direction Direction { get; private set; } + + public Position Head => _segments.First!.Value; + + public IReadOnlyCollection Segments => _segments; + + public void SetDirection(Direction direction) + { + if (direction == Direction) + return; + + if (AreOpposite(Direction, direction)) + return; + + Direction = direction; + } + + public Position PeekNextHead() + { + var offset = DirectionToOffset(Direction); + return new Position(Head.X + offset.X, Head.Y + offset.Y); + } + + public Position Move(bool grow = false) + { + var newHead = PeekNextHead(); + + _segments.AddFirst(newHead); + + if (!grow) + _segments.RemoveLast(); + + return newHead; + } + + public bool Occupies(Position position) => + _segments.Contains(position); + + private static Position DirectionToOffset(Direction direction) => + direction switch + { + Direction.Up => new Position(0, -1), + Direction.Down => new Position(0, 1), + Direction.Left => new Position(-1, 0), + Direction.Right => new Position(1, 0), + _ => throw new ArgumentOutOfRangeException(nameof(direction)) + }; + + private static bool AreOpposite(Direction current, Direction next) => + (current, next) switch + { + (Direction.Up, Direction.Down) => true, + (Direction.Down, Direction.Up) => true, + (Direction.Left, Direction.Right) => true, + (Direction.Right, Direction.Left) => true, + _ => false + }; +} diff --git a/Snake.Core/SnakeGame.cs b/Snake.Core/SnakeGame.cs new file mode 100644 index 0000000..fe01bfe --- /dev/null +++ b/Snake.Core/SnakeGame.cs @@ -0,0 +1,60 @@ +namespace Snake.Core; + +public sealed class SnakeGame +{ + public SnakeGame(int width, int height, Random? random = null) + { + _random = random ?? Random.Shared; + Board = new Board(width, height); + + var start = new Position(width / 2, height / 2); + Snake = new Snake(start, length: 3, Direction.Right); + Food = new Food(Board, Snake.Segments, _random); + } + + private readonly Random _random; + + public Board Board { get; } + + public Snake Snake { get; } + + public Food Food { get; } + + public int Score { get; private set; } + + public bool IsGameOver { get; private set; } + + public void SetDirection(Direction direction) => Snake.SetDirection(direction); + + public GameTickResult Tick() + { + if (IsGameOver) + return GameTickResult.GameOver; + + var newHead = Snake.PeekNextHead(); + + if (!Board.IsWithinBounds(newHead) || Snake.Occupies(newHead)) + { + IsGameOver = true; + return GameTickResult.GameOver; + } + + var ateFood = newHead == Food.Position; + Snake.Move(grow: ateFood); + + if (ateFood) + { + Score++; + if (Snake.Segments.Count >= Board.Width * Board.Height) + { + IsGameOver = true; + return GameTickResult.AteFood; + } + + Food.Respawn(Snake.Segments); + return GameTickResult.AteFood; + } + + return GameTickResult.Moved; + } +} diff --git a/Snake.sln b/Snake.sln new file mode 100644 index 0000000..53900e8 --- /dev/null +++ b/Snake.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Snake.Core", "Snake.Core\Snake.Core.csproj", "{C74F838E-FFB4-499F-99DF-57142C110454}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Snake.CLI", "Snake.CLI\Snake.CLI.csproj", "{8DB20DDA-CE97-41B6-B51E-C195DD387C70}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Snake.Avalonia", "Snake.Avalonia\Snake.Avalonia.csproj", "{4C16155D-6998-4635-8478-5F27FDC4189D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C74F838E-FFB4-499F-99DF-57142C110454}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C74F838E-FFB4-499F-99DF-57142C110454}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C74F838E-FFB4-499F-99DF-57142C110454}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C74F838E-FFB4-499F-99DF-57142C110454}.Release|Any CPU.Build.0 = Release|Any CPU + {8DB20DDA-CE97-41B6-B51E-C195DD387C70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8DB20DDA-CE97-41B6-B51E-C195DD387C70}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8DB20DDA-CE97-41B6-B51E-C195DD387C70}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8DB20DDA-CE97-41B6-B51E-C195DD387C70}.Release|Any CPU.Build.0 = Release|Any CPU + {4C16155D-6998-4635-8478-5F27FDC4189D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4C16155D-6998-4635-8478-5F27FDC4189D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C16155D-6998-4635-8478-5F27FDC4189D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4C16155D-6998-4635-8478-5F27FDC4189D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/global.json b/global.json new file mode 100644 index 0000000..ad84cab --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.0", + "rollForward": "latestMinor" + } +}