@yyyu-star/arcade-kit 0.10.2 → 0.11.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.
@@ -0,0 +1,652 @@
1
+ /**
2
+ * 牌桌互动组件(0.11.0,仓库根 docs/table-fun.md 第 0 节):头像菜单、互动面板、表情菜单、首次提示、冷却和护盾、连击和回敬、
3
+ * 电脑的反应、名场面横幅。几个牌桌游戏共用一套,外观、位置规则、动画、声音完全一样;游戏只做接线:
4
+ * 告诉组件每个座位的头像在屏幕上的矩形、名字、是不是电脑和互动性格,哪些地方不能盖(手牌、操作按钮),怎么把东西发出去,
5
+ * 再把收到的(好友房经房间转发的,单机自己的)交给 receive、把公开的名场面交给 moment。
6
+ *
7
+ * - DOM 实现:头像上盖一块透明的热区接点按和长按(画布游戏把世界坐标换成屏幕矩形传进来就行),菜单和面板是普通的 DOM,点按区 ≥ 44px;
8
+ * 效果(表情、道具、污渍)走 fun.ts 的图层,点击穿透。这个文件只在调用时碰 DOM,import 时不碰(房间、测试里 import 没事)。
9
+ * - 动画只动 transform、opacity;量位置(getBoundingClientRect)只在开菜单、给冒烟报控件时。
10
+ * - 公平:人人一样、免费;只是画面和声音,不停计时、不改规则;电脑的出牌不读这些。电脑的反应只在画面层(单机)或房间的互动钩子里算。
11
+ * - kit 不认识任何游戏:短语表、名场面的映射、电脑人物都由游戏给。
12
+ */
13
+ import { PROPS, STICKERS, createFunLayer, propSvg, showSticker, stickerSvg, throwAt } from './fun.js';
14
+ import { BOT_REACT, FUN_TIMING, FunCooldown, FunTally, botReact, funItem, newBotState, placeMenu, propId, stickerId, stickerMood, } from './fun-logic.js';
15
+ export * from './fun-logic.js';
16
+ const DEFAULT_LINES = {
17
+ angry: ['等着瞧', '你给我等着', '好啊你'],
18
+ sweat: ['手滑了', '这……', '别扔了别扔了'],
19
+ love: ['谢谢!', '够意思', '收下了'],
20
+ smug: ['就这?', '承让承让', '手气好没办法'],
21
+ shock: ['好家伙', '不是吧', '这都行?'],
22
+ cry: ['服了', '下把再来', '太难了'],
23
+ };
24
+ const HINT_TEXT = '点头像扔道具,长按连扔';
25
+ const CSS_ID = 'arcade-table-fun-css';
26
+ const CSS = `
27
+ .atf-ui{position:fixed;inset:0;pointer-events:none;font-family:inherit;-webkit-tap-highlight-color:transparent}
28
+ .atf-hot{position:absolute;left:0;top:0;pointer-events:auto;border-radius:50%;touch-action:manipulation;cursor:pointer;-webkit-user-select:none;user-select:none}
29
+ .atf-away{position:absolute;inset:0;pointer-events:auto}
30
+ .atf-box{position:absolute;left:0;top:0;pointer-events:auto;background:rgba(43,29,20,.96);border:2px solid #d4a64a;border-radius:14px;padding:8px;box-sizing:border-box;color:#fff6e0;box-shadow:0 6px 18px rgba(0,0,0,.45);transform-origin:center;animation:atf-pop .14s ease-out}
31
+ .atf-title{font-size:15px;font-weight:700;color:#f3d98b;margin:0 4px 6px}
32
+ .atf-row{display:flex;gap:4px;flex-wrap:wrap}
33
+ .atf-cell{position:relative;width:48px;height:48px;border:0;border-radius:10px;background:#4a3220;padding:4px;box-sizing:border-box;cursor:pointer;overflow:hidden}
34
+ .atf-cell svg{width:100%;height:100%;display:block;pointer-events:none}
35
+ .atf-chip{min-height:44px;border:1px solid rgba(212,166,74,.6);border-radius:22px;background:#7a2a1e;color:#fff6e0;font:inherit;font-size:14px;font-weight:700;padding:0 12px;cursor:pointer}
36
+ .atf-chip.atf-soft{background:#4a3220}.atf-chip.atf-grey{background:#3a3a3a}.atf-chip.atf-on{background:#b3202a}.atf-chip.atf-pick{background:#3a6a2a}
37
+ .atf-grid{display:grid;gap:4px}
38
+ .atf-scroll{overflow-y:auto;overscroll-behavior:contain}
39
+ .atf-cool{position:absolute;inset:0;border-radius:10px;background:rgba(0,0,0,.5);pointer-events:none}
40
+ .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:#f3d98b;animation:atf-spin .7s linear infinite}
41
+ .atf-btn{position:absolute;left:0;top:0;pointer-events:auto;min-width:44px;min-height:44px;border-radius:22px;border:2px solid #d4a64a;background:#5a3a22;color:#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}
42
+ .atf-btn svg{width:22px;height:22px}
43
+ .atf-bubble{position:absolute;left:0;top:0;max-width:220px;padding:6px 12px;border-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}
44
+ .atf-flash{position:absolute;left:0;top:0;font-size:26px;font-weight:900;color:#f3d98b;-webkit-text-stroke:1.5px #2b1d14;pointer-events:none;white-space:nowrap}
45
+ .atf-banner{position:absolute;left:50%;top:18%;padding:10px 34px;background:linear-gradient(#c8323a,#8a1a20);border:2px solid #f3d98b;border-radius:6px;color:#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)}
46
+ .atf-banner small{display:block;font-size:15px;letter-spacing:1px;color:#f3d98b}
47
+ @keyframes atf-pop{from{opacity:0;scale:.92}to{opacity:1;scale:1}}
48
+ @keyframes atf-spin{to{transform:rotate(360deg)}}
49
+ `;
50
+ 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>';
51
+ /** 挂子元素(Workers 的类型里也有一个 append,签名对不上,所以统一用 appendChild) */
52
+ function add(parent, ...kids) {
53
+ for (const k of kids)
54
+ parent.appendChild(k);
55
+ }
56
+ /** 动画:只动 transform、opacity;没有 animate 的环境按时长等 */
57
+ function anim(el, frames, ms, delay = 0) {
58
+ if (typeof el.animate !== 'function')
59
+ return new Promise((ok) => setTimeout(ok, ms + delay));
60
+ return el.animate(frames, { duration: ms, delay, easing: 'ease-out', fill: 'both' }).finished.then(() => undefined, () => undefined);
61
+ }
62
+ export class TableFun {
63
+ layer;
64
+ ui;
65
+ doc;
66
+ o;
67
+ P;
68
+ cool = new FunCooldown();
69
+ tally = new FunTally();
70
+ bots = newBotState();
71
+ hots = [];
72
+ btn = null;
73
+ open = null;
74
+ tab = 'phrase';
75
+ target = -1;
76
+ lastProp = 'tomato';
77
+ lastSticker = 'smug';
78
+ timers = new Set();
79
+ dead = false;
80
+ onResize = () => this.refresh();
81
+ constructor(opts) {
82
+ this.o = opts;
83
+ this.P = opts.phrases.length;
84
+ this.doc = document;
85
+ if (!this.doc.getElementById(CSS_ID)) {
86
+ const st = this.doc.createElement('style');
87
+ st.id = CSS_ID;
88
+ st.textContent = CSS;
89
+ this.doc.head.appendChild(st);
90
+ }
91
+ const z = opts.zIndex ?? 50;
92
+ this.layer = createFunLayer({ zIndex: z });
93
+ this.ui = this.doc.createElement('div');
94
+ this.ui.className = 'atf-ui';
95
+ this.ui.style.zIndex = String(z + 1);
96
+ this.doc.body.appendChild(this.ui);
97
+ window.addEventListener('resize', this.onResize);
98
+ this.refresh();
99
+ this.later(1500, () => this.hint());
100
+ }
101
+ now() {
102
+ return this.o.now?.() ?? Date.now();
103
+ }
104
+ rand() {
105
+ return this.o.rand?.() ?? Math.random();
106
+ }
107
+ later(ms, fn) {
108
+ const t = setTimeout(() => {
109
+ this.timers.delete(t);
110
+ if (!this.dead)
111
+ fn();
112
+ }, ms);
113
+ this.timers.add(t);
114
+ }
115
+ seat(i) {
116
+ return this.o.seats()[i] ?? null;
117
+ }
118
+ meSeat() {
119
+ return this.o.seats().findIndex((s) => !!s?.me);
120
+ }
121
+ canSend() {
122
+ return this.o.canSend?.() ?? true;
123
+ }
124
+ enabled() {
125
+ return this.o.enabled?.() ?? true;
126
+ }
127
+ // ───────────── 布局 ─────────────
128
+ /** 头像挪了、窗口变了、座位换人:重新摆热区和按钮(游戏重排画面后调;窗口 resize 自己会调) */
129
+ refresh() {
130
+ if (this.dead)
131
+ return;
132
+ const seats = this.o.seats();
133
+ while (this.hots.length < seats.length)
134
+ this.hots.push(this.makeHot(this.hots.length));
135
+ this.hots.forEach((h, i) => {
136
+ const s = seats[i];
137
+ if (!s) {
138
+ h.style.display = 'none';
139
+ return;
140
+ }
141
+ h.style.display = '';
142
+ h.style.width = `${Math.max(44, s.rect.width)}px`;
143
+ h.style.height = `${Math.max(44, s.rect.height)}px`;
144
+ h.style.transform = `translate(${s.rect.left + s.rect.width / 2 - Math.max(44, s.rect.width) / 2}px,${s.rect.top + s.rect.height / 2 - Math.max(44, s.rect.height) / 2}px)`;
145
+ });
146
+ const r = this.o.button?.() ?? null;
147
+ if (!r) {
148
+ this.btn?.remove();
149
+ this.btn = null;
150
+ }
151
+ else {
152
+ if (!this.btn) {
153
+ const b = this.doc.createElement('button');
154
+ b.className = 'atf-btn';
155
+ b.type = 'button';
156
+ b.innerHTML = `${SMILE}<span>互动</span>`;
157
+ b.addEventListener('click', (e) => {
158
+ e.stopPropagation();
159
+ if (this.open?.dataset['kind'] === 'panel')
160
+ this.closeAll();
161
+ else
162
+ this.openPanel(this.tab);
163
+ });
164
+ this.ui.appendChild(b);
165
+ this.btn = b;
166
+ }
167
+ this.btn.style.width = `${Math.max(44, r.width)}px`;
168
+ this.btn.style.height = `${Math.max(44, r.height)}px`;
169
+ this.btn.style.transform = `translate(${r.left}px,${r.top}px)`;
170
+ }
171
+ }
172
+ /** 头像热区:点一下开菜单(别人)或表情菜单(自己);按住 longPressMs 直接扔上次的道具 / 发上次的表情 */
173
+ makeHot(i) {
174
+ const h = this.doc.createElement('div');
175
+ h.className = 'atf-hot';
176
+ h.dataset['seat'] = String(i);
177
+ let timer = null;
178
+ let fired = false;
179
+ const clear = () => {
180
+ if (timer)
181
+ clearTimeout(timer);
182
+ timer = null;
183
+ };
184
+ h.addEventListener('pointerdown', (e) => {
185
+ e.stopPropagation();
186
+ fired = false;
187
+ clear();
188
+ timer = setTimeout(() => {
189
+ timer = null;
190
+ fired = true;
191
+ this.quick(i);
192
+ }, FUN_TIMING.longPressMs);
193
+ });
194
+ h.addEventListener('pointerup', (e) => {
195
+ e.stopPropagation();
196
+ clear();
197
+ if (!fired)
198
+ this.tapSeat(i);
199
+ });
200
+ h.addEventListener('pointerleave', clear);
201
+ h.addEventListener('pointercancel', clear);
202
+ h.addEventListener('contextmenu', (e) => e.preventDefault());
203
+ this.ui.appendChild(h);
204
+ return h;
205
+ }
206
+ tapSeat(i) {
207
+ const s = this.seat(i);
208
+ if (!s || !this.canSend())
209
+ return;
210
+ if (this.open?.dataset['seat'] === String(i))
211
+ return this.closeAll();
212
+ if (s.me)
213
+ this.openStickers(i);
214
+ else
215
+ this.openMenu(i);
216
+ }
217
+ /** 长按:别人 → 扔上次那个道具;自己 → 发上次那个表情 */
218
+ quick(i) {
219
+ const s = this.seat(i);
220
+ if (!s || !this.canSend())
221
+ return;
222
+ this.closeAll();
223
+ if (s.me)
224
+ this.pick(stickerId(this.P, STICKERS.indexOf(this.lastSticker)));
225
+ else
226
+ this.pick(propId(this.P, PROPS.indexOf(this.lastProp)), i);
227
+ }
228
+ // ───────────── 发 ─────────────
229
+ pick(id, to) {
230
+ const it = funItem(id, this.P);
231
+ if (!it || !this.canSend())
232
+ return false;
233
+ if (!this.cool.take(it.kind === 'prop' ? 'prop' : 'talk', this.now()))
234
+ return false;
235
+ if (it.kind === 'prop')
236
+ this.lastProp = PROPS[it.index];
237
+ if (it.kind === 'sticker')
238
+ this.lastSticker = STICKERS[it.index];
239
+ this.closeAll();
240
+ this.o.send(id, to);
241
+ return true;
242
+ }
243
+ // ───────────── 菜单、面板 ─────────────
244
+ box(kind, w, h, anchor, seat) {
245
+ this.closeAll();
246
+ const away = this.doc.createElement('div');
247
+ away.className = 'atf-away';
248
+ away.addEventListener('pointerdown', (e) => {
249
+ e.stopPropagation();
250
+ this.closeAll();
251
+ });
252
+ const b = this.doc.createElement('div');
253
+ b.className = 'atf-box';
254
+ b.dataset['kind'] = kind;
255
+ if (seat !== undefined)
256
+ b.dataset['seat'] = String(seat);
257
+ b.style.width = `${w}px`;
258
+ b.style.height = `${h}px`;
259
+ const view = { width: window.innerWidth, height: window.innerHeight };
260
+ const at = placeMenu(anchor, w, h, view, this.o.avoid?.() ?? []);
261
+ b.style.transform = `translate(${at.left}px,${at.top}px)`;
262
+ b.addEventListener('pointerdown', (e) => e.stopPropagation());
263
+ const wrap = this.doc.createElement('div');
264
+ wrap.dataset['kind'] = kind;
265
+ if (seat !== undefined)
266
+ wrap.dataset['seat'] = String(seat);
267
+ add(wrap, away, b);
268
+ this.ui.appendChild(wrap);
269
+ this.open = wrap;
270
+ return b;
271
+ }
272
+ el(tag, cls, text) {
273
+ const e = this.doc.createElement(tag);
274
+ e.className = cls;
275
+ if (text !== undefined)
276
+ e.textContent = text;
277
+ return e;
278
+ }
279
+ chip(label, key, fn, cls = '') {
280
+ const c = this.el('button', `atf-chip ${cls}`, label);
281
+ c.type = 'button';
282
+ c.dataset['key'] = key;
283
+ c.addEventListener('click', (e) => {
284
+ e.stopPropagation();
285
+ fn();
286
+ });
287
+ return c;
288
+ }
289
+ cell(svg, key, fn, prop = false) {
290
+ const c = this.el('button', 'atf-cell');
291
+ c.type = 'button';
292
+ c.dataset['key'] = key;
293
+ c.innerHTML = svg;
294
+ const left = prop ? this.cool.left('prop', this.now()) : 0;
295
+ if (left > 0) {
296
+ // 冷却中:盖一层转圈,到点拿掉
297
+ const ov = this.el('div', 'atf-cool');
298
+ c.appendChild(ov);
299
+ setTimeout(() => ov.remove(), left);
300
+ }
301
+ c.addEventListener('click', (e) => {
302
+ e.stopPropagation();
303
+ fn();
304
+ });
305
+ return c;
306
+ }
307
+ /** 点别人头像:第一排 8 个道具(第一格是上次扔的那个),第二排 3 句点名短语,最后"屏蔽他" */
308
+ openMenu(v) {
309
+ const s = this.seat(v);
310
+ if (!s)
311
+ return;
312
+ const b = this.box('menu', 8 * 52 + 16, 196, s.rect, v);
313
+ add(b, this.el('div', 'atf-title', `对 ${s.name}`));
314
+ const row = this.el('div', 'atf-row');
315
+ const order = [this.lastProp, ...PROPS.filter((p) => p !== this.lastProp)];
316
+ order.forEach((p, k) => add(row, this.cell(propSvg(p), `fun:prop${k}`, () => void this.pick(propId(this.P, PROPS.indexOf(p)), v), true)));
317
+ add(b, row);
318
+ const row2 = this.el('div', 'atf-row');
319
+ row2.style.marginTop = '6px';
320
+ const named = this.o.named ?? [0, 1, 2];
321
+ named.slice(0, 3).forEach((ix, k) => {
322
+ const c = this.chip(this.o.phrases[ix] ?? '', `fun:named${k}`, () => void this.pick(ix, v));
323
+ c.style.flex = '1';
324
+ add(row2, c);
325
+ });
326
+ add(b, row2);
327
+ const blocked = this.isBlocked(v);
328
+ const blk = this.chip(blocked ? '不再屏蔽' : '屏蔽他', 'fun:block', () => {
329
+ this.setBlocked(v, !blocked);
330
+ this.closeAll();
331
+ }, 'atf-grey');
332
+ blk.style.marginTop = '6px';
333
+ blk.style.width = '100%';
334
+ add(b, blk);
335
+ }
336
+ /** 点自己头像:12 个表情 */
337
+ openStickers(v) {
338
+ const s = this.seat(v);
339
+ if (!s)
340
+ return;
341
+ const b = this.box('stickers', 6 * 52 + 16, 2 * 52 + 16, s.rect, v);
342
+ const g = this.el('div', 'atf-grid');
343
+ g.style.gridTemplateColumns = 'repeat(6,48px)';
344
+ STICKERS.forEach((st, k) => add(g, this.cell(stickerSvg(st), `fun:sticker${k}`, () => void this.pick(stickerId(this.P, k)))));
345
+ add(b, g);
346
+ }
347
+ /** "互动"按钮:短语 / 表情 / 道具三个页签;道具页先选人再选道具 */
348
+ openPanel(tab = this.tab) {
349
+ if (!this.canSend() || !this.btn)
350
+ return;
351
+ this.tab = tab;
352
+ const r = this.btn.getBoundingClientRect();
353
+ const b = this.box('panel', 440, 300, r);
354
+ const tabs = this.el('div', 'atf-row');
355
+ ['phrase', 'sticker', 'prop'].forEach((t, i) => {
356
+ const c = this.chip(['短语', '表情', '道具'][i], `fun:tab:${t}`, () => this.openPanel(t), t === tab ? 'atf-on' : 'atf-soft');
357
+ c.style.flex = '1';
358
+ add(tabs, c);
359
+ });
360
+ const others = this.o.seats().map((s, i) => (s && !s.me ? i : -1)).filter((i) => i >= 0);
361
+ const all = others.length > 0 && others.every((i) => this.isBlocked(i));
362
+ const blk = this.chip(all ? '全部恢复' : '全部屏蔽', 'fun:blockAll', () => {
363
+ for (const i of others)
364
+ this.setBlocked(i, !all);
365
+ this.closeAll();
366
+ }, 'atf-grey');
367
+ add(tabs, blk);
368
+ add(b, tabs);
369
+ const body = this.el('div', 'atf-scroll');
370
+ body.style.maxHeight = '224px';
371
+ body.style.marginTop = '8px';
372
+ if (tab === 'phrase') {
373
+ const g = this.el('div', 'atf-grid');
374
+ g.style.gridTemplateColumns = 'repeat(3,1fr)';
375
+ this.o.phrases.forEach((p, i) => add(g, this.chip(p, `fun:phrase${i}`, () => void this.pick(i), 'atf-soft')));
376
+ add(body, g);
377
+ }
378
+ else if (tab === 'sticker') {
379
+ const g = this.el('div', 'atf-grid');
380
+ g.style.gridTemplateColumns = 'repeat(6,48px)';
381
+ g.style.justifyContent = 'center';
382
+ STICKERS.forEach((st, k) => add(g, this.cell(stickerSvg(st), `fun:psticker${k}`, () => void this.pick(stickerId(this.P, k)))));
383
+ add(body, g);
384
+ }
385
+ else {
386
+ if (!others.includes(this.target))
387
+ this.target = others[0] ?? -1;
388
+ const who = this.el('div', 'atf-row');
389
+ for (const i of others) {
390
+ const c = this.chip(`扔给 ${this.seat(i).name}`, `fun:target${i}`, () => {
391
+ this.target = i;
392
+ this.openPanel('prop');
393
+ }, i === this.target ? 'atf-pick' : 'atf-soft');
394
+ c.style.flex = '1';
395
+ add(who, c);
396
+ }
397
+ const row = this.el('div', 'atf-row');
398
+ row.style.marginTop = '10px';
399
+ row.style.justifyContent = 'center';
400
+ PROPS.forEach((p, k) => add(row, this.cell(propSvg(p), `fun:pprop${k}`, () => void this.pick(propId(this.P, k), this.target), true)));
401
+ add(body, who, row);
402
+ }
403
+ add(b, body);
404
+ // 高度跟着内容(短语少时不留一大块空),量完按真实高度重新避开禁区
405
+ b.style.height = 'auto';
406
+ const h = b.offsetHeight;
407
+ const at = placeMenu(r, 440, h, { width: window.innerWidth, height: window.innerHeight }, this.o.avoid?.() ?? []);
408
+ b.style.transform = `translate(${at.left}px,${at.top}px)`;
409
+ }
410
+ closeAll() {
411
+ this.open?.remove();
412
+ this.open = null;
413
+ }
414
+ /** 有菜单或面板开着(游戏可以据此暂停自己的快捷键) */
415
+ isOpen() {
416
+ return this.open !== null;
417
+ }
418
+ // ───────────── 屏蔽(只是自己不看;这一局 / 这个房间有效) ─────────────
419
+ blockKey() {
420
+ return `${this.o.storageKey}.fun.blocked`;
421
+ }
422
+ blockedNames() {
423
+ try {
424
+ const raw = JSON.parse(sessionStorage.getItem(this.blockKey()) ?? 'null');
425
+ return raw && raw.scope === (this.o.scope?.() ?? 'solo') && Array.isArray(raw.names) ? raw.names.filter((x) => typeof x === 'string') : [];
426
+ }
427
+ catch {
428
+ return [];
429
+ }
430
+ }
431
+ isBlocked(seat) {
432
+ const s = this.seat(seat);
433
+ return !!s && !s.me && this.blockedNames().includes(`${seat}:${s.name}`);
434
+ }
435
+ setBlocked(seat, on) {
436
+ const s = this.seat(seat);
437
+ if (!s)
438
+ return;
439
+ const key = `${seat}:${s.name}`;
440
+ const names = this.blockedNames().filter((n) => !n.startsWith(`${seat}:`));
441
+ if (on)
442
+ names.push(key);
443
+ try {
444
+ sessionStorage.setItem(this.blockKey(), JSON.stringify({ scope: this.o.scope?.() ?? 'solo', names }));
445
+ }
446
+ catch {
447
+ // 存不了就只是不记
448
+ }
449
+ }
450
+ // ───────────── 收 ─────────────
451
+ /** 有人发了东西(好友房房间转发来的、单机自己的、电脑的):按种类播;别人的东西在关了互动、被屏蔽时不播 */
452
+ receive(from, id, to) {
453
+ if (this.dead)
454
+ return;
455
+ const it = funItem(id, this.P);
456
+ const src = this.seat(from);
457
+ if (!it || !src)
458
+ return;
459
+ if (!src.me && (!this.enabled() || this.isBlocked(from)))
460
+ return;
461
+ // 好友房的电脑(房间里 botReact 算的):晚一点播,表情配一句话,和单机的电脑看起来一样
462
+ if (src.bot && !this.o.localBots) {
463
+ this.later(FUN_TIMING.botLagMs, () => this.play(from, it, to, true));
464
+ return;
465
+ }
466
+ this.play(from, it, to, false);
467
+ }
468
+ play(from, it, to, botTalk) {
469
+ const src = this.seat(from);
470
+ if (!src || (!src.me && (!this.enabled() || this.isBlocked(from))))
471
+ return;
472
+ if (it.kind === 'phrase') {
473
+ const t = to !== undefined && to !== from ? this.seat(to) : null;
474
+ this.bubble(from, `${t ? `@${t.name} ` : ''}${this.o.phrases[it.index] ?? ''}`);
475
+ this.o.log?.(`短语 ${from}:${it.index}`);
476
+ }
477
+ else if (it.kind === 'sticker') {
478
+ const name = STICKERS[it.index];
479
+ void showSticker(this.layer, src.rect, name);
480
+ const mood = botTalk ? stickerMood(name) : null;
481
+ if (mood)
482
+ this.bubble(from, this.line(mood));
483
+ this.o.log?.(`表情 ${from}:${name}`);
484
+ }
485
+ else {
486
+ const prop = PROPS[it.index];
487
+ const dst = to === undefined ? null : this.seat(to);
488
+ if (!dst || to === from)
489
+ return;
490
+ const r = this.tally.hit(from, to, this.now());
491
+ if (r.shield) {
492
+ this.flash(to, '护盾', '#9fd8ff');
493
+ this.o.log?.(`护盾 ${to}`);
494
+ return;
495
+ }
496
+ void throwAt(this.layer, src.rect, dst.rect, prop, {
497
+ sound: this.o.sound,
498
+ target: to,
499
+ onHit: () => {
500
+ this.o.onHit?.(to, prop);
501
+ if (r.combo >= 2)
502
+ this.flash(to, `×${r.combo}`, '#f3d98b');
503
+ },
504
+ });
505
+ if (r.revenge)
506
+ this.flash(to, '回敬!', '#ffb070', -0.95);
507
+ this.o.log?.(`道具 ${from}>${to}:${prop}${r.combo >= 2 ? ` ×${r.combo}` : ''}${r.revenge ? ' 回敬' : ''}`);
508
+ }
509
+ // 单机:冲电脑来的,电脑按性格反应(好友房的电脑在房间里算)
510
+ if (this.o.localBots && to !== undefined && to !== from && this.seat(to)?.bot && (it.kind === 'prop' || it.kind === 'phrase')) {
511
+ const prop = it.kind === 'prop' ? PROPS[it.index] : undefined;
512
+ this.react({ kind: 'hit', from, to, ...(prop ? { prop } : {}) }, [to]);
513
+ }
514
+ }
515
+ /** 名场面(游戏把自己的公开事件映射过来):单机里电脑按性格起哄;横幅另由 banner 播 */
516
+ moment(ev) {
517
+ if (!this.o.localBots)
518
+ return;
519
+ const bots = this.o.seats().map((s, i) => (s?.bot ? i : -1)).filter((i) => i >= 0);
520
+ this.react(ev, bots);
521
+ }
522
+ react(ev, seats) {
523
+ if (!this.enabled())
524
+ return;
525
+ for (const i of seats) {
526
+ const s = this.seat(i);
527
+ if (!s?.bot)
528
+ continue;
529
+ const a = botReact(ev, { seat: i, temper: s.temper ?? 'normal' }, this.bots, this.now(), () => this.rand(), this.o.react ?? BOT_REACT);
530
+ if (a)
531
+ this.later(a.delay, () => this.perform(a));
532
+ }
533
+ }
534
+ /** 电脑的反应一定看得见:表情加一句话的气泡同时出;回敬再让道具飞过去 */
535
+ perform(a) {
536
+ const s = this.seat(a.from);
537
+ if (!s || !this.enabled() || this.isBlocked(a.from))
538
+ return;
539
+ void showSticker(this.layer, s.rect, a.sticker);
540
+ const t = a.to !== undefined ? this.seat(a.to) : null;
541
+ this.bubble(a.from, `${t && a.prop === undefined ? `@${t.name} ` : ''}${this.line(a.mood)}`);
542
+ this.o.log?.(`电脑 ${a.from}:${a.mood}`);
543
+ if (a.prop && a.to !== undefined)
544
+ this.receive(a.from, propId(this.P, PROPS.indexOf(a.prop)), a.to);
545
+ }
546
+ line(mood) {
547
+ const lines = this.o.moodLines?.[mood] ?? DEFAULT_LINES[mood];
548
+ return lines[Math.floor(this.rand() * lines.length) % lines.length];
549
+ }
550
+ // ───────────── 小效果 ─────────────
551
+ /** 头像旁的一句话:朝屏幕中间那侧冒,2.4 秒后淡出 */
552
+ bubble(seat, text, ms = 2400) {
553
+ const s = this.seat(seat);
554
+ if (!s)
555
+ return;
556
+ const b = this.el('div', 'atf-bubble', text);
557
+ this.layer.el.appendChild(b);
558
+ const w = Math.min(220, b.offsetWidth || text.length * 15 + 24);
559
+ const cx = s.rect.left + s.rect.width / 2;
560
+ const right = cx < window.innerWidth / 2;
561
+ const x = Math.max(4, Math.min(window.innerWidth - w - 4, right ? s.rect.left + s.rect.width + 6 : s.rect.left - 6 - w));
562
+ const y = Math.max(4, s.rect.top - 6);
563
+ b.style.transform = `translate(${x}px,${y}px)`;
564
+ void anim(b, [{ opacity: 0 }, { opacity: 1 }], 160).then(() => anim(b, [{ opacity: 1 }, { opacity: 0 }], 260, ms)).then(() => b.remove());
565
+ }
566
+ /** 头像上方冒一个字(×N、回敬!、护盾):放大弹一下再飘走 */
567
+ flash(seat, label, color, dy = -0.7) {
568
+ const s = this.seat(seat);
569
+ if (!s)
570
+ return;
571
+ const f = this.el('div', 'atf-flash', label);
572
+ f.style.color = color;
573
+ this.layer.el.appendChild(f);
574
+ const x = s.rect.left + s.rect.width * 0.6;
575
+ const y = s.rect.top + s.rect.height * (0.5 + dy) - 14;
576
+ const at = (k) => `translate(${x.toFixed(1)}px,${y.toFixed(1)}px) ${k}`;
577
+ void anim(f, [{ transform: at('scale(.4)'), opacity: 0 }, { transform: at('scale(1.15)'), opacity: 1 }, { transform: at('scale(1)'), opacity: 1 }], 240)
578
+ .then(() => anim(f, [{ transform: at('translateY(0)'), opacity: 1 }, { transform: at('translateY(-24px)'), opacity: 0 }], 380, 700))
579
+ .then(() => f.remove());
580
+ }
581
+ /** 名场面横幅:屏幕上方居中一条红底金边,1.8 秒 */
582
+ banner(text, sub) {
583
+ const b = this.el('div', 'atf-banner', text);
584
+ if (sub)
585
+ add(b, this.el('small', '', sub));
586
+ this.layer.el.appendChild(b);
587
+ void anim(b, [{ transform: 'translateX(-50%) scaleX(.2)', opacity: 0 }, { transform: 'translateX(-50%) scaleX(1.05)', opacity: 1 }, { transform: 'translateX(-50%) scaleX(1)', opacity: 1 }], 280)
588
+ .then(() => anim(b, [{ opacity: 1 }, { opacity: 0 }], 300, 1500))
589
+ .then(() => b.remove());
590
+ }
591
+ /** 首次进牌桌:在一个电脑(没有就别人)头像旁说一句怎么玩,3 秒后收起,只出一次 */
592
+ hint() {
593
+ const key = `${this.o.storageKey}.hint.fun`;
594
+ try {
595
+ if (localStorage.getItem(key))
596
+ return;
597
+ }
598
+ catch {
599
+ return;
600
+ }
601
+ const seats = this.o.seats();
602
+ const i = seats.findIndex((s) => !!s?.bot);
603
+ const at = i >= 0 ? i : seats.findIndex((s) => !!s && !s.me);
604
+ if (at < 0 || !this.canSend() || !this.enabled() || this.open)
605
+ return this.later(2000, () => this.hint());
606
+ try {
607
+ localStorage.setItem(key, '1');
608
+ }
609
+ catch {
610
+ // 记不住就下次再提示一次
611
+ }
612
+ this.bubble(at, HINT_TEXT, FUN_TIMING.hintMs);
613
+ this.o.log?.('提示 头像');
614
+ }
615
+ // ───────────── 游戏的弹窗、离开牌桌 ─────────────
616
+ /** 游戏开自己的弹窗时藏起来(正在播的不中断),关掉后 show */
617
+ hide() {
618
+ this.closeAll();
619
+ this.layer.hide();
620
+ this.ui.style.visibility = 'hidden';
621
+ }
622
+ show() {
623
+ this.layer.show();
624
+ this.ui.style.visibility = '';
625
+ }
626
+ /** 冒烟用:按钮、头像热区、开着的菜单里每一项的屏幕中心和大小(查点按区 ≥ 44) */
627
+ controls() {
628
+ const out = {};
629
+ const c = (el) => {
630
+ const r = el.getBoundingClientRect();
631
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2, w: r.width, h: r.height };
632
+ };
633
+ if (this.btn)
634
+ out['fun:button'] = c(this.btn);
635
+ this.hots.forEach((h, i) => {
636
+ if (h.style.display !== 'none')
637
+ out[`fun:seat${i}`] = c(h);
638
+ });
639
+ this.open?.querySelectorAll('[data-key]').forEach((e) => (out[e.dataset['key']] = c(e)));
640
+ return out;
641
+ }
642
+ destroy() {
643
+ this.dead = true;
644
+ for (const t of this.timers)
645
+ clearTimeout(t);
646
+ this.timers.clear();
647
+ window.removeEventListener('resize', this.onResize);
648
+ this.ui.remove();
649
+ this.layer.destroy();
650
+ }
651
+ }
652
+ //# sourceMappingURL=table-fun.js.map