diff --git a/Backend/TerritoryGame.sln b/Backend/TerritoryGame.sln new file mode 100644 index 0000000000000000000000000000000000000000..1a7c6374ce714f20767e5ee254457f19f629c9ed --- /dev/null +++ b/Backend/TerritoryGame.sln @@ -0,0 +1,48 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{CBCD9757-F36F-4C01-A2C1-9BD777F6062E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerritoryGame.API", "src\TerritoryGame.API\TerritoryGame.API.csproj", "{DC1E80F5-A19D-4A3D-8B0F-96AAACF839AB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerritoryGame.Application", "src\TerritoryGame.Application\TerritoryGame.Application.csproj", "{FE6A1536-B12B-485C-9633-6C9D093172F9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerritoryGame.Domain", "src\TerritoryGame.Domain\TerritoryGame.Domain.csproj", "{EFF83C8E-45B7-40EC-AC45-4E5547B14A6D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TerritoryGame.Infrastructure", "src\TerritoryGame.Infrastructure\TerritoryGame.Infrastructure.csproj", "{A15EC916-5A31-431B-B7AB-01B48AFC8FE8}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DC1E80F5-A19D-4A3D-8B0F-96AAACF839AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DC1E80F5-A19D-4A3D-8B0F-96AAACF839AB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DC1E80F5-A19D-4A3D-8B0F-96AAACF839AB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DC1E80F5-A19D-4A3D-8B0F-96AAACF839AB}.Release|Any CPU.Build.0 = Release|Any CPU + {FE6A1536-B12B-485C-9633-6C9D093172F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FE6A1536-B12B-485C-9633-6C9D093172F9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FE6A1536-B12B-485C-9633-6C9D093172F9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FE6A1536-B12B-485C-9633-6C9D093172F9}.Release|Any CPU.Build.0 = Release|Any CPU + {EFF83C8E-45B7-40EC-AC45-4E5547B14A6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EFF83C8E-45B7-40EC-AC45-4E5547B14A6D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EFF83C8E-45B7-40EC-AC45-4E5547B14A6D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EFF83C8E-45B7-40EC-AC45-4E5547B14A6D}.Release|Any CPU.Build.0 = Release|Any CPU + {A15EC916-5A31-431B-B7AB-01B48AFC8FE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A15EC916-5A31-431B-B7AB-01B48AFC8FE8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A15EC916-5A31-431B-B7AB-01B48AFC8FE8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A15EC916-5A31-431B-B7AB-01B48AFC8FE8}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {DC1E80F5-A19D-4A3D-8B0F-96AAACF839AB} = {CBCD9757-F36F-4C01-A2C1-9BD777F6062E} + {FE6A1536-B12B-485C-9633-6C9D093172F9} = {CBCD9757-F36F-4C01-A2C1-9BD777F6062E} + {EFF83C8E-45B7-40EC-AC45-4E5547B14A6D} = {CBCD9757-F36F-4C01-A2C1-9BD777F6062E} + {A15EC916-5A31-431B-B7AB-01B48AFC8FE8} = {CBCD9757-F36F-4C01-A2C1-9BD777F6062E} + EndGlobalSection +EndGlobal diff --git a/Backend/src/TerritoryGame.API/Controllers/AuthController.cs b/Backend/src/TerritoryGame.API/Controllers/AuthController.cs new file mode 100644 index 0000000000000000000000000000000000000000..43647aa7460bdea39b27f8bf7edee08c27c3241e --- /dev/null +++ b/Backend/src/TerritoryGame.API/Controllers/AuthController.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Mvc; +using TerritoryGame.Application.Servicece; + +namespace TerritoryGame.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly IAuthPlayer _authPlayer; + + public AuthController(IAuthPlayer authPlayer) + { + _authPlayer = authPlayer; + } + + public record LoginRequest(string NickName); + public record RegisterRequest(string NickName); + + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest request) + { + var result = await _authPlayer.LoginAsync(request.NickName); + if (result is null) + { + return Unauthorized(new { message = "玩家不存在" }); + } + return Ok(result); + } + + [HttpPost("register")] + public async Task Register([FromBody] RegisterRequest request) + { + // 基础校验:昵称不能为空 + if (string.IsNullOrWhiteSpace(request.NickName)) + { + return BadRequest(new { message = "昵称不能为空" }); + } + + var result = await _authPlayer.RegisterAsync(request.NickName); + if (result is null) + { + // 服务层在昵称已存在时返回 null,这里用 409 更贴近语义 + return Conflict(new { message = "昵称已存在,请选择其他昵称" }); + } + return Ok(result); + } +} + + diff --git a/Backend/src/TerritoryGame.API/Controllers/GameRoomController.cs b/Backend/src/TerritoryGame.API/Controllers/GameRoomController.cs new file mode 100644 index 0000000000000000000000000000000000000000..893bef2db9b4ce8b73378e4e07a39b6ca3818403 --- /dev/null +++ b/Backend/src/TerritoryGame.API/Controllers/GameRoomController.cs @@ -0,0 +1,247 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using TerritoryGame.Application.Dtos; +using TerritoryGame.Application.Servicece; +using TerritoryGame.Domain.Repositories; +using TerritoryGame.Domain.Entities.App; + +namespace TerritoryGame.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class GameRoomController : ControllerBase +{ + private readonly IGameRoomService _gameRoomService; + private readonly IGameRoomRepository _repo; + private readonly TerritoryGame.API.Services.IRoomPresence _presence; + private readonly TerritoryGame.API.Services.IRoomLiveState _liveState; + + public GameRoomController(IGameRoomService gameRoomService, IGameRoomRepository repo, TerritoryGame.API.Services.IRoomPresence presence, TerritoryGame.API.Services.IRoomLiveState liveState) + { + _gameRoomService = gameRoomService; + _repo = repo; + _presence = presence; + _liveState = liveState; + } + + private Guid? GetUserIdFromClaims() + { + var sub = User?.FindFirst(JwtRegisteredClaimNames.Sub)?.Value + ?? User?.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? User?.FindFirst("sub")?.Value; + if (Guid.TryParse(sub, out var uid)) return uid; + return null; + } + + /// + /// 创建游戏房间 + /// + [HttpPost] + public async Task CreateRoom([FromBody] CreateRoomRequest request) + { + // 从 JWT 取房主 ID(兼容多种 claim 名称) + var ownerId = GetUserIdFromClaims() ?? Guid.NewGuid(); // 开发期允许匿名,生产建议加 [Authorize] + + var result = await _gameRoomService.CreateRoomAsync(request, ownerId); + if (result is null) + { + return BadRequest(new { message = "创建房间失败,请稍后重试" }); + } + return CreatedAtAction(nameof(GetRoomById), new { roomId = result.Id }, result); + } + + /// + /// 删除游戏房间 + /// + [HttpDelete("{roomId}")] + public async Task DeleteRoom(Guid roomId) + { + var result = await _gameRoomService.DeleteRoomAsync(roomId); + if (!result) + { + return BadRequest(new { message = "删除房间失败,房间不存在或状态不允许删除" }); + } + return NoContent(); + } + + /// + /// 通过房间号获取房间 + /// + [HttpGet("code/{roomCode}")] + public async Task GetRoomByCode(string roomCode) + { + var room = await _repo.GetByRoomCodeAsync(roomCode); + if (room is null) return NotFound(new { message = "房间不存在" }); + return Ok(new GameRoomDto + { + Id = room.Id, + Name = room.Name, + DisplayName = room.DisplayName, + RoomCode = room.RoomCode, + Password = room.Password, + MaxPlayers = room.MaxPlayers, + Status = room.Status, + GameDuration = room.GameDuration, + GameType = room.GameType, + HunterCount = room.HunterCount, + RunnerCount = room.RunnerCount, + CreatedAt = room.CreatedAt, + CurrentPlayerCount = room.Sessions?.Count ?? 0 + }); + } + + public class JoinRoomRequest + { + public string RoomCode { get; set; } = string.Empty; + public string? Password { get; set; } + public int? GameType { get; set; } + } + + /// + /// 加入房间(校验密码,如设置) + /// + [HttpPost("join")] + public async Task Join([FromBody] JoinRoomRequest request) + { + var room = await _repo.GetByRoomCodeAsync(request.RoomCode); + if (room is null) return NotFound(new { message = "房间不存在" }); + // 若前端携带了 gameType,则在服务端校验是否匹配 + if (request.GameType.HasValue && (int)room.GameType != request.GameType.Value) + { + return BadRequest(new { message = "房间不属于当前选择的游戏" }); + } + if (!string.IsNullOrEmpty(room.Password)) + { + if (string.IsNullOrEmpty(request.Password) || request.Password != room.Password) + { + return Unauthorized(new { message = "密码错误" }); + } + } + if (room.Status != TerritoryGame.Domain.Entities.App.GameStatus.Waiting) + { + return BadRequest(new { message = "当前状态不可加入" }); + } + if ((room.Sessions?.Count ?? 0) >= room.MaxPlayers) + { + return BadRequest(new { message = "房间已满" }); + } + return Ok(new { message = "可以加入", roomId = room.Id }); + } + + /// + /// 根据房间ID获取房间信息 + /// + [HttpGet("id/{roomId}")] + public async Task GetRoomById(Guid roomId) + { + var result = await _gameRoomService.GetRoomAsync(roomId); + if (result is null) + { + return NotFound(new { message = "房间不存在" }); + } + return Ok(result); + } + + /// + /// 根据房间名称获取房间信息 + /// + [HttpGet("name/{roomName}")] + public async Task GetRoomByName(string roomName) + { + var result = await _gameRoomService.GetRoomByNameAsync(roomName); + if (result is null) + { + return NotFound(new { message = "房间不存在" }); + } + return Ok(result); + } + + /// + /// 获取所有房间列表 + /// + [HttpGet] + public async Task GetAllRooms([FromQuery] int? gameType = null) + { + var result = gameType.HasValue + ? (await _gameRoomService.GetAllRoomsByGameAsync((GameType)gameType.Value)).ToList() + : (await _gameRoomService.GetAllRoomsAsync()).ToList(); + var online = _presence.SnapshotCounts(); + var statuses = _liveState.SnapshotStatuses(); + foreach (var r in result) + { + var key = !string.IsNullOrWhiteSpace(r.RoomCode) ? r.RoomCode : r.Id.ToString(); + if (online.TryGetValue(key, out var cnt)) + { + r.CurrentPlayerCount = cnt; + } + if (statuses.TryGetValue(key, out var st)) + { + r.Status = st; + } + } + return Ok(result); + } + + /// + /// 获取当前用户作为房主创建的房间列表 + /// + [HttpGet("my")] + [Authorize] + public async Task GetMyRooms([FromQuery] int? gameType = null) + { + var uid = GetUserIdFromClaims(); + if (uid is null) return Unauthorized(); + var ownerId = uid.Value; + var rooms = await _repo.GetRoomsByOwnerAsync(ownerId); + var online = _presence.SnapshotCounts(); + var statuses = _liveState.SnapshotStatuses(); + var result = rooms.Select(r => new GameRoomDto + { + Id = r.Id, + Name = r.Name, + DisplayName = r.DisplayName, + RoomCode = r.RoomCode, + Password = r.Password, + MaxPlayers = r.MaxPlayers, + Status = statuses.TryGetValue(!string.IsNullOrWhiteSpace(r.RoomCode) ? r.RoomCode : r.Id.ToString(), out var st) ? st : r.Status, + GameDuration = r.GameDuration, + GameType = r.GameType, + HunterCount = r.HunterCount, + RunnerCount = r.RunnerCount, + CreatedAt = r.CreatedAt, + CurrentPlayerCount = online.TryGetValue(!string.IsNullOrWhiteSpace(r.RoomCode) ? r.RoomCode : r.Id.ToString(), out var cnt) ? cnt : (r.Sessions?.Count ?? 0) + }); + if (gameType.HasValue) + { + result = result.Where(r => (int)r.GameType == gameType.Value); + } + return Ok(result); + } + + public class KickRequest + { + public Guid RoomId { get; set; } + public Guid TargetPlayerId { get; set; } + } + + /// + /// 房主踢出玩家 + /// + [HttpPost("kick")] + public async Task Kick([FromBody] KickRequest request) + { + var room = await _repo.GetByIdAsync(request.RoomId); + if (room is null) return NotFound(new { message = "房间不存在" }); + var sub = User?.FindFirst("sub")?.Value; + if (!Guid.TryParse(sub, out var userId)) return Unauthorized(); + if (room.OwnerId != userId) return Forbid(); + + // 这里简单地删除该玩家的 Session 作为被踢出处理 + // 更完整实现应通过领域服务结束玩家会话并广播 + // 暂用仓储通用接口 + // NOTE: 无专门 Session 仓储,直接返 OK 并由前端/Hub 进行断开 + return Ok(new { message = "kicked", targetId = request.TargetPlayerId }); + } +} diff --git a/Backend/src/TerritoryGame.API/Controllers/RealtimeController.cs b/Backend/src/TerritoryGame.API/Controllers/RealtimeController.cs new file mode 100644 index 0000000000000000000000000000000000000000..d96855dd07864ed23af7909948ea69f2e047743e --- /dev/null +++ b/Backend/src/TerritoryGame.API/Controllers/RealtimeController.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Mvc; +using TerritoryGame.Application.Common.EventBus; +using TerritoryGame.Application.Dtos.Paint; +using TerritoryGame.Application.Dtos.Ranking; +using TerritoryGame.Application.Events; + +namespace TerritoryGame.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class RealtimeController : ControllerBase +{ + private readonly IEventBus _eventBus; + + public RealtimeController(IEventBus eventBus) + { + _eventBus = eventBus; + } + + [HttpPost("ranking")] + public async Task PublishRanking([FromBody] RankingDto dto, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(dto.RoomId)) return BadRequest("RoomId required"); + await _eventBus.PublishAsync(new RankingUpdatedEvent { RoomId = dto.RoomId, Ranking = dto }, ct); + return Accepted(); + } + + [HttpPost("paint")] + public async Task PublishPaint([FromQuery] string roomId, [FromQuery] string userId, [FromBody] PaintActionDto paint, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(roomId) || string.IsNullOrWhiteSpace(userId)) return BadRequest("roomId and userId required"); + await _eventBus.PublishAsync(new PaintEvent { RoomId = roomId, UserId = userId, Paint = paint }, ct); + return Accepted(); + } +} diff --git a/Backend/src/TerritoryGame.API/Controllers/RedisTestController.cs b/Backend/src/TerritoryGame.API/Controllers/RedisTestController.cs new file mode 100644 index 0000000000000000000000000000000000000000..9905395ee61d70fe4c8d1aa323dde3c478231cdc --- /dev/null +++ b/Backend/src/TerritoryGame.API/Controllers/RedisTestController.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Mvc; +using TerritoryGame.Infrastructure.Services; + +namespace TerritoryGame.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class RedisTestController : ControllerBase +{ + private readonly IRedisService _redisService; + + public RedisTestController(IRedisService redisService) + { + _redisService = redisService; + } + + /// + /// 测试Redis连接 + /// + [HttpGet("test-connection")] + public async Task TestConnection() + { + var isConnected = await _redisService.TestConnectionAsync(); + return Ok(new { + Success = isConnected, + Message = isConnected ? "Redis连接成功!" : "Redis连接失败!", + Timestamp = DateTime.UtcNow + }); + } +} \ No newline at end of file diff --git a/Backend/src/TerritoryGame.API/Hubs/GameHub.cs b/Backend/src/TerritoryGame.API/Hubs/GameHub.cs new file mode 100644 index 0000000000000000000000000000000000000000..c50b1eb1cdd0bb40d3cfb7a2ccc8a8cf8ddd97e6 --- /dev/null +++ b/Backend/src/TerritoryGame.API/Hubs/GameHub.cs @@ -0,0 +1,1163 @@ +using Microsoft.AspNetCore.SignalR; +using TerritoryGame.Application.Common.EventBus; +using TerritoryGame.Application.Dtos.Paint; +using TerritoryGame.Application.Events; +using System.Collections.Concurrent; +using TerritoryGame.Domain.Repositories; + +namespace TerritoryGame.API.Hubs; + +public class GameHub : Hub +{ + private readonly IEventBus _eventBus; + private readonly TerritoryGame.Application.Servicece.IRealtimeNotifier _notifier; + private readonly TerritoryGame.API.Services.IRoomPresence _presence; + private readonly TerritoryGame.API.Services.IRoomLiveState _liveState; + private readonly IGameRoomRepository _repo; + private readonly IHubContext _hubContext; + + // 简易内存房间状态(演示用):跟踪房间内用户与准备态,以及准备倒计时 + private class RoomState + { + public HashSet Users { get; } = new(); + public HashSet ReadyUsers { get; } = new(); + public int PreparingSeconds { get; set; } = 0; + public CancellationTokenSource? PreparationCts { get; set; } + public object SyncRoot { get; } = new(); + public string? OwnerUserId { get; set; } + public Dictionary UserNames { get; } = new(); + // PS:角色管理 + public Dictionary Roles { get; } = new(); // userId -> runner|hunter + public int HunterQuota { get; set; } = 1; + public int RunnerQuota { get; set; } = 5; + public bool IsStarted { get; set; } = false; + public int GameDurationSec { get; set; } = 180; + public int RemainingSeconds { get; set; } = 0; + + // PS:蓝色增益点(服务端权威) + public List<(int x, int y)> BlueDots { get; } = new(); + // PS:紫色增益点(拾取后立即随机传送) + public List<(int x, int y)> PurpleDots { get; } = new(); + // PS:银灰色增益点(时间调整:逃生者 -5s;追捕者 +5s) + public List<(int x, int y)> SilverDots { get; } = new(); + // PS:橙色增益点(5 秒可穿墙,由前端本地处理) + public List<(int x, int y)> OrangeDots { get; } = new(); + public CancellationTokenSource? BlueSpawnCts { get; set; } + public CancellationTokenSource? GameCts { get; set; } + public CancellationTokenSource? DeletionCts { get; set; } + public string RoomKey { get; set; } = string.Empty; // 用于确定性地图种子(房间码或Id) + public int[,]? Map { get; set; } // 0 空地, 1 墙 + // PS:玩家最近一次上报的位置(用于抓捕判定) + public Dictionary Positions { get; } = new(); + // PS:抓捕冷却,避免重复广播 + public Dictionary LastCaughtAt { get; } = new(); // key: runnerId + // PS:累计抓捕次数(本局) + public int CatchCount { get; set; } = 0; + } + private static readonly ConcurrentDictionary Rooms = new(); + private static readonly ConcurrentDictionary Connections = new(); + + public GameHub(IEventBus eventBus, TerritoryGame.Application.Servicece.IRealtimeNotifier notifier, TerritoryGame.API.Services.IRoomPresence presence, TerritoryGame.API.Services.IRoomLiveState liveState, IGameRoomRepository repo, IHubContext hubContext) + { + _eventBus = eventBus; + _notifier = notifier; + _presence = presence; + _liveState = liveState; + _repo = repo; + _hubContext = hubContext; + } + + public async Task JoinRoom(string roomId) + { + await Groups.AddToGroupAsync(Context.ConnectionId, roomId); + } + + public async Task LeaveRoom(string roomId) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomId); + if (Connections.TryRemove(Context.ConnectionId, out var info)) + { + if (Rooms.TryGetValue(info.roomId, out var state)) + { + lock (state.SyncRoot) + { + state.Users.Remove(info.userId); + state.ReadyUsers.Remove(info.userId); + } + _presence.RemoveUser(info.roomId, info.userId); + // 广播离开与最新列表 + await Clients.Group(info.roomId).SendAsync("PlayerLeft", info.userId); + var snapshot = state.Users + .Select(uid => new { userId = uid, nickname = state.UserNames.TryGetValue(uid, out var n) ? n : string.Empty }) + .Cast() + .ToList(); + await Clients.Group(info.roomId).SendAsync("UserList", snapshot); + // 若房间已空,10 秒后自动删除(若期间有人加入会被取消) + bool empty; + lock (state.SyncRoot) { empty = state.Users.Count == 0; } + if (empty) + { + _liveState.SetStatus(info.roomId, TerritoryGame.Domain.Entities.App.GameStatus.Waiting); + ScheduleRoomDeletion(info.roomId, state, TimeSpan.FromSeconds(10)); + } + } + } + } + + // 玩家在房间内进行涂色,走事件总线 + public async Task Paint(string roomId, string userId, PaintActionDto paint) + { + await _eventBus.PublishAsync(new PaintEvent + { + RoomId = roomId, + UserId = userId, + Paint = paint + }); + } + + // 撤销功能已移除 + + // 房主踢人(当前简化:未强制校验房主身份,建议在生产环境基于 JWT 校验) + public async Task Kick(string roomId, string targetUserId) + { + await _notifier.BroadcastKickedAsync(roomId, targetUserId); + } + + // 玩家注册:让服务器知道 roomId 和 userId 的关联及昵称(需在 JoinRoom 之后调用) + public async Task RegisterUser(string roomId, string userId, string? nickname = null) + { + var state = Rooms.GetOrAdd(roomId, _ => new RoomState()); + lock (state.SyncRoot) + { + if (string.IsNullOrEmpty(state.RoomKey)) state.RoomKey = roomId; + // 有人加入则取消房间删除计划 + state.DeletionCts?.Cancel(); + state.DeletionCts = null; + } + string? ownerToNotify = null; + List snapshot = new(); + // 初始化配额:尝试从数据库读取(按 RoomCode 或 Guid Id) + try + { + if (_repo != null) + { + var room = Guid.TryParse(roomId, out var gid) + ? await _repo.GetByIdAsync(gid) + : await _repo.GetByRoomCodeAsync(roomId); + if (room != null) + { + lock (state.SyncRoot) + { + state.HunterQuota = Math.Max(0, room.HunterCount); + state.RunnerQuota = Math.Max(0, room.RunnerCount); + if (room.GameDuration > 0) + state.GameDurationSec = room.GameDuration; + } + } + } + } + catch { /* ignore db failures for demo */ } + lock (state.SyncRoot) + { + state.ReadyUsers.Remove(userId); + if (!string.IsNullOrWhiteSpace(nickname)) + { + state.UserNames[userId] = nickname!; + } + if (string.IsNullOrEmpty(state.OwnerUserId)) + { + state.OwnerUserId = userId; + ownerToNotify = state.OwnerUserId; + } + // 注册即加入 Users(无论是否已选角色) + state.Users.Add(userId); + + // 生成当前用户列表快照(包含所有在房用户) + snapshot = state.Users + .Select(uid => new { userId = uid, nickname = state.UserNames.TryGetValue(uid, out var n) ? n : string.Empty }) + .Cast() + .ToList(); + } + Connections[Context.ConnectionId] = (roomId, userId); + _presence.AddUser(roomId, userId); + + // 广播加入与全量玩家列表 + await Clients.Group(roomId).SendAsync("PlayerJoined", userId, nickname ?? string.Empty); + await Clients.Group(roomId).SendAsync("UserList", snapshot); + // 同步当前已知角色信息给新加入者 + var roleSnapshot = state.Roles.Select(kv => new { userId = kv.Key, role = kv.Value }).ToArray(); + Console.WriteLine($"发送 PsRoleBulk 给用户 {userId},角色信息: {string.Join(", ", roleSnapshot.Select(r => $"{r.userId}={r.role}"))}"); + await Clients.Caller.SendAsync("PsRoleBulk", roleSnapshot); + // 广播房主(让新老客户端都对齐) + if (!string.IsNullOrEmpty(ownerToNotify)) + { + await Clients.Group(roomId).SendAsync("RoomOwnerChanged", ownerToNotify); + } + + // 如果游戏已经开始,通知新加入的用户 + bool gameInProgress = false; + lock (state.SyncRoot) + { + gameInProgress = state.IsStarted; + } + if (gameInProgress) + { + await Clients.Caller.SendAsync("GameStarted"); + // 将当前蓝点同步给新加入者 + List dots; + lock (state.SyncRoot) + { + dots = state.BlueDots.Select(d => new { x = d.x, y = d.y }).Cast().ToList(); + } + if (dots.Count > 0) + await Clients.Caller.SendAsync("PsBlueDots", dots); + // 将当前紫点同步给新加入者 + List pdots; + lock (state.SyncRoot) + { + pdots = state.PurpleDots.Select(d => new { x = d.x, y = d.y }).Cast().ToList(); + } + if (pdots.Count > 0) + await Clients.Caller.SendAsync("PsPurpleDots", pdots); + // 同步剩余时间 + int remain; + lock (state.SyncRoot) { remain = Math.Max(0, state.RemainingSeconds); } + await Clients.Caller.SendAsync("PsRemaining", remain); + // 将当前银灰点同步给新加入者 + List sdots; + lock (state.SyncRoot) + { + sdots = state.SilverDots.Select(d => new { x = d.x, y = d.y }).Cast().ToList(); + } + if (sdots.Count > 0) + await Clients.Caller.SendAsync("PsSilverDots", sdots); + // 将当前橙色点同步给新加入者 + List odots; + lock (state.SyncRoot) + { + odots = state.OrangeDots.Select(d => new { x = d.x, y = d.y }).Cast().ToList(); + } + if (odots.Count > 0) + await Clients.Caller.SendAsync("PsOrangeDots", odots); + } + + // 不在此处检查自动开始,等选择角色后再检查 + } + + // 设置准备/取消准备 + public async Task SetReady(string roomId, string userId, bool ready) + { + var state = Rooms.GetOrAdd(roomId, _ => new RoomState()); + bool allReadyNow = false; + CancellationTokenSource? ctsToStart = null; + lock (state.SyncRoot) + { + if (ready) state.ReadyUsers.Add(userId); else state.ReadyUsers.Remove(userId); + // 广播单人准备态 + // 放到锁外发送,先记录需要的信息 + // 判断是否全员准备(至少一人在线,且 Ready 覆盖 Users) + if (state.Users.Count > 0 && state.ReadyUsers.IsSupersetOf(state.Users)) + { + // 若未在准备中,则启动准备倒计时 + if (state.PreparingSeconds == 0 || state.PreparationCts == null) + { + state.PreparingSeconds = 5; + state.PreparationCts = new CancellationTokenSource(); + allReadyNow = true; + ctsToStart = state.PreparationCts; + } + } + else + { + // 如有倒计时在进行,取消 + if (state.PreparationCts != null) + { + state.PreparationCts.Cancel(); + state.PreparationCts = null; + state.PreparingSeconds = 0; + } + } + } + // 锁外广播:单人准备态变化 + await Clients.Group(roomId).SendAsync("PlayerReady", userId, ready); + + // 启动或取消倒计时 + if (allReadyNow && ctsToStart != null) + { + await Clients.Group(roomId).SendAsync("PreparationStarted", 5); + _liveState.SetStatus(roomId, TerritoryGame.Domain.Entities.App.GameStatus.Waiting); + _ = RunPreparationCountdown(roomId, ctsToStart); + } + else + { + // 若之前在准备中被取消,通知取消 + await Clients.Group(roomId).SendAsync("PreparationCanceled"); + _liveState.SetStatus(roomId, TerritoryGame.Domain.Entities.App.GameStatus.Waiting); + } + } + + private async Task RunPreparationCountdown(string roomId, CancellationTokenSource cts) + { + if (!Rooms.TryGetValue(roomId, out var state)) return; + try + { + while (true) + { + int seconds; + lock (state.SyncRoot) + { + seconds = state.PreparingSeconds; + } + if (seconds <= 0) break; + await Clients.Group(roomId).SendAsync("PreparationTick", seconds); + await Task.Delay(1000, cts.Token); + lock (state.SyncRoot) + { + state.PreparingSeconds--; + seconds = state.PreparingSeconds; + } + if (seconds <= 0) break; + } + // 开始游戏 + await Clients.Group(roomId).SendAsync("GameStarted"); + _liveState.SetStatus(roomId, TerritoryGame.Domain.Entities.App.GameStatus.Playing); + } + catch (TaskCanceledException) + { + await Clients.Group(roomId).SendAsync("PreparationCanceled"); + _liveState.SetStatus(roomId, TerritoryGame.Domain.Entities.App.GameStatus.Waiting); + } + finally + { + lock (state.SyncRoot) + { + state.PreparingSeconds = 0; + state.PreparationCts = null; + state.ReadyUsers.Clear(); + } + } + } + + public override Task OnDisconnectedAsync(Exception? exception) + { + if (Connections.TryRemove(Context.ConnectionId, out var info)) + { + if (Rooms.TryGetValue(info.roomId, out var state)) + { + lock (state.SyncRoot) + { + state.Users.Remove(info.userId); + state.ReadyUsers.Remove(info.userId); + if (state.OwnerUserId == info.userId) + { + state.OwnerUserId = state.Users.FirstOrDefault(); + } + // 若准备中且不再全员准备,取消 + if (!(state.Users.Count > 0 && state.ReadyUsers.IsSupersetOf(state.Users))) + { + state.PreparationCts?.Cancel(); + state.PreparingSeconds = 0; + state.PreparationCts = null; + } + } + _ = Task.Run(() => _presence.RemoveUser(info.roomId, info.userId)); + // 广播玩家离开 + _ = Clients.Group(info.roomId).SendAsync("PlayerLeft", info.userId); + // 移除其角色并广播 + string? removedRole = null; + lock (state.SyncRoot) + { + if (state.Roles.Remove(info.userId, out var r)) removedRole = r; + } + if (removedRole != null) + { + _ = Clients.Group(info.roomId).SendAsync("PsRole", info.userId, "left"); + } + // 广播房主变更 + _ = Clients.Group(info.roomId).SendAsync("RoomOwnerChanged", state.OwnerUserId); + // 广播最新玩家列表 + var snapshot = state.Users + .Select(uid => new { userId = uid, nickname = state.UserNames.TryGetValue(uid, out var n) ? n : string.Empty }) + .Cast() + .ToList(); + _ = Clients.Group(info.roomId).SendAsync("UserList", snapshot); + // 如果房间已无人,回到 Waiting,并计划删除 + if (state.Users.Count == 0) + { + _liveState.SetStatus(info.roomId, TerritoryGame.Domain.Entities.App.GameStatus.Waiting); + ScheduleRoomDeletion(info.roomId, state, TimeSpan.FromSeconds(10)); + } + } + } + return base.OnDisconnectedAsync(exception); + } + + // 延时删除空房:若延时内无用户加入则删库并清理内存 + private async void ScheduleRoomDeletion(string roomId, RoomState state, TimeSpan delay) + { + CancellationTokenSource cts; + lock (state.SyncRoot) + { + state.DeletionCts?.Cancel(); + state.DeletionCts = new CancellationTokenSource(); + cts = state.DeletionCts; + } + try + { + await Task.Delay(delay, cts.Token); + if (cts.Token.IsCancellationRequested) return; + bool empty; + lock (state.SyncRoot) { empty = state.Users.Count == 0; } + if (!empty) return; + + try + { + if (_repo != null) + { + if (Guid.TryParse(roomId, out var gid)) + { + await _repo.DeleteAsync(gid); + } + else + { + var room = await _repo.GetByRoomCodeAsync(roomId); + if (room != null) + { + await _repo.DeleteAsync(room.Id); + } + } + } + } + catch { /* ignore db cleanup errors */ } + finally + { + lock (state.SyncRoot) + { + state.BlueSpawnCts?.Cancel(); + state.BlueSpawnCts = null; + state.GameCts?.Cancel(); + state.GameCts = null; + } + Rooms.TryRemove(roomId, out _); + } + } + catch (TaskCanceledException) { } + } + + // 仅房主可重置:清空画布(由前端清空本地渲染和队列) + public async Task ResetCanvas(string roomId, string userId) + { + if (!Rooms.TryGetValue(roomId, out var state)) return; + bool allowed; + lock (state.SyncRoot) + { + allowed = state.OwnerUserId == userId; + } + if (!allowed) return; // 简化:直接忽略非房主请求 + await Clients.Group(roomId).SendAsync("CanvasReset"); + } + + // 仅房主可操作:重开一局并重置配额/角色 + public async Task RestartRound(string roomId, string userId) + { + if (!Rooms.TryGetValue(roomId, out var state)) return; + bool allowed; + lock (state.SyncRoot) + { + allowed = state.OwnerUserId == userId; + } + if (!allowed) return; + + // 重置内存状态 + lock (state.SyncRoot) + { + state.IsStarted = false; + state.ReadyUsers.Clear(); + state.Roles.Clear(); + state.BlueSpawnCts?.Cancel(); + state.BlueSpawnCts = null; + state.BlueDots.Clear(); + state.PurpleDots.Clear(); + state.SilverDots.Clear(); + state.OrangeDots.Clear(); + state.Map = null; + } + // 重新加载配额(若房主调整过房间配置) + try + { + if (_repo != null) + { + var room = Guid.TryParse(roomId, out var gid) + ? await _repo.GetByIdAsync(gid) + : await _repo.GetByRoomCodeAsync(roomId); + if (room != null) + { + lock (state.SyncRoot) + { + state.HunterQuota = Math.Max(0, room.HunterCount); + state.RunnerQuota = Math.Max(0, room.RunnerCount); + } + } + } + } + catch { } + + _liveState.SetStatus(roomId, TerritoryGame.Domain.Entities.App.GameStatus.Waiting); + await Clients.Group(roomId).SendAsync("RoundReset"); + } + + // ===== Pixel Survival 专用同步(基础校验版) ===== + public async Task PsUpdatePosition(string roomId, string userId, float x, float y) + { + // 仅允许房间内且匹配 userId 的连接上报 + if (!Connections.TryGetValue(Context.ConnectionId, out var info) || info.roomId != roomId || info.userId != userId) + return; + if (float.IsNaN(x) || float.IsInfinity(x) || float.IsNaN(y) || float.IsInfinity(y)) + return; + // 夹取到合理范围(避免异常数值冲击前端);客户端仍会基于地图做碰撞 + x = Math.Clamp(x, 0f, 1000f); + y = Math.Clamp(y, 0f, 1000f); + await Clients.Group(roomId).SendAsync("PsPlayerPos", userId, x, y); + + // 拾取检测(基于四舍五入后的格子坐标) + if (Rooms.TryGetValue(roomId, out var state)) + { + // 记录位置 + lock (state.SyncRoot) + { + state.Positions[userId] = (x, y); + } + int gx = (int)MathF.Round(x); + int gy = (int)MathF.Round(y); + bool picked = false; + bool purplePicked = false; + bool silverPicked = false; + bool orangePicked = false; + lock (state.SyncRoot) + { + for (int i = 0; i < state.BlueDots.Count; i++) + { + var d = state.BlueDots[i]; + if (d.x == gx && d.y == gy) + { + state.BlueDots.RemoveAt(i); + picked = true; + break; + } + } + if (!picked) + { + for (int i = 0; i < state.PurpleDots.Count; i++) + { + var d = state.PurpleDots[i]; + if (d.x == gx && d.y == gy) + { + state.PurpleDots.RemoveAt(i); + purplePicked = true; + break; + } + } + } + if (!picked && !purplePicked) + { + for (int i = 0; i < state.SilverDots.Count; i++) + { + var d = state.SilverDots[i]; + if (d.x == gx && d.y == gy) + { + state.SilverDots.RemoveAt(i); + silverPicked = true; + break; + } + } + } + if (!picked && !purplePicked && !silverPicked) + { + for (int i = 0; i < state.OrangeDots.Count; i++) + { + var d = state.OrangeDots[i]; + if (d.x == gx && d.y == gy) + { + state.OrangeDots.RemoveAt(i); + orangePicked = true; + break; + } + } + } + } + if (picked) + { + await Clients.Group(roomId).SendAsync("PsBluePicked", userId, gx, gy, 5000); // 5秒加速 + } + if (purplePicked) + { + await Clients.Group(roomId).SendAsync("PsPurplePicked", userId, gx, gy); + if (TryTeleportPlayer(roomId, state, userId, out var newPos)) + { + await Clients.Group(roomId).SendAsync("PsPlayerTeleported", userId, newPos.rx, newPos.ry); + await Clients.Group(roomId).SendAsync("PsPlayerPos", userId, newPos.rx, newPos.ry); + } + } + if (silverPicked) + { + string role; + lock (state.SyncRoot) + { + state.Roles.TryGetValue(userId, out role!); + } + int delta = (role == "hunter") ? 5 : -5; + // 权威调整剩余时间并广播 + int newRemain; + lock (state.SyncRoot) + { + state.RemainingSeconds = Math.Clamp(state.RemainingSeconds + delta, 0, 3600); + newRemain = state.RemainingSeconds; + } + await Clients.Group(roomId).SendAsync("PsSilverPicked", userId, gx, gy, delta); + await Clients.Group(roomId).SendAsync("PsRemaining", newRemain); + } + if (orangePicked) + { + await Clients.Group(roomId).SendAsync("PsOrangePicked", userId, gx, gy, 5000); + } + + // 抓捕检测:若某猎人与某逃生者同格 + string? myRole = null; + lock (state.SyncRoot) + { + state.Roles.TryGetValue(userId, out myRole); + } + if (!string.IsNullOrEmpty(myRole) && state.IsStarted) + { + // 避免锁外多次读取,复制快照 + Dictionary rolesSnap; + Dictionary posSnap; + lock (state.SyncRoot) + { + rolesSnap = new Dictionary(state.Roles); + posSnap = new Dictionary(state.Positions); + } + if (myRole == "hunter") + { + foreach (var kv in rolesSnap) + { + if (kv.Value != "runner") continue; + if (!posSnap.TryGetValue(kv.Key, out var pr)) continue; + int rx = (int)MathF.Round(pr.x); + int ry = (int)MathF.Round(pr.y); + if (rx == gx && ry == gy) + { + var key = kv.Key; // runnerId + var now = DateTime.UtcNow; + bool canEmit = true; + lock (state.SyncRoot) + { + if (state.LastCaughtAt.TryGetValue(key, out var ts) && (now - ts).TotalSeconds < 1) + canEmit = false; + else state.LastCaughtAt[key] = now; + } + if (canEmit) + { + await Clients.Group(roomId).SendAsync("PsCaught", userId, key, gx, gy); + // 递增抓捕次数并检查胜利 + bool win = false; + (int rx, int ry) respawn = (gx, gy); + lock (state.SyncRoot) + { + state.CatchCount++; + win = state.CatchCount >= (state.RunnerQuota + 4); + } + if (!win) + { + if (TryRespawnRunner(roomId, state, key, out var newPos)) + { + respawn = newPos; + await Clients.Group(roomId).SendAsync("PsRunnerRespawned", key, respawn.rx, respawn.ry); + // 同步新位置给所有人 + await Clients.Group(roomId).SendAsync("PsPlayerPos", key, respawn.rx, respawn.ry); + } + } + else + { + await EndRoundHuntersWin(roomId, state); + } + } + break; + } + } + } + else if (myRole == "runner") + { + foreach (var kv in rolesSnap) + { + if (kv.Value != "hunter") continue; + if (!posSnap.TryGetValue(kv.Key, out var ph)) continue; + int hx = (int)MathF.Round(ph.x); + int hy = (int)MathF.Round(ph.y); + if (hx == gx && hy == gy) + { + var key = userId; // runnerId 为当前用户 + var now = DateTime.UtcNow; + bool canEmit = true; + lock (state.SyncRoot) + { + if (state.LastCaughtAt.TryGetValue(key, out var ts) && (now - ts).TotalSeconds < 1) + canEmit = false; + else state.LastCaughtAt[key] = now; + } + if (canEmit) + { + await Clients.Group(roomId).SendAsync("PsCaught", kv.Key, userId, gx, gy); + bool win = false; + (int rx, int ry) respawn = (gx, gy); + lock (state.SyncRoot) + { + state.CatchCount++; + win = state.CatchCount >= (state.RunnerQuota + 4); + } + if (!win) + { + if (TryRespawnRunner(roomId, state, userId, out var newPos)) + { + respawn = newPos; + await Clients.Group(roomId).SendAsync("PsRunnerRespawned", userId, respawn.rx, respawn.ry); + await Clients.Group(roomId).SendAsync("PsPlayerPos", userId, respawn.rx, respawn.ry); + } + } + else + { + await EndRoundHuntersWin(roomId, state); + } + } + break; + } + } + } + } + } + } + + private bool TryRespawnRunner(string roomId, RoomState state, string runnerId, out (int rx, int ry) respawn) + { + respawn = (0, 0); + EnsureMapInited(roomId, state); + if (state.Map == null) return false; + const int COLS = 64, ROWS = 36; + // 使用房间种子派生随机,尽量稳定 + var rng = new Mulberry32(Fnv1a32(state.RoomKey + ":respawn:" + DateTime.UtcNow.ToString("yyyyMMddHHmmss"))); + for (int i = 0; i < 300; i++) + { + int x = rng.NextInt(1, COLS - 2); + int y = rng.NextInt(1, ROWS - 2); + if (state.Map[y, x] != 0) continue; + respawn = (x, y); + lock (state.SyncRoot) + { + state.Positions[runnerId] = (x, y); + } + return true; + } + return false; + } + + private async Task EndRoundHuntersWin(string roomId, RoomState state) + { + lock (state.SyncRoot) + { + state.IsStarted = false; + state.CatchCount = 0; + state.BlueSpawnCts?.Cancel(); + state.BlueSpawnCts = null; + state.GameCts?.Cancel(); + state.GameCts = null; + state.RemainingSeconds = 0; + } + await Clients.Group(roomId).SendAsync("PsHuntersWin"); + _liveState.SetStatus(roomId, TerritoryGame.Domain.Entities.App.GameStatus.Waiting); + } + + // 前端在倒计时结束且未出现猎人胜利时调用,服务端统一宣布逃生者胜利(幂等) + public async Task AnnounceRunnersWin(string roomId, string userId) + { + if (!Rooms.TryGetValue(roomId, out var state)) return; + bool shouldBroadcast = false; + lock (state.SyncRoot) + { + if (state.IsStarted) + { + state.IsStarted = false; + state.CatchCount = 0; + state.BlueSpawnCts?.Cancel(); + state.BlueSpawnCts = null; + state.GameCts?.Cancel(); + state.GameCts = null; + state.RemainingSeconds = 0; + shouldBroadcast = true; + } + } + if (shouldBroadcast) + { + await Clients.Group(roomId).SendAsync("PsRunnersWin"); + _liveState.SetStatus(roomId, TerritoryGame.Domain.Entities.App.GameStatus.Waiting); + } + } + + // 技能同步已移除 + + public async Task PsSetRole(string roomId, string userId, string role) + { + if (!Connections.TryGetValue(Context.ConnectionId, out var info) || info.roomId != roomId || info.userId != userId) + return; + if (!Rooms.TryGetValue(roomId, out var state)) { await Clients.Group(roomId).SendAsync("PsRole", userId, role); return; } + role = (role == "hunter") ? "hunter" : "runner"; + bool allowed = false; + lock (state.SyncRoot) + { + // 计算当前人数 + var hunters = state.Roles.Values.Count(v => v == "hunter"); + var runners = state.Roles.Values.Count(v => v == "runner"); + var current = state.Roles.TryGetValue(userId, out var cur) ? cur : null; + + if (current == role) + { + allowed = true; + } + else if (role == "hunter") + { + if (hunters < state.HunterQuota) + { + state.Roles[userId] = "hunter"; + allowed = true; + } + } + else // runner + { + if (runners < state.RunnerQuota) + { + state.Roles[userId] = "runner"; + allowed = true; + } + } + + } + if (allowed) + { + await Clients.Group(roomId).SendAsync("PsRole", userId, role); + + // 每次角色变更后尝试自动开始 + await TryAutoStart(roomId, state); + } + else + { + // 拒绝则回发当前可见角色(不改变) + if (state.Roles.TryGetValue(userId, out var cur)) + await Clients.Caller.SendAsync("PsRole", userId, cur); + } + } + + private async Task TryAutoStart(string roomId, RoomState state) + { + bool startNow = false; + lock (state.SyncRoot) + { + if (!state.IsStarted) + { + var hunters = state.Roles.Values.Count(v => v == "hunter"); + var runners = state.Roles.Values.Count(v => v == "runner"); + if (hunters == state.HunterQuota && runners == state.RunnerQuota) + { + state.IsStarted = true; + startNow = true; + } + } + } + if (startNow) + { + await Clients.Group(roomId).SendAsync("GameStarted"); + _liveState.SetStatus(roomId, TerritoryGame.Domain.Entities.App.GameStatus.Playing); + // 启动地图与蓝点生成循环 + EnsureMapInited(roomId, state); + StartBlueSpawnLoop(roomId, state, TimeSpan.FromSeconds(30)); + // 启动服务器权威倒计时 + StartGameCountdown(roomId, state); + } + } + + private void EnsureMapInited(string roomId, RoomState state) + { + lock (state.SyncRoot) + { + if (state.Map != null) return; + const int COLS = 64, ROWS = 36; + state.Map = new int[ROWS, COLS]; + // 简单确定性伪随机:基于房间Key + uint seed = Fnv1a32(state.RoomKey ?? roomId); + var rng = new Mulberry32(seed); + for (int y = 0; y < ROWS; y++) + { + for (int x = 0; x < COLS; x++) + { + if (x == 0 || y == 0 || x == COLS - 1 || y == ROWS - 1) state.Map[y, x] = 1; + else state.Map[y, x] = (rng.NextFloat() < 0.12f) ? 1 : 0; + } + } + state.BlueDots.Clear(); + state.PurpleDots.Clear(); + state.SilverDots.Clear(); + state.OrangeDots.Clear(); + } + } + + private async void StartBlueSpawnLoop(string roomId, RoomState state, TimeSpan interval) + { + CancellationTokenSource cts; + lock (state.SyncRoot) + { + state.BlueSpawnCts?.Cancel(); + state.BlueSpawnCts = new CancellationTokenSource(); + cts = state.BlueSpawnCts; + } + try + { + // 先立即生成一批(5 个蓝点);银/橙初始不生成;紫初始生成 1 个 + int initCount = 0; + for (int i = 0; i < 5; i++) if (TrySpawnBlueDot(state)) initCount++; + if (initCount > 0) + { + var dots = state.BlueDots.Select(d => new { x = d.x, y = d.y }).ToArray(); + await _hubContext.Clients.Group(roomId).SendAsync("PsBlueDots", dots); + } + int initPurple = 0; + if (TrySpawnPurpleDot(state)) initPurple++; + if (initPurple > 0) + { + var pdots = state.PurpleDots.Select(d => new { x = d.x, y = d.y }).ToArray(); + await _hubContext.Clients.Group(roomId).SendAsync("PsPurpleDots", pdots); + } + int tick = 0; + while (!cts.Token.IsCancellationRequested) + { + await Task.Delay(interval, cts.Token); + if (cts.Token.IsCancellationRequested) break; + tick++; + // 每个周期生成 5 个新蓝点,逐个广播 + for (int i = 0; i < 5; i++) + { + if (TrySpawnBlueDot(state)) + { + var last = state.BlueDots.Last(); + await _hubContext.Clients.Group(roomId).SendAsync("PsBlueSpawned", last.x, last.y); + } + } + // 每周期生成 1 个紫点 + if (TrySpawnPurpleDot(state)) + { + var lastp = state.PurpleDots.Last(); + await _hubContext.Clients.Group(roomId).SendAsync("PsPurpleSpawned", lastp.x, lastp.y); + } + // 每周期生成 2 个橙点 + for (int i = 0; i < 2; i++) + { + if (TrySpawnOrangeDot(state)) + { + var lasto = state.OrangeDots.Last(); + await _hubContext.Clients.Group(roomId).SendAsync("PsOrangeSpawned", lasto.x, lasto.y); + } + } + // 每两周期(60s)生成 1 个银灰点 + if (tick % 2 == 0) + { + if (TrySpawnSilverDot(state)) + { + var lasts = state.SilverDots.Last(); + await _hubContext.Clients.Group(roomId).SendAsync("PsSilverSpawned", lasts.x, lasts.y); + } + } + } + } + catch (TaskCanceledException) { } + } + + private bool TrySpawnBlueDot(RoomState state) + { + const int COLS = 64, ROWS = 36; + if (state.Map == null) return false; + // 简单随机尝试 + var rng = new Mulberry32(Fnv1a32(Guid.NewGuid().ToString())); + for (int i = 0; i < 200; i++) + { + int x = rng.NextInt(1, COLS - 2); + int y = rng.NextInt(1, ROWS - 2); + if (state.Map[y, x] != 0) continue; + // 不重复 + if (state.BlueDots.Any(d => d.x == x && d.y == y)) continue; + if (state.PurpleDots.Any(d => d.x == x && d.y == y)) continue; + if (state.SilverDots.Any(d => d.x == x && d.y == y)) continue; + if (state.OrangeDots.Any(d => d.x == x && d.y == y)) continue; + state.BlueDots.Add((x, y)); + return true; + } + return false; + } + + private bool TrySpawnPurpleDot(RoomState state) + { + const int COLS = 64, ROWS = 36; + if (state.Map == null) return false; + var rng = new Mulberry32(Fnv1a32(Guid.NewGuid().ToString())); + for (int i = 0; i < 200; i++) + { + int x = rng.NextInt(1, COLS - 2); + int y = rng.NextInt(1, ROWS - 2); + if (state.Map[y, x] != 0) continue; + if (state.PurpleDots.Any(d => d.x == x && d.y == y)) continue; + if (state.BlueDots.Any(d => d.x == x && d.y == y)) continue; + if (state.SilverDots.Any(d => d.x == x && d.y == y)) continue; + if (state.OrangeDots.Any(d => d.x == x && d.y == y)) continue; + state.PurpleDots.Add((x, y)); + return true; + } + return false; + } + + private bool TrySpawnSilverDot(RoomState state) + { + const int COLS = 64, ROWS = 36; + if (state.Map == null) return false; + var rng = new Mulberry32(Fnv1a32(Guid.NewGuid().ToString())); + for (int i = 0; i < 200; i++) + { + int x = rng.NextInt(1, COLS - 2); + int y = rng.NextInt(1, ROWS - 2); + if (state.Map[y, x] != 0) continue; + if (state.SilverDots.Any(d => d.x == x && d.y == y)) continue; + if (state.BlueDots.Any(d => d.x == x && d.y == y)) continue; + if (state.PurpleDots.Any(d => d.x == x && d.y == y)) continue; + if (state.OrangeDots.Any(d => d.x == x && d.y == y)) continue; + state.SilverDots.Add((x, y)); + return true; + } + return false; + } + + private bool TrySpawnOrangeDot(RoomState state) + { + const int COLS = 64, ROWS = 36; + if (state.Map == null) return false; + var rng = new Mulberry32(Fnv1a32(Guid.NewGuid().ToString())); + for (int i = 0; i < 200; i++) + { + int x = rng.NextInt(1, COLS - 2); + int y = rng.NextInt(1, ROWS - 2); + if (state.Map[y, x] != 0) continue; + if (state.OrangeDots.Any(d => d.x == x && d.y == y)) continue; + if (state.BlueDots.Any(d => d.x == x && d.y == y)) continue; + if (state.PurpleDots.Any(d => d.x == x && d.y == y)) continue; + if (state.SilverDots.Any(d => d.x == x && d.y == y)) continue; + state.OrangeDots.Add((x, y)); + return true; + } + return false; + } + + private bool TryTeleportPlayer(string roomId, RoomState state, string userId, out (int rx, int ry) newPos) + { + newPos = (0, 0); + EnsureMapInited(roomId, state); + if (state.Map == null) return false; + const int COLS = 64, ROWS = 36; + var rng = new Mulberry32(Fnv1a32(state.RoomKey + ":tp:" + Guid.NewGuid().ToString("N"))); + for (int i = 0; i < 300; i++) + { + int x = rng.NextInt(1, COLS - 2); + int y = rng.NextInt(1, ROWS - 2); + if (state.Map[y, x] != 0) continue; + lock (state.SyncRoot) + { + state.Positions[userId] = (x, y); + } + newPos = (x, y); + return true; + } + return false; + } + + // FNV-1a 32 与 Mulberry32 简实现 + private static uint Fnv1a32(string s) + { + unchecked + { + uint h = 2166136261; + foreach (var ch in s) + { + h ^= ch; + h *= 16777619; + } + return h; + } + } + private struct Mulberry32 + { + private uint _state; + public Mulberry32(uint seed) { _state = seed; } + public float NextFloat() + { + unchecked + { + uint t = _state += 0x6D2B79F5; + t = (t ^ (t >> 15)) * (t | 1); + t ^= t + ((t ^ (t >> 7)) * (t | 61)); + return ((t ^ (t >> 14)) >> 0) / 4294967296f; + } + } + public int NextInt(int min, int max) + { + return (int)MathF.Floor(NextFloat() * (max - min + 1)) + min; + } + } + + // 服务器权威倒计时:每秒广播 PsRemaining;到 0 时宣布逃生者胜利 + private async void StartGameCountdown(string roomId, RoomState state) + { + CancellationTokenSource cts; + int firstRemain; + lock (state.SyncRoot) + { + state.GameCts?.Cancel(); + state.GameCts = new CancellationTokenSource(); + cts = state.GameCts; + state.RemainingSeconds = state.GameDurationSec > 0 ? state.GameDurationSec : 180; + firstRemain = state.RemainingSeconds; + } + try + { + await _hubContext.Clients.Group(roomId).SendAsync("PsRemaining", firstRemain); + while (!cts.Token.IsCancellationRequested) + { + await Task.Delay(1000, cts.Token); + if (cts.Token.IsCancellationRequested) break; + int remain; + lock (state.SyncRoot) + { + if (state.RemainingSeconds > 0) state.RemainingSeconds--; + remain = state.RemainingSeconds; + } + await _hubContext.Clients.Group(roomId).SendAsync("PsRemaining", remain); + if (remain <= 0) + { + bool announce = false; + lock (state.SyncRoot) + { + if (state.IsStarted) announce = true; + } + if (announce) + { + await AnnounceRunnersWin(roomId, "system"); + } + break; + } + } + } + catch (TaskCanceledException) { } + } +} diff --git a/Backend/src/TerritoryGame.API/Program.cs b/Backend/src/TerritoryGame.API/Program.cs new file mode 100644 index 0000000000000000000000000000000000000000..35706b03093a6e4e52696ff0767ed997699496e2 --- /dev/null +++ b/Backend/src/TerritoryGame.API/Program.cs @@ -0,0 +1,94 @@ +using TerritoryGame.Infrastructure; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using System.Text; +using TerritoryGame.Application.Servicece; +using TerritoryGame.API.Services; +using TerritoryGame.Application; +using TerritoryGame.API.Hubs; +using System; + +var builder = WebApplication.CreateBuilder(args); + +// 注册服务 +builder.Services.AddControllers(); +builder.Services.AddInfrastructureServices(builder.Configuration); +builder.Services.AddApplicationServices(builder.Configuration); +// builder.Services.AddScoped(); +builder.Services.AddScoped(); +// builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + + + +// SignalR +builder.Services.AddSignalR(); + +// CORS +var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? Array.Empty(); +builder.Services.AddCors(options => +{ + options.AddPolicy("CorsPolicy", policy => + { + if (allowedOrigins.Length > 0) + { + policy.WithOrigins(allowedOrigins) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + } + else + { + // 开发兜底:未配置时放开(不附带凭据) + policy.AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod(); + } + }); +}); + +// MediatR is registered in Application layer + +// JWT 配置(使用简单的内联配置示例,建议改为 appsettings) +var jwtKey = builder.Configuration["Jwt:Key"] ?? "dev-secret-key-change"; +var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "TerritoryGame"; +var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "TerritoryGameAudience"; + +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}).AddJwtBearer(options => +{ + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtIssuer, + ValidAudience = jwtAudience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)) + }; +}); + +var app = builder.Build(); + +// 配置中间件管道 +if (app.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); +} + +app.UseRouting(); +app.UseCors("CorsPolicy"); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); + +// SignalR endpoints +app.MapHub("/gamehub"); + +app.Run(); diff --git a/Backend/src/TerritoryGame.API/Properties/launchSettings.json b/Backend/src/TerritoryGame.API/Properties/launchSettings.json new file mode 100644 index 0000000000000000000000000000000000000000..3df6b2f96c139146cf5a1aac3e6646ebdd784fff --- /dev/null +++ b/Backend/src/TerritoryGame.API/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:20050", + "sslPort": 44385 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5056", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7208;http://localhost:5056", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Backend/src/TerritoryGame.API/Services/IRoomLiveState.cs b/Backend/src/TerritoryGame.API/Services/IRoomLiveState.cs new file mode 100644 index 0000000000000000000000000000000000000000..b82bb7ca19a42bd698300918fd9c31fd982b230a --- /dev/null +++ b/Backend/src/TerritoryGame.API/Services/IRoomLiveState.cs @@ -0,0 +1,32 @@ +using System.Collections.Concurrent; +using TerritoryGame.Domain.Entities.App; + +namespace TerritoryGame.API.Services; + +public interface IRoomLiveState +{ + void SetStatus(string roomId, GameStatus status); + bool TryGetStatus(string roomId, out GameStatus status); + IReadOnlyDictionary SnapshotStatuses(); +} + +public class InMemoryRoomLiveState : IRoomLiveState +{ + private readonly ConcurrentDictionary _states = new(); + + public void SetStatus(string roomId, GameStatus status) + { + if (string.IsNullOrWhiteSpace(roomId)) return; + _states[roomId] = status; + } + + public bool TryGetStatus(string roomId, out GameStatus status) + { + return _states.TryGetValue(roomId, out status); + } + + public IReadOnlyDictionary SnapshotStatuses() + { + return new Dictionary(_states); + } +} diff --git a/Backend/src/TerritoryGame.API/Services/IRoomPresence.cs b/Backend/src/TerritoryGame.API/Services/IRoomPresence.cs new file mode 100644 index 0000000000000000000000000000000000000000..f2477d23e7d4e57689b1e46bbfd0d3747bba338e --- /dev/null +++ b/Backend/src/TerritoryGame.API/Services/IRoomPresence.cs @@ -0,0 +1,55 @@ +using System.Collections.Concurrent; + +namespace TerritoryGame.API.Services; + +public interface IRoomPresence +{ + void AddUser(string roomId, string userId); + void RemoveUser(string roomId, string userId); + IReadOnlyDictionary SnapshotCounts(); +} + +public class InMemoryRoomPresence : IRoomPresence +{ + private readonly ConcurrentDictionary> _roomUsers = new(); + private readonly ConcurrentDictionary _locks = new(); + + public void AddUser(string roomId, string userId) + { + if (string.IsNullOrWhiteSpace(roomId) || string.IsNullOrWhiteSpace(userId)) return; + var set = _roomUsers.GetOrAdd(roomId, _ => new HashSet()); + var guard = _locks.GetOrAdd(roomId, _ => new object()); + lock (guard) + { + set.Add(userId); + } + } + + public void RemoveUser(string roomId, string userId) + { + if (string.IsNullOrWhiteSpace(roomId) || string.IsNullOrWhiteSpace(userId)) return; + if (_roomUsers.TryGetValue(roomId, out var set)) + { + var guard = _locks.GetOrAdd(roomId, _ => new object()); + lock (guard) + { + set.Remove(userId); + if (set.Count == 0) + { + _roomUsers.TryRemove(roomId, out _); + _locks.TryRemove(roomId, out _); + } + } + } + } + + public IReadOnlyDictionary SnapshotCounts() + { + var dict = new Dictionary(); + foreach (var kv in _roomUsers) + { + dict[kv.Key] = kv.Value.Count; + } + return dict; + } +} diff --git a/Backend/src/TerritoryGame.API/Services/IRoomStatus.cs b/Backend/src/TerritoryGame.API/Services/IRoomStatus.cs new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/Backend/src/TerritoryGame.API/Services/JwtTokenGenerator.cs b/Backend/src/TerritoryGame.API/Services/JwtTokenGenerator.cs new file mode 100644 index 0000000000000000000000000000000000000000..f5353908ea229ff9581f71ff21eb67756465aafc --- /dev/null +++ b/Backend/src/TerritoryGame.API/Services/JwtTokenGenerator.cs @@ -0,0 +1,47 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using TerritoryGame.Application.Servicece; + +namespace TerritoryGame.API.Services; + +public class JwtTokenGenerator : IJwtTokenGenerator +{ + private readonly IConfiguration _configuration; + + public JwtTokenGenerator(IConfiguration configuration) + { + _configuration = configuration; + } + + public string GenerateToken(Guid playerId, string nickName) + { + var key = _configuration["Jwt:Key"] ?? "dev-secret-key-change"; + var issuer = _configuration["Jwt:Issuer"] ?? "TerritoryGame"; + var audience = _configuration["Jwt:Audience"] ?? "TerritoryGameAudience"; + var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)); + var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256); + + var claims = new List + { + new Claim(JwtRegisteredClaimNames.Sub, playerId.ToString()), + new Claim(JwtRegisteredClaimNames.UniqueName, nickName) + }; + + var expiresMinutes = int.TryParse(_configuration["Jwt:ExpiresMinutes"], out var m) ? m : 120; + + var token = new JwtSecurityToken( + issuer: issuer, + audience: audience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(expiresMinutes), + signingCredentials: credentials + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} + + diff --git a/Backend/src/TerritoryGame.API/Services/SignalRRealtimeNotifier.cs b/Backend/src/TerritoryGame.API/Services/SignalRRealtimeNotifier.cs new file mode 100644 index 0000000000000000000000000000000000000000..5a28aa3ab4f81aab780bc7aefb772e28860f94bc --- /dev/null +++ b/Backend/src/TerritoryGame.API/Services/SignalRRealtimeNotifier.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.SignalR; +using TerritoryGame.API.Hubs; +using TerritoryGame.Application.Dtos.Paint; +using TerritoryGame.Application.Dtos.Ranking; +using TerritoryGame.Application.Servicece; + +namespace TerritoryGame.API.Services; + +public class SignalRRealtimeNotifier : IRealtimeNotifier +{ + private readonly IHubContext _hubContext; + + public SignalRRealtimeNotifier(IHubContext hubContext) + { + _hubContext = hubContext; + } + + public async Task BroadcastPaintAsync(string roomId, string userId, PaintActionDto paint, CancellationToken cancellationToken = default) + { + await _hubContext.Clients.Group(roomId).SendAsync("ReceivePaint", userId, paint, cancellationToken); + } + + public async Task BroadcastRankingAsync(string roomId, RankingDto ranking, CancellationToken cancellationToken = default) + { + await _hubContext.Clients.Group(roomId).SendAsync("UpdateRanking", ranking, cancellationToken); + } + + public async Task BroadcastKickedAsync(string roomId, string targetUserId, CancellationToken cancellationToken = default) + { + await _hubContext.Clients.Group(roomId).SendAsync("PlayerKicked", targetUserId, cancellationToken); + } + + // 撤销功能已移除 +} diff --git a/Backend/src/TerritoryGame.API/TerritoryGame.API.csproj b/Backend/src/TerritoryGame.API/TerritoryGame.API.csproj new file mode 100644 index 0000000000000000000000000000000000000000..9534e674a81d5674bb9008cbb736a7b20aae4e55 --- /dev/null +++ b/Backend/src/TerritoryGame.API/TerritoryGame.API.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/Backend/src/TerritoryGame.API/TerritoryGame.API.http b/Backend/src/TerritoryGame.API/TerritoryGame.API.http new file mode 100644 index 0000000000000000000000000000000000000000..42bd0dffdbd891f37abb1c7b3cfe18b2cd88ed5f --- /dev/null +++ b/Backend/src/TerritoryGame.API/TerritoryGame.API.http @@ -0,0 +1,19 @@ +@url = http://localhost:5056 + +# 在这里添加您的API测试 + +### 登录(按 NickName) +POST {{url}}/api/auth/login +Content-Type: application/json + +{ + "nickName": "new-nickname" +} + +### 注册(按 NickName) +POST {{url}}/api/auth/register +Content-Type: application/json + +{ + "nickName": "admin" +} diff --git a/Backend/src/TerritoryGame.API/appsettings.json b/Backend/src/TerritoryGame.API/appsettings.json new file mode 100644 index 0000000000000000000000000000000000000000..3a1eee1496b0e79b9562f1d22abf78d1738d13ea --- /dev/null +++ b/Backend/src/TerritoryGame.API/appsettings.json @@ -0,0 +1,33 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Host=njy22.cn;Database=game;Username=postgres;Password=postgreSQL@njy22.cn;" + }, + "Jwt": { + "Key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "Issuer": "TerritoryGame", + "Audience": "TerritoryGameAudience", + "ExpiresMinutes": 120 + }, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://0.0.0.0:5056" + } + } + }, + "Cors": { + "AllowedOrigins": [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://njy22.cn:5173", + "http://njy22.cn:5000" + ] + } +} \ No newline at end of file diff --git a/Backend/src/TerritoryGame.API/http/gameroom.http b/Backend/src/TerritoryGame.API/http/gameroom.http new file mode 100644 index 0000000000000000000000000000000000000000..11b48576875482820f43bacff1a7ea516ab97769 --- /dev/null +++ b/Backend/src/TerritoryGame.API/http/gameroom.http @@ -0,0 +1,272 @@ +@url = http://localhost:5056 +@contentType = application/json + +### 游戏房间管理API测试 + +# ======================================== +# 1. 创建游戏房间测试 +# ======================================== + +### 创建默认房间(无密码,默认配置) +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{ + "maxPlayers": 6, + "gameDuration": 180 +} + +### 创建带密码的房间 +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{ + "password": "123456", + "maxPlayers": 8, + "gameDuration": 300 +} + +### 创建自定义配置房间 +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{ + "password": "game2024", + "maxPlayers": 4, + "gameDuration": 120 +} + +### 创建房间(最小配置) +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{} + +# ======================================== +# 2. 查询房间测试 +# ======================================== + +### 获取所有房间列表 +GET {{url}}/api/GameRoom + +### 根据房间ID查询房间(需要替换为实际的房间ID) +GET {{url}}/api/GameRoom/id/00000000-0000-0000-0000-000000000000 + +### 根据房间名称查询房间 +GET {{url}}/api/GameRoom/name/652592 + +# ======================================== +# 3. 删除房间测试 +# ======================================== + +### 删除房间(需要替换为实际的房间ID) +DELETE {{url}}/api/GameRoom/00000000-0000-0000-0000-000000000000 + +# ======================================== +# 4. 错误情况测试 +# ======================================== + +### 测试无效的房间ID格式 +GET {{url}}/api/GameRoom/invalid-id + +### 测试删除不存在的房间 +DELETE {{url}}/api/GameRoom/11111111-1111-1111-1111-111111111111 + +### 测试创建房间时无效参数 +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{ + "maxPlayers": -1, + "gameDuration": 0 +} + +### 测试创建房间时无效的最大玩家数 +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{ + "maxPlayers": 100, + "gameDuration": 1000 +} + +# ======================================== +# 5. 批量测试场景 +# ======================================== + +### 批量创建多个房间进行测试 +# 房间1:默认配置 +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{} + +--- +# 房间2:带密码 +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{ + "password": "test123" +} + +--- +# 房间3:自定义配置 +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{ + "maxPlayers": 10, + "gameDuration": 600 +} + +--- +# 获取所有房间验证创建结果 +GET {{url}}/api/GameRoom + +# ======================================== +# 6. 性能测试场景 +# ======================================== + +### 测试创建大量房间(注意:这可能会创建很多房间) +# 创建10个房间 +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{} + +--- +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{} + +--- +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{} + +--- +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{} + +--- +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{} + +--- +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{} + +--- +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{} + +--- +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{} + +--- +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{} + +--- +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{} + +--- +# 验证所有房间 +GET {{url}}/api/GameRoom + +# ======================================== +# 7. 边界值测试 +# ======================================== + +### 测试边界值:最大玩家数 +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{ + "maxPlayers": 1 +} + +--- +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{ + "maxPlayers": 20 +} + +### 测试边界值:游戏时长 +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{ + "gameDuration": 60 +} + +--- +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{ + "gameDuration": 3600 +} + +### 测试边界值:密码长度 +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{ + "password": "a" +} + +--- +POST {{url}}/api/GameRoom +Content-Type: {{contentType}} + +{ + "password": "very-long-password-that-might-cause-issues-if-not-properly-handled" +} + +# ======================================== +# 8. 清理测试数据 +# ======================================== + +### 获取所有房间以便清理 +GET {{url}}/api/GameRoom + +### 删除测试房间(需要根据实际返回的房间ID进行删除) +# DELETE {{url}}/api/GameRoom/{room-id-1} +# DELETE {{url}}/api/GameRoom/{room-id-2} +# DELETE {{url}}/api/GameRoom/{room-id-3} + +# ======================================== +# 使用说明 +# ======================================== + +# 1. 首先运行创建房间的测试,获取房间ID +# 2. 使用返回的房间ID替换查询和删除测试中的占位符 +# 3. 运行查询测试验证房间创建成功 +# 4. 运行删除测试验证删除功能 +# 5. 运行错误测试验证错误处理 +# 6. 最后运行清理测试删除测试数据 + +# 注意:某些测试可能需要根据实际的房间ID进行调整 +# 建议按顺序运行测试,确保测试的连贯性 diff --git a/Backend/src/TerritoryGame.API/http/realtime.http b/Backend/src/TerritoryGame.API/http/realtime.http new file mode 100644 index 0000000000000000000000000000000000000000..7de5c367e04df4612f80d420e1f8126fbfb426c0 --- /dev/null +++ b/Backend/src/TerritoryGame.API/http/realtime.http @@ -0,0 +1,40 @@ +@host = http://localhost:5056 +@roomId = 3d1367ed-51be-469b-8c0b-e66630a35958 +@userId = e89e9e4e-9648-4bd8-9405-b82e2a2563db + + +### +# 提示: +# 1) 将上面的 @roomId 与 @userId 替换为真实 GUID(来自创建房间与玩家注册/登录后返回的 Id)。 +# 2) 发送 paint 请求后: +# - 房间内所有连接 /gamehub 并 JoinRoom(@roomId) 的客户端应立即收到 ReceivePaint。 +# - 后台消费者会异步写入 PaintActions。 +# 3) 发送 ranking 请求后,客户端应收到 UpdateRanking。 + + +### 模拟涂色事件 -> 立即广播 + 入队 + 异步入库 +POST {{host}}/api/realtime/paint?roomId={{roomId}}&userId={{userId}} +Content-Type: application/json + +{ + "color": "#ff3366", + "brushSize": 12, + "points": [ + { "x": 120.5, "y": 240.25, "t": {{$timestamp}} }, + { "x": 122.0, "y": 242.0, "t": {{$timestamp}} } + ] +} + + +### 模拟排行榜更新 -> 立即广播 +POST {{host}}/api/realtime/ranking +Content-Type: application/json + +{ + "roomId": "{{roomId}}", + "generatedAt": {{$timestamp}}, + "items": [ + { "userId": "{{userId}}", "area": 3200.5, "score": 128 }, + { "userId": "00000000-0000-0000-0000-000000000003", "area": 2800.0, "score": 112 } + ] +} \ No newline at end of file diff --git a/Backend/src/TerritoryGame.Application/Class1.cs b/Backend/src/TerritoryGame.Application/Class1.cs new file mode 100644 index 0000000000000000000000000000000000000000..8e7a47a9ca8bec27e6a612c27a7b821fd3f6f195 --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Class1.cs @@ -0,0 +1,6 @@ +namespace TerritoryGame.Application; + +public class Class1 +{ + +} diff --git a/Backend/src/TerritoryGame.Application/Common/EventBus/EventQueue.cs b/Backend/src/TerritoryGame.Application/Common/EventBus/EventQueue.cs new file mode 100644 index 0000000000000000000000000000000000000000..506b51c699e96b3e1139e92f438bdc797d072b56 --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Common/EventBus/EventQueue.cs @@ -0,0 +1,28 @@ +using System.Threading.Channels; + +namespace TerritoryGame.Application.Common.EventBus; + +public interface IEventQueue +{ + ChannelWriter Writer { get; } + ChannelReader Reader { get; } +} + +public class InMemoryEventQueue : IEventQueue +{ + private readonly Channel _channel; + + public InMemoryEventQueue() + { + var options = new UnboundedChannelOptions + { + SingleReader = false, + SingleWriter = false, + AllowSynchronousContinuations = false + }; + _channel = Channel.CreateUnbounded(options); + } + + public ChannelWriter Writer => _channel.Writer; + public ChannelReader Reader => _channel.Reader; +} diff --git a/Backend/src/TerritoryGame.Application/Common/EventBus/IEvent.cs b/Backend/src/TerritoryGame.Application/Common/EventBus/IEvent.cs new file mode 100644 index 0000000000000000000000000000000000000000..07a306650c6f82de4c6ef783cf805d5c7092bcc7 --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Common/EventBus/IEvent.cs @@ -0,0 +1,4 @@ +namespace TerritoryGame.Application.Common.EventBus; + +// Marker interface for domain/application events +public interface IEvent { } diff --git a/Backend/src/TerritoryGame.Application/Common/EventBus/IEventBus.cs b/Backend/src/TerritoryGame.Application/Common/EventBus/IEventBus.cs new file mode 100644 index 0000000000000000000000000000000000000000..00dd263932bf02a06671b109326bc4bad017ba1d --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Common/EventBus/IEventBus.cs @@ -0,0 +1,22 @@ +namespace TerritoryGame.Application.Common.EventBus; + +public interface IEventBus +{ + Task PublishAsync(TEvent @event, CancellationToken cancellationToken = default) where TEvent : IEvent; +} + +public class InMemoryEventBus : IEventBus +{ + private readonly IEventQueue _queue; + + public InMemoryEventBus(IEventQueue queue) + { + _queue = queue; + } + + public Task PublishAsync(TEvent @event, CancellationToken cancellationToken = default) where TEvent : IEvent + { + // push to queue, do not await consumers + return _queue.Writer.WriteAsync(@event, cancellationToken).AsTask(); + } +} diff --git a/Backend/src/TerritoryGame.Application/Common/EventBus/IEventHandler.cs b/Backend/src/TerritoryGame.Application/Common/EventBus/IEventHandler.cs new file mode 100644 index 0000000000000000000000000000000000000000..e16ad8d86f6fec874602c70c4468d5597cacb0b1 --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Common/EventBus/IEventHandler.cs @@ -0,0 +1,6 @@ +namespace TerritoryGame.Application.Common.EventBus; + +public interface IEventHandler where TEvent : IEvent +{ + Task HandleAsync(TEvent @event, CancellationToken cancellationToken = default); +} diff --git a/Backend/src/TerritoryGame.Application/Dtos/Auth/AuthLoginResult.cs b/Backend/src/TerritoryGame.Application/Dtos/Auth/AuthLoginResult.cs new file mode 100644 index 0000000000000000000000000000000000000000..ce11b25ca26dddf29d88a2429c657eb331761bf9 --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Dtos/Auth/AuthLoginResult.cs @@ -0,0 +1,9 @@ +namespace TerritoryGame.Application.Dtos; + +public class AuthLoginResult +{ + public string Token { get; set; } = default!; + public AuthPlayer Player { get; set; } = default!; +} + + diff --git a/Backend/src/TerritoryGame.Application/Dtos/Auth/AuthPlayer.cs b/Backend/src/TerritoryGame.Application/Dtos/Auth/AuthPlayer.cs new file mode 100644 index 0000000000000000000000000000000000000000..c075315f2b1c3167d9ff74575e9155827d70066b --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Dtos/Auth/AuthPlayer.cs @@ -0,0 +1,9 @@ +namespace TerritoryGame.Application.Dtos; + +public class AuthPlayer +{ + public string NickName { get; set; } = default!; + public string? Avatar { get; set; } + public int TotalGames { get; set; } + public int WinCount { get; set; } +} \ No newline at end of file diff --git a/Backend/src/TerritoryGame.Application/Dtos/GameRoom/CreateRoomRequest.cs b/Backend/src/TerritoryGame.Application/Dtos/GameRoom/CreateRoomRequest.cs new file mode 100644 index 0000000000000000000000000000000000000000..93ad6d6edc990deba198db4f7c3093fd61d6c664 --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Dtos/GameRoom/CreateRoomRequest.cs @@ -0,0 +1,59 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using TerritoryGame.Domain.Entities.App; + +namespace TerritoryGame.Application.Dtos; + +/// +/// 创建房间请求 +/// +public class CreateRoomRequest +{ + /// + /// 房间名(房主自定义展示名称) + /// + [Required] + [StringLength(30, MinimumLength = 1, ErrorMessage = "房间名长度需在 1~30 之间")] + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + /// + /// 房间密码(可选) + /// + [JsonPropertyName("password")] + public string? Password { get; set; } + + /// + /// 最大玩家数量 + /// + [Range(2, 16, ErrorMessage = "最大玩家数量需在 2~16 之间")] + [JsonPropertyName("maxPlayers")] + public int MaxPlayers { get; set; } = 6; + + /// + /// 游戏时长(秒) + /// + [Range(30, 3600, ErrorMessage = "游戏时长需在 30~3600 秒之间")] + [JsonPropertyName("gameDuration")] + public int GameDuration { get; set; } = 180; + + /// + /// 游戏类型(0=TG 领地争夺,1=PS 逃生像素),默认 TG + /// + [JsonPropertyName("gameType")] + public GameType GameType { get; set; } = GameType.TG; + + /// + /// 追捕者数量(仅 PS 使用) + /// + [Range(0, 16)] + [JsonPropertyName("hunterCount")] + public int HunterCount { get; set; } = 1; + + /// + /// 逃生者数量(仅 PS 使用) + /// + [Range(0, 16)] + [JsonPropertyName("runnerCount")] + public int RunnerCount { get; set; } = 5; +} diff --git a/Backend/src/TerritoryGame.Application/Dtos/GameRoom/GameRoomDto.cs b/Backend/src/TerritoryGame.Application/Dtos/GameRoom/GameRoomDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..138d1229b99f8d66d99970aa8a5722e0246aa511 --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Dtos/GameRoom/GameRoomDto.cs @@ -0,0 +1,88 @@ +using System.Text.Json.Serialization; +using TerritoryGame.Domain.Entities.App; + +namespace TerritoryGame.Application.Dtos; + +/// +/// 游戏房间信息 +/// +public class GameRoomDto +{ + /// + /// 房间ID + /// + [JsonPropertyName("id")] + public Guid Id { get; set; } + + /// + /// 房间号(6位数字) + /// + [JsonPropertyName("name")] + public string Name { get; set; } = default!; + /// + /// 房间显示名称(房主自定义) + /// + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + /// + /// 房间号(与 Name 一致,后续以 RoomCode 为准) + /// + [JsonPropertyName("roomCode")] + public string RoomCode { get; set; } = string.Empty; + + /// + /// 房间密码(可选) + /// + [JsonPropertyName("password")] + public string? Password { get; set; } + + /// + /// 最大玩家数量 + /// + [JsonPropertyName("maxPlayers")] + public int MaxPlayers { get; set; } + + /// + /// 当前状态 + /// + [JsonPropertyName("status")] + public GameStatus Status { get; set; } + + /// + /// 游戏时长(秒) + /// + [JsonPropertyName("gameDuration")] + public int GameDuration { get; set; } + + /// + /// 创建时间 + /// + [JsonPropertyName("createdAt")] + public DateTime CreatedAt { get; set; } + + /// + /// 当前玩家数量 + /// + [JsonPropertyName("currentPlayerCount")] + public int CurrentPlayerCount { get; set; } + + /// + /// 游戏类型(0=TG,1=PS) + /// + [JsonPropertyName("gameType")] + public GameType GameType { get; set; } + + /// + /// 追捕者数量(PS 用) + /// + [JsonPropertyName("hunterCount")] + public int HunterCount { get; set; } + + /// + /// 逃生者数量(PS 用) + /// + [JsonPropertyName("runnerCount")] + public int RunnerCount { get; set; } +} + + diff --git a/Backend/src/TerritoryGame.Application/Dtos/Paint/PaintActionDto.cs b/Backend/src/TerritoryGame.Application/Dtos/Paint/PaintActionDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..2c97125fc5aa9365f1a71396e3005f6ed39cf5da --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Dtos/Paint/PaintActionDto.cs @@ -0,0 +1,15 @@ +namespace TerritoryGame.Application.Dtos.Paint; + +public class PaintActionDto +{ + public string Color { get; set; } = "#000000"; + public float BrushSize { get; set; } = 1.0f; + public List Points { get; set; } = new(); +} + +public class PointDto +{ + public float X { get; set; } + public float Y { get; set; } + public long T { get; set; } // timestamp ms +} diff --git a/Backend/src/TerritoryGame.Application/Dtos/Ranking/RankingDto.cs b/Backend/src/TerritoryGame.Application/Dtos/Ranking/RankingDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..73841230153196d50567027392d229e5ced43e40 --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Dtos/Ranking/RankingDto.cs @@ -0,0 +1,15 @@ +namespace TerritoryGame.Application.Dtos.Ranking; + +public class RankingDto +{ + public string RoomId { get; set; } = default!; + public List Items { get; set; } = new(); + public long GeneratedAt { get; set; } +} + +public class RankingItemDto +{ + public string UserId { get; set; } = default!; + public double Area { get; set; } + public int Score { get; set; } +} diff --git a/Backend/src/TerritoryGame.Application/Events/PaintEvent.cs b/Backend/src/TerritoryGame.Application/Events/PaintEvent.cs new file mode 100644 index 0000000000000000000000000000000000000000..215f2b622af204ba66022561da988cf5b5a9af57 --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Events/PaintEvent.cs @@ -0,0 +1,11 @@ +using TerritoryGame.Application.Common.EventBus; +using TerritoryGame.Application.Dtos.Paint; + +namespace TerritoryGame.Application.Events; + +public class PaintEvent : IEvent +{ + public string RoomId { get; set; } = default!; + public string UserId { get; set; } = default!; + public PaintActionDto Paint { get; set; } = default!; +} diff --git a/Backend/src/TerritoryGame.Application/Events/RankingUpdatedEvent.cs b/Backend/src/TerritoryGame.Application/Events/RankingUpdatedEvent.cs new file mode 100644 index 0000000000000000000000000000000000000000..6e38b1855df547cec2ab8d9386c4e01725abda71 --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Events/RankingUpdatedEvent.cs @@ -0,0 +1,10 @@ +using TerritoryGame.Application.Common.EventBus; +using TerritoryGame.Application.Dtos.Ranking; + +namespace TerritoryGame.Application.Events; + +public class RankingUpdatedEvent : IEvent +{ + public string RoomId { get; set; } = default!; + public RankingDto Ranking { get; set; } = default!; +} diff --git a/Backend/src/TerritoryGame.Application/ServiceCollectionExtension.cs b/Backend/src/TerritoryGame.Application/ServiceCollectionExtension.cs new file mode 100644 index 0000000000000000000000000000000000000000..2394490fd10f80e94693209081e19fb9bd43cdc2 --- /dev/null +++ b/Backend/src/TerritoryGame.Application/ServiceCollectionExtension.cs @@ -0,0 +1,37 @@ + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; +using TerritoryGame.Application.Servicece; +using TerritoryGame.Application.Common.EventBus; + +namespace TerritoryGame.Application; + +public static class ServiceCollectionExtension +{ + public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration) + { + + + services.AddScoped(); + // services.AddScoped(); + services.AddScoped(); + + // EventBus + services.AddSingleton(); + // 自动注册所有 IEventHandler<> 实现 + var assembly = Assembly.GetExecutingAssembly(); + var handlerInterfaceType = typeof(Application.Common.EventBus.IEventHandler<>); + foreach (var type in assembly.GetTypes()) + { + var interfaces = type.GetInterfaces() + .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == handlerInterfaceType); + foreach (var i in interfaces) + { + services.AddTransient(i, type); + } + } + + return services; + } +} diff --git a/Backend/src/TerritoryGame.Application/Servicece/Implementations/AuthPlayer.cs b/Backend/src/TerritoryGame.Application/Servicece/Implementations/AuthPlayer.cs new file mode 100644 index 0000000000000000000000000000000000000000..4fade0b06b83caace3da3fe90d4ad1020c8fd68a --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Servicece/Implementations/AuthPlayer.cs @@ -0,0 +1,89 @@ +using System.Threading; +using System.Threading.Tasks; +using TerritoryGame.Application.Dtos; +using TerritoryGame.Domain.Entities.App; +using TerritoryGame.Domain.Repositories; +using TerritoryGame.Application.Servicece; + +namespace TerritoryGame.Application.Servicece; + +public class AuthPlayerService : IAuthPlayer +{ + private readonly IPlayerRepository _playerRepository; + private readonly IJwtTokenGenerator _jwtTokenGenerator; + + public AuthPlayerService(IPlayerRepository playerRepository, IJwtTokenGenerator jwtTokenGenerator) + { + _playerRepository = playerRepository; + _jwtTokenGenerator = jwtTokenGenerator; + } + + public async Task LoginAsync(string nickName, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(nickName)) + { + return null; + } + + var player = await _playerRepository.GetByNickNameAsync(nickName); + if (player == null) + { + return null; + } + + var authPlayer = new AuthPlayer + { + NickName = player.NickName, + Avatar = player.Avatar, + TotalGames = player.TotalGames, + WinCount = player.WinCount + }; + + var token = _jwtTokenGenerator.GenerateToken(player.Id, player.NickName); + + return new AuthLoginResult + { + Token = token, + Player = authPlayer + }; + } + + public async Task RegisterAsync(string nickName, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(nickName)) + { + return null; + } + + // 检查昵称是否已存在 + if (await _playerRepository.IsNickNameExistsAsync(nickName)) + { + // 昵称已存在,返回null表示注册失败 + return null; + } + + var newPlayer = new Player + { + NickName = nickName, + Avatar = null, + TotalGames = 0, + WinCount = 0 + }; + var created = await _playerRepository.CreatedAsync(newPlayer); + + var authPlayer = new AuthPlayer + { + NickName = created.NickName, + Avatar = created.Avatar, + TotalGames = created.TotalGames, + WinCount = created.WinCount + }; + + var token = _jwtTokenGenerator.GenerateToken(created.Id, created.NickName); + return new AuthLoginResult + { + Token = token, + Player = authPlayer + }; + } +} \ No newline at end of file diff --git a/Backend/src/TerritoryGame.Application/Servicece/Implementations/Events/PaintEventHandler.cs b/Backend/src/TerritoryGame.Application/Servicece/Implementations/Events/PaintEventHandler.cs new file mode 100644 index 0000000000000000000000000000000000000000..2add62ce7e64143f28923025d7454fe368abd31d --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Servicece/Implementations/Events/PaintEventHandler.cs @@ -0,0 +1,21 @@ +using TerritoryGame.Application.Common.EventBus; +using TerritoryGame.Application.Events; +using TerritoryGame.Application.Servicece; + +namespace TerritoryGame.Application.Servicece.Implementations.Events; + +public class PaintEventHandler : IEventHandler +{ + private readonly IRealtimeNotifier _notifier; + + public PaintEventHandler(IRealtimeNotifier notifier) + { + _notifier = notifier; + } + + public async Task HandleAsync(PaintEvent notification, CancellationToken cancellationToken = default) + { + await _notifier.BroadcastPaintAsync(notification.RoomId, notification.UserId, notification.Paint, cancellationToken); + // TODO: 这里可以发布计算排行的事件 CalculateRankingEvent + } +} diff --git a/Backend/src/TerritoryGame.Application/Servicece/Implementations/Events/RankingUpdatedEventHandler.cs b/Backend/src/TerritoryGame.Application/Servicece/Implementations/Events/RankingUpdatedEventHandler.cs new file mode 100644 index 0000000000000000000000000000000000000000..510ac183ccd56deb5881ab260a6c4c1111c9e3bb --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Servicece/Implementations/Events/RankingUpdatedEventHandler.cs @@ -0,0 +1,20 @@ +using TerritoryGame.Application.Common.EventBus; +using TerritoryGame.Application.Events; +using TerritoryGame.Application.Servicece; + +namespace TerritoryGame.Application.Servicece.Implementations.Events; + +public class RankingUpdatedEventHandler : IEventHandler +{ + private readonly IRealtimeNotifier _notifier; + + public RankingUpdatedEventHandler(IRealtimeNotifier notifier) + { + _notifier = notifier; + } + + public async Task HandleAsync(RankingUpdatedEvent @event, CancellationToken cancellationToken = default) + { + await _notifier.BroadcastRankingAsync(@event.RoomId, @event.Ranking, cancellationToken); + } +} diff --git a/Backend/src/TerritoryGame.Application/Servicece/Implementations/GameRoomService.cs b/Backend/src/TerritoryGame.Application/Servicece/Implementations/GameRoomService.cs new file mode 100644 index 0000000000000000000000000000000000000000..6b7d558208f98bb72ccba995497fc7409ebf3a97 --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Servicece/Implementations/GameRoomService.cs @@ -0,0 +1,167 @@ +using System.Threading; +using System.Threading.Tasks; +using TerritoryGame.Application.Dtos; +using TerritoryGame.Domain.Entities.App; +using TerritoryGame.Domain.Repositories; +using TerritoryGame.Application.Servicece; + +namespace TerritoryGame.Application.Servicece; + +public class GameRoomService : IGameRoomService +{ + private readonly IGameRoomRepository _gameRoomRepository; + private readonly Random _random; + + public GameRoomService(IGameRoomRepository gameRoomRepository) + { + _gameRoomRepository = gameRoomRepository; + _random = new Random(); + } + + public async Task CreateRoomAsync(CreateRoomRequest request, Guid ownerId, CancellationToken cancellationToken = default) + { + // 兜底限制范围(即使绕过模型验证也不越界) + var maxPlayers = Math.Clamp(request.MaxPlayers, 2, 16); + var gameDuration = Math.Clamp(request.GameDuration, 30, 3600); + var hunterCount = Math.Clamp(request.HunterCount, 0, 16); + var runnerCount = Math.Clamp(request.RunnerCount, 0, 16); + if (request.GameType == GameType.PS) + { + // 如果没填人数,给出默认 + if (request.HunterCount <= 0 && request.RunnerCount <= 0) + { + hunterCount = 1; runnerCount = Math.Max(1, maxPlayers - 1); + } + // 总人数不能超过 maxPlayers + if (hunterCount + runnerCount > maxPlayers) + { + // 优先保留猎人数量,把逃生者压缩到上限内 + runnerCount = Math.Max(0, maxPlayers - hunterCount); + } + // 如果两者为 0,至少放 1 个逃生者 + if (hunterCount == 0 && runnerCount == 0) runnerCount = 1; + } + + // 生成唯一的6位数字房间号 + string roomName; + int maxAttempts = 100; // 最大尝试次数,防止无限循环 + int attempts = 0; + + do + { + roomName = GenerateRandomRoomName(); + attempts++; + + // 如果尝试次数过多,返回null表示创建失败 + if (attempts > maxAttempts) + { + return null; + } + } while (await _gameRoomRepository.IsRoomNameExistsAsync(roomName)); + + // 创建新房间 + var newRoom = new GameRoom + { + Name = roomName, + RoomCode = roomName, + DisplayName = request.DisplayName, + Password = request.Password, + MaxPlayers = maxPlayers, + Status = Domain.Entities.App.GameStatus.Waiting, + GameDuration = gameDuration, + GameType = request.GameType, + OwnerId = ownerId, + HunterCount = hunterCount, + RunnerCount = runnerCount + }; + + var created = await _gameRoomRepository.CreatedAsync(newRoom); + + return MapToDto(created); + } + + public async Task DeleteRoomAsync(Guid roomId, CancellationToken cancellationToken = default) + { + var room = await _gameRoomRepository.GetByIdAsync(roomId); + if (room == null) + { + return false; + } + + // 检查房间状态,只有在等待状态下的房间才能删除 + if (room.Status != Domain.Entities.App.GameStatus.Waiting) + { + return false; + } + + await _gameRoomRepository.DeleteAsync(roomId); + return true; + } + + public async Task GetRoomAsync(Guid roomId, CancellationToken cancellationToken = default) + { + var room = await _gameRoomRepository.GetByIdAsync(roomId); + if (room == null) + { + return null; + } + return MapToDto(room); + } + + public async Task GetRoomByNameAsync(string roomName, CancellationToken cancellationToken = default) + { + var room = await _gameRoomRepository.GetByRoomNameAsync(roomName); + if (room == null) + { + return null; + } + + return MapToDto(room); + } + + public async Task> GetAllRoomsAsync(CancellationToken cancellationToken = default) + { + var rooms = await _gameRoomRepository.GetAllWithSessionsAsync(); + return rooms.Select(MapToDto); + } + + public async Task> GetAllRoomsByGameAsync(GameType gameType, CancellationToken cancellationToken = default) + { + var rooms = await _gameRoomRepository.GetAllWithSessionsByGameAsync(gameType); + return rooms.Select(MapToDto); + } + + /// + /// 生成随机的6位数字房间名 + /// + /// 6位数字字符串 + private string GenerateRandomRoomName() + { + return _random.Next(100000, 999999).ToString(); + } + + /// + /// 将GameRoom实体映射为GameRoomDto + /// + /// 房间实体 + /// 房间DTO + private GameRoomDto MapToDto(GameRoom room) + { + return new GameRoomDto + { + Id = room.Id, + Name = room.Name, + DisplayName = room.DisplayName, + RoomCode = room.RoomCode, + Password = room.Password, + MaxPlayers = room.MaxPlayers, + Status = room.Status, + GameDuration = room.GameDuration, + GameType = room.GameType, + HunterCount = room.HunterCount, + RunnerCount = room.RunnerCount, + CreatedAt = room.CreatedAt, + CurrentPlayerCount = room.Sessions?.Count ?? 0 + }; + } +} diff --git a/Backend/src/TerritoryGame.Application/Servicece/Interfaces/IAuthPlayer.cs b/Backend/src/TerritoryGame.Application/Servicece/Interfaces/IAuthPlayer.cs new file mode 100644 index 0000000000000000000000000000000000000000..268d568b6a6a582b2ae1a51cbf8bb4b89d2bc689 --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Servicece/Interfaces/IAuthPlayer.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; +using TerritoryGame.Application.Dtos; + +namespace TerritoryGame.Application.Servicece; + +public interface IAuthPlayer +{ + Task LoginAsync(string nickName, CancellationToken cancellationToken = default); + Task RegisterAsync(string nickName, CancellationToken cancellationToken = default); +} diff --git a/Backend/src/TerritoryGame.Application/Servicece/Interfaces/IGameRoomService.cs b/Backend/src/TerritoryGame.Application/Servicece/Interfaces/IGameRoomService.cs new file mode 100644 index 0000000000000000000000000000000000000000..eac98a89721ba454b3bd18828b2ef20b3883c07a --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Servicece/Interfaces/IGameRoomService.cs @@ -0,0 +1,53 @@ +using System.Threading; +using System.Threading.Tasks; +using TerritoryGame.Application.Dtos; +using TerritoryGame.Domain.Entities.App; + +namespace TerritoryGame.Application.Servicece; + +public interface IGameRoomService +{ + /// + /// 创建游戏房间 + /// + /// 创建房间请求 + /// 取消令牌 + /// 创建的房间信息 + Task CreateRoomAsync(CreateRoomRequest request, Guid ownerId, CancellationToken cancellationToken = default); + + /// + /// 删除游戏房间 + /// + /// 房间ID + /// 取消令牌 + /// 是否删除成功 + Task DeleteRoomAsync(Guid roomId, CancellationToken cancellationToken = default); + + /// + /// 根据房间ID获取房间信息 + /// + /// 房间ID + /// 取消令牌 + /// 房间信息 + Task GetRoomAsync(Guid roomId, CancellationToken cancellationToken = default); + + /// + /// 根据房间名称获取房间信息 + /// + /// 房间名称 + /// 取消令牌 + /// 房间信息 + Task GetRoomByNameAsync(string roomName, CancellationToken cancellationToken = default); + + /// + /// 获取所有房间列表 + /// + /// 取消令牌 + /// 房间列表 + Task> GetAllRoomsAsync(CancellationToken cancellationToken = default); + + /// + /// 按游戏类型获取所有房间列表 + /// + Task> GetAllRoomsByGameAsync(GameType gameType, CancellationToken cancellationToken = default); +} diff --git a/Backend/src/TerritoryGame.Application/Servicece/Interfaces/IJwtTokenGenerator.cs b/Backend/src/TerritoryGame.Application/Servicece/Interfaces/IJwtTokenGenerator.cs new file mode 100644 index 0000000000000000000000000000000000000000..43ca18d3b2d8717e9022d183ecf6c17bec8b0075 --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Servicece/Interfaces/IJwtTokenGenerator.cs @@ -0,0 +1,8 @@ +namespace TerritoryGame.Application.Servicece; + +public interface IJwtTokenGenerator +{ + string GenerateToken(Guid playerId, string nickName); +} + + diff --git a/Backend/src/TerritoryGame.Application/Servicece/Interfaces/IRealtimeNotifier.cs b/Backend/src/TerritoryGame.Application/Servicece/Interfaces/IRealtimeNotifier.cs new file mode 100644 index 0000000000000000000000000000000000000000..1234778467816e47627ae90e69867db604c95878 --- /dev/null +++ b/Backend/src/TerritoryGame.Application/Servicece/Interfaces/IRealtimeNotifier.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; +using TerritoryGame.Application.Dtos.Paint; +using TerritoryGame.Application.Dtos.Ranking; + +namespace TerritoryGame.Application.Servicece; + +public interface IRealtimeNotifier +{ + Task BroadcastPaintAsync(string roomId, string userId, PaintActionDto paint, CancellationToken cancellationToken = default); + Task BroadcastRankingAsync(string roomId, RankingDto ranking, CancellationToken cancellationToken = default); + Task BroadcastKickedAsync(string roomId, string targetUserId, CancellationToken cancellationToken = default); +} diff --git a/Backend/src/TerritoryGame.Application/TerritoryGame.Application.csproj b/Backend/src/TerritoryGame.Application/TerritoryGame.Application.csproj new file mode 100644 index 0000000000000000000000000000000000000000..8a614d29b3d5855084ddf06264ba6bd3ce4a390c --- /dev/null +++ b/Backend/src/TerritoryGame.Application/TerritoryGame.Application.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + + net8.0 + enable + enable + + + diff --git a/Backend/src/TerritoryGame.Domain/Class1.cs b/Backend/src/TerritoryGame.Domain/Class1.cs new file mode 100644 index 0000000000000000000000000000000000000000..23fe78c8cfced99969de368cdae2afb2697b07a7 --- /dev/null +++ b/Backend/src/TerritoryGame.Domain/Class1.cs @@ -0,0 +1,6 @@ +namespace TerritoryGame.Domain; + +public class Class1 +{ + +} diff --git a/Backend/src/TerritoryGame.Domain/Entities/App/GameRooms.cs b/Backend/src/TerritoryGame.Domain/Entities/App/GameRooms.cs new file mode 100644 index 0000000000000000000000000000000000000000..02a3b8b5228ddf8feadfe338d27359ce7c312efe --- /dev/null +++ b/Backend/src/TerritoryGame.Domain/Entities/App/GameRooms.cs @@ -0,0 +1,40 @@ +using TerritoryGame.Domain.Entities; + +namespace TerritoryGame.Domain.Entities.App; + +public class GameRoom : EntityBase +{ + // 兼容:Name 作为房间号使用的历史字段 + public string Name { get; set; } = default!; + // 新增:房间显示名称(由房主填写) + public string DisplayName { get; set; } = string.Empty; + // 新增:房间号(6位数字,与 Name 一致,逐步迁移到该字段) + public string RoomCode { get; set; } = string.Empty; + public string? Password { get; set; } + public int MaxPlayers { get; set; } = 6; + public GameStatus Status { get; set; } = GameStatus.Waiting; + public int GameDuration { get; set; } = 180; + public Guid OwnerId { get; set; } + // 新增:游戏类型(0=领地争夺TG,1=逃生像素PS) + public GameType GameType { get; set; } = GameType.TG; + + // PS 专用:角色人数配额(房主创建时设定) + public int HunterCount { get; set; } = 1; + public int RunnerCount { get; set; } = 5; + + public ICollection Sessions { get; set; } = new List(); + public ICollection PaintActions { get; set; } = new List(); +} + +public enum GameStatus +{ + Waiting, + Playing, + Finished +} + +public enum GameType +{ + TG = 0, + PS = 1 +} \ No newline at end of file diff --git a/Backend/src/TerritoryGame.Domain/Entities/App/GameSessions.cs b/Backend/src/TerritoryGame.Domain/Entities/App/GameSessions.cs new file mode 100644 index 0000000000000000000000000000000000000000..565ed960bb26240fbd0e626ec7134638c31e4c64 --- /dev/null +++ b/Backend/src/TerritoryGame.Domain/Entities/App/GameSessions.cs @@ -0,0 +1,15 @@ +using TerritoryGame.Domain.Entities; + +namespace TerritoryGame.Domain.Entities.App; + +public class GameSession : EntityBase +{ + public Guid RoomId { get; set; } + public Guid PlayerId { get; set; } + public string PlayerColor { get; set; } = default!; + public int FinalArea { get; set; } + public int? Rank { get; set; } + + public GameRoom Room { get; set; } = default!; + public Player Player { get; set; } = default!; +} \ No newline at end of file diff --git a/Backend/src/TerritoryGame.Domain/Entities/App/PaintActions.cs b/Backend/src/TerritoryGame.Domain/Entities/App/PaintActions.cs new file mode 100644 index 0000000000000000000000000000000000000000..87e444635b883d11db521b40858d188a57a4ded8 --- /dev/null +++ b/Backend/src/TerritoryGame.Domain/Entities/App/PaintActions.cs @@ -0,0 +1,16 @@ +using TerritoryGame.Domain.Entities; + +namespace TerritoryGame.Domain.Entities.App; + +public class PaintAction : EntityBase +{ + 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 GameRoom Room { get; set; } = default!; + public Player Player { get; set; } = default!; +} \ No newline at end of file diff --git a/Backend/src/TerritoryGame.Domain/Entities/App/Players.cs b/Backend/src/TerritoryGame.Domain/Entities/App/Players.cs new file mode 100644 index 0000000000000000000000000000000000000000..3f1f455457ae1ea40646abd8f5707d086ffcc282 --- /dev/null +++ b/Backend/src/TerritoryGame.Domain/Entities/App/Players.cs @@ -0,0 +1,14 @@ +using TerritoryGame.Domain.Entities; + +namespace TerritoryGame.Domain.Entities.App; + +public class Player : EntityBase +{ + public string NickName { get; set; } = default!; + public string? Avatar { get; set; } + public int TotalGames { get; set; } + public int WinCount { get; set; } + + public ICollection Sessions { get; set; } = new List(); + public ICollection PaintActions { get; set; } = new List(); +} \ No newline at end of file diff --git a/Backend/src/TerritoryGame.Domain/Entities/EntityBase.cs b/Backend/src/TerritoryGame.Domain/Entities/EntityBase.cs new file mode 100644 index 0000000000000000000000000000000000000000..d6727f44205ebc9bdb60e0fa61089baa2ec2e065 --- /dev/null +++ b/Backend/src/TerritoryGame.Domain/Entities/EntityBase.cs @@ -0,0 +1,15 @@ +namespace TerritoryGame.Domain.Entities; + +public class EntityBase +{ + //ID + public Guid Id { get; set; } + //创建时间 + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + //更新时间 + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + //创建人 + public Guid CreatedBy { get; set; } = Guid.Empty; + //更新人 + public Guid UpdatedBy { get; set; } = Guid.Empty; +} \ No newline at end of file diff --git a/Backend/src/TerritoryGame.Domain/Repositories/IGameRoomRepository.cs b/Backend/src/TerritoryGame.Domain/Repositories/IGameRoomRepository.cs new file mode 100644 index 0000000000000000000000000000000000000000..6b9fa8753ee417453cb140e3145a572cfddd2851 --- /dev/null +++ b/Backend/src/TerritoryGame.Domain/Repositories/IGameRoomRepository.cs @@ -0,0 +1,42 @@ +using TerritoryGame.Domain.Entities.App; + +namespace TerritoryGame.Domain.Repositories; + +/// +/// 游戏房间仓储接口 +/// +public interface IGameRoomRepository : IRepository +{ + /// + /// 根据房间名称查询房间 + /// + /// 房间名称 + /// 房间实体,如果不存在则返回null + Task GetByRoomNameAsync(string roomName); + Task GetByRoomCodeAsync(string roomCode); + + /// + /// 检查房间名称是否已存在 + /// + /// 房间名称 + /// 如果存在返回true,否则返回false + Task IsRoomNameExistsAsync(string roomName); + Task IsRoomCodeExistsAsync(string roomCode); + + /// + /// 获取所有等待中的房间 + /// + /// 等待中的房间列表 + Task> GetWaitingRoomsAsync(); + + /// + /// 获取所有房间并包含 Sessions,用于统计当前玩家数量 + /// + Task> GetAllWithSessionsAsync(); + Task> GetAllWithSessionsByGameAsync(GameType gameType); + + /// + /// 获取指定房主创建的所有房间 + /// + Task> GetRoomsByOwnerAsync(Guid ownerId); +} diff --git a/Backend/src/TerritoryGame.Domain/Repositories/IPlayerRepository.cs b/Backend/src/TerritoryGame.Domain/Repositories/IPlayerRepository.cs new file mode 100644 index 0000000000000000000000000000000000000000..8d94273b73a05fd8a21242e606cc4d0333d3ec99 --- /dev/null +++ b/Backend/src/TerritoryGame.Domain/Repositories/IPlayerRepository.cs @@ -0,0 +1,23 @@ +using TerritoryGame.Domain.Entities.App; + +namespace TerritoryGame.Domain.Repositories; + +/// +/// 玩家仓储接口 +/// +public interface IPlayerRepository : IRepository +{ + /// + /// 根据昵称查询玩家 + /// + /// 昵称 + /// 玩家实体,如果不存在则返回null + Task GetByNickNameAsync(string nickName); + + /// + /// 检查昵称是否已存在 + /// + /// 昵称 + /// 如果存在返回true,否则返回false + Task IsNickNameExistsAsync(string nickName); +} diff --git a/Backend/src/TerritoryGame.Domain/Repositories/IRepository.cs b/Backend/src/TerritoryGame.Domain/Repositories/IRepository.cs new file mode 100644 index 0000000000000000000000000000000000000000..6f579dc7289849276749240bb884b042e98a6a87 --- /dev/null +++ b/Backend/src/TerritoryGame.Domain/Repositories/IRepository.cs @@ -0,0 +1,19 @@ +namespace TerritoryGame.Domain.Repositories; + +/// +/// 通用仓储接口 +/// +/// 实体类型 +public interface IRepository where T : class +{ + //根据ID查询 + Task GetByIdAsync(Guid Id); + //查询所有 + Task> GetAllAsync(); + //创建 + Task CreatedAsync(T entity); + //更新 + Task UpdateAsync(T entity); + //删除 + Task DeleteAsync(Guid Id); +} \ No newline at end of file diff --git a/Backend/src/TerritoryGame.Domain/TerritoryGame.Domain.csproj b/Backend/src/TerritoryGame.Domain/TerritoryGame.Domain.csproj new file mode 100644 index 0000000000000000000000000000000000000000..fa71b7ae6a34999a3f96c40d9a0b870b311d11dd --- /dev/null +++ b/Backend/src/TerritoryGame.Domain/TerritoryGame.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/Backend/src/TerritoryGame.Infrastructure/Class1.cs b/Backend/src/TerritoryGame.Infrastructure/Class1.cs new file mode 100644 index 0000000000000000000000000000000000000000..23a9b3da37d7f9b9732d7fd3f44ea39d0c005d8b --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Class1.cs @@ -0,0 +1,6 @@ +namespace TerritoryGame.Infrastructure; + +public class Class1 +{ + +} diff --git a/Backend/src/TerritoryGame.Infrastructure/Data/GameDbContext.cs b/Backend/src/TerritoryGame.Infrastructure/Data/GameDbContext.cs new file mode 100644 index 0000000000000000000000000000000000000000..646be442c53a8710d1902e1c7950e78c5f4c64c9 --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Data/GameDbContext.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using TerritoryGame.Domain.Entities.App; + +namespace TerritoryGame.Infrastructure.Data; + +public class GameDbContext : DbContext +{ + public GameDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet GameRooms { get; set; } + public DbSet Players { get; set; } + public DbSet GameSessions { get; set; } + public DbSet PaintActions { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // 配置实体关系 + modelBuilder.Entity() + .HasMany(g => g.Sessions) + .WithOne(s => s.Room) + .HasForeignKey(s => s.RoomId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(g => g.PaintActions) + .WithOne(p => p.Room) + .HasForeignKey(p => p.RoomId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(p => p.Sessions) + .WithOne(s => s.Player) + .HasForeignKey(s => s.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(p => p.PaintActions) + .WithOne(pa => pa.Player) + .HasForeignKey(pa => pa.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + } +} + diff --git a/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819024446_EnsureRoomColumns.Designer.cs b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819024446_EnsureRoomColumns.Designer.cs new file mode 100644 index 0000000000000000000000000000000000000000..1a678907bb8b804fbf70971dcaa6b56b5bd9ac33 --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819024446_EnsureRoomColumns.Designer.cs @@ -0,0 +1,256 @@ +// +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("20250819024446_EnsureRoomColumns")] + partial class EnsureRoomColumns + { + /// + 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.GameRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameDuration") + .HasColumnType("integer"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasColumnType("text"); + + b.Property("RoomCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("GameRooms"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("FinalArea") + .HasColumnType("integer"); + + b.Property("PlayerColor") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlayerId") + .HasColumnType("uuid"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.PaintAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BrushSize") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("PlayerId") + .HasColumnType("uuid"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId"); + + b.ToTable("PaintActions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Avatar") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("NickName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalGames") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("WinCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameSession", b => + { + b.HasOne("TerritoryGame.Domain.Entities.App.Player", "Player") + .WithMany("Sessions") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TerritoryGame.Domain.Entities.App.GameRoom", "Room") + .WithMany("Sessions") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Room"); + }); + + 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("PaintActions") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Room"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameRoom", b => + { + b.Navigation("PaintActions"); + + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.Player", b => + { + b.Navigation("PaintActions"); + + b.Navigation("Sessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819024446_EnsureRoomColumns.cs b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819024446_EnsureRoomColumns.cs new file mode 100644 index 0000000000000000000000000000000000000000..d055aef56f57642ef435378d8682d700d6a5eae5 --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819024446_EnsureRoomColumns.cs @@ -0,0 +1,158 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TerritoryGame.Infrastructure.Migrations +{ + /// + public partial class EnsureRoomColumns : 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: "text", nullable: false), + DisplayName = table.Column(type: "text", nullable: false), + RoomCode = table.Column(type: "text", nullable: false), + Password = table.Column(type: "text", nullable: true), + MaxPlayers = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "integer", nullable: false), + GameDuration = table.Column(type: "integer", nullable: false), + OwnerId = table.Column(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "uuid", nullable: false), + UpdatedBy = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GameRooms", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Players", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + NickName = table.Column(type: "text", nullable: false), + Avatar = table.Column(type: "text", nullable: true), + TotalGames = table.Column(type: "integer", nullable: false), + WinCount = 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), + CreatedBy = table.Column(type: "uuid", nullable: false), + UpdatedBy = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Players", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "GameSessions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RoomId = table.Column(type: "uuid", nullable: false), + PlayerId = table.Column(type: "uuid", nullable: false), + PlayerColor = table.Column(type: "text", nullable: false), + FinalArea = table.Column(type: "integer", nullable: false), + Rank = table.Column(type: "integer", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "uuid", nullable: false), + UpdatedBy = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GameSessions", x => x.Id); + table.ForeignKey( + name: "FK_GameSessions_GameRooms_RoomId", + column: x => x.RoomId, + principalTable: "GameRooms", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GameSessions_Players_PlayerId", + column: x => x.PlayerId, + principalTable: "Players", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + 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), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "uuid", nullable: false), + UpdatedBy = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PaintActions", x => x.Id); + table.ForeignKey( + name: "FK_PaintActions_GameRooms_RoomId", + column: x => x.RoomId, + principalTable: "GameRooms", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PaintActions_Players_PlayerId", + column: x => x.PlayerId, + principalTable: "Players", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_GameSessions_PlayerId", + table: "GameSessions", + column: "PlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_GameSessions_RoomId", + table: "GameSessions", + column: "RoomId"); + + migrationBuilder.CreateIndex( + name: "IX_PaintActions_PlayerId", + table: "PaintActions", + column: "PlayerId"); + + migrationBuilder.CreateIndex( + name: "IX_PaintActions_RoomId", + table: "PaintActions", + column: "RoomId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "GameSessions"); + + migrationBuilder.DropTable( + name: "PaintActions"); + + migrationBuilder.DropTable( + name: "GameRooms"); + + migrationBuilder.DropTable( + name: "Players"); + } + } +} diff --git a/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819060000_AddGameTypeToRooms.cs b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819060000_AddGameTypeToRooms.cs new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819101351_AddGameTypeToRoomsEf.Designer.cs b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819101351_AddGameTypeToRoomsEf.Designer.cs new file mode 100644 index 0000000000000000000000000000000000000000..c4eeafd1a6ee66557821886cb7be72cd993445a6 --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819101351_AddGameTypeToRoomsEf.Designer.cs @@ -0,0 +1,260 @@ +// +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("20250819101351_AddGameTypeToRoomsEf")] + partial class AddGameTypeToRoomsEf + { + /// + 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.GameRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameDuration") + .HasColumnType("integer"); + + b.Property("GameType") + .IsRequired() + .HasColumnType("text"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasColumnType("text"); + + b.Property("RoomCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("GameRooms"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("FinalArea") + .HasColumnType("integer"); + + b.Property("PlayerColor") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlayerId") + .HasColumnType("uuid"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.PaintAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BrushSize") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("PlayerId") + .HasColumnType("uuid"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId"); + + b.ToTable("PaintActions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Avatar") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("NickName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalGames") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("WinCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameSession", b => + { + b.HasOne("TerritoryGame.Domain.Entities.App.Player", "Player") + .WithMany("Sessions") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TerritoryGame.Domain.Entities.App.GameRoom", "Room") + .WithMany("Sessions") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Room"); + }); + + 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("PaintActions") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Room"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameRoom", b => + { + b.Navigation("PaintActions"); + + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.Player", b => + { + b.Navigation("PaintActions"); + + b.Navigation("Sessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819101351_AddGameTypeToRoomsEf.cs b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819101351_AddGameTypeToRoomsEf.cs new file mode 100644 index 0000000000000000000000000000000000000000..d44e237acb75edfb751b2287fc401408a8ab9430 --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819101351_AddGameTypeToRoomsEf.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TerritoryGame.Infrastructure.Migrations +{ + /// + public partial class AddGameTypeToRoomsEf : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819102146_FixGameTypeColumn.Designer.cs b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819102146_FixGameTypeColumn.Designer.cs new file mode 100644 index 0000000000000000000000000000000000000000..fd5658bf5ae42ff623ceb8b85c932a569585fd4d --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819102146_FixGameTypeColumn.Designer.cs @@ -0,0 +1,260 @@ +// +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("20250819102146_FixGameTypeColumn")] + partial class FixGameTypeColumn + { + /// + 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.GameRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameDuration") + .HasColumnType("integer"); + + b.Property("GameType") + .IsRequired() + .HasColumnType("text"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasColumnType("text"); + + b.Property("RoomCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("GameRooms"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("FinalArea") + .HasColumnType("integer"); + + b.Property("PlayerColor") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlayerId") + .HasColumnType("uuid"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.PaintAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BrushSize") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("PlayerId") + .HasColumnType("uuid"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId"); + + b.ToTable("PaintActions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Avatar") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("NickName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalGames") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("WinCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameSession", b => + { + b.HasOne("TerritoryGame.Domain.Entities.App.Player", "Player") + .WithMany("Sessions") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TerritoryGame.Domain.Entities.App.GameRoom", "Room") + .WithMany("Sessions") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Room"); + }); + + 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("PaintActions") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Room"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameRoom", b => + { + b.Navigation("PaintActions"); + + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.Player", b => + { + b.Navigation("PaintActions"); + + b.Navigation("Sessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819102146_FixGameTypeColumn.cs b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819102146_FixGameTypeColumn.cs new file mode 100644 index 0000000000000000000000000000000000000000..df9374bb9c16211698f783b3092451ae744232d9 --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819102146_FixGameTypeColumn.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TerritoryGame.Infrastructure.Migrations +{ + /// + public partial class FixGameTypeColumn : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GameType", + table: "GameRooms", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GameType", + table: "GameRooms"); + } + } +} diff --git a/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819103056_AddGameType_Apply.Designer.cs b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819103056_AddGameType_Apply.Designer.cs new file mode 100644 index 0000000000000000000000000000000000000000..4403f8e32ef97c16088aff5f2d6f00a7ad2d1037 --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819103056_AddGameType_Apply.Designer.cs @@ -0,0 +1,259 @@ +// +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("20250819103056_AddGameType_Apply")] + partial class AddGameType_Apply + { + /// + 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.GameRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameDuration") + .HasColumnType("integer"); + + b.Property("GameType") + .HasColumnType("integer"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasColumnType("text"); + + b.Property("RoomCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("GameRooms"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("FinalArea") + .HasColumnType("integer"); + + b.Property("PlayerColor") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlayerId") + .HasColumnType("uuid"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.PaintAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BrushSize") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("PlayerId") + .HasColumnType("uuid"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId"); + + b.ToTable("PaintActions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Avatar") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("NickName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalGames") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("WinCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameSession", b => + { + b.HasOne("TerritoryGame.Domain.Entities.App.Player", "Player") + .WithMany("Sessions") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TerritoryGame.Domain.Entities.App.GameRoom", "Room") + .WithMany("Sessions") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Room"); + }); + + 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("PaintActions") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Room"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameRoom", b => + { + b.Navigation("PaintActions"); + + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.Player", b => + { + b.Navigation("PaintActions"); + + b.Navigation("Sessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819103056_AddGameType_Apply.cs b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819103056_AddGameType_Apply.cs new file mode 100644 index 0000000000000000000000000000000000000000..c41739395f6741a3c03643b1b359f0fd981518ac --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819103056_AddGameType_Apply.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TerritoryGame.Infrastructure.Migrations +{ + /// + public partial class AddGameType_Apply : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819115335_AddPsRoleQuotas.Designer.cs b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819115335_AddPsRoleQuotas.Designer.cs new file mode 100644 index 0000000000000000000000000000000000000000..4240f65c6dfe8bf05e1b712ea2313e20e11c947b --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819115335_AddPsRoleQuotas.Designer.cs @@ -0,0 +1,265 @@ +// +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("20250819115335_AddPsRoleQuotas")] + partial class AddPsRoleQuotas + { + /// + 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.GameRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameDuration") + .HasColumnType("integer"); + + b.Property("GameType") + .HasColumnType("integer"); + + b.Property("HunterCount") + .HasColumnType("integer"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasColumnType("text"); + + b.Property("RoomCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("RunnerCount") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("GameRooms"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("FinalArea") + .HasColumnType("integer"); + + b.Property("PlayerColor") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlayerId") + .HasColumnType("uuid"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.PaintAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BrushSize") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("PlayerId") + .HasColumnType("uuid"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId"); + + b.ToTable("PaintActions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Avatar") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("NickName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalGames") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("WinCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameSession", b => + { + b.HasOne("TerritoryGame.Domain.Entities.App.Player", "Player") + .WithMany("Sessions") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TerritoryGame.Domain.Entities.App.GameRoom", "Room") + .WithMany("Sessions") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Room"); + }); + + 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("PaintActions") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Room"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameRoom", b => + { + b.Navigation("PaintActions"); + + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.Player", b => + { + b.Navigation("PaintActions"); + + b.Navigation("Sessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819115335_AddPsRoleQuotas.cs b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819115335_AddPsRoleQuotas.cs new file mode 100644 index 0000000000000000000000000000000000000000..403a64b229c2147e5510f747324b9c4232b44d0b --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Migrations/20250819115335_AddPsRoleQuotas.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TerritoryGame.Infrastructure.Migrations +{ + /// + public partial class AddPsRoleQuotas : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "HunterCount", + table: "GameRooms", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "RunnerCount", + table: "GameRooms", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "HunterCount", + table: "GameRooms"); + + migrationBuilder.DropColumn( + name: "RunnerCount", + table: "GameRooms"); + } + } +} diff --git a/Backend/src/TerritoryGame.Infrastructure/Migrations/GameDbContextModelSnapshot.cs b/Backend/src/TerritoryGame.Infrastructure/Migrations/GameDbContextModelSnapshot.cs new file mode 100644 index 0000000000000000000000000000000000000000..968a714ab1562c946bbe7ed51db7b2d4985c8cb0 --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Migrations/GameDbContextModelSnapshot.cs @@ -0,0 +1,262 @@ +// +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", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameRoom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameDuration") + .HasColumnType("integer"); + + b.Property("GameType") + .HasColumnType("integer"); + + b.Property("HunterCount") + .HasColumnType("integer"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasColumnType("text"); + + b.Property("RoomCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("RunnerCount") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("GameRooms"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("FinalArea") + .HasColumnType("integer"); + + b.Property("PlayerColor") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlayerId") + .HasColumnType("uuid"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId"); + + b.ToTable("GameSessions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.PaintAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BrushSize") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("PlayerId") + .HasColumnType("uuid"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.HasIndex("RoomId"); + + b.ToTable("PaintActions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Avatar") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("NickName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalGames") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("WinCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameSession", b => + { + b.HasOne("TerritoryGame.Domain.Entities.App.Player", "Player") + .WithMany("Sessions") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TerritoryGame.Domain.Entities.App.GameRoom", "Room") + .WithMany("Sessions") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Room"); + }); + + 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("PaintActions") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Room"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.GameRoom", b => + { + b.Navigation("PaintActions"); + + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("TerritoryGame.Domain.Entities.App.Player", b => + { + b.Navigation("PaintActions"); + + b.Navigation("Sessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/src/TerritoryGame.Infrastructure/Repositories/EfRepository.cs b/Backend/src/TerritoryGame.Infrastructure/Repositories/EfRepository.cs new file mode 100644 index 0000000000000000000000000000000000000000..b46f291d0235cc6a9fd239e22f205366d7495747 --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Repositories/EfRepository.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using TerritoryGame.Domain.Repositories; +using TerritoryGame.Infrastructure.Data; + +namespace TerritoryGame.Infrastructure.Repositories; + +/// +/// EF Core通用仓储实现 +/// +/// 实体类型 +public class EfRepository : IRepository where T : class +{ + protected readonly GameDbContext _context; + protected readonly DbSet _dbSet; + + public EfRepository(GameDbContext context) + { + _context = context; + _dbSet = context.Set(); + } + + /// + /// 根据ID查询 + /// + public async Task GetByIdAsync(Guid Id) + { + return await _dbSet.FindAsync(Id); + } + + /// + /// 查询所有 + /// + public async Task> GetAllAsync() + { + return await _dbSet.ToListAsync(); + } + + /// + /// 创建 + /// + public async Task CreatedAsync(T entity) + { + var result = await _dbSet.AddAsync(entity); + await _context.SaveChangesAsync(); + return result.Entity; + } + + /// + /// 更新 + /// + public async Task UpdateAsync(T entity) + { + _dbSet.Update(entity); + await _context.SaveChangesAsync(); + return entity; + } + + /// + /// 删除 + /// + public async Task DeleteAsync(Guid Id) + { + var entity = await GetByIdAsync(Id); + if (entity != null) + { + _dbSet.Remove(entity); + await _context.SaveChangesAsync(); + } + } +} \ No newline at end of file diff --git a/Backend/src/TerritoryGame.Infrastructure/Repositories/GameRoomRepository.cs b/Backend/src/TerritoryGame.Infrastructure/Repositories/GameRoomRepository.cs new file mode 100644 index 0000000000000000000000000000000000000000..a01858446333c33742b4858e33d598e7af5eb351 --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Repositories/GameRoomRepository.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore; +using TerritoryGame.Domain.Entities.App; +using TerritoryGame.Domain.Repositories; +using TerritoryGame.Infrastructure.Data; + +namespace TerritoryGame.Infrastructure.Repositories; + +/// +/// 游戏房间仓储实现 +/// +public class GameRoomRepository : EfRepository, IGameRoomRepository +{ + public GameRoomRepository(GameDbContext context) : base(context) + { + } + + /// + /// 根据房间名称查询房间 + /// + public async Task GetByRoomNameAsync(string roomName) + { + return await _dbSet.FirstOrDefaultAsync(r => r.Name == roomName); + } + + public async Task GetByRoomCodeAsync(string roomCode) + { + return await _dbSet.FirstOrDefaultAsync(r => r.RoomCode == roomCode); + } + + /// + /// 检查房间名称是否已存在 + /// + public async Task IsRoomNameExistsAsync(string roomName) + { + return await _dbSet.AnyAsync(r => r.Name == roomName); + } + + public async Task IsRoomCodeExistsAsync(string roomCode) + { + return await _dbSet.AnyAsync(r => r.RoomCode == roomCode); + } + + /// + /// 获取所有等待中的房间 + /// + public async Task> GetWaitingRoomsAsync() + { + return await _dbSet.Where(r => r.Status == GameStatus.Waiting) + .Include(r => r.Sessions) + .ToListAsync(); + } + + public async Task> GetRoomsByOwnerAsync(Guid ownerId) + { + return await _dbSet.Where(r => r.OwnerId == ownerId) + .Include(r => r.Sessions) + .OrderByDescending(r => r.CreatedAt) + .ToListAsync(); + } + + public async Task> GetAllWithSessionsAsync() + { + return await _dbSet + .Include(r => r.Sessions) + .ToListAsync(); + } + + public async Task> GetAllWithSessionsByGameAsync(GameType gameType) + { + return await _dbSet + .Where(r => r.GameType == gameType) + .Include(r => r.Sessions) + .ToListAsync(); + } +} diff --git a/Backend/src/TerritoryGame.Infrastructure/Repositories/PlayerRepository.cs b/Backend/src/TerritoryGame.Infrastructure/Repositories/PlayerRepository.cs new file mode 100644 index 0000000000000000000000000000000000000000..4b23716b8b5f981b87a8df443f2254685796b884 --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Repositories/PlayerRepository.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using TerritoryGame.Domain.Entities.App; +using TerritoryGame.Domain.Repositories; +using TerritoryGame.Infrastructure.Data; + +namespace TerritoryGame.Infrastructure.Repositories; + +/// +/// 玩家仓储实现 +/// +public class PlayerRepository : EfRepository, IPlayerRepository +{ + public PlayerRepository(GameDbContext context) : base(context) + { + } + + /// + /// 根据昵称查询玩家 + /// + public async Task GetByNickNameAsync(string nickName) + { + return await _dbSet.FirstOrDefaultAsync(p => p.NickName == nickName); + } + + /// + /// 检查昵称是否已存在 + /// + public async Task IsNickNameExistsAsync(string nickName) + { + return await _dbSet.AnyAsync(p => p.NickName == nickName); + } +} diff --git a/Backend/src/TerritoryGame.Infrastructure/Services/EventConsumerBackgroundService.cs b/Backend/src/TerritoryGame.Infrastructure/Services/EventConsumerBackgroundService.cs new file mode 100644 index 0000000000000000000000000000000000000000..dc44d5375b6d09709b13c7860b47d6924f7f04f2 --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Services/EventConsumerBackgroundService.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using TerritoryGame.Application.Common.EventBus; +using TerritoryGame.Application.Events; +using TerritoryGame.Domain.Entities.App; +using TerritoryGame.Infrastructure.Data; + +namespace TerritoryGame.Infrastructure.Services; + +public class EventConsumerBackgroundService : BackgroundService +{ + private readonly ILogger _logger; + private readonly IEventQueue _queue; + private readonly IServiceProvider _services; + + public EventConsumerBackgroundService( + ILogger logger, + IEventQueue queue, + IServiceProvider services) + { + _logger = logger; + _queue = queue; + _services = services; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (var evt in _queue.Reader.ReadAllAsync(stoppingToken)) + { + try + { + // persist and dispatch + using var scope = _services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + switch (evt) + { + case PaintEvent pe: + // Persist minimal paint snapshot; mapping from DTO to entity simplified + if (Guid.TryParse(pe.RoomId, out var roomGuid) && Guid.TryParse(pe.UserId, out var playerGuid)) + { + var first = pe.Paint.Points.FirstOrDefault(); + var paintEntity = new PaintAction + { + RoomId = roomGuid, + PlayerId = playerGuid, + X = first?.X is float fx ? (int)fx : 0, + Y = first?.Y is float fy ? (int)fy : 0, + BrushSize = (int)pe.Paint.BrushSize, + Timestamp = DateTime.UtcNow + }; + db.PaintActions.Add(paintEntity); + await db.SaveChangesAsync(stoppingToken); + } + break; + } + + // dispatch to handlers + var handlerType = typeof(IEventHandler<>).MakeGenericType(evt.GetType()); + var handlers = scope.ServiceProvider.GetServices(handlerType); + foreach (var handler in handlers) + { + var method = handlerType.GetMethod("HandleAsync"); + if (method != null) + { + var task = (Task)method.Invoke(handler, new object[] { evt, stoppingToken })!; + await task; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while consuming event {EventType}", evt.GetType().Name); + } + } + } +} diff --git a/Backend/src/TerritoryGame.Infrastructure/Services/NullRedisService.cs b/Backend/src/TerritoryGame.Infrastructure/Services/NullRedisService.cs new file mode 100644 index 0000000000000000000000000000000000000000..ac2ad6e71b785770e36bb3943058802a09876bcc --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Services/NullRedisService.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading.Tasks; + +namespace TerritoryGame.Infrastructure.Services; + +/// +/// 空实现的 Redis 服务,用于本地或未配置 Redis 的环境。 +/// +public class NullRedisService : IRedisService +{ + public Task SetAsync(string key, string value, TimeSpan? expiry = null) + => Task.FromResult(true); + + public Task GetAsync(string key) + => Task.FromResult(null); + + public Task DeleteAsync(string key) + => Task.FromResult(true); + + public Task TestConnectionAsync() + => Task.FromResult(false); +} diff --git a/Backend/src/TerritoryGame.Infrastructure/Services/RedisService.cs b/Backend/src/TerritoryGame.Infrastructure/Services/RedisService.cs new file mode 100644 index 0000000000000000000000000000000000000000..6c9680d64f50c2096a78fef5adc498c41936506f --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/Services/RedisService.cs @@ -0,0 +1,73 @@ +using StackExchange.Redis; + +namespace TerritoryGame.Infrastructure.Services; + +/// +/// Redis服务 +/// +public interface IRedisService +{ + /// + /// 设置键值对 + /// + Task SetAsync(string key, string value, TimeSpan? expiry = null); + + /// + /// 获取值 + /// + Task GetAsync(string key); + + /// + /// 删除键 + /// + Task DeleteAsync(string key); + + /// + /// 测试连接 + /// + Task TestConnectionAsync(); +} + +/// +/// Redis服务实现 +/// +public class RedisService : IRedisService +{ + private readonly IConnectionMultiplexer _redis; + private readonly IDatabase _database; + + public RedisService(IConnectionMultiplexer redis) + { + _redis = redis; + _database = redis.GetDatabase(); + } + + public async Task SetAsync(string key, string value, TimeSpan? expiry = null) + { + return await _database.StringSetAsync(key, value, expiry); + } + + public async Task GetAsync(string key) + { + var value = await _database.StringGetAsync(key); + return value.HasValue ? value.ToString() : null; + } + + public async Task DeleteAsync(string key) + { + return await _database.KeyDeleteAsync(key); + } + + public async Task TestConnectionAsync() + { + try + { + await _database.PingAsync(); + return true; + } + catch + { + return false; + } + } +} \ No newline at end of file diff --git a/Backend/src/TerritoryGame.Infrastructure/ServicesCollectionExtension.cs b/Backend/src/TerritoryGame.Infrastructure/ServicesCollectionExtension.cs new file mode 100644 index 0000000000000000000000000000000000000000..a0d276eba6e7519b13ff0097482e598a69c0c15c --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/ServicesCollectionExtension.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; +using TerritoryGame.Domain.Repositories; +using TerritoryGame.Infrastructure.Data; +using TerritoryGame.Infrastructure.Repositories; +using TerritoryGame.Infrastructure.Services; +using TerritoryGame.Application.Common.EventBus; + +namespace TerritoryGame.Infrastructure; + +/// +/// 基础设施层服务注册扩展 +/// +public static class ServicesCollectionExtension +{ + /// + /// 添加基础设施层服务 + /// + /// 服务集合 + /// 配置 + /// 服务集合 + public static IServiceCollection AddInfrastructureServices(this IServiceCollection services, IConfiguration configuration) + { + // 注册DbContext + services.AddDbContext(options => + options.UseNpgsql(configuration.GetConnectionString("DefaultConnection"))); + + // 注册Redis(可选)。如果未配置或连接失败,则回退到空实现以保证服务可启动。 + var redisConnectionString = configuration.GetConnectionString("Redis"); + if (!string.IsNullOrWhiteSpace(redisConnectionString)) + { + try + { + var mux = ConnectionMultiplexer.Connect(redisConnectionString); + services.AddSingleton(mux); + services.AddScoped(); + } + catch + { + // 回退:注册空实现 + services.AddSingleton(); + } + } + else + { + // 未配置Redis连接串,使用空实现 + services.AddSingleton(); + } + + // 注册仓储服务 + services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>)); + services.AddScoped(); + services.AddScoped(); + + // Event queue + background consumer + services.AddSingleton(); + services.AddHostedService(); + + return services; + } +} + diff --git a/Backend/src/TerritoryGame.Infrastructure/TerritoryGame.Infrastructure.csproj b/Backend/src/TerritoryGame.Infrastructure/TerritoryGame.Infrastructure.csproj new file mode 100644 index 0000000000000000000000000000000000000000..31b97a0d9974e2fc6a2a2a45d5d6d9f8b7b09f45 --- /dev/null +++ b/Backend/src/TerritoryGame.Infrastructure/TerritoryGame.Infrastructure.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000000000000000000000000000000000000..3b510aa687ba5d3dbaec1b9c6989327f84261a21 --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,8 @@ +[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}] +charset = utf-8 +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +max_line_length = 100 diff --git a/frontend/.gitattributes b/frontend/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..6313b56c57848efce05faa7aa7e901ccfc2886ea --- /dev/null +++ b/frontend/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..8ee54e8d343e466a213c8c30aa04be77126b170d --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +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 +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*.tsbuildinfo diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 0000000000000000000000000000000000000000..29a2402ef050746efe041b9e3393bf33796407c3 --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "semi": false, + "singleQuote": true, + "printWidth": 100 +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..2acd9ad5a1337178dc62c5f0072ba92e93f9e275 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,46 @@ +# Frontend Notes + +## Config +- VITE_API_BASE: Backend base url (default http://localhost:5056) +- VITE_SIGNALR_CDN: Optional, custom CDN for @microsoft/signalr browser build + +## SignalR client +Uses a dynamic script loader for the browser build of SignalR to avoid local install issues. + +## Usage +- Pass roomId and userId via URL query to `RoomLobby` route: `?roomId=&userId=`. +# frontend + +This template should help get you started developing with Vue 3 in Vite. + +## Recommended IDE Setup + +[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). + +## Customize configuration + +See [Vite Configuration Reference](https://vite.dev/config/). + +## Project Setup + +```sh +npm install +``` + +### Compile and Hot-Reload for Development + +```sh +npm run dev +``` + +### Compile and Minify for Production + +```sh +npm run build +``` + +### Lint with [ESLint](https://eslint.org/) + +```sh +npm run lint +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..7807d8b33d25d86a60b25349e940aa9ae59c3d2e --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,26 @@ +import { defineConfig, globalIgnores } from 'eslint/config' +import globals from 'globals' +import js from '@eslint/js' +import pluginVue from 'eslint-plugin-vue' +import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' + +export default defineConfig([ + { + name: 'app/files-to-lint', + files: ['**/*.{js,mjs,jsx,vue}'], + }, + + globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']), + + { + languageOptions: { + globals: { + ...globals.browser, + }, + }, + }, + + js.configs.recommended, + ...pluginVue.configs['flat/essential'], + skipFormatting, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..b19040a0e68e0bffcc502662b32b2aa2eb08c055 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite App + + +
+ + + diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..5a1f2d222a302a174e710614c6d76531b7bda926 --- /dev/null +++ b/frontend/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + }, + "exclude": ["node_modules", "dist"] +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..529ba3dc237a62167631afccd5ff3a16136879f7 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "frontend", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint . --fix", + "format": "prettier --write src/" + }, + "dependencies": { + "@microsoft/signalr": "^8.0.7", + "pinia": "^3.0.3", + "vue": "^3.5.18", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@eslint/js": "^9.31.0", + "@vitejs/plugin-vue": "^6.0.1", + "@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" + } +} \ No newline at end of file diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..df36fcfb72584e00488330b560ebcf34a41c64c2 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000000000000000000000000000000000000..d271729936ba80c38aed6ff58f4abacdf3adbc10 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/frontend/src/assets/base.css b/frontend/src/assets/base.css new file mode 100644 index 0000000000000000000000000000000000000000..8816868a41b651f318dee87c6784ebcd6e29eca1 --- /dev/null +++ b/frontend/src/assets/base.css @@ -0,0 +1,86 @@ +/* color palette from */ +: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-indigo: #2c3e50; + + --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: var(--vt-c-indigo); + --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); +} + +/* semantic color variables for this project */ +:root { + --color-background: var(--vt-c-white); + --color-background-soft: var(--vt-c-white-soft); + --color-background-mute: var(--vt-c-white-mute); + + --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: 160px; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-background: var(--vt-c-black); + --color-background-soft: var(--vt-c-black-soft); + --color-background-mute: var(--vt-c-black-mute); + + --color-border: var(--vt-c-divider-dark-2); + --color-border-hover: var(--vt-c-divider-dark-1); + + --color-heading: var(--vt-c-text-dark-1); + --color-text: var(--vt-c-text-dark-2); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + font-weight: normal; +} + +body { + min-height: 100vh; + color: var(--color-text); + background: var(--color-background); + transition: + color 0.5s, + background-color 0.5s; + line-height: 1.6; + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + Oxygen, + Ubuntu, + Cantarell, + 'Fira Sans', + 'Droid Sans', + 'Helvetica Neue', + sans-serif; + font-size: 15px; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/frontend/src/assets/logo.svg b/frontend/src/assets/logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..7565660356e5b3723c9c33d508b830c9cfbea29f --- /dev/null +++ b/frontend/src/assets/logo.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css new file mode 100644 index 0000000000000000000000000000000000000000..36fb845b5232b8594b0d0f0e61a8cff0b73a4128 --- /dev/null +++ b/frontend/src/assets/main.css @@ -0,0 +1,35 @@ +@import './base.css'; + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + font-weight: normal; +} + +a, +.green { + text-decoration: none; + color: hsla(160, 100%, 37%, 1); + transition: 0.4s; + padding: 3px; +} + +@media (hover: hover) { + a:hover { + background-color: hsla(160, 100%, 37%, 0.2); + } +} + +@media (min-width: 1024px) { + body { + display: flex; + place-items: center; + } + + #app { + display: grid; + grid-template-columns: 1fr 1fr; + padding: 0 2rem; + } +} diff --git a/frontend/src/components/AreaStats.vue b/frontend/src/components/AreaStats.vue new file mode 100644 index 0000000000000000000000000000000000000000..6e5e49d45b065d9fab269aef5a6255435f4ac930 --- /dev/null +++ b/frontend/src/components/AreaStats.vue @@ -0,0 +1,131 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/GameCanvas.vue b/frontend/src/components/GameCanvas.vue new file mode 100644 index 0000000000000000000000000000000000000000..896215dcfb7c8fa390c08d906776e089840480b2 --- /dev/null +++ b/frontend/src/components/GameCanvas.vue @@ -0,0 +1,678 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/components/GameTimer.vue b/frontend/src/components/GameTimer.vue new file mode 100644 index 0000000000000000000000000000000000000000..2425e6ce0038e8a91147c5d4e5e9fbada92101d0 --- /dev/null +++ b/frontend/src/components/GameTimer.vue @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/frontend/src/components/PlayerList.vue b/frontend/src/components/PlayerList.vue new file mode 100644 index 0000000000000000000000000000000000000000..95ff17951407fd3a94ead3cac9e367e9b8c4f761 --- /dev/null +++ b/frontend/src/components/PlayerList.vue @@ -0,0 +1,55 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000000000000000000000000000000000000..03dfd0f0faa115160beeb10cd50cdb83da345f97 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,14 @@ +// import './assets/main.css' + +import { createApp } from 'vue' +import { createPinia } from 'pinia' + +import App from './App.vue' +import router from './router' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) + +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000000000000000000000000000000000000..739dac988d3d228b01178a5d818d2279cb5079e7 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,90 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { usePlayerStore } from '@/stores/player' + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + // 登录 + { + path: '/login', + name: 'login', + component: () => import('../views/Login.vue'), + meta: { public: true } + }, + // 游戏:逃生像素(规则&介绍页) + { + path: '/games/pixel-survival', + name: 'pixelSurvival', + component: () => import('@/views/games/PixelSurvival.vue'), + meta: { public: true } + }, + // 选择游戏(新的首页) + { + path: '/', + name: 'gameSelect', + component: () => import('@/views/GameSelect.vue'), + meta: { public: true } + }, + // 游戏大厅(从选择页面进入) + { + path: '/lobby', + name: 'gameLobby', + component: () => import('@/views/GameLobby.vue'), + meta: { public: true } + }, + // 房间大厅 + { + path: '/roomLobby', + name: 'roomLobby', + component: () => import('../views/RoomLobby.vue'), + }, + // 游戏画布 + { + path: '/gameCanvas', + name: 'gameCanvas', + component: () => import('../components/GameCanvas.vue'), + }, + // 按游戏区分的“进入房间”页面 + { + path: '/room/tg', + name: 'roomTG', + component: () => import('@/views/RoomLobby.vue'), + }, + { + path: '/room/ps', + name: 'roomPS', + component: () => import('@/views/games/PixelSurvivalRoom.vue'), + }, + // 玩家列表 + { + path: '/playerList', + name: 'playerList', + component: () => import('../components/PlayerList.vue'), + }, + // 游戏计时器 + { + path: '/gameTimer', + name: 'gameTimer', + component: () => import('../components/GameTimer.vue'), + }, + // // 面积统计组件 + // { + // path: '/areaStatistics', + // name: 'areaStatistics', + // component: () => import('@/components/AreaStatistics.vue'), + // } + ], +}) + +// 简单登录守卫:进入房间相关路由需要 token +router.beforeEach((to) => { + const player = usePlayerStore() + if (!player.token) player.loadFromStorage() + const isAuthed = !!player.token + if (!to.meta?.public && !isAuthed) { + return { name: 'login', query: to.query } + } + return true +}) + +export default router diff --git a/frontend/src/services/authService.js b/frontend/src/services/authService.js new file mode 100644 index 0000000000000000000000000000000000000000..4af8b0e826d0d639e430b2b995239d3a82ac81ac --- /dev/null +++ b/frontend/src/services/authService.js @@ -0,0 +1,39 @@ +const API_BASE = 'http://localhost:5056' + +export async function login(nickName) { + const res = await fetch(`${API_BASE}/api/Auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nickName }) + }) + if (!res.ok) { + let msg = '登录失败' + try { + const data = await res.json() + if (data?.message) msg = data.message + } catch (e) { + void e + } + throw new Error(msg) + } + return res.json() +} + +export async function register(nickName) { + const res = await fetch(`${API_BASE}/api/Auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nickName }) + }) + if (!res.ok) { + let msg = '注册失败' + try { + const data = await res.json() + if (data?.message) msg = data.message + } catch (e) { + void e + } + throw new Error(msg) + } + return res.json() +} diff --git a/frontend/src/services/gameServices.js b/frontend/src/services/gameServices.js new file mode 100644 index 0000000000000000000000000000000000000000..4c3d71d6a1f9e0dd7d33864f2a8131acd542648a --- /dev/null +++ b/frontend/src/services/gameServices.js @@ -0,0 +1,86 @@ +// 后端 HTTP 服务封装(可按需扩展) +const API_BASE = 'http://localhost:5056' + +export async function publishPaint(roomId, userId, paint) { + const res = await fetch(`${API_BASE}/api/realtime/paint?roomId=${roomId}&userId=${userId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(paint) + }) + if (!res.ok) throw new Error('publishPaint failed') +} + +export async function publishRanking(ranking) { + const res = await fetch(`${API_BASE}/api/realtime/ranking`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ranking) + }) + if (!res.ok) throw new Error('publishRanking failed') +} + +// 房间 APIs +export async function createRoom({ displayName, password = null, maxPlayers = 6, gameDuration = 180, gameType, hunterCount = 1, runnerCount = 5 } = {}) { + if (!displayName || !displayName.trim()) throw new Error('displayName is required') + const token = JSON.parse(localStorage.getItem('tg_auth') || '{}').token + const headers = { 'Content-Type': 'application/json' } + if (token) headers['Authorization'] = `Bearer ${token}` + const res = await fetch(`${API_BASE}/api/GameRoom`, { + method: 'POST', + headers, + body: JSON.stringify({ displayName, password, maxPlayers, gameDuration, gameType, hunterCount, runnerCount }) + }) + if (!res.ok) throw new Error('createRoom failed') + return res.json() +} + +export async function getRoomByName(roomName) { + const res = await fetch(`${API_BASE}/api/GameRoom/name/${encodeURIComponent(roomName)}`) + if (res.status === 404) return null + if (!res.ok) throw new Error('getRoomByName failed') + return res.json() +} + +export async function getRoomById(roomId) { + const res = await fetch(`${API_BASE}/api/GameRoom/id/${encodeURIComponent(roomId)}`) + if (res.status === 404) return null + if (!res.ok) throw new Error('getRoomById failed') + return res.json() +} + +export async function getAllRooms(gameType) { + const url = (gameType === 0 || gameType === 1) ? `${API_BASE}/api/GameRoom?gameType=${gameType}` : `${API_BASE}/api/GameRoom` + const res = await fetch(url) + if (!res.ok) throw new Error('getAllRooms failed') + return res.json() +} + +export async function getRoomByCode(roomCode) { + const res = await fetch(`${API_BASE}/api/GameRoom/code/${encodeURIComponent(roomCode)}`) + if (res.status === 404) return null + if (!res.ok) throw new Error('getRoomByCode failed') + return res.json() +} + +export async function joinRoom(roomCode, password, gameType) { + const res = await fetch(`${API_BASE}/api/GameRoom/join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ roomCode, password: password || null, gameType }) + }) + if (res.status === 401) throw new Error('密码错误') + if (!res.ok) throw new Error('joinRoom failed') + return res.json() +} + +export async function getMyRooms(gameType) { + const token = JSON.parse(localStorage.getItem('tg_auth') || '{}').token + if (!token) throw new Error('not authed') + const url = (gameType === 0 || gameType === 1) ? `${API_BASE}/api/GameRoom/my?gameType=${gameType}` : `${API_BASE}/api/GameRoom/my` + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}` } + }) + if (res.status === 401) throw new Error('unauthorized') + if (!res.ok) throw new Error('getMyRooms failed') + return res.json() +} \ No newline at end of file diff --git a/frontend/src/services/socketService.js b/frontend/src/services/socketService.js new file mode 100644 index 0000000000000000000000000000000000000000..656bb653a9f86ecc121d940cf7b19b8d9489b7a3 --- /dev/null +++ b/frontend/src/services/socketService.js @@ -0,0 +1,309 @@ +// SignalR 实时通信客户端(Web/App 通用) +import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr' + +const API_BASE = 'http://localhost:5056' +const HUB_URL = `${API_BASE}/gamehub` + +let connection = null +let currentRoomId = null + +const listeners = { + onPaint: null, + onRanking: null, + onConnected: null, + onDisconnected: null, + onKicked: null, + onPlayerReady: null, + onPreparationStarted: null, + onPreparationTick: null, + onPreparationCanceled: null, + onGameStarted: null, + onRoomOwnerChanged: null, + onCanvasReset: null, + onUserList: null, + onPlayerJoined: null, + onPlayerLeft: null, + // PS 专用 + onPsPlayerPos: null, + onPsRole: null, + onPsRoleBulk: null, + onRoundReset: null, + onPsBlueDots: null, + onPsBlueSpawned: null, + onPsBluePicked: null, + onPsPurpleDots: null, + onPsPurpleSpawned: null, + onPsPurplePicked: null, + onPsPlayerTeleported: null, + onPsSilverDots: null, + onPsSilverSpawned: null, + onPsSilverPicked: null, + onPsOrangeDots: null, + onPsOrangeSpawned: null, + onPsOrangePicked: null, + onPsCaught: null, + onPsRunnerRespawned: null, + onPsHuntersWin: null, + onPsRunnersWin: null, + onPsRemaining: null, +} + +export function setListeners({ onPaint, onRanking, onConnected, onDisconnected, onKicked, onPlayerReady, onPreparationStarted, onPreparationTick, onPreparationCanceled, onGameStarted, onRoomOwnerChanged, onCanvasReset, onUserList, onPlayerJoined, onPlayerLeft, onPsPlayerPos, onPsRole, onPsRoleBulk, onRoundReset, onPsBlueDots, onPsBlueSpawned, onPsBluePicked, onPsPurpleDots, onPsPurpleSpawned, onPsPurplePicked, onPsPlayerTeleported, onPsSilverDots, onPsSilverSpawned, onPsSilverPicked, onPsOrangeDots, onPsOrangeSpawned, onPsOrangePicked, onPsCaught, onPsRunnerRespawned, onPsHuntersWin, onPsRunnersWin, onPsRemaining } = {}) { + if (typeof onPaint === 'function') listeners.onPaint = onPaint + if (typeof onRanking === 'function') listeners.onRanking = onRanking + if (typeof onConnected === 'function') listeners.onConnected = onConnected + if (typeof onDisconnected === 'function') listeners.onDisconnected = onDisconnected + if (typeof onKicked === 'function') listeners.onKicked = onKicked + if (typeof onPlayerReady === 'function') listeners.onPlayerReady = onPlayerReady + if (typeof onPreparationStarted === 'function') listeners.onPreparationStarted = onPreparationStarted + if (typeof onPreparationTick === 'function') listeners.onPreparationTick = onPreparationTick + if (typeof onPreparationCanceled === 'function') listeners.onPreparationCanceled = onPreparationCanceled + if (typeof onGameStarted === 'function') listeners.onGameStarted = onGameStarted + if (typeof onRoomOwnerChanged === 'function') listeners.onRoomOwnerChanged = onRoomOwnerChanged + if (typeof onCanvasReset === 'function') listeners.onCanvasReset = onCanvasReset + if (typeof onUserList === 'function') listeners.onUserList = onUserList + if (typeof onPlayerJoined === 'function') listeners.onPlayerJoined = onPlayerJoined + if (typeof onPlayerLeft === 'function') listeners.onPlayerLeft = onPlayerLeft + if (typeof onPsPlayerPos === 'function') listeners.onPsPlayerPos = onPsPlayerPos + if (typeof onPsRole === 'function') listeners.onPsRole = onPsRole + if (typeof onPsRoleBulk === 'function') listeners.onPsRoleBulk = onPsRoleBulk + if (typeof onRoundReset === 'function') listeners.onRoundReset = onRoundReset + if (typeof onPsBlueDots === 'function') listeners.onPsBlueDots = onPsBlueDots + if (typeof onPsBlueSpawned === 'function') listeners.onPsBlueSpawned = onPsBlueSpawned + if (typeof onPsBluePicked === 'function') listeners.onPsBluePicked = onPsBluePicked + if (typeof onPsPurpleDots === 'function') listeners.onPsPurpleDots = onPsPurpleDots + if (typeof onPsPurpleSpawned === 'function') listeners.onPsPurpleSpawned = onPsPurpleSpawned + if (typeof onPsPurplePicked === 'function') listeners.onPsPurplePicked = onPsPurplePicked + if (typeof onPsPlayerTeleported === 'function') listeners.onPsPlayerTeleported = onPsPlayerTeleported + if (typeof onPsSilverDots === 'function') listeners.onPsSilverDots = onPsSilverDots + if (typeof onPsSilverSpawned === 'function') listeners.onPsSilverSpawned = onPsSilverSpawned + if (typeof onPsSilverPicked === 'function') listeners.onPsSilverPicked = onPsSilverPicked + if (typeof onPsOrangeDots === 'function') listeners.onPsOrangeDots = onPsOrangeDots + if (typeof onPsOrangeSpawned === 'function') listeners.onPsOrangeSpawned = onPsOrangeSpawned + if (typeof onPsOrangePicked === 'function') listeners.onPsOrangePicked = onPsOrangePicked + if (typeof onPsCaught === 'function') listeners.onPsCaught = onPsCaught + if (typeof onPsRunnerRespawned === 'function') listeners.onPsRunnerRespawned = onPsRunnerRespawned + if (typeof onPsHuntersWin === 'function') listeners.onPsHuntersWin = onPsHuntersWin + if (typeof onPsRunnersWin === 'function') listeners.onPsRunnersWin = onPsRunnersWin + if (typeof onPsRemaining === 'function') listeners.onPsRemaining = onPsRemaining +} + +export async function connectAndJoin(roomId) { + if (!connection) { + connection = new HubConnectionBuilder() + .withUrl(HUB_URL) + .withAutomaticReconnect({ nextRetryDelayInMilliseconds: () => 1000 }) + .configureLogging(LogLevel.Information) + .build() + + connection.on('ReceivePaint', (userId, paintData) => { + listeners.onPaint && listeners.onPaint(userId, paintData) + }) + + connection.on('UpdateRanking', (ranking) => { + listeners.onRanking && listeners.onRanking(ranking) + }) + + connection.on('PlayerKicked', (targetUserId) => { + listeners.onKicked && listeners.onKicked(targetUserId) + }) + + connection.on('PlayerReady', (userId, ready) => { + listeners.onPlayerReady && listeners.onPlayerReady(userId, ready) + }) + connection.on('PreparationStarted', (seconds) => { + listeners.onPreparationStarted && listeners.onPreparationStarted(seconds) + }) + connection.on('PreparationTick', (seconds) => { + listeners.onPreparationTick && listeners.onPreparationTick(seconds) + }) + connection.on('PreparationCanceled', () => { + listeners.onPreparationCanceled && listeners.onPreparationCanceled() + }) + connection.on('GameStarted', () => { + listeners.onGameStarted && listeners.onGameStarted() + }) + connection.on('RoomOwnerChanged', (ownerUserId) => { + listeners.onRoomOwnerChanged && listeners.onRoomOwnerChanged(ownerUserId) + }) + connection.on('CanvasReset', () => { + listeners.onCanvasReset && listeners.onCanvasReset() + }) + + connection.on('UserList', (list) => { + listeners.onUserList && listeners.onUserList(list) + }) + connection.on('PlayerJoined', (userId, nickname) => { + listeners.onPlayerJoined && listeners.onPlayerJoined(userId, nickname) + }) + connection.on('PlayerLeft', (userId) => { + listeners.onPlayerLeft && listeners.onPlayerLeft(userId) + }) + + // PS 专用广播事件 + connection.on('PsPlayerPos', (userId, x, y) => { + listeners.onPsPlayerPos && listeners.onPsPlayerPos(userId, x, y) + }) + // 技能事件已移除 + connection.on('PsRole', (userId, role) => { + listeners.onPsRole && listeners.onPsRole(userId, role) + }) + connection.on('PsRoleBulk', (pairs) => { + listeners.onPsRoleBulk && listeners.onPsRoleBulk(pairs) + }) + + connection.on('PsBlueDots', (dots) => { + listeners.onPsBlueDots && listeners.onPsBlueDots(dots) + }) + connection.on('PsBlueSpawned', (x, y) => { + listeners.onPsBlueSpawned && listeners.onPsBlueSpawned(x, y) + }) + connection.on('PsBluePicked', (userId, x, y, durationMs) => { + listeners.onPsBluePicked && listeners.onPsBluePicked(userId, x, y, durationMs) + }) + + connection.on('PsPurpleDots', (dots) => { + listeners.onPsPurpleDots && listeners.onPsPurpleDots(dots) + }) + connection.on('PsPurpleSpawned', (x, y) => { + listeners.onPsPurpleSpawned && listeners.onPsPurpleSpawned(x, y) + }) + connection.on('PsPurplePicked', (userId, x, y) => { + listeners.onPsPurplePicked && listeners.onPsPurplePicked(userId, x, y) + }) + connection.on('PsPlayerTeleported', (userId, x, y) => { + listeners.onPsPlayerTeleported && listeners.onPsPlayerTeleported(userId, x, y) + }) + + connection.on('PsSilverDots', (dots) => { + listeners.onPsSilverDots && listeners.onPsSilverDots(dots) + }) + connection.on('PsSilverSpawned', (x, y) => { + listeners.onPsSilverSpawned && listeners.onPsSilverSpawned(x, y) + }) + connection.on('PsSilverPicked', (userId, x, y, deltaSeconds) => { + listeners.onPsSilverPicked && listeners.onPsSilverPicked(userId, x, y, deltaSeconds) + }) + + connection.on('PsOrangeDots', (dots) => { + listeners.onPsOrangeDots && listeners.onPsOrangeDots(dots) + }) + connection.on('PsOrangeSpawned', (x, y) => { + listeners.onPsOrangeSpawned && listeners.onPsOrangeSpawned(x, y) + }) + connection.on('PsOrangePicked', (userId, x, y, durationMs) => { + listeners.onPsOrangePicked && listeners.onPsOrangePicked(userId, x, y, durationMs) + }) + + connection.on('PsCaught', (hunterId, runnerId, x, y) => { + listeners.onPsCaught && listeners.onPsCaught(hunterId, runnerId, x, y) + }) + connection.on('PsRunnerRespawned', (runnerId, x, y) => { + listeners.onPsRunnerRespawned && listeners.onPsRunnerRespawned(runnerId, x, y) + }) + connection.on('PsHuntersWin', () => { + listeners.onPsHuntersWin && listeners.onPsHuntersWin() + }) + connection.on('PsRunnersWin', () => { + listeners.onPsRunnersWin && listeners.onPsRunnersWin() + }) + + connection.on('PsRemaining', (seconds) => { + listeners.onPsRemaining && listeners.onPsRemaining(seconds) + }) + + connection.on('RoundReset', () => { + listeners.onRoundReset && listeners.onRoundReset() + }) + + connection.onreconnected(() => { + if (currentRoomId) joinRoom(currentRoomId) + listeners.onConnected && listeners.onConnected() + }) + + connection.onclose(() => { + listeners.onDisconnected && listeners.onDisconnected() + }) + } + + if (connection.state === 'Disconnected') { + await connection.start() + listeners.onConnected && listeners.onConnected() + } + await joinRoom(roomId) +} + +export async function joinRoom(roomId) { + if (!connection) return + currentRoomId = roomId + await connection.invoke('JoinRoom', roomId) +} + +export async function leaveRoom() { + if (!connection || !currentRoomId) return + await connection.invoke('LeaveRoom', currentRoomId) + currentRoomId = null +} + +export async function disconnect() { + try { + if (connection) { + if (currentRoomId) await leaveRoom() + await connection.stop() + } + } finally { + connection = null + } +} + +export async function sendPaint(userId, paintData) { + if (!connection || !currentRoomId) return + await connection.invoke('Paint', currentRoomId, userId, paintData) +} + +// 撤销功能已移除 + +// export async function kickPlayer(targetUserId) { +// if (!connection || !currentRoomId) return +// await connection.invoke('Kick', currentRoomId, targetUserId) +// } + +export async function registerUser(userId, nickname = '') { + if (!connection || !currentRoomId) return + await connection.invoke('RegisterUser', currentRoomId, userId, nickname) +} + +export async function setReady(userId, ready) { + if (!connection || !currentRoomId) return + await connection.invoke('SetReady', currentRoomId, userId, ready) +} + +export async function resetCanvas(userId) { + if (!connection || !currentRoomId) return + await connection.invoke('ResetCanvas', currentRoomId, userId) +} + +export function getHubState() { + return connection?.state || 'Disconnected' +} + +// ===== PS 专用:发送方法 ===== +export async function psUpdatePosition(userId, x, y) { + if (!connection || !currentRoomId) return + await connection.invoke('PsUpdatePosition', currentRoomId, userId, x, y) +} +export async function psSetRole(userId, role) { + if (!connection || !currentRoomId) return + await connection.invoke('PsSetRole', currentRoomId, userId, role) +} + +export async function restartRound(userId) { + if (!connection || !currentRoomId) return + await connection.invoke('RestartRound', currentRoomId, userId) +} + +// 宣布逃生者胜利(仅在本地判断时间到且尚未收到猎人胜利时调用;服务端幂等) +export async function announceRunnersWin(userId) { + if (!connection || !currentRoomId) return + await connection.invoke('AnnounceRunnersWin', currentRoomId, userId) +} \ No newline at end of file diff --git a/frontend/src/stores/counter.js b/frontend/src/stores/counter.js new file mode 100644 index 0000000000000000000000000000000000000000..b6757ba5723c5b89b35d011b9558d025bbcde402 --- /dev/null +++ b/frontend/src/stores/counter.js @@ -0,0 +1,12 @@ +import { ref, computed } from 'vue' +import { defineStore } from 'pinia' + +export const useCounterStore = defineStore('counter', () => { + const count = ref(0) + const doubleCount = computed(() => count.value * 2) + function increment() { + count.value++ + } + + return { count, doubleCount, increment } +}) diff --git a/frontend/src/stores/game.js b/frontend/src/stores/game.js new file mode 100644 index 0000000000000000000000000000000000000000..17d014f8c0b0a88915768373d1bc05771ed24c55 --- /dev/null +++ b/frontend/src/stores/game.js @@ -0,0 +1,23 @@ +import { defineStore } from 'pinia' + +export const useGameStore = defineStore('game', { + state: () => ({ + paints: [], + ranking: null, + hubState: 'Disconnected', // SignalR 连接状态 + }), + actions: { + addPaint(userId, paint) { + this.paints.push({ userId, paint }) + }, + setRanking(ranking) { + this.ranking = ranking + }, + setHubState(state) { + this.hubState = state + }, + clearPaints() { + this.paints = [] + } + } +}) \ No newline at end of file diff --git a/frontend/src/stores/player.js b/frontend/src/stores/player.js new file mode 100644 index 0000000000000000000000000000000000000000..571b1d923ff32dc6a057608665ec65d66d42dea9 --- /dev/null +++ b/frontend/src/stores/player.js @@ -0,0 +1,33 @@ +import { defineStore } from 'pinia' + +export const usePlayerStore = defineStore('player', { + state: () => ({ + userId: '', + nickname: '', + token: '', + }), + actions: { + setUser({ userId = '', nickname = '', token = '' }) { + this.userId = userId + this.nickname = nickname + this.token = token + try { localStorage.setItem('tg_auth', JSON.stringify({ userId, nickname, token })) } catch { /* ignore */ } + }, + loadFromStorage() { + try { + const raw = localStorage.getItem('tg_auth') + if (!raw) return + const obj = JSON.parse(raw) + this.userId = obj.userId || '' + this.nickname = obj.nickname || '' + this.token = obj.token || '' + } catch { /* ignore */ } + }, + logout() { + this.userId = '' + this.nickname = '' + this.token = '' + try { localStorage.removeItem('tg_auth') } catch { /* ignore */ } + } + } +}) \ No newline at end of file diff --git a/frontend/src/stores/room.js b/frontend/src/stores/room.js new file mode 100644 index 0000000000000000000000000000000000000000..3bb7216d447b7221e9405b143c11f5bcb54b3bf0 --- /dev/null +++ b/frontend/src/stores/room.js @@ -0,0 +1,14 @@ +import { defineStore } from 'pinia' + +export const useRoomStore = defineStore('room', { + state: () => ({ + roomId: '', + roomName: '', + }), + actions: { + setRoom({ roomId, roomName }) { + this.roomId = roomId + this.roomName = roomName + } + } +}) \ No newline at end of file diff --git a/frontend/src/utils/areaCalculator.js b/frontend/src/utils/areaCalculator.js new file mode 100644 index 0000000000000000000000000000000000000000..ecfeaf5c637eb20b0a88acdde820075a20ebbcc0 --- /dev/null +++ b/frontend/src/utils/areaCalculator.js @@ -0,0 +1,154 @@ +// 计算面积 +// utils/areaCalculator.js +export class AreaCalculator { + constructor(gridSize) { + this.gridSize = gridSize + } + + /** + * 从像素数据中识别并计算各个玩家的区域面积 + * @param {Array>} pixelData - 二维数组表示的像素数据 + * @returns {Array<{playerId: number, area: number, polygons: Array>}>} + */ + calculateAreas(pixelData) { + const visited = new Set() + const results = [] + + // 遍历所有像素 + for (let y = 0; y < pixelData.length; y++) { + for (let x = 0; x < pixelData[y].length; x++) { + const playerId = pixelData[y][x] + if (playerId === 0 || visited.has(`${x},${y}`)) continue + + // 找到连续区域 + const { area, boundary } = this.floodFill(pixelData, x, y, playerId, visited) + + // 简化边界为多边形 + const polygon = this.simplifyBoundary(boundary) + + // 添加到结果 + let playerResult = results.find(r => r.playerId === playerId) + if (!playerResult) { + playerResult = { playerId, area: 0, polygons: [] } + results.push(playerResult) + } + playerResult.area += area + playerResult.polygons.push(polygon) + } + } + + return results + } + + /** + * 洪水填充算法识别连续区域 + */ + floodFill(pixelData, startX, startY, targetPlayerId, visited) { + const queue = [{ x: startX, y: startY }] + const boundary = new Set() + let area = 0 + + while (queue.length > 0) { + const { x, y } = queue.shift() + const key = `${x},${y}` + + // 跳过已访问或不符合条件的像素 + if (visited.has(key)) continue + if (x < 0 || x >= pixelData[0].length || y < 0 || y >= pixelData.length) continue + if (pixelData[y][x] !== targetPlayerId) continue + + visited.add(key) + area++ + + // 检查8个方向的邻居 + const neighbors = [ + { x: x - 1, y }, { x: x + 1, y }, { x, y: y - 1 }, { x, y: y + 1 }, + { x: x - 1, y: y - 1 }, { x: x + 1, y: y - 1 }, { x: x - 1, y: y + 1 }, { x: x + 1, y: y + 1 } + ] + + for (const neighbor of neighbors) { + const { x: nx, y: ny } = neighbor + if (nx < 0 || nx >= pixelData[0].length || ny < 0 || ny >= pixelData.length) { + boundary.add(key) + continue + } + + if (pixelData[ny][nx] !== targetPlayerId) { + boundary.add(key) + } else { + queue.push(neighbor) + } + } + } + + return { + area, boundary: Array.from(boundary).map(k => { + const [x, y] = k.split(',').map(Number) + return { x, y } + }) + } + } + + /** + * 简化边界为多边形 + */ + simplifyBoundary(boundaryPoints) { + if (boundaryPoints.length === 0) return [] + + // 使用凸包算法简化边界 + return this.convexHull(boundaryPoints) + } + + /** + * Andrew's monotone chain 凸包算法 + */ + convexHull(points) { + if (points.length <= 1) return points + + points.sort((a, b) => a.x === b.x ? a.y - b.y : a.x - b.x) + + const lower = [] + for (const point of points) { + while (lower.length >= 2 && this.cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) { + lower.pop() + } + lower.push(point) + } + + const upper = [] + for (let i = points.length - 1; i >= 0; i--) { + const point = points[i] + while (upper.length >= 2 && this.cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) { + upper.pop() + } + upper.push(point) + } + + lower.pop() + upper.pop() + return lower.concat(upper) + } + + /** + * 计算叉积 + */ + cross(o, a, b) { + return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x) + } + + /** + * 计算区域面积(使用鞋带公式) + */ + calculatePolygonArea(polygon) { + if (polygon.length < 3) return 0 + + let area = 0 + for (let i = 0; i < polygon.length; i++) { + const j = (i + 1) % polygon.length + area += polygon[i].x * polygon[j].y + area -= polygon[j].x * polygon[i].y + } + + return Math.abs(area / 2) * this.gridSize * this.gridSize + } +} \ No newline at end of file diff --git a/frontend/src/utils/canvas.js b/frontend/src/utils/canvas.js new file mode 100644 index 0000000000000000000000000000000000000000..98e452ab69cd61f8feacfd2c33b718b671a0beb7 --- /dev/null +++ b/frontend/src/utils/canvas.js @@ -0,0 +1,162 @@ +// Canva画图工具 +import { AreaCalculator } from './areaCalculator' + +export class PixelCanvas { + constructor(canvasElement, options = {}) { + this.canvas = canvasElement + this.ctx = canvasElement.getContext('2d') + this.gridSize = options.gridSize || 8 + this.width = options.width || 800 + this.height = options.height || 600 + this.brushSize = options.brushSize || 16 + + // 初始化画布 + this.initCanvas() + + // 存储绘制历史 + this.history = [] + this.currentStep = [] + + // 像素数据存储 + this.pixelData = new Array(Math.ceil(this.height / this.gridSize)) + .fill() + .map(() => new Array(Math.ceil(this.width / this.gridSize)).fill(0)) + + // 添加区域计算器 + this.areaCalculator = new AreaCalculator(this.gridSize) + + // 添加区域计算相关属性 + this.areas = [] + this.lastCalculateTime = 0 + this.calculateInterval = 1000 // 1秒计算一次区域 + } + + initCanvas() { + this.canvas.width = this.width + this.canvas.height = this.height + this.drawGrid() + } + + drawGrid() { + this.ctx.strokeStyle = '#333' + this.ctx.lineWidth = 1 + + // 绘制垂直线 + for (let x = 0; x <= this.width; x += this.gridSize) { + this.ctx.beginPath() + this.ctx.moveTo(x, 0) + this.ctx.lineTo(x, this.height) + this.ctx.stroke() + } + + // 绘制水平线 + for (let y = 0; y <= this.height; y += this.gridSize) { + this.ctx.beginPath() + this.ctx.moveTo(0, y) + this.ctx.lineTo(this.width, y) + this.ctx.stroke() + } + } + + paint(x, y, color, playerId) { + // 转换为网格坐标 + const gridX = Math.floor(x / this.gridSize) + const gridY = Math.floor(y / this.gridSize) + + // 计算影响范围 + const radius = Math.floor(this.brushSize / this.gridSize / 2) + const pixelsPainted = [] + + // 绘制圆形区域 + for (let dx = -radius; dx <= radius; dx++) { + for (let dy = -radius; dy <= radius; dy++) { + const nx = gridX + dx + const ny = gridY + dy + + // 检查边界 + if (nx >= 0 && nx < this.pixelData[0].length && + ny >= 0 && ny < this.pixelData.length) { + + // 检查是否在圆形范围内 + if (dx * dx + dy * dy <= radius * radius) { + // 如果这个像素不属于当前玩家 + if (this.pixelData[ny][nx] !== playerId) { + this.pixelData[ny][nx] = playerId + this.ctx.fillStyle = color + this.ctx.fillRect( + nx * this.gridSize, + ny * this.gridSize, + this.gridSize, + this.gridSize + ) + pixelsPainted.push({ x: nx, y: ny }) + } + } + } + } + } + + // 记录当前步骤 + if (pixelsPainted.length > 0) { + this.currentStep.push({ + playerId, + pixels: pixelsPainted + }) + } + + return pixelsPainted.length + } + + /** + * 计算并更新区域数据 + */ + calculateAreas() { + const now = Date.now() + if (now - this.lastCalculateTime < this.calculateInterval) return + + this.lastCalculateTime = now + this.areas = this.areaCalculator.calculateAreas(this.pixelData) + return this.areas + } + + /** + * 获取各玩家面积占比 + */ + getAreaRatios() { + const areas = this.calculateAreas() + const totalArea = areas.reduce((sum, area) => sum + area.area, 0) + + return areas.map(area => ({ + playerId: area.playerId, + ratio: totalArea > 0 ? (area.area / totalArea) * 100 : 0, + area: area.area + })) + } + + undo() { + if (this.currentStep.length === 0) return false + + const step = this.currentStep.pop() + for (const pixel of step.pixels) { + this.pixelData[pixel.y][pixel.x] = 0 + this.ctx.clearRect( + pixel.x * this.gridSize, + pixel.y * this.gridSize, + this.gridSize, + this.gridSize + ) + } + + // 重新绘制网格线 + this.drawGrid() + return true + } + + clear() { + this.ctx.clearRect(0, 0, this.width, this.height) + this.drawGrid() + this.pixelData.forEach(row => row.fill(0)) + this.history = [] + this.currentStep = [] + } +} \ No newline at end of file diff --git a/frontend/src/utils/jwt.js b/frontend/src/utils/jwt.js new file mode 100644 index 0000000000000000000000000000000000000000..102444475c1e78beb48ed935961a677bb675d69f --- /dev/null +++ b/frontend/src/utils/jwt.js @@ -0,0 +1,17 @@ +export function decodeJwt(token) { + try { + const payload = token.split('.')[1] + const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/')) + // handle unicode + const uriDecoded = decodeURIComponent(Array.prototype.map.call(json, c => + '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) + ).join('')) + return JSON.parse(uriDecoded) + } catch { + try { + return JSON.parse(atob(token.split('.')[1])) + } catch { + return null + } + } +} diff --git a/frontend/src/views/GameLobby.vue b/frontend/src/views/GameLobby.vue new file mode 100644 index 0000000000000000000000000000000000000000..6779ea8f3d94f18bc4bfb9764743ae18b4dafddf --- /dev/null +++ b/frontend/src/views/GameLobby.vue @@ -0,0 +1,755 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/GameSelect.vue b/frontend/src/views/GameSelect.vue new file mode 100644 index 0000000000000000000000000000000000000000..b3a67b17cfc045ac37d4cecd886e726d2d2488db --- /dev/null +++ b/frontend/src/views/GameSelect.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000000000000000000000000000000000000..88097a9197c609bf4ed3099496632ebf04f4c5ee --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/frontend/src/views/RoomLobby.vue b/frontend/src/views/RoomLobby.vue new file mode 100644 index 0000000000000000000000000000000000000000..1826f985bbac2f8228ed05e6763ac369b92c238f --- /dev/null +++ b/frontend/src/views/RoomLobby.vue @@ -0,0 +1,849 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/views/games/PixelSurvival.vue b/frontend/src/views/games/PixelSurvival.vue new file mode 100644 index 0000000000000000000000000000000000000000..5977feb93a00c3051ee3cb5e028fd268929cd4ee --- /dev/null +++ b/frontend/src/views/games/PixelSurvival.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/frontend/src/views/games/PixelSurvivalRoom.vue b/frontend/src/views/games/PixelSurvivalRoom.vue new file mode 100644 index 0000000000000000000000000000000000000000..f0507fd879117cf4e11c41d016bff95335416b89 --- /dev/null +++ b/frontend/src/views/games/PixelSurvivalRoom.vue @@ -0,0 +1,1216 @@ + + + + + diff --git a/frontend/src/views/lobbies/PixelSurvivalLobby.vue b/frontend/src/views/lobbies/PixelSurvivalLobby.vue new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000000000000000000000000000000000000..4217010a3178372181948ce34c4d5045dfa18325 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,18 @@ +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(), + vueDevTools(), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + }, + }, +})