feat: add backend for multiplayer support

This commit is contained in:
2025-07-29 01:32:02 -04:00
parent 3f1fe2c693
commit 8ea2d591a0
32 changed files with 300 additions and 3 deletions

56
backend/.gitignore vendored Normal file
View File

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

193
backend/Program.cs Normal file
View File

@@ -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<string, Game>();
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<byte>(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<byte>(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<Player> Players { get; } = new();
public List<string> 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<string, Game> 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<string, Game> 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<string, Game> 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" });
}
}
}
}

View File

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

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

9
backend/appsettings.json Normal file
View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

9
backend/backend.csproj Normal file
View File

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

View File

View File

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

View File

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 192 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

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