@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
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 牌桌聊天的界面(0.19.0,docs/chat.md 第 5 节):电脑上牌桌一角常驻的聊天框、手机上的"聊天"按钮和贴底输入条、弹幕、最近 30 条记录。
|
|
3
|
+
* 所有者 2026-10-18:聊天太简陋、不显眼,电脑上气泡太小。TableFun 建它、喂它,游戏不直接用(不进 package.json 的 exports)。
|
|
4
|
+
* 和 table-fun.ts 同样的规矩:import 时不碰 DOM;动画只动 transform、opacity;量位置只在摆放(refresh)和出一条弹幕时。
|
|
5
|
+
*/
|
|
6
|
+
import { CHAT_MAX, chatRejectText, type ChatBlockWhy } from './chat.js';
|
|
7
|
+
import { DANMAKU, barBottom, chatFont, chatMode, danmakuLanes, danmakuSpeed, pickLane, placeBeside, placeDock, seatColor, type Box, type ChatMode } from './fun-logic.js';
|
|
8
|
+
|
|
9
|
+
export type ChatResultLike = { ok: true } | { ok: false; code: string; why?: ChatBlockWhy; waitMs?: number };
|
|
10
|
+
|
|
11
|
+
/** 记录里的一行:打的字和快捷短语都记(只在内存里,关页面、换房间就没了) */
|
|
12
|
+
export interface ChatLine {
|
|
13
|
+
seat: number;
|
|
14
|
+
name: string;
|
|
15
|
+
text: string;
|
|
16
|
+
me: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 存在游戏给的 key 下(`<游戏id>.chat` 这类,checkPrefsKey 查过):没设过的项按默认 */
|
|
20
|
+
interface ChatPrefs {
|
|
21
|
+
/** 弹幕开关;没设过:系统要求减少动态效果、或省内存模式时默认关,其余默认开 */
|
|
22
|
+
danmaku?: boolean;
|
|
23
|
+
/** 电脑版聊天框收起来了 */
|
|
24
|
+
folded?: boolean;
|
|
25
|
+
/** 电脑版聊天框的记录展开成长条 */
|
|
26
|
+
long?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ChatHost {
|
|
30
|
+
/** 点得到的那一层(.atf-ui)和点击穿透的效果层(弹幕画在这里) */
|
|
31
|
+
ui: HTMLElement;
|
|
32
|
+
fx: HTMLElement;
|
|
33
|
+
prefsKey: string;
|
|
34
|
+
/** 快捷词:游戏的短语表,点了按短语发(走互动的发送通道,没开打字聊天也能用) */
|
|
35
|
+
phrases: readonly string[];
|
|
36
|
+
phrase(i: number): void;
|
|
37
|
+
/** 打字:好友房 RoomClient.chat、单机 localChat */
|
|
38
|
+
send(text: string): ChatResultLike;
|
|
39
|
+
/** 现在能不能发(旁观、没坐下不能) */
|
|
40
|
+
canSend(): boolean;
|
|
41
|
+
/** 不能盖的地方:游戏的禁区、"互动"按钮、头像、名牌 */
|
|
42
|
+
blocked(): Box[];
|
|
43
|
+
/** 正在显示的气泡(弹幕的轨道也避开) */
|
|
44
|
+
bubbles(): Box[];
|
|
45
|
+
/** 各家头像和名牌(默认弹幕区的上沿压到上方各家的下面) */
|
|
46
|
+
seatBoxes(): Box[];
|
|
47
|
+
/** 手机输入条不能盖的:游戏的禁区(手牌、操作按钮)、"互动"按钮、自己的头像和名牌(别家的头像打字时盖住没关系) */
|
|
48
|
+
barBlocked(): Box[];
|
|
49
|
+
/** "互动"按钮的矩形(手机版"聊天"按钮挨着它、跟它一样大),按同样的尺寸参数画 */
|
|
50
|
+
button(): Box | null;
|
|
51
|
+
styleButton(b: HTMLElement): void;
|
|
52
|
+
/** 游戏指定电脑版聊天框的位置(只用 left、top、width),不给就自己挑角 */
|
|
53
|
+
dock?(): Box | null;
|
|
54
|
+
/** 游戏指定手机版"聊天"按钮的位置,不给就挨着"互动"按钮 */
|
|
55
|
+
chatButton?(): Box | null;
|
|
56
|
+
/** 游戏指定弹幕区,不给就是牌桌上半部分 */
|
|
57
|
+
area?(): Box | null;
|
|
58
|
+
/** 省内存模式(弹幕默认关) */
|
|
59
|
+
lite(): boolean;
|
|
60
|
+
/** 手机版"记录":打开互动面板的聊天页 */
|
|
61
|
+
history(): void;
|
|
62
|
+
later(ms: number, fn: () => void): void;
|
|
63
|
+
now(): number;
|
|
64
|
+
log?(line: string): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const CHAT_CSS = `
|
|
68
|
+
.atc-dock{position:absolute;left:0;top:0;pointer-events:auto;display:flex;flex-direction:column;gap:6px;background:var(--ak-bg,rgba(43,29,20,.9));border:2px solid var(--ak-border,#d4a64a);border-radius:var(--ak-radius,14px);padding:8px;box-sizing:border-box;color:var(--ak-fg,#fff6e0);box-shadow:0 6px 18px rgba(0,0,0,.4)}
|
|
69
|
+
.atc-head{display:flex;align-items:center;gap:6px}
|
|
70
|
+
.atc-head b{flex:1;color:var(--ak-title,#f3d98b);font-size:1.05em}
|
|
71
|
+
.atc-head .atf-chip{min-height:36px;padding:0 10px;font-size:.8em}
|
|
72
|
+
.atc-log{display:flex;flex-direction:column;gap:2px;line-height:1.45;font-size:.9em;overflow-y:auto;overscroll-behavior:contain}
|
|
73
|
+
.atc-log .atf-empty{opacity:.6}
|
|
74
|
+
.atc-log b,.atf-log b{font-weight:700;margin-right:4px}
|
|
75
|
+
.atc-quick{display:flex;gap:6px;overflow-x:auto;overscroll-behavior:contain;scrollbar-width:none;flex-shrink:0;padding-right:28px;-webkit-mask-image:linear-gradient(to right,#000 calc(100% - 36px),transparent);mask-image:linear-gradient(to right,#000 calc(100% - 36px),transparent)}
|
|
76
|
+
.atc-quick::-webkit-scrollbar{display:none}
|
|
77
|
+
.atc-quick .atf-chip{flex:none;white-space:nowrap;font-size:.8em}
|
|
78
|
+
.atc-row{display:flex;gap:6px;align-items:center}
|
|
79
|
+
.atc-row .atf-input{font-size:max(16px,.85em)}
|
|
80
|
+
.atc-row .atf-chip{flex:none}
|
|
81
|
+
.atc-pill{position:absolute;left:0;top:0;pointer-events:auto;min-height:44px;display:flex;align-items:center;gap:6px;padding:0 16px;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-weight:700;cursor:pointer}
|
|
82
|
+
.atc-pill svg{width:1.2em;height:1.2em}
|
|
83
|
+
.atc-pill.atf-dot::after{content:'';position:absolute;right:4px;top:4px;width:10px;height:10px;border-radius:50%;background:#e8323a;border:2px solid var(--ak-fg,#fff6e0)}
|
|
84
|
+
.atc-dock .atf-note:empty,.atc-bar .atf-note:empty{display:none}
|
|
85
|
+
.atc-bar{position:absolute;left:8px;right:8px;top:0;pointer-events:auto;display:flex;flex-direction:column;gap:6px;background:var(--ak-bg,rgba(43,29,20,.94));border:2px solid var(--ak-border,#d4a64a);border-radius:var(--ak-radius,14px);padding:6px;box-sizing:border-box;color:var(--ak-fg,#fff6e0);box-shadow:0 -4px 16px rgba(0,0,0,.4)}
|
|
86
|
+
.atc-dms{position:absolute;inset:0;z-index:0;pointer-events:none;overflow:hidden}
|
|
87
|
+
.atc-dm{position:absolute;left:0;top:0;white-space:nowrap;font-weight:700;color:#fff;padding:2px 12px;border-radius:999px;background:rgba(0,0,0,.38);text-shadow:0 1px 2px rgba(0,0,0,.8);pointer-events:none;will-change:transform}
|
|
88
|
+
.atc-dm.atc-me{background:rgba(122,42,30,.78);border:2px solid var(--ak-title,#f3d98b);color:#fff6e0}
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
const CHAT_ICON = '<svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="5" stroke-linejoin="round"><path d="M10 14h44v28H30l-12 10v-10h-8z"/></svg>';
|
|
92
|
+
|
|
93
|
+
/** prefsKey 只认 `<游戏id>.<名字>` 的写法(存储前缀规则:kit 写的 key 一定带游戏前缀),不合格构造时就抛错 */
|
|
94
|
+
export function checkPrefsKey(key: unknown, storageKey: string): string {
|
|
95
|
+
if (typeof key !== 'string' || !key.startsWith(`${storageKey}.`) || !/^[a-z0-9][a-z0-9-]*(\.[a-z0-9-]+)+$/.test(key) || key.length > 64) {
|
|
96
|
+
throw new Error(`TableFun prefsKey 要写成 "${storageKey}.<名字>"(小写字母、数字、连字符),收到 ${JSON.stringify(key)}`);
|
|
97
|
+
}
|
|
98
|
+
return key;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function add(parent: Node, ...kids: Node[]): void {
|
|
102
|
+
for (const k of kids) parent.appendChild(k);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class ChatUi {
|
|
106
|
+
/** 最近 KEEP 条,新的在后 */
|
|
107
|
+
readonly lines: ChatLine[] = [];
|
|
108
|
+
static readonly KEEP = 30;
|
|
109
|
+
private readonly h: ChatHost;
|
|
110
|
+
private readonly doc: Document;
|
|
111
|
+
private prefs: ChatPrefs;
|
|
112
|
+
private on = false;
|
|
113
|
+
private mode: ChatMode = 'desk';
|
|
114
|
+
private font = 18;
|
|
115
|
+
private dockEl: HTMLDivElement | null = null;
|
|
116
|
+
private pill: HTMLButtonElement | null = null;
|
|
117
|
+
private btn: HTMLButtonElement | null = null;
|
|
118
|
+
private bar: HTMLDivElement | null = null;
|
|
119
|
+
/** 渲染记录的地方:电脑版聊天框里的那块、互动面板聊天页里的那块 */
|
|
120
|
+
private logs = new Set<HTMLElement>();
|
|
121
|
+
private unread = false;
|
|
122
|
+
private freeAt: number[] = [];
|
|
123
|
+
private live = 0;
|
|
124
|
+
private pending: ChatLine[] = [];
|
|
125
|
+
private waiting = false;
|
|
126
|
+
private vvFix: (() => void) | null = null;
|
|
127
|
+
/** 弹幕自己一层,垫在效果层最底下:气泡、表情、道具永远在弹幕上面 */
|
|
128
|
+
private dm: HTMLDivElement | null = null;
|
|
129
|
+
|
|
130
|
+
constructor(host: ChatHost) {
|
|
131
|
+
this.h = host;
|
|
132
|
+
this.doc = host.ui.ownerDocument;
|
|
133
|
+
this.prefs = this.load();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private dmLayer(): HTMLDivElement {
|
|
137
|
+
if (!this.dm) {
|
|
138
|
+
this.dm = this.el('div', 'atc-dms');
|
|
139
|
+
this.h.fx.insertBefore(this.dm, this.h.fx.firstChild);
|
|
140
|
+
}
|
|
141
|
+
return this.dm;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ───────────── 设置 ─────────────
|
|
145
|
+
|
|
146
|
+
private load(): ChatPrefs {
|
|
147
|
+
try {
|
|
148
|
+
const raw = JSON.parse(localStorage.getItem(this.h.prefsKey) ?? 'null') as ChatPrefs | null;
|
|
149
|
+
if (!raw || typeof raw !== 'object') return {};
|
|
150
|
+
const out: ChatPrefs = {};
|
|
151
|
+
for (const k of ['danmaku', 'folded', 'long'] as const) if (typeof raw[k] === 'boolean') out[k] = raw[k];
|
|
152
|
+
return out;
|
|
153
|
+
} catch {
|
|
154
|
+
return {};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
private save(): void {
|
|
158
|
+
try {
|
|
159
|
+
localStorage.setItem(this.h.prefsKey, JSON.stringify(this.prefs));
|
|
160
|
+
} catch {
|
|
161
|
+
// 存不了就只是这一次有效
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 弹幕开着吗:设过就按设的;没设过,系统要求减少动态效果或省内存模式时关 */
|
|
166
|
+
danmaku(): boolean {
|
|
167
|
+
if (this.prefs.danmaku !== undefined) return this.prefs.danmaku;
|
|
168
|
+
const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
169
|
+
return !reduce && !this.h.lite();
|
|
170
|
+
}
|
|
171
|
+
setDanmaku(on: boolean): void {
|
|
172
|
+
this.prefs.danmaku = on;
|
|
173
|
+
this.save();
|
|
174
|
+
if (!on) {
|
|
175
|
+
this.pending = [];
|
|
176
|
+
this.dm?.replaceChildren();
|
|
177
|
+
}
|
|
178
|
+
this.h.ui.querySelectorAll<HTMLElement>('[data-key="chat:danmaku"],[data-key="fun:danmaku"]').forEach((c) => (c.textContent = this.danmakuLabel()));
|
|
179
|
+
this.h.log?.(`弹幕 ${on ? '开' : '关'}`);
|
|
180
|
+
}
|
|
181
|
+
danmakuLabel(): string {
|
|
182
|
+
return `弹幕:${this.danmaku() ? '开' : '关'}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ───────────── 摆放 ─────────────
|
|
186
|
+
|
|
187
|
+
/** on = 这会儿有聊天(游戏开了聊天、设置没关)。窗口变了、座位挪了由 TableFun.refresh 调 */
|
|
188
|
+
refresh(on: boolean): void {
|
|
189
|
+
this.on = on;
|
|
190
|
+
const view = { width: window.innerWidth, height: window.innerHeight };
|
|
191
|
+
this.mode = chatMode(view);
|
|
192
|
+
this.font = chatFont(view, this.mode);
|
|
193
|
+
if (!on) {
|
|
194
|
+
this.clearDesk();
|
|
195
|
+
this.clearPhone();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (this.mode === 'desk') {
|
|
199
|
+
this.clearPhone();
|
|
200
|
+
this.drawDesk(view);
|
|
201
|
+
} else {
|
|
202
|
+
this.clearDesk();
|
|
203
|
+
this.drawPhone(view);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 聊天自己占的地方(气泡、弹幕要避开) */
|
|
208
|
+
rects(): Box[] {
|
|
209
|
+
return [this.dockEl, this.pill, this.btn, this.bar].filter((e): e is NonNullable<typeof e> => !!e).map((e) => e.getBoundingClientRect());
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 有输入条或聊天框里的输入框正在用(游戏据此暂停快捷键) */
|
|
213
|
+
typing(): boolean {
|
|
214
|
+
return !!this.bar || this.doc.activeElement?.classList.contains('atc-input') === true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private clearDesk(): void {
|
|
218
|
+
this.dockEl?.remove();
|
|
219
|
+
this.pill?.remove();
|
|
220
|
+
if (this.dockEl) this.logs.delete(this.dockEl.querySelector('.atc-log')!);
|
|
221
|
+
this.dockEl = null;
|
|
222
|
+
this.pill = null;
|
|
223
|
+
}
|
|
224
|
+
private clearPhone(): void {
|
|
225
|
+
this.btn?.remove();
|
|
226
|
+
this.btn = null;
|
|
227
|
+
this.closeBar();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private drawDesk(view: { width: number; height: number }): void {
|
|
231
|
+
const w = Math.round(this.font * 17);
|
|
232
|
+
const fixed = this.h.dock?.() ?? null;
|
|
233
|
+
if (this.prefs.folded) {
|
|
234
|
+
if (this.dockEl) this.clearDesk();
|
|
235
|
+
if (!this.pill) {
|
|
236
|
+
const p = this.el('button', 'atc-pill');
|
|
237
|
+
p.type = 'button';
|
|
238
|
+
p.dataset['key'] = 'chat:open';
|
|
239
|
+
p.innerHTML = `${CHAT_ICON}<span>聊天</span>`;
|
|
240
|
+
p.addEventListener('click', (e) => {
|
|
241
|
+
e.stopPropagation();
|
|
242
|
+
this.fold(false);
|
|
243
|
+
});
|
|
244
|
+
this.h.ui.appendChild(p);
|
|
245
|
+
this.pill = p;
|
|
246
|
+
}
|
|
247
|
+
this.pill.style.fontSize = `${this.font - 2}px`;
|
|
248
|
+
this.pill.classList.toggle('atf-dot', this.unread);
|
|
249
|
+
const pw = this.pill.offsetWidth || 110;
|
|
250
|
+
const at = fixed ? { left: fixed.left, top: fixed.top } : placeDock(view, pw, 48, this.h.blocked());
|
|
251
|
+
this.pill.style.transform = `translate(${at.left}px,${at.top}px)`;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (!this.dockEl) this.dockEl = this.buildDock();
|
|
255
|
+
const d = this.dockEl;
|
|
256
|
+
d.style.fontSize = `${this.font - 2}px`;
|
|
257
|
+
d.style.width = `${fixed?.width ?? w}px`;
|
|
258
|
+
const log = d.querySelector<HTMLElement>('.atc-log')!;
|
|
259
|
+
const line = (this.font - 2) * 0.9 * 1.45 + 2;
|
|
260
|
+
// 按内容高度:最少两行(空的时候那句提示),最多 5 行;展开后最多半屏
|
|
261
|
+
log.style.minHeight = `${Math.round(line * 2)}px`;
|
|
262
|
+
log.style.maxHeight = `${Math.round(this.prefs.long ? Math.min(view.height * 0.5, line * ChatUi.KEEP) : line * 5)}px`;
|
|
263
|
+
const input = d.querySelector<HTMLInputElement>('.atc-input');
|
|
264
|
+
if (input) input.disabled = !this.h.canSend();
|
|
265
|
+
const at = fixed ? { left: fixed.left, top: fixed.top } : placeDock(view, w, d.offsetHeight || 260, this.h.blocked());
|
|
266
|
+
d.style.transform = `translate(${at.left}px,${at.top}px)`;
|
|
267
|
+
log.scrollTop = log.scrollHeight;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private buildDock(): HTMLDivElement {
|
|
271
|
+
const d = this.el('div', 'atc-dock');
|
|
272
|
+
d.dataset['key'] = 'chat:dock';
|
|
273
|
+
d.addEventListener('pointerdown', (e) => e.stopPropagation());
|
|
274
|
+
const head = this.el('div', 'atc-head');
|
|
275
|
+
add(
|
|
276
|
+
head,
|
|
277
|
+
this.el('b', '', '聊天'),
|
|
278
|
+
this.chip(this.danmakuLabel(), 'chat:danmaku', () => this.setDanmaku(!this.danmaku()), 'atf-soft'),
|
|
279
|
+
this.chip(this.prefs.long ? '少看几条' : '展开记录', 'chat:more', () => {
|
|
280
|
+
this.prefs.long = !this.prefs.long;
|
|
281
|
+
this.save();
|
|
282
|
+
this.clearDesk();
|
|
283
|
+
this.refresh(this.on);
|
|
284
|
+
}, 'atf-soft'),
|
|
285
|
+
this.chip('收起', 'chat:fold', () => this.fold(true), 'atf-grey'),
|
|
286
|
+
);
|
|
287
|
+
const log = this.el('div', 'atc-log');
|
|
288
|
+
this.logs.add(log);
|
|
289
|
+
const { row, note } = this.inputRow('chat', 'atc-input');
|
|
290
|
+
add(d, head, log, this.quick('chat'), row, note);
|
|
291
|
+
this.h.ui.appendChild(d);
|
|
292
|
+
this.render();
|
|
293
|
+
return d;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private fold(on: boolean): void {
|
|
297
|
+
this.prefs.folded = on;
|
|
298
|
+
this.save();
|
|
299
|
+
if (!on) this.setUnread(false);
|
|
300
|
+
this.clearDesk();
|
|
301
|
+
this.refresh(this.on);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private drawPhone(view: { width: number; height: number }): void {
|
|
305
|
+
const r = this.h.button();
|
|
306
|
+
if (!r) {
|
|
307
|
+
this.clearPhone();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (!this.btn) {
|
|
311
|
+
const b = this.el('button', 'atf-btn');
|
|
312
|
+
b.type = 'button';
|
|
313
|
+
b.dataset['key'] = 'chat:button';
|
|
314
|
+
b.innerHTML = `${CHAT_ICON}<span>聊天</span>`;
|
|
315
|
+
b.addEventListener('click', (e) => {
|
|
316
|
+
e.stopPropagation();
|
|
317
|
+
if (this.bar) this.closeBar();
|
|
318
|
+
else this.openBar();
|
|
319
|
+
});
|
|
320
|
+
this.h.ui.appendChild(b);
|
|
321
|
+
this.btn = b;
|
|
322
|
+
}
|
|
323
|
+
const b = this.btn;
|
|
324
|
+
this.h.styleButton(b);
|
|
325
|
+
const w = Math.max(44, r.width);
|
|
326
|
+
const hh = Math.max(44, r.height);
|
|
327
|
+
b.style.width = `${w}px`;
|
|
328
|
+
b.style.height = `${hh}px`;
|
|
329
|
+
b.classList.toggle('atf-dot', this.unread);
|
|
330
|
+
// 游戏给了位置就放那;不然挨着"互动"按钮(先右后左再上下),手牌、名牌、头像都避开
|
|
331
|
+
const own = this.h.chatButton?.() ?? null;
|
|
332
|
+
const at = own ? { left: own.left, top: own.top } : placeBeside(r, w, hh, view, this.h.blocked(), 4, 8);
|
|
333
|
+
if (own) {
|
|
334
|
+
b.style.width = `${Math.max(44, own.width)}px`;
|
|
335
|
+
b.style.height = `${Math.max(44, own.height)}px`;
|
|
336
|
+
}
|
|
337
|
+
b.style.transform = `translate(${at.left}px,${at.top}px)`;
|
|
338
|
+
if (this.bar) this.placeBar();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** 手机版输入条:快捷词一排、输入框、发送、记录、收起;贴在手牌和操作按钮上面 */
|
|
342
|
+
openBar(): void {
|
|
343
|
+
if (this.bar || !this.on) return;
|
|
344
|
+
const bar = this.el('div', 'atc-bar');
|
|
345
|
+
bar.dataset['key'] = 'chat:bar';
|
|
346
|
+
bar.style.fontSize = `${this.font + 1}px`;
|
|
347
|
+
bar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
|
348
|
+
const { row, note, input } = this.inputRow('chat', 'atc-input');
|
|
349
|
+
add(
|
|
350
|
+
row,
|
|
351
|
+
this.chip('记录', 'chat:history', () => {
|
|
352
|
+
this.closeBar();
|
|
353
|
+
this.h.history();
|
|
354
|
+
}, 'atf-soft'),
|
|
355
|
+
this.chip('✕', 'chat:close', () => this.closeBar(), 'atf-grey'),
|
|
356
|
+
);
|
|
357
|
+
add(bar, this.quick('chat'), row, note);
|
|
358
|
+
this.h.ui.appendChild(bar);
|
|
359
|
+
this.bar = bar;
|
|
360
|
+
this.setUnread(false);
|
|
361
|
+
input.addEventListener('focus', () => this.hookViewport());
|
|
362
|
+
this.placeBar();
|
|
363
|
+
this.h.log?.('聊天 输入条');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
closeBar(): void {
|
|
367
|
+
this.unhookViewport();
|
|
368
|
+
this.bar?.remove();
|
|
369
|
+
this.bar = null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private placeBar(): void {
|
|
373
|
+
const bar = this.bar;
|
|
374
|
+
if (!bar) return;
|
|
375
|
+
const view = { width: window.innerWidth, height: window.innerHeight };
|
|
376
|
+
const h = bar.offsetHeight || 120;
|
|
377
|
+
let bottom = barBottom(view, h, [...this.h.barBlocked(), ...(this.btn ? [this.btn.getBoundingClientRect()] : [])]);
|
|
378
|
+
// 键盘弹起:可见区变矮,输入条跟着站到键盘上面
|
|
379
|
+
const vv = window.visualViewport;
|
|
380
|
+
if (vv && this.doc.activeElement?.classList.contains('atc-input')) bottom = Math.min(bottom, vv.offsetTop + vv.height - 6);
|
|
381
|
+
bar.style.transform = `translateY(${Math.max(4, bottom - h)}px)`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private hookViewport(): void {
|
|
385
|
+
const vv = window.visualViewport;
|
|
386
|
+
if (!vv || this.vvFix) return;
|
|
387
|
+
const fix = (): void => this.placeBar();
|
|
388
|
+
this.vvFix = fix;
|
|
389
|
+
vv.addEventListener('resize', fix);
|
|
390
|
+
vv.addEventListener('scroll', fix);
|
|
391
|
+
fix();
|
|
392
|
+
}
|
|
393
|
+
private unhookViewport(): void {
|
|
394
|
+
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
|
395
|
+
if (this.vvFix && vv) {
|
|
396
|
+
vv.removeEventListener('resize', this.vvFix);
|
|
397
|
+
vv.removeEventListener('scroll', this.vvFix);
|
|
398
|
+
}
|
|
399
|
+
this.vvFix = null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ───────────── 小部件 ─────────────
|
|
403
|
+
|
|
404
|
+
private el<K extends keyof HTMLElementTagNameMap>(tag: K, cls: string, text?: string): HTMLElementTagNameMap[K] {
|
|
405
|
+
const e = this.doc.createElement(tag);
|
|
406
|
+
e.className = cls;
|
|
407
|
+
if (text !== undefined) e.textContent = text;
|
|
408
|
+
return e;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
chip(label: string, key: string, fn: () => void, cls = ''): HTMLButtonElement {
|
|
412
|
+
const c = this.el('button', `atf-chip ${cls}`.trim(), label);
|
|
413
|
+
c.type = 'button';
|
|
414
|
+
c.dataset['key'] = key;
|
|
415
|
+
c.addEventListener('click', (e) => {
|
|
416
|
+
e.stopPropagation();
|
|
417
|
+
fn();
|
|
418
|
+
});
|
|
419
|
+
return c;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** 输入框上方那排快捷词:点了直接按短语发(冷却和互动共用,被冷却挡住时不发) */
|
|
423
|
+
private quick(prefix: string): HTMLDivElement {
|
|
424
|
+
const q = this.el('div', 'atc-quick');
|
|
425
|
+
this.h.phrases.forEach((p, i) => add(q, this.chip(p, `${prefix}:phrase${i}`, () => this.h.phrase(i), 'atf-soft')));
|
|
426
|
+
return q;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* 一行输入框 + 发送,下面一行小字(还能打几个字、为什么没发出去)。回车发送;被限速时"发送"倒计时。
|
|
431
|
+
* 电脑版聊天框、手机输入条、互动面板的聊天页共用
|
|
432
|
+
*/
|
|
433
|
+
inputRow(prefix: string, cls = ''): { row: HTMLDivElement; note: HTMLDivElement; input: HTMLInputElement } {
|
|
434
|
+
const row = this.el('div', 'atc-row');
|
|
435
|
+
const input = this.el('input', `atf-input ${cls}`.trim());
|
|
436
|
+
input.type = 'text';
|
|
437
|
+
input.enterKeyHint = 'send';
|
|
438
|
+
input.autocomplete = 'off';
|
|
439
|
+
input.placeholder = '说点什么,回车发送';
|
|
440
|
+
input.dataset['key'] = prefix === 'fun' ? 'fun:chatInput' : `${prefix}:input`;
|
|
441
|
+
const send = this.chip('发送', prefix === 'fun' ? 'fun:chatSend' : `${prefix}:send`, () => go());
|
|
442
|
+
const note = this.el('div', 'atf-note');
|
|
443
|
+
const left = (): void => {
|
|
444
|
+
const n = CHAT_MAX - Array.from(input.value.trim()).length;
|
|
445
|
+
note.textContent = n < 0 ? `多了 ${-n} 个字` : input.value ? `还能打 ${n} 个字` : '';
|
|
446
|
+
send.disabled = n < 0 || send.dataset['wait'] === '1' || !this.h.canSend();
|
|
447
|
+
};
|
|
448
|
+
const go = (): void => {
|
|
449
|
+
if (!input.value.trim() || send.disabled) return;
|
|
450
|
+
const r = this.h.send(input.value);
|
|
451
|
+
if (r.ok) {
|
|
452
|
+
input.value = '';
|
|
453
|
+
left();
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
note.textContent = chatRejectText(r.code, r.why);
|
|
457
|
+
if (r.code === 'chat_too_fast' && r.waitMs) this.countdown(send, r.waitMs, left);
|
|
458
|
+
};
|
|
459
|
+
input.addEventListener('input', left);
|
|
460
|
+
input.addEventListener('keydown', (e) => {
|
|
461
|
+
// 打字时不让游戏的快捷键接到
|
|
462
|
+
e.stopPropagation();
|
|
463
|
+
if (e.key === 'Enter' && !e.isComposing) go();
|
|
464
|
+
if (e.key === 'Escape') input.blur();
|
|
465
|
+
});
|
|
466
|
+
add(row, input, send);
|
|
467
|
+
left();
|
|
468
|
+
return { row, note, input };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private countdown(btn: HTMLButtonElement, ms: number, done: () => void): void {
|
|
472
|
+
btn.dataset['wait'] = '1';
|
|
473
|
+
btn.disabled = true;
|
|
474
|
+
const end = this.h.now() + ms;
|
|
475
|
+
const tick = (): void => {
|
|
476
|
+
const s = Math.ceil((end - this.h.now()) / 1000);
|
|
477
|
+
if (s <= 0 || !btn.isConnected) {
|
|
478
|
+
btn.textContent = '发送';
|
|
479
|
+
delete btn.dataset['wait'];
|
|
480
|
+
done();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
btn.textContent = `${s} 秒`;
|
|
484
|
+
this.h.later(Math.min(1000, end - this.h.now()), tick);
|
|
485
|
+
};
|
|
486
|
+
tick();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** 房间拒了一条:看得见的小字行都说一声 */
|
|
490
|
+
note(text: string): void {
|
|
491
|
+
this.h.ui.querySelectorAll<HTMLElement>('.atf-note').forEach((n) => (n.textContent = text));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ───────────── 记录 ─────────────
|
|
495
|
+
|
|
496
|
+
/** 互动面板的聊天页挂上 / 摘下它的记录区 */
|
|
497
|
+
bindLog(el: HTMLElement | null, old?: HTMLElement | null): void {
|
|
498
|
+
if (old) this.logs.delete(old);
|
|
499
|
+
if (el) {
|
|
500
|
+
this.logs.add(el);
|
|
501
|
+
this.renderOne(el);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private render(): void {
|
|
506
|
+
for (const el of this.logs) this.renderOne(el);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private renderOne(log: HTMLElement): void {
|
|
510
|
+
log.replaceChildren();
|
|
511
|
+
if (this.lines.length === 0) add(log, this.el('div', 'atf-empty', '还没人说话。这里的话不保存,散了就没了'));
|
|
512
|
+
for (const m of this.lines) {
|
|
513
|
+
const row = this.el('div', '');
|
|
514
|
+
const b = this.el('b', '', `${m.name}:`);
|
|
515
|
+
b.style.color = seatColor(m.seat);
|
|
516
|
+
add(row, b, this.doc.createTextNode(m.text));
|
|
517
|
+
add(log, row);
|
|
518
|
+
}
|
|
519
|
+
log.scrollTop = log.scrollHeight;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** 有人说了一句(打字或快捷短语):进记录、飘弹幕;看不见聊天的时候亮小红点 */
|
|
523
|
+
push(line: ChatLine, panelOpen: boolean): void {
|
|
524
|
+
this.lines.push(line);
|
|
525
|
+
while (this.lines.length > ChatUi.KEEP) this.lines.shift();
|
|
526
|
+
this.render();
|
|
527
|
+
if (!line.me && !panelOpen && !this.bar && !(this.mode === 'desk' && this.dockEl)) this.setUnread(true);
|
|
528
|
+
this.danmakuOf(line);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
setUnread(on: boolean): void {
|
|
532
|
+
this.unread = on;
|
|
533
|
+
this.btn?.classList.toggle('atf-dot', on);
|
|
534
|
+
this.pill?.classList.toggle('atf-dot', on);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ───────────── 弹幕 ─────────────
|
|
538
|
+
|
|
539
|
+
private danmakuOf(line: ChatLine): void {
|
|
540
|
+
if (!this.on || !this.danmaku()) return;
|
|
541
|
+
const cap = this.mode === 'desk' ? DANMAKU.maxDesk : DANMAKU.maxPhone;
|
|
542
|
+
if (this.live >= cap || !this.fly(line)) {
|
|
543
|
+
// 满了或没有空轨道:排着,最多 DANMAKU.queue 条,再多丢最老的
|
|
544
|
+
this.pending.push(line);
|
|
545
|
+
while (this.pending.length > DANMAKU.queue) this.pending.shift();
|
|
546
|
+
this.wake();
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* 弹幕区:游戏给了就用;不给是屏幕 8%–58%,上沿再压到最上面一排座位(头像顶边在屏高 20% 以内的,就是对家那一排)的头像、名牌下沿以下,
|
|
552
|
+
* 顶栏和对家名牌不被横穿;剩下不够两行时往下放宽(最多到 75%),不往上退
|
|
553
|
+
*/
|
|
554
|
+
private area(): Box {
|
|
555
|
+
const a = this.h.area?.();
|
|
556
|
+
if (a) return a;
|
|
557
|
+
const w = window.innerWidth;
|
|
558
|
+
const hh = window.innerHeight;
|
|
559
|
+
const lineH = Math.round(this.font * 1.5) + 10;
|
|
560
|
+
const top = Math.max(Math.round(hh * 0.08), ...this.h.seatBoxes().filter((b) => b.top < hh * 0.2).map((b) => b.top + b.height + 4));
|
|
561
|
+
const bottom = Math.min(Math.round(hh * 0.75), Math.max(Math.round(hh * 0.58), top + lineH * 2));
|
|
562
|
+
return { left: 0, top, width: w, height: Math.max(0, bottom - top) };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** 找空轨道飞一条;没有空的返回 false */
|
|
566
|
+
private fly(line: ChatLine): boolean {
|
|
567
|
+
const area = this.area();
|
|
568
|
+
const lineH = Math.round(this.font * 1.5) + 10;
|
|
569
|
+
const lanes = danmakuLanes(area, lineH, [...this.h.blocked(), ...this.h.bubbles(), ...this.rects()], this.mode === 'desk' ? DANMAKU.lanesDesk : DANMAKU.lanesPhone);
|
|
570
|
+
if (lanes.length === 0) return true;
|
|
571
|
+
if (this.freeAt.length !== lanes.length) this.freeAt = lanes.map(() => 0);
|
|
572
|
+
const now = this.h.now();
|
|
573
|
+
const i = pickLane(this.freeAt, now);
|
|
574
|
+
if (i < 0) return false;
|
|
575
|
+
const e = this.el('div', `atc-dm${line.me ? ' atc-me' : ''}`);
|
|
576
|
+
e.style.fontSize = `${this.font}px`;
|
|
577
|
+
const b = this.el('b', '', `${line.name}:`);
|
|
578
|
+
b.style.color = line.me ? '' : seatColor(line.seat);
|
|
579
|
+
add(e, b, this.doc.createTextNode(line.text));
|
|
580
|
+
this.dmLayer().appendChild(e);
|
|
581
|
+
const w = e.offsetWidth || line.text.length * this.font + 60;
|
|
582
|
+
const v = danmakuSpeed(area.width);
|
|
583
|
+
const y = lanes[i]! + (lineH - (e.offsetHeight || lineH)) / 2;
|
|
584
|
+
this.freeAt[i] = now + (w + DANMAKU.gap) / v;
|
|
585
|
+
const from = `translate(${area.left + area.width}px,${y}px)`;
|
|
586
|
+
const to = `translate(${area.left - w}px,${y}px)`;
|
|
587
|
+
e.style.transform = from;
|
|
588
|
+
this.live++;
|
|
589
|
+
const done = (): void => {
|
|
590
|
+
e.remove();
|
|
591
|
+
this.live--;
|
|
592
|
+
this.wake();
|
|
593
|
+
};
|
|
594
|
+
if (typeof e.animate !== 'function') {
|
|
595
|
+
this.h.later((area.width + w) / v, done);
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
e.animate([{ transform: from }, { transform: to }], { duration: (area.width + w) / v, easing: 'linear', fill: 'both' }).finished.then(done, done);
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** 排着的弹幕:到最早那条轨道空出来时再试 */
|
|
603
|
+
private wake(): void {
|
|
604
|
+
// 满了不用等:哪条飞完了 done 会再叫
|
|
605
|
+
if (this.waiting || this.pending.length === 0 || this.live >= (this.mode === 'desk' ? DANMAKU.maxDesk : DANMAKU.maxPhone)) return;
|
|
606
|
+
const now = this.h.now();
|
|
607
|
+
const ms = this.freeAt.length ? Math.max(50, Math.min(...this.freeAt) - now) : 200;
|
|
608
|
+
this.waiting = true;
|
|
609
|
+
this.h.later(ms, () => {
|
|
610
|
+
this.waiting = false;
|
|
611
|
+
if (!this.on || !this.danmaku()) {
|
|
612
|
+
this.pending = [];
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const cap = this.mode === 'desk' ? DANMAKU.maxDesk : DANMAKU.maxPhone;
|
|
616
|
+
while (this.pending.length && this.live < cap && this.fly(this.pending[0]!)) this.pending.shift();
|
|
617
|
+
this.wake();
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
destroy(): void {
|
|
622
|
+
this.unhookViewport();
|
|
623
|
+
this.pending = [];
|
|
624
|
+
this.logs.clear();
|
|
625
|
+
this.dm?.remove();
|
|
626
|
+
}
|
|
627
|
+
}
|