FlowComments

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

目前为 2022-05-01 提交的版本。查看 最新版本

此脚本不应直接安装,它是一个供其他脚本使用的外部库。如果您需要使用该库,请在脚本元属性加入:// @require https://update.gf.qytechs.cn/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
  }
}

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址