|
|
@@ -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"
|