diff --git a/backend/src/TerritoryGame.Api/Controllers/RoomsController.cs b/backend/src/TerritoryGame.Api/Controllers/RoomsController.cs index b84863ece40578a4e9b03d9093ea5d9781ed3014..592b23d981a1b2b001ba477af0d4fd257ae81419 100644 --- a/backend/src/TerritoryGame.Api/Controllers/RoomsController.cs +++ b/backend/src/TerritoryGame.Api/Controllers/RoomsController.cs @@ -155,6 +155,11 @@ namespace TerritoryGame.Api.Controllers } var room = await _gameService.CreateRoomAsync(userId, request.RoomName, request.MaxPlayers); + // 如果客户端传了游戏时长,覆盖到房间配置 + if (room != null && request.GameDuration > 0) + { + room.GameDuration = request.GameDuration; + } if (room == null) { return BadRequest(ApiResult.Failed(400, "创建房间失败")); diff --git a/backend/src/TerritoryGame.Api/Program.cs b/backend/src/TerritoryGame.Api/Program.cs index 163473272d042eae7865e2da07edaa116698dd3d..5b9d937e455989a154af7990c2bed1bb60491ea6 100644 --- a/backend/src/TerritoryGame.Api/Program.cs +++ b/backend/src/TerritoryGame.Api/Program.cs @@ -16,6 +16,7 @@ using System.Text; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization.Policy; using TerritoryGame.Api.Middleware; +using Microsoft.AspNetCore.HttpOverrides; var builder = WebApplication.CreateBuilder(args); @@ -49,6 +50,21 @@ builder.Services.AddAuthentication("Bearer") IssuerSigningKey = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey( System.Text.Encoding.UTF8.GetBytes(builder.Configuration["JwtSettings:SecretKey"]!)) }; + + // 允许 SignalR 通过查询字符串传递 access_token(用于 WebSocket 连接) + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + var path = context.HttpContext.Request.Path; + if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/gamehub")) + { + context.Token = accessToken!; + } + return Task.CompletedTask; + } + }; }); builder.Services.AddAuthorization(); @@ -77,6 +93,12 @@ if (app.Environment.IsDevelopment()) app.UseSwaggerUI(); } +// 让应用识别来自反向代理的原始协议/主机等(用于生成正确的重定向与链接) +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto +}); + app.UseHttpsRedirection(); // 使用CORS @@ -89,5 +111,7 @@ app.MapControllers(); // 映射SignalR Hub app.MapHub("/gamehub"); +// 兼容部分反向代理仅转发 /api/* 的情况 +app.MapHub("/api/gamehub"); app.Run(); \ No newline at end of file diff --git a/backend/src/TerritoryGame.Application/Dtos/GameDtos.cs b/backend/src/TerritoryGame.Application/Dtos/GameDtos.cs index c8d6c64c6600c51ba5b49877c4b37e14a42dda8e..e15994c99ab0f083d1e36f8a3eddb8a99196a904 100644 --- a/backend/src/TerritoryGame.Application/Dtos/GameDtos.cs +++ b/backend/src/TerritoryGame.Application/Dtos/GameDtos.cs @@ -44,6 +44,8 @@ public class CreateRoomRequest { public string RoomName { get; set; } = string.Empty; public int MaxPlayers { get; set; } = 4; + // 新增:游戏时长(分钟) + public int GameDuration { get; set; } = 2; } /// diff --git a/backend/src/TerritoryGame.Infrastructure/Migrations/20250819170044_InitCreate6.Designer.cs b/backend/src/TerritoryGame.Infrastructure/Migrations/20250819170044_InitCreate6.Designer.cs new file mode 100644 index 0000000000000000000000000000000000000000..8fd2616ab5e1ffcb3be082e83b8145e88e0232bc --- /dev/null +++ b/backend/src/TerritoryGame.Infrastructure/Migrations/20250819170044_InitCreate6.Designer.cs @@ -0,0 +1,714 @@ +// +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(TerritoryGameDbContext))] + [Migration("20250819170044_InitCreate6")] + partial class InitCreate6 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Duration") + .HasColumnType("integer") + .HasColumnName("duration"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("PlayerCount") + .HasColumnType("integer") + .HasColumnName("player_count"); + + b.Property("RoomId") + .HasColumnType("uuid") + .HasColumnName("room_id"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("room_name"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WinnerId") + .HasColumnType("uuid") + .HasColumnName("winner_id"); + + b.Property("WinnerUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("winner_username"); + + b.HasKey("Id"); + + b.HasIndex("RoomId"); + + b.HasIndex("WinnerId"); + + b.ToTable("game_records", (string)null); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CanvasData") + .HasColumnType("text") + .HasColumnName("canvas_data"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_time"); + + b.Property("GameDuration") + .HasColumnType("integer") + .HasColumnName("game_duration"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("MaxPlayers") + .HasColumnType("integer") + .HasColumnName("max_players"); + + b.Property("RoomName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("room_name"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.ToTable("game_rooms", (string)null); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.LeaderboardEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BestStreak") + .HasColumnType("integer") + .HasColumnName("best_streak"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStreak") + .HasColumnType("integer") + .HasColumnName("current_streak"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("LastGameDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_game_date"); + + b.Property("PlayerId") + .HasColumnType("uuid") + .HasColumnName("player_id"); + + b.Property("PlayerId1") + .HasColumnType("uuid"); + + b.Property("Rank") + .HasColumnType("integer") + .HasColumnName("rank"); + + b.Property("TotalAreaPainted") + .HasColumnType("integer") + .HasColumnName("total_area_painted"); + + b.Property("TotalGamesWon") + .HasColumnType("integer") + .HasColumnName("total_games_won"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("username"); + + b.Property("WinRate") + .HasColumnType("double precision") + .HasColumnName("win_rate"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId1"); + + b.ToTable("leaderboard_entries", (string)null); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.PaintAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("color"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("PlayerId") + .HasColumnType("uuid") + .HasColumnName("player_id"); + + b.Property("RoomId") + .HasColumnType("uuid") + .HasColumnName("room_id"); + + b.Property("Timestamp") + .HasColumnType("bigint") + .HasColumnName("timestamp"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("tool"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("X") + .HasColumnType("integer") + .HasColumnName("x"); + + b.Property("Y") + .HasColumnType("integer") + .HasColumnName("y"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId"); + + b.ToTable("paint_actions", (string)null); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AreaCount") + .HasColumnType("integer") + .HasColumnName("area_count"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("color"); + + b.Property("ConnectionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("connection_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("IsOnline") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_online"); + + b.Property("IsReady") + .HasColumnType("boolean"); + + b.Property("LastSeen") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.ToTable("players", (string)null); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.PlayerStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AverageAreaPerGame") + .HasColumnType("double precision") + .HasColumnName("average_area_per_game"); + + b.Property("BestStreak") + .HasColumnType("integer") + .HasColumnName("best_streak"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CurrentStreak") + .HasColumnType("integer") + .HasColumnName("current_streak"); + + b.Property("FirstGameDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_game_date"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("LastGameDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_game_date"); + + b.Property("PlayerId") + .HasColumnType("uuid") + .HasColumnName("player_id"); + + b.Property("PlayerId1") + .HasColumnType("uuid"); + + b.Property("TotalAreaPainted") + .HasColumnType("integer") + .HasColumnName("total_area_painted"); + + b.Property("TotalGamesPlayed") + .HasColumnType("integer") + .HasColumnName("total_games_played"); + + b.Property("TotalGamesWon") + .HasColumnType("integer") + .HasColumnName("total_games_won"); + + b.Property("TotalPlayTime") + .HasColumnType("interval") + .HasColumnName("total_play_time"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("PlayerId1"); + + b.ToTable("player_stats", (string)null); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountStatus") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("account_status"); + + b.Property("Avatar") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("avatar"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("email"); + + b.Property("FailedLoginAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("failed_login_attempts"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("LastActivityTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_activity_time"); + + b.Property("LastFailedLogin") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_failed_login"); + + b.Property("LastLoginTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_time"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("password"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("phone"); + + b.Property("Salt") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)") + .HasColumnName("salt"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Phone"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.UserLevel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValue(new DateTime(2025, 8, 19, 17, 0, 43, 156, DateTimeKind.Utc).AddTicks(8813)) + .HasColumnName("created_at"); + + b.Property("CurrentExperience") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("current_experience"); + + b.Property("CurrentLevel") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("current_level"); + + b.Property("CurrentTitle") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasDefaultValue("墨痕初绽") + .HasColumnName("current_title"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("LastLevelUpTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_level_up_time"); + + b.Property("TotalExperience") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("total_experience"); + + b.Property("UnlockedTitles") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("unlocked_titles"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValue(new DateTime(2025, 8, 19, 17, 0, 43, 156, DateTimeKind.Utc).AddTicks(9350)) + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_levels", (string)null); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.LeaderboardEntry", b => + { + b.HasOne("TerritoryGame.Domain.Entities.App.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TerritoryGame.Domain.Entities.App.Player", "Player") + .WithMany() + .HasForeignKey("PlayerId1") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.PaintAction", b => + { + b.HasOne("TerritoryGame.Domain.Entities.App.Player", "Player") + .WithMany("PaintActions") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TerritoryGame.Domain.Entities.App.GameRoom", "Room") + .WithMany() + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Room"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.PlayerStats", b => + { + b.HasOne("TerritoryGame.Domain.Entities.App.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TerritoryGame.Domain.Entities.App.Player", "Player") + .WithMany() + .HasForeignKey("PlayerId1") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.UserLevel", b => + { + b.HasOne("TerritoryGame.Domain.Entities.App.User", "User") + .WithOne() + .HasForeignKey("TerritoryGame.Domain.Entities.App.UserLevel", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.Player", b => + { + b.Navigation("PaintActions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TerritoryGame.Infrastructure/Migrations/20250819170044_InitCreate6.cs b/backend/src/TerritoryGame.Infrastructure/Migrations/20250819170044_InitCreate6.cs new file mode 100644 index 0000000000000000000000000000000000000000..2df8dfa68129bebaf179c508849f5da4a9948095 --- /dev/null +++ b/backend/src/TerritoryGame.Infrastructure/Migrations/20250819170044_InitCreate6.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TerritoryGame.Infrastructure.Migrations +{ + /// + public partial class InitCreate6 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "updated_at", + table: "user_levels", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(2025, 8, 19, 17, 0, 43, 156, DateTimeKind.Utc).AddTicks(9350), + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldDefaultValue: new DateTime(2025, 8, 19, 9, 29, 51, 725, DateTimeKind.Utc).AddTicks(1296)); + + migrationBuilder.AlterColumn( + name: "created_at", + table: "user_levels", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(2025, 8, 19, 17, 0, 43, 156, DateTimeKind.Utc).AddTicks(8813), + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldDefaultValue: new DateTime(2025, 8, 19, 9, 29, 51, 725, DateTimeKind.Utc).AddTicks(1019)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "updated_at", + table: "user_levels", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(2025, 8, 19, 9, 29, 51, 725, DateTimeKind.Utc).AddTicks(1296), + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldDefaultValue: new DateTime(2025, 8, 19, 17, 0, 43, 156, DateTimeKind.Utc).AddTicks(9350)); + + migrationBuilder.AlterColumn( + name: "created_at", + table: "user_levels", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(2025, 8, 19, 9, 29, 51, 725, DateTimeKind.Utc).AddTicks(1019), + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldDefaultValue: new DateTime(2025, 8, 19, 17, 0, 43, 156, DateTimeKind.Utc).AddTicks(8813)); + } + } +} diff --git a/backend/src/TerritoryGame.Infrastructure/Migrations/TerritoryGameDbContextModelSnapshot.cs b/backend/src/TerritoryGame.Infrastructure/Migrations/TerritoryGameDbContextModelSnapshot.cs index fabeb4e6a817ba7510001249d343981d31d6255a..f0ee0d82d5918929707b9bb1a0726a122e18396f 100644 --- a/backend/src/TerritoryGame.Infrastructure/Migrations/TerritoryGameDbContextModelSnapshot.cs +++ b/backend/src/TerritoryGame.Infrastructure/Migrations/TerritoryGameDbContextModelSnapshot.cs @@ -569,7 +569,7 @@ namespace TerritoryGame.Infrastructure.Migrations b.Property("CreatedAt") .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") - .HasDefaultValue(new DateTime(2025, 8, 19, 9, 29, 51, 725, DateTimeKind.Utc).AddTicks(1019)) + .HasDefaultValue(new DateTime(2025, 8, 19, 17, 0, 43, 156, DateTimeKind.Utc).AddTicks(8813)) .HasColumnName("created_at"); b.Property("CurrentExperience") @@ -622,7 +622,7 @@ namespace TerritoryGame.Infrastructure.Migrations b.Property("UpdatedAt") .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") - .HasDefaultValue(new DateTime(2025, 8, 19, 9, 29, 51, 725, DateTimeKind.Utc).AddTicks(1296)) + .HasDefaultValue(new DateTime(2025, 8, 19, 17, 0, 43, 156, DateTimeKind.Utc).AddTicks(9350)) .HasColumnName("updated_at"); b.Property("UserId") diff --git a/frontend/src/components/game/GameLeftSidebar.vue b/frontend/src/components/game/GameLeftSidebar.vue index 9a9f9f5c545e09f390a83654f69b667a7f6d1309..cf9ec198c33d52c13a53df9ecabe0924c6cde02a 100644 --- a/frontend/src/components/game/GameLeftSidebar.vue +++ b/frontend/src/components/game/GameLeftSidebar.vue @@ -65,7 +65,9 @@ defineOptions({ const props = defineProps({ gameTime: { type: Number, required: true }, // 游戏剩余时间(秒) gameStatus: { type: String, required: true }, // 游戏状态:waiting/playing/finished - players: { type: Array, required: true } // 玩家列表数据 + players: { type: Array, required: true }, // 玩家列表数据 + // 可选传入画布数据(仅用于统计像素总数等展示,不参与胜负计算) + canvasData: { type: Object, default: () => ({ pixels: {} }) } }) // 定义 Emits:向父组件发送的事件 @@ -177,7 +179,7 @@ const updateAreaStats = () => { emit('area-stats-updated', { playerAreas, ranking, - totalPixels: Object.keys(props.canvasData.pixels).length + totalPixels: props?.canvasData?.pixels ? Object.keys(props.canvasData.pixels).length : 0 }) } @@ -208,6 +210,36 @@ const calculateWinner = () => { return winner } +// 计算各玩家的面积占比(基于传入的 players 列表的 area 字段) +const calculatePlayerAreas = () => { + const areas = {} + const total = totalArea.value || 0 + props.players.forEach(p => { + const key = p.id ?? p.name + const areaVal = Number(p.area) || 0 + areas[key] = total > 0 ? Math.round((areaVal / total) * 1000) / 10 : 0 // 保留1位小数 + }) + return areas +} + +// 获取排行榜(降序) +const getPlayerRanking = () => { + const list = props.players.map(p => ({ + id: p.id ?? p.name, + name: p.name, + color: p.color, + area: Number(p.area) || 0 + })) + list.sort((a, b) => b.area - a.area) + return list +} + +// 根据 playerId 或名称获取颜色 +const getPlayerColor = (playerIdOrName) => { + const found = props.players.find(p => (p.id ?? p.name) === playerIdOrName || p.name === playerIdOrName) + return found?.color || '#3B82F6' +} + // 获取玩家名称 const getPlayerName = (playerId) => { const nameMap = { diff --git a/frontend/src/stores/game.js b/frontend/src/stores/game.js index 041480429ac91973c85d475db98cd08b2606c380..e4f1bbb58337c3e8a572515efc7f1a1ac34794fe 100644 --- a/frontend/src/stores/game.js +++ b/frontend/src/stores/game.js @@ -16,6 +16,8 @@ export const useGameStore = defineStore('game', () => { // 游戏状态 const gameStatus = ref('waiting') // waiting, playing, finished const gameTime = ref(120) // 游戏时间(秒) + const gameEndAtMs = ref(null) // 基于服务端 timeLeft 计算的目标结束时间戳(毫秒) + let gameTimeSyncTimer = null // 基于目标结束时间的统一同步计时器 const currentPlayer = ref('你') const currentRoomId = ref(null) const currentRoomName = ref('') @@ -138,6 +140,11 @@ export const useGameStore = defineStore('game', () => { /** 将游戏置为已结束 */ const endGame = () => { gameStatus.value = 'finished' + // 停止统一计时器 + if (gameTimeSyncTimer) { + clearInterval(gameTimeSyncTimer) + gameTimeSyncTimer = null + } } /** @@ -154,6 +161,20 @@ export const useGameStore = defineStore('game', () => { const setGameTime = (time) => { gameTime.value = time } + + // 启动统一的基于服务端 endAt 的计时器(每个客户端都以相同 endAt 计算,避免各自累加误差) + const startSyncedGameTimer = () => { + if (!gameEndAtMs.value) return + if (gameTimeSyncTimer) clearInterval(gameTimeSyncTimer) + gameTimeSyncTimer = setInterval(() => { + const now = Date.now() + const left = Math.max(0, Math.floor((gameEndAtMs.value - now) / 1000)) + if (left !== gameTime.value) gameTime.value = left + if (left <= 0 && gameStatus.value === 'playing') { + endGame() + } + }, 1000) + } /** 设置当前房间信息 */ const setCurrentRoom = (roomId, roomName, maxPlayers = 5) => { @@ -654,13 +675,34 @@ export const useGameStore = defineStore('game', () => { } // 实时更新游戏状态 - const updateGameStatus = (status, timeLeft) => { - if (status) { + const updateGameStatus = (status, timeLeft, startTimeMs, durationSeconds) => { + if (startTimeMs && durationSeconds) { + // 优先使用服务器的开始时间 + 总时长,避免网络延迟带来的初始偏差 + gameEndAtMs.value = Number(startTimeMs) + Number(durationSeconds) * 1000 + startSyncedGameTimer() + const left = Math.max(0, Math.floor((gameEndAtMs.value - Date.now()) / 1000)) + gameTime.value = left + // 如果剩余<=0,强制进入结束态,避免服务端状态延迟 + if (left <= 0) { + gameStatus.value = 'finished' + } else if (status) { + gameStatus.value = status + } + } else if (timeLeft !== undefined) { + // 后备:仅收到剩余秒数时,以本地当前时间基准计算 + gameEndAtMs.value = Date.now() + (Number(timeLeft) || 0) * 1000 + startSyncedGameTimer() + // 立即同步一次显示,避免下一秒才更新 + const left = Math.max(0, Math.floor((gameEndAtMs.value - Date.now()) / 1000)) + gameTime.value = left + if (left <= 0) { + gameStatus.value = 'finished' + } else if (status) { + gameStatus.value = status + } + } else if (status) { gameStatus.value = status } - if (timeLeft !== undefined) { - gameTime.value = timeLeft - } console.log('更新游戏状态:', status, '剩余时间:', timeLeft) } diff --git a/frontend/src/stores/socket.js b/frontend/src/stores/socket.js index 49b6135ad35e253171a3795e0b6fd3e1a7900468..b7c2d14b58787d46d22c384f923e1dde43e8b570 100644 --- a/frontend/src/stores/socket.js +++ b/frontend/src/stores/socket.js @@ -104,9 +104,9 @@ export const useSocketStore = defineStore('socket', () => { socket.value = new HubConnectionBuilder() .withUrl(hubUrl, { accessTokenFactory: () => getAccessToken(), + // 允许协商并使用可用传输进行回退(WebSocket/SSE/LongPolling) skipNegotiation: false, - transport: 1, // WebSockets - withCredentials: false // 避免浏览器附带凭证导致 CORS 需要非通配符 + withCredentials: false }) .withAutomaticReconnect([0, 1000, 2000, 5000]) .configureLogging(LogLevel.Information) diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js index 5f31860a10b14c0a86e5045b047d662a3f5b19a7..9072e9589462873026a60df34adb5b2ac12e4f6c 100644 --- a/frontend/src/utils/request.js +++ b/frontend/src/utils/request.js @@ -6,8 +6,8 @@ const useProxy = import.meta?.env?.VITE_USE_PROXY === 'true' const isDev = !!import.meta?.env?.DEV const apiFromEnv = import.meta?.env?.VITE_API_BASE_URL // 在开发且开启代理时,走本地相对路径 /api,从而被 Vite 代理到后端 -// const API_BASE_URL = (isDev && useProxy) ? '/api' : (apiFromEnv || 'http://api-territory-game.monkeymeerkat.cn/api') -const API_BASE_URL = (isDev && useProxy) ? '/api' : (apiFromEnv || 'http://localhost:5004/api') +const API_BASE_URL = (isDev && useProxy) ? '/api' : (apiFromEnv || 'http://api-territory-game.monkeymeerkat.cn/api') +// const API_BASE_URL = (isDev && useProxy) ? '/api' : (apiFromEnv || 'http://localhost:5004/api') const request = axios.create({ baseURL: API_BASE_URL, timeout: 10000, diff --git a/frontend/src/views/GameView.vue b/frontend/src/views/GameView.vue index a807edfc4484e1e4813a42f9cc04a083895a9dca..05360de62541a18cf8d047f3a1d3e8c5c95dc89c 100644 --- a/frontend/src/views/GameView.vue +++ b/frontend/src/views/GameView.vue @@ -100,7 +100,7 @@ @room-joined="handleRoomJoined" /> - + @@ -159,6 +159,7 @@ export default { const showStats = ref(false) const showTip = ref(true) const showRoomInfo = ref(false) + const showGameOverDialog = ref(false) const showRoomDialog = ref(false) // 实时数据状态 @@ -415,6 +416,7 @@ export default { // 更新游戏状态为结束 gameStore.endGame() + showGameOverDialog.value = true // 显示获胜者信息 if (data.winner) { @@ -428,6 +430,7 @@ export default { // 更新游戏状态 gameStore.endGame() + showGameOverDialog.value = true // 最终计算所有玩家面积 gameStore.recalculateAllPlayerAreas() @@ -657,13 +660,17 @@ export default { // 更新游戏状态和时间 const status = data?.gameStatus || 'playing' const timeLeft = typeof data?.timeLeft === 'number' ? data.timeLeft : undefined - gameStore.updateGameStatus(status, timeLeft) + const startTimeMs = data?.startTime ? new Date(data.startTime).getTime() : undefined + const durationSeconds = typeof data?.duration === 'number' ? data.duration : undefined + gameStore.updateGameStatus(status, timeLeft, startTimeMs, durationSeconds) }) // 监听游戏状态更新 socketManager.on('GameStatusUpdated', (data) => { console.log('游戏状态更新:', data) - gameStore.updateGameStatus(data.status, data.timeLeft) + const startTimeMs = data?.startTime ? new Date(data.startTime).getTime() : undefined + const durationSeconds = typeof data?.duration === 'number' ? data.duration : undefined + gameStore.updateGameStatus(data.status, data.timeLeft, startTimeMs, durationSeconds) }) // 监听绘画事件 diff --git a/frontend/vite.config.js b/frontend/vite.config.js index c086dbc83d2ed71ca81cc5293358860872464398..25ee164c11c49b4ea3e180803269fa9070fd2997 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -27,13 +27,20 @@ export default defineConfig(({ mode }) => { changeOrigin: true, secure: false, }, - // 将以 /hubs 开头的请求代理到后端,用于SignalR连接 + // 将以 /hubs 开头的请求代理到后端,用于SignalR连接(保留兼容) '/hubs': { target: proxyTarget, changeOrigin: true, secure: false, ws: true, // 支持WebSocket }, + // 将 SignalR Hub 路径 /gamehub 代理到后端(实际后端映射的是 /gamehub) + '/gamehub': { + target: proxyTarget, + changeOrigin: true, + secure: false, + ws: true, // 支持WebSocket与协商请求 + }, } : undefined } }