File size: 55,432 Bytes
a97ef0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e67dc56
a97ef0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
// ♈ 白羊座 —— 十幕场景
import { PI2, clamp01, mix, span, E, life, hash, rgba, GOLD, FIRE, RED, CREAM, SKY, LEAF, SERIF, SANS, DISPLAY,
  bloom, star, embers, burst, gold, say, drop, card, trace, hornPath, ramHead, ramSide, ariesGlyph, marsGlyph, laurel, flame, proj, rr, measure } from './kit.js';
import { drawBadge } from './badge.js';

/* ---------- 星表(赤经 h,赤纬 °,星等) ---------- */
const ARI = { hamal: [2.1194, 23.46, 2.0], sheratan: [1.9106, 20.81, 2.64], mesarthim: [1.8922, 19.29, 3.86], a41: [2.833, 27.26, 3.63], a35: [2.725, 27.71, 4.66], a39: [2.798, 29.25, 4.51] };
const ARI_LINES = [['a41', 'hamal'], ['hamal', 'sheratan'], ['sheratan', 'mesarthim']];
const TAU_ = { ald: [4.598, 16.51, .85], eps: [4.477, 19.18, 3.5], gam: [4.33, 15.63, 3.6], del: [4.382, 17.54, 3.8], th: [4.478, 15.87, 3.4], elnath: [5.438, 28.61, 1.65], zeta: [5.627, 21.14, 3.0], lam: [4.011, 12.49, 3.4], omi: [3.414, 9.03, 3.6] };
const TAU_LINES = [['gam', 'del'], ['del', 'eps'], ['eps', 'elnath'], ['gam', 'th'], ['th', 'ald'], ['ald', 'zeta'], ['gam', 'lam'], ['lam', 'omi']];
const PSC = { eta: [1.525, 15.35, 3.6], omi: [1.757, 9.16, 4.3], alr: [2.034, 2.76, 3.8], nu: [1.69, 5.49, 4.4], mu: [1.503, 6.14, 4.8], eps: [1.049, 7.89, 4.3], del: [.811, 7.58, 4.4], tau: [1.194, 30.09, 4.5], phi: [1.229, 24.58, 4.6], ups: [1.325, 27.26, 4.7] };
const PSC_LINES = [['eta', 'omi'], ['omi', 'alr'], ['alr', 'nu'], ['nu', 'mu'], ['mu', 'eps'], ['eps', 'del'], ['eta', 'phi'], ['phi', 'ups'], ['ups', 'tau']];
const PLEIADES = [[3.791, 24.105, 2.9], [3.819, 24.053, 3.6], [3.748, 24.113, 3.7], [3.763, 24.367, 3.9], [3.774, 23.948, 4.2], [3.747, 23.946, 4.3], [3.814, 24.137, 5.0]];
const radius = m => 1.4 + (4.6 - m) * 1.25;

function chart(W, H, ra0, dec0, ppd, ox = 0, oy = 0) { return s => { const [x, y] = proj(s[0], s[1], ra0, dec0); return [W / 2 + ox + x * ppd, H / 2 + oy + y * ppd]; }; }
function figure(ctx, cat, lines, P, k, col, lw, starA, starCol, S) {
  ctx.save(); ctx.strokeStyle = col; ctx.lineWidth = lw; ctx.lineCap = 'round';
  lines.forEach(([a, b], i) => { const kk = E.sine(clamp01(k * lines.length - i)); if (kk <= 0) return; const p = P(cat[a]), q = P(cat[b]);
    ctx.beginPath(); ctx.moveTo(...p); ctx.lineTo(mix(p[0], q[0], kk), mix(p[1], q[1], kk)); ctx.stroke(); });
  ctx.restore();
  if (starA > 0) for (const key in cat) { const [x, y] = P(cat[key]); star(ctx, x, y, radius(cat[key][2]) * S * .9, starCol, starA, cat[key][2] < 3 ? .8 : 0); }
}
const FIELD = Array.from({ length: 320 }, (_, i) => [.6 + hash(i) * 5.4, -2 + hash(i + 500) * 36, 4.6 + hash(i + 900) * 1.6, hash(i + 77) * PI2]);
function fieldStars(ctx, P, t, a, S) {
  ctx.fillStyle = '#f2ecff';
  for (const [ra, dec, m, ph] of FIELD) { const [x, y] = P([ra, dec]); ctx.globalAlpha = a * (.3 + .25 * Math.sin(t * 1.4 + ph)); ctx.beginPath(); ctx.arc(x, y, (6.6 - m) * .75 * S, 0, PI2); ctx.fill(); }
  ctx.globalAlpha = 1;
}
function tag(ctx, x, y, title, sub, a, S, o = {}) {
  if (a <= 0) return;
  const side = o.side ?? 1, col = o.color ?? '#ffd98f';
  ctx.save(); ctx.globalAlpha *= a; ctx.strokeStyle = rgba([255, 214, 140], .7); ctx.lineWidth = 1.6 * S;
  const ex = x + side * 34 * S, ey = y - 34 * S;
  ctx.beginPath(); ctx.moveTo(x + side * 10 * S, y - 10 * S); ctx.lineTo(ex, ey); ctx.lineTo(ex + side * 30 * S, ey); ctx.stroke(); ctx.restore();
  const tx = ex + side * 40 * S, al = side > 0 ? 'left' : 'right';
  say(ctx, title, tx, ey - (sub ? 12 * S : 0), { size: (o.size ?? 34) * S, align: al, fill: col, alpha: a, shadow: 'rgba(0,0,0,.85)', blur: 12 * S });
  if (sub) say(ctx, sub, tx, ey + 22 * S, { size: 21 * S, align: al, fill: 'rgba(255,240,225,.82)', alpha: a, font: SANS, weight: 400, shadow: 'rgba(0,0,0,.8)', blur: 8 * S });
}
function ring(ctx, x, y, r, a, c = GOLD, lw = 2) { if (a <= 0) return; ctx.save(); ctx.globalAlpha *= a; ctx.strokeStyle = rgba(c); ctx.lineWidth = lw; ctx.beginPath(); ctx.arc(x, y, r, 0, PI2); ctx.stroke(); ctx.restore(); }
function ping(ctx, x, y, u, S, c = GOLD) { for (let i = 0; i < 2; i++) { const k = (u - i * .3) / 1.3; if (k > 0 && k < 1) ring(ctx, x, y, (10 + 110 * E.out3(k)) * S, (1 - k) * .9, c, 2.4 * S); } }
// 华丽标题卡(双线金框 + 角饰)
function plaque(ctx, cx, cy, w, h, a, S) {
  if (a <= 0) return;
  ctx.save(); ctx.globalAlpha *= a;
  rr(ctx, cx - w / 2, cy - h / 2, w, h, 14 * S); const g = ctx.createLinearGradient(0, cy - h / 2, 0, cy + h / 2); g.addColorStop(0, 'rgba(40,12,8,.78)'); g.addColorStop(1, 'rgba(14,4,6,.78)'); ctx.fillStyle = g; ctx.fill();
  ctx.strokeStyle = gold(ctx, cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2); ctx.lineWidth = 2.5 * S; ctx.stroke();
  rr(ctx, cx - w / 2 + 9 * S, cy - h / 2 + 9 * S, w - 18 * S, h - 18 * S, 9 * S); ctx.lineWidth = 1 * S; ctx.globalAlpha *= .6; ctx.stroke(); ctx.globalAlpha /= .6;
  for (const [dx, dy] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) { const x = cx + dx * (w / 2 - 9 * S), y = cy + dy * (h / 2 - 9 * S); ctx.fillStyle = '#ffd98f'; ctx.beginPath(); ctx.moveTo(x, y - 7 * S); ctx.lineTo(x + 7 * S, y); ctx.lineTo(x, y + 7 * S); ctx.lineTo(x - 7 * S, y); ctx.fill(); }
  ctx.restore();
}
// 海浪
function sea(ctx, t, W, H, top, S, a = 1, tint = [20, 70, 120]) {
  for (let L = 0; L < 4; L++) {
    const y0 = top + L * 34 * S, amp = (10 + L * 5) * S, sp = .6 + L * .25;
    ctx.beginPath(); ctx.moveTo(0, H);
    for (let x = 0; x <= W + 20; x += 20) ctx.lineTo(x, y0 + Math.sin(x * .006 / (S || 1) + t * sp + L) * amp + Math.sin(x * .017 / (S || 1) - t * sp * 1.3) * amp * .4);
    ctx.lineTo(W, H); ctx.closePath();
    ctx.fillStyle = rgba([tint[0] - L * 4, tint[1] - L * 12, tint[2] - L * 18], a * (.55 + L * .12)); ctx.fill();
    ctx.strokeStyle = rgba([150, 220, 255], a * .25); ctx.lineWidth = 1.5 * S; ctx.stroke();
  }
}

export const ACTS = {};

/* ============ 1 序章 · 第一缕春光 ============ */
ACTS.dawn = (ctx, u, W, H, X) => {
  const { S, P, cue } = X;
  // 地平线日出
  const sun = E.sine(span(u, cue(0) - .5, cue(2) + 1.5));
  const hy = H * (P ? .78 : .8);
  if (sun > 0) {
    const g = ctx.createLinearGradient(0, hy - 400 * S, 0, H);
    g.addColorStop(0, 'rgba(255,140,60,0)'); g.addColorStop(.6, rgba([255, 150, 70], .35 * sun)); g.addColorStop(1, rgba([255, 210, 140], .55 * sun));
    ctx.fillStyle = g; ctx.fillRect(0, hy - 400 * S, W, H);
    bloom(ctx, W / 2, hy + 40 * S, 700 * S * sun, [255, 170, 80], .9 * sun);
    // 光束
    ctx.save(); ctx.globalCompositeOperation = 'lighter';
    for (let i = 0; i < 9; i++) { const ang = -Math.PI / 2 + (i - 4) * .2 + Math.sin(u * .3 + i) * .03; const L = Math.max(W, H) * 1.1;
      const gr = ctx.createLinearGradient(W / 2, hy, W / 2 + Math.cos(ang) * L, hy + Math.sin(ang) * L); gr.addColorStop(0, rgba([255, 200, 120], .09 * sun)); gr.addColorStop(1, 'rgba(255,200,120,0)');
      ctx.fillStyle = gr; ctx.beginPath(); ctx.moveTo(W / 2, hy); ctx.lineTo(W / 2 + Math.cos(ang - .04) * L, hy + Math.sin(ang - .04) * L); ctx.lineTo(W / 2 + Math.cos(ang + .04) * L, hy + Math.sin(ang + .04) * L); ctx.fill(); }
    ctx.restore();
    // 山脊剪影
    ctx.fillStyle = '#0b0204'; ctx.beginPath(); ctx.moveTo(0, H);
    for (let x = 0; x <= W; x += 16) ctx.lineTo(x, hy + 30 * S + Math.sin(x / W * 7 + 1) * 22 * S + Math.sin(x / W * 19) * 9 * S);
    ctx.lineTo(W, H); ctx.fill();
  }
  embers(ctx, u, W, H, { n: 70, seed: 3, alpha: .9, spread: 1.2 });
  // 公羊头:从余烬中显形
  const hx = P ? W / 2 : W * .3, hyy = P ? H * .3 : H * .44, hs = (P ? 1.65 : 1.35) * S;
  const form = span(u, cue(3) - .4, cue(3) + 2.4);
  if (form > 0 && form < 1) for (let i = 0; i < 90; i++) { const ang = hash(i) * PI2, r = (200 + hash(i + 9) * 600) * S * (1 - E.out3(form)); bloom(ctx, hx + Math.cos(ang + form * 2) * r, hyy + Math.sin(ang + form * 2) * r, 10 * S, FIRE, Math.sin(form * Math.PI)); }
  const charge = span(u, cue(4), cue(4) + .7), shake = charge > 0 && charge < 1 ? Math.sin(u * 60) * 4 * S * (1 - charge) : 0;
  const tilt = .18 * E.out3(charge) * (1 - span(u, cue(5) - .3, cue(5) + .6));
  const lunge = 1 + .08 * E.out3(charge) * (1 - span(u, cue(5) - .3, cue(5) + .6));
  bloom(ctx, hx, hyy, 380 * S * form, [255, 150, 60], .55 * form);
  ramHead(ctx, hx + shake, hyy, hs * lunge * mix(.9, 1, E.out3(form)), { k: form, tilt, eye: 1 + charge * .6 });
  if (charge > 0) burst(ctx, u, cue(4) + .25, hx, hyy + 40 * S, S, { n: 70, color: FIRE, speed: 700 });
  if (charge > 0 && charge < 1) { const k = charge; ring(ctx, hx, hyy + 60 * S, 600 * S * E.out3(k), (1 - k) * .7, [255, 180, 90], 4 * S); }
  // 标题
  const tk = span(u, cue(5) + .1, cue(5) + 1.6);
  const tx = P ? W / 2 : W * .67, ty = P ? H * .57 : H * .34;
  bloom(ctx, tx, ty, 500 * S * tk, [255, 120, 50], .35 * tk);
  drop(ctx, '白羊座', tx, ty, tk, { size: (P ? 170 : 160) * S, metal: true, track: 30 * S, weight: 900, shadow: 'rgba(255,120,40,.7)', blur: 40 * S });
  say(ctx, 'A R I E S', tx, ty + (P ? 118 : 112) * S, { size: 40 * S, fill: '#ffd98f', alpha: span(u, cue(5) + 1, cue(5) + 2), font: DISPLAY, weight: 600, track: 10 * S });
  // 第一宫徽记
  const nk = E.back(span(u, cue(5) + 1.4, cue(5) + 2.2));
  if (nk > 0) {
    const by = ty + (P ? 250 : 225) * S, bx = tx - 150 * S;
    ctx.save(); ctx.translate(bx, by); ctx.scale(nk, nk);
    ctx.beginPath(); for (let i = 0; i < 16; i++) { const r = i % 2 ? 46 * S : 58 * S, a = i / 16 * PI2; ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r); } ctx.closePath();
    ctx.fillStyle = gold(ctx, -60 * S, -60 * S, 60 * S, 60 * S); ctx.shadowColor = 'rgba(255,150,60,.9)'; ctx.shadowBlur = 30 * S; ctx.fill(); ctx.restore();
    say(ctx, 'No.1', bx, by - 2 * S, { size: 30 * S * nk, fill: '#5a2a06', font: DISPLAY, weight: 800 });
    say(ctx, '黄道第一宫', bx + 85 * S, by - 20 * S, { size: 34 * S, fill: '#fff0d0', align: 'left', alpha: clamp01(nk), shadow: 'rgba(90,20,0,.9)', blur: 10 * S });
    say(ctx, '十二星座 · 首位', bx + 85 * S, by + 24 * S, { size: 24 * S, fill: '#ffe2b0', align: 'left', alpha: clamp01(nk), font: SANS, weight: 600 });
  }
  // 日期缎带
  const dk = span(u, cue(6) - .2, cue(6) + 1);
  if (dk > 0) {
    const ry = ty + (P ? 385 : 360) * S, rw = 460 * S * E.out3(dk), rh = 64 * S, rx = tx;
    ctx.save(); ctx.globalAlpha = dk;
    for (const d of [-1, 1]) { ctx.fillStyle = '#7a1410'; ctx.beginPath(); const ex = rx + d * rw / 2; ctx.moveTo(ex - d * 10 * S, ry - rh / 2 + 10 * S); ctx.lineTo(ex + d * 60 * S, ry - rh / 2 + 10 * S); ctx.lineTo(ex + d * 40 * S, ry + 5 * S); ctx.lineTo(ex + d * 60 * S, ry + rh / 2 + 10 * S); ctx.lineTo(ex - d * 10 * S, ry + rh / 2 + 10 * S); ctx.fill(); }
    const g = ctx.createLinearGradient(0, ry - rh / 2, 0, ry + rh / 2); g.addColorStop(0, '#d8352a'); g.addColorStop(1, '#8e1712');
    ctx.fillStyle = g; ctx.fillRect(rx - rw / 2, ry - rh / 2, rw, rh);
    ctx.strokeStyle = gold(ctx, rx - rw / 2, ry, rx + rw / 2, ry); ctx.lineWidth = 2 * S; ctx.strokeRect(rx - rw / 2 + 5 * S, ry - rh / 2 + 5 * S, rw - 10 * S, rh - 10 * S);
    ctx.restore();
    say(ctx, '3.21 — 4.19', rx, ry + 2 * S, { size: 38 * S, fill: '#fff1d0', alpha: span(dk, .5, 1), font: DISPLAY, weight: 700, track: 4 * S });
  }
};

/* ============ 2 为什么排第一 ============ */
const SIGNS = [['♈', '白羊'], ['♉', '金牛'], ['♊', '双子'], ['♋', '巨蟹'], ['♌', '狮子'], ['♍', '处女'], ['♎', '天秤'], ['♏', '天蝎'], ['♐', '射手'], ['♑', '摩羯'], ['♒', '水瓶'], ['♓', '双鱼']];
ACTS.first = (ctx, u, W, H, X) => {
  const { S, P, cue } = X;
  const cx = W / 2, cy = P ? H * .42 : H * .47;
  const rx = (P ? W * .4 : H * .62), ry = rx * (P ? .5 : .38);
  const ph2 = span(u, cue(7) - .6, cue(7) + .4); // 进入“坐标网格”阶段
  const ringA = span(u, .2, 1.6) * (1 - ph2 * .94);
  // 黄道环
  ctx.save(); ctx.globalAlpha = ringA; ctx.strokeStyle = gold(ctx, cx - rx, cy, cx + rx, cy); ctx.lineWidth = 3 * S;
  ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, PI2); ctx.stroke(); ctx.lineWidth = 1 * S; ctx.beginPath(); ctx.ellipse(cx, cy, rx * 1.08, ry * 1.08, 0, 0, PI2); ctx.stroke(); ctx.restore();
  // 地球在中心
  bloom(ctx, cx, cy, 80 * S, SKY, .6 * ringA);
  ctx.save(); ctx.globalAlpha = ringA; const eg = ctx.createRadialGradient(cx - 10 * S, cy - 10 * S, 2, cx, cy, 30 * S); eg.addColorStop(0, '#8fd0ff'); eg.addColorStop(1, '#1d4f9a'); ctx.fillStyle = eg; ctx.beginPath(); ctx.arc(cx, cy, 30 * S, 0, PI2); ctx.fill(); ctx.restore();
  // 太阳沿黄道运行,在春分落入白羊
  const base = Math.PI / 2; // 白羊位于环的正前方(下方)
  const posOf = i => base + i / 12 * PI2;
  const sunK = E.sine(span(u, cue(2), cue(4) + .6));
  const sunAng = posOf(-3) + sunK * (posOf(0) - posOf(-3));
  const numK = span(u, cue(5), cue(6) + 1.2); // 1→12 依次点亮
  SIGNS.forEach(([g, n], i) => {
    const ang = posOf(i), x = cx + Math.cos(ang) * rx, y = cy + Math.sin(ang) * ry;
    const front = (Math.sin(ang) + 1) / 2; const sc = .75 + front * .45;
    const isA = i === 0, hl = isA ? span(u, cue(4), cue(4) + .6) : 0;
    const lit = clamp01(numK * 12 - i);
    const r = 34 * S * sc * (1 + hl * .35);
    ctx.save(); ctx.globalAlpha = ringA * (.55 + front * .45);
    ctx.fillStyle = isA ? 'rgba(90,30,10,.9)' : 'rgba(25,14,30,.85)'; ctx.strokeStyle = isA ? '#ffd98f' : rgba([255, 220, 160], .35 + lit * .6);
    ctx.lineWidth = (isA ? 3 : 1.5) * S; ctx.beginPath(); ctx.arc(x, y, r, 0, PI2); ctx.fill(); ctx.stroke(); ctx.restore();
    if (isA && hl > 0) bloom(ctx, x, y, r * 4, FIRE, hl * .7);
    say(ctx, g + '\uFE0E', x, y - 2 * S, { size: 34 * S * sc * (1 + hl * .35), fill: isA ? '#ffd98f' : '#f2e6ff', alpha: ringA, font: '"Noto Sans Symbols 2","Segoe UI Symbol","DejaVu Sans",serif', weight: 400 });
    say(ctx, n, x, y + r + 18 * S, { size: 19 * S * sc, fill: isA ? '#ffd98f' : 'rgba(240,230,255,.75)', alpha: ringA, font: SANS, weight: 600 });
    if (lit > 0) say(ctx, String(i + 1), x + r * .9, y - r * .9, { size: 20 * S * sc, fill: '#fff', alpha: ringA * lit, font: DISPLAY, weight: 800, shadow: 'rgba(255,120,40,.9)', blur: 10 * S });
  });
  if (ringA > 0) { const x = cx + Math.cos(sunAng) * rx, y = cy + Math.sin(sunAng) * ry; bloom(ctx, x, y, 150 * S, [255, 190, 90], ringA * span(u, cue(2) - .5, cue(2) + .3)); star(ctx, x, y, 10 * S, [255, 220, 150], ringA * span(u, cue(2) - .5, cue(2) + .3), 1.2); }
  // 春分:昼夜等长 & 万物复苏(嫩芽与花瓣)
  const sp = life(u, cue(3) - .2, cue(5) + .5, .6, .8);
  if (sp > 0) {
    const ax = cx, ay = cy + ry;
    for (let i = 0; i < 14; i++) { const ang = -Math.PI / 2 + (i - 6.5) * .22, k = E.out3(clamp01((u - cue(3)) * 1.2 - i * .05)); const L = 120 * S * k;
      ctx.save(); ctx.globalAlpha = sp; ctx.strokeStyle = '#8be38f'; ctx.lineWidth = 2.5 * S; ctx.beginPath(); ctx.moveTo(ax, ay); ctx.quadraticCurveTo(ax + Math.cos(ang) * L * .4, ay - L * .6, ax + Math.cos(ang) * L, ay + Math.sin(ang) * L); ctx.stroke();
      ctx.fillStyle = i % 3 ? '#ffb7c8' : '#fff1b0'; ctx.beginPath(); ctx.arc(ax + Math.cos(ang) * L, ay + Math.sin(ang) * L, 5 * S * k, 0, PI2); ctx.fill(); ctx.restore(); }
    const ly = P ? cy - ry - 120 * S : cy - ry - 90 * S;
    card(ctx, cx - 210 * S, ly - 40 * S, 420 * S, 80 * S, { alpha: sp, r: 40 * S });
    say(ctx, '春分 · 昼夜等长', cx, ly, { size: 36 * S, fill: '#ffe7b8', alpha: sp });
  }
  const startK = life(u, cue(5) - .2, cue(7) - .3, .5, .5);
  if (startK > 0) { const x = cx, y = cy + ry; say(ctx, '起点 START', x, y + 120 * S, { size: 44 * S, metal: true, alpha: startK, font: DISPLAY, weight: 800, shadow: 'rgba(255,120,40,.7)' }); }
  // 天球坐标:赤道 × 黄道 的交点 = 春分点 = 白羊座第一点
  if (ph2 > 0) {
    const gx = W / 2, gy = P ? H * .44 : H * .46, L = (P ? W * .44 : W * .32);
    const k1 = E.sine(span(u, cue(7) - .3, cue(7) + 1.2)), k2 = E.sine(span(u, cue(7) + .4, cue(7) + 1.8));
    ctx.save(); ctx.lineCap = 'round';
    ctx.strokeStyle = rgba(SKY, .85 * ph2); ctx.lineWidth = 3 * S; ctx.beginPath(); ctx.moveTo(gx - L * k1, gy); ctx.lineTo(gx + L * k1, gy); ctx.stroke();
    const tilt = -23.4 * Math.PI / 180;
    ctx.strokeStyle = rgba([255, 200, 110], .9 * ph2); ctx.beginPath(); ctx.moveTo(gx - Math.cos(tilt) * L * k2, gy - Math.sin(tilt) * L * k2); ctx.lineTo(gx + Math.cos(tilt) * L * k2, gy + Math.sin(tilt) * L * k2); ctx.stroke(); ctx.restore();
    say(ctx, '天赤道', gx - L + 10 * S, gy + 30 * S, { size: 24 * S, align: 'left', fill: rgba(SKY), alpha: ph2 * k1, font: SANS, weight: 600 });
    say(ctx, '黄道(太阳的路径)', gx + L * .55, gy + Math.sin(-tilt) * L * .55 + 30 * S * 0 - 40 * S, { size: 24 * S, fill: '#ffd98f', alpha: ph2 * k2, font: SANS, weight: 600 });
    const gk = span(u, cue(7) + 1, cue(7) + 2.6);
    if (gk > 0) { ping(ctx, gx, gy, u - cue(7) - 1, S, [255, 200, 120]); bloom(ctx, gx, gy, 140 * S, [255, 170, 80], gk); ariesGlyph(ctx, gx, gy - 150 * S, .75 * S, gk); }
    const tk = span(u, cue(7) + 2, cue(7) + 3);
    const py = P ? gy + 300 * S : gy + 255 * S;
    card(ctx, W / 2 - 330 * S, py - 62 * S, 660 * S, 124 * S, { alpha: tk });
    say(ctx, '春分点 =「白羊座第一点」', W / 2, py - 18 * S, { size: 38 * S, metal: true, alpha: tk });
    say(ctx, 'First Point of Aries · 天文记号 ♈︎', W / 2, py + 30 * S, { size: 22 * S, fill: 'rgba(255,236,210,.85)', alpha: tk, font: SANS, weight: 400 });
  }
};

/* ============ 3 找到它 ============ */
ACTS.find = (ctx, u, W, H, X) => {
  const { S, P, cue } = X;
  const ppd = P ? W * .92 / 40 : Math.min(W * .9 / 66, H * .78 / 34);
  const zk = E.sine(span(u, cue(2) - .6, cue(2) + 2.2));
  const zoom = 1 + (P ? .35 : .55) * zk;
  const Pj = chart(W, H, mix(P ? 2.95 : 2.9, P ? 2.6 : 2.75, zk), mix(18, 21.5, zk), ppd * zoom, 0, P ? -H * .06 : -H * .02);
  fieldStars(ctx, Pj, u, span(u, 0, 1.5), S);
  const pk = span(u, cue(1) - .2, cue(1) + 1.6), tk = span(u, cue(1) + .5, cue(1) + 2.2);
  figure(ctx, PSC, PSC_LINES, Pj, pk, rgba(SKY, .45), 1.8 * S, pk * .7, [190, 210, 255], S);
  figure(ctx, TAU_, TAU_LINES, Pj, tk, rgba([255, 170, 130], .45), 1.8 * S, tk * .7, [255, 190, 160], S);
  const fade = 1 - span(u, cue(5) - .5, cue(5) + 1) * .5;
  { const [x, y] = Pj([1.35, 11]); say(ctx, '双鱼座', x, y + 50 * S, { size: 32 * S, fill: '#bcd0ff', alpha: pk * fade }); }
  { const [x, y] = Pj(TAU_.ald); say(ctx, '金牛座', x - 10 * S, y + 60 * S, { size: 32 * S, fill: '#ffc2a8', alpha: tk * fade }); }
  // 三颗亮星 + 羊角弧
  const ak = span(u, cue(2) - .2, cue(2) + 2);
  ['mesarthim', 'sheratan', 'hamal'].forEach((k, i) => { const [x, y] = Pj(ARI[k]); const kk = span(u, cue(2) + i * .45, cue(2) + i * .45 + .5); if (kk > 0) { star(ctx, x, y, radius(ARI[k][2]) * 2 * S, [255, 215, 150], kk, 1.2); ping(ctx, x, y, u - cue(2) - i * .45, S); } });
  figure(ctx, ARI, ARI_LINES, Pj, ak, rgba([255, 214, 140], .95), 3 * S, 0, GOLD, S);
  { const [x, y] = Pj(ARI.a41); star(ctx, x, y, radius(ARI.a41[2]) * 1.3 * S, [255, 215, 150], ak, .5); }
  // 羊角幽灵:沿弧线的螺旋
  const hk = span(u, cue(3) - .2, cue(3) + 1.8);
  if (hk > 0) {
    const [x1, y1] = Pj(ARI.mesarthim), [x2, y2] = Pj(ARI.hamal);
    const r0 = Math.hypot(x2 - x1, y2 - y1) * 1.05;
    const h = hornPath(x1 + (x2 - x1) * .25, y1 - r0 * .55, r0 * .62, 1.1, -1, Math.PI * .55, .35);
    ctx.save(); ctx.globalAlpha = .3 * hk * (1 - span(u, cue(5), cue(5) + 1.5)); ctx.fillStyle = gold(ctx, x1 - r0, y1 - r0, x2 + r0, y2 + r0); ctx.fill(h.shape); ctx.restore();
    trace(ctx, h.spine, h.len * 1.9, hk, { width: 2 * S, color: rgba([255, 220, 150], .7 * (1 - span(u, cue(5), cue(5) + 1.5))), glow: 'rgba(255,190,90,.8)' });
  }
  { const [x, y] = Pj(ARI.hamal); say(ctx, '白羊座', x + 10 * S, y - 110 * S, { size: 48 * S, metal: true, alpha: span(u, cue(2) + 1.2, cue(2) + 2.2), shadow: 'rgba(255,120,40,.6)', track: 6 * S }); }
  // 昴星团
  const mk = span(u, cue(4) - .2, cue(4) + 1);
  if (mk > 0) {
    const c = Pj([3.78, 24.1]); bloom(ctx, c[0], c[1], 90 * S, [150, 190, 255], .6 * mk);
    PLEIADES.forEach((s, i) => { const [x, y] = Pj(s); const tw = .7 + .3 * Math.sin(u * 5 + i * 2); star(ctx, mix(c[0], x, 1) , y, (5.4 - s[2]) * 1.2 * S, [190, 215, 255], mk * tw, .6); });
    tag(ctx, c[0], c[1], '昴星团', 'M45 · 七姐妹', mk, S, { side: -1, color: '#cfe0ff' });
  }
  // 娄宿 / 胃宿
  const lk = span(u, cue(6) - .3, cue(6) + .8);
  if (lk > 0) {
    const box = (keys, label, sub, side) => {
      const pts = keys.map(k => Pj(ARI[k])); const xs = pts.map(p => p[0]), ys = pts.map(p => p[1]); const pad = 34 * S;
      const x0 = Math.min(...xs) - pad, y0 = Math.min(...ys) - pad, x1 = Math.max(...xs) + pad, y1 = Math.max(...ys) + pad;
      ctx.save(); ctx.globalAlpha = lk; ctx.setLineDash([6 * S, 6 * S]); ctx.strokeStyle = '#ff9b7a'; ctx.lineWidth = 2 * S; rr(ctx, x0, y0, x1 - x0, y1 - y0, 14 * S); ctx.stroke(); ctx.restore();
      const sx = side > 0 ? x1 + 18 * S : x0 - 18 * S; const sy = side > 0 ? y1 : y0;
      ctx.save(); ctx.globalAlpha = lk; ctx.fillStyle = '#b3261e'; rr(ctx, sx - (side > 0 ? 0 : 70 * S), sy - 35 * S, 70 * S, 70 * S, 8 * S); ctx.fill(); ctx.restore();
      say(ctx, label, sx + (side > 0 ? 35 : -35) * S, sy + 2 * S, { size: 44 * S, fill: '#fff2e6', alpha: lk, weight: 900 });
      say(ctx, sub, sx + (side > 0 ? 35 : -35) * S, sy + 62 * S, { size: 20 * S, fill: '#ffd7c8', alpha: lk, font: SANS, weight: 600 });
    };
    box(['hamal', 'sheratan', 'mesarthim'], '娄', '娄宿', 1);
    box(['a35', 'a39', 'a41'], '胃', '胃宿', -1);
  }
};

/* ============ 4 星星的名字 ============ */
ACTS.names = (ctx, u, W, H, X) => {
  const { S, P, cue } = X;
  const phB = E.sine(span(u, cue(5) - .6, cue(5) + .6));
  // —— A:哈马尔
  const A = 1 - phB;
  if (A > 0) {
    const sx = P ? W / 2 : W * .34, sy = P ? H * .36 : H * .47, R = (P ? 190 : 210) * S * E.out3(span(u, 0, 1.6));
    ctx.save(); ctx.globalAlpha = A;
    bloom(ctx, sx, sy, R * 3.2, [255, 140, 60], .8);
    const g = ctx.createRadialGradient(sx - R * .3, sy - R * .3, R * .1, sx, sy, R); g.addColorStop(0, '#fff0c8'); g.addColorStop(.45, '#ffaa48'); g.addColorStop(1, '#b3420f');
    ctx.fillStyle = g; ctx.beginPath(); ctx.arc(sx, sy, R, 0, PI2); ctx.fill();
    // 表面对流纹
    ctx.save(); ctx.beginPath(); ctx.arc(sx, sy, R, 0, PI2); ctx.clip(); ctx.globalCompositeOperation = 'overlay';
    for (let i = 0; i < 160; i++) { const a = hash(i) * PI2 + u * .04 * (hash(i + 1) - .5), d = Math.sqrt(hash(i + 3)) * R; ctx.fillStyle = i % 2 ? 'rgba(255,236,190,.10)' : 'rgba(140,40,0,.10)'; ctx.beginPath(); ctx.arc(sx + Math.cos(a) * d, sy + Math.sin(a) * d, R * (.04 + hash(i + 8) * .06), 0, PI2); ctx.fill(); }
    ctx.restore();
    const limb = ctx.createRadialGradient(sx, sy, R * .55, sx, sy, R); limb.addColorStop(0, 'rgba(90,20,0,0)'); limb.addColorStop(1, 'rgba(90,20,0,.55)'); ctx.fillStyle = limb; ctx.beginPath(); ctx.arc(sx, sy, R, 0, PI2); ctx.fill();
    ctx.restore();
    const nx = P ? W / 2 : W * .66, ny = P ? H * .64 : H * .3;
    say(ctx, '哈马尔', nx, ny, { size: 86 * S, metal: true, alpha: A * span(u, cue(0), cue(0) + .8), shadow: 'rgba(255,120,40,.6)', track: 8 * S });
    say(ctx, 'Hamal · α Ari · 娄宿三', nx, ny + 62 * S, { size: 26 * S, fill: '#ffe2c0', alpha: A * span(u, cue(0) + .4, cue(0) + 1.2), font: SANS, weight: 600 });
    const yk = E.back(span(u, cue(2) - .2, cue(2) + .5));
    if (yk > 0) {
      const bx = nx, by = ny + 170 * S; ctx.save(); ctx.translate(bx, by); ctx.scale(yk, yk); ctx.rotate(-.06);
      ctx.fillStyle = '#b3261e'; rr(ctx, -62 * S, -62 * S, 124 * S, 124 * S, 12 * S); ctx.fill(); ctx.strokeStyle = '#ffe6d0'; ctx.lineWidth = 3 * S; rr(ctx, -50 * S, -50 * S, 100 * S, 100 * S, 6 * S); ctx.stroke(); ctx.restore();
      say(ctx, '羊', bx, by + 4 * S, { size: 84 * S * yk, fill: '#fff2e6', alpha: A, weight: 900 });
      say(ctx, 'الحمل  al-ḥamal', bx, by + 100 * S, { size: 24 * S, fill: '#ffd7b8', alpha: A * clamp01(yk), font: SANS, weight: 600 });
    }
    // 橙色巨星 / 距离
    const ok = span(u, cue(3), cue(3) + .8);
    if (ok > 0) { tag(ctx, sx + R * .7, sy - R * .7, '橙色巨星', '直径约为太阳的 15 倍', A * ok, S, { side: 1 });
      const sunR = R / 15; star(ctx, sx + R + 90 * S, sy + R * .6, sunR, [255, 240, 200], A * ok, 0); say(ctx, '太阳', sx + R + 90 * S, sy + R * .6 + 28 * S, { size: 20 * S, fill: '#fff', alpha: A * ok, font: SANS, weight: 600 }); }
    const dk = span(u, cue(4) - .2, cue(4) + 1.2);
    if (dk > 0) {
      const bx0 = P ? W * .12 : W * .52, bx1 = P ? W * .88 : W * .9, by = P ? H * .78 : H * .78;
      ctx.save(); ctx.globalAlpha = A * dk; ctx.strokeStyle = 'rgba(255,220,170,.5)'; ctx.lineWidth = 2 * S; ctx.setLineDash([8 * S, 8 * S]); ctx.beginPath(); ctx.moveTo(bx0, by); ctx.lineTo(bx1, by); ctx.stroke(); ctx.restore();
      const px = mix(bx1, bx0, E.sine(clamp01((u - cue(4)) / 2.2) % 1)); bloom(ctx, px, by, 30 * S, [255, 200, 120], A * dk);
      say(ctx, '地球', bx0, by + 32 * S, { size: 22 * S, fill: '#cfe0ff', alpha: A * dk, font: SANS, weight: 600 }); say(ctx, '哈马尔', bx1, by + 32 * S, { size: 22 * S, fill: '#ffd0a0', alpha: A * dk, font: SANS, weight: 600 });
      say(ctx, '≈ 66 光年', (bx0 + bx1) / 2, by - 36 * S, { size: 40 * S, metal: true, alpha: A * dk, font: SERIF });
    }
  }
  // —— B:娄宿二 · 双星
  if (phB > 0) {
    const cx = W / 2, cy = P ? H * .42 : H * .46, R = (P ? 330 : 300) * S;
    const scope = span(u, cue(6) - .2, cue(6) + .8);
    // 目镜视野
    ctx.save(); ctx.globalAlpha = phB;
    if (scope > 0) {
      ctx.fillStyle = rgba([0, 0, 0], .75 * scope); ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.arc(cx, cy, R * mix(1.6, 1, E.out3(scope)), 0, PI2, true); ctx.fill();
      ctx.strokeStyle = gold(ctx, cx - R, cy - R, cx + R, cy + R); ctx.lineWidth = 8 * S; ctx.beginPath(); ctx.arc(cx, cy, R * mix(1.6, 1, E.out3(scope)), 0, PI2); ctx.stroke();
      ctx.strokeStyle = rgba([255, 220, 160], .25 * scope); ctx.lineWidth = 1 * S; ctx.beginPath(); ctx.moveTo(cx - R, cy); ctx.lineTo(cx + R, cy); ctx.moveTo(cx, cy - R); ctx.lineTo(cx, cy + R); ctx.stroke();
    }
    ctx.restore();
    const split = E.out5(span(u, cue(7) - .2, cue(7) + 1));
    const d = 70 * S * split;
    star(ctx, cx - d, cy, 9 * S, [230, 240, 255], phB, 1); star(ctx, cx + d, cy, 8.5 * S, [230, 240, 255], phB * (split > .05 ? 1 : 0), 1);
    say(ctx, '娄宿二 · Mesarthim', cx, cy - R - (P ? 70 : 50) * S, { size: 40 * S, metal: true, alpha: phB * span(u, cue(5), cue(5) + .8) });
    if (split > .5) say(ctx, '双星!', cx, cy + 110 * S, { size: 52 * S, fill: '#fff4dc', alpha: phB * span(split, .5, 1), shadow: 'rgba(120,170,255,.8)' });
    const yk = span(u, cue(8) - .2, cue(8) + .6);
    if (yk > 0) {
      const yy = P ? cy + R + 120 * S : cy + R * .35, xx = P ? W / 2 : W * .82;
      say(ctx, '1664', P ? xx : W * .17, P ? yy : cy - 40 * S, { size: 110 * S, metal: true, alpha: phB * yk, font: DISPLAY, weight: 800, shadow: 'rgba(255,140,60,.6)' });
      const hk = span(u, cue(9) - .2, cue(9) + .7);
      say(ctx, '罗伯特 · 胡克', P ? xx : W * .17, P ? yy + 90 * S : cy + 60 * S, { size: 36 * S, fill: '#ffe2c0', alpha: phB * hk });
      say(ctx, 'Robert Hooke', P ? xx : W * .17, P ? yy + 135 * S : cy + 105 * S, { size: 24 * S, fill: 'rgba(255,230,200,.8)', alpha: phB * hk, font: DISPLAY, weight: 500 });
      // 古典望远镜
      if (!P) { ctx.save(); ctx.globalAlpha = phB * hk; ctx.translate(W * .83, cy + 40 * S); ctx.rotate(-.5);
        ctx.fillStyle = gold(ctx, -160 * S, 0, 160 * S, 0); rr(ctx, -150 * S, -18 * S, 300 * S, 36 * S, 10 * S); ctx.fill(); rr(ctx, 150 * S, -24 * S, 30 * S, 48 * S, 6 * S); ctx.fill();
        ctx.strokeStyle = '#7a4a10'; ctx.lineWidth = 2 * S; for (let i = -100; i <= 100; i += 50) { ctx.beginPath(); ctx.moveTo(i * S, -18 * S); ctx.lineTo(i * S, 18 * S); ctx.stroke(); } ctx.restore();
        ctx.save(); ctx.globalAlpha = phB * hk; ctx.strokeStyle = '#c99a4a'; ctx.lineWidth = 6 * S; ctx.beginPath(); ctx.moveTo(W * .83, cy + 40 * S); ctx.lineTo(W * .8, cy + 230 * S); ctx.moveTo(W * .83, cy + 40 * S); ctx.lineTo(W * .87, cy + 230 * S); ctx.stroke(); ctx.restore(); }
    }
    const fk = span(u, cue(10) - .1, cue(10) + .8);
    if (fk > 0) { const y = P ? H * .1 + 30 * S : cy + R + 52 * S; card(ctx, W / 2 - 360 * S, y - 40 * S, 720 * S, 80 * S, { alpha: phB * fk, r: 40 * S }); say(ctx, '人类最早用望远镜发现的双星之一', W / 2, y, { size: 32 * S, fill: '#ffe7b8', alpha: phB * fk }); }
  }
};

/* ============ 5 金羊毛 · 飞越大海 ============ */
function figureKid(ctx, x, y, s, a, girl, t) {
  if (a <= 0) return; ctx.save(); ctx.translate(x, y); ctx.scale(s, s); ctx.globalAlpha *= a; ctx.fillStyle = '#120608';
  ctx.beginPath(); ctx.arc(0, -46, 9, 0, PI2); ctx.fill();
  ctx.beginPath(); ctx.moveTo(-9, -36); ctx.lineTo(9, -36); ctx.lineTo(girl ? 16 : 10, 0); ctx.lineTo(girl ? -16 : -10, 0); ctx.closePath(); ctx.fill();
  if (girl) { ctx.beginPath(); ctx.moveTo(-6, -52); ctx.quadraticCurveTo(-22 + Math.sin(t * 3) * 3, -40, -16, -24); ctx.lineTo(-8, -40); ctx.fill(); }
  ctx.fillRect(-6, 0, 4, 14); ctx.fillRect(2, 0, 4, 14); ctx.restore();
}
ACTS.fleece1 = (ctx, u, W, H, X) => {
  const { S, P, cue } = X;
  const seaTop = H * (P ? .72 : .74);
  // 月亮
  bloom(ctx, W * .8, H * .2, 180 * S, [200, 220, 255], .5); ctx.fillStyle = '#eef3ff'; ctx.beginPath(); ctx.arc(W * .8, H * .2, 40 * S, 0, PI2); ctx.fill();
  // 标题卡
  const tk = life(u, .3, cue(2) - .1, .7, .5);
  if (tk > 0) { const y = P ? H * .3 : H * .36; plaque(ctx, W / 2, y, 720 * S, 210 * S, tk, S);
    say(ctx, '金羊毛传说', W / 2, y - 26 * S, { size: 72 * S, metal: true, alpha: tk, track: 10 * S, shadow: 'rgba(255,140,60,.5)' });
    say(ctx, 'THE GOLDEN FLEECE', W / 2, y + 52 * S, { size: 28 * S, fill: '#ffe2b0', alpha: tk, font: DISPLAY, weight: 600, track: 8 * S }); }
  // 悬崖 + 兄妹 + 暗云闪电
  const ck = life(u, cue(2) - .4, cue(5) + .4, .6, .6);
  const cliffX = P ? W * .18 : W * .14;
  if (ck > 0) {
    ctx.save(); ctx.globalAlpha = ck; ctx.fillStyle = '#0a0406'; ctx.beginPath(); ctx.moveTo(0, H); ctx.lineTo(0, seaTop - 160 * S); ctx.lineTo(cliffX * .6, seaTop - 175 * S); ctx.lineTo(cliffX + 40 * S, seaTop - 150 * S); ctx.lineTo(cliffX + 80 * S, seaTop + 20 * S); ctx.lineTo(cliffX + 60 * S, H); ctx.fill(); ctx.restore();
    figureKid(ctx, cliffX - 20 * S, seaTop - 165 * S, 2.4 * S, ck * (1 - span(u, cue(5) - .3, cue(5) + .3)), false, u);
    figureKid(ctx, cliffX + 40 * S, seaTop - 160 * S, 2.15 * S, ck * (1 - span(u, cue(5) - .3, cue(5) + .3)), true, u);
    const storm = life(u, cue(3) - .2, cue(4) + .6, .5, .6);
    if (storm > 0) {
      for (let i = 0; i < 7; i++) { const x = W * (i / 6), y = H * .12 + Math.sin(i * 2 + u * .5) * 20 * S; bloom(ctx, x, y, 260 * S, [60, 10, 20], storm * .9); }
      const fl = Math.max(0, Math.sin(u * 7.3) - .85) * 6.6 * storm;
      if (fl > 0) { ctx.save(); ctx.strokeStyle = rgba([255, 200, 220], fl); ctx.lineWidth = 3 * S; ctx.shadowColor = '#ffb0c8'; ctx.shadowBlur = 20 * S; ctx.beginPath(); let x = W * .45, y = 0; ctx.moveTo(x, y); for (let i = 0; i < 7; i++) { x += (hash(i + Math.floor(u * 7)) - .5) * 80 * S; y += 60 * S; ctx.lineTo(x, y); } ctx.stroke(); ctx.restore();
        ctx.fillStyle = rgba([255, 230, 240], fl * .12); ctx.fillRect(0, 0, W, H); }
      say(ctx, '继母的迫害', W / 2, P ? H * .2 : H * .2, { size: 40 * S, fill: '#ffb8b8', alpha: storm, shadow: 'rgba(0,0,0,.8)' });
    }
  }
  // 金羊从天而降 → 驮着兄妹飞越大海
  const desc = span(u, cue(4) - .2, cue(4) + 2.4);
  const fly = span(u, cue(5) - .2, X.dur + 4);
  if (desc > 0) {
    const x0 = cliffX + 60 * S, y0 = seaTop - 260 * S;
    let x = mix(W * .55, x0, E.out3(desc)), y = mix(-120 * S, y0, E.out3(desc));
    const fx = mix(x0, W * 1.15, fly), fy = mix(y0, H * (P ? .36 : .3), Math.sin(fly * Math.PI) * .9 + fly * .2);
    if (fly > 0) { x = fx; y = fy + Math.sin(u * 2) * 10 * S; }
    if (desc < 1) { // 光柱
      ctx.save(); ctx.globalCompositeOperation = 'lighter'; const g = ctx.createLinearGradient(x, 0, x, y); g.addColorStop(0, 'rgba(255,220,140,0)'); g.addColorStop(1, rgba([255, 220, 140], .35 * Math.sin(desc * Math.PI))); ctx.fillStyle = g; ctx.fillRect(x - 90 * S, 0, 180 * S, y); ctx.restore(); }
    // 金粉尾迹
    for (let i = 1; i < 28; i++) { const k = i / 28; const fq = Math.max(0, fly - k * .06), tx = fly > 0 ? mix(x0, W * 1.15, fq) : x, ty = fly > 0 ? mix(y0, H * (P ? .36 : .3), Math.sin(fq * Math.PI) * .9 + fq * .2) : y - k * 200 * S;
      bloom(ctx, tx - 40 * S, ty + 10 * S, 16 * S * (1 - k), GOLD, (1 - k) * .7); }
    bloom(ctx, x, y, 260 * S, [255, 190, 90], .55);
    const rs = (P ? 1.15 : 1.45) * S;
    ramSide(ctx, x, y, rs, { t: u, fly: 1, shine: 1.2 });
    // 背上的兄妹
    const onBack = fly > 0 ? 1 : span(desc, .9, 1);
    figureKid(ctx, x - 20 * rs, y - 52 * rs, .95 * rs, onBack, false, u);
    const fall = span(u, cue(7) - .1, cue(7) + 1.8);
    if (fall <= 0) figureKid(ctx, x - 55 * rs, y - 50 * rs, .9 * rs, onBack, true, u);
    else {
      const fx0 = x - 55 * rs, hx = fx0 - fall * 60 * S, hy = mix(y - 50 * rs, seaTop + 10 * S, E.in3(fall));
      if (fall < .98) { ctx.save(); ctx.translate(hx, hy); ctx.rotate(fall * 3); figureKid(ctx, 0, 0, .9 * rs, 1, true, u); ctx.restore(); }
      if (fall >= .98) { const k = (u - (cue(7) + 1.8)); for (let i = 0; i < 3; i++) { const kk = (k - i * .25) / 1.5; if (kk > 0 && kk < 1) { ctx.save(); ctx.strokeStyle = rgba([200, 235, 255], 1 - kk); ctx.lineWidth = 2 * S; ctx.beginPath(); ctx.ellipse(fx0 - 60 * S, seaTop + 10 * S, 120 * S * kk, 22 * S * kk, 0, 0, PI2); ctx.stroke(); ctx.restore(); } } }
    }
  }
  sea(ctx, u, W, H, seaTop, S, 1);
  // 赫勒斯滂
  const hk = span(u, cue(8) + .2, cue(8) + 1.2);
  if (hk > 0) { const y = P ? H * .86 : H * .86; say(ctx, '赫勒斯滂 Hellespont', W / 2, y - 30 * S - (P ? 40 * S : 0), { size: 50 * S, metal: true, alpha: hk, shadow: 'rgba(0,30,60,.9)' }); say(ctx, '意为“赫勒之海” · 今达达尼尔海峡', W / 2, y + 22 * S - (P ? 40 * S : 0), { size: 24 * S, fill: '#d8ecff', alpha: hk, font: SANS, weight: 600, shadow: 'rgba(0,0,0,.8)' }); }
};

/* ============ 6 金羊毛 · 升上星空 ============ */
ACTS.fleece2 = (ctx, u, W, H, X) => {
  const { S, P, cue } = X;
  // A:航线 希腊 → 科尔基斯
  const mk = life(u, .2, cue(1) + .2, .6, .6);
  if (mk > 0) {
    const ax = P ? W * .2 : W * .25, ay = P ? H * .52 : H * .58, bx = P ? W * .8 : W * .75, by = P ? H * .32 : H * .32;
    ctx.save(); ctx.globalAlpha = mk; ctx.strokeStyle = '#ffd98f'; ctx.lineWidth = 3 * S; ctx.setLineDash([12 * S, 10 * S]);
    const k = E.sine(span(u, .4, cue(0) + 2.6)); ctx.beginPath(); for (let i = 0; i <= 60 * k; i++) { const q = i / 60; const x = mix(ax, bx, q), y = mix(ay, by, q) - Math.sin(q * Math.PI) * 140 * S; i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); } ctx.stroke(); ctx.restore();
    for (const [x, y, n, sub] of [[ax, ay, '希腊', 'Greece'], [bx, by, '科尔基斯', 'Colchis · 黑海东岸']]) { star(ctx, x, y, 7 * S, GOLD, mk, .8); say(ctx, n, x, y + 48 * S, { size: 38 * S, fill: '#ffe6b8', alpha: mk }); say(ctx, sub, x, y + 86 * S, { size: 20 * S, fill: 'rgba(255,240,220,.75)', alpha: mk, font: SANS, weight: 400 }); }
    const q = k; const rx = mix(ax, bx, q), ry = mix(ay, by, q) - Math.sin(q * Math.PI) * 140 * S;
    ramSide(ctx, rx, ry - 30 * S, .55 * S, { t: u, fly: 1, alpha: mk });
  }
  // B:圣林 · 金羊毛 · 巨龙 · 阿尔戈号
  const gk = life(u, cue(1) - .3, cue(4) + .3, .7, .7);
  if (gk > 0) {
    const tx = P ? W * .5 : W * .36, gy = H * (P ? .7 : .8);
    // 地面与圣树
    ctx.save(); ctx.globalAlpha = gk; ctx.fillStyle = '#071209'; ctx.fillRect(0, gy, W, H - gy);
    ctx.strokeStyle = '#2a1a0c'; ctx.lineCap = 'round';
    const branch = (x, y, ang, len, w, d) => { if (d > 6) return; const x2 = x + Math.cos(ang) * len, y2 = y + Math.sin(ang) * len; ctx.lineWidth = w; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x2, y2); ctx.stroke();
      if (d >= 4) { for (let j = 0; j < 3; j++) { ctx.fillStyle = rgba([30 + j * 18, 80 + j * 25, 45 + j * 10], .75); ctx.beginPath(); ctx.arc(x2 + (hash(x2 + j) - .5) * 30 * S, y2 - j * 8 * S, (30 - j * 6) * S, 0, PI2); ctx.fill(); } }
      branch(x2, y2, ang - .45 + hash(d * 9 + x) * .2, len * .74, w * .7, d + 1); branch(x2, y2, ang + .4 - hash(d * 5 + y) * .2, len * .72, w * .7, d + 1); };
    branch(tx, gy, -Math.PI / 2, 190 * S, 32 * S, 0); ctx.restore();
    // 金羊毛
    const fx = tx + 80 * S, fy = gy - 300 * S, fk = span(u, cue(1), cue(1) + 1) * gk;
    bloom(ctx, fx, fy, 260 * S, [255, 200, 90], fk * (.8 + .2 * Math.sin(u * 3)));
    ctx.save(); ctx.globalAlpha = fk; ctx.translate(fx, fy); ctx.rotate(Math.sin(u * 1.2) * .05);
    const fl = new Path2D(); fl.moveTo(-60 * S, 0); fl.bezierCurveTo(-80 * S, 60 * S, -40 * S, 130 * S, 0, 120 * S); fl.bezierCurveTo(40 * S, 130 * S, 80 * S, 60 * S, 60 * S, 0); fl.bezierCurveTo(30 * S, -10 * S, -30 * S, -10 * S, -60 * S, 0);
    ctx.fillStyle = gold(ctx, -80 * S, 0, 80 * S, 130 * S); ctx.shadowColor = '#ffcc66'; ctx.shadowBlur = 40 * S; ctx.fill(fl); ctx.shadowBlur = 0;
    ctx.clip(fl); ctx.strokeStyle = 'rgba(120,70,10,.5)'; ctx.lineWidth = 2 * S; for (let i = 0; i < 18; i++) { ctx.beginPath(); ctx.arc((-50 + (i * 31) % 100) * S, (15 + Math.floor(i / 3.3) * 22) * S, 10 * S, .3, 4.8); ctx.stroke(); }
    ctx.restore();
    say(ctx, '金羊毛', fx + 150 * S, fy + 40 * S, { size: 40 * S, metal: true, alpha: fk, shadow: 'rgba(0,0,0,.6)' });
    // 巨龙缠树
    const dk = span(u, cue(2) - .2, cue(2) + 1.6) * gk;
    if (dk > 0) {
      const pts = []; for (let i = 0; i <= 80; i++) { const q = i / 80; const ang = q * PI2 * 2.2 + u * .3; pts.push([tx + Math.cos(ang) * (70 - q * 20) * S, gy - 20 * S - q * 230 * S + Math.sin(ang) * 18 * S, Math.sin(ang)]); }
      const n = Math.floor(pts.length * dk);
      for (let i = 1; i < n; i++) { const [x1, y1, z] = pts[i - 1], [x2, y2] = pts[i]; ctx.strokeStyle = z > 0 ? '#2f7a3c' : '#1a4524'; ctx.lineWidth = (30 - i * .22) * S; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
        if (i % 4 === 0) { ctx.fillStyle = 'rgba(160,230,140,.35)'; ctx.beginPath(); ctx.arc(x2, y2 - 6 * S, 4 * S, 0, PI2); ctx.fill(); } }
      if (n > 2) { const [hx, hy] = pts[n - 1]; ctx.fillStyle = '#2f7a3c'; ctx.beginPath(); ctx.ellipse(hx + 20 * S, hy - 6 * S, 34 * S, 18 * S, -.2, 0, PI2); ctx.fill(); bloom(ctx, hx + 32 * S, hy - 12 * S, 20 * S, [255, 210, 60], .9 * (.7 + .3 * Math.sin(u * 4)));
        ctx.fillStyle = '#ffe680'; ctx.beginPath(); ctx.arc(hx + 32 * S, hy - 12 * S, 3.5 * S, 0, PI2); ctx.fill(); }
      say(ctx, '永不入睡的巨龙', P ? W / 2 : tx - 280 * S, P ? gy + 80 * S : gy - 120 * S, { size: 40 * S, fill: '#bff0c0', alpha: dk, shadow: 'rgba(0,0,0,.8)' });
    }
    // 阿尔戈号
    const ak = span(u, cue(3) - .2, cue(3) + 1) * gk;
    if (ak > 0) {
      const sx = mix(W * 1.1, P ? W * .62 : W * .72, E.out3(span(u, cue(3) - .2, cue(3) + 3))), sy = (P ? H * .4 : H * .55) + Math.sin(u * 1.6) * 6 * S;
      ctx.save(); ctx.globalAlpha = ak; ctx.translate(sx, sy); const k = P ? .8 : 1; ctx.scale(k * S, k * S);
      ctx.fillStyle = '#3b1d0c'; ctx.beginPath(); ctx.moveTo(-170, 0); ctx.quadraticCurveTo(0, 60, 170, 0); ctx.lineTo(200, -40); ctx.lineTo(150, -10); ctx.lineTo(-150, -10); ctx.lineTo(-190, -50); ctx.closePath(); ctx.fill();
      ctx.strokeStyle = gold(ctx, -200, 0, 200, 0); ctx.lineWidth = 3; ctx.stroke();
      ctx.strokeStyle = '#2a140a'; ctx.lineWidth = 6; ctx.beginPath(); ctx.moveTo(0, -10); ctx.lineTo(0, -200); ctx.stroke();
      ctx.fillStyle = 'rgba(255,240,210,.92)'; ctx.beginPath(); ctx.moveTo(-90, -190); ctx.quadraticCurveTo(0, -150 + Math.sin(u * 2) * 6, 90, -190); ctx.lineTo(80, -40); ctx.quadraticCurveTo(0, -60, -80, -40); ctx.closePath(); ctx.fill();
      ctx.fillStyle = '#b3261e'; ctx.beginPath(); ctx.arc(0, -115, 26, 0, PI2); ctx.fill(); ctx.strokeStyle = '#ffd98f'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(0, -115, 18, 0, PI2); ctx.stroke();
      ctx.strokeStyle = '#2a140a'; ctx.lineWidth = 3; for (let i = -5; i <= 5; i++) { const sw = Math.sin(u * 3 + i) * 10; ctx.beginPath(); ctx.moveTo(i * 26, 10); ctx.lineTo(i * 26 + sw - 12, 60); ctx.stroke(); }
      ctx.restore();
      say(ctx, '阿尔戈号 · 伊阿宋的远航', sx, sy + (P ? 90 : 110) * S, { size: 32 * S, fill: '#ffe6b8', alpha: ak, shadow: 'rgba(0,0,0,.85)' });
    }
  }
  // C:升上夜空 → 星座
  const rk = span(u, cue(4) - .4, cue(4) + .6);
  if (rk > 0) {
    const up = E.sine(span(u, cue(4), cue(6) + .2));
    const x = W / 2, y = mix(H * .78, H * (P ? .42 : .46), up), s = mix(.9, 1.6, up) * S * (P ? .9 : 1);
    const toStars = span(u, cue(6) - .3, cue(6) + 1.2);
    bloom(ctx, x, y, 420 * S * up, [255, 200, 110], .5 * rk);
    ramSide(ctx, x - 20 * s, y, s, { t: u, fly: 1, alpha: rk * (1 - toStars * .7) });
    const pts = [[-90, -30], [-40, -50], [20, -45], [70, -25], [115, -75], [135, -55], [-70, 30], [55, 28]];
    const ln = [[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [0, 6], [3, 7]];
    if (toStars > 0) {
      ctx.save(); ctx.globalAlpha = toStars; ctx.strokeStyle = 'rgba(255,220,150,.85)'; ctx.lineWidth = 2.4 * S;
      ln.forEach(([a, b], i) => { const kk = clamp01(toStars * ln.length - i); const p = pts[a], q = pts[b]; ctx.beginPath(); ctx.moveTo(x - 20 * s + p[0] * s, y + p[1] * s); ctx.lineTo(x - 20 * s + mix(p[0], q[0], kk) * s, y + mix(p[1], q[1], kk) * s); ctx.stroke(); });
      ctx.restore();
      pts.forEach(([px, py], i) => star(ctx, x - 20 * s + px * s, y + py * s, (i === 4 ? 5 : 3.2) * S, [255, 225, 160], toStars, i === 4 ? 1 : .4));
      say(ctx, '白羊座', x, y + 150 * S, { size: 64 * S, metal: true, alpha: toStars, track: 10 * S, shadow: 'rgba(255,120,40,.7)' });
    }
    if (up > 0 && up < 1) embers(ctx, u, W, H, { n: 40, seed: 11, color: GOLD, alpha: rk * .8, spread: .5 });
  }
};

/* ============ 7 ♈ 的秘密 ============ */
ACTS.glyph = (ctx, u, W, H, X) => {
  const { S, P, cue } = X;
  const cx = W / 2, cy = P ? H * .42 : H * .45;
  const merge = E.sine(span(u, cue(5) - .4, cue(5) + 1.4));
  const sep = (P ? 230 : 330) * S * (1 - merge);
  // 羊角(左)
  const hk = span(u, cue(1) - .3, cue(1) + 1.8) * (1 - merge);
  if (hk > 0) for (const d of [-1, 1]) {
    const h = hornPath(cx - sep + d * 95 * S, cy - 60 * S, 120 * S, 1.15, d, d > 0 ? -Math.PI * .95 : -Math.PI * .05, .45);
    ctx.save(); ctx.globalAlpha = span(hk, .4, 1); ctx.fillStyle = gold(ctx, cx - sep - 160 * S, cy - 140 * S, cx - sep + 160 * S, cy + 60 * S); ctx.shadowColor = 'rgba(255,160,60,.8)'; ctx.shadowBlur = 24 * S; ctx.fill(h.shape); ctx.restore();
    trace(ctx, h.spine, h.len * 1.9, hk, { width: 4 * S, color: '#ffe3a0', glow: 'rgba(255,180,80,.9)' });
  }
  // 嫩芽(右)
  const sk = span(u, cue(2) - .2, cue(2) + 2) * (1 - merge);
  if (sk > 0) {
    const bx = cx + sep, by = cy + 120 * S, g = E.out3(sk);
    ctx.save(); ctx.strokeStyle = '#7fdc85'; ctx.lineWidth = 9 * S; ctx.lineCap = 'round'; ctx.shadowColor = 'rgba(140,255,150,.7)'; ctx.shadowBlur = 18 * S;
    ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx, by - 150 * S * g); ctx.stroke();
    for (const d of [-1, 1]) { const k2 = span(sk, .4, 1); ctx.beginPath(); ctx.moveTo(bx, by - 150 * S * g); ctx.bezierCurveTo(bx, by - 220 * S * k2, bx + d * 90 * S * k2, by - 230 * S * k2, bx + d * 80 * S * k2, by - 160 * S * k2); ctx.stroke();
      ctx.fillStyle = 'rgba(140,230,140,.35)'; ctx.fill(); }
    ctx.restore();
    ctx.fillStyle = '#2a1408'; ctx.beginPath(); ctx.ellipse(bx, by + 8 * S, 120 * S * g, 18 * S, 0, 0, PI2); ctx.fill();
  }
  // 说明
  const k1 = life(u, cue(3) - .2, cue(5) + .2, .5, .5), k2 = life(u, cue(4) - .2, cue(5) + .2, .5, .5);
  const ly = P ? cy + 300 * S : cy + 260 * S;
  say(ctx, '羊角', cx - sep, ly, { size: 50 * S, metal: true, alpha: k1 }); say(ctx, '勇往直前', cx - sep, ly + 60 * S, { size: 32 * S, fill: '#ffd9a0', alpha: k1 });
  say(ctx, '嫩芽', cx + sep, ly, { size: 50 * S, fill: '#b9f5b6', alpha: k2, shadow: 'rgba(80,200,90,.6)' }); say(ctx, '新的开始', cx + sep, ly + 60 * S, { size: 32 * S, fill: '#d8ffd2', alpha: k2 });
  // 合体 ♈
  if (merge > 0) {
    bloom(ctx, cx, cy, 520 * S * merge, [255, 170, 80], .6 * merge);
    ariesGlyph(ctx, cx, cy - 10 * S, (P ? 1.7 : 1.6) * S * mix(.7, 1, merge), span(u, cue(5) - .1, cue(5) + 1.6), { width: 16 });
    // 春花飘落
    for (let i = 0; i < 40; i++) { const ph = (u * .12 + hash(i)) % 1; const x = W * hash(i + 5) + Math.sin(u + i) * 30 * S, y = -40 * S + ph * (H + 80 * S);
      ctx.save(); ctx.translate(x, y); ctx.rotate(u * (1 + hash(i)) + i); ctx.globalAlpha = merge * .8; ctx.fillStyle = i % 3 ? '#ffc1d0' : '#fff0b8'; ctx.beginPath(); ctx.ellipse(0, 0, 8 * S, 4.5 * S, 0, 0, PI2); ctx.fill(); ctx.restore(); }
    const tk = span(u, cue(6) - .3, cue(6) + .7);
    say(ctx, '春天的样子 · 白羊座的样子', cx, P ? cy + 330 * S : cy + 270 * S, { size: 44 * S, metal: true, alpha: tk, track: 4 * S, shadow: 'rgba(255,120,40,.6)' });
  }
};

/* ============ 8 火象 · 火星 ============ */
ACTS.fire = (ctx, u, W, H, X) => {
  const { S, P, cue } = X;
  embers(ctx, u, W, H, { n: 90, seed: 21, alpha: 1, spread: 1.3, rise: 1.2 });
  const top = P ? H * .2 : H * .28;
  // 火象
  const fk = span(u, cue(1) - .2, cue(1) + .8), mk = span(u, cue(2) - .2, cue(2) + .8);
  const fx = P ? W * .28 : W * .27, mx = P ? W * .72 : W * .73;
  const shrink = E.sine(span(u, cue(3) - .4, cue(3) + .6));
  const s1 = mix(1, .6, shrink), yy = mix(top + 60 * S, (P ? H * .12 : H * .16), shrink);
  if (fk > 0) { flame(ctx, fx, yy + 70 * S * s1, 1.05 * S * s1 * E.back(fk), u, fk); say(ctx, '火象星座', fx, yy + 130 * S * s1, { size: 44 * S * s1, metal: true, alpha: fk }); }
  if (mk > 0) {
    const r = 70 * S * s1 * E.back(mk); bloom(ctx, mx, yy, r * 3, [255, 80, 50], mk * .7);
    const g = ctx.createRadialGradient(mx - r * .3, yy - r * .3, r * .1, mx, yy, r); g.addColorStop(0, '#ffb08a'); g.addColorStop(.6, '#d2462a'); g.addColorStop(1, '#6e1a10');
    ctx.save(); ctx.globalAlpha = mk; ctx.fillStyle = g; ctx.beginPath(); ctx.arc(mx, yy, Math.max(1, r), 0, PI2); ctx.fill(); ctx.restore();
    marsGlyph(ctx, mx + r * 1.4, yy - r * .9, .8 * S * s1, mk);
    say(ctx, '守护星 · 火星', mx, yy + 130 * S * s1, { size: 44 * S * s1, metal: true, alpha: mk });
  }
  // 特质勋章
  const traits = [['热情', 'PASSION', cue(3) + .5], ['勇敢', 'COURAGE', cue(3) + 1.3], ['直率', 'HONESTY', cue(3) + 2.1], ['行动派', 'ACTION', cue(4)]];
  const cols = P ? 2 : 4, cw = P ? W * .4 : W * .19, chh = (P ? 210 : 220) * S;
  const by = P ? H * .44 : H * .56;
  const fade = (1 - span(u, cue(6) - .4, cue(6) + .4) * .55) * (1 - span(u, cue(8), cue(8) + .6));
  traits.forEach(([zh, en, t0], i) => {
    const k = E.back(span(u, t0, t0 + .6)); if (k <= 0) return;
    const col = i % cols, row = Math.floor(i / cols);
    const x = W / 2 + (col - (cols - 1) / 2) * (cw + 24 * S), y = by + row * (chh + 24 * S);
    ctx.save(); ctx.translate(x, y); ctx.scale(k, k);
    card(ctx, -cw / 2, -chh / 2, cw, chh, { alpha: fade, r: 24 * S, top: 'rgba(90,20,12,.8)', bottom: 'rgba(30,6,6,.8)' });
    ctx.restore();
    const a = clamp01(k) * fade;
    flame(ctx, x, y - 20 * S, .32 * S * k, u + i, a * .9);
    say(ctx, zh, x, y + 30 * S, { size: 46 * S * k, metal: true, alpha: a });
    say(ctx, en, x, y + 74 * S, { size: 18 * S * k, fill: '#ffcfaa', alpha: a, font: DISPLAY, weight: 600, track: 4 * S });
  });
  // 冲锋:行动派
  const ch = span(u, cue(5) - .2, cue(5) + 1.3);
  if (ch > 0 && ch < 1) {
    const x = mix(-300 * S, W + 300 * S, E.in3(ch) * .4 + ch * .6), y = P ? H * .74 : H * .86;
    for (let i = 0; i < 12; i++) { ctx.strokeStyle = rgba([255, 200, 120], .5 * (1 - ch)); ctx.lineWidth = 3 * S; ctx.beginPath(); const yy2 = y - 80 * S + i * 14 * S; ctx.moveTo(x - 200 * S - hash(i) * 300 * S, yy2); ctx.lineTo(x - 120 * S, yy2); ctx.stroke(); }
    ramSide(ctx, x, y - 40 * S, .9 * S, { t: u * 1.6, gait: 1 });
  }
  // 急性子的小乌云 → 立刻放晴
  const ck = life(u, cue(6) - .2, cue(8) + .2, .4, .4);
  if (ck > 0) {
    const x = W / 2, y = P ? H * .7 : H * .66;
    const clear = span(u, cue(7) + .3, cue(7) + 1.5);
    const cs = (1 - clear) * ck;
    for (const [dx, dy, r] of [[-50, 0, 46], [0, -20, 56], [50, 0, 46], [0, 16, 50]]) { ctx.fillStyle = rgba([90, 80, 100], .9 * cs); ctx.beginPath(); ctx.arc(x + dx * S, y + dy * S, r * S, 0, PI2); ctx.fill(); }
    if (cs > .2) { ctx.strokeStyle = rgba([255, 230, 120], cs); ctx.lineWidth = 4 * S; ctx.beginPath(); ctx.moveTo(x, y + 40 * S); ctx.lineTo(x - 12 * S, y + 70 * S); ctx.lineTo(x + 6 * S, y + 70 * S); ctx.lineTo(x - 6 * S, y + 100 * S); ctx.stroke(); }
    if (clear > 0) { bloom(ctx, x, y, 200 * S * clear, [255, 220, 120], ck * clear); for (let i = 0; i < 12; i++) { const a = i / 12 * PI2 + u * .4; ctx.strokeStyle = rgba([255, 220, 130], ck * clear); ctx.lineWidth = 4 * S; ctx.beginPath(); ctx.moveTo(x + Math.cos(a) * 60 * S, y + Math.sin(a) * 60 * S); ctx.lineTo(x + Math.cos(a) * 90 * S, y + Math.sin(a) * 90 * S); ctx.stroke(); }
      ctx.fillStyle = rgba([255, 214, 110], ck * clear); ctx.beginPath(); ctx.arc(x, y, 46 * S, 0, PI2); ctx.fill(); }
    say(ctx, clear > .5 ? '来得快 · 去得快 · 从不记仇' : '急性子?', x, y - (P ? 120 : 110) * S, { size: 36 * S, fill: '#fff1d6', alpha: ck, shadow: 'rgba(0,0,0,.7)' });
  }
  // 春雷
  const th = u - cue(8) - .2;
  if (th > 0 && th < 1.4) {
    const fl = Math.max(0, 1 - th * 2.2) + Math.max(0, .5 - Math.abs(th - .45) * 4) ; ctx.fillStyle = rgba([255, 245, 230], Math.min(.8, fl * .7)); ctx.fillRect(0, 0, W, H);
    ctx.save(); ctx.strokeStyle = rgba([255, 255, 255], Math.min(1, fl * 1.4)); ctx.shadowColor = '#ffe3a0'; ctx.shadowBlur = 40 * S; ctx.lineWidth = 7 * S; ctx.beginPath(); let x = W * .55, y = 0; ctx.moveTo(x, y);
    for (let i = 0; i < 9; i++) { x += (hash(i + 40) - .5) * 140 * S; y += H * .07; ctx.lineTo(x, y); } ctx.stroke(); ctx.restore();
  }
  const wk = span(u, cue(9) - .2, cue(9) + 1);
  if (wk > 0) { bloom(ctx, W / 2, H / 2, Math.max(W, H) * .6 * wk, [255, 200, 120], .4 * (1 - span(u, cue(9) + 1.5, cue(9) + 2.5)));
    say(ctx, '春天的第一声惊雷', W / 2, P ? H * .62 : H * .44, { size: 64 * S, metal: true, alpha: wk * (1 - span(u, X.dur - .8, X.dur)), shadow: 'rgba(255,120,40,.8)', track: 6 * S }); }
};

/* ============ 9 荣耀铭牌 ============ */
ACTS.badge = (ctx, u, W, H, X) => {
  const { S, P, cue } = X;
  // 聚光
  const sp = span(u, 0, 1.2);
  const g = ctx.createRadialGradient(W / 2, H * .45, 0, W / 2, H * .45, Math.max(W, H) * .7); g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, rgba([0, 0, 0], .6 * sp)); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
  // 光柱
  ctx.save(); ctx.globalCompositeOperation = 'lighter'; const lg = ctx.createLinearGradient(W / 2, 0, W / 2, H * .6); lg.addColorStop(0, rgba([255, 210, 140], .22 * sp)); lg.addColorStop(1, 'rgba(255,210,140,0)'); ctx.fillStyle = lg;
  ctx.beginPath(); ctx.moveTo(W / 2 - 60 * S, 0); ctx.lineTo(W / 2 + 60 * S, 0); ctx.lineTo(W / 2 + 420 * S, H * .75); ctx.lineTo(W / 2 - 420 * S, H * .75); ctx.fill(); ctx.restore();
  const forge = span(u, cue(0) - .4, cue(1) + .4);
  const size = P ? Math.min(W * .92, H * .58) : H * .7;
  const cx = W / 2, cy = P ? H * .43 : H * .45;
  drawBadge(ctx, cx, cy, size, {
    t: u, forge, engrave: span(u, cue(2) - .2, cue(3) + .8), ribbon: span(u, cue(4) - .3, cue(4) + 1), name: span(u, cue(5) - .3, cue(5) + 1),
    shine: (u - cue(4)) / 2.2, holder: X.opts.name, birthday: X.opts.birthday,
  });
  // 礼花
  burst(ctx, u, cue(1) + .2, cx, cy, S, { n: 90, color: GOLD, speed: 900, dur: 2.2, seed: 1 });
  burst(ctx, u, cue(4) + .4, cx - size * .45, cy - size * .3, S, { n: 60, color: [255, 150, 90], speed: 600, dur: 2, seed: 2 });
  burst(ctx, u, cue(4) + .7, cx + size * .45, cy - size * .3, S, { n: 60, color: [255, 220, 140], speed: 600, dur: 2, seed: 3 });
  burst(ctx, u, cue(5) + 1.4, cx, cy - size * .2, S, { n: 120, color: GOLD, speed: 1100, dur: 2.6, seed: 4 });
  // 金色纸屑
  const cf = span(u, cue(4), cue(4) + 1);
  if (cf > 0) for (let i = 0; i < 70; i++) { const ph = ((u - cue(4)) * (.18 + hash(i) * .12) + hash(i + 3)) % 1; const x = W * hash(i + 9) + Math.sin(u * 2 + i) * 30 * S, y = -30 * S + ph * (H + 60 * S);
    ctx.save(); ctx.translate(x, y); ctx.rotate(u * 3 + i); ctx.scale(1, Math.sin(u * 5 + i)); ctx.globalAlpha = cf * .9; ctx.fillStyle = i % 3 === 0 ? '#ff6a4a' : i % 3 === 1 ? '#ffd98f' : '#fff4d6'; ctx.fillRect(-5 * S, -3 * S, 10 * S, 6 * S); ctx.restore(); }
};

/* ============ 10 尾声 · 出发 ============ */
ACTS.onward = (ctx, u, W, H, X) => {
  const { S, P, cue, dur } = X;
  const hy = H * (P ? .76 : .78);
  const rise = E.sine(span(u, 0, cue(1) + 2));
  bloom(ctx, W * .5, hy, 800 * S * rise, [255, 170, 80], .8 * rise);
  const g = ctx.createLinearGradient(0, hy - 300 * S, 0, hy); g.addColorStop(0, 'rgba(255,150,70,0)'); g.addColorStop(1, rgba([255, 190, 110], .45 * rise)); ctx.fillStyle = g; ctx.fillRect(0, hy - 300 * S, W, 300 * S);
  ctx.fillStyle = '#0a0304'; ctx.beginPath(); ctx.moveTo(0, H); for (let x = 0; x <= W; x += 16) ctx.lineTo(x, hy + Math.sin(x / W * 5 + 2) * 18 * S); ctx.lineTo(W, H); ctx.fill();
  // 天上的星座
  const ck = span(u, cue(1) - .2, cue(2) + .5);
  if (ck > 0) {
    const ppd = (P ? W * .62 : W * .3) / 14;
    const Pj = chart(W, H, 2.35, 23, ppd, P ? 0 : W * .25, P ? -H * .3 : -H * .2);
    figure(ctx, ARI, ARI_LINES, Pj, ck, rgba([255, 220, 150], .85), 3 * S, ck, [255, 225, 160], S);
  }
  // 奔跑的公羊(剪影 + 金边)
  const rx = mix(-200 * S, W * (P ? .5 : .42), E.out3(span(u, .3, cue(2) + 1))), ry = hy - 70 * S;
  ramSide(ctx, rx, ry - 20 * S, (P ? 1.1 : 1.3) * S, { t: u * 1.4, gait: 1 - span(u, cue(2) + .6, cue(2) + 1.6) * .9, alpha: 1 - span(u, cue(4), cue(4) + 1.2) });
  const wave = span(u, cue(2) + .2, cue(3));
  if (wave > 0 && wave < 1) { bloom(ctx, rx + 110 * S, ry - 80 * S, 60 * S, GOLD, Math.sin(wave * Math.PI)); ring(ctx, rx + 110 * S, ry - 80 * S, 80 * S * wave, 1 - wave, GOLD, 3 * S); }
  // 结尾标题
  const tk = span(u, cue(4) - .3, cue(4) + 1.2);
  const ty = P ? H * .44 : H * .44;
  if (tk > 0) {
    ariesGlyph(ctx, W / 2, ty - 150 * S, .7 * S, span(u, cue(4) - .3, cue(4) + 1.4));
    drop(ctx, '白羊座', W / 2, ty, tk, { size: 120 * S, metal: true, track: 24 * S, weight: 900, shadow: 'rgba(255,120,40,.7)', blur: 36 * S });
    say(ctx, '春天,从这里出发', W / 2, ty + 100 * S, { size: 44 * S, fill: '#fff0d4', alpha: span(u, cue(4) + .8, cue(4) + 1.8), track: 8 * S, shadow: 'rgba(0,0,0,.7)' });
    say(ctx, '愿你的每一次出发,都被星光照亮', W / 2, ty + 170 * S, { size: 28 * S, fill: '#ffd9a0', alpha: span(u, cue(5) + .3, cue(5) + 1.3), font: SANS, weight: 600, track: 3 * S });
  }
  embers(ctx, u, W, H, { n: 50, seed: 31, color: GOLD, alpha: .8 });
  const fo = span(u, dur - 1.5, dur); if (fo > 0) { ctx.fillStyle = rgba([4, 1, 2], fo); ctx.fillRect(0, 0, W, H); }
};
export { ARI, ARI_LINES, chart, figure };