diff --git a/.cloudbase-sites/preview.json b/.cloudbase-sites/preview.json new file mode 100644 index 0000000..9535b25 --- /dev/null +++ b/.cloudbase-sites/preview.json @@ -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" +} diff --git a/package.json b/package.json index 70232be..ae59654 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c04a6ec..ffcec11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/api/game-api.ts b/src/api/game-api.ts index a9e8099..c9ffed8 100644 --- a/src/api/game-api.ts +++ b/src/api/game-api.ts @@ -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, diff --git a/src/components/jackpot-pool-ticker.tsx b/src/components/jackpot-pool-ticker.tsx new file mode 100644 index 0000000..fed9e25 --- /dev/null +++ b/src/components/jackpot-pool-ticker.tsx @@ -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() + const amountCharacterItems = Array.from(amountLabel).map((character) => { + const nextCount = (amountCharacterCounts.get(character) ?? 0) + 1 + + amountCharacterCounts.set(character, nextCount) + + return { + character, + key: `${character}-${nextCount}`, + } + }) + + return ( +
+ + + {t('game.jackpotPool.label')} + + + {amountCharacterItems.map(({ character, key }) => { + const isNumber = /\d/.test(character) + + return ( + + ) + })} + +
+ ) +} diff --git a/src/components/message-broadcast.tsx b/src/components/message-broadcast.tsx index 3712acb..0feb071 100644 --- a/src/components/message-broadcast.tsx +++ b/src/components/message-broadcast.tsx @@ -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, )} > - -
- - {activeBroadcast ? ( - - - {t('game.jackpotBroadcast.prefix')} - - - {activeBroadcast.nickname} - - - {t('game.jackpotBroadcast.separator')} - - - {t('game.jackpotBroadcast.streak', { - count: activeBroadcast.currentStreak, - })} - - - {t('game.jackpotBroadcast.winAction')} - - - {formatWinAmount(activeBroadcast.totalWin)} - - - ) : null} - +
+ +
+ + {activeBroadcast ? ( + + + {t('game.jackpotBroadcast.prefix')} + + + {activeBroadcast.nickname} + + + {t('game.jackpotBroadcast.separator')} + + + {t('game.jackpotBroadcast.streak', { + count: activeBroadcast.currentStreak, + })} + + + {t('game.jackpotBroadcast.winAction')} + + + {formatWinAmount(activeBroadcast.totalWin)} + + + ) : null} + +
+ ) } diff --git a/src/features/game/shared/initial-state.ts b/src/features/game/shared/initial-state.ts index 3502e1f..e576455 100644 --- a/src/features/game/shared/initial-state.ts +++ b/src/features/game/shared/initial-state.ts @@ -48,6 +48,7 @@ function createEmptyDashboardState(nowIso: string): DashboardState { return { countdownMs: 0, featuredCellId: null, + jackpotPool: null, onlinePlayers: 0, tableLimitMax: 0, tableLimitMin: 0, diff --git a/src/hooks/use-game-realtime-sync.ts b/src/hooks/use-game-realtime-sync.ts index 2060857..5fe620b 100644 --- a/src/hooks/use-game-realtime-sync.ts +++ b/src/hooks/use-game-realtime-sync.ts @@ -63,6 +63,7 @@ function applyLobbySync(result: Awaited>) { }, dashboard: { ...currentSessionState.dashboard, + jackpotPool: result.snapshot.dashboard.jackpotPool, tableLimitMax: result.snapshot.dashboard.tableLimitMax, tableLimitMin: result.snapshot.dashboard.tableLimitMin, }, diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 70910b8..c31c4cc 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -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', diff --git a/src/locales/id-ID.ts b/src/locales/id-ID.ts index f099eda..692f1e8 100644 --- a/src/locales/id-ID.ts +++ b/src/locales/id-ID.ts @@ -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', diff --git a/src/locales/ms-MY.ts b/src/locales/ms-MY.ts index fd7799a..22722e0 100644 --- a/src/locales/ms-MY.ts +++ b/src/locales/ms-MY.ts @@ -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', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 4b6cb63..a2e009d 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -175,6 +175,11 @@ export default { streak: '{{count}} 连胜', winAction: '赢得', }, + jackpotPool: { + ariaLabel: '彩金池当前金额 {{amount}}', + iconAlt: '彩金池', + label: '彩金池', + }, actions: { unifiedBetHint: '统一下注额', totalBet: '总下注', diff --git a/src/style/index.css b/src/style/index.css index 3f66416..c218802 100644 --- a/src/style/index.css +++ b/src/style/index.css @@ -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; diff --git a/src/type/game.type.ts b/src/type/game.type.ts index e3a643d..6658927 100644 --- a/src/type/game.type.ts +++ b/src/type/game.type.ts @@ -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