catchDoll.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. import { Images } from '@/constants/images';
  2. import ServiceWallet from '@/services/wallet';
  3. import Service from '@/services/weal';
  4. import { Ionicons } from '@expo/vector-icons';
  5. import { Image } from 'expo-image';
  6. import { useRouter } from 'expo-router';
  7. import React, { useEffect, useRef, useState } from 'react';
  8. import { Alert, Dimensions, ImageBackground, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
  9. import Animated, {
  10. cancelAnimation,
  11. Easing,
  12. useAnimatedStyle,
  13. useSharedValue,
  14. withRepeat,
  15. withSequence,
  16. withTiming
  17. } from 'react-native-reanimated';
  18. import { useSafeAreaInsets } from 'react-native-safe-area-context';
  19. import { CatchRuleModal, CatchRuleModalRef } from './components/CatchRuleModal';
  20. import { DollPrizeModal, DollPrizeModalRef } from './components/DollPrizeModal';
  21. import { DollResultModal, DollResultModalRef } from './components/DollResultModal';
  22. import { LackMolibModal, LackMolibModalRef } from './components/LackMolibModal';
  23. import { PressSureModal, PressSureModalRef } from './components/PressSureModal';
  24. import { WinRecordModal, WinRecordModalRef } from './components/WinRecordModal';
  25. const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
  26. const BALL_COUNT = 20; // Optimized count
  27. const BALL_SIZE = 66;
  28. const BALL_CONTAINER_WIDTH = 275;
  29. const BALL_CONTAINER_HEIGHT = 278;
  30. // Separate Ball Component for Performance
  31. const Ball = React.memo(({ index }: { index: number }) => {
  32. const x = useSharedValue(Math.random() * (BALL_CONTAINER_WIDTH - BALL_SIZE));
  33. const y = useSharedValue(Math.random() * (BALL_CONTAINER_HEIGHT - BALL_SIZE));
  34. const rotate = useSharedValue(Math.random() * 360);
  35. useEffect(() => {
  36. // Generate a sequence of random moves to simulate "chaos" on UI thread
  37. const xMoves = Array.from({ length: 10 }).map(() =>
  38. withTiming(Math.random() * (BALL_CONTAINER_WIDTH - BALL_SIZE), {
  39. duration: 2000 + Math.random() * 1500,
  40. easing: Easing.linear
  41. })
  42. );
  43. const yMoves = Array.from({ length: 10 }).map(() =>
  44. withTiming(Math.random() * (BALL_CONTAINER_HEIGHT - BALL_SIZE), {
  45. duration: 2000 + Math.random() * 1500,
  46. easing: Easing.linear
  47. })
  48. );
  49. const rMoves = Array.from({ length: 10 }).map(() =>
  50. withTiming(Math.random() * 360, {
  51. duration: 2000 + Math.random() * 1500,
  52. easing: Easing.linear
  53. })
  54. );
  55. // @ts-ignore: spread argument for withSequence
  56. x.value = withRepeat(withSequence(...xMoves), -1, true);
  57. // @ts-ignore
  58. y.value = withRepeat(withSequence(...yMoves), -1, true);
  59. // @ts-ignore
  60. rotate.value = withRepeat(withSequence(...rMoves), -1, true);
  61. return () => {
  62. cancelAnimation(x);
  63. cancelAnimation(y);
  64. cancelAnimation(rotate);
  65. };
  66. }, []);
  67. const animatedStyle = useAnimatedStyle(() => {
  68. return {
  69. transform: [
  70. { translateX: x.value },
  71. { translateY: y.value },
  72. { rotate: `${rotate.value}deg` } // Reanimated handles string interpolation
  73. ]
  74. };
  75. });
  76. return (
  77. <Animated.View style={[styles.ball, animatedStyle]}>
  78. <Image source={{ uri: Images.welfare.qijiWelfareDollBall }} style={styles.fullSize} />
  79. </Animated.View>
  80. );
  81. });
  82. export default function CatchDollScreen() {
  83. const router = useRouter();
  84. const insets = useSafeAreaInsets();
  85. // State
  86. const [molibi, setMolibi] = useState(0);
  87. const [luckWheelGoodsList, setLuckWheelGoodsList] = useState<any[]>([]);
  88. const [loading, setLoading] = useState(false);
  89. const [lotteryFlag, setLotteryFlag] = useState(false);
  90. // Reanimated Values for interactions
  91. const switchRotateVal = useSharedValue(0);
  92. const dropScale = useSharedValue(0);
  93. const dropOpacity = useSharedValue(0);
  94. // Modals
  95. const ruleRef = useRef<CatchRuleModalRef>(null);
  96. const winRecordRef = useRef<WinRecordModalRef>(null);
  97. const resultRef = useRef<DollResultModalRef>(null);
  98. const prizeRef = useRef<DollPrizeModalRef>(null);
  99. const pressSureRef = useRef<PressSureModalRef>(null);
  100. const lackMolibRef = useRef<LackMolibModalRef>(null);
  101. useEffect(() => {
  102. initData();
  103. }, []);
  104. const initData = async () => {
  105. getMolibi();
  106. getDetail();
  107. };
  108. const getMolibi = async () => {
  109. const res = await ServiceWallet.info('MAGIC_POWER_COIN');
  110. if (res) {
  111. setMolibi(res.balance);
  112. }
  113. };
  114. const getDetail = async () => {
  115. const res = await Service.catchDollDetail();
  116. if (res.code == 0) {
  117. setLuckWheelGoodsList(res.data.luckWheelGoodsList);
  118. } else {
  119. Alert.alert('提示', res.msg);
  120. }
  121. };
  122. const handleBack = () => router.back();
  123. const handlePress1 = () => {
  124. if (lotteryFlag) {
  125. Alert.alert('提示', '请不要重复点击');
  126. return;
  127. }
  128. if (molibi === 0) {
  129. lackMolibRef.current?.show();
  130. } else {
  131. pressSureRef.current?.show(1);
  132. }
  133. };
  134. const handlePress5 = () => {
  135. if (lotteryFlag) {
  136. Alert.alert('提示', '请不要重复点击');
  137. return;
  138. }
  139. if (molibi < 5) {
  140. lackMolibRef.current?.show();
  141. } else {
  142. pressSureRef.current?.show(5);
  143. }
  144. };
  145. const onConfirmPress = (quantity: number) => {
  146. playLottery(quantity);
  147. };
  148. const playLottery = async (quantity: number) => {
  149. setLotteryFlag(true);
  150. const res = await Service.dollLottery({ quantity });
  151. if (res.code == 0) {
  152. getMolibi();
  153. // Animate Switch
  154. switchRotateVal.value = withSequence(
  155. withTiming(90, { duration: 500, easing: Easing.inOut(Easing.ease) }),
  156. withTiming(0, { duration: 500, easing: Easing.inOut(Easing.ease) })
  157. );
  158. setTimeout(() => {
  159. // Animate Drop
  160. dropOpacity.value = 1;
  161. dropScale.value = 1;
  162. dropOpacity.value = withTiming(0, { duration: 2000 });
  163. dropScale.value = withTiming(0, { duration: 2000 });
  164. }, 800);
  165. setTimeout(() => {
  166. resultRef.current?.show(res.data);
  167. setLotteryFlag(false);
  168. }, 2000);
  169. } else {
  170. Alert.alert('提示', res.msg);
  171. setLotteryFlag(false);
  172. }
  173. };
  174. const switchStyle = useAnimatedStyle(() => ({
  175. transform: [{ rotate: `${switchRotateVal.value}deg` }]
  176. }));
  177. const dropStyle = useAnimatedStyle(() => ({
  178. opacity: dropOpacity.value,
  179. transform: [{ scale: dropScale.value }]
  180. }));
  181. return (
  182. <ImageBackground source={{ uri: Images.common.commonBg }} style={styles.container}>
  183. {/* Header */}
  184. <View style={[styles.header, { paddingTop: insets.top, minHeight: 44 + insets.top }]}>
  185. <TouchableOpacity onPress={handleBack} style={styles.backBtn}>
  186. <Ionicons name="chevron-back" size={24} color="#fff" />
  187. </TouchableOpacity>
  188. <Text style={styles.title}>扭蛋机</Text>
  189. </View>
  190. {/* Fixed Record Button */}
  191. <TouchableOpacity onPress={() => winRecordRef.current?.show()} style={styles.recordBtn}>
  192. <ImageBackground source={{ uri: Images.welfare.qijiWelfareRecordBg }} style={styles.recordBg}>
  193. <Text style={styles.recordText}>中奖记录</Text>
  194. </ImageBackground>
  195. </TouchableOpacity>
  196. <ScrollView contentContainerStyle={styles.scrollContent}>
  197. <View style={styles.content}>
  198. {/* Rule Button */}
  199. <TouchableOpacity onPress={() => ruleRef.current?.show()} style={styles.ruleBtn}>
  200. <ImageBackground source={{ uri: Images.welfare.catchDollRule }} style={styles.fullSize} />
  201. </TouchableOpacity>
  202. {/* Machine */}
  203. <View style={styles.machineBox}>
  204. <ImageBackground source={{ uri: Images.welfare.qijiWelfareDollBox }} style={styles.machineBg} resizeMode="stretch">
  205. {/* Prizes Scroll */}
  206. <View style={styles.prizesScrollBox}>
  207. <ScrollView horizontal showsHorizontalScrollIndicator={false}>
  208. {luckWheelGoodsList.map((item, index) => (
  209. <View key={index} style={styles.prizeItem}>
  210. <Image source={{ uri: item.cover }} style={styles.prizeImg} />
  211. </View>
  212. ))}
  213. </ScrollView>
  214. </View>
  215. {/* Molibi Count & Add */}
  216. <View style={styles.molibiBox}>
  217. <Text style={styles.molibiText}>源力币:<Text style={styles.molibiNum}>{molibi}</Text> 个</Text>
  218. <TouchableOpacity onPress={() => router.push('/box' as any)} style={styles.addMolibiBtn}>
  219. <Image source={{ uri: Images.welfare.molibiBoxBtn }} style={styles.fullSize} resizeMode="contain" />
  220. </TouchableOpacity>
  221. </View>
  222. {/* Switch */}
  223. <Animated.View style={[styles.switchBox, switchStyle]}>
  224. <Image source={{ uri: Images.welfare.qijiWelfareDollBi1 }} style={styles.fullSize} />
  225. </Animated.View>
  226. <Animated.View style={[styles.switchBoxRight, switchStyle]}>
  227. <Image source={{ uri: Images.welfare.qijiWelfareDollBi5 }} style={styles.fullSize} />
  228. </Animated.View>
  229. {/* Balls */}
  230. <View style={styles.ballsContainer}>
  231. {Array.from({ length: BALL_COUNT }).map((_, index) => (
  232. <Ball key={index} index={index} />
  233. ))}
  234. </View>
  235. {/* Dropping Ball */}
  236. <Animated.View style={[styles.droppingBall, dropStyle]}>
  237. <Image source={{ uri: Images.welfare.qijiWelfareDollBall }} style={styles.fullSize} />
  238. </Animated.View>
  239. {/* Opening hole image */}
  240. <Image source={{ uri: Images.welfare.opening }} style={styles.opening} />
  241. {/* Buttons */}
  242. <TouchableOpacity onPress={handlePress1} style={[styles.playBtn, styles.playBtn1]}>
  243. <Image source={{ uri: Images.welfare.qijiWelfareDollOne }} style={styles.fullSize} resizeMode="contain" />
  244. </TouchableOpacity>
  245. <TouchableOpacity onPress={handlePress5} style={[styles.playBtn, styles.playBtn5]}>
  246. <Image source={{ uri: Images.welfare.qijiWelfareDollFive }} style={styles.fullSize} resizeMode="contain" />
  247. </TouchableOpacity>
  248. </ImageBackground>
  249. </View>
  250. </View>
  251. </ScrollView>
  252. {/* Modals */}
  253. <CatchRuleModal ref={ruleRef} />
  254. <WinRecordModal ref={winRecordRef} />
  255. <DollResultModal ref={resultRef} />
  256. <DollPrizeModal ref={prizeRef} />
  257. <PressSureModal ref={pressSureRef} onPress={onConfirmPress} />
  258. <LackMolibModal ref={lackMolibRef} />
  259. </ImageBackground>
  260. );
  261. }
  262. const styles = StyleSheet.create({
  263. container: {
  264. flex: 1,
  265. width: '100%',
  266. height: '100%',
  267. },
  268. header: {
  269. // height: 44, // Removing fixed height to allow dynamic override
  270. flexDirection: 'row',
  271. alignItems: 'center',
  272. justifyContent: 'center',
  273. zIndex: 100,
  274. },
  275. backBtn: {
  276. position: 'absolute',
  277. left: 10,
  278. bottom: 10,
  279. zIndex: 101,
  280. },
  281. title: {
  282. color: '#fff',
  283. fontSize: 16,
  284. fontWeight: 'bold',
  285. },
  286. scrollContent: {
  287. flexGrow: 1,
  288. paddingBottom: 50,
  289. paddingTop: 40, // Push entire content down to avoid header overlap
  290. },
  291. content: {
  292. width: '100%',
  293. alignItems: 'center',
  294. position: 'relative',
  295. height: 750,
  296. },
  297. fullSize: {
  298. width: '100%',
  299. height: '100%',
  300. },
  301. ruleBtn: {
  302. position: 'absolute',
  303. left: 13,
  304. top: 422,
  305. zIndex: 10,
  306. width: 62,
  307. height: 20,
  308. },
  309. recordBtn: {
  310. position: 'absolute',
  311. right: 0,
  312. top: 200, // Fixed position from top of screen
  313. zIndex: 99,
  314. },
  315. recordBg: {
  316. width: 26,
  317. height: 80, // Increased from 70 to fit 4 chars
  318. justifyContent: 'center',
  319. alignItems: 'center',
  320. paddingTop: 5, // Reduced padding slightly
  321. },
  322. recordText: {
  323. fontSize: 12,
  324. color: '#fff',
  325. fontWeight: 'bold',
  326. width: 12,
  327. textAlign: 'center',
  328. textShadowColor: '#6C3200',
  329. textShadowOffset: { width: 1, height: 1 },
  330. textShadowRadius: 1,
  331. },
  332. machineBox: {
  333. marginTop: 20, // Reverted to 20, spacing handled by scrollContent padding
  334. width: '100%',
  335. alignItems: 'center',
  336. },
  337. machineBg: {
  338. width: '100%',
  339. height: 706,
  340. position: 'relative',
  341. // resizeMode: 'stretch' // Applied in component prop
  342. },
  343. prizesScrollBox: {
  344. width: 250,
  345. alignSelf: 'center',
  346. marginTop: 110,
  347. height: 50,
  348. },
  349. prizeItem: {
  350. width: 46,
  351. height: 46,
  352. borderRadius: 4,
  353. backgroundColor: '#ADAEF6',
  354. borderWidth: 2.5,
  355. borderColor: '#8687E4',
  356. marginRight: 5,
  357. justifyContent: 'center',
  358. alignItems: 'center',
  359. },
  360. prizeImg: {
  361. width: '100%',
  362. height: '100%',
  363. },
  364. molibiBox: {
  365. position: 'absolute',
  366. top: 465,
  367. left: 42,
  368. width: 120,
  369. height: 67,
  370. backgroundColor: '#1E1C5B',
  371. borderRadius: 8,
  372. alignItems: 'center',
  373. paddingTop: 5,
  374. },
  375. molibiText: {
  376. color: '#7982CB',
  377. fontSize: 12,
  378. },
  379. molibiNum: {
  380. color: '#FF8400',
  381. fontSize: 18,
  382. fontWeight: '400',
  383. },
  384. addMolibiBtn: {
  385. width: 105,
  386. height: 30,
  387. marginTop: 5,
  388. },
  389. opening: {
  390. position: 'absolute',
  391. top: 455,
  392. right: 42,
  393. width: 133,
  394. height: 82,
  395. },
  396. switchBox: {
  397. position: 'absolute',
  398. top: 560,
  399. left: 42,
  400. width: 65,
  401. height: 65,
  402. zIndex: 3,
  403. },
  404. switchBoxRight: { // switchBox5
  405. position: 'absolute',
  406. top: 560,
  407. right: 42,
  408. width: 65,
  409. height: 65,
  410. zIndex: 3,
  411. },
  412. ballsContainer: {
  413. position: 'absolute',
  414. top: 125,
  415. left: 43,
  416. width: 275,
  417. height: 278,
  418. zIndex: 9,
  419. overflow: 'hidden',
  420. },
  421. ball: {
  422. width: BALL_SIZE,
  423. height: BALL_SIZE,
  424. position: 'absolute',
  425. left: 0,
  426. top: 0,
  427. },
  428. droppingBall: {
  429. position: 'absolute',
  430. top: 475,
  431. right: 87,
  432. width: 49,
  433. height: 48,
  434. zIndex: 3,
  435. },
  436. playBtn: {
  437. position: 'absolute',
  438. bottom: 90, // Match Vue: 179rpx / 2 = 89.5
  439. zIndex: 10,
  440. width: 73,
  441. height: 50,
  442. },
  443. playBtn1: {
  444. left: 98,
  445. },
  446. playBtn5: {
  447. right: 98,
  448. }
  449. });