feat: initial project structure with Snake.Core, CLI and Avalonia skeleton

This commit is contained in:
Heller
2026-06-17 19:02:07 +00:00
commit 42fcaf8631
25 changed files with 661 additions and 0 deletions

66
.gitignore vendored Normal file
View File

@@ -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

8
Directory.Build.props Normal file
View File

@@ -0,0 +1,8 @@
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>

66
README.md Normal file
View File

@@ -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` — игровой цикл, счёт, коллизии со стенами и собой

10
Snake.Avalonia/App.axaml Normal file
View File

@@ -0,0 +1,10 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Snake.Avalonia.App"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>

View File

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

View File

@@ -0,0 +1,10 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="using:Snake.Avalonia.Views"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Snake.Avalonia.MainWindow"
Title="Snake">
<views:GameView />
</Window>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Snake.Avalonia;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}

24
Snake.Avalonia/Program.cs Normal file
View File

@@ -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<App>()
.UsePlatformDetect()
#if DEBUG
.WithDeveloperTools()
#endif
.WithInterFont()
.LogToTrace();
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Snake.Core\Snake.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Snake.Avalonia.Views.GameView">
<TextBlock Text="Snake game UI — connect Snake.Core here"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</UserControl>

View File

@@ -0,0 +1,15 @@
using Avalonia.Controls;
namespace Snake.Avalonia.Views;
/// <summary>
/// Placeholder for the Avalonia game view.
/// Wire up <see cref="Snake.Core.SnakeGame"/> here for rendering and input.
/// </summary>
public partial class GameView : UserControl
{
public GameView()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="Snake.Avalonia.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@@ -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.");
}
}

44
Snake.CLI/Program.cs Normal file
View File

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

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Snake.Core\Snake.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

24
Snake.Core/Board.cs Normal file
View File

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

9
Snake.Core/Direction.cs Normal file
View File

@@ -0,0 +1,9 @@
namespace Snake.Core;
public enum Direction
{
Up,
Down,
Left,
Right
}

38
Snake.Core/Food.cs Normal file
View File

@@ -0,0 +1,38 @@
namespace Snake.Core;
public sealed class Food
{
private readonly Random _random;
public Food(Board board, IEnumerable<Position> 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<Position> occupiedPositions)
{
var occupied = occupiedPositions.ToHashSet();
var freeCells = new List<Position>();
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)];
}
}

View File

@@ -0,0 +1,8 @@
namespace Snake.Core;
public enum GameTickResult
{
Moved,
AteFood,
GameOver
}

3
Snake.Core/Position.cs Normal file
View File

@@ -0,0 +1,3 @@
namespace Snake.Core;
public readonly record struct Position(int X, int Y);

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

80
Snake.Core/Snake.cs Normal file
View File

@@ -0,0 +1,80 @@
namespace Snake.Core;
public sealed class Snake
{
private readonly LinkedList<Position> _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<Position> 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
};
}

60
Snake.Core/SnakeGame.cs Normal file
View File

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

34
Snake.sln Normal file
View File

@@ -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

6
global.json Normal file
View File

@@ -0,0 +1,6 @@
{
"sdk": {
"version": "8.0.0",
"rollForward": "latestMinor"
}
}