feat(game): 添加奖池功能支持

- 在国际化文件中添加奖池相关的翻译配置
- 定义 JackpotPoolState 和 GameJackpotPoolDto 类型接口
- 在 DashboardState 中添加 jackpotPool 字段
- 实现 normalizeJackpotPoolState 函数处理奖池数据转换
- 更新游戏 API 中的初始化和数据归一化逻辑
- 添加 Oxanium 字体依赖并配置奖池金额字体样式
- 实现 JackpotPoolTicker 组件显示动态增长的奖池金额
- 在消息广播组件中集成奖池显示功能
- 更新实时同步钩子中的奖池数据处理逻辑
This commit is contained in:
JiaJun
2026-07-15 15:09:42 +08:00
parent 2c1a69dd9a
commit fe3b839297
14 changed files with 286 additions and 55 deletions

View File

@@ -0,0 +1,12 @@
{
"pid": 98343,
"port": 17174,
"host": "0.0.0.0",
"base": "/",
"framework": "vite-react",
"command": "/Users/jiaunun/.nvm/versions/node/v24.15.0/bin/node /Users/jiaunun/Desktop/36-character-flower/node_modules/vite/bin/vite.js --host 0.0.0.0 --port 17174 --strictPort --base /",
"internalUrl": "http://127.0.0.1:17174/",
"logPath": "/Users/jiaunun/Desktop/36-character-flower/.cloudbase-sites/logs/preview-1784093877629.log",
"cwd": "/Users/jiaunun/Desktop/36-character-flower",
"startedAt": "2026-07-15T05:37:58.655Z"
}

View File

@@ -29,6 +29,7 @@
},
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/oxanium": "^5.2.8",
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.99.0",
"@tanstack/react-query-devtools": "^5.99.0",

8
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@fontsource-variable/geist':
specifier: ^5.2.8
version: 5.2.9
'@fontsource-variable/oxanium':
specifier: ^5.2.8
version: 5.2.8
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.4.0(react-hook-form@7.75.0(react@19.2.5))
@@ -635,6 +638,9 @@ packages:
'@fontsource-variable/geist@5.2.9':
resolution: {integrity: sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ==}
'@fontsource-variable/oxanium@5.2.8':
resolution: {integrity: sha512-W3HWxRLXVB6yox3dgm1DIGOp98pz8KglwiM6/2BsMopvZrg98mXI9citFWz2pUX0FOOLwf8fmHxX+KrIn+6BoA==}
'@hono/node-server@1.19.14':
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
engines: {node: '>=18.14.1'}
@@ -4785,6 +4791,8 @@ snapshots:
'@fontsource-variable/geist@5.2.9': {}
'@fontsource-variable/oxanium@5.2.8': {}
'@hono/node-server@1.19.14(hono@4.12.18)':
dependencies:
hono: 4.12.18

View File

@@ -30,6 +30,7 @@ import type {
GameBootstrapSnapshot,
GameCell,
GameCellDto,
GameJackpotPoolDto,
GameLobbyInitDto,
GameLobbyInitResult,
GameLobbyPeriodDto,
@@ -40,6 +41,7 @@ import type {
GameRoundFeedDto,
HistoryEntry,
HistoryEntryDto,
JackpotPoolState,
NoticeConfirmDto,
NoticeDetailDto,
NoticeListDto,
@@ -164,10 +166,33 @@ function normalizeAnnouncementState(dto: AnnouncementStateDto) {
} satisfies AnnouncementState
}
function normalizeJackpotPoolState(
dto: GameJackpotPoolDto | null | undefined,
updatedAt: string | null,
) {
if (!dto) {
return null
}
const current = Number(dto.current)
const speed = Number(dto.speed)
if (!Number.isFinite(current) || !Number.isFinite(speed)) {
return null
}
return {
current,
speedPerSecond: Math.max(0, speed),
updatedAt,
} satisfies JackpotPoolState
}
function normalizeDashboardState(dto: DashboardStateDto) {
return {
countdownMs: dto.countdown_ms,
featuredCellId: dto.featured_cell_id,
jackpotPool: normalizeJackpotPoolState(dto.jackpot_pool, dto.updated_at),
onlinePlayers: dto.online_players,
tableLimitMax: dto.table_limit_max,
tableLimitMin: dto.table_limit_min,
@@ -334,6 +359,7 @@ export function normalizeGameLobbyInit(dto: GameLobbyInitDto) {
dashboard: {
countdownMs: 0,
featuredCellId: null,
jackpotPool: normalizeJackpotPoolState(dto.jackpot_pool, baseIso),
onlinePlayers: 0,
tableLimitMax: Number(dto.bet_config.max_bet_per_number) || 0,
tableLimitMin: Number(dto.bet_config.min_bet_per_number) || 0,

View File

@@ -0,0 +1,138 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import diamond from '@/assets/system/diamond.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import { cn } from '@/lib/utils.ts'
import { useGameSessionStore } from '@/store/game'
const JACKPOT_POOL_TICK_MS = 1000
const jackpotPoolFormatter = new Intl.NumberFormat('en-US', {
maximumFractionDigits: 2,
minimumFractionDigits: 2,
})
type JackpotPoolTickerProps = {
className?: string
variant?: 'inline' | 'standalone'
}
export function JackpotPoolTicker({
className,
variant = 'standalone',
}: JackpotPoolTickerProps) {
const { t } = useTranslation()
const jackpotPool = useGameSessionStore(
(state) => state.dashboard.jackpotPool,
)
const poolCurrent = jackpotPool?.current ?? null
const poolSpeedPerSecond = jackpotPool?.speedPerSecond ?? 0
const poolUpdatedAt = jackpotPool?.updatedAt ?? null
const [nowMs, setNowMs] = useState(() => Date.now())
useEffect(() => {
setNowMs(Date.now())
if (poolCurrent === null || poolSpeedPerSecond <= 0) {
return
}
const timerId = window.setInterval(() => {
setNowMs(Date.now())
}, JACKPOT_POOL_TICK_MS)
return () => {
window.clearInterval(timerId)
}
}, [poolCurrent, poolSpeedPerSecond])
const displayAmount = useMemo(() => {
if (poolCurrent === null) {
return null
}
const updatedAtMs = poolUpdatedAt ? Date.parse(poolUpdatedAt) : Number.NaN
const baseTimeMs = Number.isFinite(updatedAtMs) ? updatedAtMs : nowMs
const elapsedSeconds = Math.max(0, (nowMs - baseTimeMs) / 1000)
return poolCurrent + elapsedSeconds * poolSpeedPerSecond
}, [nowMs, poolCurrent, poolSpeedPerSecond, poolUpdatedAt])
if (displayAmount === null) {
return null
}
const amountLabel = jackpotPoolFormatter.format(displayAmount)
const isInline = variant === 'inline'
const amountCharacterCounts = new Map<string, number>()
const amountCharacterItems = Array.from(amountLabel).map((character) => {
const nextCount = (amountCharacterCounts.get(character) ?? 0) + 1
amountCharacterCounts.set(character, nextCount)
return {
character,
key: `${character}-${nextCount}`,
}
})
return (
<section
aria-label={t('game.jackpotPool.ariaLabel', { amount: amountLabel })}
className={cn(
'relative flex w-full min-w-0 items-center overflow-hidden whitespace-nowrap',
isInline
? 'h-design-20 rounded-[3px] border border-[#B86A13]/55 bg-[rgba(32,18,3,0.72)] px-design-4 md:h-design-52 md:rounded-[4px] md:px-design-10'
: 'h-design-34 !rounded-[4px] border border-[#B86A13]/70 bg-[rgba(32,18,3,0.82)] !px-design-10 !py-0 md:h-design-48 md:!rounded-[5px] md:!px-design-16',
className,
)}
>
<SmartImage
alt={t('game.jackpotPool.iconAlt')}
className={cn(
'shrink-0',
isInline
? 'mr-design-3 h-design-10 w-design-10 md:mr-design-8 md:h-design-24 md:w-design-24'
: 'mr-design-5 h-design-14 w-design-14 md:mr-design-10 md:h-design-26 md:w-design-26',
)}
src={diamond}
/>
<span
className={cn(
'shrink-0 font-bold tracking-[0.08em] text-[#D8AA49]',
isInline
? 'mr-[20px] text-design-6 md:text-design-12'
: 'mr-[20px] text-design-8 md:text-design-14',
)}
>
{t('game.jackpotPool.label')}
</span>
<span
className={cn(
'min-w-0 shrink truncate font-jackpot-amount font-extrabold leading-none tabular-nums [font-variation-settings:"wght"_760]',
isInline
? 'text-design-10 md:text-design-27'
: 'text-design-16 md:text-design-30',
)}
>
{amountCharacterItems.map(({ character, key }) => {
const isNumber = /\d/.test(character)
return (
<span
aria-hidden="true"
className={cn(
'inline-block text-[#FFD15A]',
isNumber
? '[text-shadow:0_0_calc(var(--design-unit)*3)_rgba(255,240,166,0.88),0_0_calc(var(--design-unit)*7)_rgba(255,209,90,0.64),0_calc(var(--design-unit)*1)_0_rgba(92,43,2,0.92)]'
: 'mx-[0.03em] text-[#D49322]',
)}
key={key}
>
{character}
</span>
)
})}
</span>
</section>
)
}

View File

@@ -2,6 +2,7 @@ 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 { JackpotPoolTicker } from '@/components/jackpot-pool-ticker.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { cn } from '@/lib/utils.ts'
import { useGameSessionStore } from '@/store/game'
@@ -103,65 +104,71 @@ export function MessageBroadcast({ className }: MessageBroadcastProps) {
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',
'h-design-24 gap-design-4 !rounded-[3px] !px-design-5 !py-0 text-design-8',
'md:h-design-65 md:gap-design-12 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 className="flex min-w-0 flex-1 items-center gap-design-4 md:gap-design-10">
<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>
</div>
<JackpotPoolTicker
variant="inline"
className="!w-fit max-w-[54%] shrink-0 md:max-w-[calc(var(--design-unit)*320)]"
/>
</section>
)
}

View File

@@ -48,6 +48,7 @@ function createEmptyDashboardState(nowIso: string): DashboardState {
return {
countdownMs: 0,
featuredCellId: null,
jackpotPool: null,
onlinePlayers: 0,
tableLimitMax: 0,
tableLimitMin: 0,

View File

@@ -63,6 +63,7 @@ function applyLobbySync(result: Awaited<ReturnType<typeof getGameLobbyInit>>) {
},
dashboard: {
...currentSessionState.dashboard,
jackpotPool: result.snapshot.dashboard.jackpotPool,
tableLimitMax: result.snapshot.dashboard.tableLimitMax,
tableLimitMin: result.snapshot.dashboard.tableLimitMin,
},

View File

@@ -178,6 +178,11 @@ export default {
streak: '{{count}}-win streak',
winAction: 'won',
},
jackpotPool: {
ariaLabel: 'Current jackpot pool {{amount}}',
iconAlt: 'Jackpot pool',
label: 'Jackpot',
},
actions: {
unifiedBetHint: 'Unified bet',
totalBet: 'Total bet',

View File

@@ -177,6 +177,11 @@ export default {
streak: 'streak {{count}} menang',
winAction: 'memenangkan',
},
jackpotPool: {
ariaLabel: 'Pool jackpot saat ini {{amount}}',
iconAlt: 'Pool jackpot',
label: 'Jackpot',
},
actions: {
unifiedBetHint: 'Bet seragam',
totalBet: 'Total bet',

View File

@@ -180,6 +180,11 @@ export default {
streak: 'streak {{count}} menang',
winAction: 'memenangi',
},
jackpotPool: {
ariaLabel: 'Dana jackpot semasa {{amount}}',
iconAlt: 'Dana jackpot',
label: 'Jackpot',
},
actions: {
unifiedBetHint: 'Taruhan seragam',
totalBet: 'Jumlah taruhan',

View File

@@ -175,6 +175,11 @@ export default {
streak: '{{count}} 连胜',
winAction: '赢得',
},
jackpotPool: {
ariaLabel: '彩金池当前金额 {{amount}}',
iconAlt: '彩金池',
label: '彩金池',
},
actions: {
unifiedBetHint: '统一下注额',
totalBet: '总下注',

View File

@@ -3,6 +3,7 @@
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@import "@fontsource-variable/oxanium";
@custom-variant dark (&:is(.dark *));
@@ -12,6 +13,8 @@
--font-mono:
"JetBrains Mono", "SFMono-Regular", "SF Mono", Consolas, monospace;
--font-countdown: "Countdown", "Inter", "SF Pro Display", sans-serif;
--font-jackpot-amount:
"Oxanium Variable", "Countdown", "SF Pro Display", sans-serif;
--color-game-bg: #07111f;
--color-game-surface: #0d1b2d;
--color-game-surface-strong: #12253d;

View File

@@ -109,9 +109,16 @@ export interface AnnouncementState {
lastUpdatedAt: string | null
}
export interface JackpotPoolState {
current: number
speedPerSecond: number
updatedAt: string | null
}
export interface DashboardState {
countdownMs: number
featuredCellId: number | null
jackpotPool: JackpotPoolState | null
onlinePlayers: number
tableLimitMax: number
tableLimitMin: number
@@ -332,6 +339,7 @@ export interface AnnouncementStateDto {
export interface DashboardStateDto {
countdown_ms: number
featured_cell_id: number | null
jackpot_pool?: GameJackpotPoolDto | null
online_players: number
table_limit_max: number
table_limit_min: number
@@ -339,6 +347,11 @@ export interface DashboardStateDto {
updated_at: string | null
}
export interface GameJackpotPoolDto {
current: string
speed: string
}
export interface ConnectionStateDto {
connected_at: string | null
last_error: string | null
@@ -451,6 +464,7 @@ export interface GameLobbyStreakWinRewardDto {
export interface GameLobbyInitDto {
bet_config: GameLobbyBetConfigDto
dictionary: GameLobbyDictionaryItemDto[]
jackpot_pool?: GameJackpotPoolDto | null
period?: GameLobbyPeriodDto | null
runtime_enabled: boolean
server_time: number