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