Add reactions (Sheklak), fix hakem card visibility during trump choice
- Reactions/emotes in-game: tray of owned emojis + animated per-seat bubbles (feature named "شکلک / Sheklak"). Packs: starter (free), champion/legend (earned by rating/wins), emotions/taunt (purchasable in shop) - OnlineService.sendReaction/onReaction; mock echoes you + random opponents - Fix: human hakem's 5 cards were blurred behind the trump-chooser overlay — raise hand to z-50 during choosing-trump so cards stay readable Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Crown, LogOut, WifiOff } from "lucide-react";
|
||||
import { Crown, LogOut, SmilePlus, WifiOff } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TURN_MS, useGameStore } from "@/lib/game-store";
|
||||
import { legalMoves } from "@/lib/hokm/engine";
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
} from "@/lib/hokm/types";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { cardStyleById } from "@/lib/online/gamification";
|
||||
import { cardStyleById, ownedReactions } from "@/lib/online/gamification";
|
||||
import { getService } from "@/lib/online/service";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { PlayingCard } from "./PlayingCard";
|
||||
|
||||
@@ -89,6 +90,7 @@ export function GameTable({ onExit }: { onExit?: () => void } = {}) {
|
||||
<TurnIndicator />
|
||||
<TurnTimer />
|
||||
<DisconnectBanner />
|
||||
<Reactions />
|
||||
|
||||
{/* Overlays */}
|
||||
<AnimatePresence>
|
||||
@@ -332,10 +334,17 @@ function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
|
||||
|
||||
const sorted = sortHand(hand);
|
||||
const myTurn = phase === "playing" && turn === 0;
|
||||
// While choosing trump the hakem must see their cards above the chooser overlay.
|
||||
const choosing = phase === "choosing-trump";
|
||||
const n = sorted.length;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0 inset-x-0 z-20 flex justify-center pb-3 pointer-events-none">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-0 inset-x-0 flex justify-center pb-3 pointer-events-none",
|
||||
choosing ? "z-50" : "z-20"
|
||||
)}
|
||||
>
|
||||
<div className="relative flex items-end justify-center pointer-events-auto">
|
||||
{sorted.map((card, i) => {
|
||||
const playable = myTurn && legalIds.has(card.id);
|
||||
@@ -468,6 +477,91 @@ function DisconnectBanner() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------- Reactions ------------------------------ */
|
||||
|
||||
const REACTION_POS: Record<number, string> = {
|
||||
0: "bottom-44 left-1/2 -translate-x-1/2",
|
||||
1: "top-1/2 right-20 -translate-y-1/2",
|
||||
2: "top-28 left-1/2 -translate-x-1/2",
|
||||
3: "top-1/2 left-20 -translate-y-1/2",
|
||||
};
|
||||
|
||||
interface Bubble {
|
||||
id: string;
|
||||
seat: number;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
function Reactions() {
|
||||
const profile = useSessionStore((s) => s.profile);
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [bubbles, setBubbles] = useState<Bubble[]>([]);
|
||||
const list = profile ? ownedReactions(profile) : [];
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = getService().onReaction((seat, emoji) => {
|
||||
const id = `${seat}-${Date.now()}-${Math.random()}`;
|
||||
setBubbles((b) => [...b, { id, seat, emoji }]);
|
||||
setTimeout(() => setBubbles((b) => b.filter((x) => x.id !== id)), 2600);
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
const send = (emoji: string) => {
|
||||
getService().sendReaction(emoji);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* floating bubbles */}
|
||||
{bubbles.map((b) => (
|
||||
<motion.div
|
||||
key={b.id}
|
||||
initial={{ opacity: 0, scale: 0.4, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: -18 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={cn("absolute z-40 pointer-events-none", REACTION_POS[b.seat])}
|
||||
>
|
||||
<span className="text-4xl drop-shadow-lg">{b.emoji}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* tray */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 12, scale: 0.95 }}
|
||||
className="absolute bottom-20 ltr:right-4 rtl:left-4 z-50 glass rounded-2xl p-2 grid grid-cols-5 gap-1 max-w-[260px]"
|
||||
>
|
||||
{list.map((emoji, i) => (
|
||||
<button
|
||||
key={`${emoji}-${i}`}
|
||||
onClick={() => send(emoji)}
|
||||
className="size-10 rounded-xl hover:bg-navy-800 transition flex items-center justify-center text-2xl"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* button */}
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="absolute bottom-4 ltr:right-4 rtl:left-4 z-50 glass rounded-full p-3 hover:bg-navy-800 transition"
|
||||
title={t("reactions.title")}
|
||||
>
|
||||
<SmilePlus className="size-5 text-gold-400" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------ Overlays ------------------------------ */
|
||||
|
||||
function Backdrop({ children }: { children: React.ReactNode }) {
|
||||
|
||||
Reference in New Issue
Block a user