diff --git a/README.md b/1.md similarity index 99% rename from README.md rename to 1.md index 979f6c9b14a7a5c3c8549950089e53365f4030c9..9ab6264b5a754db0c4ff8b0c5d92380cada6f285 100644 --- a/README.md +++ b/1.md @@ -461,4 +461,4 @@ export const useWhiteboardStore = defineStore('whiteboard', { - **事件驱动通信**:领域事件的正确使用和处理 - **分层架构遵循**:各层职责清晰,依赖关系正确 -这个项目既实用又有挑战性,非常适合作为高职学生的综合实训项目! +这个项目既实用又有挑战性,非常适合作为高职学生的综合实训项目! \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000000000000000000000000000000000000..0d5285d967862a2e7989a379f65ee312ac80b237 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,295 @@ +# 涂色抢地盘游戏 - 部署指南 + +## 项目概述 +这是一个基于Vue3和.NET 8的多人实时涂色抢地盘游戏,本指南将指导您如何部署该项目到网页服务器上。 + +## 部署前准备 + +### 环境要求 +- 前端:Node.js 16+,Nginx或其他Web服务器 +- 后端:.NET 8 SDK,PostgreSQL数据库,Redis + +### 必要配置 +1. **数据库配置**: + - 创建PostgreSQL数据库 + - 执行数据库迁移脚本 + +2. **Redis配置**: + - 确保Redis服务正常运行 + +3. **后端配置**: + - 修改`backend/src/TerritoryGame.Api/appsettings.json`中的数据库连接字符串和Redis配置 + +## 前端部署 + +### 配置环境变量 +在部署前,您需要配置前端环境变量以指向正确的后端API地址。 + +1. 在前端项目根目录创建`.env.production`文件: +```env +VITE_API_URL=http://your-backend-domain.com # 替换为您的后端域名 +``` + +2. 如果您需要在构建时指定环境变量,可以使用命令行参数: +```bash +npm run build -- --mode production +``` + +### 构建静态文件 +前端已经构建完成,生成的静态文件位于`frontend/dist`目录下: +``` +frontend/dist/ +├── assets/ +│ ├── GameRoomView-BYlJY0ji.js +│ ├── GameRoomView-CrYcQXFb.css +│ ├── index-Bx2j99MR.css +│ └── index-DkNJZmDj.js +├── favicon.ico +└── index.html +``` + +### 部署到Nginx +1. 安装Nginx +2. 配置Nginx服务: + +```nginx +server { + listen 80; + server_name your-domain.com; # 替换为您的域名 + + root C:\Users\Asus\Desktop\Play\frontend\dist; # 静态文件目录 + index index.html; + + location / { + try_files $uri $uri/ /index.html; # 支持Vue路由 + } + + # 代理API请求到后端服务 + location /api { + proxy_pass http://localhost:5120; # 后端服务地址 + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection keep-alive; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + # 代理SignalR请求 + location /hubs { + proxy_pass http://localhost:5120; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} +``` + +3. 启动Nginx服务 + +## 后端部署 + +### 发布后端应用 +1. 打开终端,导航到后端项目目录: +```bash +cd C:\Users\Asus\Desktop\Play\backend\src\TerritoryGame.Api +``` + +2. 发布应用: +```bash +dotnet publish -c Release -o ./publish +``` + +### 运行后端服务 +1. 导航到发布目录: +```bash +cd ./publish +``` + +2. 运行服务: +```bash +dotnet TerritoryGame.Api.dll +``` + +### 使用Docker部署(可选) +1. 创建Dockerfile: +```dockerfile +# 后端API Dockerfile +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +EXPOSE 5120 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY ["TerritoryGame.Api/TerritoryGame.Api.csproj", "TerritoryGame.Api/"] +COPY ["TerritoryGame.Application/TerritoryGame.Application.csproj", "TerritoryGame.Application/"] +COPY ["TerritoryGame.Domain/TerritoryGame.Domain.csproj", "TerritoryGame.Domain/"] +COPY ["TerritoryGame.Infrastructure/TerritoryGame.Infrastructure.csproj", "TerritoryGame.Infrastructure/"] +RUN dotnet restore "TerritoryGame.Api/TerritoryGame.Api.csproj" +COPY . . +WORKDIR "/src/TerritoryGame.Api" +RUN dotnet build "TerritoryGame.Api.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "TerritoryGame.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "TerritoryGame.Api.dll"] +``` + +2. 创建docker-compose.yml: +```yaml +version: '3.8' + +services: + api: + build: ./backend/src + ports: + - "5120:5120" + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=territory_game;Username=postgres;Password=your_password + - Redis__Configuration=redis:6379 + depends_on: + - postgres + - redis + + postgres: + image: postgres:14 + environment: + - POSTGRES_DB=territory_game + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=your_password + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:6 + volumes: + - redis_data:/data + + nginx: + image: nginx:latest + ports: + - "80:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf + - ./frontend/dist:/usr/share/nginx/html + depends_on: + - api + +volumes: + postgres_data: + redis_data: +``` + +3. 启动Docker容器: +```bash +docker-compose up -d +``` + +## 配置跨域 +在开发环境中,后端配置了允许所有来源的跨域请求,但在生产环境中应限制为仅允许您的前端域名。 + +1. 修改`backend/src/TerritoryGame.Api/Program.cs`中的跨域配置: +```csharp +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowFrontend", + policy => + { + policy.WithOrigins("http://your-frontend-domain.com") // 替换为您的前端域名 + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + }); +}); +``` + +2. 在`app.UseRouting()`之后添加: +```csharp +app.UseCors("AllowFrontend"); +``` + +3. 移除或注释掉原来的`AllowAll`策略: +```csharp +// 移除以下代码 +/* +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", builder => + { + builder.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); +*/ +``` + +## 安全考虑 + +1. **敏感信息管理**: + - 不要将数据库密码、API密钥等敏感信息硬编码到配置文件中 + - 使用环境变量或密钥管理服务存储敏感信息 + - 修改`appsettings.json`中的`ConnectionStrings`部分,使用环境变量: + ```json + { + "ConnectionStrings": { + "DefaultConnection": "Host=${DB_HOST};Port=${DB_PORT};Database=${DB_NAME};Username=${DB_USER};Password=${DB_PASSWORD}", + "Redis": "${REDIS_HOST}:${REDIS_PORT}" + } + } + ``` + +2. **HTTPS配置**: + - 在生产环境中启用HTTPS + - 配置SSL证书 + - 修改`Program.cs`,确保重定向到HTTPS: + ```csharp + app.UseHttpsRedirection(); + ``` + +3. **请求限制**: + - 添加请求频率限制以防止DoS攻击 + - 使用ASP.NET Core的`RateLimiter`中间件 + +## 启动应用 +1. 确保PostgreSQL和Redis服务正常运行 +2. 启动后端服务 +3. 启动Nginx服务 +4. 在浏览器中访问您的域名 + +## 常见问题 +1. **前端无法连接到后端API**: + - 检查后端服务是否正常运行 + - 检查Nginx代理配置是否正确 + - 检查跨域配置是否正确 + +2. **数据库连接失败**: + - 检查数据库连接字符串是否正确 + - 确保PostgreSQL服务正常运行 + +3. **Redis缓存问题**: + - 确保Redis服务正常运行 + - 检查Redis配置是否正确 + +## 项目结构回顾 +``` +Play/ +├── backend/ # .NET 8后端项目 +│ └── src/ +│ ├── TerritoryGame.Api/ # API层 +│ ├── TerritoryGame.Application/ # 应用层 +│ ├── TerritoryGame.Domain/ # 领域层 +│ ├── TerritoryGame.Infrastructure/ # 基础设施层 +│ └── TerritoryGame.Tests/ # 测试项目 +├── frontend/ # Vue3前端项目 +│ ├── dist/ # 构建后的静态文件 +│ └── src/ # 源代码 +├── 1.md # 开发指南 +├── 需求文档.md # 需求文档 +└── DEPLOYMENT.md # 部署指南(本文件) +``` \ No newline at end of file diff --git a/backend/DependencyInjectionTest.cs b/backend/DependencyInjectionTest.cs new file mode 100644 index 0000000000000000000000000000000000000000..3c680c19fecbe20af4a7c628483654947a3113a1 --- /dev/null +++ b/backend/DependencyInjectionTest.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.DependencyInjection; +using System; +using TerritoryGame.Application.Services; + +class Program +{ + static void Main(string[] args) + { + // 创建服务集合 + var services = new ServiceCollection(); + + // 注册服务 + services.AddScoped(); + + // 构建服务提供程序 + var serviceProvider = services.BuildServiceProvider(); + + // 解析服务 + try + { + var gameService = serviceProvider.GetRequiredService(); + Console.WriteLine("成功解析IGameService"); + + // 测试服务功能 + var room = gameService.CreateRoom("测试房间"); + Console.WriteLine($"成功创建房间: {room.Name}, ID: {room.Id}"); + } + catch (Exception ex) + { + Console.WriteLine($"解析IGameService失败: {ex.Message}"); + Console.WriteLine(ex.StackTrace); + } + } +} \ No newline at end of file diff --git a/backend/DependencyInjectionTest/DependencyInjectionTest.csproj b/backend/DependencyInjectionTest/DependencyInjectionTest.csproj new file mode 100644 index 0000000000000000000000000000000000000000..b4c478e0c89cdd4cc4615ef201d7a7a413ea0546 --- /dev/null +++ b/backend/DependencyInjectionTest/DependencyInjectionTest.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + diff --git a/backend/DependencyInjectionTest/Program.cs b/backend/DependencyInjectionTest/Program.cs new file mode 100644 index 0000000000000000000000000000000000000000..342b680dc766ccb19c67f75d2bfe8992d2bb248d --- /dev/null +++ b/backend/DependencyInjectionTest/Program.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.DependencyInjection; +using System; +using TerritoryGame.Application.Services; + +class Program +{ + static void Main(string[] args) + { + // 创建服务集合 + var services = new ServiceCollection(); + + // 注册服务 + services.AddScoped(); + + // 构建服务提供程序 + var serviceProvider = services.BuildServiceProvider(); + + // 解析服务 + try + { + var gameService = serviceProvider.GetRequiredService(); + Console.WriteLine("成功解析IGameService"); + + // 测试服务功能 + var room = gameService.CreateRoom("测试房间"); + Console.WriteLine($"成功创建房间: {room.Name}, ID: {room.Id}"); + } + catch (Exception ex) + { + Console.WriteLine($"解析IGameService失败: {ex.Message}"); + Console.WriteLine(ex.StackTrace); + } + } +} diff --git a/backend/GameServiceTest/GameServiceTest.csproj b/backend/GameServiceTest/GameServiceTest.csproj new file mode 100644 index 0000000000000000000000000000000000000000..54b5f7cb1af62d51b1c58eb162fa15656e925471 --- /dev/null +++ b/backend/GameServiceTest/GameServiceTest.csproj @@ -0,0 +1,19 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/backend/GameServiceTest/Program.cs b/backend/GameServiceTest/Program.cs new file mode 100644 index 0000000000000000000000000000000000000000..13cd882ca10738473efb9c751056bac70280fe0a --- /dev/null +++ b/backend/GameServiceTest/Program.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using TerritoryGame.Application.Services; +using TerritoryGame.Domain.Entities; + +// 设置依赖注入 +var services = new ServiceCollection(); +services.AddScoped(); +var serviceProvider = services.BuildServiceProvider(); + +// 解析GameService +var gameService = serviceProvider.GetRequiredService(); +Console.WriteLine("成功解析GameService"); + +// 测试创建房间 +var room = gameService.CreateRoom("测试房间"); +Console.WriteLine($"成功创建房间: {room.Id} - {room.Name}"); +Console.WriteLine("测试成功!"); diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d7841b00037b9e719d0685c53c175916bbccc885 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,64 @@ +# 涂色抢地盘游戏 - 后端实现 + +## 项目概述 +这是一个基于ASP.NET Core的涂色抢地盘游戏后端系统,提供房间管理、游戏逻辑、实时通信等功能。 + +## 技术栈 +- ASP.NET Core 6.0 +- Entity Framework Core +- PostgreSQL +- Redis +- SignalR +- MediatR + +## 项目结构 +``` +TerritoryGame.sln +├── src/ +│ ├── TerritoryGame.Api/ # API项目 +│ │ ├── Controllers/ # API控制器 +│ │ ├── Hubs/ # SignalR Hubs +│ │ ├── Program.cs # 入口文件 +│ │ └── appsettings.json # 配置文件 +│ ├── TerritoryGame.Application/ # 应用服务 +│ │ └── Services/ # 服务实现 +│ ├── TerritoryGame.Domain/ # 领域层 +│ │ ├── Entities/ # 实体 +│ │ └── Services/ # 领域服务接口 +│ └── TerritoryGame.Infrastructure/ # 基础设施层 +│ ├── Data/ # 数据访问 +│ └── DependencyInjection.cs # 依赖注入配置 +└── tests/ # 测试项目 +``` + +## 功能模块 +1. **房间管理**:创建房间、加入房间、离开房间、获取可用房间 +2. **游戏管理**:开始游戏、结束游戏、处理涂色动作 +3. **玩家管理**:玩家信息、排名、面积计算 +4. **实时通信**:基于SignalR的实时消息推送 + +## 配置要求 +1. PostgreSQL数据库 +2. Redis服务器 + +## 启动指南 +1. 确保已安装PostgreSQL和Redis +2. 更新`appsettings.json`中的连接字符串 +3. 运行数据库迁移: + ``` + dotnet ef migrations add InitialCreate --project src/TerritoryGame.Infrastructure --startup-project src/TerritoryGame.Api + dotnet ef database update --project src/TerritoryGame.Infrastructure --startup-project src/TerritoryGame.Api + ``` +4. 启动应用程序: + ``` + dotnet run --project src/TerritoryGame.Api + ``` + +## API文档 +启动应用程序后,访问`http://localhost:5000/swagger`查看API文档。 + +## 注意事项 +- 本项目使用MediatR实现CQRS模式 +- 使用Redis缓存频繁访问的数据 +- 使用SignalR实现实时通信 +- 面积计算逻辑在实际应用中可能需要根据需求进行优化 \ No newline at end of file diff --git a/backend/SimpleDI/Program.cs b/backend/SimpleDI/Program.cs new file mode 100644 index 0000000000000000000000000000000000000000..29e3f3fbac444167237c41d63f897fb1b94efdc7 --- /dev/null +++ b/backend/SimpleDI/Program.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using TerritoryGame.Application.Services; + +class Program +{ + static void Main(string[] args) + { + Console.WriteLine("简单依赖注入测试"); + + // 创建服务集合 + var services = new ServiceCollection(); + + // 注册服务 + Console.WriteLine("注册IGameService..."); + services.AddScoped(); + + // 检查注册的服务 + Console.WriteLine("已注册的服务:"); + foreach (var service in services) + { + Console.WriteLine($"服务类型: {service.ServiceType.Name}"); + Console.WriteLine($"实现类型: {service.ImplementationType?.Name}"); + Console.WriteLine($"生命周期: {service.Lifetime}"); + Console.WriteLine("------------------------"); + } + + // 构建服务提供程序 + Console.WriteLine("构建服务提供程序..."); + try + { + var serviceProvider = services.BuildServiceProvider(); + Console.WriteLine("成功构建服务提供程序"); + + // 解析服务 + Console.WriteLine("尝试解析IGameService..."); + var gameService = serviceProvider.GetRequiredService(); + Console.WriteLine("成功解析IGameService"); + + // 测试服务功能 + var room = gameService.CreateRoom("测试房间"); + Console.WriteLine($"成功创建房间: {room.Name}, ID: {room.Id}"); + } + catch (Exception ex) + { + Console.WriteLine($"构建服务提供程序失败: {ex.Message}"); + Console.WriteLine(ex.StackTrace); + if (ex.InnerException != null) + { + Console.WriteLine($"内部异常: {ex.InnerException.Message}"); + Console.WriteLine(ex.InnerException.StackTrace); + } + } + } +} \ No newline at end of file diff --git a/backend/SimpleDI/SimpleDI.csproj b/backend/SimpleDI/SimpleDI.csproj new file mode 100644 index 0000000000000000000000000000000000000000..e783f0943aa2235148efd6acc20dc444a09ef4a3 --- /dev/null +++ b/backend/SimpleDI/SimpleDI.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + \ No newline at end of file diff --git a/backend/SimpleTest/Program.cs b/backend/SimpleTest/Program.cs new file mode 100644 index 0000000000000000000000000000000000000000..abaca28c6f53c2b8c64eb52322968fc0f2aaf87e --- /dev/null +++ b/backend/SimpleTest/Program.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.DependencyInjection; +using TerritoryGame.Application.Services; +using System; + +class Program +{ + static void Main(string[] args) + { + // 创建服务集合 + var services = new ServiceCollection(); + // 注册服务 + services.AddScoped(); + // 构建服务提供程序 + var serviceProvider = services.BuildServiceProvider(); + + // 测试依赖注入 + try + { + using var scope = serviceProvider.CreateScope(); + var gameService = scope.ServiceProvider.GetRequiredService(); + Console.WriteLine("成功解析IGameService"); + var room = gameService.CreateRoom("Test Room"); + Console.WriteLine($"成功创建房间: {room.Id}"); + } + catch (Exception ex) + { + Console.WriteLine("解析IGameService失败: " + ex.Message); + Console.WriteLine("异常堆栈: " + ex.StackTrace); + if (ex.InnerException != null) + { + Console.WriteLine("内部异常: " + ex.InnerException.Message); + Console.WriteLine("内部异常堆栈: " + ex.InnerException.StackTrace); + } + } + } +} \ No newline at end of file diff --git a/backend/SimpleTest/SimpleTest.csproj b/backend/SimpleTest/SimpleTest.csproj new file mode 100644 index 0000000000000000000000000000000000000000..e783f0943aa2235148efd6acc20dc444a09ef4a3 --- /dev/null +++ b/backend/SimpleTest/SimpleTest.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + \ No newline at end of file diff --git a/backend/TerritoryGame.Api/Program.cs b/backend/TerritoryGame.Api/Program.cs deleted file mode 100644 index 788187ef933dab3d647742c1632eceab1d6cbcdf..0000000000000000000000000000000000000000 --- a/backend/TerritoryGame.Api/Program.cs +++ /dev/null @@ -1,15 +0,0 @@ -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container. -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); -builder.Services.AddControllers(); -var app = builder.Build(); - - - -app.MapControllers(); - -app.Run(); - - diff --git a/backend/TerritoryGame.Api/TerritoryGame.Api.http b/backend/TerritoryGame.Api/TerritoryGame.Api.http deleted file mode 100644 index d6a785f3d30aa5c998a53e8708da8f80d464fd08..0000000000000000000000000000000000000000 --- a/backend/TerritoryGame.Api/TerritoryGame.Api.http +++ /dev/null @@ -1,6 +0,0 @@ -@TerritoryGame.Api_HostAddress = http://localhost:5029 - -GET {{TerritoryGame.Api_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/backend/TerritoryGame.Application/TerritoryGame.Application.csproj b/backend/TerritoryGame.Application/TerritoryGame.Application.csproj deleted file mode 100644 index 125f4c93bc98c2ee5a8520de3a0017a6c86f5047..0000000000000000000000000000000000000000 --- a/backend/TerritoryGame.Application/TerritoryGame.Application.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - net9.0 - enable - enable - - - diff --git a/backend/TerritoryGame.Infrastructure/TerritoryGame.Infrastructure.csproj b/backend/TerritoryGame.Infrastructure/TerritoryGame.Infrastructure.csproj deleted file mode 100644 index 125f4c93bc98c2ee5a8520de3a0017a6c86f5047..0000000000000000000000000000000000000000 --- a/backend/TerritoryGame.Infrastructure/TerritoryGame.Infrastructure.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - net9.0 - enable - enable - - - diff --git a/backend/TestDI/Program.cs b/backend/TestDI/Program.cs new file mode 100644 index 0000000000000000000000000000000000000000..8bfcce17a2d3e6a1d35fc34279e000d80b20340a --- /dev/null +++ b/backend/TestDI/Program.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using TerritoryGame.Application.Services; +using System; + +var builder = WebApplication.CreateBuilder(args); + +// 添加服务 +builder.Services.AddScoped(); +builder.Services.AddControllers(); + +builder.Logging.ClearProviders(); + +var app = builder.Build(); + +// 测试依赖注入 +try +{ + using var scope = app.Services.CreateScope(); + var serviceProvider = scope.ServiceProvider; + var gameService = serviceProvider.GetRequiredService(); + Console.WriteLine("成功解析IGameService"); + var room = gameService.CreateRoom("Test Room"); + Console.WriteLine($"成功创建房间: {room.Id}"); +} +catch (Exception ex) +{ + Console.WriteLine("解析IGameService失败: " + ex.Message); + Console.WriteLine("异常堆栈: " + ex.StackTrace); + if (ex.InnerException != null) + { + Console.WriteLine("内部异常: " + ex.InnerException.Message); + Console.WriteLine("内部异常堆栈: " + ex.InnerException.StackTrace); + } +} + +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/backend/TerritoryGame.Api/TerritoryGame.Api.csproj b/backend/TestDI/TestDI.csproj similarity index 53% rename from backend/TerritoryGame.Api/TerritoryGame.Api.csproj rename to backend/TestDI/TestDI.csproj index 653803b2e83c2065c96826ebfa3daeac709389df..299b09fa9146a9952133ff712bca3c63e8e31cfc 100644 --- a/backend/TerritoryGame.Api/TerritoryGame.Api.csproj +++ b/backend/TestDI/TestDI.csproj @@ -1,13 +1,13 @@ - net9.0 + net8.0 enable enable - + - + \ No newline at end of file diff --git a/backend/TestGameService.cs b/backend/TestGameService.cs new file mode 100644 index 0000000000000000000000000000000000000000..c53b18cabbb3674d006be3ed607603b3e6335a95 --- /dev/null +++ b/backend/TestGameService.cs @@ -0,0 +1,32 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using TerritoryGame.Application.Services; +using TerritoryGame.Domain.Entities; + +class Program +{ + static void Main(string[] args) + { + try + { + // 设置依赖注入 + var services = new ServiceCollection(); + services.AddScoped(); + var serviceProvider = services.BuildServiceProvider(); + + // 解析GameService + var gameService = serviceProvider.GetRequiredService(); + Console.WriteLine("成功解析GameService"); + + // 测试创建房间 + var room = gameService.CreateRoom("测试房间"); + Console.WriteLine($"成功创建房间: {room.Id} - {room.Name}"); + Console.WriteLine("测试成功!"); + } + catch (Exception ex) + { + Console.WriteLine($"测试失败: {ex.Message}"); + Console.WriteLine(ex.StackTrace); + } + } +} \ No newline at end of file diff --git a/backend/TestWebApi/Program.cs b/backend/TestWebApi/Program.cs new file mode 100644 index 0000000000000000000000000000000000000000..e99d5e912a2eb8ef07276d03538c5cebdc104a37 --- /dev/null +++ b/backend/TestWebApi/Program.cs @@ -0,0 +1,35 @@ +using TerritoryGame.Application.Services; + +var builder = WebApplication.CreateBuilder(args); + +// 添加服务到容器 +builder.Services.AddScoped(); + +var app = builder.Build(); + +// 尝试解析IGameService以验证依赖注入 +try +{ + using var scope = app.Services.CreateScope(); + var gameService = scope.ServiceProvider.GetRequiredService(); + Console.WriteLine("成功解析IGameService"); +} +catch (Exception ex) +{ + Console.WriteLine("解析IGameService失败: " + ex.Message); + Console.WriteLine("异常堆栈: " + ex.StackTrace); + if (ex.InnerException != null) + { + Console.WriteLine("内部异常: " + ex.InnerException.Message); + Console.WriteLine("内部异常堆栈: " + ex.InnerException.StackTrace); + } +} + +// 添加测试端点 +app.MapGet("/test", (IGameService gameService) => +{ + var room = gameService.CreateRoom("测试房间"); + return Results.Ok(new { message = "成功创建房间", roomId = room.Id, roomName = room.Name }); +}); + +app.Run(); diff --git a/backend/TestWebApi/Properties/launchSettings.json b/backend/TestWebApi/Properties/launchSettings.json new file mode 100644 index 0000000000000000000000000000000000000000..5b466ebb604192524526dd5f2fd122ac60731f41 --- /dev/null +++ b/backend/TestWebApi/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:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7064;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/backend/TestWebApi/TestWebApi.csproj b/backend/TestWebApi/TestWebApi.csproj new file mode 100644 index 0000000000000000000000000000000000000000..b43a30519b7c279f4e6235b568867ea72bf85d39 --- /dev/null +++ b/backend/TestWebApi/TestWebApi.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/backend/TerritoryGame.Api/appsettings.json b/backend/TestWebApi/appsettings.json similarity index 100% rename from backend/TerritoryGame.Api/appsettings.json rename to backend/TestWebApi/appsettings.json diff --git a/backend/src/TerritoryGame.Api/Controllers/GameController.cs b/backend/src/TerritoryGame.Api/Controllers/GameController.cs new file mode 100644 index 0000000000000000000000000000000000000000..ba33bf6e8c62a4e12a6bf91864c781ae36da6d69 --- /dev/null +++ b/backend/src/TerritoryGame.Api/Controllers/GameController.cs @@ -0,0 +1,280 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using TerritoryGame.Domain.Entities; +using TerritoryGame.Domain.Services; +using TerritoryGame.Api.Hubs; + +namespace TerritoryGame.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class GameController : ControllerBase +{ + private readonly IGameService _gameService; + private readonly IHubContext _hubContext; + + public GameController(IGameService gameService, IHubContext hubContext) + { + _gameService = gameService; + _hubContext = hubContext; + } + + // 创建房间 + [HttpPost("rooms")] + public async Task> CreateRoom([FromBody] CreateRoomRequest request) + { + var room = await _gameService.CreateRoomAsync( + request.RoomName, + request.Password, + request.MaxPlayers); + + return CreatedAtAction(nameof(GetRoom), new { roomId = room.Id }, room); + } + + // 获取房间 + [HttpGet("rooms/{roomId}")] + public async Task> GetRoom(Guid roomId) + { + try + { + var room = await _gameService.GetRoomByIdAsync(roomId); + return Ok(room); + } + catch (KeyNotFoundException ex) + { + return NotFound(ex.Message); + } + } + + // 加入房间 + [HttpPost("rooms/{roomId}/join")] + public async Task> JoinRoom(Guid roomId, [FromBody] JoinRoomRequest request) + { + try + { + var player = new Player + { + Id = Guid.NewGuid(), + NickName = request.NickName + }; + + var room = await _gameService.JoinRoomAsync(roomId, player); + + // 通知房间内其他玩家 + await _hubContext.Clients.Group(roomId.ToString()) + .SendAsync("PlayerJoined", player); + + return Ok(room); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + catch (KeyNotFoundException ex) + { + return NotFound(ex.Message); + } + } + + // 离开房间 + [HttpPost("rooms/{roomId}/leave")] + public async Task LeaveRoom(Guid roomId, [FromBody] LeaveRoomRequest request) + { + try + { + await _gameService.LeaveRoomAsync(roomId, request.PlayerId); + + // 通知房间内其他玩家 + await _hubContext.Clients.Group(roomId.ToString()) + .SendAsync("PlayerLeft", request.PlayerId); + + return NoContent(); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + catch (KeyNotFoundException ex) + { + return NotFound(ex.Message); + } + } + + // 获取可用房间 + [HttpGet("rooms/available")] + public async Task>> GetAvailableRooms() + { + var rooms = await _gameService.GetAvailableRoomsAsync(); + return Ok(rooms); + } + + // 开始游戏 + [HttpPost("rooms/{roomId}/start")] + public async Task StartGame(Guid roomId) + { + try + { + await _gameService.StartGameAsync(roomId); + + // 通知房间内所有玩家游戏开始 + await _hubContext.Clients.Group(roomId.ToString()) + .SendAsync("GameStarted"); + + return NoContent(); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + catch (KeyNotFoundException ex) + { + return NotFound(ex.Message); + } + } + + // 结束游戏 + [HttpPost("rooms/{roomId}/end")] + public async Task EndGame(Guid roomId) + { + try + { + await _gameService.EndGameAsync(roomId); + + // 通知房间内所有玩家游戏结束 + await _hubContext.Clients.Group(roomId.ToString()) + .SendAsync("GameEnded"); + + return NoContent(); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + catch (KeyNotFoundException ex) + { + return NotFound(ex.Message); + } + } + + // 处理涂色动作 + [HttpPost("rooms/{roomId}/paint")] + public async Task ProcessPaintAction(Guid roomId, [FromBody] PaintActionRequest request) + { + try + { + var action = new PaintAction + { + Id = Guid.NewGuid(), + RoomId = roomId, + PlayerId = request.PlayerId, + X = request.X, + Y = request.Y, + BrushSize = request.BrushSize, + Timestamp = DateTime.UtcNow + }; + + await _gameService.ProcessPaintActionAsync(roomId, action); + + // 广播涂色动作给房间内其他玩家 + await _hubContext.Clients.Group(roomId.ToString()) + .SendAsync("PaintAction", action); + + return NoContent(); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + catch (KeyNotFoundException ex) + { + return NotFound(ex.Message); + } + } + + // 获取房间中的玩家 + [HttpGet("rooms/{roomId}/players")] + public async Task>> GetPlayersInRoom(Guid roomId) + { + try + { + var players = await _gameService.GetPlayersInRoomAsync(roomId); + return Ok(players); + } + catch (KeyNotFoundException ex) + { + return NotFound(ex.Message); + } + } + + // 获取房间排名 + [HttpGet("rooms/{roomId}/ranking")] + public async Task>> GetRoomRanking(Guid roomId) + { + try + { + var ranking = await _gameService.GetRoomRankingAsync(roomId); + return Ok(ranking); + } + catch (KeyNotFoundException ex) + { + return NotFound(ex.Message); + } + } + + // 获取游戏状态 + [HttpGet("rooms/{roomId}/status")] + public async Task> GetGameStatus(Guid roomId) + { + try + { + var status = await _gameService.GetGameStatusAsync(roomId); + return Ok(status); + } + catch (KeyNotFoundException ex) + { + return NotFound(ex.Message); + } + } + + // 获取剩余时间 + [HttpGet("rooms/{roomId}/time-left")] + public async Task> GetTimeLeft(Guid roomId) + { + try + { + var timeLeft = await _gameService.GetTimeLeftAsync(roomId); + return Ok(timeLeft); + } + catch (KeyNotFoundException ex) + { + return NotFound(ex.Message); + } + } +} + +// 请求模型 +public class CreateRoomRequest +{ + public string RoomName { get; set; } = string.Empty; + public string? Password { get; set; } + public int MaxPlayers { get; set; } = 6; +} + +public class JoinRoomRequest +{ + public string NickName { get; set; } = string.Empty; +} + +public class LeaveRoomRequest +{ + public Guid PlayerId { get; set; } +} + +public class PaintActionRequest +{ + public Guid PlayerId { get; set; } + public int X { get; set; } + public int Y { get; set; } + public int BrushSize { get; set; } +} \ No newline at end of file diff --git a/backend/src/TerritoryGame.Api/Hubs/GameHub.cs b/backend/src/TerritoryGame.Api/Hubs/GameHub.cs new file mode 100644 index 0000000000000000000000000000000000000000..1845482979e409b728a304e589936866c52e9bc9 --- /dev/null +++ b/backend/src/TerritoryGame.Api/Hubs/GameHub.cs @@ -0,0 +1,330 @@ +using Microsoft.AspNetCore.SignalR; +using TerritoryGame.Domain.Entities; +using DomainServices = TerritoryGame.Domain.Services; +using System.Security.Claims; + +namespace TerritoryGame.Api.Hubs; + +public class GameHub : Hub +{ + private readonly DomainServices.IGameService _gameService; + + public GameHub(DomainServices.IGameService gameService) + { + _gameService = gameService; + } + // 获取房间列表 + public async Task GetRoomList() + { + try + { + var rooms = await _gameService.GetAvailableRoomsAsync(); + await Clients.Caller.SendAsync("RoomListUpdated", new { rooms }); + } + catch (Exception ex) + { + await Clients.Caller.SendAsync("GetRoomListFailed", new { success = false, message = ex.Message }); + } + } + + // 获取房间信息 + public async Task GetRoomInfo(string roomId) + { + try + { + var room = await _gameService.GetRoomByIdAsync(Guid.Parse(roomId)); + await Clients.Caller.SendAsync("RoomUpdated", room); + } + catch (Exception ex) + { + await Clients.Caller.SendAsync("GetRoomInfoFailed", new { success = false, message = ex.Message }); + } + } + + // 创建房间 + public async Task CreateRoom(dynamic parameters) + { + try + { + string roomName = parameters.name; + string? password = parameters.password; + int maxPlayers = parameters.maxPlayers; + int gameTime = parameters.gameTime; + string description = parameters.description; + string difficulty = parameters.difficulty; + string mapType = parameters.mapType; + bool allowSpectators = parameters.allowSpectators; + + // 这里可以添加对额外参数的处理逻辑 + // 例如保存描述、难度等信息到数据库 + + var room = await _gameService.CreateRoomAsync(roomName, password, maxPlayers, gameTime); + await Clients.Caller.SendAsync("RoomCreated", new { success = true, roomId = room.Id }); + // 优化性能:只在房间创建成功后通知其他客户端更新房间列表 + var rooms = await _gameService.GetAvailableRoomsAsync(); + await Clients.Others.SendAsync("RoomListUpdated", new { rooms }); + } + catch (Exception ex) + { + await Clients.Caller.SendAsync("CreateRoomFailed", new { success = false, message = ex.Message }); + } + } + + // 观战房间 + public async Task SpectateRoom(string roomId, string? password = null){ + try{ + var room = await _gameService.GetRoomByIdAsync(Guid.Parse(roomId)); + + // 验证密码 + if (!string.IsNullOrEmpty(room.Password) && room.Password != password){ + await Clients.Caller.SendAsync("OnSpectateRoomResult", new { success = false, message = "密码错误" }); + return; + } + + // 创建观战者 + var spectatorId = Guid.NewGuid(); + var spectator = new Spectator{ + Id = spectatorId, + NickName = "Spectator_" + spectatorId.ToString().Substring(0, 8), + RoomId = room.Id + }; + + // 存储观战者ID与连接ID的映射关系 + Context.Items["SpectatorId"] = spectatorId; + Context.Items["RoomId"] = room.Id; + + // 添加到房间组 + await Groups.AddToGroupAsync(Context.ConnectionId, roomId); + await Clients.Caller.SendAsync("OnSpectateRoomResult", new { success = true, roomId = room.Id }); + + // 通知房间内其他玩家有观战者加入 + await Clients.Group(roomId).SendAsync("SpectatorJoined", spectator); + + // 更新房间信息 + await _gameService.AddSpectatorAsync(room.Id, spectator); + }catch(Exception ex){ + await Clients.Caller.SendAsync("OnSpectateRoomResult", new { success = false, message = ex.Message }); + } + } + + // 加入房间 + public async Task JoinRoom(string roomId, string? password = null) + { + try + { + var room = await _gameService.GetRoomByIdAsync(Guid.Parse(roomId)); + + // 验证密码 + if (!string.IsNullOrEmpty(room.Password) && room.Password != password) + { + await Clients.Caller.SendAsync("OnJoinRoomResult", new { success = false, message = "密码错误" }); + return; + } + + // 创建玩家 + // 生成新的Guid作为玩家ID + var playerId = Guid.NewGuid(); + // 从Guid中提取一部分作为玩家名称的一部分,确保唯一性 + var uniqueSuffix = playerId.ToString().Substring(0, 8); + var player = new Player + { + Id = playerId, + NickName = "Player_" + uniqueSuffix, + RoomId = room.Id + }; + // 存储玩家ID与连接ID的映射关系 + Context.Items["PlayerId"] = playerId; + + // 加入房间 + room = await _gameService.JoinRoomAsync(room.Id, player); + await Groups.AddToGroupAsync(Context.ConnectionId, roomId); + await Clients.Caller.SendAsync("OnJoinRoomResult", new { success = true, roomId = room.Id }); + // 将连接ID存储在HttpContext.Items中,以便后续使用 + Context.Items["PlayerId"] = player.Id; + Context.Items["RoomId"] = room.Id; + await Clients.Group(roomId).SendAsync("PlayerJoined", player); + await Clients.Others.SendAsync("RoomListUpdated", await _gameService.GetAvailableRoomsAsync()); + } + catch (Exception ex) + { + await Clients.Caller.SendAsync("OnJoinRoomResult", new { success = false, message = ex.Message }); + } + } + + // 离开房间组 + public async Task LeaveRoom(string roomId) + { + try + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomId); + + // 从上下文获取玩家ID + if (Context.Items.TryGetValue("PlayerId", out var playerIdObj) && playerIdObj is Guid playerId) + { + // 通知其他玩家该玩家已离开 + await NotifyPlayerLeft(roomId, playerId); + + // 清除上下文项 + Context.Items.Remove("PlayerId"); + Context.Items.Remove("RoomId"); + } + + // 返回成功响应 + await Clients.Caller.SendAsync("LeaveRoomResult", new { success = true }); + } + catch (Exception ex) + { + await Clients.Caller.SendAsync("LeaveRoomResult", new { success = false, message = ex.Message }); + } + } + + // 发送聊天消息 + public async Task SendChatMessage(string roomId, string message) + { + try + { + // 从上下文获取玩家ID + if (Context.Items.TryGetValue("PlayerId", out var playerIdObj) && playerIdObj is Guid playerId) + { + // 创建聊天消息对象 + var chatMessage = new + { + PlayerId = playerId, + Content = message, + Timestamp = DateTime.UtcNow + }; + + // 广播消息给房间内的所有玩家 + await Clients.Group(roomId).SendAsync("ChatMessageReceived", chatMessage); + + // 返回成功响应 + await Clients.Caller.SendAsync("SendChatMessageResult", new { success = true }); + } + else + { + await Clients.Caller.SendAsync("SendChatMessageResult", new { success = false, message = "未找到玩家信息" }); + } + } + catch (Exception ex) + { + await Clients.Caller.SendAsync("SendChatMessageResult", new { success = false, message = ex.Message }); + } + } + + // 发送涂色动作 + public async Task SendPaintAction(PaintAction action) + { + await Clients.Group(action.RoomId.ToString()) + .SendAsync("PaintAction", action); + } + + // 玩家加入通知 + public async Task NotifyPlayerJoined(string roomId, Player player) + { + await Clients.Group(roomId) + .SendAsync("PlayerJoined", player); + } + + // 玩家离开通知 + public async Task NotifyPlayerLeft(string roomId, Guid playerId) + { + await Clients.Group(roomId) + .SendAsync("PlayerLeft", playerId); + } + + // 游戏开始通知 + public async Task NotifyGameStarted(string roomId) + { + await Clients.Group(roomId) + .SendAsync("GameStarted"); + } + + // 游戏结束通知 + public async Task NotifyGameEnded(string roomId) + { + await Clients.Group(roomId) + .SendAsync("GameEnded"); + } + + // 面积更新通知 + public async Task NotifyAreaUpdated(string roomId, Guid playerId, int area) + { + await Clients.Group(roomId) + .SendAsync("AreaUpdated", playerId, area); + } + + // 排名更新通知 + public async Task NotifyRankingUpdated(string roomId, List ranking) + { + await Clients.Group(roomId) + .SendAsync("RankingUpdated", ranking); + } + + // 获取排行榜 + public async Task GetLeaderboard(int topN = 10) + { + try + { + var leaderboard = await _gameService.GetLeaderboardAsync(topN); + await Clients.Caller.SendAsync("LeaderboardUpdated", leaderboard); + } + catch (Exception ex) + { + await Clients.Caller.SendAsync("GetLeaderboardFailed", new { success = false, message = ex.Message }); + } + } + + // 获取游戏统计 + public async Task GetGameStats() + { + try + { + var stats = await _gameService.GetGameStatsAsync(); + await Clients.Caller.SendAsync("GameStatsUpdated", stats); + } + catch (Exception ex) + { + await Clients.Caller.SendAsync("GetGameStatsFailed", new { success = false, message = ex.Message }); + } + } + + // 快速开始游戏 + public async Task QuickStartGame() + { + try + { + // 创建一个默认设置的房间 + var room = await _gameService.CreateRoomAsync("Quick Game", null, 6, 180); + + // 创建玩家 + var playerId = Guid.NewGuid(); + var uniqueSuffix = playerId.ToString().Substring(0, 8); + var player = new Player + { + Id = playerId, + NickName = "Player_" + uniqueSuffix, + RoomId = room.Id + }; + + // 存储玩家ID与连接ID的映射关系 + Context.Items["PlayerId"] = playerId; + + // 加入房间 + room = await _gameService.JoinRoomAsync(room.Id, player); + await Groups.AddToGroupAsync(Context.ConnectionId, room.Id.ToString()); + + // 发送快速开始结果 + await Clients.Caller.SendAsync("QuickStartResult", new { success = true, roomId = room.Id }); + + // 将连接ID存储在HttpContext.Items中 + Context.Items["PlayerId"] = player.Id; + Context.Items["RoomId"] = room.Id; + await Clients.Group(room.Id.ToString()).SendAsync("PlayerJoined", player); + await Clients.Others.SendAsync("RoomListUpdated", await _gameService.GetAvailableRoomsAsync()); + } + catch (Exception ex) + { + await Clients.Caller.SendAsync("QuickStartResult", new { success = false, message = ex.Message }); + } + } +} \ No newline at end of file diff --git a/backend/src/TerritoryGame.Api/Program.cs b/backend/src/TerritoryGame.Api/Program.cs new file mode 100644 index 0000000000000000000000000000000000000000..46e60c8464748df0efc9ec1b8fb09bf7570a8a4b --- /dev/null +++ b/backend/src/TerritoryGame.Api/Program.cs @@ -0,0 +1,79 @@ +using System.Reflection; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.SignalR; +using TerritoryGame.Infrastructure; +using TerritoryGame.Application; +using TerritoryGame.Domain; +using TerritoryGame.Domain.Services; +using TerritoryGame.Application.Services; +using TerritoryGame.Api.Hubs; +using TerritoryGame.Infrastructure.Data; +using Npgsql; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddInfrastructure(builder.Configuration); + +// Add MediatR + +// Add MediatR +builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)); +builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(TerritoryGame.Application.Class1).Assembly)); + +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new OpenApiInfo { Title = "TerritoryGame API", Version = "v1" }); +}); + +// Add SignalR +builder.Services.AddSignalR(); + +// Register application services +builder.Services.AddScoped(); + +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", builder => + { + builder.WithOrigins("http://localhost:5173", "http://localhost:5120") + .SetIsOriginAllowedToAllowWildcardSubdomains() + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials(); + }); +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "TerritoryGame API v1")); +} + +app.UseHttpsRedirection(); + +app.UseCors("AllowAll"); + +app.UseAuthorization(); + +app.MapControllers(); + +// Map SignalR hubs +app.MapHub("/gamehub"); + +app.Run(); + + + diff --git a/backend/TerritoryGame.Api/Properties/launchSettings.json b/backend/src/TerritoryGame.Api/Properties/launchSettings.json similarity index 80% rename from backend/TerritoryGame.Api/Properties/launchSettings.json rename to backend/src/TerritoryGame.Api/Properties/launchSettings.json index aac7f91916ff69e6962ac16f6984f986e9ec695e..cb1a2f7a10b5e5ab018dba8d00a257cd56c4365e 100644 --- a/backend/TerritoryGame.Api/Properties/launchSettings.json +++ b/backend/src/TerritoryGame.Api/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://localhost:5029", + "applicationUrl": "http://localhost:5120", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -14,7 +14,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "https://localhost:7290;http://localhost:5029", + "applicationUrl": "https://localhost:7068;http://localhost:5120", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/backend/src/TerritoryGame.Api/TerritoryGame.Api.csproj b/backend/src/TerritoryGame.Api/TerritoryGame.Api.csproj new file mode 100644 index 0000000000000000000000000000000000000000..6bd1a28e45dd12781cfaecf9ef343a274f592395 --- /dev/null +++ b/backend/src/TerritoryGame.Api/TerritoryGame.Api.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/backend/src/TerritoryGame.Api/TerritoryGame.Api.http b/backend/src/TerritoryGame.Api/TerritoryGame.Api.http new file mode 100644 index 0000000000000000000000000000000000000000..b24cbe7148ed570accef58aa0bd4c1c12e7a6d81 --- /dev/null +++ b/backend/src/TerritoryGame.Api/TerritoryGame.Api.http @@ -0,0 +1,71 @@ +@url = http://localhost:5120 + +### +# 测试天气预测端点 +GET {{url}}/weatherforecast/ +Accept: application/json + +### +# 测试创建房间端点 +POST {{url}}/api/Game/rooms +Content-Type: application/json + +{ + "roomName": "测试房间", + "password": null, + "maxPlayers": 4 +} + +### +# 测试加入房间端点 +@roomId = {{$guid}} +POST {{url}}/api/Game/rooms/3dfcbab9-3481-4d16-b445-a9bd6d9dae46/join +Content-Type: application/json + +{ + "nickName": "玩家1", + "connectionId": "conn1" +} + +### +# 测试获取房间信息端点 +GET {{url}}/api/Game/rooms/3dfcbab9-3481-4d16-b445-a9bd6d9dae46 +Accept: application/json + +### +# 测试开始游戏端点 +POST {{url}}/api/Game/rooms/3dfcbab9-3481-4d16-b445-a9bd6d9dae46/start +Accept: application/json + +### +# 测试绘制操作端点 +POST {{url}}/api/Game/rooms/3dfcbab9-3481-4d16-b445-a9bd6d9dae46/draw +Content-Type: application/json + +{ + "playerId": "{{$guid}}", + "x": 100, + "y": 100, + "color": "#FF0000" +} + +### +# 测试获取排行榜端点 +GET {{url}}/api/Game/rooms/3dfcbab9-3481-4d16-b445-a9bd6d9dae46/leaderboard +Accept: application/json + +### +# 测试获取所有房间端点 +GET {{url}}/api/Game/rooms +Accept: application/json + +### +# 测试退出房间端点 +POST {{url}}/api/Game/rooms/3dfcbab9-3481-4d16-b445-a9bd6d9dae46/leave +Content-Type: application/json + +{ + "playerId": "{{$guid}}" +} + +### diff --git a/backend/src/TerritoryGame.Api/appsettings.json b/backend/src/TerritoryGame.Api/appsettings.json new file mode 100644 index 0000000000000000000000000000000000000000..b96e1644ba602636da6c8f5ace1f53a3337b07a3 --- /dev/null +++ b/backend/src/TerritoryGame.Api/appsettings.json @@ -0,0 +1,13 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Host=39.105.7.196;Port=5432;Database=TerritoryGame;Username=postgres;Password=xyq0716", + "Redis": "39.105.7.196:6379" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/backend/TerritoryGame.Application/Class1.cs b/backend/src/TerritoryGame.Application/Class1.cs similarity index 100% rename from backend/TerritoryGame.Application/Class1.cs rename to backend/src/TerritoryGame.Application/Class1.cs diff --git a/backend/src/TerritoryGame.Application/Interfaces/IGameService.cs b/backend/src/TerritoryGame.Application/Interfaces/IGameService.cs new file mode 100644 index 0000000000000000000000000000000000000000..12e8b34a276022b4ba810e4e33e2836f9940a46e --- /dev/null +++ b/backend/src/TerritoryGame.Application/Interfaces/IGameService.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using TerritoryGame.Domain.Entities; + +namespace TerritoryGame.Application.Interfaces; + +public interface IGameService +{ + Task CreateRoomAsync(string name, string? password = null, int maxPlayers = 6, int gameDuration = 180); + Task JoinRoomAsync(Guid roomId, Player player); + Task LeaveRoomAsync(Guid roomId, Guid playerId); + Task> GetAvailableRoomsAsync(); + Task GetRoomByIdAsync(Guid roomId); + Task StartGameAsync(Guid roomId); + Task> GetPaintActionsAsync(Guid roomId); + Task ProcessPaintActionAsync(Guid roomId, PaintAction action); + Task AddSpectatorAsync(Guid roomId, Spectator spectator); + Task> GetLeaderboardAsync(int topN = 10); + Task GetGameStatsAsync(); +} diff --git a/backend/src/TerritoryGame.Application/Services/AreaCalculationService.cs b/backend/src/TerritoryGame.Application/Services/AreaCalculationService.cs new file mode 100644 index 0000000000000000000000000000000000000000..0eac94aea910bfcd3b36d136fc1c57f3592f1640 --- /dev/null +++ b/backend/src/TerritoryGame.Application/Services/AreaCalculationService.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore; +using TerritoryGame.Domain.Entities; +using TerritoryGame.Domain.Services; +using TerritoryGame.Domain.Interfaces; +using System.Collections.Generic; +using System.Linq; + +namespace TerritoryGame.Application.Services; + +public class AreaCalculationService : IAreaCalculationService +{ + private readonly IGameDbContext _context; + + public AreaCalculationService(IGameDbContext context) + { + _context = context; + } + + // 计算玩家的涂色面积 + public async Task CalculatePlayerAreaAsync(Guid roomId, Guid playerId) + { + // 获取玩家的所有涂色动作 + var paintActions = await _context.PaintActions + .Where(pa => pa.RoomId == roomId && pa.PlayerId == playerId) + .ToListAsync(); + + // 在实际应用中,这里应该有更复杂的面积计算逻辑 + // 例如,考虑重叠区域、边界计算等 + // 为了简化,我们假设每个涂色动作代表1个单位面积 + return paintActions.Count; + } + + // 批量计算所有玩家的面积 + public async Task> CalculateAllPlayersAreaAsync(Guid roomId) + { + // 获取房间内的所有玩家 + var room = await _context.GameRooms + .Include(r => r.Players) + .FirstOrDefaultAsync(r => r.Id == roomId); + + if (room == null) + { + throw new KeyNotFoundException($"Room with ID {roomId} not found"); + } + + var playerAreas = new Dictionary(); + + // 为每个玩家计算面积 + foreach (var player in room.Players) + { + var area = await CalculatePlayerAreaAsync(roomId, player.Id); + playerAreas[player.Id] = area; + } + + return playerAreas; + } + + // 更新面积统计 + public async Task UpdateAreaStatisticsAsync(Guid roomId) + { + var playerAreas = await CalculateAllPlayersAreaAsync(roomId); + + // 获取所有相关玩家 + var players = await _context.Players + .Where(p => p.RoomId == roomId) + .ToListAsync(); + + // 更新每个玩家的面积 + foreach (var player in players) + { + if (playerAreas.TryGetValue(player.Id, out var area)) + { + player.UpdateArea(area); + } + } + + await _context.SaveChangesAsync(); + } + +} \ No newline at end of file diff --git a/backend/src/TerritoryGame.Application/Services/GameService.cs b/backend/src/TerritoryGame.Application/Services/GameService.cs new file mode 100644 index 0000000000000000000000000000000000000000..a8451e779bbba4ab7bc00916a85738190032388c --- /dev/null +++ b/backend/src/TerritoryGame.Application/Services/GameService.cs @@ -0,0 +1,510 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Distributed; +using StackExchange.Redis; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using TerritoryGame.Domain.Entities; +using TerritoryGame.Domain.Interfaces; +using DomainServices = TerritoryGame.Domain.Services; + +namespace TerritoryGame.Application.Services; + +public class GameService : DomainServices.IGameService +{ + private readonly IGameDbContext _context; + private readonly IDistributedCache _cache; + private readonly DomainServices.IAreaCalculationService _areaCalculationService; + + public GameService( + IGameDbContext context, + IDistributedCache cache, + DomainServices.IAreaCalculationService areaCalculationService + ) + { + _context = context; + _cache = cache; + _areaCalculationService = areaCalculationService; + } + + // 创建房间 + public async Task CreateRoomAsync(string roomName, string? password = null, int maxPlayers = 6) + { + var room = new GameRoom + { + Name = roomName, + Password = password, + MaxPlayers = maxPlayers, + Status = GameStatus.Waiting, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + GameDuration = 180 // 默认3分钟 + }; + + _context.GameRooms.Add(room); + await _context.SaveChangesAsync(); + + try + { + // 缓存房间信息 + await CacheRoomAsync(room); + } + catch (RedisConnectionException ex) + { + // Redis连接失败,记录日志但不影响程序运行 + Console.WriteLine($"Redis connection error: {ex.Message}"); + } + + return room; + } + + // 创建房间(带游戏时间参数) + public async Task CreateRoomAsync(string roomName, string? password = null, int maxPlayers = 6, int gameTime = 180) + { + var room = new GameRoom + { + Name = roomName, + Password = password, + MaxPlayers = maxPlayers, + Status = GameStatus.Waiting, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + GameDuration = gameTime + }; + + _context.GameRooms.Add(room); + await _context.SaveChangesAsync(); + + try + { + // 缓存房间信息 + await CacheRoomAsync(room); + } + catch (RedisConnectionException ex) + { + // Redis连接失败,记录日志但不影响程序运行 + Console.WriteLine($"Redis connection error: {ex.Message}"); + } + + return room; + } + + // 获取可用房间 + public async Task> GetAvailableRoomsAsync() + { + try + { + // 先从缓存获取 + var cachedRooms = await _cache.GetStringAsync("available_rooms"); + if (!string.IsNullOrEmpty(cachedRooms)) + { + return JsonSerializer.Deserialize>(cachedRooms); + } + + // 缓存未命中,从数据库获取 + var rooms = await _context.GameRooms + .Include(r => r.Players) + .Include(r => r.Spectators) + .Where(r => r.Status == GameStatus.Waiting) + .OrderByDescending(r => r.CreatedAt) + .ToListAsync(); + + // 更新缓存 + try + { + await _cache.SetStringAsync( + "available_rooms", + JsonSerializer.Serialize(rooms), + new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromSeconds(10) } + ); + } + catch (RedisConnectionException ex) + { + Console.WriteLine($"Redis connection error: {ex.Message}"); + } + + return rooms; + } + catch (Exception ex) + { + Console.WriteLine($"Error getting available rooms: {ex.Message}"); + throw; + } + } + + // 获取房间 + public async Task GetRoomByIdAsync(Guid roomId) + { + try + { + // 先从缓存获取 + var cachedRoom = await _cache.GetStringAsync($"room:{roomId}"); + if (!string.IsNullOrEmpty(cachedRoom)) + { + return JsonSerializer.Deserialize(cachedRoom)! + ?? throw new InvalidOperationException("Failed to deserialize cached room"); + } + } + catch (RedisConnectionException ex) + { + // Redis连接失败,记录日志并继续从数据库获取 + Console.WriteLine($"Redis connection error: {ex.Message}"); + } + + // 从数据库获取 + var room = await _context.GameRooms + .Include(r => r.Players) + .FirstOrDefaultAsync(r => r.Id == roomId); + + if (room == null) + { + throw new KeyNotFoundException($"Room with ID {roomId} not found"); + } + + try + { + // 缓存房间信息 + await CacheRoomAsync(room); + } + catch (RedisConnectionException ex) + { + // Redis连接失败,记录日志但不影响程序运行 + Console.WriteLine($"Redis connection error: {ex.Message}"); + } + + return room; + } + + // 加入房间 + public async Task JoinRoomAsync(Guid roomId, Player player) + { + var room = await GetRoomByIdAsync(roomId); + + // 检查房间是否已满 + if (room.Players.Count >= room.MaxPlayers) + { + throw new InvalidOperationException("Room is full"); + } + + // 检查玩家是否已在房间中 + if (room.Players.Any(p => p.Id == player.Id)) + { + throw new InvalidOperationException("Player is already in the room"); + } + + // 添加玩家到房间 + room.AddPlayer(player); + _context.Players.Add(player); + await _context.SaveChangesAsync(); + + // 缓存更新后的房间信息 + await CacheRoomAsync(room); + + return room; + } + + // 添加观战者 + public async Task AddSpectatorAsync(Guid roomId, Spectator spectator) + { + var room = await GetRoomByIdAsync(roomId); + + // 检查观战者是否已在房间中 + if (room.Spectators.Any(s => s.Id == spectator.Id)) + { + throw new InvalidOperationException("Spectator is already in the room"); + } + + // 添加观战者到房间 + room.AddSpectator(spectator); + _context.Spectators.Add(spectator); + await _context.SaveChangesAsync(); + + // 缓存更新后的房间信息 + await CacheRoomAsync(room); + + return room; + } + + // 离开房间 + public async Task LeaveRoomAsync(Guid roomId, Guid playerId) + { + var room = await GetRoomByIdAsync(roomId); + + // 移除玩家 + room.RemovePlayer(playerId); + + // 如果房间为空,删除房间 + if (room.Players.Count == 0) + { + _context.GameRooms.Remove(room); + } + else + { + await _context.SaveChangesAsync(); + // 缓存更新后的房间信息 + await CacheRoomAsync(room); + } + + // 从数据库删除玩家 + var player = await _context.Players.FindAsync(playerId); + if (player != null) + { + _context.Players.Remove(player); + await _context.SaveChangesAsync(); + } + + // 如果游戏正在进行且房间玩家少于2人,结束游戏 + if (room.Status == GameStatus.Playing && room.Players.Count < 2) + { + await EndGameAsync(roomId); + } + } + + // 开始游戏 + public async Task StartGameAsync(Guid roomId) + { + var room = await GetRoomByIdAsync(roomId); + + // 检查是否可以开始游戏 + if (room.Status != GameStatus.Waiting) + { + throw new InvalidOperationException("Game is not in waiting state"); + } + + if (room.Players.Count < 2) + { + throw new InvalidOperationException("Not enough players to start the game"); + } + + // 开始游戏 + room.StartGame(); + await _context.SaveChangesAsync(); + + // 分配玩家颜色 + AssignPlayerColors(room); + await _context.SaveChangesAsync(); + + // 缓存更新后的房间信息 + await CacheRoomAsync(room); + } + + // 结束游戏 + public async Task EndGameAsync(Guid roomId) + { + var room = await GetRoomByIdAsync(roomId); + + // 检查是否可以结束游戏 + if (room.Status != GameStatus.Playing) + { + throw new InvalidOperationException("Game is not in playing state"); + } + + // 结束游戏 + room.EndGame(); + await _context.SaveChangesAsync(); + + // 计算最终面积 + await _areaCalculationService.UpdateAreaStatisticsAsync(roomId); + + // 更新玩家排名 + await UpdatePlayerRankingAsync(roomId); + + // 缓存更新后的房间信息 + await CacheRoomAsync(room); + } + + // 获取涂色动作 + public async Task> GetPaintActionsAsync(Guid roomId) + { + var room = await GetRoomByIdAsync(roomId); + + return await _context.PaintActions + .Where(a => a.RoomId == roomId) + .OrderBy(a => a.Timestamp) + .ToListAsync(); + } + + // 处理涂色动作 + public async Task ProcessPaintActionAsync(Guid roomId, PaintAction action) + { + var room = await GetRoomByIdAsync(roomId); + + // 检查游戏是否在进行中 + if (room.Status != GameStatus.Playing) + { + throw new InvalidOperationException("Game is not in playing state"); + } + + // 检查玩家是否在房间中 + if (!room.Players.Any(p => p.Id == action.PlayerId)) + { + throw new InvalidOperationException("Player is not in the room"); + } + + // 保存涂色动作 + _context.PaintActions.Add(action); + await _context.SaveChangesAsync(); + + // 更新玩家面积 + await _areaCalculationService.UpdateAreaStatisticsAsync(roomId); + } + + // 获取房间中的玩家 + public async Task> GetPlayersInRoomAsync(Guid roomId) + { + var room = await GetRoomByIdAsync(roomId); + return room.Players; + } + + // 更新玩家面积 + public async Task UpdatePlayerAreaAsync(Guid roomId, Guid playerId, int area) + { + var room = await GetRoomByIdAsync(roomId); + var player = room.Players.FirstOrDefault(p => p.Id == playerId); + + if (player == null) + { + throw new KeyNotFoundException($"Player with ID {playerId} not found in room {roomId}"); + } + + player.UpdateArea(area); + await _context.SaveChangesAsync(); + + // 缓存更新后的房间信息 + await CacheRoomAsync(room); + } + + // 获取房间排名 + public async Task> GetRoomRankingAsync(Guid roomId) + { + var room = await GetRoomByIdAsync(roomId); + return room.Players + .OrderByDescending(p => p.Area) + .ToList(); + } + + // 获取游戏状态 + public async Task GetGameStatusAsync(Guid roomId) + { + var room = await GetRoomByIdAsync(roomId); + return room.Status; + } + + // 获取剩余时间 + public async Task GetTimeLeftAsync(Guid roomId) + { + var room = await GetRoomByIdAsync(roomId); + + if (room.Status != GameStatus.Playing || room.StartTime == null) + { + return 0; + } + + var elapsed = (DateTime.UtcNow - room.StartTime.Value).TotalSeconds; + var timeLeft = room.GameDuration - (int)elapsed; + + return Math.Max(0, timeLeft); + } + + // 缓存房间信息 + private async Task CacheRoomAsync(GameRoom room) + { + try + { + var options = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) + }; + + var roomJson = JsonSerializer.Serialize(room); + await _cache.SetStringAsync($"room:{room.Id}", roomJson, options); + } + catch (RedisConnectionException ex) + { + // Redis连接失败,记录日志但不影响程序运行 + Console.WriteLine($"Redis connection error: {ex.Message}"); + } + } + + // 分配玩家颜色 + private void AssignPlayerColors(GameRoom room) + { + var colors = new List + { + "#FF0000", // 红色 + "#00FF00", // 绿色 + "#0000FF", // 蓝色 + "#FFFF00", // 黄色 + "#FF00FF", // 紫色 + "#00FFFF" // 青色 + }; + + // 随机打乱颜色 + var random = new Random(); + colors = colors.OrderBy(c => random.Next()).ToList(); + + // 分配颜色给玩家 + for (int i = 0; i < room.Players.Count; i++) + { + room.Players[i].Color = colors[i % colors.Count]; + } + } + + // 更新玩家排名 + private async Task UpdatePlayerRankingAsync(Guid roomId) + { + var room = await GetRoomByIdAsync(roomId); + var rankedPlayers = room.Players + .OrderByDescending(p => p.Area) + .ToList(); + + for (int i = 0; i < rankedPlayers.Count; i++) + { + rankedPlayers[i].UpdateRank(i + 1); + } + + await _context.SaveChangesAsync(); + await CacheRoomAsync(room); + } + + public async Task> GetLeaderboardAsync(int topN = 10) + { + // 获取所有玩家并按面积排序,取前topN名 + var leaderboard = await _context.Players + .OrderByDescending(p => p.Area) + .Take(topN) + .ToListAsync(); + + return leaderboard; + } + + public async Task GetGameStatsAsync() + { + // 计算游戏统计数据 + var totalRooms = await _context.GameRooms.CountAsync(); + var activeRooms = await _context.GameRooms.CountAsync(r => r.Status == GameStatus.Playing); + var totalPlayers = await _context.Players.CountAsync(); + var onlinePlayers = await _context.Players.CountAsync(p => p.IsOnline); + var totalGamesPlayed = await _context.GameRooms.CountAsync(r => r.Status == GameStatus.Finished); + + // 计算平均游戏时长(仅针对已完成的游戏) + double averageDuration = 0; + if (totalGamesPlayed > 0) + { + averageDuration = await _context.GameRooms + .Where(r => r.Status == GameStatus.Finished && r.StartTime.HasValue && r.EndTime.HasValue) + .Select(r => (r.EndTime.Value - r.StartTime.Value).TotalSeconds) + .AverageAsync(); + } + + return new GameStats + { + TotalRooms = totalRooms, + ActiveRooms = activeRooms, + TotalPlayers = totalPlayers, + OnlinePlayers = onlinePlayers, + TotalGamesPlayed = totalGamesPlayed, + AverageGameDuration = averageDuration + }; + } +} \ No newline at end of file diff --git a/backend/src/TerritoryGame.Application/TerritoryGame.Application.csproj b/backend/src/TerritoryGame.Application/TerritoryGame.Application.csproj new file mode 100644 index 0000000000000000000000000000000000000000..9035326edb4313032336b52fa5b69cf8a43fc46c --- /dev/null +++ b/backend/src/TerritoryGame.Application/TerritoryGame.Application.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + net8.0 + enable + enable + + + diff --git a/backend/TerritoryGame.Domain/Class1.cs b/backend/src/TerritoryGame.Domain/Class1.cs similarity index 100% rename from backend/TerritoryGame.Domain/Class1.cs rename to backend/src/TerritoryGame.Domain/Class1.cs diff --git a/backend/src/TerritoryGame.Domain/Entities/GameRoom.cs b/backend/src/TerritoryGame.Domain/Entities/GameRoom.cs new file mode 100644 index 0000000000000000000000000000000000000000..4eba143d9963df55e327d9f0f7cbe9b298b907ea --- /dev/null +++ b/backend/src/TerritoryGame.Domain/Entities/GameRoom.cs @@ -0,0 +1,96 @@ +namespace TerritoryGame.Domain.Entities; + +public enum GameStatus +{ + Waiting, + Playing, + Finished +} + +public class GameRoom +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public string? Password { get; set; } + public int MaxPlayers { get; set; } = 6; + public int CurrentPlayers => Players.Count; + public int SpectatorsCount => Spectators.Count; + public GameStatus Status { get; set; } = GameStatus.Waiting; + public int GameDuration { get; set; } = 180; // 3分钟 + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public List Players { get; set; } = new List(); + public List Spectators { get; set; } = new List(); + public DateTime? StartTime { get; set; } + public DateTime? EndTime { get; set; } + + // 添加玩家 + public void AddPlayer(Player player) + { + if (Players.Count >= MaxPlayers) + throw new InvalidOperationException("房间已满"); + + if (Players.Any(p => p.Id == player.Id)) + throw new InvalidOperationException("玩家已在房间中"); + + Players.Add(player); + UpdatedAt = DateTime.UtcNow; + } + + // 移除玩家 + public void RemovePlayer(Guid playerId) + { + var player = Players.FirstOrDefault(p => p.Id == playerId); + if (player != null) + { + Players.Remove(player); + UpdatedAt = DateTime.UtcNow; + } + } + + // 添加观战者 + public void AddSpectator(Spectator spectator) + { + if (Spectators.Any(s => s.Id == spectator.Id)) + throw new InvalidOperationException("观战者已在房间中"); + + Spectators.Add(spectator); + UpdatedAt = DateTime.UtcNow; + } + + // 移除观战者 + public void RemoveSpectator(Guid spectatorId) + { + var spectator = Spectators.FirstOrDefault(s => s.Id == spectatorId); + if (spectator != null) + { + Spectators.Remove(spectator); + UpdatedAt = DateTime.UtcNow; + } + } + + // 开始游戏 + public void StartGame() + { + if (Players.Count < 2) + throw new InvalidOperationException("至少需要2名玩家才能开始游戏"); + + if (Status != GameStatus.Waiting) + throw new InvalidOperationException("游戏已经开始或已结束"); + + Status = GameStatus.Playing; + StartTime = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + } + + // 结束游戏 + public void EndGame() + { + if (Status != GameStatus.Playing) + throw new InvalidOperationException("游戏尚未开始或已结束"); + + Status = GameStatus.Finished; + EndTime = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/backend/src/TerritoryGame.Domain/Entities/GameStats.cs b/backend/src/TerritoryGame.Domain/Entities/GameStats.cs new file mode 100644 index 0000000000000000000000000000000000000000..89cacb9ff511114b5c8867005ce26e7fdd016c33 --- /dev/null +++ b/backend/src/TerritoryGame.Domain/Entities/GameStats.cs @@ -0,0 +1,11 @@ +namespace TerritoryGame.Domain.Entities; + +public class GameStats +{ + public int TotalRooms { get; set; } + public int ActiveRooms { get; set; } + public int TotalPlayers { get; set; } + public int OnlinePlayers { get; set; } + public int TotalGamesPlayed { get; set; } + public double AverageGameDuration { get; set; } +} \ No newline at end of file diff --git a/backend/src/TerritoryGame.Domain/Entities/PaintAction.cs b/backend/src/TerritoryGame.Domain/Entities/PaintAction.cs new file mode 100644 index 0000000000000000000000000000000000000000..9ecd7e89225bc7f1b4f95c976b216360ff6642d7 --- /dev/null +++ b/backend/src/TerritoryGame.Domain/Entities/PaintAction.cs @@ -0,0 +1,25 @@ +namespace TerritoryGame.Domain.Entities; + +public class PaintAction +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid RoomId { get; set; } + public Guid PlayerId { get; set; } + public int X { get; set; } + public int Y { get; set; } + public int BrushSize { get; set; } = 10; + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + // 无参数构造函数 + public PaintAction() {} + + // 构造函数 + public PaintAction(Guid roomId, Guid playerId, int x, int y, int brushSize) + { + RoomId = roomId; + PlayerId = playerId; + X = x; + Y = y; + BrushSize = brushSize; + } +} \ No newline at end of file diff --git a/backend/src/TerritoryGame.Domain/Entities/Player.cs b/backend/src/TerritoryGame.Domain/Entities/Player.cs new file mode 100644 index 0000000000000000000000000000000000000000..69b61a43429bc30a7a3804c7c873e81eabc8a827 --- /dev/null +++ b/backend/src/TerritoryGame.Domain/Entities/Player.cs @@ -0,0 +1,37 @@ +namespace TerritoryGame.Domain.Entities; + +public class Player +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string NickName { get; set; } = string.Empty; + public string Color { get; set; } = string.Empty; + public int Area { get; set; } = 0; + public int Rank { get; set; } = 0; + public DateTime JoinedAt { get; set; } = DateTime.UtcNow; + public bool IsOnline { get; set; } = true; + public Guid RoomId { get; set; } + + // 更新玩家面积 + public void UpdateArea(int newArea) + { + Area = newArea; + } + + // 更新玩家排名 + public void UpdateRank(int newRank) + { + Rank = newRank; + } + + // 设置玩家离线 + public void GoOffline() + { + IsOnline = false; + } + + // 设置玩家在线 + public void GoOnline() + { + IsOnline = true; + } +} \ No newline at end of file diff --git a/backend/src/TerritoryGame.Domain/Entities/Spectator.cs b/backend/src/TerritoryGame.Domain/Entities/Spectator.cs new file mode 100644 index 0000000000000000000000000000000000000000..6eadf5330a268f7cb82adc254b5810c3ae14972e --- /dev/null +++ b/backend/src/TerritoryGame.Domain/Entities/Spectator.cs @@ -0,0 +1,17 @@ +namespace TerritoryGame.Domain.Entities; + +public class Spectator : Player +{ + // 观战者特有的属性可以在这里添加 + + // 重写父类方法(如果需要) + public new void GoOffline() + { + IsOnline = false; + } + + public new void GoOnline() + { + IsOnline = true; + } +} \ No newline at end of file diff --git a/backend/src/TerritoryGame.Domain/Interfaces/IGameDbContext.cs b/backend/src/TerritoryGame.Domain/Interfaces/IGameDbContext.cs new file mode 100644 index 0000000000000000000000000000000000000000..ea40f9ca5689aadcee81b648892690ce946dd6e7 --- /dev/null +++ b/backend/src/TerritoryGame.Domain/Interfaces/IGameDbContext.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using TerritoryGame.Domain.Entities; + +namespace TerritoryGame.Domain.Interfaces; + +public interface IGameDbContext +{ + DbSet GameRooms { get; set; } + DbSet Players { get; set; } + DbSet PaintActions { get; set; } + DbSet Spectators { get; set; } + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/backend/src/TerritoryGame.Domain/Services/IAreaCalculationService.cs b/backend/src/TerritoryGame.Domain/Services/IAreaCalculationService.cs new file mode 100644 index 0000000000000000000000000000000000000000..0c0855b120bb7787a006ec548c445270b02ec27b --- /dev/null +++ b/backend/src/TerritoryGame.Domain/Services/IAreaCalculationService.cs @@ -0,0 +1,13 @@ +namespace TerritoryGame.Domain.Services; + +public interface IAreaCalculationService +{ + // 计算玩家在房间中的涂色面积 + Task CalculatePlayerAreaAsync(Guid roomId, Guid playerId); + + // 批量计算所有玩家的面积 + Task> CalculateAllPlayersAreaAsync(Guid roomId); + + // 更新面积统计 + Task UpdateAreaStatisticsAsync(Guid roomId); +} \ No newline at end of file diff --git a/backend/src/TerritoryGame.Domain/Services/IGameService.cs b/backend/src/TerritoryGame.Domain/Services/IGameService.cs new file mode 100644 index 0000000000000000000000000000000000000000..e237c578223c637267963c6b928e5a0aedc8cd4c --- /dev/null +++ b/backend/src/TerritoryGame.Domain/Services/IGameService.cs @@ -0,0 +1,33 @@ +using TerritoryGame.Domain.Entities; + +namespace TerritoryGame.Domain.Services; + +public interface IGameService +{ + // 房间管理 + Task CreateRoomAsync(string roomName, string? password = null, int maxPlayers = 6); + Task CreateRoomAsync(string roomName, string? password = null, int maxPlayers = 6, int gameTime = 180); + Task GetRoomByIdAsync(Guid roomId); + Task JoinRoomAsync(Guid roomId, Player player); + Task LeaveRoomAsync(Guid roomId, Guid playerId); + Task> GetAvailableRoomsAsync(); + + // 游戏管理 + Task StartGameAsync(Guid roomId); + Task EndGameAsync(Guid roomId); + Task ProcessPaintActionAsync(Guid roomId, PaintAction action); + Task> GetPlayersInRoomAsync(Guid roomId); + Task UpdatePlayerAreaAsync(Guid roomId, Guid playerId, int area); + Task> GetRoomRankingAsync(Guid roomId); + + // 游戏状态 + Task GetGameStatusAsync(Guid roomId); + Task GetTimeLeftAsync(Guid roomId); + + // 观战者管理 + Task AddSpectatorAsync(Guid roomId, Spectator spectator); + + // 统计和排行榜 + Task> GetLeaderboardAsync(int topN = 10); + Task GetGameStatsAsync(); +} \ No newline at end of file diff --git a/backend/TerritoryGame.Domain/TerritoryGame.Domain.csproj b/backend/src/TerritoryGame.Domain/TerritoryGame.Domain.csproj similarity index 39% rename from backend/TerritoryGame.Domain/TerritoryGame.Domain.csproj rename to backend/src/TerritoryGame.Domain/TerritoryGame.Domain.csproj index 125f4c93bc98c2ee5a8520de3a0017a6c86f5047..3e657bd11df0a3f0282a46e58457645ad3d5dfed 100644 --- a/backend/TerritoryGame.Domain/TerritoryGame.Domain.csproj +++ b/backend/src/TerritoryGame.Domain/TerritoryGame.Domain.csproj @@ -1,9 +1,13 @@ - + - net9.0 + net8.0 enable enable + + + + diff --git a/backend/TerritoryGame.Infrastructure/Class1.cs b/backend/src/TerritoryGame.Infrastructure/Class1.cs similarity index 100% rename from backend/TerritoryGame.Infrastructure/Class1.cs rename to backend/src/TerritoryGame.Infrastructure/Class1.cs diff --git a/backend/src/TerritoryGame.Infrastructure/Data/GameDbContext.cs b/backend/src/TerritoryGame.Infrastructure/Data/GameDbContext.cs new file mode 100644 index 0000000000000000000000000000000000000000..c83184e49b225f7958f1e737257f5aed525e2413 --- /dev/null +++ b/backend/src/TerritoryGame.Infrastructure/Data/GameDbContext.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using TerritoryGame.Domain.Entities; +using TerritoryGame.Domain.Interfaces; + +namespace TerritoryGame.Infrastructure.Data; + +public class GameDbContext : DbContext, IGameDbContext +{ + public GameDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet GameRooms { get; set; } + public DbSet Players { get; set; } + public DbSet PaintActions { get; set; } + public DbSet Spectators { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // 配置GameRoom + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(100); + entity.Property(e => e.Password).HasMaxLength(100); + entity.Property(e => e.MaxPlayers).IsRequired(); + entity.Property(e => e.Status).IsRequired(); + entity.Property(e => e.CreatedAt).IsRequired(); + entity.Property(e => e.UpdatedAt).IsRequired(); + entity.Property(e => e.GameDuration).IsRequired(); + + // 配置关系 + entity.HasMany(e => e.Players) + .WithOne() + .HasForeignKey(p => p.RoomId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(e => e.Spectators) + .WithOne() + .HasForeignKey(s => s.RoomId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // 配置Player + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.NickName).IsRequired().HasMaxLength(50); + entity.Property(e => e.Color).IsRequired().HasMaxLength(20); + entity.Property(e => e.Area).IsRequired(); + entity.Property(e => e.Rank).IsRequired(); + entity.ToTable("Players"); // 明确指定表名 + }); + + // 配置Spectator (继承自Player) + modelBuilder.Entity(entity => + { + entity.HasBaseType(); // 明确指定基类 + entity.ToTable("Spectators"); // 明确指定表名 + // 这里可以配置Spectator特有的属性(如果有) + }); + + // 配置PaintAction + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.RoomId).IsRequired(); + entity.Property(e => e.PlayerId).IsRequired(); + entity.Property(e => e.X).IsRequired(); + entity.Property(e => e.Y).IsRequired(); + entity.Property(e => e.BrushSize).IsRequired(); + entity.Property(e => e.Timestamp).IsRequired(); + }); + } +} \ No newline at end of file diff --git a/backend/src/TerritoryGame.Infrastructure/DependencyInjection.cs b/backend/src/TerritoryGame.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000000000000000000000000000000000000..2801080b8ba5d08db30748361ac566cabe718165 --- /dev/null +++ b/backend/src/TerritoryGame.Infrastructure/DependencyInjection.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Caching.StackExchangeRedis; +using TerritoryGame.Application.Services; +using TerritoryGame.Application.Interfaces; +using TerritoryGame.Domain.Services; +using TerritoryGame.Domain.Interfaces; +using TerritoryGame.Infrastructure.Data; +using TerritoryGame.Infrastructure.Services; + +namespace TerritoryGame.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + // 添加数据库上下文 + services.AddDbContext(options => + options.UseNpgsql(configuration.GetConnectionString("DefaultConnection"))); + + // 注册DbContext接口 + services.AddScoped(); + + // 添加缓存 + services.AddStackExchangeRedisCache(options => + { + options.Configuration = configuration.GetConnectionString("Redis"); + options.InstanceName = "TerritoryGame_"; + }); + + // 注册服务 + services.AddScoped(); + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/backend/src/TerritoryGame.Infrastructure/Migrations/20250818033155_InitialCreate.Designer.cs b/backend/src/TerritoryGame.Infrastructure/Migrations/20250818033155_InitialCreate.Designer.cs new file mode 100644 index 0000000000000000000000000000000000000000..fd64511dfd0cb66443e8a9146b7227396b7c91dc --- /dev/null +++ b/backend/src/TerritoryGame.Infrastructure/Migrations/20250818033155_InitialCreate.Designer.cs @@ -0,0 +1,152 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TerritoryGame.Infrastructure.Data; + +#nullable disable + +namespace TerritoryGame.Infrastructure.Migrations +{ + [DbContext(typeof(GameDbContext))] + [Migration("20250818033155_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.GameRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("GameDuration") + .HasColumnType("integer"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Password") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("GameRooms"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.PaintAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BrushSize") + .HasColumnType("integer"); + + b.Property("PlayerId") + .HasColumnType("uuid"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("PaintActions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Area") + .HasColumnType("integer"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("IsOnline") + .HasColumnType("boolean"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NickName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.Player", b => + { + b.HasOne("TerritoryGame.Domain.Entities.GameRoom", null) + .WithMany("Players") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.GameRoom", b => + { + b.Navigation("Players"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TerritoryGame.Infrastructure/Migrations/20250818033155_InitialCreate.cs b/backend/src/TerritoryGame.Infrastructure/Migrations/20250818033155_InitialCreate.cs new file mode 100644 index 0000000000000000000000000000000000000000..8cdd16695dd6d16c2de5ce7a2da8e7b9a73dd043 --- /dev/null +++ b/backend/src/TerritoryGame.Infrastructure/Migrations/20250818033155_InitialCreate.cs @@ -0,0 +1,94 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TerritoryGame.Infrastructure.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "GameRooms", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Password = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + MaxPlayers = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "integer", nullable: false), + GameDuration = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + StartTime = table.Column(type: "timestamp with time zone", nullable: true), + EndTime = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_GameRooms", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PaintActions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RoomId = table.Column(type: "uuid", nullable: false), + PlayerId = table.Column(type: "uuid", nullable: false), + X = table.Column(type: "integer", nullable: false), + Y = table.Column(type: "integer", nullable: false), + BrushSize = table.Column(type: "integer", nullable: false), + Timestamp = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PaintActions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Players", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + NickName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Color = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Area = table.Column(type: "integer", nullable: false), + Rank = table.Column(type: "integer", nullable: false), + JoinedAt = table.Column(type: "timestamp with time zone", nullable: false), + IsOnline = table.Column(type: "boolean", nullable: false), + RoomId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Players", x => x.Id); + table.ForeignKey( + name: "FK_Players_GameRooms_RoomId", + column: x => x.RoomId, + principalTable: "GameRooms", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Players_RoomId", + table: "Players", + column: "RoomId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PaintActions"); + + migrationBuilder.DropTable( + name: "Players"); + + migrationBuilder.DropTable( + name: "GameRooms"); + } + } +} diff --git a/backend/src/TerritoryGame.Infrastructure/Migrations/20250819033027_AddSpectatorTable.Designer.cs b/backend/src/TerritoryGame.Infrastructure/Migrations/20250819033027_AddSpectatorTable.Designer.cs new file mode 100644 index 0000000000000000000000000000000000000000..963ca12954cfd6d066892616a45c9c55174545b2 --- /dev/null +++ b/backend/src/TerritoryGame.Infrastructure/Migrations/20250819033027_AddSpectatorTable.Designer.cs @@ -0,0 +1,190 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TerritoryGame.Infrastructure.Data; + +#nullable disable + +namespace TerritoryGame.Infrastructure.Migrations +{ + [DbContext(typeof(GameDbContext))] + [Migration("20250819033027_AddSpectatorTable")] + partial class AddSpectatorTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.GameRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("GameDuration") + .HasColumnType("integer"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Password") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("GameRooms"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.PaintAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BrushSize") + .HasColumnType("integer"); + + b.Property("PlayerId") + .HasColumnType("uuid"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("PaintActions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Area") + .HasColumnType("integer"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("IsOnline") + .HasColumnType("boolean"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NickName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.Spectator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsOnline") + .HasColumnType("boolean"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NickName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.ToTable("Spectators"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.Player", b => + { + b.HasOne("TerritoryGame.Domain.Entities.GameRoom", null) + .WithMany("Players") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.Spectator", b => + { + b.HasOne("TerritoryGame.Domain.Entities.GameRoom", null) + .WithMany("Spectators") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.GameRoom", b => + { + b.Navigation("Players"); + + b.Navigation("Spectators"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TerritoryGame.Infrastructure/Migrations/20250819033027_AddSpectatorTable.cs b/backend/src/TerritoryGame.Infrastructure/Migrations/20250819033027_AddSpectatorTable.cs new file mode 100644 index 0000000000000000000000000000000000000000..07b800d068ef5e405bc163ebe5cba0d77bcc732d --- /dev/null +++ b/backend/src/TerritoryGame.Infrastructure/Migrations/20250819033027_AddSpectatorTable.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TerritoryGame.Infrastructure.Migrations +{ + /// + public partial class AddSpectatorTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Spectators", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + NickName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + JoinedAt = table.Column(type: "timestamp with time zone", nullable: false), + IsOnline = table.Column(type: "boolean", nullable: false), + RoomId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Spectators", x => x.Id); + table.ForeignKey( + name: "FK_Spectators_GameRooms_RoomId", + column: x => x.RoomId, + principalTable: "GameRooms", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Spectators_RoomId", + table: "Spectators", + column: "RoomId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Spectators"); + } + } +} diff --git a/backend/src/TerritoryGame.Infrastructure/Migrations/GameDbContextModelSnapshot.cs b/backend/src/TerritoryGame.Infrastructure/Migrations/GameDbContextModelSnapshot.cs new file mode 100644 index 0000000000000000000000000000000000000000..4b35cc3d6dd358d9c55ff2ef999165e3f6971949 --- /dev/null +++ b/backend/src/TerritoryGame.Infrastructure/Migrations/GameDbContextModelSnapshot.cs @@ -0,0 +1,187 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TerritoryGame.Infrastructure.Data; + +#nullable disable + +namespace TerritoryGame.Infrastructure.Migrations +{ + [DbContext(typeof(GameDbContext))] + partial class GameDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.GameRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("GameDuration") + .HasColumnType("integer"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Password") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("GameRooms"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.PaintAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BrushSize") + .HasColumnType("integer"); + + b.Property("PlayerId") + .HasColumnType("uuid"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("PaintActions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Area") + .HasColumnType("integer"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("IsOnline") + .HasColumnType("boolean"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NickName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.Spectator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsOnline") + .HasColumnType("boolean"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NickName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.ToTable("Spectators"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.Player", b => + { + b.HasOne("TerritoryGame.Domain.Entities.GameRoom", null) + .WithMany("Players") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.Spectator", b => + { + b.HasOne("TerritoryGame.Domain.Entities.GameRoom", null) + .WithMany("Spectators") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.GameRoom", b => + { + b.Navigation("Players"); + + b.Navigation("Spectators"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TerritoryGame.Infrastructure/Services/AreaCalculationService.cs b/backend/src/TerritoryGame.Infrastructure/Services/AreaCalculationService.cs new file mode 100644 index 0000000000000000000000000000000000000000..6a98250cfc93e1dbd757ce8d01c71fd9fe05f911 --- /dev/null +++ b/backend/src/TerritoryGame.Infrastructure/Services/AreaCalculationService.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore; +using TerritoryGame.Domain.Entities; +using TerritoryGame.Domain.Services; +using TerritoryGame.Infrastructure.Data; + +namespace TerritoryGame.Infrastructure.Services; + +public class AreaCalculationService : IAreaCalculationService +{ + private readonly GameDbContext _context; + + public AreaCalculationService(GameDbContext context) + { + _context = context; + } + + // 计算玩家在房间中的涂色面积 + public async Task CalculatePlayerAreaAsync(Guid roomId, Guid playerId) + { + // 实际实现会根据游戏规则计算面积 + // 这里只是一个示例实现 + var actionsCount = await _context.PaintActions + .CountAsync(a => a.RoomId == roomId && a.PlayerId == playerId); + + // 简单地将动作数量乘以一个系数作为面积 + return actionsCount * 10; + } + + // 批量计算所有玩家的面积 + public async Task> CalculateAllPlayersAreaAsync(Guid roomId) + { + var result = new Dictionary(); + + // 获取房间中的所有玩家 + var room = await _context.GameRooms + .Include(r => r.Players) + .FirstOrDefaultAsync(r => r.Id == roomId); + + if (room == null) + { + throw new KeyNotFoundException($"Room with ID {roomId} not found"); + } + + // 计算每个玩家的面积 + foreach (var player in room.Players) + { + var area = await CalculatePlayerAreaAsync(roomId, player.Id); + result[player.Id] = area; + } + + return result; + } + + // 更新面积统计 + public async Task UpdateAreaStatisticsAsync(Guid roomId) + { + var areaDict = await CalculateAllPlayersAreaAsync(roomId); + + // 更新玩家面积 + foreach (var kvp in areaDict) + { + var player = await _context.Players.FindAsync(kvp.Key); + if (player != null) + { + player.UpdateArea(kvp.Value); + } + } + + await _context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/backend/src/TerritoryGame.Infrastructure/TerritoryGame.Infrastructure.csproj b/backend/src/TerritoryGame.Infrastructure/TerritoryGame.Infrastructure.csproj new file mode 100644 index 0000000000000000000000000000000000000000..79a91c1f4b67e382cd821a17980bb512fae5703b --- /dev/null +++ b/backend/src/TerritoryGame.Infrastructure/TerritoryGame.Infrastructure.csproj @@ -0,0 +1,25 @@ + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + net8.0 + enable + enable + + + diff --git a/backend/TerritoryGame.sln b/backend/src/TerritoryGame.sln similarity index 35% rename from backend/TerritoryGame.sln rename to backend/src/TerritoryGame.sln index d5d32070a384e3808b626fde2f5f57de5da82663..709b8d60826886c2b93f55a1d3b526661f695759 100644 --- a/backend/TerritoryGame.sln +++ b/backend/src/TerritoryGame.sln @@ -1,16 +1,17 @@ - + 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}") = "TerritoryGame.Api", "TerritoryGame.Api\TerritoryGame.Api.csproj", "{0AE5B7FB-9BE3-4647-8D69-1B16F0465ED3}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerritoryGame.Api", "TerritoryGame.Api\TerritoryGame.Api.csproj", "{791E01DD-781A-4F24-83F6-C5C6177CC87B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerritoryGame.Application", "TerritoryGame.Application\TerritoryGame.Application.csproj", "{5025DB63-8793-417A-8DF7-C58092A4C2F5}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerritoryGame.Application", "TerritoryGame.Application\TerritoryGame.Application.csproj", "{EF4F5C4B-EB9E-4A00-B2E9-CC82CB4A7663}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerritoryGame.Domain", "TerritoryGame.Domain\TerritoryGame.Domain.csproj", "{6ED03ACD-9F3B-4267-BF17-77F815B99BE9}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerritoryGame.Domain", "TerritoryGame.Domain\TerritoryGame.Domain.csproj", "{3B316F56-5E45-4106-B4A3-46CA1B69038C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerritoryGame.Infrastructure", "TerritoryGame.Infrastructure\TerritoryGame.Infrastructure.csproj", "{26551337-272A-4EAF-8226-CB24058BE4D8}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerritoryGame.Infrastructure", "TerritoryGame.Infrastructure\TerritoryGame.Infrastructure.csproj", "{F9AE91BD-14BE-4388-B50E-A24A0B072FF0}" EndProject + Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,54 +22,54 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {0AE5B7FB-9BE3-4647-8D69-1B16F0465ED3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0AE5B7FB-9BE3-4647-8D69-1B16F0465ED3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0AE5B7FB-9BE3-4647-8D69-1B16F0465ED3}.Debug|x64.ActiveCfg = Debug|Any CPU - {0AE5B7FB-9BE3-4647-8D69-1B16F0465ED3}.Debug|x64.Build.0 = Debug|Any CPU - {0AE5B7FB-9BE3-4647-8D69-1B16F0465ED3}.Debug|x86.ActiveCfg = Debug|Any CPU - {0AE5B7FB-9BE3-4647-8D69-1B16F0465ED3}.Debug|x86.Build.0 = Debug|Any CPU - {0AE5B7FB-9BE3-4647-8D69-1B16F0465ED3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0AE5B7FB-9BE3-4647-8D69-1B16F0465ED3}.Release|Any CPU.Build.0 = Release|Any CPU - {0AE5B7FB-9BE3-4647-8D69-1B16F0465ED3}.Release|x64.ActiveCfg = Release|Any CPU - {0AE5B7FB-9BE3-4647-8D69-1B16F0465ED3}.Release|x64.Build.0 = Release|Any CPU - {0AE5B7FB-9BE3-4647-8D69-1B16F0465ED3}.Release|x86.ActiveCfg = Release|Any CPU - {0AE5B7FB-9BE3-4647-8D69-1B16F0465ED3}.Release|x86.Build.0 = Release|Any CPU - {5025DB63-8793-417A-8DF7-C58092A4C2F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5025DB63-8793-417A-8DF7-C58092A4C2F5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5025DB63-8793-417A-8DF7-C58092A4C2F5}.Debug|x64.ActiveCfg = Debug|Any CPU - {5025DB63-8793-417A-8DF7-C58092A4C2F5}.Debug|x64.Build.0 = Debug|Any CPU - {5025DB63-8793-417A-8DF7-C58092A4C2F5}.Debug|x86.ActiveCfg = Debug|Any CPU - {5025DB63-8793-417A-8DF7-C58092A4C2F5}.Debug|x86.Build.0 = Debug|Any CPU - {5025DB63-8793-417A-8DF7-C58092A4C2F5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5025DB63-8793-417A-8DF7-C58092A4C2F5}.Release|Any CPU.Build.0 = Release|Any CPU - {5025DB63-8793-417A-8DF7-C58092A4C2F5}.Release|x64.ActiveCfg = Release|Any CPU - {5025DB63-8793-417A-8DF7-C58092A4C2F5}.Release|x64.Build.0 = Release|Any CPU - {5025DB63-8793-417A-8DF7-C58092A4C2F5}.Release|x86.ActiveCfg = Release|Any CPU - {5025DB63-8793-417A-8DF7-C58092A4C2F5}.Release|x86.Build.0 = Release|Any CPU - {6ED03ACD-9F3B-4267-BF17-77F815B99BE9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6ED03ACD-9F3B-4267-BF17-77F815B99BE9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6ED03ACD-9F3B-4267-BF17-77F815B99BE9}.Debug|x64.ActiveCfg = Debug|Any CPU - {6ED03ACD-9F3B-4267-BF17-77F815B99BE9}.Debug|x64.Build.0 = Debug|Any CPU - {6ED03ACD-9F3B-4267-BF17-77F815B99BE9}.Debug|x86.ActiveCfg = Debug|Any CPU - {6ED03ACD-9F3B-4267-BF17-77F815B99BE9}.Debug|x86.Build.0 = Debug|Any CPU - {6ED03ACD-9F3B-4267-BF17-77F815B99BE9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6ED03ACD-9F3B-4267-BF17-77F815B99BE9}.Release|Any CPU.Build.0 = Release|Any CPU - {6ED03ACD-9F3B-4267-BF17-77F815B99BE9}.Release|x64.ActiveCfg = Release|Any CPU - {6ED03ACD-9F3B-4267-BF17-77F815B99BE9}.Release|x64.Build.0 = Release|Any CPU - {6ED03ACD-9F3B-4267-BF17-77F815B99BE9}.Release|x86.ActiveCfg = Release|Any CPU - {6ED03ACD-9F3B-4267-BF17-77F815B99BE9}.Release|x86.Build.0 = Release|Any CPU - {26551337-272A-4EAF-8226-CB24058BE4D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {26551337-272A-4EAF-8226-CB24058BE4D8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {26551337-272A-4EAF-8226-CB24058BE4D8}.Debug|x64.ActiveCfg = Debug|Any CPU - {26551337-272A-4EAF-8226-CB24058BE4D8}.Debug|x64.Build.0 = Debug|Any CPU - {26551337-272A-4EAF-8226-CB24058BE4D8}.Debug|x86.ActiveCfg = Debug|Any CPU - {26551337-272A-4EAF-8226-CB24058BE4D8}.Debug|x86.Build.0 = Debug|Any CPU - {26551337-272A-4EAF-8226-CB24058BE4D8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {26551337-272A-4EAF-8226-CB24058BE4D8}.Release|Any CPU.Build.0 = Release|Any CPU - {26551337-272A-4EAF-8226-CB24058BE4D8}.Release|x64.ActiveCfg = Release|Any CPU - {26551337-272A-4EAF-8226-CB24058BE4D8}.Release|x64.Build.0 = Release|Any CPU - {26551337-272A-4EAF-8226-CB24058BE4D8}.Release|x86.ActiveCfg = Release|Any CPU - {26551337-272A-4EAF-8226-CB24058BE4D8}.Release|x86.Build.0 = Release|Any CPU + {791E01DD-781A-4F24-83F6-C5C6177CC87B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {791E01DD-781A-4F24-83F6-C5C6177CC87B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {791E01DD-781A-4F24-83F6-C5C6177CC87B}.Debug|x64.ActiveCfg = Debug|Any CPU + {791E01DD-781A-4F24-83F6-C5C6177CC87B}.Debug|x64.Build.0 = Debug|Any CPU + {791E01DD-781A-4F24-83F6-C5C6177CC87B}.Debug|x86.ActiveCfg = Debug|Any CPU + {791E01DD-781A-4F24-83F6-C5C6177CC87B}.Debug|x86.Build.0 = Debug|Any CPU + {791E01DD-781A-4F24-83F6-C5C6177CC87B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {791E01DD-781A-4F24-83F6-C5C6177CC87B}.Release|Any CPU.Build.0 = Release|Any CPU + {791E01DD-781A-4F24-83F6-C5C6177CC87B}.Release|x64.ActiveCfg = Release|Any CPU + {791E01DD-781A-4F24-83F6-C5C6177CC87B}.Release|x64.Build.0 = Release|Any CPU + {791E01DD-781A-4F24-83F6-C5C6177CC87B}.Release|x86.ActiveCfg = Release|Any CPU + {791E01DD-781A-4F24-83F6-C5C6177CC87B}.Release|x86.Build.0 = Release|Any CPU + {EF4F5C4B-EB9E-4A00-B2E9-CC82CB4A7663}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EF4F5C4B-EB9E-4A00-B2E9-CC82CB4A7663}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EF4F5C4B-EB9E-4A00-B2E9-CC82CB4A7663}.Debug|x64.ActiveCfg = Debug|Any CPU + {EF4F5C4B-EB9E-4A00-B2E9-CC82CB4A7663}.Debug|x64.Build.0 = Debug|Any CPU + {EF4F5C4B-EB9E-4A00-B2E9-CC82CB4A7663}.Debug|x86.ActiveCfg = Debug|Any CPU + {EF4F5C4B-EB9E-4A00-B2E9-CC82CB4A7663}.Debug|x86.Build.0 = Debug|Any CPU + {EF4F5C4B-EB9E-4A00-B2E9-CC82CB4A7663}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EF4F5C4B-EB9E-4A00-B2E9-CC82CB4A7663}.Release|Any CPU.Build.0 = Release|Any CPU + {EF4F5C4B-EB9E-4A00-B2E9-CC82CB4A7663}.Release|x64.ActiveCfg = Release|Any CPU + {EF4F5C4B-EB9E-4A00-B2E9-CC82CB4A7663}.Release|x64.Build.0 = Release|Any CPU + {EF4F5C4B-EB9E-4A00-B2E9-CC82CB4A7663}.Release|x86.ActiveCfg = Release|Any CPU + {EF4F5C4B-EB9E-4A00-B2E9-CC82CB4A7663}.Release|x86.Build.0 = Release|Any CPU + {3B316F56-5E45-4106-B4A3-46CA1B69038C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3B316F56-5E45-4106-B4A3-46CA1B69038C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B316F56-5E45-4106-B4A3-46CA1B69038C}.Debug|x64.ActiveCfg = Debug|Any CPU + {3B316F56-5E45-4106-B4A3-46CA1B69038C}.Debug|x64.Build.0 = Debug|Any CPU + {3B316F56-5E45-4106-B4A3-46CA1B69038C}.Debug|x86.ActiveCfg = Debug|Any CPU + {3B316F56-5E45-4106-B4A3-46CA1B69038C}.Debug|x86.Build.0 = Debug|Any CPU + {3B316F56-5E45-4106-B4A3-46CA1B69038C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3B316F56-5E45-4106-B4A3-46CA1B69038C}.Release|Any CPU.Build.0 = Release|Any CPU + {3B316F56-5E45-4106-B4A3-46CA1B69038C}.Release|x64.ActiveCfg = Release|Any CPU + {3B316F56-5E45-4106-B4A3-46CA1B69038C}.Release|x64.Build.0 = Release|Any CPU + {3B316F56-5E45-4106-B4A3-46CA1B69038C}.Release|x86.ActiveCfg = Release|Any CPU + {3B316F56-5E45-4106-B4A3-46CA1B69038C}.Release|x86.Build.0 = Release|Any CPU + {F9AE91BD-14BE-4388-B50E-A24A0B072FF0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F9AE91BD-14BE-4388-B50E-A24A0B072FF0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F9AE91BD-14BE-4388-B50E-A24A0B072FF0}.Debug|x64.ActiveCfg = Debug|Any CPU + {F9AE91BD-14BE-4388-B50E-A24A0B072FF0}.Debug|x64.Build.0 = Debug|Any CPU + {F9AE91BD-14BE-4388-B50E-A24A0B072FF0}.Debug|x86.ActiveCfg = Debug|Any CPU + {F9AE91BD-14BE-4388-B50E-A24A0B072FF0}.Debug|x86.Build.0 = Debug|Any CPU + {F9AE91BD-14BE-4388-B50E-A24A0B072FF0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F9AE91BD-14BE-4388-B50E-A24A0B072FF0}.Release|Any CPU.Build.0 = Release|Any CPU + {F9AE91BD-14BE-4388-B50E-A24A0B072FF0}.Release|x64.ActiveCfg = Release|Any CPU + {F9AE91BD-14BE-4388-B50E-A24A0B072FF0}.Release|x64.Build.0 = Release|Any CPU + {F9AE91BD-14BE-4388-B50E-A24A0B072FF0}.Release|x86.ActiveCfg = Release|Any CPU + {F9AE91BD-14BE-4388-B50E-A24A0B072FF0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000000000000000000000000000000000000..3b510aa687ba5d3dbaec1b9c6989327f84261a21 --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,8 @@ +[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}] +charset = utf-8 +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +max_line_length = 100 diff --git a/frontend/.gitattributes b/frontend/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..6313b56c57848efce05faa7aa7e901ccfc2886ea --- /dev/null +++ b/frontend/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf36d8d11a4f89c59c144f24795749086dd1..8ee54e8d343e466a213c8c30aa04be77126b170d 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -8,17 +8,23 @@ pnpm-debug.log* lerna-debug.log* node_modules +.DS_Store dist dist-ssr +coverage *.local +/cypress/videos/ +/cypress/screenshots/ + # Editor directories and files .vscode/* !.vscode/extensions.json .idea -.DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? + +*.tsbuildinfo diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 0000000000000000000000000000000000000000..29a2402ef050746efe041b9e3393bf33796407c3 --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "semi": false, + "singleQuote": true, + "printWidth": 100 +} diff --git a/frontend/README.md b/frontend/README.md index 33895ab2002862766f2df205d5783f14cd0c1d74..29ee773f780ea7217113cfc0d620268b6519f50b 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,5 +1,35 @@ -# Vue 3 + TypeScript + Vite +# frontend -This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..5a1f2d222a302a174e710614c6d76531b7bda926 --- /dev/null +++ b/frontend/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + }, + "exclude": ["node_modules", "dist"] +} diff --git a/frontend/package.json b/frontend/package.json index eac0ec765892816168bfdeb7e2e8bf2251deeb31..af28ab9e323e7851893c546e007f5ec96347d1d6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,24 +1,34 @@ { - "name": "vue-color-game", - "private": true, + "name": "frontend", "version": "0.0.0", + "private": true, "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, "scripts": { "dev": "vite", - "build": "vue-tsc -b && vite build", - "preview": "vite preview" + "build": "vite build", + "preview": "vite preview", + "lint": "eslint . --fix", + "format": "prettier --write src/" }, "dependencies": { + "@microsoft/signalr": "^9.0.6", + "chart.js": "^4.5.0", "pinia": "^3.0.3", - "pnpm": "^10.14.0", - "socket.io-client": "^4.8.1", - "vue": "^3.5.18" + "vue": "^3.5.18", + "vue-router": "^4.5.1" }, "devDependencies": { + "@eslint/js": "^9.31.0", "@vitejs/plugin-vue": "^6.0.1", - "@vue/tsconfig": "^0.7.0", - "typescript": "~5.8.3", - "vite": "^7.1.2", - "vue-tsc": "^3.0.5" + "@vue/eslint-config-prettier": "^10.2.0", + "eslint": "^9.31.0", + "eslint-plugin-vue": "~10.3.0", + "globals": "^16.3.0", + "prettier": "3.6.2", + "vite": "^7.0.6", + "vite-plugin-vue-devtools": "^8.0.0" } } diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..df36fcfb72584e00488330b560ebcf34a41c64c2 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb1b2a60bd50538bec9f876511b9cac21e3..0000000000000000000000000000000000000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index cea8dbff61c6d810591e96318ccf1899536aef9b..9a4e30117ead4d0c168b28de6b122bd593555148 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,72 +1,96 @@ + + - - \ No newline at end of file + diff --git a/frontend/src/assets/base.css b/frontend/src/assets/base.css new file mode 100644 index 0000000000000000000000000000000000000000..96f84ec6877df96e3298af01912c93958c3ff150 --- /dev/null +++ b/frontend/src/assets/base.css @@ -0,0 +1,105 @@ +/* 游戏主题颜色方案 */ +:root { + --vt-c-white: #ffffff; + --vt-c-white-soft: #f8f8f8; + --vt-c-white-mute: #f2f2f2; + + --vt-c-black: #181818; + --vt-c-black-soft: #222222; + --vt-c-black-mute: #282828; + + --vt-c-primary: #4361ee; + --vt-c-primary-light: #7209b7; + --vt-c-secondary: #4cc9f0; + --vt-c-accent: #f72585; + --vt-c-success: #4cc9f0; + --vt-c-warning: #ffd60a; + --vt-c-danger: #ef476f; + --vt-c-info: #118ab2; + + --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); + --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); + --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); + --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); + + --vt-c-text-light-1: #333333; + --vt-c-text-light-2: rgba(60, 60, 60, 0.66); + --vt-c-text-dark-1: var(--vt-c-white); + --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); +} + +/* 语义化颜色变量 */ +:root { + --color-background: var(--vt-c-white); + --color-background-soft: var(--vt-c-white-soft); + --color-background-mute: var(--vt-c-white-mute); + + --color-primary: var(--vt-c-primary); + --color-primary-light: var(--vt-c-primary-light); + --color-secondary: var(--vt-c-secondary); + --color-accent: var(--vt-c-accent); + --color-success: var(--vt-c-success); + --color-warning: var(--vt-c-warning); + --color-danger: var(--vt-c-danger); + --color-info: var(--vt-c-info); + + --color-border: var(--vt-c-divider-light-2); + --color-border-hover: var(--vt-c-divider-light-1); + + --color-heading: var(--vt-c-text-light-1); + --color-text: var(--vt-c-text-light-1); + + --section-gap: 40px; + --card-radius: 12px; + --card-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + --transition: all 0.3s ease; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-background: var(--vt-c-black); + --color-background-soft: var(--vt-c-black-soft); + --color-background-mute: var(--vt-c-black-mute); + + --color-border: var(--vt-c-divider-dark-2); + --color-border-hover: var(--vt-c-divider-dark-1); + + --color-heading: var(--vt-c-text-dark-1); + --color-text: var(--vt-c-text-dark-2); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + font-weight: normal; +} + +body { + min-height: 100vh; + color: var(--color-text); + background: var(--color-background); + transition: + color 0.5s, + background-color 0.5s; + line-height: 1.6; + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + Oxygen, + Ubuntu, + Cantarell, + 'Fira Sans', + 'Droid Sans', + 'Helvetica Neue', + sans-serif; + font-size: 15px; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/frontend/src/assets/logo.svg b/frontend/src/assets/logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..7565660356e5b3723c9c33d508b830c9cfbea29f --- /dev/null +++ b/frontend/src/assets/logo.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css new file mode 100644 index 0000000000000000000000000000000000000000..fa7261b4c4e5ce89c071745a369849b2f58c05b8 --- /dev/null +++ b/frontend/src/assets/main.css @@ -0,0 +1,134 @@ +@import './base.css'; + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + color: var(--color-text); + background-color: var(--color-background); + line-height: 1.6; +} + +#app { + max-width: 1400px; + margin: 0 auto; + padding: 1rem; + width: 100%; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +a { + text-decoration: none; + color: var(--color-primary); + transition: var(--transition); +} + +a:hover { + color: var(--color-primary-light); +} + +.container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; +} + +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: var(--card-radius); + font-weight: 600; + cursor: pointer; + transition: var(--transition); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.btn-primary { + background-color: var(--color-primary); + color: white; +} + +.btn-primary:hover { + background-color: var(--color-primary-light); + transform: translateY(-2px); +} + +.btn-secondary { + background-color: var(--color-secondary); + color: white; +} + +.btn-secondary:hover { + background-color: var(--color-info); + transform: translateY(-2px); +} + +.btn-danger { + background-color: var(--color-danger); + color: white; +} + +.btn-danger:hover { + background-color: #d62828; + transform: translateY(-2px); +} + +.btn-success { + background-color: var(--color-success); + color: white; +} + +.btn-success:hover { + background-color: #38b000; + transform: translateY(-2px); +} + +.card { + background-color: white; + border-radius: var(--card-radius); + box-shadow: var(--card-shadow); + padding: 1.5rem; + transition: var(--transition); +} + +.card:hover { + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12); + transform: translateY(-3px); +} + +.input { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: 8px; + font-size: 1rem; + transition: var(--transition); +} + +.input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.15); +} + +@media (min-width: 768px) { + #app { + padding: 2rem; + } +} + +@media (min-width: 1024px) { + .container { + padding: 0 2rem; + } +} diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg deleted file mode 100644 index 770e9d333ee70e75fe7c0bad7fb13e4f6ed4627a..0000000000000000000000000000000000000000 --- a/frontend/src/assets/vue.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/components/AreaStats.vue b/frontend/src/components/AreaStats.vue index 0da04662eb23db0e727a9ba1569a4bd5d7af6d00..6863b028f260af105c7752dec87648900296214e 100644 --- a/frontend/src/components/AreaStats.vue +++ b/frontend/src/components/AreaStats.vue @@ -1,46 +1,250 @@ \ No newline at end of file diff --git a/frontend/src/components/ChatComponent.vue b/frontend/src/components/ChatComponent.vue new file mode 100644 index 0000000000000000000000000000000000000000..cb51d9ff9a174920338ceb6bb946f2a59dd8a3c7 --- /dev/null +++ b/frontend/src/components/ChatComponent.vue @@ -0,0 +1,195 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/CreateRoom.vue b/frontend/src/components/CreateRoom.vue new file mode 100644 index 0000000000000000000000000000000000000000..ed2a6895b98d35b022087c09c6f37239c2df3f9f --- /dev/null +++ b/frontend/src/components/CreateRoom.vue @@ -0,0 +1,532 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/GameCanvas.vue b/frontend/src/components/GameCanvas.vue index f8f869a47c91ebd216719cf0321d90bc0dfbc7fb..be6c69078079ed28164eb1dd0c5520d93ff3f078 100644 --- a/frontend/src/components/GameCanvas.vue +++ b/frontend/src/components/GameCanvas.vue @@ -1,43 +1,361 @@ \ No newline at end of file diff --git a/frontend/src/components/GameTimer.vue b/frontend/src/components/GameTimer.vue index 0d35ed2d1ed825b54288b7e740504d26341c9bf2..fb29382a173b17092fe7c268206340d70eeaeb2c 100644 --- a/frontend/src/components/GameTimer.vue +++ b/frontend/src/components/GameTimer.vue @@ -1,57 +1,141 @@ \ No newline at end of file diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue index b58e52b965f6f9f340400a843f85ba4bd8156851..eff59f13c630e632c777d7bfc8ac3a10ba4f9263 100644 --- a/frontend/src/components/HelloWorld.vue +++ b/frontend/src/components/HelloWorld.vue @@ -1,41 +1,44 @@ - diff --git a/frontend/src/components/Leaderboard.vue b/frontend/src/components/Leaderboard.vue new file mode 100644 index 0000000000000000000000000000000000000000..d5baa7c152404d09515a31fbd1fac1a71433d292 --- /dev/null +++ b/frontend/src/components/Leaderboard.vue @@ -0,0 +1,119 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/LoadingScreen.vue b/frontend/src/components/LoadingScreen.vue new file mode 100644 index 0000000000000000000000000000000000000000..c54d0967d23d01ecd1f1f06a6b36ad631cbd5cf1 --- /dev/null +++ b/frontend/src/components/LoadingScreen.vue @@ -0,0 +1,89 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/PlayerList.vue b/frontend/src/components/PlayerList.vue index c2b01749f01d3a6d108a1a99200cb8ff0ac66522..05b9c2f4f88ab81b94f595a322f439a426d5c318 100644 --- a/frontend/src/components/PlayerList.vue +++ b/frontend/src/components/PlayerList.vue @@ -1,37 +1,164 @@ \ No newline at end of file diff --git a/frontend/src/components/ResultScreen.vue b/frontend/src/components/ResultScreen.vue new file mode 100644 index 0000000000000000000000000000000000000000..1e1bb825eb36cf5ae6d5632c9c94ed37da421377 --- /dev/null +++ b/frontend/src/components/ResultScreen.vue @@ -0,0 +1,229 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/RoomList.vue b/frontend/src/components/RoomList.vue new file mode 100644 index 0000000000000000000000000000000000000000..7bfb4c9766e64cccf9b112b4cee335b66b0af433 --- /dev/null +++ b/frontend/src/components/RoomList.vue @@ -0,0 +1,525 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/RoomLobby.vue b/frontend/src/components/RoomLobby.vue deleted file mode 100644 index ea0604a7e1208e9c52d6df0b86849c6301ed14a8..0000000000000000000000000000000000000000 --- a/frontend/src/components/RoomLobby.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/src/components/TheWelcome.vue b/frontend/src/components/TheWelcome.vue new file mode 100644 index 0000000000000000000000000000000000000000..fe48afc20626fd1457bcb3098456e8630dd4bbe7 --- /dev/null +++ b/frontend/src/components/TheWelcome.vue @@ -0,0 +1,94 @@ + + + diff --git a/frontend/src/components/WelcomeItem.vue b/frontend/src/components/WelcomeItem.vue new file mode 100644 index 0000000000000000000000000000000000000000..ac366d0740bfa462d7e9f290137601a3f3139ecc --- /dev/null +++ b/frontend/src/components/WelcomeItem.vue @@ -0,0 +1,86 @@ + + + diff --git a/frontend/src/components/icons/IconCommunity.vue b/frontend/src/components/icons/IconCommunity.vue new file mode 100644 index 0000000000000000000000000000000000000000..2dc8b055253af30fb797037e2fe260505f0cf711 --- /dev/null +++ b/frontend/src/components/icons/IconCommunity.vue @@ -0,0 +1,7 @@ + diff --git a/frontend/src/components/icons/IconDocumentation.vue b/frontend/src/components/icons/IconDocumentation.vue new file mode 100644 index 0000000000000000000000000000000000000000..6d4791cfbcf2782b3e5ffbabd042d4c47b2fbbed --- /dev/null +++ b/frontend/src/components/icons/IconDocumentation.vue @@ -0,0 +1,7 @@ + diff --git a/frontend/src/components/icons/IconEcosystem.vue b/frontend/src/components/icons/IconEcosystem.vue new file mode 100644 index 0000000000000000000000000000000000000000..c3a4f078c0bd340a33c61ea9ecd8a755d03571ed --- /dev/null +++ b/frontend/src/components/icons/IconEcosystem.vue @@ -0,0 +1,7 @@ + diff --git a/frontend/src/components/icons/IconSupport.vue b/frontend/src/components/icons/IconSupport.vue new file mode 100644 index 0000000000000000000000000000000000000000..7452834d3ef961ce24c3a072ddba2620b6158bae --- /dev/null +++ b/frontend/src/components/icons/IconSupport.vue @@ -0,0 +1,7 @@ + diff --git a/frontend/src/components/icons/IconTooling.vue b/frontend/src/components/icons/IconTooling.vue new file mode 100644 index 0000000000000000000000000000000000000000..660598d7c76644ffe126a1a1feb1606650bfb937 --- /dev/null +++ b/frontend/src/components/icons/IconTooling.vue @@ -0,0 +1,19 @@ + + diff --git a/frontend/src/main.js b/frontend/src/main.js index d0e84864b2e60561487bfde21212c0ca0001edf8..79eab09545d1c9c01fd6721d20a07a2ff6b004d6 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,7 +1,66 @@ -import { createApp } from 'vue'; -import { createPinia } from 'pinia'; -import App from './App.vue'; +import './assets/main.css' -const app = createApp(App); -app.use(createPinia()); -app.mount('#app'); \ No newline at end of file +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import { HubConnectionBuilder } from '@microsoft/signalr' + +import App from './App.vue' +import router from './router' +import socketService from './services/socketService' +import resourceLoader from './services/resourceLoader' + +// 定义需要预加载的资源 +const resourcesConfig = { + // 示例:如果有图片资源,可以这样配置 + // image: [ + // '/assets/images/background.png', + // '/assets/images/player.png' + // ], + // 示例:如果有音频资源,可以这样配置 + // audio: [ + // '/assets/audio/paint.mp3', + // '/assets/audio/game_start.mp3' + // ], + // 字体资源 + font: [ + { family: 'GameFont', url: 'https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap' } + ] +}; + +// 初始化socket服务和预加载资源 +async function initApp() { + try { + // 先初始化Socket服务 + await socketService.init(); + console.log('Socket service initialized successfully'); + + // 然后预加载资源 + console.log('Starting resource preloading...'); + await resourceLoader.preload(resourcesConfig); + console.log('Resource preloading completed'); + + // 资源加载完成后创建应用 + const app = createApp(App); + + app.use(createPinia()); + app.use(router); + + // 提供Socket服务和资源加载器 + app.provide('socketService', socketService); + app.provide('resourceLoader', resourceLoader); + + // 同时设置为全局属性,方便其他地方使用 + app.config.globalProperties.$socketService = socketService; + app.config.globalProperties.$resourceLoader = resourceLoader; + + // 挂载应用 + app.mount('#app'); + } catch (error) { + console.error('Error initializing app:', error); + // 处理初始化失败的情况 + alert('应用初始化失败,请刷新页面重试'); + } +} + +// 启动应用初始化 +initApp(); diff --git a/frontend/src/main.ts b/frontend/src/main.ts deleted file mode 100644 index 2425c0f745bef4d009cb6661b62fd9dfd62960b0..0000000000000000000000000000000000000000 --- a/frontend/src/main.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createApp } from 'vue' -import './style.css' -import App from './App.vue' - -createApp(App).mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000000000000000000000000000000000000..f7807fc641b6a992a3fed50a25669f4d8055a553 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,32 @@ +import { createRouter, createWebHistory } from 'vue-router' +import HomeView from '../views/HomeView.vue' +import CreateRoom from '../components/CreateRoom.vue' +import DrawingTestView from '../views/DrawingTestView.vue' + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/', + name: 'home', + component: HomeView, + }, + { + path: '/create-room', + name: 'createRoom', + component: CreateRoom, + }, + { + path: '/game-room/:id', + name: 'gameRoom', + component: () => import('../views/GameRoomView.vue'), + }, + { + path: '/drawing-test', + name: 'drawingTest', + component: DrawingTestView, + }, + ], +}) + +export default router diff --git a/frontend/src/services/audioService.js b/frontend/src/services/audioService.js new file mode 100644 index 0000000000000000000000000000000000000000..0d2aa36f8a7fbce5add7fbbea518deaf980f836c --- /dev/null +++ b/frontend/src/services/audioService.js @@ -0,0 +1,89 @@ +// 音效服务 +class AudioService { + constructor() { + // 初始化音效库 + this.sounds = { + joinRoom: null, + paint: null, + gameStart: null, + gameEnd: null, + error: null + }; + this.isMuted = false; + this.loadSounds(); + } + + // 加载所有音效 + loadSounds() { + // 在实际项目中,这里会加载音频文件 + // 为了简化,我们使用HTML5 Web Audio API创建简单的音效 + this.sounds.joinRoom = this.createSimpleSound(440, 0.2); + this.sounds.paint = this.createSimpleSound(880, 0.1); + this.sounds.gameStart = this.createSimpleSound(660, 0.5); + this.sounds.gameEnd = this.createSimpleSound(220, 0.5); + this.sounds.error = this.createSimpleSound(110, 0.3); + } + + // 创建简单的音效(使用Web Audio API) + createSimpleSound(frequency, duration) { + try { + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + + return { + play: () => { + if (this.isMuted) return; + // 每次播放时创建新的振荡器节点 + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime); + gainNode.gain.setValueAtTime(0, audioContext.currentTime); + gainNode.gain.linearRampToValueAtTime(0.3, audioContext.currentTime + 0.01); + gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + duration); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.start(); + oscillator.stop(audioContext.currentTime + duration); + }, + context: audioContext + }; + } catch (e) { + console.error('无法创建音效:', e); + return { play: () => {} }; + } + } + + // 播放音效 + playSound(soundName) { + if (this.sounds[soundName] && typeof this.sounds[soundName].play === 'function') { + this.sounds[soundName].play(); + } else { + console.warn(`音效 ${soundName} 不存在或无法播放`); + } + } + + // 切换静音状态 + toggleMute() { + this.isMuted = !this.isMuted; + return this.isMuted; + } + + // 设置静音状态 + setMute(isMuted) { + this.isMuted = isMuted; + return this.isMuted; + } + + // 获取当前静音状态 + getMute() { + return this.isMuted; + } +} + +// 创建单例实例 +const audioService = new AudioService(); + +export default audioService; \ No newline at end of file diff --git a/frontend/src/services/gameService.js b/frontend/src/services/gameService.js deleted file mode 100644 index 3c3c2ea026a98d6c67a4d4af2bbd99412ca7f8ff..0000000000000000000000000000000000000000 --- a/frontend/src/services/gameService.js +++ /dev/null @@ -1,26 +0,0 @@ -import { useGameStore } from '@/stores/game'; -import { usePlayerStore } from '@/stores/player'; -import { useRoomStore } from '@/stores/room'; - -export const gameService = { - startGame() { - const gameStore = useGameStore(); - gameStore.startGame(); - }, - endGame() { - const gameStore = useGameStore(); - gameStore.endGame(); - }, - updateTimer(seconds) { - const gameStore = useGameStore(); - gameStore.updateTimer(seconds); - }, - updatePlayers(players) { - const gameStore = useGameStore(); - gameStore.updatePlayers(players); - }, - updateCanvasData(data) { - const gameStore = useGameStore(); - gameStore.updateCanvasData(data); - }, -}; \ No newline at end of file diff --git a/frontend/src/services/resourceLoader.js b/frontend/src/services/resourceLoader.js new file mode 100644 index 0000000000000000000000000000000000000000..a196fca023ec66a9c147fd250147c8ae1eb590af --- /dev/null +++ b/frontend/src/services/resourceLoader.js @@ -0,0 +1,172 @@ +// 资源加载器服务 +class ResourceLoader { + constructor() { + this.resources = {}; + this.loadedCount = 0; + this.totalCount = 0; + this.loadingComplete = false; + this.loadingPromise = null; + this.listeners = { + progress: [], + complete: [] + }; + } + + // 预加载资源 + preload(resourcesConfig) { + return new Promise((resolve, reject) => { + this.loadingPromise = Promise.resolve(); + this.loadingComplete = false; + this.loadedCount = 0; + this.totalCount = Object.keys(resourcesConfig).length; + + // 如果没有资源需要加载,直接完成 + if (this.totalCount === 0) { + this.loadingComplete = true; + this.notifyComplete(); + return resolve(this.resources); + } + + // 加载每种类型的资源 + Object.entries(resourcesConfig).forEach(([type, paths]) => { + this.loadingPromise = this.loadingPromise.then(() => { + return this.loadResourcesOfType(type, paths); + }); + }); + + this.loadingPromise + .then(() => { + this.loadingComplete = true; + this.notifyComplete(); + resolve(this.resources); + }) + .catch(error => { + console.error('资源加载失败:', error); + reject(error); + }); + }); + } + + // 加载指定类型的资源 + loadResourcesOfType(type, paths) { + const promises = paths.map(path => { + return new Promise((resolve, reject) => { + switch (type) { + case 'image': + this.loadImage(path).then(image => { + this.resources[path] = image; + this.updateProgress(); + resolve(image); + }).catch(reject); + break; + case 'audio': + this.loadAudio(path).then(audio => { + this.resources[path] = audio; + this.updateProgress(); + resolve(audio); + }).catch(reject); + break; + case 'font': + this.loadFont(path).then(font => { + this.resources[path] = font; + this.updateProgress(); + resolve(font); + }).catch(reject); + break; + default: + reject(new Error(`不支持的资源类型: ${type}`)); + } + }); + }); + + return Promise.all(promises); + } + + // 加载图像资源 + loadImage(path) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.src = path; + img.onload = () => resolve(img); + img.onerror = () => reject(new Error(`无法加载图像: ${path}`)); + }); + } + + // 加载音频资源 + loadAudio(path) { + return new Promise((resolve, reject) => { + const audio = new Audio(); + audio.src = path; + audio.preload = 'auto'; + + audio.oncanplaythrough = () => resolve(audio); + audio.onerror = () => reject(new Error(`无法加载音频: ${path}`)); + }); + } + + // 加载字体资源 + loadFont(fontFamily, fontUrl) { + return new Promise((resolve, reject) => { + if (typeof fontUrl === 'undefined') { + // 如果只提供了字体名称,假设已经加载 + resolve(fontFamily); + return; + } + + const fontFace = new FontFace(fontFamily, `url(${fontUrl})`); + document.fonts.add(fontFace); + + fontFace.load() + .then(() => { + resolve(fontFamily); + }) + .catch(error => { + reject(new Error(`无法加载字体: ${fontFamily}, 错误: ${error}`)); + }); + }); + } + + // 更新加载进度 + updateProgress() { + this.loadedCount++; + const progress = Math.floor((this.loadedCount / this.totalCount) * 100); + this.notifyProgress(progress); + } + + // 通知加载进度 + notifyProgress(progress) { + this.listeners.progress.forEach(callback => callback(progress)); + } + + // 通知加载完成 + notifyComplete() { + this.listeners.complete.forEach(callback => callback(this.resources)); + } + + // 添加事件监听器 + on(eventName, callback) { + if (this.listeners[eventName]) { + this.listeners[eventName].push(callback); + + // 如果已经加载完成,立即触发complete事件 + if (eventName === 'complete' && this.loadingComplete) { + callback(this.resources); + } + } + } + + // 获取已加载的资源 + getResource(path) { + return this.resources[path]; + } + + // 检查资源是否已加载 + isLoaded() { + return this.loadingComplete; + } +} + +// 创建单例实例 +const resourceLoader = new ResourceLoader(); + +export default resourceLoader; \ No newline at end of file diff --git a/frontend/src/services/socketService.js b/frontend/src/services/socketService.js index 47093bef0ed712756fbe4b410a341928afc076e6..3496b59471a9e8d3123bac0151ca28f3e851360d 100644 --- a/frontend/src/services/socketService.js +++ b/frontend/src/services/socketService.js @@ -1,35 +1,245 @@ -import io from 'socket.io-client'; -import { useGameStore } from '@/stores/game'; -import { usePlayerStore } from '@/stores/player'; -import { useRoomStore } from '@/stores/room'; +import * as signalR from '@microsoft/signalr'; +import { ref, onMounted, onUnmounted, inject, provide } from 'vue'; -const socket = io('http://localhost:3000'); +export class SocketService { + constructor() { + // 连接到后端SignalR服务器 + this.connection = null; + this.isConnected = ref(false); + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 5; + this.eventListeners = new Map(); + this.connectionPromise = null; + this.connectionStatusListeners = new Set(); + } -export const socketService = { - connect() { - socket.on('connect', () => { - console.log('Connected to server'); + // 添加连接状态变化监听器 + onConnectionStatusChange(callback) { + this.connectionStatusListeners.add(callback); + // 立即调用一次,传入当前状态 + callback(this.isConnected.value); + return () => this.connectionStatusListeners.delete(callback); + } + + // 更新连接状态并通知所有监听器 + updateConnectionStatus(status) { + if (this.isConnected.value !== status) { + this.isConnected.value = status; + this.connectionStatusListeners.forEach(callback => callback(status)); + } + } + + // 初始化连接 - 确保连接成功后才返回 + init() { + // 如果已经有连接并且连接状态为已连接,则直接返回成功 + if (this.connection && this.isConnected.value) { + console.log('Already connected to SignalR hub'); + return Promise.resolve(true); + } + + // 如果已经有连接初始化正在进行,则返回同一个Promise + if (this.connectionPromise) { + console.log('Connection initialization already in progress'); + return this.connectionPromise; + } + + // 创建新的连接Promise + this.connectionPromise = new Promise((resolve, reject) => { + // 从环境变量获取服务器地址,如果没有则使用默认值 + const serverUrl = import.meta.env.VITE_API_URL || 'http://localhost:5120'; + console.log('Attempting to connect to server at:', `${serverUrl}/gamehub`); + + // 创建SignalR连接 + this.connection = new signalR.HubConnectionBuilder() + .withUrl(`${serverUrl}/gamehub`, { + // 允许使用所有可用的传输方式,并按优先级排序 + transport: signalR.HttpTransportType.WebSockets | signalR.HttpTransportType.ServerSentEvents | signalR.HttpTransportType.LongPolling, + // 增加超时时间到10秒 + timeout: 10000 + }) + .withAutomaticReconnect({ + nextRetryDelayInMilliseconds: (retryContext) => { + console.log(`Reconnection attempt ${retryContext.retryCount} failed, trying again in ${Math.min(1000 * Math.pow(2, retryContext.retryCount), 5000)} milliseconds`); + // 指数退避重试策略,最多等待5秒 + return Math.min(1000 * Math.pow(2, retryContext.retryCount), 5000); + }, + // 最大重试次数 + maxRetries: 5 + }) + .build(); + + // 连接成功 + this.connection.start() + .then(() => { + console.log('Successfully connected to SignalR hub'); + this.updateConnectionStatus(true); + this.reconnectAttempts = 0; + resolve(true); + + // 重新注册所有事件监听器 + this.eventListeners.forEach((callback, eventName) => { + console.log(`Registering event listener for: ${eventName}`); + this.connection.on(eventName, callback); + }); + }) + .catch((error) => { + console.error('Connection error:', error); + this.updateConnectionStatus(false); + this.connectionPromise = null; // 重置连接Promise以便下次重试 + reject(error); + }); + + // 重新连接成功 + this.connection.onreconnected((connectionId) => { + console.log(`Reconnected to SignalR hub with connection ID: ${connectionId}`); + this.updateConnectionStatus(true); + // 重新注册所有事件监听器 + console.log(`Re-registering ${this.eventListeners.size} event listeners`); + this.eventListeners.forEach((callback, eventName) => { + console.log(`Re-registering listener for event: ${eventName}`); + // 先移除可能存在的旧监听器 + this.connection.off(eventName, callback); + // 然后添加新监听器 + this.connection.on(eventName, callback); + }); + }); + + // 连接断开 + this.connection.onclose((error) => { + console.log(`Disconnected from SignalR hub: ${error ? error.message : 'Unknown reason'}`); + this.updateConnectionStatus(false); + }); }); - }, - joinRoom(roomId, player) { - socket.emit('joinRoom', { roomId, player }); - }, - leaveRoom(roomId, playerId) { - socket.emit('leaveRoom', { roomId, playerId }); - }, - startGame(roomId) { - socket.emit('startGame', { roomId }); - }, - updateCanvas(data) { - socket.emit('updateCanvas', data); - }, - onPlayersUpdate(callback) { - socket.on('playersUpdate', callback); - }, - onGameStart(callback) { - socket.on('gameStart', callback); - }, - onGameEnd(callback) { - socket.on('gameEnd', callback); - }, -}; \ No newline at end of file + + return this.connectionPromise; + } + + // 等待连接成功后执行操作,增加默认超时时间到10秒 + waitForConnection(callback, timeout = 10000) { + const startTime = Date.now(); + + const checkConnection = () => { + if (this.isConnected.value) { + callback(); + } else if (Date.now() - startTime >= timeout) { + console.error('Timeout waiting for connection'); + } else { + setTimeout(checkConnection, 100); + } + }; + + checkConnection(); + } + + // 发送消息 (调用SignalR方法) + emit(methodName, ...args) { + // 确保连接已初始化 + if (!this.connection) { + console.error(`Cannot invoke ${methodName}: Connection not initialized. Call init() first.`); + // 自动初始化连接 + this.init() + .then(() => { + console.log(`Connection initialized, invoking ${methodName}...`); + this.emit(methodName, ...args); + }) + .catch(error => { + console.error(`Failed to initialize connection when invoking ${methodName}:`, error); + }); + return; + } + + // 检查连接状态 + if (!this.isConnected.value) { + console.error(`Cannot invoke ${methodName}: Connection is not in 'Connected' state. Current state: ${this.isConnected.value ? 'Connected' : 'Connecting/Disconnected'}`); + // 尝试重新连接 + this.init() + .then(() => { + console.log(`Connection restored, retrying ${methodName}...`); + this.emit(methodName, ...args); + }) + .catch(error => { + console.error(`Failed to reconnect when invoking ${methodName}:`, error); + }); + return; + } + + console.log(`Invoking SignalR method: ${methodName}`); + // 如果最后一个参数是函数,将其作为回调 + if (typeof args[args.length - 1] === 'function') { + const callback = args.pop(); + this.connection.invoke(methodName, ...args) + .then(result => { + console.log(`Successfully invoked ${methodName}`); + callback(result); + }) + .catch(error => console.error(`Error invoking ${methodName}:`, error)); + } else { + this.connection.invoke(methodName, ...args) + .then(() => console.log(`Successfully invoked ${methodName}`)) + .catch(error => console.error(`Error invoking ${methodName}:`, error)); + } + } + + // 监听消息 + on(eventName, callback) { + console.log(`Registering listener for event: ${eventName}`); + if (!this.connection) { + console.error(`Cannot listen to ${eventName}: Connection not initialized`); + // 存储监听器,等待连接初始化后注册 + this.eventListeners.set(eventName, callback); + return () => this.off(eventName, callback); + } + + this.connection.on(eventName, callback); + this.eventListeners.set(eventName, callback); + console.log(`Successfully registered listener for event: ${eventName}`); + return () => this.off(eventName, callback); + } + + // 移除监听 + off(eventName, callback) { + if (!this.connection) { + console.error(`Cannot remove listener for ${eventName}: Connection not initialized`); + if (callback) { + this.eventListeners.delete(eventName); + } + return; + } + + if (callback) { + this.connection.off(eventName, callback); + this.eventListeners.delete(eventName); + } else { + this.connection.off(eventName); + this.eventListeners.delete(eventName); + } + } + + // 断开连接 + disconnect() { + if (this.connection) { + this.connection.stop() + .then(() => { + console.log('Disconnected from SignalR hub'); + this.connection = null; + this.isConnected = false; + this.eventListeners.clear(); + }) + .catch(error => console.error('Error disconnecting:', error)); + } + } +} + +// 创建一个全局的socket服务实例 +const socketService = new SocketService(); + +export default socketService; +export { socketService }; + +export function useSocketService() { + return inject('socketService'); +} + +export function provideSocketService(app) { + app.provide('socketService', socketService); +} \ No newline at end of file diff --git a/frontend/src/stores/counter.js b/frontend/src/stores/counter.js new file mode 100644 index 0000000000000000000000000000000000000000..b6757ba5723c5b89b35d011b9558d025bbcde402 --- /dev/null +++ b/frontend/src/stores/counter.js @@ -0,0 +1,12 @@ +import { ref, computed } from 'vue' +import { defineStore } from 'pinia' + +export const useCounterStore = defineStore('counter', () => { + const count = ref(0) + const doubleCount = computed(() => count.value * 2) + function increment() { + count.value++ + } + + return { count, doubleCount, increment } +}) diff --git a/frontend/src/stores/game.js b/frontend/src/stores/game.js index c758f86ca9274685df0b3d93a92237837ccf299d..61156f833c8e690ad7938fa0e138f8646fe36430 100644 --- a/frontend/src/stores/game.js +++ b/frontend/src/stores/game.js @@ -2,28 +2,53 @@ import { defineStore } from 'pinia'; export const useGameStore = defineStore('game', { state: () => ({ + currentRoomId: '', isGameStarted: false, - currentRound: 1, - totalRounds: 3, - timer: 180, // 3 minutes in seconds - players: [], - canvasData: null, + isGameFinished: false, + gameWinner: null, + gameSettings: { + canvasWidth: 800, + canvasHeight: 600, + gameTime: 180, + protectionRadius: 50 + } }), + + getters: { + isInGame: (state) => !!state.currentRoomId, + }, + actions: { + // 设置当前房间 + setCurrentRoom(roomId) { + this.currentRoomId = roomId; + }, + + // 开始游戏 startGame() { this.isGameStarted = true; + this.isGameFinished = false; + this.gameWinner = null; }, - endGame() { + + // 结束游戏 + endGame(winner = null) { this.isGameStarted = false; + this.isGameFinished = true; + this.gameWinner = winner; }, - updateTimer(seconds) { - this.timer = seconds; - }, - updatePlayers(players) { - this.players = players; - }, - updateCanvasData(data) { - this.canvasData = data; + + // 重置游戏状态 + resetGame() { + this.currentRoomId = ''; + this.isGameStarted = false; + this.isGameFinished = false; + this.gameWinner = null; }, + + // 更新游戏设置 + updateGameSettings(settings) { + this.gameSettings = { ...this.gameSettings, ...settings }; + } }, }); \ No newline at end of file diff --git a/frontend/src/stores/player.js b/frontend/src/stores/player.js index 08cec4c2183c107102a386b238f6499b9d64fb9e..fe97365fb6418613566cb525d1a5927cc67bbc6d 100644 --- a/frontend/src/stores/player.js +++ b/frontend/src/stores/player.js @@ -2,22 +2,51 @@ import { defineStore } from 'pinia'; export const usePlayerStore = defineStore('player', { state: () => ({ - id: null, - name: '', - color: '', - area: 0, + playerId: '', + nickname: 'Player' + Math.floor(Math.random() * 1000), + color: '#3498db', isHost: false, + score: 0, + area: 0, + rank: 0 }), + + getters: { + // 获取玩家显示名称(带颜色标识) + displayName: (state) => state.nickname, + }, + actions: { - setPlayer(player) { - this.id = player.id; - this.name = player.name; - this.color = player.color; - this.area = player.area; - this.isHost = player.isHost; + // 设置玩家信息 + setPlayerInfo(info) { + this.playerId = info.id || this.playerId; + this.nickname = info.nickname || this.nickname; + this.color = info.color || this.color; + this.isHost = info.isHost || this.isHost; }, + + // 更新玩家分数 + updateScore(score) { + this.score = score; + }, + + // 更新玩家面积 updateArea(area) { this.area = area; }, + + // 更新玩家排名 + updateRank(rank) { + this.rank = rank; + }, + + // 重置玩家状态 + resetPlayer() { + this.playerId = ''; + this.isHost = false; + this.score = 0; + this.area = 0; + this.rank = 0; + } }, }); \ No newline at end of file diff --git a/frontend/src/stores/room.js b/frontend/src/stores/room.js deleted file mode 100644 index fcb744331eb8fbad6b3472df9f2f5b692cd7cc37..0000000000000000000000000000000000000000 --- a/frontend/src/stores/room.js +++ /dev/null @@ -1,27 +0,0 @@ -import { defineStore } from 'pinia'; - -export const useRoomStore = defineStore('room', { - state: () => ({ - roomId: null, - players: [], - maxPlayers: 6, - isReady: false, - }), - actions: { - setRoom(room) { - this.roomId = room.id; - this.players = room.players; - this.maxPlayers = room.maxPlayers; - this.isReady = room.isReady; - }, - addPlayer(player) { - this.players.push(player); - }, - removePlayer(playerId) { - this.players = this.players.filter(p => p.id !== playerId); - }, - setReady(isReady) { - this.isReady = isReady; - }, - }, -}); \ No newline at end of file diff --git a/frontend/src/style.css b/frontend/src/style.css deleted file mode 100644 index f6913154345dbceb3a705e2583ed9afb3857924e..0000000000000000000000000000000000000000 --- a/frontend/src/style.css +++ /dev/null @@ -1,79 +0,0 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -.card { - padding: 2em; -} - -#app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} diff --git a/frontend/src/utils/areaCalculator.js b/frontend/src/utils/areaCalculator.js index 5563cc63f9f42185518e4708054a852a5e2daeb4..2df652b98230d503a2191f088d2f177c29c2027b 100644 --- a/frontend/src/utils/areaCalculator.js +++ b/frontend/src/utils/areaCalculator.js @@ -1,4 +1,264 @@ -export const calculateArea = (canvasData, playerColor) => { - // TODO: Implement area calculation logic - return 0; -}; \ No newline at end of file +// 面积计算器工具 + +/** + * 计算多边形面积 + * @param {Array} points - 多边形顶点坐标数组,格式: [{x, y}, {x, y}, ...] + * @returns {number} 面积 + */ +export function calculatePolygonArea(points) { + if (points.length < 3) return 0; + + let area = 0; + const n = points.length; + + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + area += points[i].x * points[j].y; + area -= points[j].x * points[i].y; + } + + return Math.abs(area) / 2; +} + +/** + * 计算画布上特定颜色区域的面积 + * @param {CanvasRenderingContext2D} ctx - 画布上下文 + * @param {string} color - 要计算的颜色 + * @param {Object} [options={}] - 可选参数 + * @param {number} [options.x=0] - 起始X坐标 + * @param {number} [options.y=0] - 起始Y坐标 + * @param {number} [options.width=ctx.canvas.width] - 宽度 + * @param {number} [options.height=ctx.canvas.height] - 高度 + * @returns {Promise} 面积 + */ +export function calculateAreaByColor(ctx, color, options = {}) { + // 优化:使用Web Worker进行计算,避免阻塞主线程 + return new Promise((resolve) => { + // 创建一个临时的worker + const worker = new Worker(URL.createObjectURL(new Blob([` + self.onmessage = function(e) { + const { imageData, rgbColor, width, height } = e.data; + const data = imageData.data; + let area = 0; + + // 遍历指定区域的像素 + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const index = (y * width + x) * 4; + const r = data[index]; + const g = data[index + 1]; + const b = data[index + 2]; + const a = data[index + 3]; + + // 如果像素颜色匹配且不透明 + if ( + r === rgbColor.r && + g === rgbColor.g && + b === rgbColor.b && + a > 128 + ) { + area++; + } + } + } + + self.postMessage(area); + self.close(); + } + `], { type: 'application/javascript' }))); + + // 提取选项 + const { x = 0, y = 0, width = ctx.canvas.width, height = ctx.canvas.height } = options; + + // 将颜色转换为RGB值 + const rgbColor = hexToRgb(color); + if (!rgbColor) { + resolve(0); + return; + } + + // 获取指定区域的图像数据 + const imageData = ctx.getImageData(x, y, width, height); + + // 向worker发送数据 + worker.postMessage({ + imageData, + rgbColor, + width, + height + }); + + // 接收计算结果 + worker.onmessage = function(e) { + resolve(e.data); + }; + + // 处理错误 + worker.onerror = function() { + resolve(0); + }; + }); +} + +/** + * 将十六进制颜色转换为RGB值 + * @param {string} hex - 十六进制颜色字符串 + * @returns {{r: number, g: number, b: number} | null} RGB值对象 + */ +function hexToRgb(hex) { + // 移除#前缀(如果有) + hex = hex.replace(/^#/, ''); + + // 检查是否为有效的十六进制颜色 + if (!/^[0-9A-F]{3}$|^[0-9A-F]{6}$/i.test(hex)) { + return null; + } + + // 处理简写形式(如#RGB) + if (hex.length === 3) { + hex = hex + .split('') + .map((char) => char + char) + .join(''); + } + + // 解析RGB值 + const bigint = parseInt(hex, 16); + const r = (bigint >> 16) & 255; + const g = (bigint >> 8) & 255; + const b = bigint & 255; + + return { r, g, b }; +} + +/** + * 计算两条线段的交点 + * @param {Object} line1 - 第一条线段,格式: {start: {x, y}, end: {x, y}} + * @param {Object} line2 - 第二条线段,格式: {start: {x, y}, end: {x, y}} + * @returns {{x: number, y: number} | null} 交点坐标,如果没有交点则返回null + */ +export function calculateLineIntersection(line1, line2) { + const { start: a1, end: a2 } = line1; + const { start: b1, end: b2 } = line2; + + const denominator = + (a1.x - a2.x) * (b1.y - b2.y) - (a1.y - a2.y) * (b1.x - b2.x); + + if (denominator === 0) return null; // 平行或共线 + + const tNumerator = + (a1.x - b1.x) * (b1.y - b2.y) - (a1.y - b1.y) * (b1.x - b2.x); + const uNumerator = + -((a1.x - a2.x) * (a1.y - b1.y) - (a1.y - a2.y) * (a1.x - b1.x)); + + const t = tNumerator / denominator; + const u = uNumerator / denominator; + + if (t >= 0 && t <= 1 && u >= 0 && u <= 1) { + return { + x: a1.x + t * (a2.x - a1.x), + y: a1.y + t * (a2.y - a1.y), + }; + } + + return null; +} + +/** + * 检查点是否在多边形内部 + * @param {Object} point - 点坐标,格式: {x, y} + * @param {Array} polygon - 多边形顶点坐标数组,格式: [{x, y}, {x, y}, ...] + * @returns {boolean} 是否在内部 + */ +export function isPointInPolygon(point, polygon) { + let inside = false; + const n = polygon.length; + + for (let i = 0, j = n - 1; i < n; j = i++) { + const xi = polygon[i].x, yi = polygon[i].y; + const xj = polygon[j].x, yj = polygon[j].y; + + const intersect = + ((yi > point.y) !== (yj > point.y)) && + (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi); + + if (intersect) inside = !inside; + } + + return inside; +} + +/** + * 简化多边形顶点(使用道格拉斯-普克算法) + * @param {Array} points - 多边形顶点坐标数组 + * @param {number} epsilon - 容差 + * @returns {Array} 简化后的顶点数组 + */ +export function simplifyPolygon(points, epsilon) { + if (points.length <= 2) return points; + + // 找到距离最大的点 + let maxDist = 0; + let index = 0; + const line = { start: points[0], end: points[points.length - 1] }; + + for (let i = 1; i < points.length - 1; i++) { + const dist = distanceFromPointToLine(points[i], line); + if (dist > maxDist) { + maxDist = dist; + index = i; + } + } + + // 如果最大距离大于容差,则递归简化 + if (maxDist > epsilon) { + const left = simplifyPolygon(points.slice(0, index + 1), epsilon); + const right = simplifyPolygon(points.slice(index), epsilon); + return left.slice(0, -1).concat(right); + } else { + return [points[0], points[points.length - 1]]; + } +} + +/** + * 计算点到线段的距离 + * @param {Object} point - 点坐标 + * @param {Object} line - 线段,格式: {start: {x, y}, end: {x, y}} + * @returns {number} 距离 + */ +function distanceFromPointToLine(point, line) { + const { start, end } = line; + + const A = point.x - start.x; + const B = point.y - start.y; + const C = end.x - start.x; + const D = end.y - start.y; + + const dot = A * C + B * D; + const lenSq = C * C + D * D; + let param = -1; + + if (lenSq !== 0) param = dot / lenSq; + + let xx, yy; + + if (param < 0) { + xx = start.x; + yy = start.y; + } else if (param > 1) { + xx = end.x; + yy = end.y; + } else { + xx = start.x + param * C; + yy = start.y + param * D; + } + + const dx = point.x - xx; + const dy = point.y - yy; + return Math.sqrt(dx * dx + dy * dy); +} + +// 导出默认函数和命名函数 +const calculateArea = calculateAreaByColor; +export default calculateArea; +export { calculateArea }; \ No newline at end of file diff --git a/frontend/src/utils/canvas.js b/frontend/src/utils/canvas.js deleted file mode 100644 index 4549a4433b2971189ab986c7d6789bc4d56d8ea1..0000000000000000000000000000000000000000 --- a/frontend/src/utils/canvas.js +++ /dev/null @@ -1,13 +0,0 @@ -export const initCanvas = (canvas, width, height) => { - const ctx = canvas.getContext('2d'); - canvas.width = width; - canvas.height = height; - ctx.fillStyle = '#f0f0f0'; - ctx.fillRect(0, 0, width, height); - return ctx; -}; - -export const drawOnCanvas = (ctx, x, y, color) => { - ctx.fillStyle = color; - ctx.fillRect(x, y, 10, 10); -}; \ No newline at end of file diff --git a/frontend/src/utils/gameLogic.js b/frontend/src/utils/gameLogic.js deleted file mode 100644 index e41abe6bfabea9c75b8418abc2573df3f20e317c..0000000000000000000000000000000000000000 --- a/frontend/src/utils/gameLogic.js +++ /dev/null @@ -1,6 +0,0 @@ -export const checkWinner = (players) => { - if (!players || players.length === 0) return null; - return players.reduce((prev, current) => { - return prev.area > current.area ? prev : current; - }); -}; \ No newline at end of file diff --git a/frontend/src/utils/gameUtils.js b/frontend/src/utils/gameUtils.js new file mode 100644 index 0000000000000000000000000000000000000000..99501761b61803e76f56bb1686868ff7d33e54bd --- /dev/null +++ b/frontend/src/utils/gameUtils.js @@ -0,0 +1,37 @@ +// 生成随机颜色 + export function getRandomColor() { + const letters = '0123456789ABCDEF'; + let color = '#'; + for (let i = 0; i < 6; i++) { + color += letters[Math.floor(Math.random() * 16)]; + } + return color; +} + +// 计算两点之间的距离 + export function calculateDistance(x1, y1, x2, y2) { + return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); +} + +// 限制值在范围内 + export function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} + +// 格式化时间(秒转分:秒) + export function formatTime(seconds) { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; +} + +// 生成唯一ID + export function generateId() { + return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); +} + +// 检测点是否在圆形区域内 + export function isPointInCircle(pointX, pointY, circleX, circleY, radius) { + const distance = calculateDistance(pointX, pointY, circleX, circleY); + return distance <= radius; +} \ No newline at end of file diff --git a/frontend/src/views/AboutView.vue b/frontend/src/views/AboutView.vue new file mode 100644 index 0000000000000000000000000000000000000000..756ad2a17909837834858538422308120cf09dab --- /dev/null +++ b/frontend/src/views/AboutView.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/views/DrawingTestView.vue b/frontend/src/views/DrawingTestView.vue new file mode 100644 index 0000000000000000000000000000000000000000..b6a7ef844ad297674255f9a7e24b5baf3cd29634 --- /dev/null +++ b/frontend/src/views/DrawingTestView.vue @@ -0,0 +1,95 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/GameRoomView.vue b/frontend/src/views/GameRoomView.vue new file mode 100644 index 0000000000000000000000000000000000000000..e1af08f2985d22f95786234b779c8b8b2810e2eb --- /dev/null +++ b/frontend/src/views/GameRoomView.vue @@ -0,0 +1,496 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue new file mode 100644 index 0000000000000000000000000000000000000000..1609678085728e41d3b83b935b150c033fd32c96 --- /dev/null +++ b/frontend/src/views/HomeView.vue @@ -0,0 +1,281 @@ + + + + + diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2a0061d6e6e1f271b21da95423b448b32..0000000000000000000000000000000000000000 --- a/frontend/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json deleted file mode 100644 index 3dbbc453c3027281f2b50fc96ffca6213e1ff799..0000000000000000000000000000000000000000 --- a/frontend/tsconfig.app.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "@vue/tsconfig/tsconfig.dom.json", - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] -} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json deleted file mode 100644 index 1ffef600d959ec9e396d5a260bd3f5b927b2cef8..0000000000000000000000000000000000000000 --- a/frontend/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] -} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json deleted file mode 100644 index f85a39906e5571aa351e61e43fff98bc0bedaa27..0000000000000000000000000000000000000000 --- a/frontend/tsconfig.node.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2023", - "lib": ["ES2023"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["vite.config.ts"] -} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index d89b5451f445bd67d44b015a8349ee20a888d74e..4217010a3178372181948ce34c4d5045dfa18325 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,12 +1,18 @@ -import { defineConfig } from 'vite'; -import vue from '@vitejs/plugin-vue'; -import path from 'path'; +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import vueDevTools from 'vite-plugin-vue-devtools' + +// https://vite.dev/config/ export default defineConfig({ - plugins: [vue()], + plugins: [ + vue(), + vueDevTools(), + ], resolve: { alias: { - '@': path.resolve(__dirname, './src'), + '@': fileURLToPath(new URL('./src', import.meta.url)) }, }, -}); \ No newline at end of file +}) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts deleted file mode 100644 index bbcf80cca93e72ac5583c66d220957216a001f94..0000000000000000000000000000000000000000 --- a/frontend/vite.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vite' -import vue from '@vitejs/plugin-vue' - -// https://vite.dev/config/ -export default defineConfig({ - plugins: [vue()], -}) diff --git a/query b/query new file mode 100644 index 0000000000000000000000000000000000000000..7800f0fad3fff739d0f61678a666b7a25d0fca50 --- /dev/null +++ b/query @@ -0,0 +1 @@ +redis diff --git a/start.bat b/start.bat new file mode 100644 index 0000000000000000000000000000000000000000..a53d77e78c4d1deb287c64c7783dc71a996907cf --- /dev/null +++ b/start.bat @@ -0,0 +1,13 @@ +@echo off + +:: 启动后端服务 +start cmd /k "cd backend\src\TerritoryGame.API && dotnet run" + +:: 等待后端启动 +ping 127.0.0.1 -n 5 > nul + +:: 启动前端服务 +start cmd /k "cd frontend && npm install && npm run dev" + +:: 打开浏览器 +start http://localhost:5173 \ No newline at end of file diff --git a/docs/REQUIREMENTS.md "b/\351\234\200\346\261\202\346\226\207\346\241\243.md" similarity index 99% rename from docs/REQUIREMENTS.md rename to "\351\234\200\346\261\202\346\226\207\346\241\243.md" index 7404fa191751e4a2358546d714fb64d20605b048..6372bbc30e918007a7554dbbce7cd2af11bc686c 100644 --- a/docs/REQUIREMENTS.md +++ "b/\351\234\200\346\261\202\346\226\207\346\241\243.md" @@ -75,7 +75,7 @@ ### 前端架构 - **框架**:Vue 3 + Composition API - **状态管理**:Pinia -- **实时通信**:Socket.io-client +- **实时通信**:@microsoft/signalr - **Canvas库**:原生 Canvas API(简单直接) - **UI组件**:Element Plus @@ -721,4 +721,4 @@ public class GameStartedEventHandler : IDomainEventHandler - **查询侧**:负责读操作,可以直接查询数据库或缓存 - **事件同步**:通过领域事件保持读写两侧数据同步 -这份DDD实践指导为您的学生提供了具体的代码示例和实现要点,帮助他们更好地理解和应用DDD架构模式。 +这份DDD实践指导为您的学生提供了具体的代码示例和实现要点,帮助他们更好地理解和应用DDD架构模式。 \ No newline at end of file