topfans/frontend/pages/support-activity/components/FloatingBubbles.vue
2026-04-07 23:08:49 +08:00

639 lines
17 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="floating-bubbles">
<!-- 角色图片区域 -->
<view class="character-layer">
<view
v-for="char in activeCharacters"
:key="char.id"
class="character-wrapper"
:style="char.style"
>
<image
:src="char.imageSrc"
class="character-img"
mode="aspectFit"
/>
</view>
</view>
<!-- 对话框层 - 独立于角色层,始终在最上方 -->
<view class="bubble-layer">
<view
v-for="char in activeCharacters"
:key="'bubble-' + char.id"
class="bubble-wrapper"
:style="char.style"
>
<view class="character-bubble-wrapper">
<view
v-for="bubble in char.bubbles"
:key="bubble.id"
class="dialog-bubble"
:class="'arrow-' + char.arrowPosition"
:style="bubble.style"
>
<view class="bubble-text-container">
<text class="bubble-text">{{ bubble.text }}</text>
<text v-if="bubble.showSupport" class="bubble-text-zl">(点击助力)</text>
</view>
<!-- 对话框小三角 -->
<view class="dialog-arrow" :class="'arrow-' + char.arrowPosition"></view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, watch, onUnmounted, computed } from 'vue'
import screenCache from '@/utils/screen-cache'
import { BUBBLE_CONFIG } from '@/utils/performance-config'
// 互动角色图片
const CHARACTER_IMAGES = [
'/static/rank/activity-support-icon/square-interaction-char/1.png',
'/static/rank/activity-support-icon/square-interaction-char/2.png',
'/static/rank/activity-support-icon/square-interaction-char/3.png',
'/static/rank/activity-support-icon/square-interaction-char/4.png'
]
const props = defineProps({
bubbleTexts: {
type: Array,
default: () => []
},
isActive: {
type: Boolean,
default: true
},
current: {
type: Number,
default: 0
},
target: {
type: Number,
default: 1000
}
})
const activeBubbles = ref([])
const activeCharacters = ref([])
const MAX_BUBBLES = BUBBLE_CONFIG.MAX_COUNT
const MAX_ACTIVE_DIALOGS = 4 // 最多同时显示4个对话框
let bubbleIdCounter = 0
let characterIdCounter = 0
let generationTimer = null
let characterBubbleTimer = null
let isGenerating = false
let cachedViewportWidth = 0
// 跟踪所有 setTimeout 定时器以便清理
let characterGenerationTimers = []
let bubbleRemovalTimers = []
// 计算进度百分比
const progressPercent = computed(() => {
if (props.target === 0) return 0
return Math.min((props.current / props.target) * 100, 100)
})
// 根据进度计算角色数量 (进度越高角色越多最多10个)
const characterCount = computed(() => {
const percent = progressPercent.value
if (percent >= 90) return 10
if (percent >= 70) return 8
if (percent >= 50) return 6
if (percent >= 30) return 4
if (percent >= 10) return 2
return 1
})
// 在蓝色框内随机生成位置,避开蛋糕禁区
function generateRandomPosition(existingPositions, minDistance = 60) {
const maxAttempts = 100
let attempts = 0
// 蓝色框(容器)尺寸
const containerWidth = 728 // rpx
const containerHeight = 536 // rpx
const characterWidth = 120 // rpx
const characterHeight = 200 // rpx
// 蛋糕禁区尺寸 - 中心区域
const cakeZoneWidth = 300 // rpx
const cakeZoneHeight = 300 // rpx
// 对话框显示区域 - 容器下半部分
const dialogAreaTop = containerHeight / 2 // 从容器中心线开始(下半部分)
while (attempts < maxAttempts) {
// 在蓝色框内随机生成位置
const x = (Math.random() - 0.5) * (containerWidth - characterWidth - 40)
const y = (Math.random() - 0.5) * (containerHeight - characterHeight - 40)
// 检查是否在蛋糕禁区内
const isInCakeZone =
Math.abs(x) < (cakeZoneWidth / 2) &&
Math.abs(y) < (cakeZoneHeight / 2)
if (isInCakeZone) {
attempts++
continue // 在蛋糕禁区内,重新生成
}
// 检查是否超出蓝色框边界
const isOutOfBounds =
Math.abs(x) + (characterWidth / 2) > (containerWidth / 2) ||
Math.abs(y) + (characterHeight / 2) > (containerHeight / 2)
if (isOutOfBounds) {
attempts++
continue // 超出边界,重新生成
}
// 检查是否与现有位置重叠
const isTooClose = existingPositions.some(pos => {
const distance = Math.sqrt(
Math.pow(pos.x - x, 2) + Math.pow(pos.y - y, 2)
)
return distance < minDistance
})
if (!isTooClose) {
// 计算角度(用于判断小三角方向)
const angle = Math.atan2(y, x) * (180 / Math.PI) + 180
// 判断是否在容器下半部分(对话框显示区域)
// y坐标大于0表示在容器下半部分
const isInDialogArea = y > 0
// 判断小三角方向根据x坐标判断
// x < 0 在左侧x >= 0 在右侧
const arrowPosition = x < 0 ? 'left' : 'right'
return { x, y, angle, isInDialogArea, arrowPosition }
}
attempts++
}
// 如果尝试多次仍然失败,返回 null 表示无法生成
return null
}
// 初始化角色图片 - 根据进度动态添加角色
function initCharacters() {
const targetCount = characterCount.value
const currentCount = activeCharacters.value.length
// 如果当前角色数量已经达到目标,不再添加
if (currentCount >= targetCount) return
// 收集现有角色的位置
const existingPositions = activeCharacters.value.map(char => {
// 从 transform 中提取 x, y 坐标
const transform = char.style.transform || ''
const match = transform.match(/translate\((-?\d+(?:\.\d+)?)rpx,\s*(-?\d+(?:\.\d+)?)rpx\)/)
if (match) {
return { x: parseFloat(match[1]), y: parseFloat(match[2]) }
}
return { x: 0, y: 0 }
})
// 只添加需要增加的角色
let addedCount = 0
for (let i = currentCount; i < targetCount && addedCount < 20; i++) {
const timerId = setTimeout(() => {
const randomIndex = Math.floor(Math.random() * CHARACTER_IMAGES.length)
const imageSrc = CHARACTER_IMAGES[randomIndex]
const charId = characterIdCounter++
// 在蓝色框内随机生成位置,避开红色框
const position = generateRandomPosition(existingPositions)
// 如果无法生成有效位置(在红色框内或超出边界或重叠),跳过
if (!position) {
return
}
existingPositions.push(position) // 添加到已有位置列表
activeCharacters.value.push({
id: charId,
imageSrc: imageSrc,
bubbles: [],
arrowPosition: position.arrowPosition, // 使用位置函数返回的方向
isInDialogArea: position.isInDialogArea, // 记录是否在对话框显示区域(容器下半部分)
style: {
left: '50%',
top: '50%',
transform: `translate(${position.x}rpx, ${position.y}rpx) translateX(-50%) translateY(-50%)`,
animation: 'character-appear 0.5s ease-out forwards'
}
})
addedCount++
// 当第一个角色显示后,开始生成气泡
if (activeCharacters.value.length === 1) {
const bubbleTimerId = setTimeout(() => {
startCharacterBubbleGeneration()
}, 500)
characterGenerationTimers.push(bubbleTimerId)
}
}, addedCount * 300) // 每个新角色间隔300ms出现
characterGenerationTimers.push(timerId)
}
}
// 在角色头顶生成气泡
function generateCharacterBubble() {
if (!props.bubbleTexts || props.bubbleTexts.length === 0) return
// 只选择在容器下半部分的角色
const charactersInDialogArea = activeCharacters.value.filter(char => char.isInDialogArea)
if (charactersInDialogArea.length === 0) return
// 计算当前已显示的对话框总数
const currentDialogCount = activeCharacters.value.reduce((count, char) => {
return count + char.bubbles.length
}, 0)
// 如果已经达到最大数量,不再生成新对话框
if (currentDialogCount >= MAX_ACTIVE_DIALOGS) return
// 过滤出没有对话框的角色
const availableCharacters = charactersInDialogArea.filter(char => char.bubbles.length === 0)
if (availableCharacters.length === 0) return
// 获取所有已有对话框的角色位置
const existingBubblePositions = activeCharacters.value
.filter(char => char.bubbles.length > 0)
.map(char => {
const transform = char.style.transform || ''
const match = transform.match(/translate\((-?\d+(?:\.\d+)?)rpx,\s*(-?\d+(?:\.\d+)?)rpx\)/)
if (match) {
return { x: parseFloat(match[1]), y: parseFloat(match[2]) }
}
return null
})
.filter(pos => pos !== null)
// 从可用角色中筛选出对话框不会重叠的角色
const nonOverlappingCharacters = availableCharacters.filter(char => {
const transform = char.style.transform || ''
const match = transform.match(/translate\((-?\d+(?:\.\d+)?)rpx,\s*(-?\d+(?:\.\d+)?)rpx\)/)
if (!match) return true
const charX = parseFloat(match[1])
const charY = parseFloat(match[2])
// 检查是否与现有对话框位置重叠(对话框在角色上方)
const minDistance = 150 // rpx对话框之间的最小距离
const isOverlapping = existingBubblePositions.some(pos => {
const distance = Math.sqrt(
Math.pow(pos.x - charX, 2) + Math.pow(pos.y - charY, 2)
)
return distance < minDistance
})
return !isOverlapping
})
if (nonOverlappingCharacters.length === 0) return
// 随机选择一个不会重叠的角色
const charIndex = Math.floor(Math.random() * nonOverlappingCharacters.length)
const character = nonOverlappingCharacters[charIndex]
// 随机选择气泡文字
const text = props.bubbleTexts[Math.floor(Math.random() * props.bubbleTexts.length)]
const bubbleId = bubbleIdCounter++
const bubble = {
id: bubbleId,
text,
showSupport: Math.random() > 0.5, // 50%概率显示"(点击助力)"
style: {}
}
// 添加到角色头顶(只保留一个)
character.bubbles = [bubble]
// 30秒后移除对话框
const removalTimerId = setTimeout(() => {
const index = character.bubbles.findIndex(b => b.id === bubbleId)
if (index > -1) {
character.bubbles.splice(index, 1)
}
// 从跟踪列表中移除
const timerIndex = bubbleRemovalTimers.indexOf(removalTimerId)
if (timerIndex > -1) {
bubbleRemovalTimers.splice(timerIndex, 1)
}
}, 30000) // 30秒
bubbleRemovalTimers.push(removalTimerId)
}
// 启动角色头顶气泡生成
function startCharacterBubbleGeneration() {
if (characterBubbleTimer) return
// 初始生成1-2个气泡
const initialCount = Math.floor(Math.random() * 2) + 1
for (let i = 0; i < initialCount; i++) {
const timerId = setTimeout(() => {
generateCharacterBubble()
}, i * 500)
characterGenerationTimers.push(timerId)
}
// 每隔随机时间生成新对话框保持最多4个在线
characterBubbleTimer = setInterval(() => {
if (props.isActive) {
generateCharacterBubble()
}
}, Math.random() * 5000 + 5000) // 5-10秒随机间隔
}
// 停止角色头顶气泡生成
function stopCharacterBubbleGeneration() {
if (characterBubbleTimer) {
clearInterval(characterBubbleTimer)
characterBubbleTimer = null
}
}
// 监听角色数量变化 - 根据进度实时添加角色
watch(characterCount, (newCount, oldCount) => {
if (newCount > activeCharacters.value.length) {
// 需要添加更多角色
initCharacters()
}
}, { immediate: true })
// 监听 isActive 变化 - 合并重复的 watcher
watch(() => props.isActive, (newVal) => {
if (newVal) {
// 初始化角色(如果还没有)
if (activeCharacters.value.length === 0) {
initCharacters()
}
// 启动气泡生成
startCharacterBubbleGeneration()
} else {
// 停止气泡生成
stopCharacterBubbleGeneration()
}
}, { immediate: true })
// 初始化时缓存视口宽度
cachedViewportWidth = screenCache.getWindowWidth()
onUnmounted(() => {
stopCharacterBubbleGeneration()
clearAllBubbleTimers()
clearAllCharacterGenerationTimers()
clearAllBubbleRemovalTimers()
})
/**
* 清除所有气泡定时器(防止内存泄漏)
*/
function clearAllBubbleTimers() {
// 清除角色气泡定时器
activeCharacters.value.forEach(char => {
char.bubbles = []
})
}
/**
* 清除所有角色生成定时器
*/
function clearAllCharacterGenerationTimers() {
characterGenerationTimers.forEach(timerId => {
clearTimeout(timerId)
})
characterGenerationTimers = []
}
/**
* 清除所有气泡移除定时器
*/
function clearAllBubbleRemovalTimers() {
bubbleRemovalTimers.forEach(timerId => {
clearTimeout(timerId)
})
bubbleRemovalTimers = []
}
</script>
<style scoped>
.floating-bubbles {
position: fixed;
top: 31%;
left: 0;
right: 0;
height: 48%;
pointer-events: none;
z-index: 100;
overflow: visible;
}
/* 角色图片层 - 定位在蛋糕区域周围 */
.character-layer {
position: absolute;
top: 65%;
left: 50%;
transform: translate(-50%, -50%);
width: 728rpx;
height: 536rpx;
overflow: visible;
z-index: 1;
}
/* 对话框层 - 独立层,始终在角色层上方 */
.bubble-layer {
position: absolute;
top: 65%;
left: 50%;
transform: translate(-50%, -50%);
width: 728rpx;
height: 536rpx;
overflow: visible;
z-index: 100;
pointer-events: none;
}
.bubble-wrapper {
position: absolute;
width: 120rpx;
height: 200rpx;
}
.character-wrapper {
position: absolute;
width: 120rpx;
height: 200rpx;
z-index: 1;
}
.character-img {
width: 100%;
height: 100%;
position: relative;
z-index: 1;
}
/* 角色头顶对话框容器 */
.character-bubble-wrapper {
position: absolute;
bottom: 76%;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
z-index: 1000;
}
/* 对话框样式 */
.dialog-bubble {
position: relative;
padding: 16rpx 24rpx;
background: rgba(0, 0, 0, 0.5);
border-radius: 16rpx;
animation: dialog-appear 0.3s ease-out forwards;
white-space: nowrap;
max-width: 280rpx;
display: flex;
flex-direction: column;
z-index: 1001;
}
/* 对话框文本容器 - 上下布局 */
.bubble-text-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
/* 对话框小三角 - 默认在底部中心 */
.dialog-arrow {
position: absolute;
bottom: -10rpx;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 12rpx solid transparent;
border-right: 12rpx solid transparent;
border-top: 12rpx solid rgba(0, 0, 0, 0.5);
}
/* 左侧角色 - 对话框向右偏移,小三角在左边对准角色头部 */
.dialog-bubble.arrow-left {
animation: dialog-appear-left 0.3s ease-out forwards;
}
.dialog-bubble.arrow-left .dialog-arrow {
left: 10rpx;
right: auto;
transform: none;
}
/* 右侧角色 - 对话框向左偏移,小三角在右边对准角色头部 */
.dialog-bubble.arrow-right {
animation: dialog-appear-right 0.3s ease-out forwards;
}
.dialog-bubble.arrow-right .dialog-arrow {
left: auto;
right: 10rpx;
transform: none;
}
.bubble-text {
font-size: 17rpx;
color: #fff;
text-shadow:
0 3rpx 6rpx rgba(0, 0, 0, 0.4),
0 1rpx 3rpx rgba(0, 0, 0, 0.3);
white-space: nowrap;
font-family: 'ZaoZiGongFangJianHei-1';
text-align: center;
}
.bubble-text-zl {
font-size: 17rpx;
color: #FFA500;
white-space: nowrap;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-align: center;
margin-top: 4rpx;
}
/* 对话框出现动画 - 左侧角色(向右偏移) */
@keyframes dialog-appear-left {
0% {
transform: translateX(80rpx) scale(0.8) translateY(10rpx);
opacity: 0;
}
100% {
transform: translateX(48rpx) scale(1) translateY(0);
opacity: 1;
}
}
/* 对话框出现动画 - 右侧角色(向左偏移) */
@keyframes dialog-appear-right {
0% {
transform: translateX(-80rpx) scale(0.8) translateY(10rpx);
opacity: 0;
}
100% {
transform: translateX(-48rpx) scale(1) translateY(0);
opacity: 1;
}
}
/* 对话框出现动画 - 默认(保留以防万一) */
@keyframes dialog-appear {
0% {
transform: scale(0.8) translateY(10rpx);
opacity: 0;
}
100% {
transform: scale(1) translateY(0);
opacity: 1;
}
}
/* 对话框淡出动画 */
@keyframes dialog-fade {
0%, 70% {
opacity: 1;
}
100% {
opacity: 0;
}
}
/* 角色出现动画 */
@keyframes character-appear {
0% {
transform: translate(var(--char-x, 0), var(--char-y, 0)) translateX(-50%) translateY(-50%) scale(0);
opacity: 0;
}
100% {
transform: translate(var(--char-x, 0), var(--char-y, 0)) translateX(-50%) translateY(-50%) scale(1);
opacity: 1;
}
}
</style>