@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
|
@@ -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
|