实时弹幕系统的设计与实现
发布于 4个月前 作者 cstackess 2729 次浏览 来自 分享

看新年晚会的时候,发现最大的乐趣就是微信上墙了,但是量大了要等好久才能看见自己发的,为什么不能是弹幕的形式呢? 发现在GitHub上开源了一个JS弹幕模块核心CommentCoreLibrary,慢慢开始学习Node.js的一套。原来是比较做后台开发的,也是第一次做这样的分享,请大家多多指教啦……

一、Express

Express是Node.js最流行的一款web框架,小而灵活。Node.js和npm的安装配置可以参考这里

可以通过npm安装Express(参考),也可以使用Express application generator快速产生一个Express样例(参考)

对于Express初学者,用Express application generator生成样例更有利于快速上手。因此就以此为例:

# install Express application generator 
$ npm install express-generator -g

# create an Express app named danmaku
$ express danmaku

# install dependencies
$ cd danmaku
$ npm install

# run the app on Windows
$ set DEBUG=danmaku & node .\bin\www
# or
$ npm start

关于set DEBUG=danmaku可以见此文。 可以用npm start启动服务器是因为在packege.json中有了这么一段:

  "scripts": {
    "start": "node ./bin/www"
  }

Express的4比之3,把服务器配置和服务器启动做了分离,原来都在app.js里,现在将启动代码放到了www中。 现在,浏览一遍这个Express样例,对这框架就可以知道个大概了。

  • bin:存放启动项目的脚本文件
  • node_modules:存放所有的项目依赖库
  • public:静态文件(css、js、img等)
  • routes:路由文件(MVC中的C,controller)
  • views:页面文件(jade或ejs模板)
  • package.json:项目依赖配置及开发者信息
  • app.js:应用核心配置文件

更多参考:

  1. Node.js开发框架Express3.0开发手记–从零开始
  2. Node.js开发框架Express4.x
  3. Express实例

二、路由

将实时弹幕系统实际上是分为三个角色:

  • 服务端:监听客户端连接、弹幕事件等并响应。
  • 发射客户端:由用户发射弹幕。以emitCtrl.js作为emit页面的controller。
  • 屏幕客户端:接收弹幕并显示。以indexCtrl.js作为index页面的controller。

structure.png

添加 routes/indexCtrl.js

var express = require('express');
var router = express.Router();

/* GET home page. */
router.get('/', function(req, res, next) {
  res.render('index',{title:"danmaku"});
});

module.exports = router;

添加 routes/emitCtrl.js

var express = require('express');
var router = express.Router();

/* GET emit page. */
router.get('/', function(req, res, next) {
  res.render('emit');
});

module.exports = router;

修改 app.js

var indexCtrl = require('./routes/indexCtrl');
var emitCtrl = require('./routes/emitCtrl');
...
app.use('/', indexCtrl);
app.use('/emit', emitCtrl);

启动后可查看到index页面。 index_0.png

在后面还会对emitCtrl.js增加弹幕配置的文件config.json的读取。


三、屏幕客户端

1. 静态

CommentCoreLibrary是GitHub上开源的JS弹幕模块核心,提供从基本骨架到高级弹幕的支持。

考虑到实际,感觉并不应该引入外部库。如果作为外部库用,需要

$ npm install comment-core-library --save

使用时(去除public)

<link rel="stylesheet" href="/node_modules/comment-core-library/build/style.css" />
<script src="/node_modules/comment-core-library/build/CommentCoreLibrary.js"></script>

另外CommentCoreLibrary模块也有点笨重。

所以换种方式,将CommentCoreLibrary.js放入public/javascripts,style.css放入public/stylesheets中

添加views/index.jade

doctype html
html
    head
        title= title
        link(rel='stylesheet', href='/stylesheets/style.css')
        link(rel='stylesheet', href='/stylesheets/index.css')
        script(src='/javascripts/CommentCoreLibrary.js')
    body
        #my-player.abp(style='width:100%; height:600px; background:#000;')
            #my-comment-stage.container
        ul#messages
        script(src='http://cdn.bootcss.com/jquery/2.1.3/jquery.js')
        script(src='/javascripts/index.js')

添加public/stylesheets/index.css

* { margin: 0; padding: 0; box-sizing: border-box; }
#messages { list-style-type: none; margin: 0; padding: 0; }
#messages li { padding: 5px 10px; }
#messages li:nth-child(odd) { background: #eee; }
body {
    margin:0px;
    padding:0px;
    font-family: "Segoe UI", "Microsoft Yahei", sans-serif;
}

添加public/javascripts/index.js

window.addEventListener('load', function () {
    // 在窗体载入完毕后再绑定
    var CM = new CommentManager($('#my-comment-stage'));
    CM.init();
    // 先启用弹幕播放(之后可以停止)
    CM.start();
    // 开放 CM 对象到全局这样就可以在 console 终端里操控
    window.CM = CM;
});

然后在Console里怒射一弹:

var danmaku = {
    "mode": 1,
    "text": "hello world",
    "stime": 0,
    "size": 25,
    "color": 0xff00ff,
    "dur": 10000
};
CM.send(danmaku);

不过这其实根本没用上服务器,也就是静态网页一样的效果。

index_1.png

2. 动态(服务端)

动态是实现一个真正的“屏幕客户端”,监听等待“显示弹幕”的事件,并实时显示。

CommentCoreLibrary的Doc中有一段:

实时弹幕也需要后端服务器的支持。实时弹幕可以采取Polling(定时读取)或者 Push Notify(监听等待)两个主动和被动模式实现。

WebSocketHTML5开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。既然是双向通信,就意味着服务器端和客户端可以同时发送并响应请求,而不再像HTTP的请求和响应。WebSocket通信协议于2011年被IETF定为标准RFC 6455,WebSocketAPI被W3C定为标准。知乎上关于WebSocket的科普

Socket.IO是一个开源的WebSocket库,它通过Node.js实现WebSocket服务端,同时也提供客户端JS库。Socket.IO支持以事件为基础的实时双向通讯,它可以工作在任何平台、浏览器或移动设备。 Socket.IO支持4种协议:WebSocket、htmlfile、xhr-polling、jsonp-polling,它会自动根据浏览器选择适合的通讯方式,从而让开发者可以聚焦到功能的实现而不是平台的兼容性,同时具有不错的稳定性和性能。 p.s. 实际过程中踩到了phpwebsocket的坑。

用npm导入Socket.IO:

npm install socket.io --save

修改www(Express4从app.js里把启动分出来了)

// Create socket.io
var io = require('socket.io')(server);
...
// Wait for socket event
io.on('connection', function(socket){
    console.log('a user connected');
    socket.on('disconnect', function(){
        console.log('user disconnected');
    });
    socket.on('danmaku send', function(msg){
        console.log('message: ' + msg);
        io.emit('danmaku show', msg);
    });
});

修改index.jade

script(src='http://cdn.bootcss.com/socket.io/1.3.2/socket.io.js')
script(src='http://cdn.bootcss.com/jquery/2.1.3/jquery.js')
script(src='/javascripts/index.js')

修改index.js

window.addEventListener('load', function () {
    // 在窗体载入完毕后再绑定
    var CM = new CommentManager($('#my-comment-stage'));
    CM.init();
    // 先启用弹幕播放(之后可以停止)
    CM.start();
    // 开放 CM 对象到全局这样就可以在 console 终端里操控
    window.CM = CM;
    
    var socket = io();
    socket.on('danmaku show', function (msg) {
        console.log(msg);
        $('#messages').append($('<li>').text(msg));
        var danmaku = JSON.parse(msg);
        CM.send(danmaku);
    });
});

这样就由服务端监听了“connection”、“disconnect”和“danmaku send”三个事件,特别是在收到“danmaku send”时会发送“danmaku show”事件。而屏幕客户端监听“danmaku show”事件,并把传递来的弹幕显示出来。

启动后打开index,确实能看到"connection"事件执行的提示。 index_2.png

四、发射客户端

发射客户端发送“danmaku send”事件及弹幕给服务端。

除此之外,在CommentCoreLibrary里可以对弹幕属性进行设置,比如文字大小、模式、颜色,将它们的可选值写成配置文件,并设定默认值。

添加public/jsons/config.json

{"sizes":[{"size":12,"title":"非常小"},{"size":16,"title":"较小"},{"size":18,"title":"小"},{"size":25,"title":"中"},{"size":36,"title":"大"},{"size":45,"title":"较大"},{"size":64,"title":"非常大"}],

"modes":[{"mode":1,"title":"顶端滚动"},{"mode":2,"title":"底端滚动"},{"mode":5,"title":"顶端渐隐"},{"mode":4,"title":"底端渐隐"},{"mode":6,"title":"逆向滚动"}],

"colors":[{"color":"000000","title":"黑色"},{"color":"C0C0C0","title":"灰色"},{"color":"ffffff","title":"白色"},{"color":"ff0000","title":"红色"},{"color":"00ff00","title":"绿色"},{"color":"0000ff","title":"蓝色"},{"color":"ffff00","title":"黄色"},{"color":"00ffff","title":"墨绿"},{"color":"ff00ff","title":"洋红"}],

"inits":{"size":3,"mode":0,"color":4}}

修改emitCtrl.js,读取配置

var fs = require('fs');
...
/* GET emit page. */
router.get('/', function (req, res, next) {
    var config = JSON.parse(fs.readFileSync(__dirname + './../public/jsons/config.json'));
    res.render('emit', { title: 'Emitter', sizes: config.sizes, modes: config.modes, colors: config.colors, inits: config.inits});
});

添加views/emit.jade

doctype html
html
    head
        title= title
        meta(name='viewport', content='width=device-width, initial-scale=1,maximum-scale=1')
        link(rel='stylesheet',href='http://cdn.bootcss.com/jquery-mobile/1.4.3/jquery.mobile.css')
        script(src='http://cdn.bootcss.com/socket.io/1.3.2/socket.io.js')
        script(src='http://cdn.bootcss.com/jquery/2.1.3/jquery.min.js')
        script(src='http://cdn.bootcss.com/jquery-mobile/1.4.3/jquery.mobile.js')
    body
        div(data-role='page')
            div(data-role='content')
                div.ui-grid-b
                    a#size.ui-btn.ui-btn-inline.ui-block-a(href='#popupMenu_font', data-rel='popup', data-transition='pop',data-position-to="window",danmaku-size= sizes[inits.size].size )= sizes[inits.size].title
                    #popupMenu_font(data-role='popup', data-theme='b',data-overlay-theme='b', style='min-width:210px;')
                        ul(data-role='listview')
                            each val, index in sizes
                                li
                                    a(data-rel='back',danmaku-size=val.size)= val.title
                    a#mode.ui-btn.ui-btn-inline.ui-block-b(href='#popupMenu_mode', data-rel='popup', data-transition='pop',data-position-to="window",danmaku-mode= modes[inits.mode].mode  )= modes[inits.mode].title
                    #popupMenu_mode(data-role='popup', data-theme='b',data-overlay-theme='b', style='min-width:210px;')

                        ul(data-role='listview')
                            each val, index in modes
                                li
                                    a(data-rel='back',danmaku-mode=val.mode)= val.title
                    a#color.ui-btn.ui-btn-inline.ui-block-c(href='#popupMenu_color', data-rel='popup', data-transition='pop',data-position-to="window",danmaku-color= colors[inits.color].color  )= colors[inits.color].title
                    #popupMenu_color(data-role='popup', data-theme='b',data-overlay-theme='b', style='min-width:210px;')
                        .ui-grid-b
                            - var i=0;
                            each val, index in colors
                                case i++%3
                                    when 0: a.ui-block-a(data-rel="back", style='background-color:#'+val.color+';min-height:60px;line-height:60px;text-align:center',danmaku-color=val.color)= val.title
                                    when 1: a.ui-block-b(data-rel="back", style='background-color:#'+val.color+';min-height:60px;line-height:60px;text-align:center',danmaku-color=val.color)= val.title
                                    when 2: a.ui-block-c(data-rel="back", style='background-color:#'+val.color+';min-height:60px;line-height:60px;text-align:center',danmaku-color=val.color)= val.title 

                textarea#msg(placeholder='来一发弹幕~')
                button#btnSend 发射
        script(src='/javascripts/emit.js')

添加public/javascripts/emit.js

var socket = io();

$('#popupMenu_font a').click(function(e){
    $('#size').text($(e.target).text()).attr("danmaku-size",$(e.target).attr("danmaku-size"));
});

$('#popupMenu_mode a').click(function(e){
    $('#mode').text($(e.target).text()).attr("danmaku-mode",$(e.target).attr("danmaku-mode"));
});

$('#popupMenu_color a').click(function(e){
    $('#color').text($(e.target).text()).attr("danmaku-color",$(e.target).attr("danmaku-color"));
});

$('#btnSend').click(function(e){
    e.preventDefault();
    var danmaku = {
        "mode": Number($("#mode").attr("danmaku-mode")),
        "text": $('#msg').val(),
        "stime":0,
        "size": Number($("#size").attr("danmaku-size")),
        "color":parseInt($("#color").attr("danmaku-color"),16),
        "dur":10000
    };
    var msg=JSON.stringify(danmaku);
    console.log(msg);
    socket.emit('danmaku send',msg);
    $('#msg').val("");
});

最后整个效果就是这样啦~ 源码在此https://github.com/cstackess/danmaku emit.png

7 回复

我也踩到了phpwebsocket的坑,才转的node.

@hezedu 是的,感觉Socket.IO是一众WebSocket实现中相较出色的

很不错~~,mark

谢谢支持…… 一般真的需要用才是我做东西的动力……

Gruntfile.coffee怎么不用js文件?

回到顶部