Files
HokmPlay/src/components/screens/GameScreen.tsx
T
soroush.asadi 03dfbe1e67
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 7m38s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 1s
Match intro "players joining" loading screen + i18n fix; checkpoint
- MatchIntroOverlay: UNO-style pre-game reveal — the 4 seats animate into the
  table (with "?" placeholders until each player's data streams in for live
  matches), a 3-2-1-GO countdown, then the table shows. Wired via game-store
  matchIntroPending/consumeIntro, rendered online-only in GameScreen.
- Fix: intro.found / intro.getReady / intro.go existed only in the Persian dict;
  added the English strings (would have shown raw keys to EN users).
- Checkpoint of the in-progress UI/social batch (CoinsPill, shop titles section,
  friend-request rate limit, etc.) — all green.

Verified: tsc + next build + scripts/sim.ts + dotnet build server/Hokm.slnx all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:58:54 +03:30

197 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { GameTable } from "@/components/GameTable";
import { PostMatchRewardsModal } from "@/components/online/PostMatchRewardsModal";
import { MatchIntroOverlay } from "@/components/online/MatchIntroOverlay";
import { useGameStore } from "@/lib/game-store";
import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { getService } from "@/lib/online/service";
import { pushNotification } from "@/lib/notification-store";
import { celebrate } from "@/lib/celebration-store";
import { MatchSummary, RewardResult } from "@/lib/online/types";
export function GameScreen() {
const game = useGameStore((s) => s.game);
const mode = useGameStore((s) => s.mode);
const live = useGameStore((s) => s.live);
const serverReward = useGameStore((s) => s.serverReward);
const tally = useGameStore((s) => s.tally);
const meta = useGameStore((s) => s.matchMeta);
const reset = useGameStore((s) => s.reset);
const forfeited = useGameStore((s) => s.forfeited);
const forfeitRequest = useGameStore((s) => s.forfeitRequest);
const respondForfeit = useGameStore((s) => s.respondForfeit);
const introPending = useGameStore((s) => s.matchIntroPending);
const consumeIntro = useGameStore((s) => s.consumeIntro);
const returnTo = useUIStore((s) => s.returnTo);
const go = useUIStore((s) => s.go);
const refreshProfile = useSessionStore((s) => s.refreshProfile);
const { t } = useI18n();
const [reward, setReward] = useState<RewardResult | null>(null);
const submitted = useRef(false);
// Leaving the table (back button, browser/hardware back) keeps the match alive
// & resumable — pause is handled by the unmount effect below. A finished match
// is torn down instead.
const exit = () => {
if (useGameStore.getState().game.phase === "match-over") reset();
go(returnTo);
};
// Match truly finished (reward dismissed): tear the match down.
const finish = () => {
reset();
go(returnTo);
};
// Any way the table unmounts (exit button, hardware/browser back), keep an
// in-progress match alive and resumable instead of letting it run unattended.
useEffect(() => {
return () => {
const gs = useGameStore.getState();
if (gs.started && gs.game.phase !== "match-over") gs.minimize();
};
}, []);
const notifyAchievements = (r: RewardResult) => {
for (const a of r.newAchievements)
pushNotification({
kind: "achievement",
titleFa: "دستاورد جدید",
titleEn: "New achievement",
bodyFa: a.nameFa,
bodyEn: a.nameEn,
icon: a.icon,
});
};
// Splashy celebration overlay for every achievement / level-up earned in the
// match — fired once the post-match reward summary is dismissed so each unlock
// gets its own animated moment (queued by the celebration store).
const celebrateRewards = (r: RewardResult | null) => {
if (!r) return;
if (r.leveledUp) {
celebrate({
variant: "xp",
icon: "🎚️",
title: t("reward.levelUp"),
levelBefore: r.levelBefore,
levelAfter: r.levelAfter,
});
}
for (const a of r.newAchievements) {
celebrate({
variant: "purchase",
icon: a.icon,
title: t("reward.newAchievement"),
achievements: [a],
});
}
};
// Client-run games (private rooms / casual): submit the result to the server.
useEffect(() => {
if (!live && mode === "online" && game.phase === "match-over" && !submitted.current) {
submitted.current = true;
const summary: MatchSummary = {
ranked: meta.ranked,
stake: meta.stake,
won: game.matchWinner === 0,
kotFor: tally.kotFor,
kotAgainst: tally.kotAgainst,
tricksWon: tally.tricksTeam0,
rounds: game.matchScore[0] + game.matchScore[1],
trump: game.trump,
// shutout = you won and the opponent never scored a round (e.g. 70)
shutout: !forfeited && game.matchWinner === 0 && game.matchScore[1] === 0,
hakemRounds: tally.hakemRounds,
roundsWon: game.matchScore[0],
forfeit: forfeited,
};
getService()
.submitMatchResult(summary)
.then((r) => {
setReward(r);
refreshProfile();
notifyAchievements(r);
});
}
}, [live, mode, game.phase, game.matchWinner, game.matchScore, game.trump, meta, tally, forfeited, refreshProfile]);
// Server-run ranked games: the reward arrives via the hub.
useEffect(() => {
if (live && serverReward && !submitted.current) {
submitted.current = true;
setReward(serverReward);
refreshProfile();
notifyAchievements(serverReward);
}
}, [live, serverReward, refreshProfile]);
const canForfeit = game.phase !== "match-over" && !forfeited;
return (
<>
<GameTable
onExit={exit}
onForfeit={canForfeit ? () => useGameStore.getState().forfeit() : undefined}
/>
{/* UNO-style "players joining the table" intro (online matches, once) */}
<AnimatePresence>
{introPending && mode === "online" && (
<MatchIntroOverlay onDone={consumeIntro} />
)}
</AnimatePresence>
{/* teammate asked to forfeit — confirm or decline */}
{forfeitRequest && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="fixed inset-0 z-[70] flex items-center justify-center bg-navy-950/80 backdrop-blur-sm p-5"
>
<motion.div
initial={{ scale: 0.85, y: 20 }}
animate={{ scale: 1, y: 0 }}
className="glass rounded-3xl p-6 w-full max-w-xs text-center"
>
<div className="text-4xl mb-2">🏳</div>
<h2 className="gold-text text-xl font-black">{t("forfeit.title")}</h2>
<p className="text-cream/70 text-sm mt-2">
{t("forfeit.teammateAsks").replace("{name}", forfeitRequest.byName)}
</p>
<p className="text-cream/45 text-xs mt-1">{t("forfeit.rule")}</p>
<div className="flex gap-2 mt-5">
<button onClick={() => respondForfeit(true)} className="flex-1 rounded-xl py-3 bg-rose-500/80 text-white font-bold">
{t("forfeit.confirm")}
</button>
<button onClick={() => respondForfeit(false)} className="flex-1 btn-gold rounded-xl py-3">
{t("forfeit.keepPlaying")}
</button>
</div>
</motion.div>
</motion.div>
)}
{reward && (
<PostMatchRewardsModal
reward={reward}
won={game.matchWinner === 0}
onClose={() => {
const r = reward;
setReward(null);
celebrateRewards(r);
finish();
}}
/>
)}
</>
);
}