Bladeren bron

1发5发动画

zbb 3 maanden geleden
bovenliggende
commit
fe7ff391d4

+ 8 - 8
app/award-detail/components/CheckoutModal.tsx

@@ -163,14 +163,14 @@ export const CheckoutModal = forwardRef<CheckoutModalRef, CheckoutModalProps>(
           const tradeNo = res.bizTradeNo || res.tradeNo;
           setVisible(false);
           
-          // 10发以上使用全屏抽奖结果弹窗
-          if (num >= 10) {
-            lotteryResultRef.current?.show(tradeNo);
-            // onSuccess 在 LotteryResultModal 关闭时调用
-          } else {
-            // 10发以下使用简单弹窗展示结果
-            fetchLotteryResult(tradeNo);
-          }
+          // Navigation to Lottery Animation
+          router.push({
+            pathname: '/lottery' as any,
+            params: { tradeNo, num, poolId }
+          });
+          
+          // Trigger success callback (e.g. to refresh pool data)
+          onSuccess({ tradeNo, num });
         } else {
           Alert.alert('提示', res?.message || '支付失败,请重试');
         }

+ 134 - 0
app/lottery/components/LotteryReel.tsx

@@ -0,0 +1,134 @@
+import React, { useEffect, useRef } from 'react';
+import { Animated, Easing, StyleSheet, View } from 'react-native';
+
+import TransCard from './TransCard';
+
+interface LotteryReelProps {
+  pool: any[];
+  result: any;
+  width: number; 
+  itemWidth: number;
+  duration?: number;
+  index?: number;
+  delay?: number;
+}
+
+export default function LotteryReel({
+  pool,
+  result,
+  width,
+  itemWidth,
+  duration = 3000, 
+  index = 0,
+  delay = 0,
+}: LotteryReelProps) {
+  const slideAnim = useRef(new Animated.Value(0)).current;
+
+  // ... (buildReelData remains same) ...
+  const buildReelData = () => {
+    // Guard
+    if (!pool || pool.length === 0) {
+        // Log fallback
+        console.log(`Reel ${index}: Empty pool, using fallback`);
+        const fallback = result ? [result, result, result, result, result] : [];
+        return { 
+            reelData: fallback.map((item, idx) => ({ ...item, uniqueKey: `fb-${idx}` })), 
+            targetOffset: 0 
+        };
+    }
+
+    let basePool = [...pool];
+    
+    // Ensure base pool is large enough
+    if (basePool.length > 0) {
+        while (basePool.length < 10) {
+            basePool = [...basePool, ...basePool];
+        }
+    }
+    
+    // Shuffle and expand
+    // Use consistent seed or just math random 
+    const shuffle = (arr: any[]) => [...arr].sort(() => Math.random() - 0.5);
+    let array = [...shuffle(basePool)];
+    
+    if (basePool.length > 0) {
+         while (array.length < 25) { // Reduced from 50 to 25 to minimize cumulative render error
+            array = [...array, ...shuffle(basePool)];
+        }
+    }
+    
+    // Set target
+    const activeIndex = array.length - 5; // Target is closer to end
+    
+    // Inject result with winner flag
+    array[activeIndex] = { ...result, key: `res-${index}`, isWinner: true };
+    
+    const reelData = array.map((item, idx) => ({ ...item, uniqueKey: `${idx}-${item.id || item.goodsId || idx}` }));
+    
+    // Calculate target offset
+    // TranslateX moves LEFT, so negative.
+    // We want the center of item at activeIndex to be at center of View (width/2)
+    // Item center = activeIndex * itemWidth + itemWidth/2
+    // Offset = width/2 - Item center
+    const targetOffset = width / 2 - (activeIndex * itemWidth + itemWidth / 2);
+    
+    console.log(`Reel ${index} (Final): poolLength=${pool.length}, reelLength=${reelData.length}, activeIndex=${activeIndex}, target=${targetOffset}`);
+
+    return { reelData, targetOffset };
+  };
+
+  const { reelData, targetOffset } = React.useMemo(() => buildReelData(), [pool, result, width]);
+
+  useEffect(() => {
+    if (targetOffset !== 0) {
+        // Reset to 0 first
+        slideAnim.setValue(0);
+        
+        // Use requestAnimationFrame to ensure render is complete before animating
+        // helping with the "stuck" feel during initial heavy mount
+        requestAnimationFrame(() => {
+             Animated.sequence([
+                Animated.delay(delay),
+                Animated.timing(slideAnim, {
+                    toValue: targetOffset,
+                    duration: duration,
+                    easing: Easing.out(Easing.cubic), 
+                    useNativeDriver: true, 
+                })
+            ]).start();
+        });
+    }
+  }, [targetOffset, delay]);
+
+  return (
+    <View style={[styles.container, { width }]}>
+      <Animated.View style={[styles.reel, { transform: [{ translateX: slideAnim }] }]}>
+        {reelData.map((item, idx) => (
+           <View key={item.uniqueKey} style={[styles.itemWrapper, { width: itemWidth }]}>
+             <TransCard 
+                item={item} 
+                width={itemWidth === 94 ? 90 : itemWidth - 4} 
+                height={itemWidth === 94 ? 121 : 90}
+                fill={itemWidth === 94 ? 2 : 2}
+                imageWidth={itemWidth === 94 ? 74 : 55}
+                imageHeight={itemWidth === 94 ? 94 : 70}
+             />
+           </View>
+        ))}
+      </Animated.View>
+    </View>
+  );
+}
+
+const styles = StyleSheet.create({
+  container: {
+    overflow: 'hidden',
+  },
+  reel: {
+    flexDirection: 'row',
+  },
+  itemWrapper: {
+    justifyContent: 'center',
+    alignItems: 'center',
+  },
+});

+ 82 - 0
app/lottery/components/TransCard.tsx

@@ -0,0 +1,82 @@
+import { Image } from 'expo-image';
+import React from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+
+import { Images } from '@/constants/images';
+
+interface TransCardProps {
+  item: any;
+  width?: number;
+  height?: number;
+  fill?: number;
+  imageWidth?: number;
+  imageHeight?: number;
+}
+
+const LEVEL_MAP: any = {
+  A: { bg: Images.box.detail.levelA, productItem: Images.box.detail.productItemA },
+  B: { bg: Images.box.detail.levelB, productItem: Images.box.detail.productItemB },
+  C: { bg: Images.box.detail.levelC, productItem: Images.box.detail.productItemC },
+  D: { bg: Images.box.detail.levelD, productItem: Images.box.detail.productItemD },
+};
+
+export default function TransCard({
+  item,
+  width = 67,
+  height = 90,
+  fill = 2,
+  imageWidth = 55,
+  imageHeight = 70,
+}: TransCardProps) {
+  const itemWidth = width + fill * 2;
+  const levelConfig = LEVEL_MAP[item.level] || LEVEL_MAP.D;
+
+  return (
+    <View style={[styles.item, { width: itemWidth, height: height }]}>
+      <Image
+        source={{ uri: levelConfig.bg }}
+        style={[styles.super, { width: width /*, left: fill */ }]}
+        contentFit="fill"
+      />
+      {item.isWinner && (
+        <View style={{position:'absolute', top: 5, left: 5, zIndex: 100, backgroundColor:'red', padding: 2}}>
+          <Text style={{color:'white', fontSize: 10, fontWeight:'bold'}}>WINNER</Text>
+        </View>
+      )}
+      <Image
+        source={{ uri: item.cover }}
+        style={[styles.image, { width: imageWidth, height: imageHeight }]}
+        contentFit="contain"
+      />
+      <Image
+        source={{ uri: levelConfig.productItem }}
+        style={styles.itemBottom}
+        contentFit="fill"
+      />
+    </View>
+  );
+}
+
+const styles = StyleSheet.create({
+  item: {
+    position: 'relative',
+    justifyContent: 'center',
+    alignItems: 'center',
+  },
+  super: {
+    position: 'absolute',
+    top: 0,
+    height: '100%',
+  },
+  image: {
+    zIndex: 1,
+  },
+  itemBottom: {
+    position: 'absolute',
+    bottom: 0,
+    left: 0,
+    width: '100%',
+    height: 40, 
+    zIndex: 10,
+  },
+});

+ 277 - 0
app/lottery/index.tsx

@@ -0,0 +1,277 @@
+import { Audio } from 'expo-av';
+import { Image } from 'expo-image';
+import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
+import React, { useEffect, useRef, useState } from 'react';
+import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import LotteryReel from './components/LotteryReel';
+
+import { Images } from '@/constants/images';
+import services from '@/services/api';
+
+const { width: SCREEN_WIDTH } = Dimensions.get('window');
+
+// Sound URL from Uniapp config
+const SOUND_URL = 'https://cdn.acetoys.cn/kai_xin_ma_te/resource/magic/lottery.mp3';
+
+export default function LotteryScreen() {
+  const router = useRouter();
+  const params = useLocalSearchParams();
+  const insets = useSafeAreaInsets();
+  
+  const num = Number(params.num) || 1;
+  const tradeNo = params.tradeNo as string;
+  const poolId = params.poolId as string;
+
+  const [results, setResults] = useState<any[]>([]);
+  const [pool, setPool] = useState<any[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [sound, setSound] = useState<Audio.Sound>();
+  
+  // Timer Ref for cleanup
+  const timerRef = useRef<any>(null);
+
+  // Layout calculations
+  const itemWidth = (num === 1) ? 94 : 72; // Adjusted to 72 to match mask calculation exactly (was 71)
+  // Uniapp width logic:
+  // width() { return (screenWidth - (this.num == 1 ? 94 : 72)) / 2 }
+  const maskWidth = (SCREEN_WIDTH - ((num === 1) ? 94 : 72)) / 2; 
+  const padding = SCREEN_WIDTH > 375 ? 32 : 0; 
+
+  useEffect(() => {
+    init();
+    return () => {
+      sound?.unloadAsync();
+      if (timerRef.current) clearTimeout(timerRef.current);
+    };
+  }, []);
+
+  const init = async () => {
+      try {
+          console.log('LotteryScreen Init: num', num, 'tradeNo', tradeNo, 'poolId', poolId);
+          await Promise.all([loadData(), playSound()]);
+      } catch (e) {
+          console.error('LotteryScreen Init Error', e);
+      } finally {
+          setLoading(false);
+      }
+  }
+
+  const playSound = async () => {
+    try {
+      const { sound } = await Audio.Sound.createAsync(
+        { uri: SOUND_URL }
+      );
+      setSound(sound);
+      await sound.playAsync();
+    } catch (error) {
+       console.log('Error playing sound', error);
+    }
+  };
+
+  const loadData = async () => {
+      if (tradeNo) {
+         try {
+             const res: any = await services.award.getApplyResult(tradeNo);
+             const list = res?.data?.inventoryList || res?.inventoryList || [];
+             setResults(list);
+             
+             // 2. Fetch Pool (Goods) if not passed
+             // In a real app we might pass this via context or params if small
+             // For now, fetch detail to get the goods list
+             if (poolId) {
+                 const detailRes = await services.award.getPoolDetail(poolId);
+                 const goods = detailRes?.luckGoodsList || [];
+                 setPool(goods);
+             } else {
+                 // Fallback if no poolId, just use result as pool (not ideal as it's small)
+                 setPool(list);
+             }
+    
+             // Auto show result after animation
+             if (timerRef.current) clearTimeout(timerRef.current);
+             timerRef.current = setTimeout(() => {
+                 handleFinish(list);
+             }, 2800); // 2000ms duration + buffer
+         } catch (error) {
+             console.error('Load Data Error', error);
+             // Safety fallback?
+         }
+      }
+  };
+
+  const handleFinish = (finalResults?: any[]) => {
+    if (timerRef.current) {
+        clearTimeout(timerRef.current);
+        timerRef.current = null;
+    }
+    // Navigate to result modal/screen
+    const data = finalResults || results;
+    // We can navigate to a result page or show a local modal components
+    // Assuming we have a result route
+    router.replace({ 
+        pathname: '/lottery/result' as any, 
+        params: { 
+            results: JSON.stringify(data),
+            poolId
+        } 
+    });
+  };
+  
+  const handleSkip = () => {
+      sound?.stopAsync();
+      handleFinish();
+  };
+
+  if (loading) return (
+      <View style={styles.container}>
+          <Image source={{ uri: Images.mine.kaixinMineBg }} style={styles.bg} contentFit="cover" />
+      </View>
+  );
+
+  return (
+    <View style={styles.container}>
+      <Stack.Screen options={{ headerShown: false }} />
+      <Image source={{ uri: Images.mine.kaixinMineBg }} style={styles.bg} contentFit="cover" />
+      <View style={styles.maskPage} />
+
+      <View style={[styles.wrapper, { paddingTop: padding }]}>
+         <View style={styles.reelsContainer}>
+            {/* Left Column Masks */}
+            <View style={[styles.maskLeft, { width: maskWidth }]} />
+            
+            {/* Reels */}
+            <View style={num === 1 ? styles.height1 : styles.height5}>
+                {results.map((item, index) => (
+                    <View key={index} style={styles.reelRow}> 
+                        <LotteryReel 
+                            key={`reel-${index}-${pool.length}-${results.length}`} 
+                            pool={pool.length > 0 ? pool : results} 
+                            result={item}
+                            width={SCREEN_WIDTH} 
+                            itemWidth={itemWidth}
+                            index={index}
+                            delay={index * 30} // Very fast stagger
+                            duration={2000} // Faster spin (was 3000)
+                        />
+                    </View>
+                ))}
+            </View>
+
+            {/* Right Column Masks */}
+            <View style={[styles.maskRight, { width: maskWidth }]} />
+            
+            {/* Middle Frame */}
+            <Image 
+                source={{ uri: num === 1 ? Images.resource.lottery_middle_s : Images.resource.lottery_middle_l }}
+                style={[
+                    styles.middleImage, 
+                    { 
+                        height: num === 1 ? 200 : 540, 
+                        top: num === 1 ? -35 : -10,
+                    }
+                ]}
+                contentFit="contain"
+            />
+         </View>
+      </View>
+
+      <View style={styles.bottom}>
+        <TouchableOpacity style={styles.skipBtn} onPress={handleSkip}>
+            <Text style={styles.skipText}>跳过动画</Text>
+        </TouchableOpacity>
+      </View>
+    </View>
+  );
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    backgroundColor: '#222335',
+  },
+  bg: {
+    position: 'absolute',
+    width: '100%',
+    height: '100%',
+    zIndex: -1,
+  },
+  maskPage: {
+    ...StyleSheet.absoluteFillObject,
+    backgroundColor: 'rgba(0,0,0,0.4)',
+    zIndex: 0,
+  },
+  wrapper: {
+    flex: 1,
+    // justifyContent: 'center', 
+    alignItems: 'center',
+    marginTop: 100, // Adjust based on visual
+  },
+  reelsContainer: {
+    position: 'relative',
+    alignItems: 'center',
+    justifyContent: 'center',
+    // backgroundColor: 'rgba(255,0,0,0.1)', // Debug
+  },
+  reelRow: {
+      overflow: 'hidden',
+      width: SCREEN_WIDTH,
+      alignItems: 'center',
+      justifyContent: 'center',
+      marginBottom: 12, // Gap between rows
+      height: 94, // Height of one row (card height + tiny padding)
+  },
+  height1: {
+      height: 130, // 1 row + gaps
+      justifyContent: 'center',
+  },
+  height5: {
+      height: 540, // 5 rows * (~100)
+      justifyContent: 'center',
+      paddingVertical: 10,
+  },
+  maskLeft: {
+    position: 'absolute',
+    left: 0,
+    top: 0,
+    bottom: 0,
+    backgroundColor: 'rgba(0,0,0,0.5)',
+    zIndex: 10,
+  },
+  maskRight: {
+     position: 'absolute',
+     right: 0,
+     top: 0,
+     bottom: 0,
+     backgroundColor: 'rgba(0,0,0,0.5)',
+     zIndex: 10,
+  },
+  middleImage: {
+      position: 'absolute',
+      width: SCREEN_WIDTH, 
+      zIndex: 20,
+      left: 0,
+      // The image frame should be centered over the reels
+      // top is handled dynamically or via flex centering in parent if possible
+  },
+  bottom: {
+    position: 'absolute',
+    bottom: 50,
+     width: '100%',
+     alignItems: 'center',
+     zIndex: 30,
+  },
+  skipBtn: {
+      backgroundColor: 'rgba(255,255,255,0.2)',
+      paddingHorizontal: 30,
+      paddingVertical: 10,
+      borderRadius: 32,
+      borderWidth: 1,
+      borderColor: 'rgba(255,255,255,0.4)',
+  },
+  skipText: {
+      color: '#fff',
+      fontSize: 14,
+  },
+});

+ 150 - 0
app/lottery/result.tsx

@@ -0,0 +1,150 @@
+import { Image } from 'expo-image';
+import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
+import React, { useEffect, useState } from 'react';
+import { Dimensions, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+
+import { Images } from '@/constants/images';
+
+const { width: SCREEN_WIDTH } = Dimensions.get('window');
+
+export default function LotteryResultScreen() {
+  const router = useRouter();
+  const params = useLocalSearchParams();
+  
+  const [results, setResults] = useState<any[]>([]);
+
+  useEffect(() => {
+    if (params.results) {
+        try {
+            setResults(JSON.parse(params.results as string));
+        } catch (e) {
+            console.error('Failed to parse results', e);
+        }
+    }
+  }, [params.results]);
+
+  const handleClose = () => {
+    // Navigate back to the previous screen (likely detail page)
+    // Or if we came from award_detail, we might want to go back there
+    if (router.canGoBack()) {
+        router.back();
+    } else {
+        router.replace('/(tabs)/box');
+    }
+  };
+
+  const handleAgain = () => {
+     // Navigation logic to play again if needed, or just close to let user click again
+     handleClose();
+  };
+
+  return (
+    <View style={styles.container}>
+       <Stack.Screen options={{ headerShown: false }} />
+       <View style={styles.overlay} />
+       
+       <View style={styles.content}>
+           <Image 
+              source={{ uri: Images.box.resultBgA }} // Using A as default, logic should depend on rarity
+              style={styles.resultBg}
+              contentFit="contain"
+           />
+           
+           <View style={styles.resultContainer}>
+               <Text style={styles.title}>恭喜获得</Text>
+               <ScrollView contentContainerStyle={styles.scrollContent}>
+                   <View style={styles.grid}>
+                       {results.map((item, index) => (
+                           <View key={index} style={styles.item}>
+                               <Image source={{ uri: item.cover }} style={styles.itemImage} contentFit="contain" />
+                               <Text style={styles.itemName} numberOfLines={1}>{item.name}</Text>
+                           </View>
+                       ))}
+                   </View>
+               </ScrollView>
+               
+               <TouchableOpacity style={styles.button} onPress={handleClose}>
+                   <Text style={styles.buttonText}>收下奖品</Text>
+               </TouchableOpacity>
+           </View>
+       </View>
+    </View>
+  );
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    justifyContent: 'center',
+    alignItems: 'center',
+    backgroundColor: 'transparent',
+  },
+  overlay: {
+    ...StyleSheet.absoluteFillObject,
+    backgroundColor: 'rgba(0,0,0,0.7)',
+  },
+  content: {
+      width: SCREEN_WIDTH * 0.9,
+      height: 500,
+      alignItems: 'center',
+      justifyContent: 'center',
+  },
+  resultBg: {
+      position: 'absolute',
+      width: '100%',
+      height: '100%',
+  },
+  resultContainer: {
+      width: '100%',
+      height: '100%',
+      paddingTop: 100,
+      paddingHorizontal: 40,
+      alignItems: 'center',
+  },
+  title: {
+      fontSize: 24,
+      fontWeight: 'bold',
+      color: '#fff',
+      marginBottom: 20,
+      textShadowColor: 'rgba(0,0,0,0.5)',
+      textShadowOffset: { width: 1, height: 1 },
+      textShadowRadius: 2,
+  },
+  scrollContent: {
+      alignItems: 'center',
+  },
+  grid: {
+      flexDirection: 'row',
+      flexWrap: 'wrap',
+      justifyContent: 'center',
+  },
+  item: {
+      width: 80,
+      margin: 5,
+      alignItems: 'center',
+      backgroundColor: 'rgba(255,255,255,0.1)',
+      borderRadius: 8,
+      padding: 5,
+  },
+  itemImage: {
+      width: 60,
+      height: 60,
+      marginBottom: 5,
+  },
+  itemName: {
+      color: '#fff',
+      fontSize: 10,
+      textAlign: 'center',
+  },
+  button: {
+      marginTop: 20,
+      backgroundColor: '#F62C71',
+      paddingHorizontal: 40,
+      paddingVertical: 10,
+      borderRadius: 20,
+  },
+  buttonText: {
+      color: '#fff',
+      fontWeight: 'bold',
+  },
+});

+ 7 - 0
babel.config.js

@@ -0,0 +1,7 @@
+module.exports = function (api) {
+  api.cache(true);
+  return {
+    presets: ['babel-preset-expo'],
+    plugins: ['react-native-reanimated/plugin'],
+  };
+};

+ 16 - 0
constants/images.ts

@@ -1,8 +1,14 @@
 // 图片资源配置 - 对应 supermart-mini 的 ossurl.js
 const CDN_BASE = 'https://cdn.acetoys.cn/kai_xin_ma_te/supermart';
 const CDN_SUPERMART = 'https://cdn.acetoys.cn/supermart';
+const CDN_MAGIC = 'https://cdn.acetoys.cn/kai_xin_ma_te/resource/magic';
 
 export const Images = {
+  resource: {
+    lottery_middle_s: `${CDN_MAGIC}/lottery/middle_s.png`,
+    lottery_middle_l: `${CDN_MAGIC}/lottery/middle_l.png`,
+    lottery_bg: `${CDN_MAGIC}/lottery/bg.jpg`,
+  },
   common: {
     indexBg: `${CDN_BASE}/common/indexBg.png`,
     awardBg: `${CDN_BASE}/common/awardBg.png`,
@@ -79,6 +85,16 @@ export const Images = {
     sortAmount: `${CDN_BASE}/box/sortAmount.png`,
     sortAmountOnT: `${CDN_BASE}/box/sortAmountOnT.png`,
     sortAmountOnB: `${CDN_BASE}/box/sortAmountOnB.png`,
+    // Result Backgrounds
+    resultBgA: `${CDN_SUPERMART}/supermart/box/resultBgA.png`,
+    resultBgB: `${CDN_SUPERMART}/supermart/box/resultBgB.png`,
+    resultBgC: `${CDN_SUPERMART}/supermart/box/resultBgC.png`,
+    resultBgD: `${CDN_SUPERMART}/supermart/box/resultBgD.png`,
+    // Border Images
+    borderImgA: `${CDN_SUPERMART}/supermart/box/borderImgA.png`,
+    borderImgB: `${CDN_SUPERMART}/supermart/box/borderImgB.png`,
+    borderImgC: `${CDN_SUPERMART}/supermart/box/borderImgC.png`,
+    borderImgD: `${CDN_SUPERMART}/supermart/box/borderImgD.png`,
     detail: {
       mainGoodsSection: `${CDN_BASE}/box/detail/mainGoodsSection.png`,
       mainGoodsSectionBtext: `${CDN_BASE}/box/detail/mainGoodsSectionBtext.png`,