Leaderboard avatar+level+XP bar; mobile table overlap fixes
Leaderboard: each row now shows the player avatar (photo or emoji) with a level badge ring and a progress-to-next-level bar (LeaderboardEntry gained levelProgress + avatarImage; mock fills real XP for you, random for others). Mobile table: the played-card pile now scales inward on narrow screens so it no longer overlaps the opponents' side stacks (trickScale by viewport); seat avatars render above the stacks (z-20) so the side player isn't hidden; side hands nudged to the edges + top hand raised slightly on phones. Verified: tsc + next build clean; web image rebuilt on :1500. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,9 @@ export function GameTable({
|
|||||||
const muted = !sfx && !music;
|
const muted = !sfx && !music;
|
||||||
|
|
||||||
const exit = onExit ?? reset;
|
const exit = onExit ?? reset;
|
||||||
|
const vw = useViewportWidth();
|
||||||
|
// Pull the played-card pile inward on narrow screens so it clears the side stacks.
|
||||||
|
const trickScale = vw < 360 ? 0.5 : vw < 460 ? 0.64 : 1;
|
||||||
const { phase, players, hakem, trump, turn, currentTrick } = game;
|
const { phase, players, hakem, trump, turn, currentTrick } = game;
|
||||||
|
|
||||||
const legalIds = new Set(
|
const legalIds = new Set(
|
||||||
@@ -149,12 +152,12 @@ export function GameTable({
|
|||||||
<SeatAvatar seat={3} className="absolute top-1/2 left-3 -translate-y-1/2" />
|
<SeatAvatar seat={3} className="absolute top-1/2 left-3 -translate-y-1/2" />
|
||||||
|
|
||||||
{/* opponents' face-down hands */}
|
{/* opponents' face-down hands */}
|
||||||
<OpponentHand seat={2} className="absolute top-20 left-1/2 -translate-x-1/2" horizontal />
|
<OpponentHand seat={2} className="absolute top-16 sm:top-20 left-1/2 -translate-x-1/2" horizontal />
|
||||||
<OpponentHand seat={1} className="absolute top-1/2 right-16 -translate-y-1/2" />
|
<OpponentHand seat={1} className="absolute top-1/2 right-14 sm:right-16 -translate-y-1/2" />
|
||||||
<OpponentHand seat={3} className="absolute top-1/2 left-16 -translate-y-1/2" />
|
<OpponentHand seat={3} className="absolute top-1/2 left-14 sm:left-16 -translate-y-1/2" />
|
||||||
|
|
||||||
{/* center trick area */}
|
{/* center trick area (offsets scale down on narrow screens) */}
|
||||||
<TrickArea trick={currentTrick} winner={game.lastTrickWinner} phase={phase} />
|
<TrickArea trick={currentTrick} winner={game.lastTrickWinner} phase={phase} scale={trickScale} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -273,7 +276,7 @@ function SeatAvatar({ seat, className }: { seat: Seat; className?: string }) {
|
|||||||
const name = sp?.name ?? player.name;
|
const name = sp?.name ?? player.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col items-center gap-1", className)}>
|
<div className={cn("z-20 flex flex-col items-center gap-1", className)}>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={
|
animate={
|
||||||
active
|
active
|
||||||
@@ -354,10 +357,12 @@ function TrickArea({
|
|||||||
trick,
|
trick,
|
||||||
winner,
|
winner,
|
||||||
phase,
|
phase,
|
||||||
|
scale = 1,
|
||||||
}: {
|
}: {
|
||||||
trick: { seat: Seat; card: Card }[];
|
trick: { seat: Seat; card: Card }[];
|
||||||
winner: Seat | null;
|
winner: Seat | null;
|
||||||
phase: string;
|
phase: string;
|
||||||
|
scale?: number;
|
||||||
}) {
|
}) {
|
||||||
const { front } = useCardSkins();
|
const { front } = useCardSkins();
|
||||||
return (
|
return (
|
||||||
@@ -365,7 +370,7 @@ function TrickArea({
|
|||||||
<div className="relative size-1 ">
|
<div className="relative size-1 ">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{trick.map((pc) => {
|
{trick.map((pc) => {
|
||||||
const off = TRICK_OFFSET[pc.seat];
|
const off = { x: TRICK_OFFSET[pc.seat].x * scale, y: TRICK_OFFSET[pc.seat].y * scale };
|
||||||
const enter = TRICK_ENTER[pc.seat];
|
const enter = TRICK_ENTER[pc.seat];
|
||||||
const isWinner =
|
const isWinner =
|
||||||
phase === "trick-complete" && winner === pc.seat;
|
phase === "trick-complete" && winner === pc.seat;
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
|
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
|
||||||
import { RankBadge } from "@/components/online/RankBadge";
|
import { RankBadge } from "@/components/online/RankBadge";
|
||||||
|
import { Avatar } from "@/components/online/Avatar";
|
||||||
import { useOnlineStore } from "@/lib/online-store";
|
import { useOnlineStore } from "@/lib/online-store";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { avatarEmoji } from "@/lib/online/types";
|
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
const MEDALS: Record<number, string> = { 1: "🥇", 2: "🥈", 3: "🥉" };
|
const MEDALS: Record<number, string> = { 1: "🥇", 2: "🥈", 3: "🥉" };
|
||||||
@@ -27,25 +27,45 @@ export function LeaderboardScreen() {
|
|||||||
<div
|
<div
|
||||||
key={e.id}
|
key={e.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl p-2.5 flex items-center gap-3 border",
|
"rounded-xl p-2.5 flex items-center gap-2.5 border",
|
||||||
e.isYou
|
e.isYou
|
||||||
? "bg-gold-500/15 border-gold-500/50"
|
? "bg-gold-500/15 border-gold-500/50"
|
||||||
: "glass border-transparent"
|
: "glass border-transparent"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="w-7 text-center font-black text-cream/70 tabular-nums">
|
<span className="w-6 text-center font-black text-cream/70 tabular-nums shrink-0">
|
||||||
{MEDALS[e.rank] ?? e.rank}
|
{MEDALS[e.rank] ?? e.rank}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-2xl">{avatarEmoji(e.avatar)}</span>
|
|
||||||
|
{/* avatar with a level ring badge */}
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<div className="size-10 rounded-xl bg-navy-900 gold-border flex items-center justify-center overflow-hidden">
|
||||||
|
<Avatar id={e.avatar} image={e.avatarImage} size={e.avatarImage ? 40 : 26} />
|
||||||
|
</div>
|
||||||
|
<span className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 rounded-full bg-navy-950 gold-border px-1.5 text-[9px] font-black text-gold-300 leading-tight">
|
||||||
|
{e.level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-semibold text-cream truncate">
|
<div className="text-sm font-semibold text-cream truncate">
|
||||||
{e.displayName}
|
{e.displayName}
|
||||||
{e.isYou && <span className="text-gold-300"> ({t("seat.you")})</span>}
|
{e.isYou && <span className="text-gold-300"> ({t("seat.you")})</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-cream/45">
|
{/* progress to next level */}
|
||||||
{t("common.level")} {e.level}
|
<div className="mt-1 flex items-center gap-1.5">
|
||||||
|
<div className="h-1.5 flex-1 rounded-full bg-navy-900 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-gold-500 to-gold-300"
|
||||||
|
style={{ width: `${Math.round(Math.min(1, Math.max(0, e.levelProgress)) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] text-cream/40 tabular-nums shrink-0">
|
||||||
|
{t("common.level")} {e.level + 1}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RankBadge rating={e.rating} showRating />
|
<RankBadge rating={e.rating} showRating />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
STICKER_PACKS,
|
STICKER_PACKS,
|
||||||
applyMatchResult,
|
applyMatchResult,
|
||||||
dailyRewardFor,
|
dailyRewardFor,
|
||||||
|
xpNeededForLevel,
|
||||||
} from "./gamification";
|
} from "./gamification";
|
||||||
import {
|
import {
|
||||||
CreateRoomOptions,
|
CreateRoomOptions,
|
||||||
@@ -820,14 +821,17 @@ export class MockOnlineService implements OnlineService {
|
|||||||
avatar: pick(AVATARS).id,
|
avatar: pick(AVATARS).id,
|
||||||
level: randInt(5, 60),
|
level: randInt(5, 60),
|
||||||
rating: randInt(1000, 2200),
|
rating: randInt(1000, 2200),
|
||||||
|
levelProgress: Math.random(),
|
||||||
isYou: false,
|
isYou: false,
|
||||||
}));
|
}));
|
||||||
const you = {
|
const you = {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
displayName: p.displayName,
|
displayName: p.displayName,
|
||||||
avatar: p.avatar,
|
avatar: p.avatar,
|
||||||
|
avatarImage: p.avatarImage,
|
||||||
level: p.level,
|
level: p.level,
|
||||||
rating: p.rating,
|
rating: p.rating,
|
||||||
|
levelProgress: Math.min(1, p.xp / xpNeededForLevel(p.level)),
|
||||||
isYou: true,
|
isYou: true,
|
||||||
};
|
};
|
||||||
const all = [...others, you].sort((a, b) => b.rating - a.rating);
|
const all = [...others, you].sort((a, b) => b.rating - a.rating);
|
||||||
|
|||||||
@@ -364,8 +364,11 @@ export interface LeaderboardEntry {
|
|||||||
id: string;
|
id: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
avatarImage?: string; // custom uploaded photo (overrides avatar)
|
||||||
level: number;
|
level: number;
|
||||||
rating: number;
|
rating: number;
|
||||||
|
/** progress 0..1 toward the next level (for the XP bar) */
|
||||||
|
levelProgress: number;
|
||||||
isYou: boolean;
|
isYou: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user