FlowComments

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

Verze ze dne 29. 04. 2022. Zobrazit nejnovější verzi.

Tento skript by neměl být instalován přímo. Jedná se o knihovnu, kterou by měly jiné skripty využívat pomocí meta příkazu // @require https://update.greatest.deepsurf.us/scripts/444119/1045443/FlowComments.js

// ==UserScript==
// @name         FlowComments
// @namespace    midra.me
// @version      0.0.4
// @description  コメントをニコニコ風に流すやつ
// @author       Midra
// @license      MIT
// @grant        none
// @compatible   chrome >=84
// ==/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 {
  /**
   * コメント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}
   */
  #lifetime = 6000
  /**
   * オプション
   * @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 }
  }

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

  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,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 = null) {
    // 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 if (height <= 720) {
          this.changeResolution(720)
        } else {
          this.changeResolution(1080)
        }
      }
    })
    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 = null) {
    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} nowTime 時間
   */
  #update(nowTime) {
    // Canvasをリセット
    this.#context2d.clearRect(0, 0, this.#canvas.width, this.#canvas.height)

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

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

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

  /****************************************
   * ループ処理
   */
  #loop() {
    this.#update(window.performance.now())
    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
    }
  }
}