feat(game): 优化加载动画并添加无奖励结算显示
- 替换粒子动画为预加载背景图片,提升加载性能 - 添加桌面端无奖励结算界面显示玩家选择和开奖结果 - 实现消息广播组件的动画效果和状态管理 - 添加代码审查报告文档 - 更新GitNexus索引统计信息 - 调整加载界面布局和样式优化
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# 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.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# 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.
|
||||
|
||||
|
||||
53
docs/code-review-2026-06-05.md
Normal file
@@ -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 后建议补充登录态清理、下注提交、首屏渲染和资源加载相关测试。
|
||||
14
index.html
@@ -3,6 +3,20 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link
|
||||
rel="preload"
|
||||
as="image"
|
||||
href="/loading/pc-loading-bg.webp"
|
||||
media="(min-width: 769px)"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
as="image"
|
||||
href="/loading/mobile-loading-bg.webp"
|
||||
media="(max-width: 768px)"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
|
||||
BIN
public/loading/mobile-loading-bg.webp
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
public/loading/pc-loading-bg.webp
Normal file
|
After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 8.7 KiB |
@@ -10,13 +10,8 @@ const bootImageModules = import.meta.glob(
|
||||
) as Record<string, string>
|
||||
|
||||
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<void>((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]"
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_38%,rgba(35,221,255,0.18),transparent_30%),linear-gradient(180deg,#061827_0%,#020913_54%,#01050C_100%)]" />
|
||||
<div className="boot-grid absolute inset-0 opacity-50" />
|
||||
<div className="boot-scanline absolute inset-0 opacity-35" />
|
||||
<picture className="absolute inset-0">
|
||||
<source media="(max-width: 768px)" srcSet={MOBILE_LOADING_BG_URL} />
|
||||
<img
|
||||
src={PC_LOADING_BG_URL}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-contain md:object-cover"
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
loading="eager"
|
||||
/>
|
||||
</picture>
|
||||
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-[linear-gradient(180deg,rgba(1,7,14,0)_0%,rgba(1,7,14,0.5)_62%,rgba(1,7,14,0.84)_100%)]" />
|
||||
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
{particleLayout.map((particle) => (
|
||||
<span
|
||||
key={`${particle.left}-${particle.top}`}
|
||||
className="boot-particle absolute rounded-full bg-[#71FFF7] shadow-[0_0_12px_rgba(113,255,247,0.88)]"
|
||||
style={{
|
||||
animationDelay: `${particle.delayMs}ms`,
|
||||
animationDuration: `${particle.durationMs}ms`,
|
||||
height: `calc(var(--design-unit) * ${particle.size})`,
|
||||
left: particle.left,
|
||||
top: particle.top,
|
||||
width: `calc(var(--design-unit) * ${particle.size})`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-[min(calc(var(--design-unit)*720),86vw)] flex-col items-center gap-design-24 px-design-24">
|
||||
<div className="relative flex h-design-190 w-design-190 min-h-32 min-w-32 items-center justify-center md:h-design-260 md:w-design-260">
|
||||
<div className="boot-ring absolute inset-0 rounded-full border border-[rgba(105,255,250,0.38)]" />
|
||||
<div className="boot-ring boot-ring-delay absolute inset-[calc(var(--design-unit)*18)] rounded-full border border-[rgba(255,224,117,0.34)] md:inset-[calc(var(--design-unit)*24)]" />
|
||||
<div className="absolute h-[64%] w-[64%] rounded-full border border-[rgba(113,255,247,0.26)] bg-[radial-gradient(circle,rgba(18,95,113,0.68),rgba(2,13,22,0.96))] shadow-[inset_0_0_calc(var(--design-unit)*32)_rgba(113,255,247,0.18),0_0_calc(var(--design-unit)*52)_rgba(65,232,255,0.22)]" />
|
||||
<span className="relative text-design-34 font-bold leading-none text-[#FEE889] [text-shadow:0_0_calc(var(--design-unit)*16)_rgba(254,232,137,0.46)] md:text-design-50 md:[text-shadow:0_0_calc(var(--design-unit)*22)_rgba(254,232,137,0.56)]">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full overflow-hidden rounded-[calc(var(--design-unit)*10)] border border-[rgba(113,255,247,0.38)] bg-[rgba(4,20,31,0.82)] p-design-2 shadow-[0_0_calc(var(--design-unit)*22)_rgba(65,232,255,0.16),inset_0_0_calc(var(--design-unit)*10)_rgba(113,255,247,0.1)]">
|
||||
<div className="absolute left-1/2 top-[74%] flex w-[min(calc(var(--design-unit)*760),84vw)] -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-design-12 px-design-8 md:top-[72%] md:gap-design-16">
|
||||
<div className="w-full overflow-hidden rounded-[calc(var(--design-unit)*12)] border border-[rgba(42,214,255,0.52)] bg-[rgba(1,16,28,0.62)] p-design-2 shadow-[0_0_calc(var(--design-unit)*20)_rgba(13,194,255,0.24),inset_0_0_calc(var(--design-unit)*16)_rgba(48,222,255,0.12)] backdrop-blur-sm">
|
||||
<div
|
||||
className="h-design-10 rounded-[calc(var(--design-unit)*8)] bg-[linear-gradient(90deg,#34E7FF,#7BFFF6,#FEE889)] shadow-[0_0_calc(var(--design-unit)*16)_rgba(113,255,247,0.58)] transition-[width] duration-300 ease-out"
|
||||
className="h-design-10 rounded-[calc(var(--design-unit)*10)] bg-[linear-gradient(90deg,#0DE7FF_0%,#36F8FF_56%,#F9C968_100%)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(32,231,255,0.72)] transition-[width] duration-300 ease-out md:h-design-12"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-design-8 text-center">
|
||||
<div className="text-design-24 font-bold text-[#E8FFFF] [text-shadow:0_0_calc(var(--design-unit)*14)_rgba(113,255,247,0.38)]">
|
||||
{t('commonUi.boot.loading')}
|
||||
<div className="flex items-center gap-design-10 text-design-24 font-bold text-[#F8D477] md:text-design-30">
|
||||
<span>{progress}%</span>
|
||||
<span className="text-[#F9D477]">{t('commonUi.boot.loading')}</span>
|
||||
</div>
|
||||
<div className="text-design-14 text-[rgba(181,242,247,0.72)]">
|
||||
<div className="text-design-13 font-medium text-[rgba(226,248,255,0.78)] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(39,203,255,0.38)] md:text-design-16">
|
||||
{t('commonUi.boot.syncing')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
167
src/components/message-broadcast.tsx
Normal file
@@ -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<string>())
|
||||
const queuedBroadcastIdsRef = useRef(new Set<string>())
|
||||
const [queue, setQueue] = useState<JackpotBroadcastItem[]>([])
|
||||
const [activeBroadcast, setActiveBroadcast] =
|
||||
useState<JackpotBroadcastItem | null>(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 (
|
||||
<section
|
||||
aria-label={t('game.jackpotBroadcast.ariaLabel')}
|
||||
className={cn(
|
||||
'common-neon-inset flex w-full min-w-0 items-center overflow-hidden',
|
||||
'h-design-24 gap-design-5 !rounded-[3px] !px-design-7 !py-0 text-design-8',
|
||||
'md:h-design-65 md:gap-design-10 md:!rounded-[5px] md:!px-design-20 md:text-design-16',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SmartImage
|
||||
className="h-design-12 w-design-12 shrink-0 md:h-design-24 md:w-design-24"
|
||||
alt={t('game.jackpotBroadcast.iconAlt')}
|
||||
src={broadcast}
|
||||
/>
|
||||
<div className="relative h-design-16 min-w-0 flex-1 overflow-hidden md:h-design-28">
|
||||
<AnimatePresence>
|
||||
{activeBroadcast ? (
|
||||
<motion.div
|
||||
aria-label={activeBroadcastLabel}
|
||||
className="absolute inset-0 flex min-w-max items-center whitespace-nowrap font-semibold"
|
||||
key={activeBroadcast.id}
|
||||
initial={
|
||||
prefersReducedMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 1, x: '108%' }
|
||||
}
|
||||
animate={
|
||||
prefersReducedMotion
|
||||
? { opacity: 1 }
|
||||
: { opacity: 1, x: '-108%' }
|
||||
}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
duration: prefersReducedMotion
|
||||
? 0.18
|
||||
: MESSAGE_BROADCAST_ANIMATION_MS / 1000,
|
||||
ease: prefersReducedMotion ? [0.16, 1, 0.3, 1] : 'linear',
|
||||
}}
|
||||
>
|
||||
<span className="text-[#D5F7FF]">
|
||||
{t('game.jackpotBroadcast.prefix')}
|
||||
</span>
|
||||
<span className="ml-design-4 text-[#4BFFFE] md:ml-design-8">
|
||||
{activeBroadcast.nickname}
|
||||
</span>
|
||||
<span className="mx-design-4 text-[#D5F7FF] md:mx-design-8">
|
||||
{t('game.jackpotBroadcast.separator')}
|
||||
</span>
|
||||
<span className="text-[#78FF7F]">
|
||||
{t('game.jackpotBroadcast.streak', {
|
||||
count: activeBroadcast.currentStreak,
|
||||
})}
|
||||
</span>
|
||||
<span className="mx-design-4 text-[#D5F7FF] md:mx-design-8">
|
||||
{t('game.jackpotBroadcast.winAction')}
|
||||
</span>
|
||||
<span className="text-[#FFB72B]">
|
||||
{formatWinAmount(activeBroadcast.totalWin)}
|
||||
</span>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="relative z-10 flex w-design-720 max-w-[84%] flex-col items-center justify-center gap-design-18 rounded-[calc(var(--design-unit)*22)] border border-[rgba(124,232,255,0.34)] bg-[linear-gradient(180deg,rgba(7,28,40,0.96),rgba(3,12,20,0.98))] px-design-28 py-design-24 text-center shadow-[0_0_calc(var(--design-unit)*28)_rgba(70,245,255,0.28),0_0_calc(var(--design-unit)*64)_rgba(20,112,210,0.18),inset_0_0_calc(var(--design-unit)*24)_rgba(124,232,255,0.12)]">
|
||||
<div className="text-design-34 font-black tracking-[0.14em] text-[#D8FBFF] [text-shadow:0_0_calc(var(--design-unit)*16)_rgba(84,245,255,0.42)]">
|
||||
{message}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-stretch justify-center gap-design-14">
|
||||
<div className="flex w-design-180 shrink-0 flex-col items-center gap-design-8 rounded-[calc(var(--design-unit)*14)] border border-[rgba(120,255,127,0.3)] bg-[linear-gradient(180deg,rgba(5,42,36,0.82),rgba(3,16,20,0.9))] px-design-12 py-design-10">
|
||||
<div className="text-design-18 font-bold text-[rgba(216,251,255,0.78)]">
|
||||
{resultLabel}
|
||||
</div>
|
||||
<div className="relative h-design-76 w-design-76 overflow-hidden rounded-[calc(var(--design-unit)*10)] border border-[rgba(120,255,127,0.36)] bg-[rgba(1,8,13,0.84)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(120,255,127,0.2)]">
|
||||
{winningImageUrl ? (
|
||||
<img
|
||||
src={winningImageUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-full border border-[rgba(120,255,127,0.38)] bg-[rgba(4,31,31,0.78)] px-design-16 py-design-4 text-design-26 font-black leading-none text-[#78FF7F] [text-shadow:0_0_calc(var(--design-unit)*12)_rgba(120,255,127,0.52)]">
|
||||
{String(resultNumber).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-design-430 min-w-0 flex-col items-center gap-design-8 rounded-[calc(var(--design-unit)*14)] border border-[rgba(124,232,255,0.2)] bg-[rgba(3,13,20,0.38)] px-design-10 py-design-10">
|
||||
<div className="text-design-18 font-bold text-[rgba(216,251,255,0.78)]">
|
||||
{selectedLabel}
|
||||
</div>
|
||||
<div className="flex max-h-design-132 w-full flex-wrap items-center justify-center gap-design-8 overflow-hidden">
|
||||
{betItems.map((item) => (
|
||||
<div
|
||||
key={item.cellId}
|
||||
className="flex h-design-104 w-design-88 flex-col items-center justify-center gap-design-4 overflow-hidden rounded-[calc(var(--design-unit)*10)] border border-[rgba(124,232,255,0.22)] bg-[linear-gradient(180deg,rgba(8,34,42,0.9),rgba(3,13,20,0.96))] p-design-4"
|
||||
>
|
||||
<div className="relative h-design-76 w-design-76 overflow-hidden rounded-[calc(var(--design-unit)*10)] bg-[rgba(1,8,13,0.84)]">
|
||||
{item.imageUrl ? (
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-design-20 font-black leading-none text-[#4BFFFE] [text-shadow:0_0_calc(var(--design-unit)*8)_rgba(75,255,254,0.46)]">
|
||||
{String(item.cellId).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<RewardChildrenStage>('hidden')
|
||||
const [displayRewardAmount, setDisplayRewardAmount] = useState('0')
|
||||
const [hasRenderedReward, setHasRenderedReward] = useState(false)
|
||||
const [activeNoRewardKey, setActiveNoRewardKey] = useState<string | null>(
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-40 flex items-center justify-center overflow-hidden bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px] transition-opacity duration-300',
|
||||
isNoRewardFadingOut && 'pointer-events-none opacity-0',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(49,208,255,0.12),rgba(3,7,15,0.92)_58%,rgba(2,4,10,0.98)_100%)]"
|
||||
/>
|
||||
<NoRewardSettlement
|
||||
betItems={stopBetItems}
|
||||
message={t('gameDesktop.animal.noReward')}
|
||||
selectedLabel={t('gameDesktop.animal.selectedResult')}
|
||||
resultLabel={t('gameDesktop.animal.winningResult')}
|
||||
resultNumber={revealWinningCellId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (hostingFlag) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-40 flex flex-col items-center justify-center gap-design-22 bg-[rgba(2,8,14,0.38)] px-design-24 backdrop-blur-[1px]">
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<section
|
||||
aria-label="Jackpot broadcast"
|
||||
className={cn(
|
||||
'common-neon-inset flex w-full min-w-0 items-center overflow-hidden',
|
||||
'h-design-24 gap-design-5 !rounded-[3px] !px-design-7 !py-0 text-design-8',
|
||||
'md:h-design-65 md:gap-design-10 md:!rounded-[5px] md:!px-design-20 md:text-design-16',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SmartImage
|
||||
className="h-design-12 w-design-12 shrink-0 md:h-design-24 md:w-design-24"
|
||||
alt={'broadcast'}
|
||||
src={broadcast}
|
||||
/>
|
||||
<div className="relative h-design-16 min-w-0 flex-1 overflow-hidden md:h-design-28">
|
||||
<div className="h-full overflow-hidden" ref={emblaRef}>
|
||||
<div className="flex h-full items-center gap-design-36 md:gap-design-80">
|
||||
{carouselTitles.map((title) => (
|
||||
<div
|
||||
aria-hidden={title.cycleIndex > 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}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function DesktopTitle(props: MessageBroadcastProps) {
|
||||
return <MessageBroadcast {...props} />
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="relative z-10 flex w-design-336 max-w-[96%] flex-col items-center justify-center gap-design-9 rounded-[calc(var(--design-unit)*13)] border border-[rgba(124,232,255,0.34)] bg-[linear-gradient(180deg,rgba(7,28,40,0.96),rgba(3,12,20,0.98))] px-design-8 py-design-10 text-center shadow-[0_0_calc(var(--design-unit)*18)_rgba(70,245,255,0.28),0_0_calc(var(--design-unit)*34)_rgba(20,112,210,0.18),inset_0_0_calc(var(--design-unit)*16)_rgba(124,232,255,0.12)]">
|
||||
<div className="text-design-20 font-black tracking-[0.12em] text-[#D8FBFF] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(84,245,255,0.42)]">
|
||||
{message}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-stretch justify-center gap-design-6">
|
||||
<div className="flex w-design-92 shrink-0 flex-col items-center gap-design-5 rounded-[calc(var(--design-unit)*9)] border border-[rgba(120,255,127,0.3)] bg-[linear-gradient(180deg,rgba(5,42,36,0.82),rgba(3,16,20,0.9))] px-design-5 py-design-6">
|
||||
<div className="text-design-10 font-bold text-[rgba(216,251,255,0.72)]">
|
||||
{resultLabel}
|
||||
</div>
|
||||
<div className="relative h-design-46 w-design-46 overflow-hidden rounded-[calc(var(--design-unit)*6)] border border-[rgba(120,255,127,0.32)] bg-[rgba(1,8,13,0.84)]">
|
||||
{winningImageUrl ? (
|
||||
<img
|
||||
src={winningImageUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-full border border-[rgba(120,255,127,0.36)] bg-[rgba(4,31,31,0.72)] px-design-8 py-design-2 text-design-15 font-black leading-none text-[#78FF7F] [text-shadow:0_0_calc(var(--design-unit)*7)_rgba(120,255,127,0.52)]">
|
||||
{String(resultNumber).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col items-center gap-design-5 rounded-[calc(var(--design-unit)*9)] border border-[rgba(124,232,255,0.2)] bg-[rgba(3,13,20,0.38)] px-design-4 py-design-5">
|
||||
<div className="text-design-10 font-bold text-[rgba(216,251,255,0.72)]">
|
||||
{selectedLabel}
|
||||
</div>
|
||||
<div className="flex max-h-design-92 w-full flex-wrap items-center justify-center gap-design-4 overflow-hidden">
|
||||
{betItems.map((item) => (
|
||||
<div
|
||||
key={item.cellId}
|
||||
className="flex h-design-52 w-design-42 flex-col items-center justify-center gap-design-2 overflow-hidden rounded-[calc(var(--design-unit)*6)] border border-[rgba(124,232,255,0.2)] bg-[linear-gradient(180deg,rgba(8,34,42,0.88),rgba(3,13,20,0.94))] p-design-2"
|
||||
>
|
||||
<div className="relative h-design-30 w-design-30 overflow-hidden rounded-[calc(var(--design-unit)*4)] bg-[rgba(1,8,13,0.84)]">
|
||||
{item.imageUrl ? (
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-design-11 font-black leading-none text-[#4BFFFE] [text-shadow:0_0_calc(var(--design-unit)*6)_rgba(75,255,254,0.46)]">
|
||||
{String(item.cellId).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<RewardChildrenStage>('hidden')
|
||||
const [displayRewardAmount, setDisplayRewardAmount] = useState('0')
|
||||
const [hasRenderedReward, setHasRenderedReward] = useState(false)
|
||||
const [activeNoRewardKey, setActiveNoRewardKey] = useState<string | null>(
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-40 flex items-center justify-center overflow-hidden bg-[rgba(2,8,14,0.66)] px-design-6 backdrop-blur-[2px] transition-opacity duration-300',
|
||||
isNoRewardFadingOut && 'pointer-events-none opacity-0',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(49,208,255,0.12),rgba(3,7,15,0.88)_62%,rgba(2,4,10,0.96)_100%)]"
|
||||
/>
|
||||
<NoRewardSettlement
|
||||
betItems={stopBetItems}
|
||||
message={t('gameDesktop.animal.noReward')}
|
||||
selectedLabel={t('gameDesktop.animal.selectedResult')}
|
||||
resultLabel={t('gameDesktop.animal.winningResult')}
|
||||
resultNumber={revealWinningCellId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (hostingFlag) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-40 flex flex-col items-center justify-center gap-design-10 bg-[rgba(2,8,14,0.38)] px-design-10 backdrop-blur-[1px]">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '历史记录',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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<WsTestLog[]>([])
|
||||
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 (
|
||||
<main className="mx-auto flex min-h-screen max-w-5xl flex-col gap-6 px-6 py-10 text-white">
|
||||
<h1 className="text-2xl font-semibold">WS Test</h1>
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="text-sm text-white/70">Status</div>
|
||||
<div className="mt-1 text-lg">{status}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/30 p-4">
|
||||
<div className="mb-3 text-sm text-white/70">URL</div>
|
||||
<div className="break-all font-mono text-sm text-cyan-200">
|
||||
{TEST_WS_URL}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/30 p-4">
|
||||
<div className="mb-3 text-sm text-white/70">Topics</div>
|
||||
<div className="break-all font-mono text-sm text-cyan-200">
|
||||
{TEST_TOPICS.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/30 p-4">
|
||||
<div className="mb-3 text-sm text-white/70">Logs</div>
|
||||
<div className="flex flex-col gap-2 font-mono text-sm">
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-white/50">No logs yet</div>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<div key={log.id} className="break-all text-white/85">
|
||||
[{log.at}] {log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||