diff --git a/docs/code-review-2026-06-05.md b/docs/code-review-2026-06-05.md deleted file mode 100644 index 1a16e31..0000000 --- a/docs/code-review-2026-06-05.md +++ /dev/null @@ -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 后建议补充登录态清理、下注提交、首屏渲染和资源加载相关测试。 diff --git a/docs/settlement-gold-border-animation.css b/docs/settlement-gold-border-animation.css new file mode 100644 index 0000000..16b6256 --- /dev/null +++ b/docs/settlement-gold-border-animation.css @@ -0,0 +1,198 @@ +@property --gold-angle { + syntax: ""; + 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; +} diff --git a/docs/settlement-gold-border-animation.md b/docs/settlement-gold-border-animation.md new file mode 100644 index 0000000..52bea2b --- /dev/null +++ b/docs/settlement-gold-border-animation.md @@ -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 +
+
+
+``` + +移动端在 `revealFrame` 存在时渲染: + +```tsx +
+
+
+``` + +## 动画机制 + +- `--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: ""; + 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 金色旋转边框位于图片边框下方,增强结算选中效果。 + diff --git a/scripts/generate-animal-sprite.mjs b/scripts/generate-animal-sprite.mjs index 9dc4a43..0eeebb4 100644 --- a/scripts/generate-animal-sprite.mjs +++ b/scripts/generate-animal-sprite.mjs @@ -5,7 +5,7 @@ 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 lightAnimalDir = path.join(rootDir, 'src/assets/light-animal') const columns = 6 const rows = 6 @@ -13,41 +13,47 @@ 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 +async function generateSprite(sourceDir, outputFileName) { + const outputPath = path.join(sourceDir, outputFileName) + const composites = await Promise.all( + Array.from({ length: columns * rows }, async (_, index) => { + const id = index + 1 + const input = path.join(sourceDir, `${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() + const buffer = await sharp(input) + .resize(cellWidth, cellHeight, { + fit: 'cover', + position: 'center', + }) + .webp({ quality }) + .toBuffer() - return { - input: buffer, - left, - top, - } - }), -) + 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) + 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})`, -) + console.log( + `Generated ${path.relative(rootDir, outputPath)} (${columns}x${rows}, ${cellWidth}x${cellHeight})`, + ) +} + +await generateSprite(animalDir, 'animal-sprite.webp') +await generateSprite(lightAnimalDir, 'light-animal-sprite.webp') diff --git a/src/assets/light-animal/1.webp b/src/assets/light-animal/1.webp new file mode 100644 index 0000000..641d2d2 Binary files /dev/null and b/src/assets/light-animal/1.webp differ diff --git a/src/assets/light-animal/10.webp b/src/assets/light-animal/10.webp new file mode 100644 index 0000000..72b7d08 Binary files /dev/null and b/src/assets/light-animal/10.webp differ diff --git a/src/assets/light-animal/11.webp b/src/assets/light-animal/11.webp new file mode 100644 index 0000000..8deb110 Binary files /dev/null and b/src/assets/light-animal/11.webp differ diff --git a/src/assets/light-animal/12.webp b/src/assets/light-animal/12.webp new file mode 100644 index 0000000..7db5fef Binary files /dev/null and b/src/assets/light-animal/12.webp differ diff --git a/src/assets/light-animal/13.webp b/src/assets/light-animal/13.webp new file mode 100644 index 0000000..3cb6a03 Binary files /dev/null and b/src/assets/light-animal/13.webp differ diff --git a/src/assets/light-animal/14.webp b/src/assets/light-animal/14.webp new file mode 100644 index 0000000..cfffdf7 Binary files /dev/null and b/src/assets/light-animal/14.webp differ diff --git a/src/assets/light-animal/15.webp b/src/assets/light-animal/15.webp new file mode 100644 index 0000000..7153560 Binary files /dev/null and b/src/assets/light-animal/15.webp differ diff --git a/src/assets/light-animal/16.webp b/src/assets/light-animal/16.webp new file mode 100644 index 0000000..20f087a Binary files /dev/null and b/src/assets/light-animal/16.webp differ diff --git a/src/assets/light-animal/17.webp b/src/assets/light-animal/17.webp new file mode 100644 index 0000000..86e7503 Binary files /dev/null and b/src/assets/light-animal/17.webp differ diff --git a/src/assets/light-animal/18.webp b/src/assets/light-animal/18.webp new file mode 100644 index 0000000..3ce9908 Binary files /dev/null and b/src/assets/light-animal/18.webp differ diff --git a/src/assets/light-animal/19.webp b/src/assets/light-animal/19.webp new file mode 100644 index 0000000..7d96075 Binary files /dev/null and b/src/assets/light-animal/19.webp differ diff --git a/src/assets/light-animal/2.webp b/src/assets/light-animal/2.webp new file mode 100644 index 0000000..0248e27 Binary files /dev/null and b/src/assets/light-animal/2.webp differ diff --git a/src/assets/light-animal/20.webp b/src/assets/light-animal/20.webp new file mode 100644 index 0000000..abf42e0 Binary files /dev/null and b/src/assets/light-animal/20.webp differ diff --git a/src/assets/light-animal/21.webp b/src/assets/light-animal/21.webp new file mode 100644 index 0000000..eb2b1ae Binary files /dev/null and b/src/assets/light-animal/21.webp differ diff --git a/src/assets/light-animal/22.webp b/src/assets/light-animal/22.webp new file mode 100644 index 0000000..8bd7eac Binary files /dev/null and b/src/assets/light-animal/22.webp differ diff --git a/src/assets/light-animal/23.webp b/src/assets/light-animal/23.webp new file mode 100644 index 0000000..8ec8212 Binary files /dev/null and b/src/assets/light-animal/23.webp differ diff --git a/src/assets/light-animal/24.webp b/src/assets/light-animal/24.webp new file mode 100644 index 0000000..15463b6 Binary files /dev/null and b/src/assets/light-animal/24.webp differ diff --git a/src/assets/light-animal/25.webp b/src/assets/light-animal/25.webp new file mode 100644 index 0000000..3f24ffe Binary files /dev/null and b/src/assets/light-animal/25.webp differ diff --git a/src/assets/light-animal/26.webp b/src/assets/light-animal/26.webp new file mode 100644 index 0000000..80bb09b Binary files /dev/null and b/src/assets/light-animal/26.webp differ diff --git a/src/assets/light-animal/27.webp b/src/assets/light-animal/27.webp new file mode 100644 index 0000000..714a249 Binary files /dev/null and b/src/assets/light-animal/27.webp differ diff --git a/src/assets/light-animal/28.webp b/src/assets/light-animal/28.webp new file mode 100644 index 0000000..d4dde30 Binary files /dev/null and b/src/assets/light-animal/28.webp differ diff --git a/src/assets/light-animal/29.webp b/src/assets/light-animal/29.webp new file mode 100644 index 0000000..a99eb70 Binary files /dev/null and b/src/assets/light-animal/29.webp differ diff --git a/src/assets/light-animal/3.webp b/src/assets/light-animal/3.webp new file mode 100644 index 0000000..449a016 Binary files /dev/null and b/src/assets/light-animal/3.webp differ diff --git a/src/assets/light-animal/30.webp b/src/assets/light-animal/30.webp new file mode 100644 index 0000000..61f6847 Binary files /dev/null and b/src/assets/light-animal/30.webp differ diff --git a/src/assets/light-animal/31.webp b/src/assets/light-animal/31.webp new file mode 100644 index 0000000..0df89b5 Binary files /dev/null and b/src/assets/light-animal/31.webp differ diff --git a/src/assets/light-animal/32.webp b/src/assets/light-animal/32.webp new file mode 100644 index 0000000..f16fd2d Binary files /dev/null and b/src/assets/light-animal/32.webp differ diff --git a/src/assets/light-animal/33.webp b/src/assets/light-animal/33.webp new file mode 100644 index 0000000..86fbe15 Binary files /dev/null and b/src/assets/light-animal/33.webp differ diff --git a/src/assets/light-animal/34.webp b/src/assets/light-animal/34.webp new file mode 100644 index 0000000..deff7be Binary files /dev/null and b/src/assets/light-animal/34.webp differ diff --git a/src/assets/light-animal/35.webp b/src/assets/light-animal/35.webp new file mode 100644 index 0000000..65d4417 Binary files /dev/null and b/src/assets/light-animal/35.webp differ diff --git a/src/assets/light-animal/36.webp b/src/assets/light-animal/36.webp new file mode 100644 index 0000000..02856bb Binary files /dev/null and b/src/assets/light-animal/36.webp differ diff --git a/src/assets/light-animal/4.webp b/src/assets/light-animal/4.webp new file mode 100644 index 0000000..04dc882 Binary files /dev/null and b/src/assets/light-animal/4.webp differ diff --git a/src/assets/light-animal/5.webp b/src/assets/light-animal/5.webp new file mode 100644 index 0000000..b56baa8 Binary files /dev/null and b/src/assets/light-animal/5.webp differ diff --git a/src/assets/light-animal/6.webp b/src/assets/light-animal/6.webp new file mode 100644 index 0000000..b3375a0 Binary files /dev/null and b/src/assets/light-animal/6.webp differ diff --git a/src/assets/light-animal/7.webp b/src/assets/light-animal/7.webp new file mode 100644 index 0000000..fd6ed18 Binary files /dev/null and b/src/assets/light-animal/7.webp differ diff --git a/src/assets/light-animal/8.webp b/src/assets/light-animal/8.webp new file mode 100644 index 0000000..9c62d50 Binary files /dev/null and b/src/assets/light-animal/8.webp differ diff --git a/src/assets/light-animal/9.webp b/src/assets/light-animal/9.webp new file mode 100644 index 0000000..4bcbf28 Binary files /dev/null and b/src/assets/light-animal/9.webp differ diff --git a/src/assets/light-animal/light-animal-sprite.webp b/src/assets/light-animal/light-animal-sprite.webp new file mode 100644 index 0000000..5e3e34e Binary files /dev/null and b/src/assets/light-animal/light-animal-sprite.webp differ diff --git a/src/components/app-boot-resource-gate.tsx b/src/components/app-boot-resource-gate.tsx index 7f6ab6e..3beb890 100644 --- a/src/components/app-boot-resource-gate.tsx +++ b/src/components/app-boot-resource-gate.tsx @@ -7,6 +7,9 @@ const bootImageModules = import.meta.glob( '!../assets/animal/[1-9].webp', '!../assets/animal/[12][0-9].webp', '!../assets/animal/3[0-6].webp', + '!../assets/light-animal/[1-9].webp', + '!../assets/light-animal/[12][0-9].webp', + '!../assets/light-animal/3[0-6].webp', '!../assets/reward/[1-9].webp', '!../assets/reward/[12][0-9].webp', '!../assets/reward/3[0-6].webp', @@ -21,7 +24,7 @@ const BOOT_MIN_VISIBLE_MS = 900 const PC_LOADING_BG_URL = '/loading/pc-loading-bg.webp' const MOBILE_LOADING_BG_URL = '/loading/mobile-loading-bg.webp' const SPRITE_SOURCE_IMAGE_PATTERN = - /^\.\.\/assets\/(?:animal|reward)\/(?:[1-9]|[12]\d|3[0-6])\.webp$/ + /^\.\.\/assets\/(?:animal|light-animal|reward)\/(?:[1-9]|[12]\d|3[0-6])\.webp$/ function preloadImage(url: string) { return new Promise((resolve) => { diff --git a/src/features/game/components/desktop/desktop-animal.tsx b/src/features/game/components/desktop/desktop-animal.tsx index 0ca8a42..4958585 100644 --- a/src/features/game/components/desktop/desktop-animal.tsx +++ b/src/features/game/components/desktop/desktop-animal.tsx @@ -1,6 +1,6 @@ import { TriangleAlert } from 'lucide-react' -import { motion, useReducedMotion } from 'motion/react' -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { motion } from 'motion/react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import animalBorderImage from '@/assets/game/animal-border.webp' import diamondIcon from '@/assets/system/diamond.webp' @@ -17,8 +17,9 @@ import { useGameRoundStore } from '@/store/game' const SETTLEMENT_REVEAL_RANDOM_DURATION_MS = 3_200 const SETTLEMENT_REVEAL_SETTLE_DURATION_MS = 800 const SETTLEMENT_REVEAL_RESULT_HOLD_MS = 1_000 -const SETTLEMENT_REVEAL_MIN_STEP_MS = 180 -const SETTLEMENT_REVEAL_MAX_STEP_MS = 960 +const SETTLEMENT_REVEAL_SPIN_STEP_MS = 160 +const SETTLEMENT_REVEAL_MIN_STEP_MS = 300 +const SETTLEMENT_REVEAL_MAX_STEP_MS = 760 function getRandomAnimalId(ids: number[], currentId: number | null) { if (ids.length === 0) { @@ -40,7 +41,7 @@ function getRandomAnimalId(ids: number[], currentId: number | null) { function getSettlementRevealStepDelay(progress: number) { const clampedProgress = Math.min(Math.max(progress, 0), 1) - const easedProgress = clampedProgress ** 1.65 + const easedProgress = clampedProgress ** 1.35 return ( SETTLEMENT_REVEAL_MIN_STEP_MS + @@ -63,19 +64,8 @@ export function DesktopAnimal({ onSelect, }: DesktopAnimalProps) { const { t } = useTranslation() - const prefersReducedMotion = useReducedMotion() const animalIds = useMemo(() => FLOWER_IMAGE_LIST.map((item) => item.id), []) - const containerRef = useRef(null) - const cellRefs = useRef(new Map()) const [revealCellId, setRevealCellId] = useState(null) - const [revealFrame, setRevealFrame] = useState<{ - height: number - left: number - top: number - width: number - } | null>(null) - const [isRevealHoldingResult, setIsRevealHoldingResult] = useState(false) - const [isRevealSettlingResult, setIsRevealSettlingResult] = useState(false) const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase) const revealWinningCellId = useGameRoundStore( (state) => state.revealAnimation.winningCellId, @@ -102,10 +92,6 @@ export function DesktopAnimal({ showStandbyState, } = useAnimalVm(animalIds, onSelect) - const isRevealRunning = - revealPhase === 'spinning' || - (revealPhase === 'stopping' && !isRevealHoldingResult) - const isRevealResult = revealPhase === 'result' const [hasRewardOverlayStarted, setHasRewardOverlayStarted] = useState(false) const shouldHideRevealHighlight = revealPhase === 'result' && @@ -131,26 +117,20 @@ export function DesktopAnimal({ useEffect(() => { if (revealPhase === 'idle') { setRevealCellId(null) - setIsRevealHoldingResult(false) - setIsRevealSettlingResult(false) return } if (revealPhase === 'result') { setRevealCellId(shouldHideRevealHighlight ? null : revealWinningCellId) - setIsRevealHoldingResult(false) - setIsRevealSettlingResult(false) return } if (revealPhase === 'spinning') { - setIsRevealHoldingResult(false) - setIsRevealSettlingResult(false) setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId)) const intervalId = window.setInterval(() => { setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId)) - }, 70) + }, SETTLEMENT_REVEAL_SPIN_STEP_MS) return () => { window.clearInterval(intervalId) @@ -158,25 +138,18 @@ export function DesktopAnimal({ } if (revealWinningCellId === null) { - setIsRevealHoldingResult(false) - setIsRevealSettlingResult(false) return } const startedAt = performance.now() let timeoutId = 0 - setIsRevealHoldingResult(false) - setIsRevealSettlingResult(false) const step = () => { const elapsedMs = performance.now() - startedAt if (elapsedMs >= SETTLEMENT_REVEAL_RANDOM_DURATION_MS) { - setIsRevealSettlingResult(true) setRevealCellId(revealWinningCellId) timeoutId = window.setTimeout(() => { - setIsRevealSettlingResult(false) - setIsRevealHoldingResult(true) timeoutId = window.setTimeout(() => { finishRevealAnimation() }, SETTLEMENT_REVEAL_RESULT_HOLD_MS) @@ -207,45 +180,11 @@ export function DesktopAnimal({ shouldHideRevealHighlight, ]) - useLayoutEffect(() => { - if (revealCellId === null) { - setRevealFrame(null) - return - } - - const syncRevealFrame = () => { - const container = containerRef.current - const cell = cellRefs.current.get(revealCellId) - - if (!container || !cell) { - setRevealFrame(null) - return - } - - const containerRect = container.getBoundingClientRect() - const cellRect = cell.getBoundingClientRect() - - setRevealFrame({ - height: cellRect.height, - left: cellRect.left - containerRect.left, - top: cellRect.top - containerRect.top, - width: cellRect.width, - }) - } - - syncRevealFrame() - window.addEventListener('resize', syncRevealFrame) - - return () => { - window.removeEventListener('resize', syncRevealFrame) - } - }, [revealCellId]) - return (
@@ -253,13 +192,13 @@ export function DesktopAnimal({ const selectionMeta = selectionByCell[item.id] const hasPlacedSelection = Boolean(selectionMeta) const isMarqueeActive = showStandbyState && item.id === marqueeId - const isRevealWinner = - !shouldHideRevealHighlight && - (isRevealResult || isRevealHoldingResult) && - revealWinningCellId === item.id + const isRevealActive = + !shouldHideRevealHighlight && revealCellId === item.id const warningType = cellWarning?.cellId === item.id ? cellWarning.type : null const showCellWarning = warningType !== null + const animalSpriteVariant = + hasPlacedSelection || isMarqueeActive ? 'light' : 'normal' const warningLabel = warningType === 'balance' ? t('gameDesktop.animal.insufficientBalanceRecharge') @@ -270,13 +209,6 @@ export function DesktopAnimal({ return ( { - if (node) { - cellRefs.current.set(item.id, node) - } else { - cellRefs.current.delete(item.id) - } - }} type="button" disabled={lockInteraction || showStopOverlay} onClick={() => handleSelect(item.id)} @@ -302,22 +234,16 @@ export function DesktopAnimal({ : { duration: 0.16, ease: 'easeOut' } } className={cn( - 'relative flex h-design-112 flex-col items-center justify-center overflow-hidden rounded-[calc(var(--design-unit)*18)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150', + 'relative flex h-design-112 flex-col items-center justify-center rounded-[calc(var(--design-unit)*18)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150', + !isRevealActive && 'overflow-hidden', lockInteraction ? 'cursor-not-allowed opacity-90' : 'cursor-pointer hover:-translate-y-[1px]', isMarqueeActive && 'border-[rgba(121,255,250,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(85,255,247,0.98),0_0_calc(var(--design-unit)*34)_rgba(39,245,255,0.88),inset_0_0_calc(var(--design-unit)*26)_rgba(112,255,248,0.34)]', - isRevealRunning && - 'border-[rgba(104,255,249,0.76)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(68,244,255,0.46),0_0_calc(var(--design-unit)*22)_rgba(37,214,255,0.28),inset_0_0_calc(var(--design-unit)*16)_rgba(115,255,247,0.18)] brightness-115 saturate-125', - isRevealWinner && - 'border-[rgba(121,255,250,0.72)] shadow-[0_0_calc(var(--design-unit)*12)_rgba(81,248,255,0.54),0_0_calc(var(--design-unit)*22)_rgba(30,199,255,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(125,255,249,0.24)] brightness-110 saturate-120', showCellWarning && 'border-[rgba(255,92,92,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,88,88,0.56),0_0_calc(var(--design-unit)*28)_rgba(255,44,44,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(255,126,126,0.3)]', - !showStandbyState && - !hasPlacedSelection && - !isRevealWinner && - 'opacity-95', + !showStandbyState && !hasPlacedSelection && 'opacity-95', itemClassName, )} > @@ -354,15 +280,11 @@ export function DesktopAnimal({ 'pointer-events-none absolute inset-[calc(var(--design-unit)*2)] rounded-[calc(var(--design-unit)*15)] opacity-0 transition-opacity duration-150', isMarqueeActive && 'bg-[radial-gradient(circle_at_center,rgba(129,255,250,0.48)_0%,rgba(94,255,247,0.18)_38%,rgba(43,236,255,0.08)_56%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(119,255,249,0.98),0_0_calc(var(--design-unit)*28)_rgba(53,246,255,0.9),0_0_calc(var(--design-unit)*44)_rgba(37,241,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(163,255,250,0.52)]', - isRevealRunning && - 'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.36)_0%,rgba(77,244,255,0.16)_40%,rgba(27,183,255,0.07)_68%,transparent_88%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(95,249,255,0.46),inset_0_0_calc(var(--design-unit)*18)_rgba(151,255,250,0.24)]', - isRevealWinner && - 'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.34)_0%,rgba(67,226,255,0.16)_38%,rgba(25,131,255,0.07)_58%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(92,248,255,0.42),inset_0_0_calc(var(--design-unit)*18)_rgba(126,255,250,0.24)]', showCellWarning && 'bg-[radial-gradient(circle_at_center,rgba(255,106,106,0.34)_0%,rgba(255,58,58,0.18)_42%,rgba(108,0,0,0.2)_78%,transparent_100%)] opacity-100', )} /> - {!showStandbyState && !hasPlacedSelection && !isRevealWinner ? ( + {!showStandbyState && !hasPlacedSelection ? (