diff --git a/AGENTS.md b/AGENTS.md index 626e13c..b4454f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **36-character-flower** (3072 symbols, 6072 relationships, 262 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **36-character-flower** (3087 symbols, 6103 relationships, 263 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CLAUDE.md b/CLAUDE.md index 626e13c..b4454f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **36-character-flower** (3072 symbols, 6072 relationships, 262 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **36-character-flower** (3087 symbols, 6103 relationships, 263 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/docs/code-review-2026-06-05.md b/docs/code-review-2026-06-05.md new file mode 100644 index 0000000..1a16e31 --- /dev/null +++ b/docs/code-review-2026-06-05.md @@ -0,0 +1,53 @@ +# 36 字花前端全局代码审查报告 + +审查日期:2026-06-05 + +## 审查范围 + +- 全局代码静态审查 +- GitNexus 索引刷新与调用影响分析 +- `pnpm lint` +- `pnpm build` +- 本地桌面端与移动端页面渲染检查 +- DOM 文本溢出与页面横向滚动检查 + +## 验证结果 + +| 检查项 | 结果 | 备注 | +|---|---|---| +| GitNexus 索引 | 通过 | 已重新运行 `npx gitnexus analyze` | +| `pnpm lint` | 通过 | Biome 检查通过 | +| `pnpm build` | 通过 | 构建成功,但存在较大资源与 chunk | +| 桌面端页面渲染 | 通过,有样式风险 | 页面可渲染,logo 与部分数字文本存在裁切/拥挤风险 | +| 移动端页面渲染 | 通过,有样式风险 | 无页面级横向滚动,部分文本高度溢出 | +| 自动化测试 | 未发现 | 仓库内未发现有效 `test/spec` 测试文件 | + +## 问题清单 + +| 优先级 | 类型 | 位置 | 问题 | 影响 | 建议 | +|---|---|---|---|---|---| +| P0 | 安全 | `src/routes/$lang/ws-test.tsx` | `/ws-test` 测试页硬编码完整 WebSocket URL、`token`、`auth_token`,并且已被打进生产 bundle | 凭证泄露,任何人都可能从静态资源中提取连接信息 | 立刻删除该路由,或仅在开发环境注册;凭证全部移出前端 | +| P0 | 安全 | `.env.production`, `.env.development`, `.env.example`, `src/lib/api/api-client.ts` | `VITE_AUTH_TOKEN_SECRET` 作为前端环境变量参与签名 | `VITE_` 变量会暴露到浏览器,不能作为密钥使用 | 签名逻辑迁到服务端;前端只保存短期 token | +| P1 | 功能风险 | `src/lib/auth/auth-session.ts` | 登出或 token 失效时调用 `localStorage.clear()`、`sessionStorage.clear()` | 会清掉同源下非本项目或非登录相关数据;GitNexus 影响面为 CRITICAL | 改成只删除明确命名的 auth/session key | +| P1 | 性能 | `src/components/app-boot-resource-gate.tsx` | 首屏前 eager 导入并预加载所有图片资源 | 大图会阻塞应用显示,移动端首屏风险高 | 只预加载首屏必要资源,其余按页面、弹窗或交互懒加载 | +| P1 | 性能 | build 输出资源 | `pc-big-reward.json` 约 3MB,`pc-streak.webp` 约 3.5MB,`hall-music.mp3` 约 2.5MB | 首次加载体积偏大,弱网下体验差 | 压缩动效、图片、音频;奖励弹窗和音频按需加载 | +| P2 | 性能 | `src/features/game/audio/global-audio-controller.tsx` | 初始化时为所有音频创建 `Audio` 且设置 `preload='auto'` | 即使用户未开启声音,也可能提前拉取音频 | 用户解锁或启用声音后再加载,或改为 `preload='none'` | +| P2 | 架构/体积 | `src/hooks/use-auto-hosting-runner.ts` | 自动托管 chunk gzip 后仍约 341KB,疑似从 `@/api` barrel 引入过多内容 | PC/移动入口都会受影响 | 检查 `@/api` 导出,改为直接 import 具体 API,拆分 runner 依赖 | +| P2 | 功能一致性 | `src/hooks/use-game-control-vm.ts`, `src/hooks/use-auto-hosting-runner.ts` | 手动下注默认所有号码共用第一项金额;自动托管按金额/玩法分组 | 当前 UI 同步筹码所以暂不一定出错,但未来混合筹码或服务端回填时容易不一致 | 明确业务约束:要么禁止混合金额,要么手动下注也按组提交 | +| P2 | 样式 | `src/style/index.css` | 全局 `design-unit` 基于 `vw`,字体随视口宽度缩放 | 移动/桌面检查发现多处数字文本高度溢出,有裁切风险 | 字体使用固定/rem + 响应式断点,不用 viewport 直接缩放正文 | +| P3 | 样式 | `src/features/game/components/desktop/desktop-animal.tsx`, `src/features/game/components/mobile/mobile-animal.tsx` | 动物格数字区域使用 `overflow-hidden` 与 `leading-none`,DOM 检查有文本裁切 | 小屏或字体渲染差异下数字可能被切顶/切底 | 放宽容器高度/line-height,去掉不必要的隐藏裁切 | +| P3 | 样式 | `src/features/game/components/desktop/desktop-header.tsx` | 桌面截图里 logo 文案视觉上有重叠/裁切感 | 品牌区观感不稳 | 检查 logo 素材比例和容器宽高,必要时换成未裁切素材 | +| P3 | 清理 | `src/hooks/use-topup-vm.ts` | `useTopupVm` 空实现且未被引用 | 死代码增加维护噪音 | 删除或补齐为真实 ViewModel | +| P3 | 文档 | `README.md` | README 仍是 React SPA template/empty scaffold 描述 | 项目交接信息不准确 | 更新为当前项目启动、构建、环境变量、部署说明 | + +## 优先处理建议 + +1. 先处理 P0 安全问题:删除 `/ws-test` 生产暴露入口,移除前端密钥。 +2. 再处理 P1 功能与首屏性能问题:避免全量清 storage,拆掉全资源阻塞预加载。 +3. 然后处理 P2 的体积、音频加载和下注模型一致性问题。 +4. 最后做 P3 样式细节、死代码和文档维护。 + +## 备注 + +- 本次审查未修改业务代码。 +- 仓库目前未发现有效自动化测试文件,后续修复 P0/P1 后建议补充登录态清理、下注提交、首屏渲染和资源加载相关测试。 diff --git a/index.html b/index.html index 3ea8589..406f914 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,20 @@ + + const BOOT_MIN_VISIBLE_MS = 900 -const particleLayout = Array.from({ length: 36 }, (_, index) => ({ - delayMs: (index % 9) * 180, - durationMs: 3200 + (index % 7) * 260, - left: `${(index * 17 + 9) % 100}%`, - size: 2 + (index % 4), - top: `${(index * 29 + 13) % 100}%`, -})) +const PC_LOADING_BG_URL = '/loading/pc-loading-bg.webp' +const MOBILE_LOADING_BG_URL = '/loading/mobile-loading-bg.webp' function preloadImage(url: string) { return new Promise((resolve) => { @@ -100,49 +95,34 @@ function AppLoadingOverlay({ progress }: { progress: number }) { aria-label="Loading" className="fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-[#020913] text-[#D5FBFF]" > -
-
-
+ + + + +
-
- {particleLayout.map((particle) => ( - - ))} -
- -
-
-
-
-
- - {progress}% - -
- -
+
+
-
- {t('commonUi.boot.loading')} +
+ {progress}% + {t('commonUi.boot.loading')}
-
+
{t('commonUi.boot.syncing')}
diff --git a/src/components/message-broadcast.tsx b/src/components/message-broadcast.tsx new file mode 100644 index 0000000..3712acb --- /dev/null +++ b/src/components/message-broadcast.tsx @@ -0,0 +1,167 @@ +import { AnimatePresence, motion, useReducedMotion } from 'motion/react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import broadcast from '@/assets/system/broadcast.webp' +import { SmartImage } from '@/components/smart-image.tsx' +import { cn } from '@/lib/utils.ts' +import { useGameSessionStore } from '@/store/game' +import type { JackpotBroadcastItem } from '@/type' + +const winAmountFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 6, +}) +const MESSAGE_BROADCAST_ANIMATION_MS = 28_000 + +function formatWinAmount(value: string) { + const amount = Number(value) + + return Number.isFinite(amount) ? winAmountFormatter.format(amount) : value +} + +type MessageBroadcastProps = { + className?: string +} + +export function MessageBroadcast({ className }: MessageBroadcastProps) { + const { t } = useTranslation() + const prefersReducedMotion = useReducedMotion() + const jackpotBroadcasts = useGameSessionStore( + (state) => state.jackpotBroadcasts, + ) + const shownBroadcastIdsRef = useRef(new Set()) + const queuedBroadcastIdsRef = useRef(new Set()) + const [queue, setQueue] = useState([]) + const [activeBroadcast, setActiveBroadcast] = + useState(null) + const activeBroadcastLabel = useMemo(() => { + if (!activeBroadcast) { + return '' + } + + return t('game.jackpotBroadcast.ariaMessage', { + amount: formatWinAmount(activeBroadcast.totalWin), + nickname: activeBroadcast.nickname, + streak: activeBroadcast.currentStreak, + }) + }, [activeBroadcast, t]) + + useEffect(() => { + const nextBroadcasts = jackpotBroadcasts.filter((item) => { + if ( + shownBroadcastIdsRef.current.has(item.id) || + queuedBroadcastIdsRef.current.has(item.id) || + activeBroadcast?.id === item.id + ) { + return false + } + + return true + }) + + if (nextBroadcasts.length === 0) { + return + } + + for (const item of nextBroadcasts) { + queuedBroadcastIdsRef.current.add(item.id) + } + + setQueue((currentQueue) => [...currentQueue, ...nextBroadcasts]) + }, [activeBroadcast?.id, jackpotBroadcasts]) + + useEffect(() => { + if (activeBroadcast || queue.length === 0) { + return + } + + const [nextBroadcast, ...remainingQueue] = queue + + queuedBroadcastIdsRef.current.delete(nextBroadcast.id) + shownBroadcastIdsRef.current.add(nextBroadcast.id) + setActiveBroadcast(nextBroadcast) + setQueue(remainingQueue) + }, [activeBroadcast, queue]) + + useEffect(() => { + if (!activeBroadcast) { + return + } + + const timerId = window.setTimeout(() => { + setActiveBroadcast((currentBroadcast) => + currentBroadcast?.id === activeBroadcast.id ? null : currentBroadcast, + ) + }, MESSAGE_BROADCAST_ANIMATION_MS) + + return () => { + window.clearTimeout(timerId) + } + }, [activeBroadcast]) + + return ( +
+ +
+ + {activeBroadcast ? ( + + + {t('game.jackpotBroadcast.prefix')} + + + {activeBroadcast.nickname} + + + {t('game.jackpotBroadcast.separator')} + + + {t('game.jackpotBroadcast.streak', { + count: activeBroadcast.currentStreak, + })} + + + {t('game.jackpotBroadcast.winAction')} + + + {formatWinAmount(activeBroadcast.totalWin)} + + + ) : null} + +
+
+ ) +} diff --git a/src/features/game/components/desktop/desktop-animal-overlay.tsx b/src/features/game/components/desktop/desktop-animal-overlay.tsx index 7a8ef63..00e651c 100644 --- a/src/features/game/components/desktop/desktop-animal-overlay.tsx +++ b/src/features/game/components/desktop/desktop-animal-overlay.tsx @@ -28,6 +28,7 @@ import type { BetSelection, RewardAnimationType } from '@/type' const REWARD_OVERLAY_FADE_OUT_MS = 300 const REWARD_CHILDREN_FADE_IN_MS = 2_000 const REWARD_CHILDREN_VISIBLE_MS = 1_000 +const SETTLEMENT_OVERLAY_FADE_OUT_MS = 300 type RewardChildrenStage = 'hidden' | 'visible' | 'exiting' type StopBetItem = { @@ -175,6 +176,79 @@ function StopBetSummary({ ) } +function NoRewardSettlement({ + betItems, + message, + selectedLabel, + resultLabel, + resultNumber, +}: { + betItems: StopBetItem[] + message: string + selectedLabel: string + resultLabel: string + resultNumber: number +}) { + const winningImageUrl = FLOWER_IMAGE_BY_ID[resultNumber]?.animalUrl ?? '' + + return ( +
+
+ {message} +
+ +
+
+
+ {resultLabel} +
+
+ {winningImageUrl ? ( + + ) : null} +
+
+ {String(resultNumber).padStart(2, '0')} +
+
+ +
+
+ {selectedLabel} +
+
+ {betItems.map((item) => ( +
+
+ {item.imageUrl ? ( + + ) : null} +
+
+ {String(item.cellId).padStart(2, '0')} +
+
+ ))} +
+
+
+
+ ) +} + export function DesktopAnimalOverlay({ showStopOverlay, }: DesktopAnimalOverlayProps) { @@ -197,6 +271,9 @@ export function DesktopAnimalOverlay({ (state) => state.revealAnimation.rewardType, ) const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase) + const revealWinningCellId = useGameRoundStore( + (state) => state.revealAnimation.winningCellId, + ) const rewardAmount = useGameRoundStore( (state) => state.revealAnimation.rewardAmount, ) @@ -212,6 +289,13 @@ export function DesktopAnimalOverlay({ useState('hidden') const [displayRewardAmount, setDisplayRewardAmount] = useState('0') const [hasRenderedReward, setHasRenderedReward] = useState(false) + const [activeNoRewardKey, setActiveNoRewardKey] = useState( + null, + ) + const [dismissedNoRewardKey, setDismissedNoRewardKey] = useState< + string | null + >(null) + const [isNoRewardFadingOut, setIsNoRewardFadingOut] = useState(false) const stopImageSrc = i18n.resolvedLanguage?.startsWith('zh') ? zhStopImage : enStopImage @@ -235,10 +319,30 @@ export function DesktopAnimalOverlay({ revealPhase === 'result' && rewardType !== 'none' && rewardSource !== null const overlayAnimationKey = `${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}` const childTimelineKey = shouldRenderReward ? overlayAnimationKey : 'closed' + const hasWinningSelection = + revealWinningCellId !== null && + recentSuccessfulSelections.some( + (selection) => selection.cellId === revealWinningCellId, + ) + const noRewardKey = + roundId && revealWinningCellId !== null + ? `${roundId}:${revealWinningCellId}` + : null + const shouldRenderNoReward = + revealPhase === 'result' && + rewardType === 'none' && + noRewardKey !== null && + lastBetPeriodNo === roundId && + recentSuccessfulSelections.length > 0 && + !hasWinningSelection && + activeNoRewardKey === noRewardKey useEffect(() => { if (revealPhase !== 'result') { setHasRenderedReward(false) + setActiveNoRewardKey(null) + setDismissedNoRewardKey(null) + setIsNoRewardFadingOut(false) return } @@ -247,6 +351,54 @@ export function DesktopAnimalOverlay({ } }, [revealPhase, shouldRenderReward]) + useEffect(() => { + if ( + revealPhase !== 'result' || + rewardType !== 'none' || + noRewardKey === null || + lastBetPeriodNo !== roundId || + recentSuccessfulSelections.length === 0 || + hasWinningSelection || + activeNoRewardKey === noRewardKey || + dismissedNoRewardKey === noRewardKey + ) { + return + } + + setActiveNoRewardKey(noRewardKey) + setIsNoRewardFadingOut(false) + }, [ + activeNoRewardKey, + dismissedNoRewardKey, + hasWinningSelection, + lastBetPeriodNo, + noRewardKey, + recentSuccessfulSelections.length, + revealPhase, + rewardType, + roundId, + ]) + + useEffect(() => { + if (!activeNoRewardKey) { + return + } + + const fadeTimerId = window.setTimeout(() => { + setIsNoRewardFadingOut(true) + }, REWARD_OVERLAY_DURATION_MS) + const clearTimerId = window.setTimeout(() => { + setDismissedNoRewardKey(activeNoRewardKey) + setActiveNoRewardKey(null) + setIsNoRewardFadingOut(false) + }, REWARD_OVERLAY_DURATION_MS + SETTLEMENT_OVERLAY_FADE_OUT_MS) + + return () => { + window.clearTimeout(fadeTimerId) + window.clearTimeout(clearTimerId) + } + }, [activeNoRewardKey]) + useEffect(() => { if (!shouldRenderReward) { return @@ -400,6 +552,29 @@ export function DesktopAnimalOverlay({ ) } + if (shouldRenderNoReward && revealWinningCellId !== null) { + return ( +
+ + ) + } + if (hostingFlag) { return (
diff --git a/src/features/game/components/desktop/desktop-status.tsx b/src/features/game/components/desktop/desktop-status.tsx index 862057c..5f36a7c 100644 --- a/src/features/game/components/desktop/desktop-status.tsx +++ b/src/features/game/components/desktop/desktop-status.tsx @@ -9,10 +9,10 @@ import lock from '@/assets/system/lock.webp' import statusCenter from '@/assets/system/status-center.webp' import statusLine from '@/assets/system/status-line.webp' import { LottiePlayer } from '@/components/lottie-player.tsx' +import { MessageBroadcast } from '@/components/message-broadcast.tsx' import { SmartBackground } from '@/components/smart-background.tsx' import { SmartImage } from '@/components/smart-image.tsx' import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx' -import { MessageBroadcast } from '@/features/game/components/desktop/desktop-title.tsx' import { useGameStatusVm } from '@/hooks/use-game-status-vm.ts' import { cn } from '@/lib/utils.ts' diff --git a/src/features/game/components/desktop/desktop-title.tsx b/src/features/game/components/desktop/desktop-title.tsx deleted file mode 100644 index c71c637..0000000 --- a/src/features/game/components/desktop/desktop-title.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import AutoScroll from 'embla-carousel-auto-scroll' -import useEmblaCarousel from 'embla-carousel-react' -import { useEffect, useMemo } from 'react' -import broadcast from '@/assets/system/broadcast.webp' -import { SmartImage } from '@/components/smart-image.tsx' -import { cn } from '@/lib/utils.ts' -import { useGameSessionStore } from '@/store/game' - -const winAmountFormatter = new Intl.NumberFormat('en-US', { - maximumFractionDigits: 6, -}) -function getDesktopTitleCarouselCycleCount(titleCount: number) { - if (titleCount >= 8) { - return 2 - } - - if (titleCount >= 5) { - return 3 - } - - if (titleCount >= 3) { - return 5 - } - - return 8 -} - -function formatWinAmount(value: string) { - const amount = Number(value) - - return Number.isFinite(amount) ? winAmountFormatter.format(amount) : value -} - -type MessageBroadcastProps = { - className?: string -} - -export function MessageBroadcast({ className }: MessageBroadcastProps) { - const jackpotBroadcasts = useGameSessionStore( - (state) => state.jackpotBroadcasts, - ) - const hasBroadcasts = jackpotBroadcasts.length > 0 - const titles = useMemo( - () => - hasBroadcasts - ? jackpotBroadcasts.map((broadcast) => ({ - id: broadcast.id, - message: `Player ${broadcast.nickname} won ${formatWinAmount( - broadcast.totalWin, - )}`, - })) - : [{ id: 'empty', message: '' }], - [hasBroadcasts, jackpotBroadcasts], - ) - const carouselTitles = useMemo( - () => - hasBroadcasts - ? Array.from( - { length: getDesktopTitleCarouselCycleCount(titles.length) }, - (_, cycleIndex) => - titles.map((title) => ({ - ...title, - cycleIndex, - id: `${title.id}:cycle-${cycleIndex}`, - })), - ).flat() - : titles.map((title) => ({ ...title, cycleIndex: 0 })), - [hasBroadcasts, titles], - ) - const carouselTitleCount = carouselTitles.length - const autoScrollPlugin = useMemo( - () => - AutoScroll({ - playOnInit: hasBroadcasts, - speed: 0.7, - startDelay: 0, - stopOnFocusIn: false, - stopOnInteraction: false, - stopOnMouseEnter: false, - }), - [hasBroadcasts], - ) - const [emblaRef, emblaApi] = useEmblaCarousel( - { - align: 'start', - dragFree: true, - loop: hasBroadcasts, - watchDrag: false, - }, - [autoScrollPlugin], - ) - - useEffect(() => { - if (!emblaApi) { - return - } - - if (carouselTitleCount === 0) { - return - } - - emblaApi.reInit() - const autoScroll = emblaApi.plugins().autoScroll - - if (!hasBroadcasts) { - autoScroll?.stop() - return - } - - autoScroll?.reset() - autoScroll?.play() - }, [emblaApi, hasBroadcasts, carouselTitleCount]) - - return ( -
- -
-
-
- {carouselTitles.map((title) => ( -
0} - className="flex h-design-16 min-w-max shrink-0 items-center whitespace-nowrap !text-[#FF970F] md:h-design-28" - key={title.id} - > - {title.message} -
- ))} -
-
-
-
- ) -} - -export function DesktopTitle(props: MessageBroadcastProps) { - return -} diff --git a/src/features/game/components/mobile/mobile-animal-overlay.tsx b/src/features/game/components/mobile/mobile-animal-overlay.tsx index e250ee7..c57e0d8 100644 --- a/src/features/game/components/mobile/mobile-animal-overlay.tsx +++ b/src/features/game/components/mobile/mobile-animal-overlay.tsx @@ -28,6 +28,7 @@ import type { BetSelection, RewardAnimationType } from '@/type' const REWARD_OVERLAY_FADE_OUT_MS = 300 const REWARD_CHILDREN_FADE_IN_MS = 2_000 const REWARD_CHILDREN_VISIBLE_MS = 1_000 +const SETTLEMENT_OVERLAY_FADE_OUT_MS = 300 type RewardChildrenStage = 'hidden' | 'visible' | 'exiting' type StopBetItem = { @@ -173,6 +174,79 @@ function StopBetSummary({ ) } +function NoRewardSettlement({ + betItems, + message, + selectedLabel, + resultLabel, + resultNumber, +}: { + betItems: StopBetItem[] + message: string + selectedLabel: string + resultLabel: string + resultNumber: number +}) { + const winningImageUrl = FLOWER_IMAGE_BY_ID[resultNumber]?.animalUrl ?? '' + + return ( +
+
+ {message} +
+ +
+
+
+ {resultLabel} +
+
+ {winningImageUrl ? ( + + ) : null} +
+
+ {String(resultNumber).padStart(2, '0')} +
+
+ +
+
+ {selectedLabel} +
+
+ {betItems.map((item) => ( +
+
+ {item.imageUrl ? ( + + ) : null} +
+
+ {String(item.cellId).padStart(2, '0')} +
+
+ ))} +
+
+
+
+ ) +} + export function MobileAnimalOverlay({ showStopOverlay, }: MobileAnimalOverlayProps) { @@ -195,6 +269,9 @@ export function MobileAnimalOverlay({ (state) => state.revealAnimation.rewardType, ) const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase) + const revealWinningCellId = useGameRoundStore( + (state) => state.revealAnimation.winningCellId, + ) const rewardAmount = useGameRoundStore( (state) => state.revealAnimation.rewardAmount, ) @@ -210,6 +287,13 @@ export function MobileAnimalOverlay({ useState('hidden') const [displayRewardAmount, setDisplayRewardAmount] = useState('0') const [hasRenderedReward, setHasRenderedReward] = useState(false) + const [activeNoRewardKey, setActiveNoRewardKey] = useState( + null, + ) + const [dismissedNoRewardKey, setDismissedNoRewardKey] = useState< + string | null + >(null) + const [isNoRewardFadingOut, setIsNoRewardFadingOut] = useState(false) const stopImageSrc = i18n.resolvedLanguage?.startsWith('zh') ? zhStopImage : enStopImage @@ -233,10 +317,30 @@ export function MobileAnimalOverlay({ revealPhase === 'result' && rewardType !== 'none' && rewardSource !== null const overlayAnimationKey = `${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}` const childTimelineKey = shouldRenderReward ? overlayAnimationKey : 'closed' + const hasWinningSelection = + revealWinningCellId !== null && + recentSuccessfulSelections.some( + (selection) => selection.cellId === revealWinningCellId, + ) + const noRewardKey = + roundId && revealWinningCellId !== null + ? `${roundId}:${revealWinningCellId}` + : null + const shouldRenderNoReward = + revealPhase === 'result' && + rewardType === 'none' && + noRewardKey !== null && + lastBetPeriodNo === roundId && + recentSuccessfulSelections.length > 0 && + !hasWinningSelection && + activeNoRewardKey === noRewardKey useEffect(() => { if (revealPhase !== 'result') { setHasRenderedReward(false) + setActiveNoRewardKey(null) + setDismissedNoRewardKey(null) + setIsNoRewardFadingOut(false) return } @@ -245,6 +349,54 @@ export function MobileAnimalOverlay({ } }, [revealPhase, shouldRenderReward]) + useEffect(() => { + if ( + revealPhase !== 'result' || + rewardType !== 'none' || + noRewardKey === null || + lastBetPeriodNo !== roundId || + recentSuccessfulSelections.length === 0 || + hasWinningSelection || + activeNoRewardKey === noRewardKey || + dismissedNoRewardKey === noRewardKey + ) { + return + } + + setActiveNoRewardKey(noRewardKey) + setIsNoRewardFadingOut(false) + }, [ + activeNoRewardKey, + dismissedNoRewardKey, + hasWinningSelection, + lastBetPeriodNo, + noRewardKey, + recentSuccessfulSelections.length, + revealPhase, + rewardType, + roundId, + ]) + + useEffect(() => { + if (!activeNoRewardKey) { + return + } + + const fadeTimerId = window.setTimeout(() => { + setIsNoRewardFadingOut(true) + }, REWARD_OVERLAY_DURATION_MS) + const clearTimerId = window.setTimeout(() => { + setDismissedNoRewardKey(activeNoRewardKey) + setActiveNoRewardKey(null) + setIsNoRewardFadingOut(false) + }, REWARD_OVERLAY_DURATION_MS + SETTLEMENT_OVERLAY_FADE_OUT_MS) + + return () => { + window.clearTimeout(fadeTimerId) + window.clearTimeout(clearTimerId) + } + }, [activeNoRewardKey]) + useEffect(() => { if (!shouldRenderReward) { return @@ -398,6 +550,29 @@ export function MobileAnimalOverlay({ ) } + if (shouldRenderNoReward && revealWinningCellId !== null) { + return ( +
+ + ) + } + if (hostingFlag) { return (
diff --git a/src/hooks/use-auto-hosting-runner.ts b/src/hooks/use-auto-hosting-runner.ts index 652a568..6dd6341 100644 --- a/src/hooks/use-auto-hosting-runner.ts +++ b/src/hooks/use-auto-hosting-runner.ts @@ -91,6 +91,9 @@ export function useAutoHostingRunner() { const setCurrentUser = useAuthStore((state) => state.setCurrentUser) const round = useGameRoundStore((state) => state.round) const clearSelections = useGameRoundStore((state) => state.clearSelections) + const setRecentSuccessfulSelections = useGameRoundStore( + (state) => state.setRecentSuccessfulSelections, + ) const tableLimitMax = useGameSessionStore( (state) => state.dashboard.tableLimitMax, ) @@ -245,6 +248,7 @@ export function useAutoHostingRunner() { lastBetPeriodNo: submittingRoundId, }) markRoundSubmitted(submittingRoundId, parseBalance(latestBalance)) + setRecentSuccessfulSelections(selections) clearSelections() } catch (error) { if (useGameAutoHostingStore.getState().isHosting) { @@ -272,6 +276,7 @@ export function useAutoHostingRunner() { round.phase, selections, setCurrentUser, + setRecentSuccessfulSelections, stopHosting, tableLimitMax, t, diff --git a/src/hooks/use-game-realtime-sync.ts b/src/hooks/use-game-realtime-sync.ts index 697a1fa..049eed0 100644 --- a/src/hooks/use-game-realtime-sync.ts +++ b/src/hooks/use-game-realtime-sync.ts @@ -239,7 +239,8 @@ function applyJackpotHitMessage(message: GameSocketMessage) { if (jackpotHitData?.hits.length) { useGameSessionStore.getState().pushJackpotBroadcasts( jackpotHitData.hits.map((hit) => ({ - id: `${jackpotHitData.period_no}:${hit.result_number}:${hit.nickname}:${hit.total_win}`, + currentStreak: hit.current_streak, + id: `${jackpotHitData.period_no}:${hit.result_number}:${hit.nickname}:${hit.current_streak}:${hit.total_win}`, nickname: hit.nickname, periodNo: hit.period_no, totalWin: hit.total_win, diff --git a/src/lib/ws/message-parsers.ts b/src/lib/ws/message-parsers.ts index 6c7e1e3..0d67034 100644 --- a/src/lib/ws/message-parsers.ts +++ b/src/lib/ws/message-parsers.ts @@ -224,12 +224,19 @@ function extractJackpotHitItem(value: unknown): JackpotHitItemDto | null { } const resultNumber = toOptionalNumber(source.result_number) + const currentStreak = toOptionalNumber(source.current_streak) - if (typeof resultNumber !== 'number' || !Number.isInteger(resultNumber)) { + if ( + typeof resultNumber !== 'number' || + !Number.isInteger(resultNumber) || + typeof currentStreak !== 'number' || + !Number.isInteger(currentStreak) + ) { return null } return { + current_streak: currentStreak, nickname: source.nickname, period_no: source.period_no, result_number: resultNumber, diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index ff33c3c..501e3a6 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -168,6 +168,16 @@ export default { title: 'Round {{roundId}}', action: 'Start Betting', }, + jackpotBroadcast: { + ariaLabel: 'Jackpot broadcast', + ariaMessage: + '{{nickname}} is on a {{streak}}-win streak and won {{amount}}', + iconAlt: 'Broadcast', + prefix: 'Jackpot hit', + separator: 'with', + streak: '{{count}}-win streak', + winAction: 'won', + }, actions: { unifiedBetHint: 'Unified bet', totalBet: 'Total bet', @@ -532,6 +542,9 @@ export default { tapToEnter: 'Tap To Enter', getStart: 'Get Start', noBet: 'No Bet', + noReward: 'No Win This Round', + selectedResult: 'Your Picks', + winningResult: 'Winning Flower', }, history: { title: 'History', diff --git a/src/locales/id-ID.ts b/src/locales/id-ID.ts index 445ff7c..f2b3c02 100644 --- a/src/locales/id-ID.ts +++ b/src/locales/id-ID.ts @@ -167,6 +167,16 @@ export default { title: 'Ronde {{roundId}}', action: 'Mulai Bertaruh', }, + jackpotBroadcast: { + ariaLabel: 'Siaran jackpot', + ariaMessage: + '{{nickname}} sedang streak {{streak}} menang dan memenangkan {{amount}}', + iconAlt: 'Siaran', + prefix: 'Jackpot kena', + separator: 'dengan', + streak: 'streak {{count}} menang', + winAction: 'memenangkan', + }, actions: { unifiedBetHint: 'Bet seragam', totalBet: 'Total bet', @@ -532,6 +542,9 @@ export default { tapToEnter: 'Ketuk Untuk Masuk', getStart: 'Mulai', noBet: 'Belum Bertaruh', + noReward: 'Belum Menang Ronde Ini', + selectedResult: 'Pilihan Kamu', + winningResult: 'Bunga Menang', }, history: { title: 'Riwayat', diff --git a/src/locales/ms-MY.ts b/src/locales/ms-MY.ts index 4747d00..c6cced3 100644 --- a/src/locales/ms-MY.ts +++ b/src/locales/ms-MY.ts @@ -170,6 +170,16 @@ export default { title: 'Pusingan {{roundId}}', action: 'Mula Bertaruh', }, + jackpotBroadcast: { + ariaLabel: 'Siaran jackpot', + ariaMessage: + '{{nickname}} sedang dalam streak {{streak}} menang dan memenangi {{amount}}', + iconAlt: 'Siaran', + prefix: 'Jackpot kena', + separator: 'dengan', + streak: 'streak {{count}} menang', + winAction: 'memenangi', + }, actions: { unifiedBetHint: 'Taruhan seragam', totalBet: 'Jumlah taruhan', @@ -537,6 +547,9 @@ export default { tapToEnter: 'Ketik Untuk Masuk', getStart: 'Mula', noBet: 'Belum Bertaruh', + noReward: 'Belum Menang Pusingan Ini', + selectedResult: 'Pilihan Anda', + winningResult: 'Bunga Menang', }, history: { title: 'Sejarah', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index b3177a8..d17a313 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -166,6 +166,15 @@ export default { title: '{{roundId}}期', action: '开始下注', }, + jackpotBroadcast: { + ariaLabel: 'Jackpot 播报', + ariaMessage: '{{nickname}} 连胜 {{streak}} 次,赢得 {{amount}}', + iconAlt: '播报', + prefix: 'Jackpot 喜讯', + separator: '达成', + streak: '{{count}} 连胜', + winAction: '赢得', + }, actions: { unifiedBetHint: '统一下注额', totalBet: '总下注', @@ -518,6 +527,9 @@ export default { tapToEnter: '点击进入', getStart: '开始游戏', noBet: '未下注', + noReward: '本期未中奖', + selectedResult: '已下注字花', + winningResult: '中奖字花', }, history: { title: '历史记录', diff --git a/src/main/mobile-entry.tsx b/src/main/mobile-entry.tsx index 27e2cb3..f21b0f8 100644 --- a/src/main/mobile-entry.tsx +++ b/src/main/mobile-entry.tsx @@ -1,4 +1,4 @@ -import { MessageBroadcast } from '@/features/game/components/desktop/desktop-title.tsx' +import { MessageBroadcast } from '@/components/message-broadcast.tsx' import { MobileAnimal } from '@/features/game/components/mobile/mobile-animal.tsx' import { MobileBettingStartAlert } from '@/features/game/components/mobile/mobile-betting-start-alert.tsx' import { MobileControl } from '@/features/game/components/mobile/mobile-control.tsx' diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 5cd285c..ec7bbd6 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -12,7 +12,6 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LangRouteRouteImport } from './routes/$lang/route' import { Route as IndexRouteImport } from './routes/index' import { Route as LangIndexRouteImport } from './routes/$lang/index' -import { Route as LangWsTestRouteImport } from './routes/$lang/ws-test' const LangRouteRoute = LangRouteRouteImport.update({ id: '/$lang', @@ -29,36 +28,28 @@ const LangIndexRoute = LangIndexRouteImport.update({ path: '/', getParentRoute: () => LangRouteRoute, } as any) -const LangWsTestRoute = LangWsTestRouteImport.update({ - id: '/ws-test', - path: '/ws-test', - getParentRoute: () => LangRouteRoute, -} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/$lang': typeof LangRouteRouteWithChildren - '/$lang/ws-test': typeof LangWsTestRoute '/$lang/': typeof LangIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute - '/$lang/ws-test': typeof LangWsTestRoute '/$lang': typeof LangIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/$lang': typeof LangRouteRouteWithChildren - '/$lang/ws-test': typeof LangWsTestRoute '/$lang/': typeof LangIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/$lang' | '/$lang/ws-test' | '/$lang/' + fullPaths: '/' | '/$lang' | '/$lang/' fileRoutesByTo: FileRoutesByTo - to: '/' | '/$lang/ws-test' | '/$lang' - id: '__root__' | '/' | '/$lang' | '/$lang/ws-test' | '/$lang/' + to: '/' | '/$lang' + id: '__root__' | '/' | '/$lang' | '/$lang/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -89,23 +80,14 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LangIndexRouteImport parentRoute: typeof LangRouteRoute } - '/$lang/ws-test': { - id: '/$lang/ws-test' - path: '/ws-test' - fullPath: '/$lang/ws-test' - preLoaderRoute: typeof LangWsTestRouteImport - parentRoute: typeof LangRouteRoute - } } } interface LangRouteRouteChildren { - LangWsTestRoute: typeof LangWsTestRoute LangIndexRoute: typeof LangIndexRoute } const LangRouteRouteChildren: LangRouteRouteChildren = { - LangWsTestRoute: LangWsTestRoute, LangIndexRoute: LangIndexRoute, } diff --git a/src/routes/$lang/ws-test.tsx b/src/routes/$lang/ws-test.tsx deleted file mode 100644 index d454414..0000000 --- a/src/routes/$lang/ws-test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' -import { useEffect, useState } from 'react' - -const TEST_WS_URL = - 'wss://zihua-api.h55555game.top/ws/?token=d77371f4-d053-475a-9c53-bfa11eb921c2&auth_token=708df54d-c647-46fc-b6ee-4339298d1ed4&device_id=web_0bc09c22-9157-4398-b4b3-57584ece9da9&lang=zh' - -const TEST_TOPICS = [ - 'period.tick', - 'user.streak', - 'period.opened', - 'period.locked', - 'period.payout', - 'bet.accepted', - 'wallet.changed', - 'auto.spin.progress', - 'admin.live.snapshot', - 'admin.live.opened', - 'jackpot.hit', - 'bet.win', -] as const - -type WsTestLog = { - at: string - id: string - message: string -} - -export const Route = createFileRoute('/$lang/ws-test')({ - component: WsTestPage, -}) - -function WsTestPage() { - const [logs, setLogs] = useState([]) - const [status, setStatus] = useState('idle') - - useEffect(() => { - const appendLog = (message: string) => { - setLogs((current) => [ - ...current, - { - at: new Date().toISOString(), - id: crypto.randomUUID(), - message, - }, - ]) - } - - appendLog(`creating websocket: ${TEST_WS_URL}`) - setStatus('connecting') - - const socket = new WebSocket(TEST_WS_URL) - - socket.addEventListener('open', () => { - appendLog('open') - socket.send( - JSON.stringify({ - action: 'subscribe', - topics: [...TEST_TOPICS], - }), - ) - appendLog(`subscribe: ${TEST_TOPICS.join(', ')}`) - setStatus('open') - }) - - socket.addEventListener('message', (event) => { - appendLog(`message: ${String(event.data)}`) - }) - - socket.addEventListener('error', () => { - appendLog('error') - setStatus('error') - }) - - socket.addEventListener('close', (event) => { - appendLog( - `close code=${event.code} reason=${event.reason || '(empty)'} wasClean=${event.wasClean}`, - ) - setStatus('closed') - }) - - return () => { - socket.close() - } - }, []) - - return ( -
-

WS Test

-
-
Status
-
{status}
-
-
-
URL
-
- {TEST_WS_URL} -
-
-
-
Topics
-
- {TEST_TOPICS.join(', ')} -
-
-
-
Logs
-
- {logs.length === 0 ? ( -
No logs yet
- ) : ( - logs.map((log) => ( -
- [{log.at}] {log.message} -
- )) - )} -
-
-
- ) -} diff --git a/src/type/game.type.ts b/src/type/game.type.ts index 0e7401b..dee5c64 100644 --- a/src/type/game.type.ts +++ b/src/type/game.type.ts @@ -193,6 +193,7 @@ export interface AutoHostingStopRules { } export interface JackpotBroadcastItem { + currentStreak: number id: string nickname: string periodNo: string @@ -428,6 +429,7 @@ export interface GamePeriodTickDto { } export interface JackpotHitItemDto { + current_streak: number nickname: string period_no: string result_number: number