Canvas 渲染相比 DOM 操作,在弹幕数量非常庞大时通常具有性能优势,但实现起来也更复杂,因为我们需要手动处理绘制、文本测量、布局和动画的每一个细节。
核心思路 (Canvas):
-
HTML 结构: 只需要一个
<canvas>元素用于绘制,以及与之前类似的控制按钮和输入区域。 -
CSS 样式: 主要用于布局容器、画布本身以及控制元件。画布的显示区域(半屏、1/4屏)可以通过 CSS 控制容器大小或在 JS 中控制绘制区域。
-
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 版本):
-
HTML 结构:
- 核心变化是使用
<canvas id="barrage-canvas">替代了之前的.barrage-overlaydiv。 - 其他控制元素(按钮、输入框)保持不变。
- 核心变化是使用
-
CSS 样式:
#barrage-canvas: 设置为position: absolute,覆盖在.barrage-container上,width: 100%,height: 100%使其 CSS 显示尺寸填充容器。pointer-events: none保持不变。- 其他样式与 DOM 版本基本一致,用于布局和美化。屏幕模式的视觉效果(如容器高度变化)依然可以由 CSS 控制,但 Canvas 内部的绘制区域由 JS 管理。
-
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_SIZE。width使用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 的物理像素width和height(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):-
计算
deltaTime。 -
如果暂停,跳过更新和绘制。
-
ctx.clearRect(0, 0, canvasWidth, drawingHeight): 清空当前活动绘制区域。 -
遍历
activeBarrages:- 调用
barrage.update(deltaTime)。 - 检查
isOffscreen()或isExpired(),如果为true,则跳过该弹幕,不绘制也不加入下一帧的列表。 - 检查弹幕是否在当前
drawingHeight内,如果在,则调用barrage.draw(ctx)。 - 将需要保留的弹幕添加到
remainingBarrages。
- 调用
-
activeBarrages = remainingBarrages;更新列表。 -
requestAnimationFrame(renderLoop)请求下一帧。
-
-
pauseBarrages(),resumeBarrages(),clearBarrages(): 控制逻辑与 DOM 版本类似,但操作的是isPaused状态和activeBarrages数组。resumeBarrages需要重置lastTimestamp。clearBarrages只需清空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实例)仍会占用内存。