前端弹幕系统

478 阅读18分钟

一个功能相对完善的前端弹幕系统。这个系统将支持半屏、全屏、1/4屏显示模式,以及不同颜色的滚动弹幕和固定位置弹幕(可定时消失或常驻)。

核心思路:

  1. HTML 结构: 包含视频(或占位符)、弹幕渲染区域、控制按钮(模式切换)、输入区域。

  2. CSS 样式: 定义容器、弹幕层、不同模式下的样式、弹幕自身样式(颜色、字体、动画)、固定弹幕位置。

  3. JavaScript 逻辑:

    • 弹幕类 (Barrage Class): 封装单条弹幕的数据(文本、颜色、类型、速度、位置)和行为(移动、渲染、判断是否移出屏幕)。
    • 弹幕管理器 (Barrage Manager): 负责接收新弹幕、管理弹幕轨道(避免严重重叠)、控制弹幕的创建、动画循环、销毁、模式切换、处理固定弹幕的计时。
    • 动画循环: 使用 requestAnimationFrame 实现流畅的弹幕移动。
    • 事件处理: 处理发送按钮点击、模式切换按钮点击。

完整 HTML 文件 (包含 CSS 和 JavaScript)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>功能完善的弹幕系统</title>
    <style>
        /* CSS 样式 */
        body {
            font-family: sans-serif;
            margin: 0;
            padding: 0;
            background-color: #f0f0f0;
            display: flex;
            flex-direction: column;
            align-items: center;
            padding-top: 20px;
        }

        /* 弹幕容器总父级 */
        .barrage-container-wrapper {
            width: 80%; /* 或者根据需要设置更具体的宽度 */
            max-width: 960px; /* 最大宽度 */
            margin-bottom: 20px;
            /* 为了演示方便,给个背景色 */
            background-color: #eee;
            padding: 10px;
            border-radius: 5px;
            box-shadow: 0 2px 5px rgba(0,0,0,0.1);
        }

        /* 弹幕容器,相对定位是关键,作为弹幕项绝对定位的基准 */
        .barrage-container {
            position: relative;
            width: 100%;
            /* 高度由视频或内容决定,这里用aspect-ratio模拟 */
            aspect-ratio: 16 / 9;
            background-color: #333; /* 模拟视频区域背景 */
            overflow: hidden; /* 隐藏超出容器的弹幕 */
            border-radius: 3px;
        }

        /* 视频占位符 (可以用实际 <video> 替换) */
        .video-placeholder {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            display: flex;
            justify-content: center;
            align-items: center;
            color: #fff;
            font-size: 24px;
            background-color: #222; /* 深一点的背景 */
            z-index: 1; /* 在弹幕层下方 */
        }

        /* 弹幕渲染层,覆盖在视频/内容之上 */
        .barrage-overlay {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%; /* 默认全屏 */
            z-index: 10; /* 确保在视频之上 */
            pointer-events: none; /* 允许点击穿透到视频播放器 */
            overflow: hidden; /* 再次确认隐藏溢出 */
            transition: height 0.3s ease, top 0.3s ease; /* 为模式切换添加过渡动画 */
        }

        /* --- 屏幕模式 --- */
        .barrage-overlay.full-screen {
            top: 0;
            height: 100%;
        }

        .barrage-overlay.half-screen {
            top: 0;
            height: 50%; /* 半屏 */
        }

        .barrage-overlay.quarter-screen {
            top: 0;
            height: 25%; /* 1/4屏 */
        }

        /* --- 弹幕项基础样式 --- */
        .barrage-item {
            position: absolute; /* 绝对定位,相对于 .barrage-overlay */
            white-space: nowrap; /* 防止弹幕文字换行 */
            color: #fff; /* 默认白色 */
            font-size: 20px; /* 默认字号,可调整 */
            font-weight: bold;
            text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7); /* 文字描边,增强可读性 */
            will-change: transform; /* 性能优化:提示浏览器该元素会变化 */
            padding: 2px 5px;
            border-radius: 3px;
            background-color: rgba(0, 0, 0, 0.2); /* 轻微背景,可选 */
            /* 默认从右侧进入 */
            transform: translateX(0); /* JS会更新这个值 */
            left: 100%; /* 起始位置在容器右侧外部 */
        }

        /* --- 滚动弹幕特定样式 --- */
        .barrage-item.scroll {
            /* 速度由JS控制 */
        }

        /* --- 固定弹幕特定样式 --- */
        .barrage-item.fixed-top,
        .barrage-item.fixed-bottom {
            /* 固定弹幕不从右侧进入,直接定位 */
            left: 50%;
            transform: translateX(-50%); /* 水平居中 */
            text-align: center;
        }

        .barrage-item.fixed-top {
            top: 10px; /* 距离顶部距离 */
        }

        .barrage-item.fixed-bottom {
            bottom: 10px; /* 距离底部距离 */
            /* 注意:如果 .barrage-overlay 高度变化(如半屏),bottom 依然相对于 overlay 的底部 */
        }

        /* --- 控制区域 --- */
        .barrage-controls,
        .barrage-input-area {
            margin-top: 15px;
            display: flex;
            gap: 10px; /* 元素间距 */
            justify-content: center;
            align-items: center;
            flex-wrap: wrap; /* 允许换行 */
        }

        .barrage-controls button,
        .barrage-input-area button {
            padding: 8px 15px;
            font-size: 14px;
            cursor: pointer;
            border: none;
            border-radius: 4px;
            background-color: #007bff;
            color: white;
            transition: background-color 0.2s ease;
        }
        .barrage-controls button:hover,
        .barrage-input-area button:hover {
            background-color: #0056b3;
        }
        .barrage-controls button.active {
            background-color: #28a745; /* 激活状态 */
        }


        .barrage-input-area input[type="text"] {
            padding: 8px;
            font-size: 14px;
            border: 1px solid #ccc;
            border-radius: 4px;
            flex-grow: 1; /* 占据多余空间 */
            min-width: 150px;
        }

        .barrage-input-area input[type="color"] {
            padding: 0;
            border: none;
            width: 40px;
            height: 36px; /* 与按钮高度接近 */
            cursor: pointer;
            vertical-align: middle; /* 对齐 */
        }

        .barrage-input-area select {
             padding: 8px;
             font-size: 14px;
             border: 1px solid #ccc;
             border-radius: 4px;
             height: 36px; /* 与按钮高度接近 */
        }

        /* 响应式调整 - 简单示例 */
        @media (max-width: 768px) {
            .barrage-container-wrapper {
                width: 95%;
            }
            .barrage-item {
                font-size: 16px; /* 移动端适当减小字号 */
            }
            .barrage-controls,
            .barrage-input-area {
                flex-direction: column; /* 窄屏时垂直排列 */
                align-items: stretch; /* 拉伸以填充宽度 */
            }
            .barrage-input-area input[type="text"] {
                 min-width: unset; /* 移除最小宽度限制 */
                 width: 100%; /* 占据全部宽度 */
                 box-sizing: border-box; /* 防止 padding 导致溢出 */
            }
             .barrage-input-area > * { /* 让输入区域所有子元素宽度一致 */
                width: 100%;
                box-sizing: border-box;
                margin-bottom: 5px; /* 添加一些间距 */
            }
            .barrage-input-area input[type="color"]{
                 width: 100%; /* 颜色选择器也撑满 */
            }
        }

    </style>
</head>
<body>

    <div class="barrage-container-wrapper">
        <!-- 弹幕容器 -->
        <div class="barrage-container">
            <!-- 视频占位符 -->
            <div class="video-placeholder">模拟视频区域</div>
            <!-- 弹幕渲染层 -->
            <div class="barrage-overlay full-screen" id="barrage-overlay">
                <!-- 弹幕将动态添加到这里 -->
            </div>
        </div>

        <!-- 控制按钮 -->
        <div class="barrage-controls">
            <span>显示模式:</span>
            <button id="mode-full" class="active" data-mode="full-screen">全屏</button>
            <button id="mode-half" data-mode="half-screen">半屏</button>
            <button id="mode-quarter" data-mode="quarter-screen">1/4屏</button>
            <button id="btn-pause">暂停弹幕</button>
            <button id="btn-resume" style="display: none;">恢复弹幕</button>
            <button id="btn-clear">清空弹幕</button>
        </div>

        <!-- 输入区域 -->
        <div class="barrage-input-area">
            <input type="text" id="barrage-text" placeholder="输入弹幕内容...">
            <input type="color" id="barrage-color" value="#FFFFFF" title="选择颜色">
            <select id="barrage-type">
                <option value="scroll" selected>滚动</option>
                <option value="top">顶部固定</option>
                <option value="bottom">底部固定</option>
            </select>
            <input type="number" id="barrage-duration" placeholder="固定时长(秒, 0=常驻)" min="0" style="width: 150px;">
            <button id="send-barrage">发送弹幕</button>
        </div>
    </div>

    <script>
        // JavaScript 逻辑
        (function() {
            "use strict"; // 启用严格模式

            // --- 配置项 ---
            const CONFIG = {
                TRACK_HEIGHT: 30,       // 每条弹幕轨道的高度(用于计算轨道数量)
                DEFAULT_SPEED: 2,       // 滚动弹幕默认速度 (像素/帧)
                MAX_SPEED_OFFSET: 1,    // 滚动弹幕速度随机偏移量上限
                MIN_SPEED_OFFSET: -0.5, // 滚动弹幕速度随机偏移量下限
                FIXED_DURATION: 5000,   // 固定弹幕默认显示时长 (毫秒)
                BUFFER_TIME: 3000,      // 轨道预留时间 (毫秒),防止弹幕刚出现就重叠
                FONT_SIZE: 20,          // 基础字号,应与CSS同步或动态获取
            };

            // --- DOM 元素获取 ---
            const barrageOverlay = document.getElementById('barrage-overlay');
            const barrageInput = document.getElementById('barrage-text');
            const colorInput = document.getElementById('barrage-color');
            const typeSelect = document.getElementById('barrage-type');
            const durationInput = document.getElementById('barrage-duration');
            const sendButton = document.getElementById('send-barrage');
            const modeButtons = document.querySelectorAll('.barrage-controls button[data-mode]');
            const pauseButton = document.getElementById('btn-pause');
            const resumeButton = document.getElementById('btn-resume');
            const clearButton = document.getElementById('btn-clear');

            // --- 状态变量 ---
            let isPaused = false;
            let animationFrameId = null;
            let activeBarrages = []; // 存储当前屏幕上所有弹幕对象
            let tracks = [];         // 存储滚动弹幕轨道信息
            let overlayWidth = barrageOverlay.offsetWidth;
            let overlayHeight = barrageOverlay.offsetHeight;
            let trackCount = Math.floor(overlayHeight / CONFIG.TRACK_HEIGHT);

            // --- 弹幕类 (Barrage Class) ---
            class Barrage {
                constructor(text, options = {}) {
                    this.text = text;
                    this.color = options.color || '#FFFFFF';
                    this.type = options.type || 'scroll'; // 'scroll', 'top', 'bottom'
                    this.duration = options.duration; // 固定弹幕的显示时长(ms), undefined 表示常驻或滚动
                    this.id = `barrage-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; // 唯一ID

                    this.el = this.createElement(); // 创建DOM元素

                    if (this.type === 'scroll') {
                        this.speed = options.speed || (CONFIG.DEFAULT_SPEED + Math.random() * (CONFIG.MAX_SPEED_OFFSET - CONFIG.MIN_SPEED_OFFSET) + CONFIG.MIN_SPEED_OFFSET);
                        this.track = options.track; // 分配的轨道号
                        this.yPos = this.track * CONFIG.TRACK_HEIGHT + Math.random() * (CONFIG.TRACK_HEIGHT - CONFIG.FONT_SIZE); // 在轨道内随机垂直位置
                        this.xPos = overlayWidth; // 初始X坐标在屏幕右侧外
                        this.el.style.top = `${Math.max(0, Math.min(this.yPos, overlayHeight - CONFIG.FONT_SIZE))}px`; // 限制在可视区
                        this.el.style.left = '100%'; // CSS left 控制初始位置
                        this.el.style.transform = `translateX(0px)`; // JS通过transform移动
                    } else {
                        // 固定弹幕
                        this.el.classList.add(this.type === 'top' ? 'fixed-top' : 'fixed-bottom');
                        // 位置由CSS控制 (left: 50%, transform: translateX(-50%), top/bottom)
                        if (typeof this.duration === 'number' && this.duration > 0) {
                            this.timeoutId = setTimeout(() => {
                                this.remove();
                            }, this.duration);
                        }
                        // 注意:固定弹幕不参与滚动动画循环的位置更新
                    }
                }

                // 创建弹幕DOM元素
                createElement() {
                    const el = document.createElement('div');
                    el.id = this.id;
                    el.classList.add('barrage-item', this.type); // 添加基础类和类型类
                    el.textContent = this.text;
                    el.style.color = this.color;
                    // el.style.fontSize = `${CONFIG.FONT_SIZE}px`; // 如果需要动态字号
                    // 性能优化:对于频繁移动的元素,使用translateZ(0)或will-change可以提升性能
                    el.style.willChange = 'transform';
                    // el.style.transform = 'translateZ(0)'; // 强制GPU加速(可能过度优化)
                    return el;
                }

                // 渲染到屏幕 (添加到DOM)
                render() {
                    barrageOverlay.appendChild(this.el);
                    // 获取实际宽度,用于判断何时离开屏幕以及轨道占用计算
                    this.width = this.el.offsetWidth;
                }

                // 移动弹幕 (仅滚动类型)
                move() {
                    if (this.type !== 'scroll' || isPaused) return;

                    this.xPos -= this.speed;
                    this.el.style.transform = `translateX(${this.xPos - overlayWidth}px)`; // 相对于初始left:100%的位置移动

                    // console.log(`Moving ${this.id}: xPos=${this.xPos}`); // Debugging
                }

                // 判断弹幕是否完全移出屏幕左侧 (滚动类型)
                isOffscreen() {
                    // 当弹幕的右边缘移动到屏幕左边缘之外时,视为移出
                    return this.type === 'scroll' && (this.xPos + this.width < 0);
                }

                // 从DOM中移除并清理资源
                remove() {
                    // console.log(`Removing ${this.id}`); // Debugging
                    if (this.el && this.el.parentNode) {
                        this.el.parentNode.removeChild(this.el);
                    }
                    // 如果是定时固定的弹幕,清除定时器
                    if (this.timeoutId) {
                        clearTimeout(this.timeoutId);
                        this.timeoutId = null;
                    }
                    // 从 activeBarrages 数组中移除自身
                    const index = activeBarrages.findIndex(b => b.id === this.id);
                    if (index > -1) {
                        activeBarrages.splice(index, 1);
                    }
                }
            } // End of Barrage Class

            // --- 弹幕管理器 功能 ---

            // 初始化轨道信息
            function initializeTracks() {
                tracks = [];
                overlayHeight = barrageOverlay.offsetHeight; // 获取当前实际高度
                trackCount = Math.max(1, Math.floor(overlayHeight / CONFIG.TRACK_HEIGHT)); // 至少1条轨道
                console.log(`Overlay height: ${overlayHeight}px, Track count: ${trackCount}`);
                for (let i = 0; i < trackCount; i++) {
                    // lastBarrageEndTime: 记录该轨道最后一条弹幕的尾部离开屏幕右侧(即刚完全进入屏幕)的时间戳
                    // 用于防止新弹幕在轨道刚空闲时就立即发射,造成视觉重叠
                    tracks.push({ id: i, lastBarrageEndTime: 0 });
                }
            }

            // 寻找一个合适的滚动弹幕轨道
            function findAvailableTrack() {
                const now = Date.now();
                // 尝试寻找完全空闲或等待时间最短的轨道
                let bestTrack = -1;
                let minEndTime = Infinity;

                for (let i = 0; i < tracks.length; i++) {
                    if (tracks[i].lastBarrageEndTime <= now) {
                        // 找到一个当前可用的轨道
                        bestTrack = i;
                        break; // 优先使用立即可用的
                    } else if (tracks[i].lastBarrageEndTime < minEndTime) {
                        // 如果没有立即可用的,找一个最快可用的
                        minEndTime = tracks[i].lastBarrageEndTime;
                        bestTrack = i;
                    }
                }

                 // 如果所有轨道都在缓冲期,随机选一个(或选择等待时间最短的那个)
                if (bestTrack === -1 && tracks.length > 0) {
                   // 简单策略:轮流分配或随机分配
                   // bestTrack = Math.floor(Math.random() * tracks.length);
                   // 或者就用上面找到的 minEndTime 对应的 bestTrack
                   console.warn("All tracks busy, might cause overlap. Assigning to track:", bestTrack);
                } else if (tracks.length === 0) {
                    console.error("No tracks available!");
                    return -1; // 没有轨道可用
                }


                return bestTrack;
            }

            // 更新轨道占用信息
            function updateTrackUsage(trackIndex, barrageWidth, speed) {
                if (trackIndex < 0 || trackIndex >= tracks.length) return;

                // 计算弹幕完全进入屏幕所需时间 (ms)
                const enterDuration = (barrageWidth / speed) * (1000 / 60); // 假设60fps

                // 计算弹幕尾部离开屏幕右侧的时间戳
                // 加上一个缓冲时间,避免紧挨着发射
                const endTime = Date.now() + enterDuration + CONFIG.BUFFER_TIME;

                tracks[trackIndex].lastBarrageEndTime = Math.max(tracks[trackIndex].lastBarrageEndTime, endTime);
                // console.log(`Track ${trackIndex} updated, next available at ${new Date(endTime).toLocaleTimeString()}`);
            }


            // 添加一条新弹幕
            function addBarrage(text, options = {}) {
                if (!text || text.trim() === "") return; // 不添加空弹幕

                const barrageOptions = { ...options }; // 复制一份,避免修改原对象

                if (barrageOptions.type === 'scroll') {
                    const trackIndex = findAvailableTrack();
                    if (trackIndex === -1) {
                        console.warn("No available track for scroll barrage:", text);
                        return; // 没有可用轨道,暂时不发送
                    }
                    barrageOptions.track = trackIndex;

                    const newBarrage = new Barrage(text, barrageOptions);
                    activeBarrages.push(newBarrage);
                    newBarrage.render(); // 添加到DOM

                    // 渲染后才能获取宽度
                    requestAnimationFrame(() => {
                         if (newBarrage.el) { // 确保元素还在
                            newBarrage.width = newBarrage.el.offsetWidth;
                            updateTrackUsage(trackIndex, newBarrage.width, newBarrage.speed);
                         }
                    });

                } else { // 固定弹幕 (top/bottom)
                    // 可以考虑限制同位置固定弹幕数量,这里简化处理,直接添加
                    const newBarrage = new Barrage(text, barrageOptions);
                    activeBarrages.push(newBarrage);
                    newBarrage.render();
                }

                // console.log("Added barrage:", text, barrageOptions);
            }

            // 动画循环
            function animationLoop() {
                if (isPaused) {
                    animationFrameId = requestAnimationFrame(animationLoop);
                    return;
                }

                // 清理旧的(已移出屏幕的)滚动弹幕
                activeBarrages = activeBarrages.filter(barrage => {
                    if (barrage.type === 'scroll' && barrage.isOffscreen()) {
                        barrage.remove(); // 从DOM移除
                        return false; // 从数组移除
                    }
                    return true;
                });

                // 移动所有活动的滚动弹幕
                activeBarrages.forEach(barrage => {
                    if (barrage.type === 'scroll') {
                        barrage.move();
                    }
                });

                // 继续下一帧
                animationFrameId = requestAnimationFrame(animationLoop);
            }

            // 暂停弹幕
            function pauseBarrages() {
                if (isPaused) return;
                isPaused = true;
                pauseButton.style.display = 'none';
                resumeButton.style.display = 'inline-block';
                // 注意:这里只暂停了滚动弹幕的移动,固定弹幕的定时器不受影响
                // 如果需要暂停定时器,需要额外逻辑记录剩余时间
                console.log("Barrages paused.");
            }

            // 恢复弹幕
            function resumeBarrages() {
                if (!isPaused) return;
                isPaused = false;
                pauseButton.style.display = 'inline-block';
                resumeButton.style.display = 'none';
                // 无需重新启动 animationLoop,因为它内部会检查 isPaused 状态
                console.log("Barrages resumed.");
            }

            // 清空所有弹幕
            function clearBarrages() {
                 console.log("Clearing all barrages...");
                 // 停止动画循环以防在移除过程中添加新弹幕
                 if (animationFrameId) {
                    cancelAnimationFrame(animationFrameId);
                    animationFrameId = null;
                 }

                 // 移除所有弹幕元素并清除定时器
                 activeBarrages.forEach(barrage => barrage.remove()); // remove方法会处理定时器
                 activeBarrages = []; // 清空数组

                 // 重置轨道状态
                 initializeTracks();

                 // 如果是暂停状态,恢复按钮状态
                 isPaused = false;
                 pauseButton.style.display = 'inline-block';
                 resumeButton.style.display = 'none';

                 // 重新启动动画循环(如果需要,比如希望清空后还能继续接收新弹幕)
                 startAnimationLoop();
                 console.log("All barrages cleared.");
            }

            // 设置显示模式
            function setMode(mode) {
                barrageOverlay.className = `barrage-overlay ${mode}`; // 更新CSS类
                // 更新当前激活按钮的样式
                modeButtons.forEach(button => {
                    button.classList.toggle('active', button.dataset.mode === mode);
                });

                // 模式改变后,overlay尺寸可能变化,需要重新计算轨道
                // 延迟一点执行,等待CSS过渡完成(如果需要精确)
                // 或者立即执行,但要知道尺寸可能不是最终过渡后的尺寸
                setTimeout(() => {
                    overlayWidth = barrageOverlay.offsetWidth;
                    // 注意:高度变化会影响轨道计算,需要重新初始化
                    initializeTracks();
                    // 可选:是否清空现有弹幕?或者让它们在旧尺寸下继续?
                    // 清空可能是最简单的处理方式
                    // clearBarrages(); // 取消注释则切换模式时清空
                    console.log(`Mode changed to ${mode}. Overlay dimensions: ${overlayWidth}x${overlayHeight}. Tracks re-initialized.`);
                }, 50); // 短暂延迟,可以根据CSS过渡时间调整
            }

            // 处理发送按钮点击
            function handleSendBarrage() {
                const text = barrageInput.value;
                const color = colorInput.value;
                const type = typeSelect.value;
                let duration = durationInput.value ? parseInt(durationInput.value, 10) * 1000 : undefined; // 转毫秒

                if (type !== 'scroll' && duration === undefined) {
                    // 如果是固定弹幕且未指定时长,给个默认值或设为0表示常驻
                    duration = CONFIG.FIXED_DURATION; // 或者设为 0
                    // durationInput.value = CONFIG.FIXED_DURATION / 1000; // 更新输入框显示默认值
                }
                 if (type !== 'scroll' && duration === 0) {
                     duration = undefined; // 0 表示常驻,用 undefined 传递给 Barrage 类
                 }

                addBarrage(text, { color, type, duration });

                barrageInput.value = ''; // 清空输入框
            }

            // 启动动画循环
            function startAnimationLoop() {
                 if (!animationFrameId) {
                    animationFrameId = requestAnimationFrame(animationLoop);
                 }
            }

            // --- 事件监听器 ---
            sendButton.addEventListener('click', handleSendBarrage);
            barrageInput.addEventListener('keypress', (e) => {
                if (e.key === 'Enter') {
                    handleSendBarrage();
                }
            });

            modeButtons.forEach(button => {
                button.addEventListener('click', () => {
                    setMode(button.dataset.mode);
                });
            });

            pauseButton.addEventListener('click', pauseBarrages);
            resumeButton.addEventListener('click', resumeBarrages);
            clearButton.addEventListener('click', clearBarrages);

            // 当窗口大小改变时,重新计算尺寸和轨道(节流处理更佳)
            let resizeTimeout;
            window.addEventListener('resize', () => {
                clearTimeout(resizeTimeout);
                resizeTimeout = setTimeout(() => {
                    console.log("Window resized, recalculating...");
                    overlayWidth = barrageOverlay.offsetWidth;
                    // 高度变化需要重新初始化轨道
                    initializeTracks();
                    // 可选:是否清空或调整现有弹幕?
                }, 250); // 延迟250ms执行,避免频繁触发
            });

            // --- 初始化 ---
            function init() {
                console.log("Initializing Barrage System...");
                initializeTracks(); // 初始化轨道信息
                startAnimationLoop(); // 启动动画循环

                // 设置默认模式激活状态
                 setMode('full-screen'); // 默认全屏

                 // 添加一些示例弹幕
                 addBarrage("欢迎来到弹幕世界!", { color: "#FFD700", type: "scroll" });
                 setTimeout(() => addBarrage("这是一条滚动弹幕~", { color: "#00FF00" }), 1000);
                 setTimeout(() => addBarrage("顶部固定弹幕 (5秒)", { color: "#FF69B4", type: "top", duration: 5000 }), 2000);
                 setTimeout(() => addBarrage("底部常驻弹幕", { color: "#1E90FF", type: "bottom", duration: 0 }), 3000); // duration 0 表示常驻
                 setTimeout(() => addBarrage("1/4屏模式试试看?", { color: "#FFA500" }), 5000);

                 console.log("Barrage System Initialized.");
            }

            // --- 启动 ---
            // 等待DOM加载完成
            if (document.readyState === 'loading') {
                document.addEventListener('DOMContentLoaded', init);
            } else {
                init(); // DOM已加载
            }

        })(); // 立即执行函数表达式 (IIFE),避免污染全局作用域
    </script>

</body>
</html>

代码讲解:

  1. HTML 结构 (<body> 部分):

    • .barrage-container-wrapper: 包裹整个弹幕显示区域和控制区域,方便整体布局和响应式设计。
    • .barrage-container: 核心容器,position: relative 是关键,作为内部绝对定位元素的参考。aspect-ratio 模拟视频比例,overflow: hidden 防止弹幕溢出容器。
    • .video-placeholder: 一个简单的 div 模拟视频区域,实际应用中可以替换为 <video> 标签。z-index: 1 确保它在弹幕层下方。
    • .barrage-overlay: 弹幕实际渲染的图层,position: absolute 覆盖在 .barrage-container 上。z-index: 10 确保它在最上层。pointer-events: none 允许鼠标事件穿透到下方的视频播放器。这个元素的 heighttop 会根据模式(全屏、半屏、1/4屏)通过添加/移除 CSS 类来改变。
    • .barrage-controls: 包含模式切换、暂停/恢复、清空按钮。
    • .barrage-input-area: 包含文本输入框、颜色选择器、类型下拉框、固定时长输入框和发送按钮。
  2. CSS 样式 (<style> 部分):

    • 基础布局: 使用 Flexbox 居中 .barrage-container-wrapper

    • 容器样式: 设置 .barrage-container.barrage-overlay 的定位、尺寸、背景、层级。transition 属性为 .barrage-overlayheighttop 变化添加平滑过渡效果。

    • 屏幕模式: .full-screen, .half-screen, .quarter-screen 类通过修改 heighttop 来控制弹幕显示区域的大小和位置。

    • 弹幕项样式 (.barrage-item):

      • position: absolute: 相对于 .barrage-overlay 定位。
      • white-space: nowrap: 防止文字换行。
      • color, font-size, font-weight, text-shadow: 定义外观。text-shadow 模拟描边效果,提高在复杂背景下的可读性。
      • will-change: transform: 性能优化提示,告知浏览器 transform 属性会频繁变动。
      • left: 100%: 滚动弹幕的初始水平位置在容器右侧外部。
      • transform: translateX(0): 初始变换值,JavaScript 将通过修改 translateX 的值来实现向左移动。
    • 固定弹幕样式: .fixed-top, .fixed-bottom 类覆盖了部分 .barrage-item 样式。它们使用 left: 50%; transform: translateX(-50%); 实现水平居中,并用 topbottom 属性固定垂直位置。

    • 控件样式: 美化按钮、输入框等。.active 类用于高亮当前选中的模式按钮。

    • 响应式设计: 使用 @media (max-width: 768px) 为窄屏幕(如手机)提供不同的布局,例如将控制按钮和输入区域垂直排列,输入框宽度调整为100%。

  3. JavaScript 逻辑 (<script> 部分):

    • IIFE (立即执行函数表达式): (function() { ... })(); 创建了一个独立的作用域,避免定义的变量和函数污染全局命名空间。

    • 严格模式: "use strict"; 启用 JavaScript 的严格模式,有助于捕捉常见错误。

    • 配置项 (CONFIG): 将一些可调参数(如轨道高度、速度、默认时长等)集中管理,方便修改。

    • DOM 元素获取: 获取需要操作的 HTML 元素的引用。

    • 状态变量:

      • isPaused: 标记弹幕是否处于暂停状态。
      • animationFrameId: 存储 requestAnimationFrame 返回的 ID,用于取消动画帧。
      • activeBarrages: 数组,存储当前所有在屏幕上活动(包括即将进入和正在显示)的 Barrage 对象实例。
      • tracks: 数组,用于管理滚动弹幕的水平轨道,避免过度重叠。每个轨道对象记录了该轨道预计何时可用 (lastBarrageEndTime)。
      • overlayWidth, overlayHeight, trackCount: 缓存弹幕区域的尺寸和计算出的轨道数量,窗口大小变化时会更新。
    • Barrage 类:

      • constructor: 初始化弹幕对象的属性(文本、颜色、类型、时长、唯一ID)。创建 DOM 元素 (this.el)。根据类型 (scroll, top, bottom) 设置不同的初始样式和行为。滚动弹幕计算初始 Y 坐标和速度;固定弹幕设置对应的 CSS 类,并根据 duration 设置 setTimeout 定时移除。
      • createElement: 创建弹幕的 div 元素,设置 ID、CSS 类、文本内容、颜色和性能优化相关的样式。
      • render: 将创建的 DOM 元素 (this.el) 添加到 .barrage-overlay 中,使其在页面上可见。渲染后获取元素的实际宽度 (this.width),这对于计算滚动弹幕何时离开屏幕和轨道占用时间很重要。
      • move: (仅用于滚动弹幕) 更新弹幕的 xPos (逻辑 X 坐标),并通过修改 style.transform = translateX(...) 来实际移动 DOM 元素。注意 translateX 的值是相对于初始 left: 100% 的偏移量。
      • isOffscreen: (仅用于滚动弹幕) 判断弹幕是否已经完全移动到屏幕左侧之外。
      • remove: 从 DOM 中移除弹幕元素。如果存在定时器 (timeoutId),则清除它。同时,从 activeBarrages 数组中移除该弹幕对象。这是弹幕生命周期的终点。
    • 弹幕管理器功能 (全局函数):

      • initializeTracks: 根据当前 .barrage-overlay 的高度和 CONFIG.TRACK_HEIGHT 计算可用轨道数量,并初始化 tracks 数组,将每个轨道的 lastBarrageEndTime 设为 0。

      • findAvailableTrack: 为新的滚动弹幕寻找一个合适的轨道。优先选择已经完全空闲 (lastBarrageEndTime <= now) 的轨道。如果没有立即可用的,则选择 lastBarrageEndTime 最小(即最快会变空闲)的轨道。这是避免弹幕重叠的关键逻辑之一。

      • updateTrackUsage: 当一个新的滚动弹幕被分配到某个轨道后,根据其宽度和速度计算它完全进入屏幕所需的时间,并加上一个缓冲时间 (CONFIG.BUFFER_TIME),更新该轨道的 lastBarrageEndTime。这能有效减少连续发射的弹幕在起点就重叠的问题。

      • addBarrage: 核心函数,用于添加新弹幕。接收文本和选项对象。根据 type 创建 Barrage 实例。如果是滚动弹幕,先调用 findAvailableTrack 获取轨道号,如果找到则创建弹幕、添加到 activeBarrages、渲染到 DOM,并在下一帧获取宽度后调用 updateTrackUsage。如果是固定弹幕,直接创建、添加、渲染。

      • animationLoop: 使用 requestAnimationFrame 实现的主动画循环。每一帧执行:

        1. 过滤 activeBarrages 数组,移除已经 isOffscreen() 的滚动弹幕(调用其 remove 方法)。
        2. 遍历剩余的 activeBarrages,对所有滚动弹幕调用 move() 方法更新位置。
        3. 递归调用 requestAnimationFrame(animationLoop) 请求下一帧。
        4. 如果 isPausedtrue,则不执行移动和移除逻辑,但仍然请求下一帧,以便在恢复时能继续。
      • pauseBarrages, resumeBarrages, clearBarrages: 控制弹幕的暂停、恢复和清空。clearBarrages 会停止动画循环,移除所有弹幕 DOM 元素,清除定时器,清空 activeBarrages 数组,重置轨道信息,然后可以重新启动动画循环。

      • setMode: 处理模式切换。更新 .barrage-overlay 的 CSS 类,更新按钮激活状态,并在短暂延迟后重新获取 overlay 尺寸并重新初始化轨道 (initializeTracks)。

      • handleSendBarrage: 当用户点击发送按钮或在输入框按回车时触发。获取输入框、颜色选择器、类型下拉框、时长输入框的值,构造 options 对象,调用 addBarrage。清空输入框。处理固定弹幕时长(将秒转为毫秒,0 或空表示常驻)。

      • startAnimationLoop: 启动(或重新启动)动画循环。

    • 事件监听器:

      • 为发送按钮、输入框回车、模式按钮、暂停/恢复/清空按钮绑定相应的处理函数。
      • 监听 windowresize 事件。当窗口大小改变时,使用 setTimeout 进行节流处理,延迟执行尺寸重新计算和轨道重新初始化。
    • 初始化 (init 函数):

      • 在 DOM 加载完成后执行。
      • 调用 initializeTracks 初始化轨道。
      • 调用 startAnimationLoop 启动动画。
      • 设置默认的显示模式(例如全屏)。
      • 可以添加一些示例弹幕,演示不同类型和颜色。
    • 启动: 判断 DOM 是否已加载,如果未加载则监听 DOMContentLoaded 事件,否则直接调用 init

总结与可扩展性:

这个实现提供了一个功能相对完整的弹幕系统基础。它包含了多种显示模式、不同类型的弹幕(滚动、顶部固定、底部固定)、颜色自定义、定时消失或常驻、暂停/恢复/清空等功能。代码结构清晰,使用了类来封装弹幕对象,并通过管理器函数来协调整个系统。

可进一步扩展的方向:

  1. 性能优化:

    • 对象池: 对于频繁创建和销毁的弹幕对象,使用对象池可以减少垃圾回收压力。
    • Canvas 渲染: 对于极大量的弹幕,使用 Canvas 绘制通常比 DOM 操作性能更好,但实现更复杂。
    • 更精细的轨道管理: 实现更复杂的碰撞检测和避让算法。
    • 节流/防抖: 对用户输入(发送弹幕)和窗口大小调整进行更严格的节流或防抖处理。
  2. 功能增强:

    • 用户交互: 点击弹幕进行点赞、举报等。
    • 特殊弹幕: 如带有特殊效果(发光、放大)、图标或用户头像的弹幕。
    • 弹幕密度控制: 根据屏幕上的弹幕数量动态调整是否接受新弹幕或调整弹幕速度。
    • 与视频同步: 将弹幕的显示与视频播放时间精确关联。这通常需要监听视频播放器的 timeupdate 事件,并将弹幕数据与时间戳绑定。
    • 后端集成: 从服务器加载弹幕数据,并将用户发送的弹幕提交到服务器。
    • 弹幕过滤/屏蔽: 基于关键词、用户 ID 等屏蔽某些弹幕。
    • 字体大小/透明度设置: 允许用户自定义弹幕的显示效果。