@yyyu-star/arcade-kit 0.0.0-stage → 0.10.2

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/LICENSE +21 -0
  3. package/README.md +441 -2
  4. package/dist/auth.d.ts +66 -0
  5. package/dist/auth.d.ts.map +1 -0
  6. package/dist/auth.js +86 -0
  7. package/dist/auth.js.map +1 -0
  8. package/dist/client.d.ts +194 -0
  9. package/dist/client.d.ts.map +1 -0
  10. package/dist/client.js +379 -0
  11. package/dist/client.js.map +1 -0
  12. package/dist/fun-art.d.ts +41 -0
  13. package/dist/fun-art.d.ts.map +1 -0
  14. package/dist/fun-art.js +141 -0
  15. package/dist/fun-art.js.map +1 -0
  16. package/dist/fun.d.ts +87 -0
  17. package/dist/fun.d.ts.map +1 -0
  18. package/dist/fun.js +512 -0
  19. package/dist/fun.js.map +1 -0
  20. package/dist/game.d.ts +160 -0
  21. package/dist/game.d.ts.map +1 -0
  22. package/dist/game.js +41 -0
  23. package/dist/game.js.map +1 -0
  24. package/dist/guest.d.ts +60 -0
  25. package/dist/guest.d.ts.map +1 -0
  26. package/dist/guest.js +87 -0
  27. package/dist/guest.js.map +1 -0
  28. package/dist/internal.d.ts +33 -0
  29. package/dist/internal.d.ts.map +1 -0
  30. package/dist/internal.js +53 -0
  31. package/dist/internal.js.map +1 -0
  32. package/dist/meter.d.ts +102 -0
  33. package/dist/meter.d.ts.map +1 -0
  34. package/dist/meter.js +207 -0
  35. package/dist/meter.js.map +1 -0
  36. package/dist/protocol.d.ts +227 -0
  37. package/dist/protocol.d.ts.map +1 -0
  38. package/dist/protocol.js +65 -0
  39. package/dist/protocol.js.map +1 -0
  40. package/dist/rng.d.ts +40 -0
  41. package/dist/rng.d.ts.map +1 -0
  42. package/dist/rng.js +119 -0
  43. package/dist/rng.js.map +1 -0
  44. package/dist/room.d.ts +352 -0
  45. package/dist/room.d.ts.map +1 -0
  46. package/dist/room.js +1563 -0
  47. package/dist/room.js.map +1 -0
  48. package/dist/testing.d.ts +125 -0
  49. package/dist/testing.d.ts.map +1 -0
  50. package/dist/testing.js +272 -0
  51. package/dist/testing.js.map +1 -0
  52. package/dist/worker.d.ts +92 -0
  53. package/dist/worker.d.ts.map +1 -0
  54. package/dist/worker.js +184 -0
  55. package/dist/worker.js.map +1 -0
  56. package/package.json +107 -4
  57. package/src/auth.ts +100 -0
  58. package/src/client.ts +438 -0
  59. package/src/fun-art.ts +184 -0
  60. package/src/fun.ts +626 -0
  61. package/src/game.ts +200 -0
  62. package/src/guest.ts +122 -0
  63. package/src/internal.ts +64 -0
  64. package/src/meter.ts +255 -0
  65. package/src/protocol.ts +243 -0
  66. package/src/rng.ts +131 -0
  67. package/src/room.ts +1677 -0
  68. package/src/testing.ts +300 -0
  69. package/src/worker.ts +234 -0
package/src/fun.ts ADDED
@@ -0,0 +1,626 @@
1
+ /**
2
+ * 好玩素材(0.7.0):表情包气泡、互动道具(扔番茄、臭鸡蛋……),浏览器里用,纯 DOM + SVG + Web Animations,不依赖任何渲染库。
3
+ *
4
+ * - 编号到内容的表由游戏自己定:房间只转发 emote 的 id(RoomGame.emotes 是总数),游戏按"自己的起始编号 + 下标"映射到 STICKERS、PROPS;
5
+ * 这两个列表顺序固定、只往后加。kit 只管画法和动画,不知道哪个游戏、哪句话;
6
+ * - 只动 transform 和 opacity(合成层完成,不触发重排),所有元素 pointer-events: none,点击穿透到下面的牌桌;
7
+ * - 同一个图层同时最多 MAX_PLAYING 个效果在播,多出来的直接丢掉(返回 false);同一个头像上的污渍最多叠 MAX_STAINS 层,再多先去掉最早的;
8
+ * - 声音不在这里放:throwAt 在起飞、引信、命中时调 opts.sound(prop, phase),游戏用自己的音频(跟着静音开关)去放,
9
+ * 配方是 playFunSound(WebAudio 现场合成);
10
+ * - 被扔的头像抖一下由游戏做(头像在游戏的画布或 DOM 里,kit 碰不到):命中时调 opts.onHit(prop)。
11
+ * 这个文件引用 DOM,但只在调用时才碰:在 workerd、Node 里 import 不会出错(测试只测纯函数和素材)。
12
+ */
13
+ import { BURST, DRIP, PETAL, PROPS, SHELL, SMOKE, SOOT, SPARK, SPLAT_TOMATO, STAR, STICKERS, STINK, dot, propSvg, splatEgg, stickerSvg, type PropId, type StickerId } from './fun-art.js';
14
+
15
+ export { PROPS, STICKERS, propSvg, stickerSvg, type PropId, type StickerId };
16
+
17
+ /** 同一个图层同时最多几个效果在播 */
18
+ export const MAX_PLAYING = 4;
19
+ /** 同一个头像上的污渍最多叠几层 */
20
+ export const MAX_STAINS = 3;
21
+ /** 默认的图层 z-index:比牌桌高、比游戏的弹窗低(游戏的弹窗要高于它,或者打开弹窗时 hide) */
22
+ export const FUN_LAYER_Z = 50;
23
+
24
+ /** 屏幕坐标的矩形:getBoundingClientRect() 的结果(DOMRect)直接能传 */
25
+ export interface RectLike {
26
+ left: number;
27
+ top: number;
28
+ width: number;
29
+ height: number;
30
+ }
31
+
32
+ export interface FunLayer {
33
+ /** 根元素:position: fixed; inset: 0; pointer-events: none,带 class arcade-fun */
34
+ readonly el: HTMLElement;
35
+ /** 藏起来(visibility: hidden):正在播的不中断,show 时接着显示。游戏打开弹窗时用 */
36
+ hide(): void;
37
+ show(): void;
38
+ /** 拿掉整个图层(离开牌桌时) */
39
+ destroy(): void;
40
+ }
41
+
42
+ export interface LayerOptions {
43
+ /** 默认 FUN_LAYER_Z(50) */
44
+ zIndex?: number;
45
+ /** 挂在哪里,默认 document.body */
46
+ parent?: HTMLElement;
47
+ }
48
+
49
+ /**
50
+ * 建一个铺满屏幕、点击穿透的图层。挂在 body 上:游戏的界面如果在自己的层叠上下文里(fixed 容器、带 transform 的舞台),
51
+ * 光靠 z-index 夹不到"牌桌之上、弹窗之下",这时打开弹窗前 hide()、关掉后 show()。
52
+ */
53
+ export function createFunLayer(opts: LayerOptions = {}): FunLayer {
54
+ const doc = (opts.parent ?? document.body).ownerDocument;
55
+ const el = doc.createElement('div');
56
+ el.className = 'arcade-fun';
57
+ el.setAttribute('aria-hidden', 'true');
58
+ el.style.cssText = `position:fixed;inset:0;pointer-events:none;overflow:hidden;contain:strict;z-index:${opts.zIndex ?? FUN_LAYER_Z}`;
59
+ (opts.parent ?? doc.body).appendChild(el);
60
+ return {
61
+ el,
62
+ hide: () => void (el.style.visibility = 'hidden'),
63
+ show: () => void (el.style.visibility = ''),
64
+ destroy: () => el.remove(),
65
+ };
66
+ }
67
+
68
+ export type FunPhase = 'fly' | 'fuse' | 'hit';
69
+
70
+ export interface ThrowOptions {
71
+ /** 起飞(fly)、炸弹点引信(fuse)、命中(hit)时调:游戏放声音(playFunSound),自己判断静音 */
72
+ sound?(prop: PropId, phase: FunPhase): void;
73
+ /** 命中那一刻:游戏让被扔的头像抖一下、缩一下 */
74
+ onHit?(prop: PropId): void;
75
+ /**
76
+ * 污渍按谁记(同一个人最多叠 MAX_STAINS 层):游戏传座位号之类最稳。不传就按目标位置取整(头像在抖时位置差几个像素也算同一个人)
77
+ */
78
+ target?: string | number;
79
+ }
80
+
81
+ // ---------- 纯函数(测试覆盖) ----------
82
+
83
+ export interface Pt {
84
+ x: number;
85
+ y: number;
86
+ }
87
+
88
+ /** 抛物线上的 n 个点(含起点终点):二次贝塞尔,控制点在中点往上抬 lift(默认按距离算,至少 60) */
89
+ export function arcPoints(a: Pt, b: Pt, n: number, lift?: number): Pt[] {
90
+ const d = Math.hypot(b.x - a.x, b.y - a.y);
91
+ const h = lift ?? Math.max(60, d * 0.35);
92
+ const c = { x: (a.x + b.x) / 2, y: Math.min(a.y, b.y) - h };
93
+ return Array.from({ length: n }, (_, i) => {
94
+ const t = n === 1 ? 1 : i / (n - 1);
95
+ const u = 1 - t;
96
+ return { x: u * u * a.x + 2 * u * t * c.x + t * t * b.x, y: u * u * a.y + 2 * u * t * c.y + t * t * b.y };
97
+ });
98
+ }
99
+
100
+ /** 屏幕矩形换成图层里的中心点和边长(取宽高里小的那个) */
101
+ export function centerIn(r: RectLike, origin: Pt): { c: Pt; s: number } {
102
+ return { c: { x: r.left - origin.x + r.width / 2, y: r.top - origin.y + r.height / 2 }, s: Math.max(24, Math.min(r.width, r.height)) };
103
+ }
104
+
105
+ // ---------- 播放的账(按图层记) ----------
106
+
107
+ const playing = new WeakMap<HTMLElement, number>();
108
+ const stains = new WeakMap<HTMLElement, Map<string, HTMLElement[]>>();
109
+
110
+ /** 占一个播放名额;满了返回 false(调用方直接丢掉这一次) */
111
+ function claim(el: HTMLElement): boolean {
112
+ const n = playing.get(el) ?? 0;
113
+ if (n >= MAX_PLAYING) return false;
114
+ playing.set(el, n + 1);
115
+ return true;
116
+ }
117
+ function release(el: HTMLElement): void {
118
+ playing.set(el, Math.max(0, (playing.get(el) ?? 1) - 1));
119
+ }
120
+
121
+ /** 正在播几个(冒烟、测试看) */
122
+ export function playingCount(layer: FunLayer): number {
123
+ return playing.get(layer.el) ?? 0;
124
+ }
125
+
126
+ // ---------- DOM 小工具 ----------
127
+
128
+ const wait = (ms: number): Promise<void> => new Promise((ok) => setTimeout(ok, ms));
129
+ const pos = (p: Pt, size: number, extra = ''): string => `translate(${(p.x - size / 2).toFixed(1)}px,${(p.y - size / 2).toFixed(1)}px) ${extra}`;
130
+
131
+ function sprite(layer: HTMLElement, svg: string, size: number, p: Pt, extra = '', opacity = 1): HTMLDivElement {
132
+ const d = layer.ownerDocument.createElement('div');
133
+ d.style.cssText = `position:absolute;left:0;top:0;width:${size}px;height:${size}px;pointer-events:none;will-change:transform,opacity;opacity:${opacity};transform:${pos(p, size, extra)}`;
134
+ d.innerHTML = svg;
135
+ layer.appendChild(d);
136
+ return d;
137
+ }
138
+
139
+ /** Web Animations;没有就按时长等一下(老浏览器只是没有动画,不报错) */
140
+ function play(el: HTMLElement, frames: Keyframe[], ms: number, o: { delay?: number; easing?: string } = {}): Promise<void> {
141
+ if (typeof el.animate !== 'function') return wait(ms + (o.delay ?? 0));
142
+ const a = el.animate(frames, { duration: ms, delay: o.delay ?? 0, easing: o.easing ?? 'linear', fill: 'both' });
143
+ return a.finished.then(
144
+ () => undefined,
145
+ () => undefined,
146
+ );
147
+ }
148
+
149
+ function origin(layer: HTMLElement): Pt {
150
+ // 自己建的图层铺满屏幕,原点就是 (0, 0);挂在别处的按它的位置换算(只读一次)
151
+ if (layer.classList.contains('arcade-fun') && layer.style.position === 'fixed') return { x: 0, y: 0 };
152
+ const r = layer.getBoundingClientRect();
153
+ return { x: r.left, y: r.top };
154
+ }
155
+
156
+ /** 污渍:挂到这个头像的账上,超过 MAX_STAINS 层先去掉最早的;留 ms 毫秒再淡出 */
157
+ function stain(layer: HTMLElement, key: string, group: HTMLElement, ms: number): void {
158
+ let byKey = stains.get(layer);
159
+ if (!byKey) stains.set(layer, (byKey = new Map()));
160
+ const list = byKey.get(key) ?? [];
161
+ list.push(group);
162
+ while (list.length > MAX_STAINS) list.shift()!.remove();
163
+ byKey.set(key, list);
164
+ void play(group, [{ opacity: 1 }, { opacity: 0 }], 400, { delay: ms }).then(() => {
165
+ group.remove();
166
+ const now = byKey.get(key);
167
+ if (now) {
168
+ const i = now.indexOf(group);
169
+ if (i >= 0) now.splice(i, 1);
170
+ }
171
+ });
172
+ }
173
+
174
+ function group(layer: HTMLElement): HTMLDivElement {
175
+ const g = layer.ownerDocument.createElement('div');
176
+ g.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;will-change:opacity';
177
+ layer.appendChild(g);
178
+ return g;
179
+ }
180
+
181
+ // ---------- 表情包 ----------
182
+
183
+ /** 每个表情的小动作(1 秒内) */
184
+ const STICKER_MOTION: Record<StickerId, string[]> = {
185
+ smug: ['rotate(0deg)', 'rotate(-10deg)', 'rotate(8deg)', 'rotate(-6deg)', 'rotate(0deg)'],
186
+ doge: ['translateY(0)', 'translateY(-8%) rotate(-6deg)', 'translateY(0)', 'translateY(-6%) rotate(6deg)', 'translateY(0)'],
187
+ cry: ['translateX(0)', 'translateX(-5%)', 'translateX(5%)', 'translateX(-5%)', 'translateX(5%)', 'translateX(0)'],
188
+ sweat: ['rotate(0deg)', 'rotate(-4deg) translateY(3%)', 'rotate(0deg)', 'rotate(-4deg) translateY(3%)', 'rotate(0deg)'],
189
+ shock: ['scale(1)', 'scale(1.25) translateY(-10%)', 'scale(0.95)', 'scale(1.08)', 'scale(1)'],
190
+ sly: ['rotate(0deg)', 'rotate(8deg)', 'rotate(-8deg)', 'rotate(6deg)', 'rotate(0deg)'],
191
+ love: ['scale(1)', 'scale(1.15)', 'scale(1)', 'scale(1.15)', 'scale(1)'],
192
+ angry: ['translateX(0)', 'translateX(-6%) rotate(-3deg)', 'translateX(6%) rotate(3deg)', 'translateX(-6%)', 'translateX(6%)', 'translateX(0)'],
193
+ sleep: ['scale(1) rotate(0deg)', 'scale(1.05) rotate(-4deg)', 'scale(1) rotate(0deg)', 'scale(1.05) rotate(-4deg)', 'scale(1) rotate(0deg)'],
194
+ clap: ['scale(1)', 'scale(0.9)', 'scale(1.06)', 'scale(0.9)', 'scale(1.06)', 'scale(1)'],
195
+ lol: ['rotate(0deg)', 'rotate(-12deg)', 'rotate(10deg)', 'rotate(-8deg)', 'rotate(0deg)'],
196
+ cool: ['translateX(-30%) rotate(-10deg)', 'translateX(4%) rotate(4deg)', 'translateX(0) rotate(0deg)', 'rotate(-4deg)', 'rotate(0deg)'],
197
+ };
198
+
199
+ /**
200
+ * 在一个人头像旁冒一个表情(rect 是头像的屏幕矩形):弹出来、做 1 秒内的小动作、停一会儿淡出,共约 2 秒。
201
+ * 名额满了返回 false。
202
+ */
203
+ export async function showSticker(layer: FunLayer, rect: RectLike, id: StickerId): Promise<boolean> {
204
+ const el = layer.el;
205
+ if (!claim(el)) return false;
206
+ try {
207
+ const { c, s } = centerIn(rect, origin(el));
208
+ const size = Math.min(96, Math.max(44, s * 0.9));
209
+ // 冒在头像右上方,不盖住头像本身
210
+ const p = { x: c.x + s * 0.45, y: c.y - s * 0.55 };
211
+ const d = sprite(el, stickerSvg(id), size, p, 'scale(0)');
212
+ await play(d, [{ transform: pos(p, size, 'scale(0)') }, { transform: pos(p, size, 'scale(1.18)') }, { transform: pos(p, size, 'scale(1)') }], 220, { easing: 'ease-out' });
213
+ await play(d, STICKER_MOTION[id].map((m) => ({ transform: pos(p, size, m) })), 800, { easing: 'ease-in-out' });
214
+ await play(d, [{ opacity: 1 }, { opacity: 1, offset: 0.6 }, { opacity: 0 }], 1000);
215
+ d.remove();
216
+ return true;
217
+ } finally {
218
+ release(el);
219
+ }
220
+ }
221
+
222
+ // ---------- 互动道具 ----------
223
+
224
+ /** 飞行时转几圈(度) */
225
+ const SPIN: Record<PropId, number> = { tomato: 540, egg: 600, stinkyEgg: 600, slipper: 900, bomb: 360, rose: 200, like: 0, cheers: 30 };
226
+ const FLY_MS = 700;
227
+
228
+ /**
229
+ * 从 from(发的人头像的屏幕矩形)扔到 to(对象头像),命中后按道具播效果,污渍留 1.5–2 秒再淡出。
230
+ * 名额满了直接返回 false;播完(不含污渍的停留)返回 true。
231
+ */
232
+ export async function throwAt(layer: FunLayer, from: RectLike, to: RectLike, prop: PropId, opts: ThrowOptions = {}): Promise<boolean> {
233
+ const el = layer.el;
234
+ if (!claim(el)) return false;
235
+ try {
236
+ const o = origin(el);
237
+ const a = centerIn(from, o).c;
238
+ const { c, s } = centerIn(to, o);
239
+ const size = Math.round(Math.min(84, Math.max(40, s * 0.72)));
240
+ const svg = propSvg(prop);
241
+ const n = 14;
242
+ const pts = arcPoints(a, c, n);
243
+ const frames = (alpha: number): Keyframe[] =>
244
+ pts.map((p, i) => ({ transform: pos(p, size, `rotate(${((SPIN[prop] * i) / (n - 1)).toFixed(0)}deg) scale(${(1 + 0.3 * Math.sin((Math.PI * i) / (n - 1))).toFixed(2)})`), opacity: alpha }));
245
+ opts.sound?.(prop, 'fly');
246
+ // 两层残影跟在后面
247
+ const ghosts = [0.32, 0.16].map((alpha, i) => {
248
+ const g = sprite(el, svg, size, a, '', 0);
249
+ void play(g, frames(alpha), FLY_MS, { delay: 45 * (i + 1), easing: 'cubic-bezier(.3,.1,.6,1)' }).then(() => {
250
+ g.remove();
251
+ });
252
+ return g;
253
+ });
254
+ const main = sprite(el, svg, size, a);
255
+ await play(main, frames(1), FLY_MS, { easing: 'cubic-bezier(.3,.1,.6,1)' });
256
+ ghosts.forEach((g) => g.style.setProperty('display', 'none'));
257
+ const grid = Math.max(24, s * 0.75);
258
+ const key = opts.target !== undefined ? `t:${opts.target}` : `${Math.round(c.x / grid)}:${Math.round(c.y / grid)}`;
259
+ await HIT[prop]({ el, main, c, s, size, key, prop, opts });
260
+ return true;
261
+ } finally {
262
+ release(el);
263
+ }
264
+ }
265
+
266
+ interface Hit {
267
+ el: HTMLElement;
268
+ /** 飞过来的那个道具(各效果自己决定留着还是拿掉) */
269
+ main: HTMLDivElement;
270
+ c: Pt;
271
+ s: number;
272
+ size: number;
273
+ key: string;
274
+ prop: PropId;
275
+ opts: ThrowOptions;
276
+ }
277
+
278
+ const rnd = (a: number, b: number): number => a + Math.random() * (b - a);
279
+
280
+ /** 一把小东西往四周溅开(汁水、碎片、星星),各自带重力感,淡出后拿掉 */
281
+ function scatter(el: HTMLElement, svg: string, c: Pt, count: number, dist: [number, number], size: number, ms: number, opt: { spin?: boolean; up?: number } = {}): Promise<void> {
282
+ const all: Promise<void>[] = [];
283
+ for (let i = 0; i < count; i++) {
284
+ const ang = (Math.PI * 2 * i) / count + rnd(-0.3, 0.3);
285
+ const r = rnd(dist[0], dist[1]);
286
+ const end = { x: c.x + Math.cos(ang) * r, y: c.y + Math.sin(ang) * r * 0.8 + (opt.up ?? r * 0.4) };
287
+ const mid = { x: c.x + Math.cos(ang) * r * 0.6, y: c.y + Math.sin(ang) * r * 0.6 - r * 0.15 };
288
+ const sz = size * rnd(0.6, 1.1);
289
+ const d = sprite(el, svg, sz, c);
290
+ const rot = opt.spin ? rnd(-200, 200) : 0;
291
+ all.push(
292
+ play(
293
+ d,
294
+ [
295
+ { transform: pos(c, sz, 'scale(.4) rotate(0deg)'), opacity: 1 },
296
+ { transform: pos(mid, sz, `scale(1) rotate(${rot / 2}deg)`), opacity: 1, offset: 0.35 },
297
+ { transform: pos(end, sz, `scale(.8) rotate(${rot}deg)`), opacity: 0 },
298
+ ],
299
+ ms,
300
+ { easing: 'ease-out' },
301
+ ).then(() => {
302
+ d.remove();
303
+ }),
304
+ );
305
+ }
306
+ return Promise.all(all).then(() => undefined);
307
+ }
308
+
309
+ /** 糊上去的一大摊:先"啪"地放大,再挂到污渍账上 */
310
+ async function splat(h: Hit, svg: string, scale: number, extra?: (g: HTMLElement) => void): Promise<HTMLElement> {
311
+ const g = group(h.el);
312
+ const sz = h.s * scale;
313
+ const d = sprite(g, svg, sz, h.c, `rotate(${rnd(-20, 20).toFixed(0)}deg)`);
314
+ extra?.(g);
315
+ stain(h.el, h.key, g, 1700);
316
+ await play(d, [{ transform: pos(h.c, sz, 'scale(.25)') }, { transform: pos(h.c, sz, 'scale(1.18)'), offset: 0.6 }, { transform: pos(h.c, sz, 'scale(1)') }], 180, { easing: 'ease-out' });
317
+ return g;
318
+ }
319
+
320
+ const HIT: Record<PropId, (h: Hit) => Promise<void>> = {
321
+ tomato: async (h) => {
322
+ h.main.remove();
323
+ h.opts.sound?.(h.prop, 'hit');
324
+ h.opts.onHit?.(h.prop);
325
+ await Promise.all([splat(h, SPLAT_TOMATO, 1.55), scatter(h.el, dot('#d93a28'), h.c, 11, [h.s * 0.7, h.s * 1.3], h.s * 0.16, 560)]);
326
+ },
327
+ egg: (h) => eggHit(h, false),
328
+ stinkyEgg: (h) => eggHit(h, true),
329
+ slipper: async (h) => {
330
+ h.opts.sound?.(h.prop, 'hit');
331
+ h.opts.onHit?.(h.prop);
332
+ const sz = h.size;
333
+ const off = { x: h.c.x + h.s * 0.6, y: h.c.y + h.s * 1.1 };
334
+ // 拍扁一下再弹开掉下去;星星绕着冒出来
335
+ await Promise.all([
336
+ play(
337
+ h.main,
338
+ [
339
+ { transform: pos(h.c, sz, 'rotate(0deg) scale(1.25,.8)') },
340
+ { transform: pos({ x: h.c.x + h.s * 0.3, y: h.c.y - h.s * 0.4 }, sz, 'rotate(120deg) scale(1)'), offset: 0.35 },
341
+ { transform: pos(off, sz, 'rotate(260deg) scale(.9)'), opacity: 0 },
342
+ ],
343
+ 650,
344
+ { easing: 'ease-in' },
345
+ ).then(() => {
346
+ h.main.remove();
347
+ }),
348
+ scatter(h.el, STAR, { x: h.c.x, y: h.c.y - h.s * 0.2 }, 5, [h.s * 0.55, h.s * 0.8], h.s * 0.28, 950, { spin: true, up: -h.s * 0.05 }),
349
+ ]);
350
+ },
351
+ bomb: async (h) => {
352
+ // 引信闪三下,再炸开:冒烟,头像熏黑一下
353
+ h.opts.sound?.(h.prop, 'fuse');
354
+ const sp = { x: h.c.x + h.size * 0.42, y: h.c.y - h.size * 0.42 };
355
+ const spark = sprite(h.el, SPARK, h.size * 0.6, sp);
356
+ await play(spark, [0.6, 1.5, 0.6, 1.5, 0.6, 1.7].map((k) => ({ transform: pos(sp, h.size * 0.6, `scale(${k})`) })), 520);
357
+ spark.remove();
358
+ h.main.remove();
359
+ h.opts.sound?.(h.prop, 'hit');
360
+ h.opts.onHit?.(h.prop);
361
+ const bz = h.s * 2.4;
362
+ const burst = sprite(h.el, BURST, bz, h.c);
363
+ const soot = sprite(h.el, SOOT, h.s * 0.98, h.c, '', 0);
364
+ const smoke = [0, 1, 2, 3].map((i) => {
365
+ const from = { x: h.c.x + rnd(-h.s * 0.25, h.s * 0.25), y: h.c.y };
366
+ const d = sprite(h.el, SMOKE, h.s * 0.85, from, '', 0);
367
+ return play(
368
+ d,
369
+ [
370
+ { transform: pos(from, h.s * 0.85, 'scale(.4)'), opacity: 0.85 },
371
+ { transform: pos({ x: from.x + rnd(-24, 24), y: from.y - h.s * 1.1 }, h.s * 0.85, 'scale(1.5)'), opacity: 0 },
372
+ ],
373
+ 1100,
374
+ { delay: i * 70, easing: 'ease-out' },
375
+ ).then(() => {
376
+ d.remove();
377
+ });
378
+ });
379
+ await Promise.all([
380
+ play(burst, [{ transform: pos(h.c, bz, 'scale(.2) rotate(0deg)'), opacity: 1 }, { transform: pos(h.c, bz, 'scale(1.05) rotate(15deg)'), opacity: 1, offset: 0.45 }, { transform: pos(h.c, bz, 'scale(1.2) rotate(25deg)'), opacity: 0 }], 650, { easing: 'ease-out' }).then(() => {
381
+ burst.remove();
382
+ }),
383
+ play(soot, [{ opacity: 0 }, { opacity: 0.78, offset: 0.1 }, { opacity: 0.78, offset: 0.45 }, { opacity: 0 }], 1100).then(() => {
384
+ soot.remove();
385
+ }),
386
+ ...smoke,
387
+ ]);
388
+ },
389
+ rose: async (h) => {
390
+ h.opts.sound?.(h.prop, 'hit');
391
+ h.opts.onHit?.(h.prop);
392
+ // 玫瑰落在头像下沿,花瓣从上面飘下来;玫瑰本身当"污渍"留一会儿
393
+ const g = group(h.el);
394
+ const rest = { x: h.c.x, y: h.c.y + h.s * 0.3 };
395
+ h.main.remove();
396
+ const rose = sprite(g, propSvg('rose'), h.size * 1.3, rest);
397
+ stain(h.el, h.key, g, 1600);
398
+ void play(rose, [{ transform: pos(rest, h.size * 1.3, 'rotate(-30deg) scale(1.2)') }, { transform: pos(rest, h.size * 1.3, 'rotate(-18deg) scale(1)') }], 260, { easing: 'ease-out' });
399
+ const petals = Array.from({ length: 9 }, (_, i) => {
400
+ const x0 = h.c.x + rnd(-h.s * 0.6, h.s * 0.6);
401
+ const top = { x: x0, y: h.c.y - h.s * 0.7 };
402
+ const sz = h.s * 0.24;
403
+ const d = sprite(h.el, PETAL, sz, top, '', 0);
404
+ const sway = rnd(10, 20);
405
+ return play(
406
+ d,
407
+ [
408
+ { transform: pos(top, sz, 'rotate(0deg)'), opacity: 0 },
409
+ { transform: pos({ x: x0 + sway, y: top.y + h.s * 0.35 }, sz, 'rotate(60deg)'), opacity: 1, offset: 0.25 },
410
+ { transform: pos({ x: x0 - sway, y: top.y + h.s * 0.75 }, sz, 'rotate(140deg)'), opacity: 1, offset: 0.6 },
411
+ { transform: pos({ x: x0 + sway / 2, y: top.y + h.s * 1.2 }, sz, 'rotate(220deg)'), opacity: 0 },
412
+ ],
413
+ 1400,
414
+ { delay: i * 90, easing: 'ease-in-out' },
415
+ ).then(() => {
416
+ d.remove();
417
+ });
418
+ });
419
+ await Promise.all(petals);
420
+ },
421
+ like: async (h) => {
422
+ h.opts.sound?.(h.prop, 'hit');
423
+ h.opts.onHit?.(h.prop);
424
+ const sz = h.size;
425
+ const up = { x: h.c.x, y: h.c.y - h.s * 0.45 };
426
+ await Promise.all([
427
+ play(
428
+ h.main,
429
+ [
430
+ { transform: pos(h.c, sz, 'scale(.5)'), opacity: 1 },
431
+ { transform: pos(h.c, sz, 'scale(1.9)'), opacity: 1, offset: 0.2 },
432
+ { transform: pos(h.c, sz, 'scale(1.5)'), opacity: 1, offset: 0.35 },
433
+ { transform: pos(up, sz, 'scale(1.5)'), opacity: 0 },
434
+ ],
435
+ 1300,
436
+ { easing: 'ease-out' },
437
+ ).then(() => {
438
+ h.main.remove();
439
+ }),
440
+ scatter(h.el, STAR, h.c, 6, [h.s * 0.6, h.s * 0.9], h.s * 0.2, 850, { spin: true, up: -h.s * 0.1 }),
441
+ ]);
442
+ },
443
+ cheers: async (h) => {
444
+ h.main.remove();
445
+ // 两杯从两边碰到一起:叮一下,泡沫往上冒
446
+ const sz = h.size * 1.25;
447
+ const mug = propSvg('cheers');
448
+ const l = sprite(h.el, mug, sz, h.c);
449
+ const r = sprite(h.el, mug, sz, h.c);
450
+ const at = (dx: number, k: string): string => pos({ x: h.c.x + dx, y: h.c.y }, sz, k);
451
+ const meet = Promise.all([
452
+ play(l, [{ transform: at(-h.s * 0.7, 'rotate(0deg)') }, { transform: at(-h.s * 0.2, 'rotate(18deg)') }], 260, { easing: 'ease-in' }),
453
+ play(r, [{ transform: at(h.s * 0.7, 'scaleX(-1) rotate(0deg)') }, { transform: at(h.s * 0.2, 'scaleX(-1) rotate(18deg)') }], 260, { easing: 'ease-in' }),
454
+ ]);
455
+ await meet;
456
+ h.opts.sound?.(h.prop, 'hit');
457
+ h.opts.onHit?.(h.prop);
458
+ const top = { x: h.c.x, y: h.c.y - h.s * 0.35 };
459
+ const bubbles = Array.from({ length: 7 }, (_, i) => {
460
+ const from = { x: top.x + rnd(-h.s * 0.25, h.s * 0.25), y: top.y };
461
+ const sz2 = h.s * rnd(0.07, 0.13);
462
+ const d = sprite(h.el, dot('#ffffff', '#f5d27a'), sz2, from, '', 0);
463
+ return play(
464
+ d,
465
+ [
466
+ { transform: pos(from, sz2, 'scale(.5)'), opacity: 0.95 },
467
+ { transform: pos({ x: from.x + rnd(-10, 10), y: from.y - h.s * rnd(0.4, 0.8) }, sz2, 'scale(1.2)'), opacity: 0 },
468
+ ],
469
+ 900,
470
+ { delay: i * 60, easing: 'ease-out' },
471
+ ).then(() => {
472
+ d.remove();
473
+ });
474
+ });
475
+ await Promise.all([
476
+ scatter(h.el, STAR, top, 4, [h.s * 0.3, h.s * 0.5], h.s * 0.22, 550, { spin: true, up: -h.s * 0.1 }),
477
+ ...bubbles,
478
+ play(l, [{ opacity: 1 }, { opacity: 1, offset: 0.6 }, { opacity: 0 }], 1100, {}).then(() => {
479
+ l.remove();
480
+ }),
481
+ play(r, [{ opacity: 1 }, { opacity: 1, offset: 0.6 }, { opacity: 0 }], 1100, {}).then(() => {
482
+ r.remove();
483
+ }),
484
+ ]);
485
+ },
486
+ };
487
+
488
+ async function eggHit(h: Hit, stinky: boolean): Promise<void> {
489
+ h.main.remove();
490
+ h.opts.sound?.(h.prop, 'hit');
491
+ h.opts.onHit?.(h.prop);
492
+ const shells = scatter(h.el, SHELL, h.c, 4, [h.s * 0.5, h.s * 0.85], h.s * 0.28, 700, { spin: true });
493
+ const g = await splat(h, splatEgg(stinky), 1.35, (grp) => {
494
+ // 慢慢往下流的一道
495
+ const dz = h.s * 0.42;
496
+ const p = { x: h.c.x + h.s * 0.12, y: h.c.y + h.s * 0.18 };
497
+ const drip = sprite(grp, DRIP, dz, p);
498
+ void play(drip, [{ transform: pos(p, dz, 'translateY(0) scaleY(.5)') }, { transform: pos({ x: p.x, y: p.y + h.s * 0.6 }, dz, 'scaleY(1.4)') }], 1700, { delay: 150, easing: 'ease-in' });
499
+ });
500
+ if (stinky) {
501
+ // 几缕臭气飘起来(跟着污渍一起淡出)
502
+ for (let i = 0; i < 3; i++) {
503
+ const from = { x: h.c.x + (i - 1) * h.s * 0.28, y: h.c.y - h.s * 0.15 };
504
+ const sz = h.s * 0.5;
505
+ const d = sprite(g, STINK, sz, from, '', 0);
506
+ void play(
507
+ d,
508
+ [
509
+ { transform: pos(from, sz, 'scale(.6)'), opacity: 0 },
510
+ { transform: pos({ x: from.x + 6, y: from.y - h.s * 0.3 }, sz, 'scale(1)'), opacity: 0.9, offset: 0.4 },
511
+ { transform: pos({ x: from.x - 6, y: from.y - h.s * 0.6 }, sz, 'scale(1.1)'), opacity: 0 },
512
+ ],
513
+ 1100,
514
+ { delay: 120 + i * 200, easing: 'ease-out' },
515
+ );
516
+ }
517
+ }
518
+ await shells;
519
+ }
520
+
521
+ // ---------- 音效配方(WebAudio 现场合成,游戏用自己的 AudioContext 和输出节点放,跟着自己的静音开关) ----------
522
+
523
+ const noiseCache = new WeakMap<BaseAudioContext, AudioBuffer>();
524
+
525
+ function noiseOf(ac: BaseAudioContext): AudioBuffer {
526
+ let b = noiseCache.get(ac);
527
+ if (!b) {
528
+ b = ac.createBuffer(1, Math.floor(ac.sampleRate * 0.6), ac.sampleRate);
529
+ const d = b.getChannelData(0);
530
+ for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
531
+ noiseCache.set(ac, b);
532
+ }
533
+ return b;
534
+ }
535
+
536
+ function tone(ac: BaseAudioContext, out: AudioNode, t: number, f0: number, f1: number, dur: number, vol: number, type: OscillatorType = 'sine'): void {
537
+ const o = ac.createOscillator();
538
+ const g = ac.createGain();
539
+ o.type = type;
540
+ o.frequency.setValueAtTime(f0, t);
541
+ o.frequency.exponentialRampToValueAtTime(Math.max(1, f1), t + dur);
542
+ g.gain.setValueAtTime(0.0001, t);
543
+ g.gain.linearRampToValueAtTime(vol, t + 0.005);
544
+ g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
545
+ o.connect(g).connect(out);
546
+ o.start(t);
547
+ o.stop(t + dur + 0.02);
548
+ o.onended = () => {
549
+ o.disconnect();
550
+ g.disconnect();
551
+ };
552
+ }
553
+
554
+ function hiss(ac: BaseAudioContext, out: AudioNode, t: number, type: BiquadFilterType, f0: number, f1: number, dur: number, vol: number): void {
555
+ const src = ac.createBufferSource();
556
+ src.buffer = noiseOf(ac);
557
+ src.loop = true;
558
+ const f = ac.createBiquadFilter();
559
+ f.type = type;
560
+ f.frequency.setValueAtTime(f0, t);
561
+ f.frequency.exponentialRampToValueAtTime(Math.max(20, f1), t + dur);
562
+ const g = ac.createGain();
563
+ g.gain.setValueAtTime(0.0001, t);
564
+ g.gain.linearRampToValueAtTime(vol, t + 0.004);
565
+ g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
566
+ src.connect(f).connect(g).connect(out);
567
+ src.start(t);
568
+ src.stop(t + dur + 0.02);
569
+ src.onended = () => {
570
+ src.disconnect();
571
+ f.disconnect();
572
+ g.disconnect();
573
+ };
574
+ }
575
+
576
+ /**
577
+ * 道具的音效(全部 0.1–1 秒):fly 起飞的嗖声,fuse 炸弹引信的嘶嘶声,hit 各道具命中的声音(蛋壳碎、番茄爆、拖鞋啪、炸弹轰、碰杯叮……)。
578
+ * 游戏在 ThrowOptions.sound 里调它,传自己的 AudioContext 和输出节点(静音时别调)。出错不往外抛。
579
+ */
580
+ export function playFunSound(ac: BaseAudioContext, out: AudioNode, prop: PropId, phase: FunPhase, vol = 1): void {
581
+ try {
582
+ const t = ac.currentTime + 0.005;
583
+ const v = (x: number): number => x * vol;
584
+ if (phase === 'fly') return hiss(ac, out, t, 'bandpass', 400, 2400, 0.45, v(0.16));
585
+ if (phase === 'fuse') {
586
+ for (let i = 0; i < 4; i++) hiss(ac, out, t + i * 0.12, 'highpass', 5000, 3000, 0.1, v(0.12));
587
+ return;
588
+ }
589
+ switch (prop) {
590
+ case 'egg':
591
+ case 'stinkyEgg':
592
+ for (let i = 0; i < 3; i++) hiss(ac, out, t + i * 0.025, 'highpass', 3000, 2500, 0.012, v(0.3));
593
+ hiss(ac, out, t + 0.06, 'lowpass', 700, 180, 0.28, v(0.4));
594
+ if (prop === 'stinkyEgg') tone(ac, out, t + 0.25, 200, 110, 0.5, v(0.18), 'triangle');
595
+ return;
596
+ case 'tomato':
597
+ hiss(ac, out, t, 'lowpass', 1800, 260, 0.3, v(0.55));
598
+ tone(ac, out, t, 150, 60, 0.16, v(0.4));
599
+ return;
600
+ case 'slipper':
601
+ hiss(ac, out, t, 'highpass', 1500, 1200, 0.06, v(0.65));
602
+ tone(ac, out, t, 900, 480, 0.05, v(0.2), 'triangle');
603
+ tone(ac, out, t + 0.12, 1800, 2100, 0.12, v(0.12));
604
+ tone(ac, out, t + 0.22, 2200, 2500, 0.12, v(0.1));
605
+ return;
606
+ case 'bomb':
607
+ tone(ac, out, t, 120, 32, 1.0, v(0.8));
608
+ hiss(ac, out, t, 'lowpass', 4500, 120, 0.9, v(0.6));
609
+ return;
610
+ case 'rose':
611
+ [1047, 1319, 1568].forEach((f, i) => tone(ac, out, t + i * 0.09, f, f, 0.5, v(0.12), 'triangle'));
612
+ return;
613
+ case 'like':
614
+ tone(ac, out, t, 600, 1200, 0.08, v(0.3));
615
+ tone(ac, out, t + 0.08, 1568, 1568, 0.4, v(0.16));
616
+ return;
617
+ case 'cheers':
618
+ tone(ac, out, t, 2637, 2637, 0.35, v(0.18));
619
+ tone(ac, out, t, 3520, 3520, 0.3, v(0.12));
620
+ hiss(ac, out, t + 0.05, 'highpass', 6000, 5000, 0.4, v(0.06));
621
+ return;
622
+ }
623
+ } catch {
624
+ // 声音出错不能影响游戏
625
+ }
626
+ }