feat(avatars): show the uploaded profile photo everywhere
Previously the uploaded profile photo only appeared in a few places (profile,
top bar, leaderboard, public profile); chat, friends, game table, match intro,
post-match roster and private rooms showed the emoji avatar only.
- carry avatarImage end-to-end:
- server DTOs: FriendDto, SeatPlayerDto, RoomPlayerDto, MatchmakeRequest +
Player/SeatSlot/PSeat; ResolveProfile now returns avatarImage; FriendDtoFor
fills it from the profile.
- client types: Friend, RoomSeat.player, MatchmakingState.players,
ServerSeatPlayer, SeatPlayer (adds avatarId + avatarImage).
- signalr-service: send my avatarImage on StartMatchmaking/CreatePrivateRoom;
carry it through mapRoom.
- game-store: applyServerState + newOnlineMatch + offline match now populate
avatarId/avatarImage (seat 0 uses your own profile photo).
- render every avatar through the shared <Avatar> component (image → emoji
fallback): ChatScreen, FriendsScreen (requests/friends/chats), GameTable
seats, MatchIntroOverlay, MatchPlayersList, RoomScreen.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ namespace Hokm.Server.Game;
|
|||||||
public record CardDto(string Suit, int Rank, string Id);
|
public record CardDto(string Suit, int Rank, string Id);
|
||||||
public record PlayedCardDto(int Seat, CardDto Card);
|
public record PlayedCardDto(int Seat, CardDto Card);
|
||||||
public record PlayerDto(int Seat, string Name, int Team, bool IsHuman, int HandCount, List<CardDto>? Hand);
|
public record PlayerDto(int Seat, string Name, int Team, bool IsHuman, int HandCount, List<CardDto>? 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 RoundResultDto(int WinningTeam, int[] Tricks, bool Kot, int Points);
|
||||||
|
|
||||||
public record GameStateDto(
|
public record GameStateDto(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
namespace Hokm.Server.Game;
|
namespace Hokm.Server.Game;
|
||||||
|
|
||||||
// Wire DTOs for private rooms (camelCase JSON → TS client types).
|
// 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 RoomSeatDto(int Seat, string Kind, RoomPlayerDto? Player); // kind: empty|invited|bot|human
|
||||||
public record RoomDto(string Id, string Code, string HostId, string Status, List<RoomSeatDto> Seats, int TargetScore, int Stake, bool Ranked);
|
public record RoomDto(string Id, string Code, string HostId, string Status, List<RoomSeatDto> Seats, int TargetScore, int Stake, bool Ranked);
|
||||||
public record RoomInviteDto(string RoomId, string Code, string HostName, int Stake);
|
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? UserId;
|
||||||
public string Name = "";
|
public string Name = "";
|
||||||
public string Avatar = "a-fox";
|
public string Avatar = "a-fox";
|
||||||
|
public string? AvatarImage;
|
||||||
public int Level;
|
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 };
|
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 };
|
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;
|
_privateRooms[room.Id] = room;
|
||||||
_userPrivate[host.UserId] = room.Id;
|
_userPrivate[host.UserId] = room.Id;
|
||||||
PushRoom(room);
|
PushRoom(room);
|
||||||
@@ -62,14 +63,14 @@ public sealed partial class GameManager
|
|||||||
{
|
{
|
||||||
if (seat is < 1 or > 3 || string.IsNullOrEmpty(friendId) || friendId == hostId) return;
|
if (seat is < 1 or > 3 || string.IsNullOrEmpty(friendId) || friendId == hostId) return;
|
||||||
// Authoritative friend identity (so the pending seat shows their real name/avatar).
|
// 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)
|
lock (_proomLock)
|
||||||
{
|
{
|
||||||
if (!HostRoom(hostId, out var room)) return;
|
if (!HostRoom(hostId, out var room)) return;
|
||||||
if (room!.Seats[seat].Kind is not ("empty" or "invited")) 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
|
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
|
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;
|
_pendingInvite[friendId] = room.Id;
|
||||||
_ = _hub.Clients.User(friendId).SendAsync("roomInvite", new RoomInviteDto(room.Id, room.Code, room.Seats[0].Name, room.Stake));
|
_ = _hub.Clients.User(friendId).SendAsync("roomInvite", new RoomInviteDto(room.Id, room.Code, room.Seats[0].Name, room.Stake));
|
||||||
PushRoom(room);
|
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;
|
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);
|
var seat = room.Seats.FirstOrDefault(x => x.Kind == "invited" && x.UserId == userId);
|
||||||
if (seat == null) return;
|
if (seat == null) return;
|
||||||
var (name, avatar, level) = ResolveProfile(userId, seat.Name, seat.Avatar, seat.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, Level = 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;
|
_userPrivate[userId] = room.Id;
|
||||||
PushRoom(room);
|
PushRoom(room);
|
||||||
}
|
}
|
||||||
@@ -139,7 +140,7 @@ public sealed partial class GameManager
|
|||||||
{
|
{
|
||||||
var s = room.Seats[i];
|
var s = room.Seats[i];
|
||||||
slots[i] = s.Kind == "human"
|
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,
|
: new SeatSlot { Seat = i, IsBot = true,
|
||||||
Name = s.Kind == "bot" && s.Name.Length > 0 ? s.Name : BotNames[_rng.Next(BotNames.Length)],
|
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)],
|
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
|
try
|
||||||
{
|
{
|
||||||
using var scope = _scopes.CreateScope();
|
using var scope = _scopes.CreateScope();
|
||||||
var svc = scope.ServiceProvider.GetRequiredService<ProfileService>();
|
var svc = scope.ServiceProvider.GetRequiredService<ProfileService>();
|
||||||
var p = svc.GetOrCreate(userId, null).GetAwaiter().GetResult();
|
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)
|
private void PushRoom(PRoom room)
|
||||||
@@ -225,7 +226,7 @@ public sealed partial class GameManager
|
|||||||
private static RoomSeatDto SeatDto(PSeat s) =>
|
private static RoomSeatDto SeatDto(PSeat s) =>
|
||||||
s.Kind == "empty"
|
s.Kind == "empty"
|
||||||
? new RoomSeatDto(s.Seat, "empty", null)
|
? 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));
|
||||||
|
|
||||||
/// <summary>Turn a fixed seat arrangement into a live match (used by private-room start).</summary>
|
/// <summary>Turn a fixed seat arrangement into a live match (used by private-room start).</summary>
|
||||||
private void StartMatchSeats(SeatSlot[] seats, int stake, int targetScore)
|
private void StartMatchSeats(SeatSlot[] seats, int stake, int targetScore)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public sealed class Player
|
|||||||
public required string UserId { get; init; }
|
public required string UserId { get; init; }
|
||||||
public string Name { get; init; } = "";
|
public string Name { get; init; } = "";
|
||||||
public string Avatar { get; init; } = "a-fox";
|
public string Avatar { get; init; } = "a-fox";
|
||||||
|
public string? AvatarImage { get; init; }
|
||||||
public int Level { get; init; }
|
public int Level { get; init; }
|
||||||
public string Plan { get; init; } = "free";
|
public string Plan { get; init; } = "free";
|
||||||
}
|
}
|
||||||
@@ -142,7 +143,7 @@ public sealed partial class GameManager
|
|||||||
if (i < humans.Count)
|
if (i < humans.Count)
|
||||||
{
|
{
|
||||||
var h = humans[i];
|
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
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public sealed class SeatSlot
|
|||||||
public string? UserId { get; set; }
|
public string? UserId { get; set; }
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
public string Avatar { get; set; } = "a-fox";
|
public string Avatar { get; set; } = "a-fox";
|
||||||
|
public string? AvatarImage { get; set; }
|
||||||
public int Level { get; set; }
|
public int Level { get; set; }
|
||||||
public bool IsBot { get; set; }
|
public bool IsBot { get; set; }
|
||||||
public bool Connected { get; set; } = true;
|
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();
|
p.Seat == viewerSeat ? p.Hand.Select(Map.Card).ToList() : null)).ToList();
|
||||||
|
|
||||||
var seatPlayers = Seats.OrderBy(s => s.Seat)
|
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
|
RoundResultDto? rr = State.LastRoundResult is null ? null
|
||||||
: new RoundResultDto(State.LastRoundResult.WinningTeam, State.LastRoundResult.Tricks,
|
: new RoundResultDto(State.LastRoundResult.WinningTeam, State.LastRoundResult.Tricks,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.SignalR;
|
|||||||
|
|
||||||
namespace Hokm.Server.Hubs;
|
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]
|
[Authorize]
|
||||||
public sealed class GameHub : Hub
|
public sealed class GameHub : Hub
|
||||||
@@ -32,6 +32,7 @@ public sealed class GameHub : Hub
|
|||||||
UserId = Uid,
|
UserId = Uid,
|
||||||
Name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name,
|
Name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name,
|
||||||
Avatar = req.Avatar,
|
Avatar = req.Avatar,
|
||||||
|
AvatarImage = req.AvatarImage,
|
||||||
Level = req.Level,
|
Level = req.Level,
|
||||||
Plan = req.Plan,
|
Plan = req.Plan,
|
||||||
});
|
});
|
||||||
@@ -65,6 +66,7 @@ public sealed class GameHub : Hub
|
|||||||
UserId = Uid,
|
UserId = Uid,
|
||||||
Name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name,
|
Name = string.IsNullOrWhiteSpace(req.Name) ? "بازیکن" : req.Name,
|
||||||
Avatar = req.Avatar,
|
Avatar = req.Avatar,
|
||||||
|
AvatarImage = req.AvatarImage,
|
||||||
Level = req.Level,
|
Level = req.Level,
|
||||||
Plan = req.Plan,
|
Plan = req.Plan,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public class FriendDto
|
|||||||
public string Username { get; set; } = "";
|
public string Username { get; set; } = "";
|
||||||
public string DisplayName { get; set; } = "";
|
public string DisplayName { get; set; } = "";
|
||||||
public string Avatar { get; set; } = "a-fox";
|
public string Avatar { get; set; } = "a-fox";
|
||||||
|
public string? AvatarImage { get; set; }
|
||||||
public int Level { get; set; }
|
public int Level { get; set; }
|
||||||
public int Rating { get; set; }
|
public int Rating { get; set; }
|
||||||
public string Status { get; set; } = "offline"; // online | offline
|
public string Status { get; set; } = "offline"; // online | offline
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ public class SocialService
|
|||||||
Username = p?.Username ?? userId,
|
Username = p?.Username ?? userId,
|
||||||
DisplayName = p?.DisplayName ?? userId,
|
DisplayName = p?.DisplayName ?? userId,
|
||||||
Avatar = p?.Avatar ?? "a-fox",
|
Avatar = p?.Avatar ?? "a-fox",
|
||||||
|
AvatarImage = p?.AvatarImage,
|
||||||
Level = p?.Level ?? 1,
|
Level = p?.Level ?? 1,
|
||||||
Rating = p?.Rating ?? 1000,
|
Rating = p?.Rating ?? 1000,
|
||||||
Status = _mgr.IsOnline(userId) ? "online" : "offline",
|
Status = _mgr.IsOnline(userId) ? "online" : "offline",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, Zap } from "lucide-re
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useGameStore } from "@/lib/game-store";
|
import { useGameStore } from "@/lib/game-store";
|
||||||
import { useSoundStore } from "@/lib/sound-store";
|
import { useSoundStore } from "@/lib/sound-store";
|
||||||
|
import { Avatar } from "@/components/online/Avatar";
|
||||||
import { legalMoves } from "@/lib/hokm/engine";
|
import { legalMoves } from "@/lib/hokm/engine";
|
||||||
import { sortHand } from "@/lib/hokm/deck";
|
import { sortHand } from "@/lib/hokm/deck";
|
||||||
import {
|
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}
|
style={!active ? { boxShadow: "0 0 0 1px rgba(212,175,55,0.2)" } : undefined}
|
||||||
>
|
>
|
||||||
{sp?.avatar ?? name.charAt(0)}
|
{sp?.avatarId || sp?.avatarImage ? (
|
||||||
|
<Avatar id={sp.avatarId ?? "a-fox"} image={sp.avatarImage} size={44} />
|
||||||
|
) : (
|
||||||
|
sp?.avatar ?? name.charAt(0)
|
||||||
|
)}
|
||||||
{isHakem && (
|
{isHakem && (
|
||||||
<Crown className="absolute -top-4 size-5 text-gold-400 fill-gold-500 drop-shadow" />
|
<Crown className="absolute -top-4 size-5 text-gold-400 fill-gold-500 drop-shadow" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useGameStore } from "@/lib/game-store";
|
|||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { teamOf, type Seat } from "@/lib/hokm/types";
|
import { teamOf, type Seat } from "@/lib/hokm/types";
|
||||||
import { titleById } from "@/lib/online/gamification";
|
import { titleById } from "@/lib/online/gamification";
|
||||||
|
import { Avatar } from "./Avatar";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
// Where each seat sits around the table + which edge it slides in from.
|
// 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)" }}
|
style={{ boxShadow: team === 0 ? "0 0 18px rgba(45,212,191,0.4)" : "0 0 18px rgba(251,113,133,0.4)" }}
|
||||||
>
|
>
|
||||||
{sp ? (
|
{sp ? (
|
||||||
sp.avatar
|
sp.avatarId || sp.avatarImage
|
||||||
|
? <Avatar id={sp.avatarId ?? "a-fox"} image={sp.avatarImage} size={48} />
|
||||||
|
: sp.avatar
|
||||||
) : (
|
) : (
|
||||||
<motion.span
|
<motion.span
|
||||||
className="text-cream/30 text-xl"
|
className="text-cream/30 text-xl"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Check, UserPlus } from "lucide-react";
|
import { Check, UserPlus } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Avatar } from "./Avatar";
|
||||||
import { useGameStore } from "@/lib/game-store";
|
import { useGameStore } from "@/lib/game-store";
|
||||||
import { useSessionStore } from "@/lib/session-store";
|
import { useSessionStore } from "@/lib/session-store";
|
||||||
import { useUIStore } from "@/lib/ui-store";
|
import { useUIStore } from "@/lib/ui-store";
|
||||||
@@ -47,8 +48,12 @@ export function MatchPlayersList() {
|
|||||||
(canAdd ? "active:scale-[0.97] transition" : "cursor-default")
|
(canAdd ? "active:scale-[0.97] transition" : "cursor-default")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-navy-900 text-lg">
|
<span className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-lg bg-navy-900 text-lg">
|
||||||
{p.avatar}
|
{p.avatarId || p.avatarImage ? (
|
||||||
|
<Avatar id={p.avatarId ?? "a-fox"} image={p.avatarImage} size={30} />
|
||||||
|
) : (
|
||||||
|
p.avatar
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="w-full truncate text-[11px] font-semibold text-cream">{p.name}</span>
|
<span className="w-full truncate text-[11px] font-semibold text-cream">{p.name}</span>
|
||||||
<span className="text-[9px] text-cream/45 leading-tight">
|
<span className="text-[9px] text-cream/45 leading-tight">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { sound } from "@/lib/sound";
|
|||||||
import { getService } from "@/lib/online/service";
|
import { getService } from "@/lib/online/service";
|
||||||
import { pushNotification } from "@/lib/notification-store";
|
import { pushNotification } from "@/lib/notification-store";
|
||||||
import { ownedReactions } from "@/lib/online/gamification";
|
import { ownedReactions } from "@/lib/online/gamification";
|
||||||
import { avatarEmoji } from "@/lib/online/types";
|
import { Avatar } from "@/components/online/Avatar";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
export function ChatScreen() {
|
export function ChatScreen() {
|
||||||
@@ -126,7 +126,7 @@ export function ChatScreen() {
|
|||||||
onClick={() => viewProfile(friend.id)}
|
onClick={() => viewProfile(friend.id)}
|
||||||
className="flex items-center gap-3 min-w-0 flex-1 text-start active:scale-[0.99] transition"
|
className="flex items-center gap-3 min-w-0 flex-1 text-start active:scale-[0.99] transition"
|
||||||
>
|
>
|
||||||
<span className="text-2xl shrink-0">{avatarEmoji(friend.avatar)}</span>
|
<span className="shrink-0"><Avatar id={friend.avatar} image={friend.avatarImage} size={34} /></span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-bold text-cream truncate">{friend.displayName}</div>
|
<div className="text-sm font-bold text-cream truncate">{friend.displayName}</div>
|
||||||
<div className={cn("text-[11px]", peerTyping ? "text-gold-300" : "text-teal-300")}>
|
<div className={cn("text-[11px]", peerTyping ? "text-gold-300" : "text-teal-300")}>
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
Friend,
|
Friend,
|
||||||
PlayerSummary,
|
PlayerSummary,
|
||||||
PresenceStatus,
|
PresenceStatus,
|
||||||
avatarEmoji,
|
|
||||||
} from "@/lib/online/types";
|
} from "@/lib/online/types";
|
||||||
import { GENDER_META } from "@/lib/social";
|
import { GENDER_META } from "@/lib/social";
|
||||||
import { titleById } from "@/lib/online/gamification";
|
import { titleById } from "@/lib/online/gamification";
|
||||||
@@ -130,8 +129,8 @@ function FriendsTab() {
|
|||||||
<div className="grid gap-2 lg:grid-cols-2">
|
<div className="grid gap-2 lg:grid-cols-2">
|
||||||
{requests.map((r) => (
|
{requests.map((r) => (
|
||||||
<div key={r.id} className="glass rounded-xl p-2.5 flex items-center gap-3">
|
<div key={r.id} className="glass rounded-xl p-2.5 flex items-center gap-3">
|
||||||
<button onClick={() => viewProfile(r.from.id)} className="text-2xl active:scale-95 transition">
|
<button onClick={() => viewProfile(r.from.id)} className="active:scale-95 transition">
|
||||||
{avatarEmoji(r.from.avatar)}
|
<Avatar id={r.from.avatar} image={r.from.avatarImage} size={34} />
|
||||||
</button>
|
</button>
|
||||||
<span className="flex-1 text-sm font-semibold text-cream">{r.from.displayName}</span>
|
<span className="flex-1 text-sm font-semibold text-cream">{r.from.displayName}</span>
|
||||||
<button onClick={() => accept(r.id)} className="size-8 rounded-lg bg-teal-600/80 grid place-items-center hover:bg-teal-600">
|
<button onClick={() => accept(r.id)} className="size-8 rounded-lg bg-teal-600/80 grid place-items-center hover:bg-teal-600">
|
||||||
@@ -156,7 +155,7 @@ function FriendsTab() {
|
|||||||
<div key={f.id} className="glass rounded-xl p-2.5 flex items-center gap-3">
|
<div key={f.id} className="glass rounded-xl p-2.5 flex items-center gap-3">
|
||||||
<button onClick={() => viewProfile(f.id)} className="flex items-center gap-3 flex-1 min-w-0 text-start active:scale-[0.99] transition">
|
<button onClick={() => viewProfile(f.id)} className="flex items-center gap-3 flex-1 min-w-0 text-start active:scale-[0.99] transition">
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<span className="text-2xl">{avatarEmoji(f.avatar)}</span>
|
<Avatar id={f.avatar} image={f.avatarImage} size={34} />
|
||||||
<span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[f.status])} />
|
<span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[f.status])} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -418,7 +417,7 @@ function MessagesTab() {
|
|||||||
>
|
>
|
||||||
<button onClick={() => open(c)} className="flex items-center gap-3 flex-1 min-w-0 text-start active:scale-[0.99] transition">
|
<button onClick={() => open(c)} className="flex items-center gap-3 flex-1 min-w-0 text-start active:scale-[0.99] transition">
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<span className="text-2xl">{avatarEmoji(c.friend.avatar)}</span>
|
<Avatar id={c.friend.avatar} image={c.friend.avatarImage} size={34} />
|
||||||
<span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[c.friend.status])} />
|
<span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[c.friend.status])} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { useOnlineStore } from "@/lib/online-store";
|
|||||||
import { useUIStore } from "@/lib/ui-store";
|
import { useUIStore } from "@/lib/ui-store";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { getService } from "@/lib/online/service";
|
import { getService } from "@/lib/online/service";
|
||||||
import { Friend, PresenceStatus, RoomSeat, avatarEmoji } from "@/lib/online/types";
|
import { Friend, PresenceStatus, RoomSeat } from "@/lib/online/types";
|
||||||
|
import { Avatar } from "@/components/online/Avatar";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
const STATUS_COLOR: Record<PresenceStatus, string> = {
|
const STATUS_COLOR: Record<PresenceStatus, string> = {
|
||||||
@@ -210,7 +211,7 @@ export function RoomScreen() {
|
|||||||
className="w-full glass rounded-xl p-2.5 flex items-center gap-3 hover:bg-navy-800/80 transition text-start"
|
className="w-full glass rounded-xl p-2.5 flex items-center gap-3 hover:bg-navy-800/80 transition text-start"
|
||||||
>
|
>
|
||||||
<span className="relative shrink-0">
|
<span className="relative shrink-0">
|
||||||
<span className="text-2xl">{avatarEmoji(f.avatar)}</span>
|
<Avatar id={f.avatar} image={f.avatarImage} size={34} />
|
||||||
<span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[f.status])} />
|
<span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[f.status])} />
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 min-w-0">
|
<span className="flex-1 min-w-0">
|
||||||
@@ -263,7 +264,7 @@ function SeatCard({
|
|||||||
<span className="text-[10px] text-cream/50">{label}</span>
|
<span className="text-[10px] text-cream/50">{label}</span>
|
||||||
{filled ? (
|
{filled ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-3xl">{avatarEmoji(seat.player?.avatar ?? "a-fox")}</span>
|
<Avatar id={seat.player?.avatar ?? "a-fox"} image={seat.player?.avatarImage} size={48} />
|
||||||
<span className="text-sm font-bold text-cream text-center max-w-full truncate">
|
<span className="text-sm font-bold text-cream text-center max-w-full truncate">
|
||||||
{seat.player?.displayName}
|
{seat.player?.displayName}
|
||||||
{seat.kind === "bot" && <span className="text-cream/40"> 🤖</span>}
|
{seat.kind === "bot" && <span className="text-cream/40"> 🤖</span>}
|
||||||
|
|||||||
+29
-15
@@ -39,7 +39,9 @@ export type GameMode = "ai" | "online";
|
|||||||
|
|
||||||
export interface SeatPlayer {
|
export interface SeatPlayer {
|
||||||
name: string;
|
name: string;
|
||||||
avatar: string; // emoji
|
avatar: string; // emoji (legacy/fallback)
|
||||||
|
avatarId?: string; // avatar id (for the shared <Avatar> component)
|
||||||
|
avatarImage?: string | null; // uploaded profile photo — shown everywhere when present
|
||||||
level: number;
|
level: number;
|
||||||
id?: string; // real player's user id (for add-friend); absent for bots/you
|
id?: string; // real player's user id (for add-friend); absent for bots/you
|
||||||
isBot?: boolean;
|
isBot?: boolean;
|
||||||
@@ -54,7 +56,7 @@ export interface GameSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface OnlineMatchConfig {
|
export interface OnlineMatchConfig {
|
||||||
players: { displayName: string; avatar: string; level: number }[]; // index = seat
|
players: { displayName: string; avatar: string; level: number; avatarImage?: string | null }[]; // index = seat
|
||||||
targetScore: number;
|
targetScore: number;
|
||||||
stake: number;
|
stake: number;
|
||||||
ranked: boolean;
|
ranked: boolean;
|
||||||
@@ -338,13 +340,18 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
turnDeadline: null,
|
turnDeadline: null,
|
||||||
disconnectedSeat: null,
|
disconnectedSeat: null,
|
||||||
reconnectDeadline: null,
|
reconnectDeadline: null,
|
||||||
seatPlayers: settings.names.map((name, i) => ({
|
seatPlayers: settings.names.map((name, i) => {
|
||||||
name,
|
const prof = i === 0 ? useSessionStore.getState().profile : null;
|
||||||
avatar: AI_AVATARS[i],
|
return {
|
||||||
level: 0,
|
name,
|
||||||
isBot: i > 0, // seat 0 is you
|
avatar: AI_AVATARS[i],
|
||||||
title: i === 0 ? useSessionStore.getState().profile?.title ?? null : null,
|
avatarId: prof?.avatar, // you → your avatar; bots fall back to the emoji
|
||||||
})),
|
avatarImage: prof?.avatarImage ?? null,
|
||||||
|
level: 0,
|
||||||
|
isBot: i > 0, // seat 0 is you
|
||||||
|
title: i === 0 ? prof?.title ?? null : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
scheduleAuto();
|
scheduleAuto();
|
||||||
},
|
},
|
||||||
@@ -368,12 +375,17 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
turnDeadline: null,
|
turnDeadline: null,
|
||||||
disconnectedSeat: null,
|
disconnectedSeat: null,
|
||||||
reconnectDeadline: null,
|
reconnectDeadline: null,
|
||||||
seatPlayers: cfg.players.map((p, i) => ({
|
seatPlayers: cfg.players.map((p, i) => {
|
||||||
name: p.displayName,
|
const prof = i === 0 ? useSessionStore.getState().profile : null;
|
||||||
avatar: avatarEmoji(p.avatar),
|
return {
|
||||||
level: p.level,
|
name: p.displayName,
|
||||||
title: i === 0 ? useSessionStore.getState().profile?.title ?? null : null,
|
avatar: avatarEmoji(p.avatar),
|
||||||
})),
|
avatarId: p.avatar,
|
||||||
|
avatarImage: p.avatarImage ?? prof?.avatarImage ?? null,
|
||||||
|
level: p.level,
|
||||||
|
title: i === 0 ? prof?.title ?? null : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
scheduleAuto();
|
scheduleAuto();
|
||||||
},
|
},
|
||||||
@@ -416,6 +428,8 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
.map((sp) => ({
|
.map((sp) => ({
|
||||||
name: sp.name,
|
name: sp.name,
|
||||||
avatar: avatarEmoji(sp.avatar),
|
avatar: avatarEmoji(sp.avatar),
|
||||||
|
avatarId: sp.avatar,
|
||||||
|
avatarImage: sp.avatarImage ?? null,
|
||||||
level: sp.level,
|
level: sp.level,
|
||||||
id: sp.userId,
|
id: sp.userId,
|
||||||
isBot: sp.isBot,
|
isBot: sp.isBot,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ interface ServerRoom {
|
|||||||
targetScore: number;
|
targetScore: number;
|
||||||
stake: number;
|
stake: number;
|
||||||
ranked: boolean;
|
ranked: boolean;
|
||||||
seats: { seat: number; kind: string; player?: { id: string; displayName: string; avatar: string; level: number } }[];
|
seats: { seat: number; kind: string; player?: { id: string; displayName: string; avatar: string; avatarImage?: string; level: number } }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_ROOM: Room = {
|
const EMPTY_ROOM: Room = {
|
||||||
@@ -318,7 +318,7 @@ export class SignalrService implements OnlineService {
|
|||||||
const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile()));
|
const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile()));
|
||||||
this.emitMM("searching");
|
this.emitMM("searching");
|
||||||
await this.conn?.invoke("StartMatchmaking", {
|
await this.conn?.invoke("StartMatchmaking", {
|
||||||
name: p.displayName, avatar: p.avatar, level: p.level, plan: p.plan,
|
name: p.displayName, avatar: p.avatar, level: p.level, plan: p.plan, avatarImage: p.avatarImage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,7 +497,7 @@ export class SignalrService implements OnlineService {
|
|||||||
await this.connect();
|
await this.connect();
|
||||||
const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile()));
|
const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile()));
|
||||||
await this.conn?.invoke("CreatePrivateRoom",
|
await this.conn?.invoke("CreatePrivateRoom",
|
||||||
{ name: p.displayName, avatar: p.avatar, level: p.level, plan: p.plan }, o.stake, o.targetScore);
|
{ name: p.displayName, avatar: p.avatar, level: p.level, plan: p.plan, avatarImage: p.avatarImage }, o.stake, o.targetScore);
|
||||||
return this.waitRoom();
|
return this.waitRoom();
|
||||||
}
|
}
|
||||||
async setPartner(_roomId: string, friendId: string | null) {
|
async setPartner(_roomId: string, friendId: string | null) {
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ export interface Friend {
|
|||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
avatarImage?: string; // uploaded photo (overrides the emoji avatar)
|
||||||
level: number;
|
level: number;
|
||||||
rating: number;
|
rating: number;
|
||||||
status: PresenceStatus;
|
status: PresenceStatus;
|
||||||
@@ -340,6 +341,7 @@ export interface RoomSeat {
|
|||||||
id: string;
|
id: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
avatarImage?: string;
|
||||||
level: number;
|
level: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -381,6 +383,7 @@ export interface MatchmakingState {
|
|||||||
id: string;
|
id: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
avatarImage?: string;
|
||||||
level: number;
|
level: number;
|
||||||
rating: number;
|
rating: number;
|
||||||
}[];
|
}[];
|
||||||
@@ -565,6 +568,7 @@ export interface ServerSeatPlayer {
|
|||||||
seat: number;
|
seat: number;
|
||||||
name: string;
|
name: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
avatarImage?: string | null;
|
||||||
level: number;
|
level: number;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
isBot: boolean;
|
isBot: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user