diff --git a/server/src/Hokm.Server/Game/Contracts.cs b/server/src/Hokm.Server/Game/Contracts.cs index 8623242..1bd8ef0 100644 --- a/server/src/Hokm.Server/Game/Contracts.cs +++ b/server/src/Hokm.Server/Game/Contracts.cs @@ -8,7 +8,7 @@ namespace Hokm.Server.Game; public record CardDto(string Suit, int Rank, string Id); public record PlayedCardDto(int Seat, CardDto Card); public record PlayerDto(int Seat, string Name, int Team, bool IsHuman, int HandCount, List? Hand); -public record SeatPlayerDto(int Seat, string Name, string Avatar, int Level, bool Connected, bool IsBot, string? UserId); +public record SeatPlayerDto(int Seat, string Name, string Avatar, int Level, bool Connected, bool IsBot, string? UserId, string? AvatarImage = null); public record RoundResultDto(int WinningTeam, int[] Tricks, bool Kot, int Points); public record GameStateDto( diff --git a/server/src/Hokm.Server/Game/GameManager.PrivateRooms.cs b/server/src/Hokm.Server/Game/GameManager.PrivateRooms.cs index e054ff5..bac783d 100644 --- a/server/src/Hokm.Server/Game/GameManager.PrivateRooms.cs +++ b/server/src/Hokm.Server/Game/GameManager.PrivateRooms.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.DependencyInjection; namespace Hokm.Server.Game; // Wire DTOs for private rooms (camelCase JSON → TS client types). -public record RoomPlayerDto(string Id, string DisplayName, string Avatar, int Level); +public record RoomPlayerDto(string Id, string DisplayName, string Avatar, int Level, string? AvatarImage = null); public record RoomSeatDto(int Seat, string Kind, RoomPlayerDto? Player); // kind: empty|invited|bot|human public record RoomDto(string Id, string Code, string HostId, string Status, List Seats, int TargetScore, int Stake, bool Ranked); public record RoomInviteDto(string RoomId, string Code, string HostName, int Stake); @@ -26,6 +26,7 @@ public sealed partial class GameManager public string? UserId; public string Name = ""; public string Avatar = "a-fox"; + public string? AvatarImage; public int Level; } @@ -51,7 +52,7 @@ public sealed partial class GameManager { var room = new PRoom { HostId = host.UserId, Stake = stake, TargetScore = target <= 0 ? 7 : target }; for (int i = 0; i < 4; i++) room.Seats[i] = new PSeat { Seat = i }; - room.Seats[0] = new PSeat { Seat = 0, Kind = "human", UserId = host.UserId, Name = host.Name, Avatar = host.Avatar, Level = host.Level }; + room.Seats[0] = new PSeat { Seat = 0, Kind = "human", UserId = host.UserId, Name = host.Name, Avatar = host.Avatar, AvatarImage = host.AvatarImage, Level = host.Level }; _privateRooms[room.Id] = room; _userPrivate[host.UserId] = room.Id; PushRoom(room); @@ -62,14 +63,14 @@ public sealed partial class GameManager { if (seat is < 1 or > 3 || string.IsNullOrEmpty(friendId) || friendId == hostId) return; // Authoritative friend identity (so the pending seat shows their real name/avatar). - var (name, avatar, level) = ResolveProfile(friendId, "", "a-fox", 1); + var (name, avatar, level, avatarImage) = ResolveProfile(friendId, "", "a-fox", 1); lock (_proomLock) { if (!HostRoom(hostId, out var room)) return; if (room!.Seats[seat].Kind is not ("empty" or "invited")) return; if (room.Seats.Any(s => s.Seat != seat && s.UserId == friendId)) return; // already in this room FreeSeatInvite(room.Seats[seat]); // if re-inviting over a prior invite - room.Seats[seat] = new PSeat { Seat = seat, Kind = "invited", UserId = friendId, Name = name, Avatar = avatar, Level = level }; + room.Seats[seat] = new PSeat { Seat = seat, Kind = "invited", UserId = friendId, Name = name, Avatar = avatar, AvatarImage = avatarImage, Level = level }; _pendingInvite[friendId] = room.Id; _ = _hub.Clients.User(friendId).SendAsync("roomInvite", new RoomInviteDto(room.Id, room.Code, room.Seats[0].Name, room.Stake)); PushRoom(room); @@ -83,8 +84,8 @@ public sealed partial class GameManager if (!_pendingInvite.TryRemove(userId, out var roomId) || !_privateRooms.TryGetValue(roomId, out var room)) return; var seat = room.Seats.FirstOrDefault(x => x.Kind == "invited" && x.UserId == userId); if (seat == null) return; - var (name, avatar, level) = ResolveProfile(userId, seat.Name, seat.Avatar, seat.Level); - room.Seats[seat.Seat] = new PSeat { Seat = seat.Seat, Kind = "human", UserId = userId, Name = name, Avatar = avatar, Level = level }; + var (name, avatar, level, avatarImage) = ResolveProfile(userId, seat.Name, seat.Avatar, seat.Level); + room.Seats[seat.Seat] = new PSeat { Seat = seat.Seat, Kind = "human", UserId = userId, Name = name, Avatar = avatar, AvatarImage = avatarImage, Level = level }; _userPrivate[userId] = room.Id; PushRoom(room); } @@ -139,7 +140,7 @@ public sealed partial class GameManager { var s = room.Seats[i]; slots[i] = s.Kind == "human" - ? new SeatSlot { Seat = i, UserId = s.UserId, Name = s.Name, Avatar = s.Avatar, Level = s.Level } + ? new SeatSlot { Seat = i, UserId = s.UserId, Name = s.Name, Avatar = s.Avatar, AvatarImage = s.AvatarImage, Level = s.Level } : new SeatSlot { Seat = i, IsBot = true, Name = s.Kind == "bot" && s.Name.Length > 0 ? s.Name : BotNames[_rng.Next(BotNames.Length)], Avatar = s.Kind == "bot" ? s.Avatar : Avatars[_rng.Next(Avatars.Length)], @@ -198,16 +199,16 @@ public sealed partial class GameManager } } - private (string name, string avatar, int level) ResolveProfile(string userId, string fbName, string fbAvatar, int fbLevel) + private (string name, string avatar, int level, string? avatarImage) ResolveProfile(string userId, string fbName, string fbAvatar, int fbLevel) { try { using var scope = _scopes.CreateScope(); var svc = scope.ServiceProvider.GetRequiredService(); var p = svc.GetOrCreate(userId, null).GetAwaiter().GetResult(); - return (p.DisplayName, p.Avatar, p.Level); + return (p.DisplayName, p.Avatar, p.Level, p.AvatarImage); } - catch { return (fbName, fbAvatar, fbLevel); } + catch { return (fbName, fbAvatar, fbLevel, null); } } private void PushRoom(PRoom room) @@ -225,7 +226,7 @@ public sealed partial class GameManager private static RoomSeatDto SeatDto(PSeat s) => s.Kind == "empty" ? new RoomSeatDto(s.Seat, "empty", null) - : new RoomSeatDto(s.Seat, s.Kind, new RoomPlayerDto(s.UserId ?? $"bot-{s.Seat}", s.Name, s.Avatar, s.Level)); + : new RoomSeatDto(s.Seat, s.Kind, new RoomPlayerDto(s.UserId ?? $"bot-{s.Seat}", s.Name, s.Avatar, s.Level, s.Kind == "bot" ? null : s.AvatarImage)); /// Turn a fixed seat arrangement into a live match (used by private-room start). private void StartMatchSeats(SeatSlot[] seats, int stake, int targetScore) diff --git a/server/src/Hokm.Server/Game/GameManager.cs b/server/src/Hokm.Server/Game/GameManager.cs index 6cc9d51..c771678 100644 --- a/server/src/Hokm.Server/Game/GameManager.cs +++ b/server/src/Hokm.Server/Game/GameManager.cs @@ -10,6 +10,7 @@ public sealed class Player public required string UserId { get; init; } public string Name { get; init; } = ""; public string Avatar { get; init; } = "a-fox"; + public string? AvatarImage { get; init; } public int Level { get; init; } public string Plan { get; init; } = "free"; } @@ -142,7 +143,7 @@ public sealed partial class GameManager if (i < humans.Count) { var h = humans[i]; - seats[i] = new SeatSlot { Seat = i, UserId = h.UserId, Name = h.Name, Avatar = h.Avatar, Level = h.Level }; + seats[i] = new SeatSlot { Seat = i, UserId = h.UserId, Name = h.Name, Avatar = h.Avatar, AvatarImage = h.AvatarImage, Level = h.Level }; } else { diff --git a/server/src/Hokm.Server/Game/GameRoom.cs b/server/src/Hokm.Server/Game/GameRoom.cs index 668894e..5cf72d6 100644 --- a/server/src/Hokm.Server/Game/GameRoom.cs +++ b/server/src/Hokm.Server/Game/GameRoom.cs @@ -12,6 +12,7 @@ public sealed class SeatSlot public string? UserId { get; set; } public string Name { get; set; } = ""; public string Avatar { get; set; } = "a-fox"; + public string? AvatarImage { get; set; } public int Level { get; set; } public bool IsBot { get; set; } public bool Connected { get; set; } = true; @@ -413,7 +414,7 @@ public sealed class GameRoom : IDisposable p.Seat == viewerSeat ? p.Hand.Select(Map.Card).ToList() : null)).ToList(); var seatPlayers = Seats.OrderBy(s => s.Seat) - .Select(s => new SeatPlayerDto(s.Seat, s.Name, s.Avatar, s.Level, s.Connected, s.IsBot, s.IsBot ? null : s.UserId)).ToList(); + .Select(s => new SeatPlayerDto(s.Seat, s.Name, s.Avatar, s.Level, s.Connected, s.IsBot, s.IsBot ? null : s.UserId, s.IsBot ? null : s.AvatarImage)).ToList(); RoundResultDto? rr = State.LastRoundResult is null ? null : new RoundResultDto(State.LastRoundResult.WinningTeam, State.LastRoundResult.Tricks, diff --git a/server/src/Hokm.Server/Hubs/GameHub.cs b/server/src/Hokm.Server/Hubs/GameHub.cs index 4849978..654b7db 100644 --- a/server/src/Hokm.Server/Hubs/GameHub.cs +++ b/server/src/Hokm.Server/Hubs/GameHub.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.SignalR; namespace Hokm.Server.Hubs; -public record MatchmakeRequest(string Name, string Avatar, int Level, string Plan); +public record MatchmakeRequest(string Name, string Avatar, int Level, string Plan, string? AvatarImage = null); [Authorize] public sealed class GameHub : Hub @@ -32,6 +32,7 @@ public sealed class GameHub : Hub UserId = Uid, Name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name, Avatar = req.Avatar, + AvatarImage = req.AvatarImage, Level = req.Level, Plan = req.Plan, }); @@ -65,6 +66,7 @@ public sealed class GameHub : Hub UserId = Uid, Name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name, Avatar = req.Avatar, + AvatarImage = req.AvatarImage, Level = req.Level, Plan = req.Plan, }; diff --git a/server/src/Hokm.Server/Social/SocialModels.cs b/server/src/Hokm.Server/Social/SocialModels.cs index b37a143..96cd5e3 100644 --- a/server/src/Hokm.Server/Social/SocialModels.cs +++ b/server/src/Hokm.Server/Social/SocialModels.cs @@ -6,6 +6,7 @@ public class FriendDto public string Username { get; set; } = ""; public string DisplayName { get; set; } = ""; public string Avatar { get; set; } = "a-fox"; + public string? AvatarImage { get; set; } public int Level { get; set; } public int Rating { get; set; } public string Status { get; set; } = "offline"; // online | offline diff --git a/server/src/Hokm.Server/Social/SocialService.cs b/server/src/Hokm.Server/Social/SocialService.cs index cabf721..2dd6af4 100644 --- a/server/src/Hokm.Server/Social/SocialService.cs +++ b/server/src/Hokm.Server/Social/SocialService.cs @@ -60,6 +60,7 @@ public class SocialService Username = p?.Username ?? userId, DisplayName = p?.DisplayName ?? userId, Avatar = p?.Avatar ?? "a-fox", + AvatarImage = p?.AvatarImage, Level = p?.Level ?? 1, Rating = p?.Rating ?? 1000, Status = _mgr.IsOnline(userId) ? "online" : "offline", diff --git a/src/components/GameTable.tsx b/src/components/GameTable.tsx index d4f2bf6..33138ff 100644 --- a/src/components/GameTable.tsx +++ b/src/components/GameTable.tsx @@ -5,6 +5,7 @@ import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, Zap } from "lucide-re import { useEffect, useMemo, useState } from "react"; import { useGameStore } from "@/lib/game-store"; import { useSoundStore } from "@/lib/sound-store"; +import { Avatar } from "@/components/online/Avatar"; import { legalMoves } from "@/lib/hokm/engine"; import { sortHand } from "@/lib/hokm/deck"; import { @@ -341,7 +342,11 @@ function SeatAvatar({ seat, className }: { seat: Seat; className?: string }) { )} style={!active ? { boxShadow: "0 0 0 1px rgba(212,175,55,0.2)" } : undefined} > - {sp?.avatar ?? name.charAt(0)} + {sp?.avatarId || sp?.avatarImage ? ( + + ) : ( + sp?.avatar ?? name.charAt(0) + )} {isHakem && ( )} diff --git a/src/components/online/MatchIntroOverlay.tsx b/src/components/online/MatchIntroOverlay.tsx index c86c38a..69c79b9 100644 --- a/src/components/online/MatchIntroOverlay.tsx +++ b/src/components/online/MatchIntroOverlay.tsx @@ -6,6 +6,7 @@ import { useGameStore } from "@/lib/game-store"; import { useI18n } from "@/lib/i18n"; import { teamOf, type Seat } from "@/lib/hokm/types"; import { titleById } from "@/lib/online/gamification"; +import { Avatar } from "./Avatar"; import { cn } from "@/lib/cn"; // Where each seat sits around the table + which edge it slides in from. @@ -39,7 +40,9 @@ function Seat({ seat }: { seat: Seat }) { style={{ boxShadow: team === 0 ? "0 0 18px rgba(45,212,191,0.4)" : "0 0 18px rgba(251,113,133,0.4)" }} > {sp ? ( - sp.avatar + sp.avatarId || sp.avatarImage + ? + : sp.avatar ) : ( - - {p.avatar} + + {p.avatarId || p.avatarImage ? ( + + ) : ( + p.avatar + )} {p.name} diff --git a/src/components/screens/ChatScreen.tsx b/src/components/screens/ChatScreen.tsx index 0f4245b..637ca5f 100644 --- a/src/components/screens/ChatScreen.tsx +++ b/src/components/screens/ChatScreen.tsx @@ -10,7 +10,7 @@ import { sound } from "@/lib/sound"; import { getService } from "@/lib/online/service"; import { pushNotification } from "@/lib/notification-store"; import { ownedReactions } from "@/lib/online/gamification"; -import { avatarEmoji } from "@/lib/online/types"; +import { Avatar } from "@/components/online/Avatar"; import { cn } from "@/lib/cn"; export function ChatScreen() { @@ -126,7 +126,7 @@ export function ChatScreen() { onClick={() => viewProfile(friend.id)} className="flex items-center gap-3 min-w-0 flex-1 text-start active:scale-[0.99] transition" > - {avatarEmoji(friend.avatar)} +
{friend.displayName}
diff --git a/src/components/screens/FriendsScreen.tsx b/src/components/screens/FriendsScreen.tsx index ba58f57..652997b 100644 --- a/src/components/screens/FriendsScreen.tsx +++ b/src/components/screens/FriendsScreen.tsx @@ -26,7 +26,6 @@ import { Friend, PlayerSummary, PresenceStatus, - avatarEmoji, } from "@/lib/online/types"; import { GENDER_META } from "@/lib/social"; import { titleById } from "@/lib/online/gamification"; @@ -130,8 +129,8 @@ function FriendsTab() {
{requests.map((r) => (
- {r.from.displayName}