uniapp小程序实现弹幕不重叠
uniapp小程序实现弹幕不重叠
1、在父组件中引入弹幕组件
<template>
<!-- 弹幕 -->
<barrage ref="barrage" class="barrage-content" @reloadDanmu="reloadDanmu"></barrage>
</template>
<script>
import barrage from './components/barrage.vue'
import {
getBarrageListApi
} from '@/api/voteApi.js'
export default {
components: {
barrage
},
data() {
return {
danmuList: [], // 弹幕列表
danmuContion: { // 弹幕查询条件
page: 1,
size: 200
}
},
onLoad(){
this.getBarrageList()
},
methods: {
async getBarrageList(isInit) {
try {
let res = await getBarrageListApi(this.danmuContion)
let resData = (res && res.data) || {}
let list = Array.isArray(resData.records) ? resData.records : []
list.map((item) => {
item.color = '#fff'
item.timestampt = new Date().getTime()
item.image = {
head: {
src: item.avatarUrl,
width: 44,
height: 44
}, // 弹幕头部添加图片
gap: 8 // 图片与文本间隔
}
item.content = `{${item.nickname}} 已为《${item.voteName}》投下宝贵的一票`
})
let danmuLength = this.danmuList.length
this.danmuList = list
this.addBarrage(isInit || danmuLength === 0)
} catch (e) {
uni.showToast({
title: (e && e.message) || '查询弹幕列表失败',
icon: 'none',
during: 2000
})
}
},
addBarrage(isInit) {
if (!isInit || !this.danmuList.length) {
return
}
const barrageComp = this.$refs && this.$refs.barrage || {}
barrageComp.getBarrageInstance({
duration: 15, // 弹幕动画时长 (移动 1500px 所需时长)
lineHeight: 2.4, // 弹幕行高
padding: [0, 0, 0, 0], // 弹幕区四周留白
alpha: 1, // 全局透明度
font: '10px PingFang SC', // 全局字体
range: [0, 1], // 弹幕显示的垂直范围,支持两个值。[0,1]表示弹幕整个随机分布,
tunnelShow: false, // 显示轨道线
tunnelMaxNum: 200, // 隧道最大缓冲长度
maxLength: 5000, // 弹幕最大字节长度,汉字算双字节
safeGap: 20, // 发送时的安全间隔
enableTap: false, // 点击弹幕停止动画高亮显示
danmuList: this.danmuList
})
},
async reloadDanmu(type) {
const barrageComp = this.$refs && this.$refs.barrage || {}
if(type === 'addDanmu') {
await this.getBarrageList(false)
barrageComp.open()
barrageComp.addData(this.danmuList)
return
}
await this.getBarrageList(true)
},
}
</script>
<style lang="less" scoped>
.barrage-conten {
width: 100%;
height: 156rpx;
position: absolute;
top: 192rpx;
box-sizing: border-box;
}
</style>
2、弹幕组件
<template>
<view class="barrage-area" :style="{'opacity': alpha, 'font-size': fontSize*2 + 'rpx', 'padding': padding}">
<block v-for="(tunnel, tunnelId) in tunnels" :key="tunnelId">
<view class="barrage-tunnel"
:style="{'height': tunnel.height*2 + 'rpx', 'border-top-width': (tunnelShow ? 1 : 0) + 'px'}">
<view class="tunnel-tips" :style="{'display': !tunnelShow ? 'none' : 'block'}">轨道{{tunnelId}}</view>
<block v-for="(bullet, bulletId) in tunnel.bullets" :key="bullet.timestampt + bulletId">
<view :data-tunnelid="{tunnelId}" :data-bulletid="{bulletId}"
:class="['bullet-item', bullet.duration > 0 ? 'bullet-move' : '', bullet.paused ? 'paused' : '']"
:style="{'color': bullet.paused ? '#fff' : bullet.color, 'line-height': tunnel.height*2 + 'rpx', 'animation-duration': bullet.duration + 's', 'animation-play-state': bullet.paused ? 'paused' : 'running'}"
@animationend="onAnimationend" @tap="onTapBullet">
<image class="bullet-item_img" v-if="bullet.image && bullet.image.head"
:style="{'width': bullet.image.head.width + 'rpx', 'height': bullet.image.head.height + 'rpx'}"
mode="aspectFill" :src="bullet.image.head.src"></image>
<view class="bullet-item_text"
:style="{'margin':'0 ' + (bullet.image && bullet.image.gap || 0) + 'rpx', opacity: 1}">
<text>{{bullet.content}}</text>
</view>
</view>
</block>
</view>
</block>
</view>
</template>
<script>
export default {
data() {
return {
fontSize: 10, // 字体大小,单位px
width: 375, // 弹幕区域宽度
height: 80, // 弹幕区域高度
duration: 15, // 弹幕动画时长
lineHeight: 3, // 弹幕行高
padding: [0, 0, 0, 0], // 弹幕区四周留白
alpha: 1, // 全局透明度
font: '10px PingFang SC', // 全局字体
range: [0, 1], // 弹幕显示的垂直范围,支持两个值。[0,1]表示弹幕整个随机分布,
tunnelShow: false, // 显示轨道线
tunnelMaxNum: 200, // 轨道最大缓冲长度
maxLength: 5000, // 弹幕最大字节长度,汉字算双字节
safeGap: 20, // 发送时的安全间隔
enableTap: false, // 点击弹幕停止动画高亮显示
tunnelHeight: 0,
tunnelNum: 0,
tunnels: [],
idleTunnels: null,
enableTunnels: {},
distance: 1500, // 移动距离, 单位px
systemInfo: {},
danmuList: []
};
},
methods: {
init() {
this.fontSize = this.getFontSize(this.font)
this.idleTunnels = new Set()
this.enableTunnels = new Set()
this.tunnels = []
this.availableHeight = (this.height - this.padding[0] - this.padding[2])
this.tunnelHeight = this.fontSize * this.lineHeight
// 轨道行数 = 弹幕区域高度/(单个弹幕高度+下边距)
this.tunnelNum = Math.floor(this.availableHeight / (this.tunnelHeight + 15))
// tunnel(轨道)
class Tunnel {
constructor(opt = {}) {
const defaultTunnelOpt = {
tunnelId: 0,
height: 0, // 轨道高度
width: 0, // 轨道宽度
safeGap: 4, // 相邻弹幕安全间隔
maxNum: 10, // 缓冲队列长度
bullets: [], // 弹幕
last: -1, // 上一条发送的弹幕序号
bulletStatus: [], // 0 空闲,1 占用中
disabled: false, // 禁用中
sending: false, // 弹幕正在发送
}
Object.assign(this, defaultTunnelOpt, opt)
this.bulletStatus = new Array(this.maxNum).fill(0)
class Bullet {
constructor(opt = {}) {
this.bulletId = opt.bulletId
}
/**
* image 结构
* {
* head: {src, width, height},
* gap: 4 // 图片与文本间隔
* }
*/
addContent(opt = {}) {
const defaultBulletOpt = {
duration: 0, // 动画时长
passtime: 0, // 弹幕穿越右边界耗时
content: '', // 文本
color: '#000000', // 默认黑色
width: 0, // 弹幕宽度
height: 0, // 弹幕高度
image: {}, // 图片
paused: false // 是否暂停
}
Object.assign(this, defaultBulletOpt, opt)
}
removeContent() {
this.addContent({})
}
}
for (let i = 0; i < this.maxNum; i++) {
this.bullets.push(new Bullet({
bulletId: i,
}))
}
}
disable() {
this.disabled = true
this.last = -1
this.sending = false
this.bulletStatus = new Array(this.maxNum).fill(1)
this.bullets.forEach(bullet => bullet.removeContent())
}
enable() {
if (this.disabled) {
this.bulletStatus = new Array(this.maxNum).fill(0)
}
this.disabled = false
}
clear() {
this.last = -1
this.sending = false
this.bulletStatus = new Array(this.maxNum).fill(0)
this.bullets.forEach(bullet => bullet.removeContent())
}
getIdleBulletIdx() {
return this.bulletStatus.indexOf(0)
}
getIdleBulletNum() {
let count = 0
this.bulletStatus.forEach(status => {
if (status === 0) count++
})
return count
}
addBullet(opt) {
if (this.disabled) return
const idx = this.getIdleBulletIdx()
if (idx >= 0) {
this.bulletStatus[idx] = 1
this.bullets[idx].addContent(opt)
}
}
removeBullet(bulletId) {
if (this.disabled) return
this.bulletStatus[bulletId] = 0
const bullet = this.bullets[bulletId]
bullet.removeContent()
}
}
for (let i = 0; i < this.tunnelNum; i++) {
this.idleTunnels.add(i) // 空闲的轨道id集合
this.enableTunnels.add(i) // 可用的轨道id集合
this.tunnels.push(new Tunnel({ // 轨道集合
width: this.width,
height: this.tunnelHeight,
safeGap: this.safeGap,
maxNum: this.tunnelMaxNum,
tunnelId: i,
}))
}
// 筛选符合范围的轨道
this.setRange()
},
resize() {
const query = uni.createSelectorQuery().in(this)
query.select('.barrage-area').boundingClientRect((res) => {
res = res || {}
let systemInfo = uni.getSystemInfoSync()
this.systemInfo = systemInfo || {}
this.width = res.width || systemInfo.windowWidth
this.height = res.height || 300
this.last = -1
this.$emit('reloadDanmu')
}).exec()
},
// 设置显示范围 range: [0,1]
setRange(range) {
range = range || this.range
const top = range[0] * this.tunnelNum
const bottom = range[1] * this.tunnelNum
// 释放符合要求的轨道
// 找到目前空闲的轨道
const idleTunnels = new Set()
const enableTunnels = new Set()
this.tunnels.forEach((tunnel, tunnelId) => {
if (tunnelId >= top && tunnelId < bottom) {
const disabled = tunnel.disabled
tunnel.enable()
enableTunnels.add(tunnelId)
if (disabled || this.idleTunnels.has(tunnelId)) {
idleTunnels.add(tunnelId)
}
} else {
tunnel.disable()
}
})
this.idleTunnels = idleTunnels
this.enableTunnels = enableTunnels
this.range = range
},
setFont(font) {
this.font = font
},
setAlpha(alpha) {
if (typeof alpha !== 'number') return
this.alpha = alpha
},
setDuration(duration) {
if (typeof duration !== 'number') return
this.duration = duration
this.clear()
},
// 开启弹幕
open() {
this._isActive = true
},
// 关闭弹幕,清除所有数据
close(cb) {
this._isActive = false
this.clear(cb)
},
clear(cb) {
this.tunnels.forEach(tunnel => tunnel.clear())
this.idleTunnels = new Set(this.enableTunnels)
if (typeof cb === 'function') {
cb()
}
},
// 添加一批弹幕,轨道满时会被丢弃
addData(data = []) {
if (!this._isActive || !data || !data.length) return
data.forEach((item, index) => {
item.timestampt = new Date().getTime()
item.content = item.content || ''
item.content = this.substring(item.content, this.maxLength)
if (!item.width) {
// 一个弹幕总长度=头像(包含边框)+文本+内边距+外边距
item.width = (44 + 4) + item.content.length * this.fontSize * 2 + (8 + 20) + 60
item.width = Math.ceil(((this.systemInfo.windowWidth || 375) / 375) * (item.width / 2))
}
this.addBullet2Tunnel(item, index)
})
// 更新弹幕
this.updateBullets()
},
// 添加至轨道
addBullet2Tunnel(opt = {}, index) {
const tunnel = this.getIdleTunnel(index)
if (tunnel === null) return
const tunnelId = tunnel.tunnelId
tunnel.addBullet(opt)
if (tunnel.getIdleBulletNum() === 0) {
this.idleTunnels.delete(tunnelId)
}
},
updateBullets() {
if (!this.tunnels || !this.tunnels.length) {
return
}
this.tunnels.map((a) => {
a.batchTime = 0 // 通过一批弹幕花费(即一次addData添加的所有弹幕)的时间
a.lastBulletIndex = a.lastBulletIndex >= 0 ? a.lastBulletIndex : -1 // 轨道最后通过的弹幕下标
a.bullets && a.bullets.map((b, bIndex) => {
if ((a.lastBulletIndex === -1 || bIndex > a.lastBulletIndex) && b.content) {
a.lastBulletIndex = bIndex
const duration = this.distance * this.duration / (this.distance + b.width)
const passDistance = b.width + a.safeGap
// 等上一条通过右边界
b.passtime1 = Math.ceil(passDistance * this.duration * 1000 / this.distance)
a.batchTime += b.passtime1
}
})
this.tunnelAnimate(a)
})
let list = JSON.parse(JSON.stringify(this.tunnels))
list.sort((a, b) => {
return b.batchTime - a.batchTime
})
let lastBullet = list[0].bullets[list[0].lastBulletIndex]
// 最后一条弹幕通过屏幕的时间
let lastPassTime = list[0].batchTime + Math.ceil((this.width) * this.duration * 1000 / this.distance)
console.log('最后一条弹幕通过屏幕的时间:', lastPassTime)
let reloadDanmuTimer = setTimeout(() => {
// 轨道已满,重置轨道并重新加载弹幕
if (!this.idleTunnels || this.idleTunnels.size === 0) {
this.last = -1
this.$emit('reloadDanmu')
} else {
this.$emit('reloadDanmu', 'addDanmu')
}
clearTimeout(reloadDanmuTimer)
}, lastPassTime)
},
tunnelAnimate(tunnel) {
if (tunnel.disabled || tunnel.sending) return
const next = (tunnel.last + 1) % tunnel.maxNum
const bullet = tunnel.bullets[next]
if (!bullet) return
if (bullet.content || bullet.image) {
tunnel.sending = true
tunnel.last = next
const duration = this.distance * this.duration / (this.distance + bullet.width)
const passDistance = bullet.width + tunnel.safeGap
bullet.duration = this.duration
// 等上一条通过右边界
bullet.passtime = Math.ceil(passDistance * bullet.duration * 1000 / this.distance)
let sendTimer = setTimeout(() => {
tunnel.sending = false
this.tunnelAnimate(tunnel)
clearTimeout(sendTimer)
}, bullet.passtime)
}
},
// 从还有余量的轨道中随机挑选一个
getIdleTunnel(addIndex) {
if (!this.idleTunnels || this.idleTunnels.size === 0) return null
const idleTunnels = Array.from(this.idleTunnels)
let index = -1
if (this.tunnelNum == 2 && (addIndex || addIndex === 0)) { // 只有两个轨道的情况下,优先手动分发轨道
index = addIndex % 2 === 0 ? 0 : 1
}
if (index === -1 || (!idleTunnels[index] && idleTunnels[index] !== 0)) { // 随机选轨道
index = this.getRandom(idleTunnels.length)
}
return this.tunnels[idleTunnels[index]]
},
animationend(opt) {
const {
tunnelId,
bulletId
} = opt
const tunnel = this.tunnels[tunnelId]
const bullet = tunnel && tunnel.bullets && tunnel.bullets[bulletId]
if (!tunnel || !bullet) return
tunnel.removeBullet(bulletId)
this.idleTunnels.add(tunnelId)
},
tapBullet(opt) {
if (!this.enableTap) return
const {
tunnelId,
bulletId
} = opt
const tunnel = this.tunnels[tunnelId]
const bullet = tunnel.bullets[bulletId]
bullet.paused = !bullet.paused
},
// 初始化弹幕组件数据
getBarrageInstance(opt) {
for (let key in opt) {
this[key] = opt[key]
}
const query = uni.createSelectorQuery().in(this)
query.select('.barrage-area').boundingClientRect((res) => {
res = res || {}
let systemInfo = uni.getSystemInfoSync()
this.systemInfo = systemInfo || {}
this.width = res.width || systemInfo.windowWidth
this.height = res.height || 80
this.init()
this.open()
this.addData(this.danmuList)
}).exec()
},
onAnimationend(e) {
const {
tunnelid,
bulletid
} = e.currentTarget.dataset
this.animationend({
tunnelId: tunnelid,
bulletId: bulletid
})
},
onTapBullet(e) {
const {
tunnelid,
bulletid
} = e.currentTarget.dataset
this.tapBullet({
tunnelId: tunnelid,
bulletId: bulletid
})
},
// 获取字节长度,中文算2个字节
getStrLen(str) {
// eslint-disable-next-line no-control-regex
return str.replace(/[^\x00-\xff]/g, 'aa').length
},
// 截取指定字节长度的子串
substring(str, n) {
if (!str) return ''
const len = this.getStrLen(str)
if (n >= len) return str
let l = 0
let result = ''
for (let i = 0; i < str.length; i++) {
const ch = str.charAt(i)
// eslint-disable-next-line no-control-regex
l = /[^\x00-\xff]/i.test(ch) ? l + 2 : l + 1
result += ch
if (l >= n) break
}
return result
},
getRandom(max = 10, min = 0) {
return Math.floor(Math.random() * (max - min) + min)
},
getFontSize(font) {
const reg = /(\d+)(px)/i
const match = font.match(reg)
return (match && match[1]) || 10
},
}
}
</script>
<style scoped>
.barrage-area {
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
z-index: 2;
pointer-events: auto;
overflow-x: hidden;
}
.barrage-tunnel {
box-sizing: border-box;
position: relative;
display: flex;
align-items: center;
border-top: 1px solid #CCB24D;
width: 100%;
margin-bottom: 30rpx;
}
.tunnel-tips {
display: inline-block;
margin-left: 60px;
}
.bullet-item {
position: absolute;
display: flex;
align-items: center;
top: 0;
left: 100%;
white-space: nowrap;
background: rgba(0, 0, 0, 0.3);
border-radius: 80rpx;
padding: 0 20rpx 0 0;
}
.bullet-item.paused {
background: #000;
opacity: 0.6;
padding: 0 10px;
z-index: 2;
}
.bullet-item_img {
max-height: 100%;
border-radius: 50%;
border: 2px solid #FFFFFF;
}
.bullet-item_text {
display: inline-block;
margin: 0;
}
.bullet-move {
animation: 0s linear slidein
}
@keyframes slidein {
0% {
transform: translate3d(0, 0, 0)
}
100% {
transform: translate3d(-1500px, 0, 0)
}
}
</style>