Turn timer + auto-play, disconnect/reconnect, cosmetics, queue & paid plan
- Turn timer (20s) for play/trump; system auto-plays a smart move on timeout - Disconnect handling (mock): wait-for-return countdown, system covers turns - Cosmetics: titles, card-back styles, custom profile-image upload, badges; pickers in Profile; shop sells card styles; reward modal shows new titles - Paid plan (pro): free players queue when server busy, pro skips; upgrade flow - OnlineService extended (upgradePlan, richer profile patch); mock implements queue + plans; gamification adds TITLES + CARD_STYLES Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Crown, LogOut } from "lucide-react";
|
||||
import { useGameStore } from "@/lib/game-store";
|
||||
import { Crown, LogOut, WifiOff } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TURN_MS, useGameStore } from "@/lib/game-store";
|
||||
import { legalMoves } from "@/lib/hokm/engine";
|
||||
import { sortHand } from "@/lib/hokm/deck";
|
||||
import {
|
||||
@@ -15,9 +16,22 @@ import {
|
||||
teamOf,
|
||||
} from "@/lib/hokm/types";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { cardStyleById } from "@/lib/online/gamification";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { PlayingCard } from "./PlayingCard";
|
||||
|
||||
function useCountdown(deadline: number | null) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (deadline == null) return;
|
||||
const id = setInterval(() => setNow(Date.now()), 250);
|
||||
return () => clearInterval(id);
|
||||
}, [deadline]);
|
||||
if (deadline == null) return null;
|
||||
return Math.max(0, Math.ceil((deadline - now) / 1000));
|
||||
}
|
||||
|
||||
export function GameTable({ onExit }: { onExit?: () => void } = {}) {
|
||||
const game = useGameStore((s) => s.game);
|
||||
const reset = useGameStore((s) => s.reset);
|
||||
@@ -73,6 +87,8 @@ export function GameTable({ onExit }: { onExit?: () => void } = {}) {
|
||||
|
||||
{/* Turn indicator */}
|
||||
<TurnIndicator />
|
||||
<TurnTimer />
|
||||
<DisconnectBanner />
|
||||
|
||||
{/* Overlays */}
|
||||
<AnimatePresence>
|
||||
@@ -220,6 +236,9 @@ function OpponentHand({
|
||||
horizontal?: boolean;
|
||||
}) {
|
||||
const count = useGameStore((s) => s.game.players[seat].hand.length);
|
||||
const styleId = useSessionStore((s) => s.profile?.cardStyle ?? "classic");
|
||||
const cs = cardStyleById(styleId);
|
||||
const back = { c1: cs.c1, c2: cs.c2, accent: cs.accent };
|
||||
const cards = Array.from({ length: count });
|
||||
return (
|
||||
<div
|
||||
@@ -234,7 +253,7 @@ function OpponentHand({
|
||||
key={i}
|
||||
style={horizontal ? { marginInlineStart: i === 0 ? 0 : -34 } : { marginTop: i === 0 ? 0 : -48 }}
|
||||
>
|
||||
<PlayingCard faceDown size="sm" />
|
||||
<PlayingCard faceDown size="sm" back={back} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -387,6 +406,68 @@ function TurnIndicator() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------- Turn timer ----------------------------- */
|
||||
|
||||
function TurnTimer() {
|
||||
const deadline = useGameStore((s) => s.turnDeadline);
|
||||
const phase = useGameStore((s) => s.game.phase);
|
||||
const secs = useCountdown(deadline);
|
||||
if (deadline == null || secs == null) return null;
|
||||
if (phase !== "playing" && phase !== "choosing-trump") return null;
|
||||
const pct = Math.max(0, Math.min(1, (deadline - Date.now()) / TURN_MS));
|
||||
const danger = secs <= 5;
|
||||
return (
|
||||
<div className="absolute bottom-[190px] left-1/2 -translate-x-1/2 z-30 w-40 text-center">
|
||||
<span
|
||||
className={cn(
|
||||
"block text-sm font-black tabular-nums mb-1",
|
||||
danger ? "text-rose-300 animate-pulse" : "text-gold-300"
|
||||
)}
|
||||
>
|
||||
{secs}
|
||||
</span>
|
||||
<div className="h-1.5 rounded-full bg-navy-900/70 overflow-hidden gold-border">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${pct * 100}%`,
|
||||
background: danger
|
||||
? "#fb7185"
|
||||
: "linear-gradient(90deg, var(--gold-500), var(--gold-300))",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisconnectBanner() {
|
||||
const seat = useGameStore((s) => s.disconnectedSeat);
|
||||
const deadline = useGameStore((s) => s.reconnectDeadline);
|
||||
const name = useGameStore((s) => (seat != null ? s.seatPlayers[seat]?.name : null));
|
||||
const secs = useCountdown(deadline);
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{seat != null && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-40"
|
||||
>
|
||||
<div className="glass rounded-2xl px-4 py-3 flex items-center gap-2.5 text-sm">
|
||||
<WifiOff className="size-5 text-rose-300 animate-pulse" />
|
||||
<span className="text-cream/90">
|
||||
{t("dc.waiting", { name: name ?? "", s: secs ?? 0 })}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------ Overlays ------------------------------ */
|
||||
|
||||
function Backdrop({ children }: { children: React.ReactNode }) {
|
||||
|
||||
Reference in New Issue
Block a user