feat(game): 实现精灵图渲染优化游戏性能

- 新增 AnimalSpriteImage 组件用于精灵图渲染
- 集成精灵图渲染到桌面版动物组件
- 集成精灵图渲染到移动版动物组件
- 重构历史记录组件使用精灵图渲染
- 更新动物覆盖层组件使用精灵图渲染
- 移除旧的单个图片资源引用
- 添加精灵图生成脚本和相关配置
- 优化启动资源加载排除精灵图源文件
- 添加精灵图位置计算工具函数
This commit is contained in:
JiaJun
2026-06-10 16:38:00 +08:00
parent da8adc336d
commit c3059549e6
20 changed files with 652 additions and 135 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

View File

@@ -2,7 +2,15 @@ import { type PropsWithChildren, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
const bootImageModules = import.meta.glob(
'../assets/**/*.{jpg,jpeg,png,svg,webp}',
[
'../assets/**/*.{jpg,jpeg,png,svg,webp}',
'!../assets/animal/[1-9].webp',
'!../assets/animal/[12][0-9].webp',
'!../assets/animal/3[0-6].webp',
'!../assets/reward/[1-9].webp',
'!../assets/reward/[12][0-9].webp',
'!../assets/reward/3[0-6].webp',
],
{
eager: true,
import: 'default',
@@ -12,6 +20,8 @@ const bootImageModules = import.meta.glob(
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$/
function preloadImage(url: string) {
return new Promise<void>((resolve) => {
@@ -33,7 +43,16 @@ function waitForFonts() {
function useBootResourceLoader() {
const imageUrls = useMemo(
() => Array.from(new Set(Object.values(bootImageModules))).filter(Boolean),
() =>
Array.from(
new Set(
Object.entries(bootImageModules)
.filter(
([sourcePath]) => !SPRITE_SOURCE_IMAGE_PATTERN.test(sourcePath),
)
.map(([, url]) => url),
),
).filter(Boolean),
[],
)
const [progress, setProgress] = useState(0)

View File

@@ -16,10 +16,8 @@ import { LottiePlayer } from '@/components/lottie-player.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image'
import { REWARD_OVERLAY_DURATION_MS } from '@/constants'
import {
FLOWER_IMAGE_BY_ID,
groupSelectionsByCell,
} from '@/features/game/shared'
import { AnimalSpriteImage } from '@/features/game/components/shared/animal-sprite-image'
import { groupSelectionsByCell } from '@/features/game/shared'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth'
import { useGameAutoHostingStore, useGameRoundStore } from '@/store/game'
@@ -34,7 +32,6 @@ type RewardChildrenStage = 'hidden' | 'visible' | 'exiting'
type StopBetItem = {
amount: number
cellId: number
imageUrl: string
}
interface DesktopAnimalOverlayProps {
@@ -115,7 +112,6 @@ function getStopBetItems(selections: BetSelection[]) {
return {
amount: meta.amount,
cellId: normalizedCellId,
imageUrl: FLOWER_IMAGE_BY_ID[normalizedCellId]?.animalUrl ?? '',
} satisfies StopBetItem
})
.sort((left, right) => left.cellId - right.cellId)
@@ -144,15 +140,11 @@ function StopBetSummary({
className="flex h-design-90 min-w-design-230 items-center gap-design-10 overflow-hidden rounded-[calc(var(--design-unit)*12)] border border-[rgba(124,232,255,0.2)] bg-[linear-gradient(90deg,rgba(7,42,38,0.94),rgba(5,23,34,0.96)_44%,rgba(18,25,25,0.94))] px-design-8 shadow-[0_0_calc(var(--design-unit)*16)_rgba(51,231,255,0.16),inset_0_calc(var(--design-unit)*1)_0_rgba(255,255,255,0.08)]"
>
<div className="relative h-design-72 w-design-72 shrink-0 overflow-hidden rounded-[calc(var(--design-unit)*10)] bg-[rgba(1,8,13,0.84)] shadow-[0_0_calc(var(--design-unit)*12)_rgba(86,247,255,0.18)]">
{item.imageUrl ? (
<SmartImage
src={item.imageUrl}
alt=""
showSkeleton={false}
className="h-full w-full"
imgClassName="object-cover"
/>
) : null}
<AnimalSpriteImage
animalId={item.cellId}
fit="cover"
className="h-full w-full"
/>
</div>
<div className="flex h-design-72 min-w-0 flex-1 flex-col items-stretch justify-center gap-design-7 leading-none">
<div className="flex h-design-28 w-full items-center justify-center rounded-full bg-[rgba(4,31,31,0.9)] text-design-24 font-black leading-none text-[#78FF7F] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(120,255,127,0.08)] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(120,255,127,0.52)]">
@@ -189,8 +181,6 @@ function NoRewardSettlement({
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)]">
@@ -203,14 +193,11 @@ function NoRewardSettlement({
{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}
<AnimalSpriteImage
animalId={resultNumber}
fit="cover"
className="h-full w-full"
/>
</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')}
@@ -228,14 +215,11 @@ function NoRewardSettlement({
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}
<AnimalSpriteImage
animalId={item.cellId}
fit="cover"
className="h-full w-full"
/>
</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')}

View File

@@ -6,6 +6,7 @@ import animalBorderImage from '@/assets/game/animal-border.webp'
import diamondIcon from '@/assets/system/diamond.webp'
import { SmartImage } from '@/components/smart-image'
import { DesktopAnimalOverlay } from '@/features/game/components/desktop/desktop-animal-overlay.tsx'
import { AnimalSpriteImage } from '@/features/game/components/shared/animal-sprite-image'
import { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx'
import { FLOWER_IMAGE_LIST } from '@/features/game/shared'
import { useAnimalVm } from '@/hooks/use-animal-vm'
@@ -367,8 +368,8 @@ export function DesktopAnimal({
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)]"
/>
) : null}
<SmartImage
src={item.animalUrl}
<AnimalSpriteImage
animalId={item.id}
alt={`animal-${item.id}`}
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)]',
@@ -378,7 +379,6 @@ export function DesktopAnimal({
'brightness-110 saturate-120 drop-shadow-[0_0_calc(var(--design-unit)*9)_rgba(106,250,255,0.44)]',
imageClassName,
)}
imgClassName="object-fill"
/>
{showCellWarning ? (
<motion.span

View File

@@ -3,9 +3,8 @@ import { useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import historyBg from '@/assets/system/history-bg.png'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image'
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
import { FLOWER_IMAGE_BY_ID } from '@/features/game/shared'
import { RewardSpriteImage } from '@/features/game/components/shared/reward-sprite-image'
import { useGameHistoryVm } from '@/hooks/use-game-history-vm.ts'
function HistoryRewardNumber({
@@ -15,10 +14,9 @@ function HistoryRewardNumber({
className?: string
number: number
}) {
const image = FLOWER_IMAGE_BY_ID[number]
const label = String(number).padStart(2, '0')
if (!image?.rewardUrl) {
if (!Number.isInteger(number) || number < 1 || number > 36) {
return (
<span
className={`inline-flex h-design-38 min-w-design-38 shrink-0 items-center justify-center px-design-5 text-design-15 font-bold text-[#D5FBFF] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(75,233,255,0.12),0_0_calc(var(--design-unit)*10)_rgba(75,233,255,0.1)] align-middle ${className ?? ''}`}
@@ -33,12 +31,10 @@ function HistoryRewardNumber({
className={`inline-flex h-design-38 w-design-38 shrink-0 items-center justify-center p-design-1 align-middle ${className ?? ''}`}
title={label}
>
<SmartImage
src={image.rewardUrl}
<RewardSpriteImage
rewardId={number}
alt={label}
showSkeleton={false}
className="h-full w-full overflow-visible"
imgClassName="object-contain"
className="h-full w-full"
/>
</span>
)

View File

@@ -16,10 +16,8 @@ import { LottiePlayer } from '@/components/lottie-player.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image'
import { REWARD_OVERLAY_DURATION_MS } from '@/constants'
import {
FLOWER_IMAGE_BY_ID,
groupSelectionsByCell,
} from '@/features/game/shared'
import { AnimalSpriteImage } from '@/features/game/components/shared/animal-sprite-image'
import { groupSelectionsByCell } from '@/features/game/shared'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth'
import { useGameAutoHostingStore, useGameRoundStore } from '@/store/game'
@@ -34,7 +32,6 @@ type RewardChildrenStage = 'hidden' | 'visible' | 'exiting'
type StopBetItem = {
amount: number
cellId: number
imageUrl: string
}
interface MobileAnimalOverlayProps {
@@ -113,7 +110,6 @@ function getStopBetItems(selections: BetSelection[]) {
return {
amount: meta.amount,
cellId: normalizedCellId,
imageUrl: FLOWER_IMAGE_BY_ID[normalizedCellId]?.animalUrl ?? '',
} satisfies StopBetItem
})
.sort((left, right) => left.cellId - right.cellId)
@@ -142,15 +138,11 @@ function StopBetSummary({
className="flex h-design-42 min-w-design-100 items-center gap-design-5 overflow-hidden rounded-[calc(var(--design-unit)*7)] border border-[rgba(124,232,255,0.2)] bg-[linear-gradient(90deg,rgba(7,42,38,0.94),rgba(5,23,34,0.96)_44%,rgba(18,25,25,0.94))] px-design-4"
>
<div className="relative h-design-32 w-design-32 shrink-0 overflow-hidden rounded-[calc(var(--design-unit)*5)] bg-[rgba(1,8,13,0.84)]">
{item.imageUrl ? (
<SmartImage
src={item.imageUrl}
alt=""
showSkeleton={false}
className="h-full w-full"
imgClassName="object-cover"
/>
) : null}
<AnimalSpriteImage
animalId={item.cellId}
fit="cover"
className="h-full w-full"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col items-stretch justify-center gap-design-3 leading-none">
<div className="flex h-design-13 w-full items-center justify-center rounded-full bg-[rgba(4,31,31,0.9)] text-design-11 font-black leading-none text-[#78FF7F] [text-shadow:0_0_calc(var(--design-unit)*7)_rgba(120,255,127,0.52)]">
@@ -187,8 +179,6 @@ function NoRewardSettlement({
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)]">
@@ -201,14 +191,11 @@ function NoRewardSettlement({
{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}
<AnimalSpriteImage
animalId={resultNumber}
fit="cover"
className="h-full w-full"
/>
</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')}
@@ -226,14 +213,11 @@ function NoRewardSettlement({
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}
<AnimalSpriteImage
animalId={item.cellId}
fit="cover"
className="h-full w-full"
/>
</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')}

View File

@@ -7,6 +7,7 @@ import diamondIcon from '@/assets/system/diamond.webp'
import { SmartImage } from '@/components/smart-image'
import { MobileAnimalOverlay } from '@/features/game/components/mobile/mobile-animal-overlay.tsx'
import { MobileStatusLine } from '@/features/game/components/mobile/mobile-status.tsx'
import { AnimalSpriteImage } from '@/features/game/components/shared/animal-sprite-image'
import { FLOWER_IMAGE_LIST } from '@/features/game/shared'
import { useAnimalVm } from '@/hooks/use-animal-vm'
import { cn } from '@/lib/utils'
@@ -372,8 +373,8 @@ export function MobileAnimal({
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)]"
/>
) : null}
<SmartImage
src={item.animalUrl}
<AnimalSpriteImage
animalId={item.id}
alt={`animal-${item.id}`}
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)]',
@@ -383,7 +384,6 @@ export function MobileAnimal({
'brightness-110 saturate-120 drop-shadow-[0_0_calc(var(--design-unit)*9)_rgba(106,250,255,0.44)]',
imageClassName,
)}
imgClassName="object-fill"
/>
{showCellWarning ? (
<motion.span

View File

@@ -1,9 +1,8 @@
import { History } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { SmartImage } from '@/components/smart-image'
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
import { FLOWER_IMAGE_BY_ID } from '@/features/game/shared'
import { RewardSpriteImage } from '@/features/game/components/shared/reward-sprite-image'
import { useGameHistoryVm } from '@/hooks/use-game-history-vm.ts'
function HistoryRewardNumber({
@@ -13,10 +12,9 @@ function HistoryRewardNumber({
className?: string
number: number
}) {
const image = FLOWER_IMAGE_BY_ID[number]
const label = String(number).padStart(2, '0')
if (!image?.rewardUrl) {
if (!Number.isInteger(number) || number < 1 || number > 36) {
return (
<span
className={`inline-flex h-design-24 min-w-design-24 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(95,233,255,0.22)] bg-[rgba(6,28,39,0.7)] px-design-4 text-design-10 font-bold text-[#D5FBFF] align-middle ${className ?? ''}`}
@@ -31,12 +29,10 @@ function HistoryRewardNumber({
className={`inline-flex h-design-24 w-design-24 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(95,233,255,0.16)] bg-[rgba(6,28,39,0.38)] p-design-1 align-middle ${className ?? ''}`}
title={label}
>
<SmartImage
src={image.rewardUrl}
<RewardSpriteImage
rewardId={number}
alt={label}
showSkeleton={false}
className="h-full w-full overflow-visible"
imgClassName="object-contain"
className="h-full w-full"
/>
</span>
)

View File

@@ -1,4 +1,5 @@
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
import { AnimalSpriteImage } from '@/features/game/components/shared/animal-sprite-image'
import type { PeriodHistoryDisplayItem } from '@/hooks/use-period-history-vm'
import { cn } from '@/lib/utils'
@@ -107,16 +108,12 @@ export function MobilePeriodHistoryList({
</span>
<span className="flex min-w-0 items-center justify-center overflow-hidden">
{item.image ? (
<img
src={item.image}
alt={item.displayResultNumber}
draggable={false}
className="h-design-34 w-auto max-w-full object-contain drop-shadow-[0_0_calc(var(--design-unit)*6)_rgba(99,243,255,0.12)]"
/>
) : (
<span className="text-design-9 text-[#668C92]">--</span>
)}
<AnimalSpriteImage
animalId={item.animalId}
alt={item.displayResultNumber}
fit="cover"
className="h-design-34 w-full drop-shadow-[0_0_calc(var(--design-unit)*6)_rgba(99,243,255,0.12)]"
/>
</span>
</div>
</div>

View File

@@ -0,0 +1,61 @@
import type { HTMLAttributes } from 'react'
import animalSpriteUrl from '@/assets/animal/animal-sprite.webp'
import {
ANIMAL_SPRITE_CELL_ASPECT_RATIO,
ANIMAL_SPRITE_COLUMNS,
ANIMAL_SPRITE_ROWS,
getAnimalSpritePosition,
} from '@/features/game/shared'
import { cn } from '@/lib/utils'
interface AnimalSpriteImageProps
extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'> {
animalId: number
alt?: string
fit?: 'cover' | 'fill'
imageClassName?: string
}
export function AnimalSpriteImage({
animalId,
alt = '',
className,
fit = 'fill',
imageClassName,
style,
...props
}: AnimalSpriteImageProps) {
const position = getAnimalSpritePosition(animalId)
const accessibilityProps = alt
? ({ 'aria-label': alt, role: 'img' } as const)
: ({ 'aria-hidden': 'true' } as const)
return (
<span
{...accessibilityProps}
{...props}
className={cn('relative block overflow-hidden', className)}
style={style}
>
{position ? (
<span
aria-hidden="true"
className={cn(
'block bg-no-repeat',
fit === 'cover'
? 'absolute left-1/2 top-1/2 h-full min-w-full -translate-x-1/2 -translate-y-1/2'
: 'absolute inset-0',
imageClassName,
)}
style={{
aspectRatio:
fit === 'cover' ? ANIMAL_SPRITE_CELL_ASPECT_RATIO : undefined,
backgroundImage: `url(${animalSpriteUrl})`,
backgroundPosition: `${position.xPercent}% ${position.yPercent}%`,
backgroundSize: `${ANIMAL_SPRITE_COLUMNS * 100}% ${ANIMAL_SPRITE_ROWS * 100}%`,
}}
/>
) : null}
</span>
)
}

View File

@@ -1,4 +1,5 @@
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
import { AnimalSpriteImage } from '@/features/game/components/shared/animal-sprite-image'
import type { PeriodHistoryDisplayItem } from '@/hooks/use-period-history-vm'
import { cn } from '@/lib/utils'
@@ -95,16 +96,12 @@ export function PeriodHistoryList({
{item.displayResultNumber}
</span>
<span className="flex h-design-50 w-design-72 shrink-0 items-center justify-end overflow-hidden">
{item.image ? (
<img
src={item.image}
alt={item.displayResultNumber}
draggable={false}
className="h-full w-auto max-w-none shrink-0 object-contain"
/>
) : (
<span className="text-design-13 text-[#668C92]">--</span>
)}
<AnimalSpriteImage
animalId={item.animalId}
alt={item.displayResultNumber}
fit="cover"
className="h-full w-full"
/>
</span>
</div>
))}

View File

@@ -0,0 +1,52 @@
import type { HTMLAttributes } from 'react'
import rewardSpriteUrl from '@/assets/reward/reward-sprite.webp'
import {
getRewardSpritePosition,
REWARD_SPRITE_CELL_ASPECT_RATIO,
REWARD_SPRITE_COLUMNS,
REWARD_SPRITE_ROWS,
} from '@/features/game/shared'
import { cn } from '@/lib/utils'
interface RewardSpriteImageProps
extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'> {
alt?: string
imageClassName?: string
rewardId: number
}
export function RewardSpriteImage({
alt = '',
className,
imageClassName,
rewardId,
style,
...props
}: RewardSpriteImageProps) {
const position = getRewardSpritePosition(rewardId)
const accessibilityProps = alt
? ({ 'aria-label': alt, role: 'img' } as const)
: ({ 'aria-hidden': 'true' } as const)
return (
<span
{...accessibilityProps}
{...props}
className={cn('relative block overflow-hidden', className)}
style={style}
>
{position ? (
<span
aria-hidden="true"
className={cn('absolute inset-0 block bg-no-repeat', imageClassName)}
style={{
aspectRatio: REWARD_SPRITE_CELL_ASPECT_RATIO,
backgroundImage: `url(${rewardSpriteUrl})`,
backgroundPosition: `${position.xPercent}% ${position.yPercent}%`,
backgroundSize: `${REWARD_SPRITE_COLUMNS * 100}% ${REWARD_SPRITE_ROWS * 100}%`,
}}
/>
) : null}
</span>
)
}

View File

@@ -1,27 +1,64 @@
import type { FlowerImageAsset } from '@/type'
const animalModules = import.meta.glob('../../../assets/animal/*.webp', {
eager: true,
import: 'default',
}) as Record<string, string>
export const ANIMAL_SPRITE_COLUMNS = 6
export const ANIMAL_SPRITE_ROWS = 6
export const ANIMAL_SPRITE_CELL_WIDTH = 446
export const ANIMAL_SPRITE_CELL_HEIGHT = 224
export const ANIMAL_SPRITE_CELL_ASPECT_RATIO = `${ANIMAL_SPRITE_CELL_WIDTH} / ${ANIMAL_SPRITE_CELL_HEIGHT}`
export const ANIMAL_SPRITE_ITEM_COUNT =
ANIMAL_SPRITE_COLUMNS * ANIMAL_SPRITE_ROWS
export const REWARD_SPRITE_COLUMNS = 6
export const REWARD_SPRITE_ROWS = 6
export const REWARD_SPRITE_CELL_WIDTH = 102
export const REWARD_SPRITE_CELL_HEIGHT = 98
export const REWARD_SPRITE_CELL_ASPECT_RATIO = `${REWARD_SPRITE_CELL_WIDTH} / ${REWARD_SPRITE_CELL_HEIGHT}`
export const REWARD_SPRITE_ITEM_COUNT =
REWARD_SPRITE_COLUMNS * REWARD_SPRITE_ROWS
const rewardModules = import.meta.glob('../../../assets/reward/*.webp', {
eager: true,
import: 'default',
}) as Record<string, string>
export function getAnimalSpritePosition(animalId: number) {
if (
!Number.isInteger(animalId) ||
animalId < 1 ||
animalId > ANIMAL_SPRITE_ITEM_COUNT
) {
return null
}
const index = animalId - 1
const column = index % ANIMAL_SPRITE_COLUMNS
const row = Math.floor(index / ANIMAL_SPRITE_COLUMNS)
return {
xPercent: (column / (ANIMAL_SPRITE_COLUMNS - 1)) * 100,
yPercent: (row / (ANIMAL_SPRITE_ROWS - 1)) * 100,
}
}
export function getRewardSpritePosition(rewardId: number) {
if (
!Number.isInteger(rewardId) ||
rewardId < 1 ||
rewardId > REWARD_SPRITE_ITEM_COUNT
) {
return null
}
const index = rewardId - 1
const column = index % REWARD_SPRITE_COLUMNS
const row = Math.floor(index / REWARD_SPRITE_COLUMNS)
return {
xPercent: (column / (REWARD_SPRITE_COLUMNS - 1)) * 100,
yPercent: (row / (REWARD_SPRITE_ROWS - 1)) * 100,
}
}
export const FLOWER_IMAGE_LIST: FlowerImageAsset[] = Array.from(
{ length: 36 },
(_, index) => {
const id = index + 1
return {
animalUrl: animalModules[`../../../assets/animal/${id}.webp`] ?? '',
id,
rewardUrl: rewardModules[`../../../assets/reward/${id}.webp`] ?? '',
}
},
).filter((item) => item.animalUrl && item.rewardUrl)
(_, index) => ({
id: index + 1,
}),
)
export const FLOWER_IMAGE_BY_ID = FLOWER_IMAGE_LIST.reduce<
Record<number, FlowerImageAsset>

View File

@@ -2,7 +2,6 @@ import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { getGamePeriodHistory } from '@/api'
import { FLOWER_IMAGE_BY_ID } from '@/features/game/shared'
import type { GamePeriodHistoryItemDto, PeriodHistoryDisplayItem } from '@/type'
export type { PeriodHistoryDisplayItem } from '@/type'
@@ -23,9 +22,9 @@ export function toPeriodHistoryDisplayItem(
item: GamePeriodHistoryItemDto,
): PeriodHistoryDisplayItem {
return {
animalId: item.result_number,
displayPeriodNo: formatPeriodNo(item.period_no),
displayResultNumber: formatResultNumber(item.result_number),
image: FLOWER_IMAGE_BY_ID[item.result_number]?.animalUrl ?? '',
isOdd: item.result_number % 2 === 1,
openTime: item.open_time,
periodNo: item.period_no,

View File

@@ -158,9 +158,9 @@ export interface SelectionSummary {
}
export interface PeriodHistoryDisplayItem {
animalId: number
displayPeriodNo: string
displayResultNumber: string
image: string
isOdd: boolean
openTime: number
periodNo: string
@@ -168,9 +168,7 @@ export interface PeriodHistoryDisplayItem {
}
export interface FlowerImageAsset {
animalUrl: string
id: number
rewardUrl: string
}
export interface RevealAnimationState {