diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..cb71731 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,56 @@ +## Build Folders +bin/ +obj/ +out/ + +## User-specific files +*.user +*.userosscache +*.suo +*.sln.docstates + +## Rider +.idea/ +*.sln.iml + +## Visual Studio Code +.vscode/ + +## ASP.NET +wwwroot/ +node_modules/ +dist/ +.env +.env.* + +## Logs +*.log +logs/ + +## Publish output +publish/ +*.db + +## Test Results +TestResults/ +*.trx +*.coverage +*.coveragexml + +## NuGet +*.nupkg +*.snupkg +.nuget/ +packages/ +project.lock.json +project.fragment.lock.json +artifacts/ + +## Temporary files +*.tmp +*.bak +*.swp + +## OS Files +.DS_Store +Thumbs.db diff --git a/backend/Program.cs b/backend/Program.cs new file mode 100644 index 0000000..fba7640 --- /dev/null +++ b/backend/Program.cs @@ -0,0 +1,193 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Collections.Concurrent; + +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); + +var games = new ConcurrentDictionary(); + +app.UseWebSockets(); + +app.Map("/", async context => +{ + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + return; + } + + var ws = await context.WebSockets.AcceptWebSocketAsync(); + var remoteAddress = context.Connection.RemoteIpAddress?.ToString(); + + var player = new Player(ws, remoteAddress); + + try + { + var buffer = new byte[4096]; + var messageBuffer = new ArraySegment(buffer); + while (ws.State == WebSocketState.Open) + { + var message = new MemoryStream(); + WebSocketReceiveResult result; + + do + { + result = await ws.ReceiveAsync(messageBuffer, CancellationToken.None); + message.Write(buffer, 0, result.Count); + } + while (!result.EndOfMessage); + + if (result.MessageType == WebSocketMessageType.Close) + break; + + var msgText = Encoding.UTF8.GetString(message.ToArray()); + + if (msgText == "ping") + { + await player.SendTextAsync("pong"); + continue; + } + + var json = JsonDocument.Parse(msgText).RootElement; + var action = json.GetProperty("action").GetString(); + var gameId = json.GetProperty("gameId").GetString(); + var clientId = json.GetProperty("clientId").GetString(); + + switch (action) + { + case "enter_game": + await GameHandlers.EnterGame(gameId, clientId, player, games); + break; + case "send_piece": + await GameHandlers.SendToOpponent(gameId, player, "receive_piece", json.GetProperty("piece"), games); + break; + case "send_stack": + await GameHandlers.SendToOpponent(gameId, player, "receive_stack", json.GetProperty("stack"), games); + break; + case "send_stats": + await GameHandlers.SendToOpponent(gameId, player, "receive_stats", json.GetProperty("stats"), games); + break; + default: + Console.WriteLine("Unsupported action."); + break; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + finally + { + Console.WriteLine("Disconnecting player..."); + await GameHandlers.ExitGame(player, games); + Console.WriteLine("Connection closed."); + } +}); + +app.Run(); + +// ------------------------- +// Models +// ------------------------- + +record Player(WebSocket Socket, string? RemoteAddress) +{ + public async Task SendTextAsync(string message) + { + var bytes = Encoding.UTF8.GetBytes(message); + await Socket.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, CancellationToken.None); + } + + public async Task SendJsonAsync(object obj) + { + var json = JsonSerializer.Serialize(obj); + await SendTextAsync(json); + } +} + +class Game +{ + public string GameId { get; } + public List Players { get; } = new(); + public List Clients { get; } = new(); + + public Game(string gameId) + { + GameId = gameId; + } + + public Player? GetOpponent(Player player) => Players.FirstOrDefault(p => p != player); +} + +// ------------------------- +// Handlers +// ------------------------- + +static class GameHandlers +{ + public static async Task EnterGame(string gameId, string clientId, Player player, ConcurrentDictionary games) + { + var game = games.GetOrAdd(gameId, _ => new Game(gameId)); + + if (!game.Clients.Contains(clientId)) + { + game.Clients.Add(clientId); + game.Players.Add(player); + + if (game.Players.Count == 1) + { + await player.SendJsonAsync(new { type = "wait_for_opponent" }); + } + else if (game.Players.Count == 2) + { + foreach (var p in game.Players) + await p.SendJsonAsync(new { type = "start_game" }); + } + } + else + { + Console.WriteLine("Already in game..."); + } + } + + public static async Task SendToOpponent(string gameId, Player sender, string type, JsonElement content, ConcurrentDictionary games) + { + if (!games.TryGetValue(gameId, out var game)) + return; + + var opponent = game.GetOpponent(sender); + if (opponent != null) + { + switch (type) + { + case "receive_piece": + await opponent.SendJsonAsync(new { type, piece = content }); + break; + case "receive_stack": + await opponent.SendJsonAsync(new { type, stack = content }); + break; + case "receive_stats": + await opponent.SendJsonAsync(new { type, stats = content }); + break; + } + } + } + + public static async Task ExitGame(Player disconnectingPlayer, ConcurrentDictionary games) + { + var game = games.Values.FirstOrDefault(g => g.Players.Contains(disconnectingPlayer)); + if (game != null) + { + games.TryRemove(game.GameId, out _); + + var opponent = game.GetOpponent(disconnectingPlayer); + if (opponent != null) + { + await opponent.SendJsonAsync(new { type = "exit_game" }); + } + } + } +} diff --git a/backend/Properties/launchSettings.json b/backend/Properties/launchSettings.json new file mode 100644 index 0000000..05a7b87 --- /dev/null +++ b/backend/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5085", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7145;http://localhost:5085", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/backend/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/backend/appsettings.json b/backend/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/backend/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/backend/backend.csproj b/backend/backend.csproj new file mode 100644 index 0000000..6568b3d --- /dev/null +++ b/backend/backend.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/.gitignore b/frontend/.gitignore similarity index 100% rename from .gitignore rename to frontend/.gitignore diff --git a/Dockerfile b/frontend/Dockerfile similarity index 100% rename from Dockerfile rename to frontend/Dockerfile diff --git a/data/config.yaml b/frontend/data/config.yaml similarity index 98% rename from data/config.yaml rename to frontend/data/config.yaml index 90b6f98..5063d09 100644 --- a/data/config.yaml +++ b/frontend/data/config.yaml @@ -97,5 +97,5 @@ color: piece-ghost: "#9BFCF0" online: - server-url: "ws://webapi.tetri5.com" + server-url: "ws://localhost:5085" #server-url: "ws://localhost:5001" diff --git a/data/image/press-start-2p-font.bmp b/frontend/data/image/press-start-2p-font.bmp similarity index 100% rename from data/image/press-start-2p-font.bmp rename to frontend/data/image/press-start-2p-font.bmp diff --git a/data/image/tetris_icon.png b/frontend/data/image/tetris_icon.png similarity index 100% rename from data/image/tetris_icon.png rename to frontend/data/image/tetris_icon.png diff --git a/data/image/title_screen.png b/frontend/data/image/title_screen.png similarity index 100% rename from data/image/title_screen.png rename to frontend/data/image/title_screen.png diff --git a/data/sound/four_lines_complete.ogg b/frontend/data/sound/four_lines_complete.ogg similarity index 100% rename from data/sound/four_lines_complete.ogg rename to frontend/data/sound/four_lines_complete.ogg diff --git a/data/sound/game_over.ogg b/frontend/data/sound/game_over.ogg similarity index 100% rename from data/sound/game_over.ogg rename to frontend/data/sound/game_over.ogg diff --git a/data/sound/level_up.ogg b/frontend/data/sound/level_up.ogg similarity index 100% rename from data/sound/level_up.ogg rename to frontend/data/sound/level_up.ogg diff --git a/data/sound/line_complete.ogg b/frontend/data/sound/line_complete.ogg similarity index 100% rename from data/sound/line_complete.ogg rename to frontend/data/sound/line_complete.ogg diff --git a/data/sound/option_change.ogg b/frontend/data/sound/option_change.ogg similarity index 100% rename from data/sound/option_change.ogg rename to frontend/data/sound/option_change.ogg diff --git a/data/sound/piece_rotate.ogg b/frontend/data/sound/piece_rotate.ogg similarity index 100% rename from data/sound/piece_rotate.ogg rename to frontend/data/sound/piece_rotate.ogg diff --git a/data/sound/piece_set.ogg b/frontend/data/sound/piece_set.ogg similarity index 100% rename from data/sound/piece_set.ogg rename to frontend/data/sound/piece_set.ogg diff --git a/data/sound/theme_music_multi.ogg b/frontend/data/sound/theme_music_multi.ogg similarity index 100% rename from data/sound/theme_music_multi.ogg rename to frontend/data/sound/theme_music_multi.ogg diff --git a/data/sound/theme_music_single.ogg b/frontend/data/sound/theme_music_single.ogg similarity index 100% rename from data/sound/theme_music_single.ogg rename to frontend/data/sound/theme_music_single.ogg diff --git a/data/sound/you_win.ogg b/frontend/data/sound/you_win.ogg similarity index 100% rename from data/sound/you_win.ogg rename to frontend/data/sound/you_win.ogg diff --git a/main.py b/frontend/main.py similarity index 100% rename from main.py rename to frontend/main.py diff --git a/requirements.local.txt b/frontend/requirements.local.txt similarity index 100% rename from requirements.local.txt rename to frontend/requirements.local.txt diff --git a/requirements.txt b/frontend/requirements.txt similarity index 100% rename from requirements.txt rename to frontend/requirements.txt diff --git a/tetri5/__init__.py b/frontend/tetri5/__init__.py similarity index 100% rename from tetri5/__init__.py rename to frontend/tetri5/__init__.py diff --git a/tetri5/entity.py b/frontend/tetri5/entity.py similarity index 100% rename from tetri5/entity.py rename to frontend/tetri5/entity.py diff --git a/tetri5/game.py b/frontend/tetri5/game.py similarity index 100% rename from tetri5/game.py rename to frontend/tetri5/game.py diff --git a/tetri5/modal.py b/frontend/tetri5/modal.py similarity index 100% rename from tetri5/modal.py rename to frontend/tetri5/modal.py diff --git a/tetri5/online.py b/frontend/tetri5/online.py similarity index 98% rename from tetri5/online.py rename to frontend/tetri5/online.py index 4f2f9b0..94a0528 100644 --- a/tetri5/online.py +++ b/frontend/tetri5/online.py @@ -30,8 +30,7 @@ class MultiplayerService(): @classmethod def init(cls) -> None: - thread = Thread(target=asyncio.get_event_loop().run_until_complete,\ - args=(_NetworkConnectionService.init(),)) + thread = Thread(target=asyncio.run, args=(_NetworkConnectionService.init(),)) thread.start() """ SEND """ diff --git a/tetri5/scene.py b/frontend/tetri5/scene.py similarity index 100% rename from tetri5/scene.py rename to frontend/tetri5/scene.py diff --git a/tetri5/util.py b/frontend/tetri5/util.py similarity index 100% rename from tetri5/util.py rename to frontend/tetri5/util.py