feat(game): 优化加载动画并添加无奖励结算显示

- 替换粒子动画为预加载背景图片,提升加载性能
- 添加桌面端无奖励结算界面显示玩家选择和开奖结果
- 实现消息广播组件的动画效果和状态管理
- 添加代码审查报告文档
- 更新GitNexus索引统计信息
- 调整加载界面布局和样式优化
This commit is contained in:
JiaJun
2026-06-08 17:59:44 +08:00
parent b6168301fe
commit 3b35c9817b
41 changed files with 681 additions and 340 deletions

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # 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. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start --> <!-- gitnexus:start -->
# GitNexus — Code Intelligence # 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. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View 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 后建议补充登录态清理、下注提交、首屏渲染和资源加载相关测试。

View File

@@ -3,6 +3,20 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <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="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
name="description" name="description"

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -10,13 +10,8 @@ const bootImageModules = import.meta.glob(
) as Record<string, string> ) as Record<string, string>
const BOOT_MIN_VISIBLE_MS = 900 const BOOT_MIN_VISIBLE_MS = 900
const particleLayout = Array.from({ length: 36 }, (_, index) => ({ const PC_LOADING_BG_URL = '/loading/pc-loading-bg.webp'
delayMs: (index % 9) * 180, const MOBILE_LOADING_BG_URL = '/loading/mobile-loading-bg.webp'
durationMs: 3200 + (index % 7) * 260,
left: `${(index * 17 + 9) % 100}%`,
size: 2 + (index % 4),
top: `${(index * 29 + 13) % 100}%`,
}))
function preloadImage(url: string) { function preloadImage(url: string) {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
@@ -100,49 +95,34 @@ function AppLoadingOverlay({ progress }: { progress: number }) {
aria-label="Loading" aria-label="Loading"
className="fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-[#020913] text-[#D5FBFF]" 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%)]" /> <picture className="absolute inset-0">
<div className="boot-grid absolute inset-0 opacity-50" /> <source media="(max-width: 768px)" srcSet={MOBILE_LOADING_BG_URL} />
<div className="boot-scanline absolute inset-0 opacity-35" /> <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"> <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">
{particleLayout.map((particle) => ( <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">
<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 <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}%` }} style={{ width: `${progress}%` }}
/> />
</div> </div>
<div className="flex flex-col items-center gap-design-8 text-center"> <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)]"> <div className="flex items-center gap-design-10 text-design-24 font-bold text-[#F8D477] md:text-design-30">
{t('commonUi.boot.loading')} <span>{progress}%</span>
<span className="text-[#F9D477]">{t('commonUi.boot.loading')}</span>
</div> </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')} {t('commonUi.boot.syncing')}
</div> </div>
</div> </div>

View 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>
)
}

View File

@@ -28,6 +28,7 @@ import type { BetSelection, RewardAnimationType } from '@/type'
const REWARD_OVERLAY_FADE_OUT_MS = 300 const REWARD_OVERLAY_FADE_OUT_MS = 300
const REWARD_CHILDREN_FADE_IN_MS = 2_000 const REWARD_CHILDREN_FADE_IN_MS = 2_000
const REWARD_CHILDREN_VISIBLE_MS = 1_000 const REWARD_CHILDREN_VISIBLE_MS = 1_000
const SETTLEMENT_OVERLAY_FADE_OUT_MS = 300
type RewardChildrenStage = 'hidden' | 'visible' | 'exiting' type RewardChildrenStage = 'hidden' | 'visible' | 'exiting'
type StopBetItem = { 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({ export function DesktopAnimalOverlay({
showStopOverlay, showStopOverlay,
}: DesktopAnimalOverlayProps) { }: DesktopAnimalOverlayProps) {
@@ -197,6 +271,9 @@ export function DesktopAnimalOverlay({
(state) => state.revealAnimation.rewardType, (state) => state.revealAnimation.rewardType,
) )
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase) const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const revealWinningCellId = useGameRoundStore(
(state) => state.revealAnimation.winningCellId,
)
const rewardAmount = useGameRoundStore( const rewardAmount = useGameRoundStore(
(state) => state.revealAnimation.rewardAmount, (state) => state.revealAnimation.rewardAmount,
) )
@@ -212,6 +289,13 @@ export function DesktopAnimalOverlay({
useState<RewardChildrenStage>('hidden') useState<RewardChildrenStage>('hidden')
const [displayRewardAmount, setDisplayRewardAmount] = useState('0') const [displayRewardAmount, setDisplayRewardAmount] = useState('0')
const [hasRenderedReward, setHasRenderedReward] = useState(false) 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') const stopImageSrc = i18n.resolvedLanguage?.startsWith('zh')
? zhStopImage ? zhStopImage
: enStopImage : enStopImage
@@ -235,10 +319,30 @@ export function DesktopAnimalOverlay({
revealPhase === 'result' && rewardType !== 'none' && rewardSource !== null revealPhase === 'result' && rewardType !== 'none' && rewardSource !== null
const overlayAnimationKey = `${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}` const overlayAnimationKey = `${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}`
const childTimelineKey = shouldRenderReward ? overlayAnimationKey : 'closed' 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(() => { useEffect(() => {
if (revealPhase !== 'result') { if (revealPhase !== 'result') {
setHasRenderedReward(false) setHasRenderedReward(false)
setActiveNoRewardKey(null)
setDismissedNoRewardKey(null)
setIsNoRewardFadingOut(false)
return return
} }
@@ -247,6 +351,54 @@ export function DesktopAnimalOverlay({
} }
}, [revealPhase, shouldRenderReward]) }, [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(() => { useEffect(() => {
if (!shouldRenderReward) { if (!shouldRenderReward) {
return 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) { if (hostingFlag) {
return ( 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]"> <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]">

View File

@@ -9,10 +9,10 @@ import lock from '@/assets/system/lock.webp'
import statusCenter from '@/assets/system/status-center.webp' import statusCenter from '@/assets/system/status-center.webp'
import statusLine from '@/assets/system/status-line.webp' import statusLine from '@/assets/system/status-line.webp'
import { LottiePlayer } from '@/components/lottie-player.tsx' import { LottiePlayer } from '@/components/lottie-player.tsx'
import { MessageBroadcast } from '@/components/message-broadcast.tsx'
import { SmartBackground } from '@/components/smart-background.tsx' import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx' import { SmartImage } from '@/components/smart-image.tsx'
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.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 { useGameStatusVm } from '@/hooks/use-game-status-vm.ts'
import { cn } from '@/lib/utils.ts' import { cn } from '@/lib/utils.ts'

View File

@@ -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} />
}

View File

@@ -28,6 +28,7 @@ import type { BetSelection, RewardAnimationType } from '@/type'
const REWARD_OVERLAY_FADE_OUT_MS = 300 const REWARD_OVERLAY_FADE_OUT_MS = 300
const REWARD_CHILDREN_FADE_IN_MS = 2_000 const REWARD_CHILDREN_FADE_IN_MS = 2_000
const REWARD_CHILDREN_VISIBLE_MS = 1_000 const REWARD_CHILDREN_VISIBLE_MS = 1_000
const SETTLEMENT_OVERLAY_FADE_OUT_MS = 300
type RewardChildrenStage = 'hidden' | 'visible' | 'exiting' type RewardChildrenStage = 'hidden' | 'visible' | 'exiting'
type StopBetItem = { 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({ export function MobileAnimalOverlay({
showStopOverlay, showStopOverlay,
}: MobileAnimalOverlayProps) { }: MobileAnimalOverlayProps) {
@@ -195,6 +269,9 @@ export function MobileAnimalOverlay({
(state) => state.revealAnimation.rewardType, (state) => state.revealAnimation.rewardType,
) )
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase) const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const revealWinningCellId = useGameRoundStore(
(state) => state.revealAnimation.winningCellId,
)
const rewardAmount = useGameRoundStore( const rewardAmount = useGameRoundStore(
(state) => state.revealAnimation.rewardAmount, (state) => state.revealAnimation.rewardAmount,
) )
@@ -210,6 +287,13 @@ export function MobileAnimalOverlay({
useState<RewardChildrenStage>('hidden') useState<RewardChildrenStage>('hidden')
const [displayRewardAmount, setDisplayRewardAmount] = useState('0') const [displayRewardAmount, setDisplayRewardAmount] = useState('0')
const [hasRenderedReward, setHasRenderedReward] = useState(false) 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') const stopImageSrc = i18n.resolvedLanguage?.startsWith('zh')
? zhStopImage ? zhStopImage
: enStopImage : enStopImage
@@ -233,10 +317,30 @@ export function MobileAnimalOverlay({
revealPhase === 'result' && rewardType !== 'none' && rewardSource !== null revealPhase === 'result' && rewardType !== 'none' && rewardSource !== null
const overlayAnimationKey = `${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}` const overlayAnimationKey = `${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}`
const childTimelineKey = shouldRenderReward ? overlayAnimationKey : 'closed' 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(() => { useEffect(() => {
if (revealPhase !== 'result') { if (revealPhase !== 'result') {
setHasRenderedReward(false) setHasRenderedReward(false)
setActiveNoRewardKey(null)
setDismissedNoRewardKey(null)
setIsNoRewardFadingOut(false)
return return
} }
@@ -245,6 +349,54 @@ export function MobileAnimalOverlay({
} }
}, [revealPhase, shouldRenderReward]) }, [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(() => { useEffect(() => {
if (!shouldRenderReward) { if (!shouldRenderReward) {
return 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) { if (hostingFlag) {
return ( 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]"> <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]">

View File

@@ -91,6 +91,9 @@ export function useAutoHostingRunner() {
const setCurrentUser = useAuthStore((state) => state.setCurrentUser) const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
const round = useGameRoundStore((state) => state.round) const round = useGameRoundStore((state) => state.round)
const clearSelections = useGameRoundStore((state) => state.clearSelections) const clearSelections = useGameRoundStore((state) => state.clearSelections)
const setRecentSuccessfulSelections = useGameRoundStore(
(state) => state.setRecentSuccessfulSelections,
)
const tableLimitMax = useGameSessionStore( const tableLimitMax = useGameSessionStore(
(state) => state.dashboard.tableLimitMax, (state) => state.dashboard.tableLimitMax,
) )
@@ -245,6 +248,7 @@ export function useAutoHostingRunner() {
lastBetPeriodNo: submittingRoundId, lastBetPeriodNo: submittingRoundId,
}) })
markRoundSubmitted(submittingRoundId, parseBalance(latestBalance)) markRoundSubmitted(submittingRoundId, parseBalance(latestBalance))
setRecentSuccessfulSelections(selections)
clearSelections() clearSelections()
} catch (error) { } catch (error) {
if (useGameAutoHostingStore.getState().isHosting) { if (useGameAutoHostingStore.getState().isHosting) {
@@ -272,6 +276,7 @@ export function useAutoHostingRunner() {
round.phase, round.phase,
selections, selections,
setCurrentUser, setCurrentUser,
setRecentSuccessfulSelections,
stopHosting, stopHosting,
tableLimitMax, tableLimitMax,
t, t,

View File

@@ -239,7 +239,8 @@ function applyJackpotHitMessage(message: GameSocketMessage) {
if (jackpotHitData?.hits.length) { if (jackpotHitData?.hits.length) {
useGameSessionStore.getState().pushJackpotBroadcasts( useGameSessionStore.getState().pushJackpotBroadcasts(
jackpotHitData.hits.map((hit) => ({ 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, nickname: hit.nickname,
periodNo: hit.period_no, periodNo: hit.period_no,
totalWin: hit.total_win, totalWin: hit.total_win,

View File

@@ -224,12 +224,19 @@ function extractJackpotHitItem(value: unknown): JackpotHitItemDto | null {
} }
const resultNumber = toOptionalNumber(source.result_number) 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 null
} }
return { return {
current_streak: currentStreak,
nickname: source.nickname, nickname: source.nickname,
period_no: source.period_no, period_no: source.period_no,
result_number: resultNumber, result_number: resultNumber,

View File

@@ -168,6 +168,16 @@ export default {
title: 'Round {{roundId}}', title: 'Round {{roundId}}',
action: 'Start Betting', 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: { actions: {
unifiedBetHint: 'Unified bet', unifiedBetHint: 'Unified bet',
totalBet: 'Total bet', totalBet: 'Total bet',
@@ -532,6 +542,9 @@ export default {
tapToEnter: 'Tap To Enter', tapToEnter: 'Tap To Enter',
getStart: 'Get Start', getStart: 'Get Start',
noBet: 'No Bet', noBet: 'No Bet',
noReward: 'No Win This Round',
selectedResult: 'Your Picks',
winningResult: 'Winning Flower',
}, },
history: { history: {
title: 'History', title: 'History',

View File

@@ -167,6 +167,16 @@ export default {
title: 'Ronde {{roundId}}', title: 'Ronde {{roundId}}',
action: 'Mulai Bertaruh', 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: { actions: {
unifiedBetHint: 'Bet seragam', unifiedBetHint: 'Bet seragam',
totalBet: 'Total bet', totalBet: 'Total bet',
@@ -532,6 +542,9 @@ export default {
tapToEnter: 'Ketuk Untuk Masuk', tapToEnter: 'Ketuk Untuk Masuk',
getStart: 'Mulai', getStart: 'Mulai',
noBet: 'Belum Bertaruh', noBet: 'Belum Bertaruh',
noReward: 'Belum Menang Ronde Ini',
selectedResult: 'Pilihan Kamu',
winningResult: 'Bunga Menang',
}, },
history: { history: {
title: 'Riwayat', title: 'Riwayat',

View File

@@ -170,6 +170,16 @@ export default {
title: 'Pusingan {{roundId}}', title: 'Pusingan {{roundId}}',
action: 'Mula Bertaruh', 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: { actions: {
unifiedBetHint: 'Taruhan seragam', unifiedBetHint: 'Taruhan seragam',
totalBet: 'Jumlah taruhan', totalBet: 'Jumlah taruhan',
@@ -537,6 +547,9 @@ export default {
tapToEnter: 'Ketik Untuk Masuk', tapToEnter: 'Ketik Untuk Masuk',
getStart: 'Mula', getStart: 'Mula',
noBet: 'Belum Bertaruh', noBet: 'Belum Bertaruh',
noReward: 'Belum Menang Pusingan Ini',
selectedResult: 'Pilihan Anda',
winningResult: 'Bunga Menang',
}, },
history: { history: {
title: 'Sejarah', title: 'Sejarah',

View File

@@ -166,6 +166,15 @@ export default {
title: '{{roundId}}期', title: '{{roundId}}期',
action: '开始下注', action: '开始下注',
}, },
jackpotBroadcast: {
ariaLabel: 'Jackpot 播报',
ariaMessage: '{{nickname}} 连胜 {{streak}} 次,赢得 {{amount}}',
iconAlt: '播报',
prefix: 'Jackpot 喜讯',
separator: '达成',
streak: '{{count}} 连胜',
winAction: '赢得',
},
actions: { actions: {
unifiedBetHint: '统一下注额', unifiedBetHint: '统一下注额',
totalBet: '总下注', totalBet: '总下注',
@@ -518,6 +527,9 @@ export default {
tapToEnter: '点击进入', tapToEnter: '点击进入',
getStart: '开始游戏', getStart: '开始游戏',
noBet: '未下注', noBet: '未下注',
noReward: '本期未中奖',
selectedResult: '已下注字花',
winningResult: '中奖字花',
}, },
history: { history: {
title: '历史记录', title: '历史记录',

View File

@@ -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 { MobileAnimal } from '@/features/game/components/mobile/mobile-animal.tsx'
import { MobileBettingStartAlert } from '@/features/game/components/mobile/mobile-betting-start-alert.tsx' import { MobileBettingStartAlert } from '@/features/game/components/mobile/mobile-betting-start-alert.tsx'
import { MobileControl } from '@/features/game/components/mobile/mobile-control.tsx' import { MobileControl } from '@/features/game/components/mobile/mobile-control.tsx'

View File

@@ -12,7 +12,6 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as LangRouteRouteImport } from './routes/$lang/route' import { Route as LangRouteRouteImport } from './routes/$lang/route'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as LangIndexRouteImport } from './routes/$lang/index' import { Route as LangIndexRouteImport } from './routes/$lang/index'
import { Route as LangWsTestRouteImport } from './routes/$lang/ws-test'
const LangRouteRoute = LangRouteRouteImport.update({ const LangRouteRoute = LangRouteRouteImport.update({
id: '/$lang', id: '/$lang',
@@ -29,36 +28,28 @@ const LangIndexRoute = LangIndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => LangRouteRoute, getParentRoute: () => LangRouteRoute,
} as any) } as any)
const LangWsTestRoute = LangWsTestRouteImport.update({
id: '/ws-test',
path: '/ws-test',
getParentRoute: () => LangRouteRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/$lang': typeof LangRouteRouteWithChildren '/$lang': typeof LangRouteRouteWithChildren
'/$lang/ws-test': typeof LangWsTestRoute
'/$lang/': typeof LangIndexRoute '/$lang/': typeof LangIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/$lang/ws-test': typeof LangWsTestRoute
'/$lang': typeof LangIndexRoute '/$lang': typeof LangIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/$lang': typeof LangRouteRouteWithChildren '/$lang': typeof LangRouteRouteWithChildren
'/$lang/ws-test': typeof LangWsTestRoute
'/$lang/': typeof LangIndexRoute '/$lang/': typeof LangIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/$lang' | '/$lang/ws-test' | '/$lang/' fullPaths: '/' | '/$lang' | '/$lang/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/$lang/ws-test' | '/$lang' to: '/' | '/$lang'
id: '__root__' | '/' | '/$lang' | '/$lang/ws-test' | '/$lang/' id: '__root__' | '/' | '/$lang' | '/$lang/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -89,23 +80,14 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LangIndexRouteImport preLoaderRoute: typeof LangIndexRouteImport
parentRoute: typeof LangRouteRoute 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 { interface LangRouteRouteChildren {
LangWsTestRoute: typeof LangWsTestRoute
LangIndexRoute: typeof LangIndexRoute LangIndexRoute: typeof LangIndexRoute
} }
const LangRouteRouteChildren: LangRouteRouteChildren = { const LangRouteRouteChildren: LangRouteRouteChildren = {
LangWsTestRoute: LangWsTestRoute,
LangIndexRoute: LangIndexRoute, LangIndexRoute: LangIndexRoute,
} }

View File

@@ -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>
)
}

View File

@@ -193,6 +193,7 @@ export interface AutoHostingStopRules {
} }
export interface JackpotBroadcastItem { export interface JackpotBroadcastItem {
currentStreak: number
id: string id: string
nickname: string nickname: string
periodNo: string periodNo: string
@@ -428,6 +429,7 @@ export interface GamePeriodTickDto {
} }
export interface JackpotHitItemDto { export interface JackpotHitItemDto {
current_streak: number
nickname: string nickname: string
period_no: string period_no: string
result_number: number result_number: number