feat(game): 优化加载动画并添加无奖励结算显示
- 替换粒子动画为预加载背景图片,提升加载性能 - 添加桌面端无奖励结算界面显示玩家选择和开奖结果 - 实现消息广播组件的动画效果和状态管理 - 添加代码审查报告文档 - 更新GitNexus索引统计信息 - 调整加载界面布局和样式优化
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
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>
|
<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"
|
||||||
|
|||||||
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>
|
) 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>
|
||||||
|
|||||||
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_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]">
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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_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]">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '历史记录',
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
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
|
||||||
|
|||||||