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

View File

@@ -1,53 +0,0 @@
# 36 字花前端全局代码审查报告
审查日期2026-06-05
## 审查范围
- 全局代码静态审查
- GitNexus 索引刷新与调用影响分析
- `pnpm lint`
- `pnpm build`
- 本地桌面端与移动端页面渲染检查
- DOM 文本溢出与页面横向滚动检查
## 验证结果
| 检查项 | 结果 | 备注 |
|---|---|---|
| GitNexus 索引 | 通过 | 已重新运行 `npx gitnexus analyze` |
| `pnpm lint` | 通过 | Biome 检查通过 |
| `pnpm build` | 通过 | 构建成功,但存在较大资源与 chunk |
| 桌面端页面渲染 | 通过,有样式风险 | 页面可渲染logo 与部分数字文本存在裁切/拥挤风险 |
| 移动端页面渲染 | 通过,有样式风险 | 无页面级横向滚动,部分文本高度溢出 |
| 自动化测试 | 未发现 | 仓库内未发现有效 `test/spec` 测试文件 |
## 问题清单
| 优先级 | 类型 | 位置 | 问题 | 影响 | 建议 |
|---|---|---|---|---|---|
| P0 | 安全 | `src/routes/$lang/ws-test.tsx` | `/ws-test` 测试页硬编码完整 WebSocket URL、`token``auth_token`,并且已被打进生产 bundle | 凭证泄露,任何人都可能从静态资源中提取连接信息 | 立刻删除该路由,或仅在开发环境注册;凭证全部移出前端 |
| P0 | 安全 | `.env.production`, `.env.development`, `.env.example`, `src/lib/api/api-client.ts` | `VITE_AUTH_TOKEN_SECRET` 作为前端环境变量参与签名 | `VITE_` 变量会暴露到浏览器,不能作为密钥使用 | 签名逻辑迁到服务端;前端只保存短期 token |
| P1 | 功能风险 | `src/lib/auth/auth-session.ts` | 登出或 token 失效时调用 `localStorage.clear()``sessionStorage.clear()` | 会清掉同源下非本项目或非登录相关数据GitNexus 影响面为 CRITICAL | 改成只删除明确命名的 auth/session key |
| P1 | 性能 | `src/components/app-boot-resource-gate.tsx` | 首屏前 eager 导入并预加载所有图片资源 | 大图会阻塞应用显示,移动端首屏风险高 | 只预加载首屏必要资源,其余按页面、弹窗或交互懒加载 |
| P1 | 性能 | build 输出资源 | `pc-big-reward.json` 约 3MB`pc-streak.webp` 约 3.5MB`hall-music.mp3` 约 2.5MB | 首次加载体积偏大,弱网下体验差 | 压缩动效、图片、音频;奖励弹窗和音频按需加载 |
| P2 | 性能 | `src/features/game/audio/global-audio-controller.tsx` | 初始化时为所有音频创建 `Audio` 且设置 `preload='auto'` | 即使用户未开启声音,也可能提前拉取音频 | 用户解锁或启用声音后再加载,或改为 `preload='none'` |
| P2 | 架构/体积 | `src/hooks/use-auto-hosting-runner.ts` | 自动托管 chunk gzip 后仍约 341KB疑似从 `@/api` barrel 引入过多内容 | PC/移动入口都会受影响 | 检查 `@/api` 导出,改为直接 import 具体 API拆分 runner 依赖 |
| P2 | 功能一致性 | `src/hooks/use-game-control-vm.ts`, `src/hooks/use-auto-hosting-runner.ts` | 手动下注默认所有号码共用第一项金额;自动托管按金额/玩法分组 | 当前 UI 同步筹码所以暂不一定出错,但未来混合筹码或服务端回填时容易不一致 | 明确业务约束:要么禁止混合金额,要么手动下注也按组提交 |
| P2 | 样式 | `src/style/index.css` | 全局 `design-unit` 基于 `vw`,字体随视口宽度缩放 | 移动/桌面检查发现多处数字文本高度溢出,有裁切风险 | 字体使用固定/rem + 响应式断点,不用 viewport 直接缩放正文 |
| P3 | 样式 | `src/features/game/components/desktop/desktop-animal.tsx`, `src/features/game/components/mobile/mobile-animal.tsx` | 动物格数字区域使用 `overflow-hidden``leading-none`DOM 检查有文本裁切 | 小屏或字体渲染差异下数字可能被切顶/切底 | 放宽容器高度/line-height去掉不必要的隐藏裁切 |
| P3 | 样式 | `src/features/game/components/desktop/desktop-header.tsx` | 桌面截图里 logo 文案视觉上有重叠/裁切感 | 品牌区观感不稳 | 检查 logo 素材比例和容器宽高,必要时换成未裁切素材 |
| P3 | 清理 | `src/hooks/use-topup-vm.ts` | `useTopupVm` 空实现且未被引用 | 死代码增加维护噪音 | 删除或补齐为真实 ViewModel |
| P3 | 文档 | `README.md` | README 仍是 React SPA template/empty scaffold 描述 | 项目交接信息不准确 | 更新为当前项目启动、构建、环境变量、部署说明 |
## 优先处理建议
1. 先处理 P0 安全问题:删除 `/ws-test` 生产暴露入口,移除前端密钥。
2. 再处理 P1 功能与首屏性能问题:避免全量清 storage拆掉全资源阻塞预加载。
3. 然后处理 P2 的体积、音频加载和下注模型一致性问题。
4. 最后做 P3 样式细节、死代码和文档维护。
## 备注
- 本次审查未修改业务代码。
- 仓库目前未发现有效自动化测试文件,后续修复 P0/P1 后建议补充登录态清理、下注提交、首屏渲染和资源加载相关测试。

View File

@@ -0,0 +1,198 @@
@property --gold-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@keyframes rotating-gold-border {
to {
--gold-angle: 360deg;
}
}
@keyframes gold-border-pulse {
0%,
100% {
opacity: 0.34;
transform: scale(0.992);
}
50% {
opacity: 0.54;
transform: scale(1.008);
}
}
.gold-reveal-shell {
--gold-angle: 0deg;
position: absolute;
inset: calc(var(--design-unit) * 2.6);
border-radius: calc(var(--design-unit) * 16);
overflow: hidden;
clip-path: inset(0 round calc(var(--design-unit) * 16));
pointer-events: none;
}
.gold-reveal-shell::before {
content: "";
position: absolute;
inset: 0;
border-radius: calc(var(--design-unit) * 16);
padding: calc(var(--design-unit) * 5.6);
background: conic-gradient(
from var(--gold-angle),
#534217 10%,
#534217 20%,
#ffe226 45%,
#534217 60%,
#534217 85%,
#ffe226 95%,
#534217 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: rotating-gold-border 8s linear infinite;
}
.gold-reveal-shell::after {
content: "";
position: absolute;
inset: calc(var(--design-unit) * 1);
border-radius: calc(var(--design-unit) * 15);
padding: calc(var(--design-unit) * 4);
background: conic-gradient(
from calc(var(--gold-angle) + 180deg),
rgba(255, 247, 210, 0) 0%,
rgba(255, 247, 210, 0) 66%,
rgba(255, 252, 232, 0.14) 71%,
rgba(255, 254, 244, 0.98) 75%,
rgba(255, 230, 130, 0.96) 79%,
rgba(255, 247, 210, 0.18) 84%,
rgba(255, 247, 210, 0) 90%,
rgba(255, 247, 210, 0) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: rotating-gold-border 2.1s linear infinite;
}
.gold-reveal-static-border {
position: absolute;
inset: calc(var(--design-unit) * 2.8);
border-radius: calc(var(--design-unit) * 16);
border: calc(var(--design-unit) * 7.4) solid rgba(181, 138, 40, 0.98);
box-shadow:
inset 0 0 calc(var(--design-unit) * 14) rgba(255, 241, 181, 0.38),
0 0 calc(var(--design-unit) * 18) rgba(255, 210, 102, 0.32);
pointer-events: none;
}
.gold-reveal-glow {
position: absolute;
inset: calc(var(--design-unit) * 1.4);
border-radius: calc(var(--design-unit) * 18);
background: radial-gradient(
circle at center,
rgba(255, 226, 92, 0.18) 0%,
rgba(219, 161, 42, 0.14) 42%,
rgba(127, 86, 13, 0.08) 62%,
transparent 82%
);
filter: blur(calc(var(--design-unit) * 2.8));
opacity: 0.36;
pointer-events: none;
animation: gold-border-pulse 1.9s ease-in-out infinite;
}
.mobile-gold-reveal-shell {
--gold-angle: 0deg;
position: absolute;
inset: calc(var(--design-unit) * 2.2);
border-radius: calc(var(--design-unit) * 7);
overflow: hidden;
clip-path: inset(0 round calc(var(--design-unit) * 7));
pointer-events: none;
}
.mobile-gold-reveal-shell::before {
content: "";
position: absolute;
inset: 0;
border-radius: calc(var(--design-unit) * 7);
padding: calc(var(--design-unit) * 2.5);
background: conic-gradient(
from var(--gold-angle),
#534217 10%,
#534217 20%,
#ffe226 45%,
#534217 60%,
#534217 85%,
#ffe226 95%,
#534217 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: rotating-gold-border 8s linear infinite;
}
.mobile-gold-reveal-shell::after {
content: "";
position: absolute;
inset: calc(var(--design-unit) * 0.5);
border-radius: calc(var(--design-unit) * 6.5);
padding: calc(var(--design-unit) * 1.7);
background: conic-gradient(
from calc(var(--gold-angle) + 180deg),
rgba(255, 247, 210, 0) 0%,
rgba(255, 247, 210, 0) 66%,
rgba(255, 247, 210, 0.14) 71%,
rgba(255, 254, 244, 0.98) 75%,
rgba(255, 230, 130, 0.96) 79%,
rgba(255, 247, 210, 0.18) 84%,
rgba(255, 247, 210, 0) 90%,
rgba(255, 247, 210, 0) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: rotating-gold-border 2.1s linear infinite;
}
.mobile-gold-reveal-static-border {
position: absolute;
inset: calc(var(--design-unit) * 2.7);
border-radius: calc(var(--design-unit) * 7);
border: calc(var(--design-unit) * 2.8) solid rgba(181, 138, 40, 0.98);
box-shadow:
inset 0 0 calc(var(--design-unit) * 7) rgba(255, 241, 181, 0.38),
0 0 calc(var(--design-unit) * 9) rgba(255, 210, 102, 0.32);
pointer-events: none;
}
.mobile-gold-reveal-glow {
position: absolute;
inset: calc(var(--design-unit) * 1.4);
border-radius: calc(var(--design-unit) * 8);
background: radial-gradient(
circle at center,
rgba(255, 226, 92, 0.2) 0%,
rgba(219, 161, 42, 0.16) 42%,
rgba(127, 86, 13, 0.08) 62%,
transparent 82%
);
filter: blur(calc(var(--design-unit) * 1.4));
opacity: 0.42;
pointer-events: none;
animation: gold-border-pulse 1.9s ease-in-out infinite;
}

View File

@@ -0,0 +1,268 @@
# 结算过程金色旋转边框样式与动画
本文档保存当前 PC 端与移动端在开奖/结算过程里使用的金色边框、旋转描边和发光动画,便于后续下载、复用或对接设计标注。
## 源码位置
- PC 端挂载位置:`src/features/game/components/desktop/desktop-animal.tsx`
- 移动端挂载位置:`src/features/game/components/mobile/mobile-animal.tsx`
- 样式定义位置:`src/style/index.css`
PC 端在 `revealFrame` 存在时渲染:
```tsx
<div className="gold-reveal-glow rounded-[calc(var(--design-unit)*16)]" />
<div className="gold-reveal-static-border rounded-[calc(var(--design-unit)*16)]" />
<div className="gold-reveal-shell rounded-[calc(var(--design-unit)*16)]" />
```
移动端在 `revealFrame` 存在时渲染:
```tsx
<div className="mobile-gold-reveal-glow z-10 rounded-[calc(var(--design-unit)*7)]" />
<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)]" />
```
## 动画机制
- `--gold-angle`:注册为 CSS angle 自定义属性,用于驱动 `conic-gradient` 旋转。
- `rotating-gold-border`:把 `--gold-angle``0deg` 推到 `360deg`,形成边框旋转。
- `gold-border-pulse`:让外层金色光晕做轻微透明度和缩放呼吸。
- `::before`主金色旋转边框8 秒一圈。
- `::after`高光扫光边框2.1 秒一圈,并从反向角度偏移 `180deg` 开始。
- `static-border`:静态金色实体边框,保证边框厚度和底色稳定。
- `glow`:外部径向金色光晕,增强结算聚焦感。
## PC 端样式
```css
@property --gold-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@keyframes rotating-gold-border {
to {
--gold-angle: 360deg;
}
}
@keyframes gold-border-pulse {
0%,
100% {
opacity: 0.34;
transform: scale(0.992);
}
50% {
opacity: 0.54;
transform: scale(1.008);
}
}
.gold-reveal-shell {
--gold-angle: 0deg;
position: absolute;
inset: calc(var(--design-unit) * 2.6);
border-radius: calc(var(--design-unit) * 16);
overflow: hidden;
clip-path: inset(0 round calc(var(--design-unit) * 16));
pointer-events: none;
}
.gold-reveal-shell::before {
content: "";
position: absolute;
inset: 0;
border-radius: calc(var(--design-unit) * 16);
padding: calc(var(--design-unit) * 5.6);
background: conic-gradient(
from var(--gold-angle),
#534217 10%,
#534217 20%,
#ffe226 45%,
#534217 60%,
#534217 85%,
#ffe226 95%,
#534217 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: rotating-gold-border 8s linear infinite;
}
.gold-reveal-shell::after {
content: "";
position: absolute;
inset: calc(var(--design-unit) * 1);
border-radius: calc(var(--design-unit) * 15);
padding: calc(var(--design-unit) * 4);
background: conic-gradient(
from calc(var(--gold-angle) + 180deg),
rgba(255, 247, 210, 0) 0%,
rgba(255, 247, 210, 0) 66%,
rgba(255, 252, 232, 0.14) 71%,
rgba(255, 254, 244, 0.98) 75%,
rgba(255, 230, 130, 0.96) 79%,
rgba(255, 247, 210, 0.18) 84%,
rgba(255, 247, 210, 0) 90%,
rgba(255, 247, 210, 0) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: rotating-gold-border 2.1s linear infinite;
}
.gold-reveal-static-border {
position: absolute;
inset: calc(var(--design-unit) * 2.8);
border-radius: calc(var(--design-unit) * 16);
border: calc(var(--design-unit) * 7.4) solid rgba(181, 138, 40, 0.98);
box-shadow:
inset 0 0 calc(var(--design-unit) * 14) rgba(255, 241, 181, 0.38),
0 0 calc(var(--design-unit) * 18) rgba(255, 210, 102, 0.32);
pointer-events: none;
}
.gold-reveal-glow {
position: absolute;
inset: calc(var(--design-unit) * 1.4);
border-radius: calc(var(--design-unit) * 18);
background: radial-gradient(
circle at center,
rgba(255, 226, 92, 0.18) 0%,
rgba(219, 161, 42, 0.14) 42%,
rgba(127, 86, 13, 0.08) 62%,
transparent 82%
);
filter: blur(calc(var(--design-unit) * 2.8));
opacity: 0.36;
pointer-events: none;
animation: gold-border-pulse 1.9s ease-in-out infinite;
}
```
## 移动端样式
```css
.mobile-gold-reveal-shell {
--gold-angle: 0deg;
position: absolute;
inset: calc(var(--design-unit) * 2.2);
border-radius: calc(var(--design-unit) * 7);
overflow: hidden;
clip-path: inset(0 round calc(var(--design-unit) * 7));
pointer-events: none;
}
.mobile-gold-reveal-shell::before {
content: "";
position: absolute;
inset: 0;
border-radius: calc(var(--design-unit) * 7);
padding: calc(var(--design-unit) * 2.5);
background: conic-gradient(
from var(--gold-angle),
#534217 10%,
#534217 20%,
#ffe226 45%,
#534217 60%,
#534217 85%,
#ffe226 95%,
#534217 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: rotating-gold-border 8s linear infinite;
}
.mobile-gold-reveal-shell::after {
content: "";
position: absolute;
inset: calc(var(--design-unit) * 0.5);
border-radius: calc(var(--design-unit) * 6.5);
padding: calc(var(--design-unit) * 1.7);
background: conic-gradient(
from calc(var(--gold-angle) + 180deg),
rgba(255, 247, 210, 0) 0%,
rgba(255, 247, 210, 0) 66%,
rgba(255, 247, 210, 0.14) 71%,
rgba(255, 254, 244, 0.98) 75%,
rgba(255, 230, 130, 0.96) 79%,
rgba(255, 247, 210, 0.18) 84%,
rgba(255, 247, 210, 0) 90%,
rgba(255, 247, 210, 0) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: rotating-gold-border 2.1s linear infinite;
}
.mobile-gold-reveal-static-border {
position: absolute;
inset: calc(var(--design-unit) * 2.7);
border-radius: calc(var(--design-unit) * 7);
border: calc(var(--design-unit) * 2.8) solid rgba(181, 138, 40, 0.98);
box-shadow:
inset 0 0 calc(var(--design-unit) * 7) rgba(255, 241, 181, 0.38),
0 0 calc(var(--design-unit) * 9) rgba(255, 210, 102, 0.32);
pointer-events: none;
}
.mobile-gold-reveal-glow {
position: absolute;
inset: calc(var(--design-unit) * 1.4);
border-radius: calc(var(--design-unit) * 8);
background: radial-gradient(
circle at center,
rgba(255, 226, 92, 0.2) 0%,
rgba(219, 161, 42, 0.16) 42%,
rgba(127, 86, 13, 0.08) 62%,
transparent 82%
);
filter: blur(calc(var(--design-unit) * 1.4));
opacity: 0.42;
pointer-events: none;
animation: gold-border-pulse 1.9s ease-in-out infinite;
}
```
## 减少动画模式
组件中已经接入 `useReducedMotion()`。当用户系统设置减少动态效果时PC 与移动端都会把旋转和呼吸动画关闭:
```tsx
style={prefersReducedMotion ? { animation: 'none' } : undefined}
```
光晕层在减少动画模式下固定为:
```tsx
style={{
animation: 'none',
opacity: 0.36,
transform: 'scale(1)',
}}
```
## 复用注意事项
- 父容器必须是 `position: relative`,三层边框节点必须是绝对定位。
- 需要提供 `--design-unit`,当前项目用它控制 PC 与移动端等比缩放。
- `@property --gold-angle` 是旋转效果的关键;如果目标浏览器不支持 `@property`,可改用 `transform: rotate()` 方案,但需要额外包一层元素。
- 移动端额外叠加了 `animalBorderImage` 图片边框CSS 金色旋转边框位于图片边框下方,增强结算选中效果。

View File

@@ -5,7 +5,7 @@ import sharp from 'sharp'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(__dirname, '..') const rootDir = path.resolve(__dirname, '..')
const animalDir = path.join(rootDir, 'src/assets/animal') const animalDir = path.join(rootDir, 'src/assets/animal')
const outputPath = path.join(animalDir, 'animal-sprite.webp') const lightAnimalDir = path.join(rootDir, 'src/assets/light-animal')
const columns = 6 const columns = 6
const rows = 6 const rows = 6
@@ -13,41 +13,47 @@ const cellWidth = 446
const cellHeight = 224 const cellHeight = 224
const quality = 82 const quality = 82
const composites = await Promise.all( async function generateSprite(sourceDir, outputFileName) {
Array.from({ length: columns * rows }, async (_, index) => { const outputPath = path.join(sourceDir, outputFileName)
const id = index + 1 const composites = await Promise.all(
const input = path.join(animalDir, `${id}.webp`) Array.from({ length: columns * rows }, async (_, index) => {
const left = (index % columns) * cellWidth const id = index + 1
const top = Math.floor(index / columns) * cellHeight const input = path.join(sourceDir, `${id}.webp`)
const left = (index % columns) * cellWidth
const top = Math.floor(index / columns) * cellHeight
const buffer = await sharp(input) const buffer = await sharp(input)
.resize(cellWidth, cellHeight, { .resize(cellWidth, cellHeight, {
fit: 'cover', fit: 'cover',
position: 'center', position: 'center',
}) })
.webp({ quality }) .webp({ quality })
.toBuffer() .toBuffer()
return { return {
input: buffer, input: buffer,
left, left,
top, top,
} }
}), }),
) )
await sharp({ await sharp({
create: { create: {
width: columns * cellWidth, width: columns * cellWidth,
height: rows * cellHeight, height: rows * cellHeight,
channels: 4, channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 }, background: { r: 0, g: 0, b: 0, alpha: 0 },
}, },
}) })
.composite(composites) .composite(composites)
.webp({ quality }) .webp({ quality })
.toFile(outputPath) .toFile(outputPath)
console.log( console.log(
`Generated ${path.relative(rootDir, outputPath)} (${columns}x${rows}, ${cellWidth}x${cellHeight})`, `Generated ${path.relative(rootDir, outputPath)} (${columns}x${rows}, ${cellWidth}x${cellHeight})`,
) )
}
await generateSprite(animalDir, 'animal-sprite.webp')
await generateSprite(lightAnimalDir, 'light-animal-sprite.webp')

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/[1-9].webp',
'!../assets/animal/[12][0-9].webp', '!../assets/animal/[12][0-9].webp',
'!../assets/animal/3[0-6].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/[1-9].webp',
'!../assets/reward/[12][0-9].webp', '!../assets/reward/[12][0-9].webp',
'!../assets/reward/3[0-6].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 PC_LOADING_BG_URL = '/loading/pc-loading-bg.webp'
const MOBILE_LOADING_BG_URL = '/loading/mobile-loading-bg.webp' const MOBILE_LOADING_BG_URL = '/loading/mobile-loading-bg.webp'
const SPRITE_SOURCE_IMAGE_PATTERN = 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) { function preloadImage(url: string) {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {

View File

@@ -1,6 +1,6 @@
import { TriangleAlert } from 'lucide-react' import { TriangleAlert } from 'lucide-react'
import { motion, useReducedMotion } from 'motion/react' import { motion } from 'motion/react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import animalBorderImage from '@/assets/game/animal-border.webp' import animalBorderImage from '@/assets/game/animal-border.webp'
import diamondIcon from '@/assets/system/diamond.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_RANDOM_DURATION_MS = 3_200
const SETTLEMENT_REVEAL_SETTLE_DURATION_MS = 800 const SETTLEMENT_REVEAL_SETTLE_DURATION_MS = 800
const SETTLEMENT_REVEAL_RESULT_HOLD_MS = 1_000 const SETTLEMENT_REVEAL_RESULT_HOLD_MS = 1_000
const SETTLEMENT_REVEAL_MIN_STEP_MS = 180 const SETTLEMENT_REVEAL_SPIN_STEP_MS = 160
const SETTLEMENT_REVEAL_MAX_STEP_MS = 960 const SETTLEMENT_REVEAL_MIN_STEP_MS = 300
const SETTLEMENT_REVEAL_MAX_STEP_MS = 760
function getRandomAnimalId(ids: number[], currentId: number | null) { function getRandomAnimalId(ids: number[], currentId: number | null) {
if (ids.length === 0) { if (ids.length === 0) {
@@ -40,7 +41,7 @@ function getRandomAnimalId(ids: number[], currentId: number | null) {
function getSettlementRevealStepDelay(progress: number) { function getSettlementRevealStepDelay(progress: number) {
const clampedProgress = Math.min(Math.max(progress, 0), 1) const clampedProgress = Math.min(Math.max(progress, 0), 1)
const easedProgress = clampedProgress ** 1.65 const easedProgress = clampedProgress ** 1.35
return ( return (
SETTLEMENT_REVEAL_MIN_STEP_MS + SETTLEMENT_REVEAL_MIN_STEP_MS +
@@ -63,19 +64,8 @@ export function DesktopAnimal({
onSelect, onSelect,
}: DesktopAnimalProps) { }: DesktopAnimalProps) {
const { t } = useTranslation() const { t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const animalIds = useMemo(() => FLOWER_IMAGE_LIST.map((item) => item.id), []) 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 [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 revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const revealWinningCellId = useGameRoundStore( const revealWinningCellId = useGameRoundStore(
(state) => state.revealAnimation.winningCellId, (state) => state.revealAnimation.winningCellId,
@@ -102,10 +92,6 @@ export function DesktopAnimal({
showStandbyState, showStandbyState,
} = useAnimalVm(animalIds, onSelect) } = useAnimalVm(animalIds, onSelect)
const isRevealRunning =
revealPhase === 'spinning' ||
(revealPhase === 'stopping' && !isRevealHoldingResult)
const isRevealResult = revealPhase === 'result'
const [hasRewardOverlayStarted, setHasRewardOverlayStarted] = useState(false) const [hasRewardOverlayStarted, setHasRewardOverlayStarted] = useState(false)
const shouldHideRevealHighlight = const shouldHideRevealHighlight =
revealPhase === 'result' && revealPhase === 'result' &&
@@ -131,26 +117,20 @@ export function DesktopAnimal({
useEffect(() => { useEffect(() => {
if (revealPhase === 'idle') { if (revealPhase === 'idle') {
setRevealCellId(null) setRevealCellId(null)
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return return
} }
if (revealPhase === 'result') { if (revealPhase === 'result') {
setRevealCellId(shouldHideRevealHighlight ? null : revealWinningCellId) setRevealCellId(shouldHideRevealHighlight ? null : revealWinningCellId)
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return return
} }
if (revealPhase === 'spinning') { if (revealPhase === 'spinning') {
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId)) setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId)) setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
}, 70) }, SETTLEMENT_REVEAL_SPIN_STEP_MS)
return () => { return () => {
window.clearInterval(intervalId) window.clearInterval(intervalId)
@@ -158,25 +138,18 @@ export function DesktopAnimal({
} }
if (revealWinningCellId === null) { if (revealWinningCellId === null) {
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return return
} }
const startedAt = performance.now() const startedAt = performance.now()
let timeoutId = 0 let timeoutId = 0
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
const step = () => { const step = () => {
const elapsedMs = performance.now() - startedAt const elapsedMs = performance.now() - startedAt
if (elapsedMs >= SETTLEMENT_REVEAL_RANDOM_DURATION_MS) { if (elapsedMs >= SETTLEMENT_REVEAL_RANDOM_DURATION_MS) {
setIsRevealSettlingResult(true)
setRevealCellId(revealWinningCellId) setRevealCellId(revealWinningCellId)
timeoutId = window.setTimeout(() => { timeoutId = window.setTimeout(() => {
setIsRevealSettlingResult(false)
setIsRevealHoldingResult(true)
timeoutId = window.setTimeout(() => { timeoutId = window.setTimeout(() => {
finishRevealAnimation() finishRevealAnimation()
}, SETTLEMENT_REVEAL_RESULT_HOLD_MS) }, SETTLEMENT_REVEAL_RESULT_HOLD_MS)
@@ -207,45 +180,11 @@ export function DesktopAnimal({
shouldHideRevealHighlight, 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 ( return (
<section <section
ref={containerRef}
className={cn( 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, className,
)} )}
> >
@@ -253,13 +192,13 @@ export function DesktopAnimal({
const selectionMeta = selectionByCell[item.id] const selectionMeta = selectionByCell[item.id]
const hasPlacedSelection = Boolean(selectionMeta) const hasPlacedSelection = Boolean(selectionMeta)
const isMarqueeActive = showStandbyState && item.id === marqueeId const isMarqueeActive = showStandbyState && item.id === marqueeId
const isRevealWinner = const isRevealActive =
!shouldHideRevealHighlight && !shouldHideRevealHighlight && revealCellId === item.id
(isRevealResult || isRevealHoldingResult) &&
revealWinningCellId === item.id
const warningType = const warningType =
cellWarning?.cellId === item.id ? cellWarning.type : null cellWarning?.cellId === item.id ? cellWarning.type : null
const showCellWarning = warningType !== null const showCellWarning = warningType !== null
const animalSpriteVariant =
hasPlacedSelection || isMarqueeActive ? 'light' : 'normal'
const warningLabel = const warningLabel =
warningType === 'balance' warningType === 'balance'
? t('gameDesktop.animal.insufficientBalanceRecharge') ? t('gameDesktop.animal.insufficientBalanceRecharge')
@@ -270,13 +209,6 @@ export function DesktopAnimal({
return ( return (
<motion.button <motion.button
key={item.id} key={item.id}
ref={(node) => {
if (node) {
cellRefs.current.set(item.id, node)
} else {
cellRefs.current.delete(item.id)
}
}}
type="button" type="button"
disabled={lockInteraction || showStopOverlay} disabled={lockInteraction || showStopOverlay}
onClick={() => handleSelect(item.id)} onClick={() => handleSelect(item.id)}
@@ -302,22 +234,16 @@ export function DesktopAnimal({
: { duration: 0.16, ease: 'easeOut' } : { duration: 0.16, ease: 'easeOut' }
} }
className={cn( 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 lockInteraction
? 'cursor-not-allowed opacity-90' ? 'cursor-not-allowed opacity-90'
: 'cursor-pointer hover:-translate-y-[1px]', : 'cursor-pointer hover:-translate-y-[1px]',
isMarqueeActive && 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)]', '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 && 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)]', '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 && !showStandbyState && !hasPlacedSelection && 'opacity-95',
!hasPlacedSelection &&
!isRevealWinner &&
'opacity-95',
itemClassName, 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', 'pointer-events-none absolute inset-[calc(var(--design-unit)*2)] rounded-[calc(var(--design-unit)*15)] opacity-0 transition-opacity duration-150',
isMarqueeActive && 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)]', '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 && 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', '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 <span
aria-hidden="true" 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)]" 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 <AnimalSpriteImage
animalId={item.id} animalId={item.id}
alt={`animal-${item.id}`} alt={`animal-${item.id}`}
variant={animalSpriteVariant}
className={cn( 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)]', '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, 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 ? ( {showCellWarning ? (
<motion.span <motion.span
initial={{ opacity: 0, scale: 0.94 }} 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} /> <DesktopAnimalOverlay showStopOverlay={showStopOverlay} />
<RoundBettingStartAlert className="z-50" /> <RoundBettingStartAlert className="z-50" />

View File

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

View File

@@ -1,6 +1,6 @@
import { TriangleAlert } from 'lucide-react' import { TriangleAlert } from 'lucide-react'
import { motion, useReducedMotion } from 'motion/react' import { motion } from 'motion/react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import animalBorderImage from '@/assets/game/animal-border.webp' import animalBorderImage from '@/assets/game/animal-border.webp'
import diamondIcon from '@/assets/system/diamond.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_RANDOM_DURATION_MS = 3_200
const SETTLEMENT_REVEAL_SETTLE_DURATION_MS = 800 const SETTLEMENT_REVEAL_SETTLE_DURATION_MS = 800
const SETTLEMENT_REVEAL_RESULT_HOLD_MS = 1_000 const SETTLEMENT_REVEAL_RESULT_HOLD_MS = 1_000
const SETTLEMENT_REVEAL_MIN_STEP_MS = 180 const SETTLEMENT_REVEAL_SPIN_STEP_MS = 160
const SETTLEMENT_REVEAL_MAX_STEP_MS = 960 const SETTLEMENT_REVEAL_MIN_STEP_MS = 300
const SETTLEMENT_REVEAL_MAX_STEP_MS = 760
function getRandomAnimalId(ids: number[], currentId: number | null) { function getRandomAnimalId(ids: number[], currentId: number | null) {
if (ids.length === 0) { if (ids.length === 0) {
@@ -40,7 +41,7 @@ function getRandomAnimalId(ids: number[], currentId: number | null) {
function getSettlementRevealStepDelay(progress: number) { function getSettlementRevealStepDelay(progress: number) {
const clampedProgress = Math.min(Math.max(progress, 0), 1) const clampedProgress = Math.min(Math.max(progress, 0), 1)
const easedProgress = clampedProgress ** 1.65 const easedProgress = clampedProgress ** 1.35
return ( return (
SETTLEMENT_REVEAL_MIN_STEP_MS + SETTLEMENT_REVEAL_MIN_STEP_MS +
@@ -63,20 +64,8 @@ export function MobileAnimal({
onSelect, onSelect,
}: MobileAnimalProps) { }: MobileAnimalProps) {
const { t } = useTranslation() const { t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const animalIds = useMemo(() => FLOWER_IMAGE_LIST.map((item) => item.id), []) 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 [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 revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const revealWinningCellId = useGameRoundStore( const revealWinningCellId = useGameRoundStore(
(state) => state.revealAnimation.winningCellId, (state) => state.revealAnimation.winningCellId,
@@ -103,10 +92,6 @@ export function MobileAnimal({
showStandbyState, showStandbyState,
} = useAnimalVm(animalIds, onSelect) } = useAnimalVm(animalIds, onSelect)
const isRevealRunning =
revealPhase === 'spinning' ||
(revealPhase === 'stopping' && !isRevealHoldingResult)
const isRevealResult = revealPhase === 'result'
const [hasRewardOverlayStarted, setHasRewardOverlayStarted] = useState(false) const [hasRewardOverlayStarted, setHasRewardOverlayStarted] = useState(false)
const shouldHideRevealHighlight = const shouldHideRevealHighlight =
revealPhase === 'result' && revealPhase === 'result' &&
@@ -132,26 +117,20 @@ export function MobileAnimal({
useEffect(() => { useEffect(() => {
if (revealPhase === 'idle') { if (revealPhase === 'idle') {
setRevealCellId(null) setRevealCellId(null)
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return return
} }
if (revealPhase === 'result') { if (revealPhase === 'result') {
setRevealCellId(shouldHideRevealHighlight ? null : revealWinningCellId) setRevealCellId(shouldHideRevealHighlight ? null : revealWinningCellId)
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return return
} }
if (revealPhase === 'spinning') { if (revealPhase === 'spinning') {
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId)) setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId)) setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
}, 70) }, SETTLEMENT_REVEAL_SPIN_STEP_MS)
return () => { return () => {
window.clearInterval(intervalId) window.clearInterval(intervalId)
@@ -159,25 +138,18 @@ export function MobileAnimal({
} }
if (revealWinningCellId === null) { if (revealWinningCellId === null) {
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return return
} }
const startedAt = performance.now() const startedAt = performance.now()
let timeoutId = 0 let timeoutId = 0
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
const step = () => { const step = () => {
const elapsedMs = performance.now() - startedAt const elapsedMs = performance.now() - startedAt
if (elapsedMs >= SETTLEMENT_REVEAL_RANDOM_DURATION_MS) { if (elapsedMs >= SETTLEMENT_REVEAL_RANDOM_DURATION_MS) {
setIsRevealSettlingResult(true)
setRevealCellId(revealWinningCellId) setRevealCellId(revealWinningCellId)
timeoutId = window.setTimeout(() => { timeoutId = window.setTimeout(() => {
setIsRevealSettlingResult(false)
setIsRevealHoldingResult(true)
timeoutId = window.setTimeout(() => { timeoutId = window.setTimeout(() => {
finishRevealAnimation() finishRevealAnimation()
}, SETTLEMENT_REVEAL_RESULT_HOLD_MS) }, SETTLEMENT_REVEAL_RESULT_HOLD_MS)
@@ -208,63 +180,29 @@ export function MobileAnimal({
shouldHideRevealHighlight, 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 ( return (
<section <section
ref={containerRef}
className={cn( 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, className,
)} )}
> >
<MobileStatusLine /> <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"> <div className="grid w-full grid-cols-4 gap-design-3">
{FLOWER_IMAGE_LIST.map((item) => { {FLOWER_IMAGE_LIST.map((item) => {
const selectionMeta = selectionByCell[item.id] const selectionMeta = selectionByCell[item.id]
const hasPlacedSelection = Boolean(selectionMeta) const hasPlacedSelection = Boolean(selectionMeta)
const isMarqueeActive = showStandbyState && item.id === marqueeId const isMarqueeActive = showStandbyState && item.id === marqueeId
const isRevealWinner = const isRevealActive =
!shouldHideRevealHighlight && !shouldHideRevealHighlight && revealCellId === item.id
(isRevealResult || isRevealHoldingResult) &&
revealWinningCellId === item.id
const warningType = const warningType =
cellWarning?.cellId === item.id ? cellWarning.type : null cellWarning?.cellId === item.id ? cellWarning.type : null
const showCellWarning = warningType !== null const showCellWarning = warningType !== null
const animalSpriteVariant =
hasPlacedSelection || isMarqueeActive ? 'light' : 'normal'
const warningLabel = const warningLabel =
warningType === 'balance' warningType === 'balance'
? t('gameDesktop.animal.insufficientBalanceRecharge') ? t('gameDesktop.animal.insufficientBalanceRecharge')
@@ -275,13 +213,6 @@ export function MobileAnimal({
return ( return (
<motion.button <motion.button
key={item.id} key={item.id}
ref={(node) => {
if (node) {
cellRefs.current.set(item.id, node)
} else {
cellRefs.current.delete(item.id)
}
}}
type="button" type="button"
disabled={lockInteraction || showStopOverlay} disabled={lockInteraction || showStopOverlay}
onClick={() => handleSelect(item.id)} onClick={() => handleSelect(item.id)}
@@ -307,22 +238,16 @@ export function MobileAnimal({
: { duration: 0.16, ease: 'easeOut' } : { duration: 0.16, ease: 'easeOut' }
} }
className={cn( 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 lockInteraction
? 'cursor-not-allowed opacity-90' ? 'cursor-not-allowed opacity-90'
: 'cursor-pointer hover:-translate-y-[1px]', : 'cursor-pointer hover:-translate-y-[1px]',
isMarqueeActive && 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)]', '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 && 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)]', '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 && !showStandbyState && !hasPlacedSelection && 'opacity-95',
!hasPlacedSelection &&
!isRevealWinner &&
'opacity-95',
itemClassName, 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', 'pointer-events-none absolute inset-[calc(var(--design-unit)*1)] rounded-[calc(var(--design-unit)*6)] opacity-0 transition-opacity duration-150',
isMarqueeActive && 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)]', '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 && 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', '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 <span
aria-hidden="true" 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)]" 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 <AnimalSpriteImage
animalId={item.id} animalId={item.id}
alt={`animal-${item.id}`} alt={`animal-${item.id}`}
variant={animalSpriteVariant}
className={cn( 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)]', '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, 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 ? ( {showCellWarning ? (
<motion.span <motion.span
initial={{ opacity: 0, scale: 0.94 }} initial={{ opacity: 0, scale: 0.94 }}
@@ -441,52 +381,6 @@ export function MobileAnimal({
})} })}
</div> </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} /> <MobileAnimalOverlay showStopOverlay={showStopOverlay} />
</div> </div>

View File

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

View File

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

View File

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