Files
HokmPlay/src/components/screens/RoomScreen.tsx
T
soroush.asadi 4739018488
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m35s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m17s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m12s
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>
2026-06-17 08:17:27 +03:30

316 lines
12 KiB
TypeScript

"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Bot, Copy, UserPlus, X } from "lucide-react";
import { useEffect, useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { useGameStore } from "@/lib/game-store";
import { useOnlineStore } from "@/lib/online-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { getService } from "@/lib/online/service";
import { Friend, PresenceStatus, RoomSeat } from "@/lib/online/types";
import { Avatar } from "@/components/online/Avatar";
import { cn } from "@/lib/cn";
const STATUS_COLOR: Record<PresenceStatus, string> = {
online: "bg-teal-400",
offline: "bg-slate-500",
"in-game": "bg-gold-400",
};
// online first, then in-game, then offline
const statusRank = (s: PresenceStatus) => (s === "online" ? 0 : s === "in-game" ? 1 : 2);
export function RoomScreen() {
const { t } = useI18n();
const room = useOnlineStore((s) => s.room);
const friends = useOnlineStore((s) => s.friends);
const loadFriends = useOnlineStore((s) => s.loadFriends);
const setPartner = useOnlineStore((s) => s.setPartner);
const inviteToSeat = useOnlineStore((s) => s.inviteToSeat);
const addBot = useOnlineStore((s) => s.addBot);
const clearSeat = useOnlineStore((s) => s.clearSeat);
const startRoom = useOnlineStore((s) => s.startRoom);
const leaveRoom = useOnlineStore((s) => s.leaveRoom);
const newOnlineMatch = useGameStore((s) => s.newOnlineMatch);
const enterServerMatch = useGameStore((s) => s.enterServerMatch);
const goGame = useUIStore((s) => s.goGame);
const go = useUIStore((s) => s.go);
const [picker, setPicker] = useState<null | { seat: 1 | 2 | 3 }>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
loadFriends();
}, [loadFriends]);
// Live: when the host starts, the server sends matchFound to every human seat
// (host + accepted friends) → each device auto-enters the server-run game.
useEffect(() => {
const svc = getService();
if (!svc.live) return;
const unsub = svc.onMatchmaking((s) => {
if (s.phase === "ready") {
enterServerMatch(svc);
goGame("home");
}
});
return unsub;
}, [enterServerMatch, goGame]);
if (!room) return null;
const hasPending = room.seats.some((s) => s.kind === "invited");
const seat = (n: number) => room.seats.find((s) => s.seat === n)!;
const statusLabel = (s: PresenceStatus) =>
s === "online" ? t("friends.online") : s === "in-game" ? t("friends.inGame") : t("friends.offline");
const pick = async (friend: Friend) => {
if (!picker) return;
if (picker.seat === 2) await setPartner(friend.id);
else await inviteToSeat(picker.seat, friend.id);
setPicker(null);
};
const copyCode = async () => {
try {
await navigator.clipboard.writeText(room.code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* ignore */
}
};
const start = async () => {
if (hasPending) return; // never start while a friend's invite is still pending
if (getService().live) {
// Server runs the match; it pushes matchFound → the effect above enters it.
await startRoom();
return;
}
// Offline mock: build a client-run match from the (bot-filled) seats.
await startRoom();
const r = useOnlineStore.getState().room!;
const players = r.seats
.slice()
.sort((a, b) => a.seat - b.seat)
.map((s) => ({
displayName: s.player!.displayName,
avatar: s.player!.avatar,
level: s.player!.level,
}));
newOnlineMatch({ players, targetScore: r.targetScore, stake: r.stake, ranked: r.ranked });
goGame("home");
};
const leave = async () => {
await leaveRoom();
go("online");
};
return (
<ScreenShell hideNav>
<ScreenHeader
title={t("room.title")}
back="online"
right={
<button
onClick={copyCode}
className="glass rounded-full px-3 py-1.5 text-xs flex items-center gap-1.5 hover:bg-navy-800/80"
>
<Copy className="size-3.5 text-gold-400" />
<span className="tabular-nums tracking-wider">{copied ? t("common.copied") : room.code}</span>
</button>
}
/>
{/* teams: stacked on phones, side-by-side in landscape so all 4 seats fit */}
<div className="grid gap-x-4 landscape:grid-cols-2">
<div>
<h3 className="text-xs text-teal-300 font-bold mb-2">{t("team.us")}</h3>
<div className="grid grid-cols-2 gap-3">
<SeatCard seat={seat(0)} role="you" onInvite={() => {}} onBot={() => {}} onClear={() => {}} />
<SeatCard
seat={seat(2)}
role="partner"
onInvite={() => setPicker({ seat: 2 })}
onBot={() => addBot(2)}
onClear={() => clearSeat(2)}
/>
</div>
</div>
<div className="mt-5 landscape:mt-0">
<h3 className="text-xs text-rose-300 font-bold mb-2">{t("room.opponents")}</h3>
<div className="grid grid-cols-2 gap-3">
<SeatCard
seat={seat(1)}
role="opp"
onInvite={() => setPicker({ seat: 1 })}
onBot={() => addBot(1)}
onClear={() => clearSeat(1)}
/>
<SeatCard
seat={seat(3)}
role="opp"
onInvite={() => setPicker({ seat: 3 })}
onBot={() => addBot(3)}
onClear={() => clearSeat(3)}
/>
</div>
</div>
</div>
{hasPending && (
<p className="text-center text-[11px] text-gold-300/80 mt-5 -mb-2">{t("room.waitAccept")}</p>
)}
<div className="flex gap-3 mt-7">
<button onClick={leave} className="glass rounded-xl px-5 py-3 text-cream/70 hover:text-cream">
{t("room.leave")}
</button>
<button
onClick={start}
disabled={hasPending}
className="btn-gold flex-1 rounded-xl py-3 text-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("room.start")}
</button>
</div>
{/* friend picker */}
<AnimatePresence>
{picker && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setPicker(null)}
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-navy-950/80 backdrop-blur-sm p-4"
>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 40, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
className="panel rounded-3xl p-5 w-full max-w-sm max-h-[70vh] overflow-y-auto"
>
<h3 className="text-lg font-black gold-text mb-1">{t("room.pickFriend")}</h3>
<p className="text-[11px] text-cream/45 mb-3">{t("room.inviteHint")}</p>
<div className="space-y-2">
{friends.length === 0 && (
<p className="text-center text-cream/40 text-sm py-8">{t("friends.empty")}</p>
)}
{[...friends]
.sort((a, b) => statusRank(a.status) - statusRank(b.status))
.map((f) => {
const inGame = f.status === "in-game";
return (
<button
key={f.id}
onClick={() => pick(f)}
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">
<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>
<span className="flex-1 min-w-0">
<span className="block text-sm font-semibold text-cream truncate">{f.displayName}</span>
<span className={cn("block text-[10px]", inGame ? "text-gold-300" : "text-cream/45")}>
{inGame ? `🎮 ${t("friends.inGame")}` : statusLabel(f.status)} · {t("common.level")} {f.level}
</span>
</span>
<span className="text-[10px] font-bold text-teal-300 shrink-0 inline-flex items-center gap-1">
<UserPlus className="size-3.5" />
{t("room.invite")}
</span>
</button>
);
})}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</ScreenShell>
);
}
function SeatCard({
seat,
role,
onInvite,
onBot,
onClear,
}: {
seat: RoomSeat;
role: "you" | "partner" | "opp";
onInvite: () => void;
onBot: () => void;
onClear: () => void;
}) {
const { t } = useI18n();
const filled = seat.kind !== "empty";
const label =
role === "you" ? t("seat.you") : role === "partner" ? t("room.partner") : t("room.opponents");
return (
<div
className={cn(
"rounded-2xl p-4 min-h-32 flex flex-col items-center justify-center gap-2 border",
role === "opp" ? "border-rose-500/25 bg-rose-950/20" : "border-teal-500/25 bg-teal-950/20"
)}
>
<span className="text-[10px] text-cream/50">{label}</span>
{filled ? (
<>
<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">
{seat.player?.displayName}
{seat.kind === "bot" && <span className="text-cream/40"> 🤖</span>}
</span>
{seat.kind === "invited" ? (
<div className="flex flex-col items-center gap-1">
<span className="text-[10px] text-gold-300 animate-pulse">{t("room.waiting")}</span>
<button
onClick={onClear}
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold text-rose-300/80 hover:text-rose-200 hover:bg-rose-500/10 transition"
>
<X className="size-3" />
{t("room.cancelInvite")}
</button>
</div>
) : (
role !== "you" && (
<button
onClick={onClear}
aria-label={t("friends.remove")}
className="grid place-items-center min-h-9 min-w-9 rounded-full text-rose-300/70 hover:text-rose-300 hover:bg-rose-500/10 transition"
>
<X className="size-4" />
</button>
)
)}
</>
) : (
<div className="flex flex-col gap-2 w-full">
<button
onClick={onInvite}
className="rounded-xl bg-navy-900/70 gold-border py-2.5 text-xs text-cream/80 hover:bg-navy-800 active:scale-[0.98] transition flex items-center justify-center gap-1.5"
>
<UserPlus className="size-4" />
{t("room.invite")}
</button>
<button
onClick={onBot}
className="rounded-xl bg-navy-900/70 gold-border py-2.5 text-xs text-cream/80 hover:bg-navy-800 active:scale-[0.98] transition flex items-center justify-center gap-1.5"
>
<Bot className="size-4" />
{t("room.addBot")}
</button>
</div>
)}
</div>
);
}