@ganziliang/desktop-pet 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/renderer/fx.js ADDED
@@ -0,0 +1,1057 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 特效引擎 —— 觉醒技能 / 战斗特效
5
+ *
6
+ * 为什么不用现有的 DOM 粒子(.p / .heart 那套):
7
+ * 一个 DOM 粒子 = 一个元素 + 一条 CSS 动画。觉醒爆发一帧要几百个粒子,
8
+ * DOM 会直接把合成层压垮;canvas 每个粒子只是一次 drawImage。
9
+ *
10
+ * 三条性能约定(都踩过):
11
+ * 1. 所有形状预渲染成离屏贴图(texCache),每帧只 drawImage。
12
+ * 每帧 createRadialGradient 在几百个粒子上会把帧率打掉一半。
13
+ * 2. 粒子用扁平对象池 + 上限覆盖,不在爆发时 new 几千个对象触发 GC 掉帧。
14
+ * 3. 加色混合 'lighter',光效自己叠加发亮,不用画多层。
15
+ *
16
+ * 坐标系:canvas 铺满窗口,内部按 CSS 像素绘制(用 dpr 变换),
17
+ * 所以调用方给的坐标就是 element.getBoundingClientRect() 的坐标,不用换算。
18
+ */
19
+
20
+ const FX = (() => {
21
+ const TAU = Math.PI * 2;
22
+ const MAX_P = 1500;
23
+
24
+ let canvas = null;
25
+ let g = null;
26
+ let W = 0;
27
+ let H = 0;
28
+ let dpr = 1;
29
+ let raf = 0;
30
+ let last = 0;
31
+
32
+ /* ------------------------------------------------------------ 颜色 */
33
+
34
+ function rgbOf(c) {
35
+ if (Array.isArray(c)) return c;
36
+ const s = String(c ?? '').trim();
37
+ if (s[0] === '#') {
38
+ let h = s.slice(1);
39
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
40
+ const n = parseInt(h, 16);
41
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
42
+ }
43
+ const m = s.match(/\d+(?:\.\d+)?/g);
44
+ return m && m.length >= 3 ? m.slice(0, 3).map(Number) : [255, 255, 255];
45
+ }
46
+
47
+ const rgba = (c, a) => {
48
+ const [r, gg, b] = rgbOf(c);
49
+ return `rgba(${r},${gg},${b},${a})`;
50
+ };
51
+
52
+ /* -------------------------------------------------------- 贴图工厂 */
53
+
54
+ const texCache = new Map();
55
+
56
+ function makeTex(key, w, h, draw) {
57
+ const hit = texCache.get(key);
58
+ if (hit) return hit;
59
+ const t = document.createElement('canvas');
60
+ t.width = w;
61
+ t.height = h;
62
+ draw(t.getContext('2d'), w, h);
63
+ texCache.set(key, t);
64
+ return t;
65
+ }
66
+
67
+ /** 圆形光斑:粒子、光柱、光晕的通用底 */
68
+ function glowTex(color, size = 96) {
69
+ return makeTex(`glow|${color}|${size}`, size, size, (c, w) => {
70
+ const r = w / 2;
71
+ const grd = c.createRadialGradient(r, r, 0, r, r, r);
72
+ grd.addColorStop(0, rgba(color, 1));
73
+ grd.addColorStop(0.26, rgba(color, 0.6));
74
+ grd.addColorStop(0.6, rgba(color, 0.15));
75
+ grd.addColorStop(1, rgba(color, 0));
76
+ c.fillStyle = grd;
77
+ c.fillRect(0, 0, w, w);
78
+ });
79
+ }
80
+
81
+ /** 四角星:觉醒爆发的主视觉粒子(白色核心 + 主题色光晕) */
82
+ function starTex(color, size = 96) {
83
+ return makeTex(`star|${color}|${size}`, size, size, (c, w) => {
84
+ const r = w / 2;
85
+ const wa = r * 0.13;
86
+ c.translate(r, r);
87
+ const grd = c.createRadialGradient(0, 0, 0, 0, 0, r);
88
+ grd.addColorStop(0, rgba(color, 0.85));
89
+ grd.addColorStop(0.34, rgba(color, 0.3));
90
+ grd.addColorStop(1, rgba(color, 0));
91
+ c.fillStyle = grd;
92
+ c.beginPath();
93
+ c.arc(0, 0, r, 0, TAU);
94
+ c.fill();
95
+ // 交叉两根尖角,拼出闪光
96
+ c.globalAlpha = 0.95;
97
+ c.fillStyle = '#ffffff';
98
+ for (let i = 0; i < 2; i++) {
99
+ c.save();
100
+ c.rotate((i * Math.PI) / 2);
101
+ c.beginPath();
102
+ c.moveTo(0, -r);
103
+ c.quadraticCurveTo(wa, -wa, r, 0);
104
+ c.quadraticCurveTo(wa, wa, 0, r);
105
+ c.quadraticCurveTo(-wa, wa, -r, 0);
106
+ c.quadraticCurveTo(-wa, -wa, 0, -r);
107
+ c.closePath();
108
+ c.fill();
109
+ c.restore();
110
+ }
111
+ });
112
+ }
113
+
114
+ /** 菱形碎片(水晶 / 玻璃 / 冰) */
115
+ function shardTex(color, size = 72) {
116
+ return makeTex(`shard|${color}|${size}`, Math.round(size * 0.4), size, (c, w, h) => {
117
+ c.translate(w / 2, h / 2);
118
+ const grd = c.createLinearGradient(-w / 2, -h / 2, w / 2, h / 2);
119
+ grd.addColorStop(0, rgba(color, 0.2));
120
+ grd.addColorStop(0.45, rgba(color, 1));
121
+ grd.addColorStop(0.56, '#ffffff');
122
+ grd.addColorStop(1, rgba(color, 0.3));
123
+ c.fillStyle = grd;
124
+ c.beginPath();
125
+ c.moveTo(0, -h / 2);
126
+ c.lineTo(w / 2, 0);
127
+ c.lineTo(0, h / 2);
128
+ c.lineTo(-w / 2, 0);
129
+ c.closePath();
130
+ c.fill();
131
+ });
132
+ }
133
+
134
+ /** 羽毛(圣光觉醒飘落) */
135
+ function featherTex(color, size = 72) {
136
+ return makeTex(`feather|${color}|${size}`, Math.round(size * 0.46), size, (c, w, h) => {
137
+ const cx = w / 2;
138
+ c.fillStyle = rgba(color, 0.92);
139
+ c.beginPath();
140
+ c.moveTo(cx, 2);
141
+ c.quadraticCurveTo(w - 1, h * 0.36, cx, h - 2);
142
+ c.quadraticCurveTo(1, h * 0.36, cx, 2);
143
+ c.closePath();
144
+ c.fill();
145
+ c.strokeStyle = 'rgba(255,255,255,0.8)';
146
+ c.lineWidth = 1.2;
147
+ c.beginPath();
148
+ c.moveTo(cx, 3);
149
+ c.lineTo(cx, h - 3);
150
+ c.stroke();
151
+ });
152
+ }
153
+
154
+ /**
155
+ * 魔法阵 / 符文阵。
156
+ * 伪符文是用笔画出来的,不依赖任何字体 —— 换机器不会变成方块。
157
+ */
158
+ function sigilTex(color, size = 512) {
159
+ return makeTex(`sigil|${color}|${size}`, size, size, (c, w) => {
160
+ const r = w / 2;
161
+ c.translate(r, r);
162
+ c.strokeStyle = rgba(color, 0.95);
163
+ c.shadowColor = rgba(color, 0.9);
164
+ c.shadowBlur = 10;
165
+ const circle = (rad, lw) => {
166
+ c.lineWidth = lw;
167
+ c.beginPath();
168
+ c.arc(0, 0, rad, 0, TAU);
169
+ c.stroke();
170
+ };
171
+ circle(r * 0.97, 2.6);
172
+ circle(r * 0.9, 1.4);
173
+ circle(r * 0.63, 1.6);
174
+ circle(r * 0.4, 2.2);
175
+
176
+ const poly = (n, rad, off) => {
177
+ c.beginPath();
178
+ for (let i = 0; i <= n; i++) {
179
+ const a = off + (i / n) * TAU;
180
+ const x = Math.cos(a) * rad;
181
+ const y = Math.sin(a) * rad;
182
+ i ? c.lineTo(x, y) : c.moveTo(x, y);
183
+ }
184
+ c.stroke();
185
+ };
186
+ c.lineWidth = 2;
187
+ poly(6, r * 0.9, 0);
188
+ poly(3, r * 0.63, Math.PI / 2);
189
+ poly(3, r * 0.63, -Math.PI / 2);
190
+
191
+ // 外圈刻度
192
+ c.lineWidth = 2.2;
193
+ for (let i = 0; i < 48; i++) {
194
+ const a = (i / 48) * TAU;
195
+ const r0 = r * (i % 4 === 0 ? 0.8 : 0.855);
196
+ c.beginPath();
197
+ c.moveTo(Math.cos(a) * r0, Math.sin(a) * r0);
198
+ c.lineTo(Math.cos(a) * r * 0.9, Math.sin(a) * r * 0.9);
199
+ c.stroke();
200
+ }
201
+
202
+ // 一圈伪符文
203
+ c.lineWidth = 1.5;
204
+ for (let i = 0; i < 24; i++) {
205
+ const a = (i / 24) * TAU;
206
+ const rad = r * 0.75;
207
+ c.save();
208
+ c.translate(Math.cos(a) * rad, Math.sin(a) * rad);
209
+ c.rotate(a);
210
+ c.beginPath();
211
+ c.moveTo(-3, -5);
212
+ c.lineTo(3, -5);
213
+ c.moveTo(i % 2 ? -2 : -3.5, 0);
214
+ c.lineTo(3, i % 3 ? 0 : 4);
215
+ c.moveTo(-2.5, 5);
216
+ c.lineTo(2.5, 5);
217
+ c.stroke();
218
+ c.restore();
219
+ }
220
+ });
221
+ }
222
+
223
+ /* ---------------------------------------------------------- 粒子池 */
224
+
225
+ const P = [];
226
+ for (let i = 0; i < MAX_P; i++) P.push({ on: false });
227
+ let pc = 0;
228
+
229
+ function take() {
230
+ for (let i = 0; i < MAX_P; i++) {
231
+ const p = P[(pc + i) % MAX_P];
232
+ if (!p.on) {
233
+ pc = (pc + i + 1) % MAX_P;
234
+ return p;
235
+ }
236
+ }
237
+ const p = P[pc]; // 池满:覆盖最旧的一个,宁可丢粒子也不要掉帧
238
+ pc = (pc + 1) % MAX_P;
239
+ return p;
240
+ }
241
+
242
+ function spawn(o) {
243
+ const p = take();
244
+ p.on = true;
245
+ p.x = o.x;
246
+ p.y = o.y;
247
+ p.vx = o.vx || 0;
248
+ p.vy = o.vy || 0;
249
+ p.ax = o.ax || 0;
250
+ p.ay = o.ay || 0;
251
+ p.drag = o.drag ?? 0.9; // 每秒保留比例:pow(drag, dt)
252
+ p.t = 0;
253
+ p.life = o.life ?? 1;
254
+ p.size = o.size ?? 32;
255
+ p.grow = o.grow ?? 0;
256
+ p.rot = o.rot ?? 0;
257
+ p.vrot = o.vrot ?? 0;
258
+ p.tex = o.tex;
259
+ p.alpha = o.alpha ?? 1;
260
+ p.fade = o.fade ?? 'out';
261
+ p.sx = o.sx ?? 1;
262
+ p.spin0 = o.spin0 ?? 0;
263
+ // norm = 用 source-over 画。加色混合画不出黑色(黑加什么都是原色),
264
+ // 所以蝠影、乌云、黑雾这类「暗的东西」必须走普通混合。
265
+ p.norm = !!o.norm;
266
+ return p;
267
+ }
268
+
269
+ function stepParticles(dt) {
270
+ for (let i = 0; i < MAX_P; i++) {
271
+ const p = P[i];
272
+ if (!p.on) continue;
273
+ p.t += dt;
274
+ if (p.t >= p.life) {
275
+ p.on = false;
276
+ continue;
277
+ }
278
+ const d = Math.pow(p.drag, dt);
279
+ p.vx = p.vx * d + p.ax * dt;
280
+ p.vy = p.vy * d + p.ay * dt;
281
+ p.x += p.vx * dt;
282
+ p.y += p.vy * dt;
283
+ p.rot += p.vrot * dt;
284
+ if (p.spin0) p.sx = Math.cos(p.spin0 + p.t * 5.5); // 羽毛翻面
285
+ }
286
+ }
287
+
288
+ function aliveParticles() {
289
+ let n = 0;
290
+ for (let i = 0; i < MAX_P; i++) if (P[i].on) n++;
291
+ return n;
292
+ }
293
+
294
+ function drawParticles() {
295
+ // 两趟:先画普通混合的暗色粒子(云/影/雾),再叠亮色光效
296
+ g.globalCompositeOperation = 'source-over';
297
+ passParticles(true);
298
+ g.globalCompositeOperation = 'lighter';
299
+ passParticles(false);
300
+ g.globalAlpha = 1;
301
+ g.globalCompositeOperation = 'source-over';
302
+ }
303
+
304
+ function passParticles(norm) {
305
+ for (let i = 0; i < MAX_P; i++) {
306
+ const p = P[i];
307
+ if (!p.on || p.norm !== norm) continue;
308
+ const k = p.t / p.life;
309
+ let a = p.alpha;
310
+ if (p.fade === 'out') a *= 1 - k;
311
+ else if (p.fade === 'inout') a *= Math.sin(Math.PI * k);
312
+ else if (p.fade === 'in') a *= Math.min(1, k / 0.15);
313
+ if (a <= 0.004) continue;
314
+
315
+ const s = Math.max(1, p.size * (1 + p.grow * k));
316
+ const tw = p.tex.width;
317
+ const th = p.tex.height;
318
+ const sc = s / Math.max(tw, th);
319
+ const dw = tw * sc;
320
+ const dh = th * sc;
321
+ g.globalAlpha = Math.min(1, a);
322
+ if (p.rot || p.sx !== 1) {
323
+ g.save();
324
+ g.translate(p.x, p.y);
325
+ g.rotate(p.rot);
326
+ g.scale(p.sx, 1);
327
+ g.drawImage(p.tex, -dw / 2, -dh / 2, dw, dh);
328
+ g.restore();
329
+ } else {
330
+ g.drawImage(p.tex, p.x - dw / 2, p.y - dh / 2, dw, dh);
331
+ }
332
+ }
333
+ }
334
+
335
+ /* -------------------------------------------------------- 矢量特效 */
336
+
337
+ const geo = [];
338
+
339
+ function push(e) {
340
+ e.t = 0;
341
+ e.life = e.life ?? 1;
342
+ geo.push(e);
343
+ return e;
344
+ }
345
+
346
+ function drawRing(e, k) {
347
+ const ease = 1 - Math.pow(1 - k, 3);
348
+ const rad = e.r0 + (e.r1 - e.r0) * ease;
349
+ const a = e.alpha * Math.pow(1 - k, 1.4);
350
+ if (a <= 0.004) return;
351
+ g.save();
352
+ g.globalAlpha = a;
353
+ g.translate(e.x, e.y);
354
+ g.scale(1, e.flat ?? 0.34);
355
+ g.strokeStyle = e.color;
356
+ g.shadowColor = e.color;
357
+ g.shadowBlur = 22;
358
+ g.lineWidth = Math.max(1, (e.width ?? 10) * (1 - k * 0.72));
359
+ g.beginPath();
360
+ g.arc(0, 0, rad, 0, TAU);
361
+ g.stroke();
362
+ if (e.inner) {
363
+ g.lineWidth = Math.max(1, (e.width ?? 10) * 0.4);
364
+ g.globalAlpha = a * 0.7;
365
+ g.beginPath();
366
+ g.arc(0, 0, rad * 0.82, 0, TAU);
367
+ g.stroke();
368
+ }
369
+ g.restore();
370
+ }
371
+
372
+ /**
373
+ * 光柱:不画矩���(硬边没法看),而是把一个圆形光斑竖向拉长 ——
374
+ * 拉伸径向渐变天然就是「中间亮、两侧和顶部柔和消失」的柱状光,零渐变开销。
375
+ */
376
+ function drawPillar(e, k) {
377
+ const open = k < 0.16 ? k / 0.16 : 1;
378
+ const rest = k < 0.16 ? 1 : Math.max(0, 1 - (k - 0.16) / 0.84);
379
+ const wid = e.w * open * (0.5 + 0.5 * rest) * (1 + 0.05 * Math.sin(e.t * 40));
380
+ const hgt = e.h * (0.3 + 0.7 * Math.min(1, k / 0.3));
381
+ const a = e.alpha * open * Math.pow(rest, 0.7);
382
+ if (a <= 0.004 || wid < 1) return;
383
+ const top = e.y - hgt;
384
+ g.save();
385
+ g.globalAlpha = a;
386
+ g.globalCompositeOperation = 'lighter';
387
+ g.drawImage(glowTex(e.color, 128), e.x - wid / 2, top, wid, hgt);
388
+ g.globalAlpha = a * 0.95;
389
+ g.drawImage(glowTex(e.core || '#ffffff', 128), e.x - wid * 0.16, top, wid * 0.32, hgt);
390
+ g.restore();
391
+ }
392
+
393
+ function drawSigil(e, k) {
394
+ const rise = Math.min(1, k / 0.24);
395
+ const a = e.alpha * (k > 0.72 ? (1 - k) / 0.28 : 1) * rise;
396
+ if (a <= 0.004) return;
397
+ const s = e.size * (0.55 + 0.45 * rise);
398
+ g.save();
399
+ g.translate(e.x, e.y);
400
+ g.scale(1, e.flat ?? 0.4);
401
+ g.rotate(e.rot ?? 0);
402
+ g.translate(0, 0);
403
+ g.rotate(e.spin ? e.spin * e.t : 0);
404
+ g.globalAlpha = a;
405
+ g.globalCompositeOperation = 'lighter';
406
+ g.drawImage(sigilTex(e.color, 512), -s / 2, -s / 2, s, s);
407
+ if (e.ccw) {
408
+ // 反向内环,两层反转有「机关在运转」的感觉
409
+ g.rotate((e.spin ? e.spin : 0) * -2.4 * e.t);
410
+ g.globalAlpha = a * 0.75;
411
+ const s2 = s * 0.66;
412
+ g.drawImage(sigilTex(e.color, 512), -s2 / 2, -s2 / 2, s2, s2);
413
+ }
414
+ g.restore();
415
+ }
416
+
417
+ function drawBolt(e, k) {
418
+ const a = e.alpha * Math.pow(1 - k, 0.6);
419
+ if (a <= 0.004) return;
420
+ // 每 ~60ms 重抖一次路径,才有电弧的「噼啪」感
421
+ if (!e.pts || e.t - (e.jitAt || -1) > 0.06) {
422
+ e.jitAt = e.t;
423
+ e.pts = [];
424
+ const seg = e.seg || 10;
425
+ for (let i = 0; i <= seg; i++) {
426
+ const f = i / seg;
427
+ const amp = (1 - Math.abs(f - 0.5) * 2) * (e.jit ?? 46);
428
+ e.pts.push([
429
+ e.x + (e.x2 - e.x) * f + (Math.random() - 0.5) * amp,
430
+ e.y + (e.y2 - e.y) * f + (Math.random() - 0.5) * amp,
431
+ ]);
432
+ }
433
+ }
434
+ g.save();
435
+ g.globalAlpha = a;
436
+ g.globalCompositeOperation = 'lighter';
437
+ g.lineJoin = 'round';
438
+ g.lineCap = 'round';
439
+ g.shadowColor = e.color;
440
+ g.shadowBlur = 26;
441
+ g.strokeStyle = e.color;
442
+ g.lineWidth = e.width ?? 6;
443
+ g.beginPath();
444
+ e.pts.forEach(([x, y], i) => (i ? g.lineTo(x, y) : g.moveTo(x, y)));
445
+ g.stroke();
446
+ g.shadowBlur = 12;
447
+ g.strokeStyle = '#ffffff';
448
+ g.lineWidth = (e.width ?? 6) * 0.34;
449
+ g.stroke();
450
+ g.restore();
451
+ }
452
+
453
+ function drawText(e, k) {
454
+ const pop = k < 0.24 ? k / 0.24 : 1;
455
+ const sc = 0.68 + 0.32 * pop + (k > 0.24 ? (k - 0.24) * 0.07 : 0);
456
+ const a = e.alpha * (k < 0.14 ? k / 0.14 : k > 0.8 ? (1 - k) / 0.2 : 1);
457
+ if (a <= 0.004) return;
458
+ g.save();
459
+ g.globalAlpha = a;
460
+ g.translate(e.x, e.y);
461
+ g.scale(sc, sc);
462
+ g.textAlign = 'center';
463
+ g.textBaseline = 'middle';
464
+ g.font = `900 ${e.size ?? 46}px "Microsoft YaHei", "PingFang SC", system-ui, sans-serif`;
465
+ g.shadowColor = e.color;
466
+ g.shadowBlur = 28;
467
+ g.lineWidth = Math.max(3, (e.size ?? 46) * 0.11);
468
+ g.strokeStyle = rgba(e.stroke ?? '#1a0a20', 0.6);
469
+ g.strokeText(e.text, 0, 0);
470
+ g.fillStyle = e.color;
471
+ g.fillText(e.text, 0, 0);
472
+ g.restore();
473
+ }
474
+
475
+ function drawBeam(e, k) {
476
+ const open = Math.min(1, k / 0.12);
477
+ const a = e.alpha * open * Math.pow(1 - k, 0.9);
478
+ if (a <= 0.004) return;
479
+ const len = Math.hypot(e.x2 - e.x, e.y2 - e.y);
480
+ const ang = Math.atan2(e.y2 - e.y, e.x2 - e.x);
481
+ g.save();
482
+ g.translate(e.x, e.y);
483
+ g.rotate(ang);
484
+ g.globalAlpha = a;
485
+ g.globalCompositeOperation = 'lighter';
486
+ const wid = (e.width ?? 60) * (0.5 + 0.9 * open) * (1 - k * 0.5);
487
+ g.drawImage(glowTex(e.color, 128), 0, -wid / 2, len, wid);
488
+ g.globalAlpha = a;
489
+ g.drawImage(glowTex(e.core || '#ffffff', 128), 0, -wid * 0.14, len, wid * 0.28);
490
+ g.restore();
491
+ }
492
+
493
+ function stepGeo(dt) {
494
+ g.globalCompositeOperation = 'lighter';
495
+ for (let i = geo.length - 1; i >= 0; i--) {
496
+ const e = geo[i];
497
+ e.t += dt;
498
+ if (e.t >= e.life) {
499
+ geo.splice(i, 1);
500
+ continue;
501
+ }
502
+ const k = e.t / e.life;
503
+ if (e.kind === 'ring') drawRing(e, k);
504
+ else if (e.kind === 'pillar') drawPillar(e, k);
505
+ else if (e.kind === 'sigil') drawSigil(e, k);
506
+ else if (e.kind === 'bolt') drawBolt(e, k);
507
+ else if (e.kind === 'beam') drawBeam(e, k);
508
+ else if (e.kind === 'text') drawText(e, k);
509
+ }
510
+ g.globalCompositeOperation = 'source-over';
511
+ g.globalAlpha = 1;
512
+ }
513
+
514
+ /* ---------------------------------------------------------- 发射器 */
515
+
516
+ // 按固定频率持续吐粒子(羽毛飘落、星尘、火花拖尾)
517
+ const emitters = [];
518
+
519
+ function emitter(o) {
520
+ emitters.push({ t: 0, acc: 0, dur: o.dur ?? 2, rate: o.rate ?? 12, spawn: o.spawn });
521
+ return emitters[emitters.length - 1];
522
+ }
523
+
524
+ function stepEmitters(dt) {
525
+ for (let i = emitters.length - 1; i >= 0; i--) {
526
+ const em = emitters[i];
527
+ em.t += dt;
528
+ if (em.t > em.dur) {
529
+ emitters.splice(i, 1);
530
+ continue;
531
+ }
532
+ em.acc += dt;
533
+ const step = 1 / em.rate;
534
+ let guard = 0;
535
+ while (em.acc >= step && guard++ < 40) {
536
+ em.acc -= step;
537
+ em.spawn(em.t);
538
+ }
539
+ }
540
+ }
541
+
542
+ /* ----------------------------------------------------------- 暗幕/闪白 */
543
+
544
+ // 暗幕是画在 canvas 里的、而不是单独一层 div:
545
+ // 特效 canvas 盖在角色之上,暗幕作为每帧的第一个绘制操作,
546
+ // 后面的粒子/文字自然就落在暗幕之上,不用去调 z-index 和 DOM 层序。
547
+ let dimNow = 0;
548
+ let dimTo = 0;
549
+ let dimRate = 0;
550
+ let dimColor = '#0a0410';
551
+ let dimSoft = false;
552
+
553
+ /**
554
+ * 暗幕 / 全屏闪白。alpha 0 = 全透明,1 = 全黑。
555
+ * 0→1 用来做「黑屏切入」,1→0 用来做「角色亮相」,alpha 1 + 白色 = 闪白。
556
+ *
557
+ * soft=true 是「暗域」:中心浓、边缘透明。加色混合的光效在亮色桌面上
558
+ * 本来就很难看清楚(白底上加白还是白),垫一层柔和的暗底,金光/雷光才能真的亮起来。
559
+ */
560
+ function dim(alpha, ms, color, soft) {
561
+ if (color) dimColor = color;
562
+ if (soft !== undefined) dimSoft = !!soft;
563
+ const target = Math.max(0, Math.min(1, alpha));
564
+ if (Math.abs(target - dimNow) < 0.001) {
565
+ dimNow = target;
566
+ dimTo = target;
567
+ return;
568
+ }
569
+ dimTo = target;
570
+ dimRate = Math.abs(dimTo - dimNow) / Math.max(0.001, (ms ?? 300) / 1000);
571
+ start();
572
+ }
573
+
574
+ function stepDim(dt) {
575
+ if (dimNow === dimTo) return;
576
+ const step = dimRate * dt;
577
+ if (Math.abs(dimTo - dimNow) <= step) dimNow = dimTo;
578
+ else dimNow += Math.sign(dimTo - dimNow) * step;
579
+ }
580
+
581
+ function drawDim() {
582
+ if (dimNow <= 0.001) return;
583
+ g.globalCompositeOperation = 'source-over';
584
+ g.globalAlpha = 1;
585
+ if (dimSoft) {
586
+ const grd = g.createRadialGradient(
587
+ W / 2, H * 0.52, Math.min(W, H) * 0.12,
588
+ W / 2, H * 0.52, Math.max(W, H) * 0.62,
589
+ );
590
+ grd.addColorStop(0, rgba(dimColor, dimNow));
591
+ grd.addColorStop(0.55, rgba(dimColor, dimNow * 0.72));
592
+ grd.addColorStop(1, rgba(dimColor, 0));
593
+ g.fillStyle = grd;
594
+ } else {
595
+ g.fillStyle = rgba(dimColor, dimNow);
596
+ }
597
+ g.fillRect(0, 0, W, H);
598
+ }
599
+
600
+ /* ----------------------------------------------------------- 时间轴 */
601
+
602
+ let script = null;
603
+ let scriptDone = null;
604
+
605
+ function play(events, duration, done) {
606
+ clearAll(true); // 保留暗幕,具体见 clearAll 的注释
607
+ script = {
608
+ t: 0,
609
+ dur: duration,
610
+ events: (events || []).map(([at, fn]) => ({ at, fn, done: false })),
611
+ };
612
+ scriptDone = done || null;
613
+ start();
614
+ }
615
+
616
+ function stepScript(dt) {
617
+ if (!script) return;
618
+ script.t += dt * 1000;
619
+ for (const e of script.events) {
620
+ if (!e.done && script.t >= e.at) {
621
+ e.done = true;
622
+ try {
623
+ e.fn();
624
+ } catch (err) {
625
+ /* 单个特效出错不该拖垮整场演出 */
626
+ }
627
+ }
628
+ }
629
+ if (script.t >= script.dur) {
630
+ const cb = scriptDone;
631
+ script = null;
632
+ scriptDone = null;
633
+ if (cb) cb();
634
+ }
635
+ }
636
+
637
+ /* ------------------------------------------------------------ 主循环 */
638
+
639
+ function frame(now) {
640
+ raf = requestAnimationFrame(frame);
641
+ const dt = Math.min(0.05, Math.max(0.001, (now - last) / 1000));
642
+ last = now;
643
+
644
+ stepScript(dt);
645
+ stepEmitters(dt);
646
+ stepParticles(dt);
647
+
648
+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
649
+ g.clearRect(0, 0, W, H);
650
+ stepDim(dt);
651
+ drawDim();
652
+ stepGeo(dt);
653
+ drawParticles();
654
+
655
+ if (!script && !emitters.length && !geo.length && dimNow === dimTo && aliveParticles() === 0) {
656
+ stop();
657
+ }
658
+ }
659
+
660
+ function start() {
661
+ if (raf) return;
662
+ last = performance.now();
663
+ raf = requestAnimationFrame(frame);
664
+ }
665
+
666
+ function stop() {
667
+ if (raf) cancelAnimationFrame(raf);
668
+ raf = 0;
669
+ clearCanvas();
670
+ }
671
+
672
+ function clearCanvas() {
673
+ if (!g) return;
674
+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
675
+ g.clearRect(0, 0, W, H);
676
+ }
677
+
678
+ function reset() {
679
+ clearAll(false);
680
+ }
681
+
682
+ /**
683
+ * keepDim=true 时保留暗幕:play() 用这个 —— 暗幕是「黑屏切入」的第一笔,
684
+ * 在 play() 之前就设好了,清掉的话角色会亮着出现在黑屏本来应该盖住的那几秒里。
685
+ */
686
+ function clearAll(keepDim) {
687
+ for (let i = 0; i < MAX_P; i++) P[i].on = false;
688
+ geo.length = 0;
689
+ emitters.length = 0;
690
+ script = null;
691
+ scriptDone = null;
692
+ if (!keepDim) {
693
+ dimNow = 0;
694
+ dimTo = 0;
695
+ dimSoft = false;
696
+ }
697
+ clearCanvas();
698
+ }
699
+
700
+ /* ------------------------------------------------------------ 公开 API */
701
+
702
+ function init(el) {
703
+ canvas = el;
704
+ g = canvas.getContext('2d');
705
+ resize();
706
+ window.addEventListener('resize', resize);
707
+ }
708
+
709
+ function resize() {
710
+ if (!canvas) return;
711
+ W = canvas.clientWidth || window.innerWidth;
712
+ H = canvas.clientHeight || window.innerHeight;
713
+ dpr = Math.min(2, window.devicePixelRatio || 1);
714
+ canvas.width = Math.max(1, Math.round(W * dpr));
715
+ canvas.height = Math.max(1, Math.round(H * dpr));
716
+ }
717
+
718
+ /** 放射爆发 */
719
+ function burst(x, y, o = {}) {
720
+ const n = o.count ?? 24;
721
+ const tex = o.tex || starTex(o.color ?? '#ffffff', 96);
722
+ const a0 = o.a0 ?? 0;
723
+ const a1 = o.a1 ?? TAU;
724
+ for (let i = 0; i < n; i++) {
725
+ const a = a0 + Math.random() * (a1 - a0);
726
+ const sp = (o.speed0 ?? 120) + Math.random() * ((o.speed1 ?? 420) - (o.speed0 ?? 120));
727
+ spawn({
728
+ x: x + (Math.random() - 0.5) * (o.spread ?? 0),
729
+ y: y + (Math.random() - 0.5) * (o.spread ?? 0),
730
+ vx: Math.cos(a) * sp,
731
+ vy: Math.sin(a) * sp * (o.flat ?? 1),
732
+ life: (o.life ?? 0.9) * (0.6 + Math.random() * 0.7),
733
+ size: (o.size ?? 44) * (0.5 + Math.random()),
734
+ grow: o.grow ?? 0.35,
735
+ tex,
736
+ alpha: o.alpha ?? 1,
737
+ drag: o.drag ?? 0.06,
738
+ ay: o.grav ?? 0,
739
+ rot: Math.random() * TAU,
740
+ vrot: (Math.random() - 0.5) * (o.vrot ?? 3),
741
+ });
742
+ }
743
+ }
744
+
745
+ /** 向心螺旋汇聚(蓄力) */
746
+ function vortex(x, y, o = {}) {
747
+ const n = o.count ?? 30;
748
+ const tex = o.tex || starTex(o.color ?? '#ffe9a8', 96);
749
+ const flat = o.flat ?? 0.55;
750
+ for (let i = 0; i < n; i++) {
751
+ const a = Math.random() * TAU;
752
+ const rad = (o.radius ?? 260) * (0.65 + Math.random() * 0.6);
753
+ const sp = (o.speed ?? 190) * (0.7 + Math.random() * 0.6);
754
+ const spin = o.spin ?? 0.7;
755
+ spawn({
756
+ x: x + Math.cos(a) * rad,
757
+ y: y + Math.sin(a) * rad * flat,
758
+ vx: -Math.cos(a) * sp - Math.sin(a) * sp * spin,
759
+ vy: (-Math.sin(a) * sp + Math.cos(a) * sp * spin) * flat,
760
+ life: (o.life ?? 1.1) * (0.65 + Math.random() * 0.6),
761
+ size: (o.size ?? 28) * (0.5 + Math.random()),
762
+ tex,
763
+ alpha: o.alpha ?? 1,
764
+ drag: 1,
765
+ fade: 'inout',
766
+ grow: o.grow ?? -0.3,
767
+ });
768
+ }
769
+ }
770
+
771
+ /** 螺旋上升气柱 */
772
+ function helix(x, y, o = {}) {
773
+ const n = o.count ?? 26;
774
+ const tex = o.tex || glowTex(o.color ?? '#ffffff', 96);
775
+ const flat = o.flat ?? 0.45;
776
+ for (let i = 0; i < n; i++) {
777
+ const a = Math.random() * TAU;
778
+ const rad = (o.radius ?? 90) * (0.4 + Math.random() * 0.8);
779
+ const sp = (o.speed ?? 150) * (0.6 + Math.random() * 0.8);
780
+ spawn({
781
+ x: x + Math.cos(a) * rad,
782
+ y: y + Math.sin(a) * rad * flat,
783
+ vx: -Math.sin(a) * rad * 2.2,
784
+ vy: -sp,
785
+ life: (o.life ?? 1.4) * (0.6 + Math.random() * 0.7),
786
+ size: (o.size ?? 26) * (0.5 + Math.random()),
787
+ tex,
788
+ drag: 0.9,
789
+ fade: 'inout',
790
+ grow: o.grow ?? 0.6,
791
+ });
792
+ }
793
+ }
794
+
795
+ /** 地上铺开的碎片(水晶炸裂、冰晶) */
796
+ function shatter(x, y, o = {}) {
797
+ const n = o.count ?? 20;
798
+ const tex = o.tex || shardTex(o.color ?? '#bfe6ff', 72);
799
+ for (let i = 0; i < n; i++) {
800
+ const a = -Math.PI / 2 + (Math.random() - 0.5) * (o.arc ?? 2.4);
801
+ const sp = (o.speed0 ?? 180) + Math.random() * ((o.speed1 ?? 520) - (o.speed0 ?? 180));
802
+ spawn({
803
+ x, y,
804
+ vx: Math.cos(a) * sp,
805
+ vy: Math.sin(a) * sp,
806
+ life: (o.life ?? 1.3) * (0.6 + Math.random() * 0.6),
807
+ size: (o.size ?? 40) * (0.5 + Math.random()),
808
+ tex,
809
+ alpha: o.alpha ?? 1,
810
+ drag: 0.25,
811
+ ay: o.grav ?? 900,
812
+ rot: Math.random() * TAU,
813
+ vrot: (Math.random() - 0.5) * 9,
814
+ });
815
+ }
816
+ }
817
+
818
+ /**
819
+ * 单个大气团 / 天体(血月、雷云、黑雾):普通混合的一团柔光/暗影。
820
+ * 加色混合画不出暗色,所以月亮用亮色走 lighter,乌云必须 norm: true。
821
+ */
822
+ function orb(x, y, o = {}) {
823
+ return spawn({
824
+ x,
825
+ y,
826
+ vx: o.vx ?? 0,
827
+ vy: o.vy ?? 0,
828
+ life: o.life ?? 3,
829
+ size: o.size ?? 480,
830
+ tex: o.tex || glowTex(o.color ?? '#ffffff', 128),
831
+ drag: o.drag ?? 1,
832
+ grow: o.grow ?? 0,
833
+ alpha: o.alpha ?? 0.85,
834
+ fade: o.fade ?? 'inout',
835
+ norm: o.norm ?? false,
836
+ });
837
+ }
838
+
839
+ /** 一批暗色剪影(蝠影 / 残魂),沿弧线飞出 */
840
+ function flock(x, y, o = {}) {
841
+ const n = o.count ?? 8;
842
+ const tex = o.tex || shardTex(o.color ?? '#0b0510', 72);
843
+ for (let i = 0; i < n; i++) {
844
+ const dir = o.dir ?? (Math.random() < 0.5 ? -1 : 1);
845
+ spawn({
846
+ x: x + (Math.random() - 0.5) * (o.spread ?? 160),
847
+ y: y + (Math.random() - 0.5) * (o.spreadY ?? 120),
848
+ vx: dir * (o.speed0 ?? 160) * (0.6 + Math.random() * 0.8),
849
+ vy: (o.vy ?? -110) * (0.5 + Math.random()),
850
+ life: (o.life ?? 2.2) * (0.6 + Math.random() * 0.7),
851
+ size: (o.size ?? 34) * (0.5 + Math.random()),
852
+ tex,
853
+ drag: 0.9,
854
+ rot: Math.atan2(-dir, 0),
855
+ vrot: dir * (0.6 + Math.random()),
856
+ alpha: o.alpha ?? 0.9,
857
+ norm: true,
858
+ fade: 'inout',
859
+ });
860
+ }
861
+ }
862
+
863
+ /** 蝠影:普通混合的暗色剪影(加色混合画不出黑,见 spawn 的 norm 注释) */
864
+ function batTex(color, size = 96) {
865
+ const w = size;
866
+ const h = Math.round(size * 0.5);
867
+ return makeTex(`bat|${color}|${size}`, w, h, (c) => {
868
+ const cx = w / 2;
869
+ const cy = h / 2;
870
+ c.fillStyle = color;
871
+ c.beginPath();
872
+ c.ellipse(cx, cy, w * 0.062, h * 0.3, 0, 0, TAU);
873
+ c.fill();
874
+ for (const s of [-1, 1]) {
875
+ c.beginPath();
876
+ c.moveTo(cx, cy - h * 0.06);
877
+ c.quadraticCurveTo(cx + s * w * 0.22, cy - h * 0.46, cx + s * w * 0.46, cy - h * 0.16);
878
+ c.quadraticCurveTo(cx + s * w * 0.28, cy + h * 0.2, cx, cy + h * 0.34);
879
+ c.closePath();
880
+ c.fill();
881
+ }
882
+ });
883
+ }
884
+
885
+ /** 飘落(羽毛 / 花瓣 / 星屑) */
886
+ function fallStream(o) {
887
+ const tex = o.tex || featherTex(o.color ?? '#fff6d0', 72);
888
+ return emitter({
889
+ dur: o.dur ?? 2,
890
+ rate: o.rate ?? 10,
891
+ spawn: (t) => {
892
+ const x = o.x + (Math.random() - 0.5) * (o.spreadX ?? 420);
893
+ spawn({
894
+ x,
895
+ y: (o.y ?? 0) - (o.spreadY ?? 60) * Math.random(),
896
+ vx: (o.vx ?? 0) + (Math.random() - 0.5) * 60,
897
+ vy: (o.vy ?? 70) + Math.random() * 90,
898
+ life: (o.life ?? 3.2) * (0.6 + Math.random() * 0.6),
899
+ size: (o.size ?? 30) * (0.6 + Math.random() * 0.8),
900
+ tex,
901
+ drag: 0.55,
902
+ rot: Math.random() * TAU,
903
+ vrot: (Math.random() - 0.5) * 2.4,
904
+ spin0: Math.random() * TAU,
905
+ fade: 'inout',
906
+ });
907
+ },
908
+ });
909
+ }
910
+
911
+ /** 拖尾光带(跑动 / 冲刺) */
912
+ function trailStream(x, y, o = {}) {
913
+ const tex = o.tex || glowTex(o.color ?? '#ffffff', 96);
914
+ return emitter({
915
+ dur: o.dur ?? 1,
916
+ rate: o.rate ?? 40,
917
+ spawn: () => {
918
+ spawn({
919
+ x: x() + (Math.random() - 0.5) * 26,
920
+ y: y() + (Math.random() - 0.5) * 26,
921
+ vx: (o.vx ?? -40) + (Math.random() - 0.5) * 60,
922
+ vy: -(o.vy ?? 20) - Math.random() * 50,
923
+ life: (o.life ?? 0.7) * (0.6 + Math.random() * 0.6),
924
+ size: (o.size ?? 34) * (0.5 + Math.random()),
925
+ tex,
926
+ drag: 0.3,
927
+ fade: 'out',
928
+ grow: 0.3,
929
+ });
930
+ },
931
+ });
932
+ }
933
+
934
+ /** 闪电(两点之间反复抖动的电弧) */
935
+ function bolt(x, y, x2, y2, o = {}) {
936
+ return push({
937
+ kind: 'bolt',
938
+ x, y, x2, y2,
939
+ color: o.color ?? '#cfa8ff',
940
+ width: o.width ?? 7,
941
+ jit: o.jit ?? 46,
942
+ seg: o.seg ?? 11,
943
+ alpha: o.alpha ?? 1,
944
+ life: o.life ?? 0.3,
945
+ });
946
+ }
947
+
948
+ /** 冲击波圆环 */
949
+ function ring(x, y, o = {}) {
950
+ return push({
951
+ kind: 'ring',
952
+ x, y,
953
+ r0: o.r0 ?? 20,
954
+ r1: o.r1 ?? 320,
955
+ width: o.width ?? 10,
956
+ flat: o.flat ?? 0.34,
957
+ inner: o.inner ?? false,
958
+ color: o.color ?? '#ffffff',
959
+ alpha: o.alpha ?? 0.9,
960
+ life: o.life ?? 0.7,
961
+ });
962
+ }
963
+
964
+ /** 冲天光柱 */
965
+ function pillar(x, y, o = {}) {
966
+ return push({
967
+ kind: 'pillar',
968
+ x, y,
969
+ w: o.w ?? 130,
970
+ h: o.h ?? 620,
971
+ color: o.color ?? '#ffd75e',
972
+ core: o.core ?? '#ffffff',
973
+ alpha: o.alpha ?? 0.95,
974
+ life: o.life ?? 1.8,
975
+ });
976
+ }
977
+
978
+ /** 地面魔法阵 */
979
+ function sigil(x, y, o = {}) {
980
+ return push({
981
+ kind: 'sigil',
982
+ x, y,
983
+ size: o.size ?? 420,
984
+ flat: o.flat ?? 0.4,
985
+ spin: o.spin ?? 0.6,
986
+ ccw: o.ccw ?? true,
987
+ rot: o.rot ?? 0,
988
+ color: o.color ?? '#ffd75e',
989
+ alpha: o.alpha ?? 0.95,
990
+ life: o.life ?? 2.4,
991
+ });
992
+ }
993
+
994
+ /** 定向光束(斜劈 / 射线) */
995
+ function beam(x, y, x2, y2, o = {}) {
996
+ return push({
997
+ kind: 'beam',
998
+ x, y, x2, y2,
999
+ width: o.width ?? 60,
1000
+ color: o.color ?? '#ffffff',
1001
+ core: o.core ?? '#ffffff',
1002
+ alpha: o.alpha ?? 0.9,
1003
+ life: o.life ?? 0.4,
1004
+ });
1005
+ }
1006
+
1007
+ /** 技能名艺术字 */
1008
+ function text(x, y, str, o = {}) {
1009
+ return push({
1010
+ kind: 'text',
1011
+ x, y, text: str,
1012
+ size: o.size ?? 46,
1013
+ color: o.color ?? '#fff3c4',
1014
+ stroke: o.stroke ?? '#2a1030',
1015
+ alpha: o.alpha ?? 1,
1016
+ life: o.life ?? 2.2,
1017
+ });
1018
+ }
1019
+
1020
+ return {
1021
+ init,
1022
+ resize,
1023
+ play,
1024
+ stop,
1025
+ reset,
1026
+ get busy() {
1027
+ return (
1028
+ !!script || emitters.length > 0 || geo.length > 0 || dimNow !== dimTo || aliveParticles() > 0
1029
+ );
1030
+ },
1031
+ get dimAlpha() {
1032
+ return dimNow;
1033
+ },
1034
+ get size() {
1035
+ return { w: W, h: H };
1036
+ },
1037
+ // 贴图工厂(编排里复用,避免每套觉醒各画一遍)
1038
+ tex: { glow: glowTex, star: starTex, shard: shardTex, feather: featherTex, sigil: sigilTex, bat: batTex },
1039
+ // 原语
1040
+ dim,
1041
+ burst,
1042
+ vortex,
1043
+ helix,
1044
+ shatter,
1045
+ orb,
1046
+ flock,
1047
+ fallStream,
1048
+ trailStream,
1049
+ bolt,
1050
+ ring,
1051
+ pillar,
1052
+ sigil,
1053
+ beam,
1054
+ text,
1055
+ emitter,
1056
+ };
1057
+ })();