HTML5 CANVAS 弹幕插件
概述
详细
修改了普通弹幕运动的算法,新增了部分功能,具体请参看附件里的CHANGELOG.md和README.md
一、概述
说实话,从第二版到现在又过了半年,本来以为可能不会写第三版的,顶多将第二版的代码重构下就可以了,没想到还是花了一个星期左右续写了第三版。主要是因为第二版中 播放器模块和弹幕模块耦合得太严重了,远远达不到我想要的效果,所以续写了第三版。这次的代码将更轻,我去除了播放器模块,使得插件的适用范围更加的扩大,而且让我有点惊喜的是在写第三版的过程中又让弹幕系统的性能进一步得到了提升,可以讲也是额外的惊喜了。
由于第三版我是用ES6语法写的,所以兼容性不是很好(没错,我只是在针对IE10以下),就算用babel转成ES5,IE依旧毒,所以后面我会抽个时间去写个ES5全兼容版本的,不考虑IE或者只是对源码感兴趣的可以尽情使用。
二、程序实现
源码总共由4部分组成:
-
普通弹幕类
-
高级弹幕类
-
主程序类
-
封装输出函数
第4个部分比较简单,就是将所有内部的接口进行过滤,选择性地暴露一些我想暴露的内部功能接口,并且提供一个对外的接口,增加一点稳定性罢了。源码如下:
let DanMuer = function(wrapper,opts){ let proxyDMer = new Proxy( new DMer(wrapper,opts), { get : function(target,key){ if(typeof target[key] == "function") return target[key].bind(target); return target[key]; } }); //保证this指向原对象 let DM = proxyDMer; //选择性的暴露某些接口 return { pause : DM.pause, //暂停 run : DM.run, //继续 start : DM.start, //运行 stop : DM.stop, //停止 changeStyle : DM.changeStyle, //修改普通弹幕全局样式 addGradient : DM.addGradient, //普通弹幕渐变 setSize : DM.setSize, //修改宽高 inputData : DM.inputData, //向普通弹幕插入数据 inputEffect : DM.inputEffect, //向高级弹幕插入数据 clear : DM.clear, //清除所有弹幕 reset : DM.reset, //重新从某个弹幕开始 addFilter : DM.addFilter, //添加过滤 removeFilter : DM.removeFilter, //删除过滤 disableEffect : DM.disableEffect, //不启用高级弹幕 enableEffect : DM.enableEffect, //启用高级弹幕 getSize : DM.getSize, //获取宽高, getFPS : DM.getFPS //获取fps }; };//提供对外的引用接口if( typeof module != 'undefined' && module.exports ){ module.exports = DanMuer; } else if( typeof define == "function" && define.amd ){ define(function(){ return DanMuer;}); } else { window.DanMuer = DanMuer; }
第3个部分属于入口类,事实上每次调用插件都会先对第3部分进行实例化,这里主要保存一些对外暴露的API接口,还有就是插件的初始化函数,事件函数以及主循环函数,用于对插件总体的控制,部分源码如下:
//初始化 constructor(wrap,opts = {}){ if(!wrap){ throw new Error("没有设置正确的wrapper"); } //datas this.wrapper = wrap; this.width = wrap.clientWidth; this.height = wrap.clientHeight; this.canvas = document.createElement("canvas"); this.canvas2 = document.createElement("canvas"); this.normal = new normalDM(this.canvas,opts); //这里是普通弹幕的对象 this.effect = new effectDM(this.canvas2,opts); //这里是高级弹幕的对象 this.name = opts.name || ""; //没卵用 this.fps = 0; //status this.drawing = opts.auto || false; this.startTime = new Date().getTime(); //fn this[init](); this[loop](); if(opts.enableEvent) this.initEvent(opts); } [init](){ //生成对应的canvas this.canvas.style.cssText = "position:absolute;z-index:100;top:0px;left:0px;"; this.canvas2.style.cssText = "position:absolute;z-index:101;top:0px;left:0px;"; this.setSize(); this.wrapper.appendChild(this.canvas); this.wrapper.appendChild(this.canvas2); } //loop [loop](normal = this.normal,effect = this.effect,prev = this.startTime){ let now = new Date().getTime(); if(!this.drawing){ normal.clearRect(); effect.clearRect(); return false; } else { let [w,h,time] = [this.width,this.height,now - prev]; this.fps = 1000 / time >> 0; //这里进行内部的循环操作 normal.update(w,h,time); effect.update(w,h,time); } requestAnimationFrame( () => { this[loop](normal,effect,now); } ); } //主要对鼠标右键进行绑定 initEvent(opts){ let [el,normal,searching] = [this.canvas2,this.normal,false]; el.onmouseup = function(e){ e = e || event; if( searching ) return false; searching = true; if( e.button == 2 ){ let [pos,result] = [e.target.getBoundingClientRect(),""]; let [x,y,i,items,item] = [ e.clientX - pos.left, e.clientY - pos.top, 0, normal.save ]; for( ; item = items[i++]; ){ let [ix,iy,w,h] = [item.x, item.y, item.width + 10, item.height]; if( x < ix || x > ix + w || y < iy - h/2 || y > iy + h/2 || item.hide || item.recovery ) continue; result = item; break; } let callback = opts.callback || function(){}; callback(result); searching = false; } }; el.oncontextmenu = function(e){ e = e || event; e.preventDefault(); }; }
源码最主要的就是第1部分和第2部分,大家在git->src里面可以看到两个类分别对应的文件,源码里面我的注释打了很多,而且每个函数的长度都不长,很容易看懂,这里就不对每一个功能做具体介绍了,下面主要讲讲几个比较重要的函数和设计思想:
/*循环,这里是对主程序暴露的主要接口,用于普通弹幕内部的循环工作,其实工作流程主要由几个步骤组成: ** 1.判断全局样式是否发生变化,保持全局样式的准确性 ** 2.判断当前弹幕机的状态(如暂停、运行等)并进行相关操作 ** 3.更新for循环的初始下标(startIndex),主要是用于性能的优化 ** 4.计算每个弹幕的状态 ** 5.绘制弹幕 ** 6.对每个弹幕的状态进行评估,如果已经显示完成就进行回收 ** 基本上其他的功能都是围绕这些步骤开始拓展和完善,明白了工作原理后其他的函数就很好理 ** 解了,都是为了完成这些工作流程而进行的,而且基本上源码里都有注释,这里就不详细说了 */ update(w,h,time){ let [items,cxt] = [this.save,this.cxt]; this.globalChanged && this.initStyle(cxt); //初始化全局样式 !this.looped && this.countWidth(items); //计算文本宽度以及初始化位置(只执行一次) if( this.paused ) return false; //暂停 this.refresh(items); //更新初始下标startIndex let [i,item] = [this.startIndex]; cxt.clearRect(0,0,w,h); for( ; item = items[i++]; ){ this.step(item,time); this.draw(item,cxt); this.recovery(item,w); } }
针对普通弹幕类还有一个有点难理解的是“通道”的获取。这里的“通道”是指弹幕从右往左运行时所在的那一行位置,这些通道是在canvas尺寸变化时生成的,不同类型的弹幕都有其通道集合。当一条新弹幕需要显示在canvas上时需要去获取它被分配的位置,也就是通道,通道被占用时,该行将不会重新放置新的弹幕, 当通道已经被分配完成后,将会随机生成一条临时通道,临时通道的位置随机出现,并且临时通过被释放时不会被收回通道集合中,而正常通道会被收回到集合中以待被下一个弹幕调用。下面是代码:
//生成通道行 countRows(){ //保存临时变量 let unitHeight = parseInt(this.globalSize) + this.space; let [rowNum , rows] = [ ( ( this.height - 20 ) / unitHeight ) >> 0, this.rows ]; //重置通道 for( let key of Object.keys(rows) ){ rows[key] = []; } //重新生成通道 for( let i = 0 ; i < rowNum; i++ ){ let obj = { idx : i, y : unitHeight * i + 20 }; rows.slide.push(obj); i >= rowNum / 2 ? rows.bottom.push(obj) : rows.top.push(obj); } //更新实例属性 this.unitHeight = unitHeight; this.rowNum = rowNum; } //获取通道 getRow(item){ //如果该弹幕正在显示中,则返回其现有通道 if( item.row ) return item.row; //获取新通道 const [rows,type] = [this.rows,item.type]; const row = ( type != "bottom" ? rows[type].shift() : rows[type].pop() ); //生成临时通道 const tempRow = this["getRow_"+type](); if( row && item.type == "slide" ){ item.x += ( row.idx * 8 ); item.speed += ( row.idx / 3 ); } //返回分配的通道 return row || tempRow; } getRow_bottom(){ return { y : 20 + this.unitHeight * ( ( Math.random() * this.rowNum / 2 + this.rowNum / 2 ) << 0 ), speedChange : false, tempItem : true }; } getRow_slide(){ return { y : 20 + this.unitHeight * ( ( Math.random() * this.rowNum ) << 0 ), speedChange : true, tempItem : true }; } getRow_top(){ return { y : 20 + this.unitHeight * ( ( Math.random() * this.rowNum / 2 ) << 0 ), speedChange : false, tempItem : true }; }
3、具体设计到哪些代码
三、html部分代码
html部分代码展示:
<div class="setting-content"> <div class="setting-list addNormal" data-status="show"> <div class="setting-item"> <label>文本:</label> <input type="text" id="normal-text" placeholder="你可以输入一段文字" > </div> <div class="setting-item"> <label>数量:</label> <input type="tel" id="normal-num" placeholder="你可以输入一个数字" maxlength="6" > </div> <div class="setting-item"> <button id="normal-btn">确定</button> </div> </div> <div class="setting-list addEffect" data-status="hide"> <div class="setting-item"> <label>类型:</label> <select id="effect-sel"> <option value="text">文本</option> <option value="rect">方形</option> <option value="circle">圆形</option> </select> </div> <div class="setting-item"> <div class="effect-list effectText"> <div class="effect-item"> <label>内容:</label> <input type="text" id="effect-text" value="我是一条弹幕" > </div> <div class="effect-item"> <label>字体大小:</label> <input type="text" id="fsize" value="26px" class="inline-input" > <label>字体粗细:</label> <input type="text" id="fweight" value="normal" class="inline-input" > </div> </div> <div class="effect-list effectRect" data-status="hide"> <div class="effect-item"> <label>宽度:</label> <input type="tel" id="rw" value="100" class="inline-input" > <label>高度:</label> <input type="tel" id="rh" value="100" class="inline-input" > </div> </div> <div class="effect-list effectCircle" data-status="hide"> <div class="effect-item"> <label>半径:</label> <input type="tel" id="radius" value="10" > </div> </div> <div class="effect-content"> <div class="effect-item"> <label>起始点 X:</label> <input type="tel" id="sx" value="0" class="inline-input" > <label>Y:</label> <input type="tel" id="sy" value="0" class="inline-input" > </div> <div class="effect-item"> <label>结束点 X:</label> <input type="tel" id="ex" value="0" class="inline-input" > <label>Y:</label> <input type="tel" id="ey" value="0" class="inline-input" > </div> <div class="effect-item"> <label>起始缩放值 X:</label> <input type="tel" id="scaleSX" value="1" class="inline-input" > <label>Y:</label> <input type="tel" id="scaleSY" value="1" class="inline-input" > </div> <div class="effect-item"> <label>结束缩放值 X:</label> <input type="tel" id="scaleEX" value="1" class="inline-input" > <label>Y:</label> <input type="tel" id="scaleEY" value="1" class="inline-input" > </div> <div class="effect-item"> <label>起始斜切角度 X:</label> <input type="tel" id="skewSX" value="0" class="inline-input" > <label>Y:</label> <input type="tel" id="skewSY" value="0" class="inline-input" > </div> <div class="effect-item"> <label>结束斜切角度 X:</label> <input type="tel" id="skewEX" value="0" class="inline-input" > <label>Y:</label> <input type="tel" id="skewEY" value="0" class="inline-input" > </div> <div class="effect-item"> <label>起始旋转角度:</label> <input type="tel" id="sr" value="0" class="inline-input" > <label>结束旋转角度:</label> <input type="tel" id="er" value="0" class="inline-input" > </div> <div class="effect-item"> <label>填充颜色:</label> <input type="text" id="fcolor" value="#66ccff" class="inline-input" > <label>描边颜色:</label> <input type="text" id="scolor" value="#cccccc" class="inline-input" > </div> <div class="effect-item"> <label>透明度:</label> <input type="tel" id="opa" value="1" class="inline-input" > <label>持续时间:</label> <input type="tel" id="dur" value="3000" class="inline-input" > </div> </div> </div> <div class="setting-item"> <button id="save-btn">保存为第<em>1</em>步</button> <button id="effect-btn">确定</button> </div> </div> <div class="setting-list addFilter" data-status="hide"> <div class="setting-item"> <label>添加 属性:</label> <input type="text" id="filter-prop" placeholder="" class="inline-input" > <label>值:</label> <input type="text" id="filter-val" placeholder="" class="inline-input" > </div> <div class="setting-item"> <button id="filter-btn">确定</button> </div> <div class="setting-item"> <label>删除 属性:</label> <input type="text" id="filter-del-prop" placeholder="" class="inline-input" > <label>值:</label> <input type="text" id="filter-del-val" placeholder="" class="inline-input" > </div> <div class="setting-item"> <button id="filter-del-btn">确定</button> </div> </div> <div class="setting-list addStyle" data-status="hide"> <div class="setting-item"> <label>字体大小:</label> <input type="text" id="gfsize" value="24px" class="inline-input" > <label>字体粗细:</label> <input type="text" id="gfweight" value="normal" class="inline-input" > </div> <div class="setting-item"> <label>字体颜色:</label> <input type="text" id="gfcolor" value="#66ccff" class="inline-input" > <label>透明度:</label> <input type="tel" id="gfopa" value="1" class="inline-input" > </div> <div class="setting-item"> <button id="changeStyle-btn">确定</button> </div> </div> <div class="setting-list addControl" data-status="hide"> <div class="setting-item"> <button id="start">启动</button> <button id="stop">停止</button> <button id="pause">暂停</button> <button id="run">继续</button> <button id="clear">清除弹幕</button> <button id="full">大屏</button> <button id="small">小屏</button> <button id="disable">禁用高级弹幕</button> <button id="enable">起用高级弹幕</button> <button id="getsize">获取宽高</button> </div> <div class="setting-item"> </div> </div> </div> </div>
四、操作、运行效果
1、文件截图
双击demos文件夹可看到运行文件
双击index.html后,操作截图:
添加字幕如下:
点击确定,提示如下:
添加成功后,选择“选项-控制项”,效果如下:
点击启动:
就出现了字幕,效果实现完毕。
五、其他补充
高级弹幕类与普通弹幕类有点微妙的差别,但总体是一样,唯一需要在意的是与计算相关的代码,因为不难所以这里也不做继续说明了,请参看源码里的注释。
就第二版来说,第三版性能更好,而且实现了播放器模块和弹幕模块的解耦,也就是说相比第二版,第三版 可以适用但不限于播放器,可用性更高,而且实现了高级弹幕的发送,未来将慢慢补齐更多的功能和代码重构。