FlowComments

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

当前为 2022-05-01 提交的版本,查看 最新版本

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

  1. // ==UserScript==
  2. // @name FlowComments
  3. // @namespace https://midra.me
  4. // @version 0.0.8
  5. // @description コメントをニコニコ風に流すやつ
  6. // @author Midra
  7. // @license MIT
  8. // @grant none
  9. // @compatible chrome >=84
  10. // @compatible safari >=15
  11. // ==/UserScript==
  12.  
  13. // @ts-check
  14. /* jshint esversion: 6 */
  15.  
  16. 'use strict'
  17.  
  18. /**
  19. * `FlowCommentItem`のオプション
  20. * @typedef {object} FlowCommentItemOption
  21. * @property {string} [fontFamily] フォント
  22. * @property {string} [color] フォントカラー
  23. * @property {number} [fontScale] 拡大率
  24. * @property {string} [position] 表示位置
  25. * @property {number} [speed] 速度
  26. * @property {number} [opacity] 透明度
  27. */
  28.  
  29. /**
  30. * `FlowComments`のオプション
  31. * @typedef {object} FlowCommentsOption
  32. * @property {number} [resolution] 解像度
  33. * @property {number} [lines] 行数
  34. * @property {number} [limit] 画面内に表示するコメントの最大数
  35. * @property {boolean} [autoResize] サイズ(比率)を自動で調整
  36. * @property {boolean} [autoAdjRes] 解像度を自動で調整
  37. */
  38.  
  39. /****************************************
  40. * @classdesc 流すコメント
  41. * @example
  42. * // idを指定する場合
  43. * const fcItem1 = new FlowCommentItem('1518633760656605184', 'ウルトラソウッ')
  44. * // idを指定しない場合
  45. * const fcItem2 = new FlowCommentItem(Symbol(), 'うんち!')
  46. */
  47. class FlowCommentItem {
  48. /**
  49. * 表示時間
  50. * @type {number}
  51. */
  52. static #lifetime = 6000
  53. /**
  54. * コメントID
  55. * @type {string | number | symbol}
  56. */
  57. #id
  58. /**
  59. * コメント本文
  60. * @type {string}
  61. */
  62. #text
  63. /**
  64. * X座標
  65. * @type {number}
  66. */
  67. x = 0
  68. /**
  69. * X座標(割合)
  70. * @type {number}
  71. */
  72. xp = 0
  73. /**
  74. * Y座標
  75. * @type {number}
  76. */
  77. y = 0
  78. /**
  79. * コメントの幅
  80. * @type {number}
  81. */
  82. width = 0
  83. /**
  84. * コメントの高さ
  85. * @type {number}
  86. */
  87. height = 0
  88. /**
  89. * 実際に流すときの距離
  90. * @type {number}
  91. */
  92. scrollWidth = 0
  93. /**
  94. * 行番号
  95. * @type {number}
  96. */
  97. line = 0
  98. /**
  99. * コメントを流し始めた時間
  100. * @type {number}
  101. */
  102. startTime = null
  103. /**
  104. * 実際の表示時間
  105. * @type {number}
  106. */
  107. #actualLifetime
  108. /**
  109. * オプション
  110. * @type {FlowCommentItemOption}
  111. */
  112. #option = {
  113. fontFamily: FlowComments.fontFamily,
  114. color: '#fff',
  115. fontScale: 1,
  116. position: 'flow',
  117. speed: 1,
  118. opacity: 0,
  119. }
  120.  
  121. /****************************************
  122. * コンストラクタ
  123. * @param {string | number | symbol} id コメントID
  124. * @param {string} text コメント本文
  125. * @param {FlowCommentItemOption} [option] オプション
  126. */
  127. constructor(id, text, option = null) {
  128. this.#id = id
  129. this.#text = text
  130. this.#option = { ...this.#option, ...option }
  131. this.#actualLifetime = FlowCommentItem.#lifetime * (this.#option.position === 'flow' ? 1.5 : 1)
  132. }
  133.  
  134. get id() { return this.#id }
  135. get text() { return this.#text }
  136. get lifetime() { return FlowCommentItem.#lifetime / this.#option.speed }
  137. get actualLifetime() { return this.#actualLifetime }
  138. get option() { return this.#option }
  139.  
  140. get top() { return this.y }
  141. get bottom() { return this.y + this.height }
  142. get left() { return this.x }
  143. get right() { return this.x + this.width }
  144. }
  145.  
  146. /****************************************
  147. * @classdesc コメントを流すやつ
  148. * @example
  149. * // 準備
  150. * const fc = new FlowComments()
  151. * document.body.appendChild(fc.canvas)
  152. * fc.start()
  153. *
  154. * // コメントを流す(追加する)
  155. * fc.pushComment(new FlowCommentItem(Symbol(), 'Hello, world!'))
  156. */
  157. class FlowComments {
  158. /**
  159. * インスタンスに割り当てられるIDのカウント用
  160. * @type {number}
  161. */
  162. static #id_cnt = 0
  163. /**
  164. * デフォルトのフォント
  165. * @type {string}
  166. */
  167. static fontFamily = 'Arial,"MS Pゴシック","MS PGothic",MSPGothic,MS-PGothic,sans-serif,-apple-system,Gulim,"黑体",SimHei'
  168. /**
  169. * インスタンスに割り当てられるID
  170. * @type {number}
  171. */
  172. #id
  173. /**
  174. * `requestAnimationFrame`の`requestID`
  175. * @type {number}
  176. */
  177. #animReqId = null
  178. /**
  179. * Canvas
  180. * @type {HTMLCanvasElement}
  181. */
  182. #canvas
  183. /**
  184. * CanvasRenderingContext2D
  185. * @type {CanvasRenderingContext2D}
  186. */
  187. #context2d
  188. /**
  189. * 現在表示中のコメント
  190. * @type {Array<FlowCommentItem>}
  191. */
  192. #comments
  193. /**
  194. * オプション
  195. * @type {FlowCommentsOption}
  196. */
  197. #option = {
  198. resolution: 720,
  199. lines: 11,
  200. limit: 0,
  201. autoResize: true,
  202. autoAdjRes: true,
  203. }
  204. /**
  205. * @type {ResizeObserver}
  206. */
  207. #resizeObs
  208.  
  209. /****************************************
  210. * コンストラクタ
  211. * @param {FlowCommentsOption} [option] オプション
  212. */
  213. constructor(option) {
  214. // ID割り当て
  215. this.#id = ++FlowComments.#id_cnt
  216.  
  217. // Canvas生成
  218. this.#canvas = document.createElement('canvas')
  219. this.#canvas.classList.add('mid-FlowComments')
  220. this.#canvas.dataset.fcid = this.#id.toString()
  221.  
  222. // サイズ変更を監視
  223. this.#resizeObs = new ResizeObserver(entries => {
  224. const { width, height } = entries[0].contentRect
  225.  
  226. // Canvasのサイズ(比率)を自動で調整
  227. if (this.#option.autoResize) {
  228. const rect_before = this.#canvas.width / this.#canvas.height
  229. const rect_resized = width / height
  230. if (0.01 < Math.abs(rect_before - rect_resized)) {
  231. this.#resizeCanvas()
  232. }
  233. }
  234.  
  235. // Canvasの解像度を自動で調整
  236. if (this.#option.autoAdjRes) {
  237. if (height <= 240) {
  238. this.changeResolution(240)
  239. } else if (height <= 480) {
  240. this.changeResolution(480)
  241. } else {
  242. this.changeResolution(720)
  243. }
  244. }
  245. })
  246. this.#resizeObs.observe(this.#canvas)
  247.  
  248. // CanvasRenderingContext2D
  249. this.#context2d = this.#canvas.getContext('2d')
  250.  
  251. // 初期化
  252. this.initialize(option)
  253. }
  254.  
  255. get id() { return this.#id }
  256. get option() { return this.#option }
  257. get canvas() { return this.#canvas }
  258. get context2d() { return this.#context2d }
  259. get comments() { return this.#comments }
  260.  
  261. get lineHeight() { return this.#canvas.height / (this.#option.lines + 0.5) }
  262. get lineSpace() { return this.lineHeight * 0.5 }
  263. get fontSize() { return this.lineHeight - this.lineSpace * 0.5 }
  264.  
  265. get isStarted() { return this.#animReqId !== null }
  266.  
  267. /****************************************
  268. * 初期化(インスタンス生成時には不要)
  269. * @param {FlowCommentsOption} [option] オプション
  270. */
  271. initialize(option) {
  272. this.stop()
  273. this.#option = { ...this.#option, ...option }
  274. this.#comments = []
  275. this.#animReqId = null
  276. this.initializeCanvas()
  277. }
  278.  
  279. /****************************************
  280. * Canvasの解像度を変更
  281. * @param {number} resolution 解像度
  282. */
  283. changeResolution(resolution) {
  284. if (Number.isFinite(resolution) && this.#option.resolution !== resolution) {
  285. this.#option.resolution = resolution
  286. this.initializeCanvas()
  287. }
  288. }
  289.  
  290. /****************************************
  291. * CanvasRenderingContext2Dを初期化
  292. */
  293. initializeCanvas() {
  294. this.#resizeCanvas()
  295. this.#context2d.clearRect(0, 0, this.#canvas.width, this.#canvas.height)
  296. }
  297.  
  298. /****************************************
  299. * CanvasRenderingContext2Dをリサイズ
  300. */
  301. #resizeCanvas() {
  302. // Canvasをリサイズ
  303. const { width, height } = this.#canvas.getBoundingClientRect()
  304. const ratio = (width === 0 && height === 0) ? (16 / 9) : (width / height)
  305. this.#canvas.width = ratio * this.#option.resolution
  306. this.#canvas.height = this.#option.resolution
  307.  
  308. // Canvasのスタイルをリセット
  309. this.#resetCanvasStyle()
  310.  
  311. // コメントの各プロパティを再計算
  312. this.#comments.forEach(this.#calcCommentProperty.bind(this))
  313. }
  314.  
  315. /****************************************
  316. * Canvasのスタイルをリセット
  317. */
  318. #resetCanvasStyle() {
  319. this.#context2d.font = `600 ${this.fontSize}px ${FlowComments.fontFamily}`
  320. this.#context2d.lineJoin = 'round'
  321. this.#context2d.fillStyle = '#fff'
  322. this.#context2d.shadowColor = '#000'
  323. this.#context2d.shadowBlur = this.#option.resolution / 200
  324. }
  325.  
  326. /****************************************
  327. * コメントの各プロパティを計算する
  328. * @param {FlowCommentItem} comment コメント
  329. */
  330. #calcCommentProperty(comment) {
  331. comment.width = this.#context2d.measureText(comment.text).width
  332. comment.scrollWidth = this.#canvas.width + comment.width
  333. comment.x = this.#canvas.width - comment.scrollWidth * comment.xp
  334. comment.y = this.lineHeight * comment.line
  335. }
  336.  
  337. /****************************************
  338. * コメントを追加(流す)
  339. * @param {FlowCommentItem} comment コメント
  340. */
  341. pushComment(comment) {
  342. if (this.#animReqId === null) return
  343.  
  344. //----------------------------------------
  345. // 画面内に表示するコメントを制限
  346. //----------------------------------------
  347. if (0 < this.#option.limit && this.#option.limit <= this.#comments.length) {
  348. this.#comments.splice(0, 1)
  349. }
  350.  
  351. //----------------------------------------
  352. // コメントの各プロパティを計算
  353. //----------------------------------------
  354. this.#calcCommentProperty(comment)
  355.  
  356. //----------------------------------------
  357. // コメント表示行を計算
  358. //----------------------------------------
  359. const spd_pushCmt = comment.scrollWidth / comment.lifetime
  360.  
  361. // [[1, 2], [2, 1], ~ , [11, 1]] ([line, cnt])
  362. const lines_over = [...Array(this.#option.lines)].map((_, i) => [i + 1, 0])
  363.  
  364. this.#comments.forEach(val => {
  365. // 残り表示時間
  366. const leftTime = val.lifetime * (1 - val.xp)
  367. // コメント追加時に重なる or 重なる予定かどうか
  368. const isOver =
  369. comment.left - spd_pushCmt * leftTime <= 0 ||
  370. comment.left <= val.right
  371. if (isOver) {
  372. lines_over[val.line - 1][1]++
  373. }
  374. })
  375.  
  376. // 重なった頻度を元に昇順で並べ替える
  377. const lines_sort = lines_over.sort(([, cntA], [, cntB]) => cntA - cntB)
  378.  
  379. comment.line = lines_sort[0][0]
  380. comment.y = this.lineHeight * comment.line
  381.  
  382. //----------------------------------------
  383. // コメントを追加
  384. //----------------------------------------
  385. this.#comments.push(comment)
  386. }
  387.  
  388. /****************************************
  389. * テキストを描画
  390. * @param {FlowCommentItem} comment コメント
  391. */
  392. #renderComment(comment) {
  393. this.#context2d.fillText(comment.text, ~~comment.x, ~~comment.y)
  394. }
  395.  
  396. /****************************************
  397. * ループ中に実行される処理
  398. * @param {number} time 時間
  399. */
  400. #update(time) {
  401. // Canvasをリセット
  402. this.#context2d.clearRect(0, 0, this.#canvas.width, this.#canvas.height)
  403.  
  404. this.#comments.forEach((comment, idx, ary) => {
  405. // コメントを流し始めた時間
  406. if (comment.startTime === null) {
  407. comment.startTime = time
  408. }
  409.  
  410. // コメントを流し始めて経過した時間
  411. const elapsedTime = time - comment.startTime
  412.  
  413. if (elapsedTime <= comment.actualLifetime) {
  414. // コメントの座標を更新(流すコメント)
  415. if (comment.option.position === 'flow') {
  416. comment.xp = elapsedTime / comment.lifetime
  417. comment.x = this.#canvas.width - comment.scrollWidth * comment.xp
  418. }
  419. // コメントを描画
  420. this.#renderComment(comment)
  421. } else {
  422. // 表示時間を超えたら消す
  423. ary.splice(idx, 1)
  424. }
  425. })
  426. }
  427.  
  428. /****************************************
  429. * ループ処理
  430. * @param {number} time 時間
  431. */
  432. #loop(time) {
  433. this.#update(time)
  434. if (this.#animReqId !== null) {
  435. this.#animReqId = window.requestAnimationFrame(this.#loop.bind(this))
  436. }
  437. }
  438.  
  439. /****************************************
  440. * コメント流しを開始
  441. */
  442. start() {
  443. if (this.#animReqId === null) {
  444. this.#animReqId = window.requestAnimationFrame(this.#loop.bind(this))
  445. }
  446. }
  447.  
  448. /****************************************
  449. * コメント流しを停止
  450. */
  451. stop() {
  452. if (this.#animReqId !== null) {
  453. window.cancelAnimationFrame(this.#animReqId)
  454. this.#animReqId = null
  455. }
  456. }
  457.  
  458. /****************************************
  459. * 解放(初期化してCanvasを削除)
  460. */
  461. dispose() {
  462. this.initialize()
  463. this.#resizeObs.unobserve(this.#canvas)
  464. this.#resizeObs = null
  465. this.#canvas.remove()
  466. this.#canvas = null
  467. }
  468. }