前端弹幕系统Canvas版本

456 阅读14分钟

Canvas 渲染相比 DOM 操作,在弹幕数量非常庞大时通常具有性能优势,但实现起来也更复杂,因为我们需要手动处理绘制、文本测量、布局和动画的每一个细节。

核心思路 (Canvas):

  1. HTML 结构: 只需要一个 <canvas> 元素用于绘制,以及与之前类似的控制按钮和输入区域。

  2. CSS 样式: 主要用于布局容器、画布本身以及控制元件。画布的显示区域(半屏、1/4屏)可以通过 CSS 控制容器大小或在 JS 中控制绘制区域。

  3. JavaScript 逻辑:

    • Canvas Context: 获取 2D 渲染上下文 (ctx)。

    • 弹幕类 (Barrage Class): 封装弹幕数据(文本、颜色、类型、速度、位置 x/y、尺寸 width/height、生命周期相关属性)。不再有 DOM 元素 (el)。

    • 弹幕管理器 (Barrage Manager):

      • 管理 activeBarrages 数组 (存储 Barrage 对象)。
      • 负责接收、创建 Barrage 对象。
      • 文本测量: 使用 ctx.measureText(text).width 获取文本宽度,这对于定位、轨道管理至关重要。
      • 轨道管理逻辑 (基于计算出的位置和尺寸)。
      • 控制主渲染循环 (requestAnimationFrame)。
      • 在循环中:清空画布 -> 更新所有弹幕状态 -> 绘制所有弹幕 -> 移除无效弹幕。
      • 处理模式切换(调整绘制区域 drawingHeight、重算轨道)。
      • 处理固定弹幕的计时和移除。
      • 处理暂停、恢复、清空。
    • 渲染: 在每一帧,清空画布指定区域,遍历 activeBarrages,设置 ctx 的样式(颜色、字体),调用 ctx.fillText() 在计算好的位置绘制文本。

    • 动画: 通过在 requestAnimationFrame 循环中不断更新弹幕对象的坐标并重绘来实现。使用 deltaTime (帧间时间差) 可以使动画更平滑,速度更稳定。

完整 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>Canvas 弹幕系统</title>
    <style>
        /* CSS 样式 - 与 DOM 版本类似,主要调整 Canvas 相关 */
        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: 16 / 9;
            background-color: #333; /* 背景色,模拟视频区 */
            overflow: hidden; /* 隐藏超出部分,虽然 Canvas 内部也会裁剪 */
            border-radius: 3px;
        }

        /* Canvas 画布 */
        #barrage-canvas {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%; /* CSS 控制显示大小 */
            height: 100%;/* CSS 控制显示大小 */
            /* background-color: rgba(0, 255, 0, 0.1); */ /* Debug: 显示Canvas区域 */
            z-index: 10;
            pointer-events: none; /* 允许点击穿透 */
        }

        /* 视频占位符 (可选,如果需要背景内容) */
        .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; /* 在 Canvas 下方 */
        }


        /* --- 控制区域 --- (与 DOM 版本一致) */
        .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;
        }
         .barrage-input-area input[type="number"] {
            padding: 8px;
            font-size: 14px;
            border: 1px solid #ccc;
            border-radius: 4px;
            width: 150px; /* 固定宽度或根据需要调整 */
            height: 36px;
            box-sizing: border-box;
        }

        /* 响应式调整 - (与 DOM 版本一致) */
        @media (max-width: 768px) {
            .barrage-container-wrapper {
                width: 95%;
            }
            .barrage-controls,
            .barrage-input-area {
                flex-direction: column;
                align-items: stretch;
            }
            .barrage-input-area input[type="text"],
            .barrage-input-area select,
            .barrage-input-area input[type="number"],
            .barrage-input-area input[type="color"] {
                 min-width: unset;
                 width: 100%;
                 box-sizing: border-box;
                 margin-bottom: 5px;
            }
        }

    </style>
</head>
<body>

    <div class="barrage-container-wrapper">
        <!-- 弹幕容器 -->
        <div class="barrage-container" id="barrage-container">
            <!-- 视频占位符 -->
            <div class="video-placeholder">模拟视频区域 (Canvas 渲染)</div>
            <!-- Canvas 画布 -->
            <canvas id="barrage-canvas"></canvas>
        </div>

        <!-- 控制按钮 -->
        <div class="barrage-controls">
            <span>显示模式:</span>
            <button id="mode-full" class="active" data-mode="full">全屏</button>
            <button id="mode-half" data-mode="half">半屏</button>
            <button id="mode-quarter" data-mode="quarter">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">
            <button id="send-barrage">发送弹幕</button>
        </div>
    </div>

    <script>
        // JavaScript 逻辑 (Canvas 版本)
        (function() {
            "use strict";

            // --- 配置项 ---
            const CONFIG = {
                TRACK_HEIGHT: 30,       // 每条弹幕轨道的高度(像素)
                FONT_SIZE: 20,          // 默认字体大小 (像素)
                FONT_FAMILY: "SimHei, 'Microsoft YaHei', sans-serif", // 字体
                DEFAULT_SPEED_PPS: 100, // 滚动弹幕默认速度 (像素/秒)
                MAX_SPEED_PPS_OFFSET: 50, // 速度随机偏移上限 (像素/秒)
                MIN_SPEED_PPS_OFFSET: -20,// 速度随机偏移下限 (像素/秒)
                FIXED_DURATION: 5000,   // 固定弹幕默认显示时长 (毫秒)
                BUFFER_TIME: 2000,      // 轨道预留缓冲时间 (毫秒)
                OPACITY: 0.9,           // 弹幕默认透明度
                FIXED_MARGIN_TOP: 10,   // 顶部固定弹幕的上边距
                FIXED_MARGIN_BOTTOM: 10,// 底部固定弹幕的下边距
                CANVAS_SCALE_FACTOR: window.devicePixelRatio || 1, // 处理高DPI屏幕,使文字更清晰
            };

            // --- DOM 元素获取 ---
            const container = document.getElementById('barrage-container');
            const canvas = document.getElementById('barrage-canvas');
            const ctx = canvas.getContext('2d');
            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 lastTimestamp = 0; // 用于计算 deltaTime
            let activeBarrages = []; // 存储当前活动的 Barrage 对象
            let tracks = [];         // 存储滚动弹幕轨道信息
            let canvasWidth = 0;     // 画布逻辑宽度
            let canvasHeight = 0;    // 画布逻辑高度
            let drawingHeight = 0;   // 当前模式下实际绘制区域的高度
            let trackCount = 0;      // 当前模式下的轨道数量
            let currentMode = 'full'; // 'full', 'half', 'quarter'

            // --- 弹幕类 (Barrage Class - Canvas Version) ---
            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/0 表示常驻或滚动
                    this.opacity = options.opacity || CONFIG.OPACITY;
                    this.id = `barrage-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
                    this.creationTime = Date.now();

                    // Canvas 专用属性
                    this.x = 0;
                    this.y = 0;
                    this.width = 0; // 文本宽度,需要测量
                    this.height = CONFIG.FONT_SIZE; // 基于字体大小估算高度

                    // 设置字体用于测量宽度
                    ctx.font = `${CONFIG.FONT_SIZE}px ${CONFIG.FONT_FAMILY}`;
                    this.width = ctx.measureText(this.text).width;

                    if (this.type === 'scroll') {
                        const baseSpeed = CONFIG.DEFAULT_SPEED_PPS;
                        const offset = Math.random() * (CONFIG.MAX_SPEED_PPS_OFFSET - CONFIG.MIN_SPEED_PPS_OFFSET) + CONFIG.MIN_SPEED_PPS_OFFSET;
                        this.speed = (baseSpeed + offset) / 1000; // 转换为 像素/毫秒
                        this.track = options.track; // 分配的轨道号
                        // 计算 Y 坐标:在轨道内垂直居中(或顶部对齐)
                        this.y = this.track * CONFIG.TRACK_HEIGHT + (CONFIG.TRACK_HEIGHT - this.height) / 2;
                        // 初始 X 坐标在画布右侧外部
                        this.x = canvasWidth;
                    } else { // 固定弹幕 (top/bottom)
                        // 水平居中
                        this.x = (canvasWidth - this.width) / 2;
                        if (this.type === 'top') {
                            this.y = CONFIG.FIXED_MARGIN_TOP;
                        } else { // bottom
                            // 注意:Y 坐标相对于 drawingHeight
                            this.y = drawingHeight - this.height - CONFIG.FIXED_MARGIN_BOTTOM;
                        }
                        // 如果 duration 是 0 或 undefined,则为常驻
                        this.isPermanent = (this.duration === undefined || this.duration <= 0);
                    }
                     // console.log(`Created Barrage: ${this.text}, Type: ${this.type}, Pos: (${this.x.toFixed(1)}, ${this.y.toFixed(1)}), W: ${this.width.toFixed(1)}`);
                }

                // 更新弹幕状态 (位置、生命周期)
                update(deltaTime) {
                    if (this.type === 'scroll') {
                        this.x -= this.speed * deltaTime; // 根据时间差移动
                    }
                    // 固定弹幕位置不变,但需要检查生命周期
                }

                // 在 Canvas 上绘制弹幕
                draw(ctx) {
                    // 设置绘制样式
                    ctx.fillStyle = this.color;
                    ctx.globalAlpha = this.opacity; // 设置透明度
                    ctx.font = `${CONFIG.FONT_SIZE}px ${CONFIG.FONT_FAMILY}`;
                    ctx.textBaseline = 'top'; // 基线设为顶部,方便 Y 坐标计算
                    ctx.textAlign = (this.type === 'scroll') ? 'left' : 'center'; // 滚动左对齐,固定居中

                    // 绘制文本
                    // 对于居中对齐的固定弹幕,x 已经是中心点;对于左对齐的滚动弹幕,x 是左边界
                    let drawX = (this.type === 'scroll') ? this.x : canvasWidth / 2;
                    ctx.fillText(this.text, drawX, this.y);

                    // 恢复默认透明度
                    ctx.globalAlpha = 1.0;
                }

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

                // 判断固定弹幕是否已到期
                isExpired() {
                    // 如果是常驻类型,则永不过期
                    if (this.type !== 'scroll' && this.isPermanent) {
                        return false;
                    }
                    // 如果是定时固定弹幕,检查时间
                    if (this.type !== 'scroll' && !this.isPermanent) {
                        return Date.now() >= this.creationTime + this.duration;
                    }
                    // 滚动弹幕不由时间决定过期
                    return false;
                }
            } // End of Barrage Class

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

            // 初始化或调整 Canvas 尺寸
            function setupCanvas() {
                const dpr = CONFIG.CANVAS_SCALE_FACTOR;
                // 获取容器的 CSS 尺寸
                const rect = container.getBoundingClientRect();
                // 设置 Canvas 的实际像素尺寸(考虑 DPR)
                canvas.width = rect.width * dpr;
                canvas.height = rect.height * dpr;
                // 设置 Canvas 的 CSS 尺寸(保持与容器一致)
                canvas.style.width = `${rect.width}px`;
                canvas.style.height = `${rect.height}px`;

                // 保存逻辑尺寸(用于内部计算)
                canvasWidth = rect.width;
                canvasHeight = rect.height;

                // 应用缩放,使得绘制时使用逻辑坐标,但渲染更清晰
                ctx.scale(dpr, dpr);

                // 根据当前模式计算实际绘制高度和轨道数
                updateDrawingArea();
                initializeTracks(); // 尺寸变化后需要重新初始化轨道

                console.log(`Canvas setup: Logic ${canvasWidth}x${canvasHeight}, Physical ${canvas.width}x${canvas.height}, DPR ${dpr}`);
            }

            // 根据模式更新绘制高度
            function updateDrawingArea() {
                switch (currentMode) {
                    case 'half':
                        drawingHeight = canvasHeight / 2;
                        break;
                    case 'quarter':
                        drawingHeight = canvasHeight / 4;
                        break;
                    case 'full':
                    default:
                        drawingHeight = canvasHeight;
                        break;
                }
                console.log(`Mode: ${currentMode}, Drawing height: ${drawingHeight}`);
            }


            // 初始化轨道信息
            function initializeTracks() {
                tracks = [];
                // 轨道数量基于实际绘制高度
                trackCount = Math.max(1, Math.floor(drawingHeight / CONFIG.TRACK_HEIGHT));
                console.log(`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;

                // 优先找完全空闲的轨道
                const availableTracks = tracks.filter(track => track.lastBarrageEndTime <= now);
                if (availableTracks.length > 0) {
                    // 如果有多个空闲,随机选一个,增加随机性
                    bestTrack = availableTracks[Math.floor(Math.random() * availableTracks.length)].id;
                } else {
                    // 没有完全空闲的,找一个结束时间最早的
                    for (let i = 0; i < tracks.length; i++) {
                        if (tracks[i].lastBarrageEndTime < minEndTime) {
                            minEndTime = tracks[i].lastBarrageEndTime;
                            bestTrack = i;
                        }
                    }
                    // console.warn(`No free track, choosing earliest available: Track ${bestTrack}`);
                }

                if (tracks.length === 0) {
                    console.error("No tracks available!");
                    return -1;
                }

                return bestTrack;
            }

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

                // 计算弹幕完全进入屏幕所需时间 (ms)
                // 弹幕需要移动自身宽度才能完全进入
                const enterDuration = barrageWidth / speedInMs;

                // 计算弹幕尾部离开画布右侧(即刚完全进入)的时间戳
                // 加上缓冲时间
                const endTime = Date.now() + enterDuration + CONFIG.BUFFER_TIME;

                // 更新轨道的预计可用时间,取当前时间和计算出的结束时间的最大值
                // 防止因计算误差或性能波动导致时间回退
                tracks[trackIndex].lastBarrageEndTime = Math.max(tracks[trackIndex].lastBarrageEndTime, endTime);
                // console.log(`Track ${trackIndex} updated, next available around ${new Date(endTime).toLocaleTimeString()}`);
            }


            // 添加一条新弹幕
            function addBarrage(text, options = {}) {
                if (!text || text.trim() === "" || !ctx) return; // 确保有内容和上下文

                const barrageOptions = { ...options };
                let newBarrage = null;

                if (barrageOptions.type === 'scroll') {
                    const trackIndex = findAvailableTrack();
                    if (trackIndex === -1) {
                        console.warn("No available track for scroll barrage:", text);
                        return; // 没有可用轨道,暂时不发送
                    }
                    barrageOptions.track = trackIndex;
                    newBarrage = new Barrage(text, barrageOptions);
                    // 创建后立即更新轨道占用信息
                    updateTrackUsage(trackIndex, newBarrage.width, newBarrage.speed);

                } else { // 固定弹幕 (top/bottom)
                    // 可以在此添加逻辑限制同位置固定弹幕数量
                    newBarrage = new Barrage(text, barrageOptions);
                    // 固定弹幕需要确保 y 坐标在当前 drawingHeight 内
                    if (newBarrage.type === 'bottom') {
                         newBarrage.y = drawingHeight - newBarrage.height - CONFIG.FIXED_MARGIN_BOTTOM;
                    }
                }

                if (newBarrage) {
                    activeBarrages.push(newBarrage);
                    // console.log(`Added barrage to active list. Total: ${activeBarrages.length}`);
                }
            }

            // 主渲染循环
            function renderLoop(timestamp) {
                // 计算自上一帧以来的时间差 (毫秒)
                const deltaTime = timestamp - lastTimestamp;
                lastTimestamp = timestamp;

                // 如果暂停,则不更新和绘制,但继续请求下一帧
                if (isPaused) {
                    animationFrameId = requestAnimationFrame(renderLoop);
                    return;
                }

                // 1. 清空画布 (只清空当前模式下的活动区域)
                // 注意:clearRect 的坐标是相对于画布左上角的物理像素坐标,但宽高是逻辑尺寸
                // 因为我们已经 ctx.scale(dpr, dpr) 了,所以可以直接用逻辑尺寸
                ctx.clearRect(0, 0, canvasWidth, drawingHeight);

                // 2. 更新和绘制所有活动弹幕
                const remainingBarrages = []; // 用于存储下一帧还需要保留的弹幕
                for (let i = 0; i < activeBarrages.length; i++) {
                    const barrage = activeBarrages[i];

                    // 更新弹幕状态 (位置、检查是否过期)
                    barrage.update(deltaTime);

                    // 检查弹幕是否应该被移除 (移出屏幕或已过期)
                    if (barrage.isOffscreen() || barrage.isExpired()) {
                        // console.log(`Removing barrage: ${barrage.text} (Offscreen: ${barrage.isOffscreen()}, Expired: ${barrage.isExpired()})`);
                        continue; // 跳过绘制,并且不加入 remainingBarrages
                    }

                    // 检查弹幕是否在当前绘制区域内 (特别是对于滚动弹幕和模式切换后)
                    // 简单的检查:如果弹幕的 y 坐标超出当前绘制高度,则不绘制(但可能仍在 activeBarrages 中移动)
                    if (barrage.y >= 0 && barrage.y + barrage.height <= drawingHeight) {
                       barrage.draw(ctx); // 绘制弹幕
                    } else if (barrage.type === 'scroll' && barrage.y > drawingHeight) {
                        // 如果一个滚动弹幕因为模式切换跑到了绘制区域外,理论上应该移除或重新分配轨道
                        // 这里简单处理:不绘制它,等它 isOffscreen() 后自然移除
                    }


                    remainingBarrages.push(barrage); // 保留此弹幕
                }

                // 更新活动弹幕列表
                activeBarrages = remainingBarrages;

                // 3. 请求下一帧动画
                animationFrameId = requestAnimationFrame(renderLoop);
            }

            // 暂停弹幕
            function pauseBarrages() {
                if (isPaused) return;
                isPaused = true;
                pauseButton.style.display = 'none';
                resumeButton.style.display = 'inline-block';
                console.log("Barrages paused.");
                // 注意:isPaused 标志会在 renderLoop 中阻止 update 和 draw
            }

            // 恢复弹幕
            function resumeBarrages() {
                if (!isPaused) return;
                isPaused = false;
                pauseButton.style.display = 'inline-block';
                resumeButton.style.display = 'none';
                // 重置 lastTimestamp,避免暂停期间的 deltaTime 过大导致弹幕跳跃
                lastTimestamp = performance.now();
                console.log("Barrages resumed.");
                // renderLoop 会在下一帧自动恢复 update 和 draw
            }

            // 清空所有弹幕
            function clearBarrages() {
                 console.log("Clearing all barrages...");
                 activeBarrages = []; // 清空数组即可,渲染循环在下一帧就不会绘制它们了
                 // 重置轨道状态,以便新弹幕可以立即使用
                 initializeTracks();
                 // 如果是暂停状态,也恢复按钮状态
                 if (isPaused) {
                    resumeBarrages(); // 调用恢复逻辑来更新按钮状态和 isPaused 标志
                 }
                 // 不需要手动停止动画循环,让它继续运行以接收新弹幕
                 console.log("All barrages cleared.");
            }

            // 设置显示模式
            function setMode(mode) {
                if (currentMode === mode) return; // 模式未改变

                console.log(`Setting mode to: ${mode}`);
                currentMode = mode;

                // 更新按钮激活状态
                modeButtons.forEach(button => {
                    button.classList.toggle('active', button.dataset.mode === mode);
                });

                // 更新绘制区域高度
                updateDrawingArea();

                // 重新计算轨道
                initializeTracks();

                // 处理现有弹幕:
                // 选项1: 清空所有弹幕 (最简单)
                // clearBarrages();

                // 选项2: 过滤掉超出新区域的固定弹幕,调整底部固定弹幕位置
                activeBarrages = activeBarrages.filter(b => {
                    if (b.type === 'top') {
                        return b.y + b.height <= drawingHeight; // 保留仍在区域内的顶部弹幕
                    } else if (b.type === 'bottom') {
                        // 重新计算底部弹幕的 y 坐标
                        b.y = drawingHeight - b.height - CONFIG.FIXED_MARGIN_BOTTOM;
                        return b.y >= 0; // 确保调整后仍在有效区域
                    }
                    // 滚动弹幕暂时不管,让它们自然流出或在渲染时被跳过
                    return true;
                });

                // 清空一次画布,确保旧区域的内容消失
                ctx.clearRect(0, 0, canvasWidth, canvasHeight); // 清空整个画布以防万一

                console.log(`Mode changed to ${mode}. Drawing height: ${drawingHeight}. Tracks re-initialized.`);
            }

            // 处理发送按钮点击
            function handleSendBarrage() {
                const text = barrageInput.value;
                const color = colorInput.value;
                const type = typeSelect.value;
                let durationOption = durationInput.value;
                let duration = undefined; // 默认 undefined

                if (type !== 'scroll') {
                    if (durationOption === null || durationOption.trim() === '') {
                        // 固定弹幕未指定时长,使用默认值
                        duration = CONFIG.FIXED_DURATION;
                    } else {
                        duration = parseInt(durationOption, 10) * 1000; // 转毫秒
                        if (isNaN(duration) || duration < 0) {
                            duration = CONFIG.FIXED_DURATION; // 非法输入也用默认值
                        }
                        // duration 为 0 表示常驻
                    }
                }


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

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

            // 启动渲染循环
            function startRenderLoop() {
                 if (!animationFrameId) {
                    console.log("Starting render loop...");
                    lastTimestamp = performance.now(); // 初始化时间戳
                    animationFrameId = requestAnimationFrame(renderLoop);
                 }
            }

            // 停止渲染循环
            function stopRenderLoop() {
                if (animationFrameId) {
                    cancelAnimationFrame(animationFrameId);
                    animationFrameId = null;
                    console.log("Render loop stopped.");
                }
            }

            // --- 事件监听器 ---
            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, re-setting up canvas...");
                    // 重新设置 Canvas 尺寸,这会清空画布并重置状态
                    setupCanvas();
                    // 可能需要重新添加一些测试弹幕或重新加载状态
                    // 注意:现有的 activeBarrages 中的坐标可能需要调整,或者干脆清空
                    // 这里选择清空,简化处理
                    activeBarrages = [];
                    console.log("Canvas re-setup complete due to resize. Barrages cleared.");
                }, 250); // 延迟执行避免频繁触发
            });

            // --- 初始化 ---
            function init() {
                console.log("Initializing Canvas Barrage System...");
                if (!ctx) {
                    console.error("Failed to get 2D context from canvas.");
                    alert("无法初始化 Canvas 渲染上下文,弹幕功能可能无法使用。");
                    return;
                }

                // 1. 设置 Canvas 初始尺寸
                setupCanvas(); // 会自动调用 updateDrawingArea 和 initializeTracks

                // 2. 绑定事件监听器 (已在全局完成)

                // 3. 设置默认模式激活状态 (setupCanvas 中已处理)
                setMode('full'); // 确保初始模式设置正确

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

                // 5. 启动渲染循环
                startRenderLoop();

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

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

        })(); // IIFE
    </script>

</body>
</html>

代码讲解 (Canvas 版本):

  1. HTML 结构:

    • 核心变化是使用 <canvas id="barrage-canvas"> 替代了之前的 .barrage-overlay div
    • 其他控制元素(按钮、输入框)保持不变。
  2. CSS 样式:

    • #barrage-canvas: 设置为 position: absolute,覆盖在 .barrage-container 上,width: 100%, height: 100% 使其 CSS 显示尺寸填充容器。pointer-events: none 保持不变。
    • 其他样式与 DOM 版本基本一致,用于布局和美化。屏幕模式的视觉效果(如容器高度变化)依然可以由 CSS 控制,但 Canvas 内部的绘制区域由 JS 管理。
  3. JavaScript 逻辑:

    • 配置项 (CONFIG): 增加了 FONT_FAMILY, DEFAULT_SPEED_PPS (像素/秒,更直观), OPACITY, FIXED_MARGIN_TOP/BOTTOM, CANVAS_SCALE_FACTOR (用于高 DPI 优化)。

    • Canvas Context: 获取 canvas.getContext('2d')

    • 状态变量:

      • lastTimestamp: 用于计算 deltaTime,实现帧率无关的平滑动画。
      • canvasWidth, canvasHeight: 画布的逻辑尺寸(CSS 像素)。
      • drawingHeight: 根据当前模式(全屏/半屏/1/4屏)计算出的实际用于绘制弹幕的垂直区域高度。
      • trackCount: 基于 drawingHeight 计算。
      • currentMode: 存储当前模式字符串。
    • Barrage 类 (Canvas 版):

      • 无 DOM 元素: 不再创建和管理 div 元素。

      • 坐标 (x, y): 直接存储数字坐标,相对于 Canvas 左上角。

      • 尺寸 (width, height): height 基于 CONFIG.FONT_SIZEwidth 使用 ctx.measureText(this.text).width 在构造时精确测量。

      • 速度 (speed): 存储为像素/毫秒,方便与 deltaTime 配合计算。

      • 生命周期: creationTime 记录创建时间戳。duration 存储固定弹幕的显示时长。isPermanent 标志用于常驻弹幕。

      • update(deltaTime): 更新滚动弹幕的 x 坐标。固定弹幕位置不变。

      • draw(ctx): 核心绘制方法。

        • 设置 ctx.fillStyle, ctx.globalAlpha, ctx.font, ctx.textBaseline, ctx.textAlign
        • 调用 ctx.fillText(this.text, drawX, this.y) 进行绘制。注意根据 textAlign 调整 drawX
      • isOffscreen(): 判断 x + width < 0

      • isExpired(): 判断 Date.now() >= this.creationTime + this.duration (仅对非永久固定弹幕)。

    • 管理器功能:

      • setupCanvas():

        • 获取设备像素比 dpr
        • 根据容器 CSS 尺寸和 dpr 设置 Canvas 的物理像素 widthheight (canvas.width, canvas.height)。
        • 设置 Canvas 的 CSS style.width, style.height
        • 存储逻辑尺寸 canvasWidth, canvasHeight
        • 调用 ctx.scale(dpr, dpr),后续绘制操作使用逻辑坐标,但输出更清晰。
        • 调用 updateDrawingArea()initializeTracks()
      • updateDrawingArea(): 根据 currentMode 计算 drawingHeight

      • initializeTracks(): 基于 drawingHeight 计算 trackCount 并初始化 tracks 数组。

      • findAvailableTrack(): 逻辑与 DOM 版本类似,但现在基于时间戳 (lastBarrageEndTime) 判断轨道可用性。增加了随机选择空闲轨道的逻辑。

      • updateTrackUsage(): 根据弹幕宽度和速度(像素/毫秒)计算弹幕完全进入屏幕所需时间,并加上缓冲时间,更新轨道的 lastBarrageEndTime

      • addBarrage(): 创建 Barrage 实例。对于滚动弹幕,查找轨道并更新轨道使用情况。对于固定弹幕,计算好 x, y 坐标(底部弹幕的 y 依赖 drawingHeight)。将实例添加到 activeBarrages

      • renderLoop(timestamp):

        1. 计算 deltaTime

        2. 如果暂停,跳过更新和绘制。

        3. ctx.clearRect(0, 0, canvasWidth, drawingHeight): 清空当前活动绘制区域。

        4. 遍历 activeBarrages:

          • 调用 barrage.update(deltaTime)
          • 检查 isOffscreen()isExpired(),如果为 true,则跳过该弹幕,不绘制也不加入下一帧的列表。
          • 检查弹幕是否在当前 drawingHeight 内,如果在,则调用 barrage.draw(ctx)
          • 将需要保留的弹幕添加到 remainingBarrages
        5. activeBarrages = remainingBarrages; 更新列表。

        6. requestAnimationFrame(renderLoop) 请求下一帧。

      • pauseBarrages(), resumeBarrages(), clearBarrages(): 控制逻辑与 DOM 版本类似,但操作的是 isPaused 状态和 activeBarrages 数组。resumeBarrages 需要重置 lastTimestampclearBarrages 只需清空 activeBarrages 数组并重置轨道。

      • setMode(mode): 更新 currentMode,按钮样式,调用 updateDrawingArea(), initializeTracks()。然后处理现有弹幕(过滤或调整位置),最后清空画布一次。

      • handleSendBarrage(): 获取用户输入,处理时长(0=常驻),调用 addBarrage

      • startRenderLoop(), stopRenderLoop(): 控制动画循环的启动和停止。

      • resizeHandler(): 窗口大小改变时,调用 setupCanvas() 重新配置画布尺寸和相关状态。为简单起见,清空了现有弹幕。在实际应用中可能需要更复杂的逻辑来保留和调整弹幕。

    • 初始化 (init): 获取上下文,调用 setupCanvas,设置初始模式,添加示例弹幕,启动 renderLoop

    • 启动: 确保在 DOM 加载完成后执行 init

Canvas 与 DOM 的对比总结:

  • 性能: Canvas 在弹幕数量极大时通常性能更好,因为它避免了大量 DOM 节点的创建、布局和渲染开销。
  • 复杂度: Canvas 实现更复杂,需要手动处理绘制、文本测量、布局、动画、事件(如果需要交互)。
  • 功能: DOM 版本更容易实现单个弹幕的事件处理(如点击)、复杂的 CSS 效果。Canvas 实现这些需要额外的工作(如碰撞检测、手动绘制效果)。
  • 文本渲染: Canvas 的文本渲染可能不如 DOM 精细(尤其是在不同浏览器和系统上),需要注意字体回退和清晰度(DPR 处理有助于改善)。
  • 内存: DOM 版本可能因大量节点占用更多内存。Canvas 本身内存占用相对固定,但 JS 对象(Barrage 实例)仍会占用内存。