@yyyu-star/arcade-kit 0.18.1 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/table-fun.ts CHANGED
@@ -10,11 +10,13 @@
10
10
  * - 公平:人人一样、免费;只是画面和声音,不停计时、不改规则;电脑的出牌不读这些。电脑的反应只在画面层(单机)或房间的互动钩子里算。
11
11
  * - kit 不认识任何游戏:短语表、名场面的映射、电脑人物都由游戏给。
12
12
  */
13
- import { CHAT_MAX, chatRejectText, type ChatBlockWhy } from './chat.js';
13
+ import { chatRejectText } from './chat.js';
14
+ import { CHAT_CSS, ChatUi, checkPrefsKey, type ChatResultLike } from './table-chat.js';
14
15
  import { applyTheme, type KitTheme } from './theme.js';
15
16
  import { PROPS, STICKERS, createFunLayer, propSvg, showSticker, stickerSvg, throwAt, type FunLayer, type FunPhase, type PropId, type RectLike, type StickerId } from './fun.js';
16
17
  import {
17
18
  BOT_REACT,
19
+ chatFont,
18
20
  FUN_TIMING,
19
21
  FunCooldown,
20
22
  FunTally,
@@ -22,6 +24,7 @@ import {
22
24
  funItem,
23
25
  newBotState,
24
26
  fitMenuWidth,
27
+ placeBubble,
25
28
  placeMenu,
26
29
  propId,
27
30
  stickerId,
@@ -36,7 +39,8 @@ import {
36
39
  } from './fun-logic.js';
37
40
 
38
41
  export * from './fun-logic.js';
39
- export type { RectLike };
42
+ export type { RectLike, ChatResultLike };
43
+ export { checkPrefsKey };
40
44
 
41
45
  /** 一个座位此刻的样子(每次用到时现问,座位换人、头像挪了都跟得上) */
42
46
  export interface FunSeat {
@@ -49,6 +53,8 @@ export interface FunSeat {
49
53
  bot?: boolean;
50
54
  /** 电脑的互动性格,默认 normal */
51
55
  temper?: FunTemper;
56
+ /** 名牌、筹码数这些挂在头像旁的东西(屏幕矩形,0.18.2):气泡避开它们,不盖住名字和钱数 */
57
+ plate?: RectLike;
52
58
  }
53
59
 
54
60
  /**
@@ -76,6 +82,11 @@ export interface TableFunOptions {
76
82
  avoid?(): readonly RectLike[];
77
83
  /** "互动"按钮放哪(屏幕矩形,组件画按钮);null 就不画(比如没坐下) */
78
84
  button?(): RectLike | null;
85
+ /**
86
+ * "互动"按钮的样子(0.18.2,替掉游戏自己对 .atf-btn 的 CSS 覆盖):scale 把字(13px)和图标(22px)一起放大(舞台放大了的大屏用,最小 1);
87
+ * row 为 true 时图标和字横排。每次 refresh 现问
88
+ */
89
+ buttonStyle?(): { scale?: number; row?: boolean };
79
90
  /** 发出去:好友房走 kit emote,单机直接回给 receive */
80
91
  send(id: number, to?: number): void;
81
92
  /** 现在能不能发(好友房:坐下了才能),默认能 */
@@ -92,6 +103,25 @@ export interface TableFunOptions {
92
103
  chat?(text: string): ChatResultLike;
93
104
  /** 设置里"聊天:开 / 不看"(和"互动"分开):不看时别人说的不冒气泡、不进记录,自己也没有聊天页;默认开 */
94
105
  chatEnabled?(): boolean;
106
+ /**
107
+ * 聊天的设置(弹幕开关、电脑版聊天框收没收起)存在哪个 localStorage key(0.19.0):必须是 `<storageKey>.<名字>`,比如 'texas.chat';
108
+ * 默认 `<storageKey>.chat`,游戏不用传
109
+ */
110
+ prefsKey?: string;
111
+ /** 省内存模式(0.19.0):开着时弹幕默认关(用户自己打开了照样飘)。不给就看 navigator.deviceMemory ≤ 2 */
112
+ lite?(): boolean;
113
+ /** 电脑版聊天框放哪(0.19.0,用 left、top、width):不给就在牌桌四个角里挑一个不压手牌、按钮、名牌的 */
114
+ chatDock?(): RectLike | null;
115
+ /**
116
+ * 弹幕在哪个区域飘(0.19.0):不给就是屏幕 8%–58%,上沿压到最上面一排座位的头像、名牌下面;轨道自动避开手牌、按钮、名牌、头像、center
117
+ */
118
+ danmakuArea?(): RectLike | null;
119
+ /** 手机版"聊天"按钮放哪(0.19.0,屏幕矩形):不给就挨着"互动"按钮,先右后左再上下挑不压东西的一侧;挤的牌桌(手机横屏)最好给 */
120
+ chatButton?(): RectLike | null;
121
+ /**
122
+ * 牌桌中间的信息区(0.19.0):剩余张数、底池、牌墩、出牌区、弃牌区这些,弹幕不横穿、聊天框和气泡不压。可以给几块
123
+ */
124
+ center?(): RectLike[];
95
125
  /** 设置里"互动:开 / 不看",关了别人的一律不播,单机电脑也不反应;默认开 */
96
126
  enabled?(): boolean;
97
127
  /** 屏蔽名单按这个分开(房间码;单机一个固定值),默认 'solo' */
@@ -129,10 +159,8 @@ const DEFAULT_LINES: Record<FunMood, readonly string[]> = {
129
159
  const HINT_TEXT = '点头像扔道具,长按连扔';
130
160
 
131
161
  /** RoomClient.chat 的结果里组件要看的部分(不 import client.ts,单机的游戏也能用组件) */
132
- export type ChatResultLike = { ok: true } | { ok: false; code: string; why?: ChatBlockWhy; waitMs?: number };
133
-
134
- /** 聊天:气泡停多久、页面里记最近几条(只在内存里) */
135
- export const CHAT_UI = { bubbleMs: 4000, keep: 30 } as const;
162
+ /** 聊天:气泡停多久(0.19.0 起 5 秒)、页面里记最近几条(只在内存里) */
163
+ export const CHAT_UI = { bubbleMs: 5000, keep: 30 } as const;
136
164
 
137
165
  /**
138
166
  * 首次提示冒在哪个座位(纯函数,测试直接测):游戏给了 pick 就听它的,它给的座位空着或不存在当作"现在不行";
@@ -165,28 +193,40 @@ const CSS = `
165
193
  .atf-cool{position:absolute;inset:0;border-radius:var(--ak-radius-btn,10px);background:rgba(0,0,0,.5);pointer-events:none}
166
194
  .atf-cool::after{content:'';position:absolute;left:50%;top:50%;width:26px;height:26px;margin:-13px 0 0 -13px;border-radius:50%;border:3px solid rgba(255,255,255,.25);border-top-color:var(--ak-title,#f3d98b);animation:atf-spin .7s linear infinite}
167
195
  .atf-btn{position:absolute;left:0;top:0;pointer-events:auto;min-width:44px;min-height:44px;border-radius:var(--ak-radius-btn,22px);border:2px solid var(--ak-border,#d4a64a);background:var(--ak-soft,#5a3a22);color:var(--ak-fg,#fff6e0);font:inherit;font-size:13px;font-weight:700;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer;padding:2px 6px;box-sizing:border-box}
168
- .atf-btn svg{width:22px;height:22px}
196
+ .atf-btn svg{width:var(--atf-icon,22px);height:var(--atf-icon,22px)}
169
197
  .atf-btn.atf-dot::after{content:'';position:absolute;right:2px;top:2px;width:10px;height:10px;border-radius:50%;background:#e8323a;border:2px solid var(--ak-fg,#fff6e0)}
170
- .atf-say{white-space:normal;max-width:240px;word-break:break-all;font-weight:600}
198
+ .atf-say{font-weight:600}
171
199
  .atf-log{display:flex;flex-direction:column;gap:4px;font-size:14px;line-height:1.4;min-height:44px}
172
200
  .atf-log b{color:var(--ak-title,#f3d98b);font-weight:700;margin-right:4px}
173
201
  .atf-log .atf-empty{opacity:.6}
174
202
  .atf-input{flex:1;min-width:0;min-height:44px;border:1px solid var(--ak-border,rgba(212,166,74,.6));border-radius:var(--ak-radius-btn,10px);background:#fffaf0;color:#2b1d14;font:inherit;font-size:16px;padding:0 10px;box-sizing:border-box}
175
203
  .atf-note{font-size:13px;margin:4px 4px 0;opacity:.85;min-height:18px}
176
204
  .atf-chip:disabled{opacity:.5;cursor:default}
177
- .atf-bubble{position:absolute;left:0;top:0;max-width:220px;padding:6px 12px;border-radius:var(--ak-radius,14px);background:#fffaf0;color:#2b1d14;font-size:15px;font-weight:700;box-shadow:0 3px 8px rgba(0,0,0,.35);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none}
205
+ .atf-bubble{position:absolute;left:0;top:0;z-index:2;max-width:240px;padding:.35em .75em;border-radius:var(--ak-radius,14px);background:#fffaf0;color:#2b1d14;font-size:15px;line-height:1.3;font-weight:700;box-shadow:0 3px 8px rgba(0,0,0,.35);white-space:normal;word-break:break-all;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;pointer-events:none}
178
206
  .atf-flash{position:absolute;left:0;top:0;font-size:26px;font-weight:900;color:var(--ak-title,#f3d98b);-webkit-text-stroke:1.5px #2b1d14;pointer-events:none;white-space:nowrap}
179
207
  .atf-banner{position:absolute;left:50%;top:18%;padding:10px 34px;background:linear-gradient(#c8323a,#8a1a20);border:2px solid var(--ak-title,#f3d98b);border-radius:6px;color:var(--ak-fg,#fff6e0);font-size:28px;font-weight:900;letter-spacing:4px;text-align:center;pointer-events:none;box-shadow:0 6px 20px rgba(0,0,0,.4)}
180
208
  .atf-banner small{display:block;font-size:15px;letter-spacing:1px;color:var(--ak-title,#f3d98b)}
181
209
  @keyframes atf-pop{from{opacity:0;scale:.92}to{opacity:1;scale:1}}
182
210
  @keyframes atf-spin{to{transform:rotate(360deg)}}
183
- `;
211
+ ${CHAT_CSS}`;
184
212
 
185
213
  const SMILE = '<svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"><circle cx="32" cy="32" r="24"/><circle cx="23" cy="27" r="2.5" fill="currentColor"/><circle cx="41" cy="27" r="2.5" fill="currentColor"/><path d="M20 37Q32 49 44 37"/></svg>';
186
214
 
187
215
  type Rect = { left: number; top: number; width: number; height: number };
188
216
  type Tab = 'phrase' | 'sticker' | 'prop' | 'chat';
189
217
 
218
+ /**
219
+ * "互动" / "聊天"按钮的尺寸参数(0.18.2):游戏没给 buttonStyle 就不写行内样式(不然会盖掉还没迁过来的游戏自己对 .atf-btn 的 CSS)
220
+ */
221
+ export function styleButton(b: HTMLElement, st: { scale?: number; row?: boolean } | undefined): void {
222
+ const k = Math.max(1, st?.scale ?? 1);
223
+ b.style.fontSize = st ? `${13 * k}px` : '';
224
+ b.style.flexDirection = st?.row ? 'row' : '';
225
+ b.style.gap = st?.row ? `${6 * k}px` : '';
226
+ if (st) b.style.setProperty('--atf-icon', `${22 * k}px`);
227
+ else b.style.removeProperty('--atf-icon');
228
+ }
229
+
190
230
  /** 挂子元素(Workers 的类型里也有一个 append,签名对不上,所以统一用 appendChild) */
191
231
  function add(parent: Node, ...kids: Node[]): void {
192
232
  for (const k of kids) parent.appendChild(k);
@@ -218,12 +258,13 @@ export class TableFun {
218
258
  private lastProp: PropId = 'tomato';
219
259
  private lastSticker: StickerId = 'smug';
220
260
  private timers = new Set<ReturnType<typeof setTimeout>>();
221
- /** 聊天记录(最近 CHAT_UI.keep 条,只在内存里;关页面、换房间就没了) */
222
- private chatLog: { seat: number; name: string; text: string }[] = [];
261
+ /** 聊天框、输入条、弹幕、记录(0.19.0,table-chat.ts) */
262
+ private readonly cui: ChatUi;
223
263
  private chatLogEl: HTMLElement | null = null;
224
264
  /** 每个人最近的那个聊天气泡:同一个人连说两条,后一条顶掉前一条 */
225
265
  private says = new Map<number, HTMLElement>();
226
- private unread = false;
266
+ /** 正在显示的气泡:新气泡也避开它们,不叠在一起 */
267
+ private live = new Set<HTMLElement>();
227
268
  /** 键盘弹出时跟着可见区挪面板(输入框聚焦时挂上) */
228
269
  private vvFix: (() => void) | null = null;
229
270
  private dead = false;
@@ -232,6 +273,7 @@ export class TableFun {
232
273
  constructor(opts: TableFunOptions) {
233
274
  // 先查 key 再碰 DOM:不合格的不留下半个界面
234
275
  checkStorageKey(opts.storageKey);
276
+ const prefsKey = checkPrefsKey(opts.prefsKey ?? `${opts.storageKey}.chat`, opts.storageKey);
235
277
  this.o = opts;
236
278
  this.P = opts.phrases.length;
237
279
  this.doc = document;
@@ -248,6 +290,32 @@ export class TableFun {
248
290
  this.ui.style.zIndex = String(z + 1);
249
291
  applyTheme(this.ui, opts.theme);
250
292
  this.doc.body.appendChild(this.ui);
293
+ this.cui = new ChatUi({
294
+ ui: this.ui,
295
+ fx: this.layer.el,
296
+ prefsKey,
297
+ phrases: opts.phrases,
298
+ phrase: (i) => void this.pick(i),
299
+ send: (t) => this.o.chat!(t),
300
+ canSend: () => this.canSend(),
301
+ blocked: () => this.blocked(),
302
+ bubbles: () => [...this.live].map((e) => e.getBoundingClientRect()),
303
+ seatBoxes: () => this.o.seats().flatMap((x) => (x ? [x.rect, ...(x.plate ? [x.plate] : [])] : [])),
304
+ barBlocked: () => {
305
+ const me = this.o.seats().find((x) => !!x?.me);
306
+ return [...(this.o.avoid?.() ?? []), ...(this.btn ? [this.btn.getBoundingClientRect()] : []), ...(me ? [me.rect, ...(me.plate ? [me.plate] : [])] : [])];
307
+ },
308
+ button: () => (this.btn ? this.btn.getBoundingClientRect() : null),
309
+ styleButton: (b) => styleButton(b, this.o.buttonStyle?.()),
310
+ dock: () => this.o.chatDock?.() ?? null,
311
+ chatButton: () => this.o.chatButton?.() ?? null,
312
+ area: () => this.o.danmakuArea?.() ?? null,
313
+ lite: () => this.o.lite?.() ?? ((navigator as { deviceMemory?: number }).deviceMemory ?? 8) <= 2,
314
+ history: () => this.openPanel('chat'),
315
+ later: (ms, fn) => this.later(ms, fn),
316
+ now: () => this.now(),
317
+ ...(opts.log ? { log: opts.log } : {}),
318
+ });
251
319
  window.addEventListener('resize', this.onResize);
252
320
  this.refresh();
253
321
  this.later(1500, () => this.hint());
@@ -307,7 +375,6 @@ export class TableFun {
307
375
  b.className = 'atf-btn';
308
376
  b.type = 'button';
309
377
  b.innerHTML = `${SMILE}<span>互动</span>`;
310
- b.classList.toggle('atf-dot', this.unread);
311
378
  b.addEventListener('click', (e) => {
312
379
  e.stopPropagation();
313
380
  if (this.open?.dataset['kind'] === 'panel') this.closeAll();
@@ -316,10 +383,33 @@ export class TableFun {
316
383
  this.ui.appendChild(b);
317
384
  this.btn = b;
318
385
  }
386
+ styleButton(this.btn, this.o.buttonStyle?.());
319
387
  this.btn.style.width = `${Math.max(44, r.width)}px`;
320
388
  this.btn.style.height = `${Math.max(44, r.height)}px`;
321
389
  this.btn.style.transform = `translate(${r.left}px,${r.top}px)`;
322
390
  }
391
+ this.cui.refresh(this.chatOn());
392
+ }
393
+
394
+ /** 聊天框、弹幕、气泡不能盖的地方:游戏的禁区、"互动"按钮、各家头像和名牌 */
395
+ private blocked(): RectLike[] {
396
+ const out: RectLike[] = [...(this.o.avoid?.() ?? []), ...(this.o.center?.() ?? [])];
397
+ if (this.btn) out.push(this.btn.getBoundingClientRect());
398
+ for (const s of this.o.seats()) {
399
+ if (!s) continue;
400
+ out.push(s.rect);
401
+ if (s.plate) out.push(s.plate);
402
+ }
403
+ return out;
404
+ }
405
+
406
+ /** 弹幕开着吗(游戏设置里显示用,0.19.0) */
407
+ danmaku(): boolean {
408
+ return this.cui.danmaku();
409
+ }
410
+ /** 游戏设置里改弹幕开关(和聊天框、面板里的"弹幕"按钮同一份设置) */
411
+ setDanmaku(on: boolean): void {
412
+ this.cui.setDanmaku(on);
323
413
  }
324
414
 
325
415
  /** 头像热区:点一下开菜单(别人)或表情菜单(自己);按住 longPressMs 直接扔上次的道具 / 发上次的表情 */
@@ -505,6 +595,7 @@ export class TableFun {
505
595
  /** "互动"按钮:短语 / 表情 / 道具三个页签;道具页先选人再选道具 */
506
596
  openPanel(tab: Tab = this.tab): void {
507
597
  if (!this.canSend() || !this.btn) return;
598
+ this.cui.closeBar();
508
599
  this.tab = tab;
509
600
  const r = this.btn.getBoundingClientRect();
510
601
  const hasChat = this.chatOn();
@@ -570,6 +661,7 @@ export class TableFun {
570
661
  closeAll(): void {
571
662
  this.open?.remove();
572
663
  this.open = null;
664
+ this.cui.bindLog(null, this.chatLogEl);
573
665
  this.chatLogEl = null;
574
666
  this.unhookViewport();
575
667
  }
@@ -580,87 +672,24 @@ export class TableFun {
580
672
  return !!this.o.chat && (this.o.chatEnabled?.() ?? true);
581
673
  }
582
674
 
583
- /** 聊天页:上面最近 30 条(新的在下),下面一行输入框 + 发送,再下面一行小字(还能打几个字、为什么没发出去) */
675
+ /** 聊天页:上面最近 30 条(新的在下,名字按座位配色),弹幕开关,下面一行输入框 + 发送,再下面一行小字(还能打几个字、为什么没发出去) */
584
676
  private chatBody(b: HTMLDivElement, body: HTMLDivElement): void {
585
- this.setUnread(false);
677
+ this.cui.setUnread(false);
586
678
  // 记录自己滚,输入框一直在下面看得见
587
679
  const log = this.el('div', 'atf-log atf-scroll');
588
680
  log.style.maxHeight = '160px';
589
681
  this.chatLogEl = log;
590
- this.renderLog();
591
- add(body, log);
592
- const row = this.el('div', 'atf-row');
682
+ this.cui.bindLog(log);
683
+ const dm = this.cui.chip(this.cui.danmakuLabel(), 'fun:danmaku', () => this.cui.setDanmaku(!this.cui.danmaku()), 'atf-soft');
684
+ dm.style.marginTop = '6px';
685
+ const { row, note, input } = this.cui.inputRow('fun');
593
686
  row.style.marginTop = '8px';
594
- row.style.flexWrap = 'nowrap';
595
- const input = this.el('input', 'atf-input');
596
- input.type = 'text';
597
- input.enterKeyHint = 'send';
598
- input.autocomplete = 'off';
599
- input.placeholder = '说点什么';
600
- input.dataset['key'] = 'fun:chatInput';
601
- const send = this.chip('发送', 'fun:chatSend', () => go());
602
- const note = this.el('div', 'atf-note');
603
- const left = (): void => {
604
- const n = CHAT_MAX - Array.from(input.value.trim()).length;
605
- note.textContent = n < 0 ? `多了 ${-n} 个字` : `还能打 ${n} 个字`;
606
- send.disabled = n < 0 || send.dataset['wait'] === '1';
607
- };
608
- const go = (): void => {
609
- if (!input.value.trim() || send.disabled) return;
610
- const r = this.o.chat!(input.value);
611
- if (r.ok) {
612
- input.value = '';
613
- left();
614
- return;
615
- }
616
- note.textContent = chatRejectText(r.code, r.why);
617
- if (r.code === 'chat_too_fast' && r.waitMs) this.countdown(send, r.waitMs, left);
618
- };
619
- input.addEventListener('input', left);
620
- input.addEventListener('keydown', (e) => {
621
- e.stopPropagation();
622
- if (e.key === 'Enter' && !e.isComposing) go();
623
- });
624
687
  input.addEventListener('focus', () => this.hookViewport(b));
625
- add(row, input, send);
626
- add(body, row, note);
627
- left();
688
+ add(body, log, dm, row, note);
628
689
  body.style.maxHeight = '';
629
690
  log.scrollTop = log.scrollHeight;
630
691
  }
631
692
 
632
- /** 被限速时"发送"按钮倒计时,到点恢复 */
633
- private countdown(btn: HTMLButtonElement, ms: number, done: () => void): void {
634
- btn.dataset['wait'] = '1';
635
- btn.disabled = true;
636
- const end = this.now() + ms;
637
- const tick = (): void => {
638
- const s = Math.ceil((end - this.now()) / 1000);
639
- if (s <= 0 || !btn.isConnected) {
640
- btn.textContent = '发送';
641
- delete btn.dataset['wait'];
642
- done();
643
- return;
644
- }
645
- btn.textContent = `${s} 秒`;
646
- this.later(Math.min(1000, end - this.now()), tick);
647
- };
648
- tick();
649
- }
650
-
651
- private renderLog(): void {
652
- const log = this.chatLogEl;
653
- if (!log) return;
654
- log.replaceChildren();
655
- if (this.chatLog.length === 0) add(log, this.el('div', 'atf-empty', '还没人说话。这里的话不保存,房间散了就没了'));
656
- for (const m of this.chatLog) {
657
- const row = this.el('div', '');
658
- add(row, this.el('b', '', `${m.name}:`), this.doc.createTextNode(m.text));
659
- add(log, row);
660
- }
661
- log.scrollTop = log.scrollHeight;
662
- }
663
-
664
693
  /**
665
694
  * iOS 键盘弹出时会盖住下半屏、页面不一定跟着滚:按 visualViewport 把面板整个往上挪到键盘上面,记录区限在可见高度的 60% 以内。
666
695
  * 只动这个面板的 top(transform 里是 placeMenu 摆的位置),收起菜单时摘掉
@@ -691,11 +720,6 @@ export class TableFun {
691
720
  this.vvFix = null;
692
721
  }
693
722
 
694
- private setUnread(on: boolean): void {
695
- this.unread = on;
696
- this.btn?.classList.toggle('atf-dot', on);
697
- }
698
-
699
723
  /**
700
724
  * 有人说了一句(好友房 RoomClient 的 onChat 交进来):头像旁冒 4 秒气泡、记进聊天页。
701
725
  * 别人说的在"聊天:不看"、被屏蔽(单个或全部屏蔽)时一律不显示;面板没开在聊天页时"互动"按钮上亮个小红点
@@ -705,25 +729,25 @@ export class TableFun {
705
729
  const src = this.seat(from);
706
730
  if (!src) return;
707
731
  if (!src.me && (!(this.o.chatEnabled?.() ?? true) || this.isBlocked(from))) return;
708
- this.chatLog.push({ seat: from, name: src.me ? '我' : src.name, text });
709
- while (this.chatLog.length > CHAT_UI.keep) this.chatLog.shift();
710
- this.says.get(from)?.remove();
732
+ const old = this.says.get(from);
733
+ if (old) {
734
+ this.live.delete(old);
735
+ old.remove();
736
+ }
711
737
  const bb = this.bubble(from, text, CHAT_UI.bubbleMs, 'atf-say');
712
738
  if (bb) this.says.set(from, bb);
713
- if (this.chatLogEl) this.renderLog();
714
- else if (!src.me) this.setUnread(true);
739
+ this.cui.push({ seat: from, name: src.me ? '我' : src.name, text, me: !!src.me }, !!this.chatLogEl);
715
740
  this.o.log?.(`聊天 ${from}`);
716
741
  }
717
742
 
718
743
  /** 房间拒了一条(RoomClient 的 onChatError):聊天页开着就在小字那行说一声 */
719
744
  chatError(code: string): void {
720
- const note = this.open?.querySelector<HTMLElement>('.atf-note');
721
- if (note) note.textContent = chatRejectText(code);
745
+ this.cui.note(chatRejectText(code));
722
746
  }
723
747
 
724
748
  /** 有菜单或面板开着(游戏可以据此暂停自己的快捷键) */
725
749
  isOpen(): boolean {
726
- return this.open !== null;
750
+ return this.open !== null || this.cui.typing();
727
751
  }
728
752
 
729
753
  // ───────────── 屏蔽(只是自己不看;这一局 / 这个房间有效) ─────────────
@@ -778,7 +802,10 @@ export class TableFun {
778
802
  if (!src || (!src.me && (!this.enabled() || this.isBlocked(from)))) return;
779
803
  if (it.kind === 'phrase') {
780
804
  const t = to !== undefined && to !== from ? this.seat(to) : null;
781
- this.bubble(from, `${t ? `@${t.name} ` : ''}${this.o.phrases[it.index] ?? ''}`);
805
+ const say = `${t ? `@${t.name} ` : ''}${this.o.phrases[it.index] ?? ''}`;
806
+ this.bubble(from, say);
807
+ // 短语也是说话:有聊天的时候进记录、飘弹幕(0.19.0)
808
+ if (this.chatOn()) this.cui.push({ seat: from, name: src.me ? '我' : src.name, text: say, me: !!src.me }, !!this.chatLogEl);
782
809
  this.o.log?.(`短语 ${from}:${it.index}`);
783
810
  } else if (it.kind === 'sticker') {
784
811
  const name = STICKERS[it.index]!;
@@ -849,19 +876,41 @@ export class TableFun {
849
876
 
850
877
  // ───────────── 小效果 ─────────────
851
878
 
852
- /** 头像旁的一句话:朝屏幕中间那侧冒,2.4 秒后淡出;cls 加样式(聊天的气泡能折行) */
879
+ /**
880
+ * 头像旁的一句话:2.4 秒后淡出(聊天 5 秒)。字号跟着屏幕(0.19.0:电脑 18–22px、手机 ≥ 15px),最多两行折行;
881
+ * 位置挑不压"互动"按钮、聊天框、各家名牌筹码和别人头像的一侧
882
+ */
853
883
  bubble(seat: number, text: string, ms = 2400, cls = ''): HTMLElement | undefined {
854
884
  const s = this.seat(seat);
855
885
  if (!s) return undefined;
856
886
  const b = this.el('div', `atf-bubble ${cls}`.trim(), text);
887
+ const view = { width: window.innerWidth, height: window.innerHeight };
888
+ const fs = chatFont(view);
889
+ // 一行约 16 个字:30 个字的聊天两行放得下
890
+ const maxW = Math.min(fs * 16 + 24, view.width - 16);
891
+ b.style.fontSize = `${fs}px`;
892
+ b.style.maxWidth = `${maxW}px`;
857
893
  this.layer.el.appendChild(b);
858
- const w = Math.min(cls ? 240 : 220, b.offsetWidth || text.length * 15 + 24);
859
- const cx = s.rect.left + s.rect.width / 2;
860
- const right = cx < window.innerWidth / 2;
861
- const x = Math.max(4, Math.min(window.innerWidth - w - 4, right ? s.rect.left + s.rect.width + 6 : s.rect.left - 6 - w));
862
- const y = Math.max(4, s.rect.top - 6);
863
- b.style.transform = `translate(${x}px,${y}px)`;
864
- void anim(b, [{ opacity: 0 }, { opacity: 1 }], 160).then(() => anim(b, [{ opacity: 1 }, { opacity: 0 }], 260, ms)).then(() => b.remove());
894
+ const w = Math.min(maxW, b.offsetWidth || text.length * fs + 24);
895
+ const h = b.offsetHeight || fs * 1.6;
896
+ // 避开"互动"按钮、各家的名牌筹码和别人的头像(0.18.2):原来总往朝中心那侧放,竖屏自己的气泡被按钮压住半截
897
+ // 按钮、聊天框叠在气泡上面(DOM 层在效果层之上),压到了字就看不见:单独当"硬"禁区,代价 ×3(0.19.1)
898
+ const avoid: RectLike[] = [...(this.o.avoid?.() ?? []), ...(this.o.center?.() ?? []), ...[...this.live].map((e) => e.getBoundingClientRect())];
899
+ const hard: RectLike[] = [...this.cui.rects(), ...(this.btn ? [this.btn.getBoundingClientRect()] : [])];
900
+ this.o.seats().forEach((o, i) => {
901
+ if (!o) return;
902
+ if (o.plate) avoid.push(o.plate);
903
+ if (i !== seat) avoid.push(o.rect);
904
+ });
905
+ const at = placeBubble(s.rect, w, h, { width: window.innerWidth, height: window.innerHeight }, avoid, hard);
906
+ b.style.transform = `translate(${at.left}px,${at.top}px)`;
907
+ this.live.add(b);
908
+ void anim(b, [{ opacity: 0 }, { opacity: 1 }], 160)
909
+ .then(() => anim(b, [{ opacity: 1 }, { opacity: 0 }], 260, ms))
910
+ .then(() => {
911
+ this.live.delete(b);
912
+ b.remove();
913
+ });
865
914
  return b;
866
915
  }
867
916
 
@@ -933,13 +982,15 @@ export class TableFun {
933
982
  this.hots.forEach((h, i) => {
934
983
  if (h.style.display !== 'none') out[`fun:seat${i}`] = c(h);
935
984
  });
936
- this.open?.querySelectorAll<HTMLElement>('[data-key]').forEach((e) => (out[e.dataset['key']!] = c(e)));
985
+ // 菜单、面板、聊天框、手机的"聊天"按钮和输入条(data-key 在 .atf-ui 里唯一)
986
+ this.ui.querySelectorAll<HTMLElement>('[data-key]').forEach((e) => (out[e.dataset['key']!] = c(e)));
937
987
  return out;
938
988
  }
939
989
 
940
990
  destroy(): void {
941
991
  this.dead = true;
942
992
  this.unhookViewport();
993
+ this.cui.destroy();
943
994
  for (const t of this.timers) clearTimeout(t);
944
995
  this.timers.clear();
945
996
  window.removeEventListener('resize', this.onResize);