zlwu 10 달 전
부모
커밋
9991704498
3개의 변경된 파일과 425개의 추가작업 그리고 48개의 파일을 삭제
  1. 35 9
      frontend/src/hooks/useEnergyStones.ts
  2. 254 31
      frontend/src/pages/ChatBotPage.tsx
  3. 136 8
      frontend/src/styles/chatbot-fix.css

+ 35 - 9
frontend/src/hooks/useEnergyStones.ts

@@ -121,7 +121,7 @@ export const useEnergyStones = (
   const [stones, setStones] = useState<EnergyStoneView[]>([]);
   const [containerSize, setContainerSize] = useState(FALLBACK_SIZE);
   const stonesRef = useRef<EnergyStoneView[]>([]);
-  const timersRef = useRef<Map<string, { remove?: number }>>(new Map());
+  const timersRef = useRef<Map<string, { fade?: number; remove?: number }>>(new Map());
 
   useEffect(() => {
     stonesRef.current = stones;
@@ -162,7 +162,10 @@ export const useEnergyStones = (
 
   useEffect(() => {
     return () => {
-      timersRef.current.forEach(({ remove }) => {
+      timersRef.current.forEach(({ fade, remove }) => {
+        if (fade) {
+          clearTimeout(fade);
+        }
         if (remove) {
           clearTimeout(remove);
         }
@@ -173,6 +176,9 @@ export const useEnergyStones = (
 
   const clearTimers = useCallback((stoneId: string) => {
     const timers = timersRef.current.get(stoneId);
+    if (timers?.fade) {
+      clearTimeout(timers.fade);
+    }
     if (timers?.remove) {
       clearTimeout(timers.remove);
     }
@@ -196,7 +202,15 @@ export const useEnergyStones = (
 
   const scheduleLifecycle = useCallback(
     (stone: EnergyStoneView) => {
-      // 超时后直接移除能量石,不使用动画
+      // 超时后先设置 fading 状态,然后延迟移除
+      const fadeTimer = window.setTimeout(() => {
+        setStones((prev) =>
+          prev.map((item) =>
+            item.stoneId === stone.stoneId ? { ...item, fading: true } : item
+          )
+        );
+      }, stone.ttlMs - 1000); // 提前 1 秒开始动画
+
       const removeTimer = window.setTimeout(() => {
         setStones((prev) => prev.filter((item) => item.stoneId !== stone.stoneId));
         timersRef.current.delete(stone.stoneId);
@@ -205,7 +219,7 @@ export const useEnergyStones = (
         }
       }, stone.ttlMs);
 
-      timersRef.current.set(stone.stoneId, { remove: removeTimer });
+      timersRef.current.set(stone.stoneId, { fade: fadeTimer, remove: removeTimer });
     },
     [eventCallback, toEventPayload]
   );
@@ -263,11 +277,23 @@ export const useEnergyStones = (
       }
 
       clearTimers(stoneId);
-      // 直接移除能量石,不设置 fading 状态,不使用动画
-      setStones((prev) => prev.filter((item) => item.stoneId !== stoneId));
-      if (eventCallback) {
-        eventCallback(stoneId, 'click', toEventPayload(stone));
-      }
+      // 先设置 fading 状态,触发动画
+      setStones((prev) =>
+        prev.map((item) =>
+          item.stoneId === stoneId ? { ...item, fading: true } : item
+        )
+      );
+      
+      // 延迟 1 秒后移除能量石(动画完成后)
+      const removeTimer = window.setTimeout(() => {
+        setStones((prev) => prev.filter((item) => item.stoneId !== stoneId));
+        timersRef.current.delete(stoneId);
+        if (eventCallback) {
+          eventCallback(stoneId, 'click', toEventPayload(stone));
+        }
+      }, 1000);
+
+      timersRef.current.set(stoneId, { remove: removeTimer });
     },
     [clearTimers, eventCallback, toEventPayload]
   );

+ 254 - 31
frontend/src/pages/ChatBotPage.tsx

@@ -124,7 +124,6 @@ export const ChatBotPage = () => {
   const cancelledRequestIdsRef = useRef<Set<string>>(new Set());
   const mobileAudioPreparedRef = useRef(false);
   const sendTimeRef = useRef<number>(0);
-  const energyStoneZoneRef = useRef<HTMLDivElement | null>(null);
   const audioWaveRef = useRef<AudioWaveHandle | null>(null);
   const isMountedRef = useRef(true);
   const [userLevelValue, setUserLevelValue] = useState(() => getUserLevel());
@@ -238,6 +237,9 @@ export const ChatBotPage = () => {
     const stored = getStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP);
     return stored === 'true';
   });
+  // 临时能量石动画状态
+  const [isTempStoneAnimating, setIsTempStoneAnimating] = useState(false);
+  const [isTempStoneVisible, setIsTempStoneVisible] = useState(true);
   const progressBarTooltipTimerRef = useRef<number | null>(null);
   const progressBarTooltipRef = useRef<HTMLImageElement | null>(null);
   const [isAvatarCUnlocked, setIsAvatarCUnlocked] = useState(() => {
@@ -572,11 +574,50 @@ export const ChatBotPage = () => {
     []
   );
 
-  const { stones: energyStones, spawnStone, handleStoneClick } = useEnergyStones(energyStoneZoneRef, {
+  // 创建一个隐藏的容器元素用于 useEnergyStones hook 计算位置
+  const hiddenEnergyStoneZoneRef = useRef<HTMLDivElement>(null);
+
+  const { stones: energyStones, spawnStone, handleStoneClick } = useEnergyStones(hiddenEnergyStoneZoneRef, {
     onEvent: emitStoneEvent,
     resolveStoneSize,
   });
 
+  // 当能量石开始消失时,设置动画的起点和终点(处理超时自动消失的情况)
+  useEffect(() => {
+    energyStones.forEach((stone) => {
+      if (stone.fading) {
+        // 使用 setTimeout 确保 DOM 已更新
+        const timer = setTimeout(() => {
+          const container = document.querySelector(`[data-stone-id="${stone.stoneId}"]`) as HTMLElement;
+          if (container) {
+            const rect = container.getBoundingClientRect();
+            const startLeft = rect.left + rect.width / 2; // 中心点 x 坐标(像素)
+            const startTop = rect.top + rect.height / 2; // 中心点 y 坐标(像素)
+            
+            // 计算终点位置(动画形象中心,距离顶部60%位置)
+            const viewportWidth = window.innerWidth;
+            const viewportHeight = window.innerHeight;
+            
+            // 计算终点 top 位置(像素)
+            const endTopPx = Math.min(
+              (452 * Math.min(viewportWidth, 600) / 375),
+              (452 / 812) * viewportHeight
+            );
+            const endLeft = viewportWidth / 2; // 50% 位置(像素)
+            
+            // 设置 CSS 变量,用于动画(使用像素值)
+            container.style.setProperty('--animation-start-left', `${startLeft}px`);
+            container.style.setProperty('--animation-start-top', `${startTop}px`);
+            container.style.setProperty('--animation-end-left', `${endLeft}px`);
+            container.style.setProperty('--animation-end-top', `${endTopPx}px`);
+          }
+        }, 0);
+        
+        return () => clearTimeout(timer);
+      }
+    });
+  }, [energyStones]);
+
   // 能量石展示区域:宽高 & 位置按设计稿 375×812 固定,以 px2vw/px2vh/px2vmin 适配
 
   const stopRecordingTimer = useCallback((options?: { preserveCountdown?: boolean }) => {
@@ -1997,10 +2038,25 @@ export const ChatBotPage = () => {
         )}
       </div>
 
-      {/* 能量石展示区域 - 单独容器,避免遮挡头像,使用设计稿固定尺寸 + px2vmin 适配 */}
+      {/* 隐藏的能量石容器 - 用于 useEnergyStones hook 计算位置 */}
       <div
+        ref={hiddenEnergyStoneZoneRef}
+        style={{
+          position: 'absolute',
+          width: px2vmin(343),   // 设计稿 343px,基于 vw/vh 最小值
+          height: px2vmin(172),  // 设计稿 172px,基于 vw/vh 最小值
+          top: px2vmin(126),     // 设计稿 top 126px,基于 vw/vh 最小值
+          left: '50%',
+          transform: 'translateX(-50%)',
+          visibility: 'hidden',
+          pointerEvents: 'none',
+          zIndex: -1,
+        }}
+      />
+
+      {/* 能量石展示区域 - 已废弃,能量石现在独立定位,不再嵌套在此容器中 */}
+      {/* <div
         className="chatbot-energy-zone absolute"
-        ref={energyStoneZoneRef}
         style={{
           width: px2vmin(343),   // 设计稿 343px,基于 vw/vh 最小值
           height: px2vmin(172),  // 设计稿 172px,基于 vw/vh 最小值
@@ -2008,33 +2064,51 @@ export const ChatBotPage = () => {
           left: '50%',
           transform: 'translateX(-50%)',
           zIndex: 4,
+          pointerEvents: 'none',
         }}
       >
-        <div className="energy-stone-layer">
-          {energyStones.map((stone) => {
-              const asset = energyStoneAssets[stone.stoneType];
-              const hintAsset = energyStoneHintAssets[stone.stoneType];
-              const stoneSize = 48; // 设计稿 48px,基于 vw/vh 最小值
-              const hintSize = 92; // 设计稿 92px,基于 vw/vh 最小值
-              const shouldShowHint = stone.stoneType === 'mission' 
-                ? !hasClickedMissionStone 
-                : !hasClickedConversationStone;
-            return (
-              <div
-                key={stone.stoneId}
-                data-stone-id={stone.stoneId}
-                data-fading={stone.fading ? 'true' : 'false'}
-                className={`energy-stone-container ${stone.stoneType === 'mission' ? 'energy-stone-mission' : ''}`}
-                style={{
-                  position: 'absolute',
-                  left: `${(stone.position.xRatio * 100).toFixed(2)}%`,
-                  top: `${(stone.position.yRatio * 100).toFixed(2)}%`,
-                  transform: 'translate(-50%, -50%)',
-                  width: px2vmin(stoneSize),
-                  height: px2vmin(stoneSize),
-                  pointerEvents: 'none',
-                }}
-              >
+        <div className="energy-stone-layer" />
+      </div> */}
+
+      {/* 正式能量石 - 独立绝对定位,限制在能量石区域内 */}
+      {energyStones.map((stone) => {
+        const asset = energyStoneAssets[stone.stoneType];
+        const hintAsset = energyStoneHintAssets[stone.stoneType];
+        const stoneSize = 48; // 设计稿 48px,基于 vw/vh 最小值
+        const hintSize = 92; // 设计稿 92px,基于 vw/vh 最小值
+        const shouldShowHint = stone.stoneType === 'mission' 
+          ? !hasClickedMissionStone 
+          : !hasClickedConversationStone;
+        
+        // 计算绝对位置:基于能量石区域的边界
+        // 区域:left 50% translateX(-50%), top: px2vmin(126), width: px2vmin(343), height: px2vmin(172)
+        // 能量石在区域内的百分比位置转换为绝对位置
+        // left: 50% + (xRatio - 0.5) * zoneWidth
+        // top: zoneTop + yRatio * zoneHeight
+        const zoneTop = px2vmin(126);
+        const zoneWidth = px2vmin(343);
+        const zoneHeight = px2vmin(172);
+        const absoluteLeft = `calc(50% + (${stone.position.xRatio} - 0.5) * ${zoneWidth})`;
+        const absoluteTop = `calc(${zoneTop} + ${stone.position.yRatio} * ${zoneHeight})`;
+        
+        return (
+          <div
+            key={stone.stoneId}
+            data-stone-id={stone.stoneId}
+            data-fading={stone.fading ? 'true' : 'false'}
+            className={`energy-stone-container ${stone.stoneType === 'mission' ? 'energy-stone-mission' : ''}`}
+            style={{
+              position: 'absolute',
+              left: absoluteLeft,
+              top: absoluteTop,
+              transform: 'translate(-50%, -50%)',
+              width: px2vmin(stoneSize),
+              height: px2vmin(stoneSize),
+              pointerEvents: 'auto',
+              cursor: 'pointer',
+              zIndex: 5,
+            }}
+          >
                 {/* 光晕层 - 只在未点击时显示,但在动画过程中也显示以跟随能量石 */}
                 {shouldShowHint && (
                   <img
@@ -2075,7 +2149,37 @@ export const ChatBotPage = () => {
                     zIndex: stone.fading ? 100 : 1,
                     pointerEvents: stone.fading ? 'none' : 'auto',
                   }}
-                  onClick={async () => {
+                  onClick={async (e) => {
+                    // 在点击时,获取能量石容器的实际位置,用于动画
+                    const container = e.currentTarget.closest('[data-stone-id]') as HTMLElement;
+                    if (container) {
+                      const rect = container.getBoundingClientRect();
+                      const startLeft = rect.left + rect.width / 2; // 中心点 x 坐标(像素)
+                      const startTop = rect.top + rect.height / 2; // 中心点 y 坐标(像素)
+                      
+                      // 计算终点位置(动画形象中心,距离顶部60%位置)
+                      // 动画形象:top 248px, height 340px, left 50%
+                      // 终点:top = 248 + 340 * 0.6 = 452px, left = 50%
+                      // 由于动画形象使用 px2vmin 定位,需要计算实际像素位置
+                      const viewportWidth = window.innerWidth;
+                      const viewportHeight = window.innerHeight;
+                      const vmin = Math.min(viewportWidth, viewportHeight);
+                      
+                      // 计算终点 top 位置(像素)
+                      // px2vmin(452) = min(452 * min(100vw, 600px) / 375, 452 / 812 * 100vh)
+                      const endTopPx = Math.min(
+                        (452 * Math.min(viewportWidth, 600) / 375),
+                        (452 / 812) * viewportHeight
+                      );
+                      const endLeft = viewportWidth / 2; // 50% 位置(像素)
+                      
+                      // 设置 CSS 变量,用于动画(使用像素值)
+                      container.style.setProperty('--animation-start-left', `${startLeft}px`);
+                      container.style.setProperty('--animation-start-top', `${startTop}px`);
+                      container.style.setProperty('--animation-end-left', `${endLeft}px`);
+                      container.style.setProperty('--animation-end-top', `${endTopPx}px`);
+                    }
+                    
                     handleStoneClick(stone.stoneId);
                     
                     // 更新点击状态(控制光晕显示)
@@ -2165,9 +2269,110 @@ export const ChatBotPage = () => {
                 </button>
               </div>
             );
-          })}
+      })}
+
+      {/* 临时能量石+光晕 - 用于测试/展示 - 可点击,点击后沿黑线移动到动画形象中心 */}
+      {isTempStoneVisible && (
+        <div
+          className={`energy-stone-container energy-stone-mission ${isTempStoneAnimating ? 'temp-stone-animating' : ''}`}
+          style={{
+            position: 'absolute',
+            left: '50%',
+            // 起点:能量石区域 top + 30% 高度(动画时由CSS动画控制)
+            ...(isTempStoneAnimating ? {} : {
+              top: `calc(${px2vmin(126)} + ${px2vmin(172)} * 0.3)`,
+            }),
+            transform: 'translate(-50%, -50%)',
+            width: px2vmin(48),
+            height: px2vmin(48),
+            pointerEvents: isTempStoneAnimating ? 'none' : 'auto',
+            cursor: isTempStoneAnimating ? 'default' : 'pointer',
+            zIndex: 5,
+          }}
+          onClick={() => {
+            if (!isTempStoneAnimating) {
+              console.log('[临时能量石] 点击触发动画');
+              setIsTempStoneAnimating(true);
+              // 动画完成后隐藏元素
+              setTimeout(() => {
+                console.log('[临时能量石] 动画完成,隐藏元素');
+                setIsTempStoneVisible(false);
+              }, 1000);
+            }
+          }}
+        >
+        {/* 光晕层 */}
+        <img
+          src={missionEnergyStoneHint}
+          alt=""
+          className="energy-stone-hint"
+          style={{
+            position: 'absolute',
+            top: '50%',
+            left: '50%',
+            transform: 'translate(-50%, -50%)',
+            transformOrigin: 'center center',
+            width: px2vmin(92),
+            height: px2vmin(92),
+            pointerEvents: 'none',
+            zIndex: 0,
+            maxWidth: 'none',
+            maxHeight: 'none',
+            objectFit: 'contain',
+          }}
+        />
+        {/* 能量石图片 */}
+        <div
+          className="energy-stone"
+          style={{
+            position: 'absolute',
+            top: '50%',
+            left: '50%',
+            transform: 'translate(-50%, -50%)',
+            transformOrigin: 'center center',
+            width: px2vmin(48),
+            height: px2vmin(48),
+            zIndex: 1,
+            pointerEvents: 'none',
+          }}
+        >
+          <img src={missionEnergyStone} alt="" aria-hidden="true" />
         </div>
       </div>
+      )}
+
+      {/* 临时连接线 - 从临时能量石中心到动画形象中心 */}
+      <svg
+        className="absolute pointer-events-none"
+        style={{
+          top: 0,
+          left: 0,
+          width: '100%',
+          height: '100%',
+          zIndex: 3,
+        }}
+      >
+        {/* 
+          起点:临时能量石中心
+          - 能量石区域 top: 126px, height: 172px
+          - 临时能量石在区域内 top: 30%
+          - 绝对位置: 126 + 172 * 0.3 = 177.6px
+          - 在设计稿 812px 高度下: 177.6 / 812 = 21.87%
+          
+          终点:动画形象距离顶部60%位置
+          - 动画形象 top: 248px, height: 340px
+          - 60%位置: 248 + 340 * 0.6 = 452px
+          - 在设计稿 812px 高度下: 452 / 812 = 55.67%
+        */}
+        <line
+          x1="50%"
+          y1="21.87%"
+          x2="50%"
+          y2="55.67%"
+          stroke="black"
+          strokeWidth="2"
+        />
+      </svg>
 
       {/* AnimaZone 头像 - 使用设计稿 375×812 基准 + px2vmin 适配 */}
       <div
@@ -2198,6 +2403,24 @@ export const ChatBotPage = () => {
         />
       </div>
 
+      {/* 能量石动画终点位置标记 - 红色圆圈 */}
+      <div
+        style={{
+          position: 'absolute',
+          // 终点:动画形象距离顶部60%位置 = 248 + 340 * 0.6 = 452px
+          top: px2vmin(452),
+          left: '50%',
+          transform: 'translate(-50%, -50%)',
+          width: px2vmin(20),
+          height: px2vmin(20),
+          borderRadius: '50%',
+          backgroundColor: 'red',
+          border: `2px solid red`,
+          zIndex: 10,
+          pointerEvents: 'none',
+        }}
+      />
+
       {/* 音频波形 - 宽度 100% 自适应 */}
       <div
         className="chatbot-audio-wave overflow-hidden"

+ 136 - 8
frontend/src/styles/chatbot-fix.css

@@ -69,6 +69,136 @@ body[data-too-small="true"] .chatbot-bg,
   animation-duration: 8s;
 }
 
+/* 临时能量石动画时禁用浮动动画,但应用移动动画 */
+.energy-stone-container.temp-stone-animating {
+  /* 移除浮动动画,应用移动动画 */
+  animation: tempStoneMoveToAvatar 1s ease-in-out forwards !important;
+}
+
+/* 临时能量石动画时,能量石和光晕同步缩小 */
+.energy-stone-container.temp-stone-animating .energy-stone {
+  animation: tempStoneShrink 1s ease-in-out forwards !important;
+}
+
+.energy-stone-container.temp-stone-animating .energy-stone-hint {
+  /* 禁用呼吸灯动画,应用缩小动画 */
+  animation: tempStoneHintShrink 1s ease-in-out forwards !important;
+}
+
+/* 临时能量石移动到动画形象中心的动画 */
+@keyframes tempStoneMoveToAvatar {
+  0% {
+    /* 起点:能量石区域 top + 30% 高度 */
+    /* 使用与React代码完全相同的计算方式 */
+    top: calc(min(calc(126 * min(100vw, 600PX) / 375), calc(15.517241379310345 * var(--vh, 1vh))) + min(calc(172 * min(100vw, 600PX) / 375), calc(21.182266009852217 * var(--vh, 1vh))) * 0.3) !important;
+    left: 50% !important;
+    transform: translate(-50%, -50%) !important;
+    opacity: 1 !important;
+  }
+  100% {
+    /* 终点:动画形象距离顶部60%位置 = 248 + 340 * 0.6 = 452px */
+    /* 使用与动画形象相同的 px2vmin 计算方式 */
+    top: min(calc(452 * min(100vw, 600PX) / 375), calc(452 / 812 * 100 * var(--vh, 1vh))) !important;
+    left: 50% !important;
+    transform: translate(-50%, -50%) !important;
+    opacity: 0 !important;
+  }
+}
+
+/* 能量石缩小动画 - 从 48px 缩小到 4.8px (10%) */
+@keyframes tempStoneShrink {
+  0% {
+    width: min(calc(48 * min(100vw, 600PX) / 375), calc(5.911330049261084 * var(--vh, 1vh))) !important;
+    height: min(calc(48 * min(100vw, 600PX) / 375), calc(5.911330049261084 * var(--vh, 1vh))) !important;
+  }
+  100% {
+    width: min(calc(4.8 * min(100vw, 600PX) / 375), calc(0.5911330049261084 * var(--vh, 1vh))) !important;
+    height: min(calc(4.8 * min(100vw, 600PX) / 375), calc(0.5911330049261084 * var(--vh, 1vh))) !important;
+  }
+}
+
+/* 光晕缩小动画 - 从 92px 缩小到 9.2px (10%) */
+@keyframes tempStoneHintShrink {
+  0% {
+    width: min(calc(92 * min(100vw, 600PX) / 375), calc(11.330049261083744 * var(--vh, 1vh))) !important;
+    height: min(calc(92 * min(100vw, 600PX) / 375), calc(11.330049261083744 * var(--vh, 1vh))) !important;
+  }
+  100% {
+    width: min(calc(9.2 * min(100vw, 600PX) / 375), calc(1.1330049261083744 * var(--vh, 1vh))) !important;
+    height: min(calc(9.2 * min(100vw, 600PX) / 375), calc(1.1330049261083744 * var(--vh, 1vh))) !important;
+  }
+}
+
+/* 正式能量石动画时禁用浮动动画,但应用移动动画 */
+/* 使用更具体的选择器,避免与旧的 data-fading 规则冲突 */
+.energy-stone-container[data-fading='true']:not([data-stone-id]) {
+  /* 旧的规则,保留兼容性 */
+}
+
+.energy-stone-container[data-fading='true'][data-stone-id] {
+  /* 移除浮动动画,应用移动动画 */
+  animation: energyStoneMoveToAvatar 1s ease-in-out forwards !important;
+  /* 确保 transform 能够正确应用 */
+  transform-origin: center center !important;
+  /* 使用 fixed 定位,以便正确使用像素值进行动画 */
+  position: fixed !important;
+  z-index: 10000 !important;
+  pointer-events: none !important;
+}
+
+/* 正式能量石动画时,能量石和光晕同步缩小 */
+.energy-stone-container[data-fading='true'][data-stone-id] .energy-stone {
+  animation: energyStoneShrink 1s ease-in-out forwards !important;
+}
+
+.energy-stone-container[data-fading='true'][data-stone-id] .energy-stone-hint {
+  /* 禁用呼吸灯动画,应用缩小动画 */
+  animation: energyStoneHintShrink 1s ease-in-out forwards !important;
+}
+
+/* 正式能量石移动到动画形象中心的动画 - 从当前位置移动到终点 */
+/* 使用 CSS 变量来设置起点和终点,支持横向和纵向移动 */
+@keyframes energyStoneMoveToAvatar {
+  0% {
+    /* 起点:使用 CSS 变量设置的起点位置 */
+    left: var(--animation-start-left, 50%) !important;
+    top: var(--animation-start-top, 0) !important;
+    transform: translate(-50%, -50%) !important;
+    opacity: 1 !important;
+  }
+  100% {
+    /* 终点:使用 CSS 变量设置的终点位置 */
+    left: var(--animation-end-left, 50%) !important;
+    top: var(--animation-end-top, 0) !important;
+    transform: translate(-50%, -50%) !important;
+    opacity: 0 !important;
+  }
+}
+
+/* 正式能量石缩小动画 - 从 48px 缩小到 4.8px (10%) */
+@keyframes energyStoneShrink {
+  0% {
+    width: min(calc(48 * min(100vw, 600PX) / 375), calc(5.911330049261084 * var(--vh, 1vh))) !important;
+    height: min(calc(48 * min(100vw, 600PX) / 375), calc(5.911330049261084 * var(--vh, 1vh))) !important;
+  }
+  100% {
+    width: min(calc(4.8 * min(100vw, 600PX) / 375), calc(0.5911330049261084 * var(--vh, 1vh))) !important;
+    height: min(calc(4.8 * min(100vw, 600PX) / 375), calc(0.5911330049261084 * var(--vh, 1vh))) !important;
+  }
+}
+
+/* 正式能量石光晕缩小动画 - 从 92px 缩小到 9.2px (10%) */
+@keyframes energyStoneHintShrink {
+  0% {
+    width: min(calc(92 * min(100vw, 600PX) / 375), calc(11.330049261083744 * var(--vh, 1vh))) !important;
+    height: min(calc(92 * min(100vw, 600PX) / 375), calc(11.330049261083744 * var(--vh, 1vh))) !important;
+  }
+  100% {
+    width: min(calc(9.2 * min(100vw, 600PX) / 375), calc(1.1330049261083744 * var(--vh, 1vh))) !important;
+    height: min(calc(9.2 * min(100vw, 600PX) / 375), calc(1.1330049261083744 * var(--vh, 1vh))) !important;
+  }
+}
+
 @keyframes energyStoneContainerFloat {
   0% {
     transform: translate(-50%, -50%) translateY(0);
@@ -108,18 +238,16 @@ div[data-fading='true'] .energy-stone {
 
 @keyframes energyStoneFadeToAvatar {
   0% {
-    /* 起始位置:保持当前 transform 和位置 */
+    /* 起始位置:使用 CSS 变量设置的起点位置 */
+    left: var(--animation-start-left, 50%);
+    top: var(--animation-start-top, 0);
     transform: translate(-50%, -50%) scale(1);
     opacity: 1;
   }
   100% {
-    /* 目标位置:动画形象中心 */
-    /* 设计稿:top 248px + height/2 (340/2=170px) = 418px */
-    /* 使用与动画形象相同的 px2vmin 计算方式 */
-    /* px2vmin(418) = min(calc(418 * min(100vw, 600PX) / 375), calc(418 / 812 * 100 * var(--vh, 1vh))) */
-    /* = min(calc(418 * min(100vw, 600PX) / 375), calc(51.48 * var(--vh, 1vh))) */
-    top: min(calc(418 * min(100vw, 600PX) / 375), calc(51.48 * var(--vh, 1vh)));
-    left: 50%;
+    /* 目标位置:使用 CSS 变量设置的终点位置 */
+    left: var(--animation-end-left, 50%);
+    top: var(--animation-end-top, 0);
     transform: translate(-50%, -50%) scale(0.1);
     opacity: 0;
   }