PointsCollector.vue 9.37 KB
<template>
  <view class="points-collector" :style="{ height: height }">
    <!-- 中心圆形显示总积分 -->
    <view class="center-circle">
      <view class="total-points">
        <text class="points-number">{{ animatedTotalPoints }}分</text>
        <text class="points-label">去兑换</text>
      </view>
    </view>

    <!-- 周围漂浮的小圆圈 -->
    <view
      v-for="(item) in floatingItems"
      :key="item.id"
      class="floating-item"
      :style="getItemStyle(item)"
      @tap="collectItem(item)"
    >
      <view v-if="item.type === 'steps'" class="item-content">
        <text class="item-value">{{ item.steps }}步</text>
        <text class="item-type">{{ item.value }}分</text>
      </view>
      <view v-else class="item-content">
        <text class="item-value">奖励</text>
        <text class="item-type">{{ item.value }}分</text>
      </view>
    </view>

    <!-- 一键收取按钮 -->
    <!-- <view
      v-if="floatingItems.length > 0"
      class="collect-all-btn"
      @tap="collectAll"
    >
      <text>一键收取</text>
    </view> -->
  </view>
</template>

<script setup>
import { ref, onMounted, nextTick, defineProps, defineExpose } from 'vue'
import Taro from '@tarojs/taro'

// 定义props
const props = defineProps({
  height: {
    type: String,
    default: '30vh'
  }
})

// 响应式数据
const totalPoints = ref(12500) // 总积分
const animatedTotalPoints = ref(12500) // 动画中的总积分
const floatingItems = ref([]) // 漂浮的积分项
const isCollecting = ref(false) // 是否正在收集,防止重复触发

/**
 * 生成模拟数据并分配随机位置
 */
const generateMockData = () => {
  const mockItems = [
    { id: 1, type: 'steps', value: 5000, steps: 5000 },
    { id: 2, type: 'points', value: 500 },
    { id: 3, type: 'steps', value: 8000, steps: 5000 },
    { id: 4, type: 'points', value: 30 },
    { id: 5, type: 'steps', value: 12000, steps: 12000 },
    { id: 6, type: 'points', value: 800 },
    { id: 7, type: 'points', value: 250 },
    { id: 8, type: 'steps', value: 6000, steps: 6000 },
    { id: 9, type: 'points', value: 1000 },
    { id: 10, type: 'steps', value: 10000, steps: 10000 },
  ].map(item => ({ ...item, collecting: false })); // 初始化collecting状态

  const maxValue = Math.max(...mockItems.map(i => i.value));
  const baseSize = 80;
  const { windowWidth, windowHeight } = Taro.getWindowInfo();
  const positionedItems = [];

  // 为每个项目分配一个不重叠的随机位置
  return mockItems.map(item => {
    let x, y, hasCollision;
    const maxAttempts = 100; // 限制尝试次数以避免无限循环
    let attempts = 0;
    const centerNoFlyZone = 25; // 中心圆形25%的禁飞区半径

    // 计算项目大小和半径
    const sizeRatio = item.value / maxValue;
    const size = baseSize + (sizeRatio * 40); // rpx
    const radiusXPercent = (size / 750) * 100 / 2 * (windowWidth / windowHeight) ; // 归一化为高度的百分比

    // 定义安全区域
    const minX = radiusXPercent;
    const maxX = 100 - radiusXPercent;
    const minY = radiusXPercent + 5;
    const maxY = 100 - radiusXPercent - 15;

    do {
      attempts++;
      if (attempts > maxAttempts) {
        console.warn('无法为项目找到不重叠的位置:', item);
        break; // 超过尝试次数,接受当前位置
      }

      // 在计算出的边界内生成位置
      x = Math.random() * (maxX - minX) + minX;
      y = Math.random() * (maxY - minY) + minY;

      // 检查与中心禁飞区的距离
      const dxCenter = x - 50;
      const dyCenter = y - 50;
      const distanceFromCenter = Math.sqrt(dxCenter * dxCenter + dyCenter * dyCenter);
      if (distanceFromCenter < centerNoFlyZone) {
        hasCollision = true;
        continue;
      }

      // 检查与其他项目的碰撞
      hasCollision = false;
      for (const pItem of positionedItems) {
        const dx = x - pItem.x;
        const dy = y - pItem.y;
        const distance = Math.sqrt(dx * dx + dy * dy);
        const combinedRadius = radiusXPercent + pItem.radiusXPercent;

        if (distance < combinedRadius) {
          hasCollision = true;
          break;
        }
      }
    } while (hasCollision);

    positionedItems.push({ ...item, x, y, radiusXPercent });
    return { ...item, x, y };
  });
}

/**
 * 获取项目样式(位置、大小和动画)
 */
const getItemStyle = (item) => {
  const baseSize = 80;
  const maxValue = Math.max(...(floatingItems.value.map(i => i.value).length > 0 ? floatingItems.value.map(i => i.value) : [1]));
  const sizeRatio = item.value / maxValue;
  const size = baseSize + (sizeRatio * 40);

  const style = {
    position: 'absolute',
    left: `${item.x}%`,
    top: `${item.y}%`,
    width: `${size}rpx`,
    height: `${size}rpx`,
    transform: 'translate(-50%, -50%) scale(1)',
    transition: 'all 0.8s cubic-bezier(0.5, -0.5, 0.5, 1.5)',
    zIndex: 15,
  };

  if (item.collecting) {
    style.left = '50%';
    style.top = '50%';
    style.transform = 'translate(-50%, -50%) scale(0)';
    style.opacity = 0;
    style.zIndex = 20; // 飞向中心时置于顶层
  }

  return style;
}

/**
 * 收集单个项目
 */
const collectItem = (item) => {
  if (item.collecting) return;
  item.collecting = true;

  setTimeout(() => {
    const totalToAdd = item.type === 'steps' ? Math.floor(item.value / 10) : item.value;
    animateNumber(totalPoints.value, totalPoints.value + totalToAdd);
    totalPoints.value += totalToAdd;

    floatingItems.value = floatingItems.value.filter(i => i.id !== item.id);
  }, 800); // 动画时长
}

/**
 * 一键收取所有积分
 */
const collectAll = async () => {
  if (isCollecting.value) return;
  isCollecting.value = true;

  let totalToAdd = 0;
  const itemsToCollect = [...floatingItems.value];

  itemsToCollect.forEach((item, index) => {
    setTimeout(() => {
      item.collecting = true;
    }, index * 80); // 依次触发动画
    totalToAdd += item.value;
  });

  const totalAnimationTime = itemsToCollect.length * 80 + 800;
  setTimeout(() => {
    animateNumber(totalPoints.value, totalPoints.value + totalToAdd);
    totalPoints.value += totalToAdd;

    floatingItems.value = [];
    isCollecting.value = false;

    // 模拟2秒后重新生成
    // setTimeout(() => {
    //   floatingItems.value = generateMockData();
    // }, 2000);
  }, totalAnimationTime);
}

/**
 * 数字滚动动画
 */
const animateNumber = (start, end) => {
  const duration = 800;
  const startTime = Date.now();
  const difference = end - start;

  const animate = () => {
    const elapsed = Date.now() - startTime;
    const progress = Math.min(elapsed / duration, 1);
    const easeOut = 1 - Math.pow(1 - progress, 3);
    animatedTotalPoints.value = Math.floor(start + difference * easeOut);

    if (progress < 1) {
      requestAnimationFrame(animate);
    }
  };
  animate();
}

// 暴露方法给父组件
defineExpose({
  collectAll
})

// 组件挂载时初始化数据
onMounted(() => {
  floatingItems.value = generateMockData();
})
</script>

<style lang="less">
.points-collector {
  position: relative;
  width: 100vw;
  height: 100vh;
  // background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  overflow: hidden;
}

.center-circle {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 200rpx;
  height: 200rpx;
  background: linear-gradient(135deg, #4A90E2, #4d96ea);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 8rpx 32rpx rgba(53, 144, 255, 0.3);
  z-index: 10;
}

.total-points {
  text-align: center;
  color: white;
}

.points-number {
  display: block;
  font-size: 48rpx;
  font-weight: bold;
  line-height: 1;
}

.points-label {
  display: block;
  font-size: 24rpx;
  margin-top: 8rpx;
  // opacity: 0.9;
}

.floating-item {
  position: absolute;
  background: rgb(62, 144, 239);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
  cursor: pointer;
  animation: float 3s ease-in-out infinite;
}

.item-content {
  text-align: center;
  color: #FFF;
}

.item-value {
  display: block;
  font-size: 22rpx;
  line-height: 1;
}

.item-type {
  display: block;
  font-size: 23rpx;
  margin-top: 4rpx;
  font-weight: bold;
  // opacity: 0.7;
}

.stack-count {
  position: absolute;
  top: -8rpx;
  right: -8rpx;
  width: 32rpx;
  height: 32rpx;
  background: #ff6b35;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-size: 20rpx;
  font-weight: bold;
}

.collect-all-btn {
  position: absolute;
  bottom: 100rpx;
  left: 50%;
  transform: translateX(-50%);
  background: linear-gradient(135deg, #ff6b35, #f7931e);
  color: white;
  padding: 24rpx 48rpx;
  border-radius: 48rpx;
  font-size: 32rpx;
  font-weight: bold;
  box-shadow: 0 8rpx 24rpx rgba(255, 107, 53, 0.3);
  z-index: 20;
}

@keyframes float {
  0%, 100% {
    transform: translate(-50%, -50%) translateY(0px) rotate(0deg);
  }
  25% {
    transform: translate(-50%, -50%) translateY(-10px) rotate(1deg);
  }
  50% {
    transform: translate(-50%, -50%) translateY(-5px) rotate(-1deg);
  }
  75% {
    transform: translate(-50%, -50%) translateY(-15px) rotate(0.5deg);
  }
}

// 响应式适配
@media screen and (max-width: 750px) {
  .center-circle {
    width: 160rpx;
    height: 160rpx;
  }

  .points-number {
    font-size: 36rpx;
  }

  .points-label {
    font-size: 24rpx;
  }
}
</style>