@yyyu-star/arcade-kit 0.17.0 → 0.18.0

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,6 +10,7 @@
10
10
  * - 公平:人人一样、免费;只是画面和声音,不停计时、不改规则;电脑的出牌不读这些。电脑的反应只在画面层(单机)或房间的互动钩子里算。
11
11
  * - kit 不认识任何游戏:短语表、名场面的映射、电脑人物都由游戏给。
12
12
  */
13
+ import { CHAT_MAX, chatRejectText, type ChatBlockWhy } from './chat.js';
13
14
  import { applyTheme, type KitTheme } from './theme.js';
14
15
  import { PROPS, STICKERS, createFunLayer, propSvg, showSticker, stickerSvg, throwAt, type FunLayer, type FunPhase, type PropId, type RectLike, type StickerId } from './fun.js';
15
16
  import {
@@ -84,6 +85,13 @@ export interface TableFunOptions {
84
85
  * 组件过 2 秒再问。不给就挑第一个电脑(没有就第一个别人),和以前一样
85
86
  */
86
87
  hintSeat?(): number | null;
88
+ /**
89
+ * 好友房聊天(0.18.0,docs/chat.md):给了它"互动"面板才有"聊天"页。好友房传 `(text) => client.chat(text)`,返回 RoomClient.chat 的结果;
90
+ * 收到的经 TableFun.chat 交进来。单机不给(单机没有聊天)
91
+ */
92
+ chat?(text: string): ChatResultLike;
93
+ /** 设置里"聊天:开 / 不看"(和"互动"分开):不看时别人说的不冒气泡、不进记录,自己也没有聊天页;默认开 */
94
+ chatEnabled?(): boolean;
87
95
  /** 设置里"互动:开 / 不看",关了别人的一律不播,单机电脑也不反应;默认开 */
88
96
  enabled?(): boolean;
89
97
  /** 屏蔽名单按这个分开(房间码;单机一个固定值),默认 'solo' */
@@ -120,6 +128,12 @@ const DEFAULT_LINES: Record<FunMood, readonly string[]> = {
120
128
 
121
129
  const HINT_TEXT = '点头像扔道具,长按连扔';
122
130
 
131
+ /** 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;
136
+
123
137
  /**
124
138
  * 首次提示冒在哪个座位(纯函数,测试直接测):游戏给了 pick 就听它的,它给的座位空着或不存在当作"现在不行";
125
139
  * 没给就挑第一个电脑,没有电脑挑第一个别人。null = 这会儿不提示,过一会儿再问
@@ -152,6 +166,14 @@ const CSS = `
152
166
  .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}
153
167
  .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}
154
168
  .atf-btn svg{width:22px;height:22px}
169
+ .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}
171
+ .atf-log{display:flex;flex-direction:column;gap:4px;font-size:14px;line-height:1.4;min-height:44px}
172
+ .atf-log b{color:var(--ak-title,#f3d98b);font-weight:700;margin-right:4px}
173
+ .atf-log .atf-empty{opacity:.6}
174
+ .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
+ .atf-note{font-size:13px;margin:4px 4px 0;opacity:.85;min-height:18px}
176
+ .atf-chip:disabled{opacity:.5;cursor:default}
155
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}
156
178
  .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}
157
179
  .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)}
@@ -163,7 +185,7 @@ const CSS = `
163
185
  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>';
164
186
 
165
187
  type Rect = { left: number; top: number; width: number; height: number };
166
- type Tab = 'phrase' | 'sticker' | 'prop';
188
+ type Tab = 'phrase' | 'sticker' | 'prop' | 'chat';
167
189
 
168
190
  /** 挂子元素(Workers 的类型里也有一个 append,签名对不上,所以统一用 appendChild) */
169
191
  function add(parent: Node, ...kids: Node[]): void {
@@ -196,6 +218,14 @@ export class TableFun {
196
218
  private lastProp: PropId = 'tomato';
197
219
  private lastSticker: StickerId = 'smug';
198
220
  private timers = new Set<ReturnType<typeof setTimeout>>();
221
+ /** 聊天记录(最近 CHAT_UI.keep 条,只在内存里;关页面、换房间就没了) */
222
+ private chatLog: { seat: number; name: string; text: string }[] = [];
223
+ private chatLogEl: HTMLElement | null = null;
224
+ /** 每个人最近的那个聊天气泡:同一个人连说两条,后一条顶掉前一条 */
225
+ private says = new Map<number, HTMLElement>();
226
+ private unread = false;
227
+ /** 键盘弹出时跟着可见区挪面板(输入框聚焦时挂上) */
228
+ private vvFix: (() => void) | null = null;
199
229
  private dead = false;
200
230
  private readonly onResize = (): void => this.refresh();
201
231
 
@@ -277,6 +307,7 @@ export class TableFun {
277
307
  b.className = 'atf-btn';
278
308
  b.type = 'button';
279
309
  b.innerHTML = `${SMILE}<span>互动</span>`;
310
+ b.classList.toggle('atf-dot', this.unread);
280
311
  b.addEventListener('click', (e) => {
281
312
  e.stopPropagation();
282
313
  if (this.open?.dataset['kind'] === 'panel') this.closeAll();
@@ -476,10 +507,13 @@ export class TableFun {
476
507
  if (!this.canSend() || !this.btn) return;
477
508
  this.tab = tab;
478
509
  const r = this.btn.getBoundingClientRect();
510
+ const hasChat = this.chatOn();
511
+ if (tab === 'chat' && !hasChat) tab = this.tab = 'phrase';
479
512
  const b = this.box('panel', 440, 300, r);
480
513
  const tabs = this.el('div', 'atf-row');
481
- (['phrase', 'sticker', 'prop'] as Tab[]).forEach((t, i) => {
482
- const c = this.chip(['短语', '表情', '道具'][i]!, `fun:tab:${t}`, () => this.openPanel(t), t === tab ? 'atf-on' : 'atf-soft');
514
+ const list: [Tab, string][] = [['phrase', '短语'], ['sticker', '表情'], ['prop', '道具'], ...(hasChat ? [['chat', '聊天'] as [Tab, string]] : [])];
515
+ list.forEach(([t, label]) => {
516
+ const c = this.chip(label, `fun:tab:${t}`, () => this.openPanel(t), t === tab ? 'atf-on' : 'atf-soft');
483
517
  c.style.flex = '1';
484
518
  add(tabs, c);
485
519
  });
@@ -494,6 +528,12 @@ export class TableFun {
494
528
  const body = this.el('div', 'atf-scroll');
495
529
  body.style.maxHeight = '224px';
496
530
  body.style.marginTop = '8px';
531
+ if (tab === 'chat') {
532
+ add(b, body);
533
+ this.chatBody(b, body);
534
+ this.fit(b, r, 440);
535
+ return;
536
+ }
497
537
  if (tab === 'phrase') {
498
538
  const g = this.el('div', 'atf-grid');
499
539
  g.style.gridTemplateColumns = 'repeat(3,1fr)';
@@ -530,6 +570,155 @@ export class TableFun {
530
570
  closeAll(): void {
531
571
  this.open?.remove();
532
572
  this.open = null;
573
+ this.chatLogEl = null;
574
+ this.unhookViewport();
575
+ }
576
+
577
+ // ───────────── 聊天(0.18.0) ─────────────
578
+
579
+ private chatOn(): boolean {
580
+ return !!this.o.chat && (this.o.chatEnabled?.() ?? true);
581
+ }
582
+
583
+ /** 聊天页:上面最近 30 条(新的在下),下面一行输入框 + 发送,再下面一行小字(还能打几个字、为什么没发出去) */
584
+ private chatBody(b: HTMLDivElement, body: HTMLDivElement): void {
585
+ this.setUnread(false);
586
+ // 记录自己滚,输入框一直在下面看得见
587
+ const log = this.el('div', 'atf-log atf-scroll');
588
+ log.style.maxHeight = '160px';
589
+ this.chatLogEl = log;
590
+ this.renderLog();
591
+ add(body, log);
592
+ const row = this.el('div', 'atf-row');
593
+ 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
+ input.addEventListener('focus', () => this.hookViewport(b));
625
+ add(row, input, send);
626
+ add(body, row, note);
627
+ left();
628
+ body.style.maxHeight = '';
629
+ log.scrollTop = log.scrollHeight;
630
+ }
631
+
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
+ /**
665
+ * iOS 键盘弹出时会盖住下半屏、页面不一定跟着滚:按 visualViewport 把面板整个往上挪到键盘上面,记录区限在可见高度的 60% 以内。
666
+ * 只动这个面板的 top(transform 里是 placeMenu 摆的位置),收起菜单时摘掉
667
+ */
668
+ private hookViewport(b: HTMLDivElement): void {
669
+ const vv = window.visualViewport;
670
+ if (!vv || this.vvFix) return;
671
+ const fix = (): void => {
672
+ const log = this.chatLogEl;
673
+ if (log) log.style.maxHeight = `${Math.max(44, Math.floor(vv.height * 0.6) - 110)}px`;
674
+ b.style.top = '0px';
675
+ const r = b.getBoundingClientRect();
676
+ const bottom = vv.offsetTop + vv.height - 8;
677
+ if (r.bottom > bottom) b.style.top = `${Math.max(vv.offsetTop + 4 - r.top, bottom - r.bottom)}px`;
678
+ };
679
+ this.vvFix = fix;
680
+ vv.addEventListener('resize', fix);
681
+ vv.addEventListener('scroll', fix);
682
+ fix();
683
+ }
684
+
685
+ private unhookViewport(): void {
686
+ const vv = typeof window !== 'undefined' ? window.visualViewport : null;
687
+ if (this.vvFix && vv) {
688
+ vv.removeEventListener('resize', this.vvFix);
689
+ vv.removeEventListener('scroll', this.vvFix);
690
+ }
691
+ this.vvFix = null;
692
+ }
693
+
694
+ private setUnread(on: boolean): void {
695
+ this.unread = on;
696
+ this.btn?.classList.toggle('atf-dot', on);
697
+ }
698
+
699
+ /**
700
+ * 有人说了一句(好友房 RoomClient 的 onChat 交进来):头像旁冒 4 秒气泡、记进聊天页。
701
+ * 别人说的在"聊天:不看"、被屏蔽(单个或全部屏蔽)时一律不显示;面板没开在聊天页时"互动"按钮上亮个小红点
702
+ */
703
+ chat(from: number, text: string, _masked = false): void {
704
+ if (this.dead || !this.o.chat) return;
705
+ const src = this.seat(from);
706
+ if (!src) return;
707
+ 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();
711
+ const bb = this.bubble(from, text, CHAT_UI.bubbleMs, 'atf-say');
712
+ if (bb) this.says.set(from, bb);
713
+ if (this.chatLogEl) this.renderLog();
714
+ else if (!src.me) this.setUnread(true);
715
+ this.o.log?.(`聊天 ${from}`);
716
+ }
717
+
718
+ /** 房间拒了一条(RoomClient 的 onChatError):聊天页开着就在小字那行说一声 */
719
+ chatError(code: string): void {
720
+ const note = this.open?.querySelector<HTMLElement>('.atf-note');
721
+ if (note) note.textContent = chatRejectText(code);
533
722
  }
534
723
 
535
724
  /** 有菜单或面板开着(游戏可以据此暂停自己的快捷键) */
@@ -660,19 +849,20 @@ export class TableFun {
660
849
 
661
850
  // ───────────── 小效果 ─────────────
662
851
 
663
- /** 头像旁的一句话:朝屏幕中间那侧冒,2.4 秒后淡出 */
664
- bubble(seat: number, text: string, ms = 2400): void {
852
+ /** 头像旁的一句话:朝屏幕中间那侧冒,2.4 秒后淡出;cls 加样式(聊天的气泡能折行) */
853
+ bubble(seat: number, text: string, ms = 2400, cls = ''): HTMLElement | undefined {
665
854
  const s = this.seat(seat);
666
- if (!s) return;
667
- const b = this.el('div', 'atf-bubble', text);
855
+ if (!s) return undefined;
856
+ const b = this.el('div', `atf-bubble ${cls}`.trim(), text);
668
857
  this.layer.el.appendChild(b);
669
- const w = Math.min(220, b.offsetWidth || text.length * 15 + 24);
858
+ const w = Math.min(cls ? 240 : 220, b.offsetWidth || text.length * 15 + 24);
670
859
  const cx = s.rect.left + s.rect.width / 2;
671
860
  const right = cx < window.innerWidth / 2;
672
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));
673
862
  const y = Math.max(4, s.rect.top - 6);
674
863
  b.style.transform = `translate(${x}px,${y}px)`;
675
864
  void anim(b, [{ opacity: 0 }, { opacity: 1 }], 160).then(() => anim(b, [{ opacity: 1 }, { opacity: 0 }], 260, ms)).then(() => b.remove());
865
+ return b;
676
866
  }
677
867
 
678
868
  /** 头像上方冒一个字(×N、回敬!、护盾):放大弹一下再飘走 */
@@ -749,6 +939,7 @@ export class TableFun {
749
939
 
750
940
  destroy(): void {
751
941
  this.dead = true;
942
+ this.unhookViewport();
752
943
  for (const t of this.timers) clearTimeout(t);
753
944
  this.timers.clear();
754
945
  window.removeEventListener('resize', this.onResize);
package/src/worker.ts CHANGED
@@ -131,8 +131,11 @@ export async function acceptJoin(request: Request, env: KitEnv, code: string, op
131
131
  limited = true;
132
132
  }
133
133
  // 新建请求,只带认好的身份和客户端版本;浏览器带来的 x-kit-user 之类的头一律不转
134
- const cv = new URL(request.url).searchParams.get('cv') ?? '0';
135
- const res = await roomStub(env.ROOM, code).fetch(`https://${KIT_HOST}/ws?cv=${encodeURIComponent(cv)}`, {
134
+ const q = new URL(request.url).searchParams;
135
+ const cv = q.get('cv') ?? '0';
136
+ // chat=1(0.18.0):新版 RoomClient 认聊天消息,房间只给这样的连接发
137
+ const chat = q.get('chat') === '1' ? '&chat=1' : '';
138
+ const res = await roomStub(env.ROOM, code).fetch(`https://${KIT_HOST}/ws?cv=${encodeURIComponent(cv)}${chat}`, {
136
139
  headers: { upgrade: 'websocket', 'x-kit-user': identityHeader(me), ...(limited ? { [LIMITED_HEADER]: '1' } : {}) },
137
140
  });
138
141
  if (!res.headers.has(MISS_HEADER)) return res;