@yyyu-star/arcade-kit 0.18.0 → 0.19.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/CHANGELOG.md +26 -0
- package/README.md +31 -2
- package/dist/chat.d.ts +34 -0
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.js +28 -1
- package/dist/chat.js.map +1 -1
- package/dist/client.d.ts +2 -19
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/fun-logic.d.ts +77 -0
- package/dist/fun-logic.d.ts.map +1 -1
- package/dist/fun-logic.js +139 -0
- package/dist/fun-logic.js.map +1 -1
- package/dist/table-chat.d.ts +148 -0
- package/dist/table-chat.d.ts.map +1 -0
- package/dist/table-chat.js +556 -0
- package/dist/table-chat.js.map +1 -0
- package/dist/table-fun.d.ts +56 -33
- package/dist/table-fun.d.ts.map +1 -1
- package/dist/table-fun.js +137 -111
- package/dist/table-fun.js.map +1 -1
- package/package.json +1 -1
- package/src/chat.ts +47 -1
- package/src/client.ts +3 -10
- package/src/fun-logic.ts +147 -0
- package/src/table-chat.ts +627 -0
- package/src/table-fun.ts +157 -107
package/src/table-fun.ts
CHANGED
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
* - 公平:人人一样、免费;只是画面和声音,不停计时、不改规则;电脑的出牌不读这些。电脑的反应只在画面层(单机)或房间的互动钩子里算。
|
|
11
11
|
* - kit 不认识任何游戏:短语表、名场面的映射、电脑人物都由游戏给。
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
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
|
-
|
|
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{
|
|
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:
|
|
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
|
-
/**
|
|
222
|
-
private
|
|
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
|
-
|
|
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.
|
|
591
|
-
|
|
592
|
-
|
|
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(
|
|
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.
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,40 @@ export class TableFun {
|
|
|
849
876
|
|
|
850
877
|
// ───────────── 小效果 ─────────────
|
|
851
878
|
|
|
852
|
-
/**
|
|
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(
|
|
859
|
-
const
|
|
860
|
-
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
+
const avoid: RectLike[] = [...(this.o.avoid?.() ?? []), ...(this.o.center?.() ?? []), ...this.cui.rects(), ...[...this.live].map((e) => e.getBoundingClientRect())];
|
|
898
|
+
if (this.btn) avoid.push(this.btn.getBoundingClientRect());
|
|
899
|
+
this.o.seats().forEach((o, i) => {
|
|
900
|
+
if (!o) return;
|
|
901
|
+
if (o.plate) avoid.push(o.plate);
|
|
902
|
+
if (i !== seat) avoid.push(o.rect);
|
|
903
|
+
});
|
|
904
|
+
const at = placeBubble(s.rect, w, h, { width: window.innerWidth, height: window.innerHeight }, avoid);
|
|
905
|
+
b.style.transform = `translate(${at.left}px,${at.top}px)`;
|
|
906
|
+
this.live.add(b);
|
|
907
|
+
void anim(b, [{ opacity: 0 }, { opacity: 1 }], 160)
|
|
908
|
+
.then(() => anim(b, [{ opacity: 1 }, { opacity: 0 }], 260, ms))
|
|
909
|
+
.then(() => {
|
|
910
|
+
this.live.delete(b);
|
|
911
|
+
b.remove();
|
|
912
|
+
});
|
|
865
913
|
return b;
|
|
866
914
|
}
|
|
867
915
|
|
|
@@ -933,13 +981,15 @@ export class TableFun {
|
|
|
933
981
|
this.hots.forEach((h, i) => {
|
|
934
982
|
if (h.style.display !== 'none') out[`fun:seat${i}`] = c(h);
|
|
935
983
|
});
|
|
936
|
-
|
|
984
|
+
// 菜单、面板、聊天框、手机的"聊天"按钮和输入条(data-key 在 .atf-ui 里唯一)
|
|
985
|
+
this.ui.querySelectorAll<HTMLElement>('[data-key]').forEach((e) => (out[e.dataset['key']!] = c(e)));
|
|
937
986
|
return out;
|
|
938
987
|
}
|
|
939
988
|
|
|
940
989
|
destroy(): void {
|
|
941
990
|
this.dead = true;
|
|
942
991
|
this.unhookViewport();
|
|
992
|
+
this.cui.destroy();
|
|
943
993
|
for (const t of this.timers) clearTimeout(t);
|
|
944
994
|
this.timers.clear();
|
|
945
995
|
window.removeEventListener('resize', this.onResize);
|