639 lines
17 KiB
Vue
639 lines
17 KiB
Vue
<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> |