feat(game): 添加动物精灵图像变体支持并优化揭示动画

- 引入 light-animal-sprite 图像资源和 variant 属性支持
- 在动物组件中实现正常和浅色精灵图像切换功能
- 重构桌面和移动版动物组件的揭示动画逻辑
- 移除冗余的运动偏好检测和布局效果钩子
- 调整揭示动画的时间参数和平滑度曲线
- 更新资源预加载配置以包含浅色动物图像
- 修改控制组件中的投注数量调整状态管理
- 优化脚本生成精灵图像的复用性
This commit is contained in:
JiaJun
2026-06-11 15:50:22 +08:00
parent c3059549e6
commit 5768632313
48 changed files with 642 additions and 381 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

View File

@@ -7,6 +7,9 @@ const bootImageModules = import.meta.glob(
'!../assets/animal/[1-9].webp',
'!../assets/animal/[12][0-9].webp',
'!../assets/animal/3[0-6].webp',
'!../assets/light-animal/[1-9].webp',
'!../assets/light-animal/[12][0-9].webp',
'!../assets/light-animal/3[0-6].webp',
'!../assets/reward/[1-9].webp',
'!../assets/reward/[12][0-9].webp',
'!../assets/reward/3[0-6].webp',
@@ -21,7 +24,7 @@ const BOOT_MIN_VISIBLE_MS = 900
const PC_LOADING_BG_URL = '/loading/pc-loading-bg.webp'
const MOBILE_LOADING_BG_URL = '/loading/mobile-loading-bg.webp'
const SPRITE_SOURCE_IMAGE_PATTERN =
/^\.\.\/assets\/(?:animal|reward)\/(?:[1-9]|[12]\d|3[0-6])\.webp$/
/^\.\.\/assets\/(?:animal|light-animal|reward)\/(?:[1-9]|[12]\d|3[0-6])\.webp$/
function preloadImage(url: string) {
return new Promise<void>((resolve) => {

View File

@@ -1,6 +1,6 @@
import { TriangleAlert } from 'lucide-react'
import { motion, useReducedMotion } from 'motion/react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { motion } from 'motion/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import animalBorderImage from '@/assets/game/animal-border.webp'
import diamondIcon from '@/assets/system/diamond.webp'
@@ -17,8 +17,9 @@ import { useGameRoundStore } from '@/store/game'
const SETTLEMENT_REVEAL_RANDOM_DURATION_MS = 3_200
const SETTLEMENT_REVEAL_SETTLE_DURATION_MS = 800
const SETTLEMENT_REVEAL_RESULT_HOLD_MS = 1_000
const SETTLEMENT_REVEAL_MIN_STEP_MS = 180
const SETTLEMENT_REVEAL_MAX_STEP_MS = 960
const SETTLEMENT_REVEAL_SPIN_STEP_MS = 160
const SETTLEMENT_REVEAL_MIN_STEP_MS = 300
const SETTLEMENT_REVEAL_MAX_STEP_MS = 760
function getRandomAnimalId(ids: number[], currentId: number | null) {
if (ids.length === 0) {
@@ -40,7 +41,7 @@ function getRandomAnimalId(ids: number[], currentId: number | null) {
function getSettlementRevealStepDelay(progress: number) {
const clampedProgress = Math.min(Math.max(progress, 0), 1)
const easedProgress = clampedProgress ** 1.65
const easedProgress = clampedProgress ** 1.35
return (
SETTLEMENT_REVEAL_MIN_STEP_MS +
@@ -63,19 +64,8 @@ export function DesktopAnimal({
onSelect,
}: DesktopAnimalProps) {
const { t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const animalIds = useMemo(() => FLOWER_IMAGE_LIST.map((item) => item.id), [])
const containerRef = useRef<HTMLElement | null>(null)
const cellRefs = useRef(new Map<number, HTMLButtonElement>())
const [revealCellId, setRevealCellId] = useState<number | null>(null)
const [revealFrame, setRevealFrame] = useState<{
height: number
left: number
top: number
width: number
} | null>(null)
const [isRevealHoldingResult, setIsRevealHoldingResult] = useState(false)
const [isRevealSettlingResult, setIsRevealSettlingResult] = useState(false)
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const revealWinningCellId = useGameRoundStore(
(state) => state.revealAnimation.winningCellId,
@@ -102,10 +92,6 @@ export function DesktopAnimal({
showStandbyState,
} = useAnimalVm(animalIds, onSelect)
const isRevealRunning =
revealPhase === 'spinning' ||
(revealPhase === 'stopping' && !isRevealHoldingResult)
const isRevealResult = revealPhase === 'result'
const [hasRewardOverlayStarted, setHasRewardOverlayStarted] = useState(false)
const shouldHideRevealHighlight =
revealPhase === 'result' &&
@@ -131,26 +117,20 @@ export function DesktopAnimal({
useEffect(() => {
if (revealPhase === 'idle') {
setRevealCellId(null)
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return
}
if (revealPhase === 'result') {
setRevealCellId(shouldHideRevealHighlight ? null : revealWinningCellId)
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return
}
if (revealPhase === 'spinning') {
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
const intervalId = window.setInterval(() => {
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
}, 70)
}, SETTLEMENT_REVEAL_SPIN_STEP_MS)
return () => {
window.clearInterval(intervalId)
@@ -158,25 +138,18 @@ export function DesktopAnimal({
}
if (revealWinningCellId === null) {
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return
}
const startedAt = performance.now()
let timeoutId = 0
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
const step = () => {
const elapsedMs = performance.now() - startedAt
if (elapsedMs >= SETTLEMENT_REVEAL_RANDOM_DURATION_MS) {
setIsRevealSettlingResult(true)
setRevealCellId(revealWinningCellId)
timeoutId = window.setTimeout(() => {
setIsRevealSettlingResult(false)
setIsRevealHoldingResult(true)
timeoutId = window.setTimeout(() => {
finishRevealAnimation()
}, SETTLEMENT_REVEAL_RESULT_HOLD_MS)
@@ -207,45 +180,11 @@ export function DesktopAnimal({
shouldHideRevealHighlight,
])
useLayoutEffect(() => {
if (revealCellId === null) {
setRevealFrame(null)
return
}
const syncRevealFrame = () => {
const container = containerRef.current
const cell = cellRefs.current.get(revealCellId)
if (!container || !cell) {
setRevealFrame(null)
return
}
const containerRect = container.getBoundingClientRect()
const cellRect = cell.getBoundingClientRect()
setRevealFrame({
height: cellRect.height,
left: cellRect.left - containerRect.left,
top: cellRect.top - containerRect.top,
width: cellRect.width,
})
}
syncRevealFrame()
window.addEventListener('resize', syncRevealFrame)
return () => {
window.removeEventListener('resize', syncRevealFrame)
}
}, [revealCellId])
return (
<section
ref={containerRef}
className={cn(
'relative grid w-full grid-cols-6 gap-design-5 overflow-hidden common-neon-inset',
'relative grid w-full grid-cols-6 gap-design-5 common-neon-inset',
!revealCellId && 'overflow-hidden',
className,
)}
>
@@ -253,13 +192,13 @@ export function DesktopAnimal({
const selectionMeta = selectionByCell[item.id]
const hasPlacedSelection = Boolean(selectionMeta)
const isMarqueeActive = showStandbyState && item.id === marqueeId
const isRevealWinner =
!shouldHideRevealHighlight &&
(isRevealResult || isRevealHoldingResult) &&
revealWinningCellId === item.id
const isRevealActive =
!shouldHideRevealHighlight && revealCellId === item.id
const warningType =
cellWarning?.cellId === item.id ? cellWarning.type : null
const showCellWarning = warningType !== null
const animalSpriteVariant =
hasPlacedSelection || isMarqueeActive ? 'light' : 'normal'
const warningLabel =
warningType === 'balance'
? t('gameDesktop.animal.insufficientBalanceRecharge')
@@ -270,13 +209,6 @@ export function DesktopAnimal({
return (
<motion.button
key={item.id}
ref={(node) => {
if (node) {
cellRefs.current.set(item.id, node)
} else {
cellRefs.current.delete(item.id)
}
}}
type="button"
disabled={lockInteraction || showStopOverlay}
onClick={() => handleSelect(item.id)}
@@ -302,22 +234,16 @@ export function DesktopAnimal({
: { duration: 0.16, ease: 'easeOut' }
}
className={cn(
'relative flex h-design-112 flex-col items-center justify-center overflow-hidden rounded-[calc(var(--design-unit)*18)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150',
'relative flex h-design-112 flex-col items-center justify-center rounded-[calc(var(--design-unit)*18)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150',
!isRevealActive && 'overflow-hidden',
lockInteraction
? 'cursor-not-allowed opacity-90'
: 'cursor-pointer hover:-translate-y-[1px]',
isMarqueeActive &&
'border-[rgba(121,255,250,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(85,255,247,0.98),0_0_calc(var(--design-unit)*34)_rgba(39,245,255,0.88),inset_0_0_calc(var(--design-unit)*26)_rgba(112,255,248,0.34)]',
isRevealRunning &&
'border-[rgba(104,255,249,0.76)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(68,244,255,0.46),0_0_calc(var(--design-unit)*22)_rgba(37,214,255,0.28),inset_0_0_calc(var(--design-unit)*16)_rgba(115,255,247,0.18)] brightness-115 saturate-125',
isRevealWinner &&
'border-[rgba(121,255,250,0.72)] shadow-[0_0_calc(var(--design-unit)*12)_rgba(81,248,255,0.54),0_0_calc(var(--design-unit)*22)_rgba(30,199,255,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(125,255,249,0.24)] brightness-110 saturate-120',
showCellWarning &&
'border-[rgba(255,92,92,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,88,88,0.56),0_0_calc(var(--design-unit)*28)_rgba(255,44,44,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(255,126,126,0.3)]',
!showStandbyState &&
!hasPlacedSelection &&
!isRevealWinner &&
'opacity-95',
!showStandbyState && !hasPlacedSelection && 'opacity-95',
itemClassName,
)}
>
@@ -354,15 +280,11 @@ export function DesktopAnimal({
'pointer-events-none absolute inset-[calc(var(--design-unit)*2)] rounded-[calc(var(--design-unit)*15)] opacity-0 transition-opacity duration-150',
isMarqueeActive &&
'bg-[radial-gradient(circle_at_center,rgba(129,255,250,0.48)_0%,rgba(94,255,247,0.18)_38%,rgba(43,236,255,0.08)_56%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(119,255,249,0.98),0_0_calc(var(--design-unit)*28)_rgba(53,246,255,0.9),0_0_calc(var(--design-unit)*44)_rgba(37,241,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(163,255,250,0.52)]',
isRevealRunning &&
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.36)_0%,rgba(77,244,255,0.16)_40%,rgba(27,183,255,0.07)_68%,transparent_88%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(95,249,255,0.46),inset_0_0_calc(var(--design-unit)*18)_rgba(151,255,250,0.24)]',
isRevealWinner &&
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.34)_0%,rgba(67,226,255,0.16)_38%,rgba(25,131,255,0.07)_58%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(92,248,255,0.42),inset_0_0_calc(var(--design-unit)*18)_rgba(126,255,250,0.24)]',
showCellWarning &&
'bg-[radial-gradient(circle_at_center,rgba(255,106,106,0.34)_0%,rgba(255,58,58,0.18)_42%,rgba(108,0,0,0.2)_78%,transparent_100%)] opacity-100',
)}
/>
{!showStandbyState && !hasPlacedSelection && !isRevealWinner ? (
{!showStandbyState && !hasPlacedSelection ? (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[calc(var(--design-unit)*2)] z-20 rounded-[calc(var(--design-unit)*15)] bg-[rgba(4,16,24,0.52)] shadow-[inset_0_0_calc(var(--design-unit)*20)_rgba(3,9,14,0.56)]"
@@ -371,15 +293,34 @@ export function DesktopAnimal({
<AnimalSpriteImage
animalId={item.id}
alt={`animal-${item.id}`}
variant={animalSpriteVariant}
className={cn(
'absolute left-[1.5%] right-[1.5%] top-[2.9%] bottom-[2.9%] z-10 overflow-hidden rounded-[calc(var(--design-unit)*14)]',
isRevealRunning &&
'brightness-115 saturate-125 drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(101,250,255,0.42)]',
isRevealWinner &&
'brightness-110 saturate-120 drop-shadow-[0_0_calc(var(--design-unit)*9)_rgba(106,250,255,0.44)]',
imageClassName,
)}
/>
{isRevealActive ? (
<span className="pointer-events-none absolute inset-0 z-40 overflow-visible rounded-[calc(var(--design-unit)*18)] border border-[rgba(121,255,250,0.95)] shadow-[0_0_calc(var(--design-unit)*4)_calc(var(--design-unit)*1)_rgba(121,255,250,0.59),0_0_calc(var(--design-unit)*12)_calc(var(--design-unit)*3)_rgba(81,248,255,0.35),0_0_calc(var(--design-unit)*24)_calc(var(--design-unit)*4)_rgba(30,199,255,0.19)] transition-opacity duration-150">
<SmartImage
src={animalBorderImage}
alt=""
aria-hidden="true"
priority
showSkeleton={false}
className="pointer-events-none absolute inset-0 z-20 h-full w-full"
imgClassName="object-fill"
/>
<span className="pointer-events-none absolute left-design-24 top-design-16 z-30 text-design-32 font-bold leading-none text-[#4BFFFE]">
{String(item.id).padStart(2, '0')}
</span>
<AnimalSpriteImage
animalId={item.id}
variant="light"
alt={`animal-${item.id}`}
className="absolute left-[1.5%] right-[1.5%] top-[2.9%] bottom-[2.9%] z-10 overflow-hidden rounded-[calc(var(--design-unit)*14)]"
/>
</span>
) : null}
{showCellWarning ? (
<motion.span
initial={{ opacity: 0, scale: 0.94 }}
@@ -435,43 +376,6 @@ export function DesktopAnimal({
)
})}
{revealFrame ? (
<div
aria-hidden="true"
className={cn(
'pointer-events-none absolute z-40 transform-gpu transition-[height,transform,width]',
prefersReducedMotion
? 'duration-0'
: isRevealSettlingResult
? 'duration-[800ms] ease-[cubic-bezier(0.16,1,0.3,1)]'
: 'duration-[160ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
)}
style={{
height: revealFrame.height,
transform: `translate(${revealFrame.left}px, ${revealFrame.top}px)`,
width: revealFrame.width,
}}
>
<div
className="gold-reveal-glow rounded-[calc(var(--design-unit)*16)]"
style={
prefersReducedMotion
? {
animation: 'none',
opacity: 0.36,
transform: 'scale(1)',
}
: undefined
}
/>
<div className="gold-reveal-static-border rounded-[calc(var(--design-unit)*16)]" />
<div
className="gold-reveal-shell rounded-[calc(var(--design-unit)*16)]"
style={prefersReducedMotion ? { animation: 'none' } : undefined}
/>
</div>
) : null}
<DesktopAnimalOverlay showStopOverlay={showStopOverlay} />
<RoundBettingStartAlert className="z-50" />

View File

@@ -28,6 +28,7 @@ export function DesktopControl() {
actionsEnabled,
canClear,
canDecreaseBetQuantity,
canIncreaseBetQuantity,
chips,
confirmLabel,
confirmState,
@@ -185,7 +186,7 @@ export function DesktopControl() {
>
{chips.map((chip) => {
const isSelected = chip.id === selectedChipId
const showLockedState = !acceptingBets
const showLockedState = !acceptingBets || chip.isDisabled
return (
<motion.button
@@ -367,11 +368,13 @@ export function DesktopControl() {
</div>
<button
type="button"
disabled={!acceptingBets}
disabled={!acceptingBets || !canIncreaseBetQuantity}
onClick={onIncreaseBetQuantity}
className={cn(
'flex h-design-40 w-design-40 shrink-0 items-center justify-center',
acceptingBets ? 'cursor-pointer' : 'cursor-not-allowed',
acceptingBets && canIncreaseBetQuantity
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50',
)}
>
<SmartImage

View File

@@ -1,6 +1,6 @@
import { TriangleAlert } from 'lucide-react'
import { motion, useReducedMotion } from 'motion/react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { motion } from 'motion/react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import animalBorderImage from '@/assets/game/animal-border.webp'
import diamondIcon from '@/assets/system/diamond.webp'
@@ -17,8 +17,9 @@ import { useGameRoundStore } from '@/store/game'
const SETTLEMENT_REVEAL_RANDOM_DURATION_MS = 3_200
const SETTLEMENT_REVEAL_SETTLE_DURATION_MS = 800
const SETTLEMENT_REVEAL_RESULT_HOLD_MS = 1_000
const SETTLEMENT_REVEAL_MIN_STEP_MS = 180
const SETTLEMENT_REVEAL_MAX_STEP_MS = 960
const SETTLEMENT_REVEAL_SPIN_STEP_MS = 160
const SETTLEMENT_REVEAL_MIN_STEP_MS = 300
const SETTLEMENT_REVEAL_MAX_STEP_MS = 760
function getRandomAnimalId(ids: number[], currentId: number | null) {
if (ids.length === 0) {
@@ -40,7 +41,7 @@ function getRandomAnimalId(ids: number[], currentId: number | null) {
function getSettlementRevealStepDelay(progress: number) {
const clampedProgress = Math.min(Math.max(progress, 0), 1)
const easedProgress = clampedProgress ** 1.65
const easedProgress = clampedProgress ** 1.35
return (
SETTLEMENT_REVEAL_MIN_STEP_MS +
@@ -63,20 +64,8 @@ export function MobileAnimal({
onSelect,
}: MobileAnimalProps) {
const { t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const animalIds = useMemo(() => FLOWER_IMAGE_LIST.map((item) => item.id), [])
const containerRef = useRef<HTMLElement | null>(null)
const animalGridRef = useRef<HTMLDivElement | null>(null)
const cellRefs = useRef(new Map<number, HTMLButtonElement>())
const [revealCellId, setRevealCellId] = useState<number | null>(null)
const [revealFrame, setRevealFrame] = useState<{
height: number
left: number
top: number
width: number
} | null>(null)
const [isRevealHoldingResult, setIsRevealHoldingResult] = useState(false)
const [isRevealSettlingResult, setIsRevealSettlingResult] = useState(false)
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const revealWinningCellId = useGameRoundStore(
(state) => state.revealAnimation.winningCellId,
@@ -103,10 +92,6 @@ export function MobileAnimal({
showStandbyState,
} = useAnimalVm(animalIds, onSelect)
const isRevealRunning =
revealPhase === 'spinning' ||
(revealPhase === 'stopping' && !isRevealHoldingResult)
const isRevealResult = revealPhase === 'result'
const [hasRewardOverlayStarted, setHasRewardOverlayStarted] = useState(false)
const shouldHideRevealHighlight =
revealPhase === 'result' &&
@@ -132,26 +117,20 @@ export function MobileAnimal({
useEffect(() => {
if (revealPhase === 'idle') {
setRevealCellId(null)
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return
}
if (revealPhase === 'result') {
setRevealCellId(shouldHideRevealHighlight ? null : revealWinningCellId)
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return
}
if (revealPhase === 'spinning') {
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
const intervalId = window.setInterval(() => {
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
}, 70)
}, SETTLEMENT_REVEAL_SPIN_STEP_MS)
return () => {
window.clearInterval(intervalId)
@@ -159,25 +138,18 @@ export function MobileAnimal({
}
if (revealWinningCellId === null) {
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return
}
const startedAt = performance.now()
let timeoutId = 0
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
const step = () => {
const elapsedMs = performance.now() - startedAt
if (elapsedMs >= SETTLEMENT_REVEAL_RANDOM_DURATION_MS) {
setIsRevealSettlingResult(true)
setRevealCellId(revealWinningCellId)
timeoutId = window.setTimeout(() => {
setIsRevealSettlingResult(false)
setIsRevealHoldingResult(true)
timeoutId = window.setTimeout(() => {
finishRevealAnimation()
}, SETTLEMENT_REVEAL_RESULT_HOLD_MS)
@@ -208,63 +180,29 @@ export function MobileAnimal({
shouldHideRevealHighlight,
])
useLayoutEffect(() => {
if (revealCellId === null) {
setRevealFrame(null)
return
}
const syncRevealFrame = () => {
const container = animalGridRef.current
const cell = cellRefs.current.get(revealCellId)
if (!container || !cell) {
setRevealFrame(null)
return
}
const containerRect = container.getBoundingClientRect()
const cellRect = cell.getBoundingClientRect()
setRevealFrame({
height: cellRect.height,
left: cellRect.left - containerRect.left,
top: cellRect.top - containerRect.top,
width: cellRect.width,
})
}
syncRevealFrame()
window.addEventListener('resize', syncRevealFrame)
return () => {
window.removeEventListener('resize', syncRevealFrame)
}
}, [revealCellId])
return (
<section
ref={containerRef}
className={cn(
'common-neon-inset-glow relative flex w-full flex-col gap-design-5 overflow-hidden !rounded-[calc(var(--design-unit)*6)] !px-design-5 !py-design-5',
'common-neon-inset-glow relative flex w-full flex-col gap-design-5 !rounded-[calc(var(--design-unit)*6)] !px-design-5 !py-design-5',
!revealCellId && 'overflow-hidden',
className,
)}
>
<MobileStatusLine />
<div ref={animalGridRef} className="relative z-10 w-full">
<div className="relative z-10 w-full">
<div className="grid w-full grid-cols-4 gap-design-3">
{FLOWER_IMAGE_LIST.map((item) => {
const selectionMeta = selectionByCell[item.id]
const hasPlacedSelection = Boolean(selectionMeta)
const isMarqueeActive = showStandbyState && item.id === marqueeId
const isRevealWinner =
!shouldHideRevealHighlight &&
(isRevealResult || isRevealHoldingResult) &&
revealWinningCellId === item.id
const isRevealActive =
!shouldHideRevealHighlight && revealCellId === item.id
const warningType =
cellWarning?.cellId === item.id ? cellWarning.type : null
const showCellWarning = warningType !== null
const animalSpriteVariant =
hasPlacedSelection || isMarqueeActive ? 'light' : 'normal'
const warningLabel =
warningType === 'balance'
? t('gameDesktop.animal.insufficientBalanceRecharge')
@@ -275,13 +213,6 @@ export function MobileAnimal({
return (
<motion.button
key={item.id}
ref={(node) => {
if (node) {
cellRefs.current.set(item.id, node)
} else {
cellRefs.current.delete(item.id)
}
}}
type="button"
disabled={lockInteraction || showStopOverlay}
onClick={() => handleSelect(item.id)}
@@ -307,22 +238,16 @@ export function MobileAnimal({
: { duration: 0.16, ease: 'easeOut' }
}
className={cn(
'relative flex h-design-36 flex-col items-center justify-center overflow-hidden rounded-[calc(var(--design-unit)*6)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150',
'relative flex h-design-36 flex-col items-center justify-center rounded-[calc(var(--design-unit)*6)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150',
!isRevealActive && 'overflow-hidden',
lockInteraction
? 'cursor-not-allowed opacity-90'
: 'cursor-pointer hover:-translate-y-[1px]',
isMarqueeActive &&
'border-[rgba(121,255,250,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(85,255,247,0.98),0_0_calc(var(--design-unit)*34)_rgba(39,245,255,0.88),inset_0_0_calc(var(--design-unit)*26)_rgba(112,255,248,0.34)]',
isRevealRunning &&
'border-[rgba(104,255,249,0.76)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(68,244,255,0.46),0_0_calc(var(--design-unit)*22)_rgba(37,214,255,0.28),inset_0_0_calc(var(--design-unit)*16)_rgba(115,255,247,0.18)] brightness-115 saturate-125',
isRevealWinner &&
'border-[rgba(121,255,250,0.72)] shadow-[0_0_calc(var(--design-unit)*12)_rgba(81,248,255,0.54),0_0_calc(var(--design-unit)*22)_rgba(30,199,255,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(125,255,249,0.24)] brightness-110 saturate-120',
showCellWarning &&
'border-[rgba(255,92,92,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,88,88,0.56),0_0_calc(var(--design-unit)*28)_rgba(255,44,44,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(255,126,126,0.3)]',
!showStandbyState &&
!hasPlacedSelection &&
!isRevealWinner &&
'opacity-95',
!showStandbyState && !hasPlacedSelection && 'opacity-95',
itemClassName,
)}
>
@@ -359,15 +284,11 @@ export function MobileAnimal({
'pointer-events-none absolute inset-[calc(var(--design-unit)*1)] rounded-[calc(var(--design-unit)*6)] opacity-0 transition-opacity duration-150',
isMarqueeActive &&
'bg-[radial-gradient(circle_at_center,rgba(129,255,250,0.48)_0%,rgba(94,255,247,0.18)_38%,rgba(43,236,255,0.08)_56%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(119,255,249,0.98),0_0_calc(var(--design-unit)*28)_rgba(53,246,255,0.9),0_0_calc(var(--design-unit)*44)_rgba(37,241,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(163,255,250,0.52)]',
isRevealRunning &&
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.36)_0%,rgba(77,244,255,0.16)_40%,rgba(27,183,255,0.07)_68%,transparent_88%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(95,249,255,0.46),inset_0_0_calc(var(--design-unit)*18)_rgba(151,255,250,0.24)]',
isRevealWinner &&
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.34)_0%,rgba(67,226,255,0.16)_38%,rgba(25,131,255,0.07)_58%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(92,248,255,0.42),inset_0_0_calc(var(--design-unit)*18)_rgba(126,255,250,0.24)]',
showCellWarning &&
'bg-[radial-gradient(circle_at_center,rgba(255,106,106,0.34)_0%,rgba(255,58,58,0.18)_42%,rgba(108,0,0,0.2)_78%,transparent_100%)] opacity-100',
)}
/>
{!showStandbyState && !hasPlacedSelection && !isRevealWinner ? (
{!showStandbyState && !hasPlacedSelection ? (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[calc(var(--design-unit)*1)] z-20 rounded-[calc(var(--design-unit)*6)] bg-[rgba(4,16,24,0.52)] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(3,9,14,0.56)]"
@@ -376,15 +297,34 @@ export function MobileAnimal({
<AnimalSpriteImage
animalId={item.id}
alt={`animal-${item.id}`}
variant={animalSpriteVariant}
className={cn(
'absolute left-[1.5%] right-[1.5%] top-[2.9%] bottom-[2.9%] z-10 overflow-hidden rounded-[calc(var(--design-unit)*6)]',
isRevealRunning &&
'brightness-115 saturate-125 drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(101,250,255,0.42)]',
isRevealWinner &&
'brightness-110 saturate-120 drop-shadow-[0_0_calc(var(--design-unit)*9)_rgba(106,250,255,0.44)]',
imageClassName,
)}
/>
{isRevealActive ? (
<span className="pointer-events-none absolute inset-0 z-40 overflow-visible rounded-[calc(var(--design-unit)*6)] border border-[rgba(121,255,250,0.95)] shadow-[0_0_calc(var(--design-unit)*3)_calc(var(--design-unit)*1)_rgba(121,255,250,0.59),0_0_calc(var(--design-unit)*9)_calc(var(--design-unit)*2)_rgba(81,248,255,0.35),0_0_calc(var(--design-unit)*18)_calc(var(--design-unit)*3)_rgba(30,199,255,0.19)] transition-opacity duration-150">
<SmartImage
src={animalBorderImage}
alt=""
aria-hidden="true"
priority
showSkeleton={false}
className="pointer-events-none absolute inset-0 z-20 h-full w-full"
imgClassName="object-fill"
/>
<span className="pointer-events-none absolute left-design-6 top-design-4 z-30 text-design-10 font-bold leading-none text-[#4BFFFE]">
{String(item.id).padStart(2, '0')}
</span>
<AnimalSpriteImage
animalId={item.id}
variant="light"
alt=""
className="absolute left-[1.5%] right-[1.5%] top-[2.9%] bottom-[2.9%] z-10 overflow-hidden rounded-[calc(var(--design-unit)*5)]"
/>
</span>
) : null}
{showCellWarning ? (
<motion.span
initial={{ opacity: 0, scale: 0.94 }}
@@ -441,52 +381,6 @@ export function MobileAnimal({
})}
</div>
{revealFrame ? (
<div
aria-hidden="true"
className={cn(
'pointer-events-none absolute left-0 top-0 z-30 transform-gpu transition-[height,transform,width]',
prefersReducedMotion
? 'duration-0'
: isRevealSettlingResult
? 'duration-[800ms] ease-[cubic-bezier(0.16,1,0.3,1)]'
: 'duration-[160ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
)}
style={{
height: revealFrame.height,
transform: `translate(${revealFrame.left}px, ${revealFrame.top}px)`,
width: revealFrame.width,
}}
>
<SmartImage
src={animalBorderImage}
alt=""
aria-hidden="true"
priority
showSkeleton={false}
className="pointer-events-none absolute inset-0 z-30 h-full w-full"
imgClassName="object-fill"
/>
<div
className="mobile-gold-reveal-glow z-10 rounded-[calc(var(--design-unit)*7)]"
style={
prefersReducedMotion
? {
animation: 'none',
opacity: 0.36,
transform: 'scale(1)',
}
: undefined
}
/>
<div className="mobile-gold-reveal-static-border z-20 rounded-[calc(var(--design-unit)*7)]" />
<div
className="mobile-gold-reveal-shell z-20 rounded-[calc(var(--design-unit)*7)]"
style={prefersReducedMotion ? { animation: 'none' } : undefined}
/>
</div>
) : null}
<MobileAnimalOverlay showStopOverlay={showStopOverlay} />
</div>

View File

@@ -28,6 +28,7 @@ export function MobileControl() {
actionsEnabled,
canClear,
canDecreaseBetQuantity,
canIncreaseBetQuantity,
chips,
confirmLabel,
confirmState,
@@ -157,7 +158,7 @@ export function MobileControl() {
>
{chips.map((chip) => {
const isSelected = chip.id === selectedChipId
const showLockedState = !acceptingBets
const showLockedState = !acceptingBets || chip.isDisabled
return (
<motion.button
@@ -314,11 +315,11 @@ export function MobileControl() {
</div>
<button
type="button"
disabled={!acceptingBets}
disabled={!acceptingBets || !canIncreaseBetQuantity}
onClick={onIncreaseBetQuantity}
className={cn(
'flex h-design-24 w-design-24 shrink-0 items-center justify-center',
acceptingBets
acceptingBets && canIncreaseBetQuantity
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50',
)}

View File

@@ -1,5 +1,6 @@
import type { HTMLAttributes } from 'react'
import animalSpriteUrl from '@/assets/animal/animal-sprite.webp'
import lightAnimalSpriteUrl from '@/assets/light-animal/light-animal-sprite.webp'
import {
ANIMAL_SPRITE_CELL_ASPECT_RATIO,
ANIMAL_SPRITE_COLUMNS,
@@ -14,6 +15,7 @@ interface AnimalSpriteImageProps
alt?: string
fit?: 'cover' | 'fill'
imageClassName?: string
variant?: 'normal' | 'light'
}
export function AnimalSpriteImage({
@@ -23,9 +25,11 @@ export function AnimalSpriteImage({
fit = 'fill',
imageClassName,
style,
variant = 'normal',
...props
}: AnimalSpriteImageProps) {
const position = getAnimalSpritePosition(animalId)
const spriteUrl = variant === 'light' ? lightAnimalSpriteUrl : animalSpriteUrl
const accessibilityProps = alt
? ({ 'aria-label': alt, role: 'img' } as const)
: ({ 'aria-hidden': 'true' } as const)
@@ -50,7 +54,7 @@ export function AnimalSpriteImage({
style={{
aspectRatio:
fit === 'cover' ? ANIMAL_SPRITE_CELL_ASPECT_RATIO : undefined,
backgroundImage: `url(${animalSpriteUrl})`,
backgroundImage: `url(${spriteUrl})`,
backgroundPosition: `${position.xPercent}% ${position.yPercent}%`,
backgroundSize: `${ANIMAL_SPRITE_COLUMNS * 100}% ${ANIMAL_SPRITE_ROWS * 100}%`,
}}

View File

@@ -102,6 +102,7 @@ export function useGameControlVm() {
amount: chip.amount,
id: chip.id,
isSelected: chip.id === activeChipId,
isDisabled: tableLimitMax > 0 && chip.amount > tableLimitMax,
src: CHIP_IMAGE_MAP.get(chip.id) ?? CHIP_IMAGE_OPTIONS[0]?.src ?? '',
valueLabel: formatChipDisplayValue(chip.amount),
}))
@@ -113,7 +114,7 @@ export function useGameControlVm() {
return left.isSelected ? 1 : -1
})
}, [activeChipId, chips])
}, [activeChipId, chips, tableLimitMax])
const selectedChip =
chipItems.find((chip) => chip.id === activeChipId) ?? chipItems[0] ?? null
@@ -126,6 +127,30 @@ export function useGameControlVm() {
const hasInsufficientBalance = hasSelections && totalBetAmount > balance
const hasExceededBetLimit =
hasSelections && tableLimitMax > 0 && totalBetAmount > tableLimitMax
const canIncreaseBetQuantity = useMemo(() => {
if (!hasSelections) {
return true
}
const activeChip = chips.find((chip) => chip.id === activeChipId)
if (!activeChip) {
return false
}
const nextQuantity = activeBetQuantity + 1
const nextTotalPerSelection = activeChip.amount * nextQuantity
const nextTotal = nextTotalPerSelection * selections.length
return tableLimitMax <= 0 || nextTotal <= tableLimitMax
}, [
hasSelections,
chips,
activeChipId,
activeBetQuantity,
selections.length,
tableLimitMax,
])
const confirmState: ConfirmState =
isSubmitting || isAutoHosting
? 'submitting'
@@ -292,6 +317,7 @@ export function useGameControlVm() {
!hasSubmittedCurrentRound &&
!isAutoHosting,
canDecreaseBetQuantity: activeBetQuantity > 1,
canIncreaseBetQuantity,
confirmLabel:
confirmState === 'idle'
? t('gameDesktop.control.selectNumbers')
@@ -306,7 +332,14 @@ export function useGameControlVm() {
isConfirmClickable: confirmState === 'ready' && !isAutoHosting,
onChipSelect: selectChip,
onDecreaseBetQuantity: () => adjustBetQuantity(-1),
onIncreaseBetQuantity: () => adjustBetQuantity(1),
onIncreaseBetQuantity: () => {
if (!canIncreaseBetQuantity) {
notify.warning(t('commonUi.toast.betLimitExceeded'))
return
}
adjustBetQuantity(1)
},
onConfirm: handleConfirm,
onClearSelections: clearSelections,
onOpenAutoSetting: handleOpenAutoSetting,