Shows a Taiwan stock ticker above the prompt and PTT danmaku while Claude is working

Claude 工作時,在輸入框上方顯示台股自選股報價,並可開啟 PTT Stock 版「盤中閒聊」/「盤後閒聊」彈幕。
| 指令 | 作用 |
|---|---|
/twstock | 顯示目前設定 |
/twstock add 2317 2454 | 新增自選股 |
/twstock remove 0050 | 移除自選股 |
/twstock pin | 切換常駐顯示 / 只在 Claude 工作時顯示 |
/twstock refresh | 立即更新報價與推文 |
/twstock danmaku | 開關彈幕(預設關閉) |
/twstock danmaku width 100 | 設定彈幕寬度上限(格數;實際寬度會自動配合畫面,不會超過) |
/twstock block 字詞 ... | 加入過濾字詞,含這些字的推文不顯示 |
/twstock unblock 字詞 ... | 移除過濾字詞 |
依台灣時間決定抓 PTT Stock 版當天的哪一篇閒聊文:
| 時段 | 抓的文章 |
|---|---|
| 週一~週五 09:00–13:30 | 當天的「盤中閒聊」 |
| 週一~週五 13:30 以後(到午夜) | 當天的「盤後閒聊」 |
| 週一~週五 09:00 以前、週六、週日 | 不自動抓 |
2026/10/07)和「盤中閒聊」或「盤後閒聊」的文章,Re:、Fw: 開頭的不算;有好幾篇符合(例如開了續集)就用最新那篇。/twstock pin 設成常駐)才會抓,閒置時不發請求。/twstock refresh/twstock danmaku 開啟彈幕/twstock pin 切換成常駐彈幕會記住「這篇文章已經讀到第幾則推文」(不是記時間),用來分辨哪些是新推文:
下面這些情況會從頭重來(回到第 1 步):
其他顯示規則:
/twstock danmaku width 設上限。證交所基本市況報導網站與 PTT 網頁版,皆非正式 API,僅供個人使用。
hooks/register.js 454 lines1// tw-ticker: 在 Claude 工作時,於輸入框上方顯示台股自選股報價,以及 PTT Stock 版盤中閒聊的彈幕
2// 報價來源:證交所「基本市況報導」網站(非正式 API,僅供個人使用)
3// 彈幕來源:PTT 網頁版 Stock 版當日「盤中閒聊」推文,收盤後改用「盤後閒聊」(只顯示內容,不顯示帳號)
4
5import { parsePushes, findDailyThread, findPrevPage, strWidth, clipWidth, renderLane, stepLanes } from './danmaku.js'
6
7const BASE = 'https://mis.twse.com.tw/stock/api/getStockInfo.jsp'
8const PTT_INDEX = 'https://www.ptt.cc/bbs/Stock/index.html'
9const PTT_INIT = { headers: { Cookie: 'over18=1' } }
10const INDEX_CH = 'tse_t00.tw' // 發行量加權股價指數,固定顯示在最前面
11const DEFAULT_LIST = ['2330', '0050']
12const POLL_MS = 60_000 // 報價:每分鐘最多一次
13const DM_POLL_MS = 90_000 // 彈幕:每 90 秒最多抓一次推文
14const DM_RECHECK_MS = 10 * 60_000 // 每 10 分鐘重新確認一次今天的閒聊串(可能開了續集)
15const FRAME_MS = 300 // 彈幕每 0.3 秒移動一格
16const LANES = 2
17const GAP = 6 // 同一軌道兩則彈幕之間至少空幾格
18const MAX_ITEM_W = 60 // 單則彈幕最長幾格(PTT 推文最長約 48 格,所以一般不會截)
19const MAX_QUEUE = 80
20const FIRST_BATCH = 15 // 剛開啟時先放最近幾則
21const POOL_SIZE = 50 // 沒有新推文時,從最近幾則輪播
22const TW_OFFSET_MS = 8 * 3600 * 1000 // 台灣時間 UTC+8
23
24// ---------- 狀態 ----------
25let watchlist = [...DEFAULT_LIST]
26let pinned = false
27let working = false
28let marketOpen = false
29let quotes = []
30let index = null // 加權指數,格式同 quotes
31let lastFetch = 0
32let lastError = ''
33let fetching = false
34
35let danmakuOn = false
36let laneWidth = 0 // 寬度上限;0 = 用滿整個 band 寬度
37let bandColumns = 0 // 上次畫 band 時的可用寬度,0 = 還不知道
38let blocklist = []
39let lanes = Array.from({ length: LANES }, () => [])
40let queue = [] // 新推文,優先播
41let pool = [] // 最近 POOL_SIZE 則,queue 空了就從這裡輪播
42let poolIdx = 0
43let dmFetching = false
44let dmLastFetch = 0
45let dmStatus = ''
46let threadUrl = ''
47let threadKey = '' // 日期 + 盤中/盤後,例如 "2026/10/07 盤後閒聊"
48let threadCheckedAt = 0
49let seen = -1 // 已經看過幾則推文;-1 代表這串還沒讀過
50
51// ---------- 小工具 ----------
52
53function num(s) {
54 const n = parseFloat(s)
55 return Number.isFinite(n) ? n : null
56}
57
58function fmt(n) {
59 return n == null ? '-' : String(Number(n.toFixed(2)))
60}
61
62function twParts(ms) {
63 const d = new Date(ms + TW_OFFSET_MS)
64 return {
65 y: d.getUTCFullYear(),
66 mo: String(d.getUTCMonth() + 1).padStart(2, '0'),
67 da: String(d.getUTCDate()).padStart(2, '0'),
68 hh: String(d.getUTCHours()).padStart(2, '0'),
69 mm: String(d.getUTCMinutes()).padStart(2, '0'),
70 day: d.getUTCDay(),
71 minutes: d.getUTCHours() * 60 + d.getUTCMinutes(),
72 }
73}
74
75function twTime(ms) {
76 const p = twParts(ms)
77 return p.hh + ':' + p.mm
78}
79
80function twDate(ms) {
81 const p = twParts(ms)
82 return p.y + '/' + p.mo + '/' + p.da
83}
84
85// 台股交易時間:週一到週五 09:00–13:30(台灣時間),未處理國定假日
86function isMarketOpen(ms) {
87 const p = twParts(ms)
88 return p.day >= 1 && p.day <= 5 && p.minutes >= 9 * 60 && p.minutes <= 13 * 60 + 30
89}
90
91function toQuote(r) {
92 const prev = num(r.y)
93 const last = num(r.z) ?? num((r.b || '').split('_')[0]) ?? prev
94 const change = last != null && prev ? last - prev : 0
95 const pct = prev ? (change / prev) * 100 : 0
96 return { code: r.c, name: r.n, last, change, pct }
97}
98
99// 推文 → 彈幕。推=紅、噓=綠、→=預設色;提到自選股的推文用黃色粗體標亮
100function toItems(pushes) {
101 const keys = [...watchlist, ...quotes.map((q) => q.name)].filter(Boolean)
102 const items = []
103 for (const p of pushes) {
104 if (blocklist.some((w) => p.text.includes(w))) continue
105 if (/^https?:\/\/\S+$/.test(p.text)) continue // 只有網址的推文跳過
106 const text = clipWidth(p.text, MAX_ITEM_W)
107 const hot = keys.some((k) => p.text.includes(k))
108 const item = { text, w: strWidth(text), bold: hot }
109 if (hot) item.color = 'yellow'
110 else if (p.kind === '推') item.color = 'red'
111 else if (p.kind === '噓') item.color = 'green'
112 items.push(item)
113 }
114 return items
115}
116
117function enqueue(pushes) {
118 queue.push(...toItems(pushes))
119 if (queue.length > MAX_QUEUE) queue = queue.slice(-MAX_QUEUE) // 推文太多就只留最新的
120}
121
122function addToPool(pushes) {
123 pool = [...pool, ...toItems(pushes)].slice(-POOL_SIZE)
124}
125
126// queue 空了就從 pool 補一則;一次只補一則,新推文進來時能馬上插播
127function refillFromPool() {
128 if (queue.length > 0 || pool.length === 0) return
129 queue.push(pool[poolIdx % pool.length])
130 poolIdx = (poolIdx + 1) % pool.length
131}
132
133function effectiveWidth() {
134 if (bandColumns <= 0) return laneWidth || 80 // 還不知道 band 多寬時先用 80
135 return laneWidth ? Math.min(laneWidth, bandColumns) : bandColumns
136}
137
138function resetLanes() {
139 lanes = Array.from({ length: LANES }, () => [])
140 queue = []
141}
142
143// ---------- 報價 ----------
144
145async function refresh($) {
146 if (fetching) return
147 fetching = true
148 try {
149 const channels = [INDEX_CH, ...watchlist.flatMap((c) => ['tse_' + c + '.tw', 'otc_' + c + '.tw'])].join('|')
150 const url = BASE + '?json=1&delay=0&ex_ch=' + encodeURIComponent(channels)
151 const res = await $.http.fetch(url)
152 if (!res.ok) throw new Error('HTTP ' + res.status)
153 const data = JSON.parse(res.text)
154 const rows = Array.isArray(data.msgArray) ? data.msgArray : []
155 quotes = watchlist
156 .map((code) => rows.find((r) => r.c === code))
157 .filter(Boolean)
158 .map(toQuote)
159 const indexRow = rows.find((r) => r.c === 't00')
160 index = indexRow ? { ...toQuote(indexRow), name: '加權' } : null
161 lastError = ''
162 lastFetch = await $.clock.now()
163 } catch (err) {
164 lastError = String((err && err.message) || err)
165 } finally {
166 fetching = false
167 $.ui.invalidate('ui.render')
168 }
169}
170
171async function tick($, force) {
172 const now = await $.clock.now()
173 marketOpen = isMarketOpen(now)
174 if (!force && !(working || pinned)) return
175 if (!force && !marketOpen && (index || quotes.length > 0)) return
176 if (!force && now - lastFetch < POLL_MS - 5_000) return
177 await refresh($)
178}
179
180// ---------- 彈幕 ----------
181
182async function findThread($, today, keyword) {
183 let url = PTT_INDEX
184 for (let i = 0; i < 2 && url; i++) {
185 const res = await $.http.fetch(url, PTT_INIT)
186 if (!res.ok) throw new Error('PTT HTTP ' + res.status)
187 const hit = findDailyThread(res.text, today, keyword)
188 if (hit) return hit
189 url = findPrevPage(res.text)
190 }
191 return null
192}
193
194// 現在該看哪一串:盤中 → 盤中閒聊;平日收盤後 → 盤後閒聊;其他時間(開盤前、週末)不抓
195function chatKind(ms) {
196 if (isMarketOpen(ms)) return '盤中閒聊'
197 const p = twParts(ms)
198 if (p.day >= 1 && p.day <= 5 && p.minutes > 13 * 60 + 30) return '盤後閒聊'
199 return null
200}
201
202async function danmakuTick($, force) {
203 if (!danmakuOn || dmFetching) return
204 const now = await $.clock.now()
205 if (!force && !(working || pinned)) return
206 const kind = chatKind(now) || (force ? '盤中閒聊' : null)
207 if (!kind) return
208 if (!force && now - dmLastFetch < DM_POLL_MS - 5_000) return
209 dmFetching = true
210 dmLastFetch = now
211 try {
212 const key = twDate(now) + ' ' + kind
213 if (threadKey !== key) {
214 // 換日或盤中 → 盤後:清掉上一串的彈幕,不再輪播舊推文
215 threadKey = key
216 threadUrl = ''
217 seen = -1
218 pool = []
219 queue = []
220 }
221 if (!threadUrl || now - threadCheckedAt > DM_RECHECK_MS) {
222 const found = await findThread($, twDate(now), kind)
223 threadCheckedAt = now
224 if (!found) {
225 if (!threadUrl) {
226 // 盤後閒聊還沒開串就空白;盤中閒聊還沒開串才提示
227 dmStatus = kind === '盤中閒聊' ? '今天的盤中閒聊還沒出現' : ''
228 return
229 }
230 } else if (found.url !== threadUrl) {
231 threadUrl = found.url
232 seen = -1
233 }
234 }
235 const res = await $.http.fetch(threadUrl, PTT_INIT)
236 if (!res.ok) throw new Error('PTT HTTP ' + res.status)
237 const pushes = parsePushes(res.text)
238 if (seen < 0 || pushes.length < seen) {
239 pool = []
240 addToPool(pushes.slice(-POOL_SIZE))
241 enqueue(pushes.slice(-FIRST_BATCH))
242 } else {
243 enqueue(pushes.slice(seen))
244 addToPool(pushes.slice(seen))
245 }
246 seen = pushes.length
247 dmStatus = '' // 正常時不顯示則數,省空間;只有出錯或還沒開串時才顯示
248 } catch (err) {
249 dmStatus = '⚠ ' + String((err && err.message) || err)
250 } finally {
251 dmFetching = false
252 $.ui.invalidate('ui.render')
253 }
254}
255
256// ---------- 設定 ----------
257
258async function saveSettings($) {
259 await $.store.set('watchlist', watchlist)
260 await $.store.set('pinned', pinned)
261 await $.store.set('danmaku', danmakuOn)
262 await $.store.set('laneMax', laneWidth)
263 await $.store.set('blocklist', blocklist)
264}
265
266async function loadSettings($) {
267 const savedList = await $.store.get('watchlist')
268 if (Array.isArray(savedList)) watchlist = savedList
269 const savedPinned = await $.store.get('pinned')
270 if (typeof savedPinned === 'boolean') pinned = savedPinned
271 const savedDm = await $.store.get('danmaku')
272 if (typeof savedDm === 'boolean') danmakuOn = savedDm
273 const savedWidth = await $.store.get('laneMax') // 舊的 laneWidth 預設存了 80,不沿用
274 if (typeof savedWidth === 'number') laneWidth = savedWidth
275 const savedBlock = await $.store.get('blocklist')
276 if (Array.isArray(savedBlock)) blocklist = savedBlock
277}
278
279// ---------- 註冊 hooks ----------
280
281export function register(on) {
282 on('session.start', async ($, e, next) => {
283 await loadSettings($)
284
285 // 報價與推文的輪詢;tick 自己決定要不要真的發請求
286 $.clock.every(30_000, async () => {
287 await tick($, false)
288 await danmakuTick($, false)
289 })
290
291 // 彈幕動畫:只有在顯示中才移動與重畫
292 $.clock.every(FRAME_MS, () => {
293 if (!danmakuOn || !(working || pinned)) return
294 refillFromPool()
295 if (stepLanes(lanes, queue, effectiveWidth(), GAP)) $.ui.invalidate('ui.render')
296 })
297
298 if (pinned) {
299 tick($, true).catch(() => {})
300 danmakuTick($, true).catch(() => {})
301 }
302
303 try {
304 await $.command.register({
305 name: 'twstock',
306 description: '台股跑馬燈:add/remove 代號、pin 常駐、refresh 更新、danmaku 彈幕、block/unblock 過濾字詞',
307 argumentHint: '[add|remove|pin|refresh|danmaku|block|unblock] ...',
308 immediate: true,
309 })
310 } catch (err) {
311 $.ui.log('無法註冊 /twstock:' + String(err))
312 }
313 return next(e)
314 })
315
316 on('turn.start', async ($, e, next) => {
317 if (e.agentId) return next(e)
318 working = true
319 $.ui.invalidate('ui.render')
320 tick($, false).catch(() => {})
321 danmakuTick($, false).catch(() => {})
322 return next(e)
323 })
324
325 on('turn.complete', async ($, e, next) => {
326 if (e.agentId) return next(e)
327 working = false
328 $.ui.invalidate('ui.render')
329 return next(e)
330 })
331
332 on('command.run', { command: 'twstock' }, async ($, e) => {
333 const [sub = '', ...rest] = (e.args || '').trim().split(/\s+/).filter(Boolean)
334 const codes = rest.map((c) => c.toUpperCase()).filter((c) => /^[0-9A-Z]{4,6}$/.test(c))
335 const action = sub.toLowerCase()
336
337 if (action === 'add') {
338 watchlist = [...new Set([...watchlist, ...codes])]
339 await saveSettings($)
340 tick($, true).catch(() => {})
341 $.ui.toast('自選股:' + watchlist.join(', '))
342 } else if (action === 'remove' || action === 'rm') {
343 watchlist = watchlist.filter((c) => !codes.includes(c))
344 quotes = quotes.filter((q) => watchlist.includes(q.code))
345 await saveSettings($)
346 $.ui.invalidate('ui.render')
347 $.ui.toast('自選股:' + (watchlist.join(', ') || '(空)'))
348 } else if (action === 'pin') {
349 pinned = !pinned
350 await saveSettings($)
351 if (pinned) {
352 tick($, true).catch(() => {})
353 danmakuTick($, true).catch(() => {})
354 }
355 $.ui.invalidate('ui.render')
356 $.ui.toast(pinned ? '跑馬燈:常駐顯示' : '跑馬燈:只在 Claude 工作時顯示')
357 } else if (action === 'refresh') {
358 tick($, true).catch(() => {})
359 danmakuTick($, true).catch(() => {})
360 $.ui.toast('正在更新…')
361 } else if (action === 'danmaku' || action === 'dm') {
362 const w = parseInt(rest[1], 10)
363 const widthArg = (rest[0] || '').toLowerCase() === 'width'
364 if (widthArg && (rest[1] || '').toLowerCase() === 'full') {
365 laneWidth = 0
366 await saveSettings($)
367 $.ui.toast('彈幕寬度:用滿整個畫面')
368 } else if (widthArg && w >= 30 && w <= 300) {
369 laneWidth = w
370 await saveSettings($)
371 $.ui.toast('彈幕寬度上限:' + w + ' 格(不超過畫面寬度)')
372 } else {
373 danmakuOn = !danmakuOn
374 if (!danmakuOn) resetLanes()
375 await saveSettings($)
376 if (danmakuOn) danmakuTick($, true).catch(() => {})
377 $.ui.toast(danmakuOn ? '💬 彈幕:開啟' : '💬 彈幕:關閉')
378 }
379 $.ui.invalidate('ui.render')
380 } else if (action === 'block') {
381 blocklist = [...new Set([...blocklist, ...rest])]
382 queue = queue.filter((it) => !blocklist.some((w) => it.text.includes(w)))
383 pool = pool.filter((it) => !blocklist.some((w) => it.text.includes(w)))
384 await saveSettings($)
385 $.ui.toast('過濾字詞:' + blocklist.length + ' 個')
386 } else if (action === 'unblock') {
387 blocklist = blocklist.filter((w) => !rest.includes(w))
388 await saveSettings($)
389 $.ui.toast('過濾字詞:' + blocklist.length + ' 個')
390 } else {
391 $.ui.toast(
392 '自選股:' + (watchlist.join(', ') || '(空)') +
393 (pinned ? ' · 常駐' : ' · 工作時顯示') +
394 (danmakuOn ? ' · 彈幕開' : ' · 彈幕關') +
395 (lastFetch ? ' · ' + twTime(lastFetch) + ' 更新' : ''),
396 )
397 }
398 // 只用 toast 回覆,不寫進對話,Claude 不會讀到
399 return {}
400 })
401
402 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
403 if (!(working || pinned)) return next(e)
404 if (!index && quotes.length === 0 && !lastError && !danmakuOn) return next(e)
405
406 // 彈幕一行若比 band 寬,引擎會把每一段都縮短並加「…」,所以寬度要跟著 band
407 if (e.props.bodyColumns > 0) bandColumns = e.props.bodyColumns
408
409 const { Box, Text } = $.ui.resolve(e)
410
411 // 台股習慣:紅漲綠跌;指數不顯示代號
412 const items = [...(index ? [{ ...index, code: '' }] : []), ...quotes].map((q) => {
413 const arrow = q.change > 0 ? '▲' : q.change < 0 ? '▼' : '─'
414 const label = q.code ? q.code + ' ' + q.name : q.name
415 const props = {
416 children: [label + ' ' + fmt(q.last) + ' ' + arrow + Math.abs(q.pct).toFixed(2) + '%'],
417 }
418 if (q.change > 0) props.color = 'red'
419 if (q.change < 0) props.color = 'green'
420 return Text(props)
421 })
422
423 const status = lastError ? '⚠ ' + lastError : !lastFetch ? '' : marketOpen ? twTime(lastFetch) : '收盤'
424 const tail = [status, danmakuOn && dmStatus ? '💬 ' + dmStatus : ''].filter(Boolean).join(' · ')
425
426 const rows = [
427 Box({
428 flexDirection: 'row',
429 columnGap: 2,
430 children: [Text({ bold: true, children: ['📈 台股'] }), ...items, Text({ dimColor: true, children: [tail || ' '] })],
431 }),
432 ]
433
434 if (danmakuOn) {
435 for (const lane of lanes) {
436 const segs = renderLane(lane, effectiveWidth())
437 const children = segs.length
438 ? segs.map((seg) => {
439 const props = { wrap: 'truncate', children: [seg.text] }
440 if (seg.color) props.color = seg.color
441 if (seg.bold) props.bold = true
442 return Text(props)
443 })
444 : [Text({ children: [' '] })]
445 rows.push(Box({ flexDirection: 'row', children }))
446 }
447 }
448
449 // 保留其他 mod 畫在 band 上的內容
450 const others = await next(e)
451 return Box({ flexDirection: 'column', children: [...rows, others].filter(Boolean) })
452 })
453}
454hooks/danmaku.js 172 lines1// danmaku.js:PTT 解析與彈幕軌道的純函式(這個檔案不呼叫任何 mods API)
2
3const PTT_ORIGIN = 'https://www.ptt.cc'
4
5// ---------- HTML 小工具 ----------
6
7export function decodeEntities(s) {
8 return s.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, (m, g) => {
9 const k = g.toLowerCase()
10 if (k === 'amp') return '&'
11 if (k === 'lt') return '<'
12 if (k === 'gt') return '>'
13 if (k === 'quot') return '"'
14 if (k === 'apos') return "'"
15 if (k === 'nbsp') return ' '
16 const cp = k.startsWith('#x') ? parseInt(k.slice(2), 16) : parseInt(k.slice(1), 10)
17 return Number.isFinite(cp) ? String.fromCodePoint(cp) : m
18 })
19}
20
21function stripTags(s) {
22 return s.replace(/<[^>]*>/g, '')
23}
24
25// ---------- PTT 解析 ----------
26
27// 在看板列表頁找當天的閒聊串,ymd 例如 "2026/10/07",keyword 為「盤中閒聊」或「盤後閒聊」
28export function findDailyThread(html, ymd, keyword = '盤中閒聊') {
29 const re = /<a href="(\/bbs\/Stock\/M\.[^"]+\.html)">([^<]*)<\/a>/g
30 let found = null
31 for (const m of html.matchAll(re)) {
32 const title = decodeEntities(m[2]).trim()
33 if (title.startsWith('Re:') || title.startsWith('Fw:')) continue
34 if (title.includes(ymd) && title.includes(keyword)) {
35 found = { url: PTT_ORIGIN + m[1], title } // 取最後一個符合的
36 }
37 }
38 return found
39}
40
41// 列表頁的「‹ 上頁」連結
42export function findPrevPage(html) {
43 const m = html.match(/href="(\/bbs\/Stock\/index\d+\.html)"[^>]*>[^<]*上頁/)
44 return m ? PTT_ORIGIN + m[1] : null
45}
46
47// 解析文章裡的推文,只保留推噓符號和內容,不保留帳號
48export function parsePushes(html) {
49 const out = []
50 const re = /<div class="push">([\s\S]*?)<\/div>/g
51 for (const m of html.matchAll(re)) {
52 const tag = m[1].match(/push-tag">([^<]*)</)
53 const content = m[1].match(/push-content">([\s\S]*?)<\/span>/)
54 if (!tag || !content) continue
55 const text = decodeEntities(stripTags(content[1]))
56 .replace(/^:\s?/, '')
57 .replace(/\s+/g, ' ')
58 .trim()
59 if (!text) continue
60 out.push({ kind: tag[1].trim(), text })
61 }
62 return out
63}
64
65// ---------- 顯示寬度(中文字在終端機佔兩格) ----------
66
67export function charWidth(cp) {
68 if (cp < 32 || (cp >= 0x7f && cp < 0xa0)) return 0
69 if (cp >= 0x300 && cp <= 0x36f) return 0 // 組合用符號
70 if (cp === 0x200b || cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f)) return 0
71 if (
72 (cp >= 0x1100 && cp <= 0x115f) ||
73 (cp >= 0x2e80 && cp <= 0xa4cf) ||
74 (cp >= 0xac00 && cp <= 0xd7a3) ||
75 (cp >= 0xf900 && cp <= 0xfaff) ||
76 (cp >= 0xfe30 && cp <= 0xfe4f) ||
77 (cp >= 0xff00 && cp <= 0xff60) ||
78 (cp >= 0xffe0 && cp <= 0xffe6) ||
79 (cp >= 0x1f300 && cp <= 0x1faff) ||
80 (cp >= 0x20000 && cp <= 0x3fffd)
81 ) {
82 return 2
83 }
84 return 1
85}
86
87export function strWidth(s) {
88 let w = 0
89 for (const ch of s) w += charWidth(ch.codePointAt(0))
90 return w
91}
92
93// 截到最多 maxW 格,超過的話結尾加 …
94export function clipWidth(s, maxW) {
95 if (strWidth(s) <= maxW) return s
96 let out = ''
97 let w = 0
98 for (const ch of s) {
99 const cw = charWidth(ch.codePointAt(0))
100 if (w + cw > maxW - 1) break
101 out += ch
102 w += cw
103 }
104 return out + '…'
105}
106
107// 取出字串第 from 到 to 格(不含 to),被切一半的寬字元補空白
108function sliceCols(s, from, to) {
109 let out = ''
110 let col = 0
111 for (const ch of s) {
112 const w = charWidth(ch.codePointAt(0))
113 const a = col
114 const b = col + w
115 col = b
116 if (b <= from && w > 0) continue
117 if (a >= to) break
118 if (a >= from && b <= to) out += ch
119 else out += ' '.repeat(Math.max(0, Math.min(b, to) - Math.max(a, from)))
120 }
121 return out
122}
123
124// ---------- 彈幕軌道 ----------
125
126// item: { text, w, color, bold, x },x 是左緣所在的欄位
127export function renderLane(lane, width) {
128 const segs = []
129 let col = 0
130 const sorted = [...lane].sort((a, b) => a.x - b.x)
131 for (const it of sorted) {
132 const start = Math.max(it.x, col)
133 if (start >= width) break
134 const end = Math.min(it.x + it.w, width)
135 if (end <= start) continue
136 if (start > col) segs.push({ text: ' '.repeat(start - col) })
137 segs.push({ text: sliceCols(it.text, start - it.x, end - it.x), color: it.color, bold: it.bold })
138 col = end
139 }
140 return segs
141}
142
143// 每一幀:全部往左移一格、移除跑出畫面的、最多補一則新的進來
144// 回傳 true 代表畫面有變,需要重畫
145export function stepLanes(lanes, queue, width, gap) {
146 let changed = false
147 for (let i = 0; i < lanes.length; i++) {
148 if (lanes[i].length === 0) continue
149 for (const it of lanes[i]) it.x -= 1
150 lanes[i] = lanes[i].filter((it) => it.x + it.w > 0)
151 changed = true
152 }
153 if (queue.length > 0) {
154 // 找右邊空間最多的軌道放新彈幕,讓各軌道錯開
155 let best = -1
156 let bestRoom = -Infinity
157 for (let i = 0; i < lanes.length; i++) {
158 const last = lanes[i].reduce((a, b) => (!a || b.x > a.x ? b : a), null)
159 const room = last ? width - (last.x + last.w) : Infinity
160 if (room >= gap && room > bestRoom) {
161 best = i
162 bestRoom = room
163 }
164 }
165 if (best >= 0) {
166 lanes[best].push({ ...queue.shift(), x: width })
167 changed = true
168 }
169 }
170 return changed
171}
172