From 9b4510c72ff4901a03904ba98a3bfa17276ee476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=A2=E9=9B=A8=E6=99=B4?= <1207713896@qq.com> Date: Tue, 19 Aug 2025 15:15:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=A4=A7=E9=83=A8=E5=88=86?= =?UTF-8?q?=EF=BC=8C=E7=8E=B0=E5=9C=A8=E4=B8=BB=E8=A6=81=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E6=9F=A5=E8=AF=A2=E4=B8=8D=E5=88=B0?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=EF=BC=8C=E7=8E=A9=E5=AE=B6=E4=B9=8B=E9=97=B4?= =?UTF-8?q?=E6=80=8E=E4=B9=88=E8=81=94=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md => 1.md | 2 +- DEPLOYMENT.md | 295 ++++++++++ backend/DependencyInjectionTest.cs | 34 ++ .../DependencyInjectionTest.csproj | 15 + backend/DependencyInjectionTest/Program.cs | 34 ++ .../GameServiceTest/GameServiceTest.csproj | 19 + backend/GameServiceTest/Program.cs | 17 + backend/README.md | 64 +++ backend/SimpleDI/Program.cs | 55 ++ backend/SimpleDI/SimpleDI.csproj | 15 + backend/SimpleTest/Program.cs | 36 ++ backend/SimpleTest/SimpleTest.csproj | 15 + backend/TerritoryGame.Api/Program.cs | 15 - .../TerritoryGame.Api/TerritoryGame.Api.http | 6 - .../TerritoryGame.Application.csproj | 9 - .../TerritoryGame.Infrastructure.csproj | 9 - backend/TestDI/Program.cs | 39 ++ .../TestDI.csproj} | 6 +- backend/TestGameService.cs | 32 ++ backend/TestWebApi/Program.cs | 35 ++ .../TestWebApi/Properties/launchSettings.json | 23 + backend/TestWebApi/TestWebApi.csproj | 14 + .../appsettings.json | 0 .../Controllers/GameController.cs | 280 +++++++++ backend/src/TerritoryGame.Api/Hubs/GameHub.cs | 330 +++++++++++ backend/src/TerritoryGame.Api/Program.cs | 79 +++ .../Properties/launchSettings.json | 4 +- .../TerritoryGame.Api.csproj | 23 + .../TerritoryGame.Api/TerritoryGame.Api.http | 71 +++ .../src/TerritoryGame.Api/appsettings.json | 13 + .../TerritoryGame.Application/Class1.cs | 0 .../Interfaces/IGameService.cs | 20 + .../Services/AreaCalculationService.cs | 80 +++ .../Services/GameService.cs | 510 +++++++++++++++++ .../TerritoryGame.Application.csproj | 19 + .../{ => src}/TerritoryGame.Domain/Class1.cs | 0 .../TerritoryGame.Domain/Entities/GameRoom.cs | 96 ++++ .../Entities/GameStats.cs | 11 + .../Entities/PaintAction.cs | 25 + .../TerritoryGame.Domain/Entities/Player.cs | 37 ++ .../Entities/Spectator.cs | 17 + .../Interfaces/IGameDbContext.cs | 14 + .../Services/IAreaCalculationService.cs | 13 + .../Services/IGameService.cs | 33 ++ .../TerritoryGame.Domain.csproj | 8 +- .../TerritoryGame.Infrastructure/Class1.cs | 0 .../Data/GameDbContext.cs | 78 +++ .../DependencyInjection.cs | 38 ++ .../20250818033155_InitialCreate.Designer.cs | 152 +++++ .../20250818033155_InitialCreate.cs | 94 ++++ ...250819033027_AddSpectatorTable.Designer.cs | 190 +++++++ .../20250819033027_AddSpectatorTable.cs | 48 ++ .../Migrations/GameDbContextModelSnapshot.cs | 187 ++++++ .../Services/AreaCalculationService.cs | 71 +++ .../TerritoryGame.Infrastructure.csproj | 25 + backend/{ => src}/TerritoryGame.sln | 107 ++-- frontend/.editorconfig | 8 + frontend/.gitattributes | 1 + frontend/.gitignore | 8 +- frontend/.prettierrc.json | 6 + frontend/README.md | 36 +- frontend/eslint.config.js | 26 + frontend/index.html | 14 +- frontend/jsconfig.json | 8 + frontend/package.json | 32 +- frontend/public/favicon.ico | Bin 0 -> 4286 bytes frontend/public/vite.svg | 1 - frontend/src/App.vue | 140 +++-- frontend/src/assets/base.css | 105 ++++ frontend/src/assets/logo.svg | 1 + frontend/src/assets/main.css | 134 +++++ frontend/src/assets/vue.svg | 1 - frontend/src/components/AreaStats.vue | 254 ++++++++- frontend/src/components/ChatComponent.vue | 195 +++++++ frontend/src/components/CreateRoom.vue | 532 ++++++++++++++++++ frontend/src/components/GameCanvas.vue | 370 +++++++++++- frontend/src/components/GameTimer.vue | 150 +++-- frontend/src/components/HelloWorld.vue | 67 +-- frontend/src/components/Leaderboard.vue | 119 ++++ frontend/src/components/LoadingScreen.vue | 89 +++ frontend/src/components/PlayerList.vue | 163 +++++- frontend/src/components/ResultScreen.vue | 229 ++++++++ frontend/src/components/RoomList.vue | 525 +++++++++++++++++ frontend/src/components/RoomLobby.vue | 70 --- frontend/src/components/TheWelcome.vue | 94 ++++ frontend/src/components/WelcomeItem.vue | 86 +++ .../src/components/icons/IconCommunity.vue | 7 + .../components/icons/IconDocumentation.vue | 7 + .../src/components/icons/IconEcosystem.vue | 7 + frontend/src/components/icons/IconSupport.vue | 7 + frontend/src/components/icons/IconTooling.vue | 19 + frontend/src/main.js | 71 ++- frontend/src/main.ts | 5 - frontend/src/router/index.js | 32 ++ frontend/src/services/audioService.js | 89 +++ frontend/src/services/gameService.js | 26 - frontend/src/services/resourceLoader.js | 172 ++++++ frontend/src/services/socketService.js | 274 +++++++-- frontend/src/stores/counter.js | 12 + frontend/src/stores/game.js | 53 +- frontend/src/stores/player.js | 49 +- frontend/src/stores/room.js | 27 - frontend/src/style.css | 79 --- frontend/src/utils/areaCalculator.js | 268 ++++++++- frontend/src/utils/canvas.js | 13 - frontend/src/utils/gameLogic.js | 6 - frontend/src/utils/gameUtils.js | 37 ++ frontend/src/views/AboutView.vue | 15 + frontend/src/views/DrawingTestView.vue | 95 ++++ frontend/src/views/GameRoomView.vue | 496 ++++++++++++++++ frontend/src/views/HomeView.vue | 281 +++++++++ frontend/src/vite-env.d.ts | 1 - frontend/tsconfig.app.json | 15 - frontend/tsconfig.json | 7 - frontend/tsconfig.node.json | 25 - frontend/vite.config.js | 18 +- frontend/vite.config.ts | 7 - query | 1 + start.bat | 13 + ...00\346\261\202\346\226\207\346\241\243.md" | 4 +- 120 files changed, 8493 insertions(+), 671 deletions(-) rename README.md => 1.md (99%) create mode 100644 DEPLOYMENT.md create mode 100644 backend/DependencyInjectionTest.cs create mode 100644 backend/DependencyInjectionTest/DependencyInjectionTest.csproj create mode 100644 backend/DependencyInjectionTest/Program.cs create mode 100644 backend/GameServiceTest/GameServiceTest.csproj create mode 100644 backend/GameServiceTest/Program.cs create mode 100644 backend/README.md create mode 100644 backend/SimpleDI/Program.cs create mode 100644 backend/SimpleDI/SimpleDI.csproj create mode 100644 backend/SimpleTest/Program.cs create mode 100644 backend/SimpleTest/SimpleTest.csproj delete mode 100644 backend/TerritoryGame.Api/Program.cs delete mode 100644 backend/TerritoryGame.Api/TerritoryGame.Api.http delete mode 100644 backend/TerritoryGame.Application/TerritoryGame.Application.csproj delete mode 100644 backend/TerritoryGame.Infrastructure/TerritoryGame.Infrastructure.csproj create mode 100644 backend/TestDI/Program.cs rename backend/{TerritoryGame.Api/TerritoryGame.Api.csproj => TestDI/TestDI.csproj} (53%) create mode 100644 backend/TestGameService.cs create mode 100644 backend/TestWebApi/Program.cs create mode 100644 backend/TestWebApi/Properties/launchSettings.json create mode 100644 backend/TestWebApi/TestWebApi.csproj rename backend/{TerritoryGame.Api => TestWebApi}/appsettings.json (100%) create mode 100644 backend/src/TerritoryGame.Api/Controllers/GameController.cs create mode 100644 backend/src/TerritoryGame.Api/Hubs/GameHub.cs create mode 100644 backend/src/TerritoryGame.Api/Program.cs rename backend/{ => src}/TerritoryGame.Api/Properties/launchSettings.json (80%) create mode 100644 backend/src/TerritoryGame.Api/TerritoryGame.Api.csproj create mode 100644 backend/src/TerritoryGame.Api/TerritoryGame.Api.http create mode 100644 backend/src/TerritoryGame.Api/appsettings.json rename backend/{ => src}/TerritoryGame.Application/Class1.cs (100%) create mode 100644 backend/src/TerritoryGame.Application/Interfaces/IGameService.cs create mode 100644 backend/src/TerritoryGame.Application/Services/AreaCalculationService.cs create mode 100644 backend/src/TerritoryGame.Application/Services/GameService.cs create mode 100644 backend/src/TerritoryGame.Application/TerritoryGame.Application.csproj rename backend/{ => src}/TerritoryGame.Domain/Class1.cs (100%) create mode 100644 backend/src/TerritoryGame.Domain/Entities/GameRoom.cs create mode 100644 backend/src/TerritoryGame.Domain/Entities/GameStats.cs create mode 100644 backend/src/TerritoryGame.Domain/Entities/PaintAction.cs create mode 100644 backend/src/TerritoryGame.Domain/Entities/Player.cs create mode 100644 backend/src/TerritoryGame.Domain/Entities/Spectator.cs create mode 100644 backend/src/TerritoryGame.Domain/Interfaces/IGameDbContext.cs create mode 100644 backend/src/TerritoryGame.Domain/Services/IAreaCalculationService.cs create mode 100644 backend/src/TerritoryGame.Domain/Services/IGameService.cs rename backend/{ => src}/TerritoryGame.Domain/TerritoryGame.Domain.csproj (39%) rename backend/{ => src}/TerritoryGame.Infrastructure/Class1.cs (100%) create mode 100644 backend/src/TerritoryGame.Infrastructure/Data/GameDbContext.cs create mode 100644 backend/src/TerritoryGame.Infrastructure/DependencyInjection.cs create mode 100644 backend/src/TerritoryGame.Infrastructure/Migrations/20250818033155_InitialCreate.Designer.cs create mode 100644 backend/src/TerritoryGame.Infrastructure/Migrations/20250818033155_InitialCreate.cs create mode 100644 backend/src/TerritoryGame.Infrastructure/Migrations/20250819033027_AddSpectatorTable.Designer.cs create mode 100644 backend/src/TerritoryGame.Infrastructure/Migrations/20250819033027_AddSpectatorTable.cs create mode 100644 backend/src/TerritoryGame.Infrastructure/Migrations/GameDbContextModelSnapshot.cs create mode 100644 backend/src/TerritoryGame.Infrastructure/Services/AreaCalculationService.cs create mode 100644 backend/src/TerritoryGame.Infrastructure/TerritoryGame.Infrastructure.csproj rename backend/{ => src}/TerritoryGame.sln (35%) create mode 100644 frontend/.editorconfig create mode 100644 frontend/.gitattributes create mode 100644 frontend/.prettierrc.json create mode 100644 frontend/eslint.config.js create mode 100644 frontend/jsconfig.json create mode 100644 frontend/public/favicon.ico delete mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/assets/base.css create mode 100644 frontend/src/assets/logo.svg create mode 100644 frontend/src/assets/main.css delete mode 100644 frontend/src/assets/vue.svg create mode 100644 frontend/src/components/ChatComponent.vue create mode 100644 frontend/src/components/CreateRoom.vue create mode 100644 frontend/src/components/Leaderboard.vue create mode 100644 frontend/src/components/LoadingScreen.vue create mode 100644 frontend/src/components/ResultScreen.vue create mode 100644 frontend/src/components/RoomList.vue delete mode 100644 frontend/src/components/RoomLobby.vue create mode 100644 frontend/src/components/TheWelcome.vue create mode 100644 frontend/src/components/WelcomeItem.vue create mode 100644 frontend/src/components/icons/IconCommunity.vue create mode 100644 frontend/src/components/icons/IconDocumentation.vue create mode 100644 frontend/src/components/icons/IconEcosystem.vue create mode 100644 frontend/src/components/icons/IconSupport.vue create mode 100644 frontend/src/components/icons/IconTooling.vue delete mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/services/audioService.js delete mode 100644 frontend/src/services/gameService.js create mode 100644 frontend/src/services/resourceLoader.js create mode 100644 frontend/src/stores/counter.js delete mode 100644 frontend/src/stores/room.js delete mode 100644 frontend/src/style.css delete mode 100644 frontend/src/utils/canvas.js delete mode 100644 frontend/src/utils/gameLogic.js create mode 100644 frontend/src/utils/gameUtils.js create mode 100644 frontend/src/views/AboutView.vue create mode 100644 frontend/src/views/DrawingTestView.vue create mode 100644 frontend/src/views/GameRoomView.vue create mode 100644 frontend/src/views/HomeView.vue delete mode 100644 frontend/src/vite-env.d.ts delete mode 100644 frontend/tsconfig.app.json delete mode 100644 frontend/tsconfig.json delete mode 100644 frontend/tsconfig.node.json delete mode 100644 frontend/vite.config.ts create mode 100644 query create mode 100644 start.bat rename docs/REQUIREMENTS.md => "\351\234\200\346\261\202\346\226\207\346\241\243.md" (99%) diff --git a/README.md b/1.md similarity index 99% rename from README.md rename to 1.md index 979f6c9..9ab6264 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 0000000..0d5285d --- /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 0000000..3c680c1 --- /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 0000000..b4c478e --- /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 0000000..342b680 --- /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 0000000..54b5f7c --- /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 0000000..13cd882 --- /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 0000000..d7841b0 --- /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 0000000..29e3f3f --- /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 0000000..e783f09 --- /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 0000000..abaca28 --- /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 0000000..e783f09 --- /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 788187e..0000000 --- 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 d6a785f..0000000 --- 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 125f4c9..0000000 --- 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 125f4c9..0000000 --- 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 0000000..8bfcce1 --- /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 653803b..299b09f 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 0000000..c53b18c --- /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 0000000..e99d5e9 --- /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 0000000..5b466eb --- /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 0000000..b43a305 --- /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 0000000..ba33bf6 --- /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 0000000..1845482 --- /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 0000000..46e60c8 --- /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 aac7f91..cb1a2f7 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 0000000..6bd1a28 --- /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 0000000..b24cbe7 --- /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 0000000..b96e164 --- /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 0000000..12e8b34 --- /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 0000000..0eac94a --- /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 0000000..a8451e7 --- /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 0000000..9035326 --- /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 0000000..4eba143 --- /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 0000000..89cacb9 --- /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 0000000..9ecd7e8 --- /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 0000000..69b61a4 --- /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 0000000..6eadf53 --- /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 0000000..ea40f9c --- /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 0000000..0c0855b --- /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 0000000..e237c57 --- /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 125f4c9..3e657bd 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 0000000..c83184e --- /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 0000000..2801080 --- /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 0000000..fd64511 --- /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 0000000..8cdd166 --- /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 0000000..963ca12 --- /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 0000000..07b800d --- /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 0000000..4b35cc3 --- /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 0000000..6a98250 --- /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 0000000..79a91c1 --- /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 d5d3207..709b8d6 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 0000000..3b510aa --- /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 0000000..6313b56 --- /dev/null +++ b/frontend/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..8ee54e8 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 0000000..29a2402 --- /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 33895ab..29ee773 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 0000000..5a1f2d2 --- /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 eac0ec7..af28ab9 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 GIT binary patch literal 4286 zcmds*O-Phc6o&64GDVCEQHxsW(p4>LW*W<827=Unuo8sGpRux(DN@jWP-e29Wl%wj zY84_aq9}^Am9-cWTD5GGEo#+5Fi2wX_P*bo+xO!)p*7B;iKlbFd(U~_d(U?#hLj56 zPhFkj-|A6~Qk#@g^#D^U0XT1cu=c-vu1+SElX9NR;kzAUV(q0|dl0|%h|dI$%VICy zJnu2^L*Te9JrJMGh%-P79CL0}dq92RGU6gI{v2~|)p}sG5x0U*z<8U;Ij*hB9z?ei z@g6Xq-pDoPl=MANPiR7%172VA%r)kevtV-_5H*QJKFmd;8yA$98zCxBZYXTNZ#QFk2(TX0;Y2dt&WitL#$96|gJY=3xX zpCoi|YNzgO3R`f@IiEeSmKrPSf#h#Qd<$%Ej^RIeeYfsxhPMOG`S`Pz8q``=511zm zAm)MX5AV^5xIWPyEu7u>qYs?pn$I4nL9J!=K=SGlKLXpE<5x+2cDTXq?brj?n6sp= zphe9;_JHf40^9~}9i08r{XM$7HB!`{Ys~TK0kx<}ZQng`UPvH*11|q7&l9?@FQz;8 zx!=3<4seY*%=OlbCbcae?5^V_}*K>Uo6ZWV8mTyE^B=DKy7-sdLYkR5Z?paTgK-zyIkKjIcpyO z{+uIt&YSa_$QnN_@t~L014dyK(fOOo+W*MIxbA6Ndgr=Y!f#Tokqv}n<7-9qfHkc3 z=>a|HWqcX8fzQCT=dqVbogRq!-S>H%yA{1w#2Pn;=e>JiEj7Hl;zdt-2f+j2%DeVD zsW0Ab)ZK@0cIW%W7z}H{&~yGhn~D;aiP4=;m-HCo`BEI+Kd6 z={Xwx{TKxD#iCLfl2vQGDitKtN>z|-AdCN|$jTFDg0m3O`WLD4_s#$S literal 0 HcmV?d00001 diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- 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 cea8dbf..9a4e301 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 0000000..96f84ec --- /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 0000000..7565660 --- /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 0000000..fa7261b --- /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 770e9d3..0000000 --- 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 0da0466..6863b02 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 0000000..cb51d9f --- /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 0000000..ed2a689 --- /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 f8f869a..be6c690 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 0d35ed2..fb29382 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 b58e52b..eff59f1 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 0000000..d5baa7c --- /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 0000000..c54d096 --- /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 c2b0174..05b9c2f 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 0000000..1e1bb82 --- /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 0000000..7bfb4c9 --- /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 ea0604a..0000000 --- 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 0000000..fe48afc --- /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 0000000..ac366d0 --- /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 0000000..2dc8b05 --- /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 0000000..6d4791c --- /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 0000000..c3a4f07 --- /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 0000000..7452834 --- /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 0000000..660598d --- /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 d0e8486..79eab09 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 2425c0f..0000000 --- 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 0000000..f7807fc --- /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 0000000..0d2aa36 --- /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 3c3c2ea..0000000 --- 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 0000000..a196fca --- /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 47093be..3496b59 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 0000000..b6757ba --- /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 c758f86..61156f8 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 08cec4c..fe97365 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 fcb7443..0000000 --- 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 f691315..0000000 --- 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 5563cc6..2df652b 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 4549a44..0000000 --- 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 e41abe6..0000000 --- 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 0000000..9950176 --- /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 0000000..756ad2a --- /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 0000000..b6a7ef8 --- /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 0000000..e1af08f --- /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 0000000..1609678 --- /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 11f02fe..0000000 --- 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 3dbbc45..0000000 --- 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 1ffef60..0000000 --- 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 f85a399..0000000 --- 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 d89b545..4217010 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 bbcf80c..0000000 --- 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 0000000..7800f0f --- /dev/null +++ b/query @@ -0,0 +1 @@ +redis diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..a53d77e --- /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 7404fa1..6372bbc 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 -- Gitee