FlowComments

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

Tính đến 01-05-2022. Xem phiên bản mới nhất.

Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị meta // @require https://update.greatest.deepsurf.us/scripts/444119/1046001/FlowComments.js

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

// @ts-check
/* jshint esversion: 6 */

'use strict'

/**
 * `FlowCommentItem`のオプション
 * @typedef   {object}  FlowCommentItemOption
 * @property  {string}  [fontFamily]  フォント
 * @property  {string}  [color]       フォントカラー
 * @property  {number}  [fontScale]   拡大率
 * @property  {string}  [position]    表示位置
 * @property  {number}  [speed]       速度
 * @property  {number}  [opacity]     透明度
 */

/**
 * `FlowComments`のオプション
 * @typedef   {object}  FlowCommentsOption
 * @property  {number}  [resolution]  解像度
 * @property  {number}  [lines]       行数
 * @property  {number}  [limit]       画面内に表示するコメントの最大数
 * @property  {boolean} [autoResize]  サイズ(比率)を自動で調整
 * @property  {boolean} [autoAdjRes]  解像度を自動で調整
 */

/****************************************
 * @classdesc 流すコメント
 * @example
 * // idを指定する場合
 * const fcItem1 = new FlowCommentItem('1518633760656605184', 'ウルトラソウッ')
 * // idを指定しない場合
 * const fcItem2 = new FlowCommentItem(Symbol(), 'うんち!')
 */
class FlowCommentItem {
  /**
   * 表示時間
   * @type {number}
   */
  static #lifetime = 6000
  /**
   * コメント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}
   */
  startTime = null
  /**
   * 実際の表示時間
   * @type {number}
   */
  #actualLifetime
  /**
   * オプション
   * @type {FlowCommentItemOption}
   */
  #option = {
    fontFamily: FlowComments.fontFamily,
    color: '#fff',
    fontScale: 1,
    position: 'flow',
    speed: 1,
    opacity: 0,
  }

  /****************************************
   * コンストラクタ
   * @param {string | number | symbol}  id        コメントID
   * @param {string}                    text      コメント本文
   * @param {FlowCommentItemOption}     [option]  オプション
   */
  constructor(id, text, option = null) {
    this.#id = id
    this.#text = text
    this.#option = { ...this.#option, ...option }
    this.#actualLifetime = FlowCommentItem.#lifetime * (this.#option.position === 'flow' ? 1.5 : 1)
  }

  get id() { return this.#id }
  get text() { return this.#text }
  get lifetime() { return FlowCommentItem.#lifetime / this.#option.speed }
  get actualLifetime() { return this.#actualLifetime }
  get option() { return this.#option }

  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
  /**
   * デフォルトのフォント
   * @type {string}
   */
  static fontFamily = 'Arial,"MS Pゴシック","MS PGothic",MSPGothic,MS-PGothic,sans-serif,-apple-system,Gulim,"黑体",SimHei'
  /**
   * インスタンスに割り当てられるID
   * @type {number}
   */
  #id
  /**
   * `requestAnimationFrame`の`requestID`
   * @type {number}
   */
  #animReqId = null
  /**
   * Canvas
   * @type {HTMLCanvasElement}
   */
  #canvas
  /**
   * CanvasRenderingContext2D
   * @type {CanvasRenderingContext2D}
   */
  #context2d
  /**
   * 現在表示中のコメント
   * @type {Array<FlowCommentItem>}
   */
  #comments
  /**
   * オプション
   * @type {FlowCommentsOption}
   */
  #option = {
    resolution: 720,
    lines: 11,
    limit: 0,
    autoResize: true,
    autoAdjRes: true,
  }
  /**
   * @type {ResizeObserver}
   */
  #resizeObs

  /****************************************
   * コンストラクタ
   * @param {FlowCommentsOption} [option] オプション
   */
  constructor(option) {
    // ID割り当て
    this.#id = ++FlowComments.#id_cnt

    // Canvas生成
    this.#canvas = document.createElement('canvas')
    this.#canvas.classList.add('mid-FlowComments')
    this.#canvas.dataset.fcid = this.#id.toString()

    // サイズ変更を監視
    this.#resizeObs = new ResizeObserver(entries => {
      const { width, height } = entries[0].contentRect

      // Canvasのサイズ(比率)を自動で調整
      if (this.#option.autoResize) {
        const rect_before = this.#canvas.width / this.#canvas.height
        const rect_resized = width / height
        if (0.01 < Math.abs(rect_before - rect_resized)) {
          this.#resizeCanvas()
        }
      }

      // Canvasの解像度を自動で調整
      if (this.#option.autoAdjRes) {
        if (height <= 240) {
          this.changeResolution(240)
        } else if (height <= 480) {
          this.changeResolution(480)
        } else {
          this.changeResolution(720)
        }
      }
    })
    this.#resizeObs.observe(this.#canvas)

    // CanvasRenderingContext2D
    this.#context2d = this.#canvas.getContext('2d')

    // 初期化
    this.initialize(option)
  }

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

  get lineHeight() { return this.#canvas.height / (this.#option.lines + 0.5) }
  get lineSpace() { return this.lineHeight * 0.5 }
  get fontSize() { return this.lineHeight - this.lineSpace * 0.5 }

  get isStarted() { return this.#animReqId !== null }

  /****************************************
   * 初期化(インスタンス生成時には不要)
   * @param {FlowCommentsOption} [option] オプション
   */
  initialize(option) {
    this.stop()
    this.#option = { ...this.#option, ...option }
    this.#comments = []
    this.#animReqId = null
    this.initializeCanvas()
  }

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

  /****************************************
   * CanvasRenderingContext2Dを初期化
   */
  initializeCanvas() {
    this.#resizeCanvas()
    this.#context2d.clearRect(0, 0, this.#canvas.width, this.#canvas.height)
  }

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

    // Canvasのスタイルをリセット
    this.#resetCanvasStyle()

    // コメントの各プロパティを再計算
    this.#comments.forEach(this.#calcCommentProperty.bind(this))
  }

  /****************************************
   * Canvasのスタイルをリセット
   */
  #resetCanvasStyle() {
    this.#context2d.font = `600 ${this.fontSize}px ${FlowComments.fontFamily}`
    this.#context2d.lineJoin = 'round'
    this.#context2d.fillStyle = '#fff'
    this.#context2d.shadowColor = '#000'
    this.#context2d.shadowBlur = this.#option.resolution / 200
  }

  /****************************************
   * コメントの各プロパティを計算する
   * @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 === null) return

    //----------------------------------------
    // 画面内に表示するコメントを制限
    //----------------------------------------
    if (0 < this.#option.limit && this.#option.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(this.#option.lines)].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 {FlowCommentItem} comment コメント
   */
  #renderComment(comment) {
    this.#context2d.fillText(comment.text, ~~comment.x, ~~comment.y)
  }

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

    this.#comments.forEach((comment, idx, ary) => {
      // コメントを流し始めた時間
      if (comment.startTime === null) {
        comment.startTime = time
      }

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

      if (elapsedTime <= comment.actualLifetime) {
        // コメントの座標を更新(流すコメント)
        if (comment.option.position === 'flow') {
          comment.xp = elapsedTime / comment.lifetime
          comment.x = this.#canvas.width - comment.scrollWidth * comment.xp
        }
        // コメントを描画
        this.#renderComment(comment)
      } else {
        // 表示時間を超えたら消す
        ary.splice(idx, 1)
      }
    })
  }

  /****************************************
   * ループ処理
   * @param {number} time 時間
   */
  #loop(time) {
    this.#update(time)
    if (this.#animReqId !== null) {
      this.#animReqId = window.requestAnimationFrame(this.#loop.bind(this))
    }
  }

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

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

  /****************************************
   * 解放(初期化してCanvasを削除)
   */
  dispose() {
    this.initialize()
    this.#canvas.remove()
    this.#canvas = null
  }
}