FlowComments

コメントをニコニコ風に流すやつ

当前为 2022-04-28 提交的版本,查看 最新版本

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.gf.qytechs.cn/scripts/444119/1044798/FlowComments.js

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         FlowComments
// @namespace    midra.me
// @version      0.0.1
// @description  コメントをニコニコ風に流すやつ
// @author       Midra
// @license      MIT
// @compatible   chrome >=84
// @grant        none
// ==/UserScript==

/* jshint esversion: 6 */

'use strict'

/****************************************
 * 型定義
 * @typedef {{
 * resolution: number,
 * opacity: number,
 * limit: number,
 * }} FlowCommentItemOption
 * 
 * @typedef {{
 * resolution: number,
 * opacity: number,
 * limit: number,
 * }} FlowCommentOption
 */

/****************************************
 * @classdesc 流すコメント
 * @example
 * // idを指定する場合
 * const fcItem1 = new FlowCommentItem('1518633760656605184', 'ウルトラソウッ')
 * // idを指定しない場合
 * const fcItem2 = new FlowCommentItem(Symbol(), 'うんち!')
 */
class FlowCommentItem {
  /**
   * コメントID
   * @type {string | number | symbol}
   */
  #id
  /**
   * コメント本文
   * @type {string}
   */
  #text
  /**
   * X座標
   * @type {number}
   */
  x = 0
  /**
   * X座標(割合)
   * @type {number}
   */
  xp = 0
  /**
   * Y座標
   * @type {number}
   */
  y = 0
  /**
   * コメントの幅
   * @type {number}
   */
  width = 0
  /**
   * コメントの高さ
   * @type {number}
   */
  height = 0
  /**
   * 実際に流すときの距離
   * @type {number}
   */
  scrollWidth = 0
  /**
   * 行番号
   * @type {number}
   */
  line = 0
  /**
   * 表示時間(期間)
   * @type {number}
   */
  lifetime = 6000
  /**
   * コメントを流し始めた時間
   * @type {number}
   */
  startTime

  /****************************************
   * コンストラクタ
   * @param {string | number | symbol} id コメントID
   * @param {string} text コメント本文
   * @param {?FlowCommentItemOption} option オプション
   */
  constructor(id, text, option) {
    this.#id = id
    this.#text = text
  }

  get id() { return this.#id }
  get text() { return this.#text }

  get top() { return this.y }
  get bottom() { return this.y + this.height }
  get left() { return this.x }
  get right() { return this.x + this.width }
}

/****************************************
 * @classdesc コメントを流すやつ
 * @example
 * // 準備
 * const fc = new FlowComments()
 * document.body.appendChild(fc.canvas)
 * fc.start()
 * 
 * // コメントを流す(追加する)
 * fc.pushComment(new FlowCommentItem(Symbol(), 'Hello, world!'))
 */
class FlowComments {
  /**
   * インスタンスに割り当てられるIDのカウント用
   * @type {number}
   */
  static #id_cnt = 0
  /**
   * インスタンスに割り当てられるID
   * @type {number}
   */
  #id
  /**
   * オプション
   * @type {FlowCommentOption}
   */
  #options = {
    resolution: 720,
    opacity: 1,
    limit: undefined,
  }
  /**
   * Canvas
   * @type {HTMLCanvasElement}
   */
  #canvas
  /**
   * CanvasRenderingContext2D
   * @type {CanvasRenderingContext2D}
   */
  #context2d
  /**
   * 現在表示中のコメント
   * @type {Array<FlowCommentItem>}
   */
  #comments
  /**
   * `AnimationFrame`の`requestID`
   * @type {number}
   */
  #_animReqId

  /****************************************
   * コンストラクタ
   * @param {FlowCommentOption} options オプション
   */
  constructor(options) {
    this.#id = ++FlowComments.#id_cnt
    this.#canvas = document.createElement('canvas')
    this.#canvas.classList.add('mid-FlowComment')
    this.#canvas.dataset.fcid = this.#id
    this.#context2d = this.#canvas.getContext('2d')
    this.initialize(options)
  }

  get id() { return this.#id }
  get options() { return this.#options }
  get canvas() { return this.#canvas }
  get context2d() { return this.#context2d }
  get comments() { return this.#comments }

  get lineHeight() { return this.#canvas.height / 11.4 }
  get lineSpace() { return this.lineHeight * 0.4 }
  get fontSize() { return this.lineHeight - this.lineSpace * 0.5 }
  get fontFamily() {
    return 'Arial,"MS Pゴシック","MS PGothic",MSPGothic,MS-PGothic,Gulim,"黑体",SimHei'
  }

  /****************************************
   * 初期化(インスタンス生成時には不要)
   * @param {FlowCommentOption} options オプション
   */
  initialize(options) {
    // オプションを設定
    if (options !== undefined) {
      if (options.resolution !== undefined) {
        this.#options.resolution = options.resolution
      }
      if (options.opacity !== undefined) {
        this.#options.opacity = options.opacity
      }
      if (options.limit !== undefined) {
        this.#options.limit = options.limit
      }
    }

    this.stop()
    this.#comments = []
    this.#_animReqId = undefined
    this.initializeCanvas()
  }

  /****************************************
   * Canvasの解像度を変更
   * @param {number} resolution 解像度
   */
  changeCanvasResolution(resolution) {
    if (Number.isFinite(resolution)) {
      this.#options.resolution = resolution
      this.initializeCanvas()
    }
  }

  /****************************************
   * CanvasRenderingContext2Dを初期化
   */
  initializeCanvas() {
    this.#resizeCanvas()
    this.#context2d.clearRect(0, 0, this.#canvas.width, this.#canvas.height)
    this.#context2d.font = `600 ${this.fontSize}px ${this.fontFamily}`
    this.#context2d.lineJoin = 'round'
    this.#context2d.fillStyle = '#fff'
    this.#context2d.shadowColor = '#000'
    this.#context2d.shadowBlur = this.#options.resolution / 200
    this.#comments.forEach(this.#calcCommentProperty.bind(this))
  }

  /****************************************
   * CanvasRenderingContext2Dをリサイズ
   */
  #resizeCanvas() {
    const { width, height } = this.#canvas.getBoundingClientRect()
    const ratio = (width === 0 && height === 0) ? (16 / 9) : (width / height)
    this.#canvas.width = ratio * this.#options.resolution
    this.#canvas.height = this.#options.resolution
  }

  /****************************************
   * コメントの各プロパティを計算する
   * @param {FlowCommentItem} comment コメント
   */
  #calcCommentProperty(comment) {
    comment.width = this.#context2d.measureText(comment.text).width
    comment.scrollWidth = this.#canvas.width + comment.width
    comment.x = this.#canvas.width - comment.scrollWidth * comment.xp
    comment.y = this.lineHeight * comment.line
  }

  /****************************************
   * コメントを追加(流す)
   * @param {FlowCommentItem} comment コメント
   */
  pushComment(comment) {
    if (this.#_animReqId === undefined) return

    //----------------------------------------
    // 画面内に表示するコメントを制限
    //----------------------------------------
    if (this.#options.limit <= this.#comments.length) {
      this.#comments.splice(0, 1)
    }

    //----------------------------------------
    // コメントの各プロパティを計算
    //----------------------------------------
    this.#calcCommentProperty(comment)

    //----------------------------------------
    // コメント表示行を計算
    //----------------------------------------
    const spd_pushCmt = comment.scrollWidth / comment.lifetime

    // [[1, 2], [2, 1], ~ , [11, 1]] ([line, cnt])
    const lines_over = [...Array(11)].map((_, i) => [i + 1, 0])

    this.#comments.forEach(val => {
      // 残り表示時間
      const leftTime = val.lifetime * (1 - val.xp)
      // コメント追加時に重なる or 重なる予定かどうか
      const isOver =
        comment.left - spd_pushCmt * leftTime <= 0 ||
        comment.left <= val.right
      if (isOver) {
        lines_over[val.line - 1][1]++
      }
    })

    // 重なった頻度を元に昇順で並べ替える
    const lines_sort = lines_over.sort(([, cntA], [, cntB]) => cntA - cntB)

    comment.line = lines_sort[0][0]
    comment.y = this.lineHeight * comment.line

    //----------------------------------------
    // コメントを追加
    //----------------------------------------
    this.#comments.push(comment)
  }

  /****************************************
   * ループ中に実行される処理(描画)
   * @param {number} now 時間
   */
  #render(now) {
    // Canvasをリセット
    this.#context2d.clearRect(0, 0, this.#canvas.width, this.#canvas.height)

    const deleteIdx = []
    this.#comments.forEach((comment, idx) => {
      // コメントを流し始めた時間
      if (comment.startTime === undefined) {
        comment.startTime = now
      }

      // コメントを流し始めて経過した時間
      const diffTime = now - comment.startTime

      if (diffTime <= comment.lifetime * 1.5) {
        // コメントの座標を更新
        comment.xp = diffTime / comment.lifetime
        comment.x = this.#canvas.width - comment.scrollWidth * comment.xp
        // コメントを描画
        this.#context2d.fillText(comment.text, comment.x, comment.y)
      } else {
        // 表示時間を超えたら消す
        deleteIdx.push(idx)
      }
    })
    // 上のループが終わってから消さないと変な挙動になる
    deleteIdx.forEach(v => this.#comments.splice(v, 1))
  }

  /****************************************
   * ループ処理
   * @param {number} time 時間
   */
  #loop() {
    this.#render(window.performance.now())
    if (this.#_animReqId !== undefined) {
      this.#_animReqId = window.requestAnimationFrame(this.#loop.bind(this))
    }
  }

  /****************************************
   * コメント流しを開始
   */
  start() {
    if (this.#_animReqId === undefined) {
      this.#_animReqId = window.requestAnimationFrame(this.#loop.bind(this))
    }
  }

  /****************************************
   * コメント流しを停止
   */
  stop() {
    if (this.#_animReqId !== undefined) {
      window.cancelAnimationFrame(this.#_animReqId)
      this.#_animReqId = undefined
    }
  }
}