Greasy Fork

FlowComments

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

目前为 2022-04-28 提交的版本。查看 最新版本

此脚本不应直接安装,它是一个供其他脚本使用的外部库。如果您需要使用该库,请在脚本元属性加入:// @require https://update.greasyfork.cloud/scripts/444119/1044798/FlowComments.js

// ==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
    }
  }
}