diff --git a/package.json b/package.json index fc48557..9e92fe1 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "commit": "cz", "dev": "pnpm generate-routes && vite", "build": "pnpm generate-routes && tsc -b && vite build", + "generate-animal-sprite": "node scripts/generate-animal-sprite.mjs", + "generate-reward-sprite": "node scripts/generate-reward-sprite.mjs", + "generate-sprites": "pnpm generate-animal-sprite && pnpm generate-reward-sprite", "generate-routes": "tsr generate", "lint": "biome check .", "lint:fix": "biome check --write .", @@ -74,6 +77,7 @@ "husky": "9.1.7", "lint-staged": "16.4.0", "postcss": "^8.5.12", + "sharp": "^0.34.5", "tailwindcss": "^4.2.2", "typescript": "~6.0.2", "vite": "^8.0.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1c0e60..de0edcd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: postcss: specifier: ^8.5.12 version: 8.5.12 + sharp: + specifier: ^0.34.5 + version: 0.34.5 tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -637,6 +640,159 @@ packages: peerDependencies: react-hook-form: ^7.55.0 + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@inquirer/ansi@2.0.5': resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} @@ -3432,6 +3588,10 @@ packages: resolution: {integrity: sha512-70fwnesNrY1GgeD7Kdzn+3SsYeyfibm8immsA5L68+OusoPTvYF01oWExl8/latKpMpvVXcbgdbbE6VFBJQ38w==} hasBin: true + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4335,6 +4495,102 @@ snapshots: '@standard-schema/utils': 0.3.0 react-hook-form: 7.75.0(react@19.2.5) + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.9.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@inquirer/ansi@2.0.5': {} '@inquirer/confirm@6.0.12(@types/node@24.12.2)': @@ -7148,6 +7404,37 @@ snapshots: - supports-color - typescript + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 diff --git a/scripts/generate-animal-sprite.mjs b/scripts/generate-animal-sprite.mjs new file mode 100644 index 0000000..9dc4a43 --- /dev/null +++ b/scripts/generate-animal-sprite.mjs @@ -0,0 +1,53 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import sharp from 'sharp' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(__dirname, '..') +const animalDir = path.join(rootDir, 'src/assets/animal') +const outputPath = path.join(animalDir, 'animal-sprite.webp') + +const columns = 6 +const rows = 6 +const cellWidth = 446 +const cellHeight = 224 +const quality = 82 + +const composites = await Promise.all( + Array.from({ length: columns * rows }, async (_, index) => { + const id = index + 1 + const input = path.join(animalDir, `${id}.webp`) + const left = (index % columns) * cellWidth + const top = Math.floor(index / columns) * cellHeight + + const buffer = await sharp(input) + .resize(cellWidth, cellHeight, { + fit: 'cover', + position: 'center', + }) + .webp({ quality }) + .toBuffer() + + return { + input: buffer, + left, + top, + } + }), +) + +await sharp({ + create: { + width: columns * cellWidth, + height: rows * cellHeight, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, +}) + .composite(composites) + .webp({ quality }) + .toFile(outputPath) + +console.log( + `Generated ${path.relative(rootDir, outputPath)} (${columns}x${rows}, ${cellWidth}x${cellHeight})`, +) diff --git a/scripts/generate-reward-sprite.mjs b/scripts/generate-reward-sprite.mjs new file mode 100644 index 0000000..4685b1c --- /dev/null +++ b/scripts/generate-reward-sprite.mjs @@ -0,0 +1,53 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import sharp from 'sharp' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(__dirname, '..') +const rewardDir = path.join(rootDir, 'src/assets/reward') +const outputPath = path.join(rewardDir, 'reward-sprite.webp') + +const columns = 6 +const rows = 6 +const cellWidth = 102 +const cellHeight = 98 +const quality = 86 + +const composites = await Promise.all( + Array.from({ length: columns * rows }, async (_, index) => { + const id = index + 1 + const input = path.join(rewardDir, `${id}.webp`) + const left = (index % columns) * cellWidth + const top = Math.floor(index / columns) * cellHeight + + const buffer = await sharp(input) + .resize(cellWidth, cellHeight, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .webp({ quality }) + .toBuffer() + + return { + input: buffer, + left, + top, + } + }), +) + +await sharp({ + create: { + width: columns * cellWidth, + height: rows * cellHeight, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, +}) + .composite(composites) + .webp({ quality }) + .toFile(outputPath) + +console.log( + `Generated ${path.relative(rootDir, outputPath)} (${columns}x${rows}, ${cellWidth}x${cellHeight})`, +) diff --git a/src/assets/animal/animal-sprite.webp b/src/assets/animal/animal-sprite.webp new file mode 100644 index 0000000..c37070c Binary files /dev/null and b/src/assets/animal/animal-sprite.webp differ diff --git a/src/assets/reward/reward-sprite.webp b/src/assets/reward/reward-sprite.webp new file mode 100644 index 0000000..7f17769 Binary files /dev/null and b/src/assets/reward/reward-sprite.webp differ diff --git a/src/components/app-boot-resource-gate.tsx b/src/components/app-boot-resource-gate.tsx index bf75732..7f6ab6e 100644 --- a/src/components/app-boot-resource-gate.tsx +++ b/src/components/app-boot-resource-gate.tsx @@ -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((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) diff --git a/src/features/game/components/desktop/desktop-animal-overlay.tsx b/src/features/game/components/desktop/desktop-animal-overlay.tsx index 00e651c..d56b40b 100644 --- a/src/features/game/components/desktop/desktop-animal-overlay.tsx +++ b/src/features/game/components/desktop/desktop-animal-overlay.tsx @@ -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)]" >
- {item.imageUrl ? ( - - ) : null} +
@@ -189,8 +181,6 @@ function NoRewardSettlement({ resultLabel: string resultNumber: number }) { - const winningImageUrl = FLOWER_IMAGE_BY_ID[resultNumber]?.animalUrl ?? '' - return (
@@ -203,14 +193,11 @@ function NoRewardSettlement({ {resultLabel}
- {winningImageUrl ? ( - - ) : null} +
{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" >
- {item.imageUrl ? ( - - ) : null} +
{String(item.cellId).padStart(2, '0')} diff --git a/src/features/game/components/desktop/desktop-animal.tsx b/src/features/game/components/desktop/desktop-animal.tsx index 2a50f97..0ca8a42 100644 --- a/src/features/game/components/desktop/desktop-animal.tsx +++ b/src/features/game/components/desktop/desktop-animal.tsx @@ -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} - {showCellWarning ? ( 36) { return ( - ) diff --git a/src/features/game/components/mobile/mobile-animal-overlay.tsx b/src/features/game/components/mobile/mobile-animal-overlay.tsx index c57e0d8..f8901f7 100644 --- a/src/features/game/components/mobile/mobile-animal-overlay.tsx +++ b/src/features/game/components/mobile/mobile-animal-overlay.tsx @@ -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" >
- {item.imageUrl ? ( - - ) : null} +
@@ -187,8 +179,6 @@ function NoRewardSettlement({ resultLabel: string resultNumber: number }) { - const winningImageUrl = FLOWER_IMAGE_BY_ID[resultNumber]?.animalUrl ?? '' - return (
@@ -201,14 +191,11 @@ function NoRewardSettlement({ {resultLabel}
- {winningImageUrl ? ( - - ) : null} +
{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" >
- {item.imageUrl ? ( - - ) : null} +
{String(item.cellId).padStart(2, '0')} diff --git a/src/features/game/components/mobile/mobile-animal.tsx b/src/features/game/components/mobile/mobile-animal.tsx index f5b6b36..f5beb1e 100644 --- a/src/features/game/components/mobile/mobile-animal.tsx +++ b/src/features/game/components/mobile/mobile-animal.tsx @@ -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} - {showCellWarning ? ( 36) { return ( - ) diff --git a/src/features/game/components/mobile/mobile-period-history-list.tsx b/src/features/game/components/mobile/mobile-period-history-list.tsx index c470092..6bb14c3 100644 --- a/src/features/game/components/mobile/mobile-period-history-list.tsx +++ b/src/features/game/components/mobile/mobile-period-history-list.tsx @@ -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({ - {item.image ? ( - {item.displayResultNumber} - ) : ( - -- - )} +
diff --git a/src/features/game/components/shared/animal-sprite-image.tsx b/src/features/game/components/shared/animal-sprite-image.tsx new file mode 100644 index 0000000..f78280b --- /dev/null +++ b/src/features/game/components/shared/animal-sprite-image.tsx @@ -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, '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 ( + + {position ? ( + + ) +} diff --git a/src/features/game/components/shared/period-history-list.tsx b/src/features/game/components/shared/period-history-list.tsx index e555f40..609cfe7 100644 --- a/src/features/game/components/shared/period-history-list.tsx +++ b/src/features/game/components/shared/period-history-list.tsx @@ -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} - {item.image ? ( - {item.displayResultNumber} - ) : ( - -- - )} +
))} diff --git a/src/features/game/components/shared/reward-sprite-image.tsx b/src/features/game/components/shared/reward-sprite-image.tsx new file mode 100644 index 0000000..ff00436 --- /dev/null +++ b/src/features/game/components/shared/reward-sprite-image.tsx @@ -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, '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 ( + + {position ? ( + + ) +} diff --git a/src/features/game/shared/flower-assets.ts b/src/features/game/shared/flower-assets.ts index 9f0f795..6e64388 100644 --- a/src/features/game/shared/flower-assets.ts +++ b/src/features/game/shared/flower-assets.ts @@ -1,27 +1,64 @@ import type { FlowerImageAsset } from '@/type' -const animalModules = import.meta.glob('../../../assets/animal/*.webp', { - eager: true, - import: 'default', -}) as Record +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 +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 diff --git a/src/hooks/use-period-history-vm.ts b/src/hooks/use-period-history-vm.ts index 1fcb0c9..beda43d 100644 --- a/src/hooks/use-period-history-vm.ts +++ b/src/hooks/use-period-history-vm.ts @@ -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, diff --git a/src/type/game.type.ts b/src/type/game.type.ts index d5ef30c..e74f415 100644 --- a/src/type/game.type.ts +++ b/src/type/game.type.ts @@ -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 {