@mumulinya167/ccm-pet-assets 2.0.0 → 2.0.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 (141) hide show
  1. package/assets/calico/calico-idle-follow.svg +324 -324
  2. package/assets/calico.svg +324 -324
  3. package/assets/clawd/LICENSE +22 -22
  4. package/assets/clawd/clawd-about-hero.svg +202 -202
  5. package/assets/clawd/clawd-collapse-sleep.svg +247 -247
  6. package/assets/clawd/clawd-error.svg +94 -94
  7. package/assets/clawd/clawd-happy.svg +160 -160
  8. package/assets/clawd/clawd-headphones-groove.svg +194 -194
  9. package/assets/clawd/clawd-idle-bubble.svg +180 -180
  10. package/assets/clawd/clawd-idle-collapse.svg +101 -101
  11. package/assets/clawd/clawd-idle-doze.svg +72 -72
  12. package/assets/clawd/clawd-idle-follow.svg +64 -64
  13. package/assets/clawd/clawd-idle-living.svg +195 -195
  14. package/assets/clawd/clawd-idle-look.svg +115 -115
  15. package/assets/clawd/clawd-idle-reading.svg +252 -252
  16. package/assets/clawd/clawd-idle-yawn.svg +158 -158
  17. package/assets/clawd/clawd-mini-alert.svg +129 -129
  18. package/assets/clawd/clawd-mini-crabwalk.svg +76 -76
  19. package/assets/clawd/clawd-mini-enter-sleep.svg +64 -64
  20. package/assets/clawd/clawd-mini-enter.svg +114 -114
  21. package/assets/clawd/clawd-mini-happy.svg +124 -124
  22. package/assets/clawd/clawd-mini-idle.svg +83 -83
  23. package/assets/clawd/clawd-mini-peek.svg +82 -82
  24. package/assets/clawd/clawd-mini-sleep.svg +112 -112
  25. package/assets/clawd/clawd-mini-typing.svg +153 -153
  26. package/assets/clawd/clawd-notification.svg +438 -438
  27. package/assets/clawd/clawd-react-annoyed.svg +167 -167
  28. package/assets/clawd/clawd-react-double-jump.svg +229 -229
  29. package/assets/clawd/clawd-react-double.svg +108 -108
  30. package/assets/clawd/clawd-react-drag.svg +102 -102
  31. package/assets/clawd/clawd-react-left.svg +102 -102
  32. package/assets/clawd/clawd-react-right.svg +102 -102
  33. package/assets/clawd/clawd-sleeping.svg +117 -117
  34. package/assets/clawd/clawd-static-base.svg +20 -20
  35. package/assets/clawd/clawd-wake.svg +277 -277
  36. package/assets/clawd/clawd-working-building.svg +329 -329
  37. package/assets/clawd/clawd-working-carrying.svg +178 -178
  38. package/assets/clawd/clawd-working-debugger.svg +245 -245
  39. package/assets/clawd/clawd-working-juggling.svg +183 -183
  40. package/assets/clawd/clawd-working-sweeping.svg +248 -248
  41. package/assets/clawd/clawd-working-thinking.svg +501 -501
  42. package/assets/clawd/clawd-working-typing-boss.svg +307 -307
  43. package/assets/clawd/clawd-working-typing.svg +273 -273
  44. package/assets/clawd/clawd-working-ultrathink.svg +166 -166
  45. package/assets/clawd/clawd-working-wizard.svg +356 -356
  46. package/assets/clawd.svg +64 -64
  47. package/assets/cloudling/cloudling/cloudling-attention.svg +379 -379
  48. package/assets/cloudling/cloudling/cloudling-building.svg +667 -667
  49. package/assets/cloudling/cloudling/cloudling-carrying.svg +604 -604
  50. package/assets/cloudling/cloudling/cloudling-conducting.svg +616 -616
  51. package/assets/cloudling/cloudling/cloudling-dozing-to-sleeping.svg +578 -578
  52. package/assets/cloudling/cloudling/cloudling-dozing.svg +358 -358
  53. package/assets/cloudling/cloudling/cloudling-error.svg +428 -428
  54. package/assets/cloudling/cloudling/cloudling-idle-reading.svg +658 -658
  55. package/assets/cloudling/cloudling/cloudling-idle-to-dozing.svg +568 -568
  56. package/assets/cloudling/cloudling/cloudling-idle-to-sleeping.svg +811 -811
  57. package/assets/cloudling/cloudling/cloudling-idle.svg +255 -255
  58. package/assets/cloudling/cloudling/cloudling-juggling.svg +648 -648
  59. package/assets/cloudling/cloudling/cloudling-mini-alert.svg +389 -389
  60. package/assets/cloudling/cloudling/cloudling-mini-crabwalk.svg +335 -335
  61. package/assets/cloudling/cloudling/cloudling-mini-enter-roll-in.svg +225 -225
  62. package/assets/cloudling/cloudling/cloudling-mini-enter-sleep.svg +231 -231
  63. package/assets/cloudling/cloudling/cloudling-mini-happy.svg +370 -370
  64. package/assets/cloudling/cloudling/cloudling-mini-idle.svg +279 -279
  65. package/assets/cloudling/cloudling/cloudling-mini-peek.svg +327 -327
  66. package/assets/cloudling/cloudling/cloudling-mini-sleep.svg +260 -260
  67. package/assets/cloudling/cloudling/cloudling-mini-typing.svg +691 -691
  68. package/assets/cloudling/cloudling/cloudling-notification.svg +493 -493
  69. package/assets/cloudling/cloudling/cloudling-react-drag.svg +83 -83
  70. package/assets/cloudling/cloudling/cloudling-sleeping-to-idle.svg +555 -555
  71. package/assets/cloudling/cloudling/cloudling-sleeping.svg +500 -500
  72. package/assets/cloudling/cloudling/cloudling-sweeping.svg +292 -292
  73. package/assets/cloudling/cloudling/cloudling-thinking.svg +719 -719
  74. package/assets/cloudling/cloudling/cloudling-typing.svg +354 -354
  75. package/assets/cloudling/cloudling-attention.svg +379 -379
  76. package/assets/cloudling/cloudling-building.svg +667 -667
  77. package/assets/cloudling/cloudling-carrying.svg +604 -604
  78. package/assets/cloudling/cloudling-conducting.svg +616 -616
  79. package/assets/cloudling/cloudling-dozing-to-sleeping.svg +578 -578
  80. package/assets/cloudling/cloudling-dozing.svg +358 -358
  81. package/assets/cloudling/cloudling-error.svg +428 -428
  82. package/assets/cloudling/cloudling-idle-reading.svg +658 -658
  83. package/assets/cloudling/cloudling-idle-to-dozing.svg +568 -568
  84. package/assets/cloudling/cloudling-idle-to-sleeping.svg +811 -811
  85. package/assets/cloudling/cloudling-idle.svg +255 -255
  86. package/assets/cloudling/cloudling-juggling.svg +648 -648
  87. package/assets/cloudling/cloudling-mini-alert.svg +389 -389
  88. package/assets/cloudling/cloudling-mini-crabwalk.svg +335 -335
  89. package/assets/cloudling/cloudling-mini-enter-roll-in.svg +225 -225
  90. package/assets/cloudling/cloudling-mini-enter-sleep.svg +231 -231
  91. package/assets/cloudling/cloudling-mini-happy.svg +370 -370
  92. package/assets/cloudling/cloudling-mini-idle.svg +279 -279
  93. package/assets/cloudling/cloudling-mini-peek.svg +327 -327
  94. package/assets/cloudling/cloudling-mini-sleep.svg +260 -260
  95. package/assets/cloudling/cloudling-mini-typing.svg +691 -691
  96. package/assets/cloudling/cloudling-notification.svg +493 -493
  97. package/assets/cloudling/cloudling-react-drag.svg +83 -83
  98. package/assets/cloudling/cloudling-sleeping-to-idle.svg +555 -555
  99. package/assets/cloudling/cloudling-sleeping.svg +500 -500
  100. package/assets/cloudling/cloudling-sweeping.svg +292 -292
  101. package/assets/cloudling/cloudling-thinking.svg +719 -719
  102. package/assets/cloudling/cloudling-typing.svg +354 -354
  103. package/assets/cloudling.svg +255 -255
  104. package/assets/ghost-attention.svg +19 -19
  105. package/assets/ghost-carrying.svg +19 -19
  106. package/assets/ghost-collapsing.svg +19 -19
  107. package/assets/ghost-dozing.svg +23 -23
  108. package/assets/ghost-error.svg +19 -19
  109. package/assets/ghost-happy.svg +23 -23
  110. package/assets/ghost-idle.svg +11 -11
  111. package/assets/ghost-juggling.svg +19 -19
  112. package/assets/ghost-notification.svg +19 -19
  113. package/assets/ghost-react-double.svg +23 -23
  114. package/assets/ghost-react-drag.svg +23 -23
  115. package/assets/ghost-react-left.svg +19 -19
  116. package/assets/ghost-react-right.svg +19 -19
  117. package/assets/ghost-sleeping.svg +19 -19
  118. package/assets/ghost-sweeping.svg +19 -19
  119. package/assets/ghost-waking.svg +19 -19
  120. package/assets/ghost-yawning.svg +23 -23
  121. package/assets/ghost.svg +11 -11
  122. package/assets/robot-attention.svg +19 -19
  123. package/assets/robot-carrying.svg +19 -19
  124. package/assets/robot-collapsing.svg +19 -19
  125. package/assets/robot-dozing.svg +23 -23
  126. package/assets/robot-error.svg +19 -19
  127. package/assets/robot-happy.svg +23 -23
  128. package/assets/robot-idle.svg +11 -11
  129. package/assets/robot-juggling.svg +19 -19
  130. package/assets/robot-notification.svg +19 -19
  131. package/assets/robot-react-double.svg +23 -23
  132. package/assets/robot-react-drag.svg +23 -23
  133. package/assets/robot-react-left.svg +19 -19
  134. package/assets/robot-react-right.svg +19 -19
  135. package/assets/robot-sleeping.svg +19 -19
  136. package/assets/robot-sweeping.svg +19 -19
  137. package/assets/robot-waking.svg +19 -19
  138. package/assets/robot-yawning.svg +23 -23
  139. package/assets/robot.svg +11 -11
  140. package/manifest.json +280 -280
  141. package/package.json +1 -1
@@ -1,811 +1,811 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <svg id="stage" xmlns="http://www.w3.org/2000/svg" width="880" height="720" viewBox="-32 -24 88 72" preserveAspectRatio="xMidYMid meet">
3
- <g id="__cloudling-export-shims" opacity="0" pointer-events="none" style="display:none">
4
- <g id="scrubber"></g>
5
- <g id="t-display"></g>
6
- </g>
7
- <script type="application/ecmascript"><![CDATA[
8
- const CLOUD_D = "M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388z";
9
-
10
- const SOFT_CLOUD_POINTS = [
11
- {x:9.064,y:3.344},{x:9.825,y:3.106},{x:10.616,y:3.004},{x:11.413,y:3.040},
12
- {x:12.192,y:3.210},{x:12.928,y:3.518},{x:13.599,y:3.948},{x:14.255,y:4.291},
13
- {x:15.046,y:4.188},{x:15.843,y:4.224},{x:16.621,y:4.398},{x:17.357,y:4.706},
14
- {x:18.029,y:5.134},{x:18.617,y:5.673},{x:19.103,y:6.306},{x:19.471,y:7.013},
15
- {x:19.715,y:7.772},{x:19.812,y:8.564},{x:19.784,y:9.361},{x:19.797,y:10.105},
16
- {x:20.287,y:10.735},{x:20.656,y:11.442},{x:20.897,y:12.202},{x:20.995,y:12.994},
17
- {x:20.964,y:13.791},{x:20.791,y:14.569},{x:20.480,y:15.304},{x:20.052,y:15.977},
18
- {x:19.516,y:16.567},{x:18.882,y:17.052},{x:18.174,y:17.418},{x:17.539,y:17.808},
19
- {x:17.231,y:18.545},{x:16.805,y:19.218},{x:16.264,y:19.805},{x:15.633,y:20.292},
20
- {x:14.925,y:20.660},{x:14.163,y:20.895},{x:13.372,y:20.996},{x:12.575,y:20.964},
21
- {x:11.796,y:20.793},{x:11.061,y:20.483},{x:10.392,y:20.049},{x:9.736,y:19.714},
22
- {x:8.946,y:19.811},{x:8.148,y:19.777},{x:7.370,y:19.606},{x:6.634,y:19.299},
23
- {x:5.962,y:18.868},{x:5.377,y:18.326},{x:4.885,y:17.698},{x:4.522,y:16.989},
24
- {x:4.283,y:16.228},{x:4.182,y:15.437},{x:4.220,y:14.640},{x:4.179,y:13.907},
25
- {x:3.696,y:13.273},{x:3.332,y:12.563},{x:3.092,y:11.803},{x:3.003,y:11.010},
26
- {x:3.038,y:10.213},{x:3.202,y:9.433},{x:3.514,y:8.699},{x:3.944,y:8.027},
27
- {x:4.484,y:7.440},{x:5.114,y:6.951},{x:5.822,y:6.586},{x:6.445,y:6.191},
28
- {x:6.752,y:5.455},{x:7.185,y:4.785},{x:7.723,y:4.197},{x:8.356,y:3.711},
29
- ];
30
-
31
- function catmullRomClosedPath(points, tension = 1) {
32
- const q = (n) => n.toFixed(3);
33
- let d = `M ${q(points[0].x)} ${q(points[0].y)}`;
34
- const n = points.length;
35
- for (let i = 0; i < n; i++) {
36
- const p0 = points[(i - 1 + n) % n];
37
- const p1 = points[i];
38
- const p2 = points[(i + 1) % n];
39
- const p3 = points[(i + 2) % n];
40
- const c1x = p1.x + (p2.x - p0.x) * tension / 6;
41
- const c1y = p1.y + (p2.y - p0.y) * tension / 6;
42
- const c2x = p2.x - (p3.x - p1.x) * tension / 6;
43
- const c2y = p2.y - (p3.y - p1.y) * tension / 6;
44
- d += ` C ${q(c1x)} ${q(c1y)} ${q(c2x)} ${q(c2y)} ${q(p2.x)} ${q(p2.y)}`;
45
- }
46
- return `${d} Z`;
47
- }
48
-
49
- function makeSoftCloudPath(pressure) {
50
- const a = clamp01(pressure);
51
- if (a <= 0.0001) return CLOUD_D;
52
- const volume = Math.sin(a * Math.PI) * 0.18;
53
- const points = SOFT_CLOUD_POINTS.map((pt) => {
54
- const distX = Math.abs(pt.x - 12);
55
- const topWeight = Math.pow(clamp01((7.2 - pt.y) / 4.2), 1.15);
56
- const contactWeight = clamp01(1 - distX / 8.6);
57
- const shoulderWeight = clamp01((distX - 3.2) / 6.2) * clamp01((16.5 - pt.y) / 12.5);
58
- const lowerWeight = clamp01((pt.y - 16.2) / 4.8);
59
- const targetTopY = 4.58 + Math.pow(distX / 8.6, 2) * 0.34;
60
- const topDown = Math.max(0, targetTopY - pt.y) * topWeight * (0.35 + contactWeight * 0.65) * a;
61
- const sideOut = Math.sign(pt.x - 12 || 1) * (topWeight * 0.95 + shoulderWeight * 0.48) * a;
62
- const lowerSettle = lowerWeight * volume * 0.18;
63
- return {
64
- x: pt.x + sideOut,
65
- y: pt.y + topDown + lowerSettle
66
- };
67
- });
68
- return catmullRomClosedPath(points, lerp(0.96, 0.72, a));
69
- }
70
-
71
- const DEFAULTS = {
72
- // 时间线 (秒)
73
- T_LAMP_APPEAR: 0.55,
74
- T_NOTICE_BEGIN: 0.35, // notice 跟 lamp_appear 重叠 0.2s
75
- T_NOTICE_END: 0.95,
76
- T_CROUCH: 0.35,
77
- T_JUMP_UP: 0.34,
78
- T_CONTACT_LEAD: 0.28, // 压扁起点略早于视觉接触, 让 1.39s 已经有形变
79
- T_IMPACT_HOLD: 0.16, // 给撞击和压扁一个可读停顿
80
- T_FALL_BACK: 0.34,
81
- T_SATISFIED: 0.72,
82
- T_HAT_DROP: 0.72,
83
- T_MASK_SLIDE: 0.72,
84
- T_SETTLE: 0.55,
85
- T_LAMP_FADE_OUT: 0.35, // 灯渐隐时长, 起点 = satisfied 结束
86
-
87
- // notice 段眼睛朝灯方向偏移
88
- noticeEyeOffsetX: -1.6,
89
- noticeEyeOffsetY: -1.4,
90
- noticeBodyTilt: -6,
91
-
92
- // 跳跃路径 (云宝从中心跳到 capsule 下方)
93
- jumpTx: -8.7,
94
- jumpTy: -4.0, // 头顶进入灯底, 由接触形变表现压扁
95
- crouchScaleY: 0.88,
96
- jumpUpScaleY: 1.08,
97
- impactScaleY: 0.88,
98
-
99
- // 灯位置
100
- lampCx: 3,
101
- lampCy: -3,
102
- lampImpactDip: 0.90, // 撞击瞬间灯 dip 峰值
103
-
104
- // toggle knob X 偏移 (相对 capsule 中心, 跟原版 sleeping toggle 锁定值)
105
- toggleDayKnobOffsetX: -3,
106
- toggleNightKnobOffsetX: -0.2,
107
-
108
- // 满意眼 (happy 微笑弧 锁定值)
109
- smileH: 1.0,
110
- smileW: 3.5,
111
-
112
- // 帽子起点
113
- hatStartTx: 3,
114
- hatStartTy: -15,
115
- hatStartRot: -30,
116
- hatStartScale: 0.7,
117
-
118
- // 眼罩起点
119
- maskStartTx: 8,
120
- maskStartTy: -4.5,
121
- maskStartRot: 14,
122
-
123
- // sleeping 锁定终点
124
- sleepingBodyTilt: -6,
125
- sleepingHatTx: -2,
126
- sleepingHatTy: -0.4,
127
- sleepingHatRot: -10,
128
- sleepingHatScale: 0.98,
129
- sleepingMaskTx: 0,
130
- sleepingMaskTy: -0.6,
131
- sleepingMaskRot: -9,
132
- sleepingMaskScale: 1.0,
133
- };
134
-
135
- let params = { ...DEFAULTS };
136
- let paused = false;
137
- let scrubbing = false;
138
-
139
- /* =========================================================================
140
- * defs (复用 v1)
141
- * ========================================================================= */
142
- function commonDefs(p, cloudD = makeSoftCloudPath(0)) {
143
- return `
144
- <linearGradient id="${p}-body" x1="12" y1="2" x2="12" y2="22" gradientUnits="userSpaceOnUse">
145
- <stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="#e0f2fe"/><stop offset="1" stop-color="#bae6fd"/>
146
- </linearGradient>
147
- <linearGradient id="${p}-eye" x1="0" y1="3" x2="0" y2="21" gradientUnits="userSpaceOnUse">
148
- <stop offset="0" stop-color="#B1A7FF"/><stop offset="0.5" stop-color="#7A9DFF"/><stop offset="1" stop-color="#3941FF"/>
149
- </linearGradient>
150
- <radialGradient id="${p}-hi" cx="0.45" cy="0.25" r="0.55">
151
- <stop offset="0" stop-color="#fff" stop-opacity="0.9"/><stop offset="1" stop-color="#fff" stop-opacity="0"/>
152
- </radialGradient>
153
- <linearGradient id="${p}-hat" x1="11" y1="-3" x2="14" y2="6" gradientUnits="userSpaceOnUse">
154
- <stop offset="0" stop-color="#7AA0FF"/><stop offset="0.45" stop-color="#3941FF"/><stop offset="1" stop-color="#1F2BC2"/>
155
- </linearGradient>
156
- <linearGradient id="${p}-cuff" x1="12" y1="3" x2="12" y2="8" gradientUnits="userSpaceOnUse">
157
- <stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="#fafbff"/><stop offset="1" stop-color="#e8ecf6"/>
158
- </linearGradient>
159
- <linearGradient id="${p}-star-yellow" x1="0" y1="-2" x2="0" y2="2" gradientUnits="userSpaceOnUse">
160
- <stop offset="0" stop-color="#FFE9A8"/><stop offset="0.5" stop-color="#FFD360"/><stop offset="1" stop-color="#F5A623"/>
161
- </linearGradient>
162
- <filter id="${p}-star-glow" x="-80%" y="-80%" width="260%" height="260%">
163
- <feDropShadow dx="0" dy="0.4" stdDeviation="0.5" flood-color="#F5A623" flood-opacity="0.32"/>
164
- </filter>
165
- <linearGradient id="${p}-z-grad" x1="0" y1="0" x2="0" y2="1">
166
- <stop offset="0" stop-color="#B1A7FF"/><stop offset="0.5" stop-color="#7A9DFF"/><stop offset="1" stop-color="#3941FF"/>
167
- </linearGradient>
168
- <filter id="${p}-z-glow" x="-50%" y="-50%" width="200%" height="200%">
169
- <feDropShadow dx="0" dy="0.3" stdDeviation="0.35" flood-color="#7A9DFF" flood-opacity="0.45"/>
170
- </filter>
171
- <filter id="${p}-shadow" x="-50%" y="-50%" width="200%" height="200%">
172
- <feDropShadow class="cloud-drop-shadow" dx="0" dy="0.6" stdDeviation="0.8" flood-color="#3941FF" flood-opacity="0.18"/>
173
- </filter>
174
- <filter id="${p}-edge-blur" x="-40%" y="-40%" width="180%" height="180%">
175
- <feGaussianBlur stdDeviation="0.16"/>
176
- </filter>
177
- <filter id="${p}-soft" x="-30%" y="-30%" width="160%" height="160%">
178
- <feGaussianBlur stdDeviation="0.18"/>
179
- </filter>
180
- <linearGradient id="${p}-toggle-day" x1="0" y1="0" x2="0" y2="1">
181
- <stop offset="0" stop-color="#E5EDFF"/><stop offset="1" stop-color="#BFD6FF"/>
182
- </linearGradient>
183
- <linearGradient id="${p}-toggle-night" x1="0" y1="0" x2="0" y2="1">
184
- <stop offset="0" stop-color="#2A3554"/><stop offset="1" stop-color="#0F1428"/>
185
- </linearGradient>
186
- <clipPath id="${p}-clip"><path d="${cloudD}"/></clipPath>
187
- `;
188
- }
189
-
190
- function makeFluffyCuff(p, cx, cy, rx, ry, asym, bumps) {
191
- let parts = [];
192
- const circles = [];
193
- for (let i = 0; i < bumps; i++) {
194
- const ang = (i * 2 * Math.PI) / bumps - Math.PI / 2;
195
- const x = cx + Math.cos(ang) * rx;
196
- const y = cy + Math.sin(ang) * ry;
197
- const sizeY = -Math.sin(ang);
198
- const sizeBoost = 0.18 * (sizeY + 1) / 2 + 0.10;
199
- const jitter = ((i * 13) % 7) / 35;
200
- let r = 0.62 + sizeBoost + jitter;
201
- if (asym !== 0) r *= 1 + asym * Math.cos(ang) * 0.28;
202
- circles.push({ x, y, r });
203
- }
204
- const innerRy = ry + 0.40;
205
- parts.push(`<g filter="url(#${p}-soft)">`);
206
- parts.push(`<ellipse cx="${cx}" cy="${cy}" rx="${(rx+0.30).toFixed(2)}" ry="${(innerRy+0.30).toFixed(2)}" fill="#7A9DFF" opacity="0.50"/>`);
207
- circles.forEach(c => parts.push(`<circle cx="${c.x.toFixed(2)}" cy="${c.y.toFixed(2)}" r="${(c.r+0.30).toFixed(2)}" fill="#7A9DFF" opacity="0.50"/>`));
208
- parts.push(`</g>`);
209
- parts.push(`<ellipse cx="${cx}" cy="${cy}" rx="${(rx+0.15).toFixed(2)}" ry="${(innerRy+0.15).toFixed(2)}" fill="#aab3c6" opacity="0.65"/>`);
210
- circles.forEach(c => parts.push(`<circle cx="${c.x.toFixed(2)}" cy="${c.y.toFixed(2)}" r="${(c.r+0.15).toFixed(2)}" fill="#aab3c6" opacity="0.65"/>`));
211
- parts.push(`<ellipse cx="${cx}" cy="${cy}" rx="${rx.toFixed(2)}" ry="${innerRy.toFixed(2)}" fill="url(#${p}-cuff)"/>`);
212
- circles.forEach(c => parts.push(`<circle cx="${c.x.toFixed(2)}" cy="${c.y.toFixed(2)}" r="${c.r.toFixed(2)}" fill="url(#${p}-cuff)"/>`));
213
- parts.push(`<ellipse cx="${cx}" cy="${(cy-ry*0.45).toFixed(2)}" rx="${(rx*0.78).toFixed(2)}" ry="${(ry*0.32).toFixed(2)}" fill="none" stroke="#fff" stroke-opacity="0.55" stroke-width="0.14"/>`);
214
- return parts.join('');
215
- }
216
-
217
- function makeBentHat(bend) {
218
- const elbowX = 12 + bend * 2.2;
219
- const elbowY = -0.5 + bend * 2.3;
220
- const tipX = 11 - bend * 5.5;
221
- const tipY = 0.0 + bend * 0.5;
222
- const tipBottomX = tipX + 1.2 + bend * 0.4;
223
- const tipBottomY = tipY + 1.4 + bend * 0.3;
224
- return {
225
- path: `M 8.5 5.0 Q 12 5.55 15.5 5.0 C 16.0 3.5 ${(elbowX+0.8).toFixed(2)} ${(elbowY+1.0).toFixed(2)} ${elbowX.toFixed(2)} ${elbowY.toFixed(2)} C ${(elbowX-0.5).toFixed(2)} ${(elbowY-0.8).toFixed(2)} ${(tipX+1.5).toFixed(2)} ${(tipY-0.5).toFixed(2)} ${tipX.toFixed(2)} ${tipY.toFixed(2)} C ${(tipX-0.3).toFixed(2)} ${(tipY+0.6).toFixed(2)} ${(tipBottomX-1.0).toFixed(2)} ${tipBottomY.toFixed(2)} ${tipBottomX.toFixed(2)} ${tipBottomY.toFixed(2)} C 8.5 4.0 8.0 4.5 8.5 5.0 Z`
226
- };
227
- }
228
-
229
- function makeStar(cx, cy, r, rotationDeg, cornerRound, innerRatio) {
230
- const innerR = r * innerRatio;
231
- const rotRad = (rotationDeg * Math.PI) / 180;
232
- const verts = [];
233
- for (let i = 0; i < 10; i++) {
234
- const ang = (i * Math.PI / 5) + rotRad - Math.PI / 2;
235
- const radius = i % 2 === 0 ? r : innerR;
236
- verts.push({ x: cx + Math.cos(ang) * radius, y: cy + Math.sin(ang) * radius });
237
- }
238
- const inP = [], outP = [];
239
- for (let i = 0; i < 10; i++) {
240
- const prev = verts[(i-1+10)%10], cur = verts[i], next = verts[(i+1)%10];
241
- const v1x = prev.x - cur.x, v1y = prev.y - cur.y;
242
- const v1Len = Math.hypot(v1x, v1y);
243
- const v2x = next.x - cur.x, v2y = next.y - cur.y;
244
- const v2Len = Math.hypot(v2x, v2y);
245
- const cr = Math.min(cornerRound, v1Len*0.48, v2Len*0.48);
246
- inP[i] = { x: cur.x + (v1x/v1Len)*cr, y: cur.y + (v1y/v1Len)*cr };
247
- outP[i] = { x: cur.x + (v2x/v2Len)*cr, y: cur.y + (v2y/v2Len)*cr };
248
- }
249
- let path = `M ${inP[0].x.toFixed(2)} ${inP[0].y.toFixed(2)} `;
250
- for (let i = 0; i < 10; i++) {
251
- path += `Q ${verts[i].x.toFixed(2)} ${verts[i].y.toFixed(2)} ${outP[i].x.toFixed(2)} ${outP[i].y.toFixed(2)} `;
252
- path += `L ${inP[(i+1)%10].x.toFixed(2)} ${inP[(i+1)%10].y.toFixed(2)} `;
253
- }
254
- return path + 'Z';
255
- }
256
-
257
- const SLEEP_MASK_PATH = {
258
- d: "M488.728,341.587l-7.846-2.599c-0.405-16.992-7.605-47.34-48.625-70.941c-38.301-22.036-96.652-33.215-173.431-33.224c-76.822,0-135.146,11.165-173.438,33.183c-42.335,24.344-48.659,55.883-48.661,72.516c0,2.608,0.135,5.183,0.352,7.735h-2.649c-0.001,0-0.001,0-0.002,0c-9.635,0-17.446,7.811-17.448,17.445c-0.001,9.636,7.81,17.449,17.445,17.45l13,0.001c15.257,28.43,45.267,47.814,79.728,47.818c20.016-0.001,36.647-11.511,54.256-23.695c20.65-14.29,44.048-30.481,77.388-30.481c33.359,0.003,56.757,16.204,77.402,30.497c17.61,12.192,34.24,23.707,54.251,23.709h0.012c24.153,0,46.861-9.404,63.941-26.481c8.937-8.936,15.751-19.422,20.239-30.839l3.111,1.031c1.82,0.604,3.67,0.89,5.489,0.89c7.318,0,14.133-4.64,16.56-11.966C502.835,354.489,497.876,344.617,488.728,341.587Z",
259
- bboxX: 15, bboxY: 232, bboxW: 487, bboxH: 200
260
- };
261
-
262
- function makeSleepMask(p, tx, ty, rot, scale) {
263
- const cx = 12 + tx, cy = 12 + ty;
264
- const W = 16.61 * scale, H = 7.55 * scale;
265
- const sx = W / SLEEP_MASK_PATH.bboxW, sy = H / SLEEP_MASK_PATH.bboxH;
266
- const cxOrig = SLEEP_MASK_PATH.bboxX + SLEEP_MASK_PATH.bboxW/2;
267
- const cyOrig = SLEEP_MASK_PATH.bboxY + SLEEP_MASK_PATH.bboxH/2;
268
- const body = `<g transform="translate(${cx} ${cy}) scale(${sx.toFixed(5)} ${sy.toFixed(5)}) translate(${(-cxOrig).toFixed(2)} ${(-cyOrig).toFixed(2)})">
269
- <path d="${SLEEP_MASK_PATH.d}" fill="#22305A" stroke="#000" stroke-width="0.22" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>
270
- </g>`;
271
- const lashW = 2.45, lashH = 0.70;
272
- const arc = `<path d="M ${(-lashW/2).toFixed(2)} 0 Q 0 ${(lashH*2).toFixed(2)} ${(lashW/2).toFixed(2)} 0" fill="none" stroke="#fff" stroke-width="0.45" stroke-linecap="round"/>`;
273
- const hairs = [];
274
- for (let i = 0; i < 3; i++) {
275
- const t = 0.18 + (i/2) * 0.64;
276
- const sxh = (1-t)*(1-t)*(-lashW/2) + t*t*(lashW/2);
277
- const syh = 2*(1-t)*t*(2*lashH);
278
- const tangX = 2*(1-t)*(lashW/2) + 2*t*(lashW/2);
279
- const tangY = 2*(1-t)*(2*lashH) - 2*t*(2*lashH);
280
- const tLen = Math.hypot(tangX, tangY);
281
- const dirX = tangY/tLen, dirY = -tangX/tLen;
282
- const useDown = dirY > 0 ? 1 : -1;
283
- const ex = sxh + dirX * 0.66 * useDown, ey = syh + dirY * 0.66 * useDown;
284
- hairs.push(`<line x1="${sxh.toFixed(2)}" y1="${syh.toFixed(2)}" x2="${ex.toFixed(2)}" y2="${ey.toFixed(2)}" stroke="#fff" stroke-width="0.38" stroke-linecap="round" opacity="0.92"/>`);
285
- }
286
- const lashGap = 6.0 * scale;
287
- const wrapEye = (ecx, sign) => `<g transform="translate(${ecx.toFixed(2)} ${(cy-0.25).toFixed(2)}) rotate(${(sign*5).toFixed(2)}) scale(1.18)">${arc}${hairs.join('')}</g>`;
288
- const lashes = wrapEye(cx - lashGap/2, -1) + wrapEye(cx + lashGap/2, +1);
289
- return `<g class="sleep-mask" transform="rotate(${rot} ${cx} ${cy})" filter="url(#${p}-shadow)">${body}${lashes}</g>`;
290
- }
291
-
292
- /* =========================================================================
293
- * 眼睛 (多模式: capsule / arrows / smile / closed)
294
- * ========================================================================= */
295
- function makeEyes(p, mode, openness, offX, offY) {
296
- const lcx = 9, rcx = 15, cy = 12; // 双眼默认位置
297
- let lEye, rEye;
298
-
299
- if (mode === 'arrows') {
300
- // > < 用力表情 (左眼 > 朝右开, 右眼 < 朝左开)
301
- // 用 typing spec stroke 1.25 + linecap=round, 字符尺寸 1.40
302
- const sz = 1.4;
303
- // > path (左眼): 左上 → 中右 → 左下
304
- const gtPath = `M ${(-sz*0.7).toFixed(2)} ${(-sz*0.85).toFixed(2)} L ${(sz*0.7).toFixed(2)} 0 L ${(-sz*0.7).toFixed(2)} ${(sz*0.85).toFixed(2)}`;
305
- // < path (右眼): 右上 → 中左 → 右下
306
- const ltPath = `M ${(sz*0.7).toFixed(2)} ${(-sz*0.85).toFixed(2)} L ${(-sz*0.7).toFixed(2)} 0 L ${(sz*0.7).toFixed(2)} ${(sz*0.85).toFixed(2)}`;
307
- lEye = `<g transform="translate(${lcx} ${cy})"><path d="${gtPath}" fill="none" stroke="url(#${p}-eye)" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/></g>`;
308
- rEye = `<g transform="translate(${rcx} ${cy})"><path d="${ltPath}" fill="none" stroke="url(#${p}-eye)" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/></g>`;
309
- } else if (mode === 'smile') {
310
- // happy 微笑弧 h=1.0 w=3.5 stroke=1.25 cy=12.7
311
- const w = params.smileW, h = params.smileH;
312
- const smileCY = 12.7;
313
- const smilePath = (cx) => `M ${(cx-w/2).toFixed(2)} ${smileCY} Q ${cx} ${(smileCY-2*h).toFixed(2)} ${(cx+w/2).toFixed(2)} ${smileCY}`;
314
- lEye = `<path d="${smilePath(lcx)}" fill="none" stroke="url(#${p}-eye)" stroke-width="1.25" stroke-linecap="round"/>`;
315
- rEye = `<path d="${smilePath(rcx)}" fill="none" stroke="url(#${p}-eye)" stroke-width="1.25" stroke-linecap="round"/>`;
316
- } else {
317
- // capsule (default) 或 closed (h 极小)
318
- const baseH = 5;
319
- const minH = 0.3;
320
- const h = Math.max(minH, baseH * openness);
321
- const y = 9.5 + (baseH - h) / 2;
322
- const rx = Math.min(1.3, h / 2);
323
- lEye = `<rect x="7.7" y="${y.toFixed(2)}" width="2.6" height="${h.toFixed(2)}" rx="${rx.toFixed(2)}" ry="${rx.toFixed(2)}" fill="url(#${p}-eye)" stroke="#fff" stroke-width="0.08" stroke-opacity="0.7"/>`;
324
- rEye = `<rect x="13.7" y="${y.toFixed(2)}" width="2.6" height="${h.toFixed(2)}" rx="${rx.toFixed(2)}" ry="${rx.toFixed(2)}" fill="url(#${p}-eye)" stroke="#fff" stroke-width="0.08" stroke-opacity="0.7"/>`;
325
- }
326
-
327
- return `<g class="eyes" transform="translate(${offX.toFixed(2)} ${offY.toFixed(2)})">${lEye}${rEye}</g>`;
328
- }
329
-
330
- function makeCloudling(p, eyeMode, eyeOpenness, eyeOffX, eyeOffY) {
331
- return `
332
- <g class="cloudling-base">
333
- <g class="edge-system">
334
- <use class="edge-contact" href="#${p}-cloud-shape" fill="none" stroke="#7d8aa7" stroke-width="1.05" stroke-linejoin="round" transform="translate(0 0.18)" filter="url(#${p}-edge-blur)"/>
335
- <use class="edge-rim-soft" href="#${p}-cloud-shape" fill="none" stroke="#7A9DFF" stroke-width="0.90" stroke-linejoin="round" filter="url(#${p}-edge-blur)"/>
336
- <use class="edge-rim-tight" href="#${p}-cloud-shape" fill="none" stroke="#aab3c6" stroke-width="0.42" stroke-linejoin="round"/>
337
- </g>
338
- <g filter="url(#${p}-shadow)">
339
- <use href="#${p}-cloud-shape" fill="url(#${p}-body)"/>
340
- <rect x="1" y="2" width="22" height="11" fill="url(#${p}-hi)" clip-path="url(#${p}-clip)"/>
341
- </g>
342
- ${makeEyes(p, eyeMode, eyeOpenness, eyeOffX, eyeOffY)}
343
- </g>
344
- `;
345
- }
346
-
347
- const STAR_LOCKED = { cx: 8.55, cy: 0.35, size: 1.00, rotation: 30, cornerRound: 0.27, innerRatio: 0.56 };
348
-
349
- function makeHat(p, tx, ty, rot, scale) {
350
- const hatData = makeBentHat(0.4);
351
- const cuffSVG = makeFluffyCuff(p, 12, 5.4, 3.7, 0.85, 0, 18);
352
- const s = STAR_LOCKED;
353
- const starPath = makeStar(s.cx, s.cy, s.size, s.rotation, s.cornerRound, s.innerRatio);
354
- return `
355
- <g class="hat-piece" transform="translate(${tx} ${ty}) rotate(${rot} 12 5.4) scale(${scale})">
356
- <g filter="url(#${p}-shadow)"><path d="${hatData.path}" fill="url(#${p}-hat)"/></g>
357
- <g class="cuff-fluffy">${cuffSVG}</g>
358
- <g class="star-piece" filter="url(#${p}-star-glow)">
359
- <path d="${starPath}" fill="url(#${p}-star-yellow)" stroke="#fff" stroke-width="0.06" stroke-opacity="0.7" stroke-linejoin="round"/>
360
- </g>
361
- </g>
362
- `;
363
- }
364
-
365
- function makeZPath() {
366
- return "M -1.0 -1.0 Q 0.0 -1.0 1.0 -1.0 Q 1.05 -0.85 0.6 -0.4 Q 0 0.1 -0.6 0.6 Q -1.05 0.95 -1.0 1.0 Q 0.0 1.0 1.0 1.0";
367
- }
368
-
369
- function makeLamp(p, cx, cy, dipY, toggleMix, dayOff, nightOff, opacity, scale, knockRot) {
370
- const ccy = cy + dipY;
371
- const mix = clamp01(toggleMix);
372
- const dayOpacity = 1 - mix;
373
- const nightOpacity = mix;
374
- const dayKnobOpacity = clamp01(1 - mix * 2.2);
375
- const nightKnobOpacity = clamp01((mix - 0.18) / 0.82);
376
- const knobX = cx + lerp(dayOff, nightOff, mix);
377
- const knobY = ccy;
378
-
379
- const dayKnob = `
380
- <g opacity="${dayKnobOpacity.toFixed(2)}">
381
- <circle cx="${knobX.toFixed(2)}" cy="${knobY.toFixed(2)}" r="2.4" fill="#fff" filter="url(#${p}-shadow)"/>
382
- <circle cx="${(knobX+0.85).toFixed(2)}" cy="${(knobY-0.95).toFixed(2)}" r="0.50" fill="#D4DFFF" opacity="0.78"/>
383
- <circle cx="${(knobX+1.15).toFixed(2)}" cy="${(knobY+0.55).toFixed(2)}" r="0.28" fill="#D4DFFF" opacity="0.72"/>
384
- </g>
385
- `;
386
-
387
- const outerR = 2.35, innerR = 1.70, innerDx = 0.80;
388
- const ix = (outerR*outerR - innerR*innerR + innerDx*innerDx) / (2*innerDx);
389
- const iy = Math.sqrt(outerR*outerR - ix*ix);
390
- const nightKnob = `
391
- <g class="crescent-knob" filter="url(#${p}-shadow)" opacity="${nightKnobOpacity.toFixed(2)}">
392
- <path d="
393
- M ${(knobX+ix).toFixed(3)} ${(knobY-iy).toFixed(3)}
394
- A ${outerR} ${outerR} 0 1 1 ${(knobX+ix).toFixed(3)} ${(knobY+iy).toFixed(3)}
395
- A ${innerR} ${innerR} 0 1 0 ${(knobX+ix).toFixed(3)} ${(knobY-iy).toFixed(3)}
396
- Z
397
- " fill="#fff"/>
398
- </g>
399
- `;
400
-
401
- const stars = `
402
- <g class="stars" opacity="${nightOpacity.toFixed(2)}">
403
- <circle cx="${(cx-1.5).toFixed(2)}" cy="${(ccy-0.9).toFixed(2)}" r="0.18" fill="#fff" opacity="0.95"/>
404
- <circle cx="${(cx-0.5).toFixed(2)}" cy="${(ccy+1.3).toFixed(2)}" r="0.14" fill="#fff" opacity="0.85"/>
405
- <circle cx="${(cx+0.6).toFixed(2)}" cy="${(ccy-0.4).toFixed(2)}" r="0.10" fill="#fff" opacity="0.75"/>
406
- <circle cx="${(cx-1.2).toFixed(2)}" cy="${(ccy+1.6).toFixed(2)}" r="0.10" fill="#fff" opacity="0.78"/>
407
- <circle cx="${(cx).toFixed(2)}" cy="${(ccy-1.3).toFixed(2)}" r="0.13" fill="#fff" opacity="0.88"/>
408
- </g>
409
- `;
410
-
411
- return `
412
- <g class="lamp-wrapper" opacity="${opacity.toFixed(2)}" transform="translate(${cx} ${ccy}) rotate(${knockRot.toFixed(3)}) scale(${scale.toFixed(3)}) translate(${-cx} ${-ccy})">
413
- <g class="lamp-group" filter="url(#${p}-shadow)">
414
- <rect class="toggle-capsule-stroke" x="${(cx-7).toFixed(2)}" y="${(ccy-3).toFixed(2)}" width="14" height="6" rx="3" ry="3" fill="url(#${p}-toggle-day)" opacity="${dayOpacity.toFixed(2)}"/>
415
- <rect class="toggle-capsule-stroke" x="${(cx-7).toFixed(2)}" y="${(ccy-3).toFixed(2)}" width="14" height="6" rx="3" ry="3" fill="url(#${p}-toggle-night)" opacity="${nightOpacity.toFixed(2)}"/>
416
- <rect x="${(cx-7).toFixed(2)}" y="${(ccy-3).toFixed(2)}" width="14" height="6" rx="3" ry="3" fill="none" stroke="#fff" stroke-opacity="0.18" stroke-width="0.12"/>
417
- </g>
418
- ${stars}
419
- ${dayKnob}
420
- ${nightKnob}
421
- </g>
422
- `;
423
- }
424
-
425
- /* =========================================================================
426
- * 时间线 + 插值
427
- * ========================================================================= */
428
- function lerp(a, b, t) { return a + (b - a) * t; }
429
- function easeOut(t) { return 1 - Math.pow(1 - t, 2); }
430
- function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
431
- function easeOutBack(t) {
432
- const c1 = 1.45;
433
- const c3 = c1 + 1;
434
- return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
435
- }
436
- function easeIn(t) { return t * t; }
437
- function easeInOut(t) { return t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t+2, 2)/2; }
438
- function clamp01(t) { return Math.max(0, Math.min(1, t)); }
439
- function progress(t, begin, end) { return clamp01((t - begin) / (end - begin)); }
440
-
441
- function getTimeline() {
442
- const tLampEnd = params.T_LAMP_APPEAR;
443
- const tNoticeEnd = params.T_NOTICE_END;
444
- const tCrouchStart = tNoticeEnd;
445
- const tCrouchEnd = tCrouchStart + params.T_CROUCH;
446
- const tJumpUpEnd = tCrouchEnd + params.T_JUMP_UP;
447
- const tImpactStart = tJumpUpEnd;
448
- const tContactStart = Math.max(tCrouchEnd, tImpactStart - params.T_CONTACT_LEAD);
449
- const tImpactEnd = tImpactStart + params.T_IMPACT_HOLD;
450
- const tFallEnd = tImpactEnd + params.T_FALL_BACK;
451
- const tSatisfiedEnd = tFallEnd + params.T_SATISFIED;
452
- const tHatDropEnd = tSatisfiedEnd + params.T_HAT_DROP;
453
- const tMaskSlideEnd = tHatDropEnd + params.T_MASK_SLIDE;
454
- const tSettleEnd = tMaskSlideEnd + params.T_SETTLE;
455
- return {
456
- tLampEnd, tNoticeBegin: params.T_NOTICE_BEGIN, tNoticeEnd,
457
- tCrouchStart, tCrouchEnd,
458
- tJumpUpEnd, tContactStart, tImpactStart, tImpactEnd,
459
- tFallEnd, tSatisfiedEnd,
460
- tHatDropEnd, tMaskSlideEnd, tSettleEnd
461
- };
462
- }
463
-
464
- function computeFrame(t) {
465
- const T = getTimeline();
466
- const f = {};
467
-
468
- // ---------- 灯渐显 → 满意后渐隐 ----------
469
- const tFadeStart = T.tSatisfiedEnd;
470
- const tFadeEnd = tFadeStart + params.T_LAMP_FADE_OUT;
471
- if (t < params.T_LAMP_APPEAR) {
472
- // 渐显 (lamp_appear 段)
473
- const p = easeOut(progress(t, 0, params.T_LAMP_APPEAR));
474
- f.lampOpacity = p;
475
- f.lampScale = lerp(0.85, 1.0, p);
476
- } else if (t < tFadeStart) {
477
- // 全显 (lamp_appear 完成 → satisfied 结束)
478
- f.lampOpacity = 1;
479
- f.lampScale = 1;
480
- } else if (t < tFadeEnd) {
481
- // 渐隐 (满意后 → 完全消失)
482
- const p = easeIn(progress(t, tFadeStart, tFadeEnd));
483
- f.lampOpacity = lerp(1, 0, p);
484
- f.lampScale = 1;
485
- } else {
486
- // 完全消失
487
- f.lampOpacity = 0;
488
- f.lampScale = 1;
489
- }
490
- f.lampScalePulse = 1;
491
- f.lampRot = 0;
492
- let contactAmount = 0;
493
- if (t >= T.tContactStart && t < T.tImpactStart) {
494
- contactAmount = lerp(0, 0.82, easeInOut(progress(t, T.tContactStart, T.tImpactStart)));
495
- } else if (t >= T.tImpactStart && t < T.tImpactEnd) {
496
- contactAmount = lerp(0.82, 1, easeOut(progress(t, T.tImpactStart, T.tImpactEnd)));
497
- } else if (t >= T.tImpactEnd && t < T.tFallEnd) {
498
- contactAmount = 1 - easeInOut(progress(t, T.tImpactEnd, T.tFallEnd));
499
- }
500
- f.contactAmount = contactAmount;
501
- f.pressAmount = t < T.tImpactStart && contactAmount > 0
502
- ? Math.sqrt(contactAmount / 0.82) * 0.82
503
- : contactAmount;
504
-
505
- // ---------- cloudling 整体 transform ----------
506
- let cloudTx = 0, cloudTy = 0, cloudRot = 0, cloudScaleX = 1, cloudScaleY = 1;
507
-
508
- if (t < T.tNoticeBegin) {
509
- // idle 默认
510
- } else if (t < T.tNoticeEnd) {
511
- // notice: 微歪头朝灯方向
512
- const p = easeOut(progress(t, T.tNoticeBegin, T.tNoticeEnd));
513
- cloudRot = lerp(0, params.noticeBodyTilt, p);
514
- } else if (t < T.tCrouchEnd) {
515
- // crouch: scaleY 压扁
516
- const p = easeIn(progress(t, T.tCrouchStart, T.tCrouchEnd));
517
- cloudTy = lerp(0, 0.45, p);
518
- cloudScaleY = lerp(1, params.crouchScaleY, p);
519
- cloudRot = params.noticeBodyTilt;
520
- } else if (t < T.tJumpUpEnd) {
521
- // jump up: 斜向上弧线顶到 capsule, 不是直线飘过去
522
- const p = easeOutCubic(progress(t, T.tCrouchEnd, T.tJumpUpEnd));
523
- cloudTx = lerp(0, params.jumpTx, p);
524
- cloudTy = lerp(0.45, params.jumpTy, p) - Math.sin(p * Math.PI) * 0.85;
525
- const jumpScaleY = lerp(params.crouchScaleY, params.jumpUpScaleY, p);
526
- cloudScaleX = lerp(1, 1.13, f.pressAmount);
527
- cloudScaleY = lerp(jumpScaleY, params.impactScaleY, f.pressAmount);
528
- cloudRot = lerp(params.noticeBodyTilt, 0, p); // 起跳过程歪头回正
529
- } else if (t < T.tImpactEnd) {
530
- // impact: 撞 capsule, 继续加深压扁, 不硬切
531
- cloudTx = params.jumpTx;
532
- cloudTy = params.jumpTy;
533
- cloudScaleX = lerp(1, 1.13, f.pressAmount);
534
- cloudScaleY = lerp(params.jumpUpScaleY, params.impactScaleY, f.pressAmount);
535
- cloudRot = 0;
536
- } else if (t < T.tFallEnd) {
537
- // fall back: 从顶位回到中心, 压扁量逐渐释放
538
- const p = easeInOut(progress(t, T.tImpactEnd, T.tFallEnd));
539
- cloudTx = lerp(params.jumpTx, 0, p);
540
- cloudTy = lerp(params.jumpTy, 0, p) + Math.sin(p * Math.PI) * 0.55;
541
- cloudScaleX = lerp(1, 1.13, f.pressAmount);
542
- cloudScaleY = lerp(1, params.impactScaleY, f.pressAmount);
543
- } else if (t < T.tSatisfiedEnd) {
544
- // satisfied: 落地后的满足小弹跳, 给表情留时间
545
- const p = progress(t, T.tFallEnd, T.tSatisfiedEnd);
546
- const settleBounce = Math.sin(p * Math.PI * 2) * Math.exp(-p * 3.2);
547
- cloudTy = settleBounce * 0.22;
548
- cloudRot = Math.sin(p * Math.PI) * 1.4;
549
- } else {
550
- // hat/mask/settle: bodyTilt 渐进到 sleeping -6°
551
- const p = easeInOut(progress(t, T.tSatisfiedEnd, T.tSettleEnd));
552
- cloudRot = lerp(0, params.sleepingBodyTilt, p);
553
- }
554
-
555
- f.cloudTx = cloudTx;
556
- f.cloudTy = cloudTy;
557
- f.cloudRot = cloudRot;
558
- f.cloudScaleX = cloudScaleX;
559
- f.cloudScaleY = cloudScaleY;
560
-
561
- // ---------- 灯反馈: 跟 contactAmount 同步渐进下压, 离开后轻微余震 ----------
562
- f.lampDipY = params.lampImpactDip * f.pressAmount;
563
- if (t >= T.tContactStart && t < T.tFallEnd + 0.35) {
564
- const contactShake = t < T.tImpactStart
565
- ? -1.0 * contactAmount
566
- : Math.sin((t - T.tImpactStart) * Math.PI * 12) * 3.8 * Math.exp(-(t - T.tImpactStart) * 4.6);
567
- f.lampRot = contactShake;
568
- if (t >= T.tImpactStart) {
569
- const dt = t - T.tImpactStart;
570
- f.lampScalePulse = 1 + Math.max(0, Math.sin(dt * Math.PI * 4.5)) * 0.035 * Math.exp(-dt * 5);
571
- }
572
- if (t >= T.tFallEnd) {
573
- const rt = t - T.tFallEnd;
574
- f.lampDipY += params.lampImpactDip * 0.08 * Math.exp(-rt * 8) * Math.sin(rt * Math.PI * 10);
575
- f.lampRot = Math.sin(rt * Math.PI * 10) * 1.4 * Math.exp(-rt * 8);
576
- }
577
- }
578
-
579
- // ---------- toggle state: 撞击后滑过去, 咔哒感比硬切清楚 ----------
580
- f.toggleMix = easeOutCubic(progress(t, T.tImpactStart + 0.03, T.tImpactStart + 0.24));
581
- f.toggleState = f.toggleMix >= 0.5 ? 'night' : 'day';
582
-
583
- // ---------- 眼睛 mode ----------
584
- // v7: 接触加深时进入 ><, 落地后给清楚的满意弧线.
585
- let eyeMode, eyeOpenness = 1, eyeOffX = 0, eyeOffY = 0;
586
- if (t < T.tNoticeBegin) {
587
- eyeMode = 'capsule';
588
- } else if (t < T.tNoticeEnd) {
589
- // notice: capsule 朝灯偏移
590
- const p = easeOut(progress(t, T.tNoticeBegin, T.tNoticeEnd));
591
- eyeMode = 'capsule';
592
- eyeOffX = lerp(0, params.noticeEyeOffsetX, p);
593
- eyeOffY = lerp(0, params.noticeEyeOffsetY, p);
594
- } else if (t < T.tImpactStart && f.contactAmount < 0.42) {
595
- // crouch + jump up: 保持朝灯偏移, 微微眯眼像在瞄准
596
- eyeMode = 'capsule';
597
- eyeOpenness = 0.82;
598
- eyeOffX = params.noticeEyeOffsetX;
599
- eyeOffY = params.noticeEyeOffsetY;
600
- } else if (t < T.tImpactEnd + 0.08) {
601
- // 接触加深后进入用力表情
602
- eyeMode = 'arrows';
603
- } else if (t < T.tFallEnd) {
604
- // fall_back: 解除用力表情, 避免像被打疼
605
- eyeMode = 'capsule';
606
- eyeOpenness = 0.72;
607
- } else if (t < T.tSatisfiedEnd) {
608
- // 落地满意 beat
609
- eyeMode = 'smile';
610
- } else if (t < T.tMaskSlideEnd) {
611
- // 戴帽时还保持一点满意, 眼罩靠近时渐困
612
- const p = progress(t, T.tSatisfiedEnd, T.tMaskSlideEnd);
613
- if (p < 0.45) {
614
- eyeMode = 'smile';
615
- } else {
616
- eyeMode = 'capsule';
617
- eyeOpenness = lerp(0.72, 0.18, progress(p, 0.45, 1));
618
- }
619
- } else {
620
- // settle + sleeping loop: 几乎闭眼 (被 mask 遮住)
621
- eyeMode = 'capsule';
622
- eyeOpenness = 0.05;
623
- }
624
- f.eyeMode = eyeMode;
625
- f.eyeOpenness = eyeOpenness;
626
- f.eyeOffX = eyeOffX;
627
- f.eyeOffY = eyeOffY;
628
-
629
- // ---------- 帽子 ----------
630
- if (t < T.tSatisfiedEnd) {
631
- f.hatVisible = false;
632
- } else if (t < T.tHatDropEnd) {
633
- f.hatVisible = true;
634
- const u = progress(t, T.tSatisfiedEnd, T.tHatDropEnd);
635
- const p = easeOutBack(u);
636
- f.hatTx = lerp(params.hatStartTx, params.sleepingHatTx, p);
637
- f.hatTy = lerp(params.hatStartTy, params.sleepingHatTy, p) - Math.sin(u * Math.PI) * 0.35;
638
- f.hatRot = lerp(params.hatStartRot, params.sleepingHatRot, p) + Math.sin(u * Math.PI * 2) * 3.5 * (1 - u);
639
- f.hatScale = lerp(params.hatStartScale, params.sleepingHatScale, p);
640
- } else {
641
- f.hatVisible = true;
642
- f.hatTx = params.sleepingHatTx;
643
- f.hatTy = params.sleepingHatTy;
644
- f.hatRot = params.sleepingHatRot;
645
- f.hatScale = params.sleepingHatScale;
646
- }
647
-
648
- // ---------- 眼罩 ----------
649
- if (t < T.tHatDropEnd) {
650
- f.maskVisible = false;
651
- } else if (t < T.tMaskSlideEnd) {
652
- f.maskVisible = true;
653
- const u = progress(t, T.tHatDropEnd, T.tMaskSlideEnd);
654
- const p = easeOutBack(u);
655
- f.maskTx = lerp(params.maskStartTx, params.sleepingMaskTx, p);
656
- f.maskTy = lerp(params.maskStartTy, params.sleepingMaskTy, p) - Math.sin(u * Math.PI) * 0.25;
657
- f.maskRot = lerp(params.maskStartRot, params.sleepingMaskRot, p);
658
- f.maskScale = params.sleepingMaskScale;
659
- } else {
660
- f.maskVisible = true;
661
- f.maskTx = params.sleepingMaskTx;
662
- f.maskTy = params.sleepingMaskTy;
663
- f.maskRot = params.sleepingMaskRot;
664
- f.maskScale = params.sleepingMaskScale;
665
- }
666
-
667
- // ---------- sleeping loop ----------
668
- f.inSleepingLoop = t >= T.tSettleEnd;
669
- if (f.inSleepingLoop) {
670
- const lt = t - T.tSettleEnd;
671
- const breathPhase = (Math.cos(2 * Math.PI * lt / 4) + 1) / 2;
672
- f.breathScale = lerp(0.96, 1.02, breathPhase);
673
- f.hatSwayDeg = Math.sin(2 * Math.PI * lt / 6) * 2.0;
674
- } else {
675
- f.breathScale = 1.0;
676
- f.hatSwayDeg = 0;
677
- }
678
-
679
- return f;
680
- }
681
-
682
- /* =========================================================================
683
- * 渲染
684
- * ========================================================================= */
685
- const stage = document.getElementById('stage');
686
-
687
- function renderFrame(t) {
688
- const f = computeFrame(t);
689
- const p = 'idl';
690
-
691
- const lamp = makeLamp(
692
- p,
693
- params.lampCx,
694
- params.lampCy,
695
- f.lampDipY,
696
- f.toggleMix,
697
- params.toggleDayKnobOffsetX,
698
- params.toggleNightKnobOffsetX,
699
- f.lampOpacity,
700
- f.lampScale * f.lampScalePulse,
701
- f.lampRot
702
- );
703
-
704
- const cloudD = makeSoftCloudPath(f.pressAmount);
705
- const cloudling = makeCloudling(p, f.eyeMode, f.eyeOpenness, f.eyeOffX, f.eyeOffY);
706
-
707
- const hat = f.hatVisible ? `<g transform="rotate(${f.hatSwayDeg.toFixed(3)} 12 5.4)">${makeHat(p, f.hatTx, f.hatTy, f.hatRot, f.hatScale)}</g>` : '';
708
- const mask = f.maskVisible ? makeSleepMask(p, f.maskTx, f.maskTy, f.maskRot, f.maskScale) : '';
709
-
710
- // Z 字 (sleeping loop)
711
- let zHTML = '';
712
- if (f.inSleepingLoop) {
713
- const T = getTimeline();
714
- const lt = t - T.tSettleEnd;
715
- const lastSpawnTime = Math.floor(lt / 1.8) * 1.8;
716
- for (let bornAt = lastSpawnTime; bornAt > lt - 2.9 && bornAt >= 0; bornAt -= 1.8) {
717
- const age = lt - bornAt;
718
- if (age < 0 || age >= 2.9) continue;
719
- const u = age / 2.9;
720
- const x = 19.4 + 4 * u;
721
- const y = 3.6 - 8 * u;
722
- const size = lerp(1.6, 1.3, u);
723
- let opacity;
724
- if (u < 0.18) opacity = (u/0.18) * 0.45;
725
- else if (u < 0.7) opacity = 0.45;
726
- else opacity = 0.45 * (1 - (u-0.7)/0.3);
727
- const sway = Math.sin(age * 2.0) * 0.3;
728
- zHTML += `<g transform="translate(${(x+sway).toFixed(2)} ${y.toFixed(2)}) scale(${size.toFixed(2)})" opacity="${opacity.toFixed(3)}">
729
- <g filter="url(#${p}-z-glow)">
730
- <path d="${makeZPath()}" fill="none" stroke="url(#${p}-z-grad)" stroke-width="0.55" stroke-linecap="round" stroke-linejoin="round"/>
731
- </g>
732
- </g>`;
733
- }
734
- }
735
-
736
- // squash origin: impact + fall_back 段用云宝头顶 (12, 4.45) 模拟"被灯顶部压扁"
737
- // 其他段用中心 (12, 12) 正常 squash & stretch
738
- const T2 = getTimeline();
739
- const squashOY = (t >= T2.tImpactStart && t < T2.tFallEnd) ? 4.45 : 12;
740
-
741
- stage.innerHTML = `
742
- <style>
743
- .edge-rim-tight { stroke-opacity: .65; }
744
- .edge-rim-soft { stroke-opacity: .50; }
745
- .edge-contact { stroke-opacity: .65; }
746
- .toggle-capsule-stroke { stroke-opacity: 0; }
747
- @media (prefers-color-scheme: light) {
748
- .edge-rim-tight { stroke-opacity: .75; }
749
- .edge-rim-soft { stroke-opacity: .60; }
750
- .edge-contact { stroke-opacity: .75; }
751
- .cloud-drop-shadow { flood-opacity: .13; }
752
- .toggle-capsule-stroke { stroke: #5C7BD9; stroke-width: 0.20; stroke-opacity: 0.55; }
753
- .food-cloud-shadow { flood-opacity: .28; flood-color: #5060FF; }
754
- .food-cloud > path {
755
- stroke: #5870E8 !important;
756
- stroke-width: 1.2 !important;
757
- stroke-opacity: .9 !important;
758
- vector-effect: non-scaling-stroke !important;
759
- }
760
- }
761
- </style>
762
- <defs>
763
- ${commonDefs(p, cloudD)}
764
- <path id="${p}-cloud-shape" d="${cloudD}"/>
765
- </defs>
766
- <!-- cloudling 在灯下面 (先渲染, 灯后渲染压在上面) -->
767
- <g class="scene" transform="translate(${f.cloudTx.toFixed(3)} ${f.cloudTy.toFixed(3)}) rotate(${f.cloudRot.toFixed(3)} 12 12)">
768
- <g class="squash" transform="translate(12 ${squashOY}) scale(${f.cloudScaleX.toFixed(4)} ${f.cloudScaleY.toFixed(4)}) translate(-12 ${(-squashOY).toFixed(3)})">
769
- <g class="breath" transform="translate(12 12) scale(${f.breathScale.toFixed(4)}) translate(-12 -12)">
770
- ${cloudling}
771
- ${hat}
772
- ${mask}
773
- </g>
774
- </g>
775
- </g>
776
- <!-- 灯在云宝上面 (云宝顶到灯底, 灯压云宝). v6 去掉拉绳三角坠. -->
777
- ${lamp}
778
- ${zHTML}
779
- `;
780
- }
781
-
782
- /* =========================================================================
783
- * 主循环 + 控件
784
- * ========================================================================= */
785
- let startT = performance.now();
786
- let elapsedOffset = 0;
787
- let pausedAt = 0;
788
-
789
- const scrubberEl = document.getElementById('scrubber');
790
- const tDisplayEl = document.getElementById('t-display');
791
-
792
- function getCurrentT() {
793
- if (scrubbing) return Number(scrubberEl.value);
794
- if (paused) return elapsedOffset + (pausedAt - startT) / 1000;
795
- return elapsedOffset + (performance.now() - startT) / 1000;
796
- }
797
-
798
- function loop() {
799
- const t = getCurrentT();
800
- const T = getTimeline();
801
- const totalLen = T.tSettleEnd + 5;
802
- scrubberEl.max = totalLen.toFixed(2);
803
- if (!scrubbing) scrubberEl.value = Math.min(t, totalLen).toFixed(3);
804
- tDisplayEl.textContent = `${t.toFixed(2)} s`;
805
- renderFrame(t);
806
- requestAnimationFrame(loop);
807
- }
808
- renderFrame(0);
809
- requestAnimationFrame(loop);
810
- ]]></script>
811
- </svg>
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg id="stage" xmlns="http://www.w3.org/2000/svg" width="880" height="720" viewBox="-32 -24 88 72" preserveAspectRatio="xMidYMid meet">
3
+ <g id="__cloudling-export-shims" opacity="0" pointer-events="none" style="display:none">
4
+ <g id="scrubber"></g>
5
+ <g id="t-display"></g>
6
+ </g>
7
+ <script type="application/ecmascript"><![CDATA[
8
+ const CLOUD_D = "M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388z";
9
+
10
+ const SOFT_CLOUD_POINTS = [
11
+ {x:9.064,y:3.344},{x:9.825,y:3.106},{x:10.616,y:3.004},{x:11.413,y:3.040},
12
+ {x:12.192,y:3.210},{x:12.928,y:3.518},{x:13.599,y:3.948},{x:14.255,y:4.291},
13
+ {x:15.046,y:4.188},{x:15.843,y:4.224},{x:16.621,y:4.398},{x:17.357,y:4.706},
14
+ {x:18.029,y:5.134},{x:18.617,y:5.673},{x:19.103,y:6.306},{x:19.471,y:7.013},
15
+ {x:19.715,y:7.772},{x:19.812,y:8.564},{x:19.784,y:9.361},{x:19.797,y:10.105},
16
+ {x:20.287,y:10.735},{x:20.656,y:11.442},{x:20.897,y:12.202},{x:20.995,y:12.994},
17
+ {x:20.964,y:13.791},{x:20.791,y:14.569},{x:20.480,y:15.304},{x:20.052,y:15.977},
18
+ {x:19.516,y:16.567},{x:18.882,y:17.052},{x:18.174,y:17.418},{x:17.539,y:17.808},
19
+ {x:17.231,y:18.545},{x:16.805,y:19.218},{x:16.264,y:19.805},{x:15.633,y:20.292},
20
+ {x:14.925,y:20.660},{x:14.163,y:20.895},{x:13.372,y:20.996},{x:12.575,y:20.964},
21
+ {x:11.796,y:20.793},{x:11.061,y:20.483},{x:10.392,y:20.049},{x:9.736,y:19.714},
22
+ {x:8.946,y:19.811},{x:8.148,y:19.777},{x:7.370,y:19.606},{x:6.634,y:19.299},
23
+ {x:5.962,y:18.868},{x:5.377,y:18.326},{x:4.885,y:17.698},{x:4.522,y:16.989},
24
+ {x:4.283,y:16.228},{x:4.182,y:15.437},{x:4.220,y:14.640},{x:4.179,y:13.907},
25
+ {x:3.696,y:13.273},{x:3.332,y:12.563},{x:3.092,y:11.803},{x:3.003,y:11.010},
26
+ {x:3.038,y:10.213},{x:3.202,y:9.433},{x:3.514,y:8.699},{x:3.944,y:8.027},
27
+ {x:4.484,y:7.440},{x:5.114,y:6.951},{x:5.822,y:6.586},{x:6.445,y:6.191},
28
+ {x:6.752,y:5.455},{x:7.185,y:4.785},{x:7.723,y:4.197},{x:8.356,y:3.711},
29
+ ];
30
+
31
+ function catmullRomClosedPath(points, tension = 1) {
32
+ const q = (n) => n.toFixed(3);
33
+ let d = `M ${q(points[0].x)} ${q(points[0].y)}`;
34
+ const n = points.length;
35
+ for (let i = 0; i < n; i++) {
36
+ const p0 = points[(i - 1 + n) % n];
37
+ const p1 = points[i];
38
+ const p2 = points[(i + 1) % n];
39
+ const p3 = points[(i + 2) % n];
40
+ const c1x = p1.x + (p2.x - p0.x) * tension / 6;
41
+ const c1y = p1.y + (p2.y - p0.y) * tension / 6;
42
+ const c2x = p2.x - (p3.x - p1.x) * tension / 6;
43
+ const c2y = p2.y - (p3.y - p1.y) * tension / 6;
44
+ d += ` C ${q(c1x)} ${q(c1y)} ${q(c2x)} ${q(c2y)} ${q(p2.x)} ${q(p2.y)}`;
45
+ }
46
+ return `${d} Z`;
47
+ }
48
+
49
+ function makeSoftCloudPath(pressure) {
50
+ const a = clamp01(pressure);
51
+ if (a <= 0.0001) return CLOUD_D;
52
+ const volume = Math.sin(a * Math.PI) * 0.18;
53
+ const points = SOFT_CLOUD_POINTS.map((pt) => {
54
+ const distX = Math.abs(pt.x - 12);
55
+ const topWeight = Math.pow(clamp01((7.2 - pt.y) / 4.2), 1.15);
56
+ const contactWeight = clamp01(1 - distX / 8.6);
57
+ const shoulderWeight = clamp01((distX - 3.2) / 6.2) * clamp01((16.5 - pt.y) / 12.5);
58
+ const lowerWeight = clamp01((pt.y - 16.2) / 4.8);
59
+ const targetTopY = 4.58 + Math.pow(distX / 8.6, 2) * 0.34;
60
+ const topDown = Math.max(0, targetTopY - pt.y) * topWeight * (0.35 + contactWeight * 0.65) * a;
61
+ const sideOut = Math.sign(pt.x - 12 || 1) * (topWeight * 0.95 + shoulderWeight * 0.48) * a;
62
+ const lowerSettle = lowerWeight * volume * 0.18;
63
+ return {
64
+ x: pt.x + sideOut,
65
+ y: pt.y + topDown + lowerSettle
66
+ };
67
+ });
68
+ return catmullRomClosedPath(points, lerp(0.96, 0.72, a));
69
+ }
70
+
71
+ const DEFAULTS = {
72
+ // 时间线 (秒)
73
+ T_LAMP_APPEAR: 0.55,
74
+ T_NOTICE_BEGIN: 0.35, // notice 跟 lamp_appear 重叠 0.2s
75
+ T_NOTICE_END: 0.95,
76
+ T_CROUCH: 0.35,
77
+ T_JUMP_UP: 0.34,
78
+ T_CONTACT_LEAD: 0.28, // 压扁起点略早于视觉接触, 让 1.39s 已经有形变
79
+ T_IMPACT_HOLD: 0.16, // 给撞击和压扁一个可读停顿
80
+ T_FALL_BACK: 0.34,
81
+ T_SATISFIED: 0.72,
82
+ T_HAT_DROP: 0.72,
83
+ T_MASK_SLIDE: 0.72,
84
+ T_SETTLE: 0.55,
85
+ T_LAMP_FADE_OUT: 0.35, // 灯渐隐时长, 起点 = satisfied 结束
86
+
87
+ // notice 段眼睛朝灯方向偏移
88
+ noticeEyeOffsetX: -1.6,
89
+ noticeEyeOffsetY: -1.4,
90
+ noticeBodyTilt: -6,
91
+
92
+ // 跳跃路径 (云宝从中心跳到 capsule 下方)
93
+ jumpTx: -8.7,
94
+ jumpTy: -4.0, // 头顶进入灯底, 由接触形变表现压扁
95
+ crouchScaleY: 0.88,
96
+ jumpUpScaleY: 1.08,
97
+ impactScaleY: 0.88,
98
+
99
+ // 灯位置
100
+ lampCx: 3,
101
+ lampCy: -3,
102
+ lampImpactDip: 0.90, // 撞击瞬间灯 dip 峰值
103
+
104
+ // toggle knob X 偏移 (相对 capsule 中心, 跟原版 sleeping toggle 锁定值)
105
+ toggleDayKnobOffsetX: -3,
106
+ toggleNightKnobOffsetX: -0.2,
107
+
108
+ // 满意眼 (happy 微笑弧 锁定值)
109
+ smileH: 1.0,
110
+ smileW: 3.5,
111
+
112
+ // 帽子起点
113
+ hatStartTx: 3,
114
+ hatStartTy: -15,
115
+ hatStartRot: -30,
116
+ hatStartScale: 0.7,
117
+
118
+ // 眼罩起点
119
+ maskStartTx: 8,
120
+ maskStartTy: -4.5,
121
+ maskStartRot: 14,
122
+
123
+ // sleeping 锁定终点
124
+ sleepingBodyTilt: -6,
125
+ sleepingHatTx: -2,
126
+ sleepingHatTy: -0.4,
127
+ sleepingHatRot: -10,
128
+ sleepingHatScale: 0.98,
129
+ sleepingMaskTx: 0,
130
+ sleepingMaskTy: -0.6,
131
+ sleepingMaskRot: -9,
132
+ sleepingMaskScale: 1.0,
133
+ };
134
+
135
+ let params = { ...DEFAULTS };
136
+ let paused = false;
137
+ let scrubbing = false;
138
+
139
+ /* =========================================================================
140
+ * defs (复用 v1)
141
+ * ========================================================================= */
142
+ function commonDefs(p, cloudD = makeSoftCloudPath(0)) {
143
+ return `
144
+ <linearGradient id="${p}-body" x1="12" y1="2" x2="12" y2="22" gradientUnits="userSpaceOnUse">
145
+ <stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="#e0f2fe"/><stop offset="1" stop-color="#bae6fd"/>
146
+ </linearGradient>
147
+ <linearGradient id="${p}-eye" x1="0" y1="3" x2="0" y2="21" gradientUnits="userSpaceOnUse">
148
+ <stop offset="0" stop-color="#B1A7FF"/><stop offset="0.5" stop-color="#7A9DFF"/><stop offset="1" stop-color="#3941FF"/>
149
+ </linearGradient>
150
+ <radialGradient id="${p}-hi" cx="0.45" cy="0.25" r="0.55">
151
+ <stop offset="0" stop-color="#fff" stop-opacity="0.9"/><stop offset="1" stop-color="#fff" stop-opacity="0"/>
152
+ </radialGradient>
153
+ <linearGradient id="${p}-hat" x1="11" y1="-3" x2="14" y2="6" gradientUnits="userSpaceOnUse">
154
+ <stop offset="0" stop-color="#7AA0FF"/><stop offset="0.45" stop-color="#3941FF"/><stop offset="1" stop-color="#1F2BC2"/>
155
+ </linearGradient>
156
+ <linearGradient id="${p}-cuff" x1="12" y1="3" x2="12" y2="8" gradientUnits="userSpaceOnUse">
157
+ <stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="#fafbff"/><stop offset="1" stop-color="#e8ecf6"/>
158
+ </linearGradient>
159
+ <linearGradient id="${p}-star-yellow" x1="0" y1="-2" x2="0" y2="2" gradientUnits="userSpaceOnUse">
160
+ <stop offset="0" stop-color="#FFE9A8"/><stop offset="0.5" stop-color="#FFD360"/><stop offset="1" stop-color="#F5A623"/>
161
+ </linearGradient>
162
+ <filter id="${p}-star-glow" x="-80%" y="-80%" width="260%" height="260%">
163
+ <feDropShadow dx="0" dy="0.4" stdDeviation="0.5" flood-color="#F5A623" flood-opacity="0.32"/>
164
+ </filter>
165
+ <linearGradient id="${p}-z-grad" x1="0" y1="0" x2="0" y2="1">
166
+ <stop offset="0" stop-color="#B1A7FF"/><stop offset="0.5" stop-color="#7A9DFF"/><stop offset="1" stop-color="#3941FF"/>
167
+ </linearGradient>
168
+ <filter id="${p}-z-glow" x="-50%" y="-50%" width="200%" height="200%">
169
+ <feDropShadow dx="0" dy="0.3" stdDeviation="0.35" flood-color="#7A9DFF" flood-opacity="0.45"/>
170
+ </filter>
171
+ <filter id="${p}-shadow" x="-50%" y="-50%" width="200%" height="200%">
172
+ <feDropShadow class="cloud-drop-shadow" dx="0" dy="0.6" stdDeviation="0.8" flood-color="#3941FF" flood-opacity="0.18"/>
173
+ </filter>
174
+ <filter id="${p}-edge-blur" x="-40%" y="-40%" width="180%" height="180%">
175
+ <feGaussianBlur stdDeviation="0.16"/>
176
+ </filter>
177
+ <filter id="${p}-soft" x="-30%" y="-30%" width="160%" height="160%">
178
+ <feGaussianBlur stdDeviation="0.18"/>
179
+ </filter>
180
+ <linearGradient id="${p}-toggle-day" x1="0" y1="0" x2="0" y2="1">
181
+ <stop offset="0" stop-color="#E5EDFF"/><stop offset="1" stop-color="#BFD6FF"/>
182
+ </linearGradient>
183
+ <linearGradient id="${p}-toggle-night" x1="0" y1="0" x2="0" y2="1">
184
+ <stop offset="0" stop-color="#2A3554"/><stop offset="1" stop-color="#0F1428"/>
185
+ </linearGradient>
186
+ <clipPath id="${p}-clip"><path d="${cloudD}"/></clipPath>
187
+ `;
188
+ }
189
+
190
+ function makeFluffyCuff(p, cx, cy, rx, ry, asym, bumps) {
191
+ let parts = [];
192
+ const circles = [];
193
+ for (let i = 0; i < bumps; i++) {
194
+ const ang = (i * 2 * Math.PI) / bumps - Math.PI / 2;
195
+ const x = cx + Math.cos(ang) * rx;
196
+ const y = cy + Math.sin(ang) * ry;
197
+ const sizeY = -Math.sin(ang);
198
+ const sizeBoost = 0.18 * (sizeY + 1) / 2 + 0.10;
199
+ const jitter = ((i * 13) % 7) / 35;
200
+ let r = 0.62 + sizeBoost + jitter;
201
+ if (asym !== 0) r *= 1 + asym * Math.cos(ang) * 0.28;
202
+ circles.push({ x, y, r });
203
+ }
204
+ const innerRy = ry + 0.40;
205
+ parts.push(`<g filter="url(#${p}-soft)">`);
206
+ parts.push(`<ellipse cx="${cx}" cy="${cy}" rx="${(rx+0.30).toFixed(2)}" ry="${(innerRy+0.30).toFixed(2)}" fill="#7A9DFF" opacity="0.50"/>`);
207
+ circles.forEach(c => parts.push(`<circle cx="${c.x.toFixed(2)}" cy="${c.y.toFixed(2)}" r="${(c.r+0.30).toFixed(2)}" fill="#7A9DFF" opacity="0.50"/>`));
208
+ parts.push(`</g>`);
209
+ parts.push(`<ellipse cx="${cx}" cy="${cy}" rx="${(rx+0.15).toFixed(2)}" ry="${(innerRy+0.15).toFixed(2)}" fill="#aab3c6" opacity="0.65"/>`);
210
+ circles.forEach(c => parts.push(`<circle cx="${c.x.toFixed(2)}" cy="${c.y.toFixed(2)}" r="${(c.r+0.15).toFixed(2)}" fill="#aab3c6" opacity="0.65"/>`));
211
+ parts.push(`<ellipse cx="${cx}" cy="${cy}" rx="${rx.toFixed(2)}" ry="${innerRy.toFixed(2)}" fill="url(#${p}-cuff)"/>`);
212
+ circles.forEach(c => parts.push(`<circle cx="${c.x.toFixed(2)}" cy="${c.y.toFixed(2)}" r="${c.r.toFixed(2)}" fill="url(#${p}-cuff)"/>`));
213
+ parts.push(`<ellipse cx="${cx}" cy="${(cy-ry*0.45).toFixed(2)}" rx="${(rx*0.78).toFixed(2)}" ry="${(ry*0.32).toFixed(2)}" fill="none" stroke="#fff" stroke-opacity="0.55" stroke-width="0.14"/>`);
214
+ return parts.join('');
215
+ }
216
+
217
+ function makeBentHat(bend) {
218
+ const elbowX = 12 + bend * 2.2;
219
+ const elbowY = -0.5 + bend * 2.3;
220
+ const tipX = 11 - bend * 5.5;
221
+ const tipY = 0.0 + bend * 0.5;
222
+ const tipBottomX = tipX + 1.2 + bend * 0.4;
223
+ const tipBottomY = tipY + 1.4 + bend * 0.3;
224
+ return {
225
+ path: `M 8.5 5.0 Q 12 5.55 15.5 5.0 C 16.0 3.5 ${(elbowX+0.8).toFixed(2)} ${(elbowY+1.0).toFixed(2)} ${elbowX.toFixed(2)} ${elbowY.toFixed(2)} C ${(elbowX-0.5).toFixed(2)} ${(elbowY-0.8).toFixed(2)} ${(tipX+1.5).toFixed(2)} ${(tipY-0.5).toFixed(2)} ${tipX.toFixed(2)} ${tipY.toFixed(2)} C ${(tipX-0.3).toFixed(2)} ${(tipY+0.6).toFixed(2)} ${(tipBottomX-1.0).toFixed(2)} ${tipBottomY.toFixed(2)} ${tipBottomX.toFixed(2)} ${tipBottomY.toFixed(2)} C 8.5 4.0 8.0 4.5 8.5 5.0 Z`
226
+ };
227
+ }
228
+
229
+ function makeStar(cx, cy, r, rotationDeg, cornerRound, innerRatio) {
230
+ const innerR = r * innerRatio;
231
+ const rotRad = (rotationDeg * Math.PI) / 180;
232
+ const verts = [];
233
+ for (let i = 0; i < 10; i++) {
234
+ const ang = (i * Math.PI / 5) + rotRad - Math.PI / 2;
235
+ const radius = i % 2 === 0 ? r : innerR;
236
+ verts.push({ x: cx + Math.cos(ang) * radius, y: cy + Math.sin(ang) * radius });
237
+ }
238
+ const inP = [], outP = [];
239
+ for (let i = 0; i < 10; i++) {
240
+ const prev = verts[(i-1+10)%10], cur = verts[i], next = verts[(i+1)%10];
241
+ const v1x = prev.x - cur.x, v1y = prev.y - cur.y;
242
+ const v1Len = Math.hypot(v1x, v1y);
243
+ const v2x = next.x - cur.x, v2y = next.y - cur.y;
244
+ const v2Len = Math.hypot(v2x, v2y);
245
+ const cr = Math.min(cornerRound, v1Len*0.48, v2Len*0.48);
246
+ inP[i] = { x: cur.x + (v1x/v1Len)*cr, y: cur.y + (v1y/v1Len)*cr };
247
+ outP[i] = { x: cur.x + (v2x/v2Len)*cr, y: cur.y + (v2y/v2Len)*cr };
248
+ }
249
+ let path = `M ${inP[0].x.toFixed(2)} ${inP[0].y.toFixed(2)} `;
250
+ for (let i = 0; i < 10; i++) {
251
+ path += `Q ${verts[i].x.toFixed(2)} ${verts[i].y.toFixed(2)} ${outP[i].x.toFixed(2)} ${outP[i].y.toFixed(2)} `;
252
+ path += `L ${inP[(i+1)%10].x.toFixed(2)} ${inP[(i+1)%10].y.toFixed(2)} `;
253
+ }
254
+ return path + 'Z';
255
+ }
256
+
257
+ const SLEEP_MASK_PATH = {
258
+ d: "M488.728,341.587l-7.846-2.599c-0.405-16.992-7.605-47.34-48.625-70.941c-38.301-22.036-96.652-33.215-173.431-33.224c-76.822,0-135.146,11.165-173.438,33.183c-42.335,24.344-48.659,55.883-48.661,72.516c0,2.608,0.135,5.183,0.352,7.735h-2.649c-0.001,0-0.001,0-0.002,0c-9.635,0-17.446,7.811-17.448,17.445c-0.001,9.636,7.81,17.449,17.445,17.45l13,0.001c15.257,28.43,45.267,47.814,79.728,47.818c20.016-0.001,36.647-11.511,54.256-23.695c20.65-14.29,44.048-30.481,77.388-30.481c33.359,0.003,56.757,16.204,77.402,30.497c17.61,12.192,34.24,23.707,54.251,23.709h0.012c24.153,0,46.861-9.404,63.941-26.481c8.937-8.936,15.751-19.422,20.239-30.839l3.111,1.031c1.82,0.604,3.67,0.89,5.489,0.89c7.318,0,14.133-4.64,16.56-11.966C502.835,354.489,497.876,344.617,488.728,341.587Z",
259
+ bboxX: 15, bboxY: 232, bboxW: 487, bboxH: 200
260
+ };
261
+
262
+ function makeSleepMask(p, tx, ty, rot, scale) {
263
+ const cx = 12 + tx, cy = 12 + ty;
264
+ const W = 16.61 * scale, H = 7.55 * scale;
265
+ const sx = W / SLEEP_MASK_PATH.bboxW, sy = H / SLEEP_MASK_PATH.bboxH;
266
+ const cxOrig = SLEEP_MASK_PATH.bboxX + SLEEP_MASK_PATH.bboxW/2;
267
+ const cyOrig = SLEEP_MASK_PATH.bboxY + SLEEP_MASK_PATH.bboxH/2;
268
+ const body = `<g transform="translate(${cx} ${cy}) scale(${sx.toFixed(5)} ${sy.toFixed(5)}) translate(${(-cxOrig).toFixed(2)} ${(-cyOrig).toFixed(2)})">
269
+ <path d="${SLEEP_MASK_PATH.d}" fill="#22305A" stroke="#000" stroke-width="0.22" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>
270
+ </g>`;
271
+ const lashW = 2.45, lashH = 0.70;
272
+ const arc = `<path d="M ${(-lashW/2).toFixed(2)} 0 Q 0 ${(lashH*2).toFixed(2)} ${(lashW/2).toFixed(2)} 0" fill="none" stroke="#fff" stroke-width="0.45" stroke-linecap="round"/>`;
273
+ const hairs = [];
274
+ for (let i = 0; i < 3; i++) {
275
+ const t = 0.18 + (i/2) * 0.64;
276
+ const sxh = (1-t)*(1-t)*(-lashW/2) + t*t*(lashW/2);
277
+ const syh = 2*(1-t)*t*(2*lashH);
278
+ const tangX = 2*(1-t)*(lashW/2) + 2*t*(lashW/2);
279
+ const tangY = 2*(1-t)*(2*lashH) - 2*t*(2*lashH);
280
+ const tLen = Math.hypot(tangX, tangY);
281
+ const dirX = tangY/tLen, dirY = -tangX/tLen;
282
+ const useDown = dirY > 0 ? 1 : -1;
283
+ const ex = sxh + dirX * 0.66 * useDown, ey = syh + dirY * 0.66 * useDown;
284
+ hairs.push(`<line x1="${sxh.toFixed(2)}" y1="${syh.toFixed(2)}" x2="${ex.toFixed(2)}" y2="${ey.toFixed(2)}" stroke="#fff" stroke-width="0.38" stroke-linecap="round" opacity="0.92"/>`);
285
+ }
286
+ const lashGap = 6.0 * scale;
287
+ const wrapEye = (ecx, sign) => `<g transform="translate(${ecx.toFixed(2)} ${(cy-0.25).toFixed(2)}) rotate(${(sign*5).toFixed(2)}) scale(1.18)">${arc}${hairs.join('')}</g>`;
288
+ const lashes = wrapEye(cx - lashGap/2, -1) + wrapEye(cx + lashGap/2, +1);
289
+ return `<g class="sleep-mask" transform="rotate(${rot} ${cx} ${cy})" filter="url(#${p}-shadow)">${body}${lashes}</g>`;
290
+ }
291
+
292
+ /* =========================================================================
293
+ * 眼睛 (多模式: capsule / arrows / smile / closed)
294
+ * ========================================================================= */
295
+ function makeEyes(p, mode, openness, offX, offY) {
296
+ const lcx = 9, rcx = 15, cy = 12; // 双眼默认位置
297
+ let lEye, rEye;
298
+
299
+ if (mode === 'arrows') {
300
+ // > < 用力表情 (左眼 > 朝右开, 右眼 < 朝左开)
301
+ // 用 typing spec stroke 1.25 + linecap=round, 字符尺寸 1.40
302
+ const sz = 1.4;
303
+ // > path (左眼): 左上 → 中右 → 左下
304
+ const gtPath = `M ${(-sz*0.7).toFixed(2)} ${(-sz*0.85).toFixed(2)} L ${(sz*0.7).toFixed(2)} 0 L ${(-sz*0.7).toFixed(2)} ${(sz*0.85).toFixed(2)}`;
305
+ // < path (右眼): 右上 → 中左 → 右下
306
+ const ltPath = `M ${(sz*0.7).toFixed(2)} ${(-sz*0.85).toFixed(2)} L ${(-sz*0.7).toFixed(2)} 0 L ${(sz*0.7).toFixed(2)} ${(sz*0.85).toFixed(2)}`;
307
+ lEye = `<g transform="translate(${lcx} ${cy})"><path d="${gtPath}" fill="none" stroke="url(#${p}-eye)" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/></g>`;
308
+ rEye = `<g transform="translate(${rcx} ${cy})"><path d="${ltPath}" fill="none" stroke="url(#${p}-eye)" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/></g>`;
309
+ } else if (mode === 'smile') {
310
+ // happy 微笑弧 h=1.0 w=3.5 stroke=1.25 cy=12.7
311
+ const w = params.smileW, h = params.smileH;
312
+ const smileCY = 12.7;
313
+ const smilePath = (cx) => `M ${(cx-w/2).toFixed(2)} ${smileCY} Q ${cx} ${(smileCY-2*h).toFixed(2)} ${(cx+w/2).toFixed(2)} ${smileCY}`;
314
+ lEye = `<path d="${smilePath(lcx)}" fill="none" stroke="url(#${p}-eye)" stroke-width="1.25" stroke-linecap="round"/>`;
315
+ rEye = `<path d="${smilePath(rcx)}" fill="none" stroke="url(#${p}-eye)" stroke-width="1.25" stroke-linecap="round"/>`;
316
+ } else {
317
+ // capsule (default) 或 closed (h 极小)
318
+ const baseH = 5;
319
+ const minH = 0.3;
320
+ const h = Math.max(minH, baseH * openness);
321
+ const y = 9.5 + (baseH - h) / 2;
322
+ const rx = Math.min(1.3, h / 2);
323
+ lEye = `<rect x="7.7" y="${y.toFixed(2)}" width="2.6" height="${h.toFixed(2)}" rx="${rx.toFixed(2)}" ry="${rx.toFixed(2)}" fill="url(#${p}-eye)" stroke="#fff" stroke-width="0.08" stroke-opacity="0.7"/>`;
324
+ rEye = `<rect x="13.7" y="${y.toFixed(2)}" width="2.6" height="${h.toFixed(2)}" rx="${rx.toFixed(2)}" ry="${rx.toFixed(2)}" fill="url(#${p}-eye)" stroke="#fff" stroke-width="0.08" stroke-opacity="0.7"/>`;
325
+ }
326
+
327
+ return `<g class="eyes" transform="translate(${offX.toFixed(2)} ${offY.toFixed(2)})">${lEye}${rEye}</g>`;
328
+ }
329
+
330
+ function makeCloudling(p, eyeMode, eyeOpenness, eyeOffX, eyeOffY) {
331
+ return `
332
+ <g class="cloudling-base">
333
+ <g class="edge-system">
334
+ <use class="edge-contact" href="#${p}-cloud-shape" fill="none" stroke="#7d8aa7" stroke-width="1.05" stroke-linejoin="round" transform="translate(0 0.18)" filter="url(#${p}-edge-blur)"/>
335
+ <use class="edge-rim-soft" href="#${p}-cloud-shape" fill="none" stroke="#7A9DFF" stroke-width="0.90" stroke-linejoin="round" filter="url(#${p}-edge-blur)"/>
336
+ <use class="edge-rim-tight" href="#${p}-cloud-shape" fill="none" stroke="#aab3c6" stroke-width="0.42" stroke-linejoin="round"/>
337
+ </g>
338
+ <g filter="url(#${p}-shadow)">
339
+ <use href="#${p}-cloud-shape" fill="url(#${p}-body)"/>
340
+ <rect x="1" y="2" width="22" height="11" fill="url(#${p}-hi)" clip-path="url(#${p}-clip)"/>
341
+ </g>
342
+ ${makeEyes(p, eyeMode, eyeOpenness, eyeOffX, eyeOffY)}
343
+ </g>
344
+ `;
345
+ }
346
+
347
+ const STAR_LOCKED = { cx: 8.55, cy: 0.35, size: 1.00, rotation: 30, cornerRound: 0.27, innerRatio: 0.56 };
348
+
349
+ function makeHat(p, tx, ty, rot, scale) {
350
+ const hatData = makeBentHat(0.4);
351
+ const cuffSVG = makeFluffyCuff(p, 12, 5.4, 3.7, 0.85, 0, 18);
352
+ const s = STAR_LOCKED;
353
+ const starPath = makeStar(s.cx, s.cy, s.size, s.rotation, s.cornerRound, s.innerRatio);
354
+ return `
355
+ <g class="hat-piece" transform="translate(${tx} ${ty}) rotate(${rot} 12 5.4) scale(${scale})">
356
+ <g filter="url(#${p}-shadow)"><path d="${hatData.path}" fill="url(#${p}-hat)"/></g>
357
+ <g class="cuff-fluffy">${cuffSVG}</g>
358
+ <g class="star-piece" filter="url(#${p}-star-glow)">
359
+ <path d="${starPath}" fill="url(#${p}-star-yellow)" stroke="#fff" stroke-width="0.06" stroke-opacity="0.7" stroke-linejoin="round"/>
360
+ </g>
361
+ </g>
362
+ `;
363
+ }
364
+
365
+ function makeZPath() {
366
+ return "M -1.0 -1.0 Q 0.0 -1.0 1.0 -1.0 Q 1.05 -0.85 0.6 -0.4 Q 0 0.1 -0.6 0.6 Q -1.05 0.95 -1.0 1.0 Q 0.0 1.0 1.0 1.0";
367
+ }
368
+
369
+ function makeLamp(p, cx, cy, dipY, toggleMix, dayOff, nightOff, opacity, scale, knockRot) {
370
+ const ccy = cy + dipY;
371
+ const mix = clamp01(toggleMix);
372
+ const dayOpacity = 1 - mix;
373
+ const nightOpacity = mix;
374
+ const dayKnobOpacity = clamp01(1 - mix * 2.2);
375
+ const nightKnobOpacity = clamp01((mix - 0.18) / 0.82);
376
+ const knobX = cx + lerp(dayOff, nightOff, mix);
377
+ const knobY = ccy;
378
+
379
+ const dayKnob = `
380
+ <g opacity="${dayKnobOpacity.toFixed(2)}">
381
+ <circle cx="${knobX.toFixed(2)}" cy="${knobY.toFixed(2)}" r="2.4" fill="#fff" filter="url(#${p}-shadow)"/>
382
+ <circle cx="${(knobX+0.85).toFixed(2)}" cy="${(knobY-0.95).toFixed(2)}" r="0.50" fill="#D4DFFF" opacity="0.78"/>
383
+ <circle cx="${(knobX+1.15).toFixed(2)}" cy="${(knobY+0.55).toFixed(2)}" r="0.28" fill="#D4DFFF" opacity="0.72"/>
384
+ </g>
385
+ `;
386
+
387
+ const outerR = 2.35, innerR = 1.70, innerDx = 0.80;
388
+ const ix = (outerR*outerR - innerR*innerR + innerDx*innerDx) / (2*innerDx);
389
+ const iy = Math.sqrt(outerR*outerR - ix*ix);
390
+ const nightKnob = `
391
+ <g class="crescent-knob" filter="url(#${p}-shadow)" opacity="${nightKnobOpacity.toFixed(2)}">
392
+ <path d="
393
+ M ${(knobX+ix).toFixed(3)} ${(knobY-iy).toFixed(3)}
394
+ A ${outerR} ${outerR} 0 1 1 ${(knobX+ix).toFixed(3)} ${(knobY+iy).toFixed(3)}
395
+ A ${innerR} ${innerR} 0 1 0 ${(knobX+ix).toFixed(3)} ${(knobY-iy).toFixed(3)}
396
+ Z
397
+ " fill="#fff"/>
398
+ </g>
399
+ `;
400
+
401
+ const stars = `
402
+ <g class="stars" opacity="${nightOpacity.toFixed(2)}">
403
+ <circle cx="${(cx-1.5).toFixed(2)}" cy="${(ccy-0.9).toFixed(2)}" r="0.18" fill="#fff" opacity="0.95"/>
404
+ <circle cx="${(cx-0.5).toFixed(2)}" cy="${(ccy+1.3).toFixed(2)}" r="0.14" fill="#fff" opacity="0.85"/>
405
+ <circle cx="${(cx+0.6).toFixed(2)}" cy="${(ccy-0.4).toFixed(2)}" r="0.10" fill="#fff" opacity="0.75"/>
406
+ <circle cx="${(cx-1.2).toFixed(2)}" cy="${(ccy+1.6).toFixed(2)}" r="0.10" fill="#fff" opacity="0.78"/>
407
+ <circle cx="${(cx).toFixed(2)}" cy="${(ccy-1.3).toFixed(2)}" r="0.13" fill="#fff" opacity="0.88"/>
408
+ </g>
409
+ `;
410
+
411
+ return `
412
+ <g class="lamp-wrapper" opacity="${opacity.toFixed(2)}" transform="translate(${cx} ${ccy}) rotate(${knockRot.toFixed(3)}) scale(${scale.toFixed(3)}) translate(${-cx} ${-ccy})">
413
+ <g class="lamp-group" filter="url(#${p}-shadow)">
414
+ <rect class="toggle-capsule-stroke" x="${(cx-7).toFixed(2)}" y="${(ccy-3).toFixed(2)}" width="14" height="6" rx="3" ry="3" fill="url(#${p}-toggle-day)" opacity="${dayOpacity.toFixed(2)}"/>
415
+ <rect class="toggle-capsule-stroke" x="${(cx-7).toFixed(2)}" y="${(ccy-3).toFixed(2)}" width="14" height="6" rx="3" ry="3" fill="url(#${p}-toggle-night)" opacity="${nightOpacity.toFixed(2)}"/>
416
+ <rect x="${(cx-7).toFixed(2)}" y="${(ccy-3).toFixed(2)}" width="14" height="6" rx="3" ry="3" fill="none" stroke="#fff" stroke-opacity="0.18" stroke-width="0.12"/>
417
+ </g>
418
+ ${stars}
419
+ ${dayKnob}
420
+ ${nightKnob}
421
+ </g>
422
+ `;
423
+ }
424
+
425
+ /* =========================================================================
426
+ * 时间线 + 插值
427
+ * ========================================================================= */
428
+ function lerp(a, b, t) { return a + (b - a) * t; }
429
+ function easeOut(t) { return 1 - Math.pow(1 - t, 2); }
430
+ function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
431
+ function easeOutBack(t) {
432
+ const c1 = 1.45;
433
+ const c3 = c1 + 1;
434
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
435
+ }
436
+ function easeIn(t) { return t * t; }
437
+ function easeInOut(t) { return t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t+2, 2)/2; }
438
+ function clamp01(t) { return Math.max(0, Math.min(1, t)); }
439
+ function progress(t, begin, end) { return clamp01((t - begin) / (end - begin)); }
440
+
441
+ function getTimeline() {
442
+ const tLampEnd = params.T_LAMP_APPEAR;
443
+ const tNoticeEnd = params.T_NOTICE_END;
444
+ const tCrouchStart = tNoticeEnd;
445
+ const tCrouchEnd = tCrouchStart + params.T_CROUCH;
446
+ const tJumpUpEnd = tCrouchEnd + params.T_JUMP_UP;
447
+ const tImpactStart = tJumpUpEnd;
448
+ const tContactStart = Math.max(tCrouchEnd, tImpactStart - params.T_CONTACT_LEAD);
449
+ const tImpactEnd = tImpactStart + params.T_IMPACT_HOLD;
450
+ const tFallEnd = tImpactEnd + params.T_FALL_BACK;
451
+ const tSatisfiedEnd = tFallEnd + params.T_SATISFIED;
452
+ const tHatDropEnd = tSatisfiedEnd + params.T_HAT_DROP;
453
+ const tMaskSlideEnd = tHatDropEnd + params.T_MASK_SLIDE;
454
+ const tSettleEnd = tMaskSlideEnd + params.T_SETTLE;
455
+ return {
456
+ tLampEnd, tNoticeBegin: params.T_NOTICE_BEGIN, tNoticeEnd,
457
+ tCrouchStart, tCrouchEnd,
458
+ tJumpUpEnd, tContactStart, tImpactStart, tImpactEnd,
459
+ tFallEnd, tSatisfiedEnd,
460
+ tHatDropEnd, tMaskSlideEnd, tSettleEnd
461
+ };
462
+ }
463
+
464
+ function computeFrame(t) {
465
+ const T = getTimeline();
466
+ const f = {};
467
+
468
+ // ---------- 灯渐显 → 满意后渐隐 ----------
469
+ const tFadeStart = T.tSatisfiedEnd;
470
+ const tFadeEnd = tFadeStart + params.T_LAMP_FADE_OUT;
471
+ if (t < params.T_LAMP_APPEAR) {
472
+ // 渐显 (lamp_appear 段)
473
+ const p = easeOut(progress(t, 0, params.T_LAMP_APPEAR));
474
+ f.lampOpacity = p;
475
+ f.lampScale = lerp(0.85, 1.0, p);
476
+ } else if (t < tFadeStart) {
477
+ // 全显 (lamp_appear 完成 → satisfied 结束)
478
+ f.lampOpacity = 1;
479
+ f.lampScale = 1;
480
+ } else if (t < tFadeEnd) {
481
+ // 渐隐 (满意后 → 完全消失)
482
+ const p = easeIn(progress(t, tFadeStart, tFadeEnd));
483
+ f.lampOpacity = lerp(1, 0, p);
484
+ f.lampScale = 1;
485
+ } else {
486
+ // 完全消失
487
+ f.lampOpacity = 0;
488
+ f.lampScale = 1;
489
+ }
490
+ f.lampScalePulse = 1;
491
+ f.lampRot = 0;
492
+ let contactAmount = 0;
493
+ if (t >= T.tContactStart && t < T.tImpactStart) {
494
+ contactAmount = lerp(0, 0.82, easeInOut(progress(t, T.tContactStart, T.tImpactStart)));
495
+ } else if (t >= T.tImpactStart && t < T.tImpactEnd) {
496
+ contactAmount = lerp(0.82, 1, easeOut(progress(t, T.tImpactStart, T.tImpactEnd)));
497
+ } else if (t >= T.tImpactEnd && t < T.tFallEnd) {
498
+ contactAmount = 1 - easeInOut(progress(t, T.tImpactEnd, T.tFallEnd));
499
+ }
500
+ f.contactAmount = contactAmount;
501
+ f.pressAmount = t < T.tImpactStart && contactAmount > 0
502
+ ? Math.sqrt(contactAmount / 0.82) * 0.82
503
+ : contactAmount;
504
+
505
+ // ---------- cloudling 整体 transform ----------
506
+ let cloudTx = 0, cloudTy = 0, cloudRot = 0, cloudScaleX = 1, cloudScaleY = 1;
507
+
508
+ if (t < T.tNoticeBegin) {
509
+ // idle 默认
510
+ } else if (t < T.tNoticeEnd) {
511
+ // notice: 微歪头朝灯方向
512
+ const p = easeOut(progress(t, T.tNoticeBegin, T.tNoticeEnd));
513
+ cloudRot = lerp(0, params.noticeBodyTilt, p);
514
+ } else if (t < T.tCrouchEnd) {
515
+ // crouch: scaleY 压扁
516
+ const p = easeIn(progress(t, T.tCrouchStart, T.tCrouchEnd));
517
+ cloudTy = lerp(0, 0.45, p);
518
+ cloudScaleY = lerp(1, params.crouchScaleY, p);
519
+ cloudRot = params.noticeBodyTilt;
520
+ } else if (t < T.tJumpUpEnd) {
521
+ // jump up: 斜向上弧线顶到 capsule, 不是直线飘过去
522
+ const p = easeOutCubic(progress(t, T.tCrouchEnd, T.tJumpUpEnd));
523
+ cloudTx = lerp(0, params.jumpTx, p);
524
+ cloudTy = lerp(0.45, params.jumpTy, p) - Math.sin(p * Math.PI) * 0.85;
525
+ const jumpScaleY = lerp(params.crouchScaleY, params.jumpUpScaleY, p);
526
+ cloudScaleX = lerp(1, 1.13, f.pressAmount);
527
+ cloudScaleY = lerp(jumpScaleY, params.impactScaleY, f.pressAmount);
528
+ cloudRot = lerp(params.noticeBodyTilt, 0, p); // 起跳过程歪头回正
529
+ } else if (t < T.tImpactEnd) {
530
+ // impact: 撞 capsule, 继续加深压扁, 不硬切
531
+ cloudTx = params.jumpTx;
532
+ cloudTy = params.jumpTy;
533
+ cloudScaleX = lerp(1, 1.13, f.pressAmount);
534
+ cloudScaleY = lerp(params.jumpUpScaleY, params.impactScaleY, f.pressAmount);
535
+ cloudRot = 0;
536
+ } else if (t < T.tFallEnd) {
537
+ // fall back: 从顶位回到中心, 压扁量逐渐释放
538
+ const p = easeInOut(progress(t, T.tImpactEnd, T.tFallEnd));
539
+ cloudTx = lerp(params.jumpTx, 0, p);
540
+ cloudTy = lerp(params.jumpTy, 0, p) + Math.sin(p * Math.PI) * 0.55;
541
+ cloudScaleX = lerp(1, 1.13, f.pressAmount);
542
+ cloudScaleY = lerp(1, params.impactScaleY, f.pressAmount);
543
+ } else if (t < T.tSatisfiedEnd) {
544
+ // satisfied: 落地后的满足小弹跳, 给表情留时间
545
+ const p = progress(t, T.tFallEnd, T.tSatisfiedEnd);
546
+ const settleBounce = Math.sin(p * Math.PI * 2) * Math.exp(-p * 3.2);
547
+ cloudTy = settleBounce * 0.22;
548
+ cloudRot = Math.sin(p * Math.PI) * 1.4;
549
+ } else {
550
+ // hat/mask/settle: bodyTilt 渐进到 sleeping -6°
551
+ const p = easeInOut(progress(t, T.tSatisfiedEnd, T.tSettleEnd));
552
+ cloudRot = lerp(0, params.sleepingBodyTilt, p);
553
+ }
554
+
555
+ f.cloudTx = cloudTx;
556
+ f.cloudTy = cloudTy;
557
+ f.cloudRot = cloudRot;
558
+ f.cloudScaleX = cloudScaleX;
559
+ f.cloudScaleY = cloudScaleY;
560
+
561
+ // ---------- 灯反馈: 跟 contactAmount 同步渐进下压, 离开后轻微余震 ----------
562
+ f.lampDipY = params.lampImpactDip * f.pressAmount;
563
+ if (t >= T.tContactStart && t < T.tFallEnd + 0.35) {
564
+ const contactShake = t < T.tImpactStart
565
+ ? -1.0 * contactAmount
566
+ : Math.sin((t - T.tImpactStart) * Math.PI * 12) * 3.8 * Math.exp(-(t - T.tImpactStart) * 4.6);
567
+ f.lampRot = contactShake;
568
+ if (t >= T.tImpactStart) {
569
+ const dt = t - T.tImpactStart;
570
+ f.lampScalePulse = 1 + Math.max(0, Math.sin(dt * Math.PI * 4.5)) * 0.035 * Math.exp(-dt * 5);
571
+ }
572
+ if (t >= T.tFallEnd) {
573
+ const rt = t - T.tFallEnd;
574
+ f.lampDipY += params.lampImpactDip * 0.08 * Math.exp(-rt * 8) * Math.sin(rt * Math.PI * 10);
575
+ f.lampRot = Math.sin(rt * Math.PI * 10) * 1.4 * Math.exp(-rt * 8);
576
+ }
577
+ }
578
+
579
+ // ---------- toggle state: 撞击后滑过去, 咔哒感比硬切清楚 ----------
580
+ f.toggleMix = easeOutCubic(progress(t, T.tImpactStart + 0.03, T.tImpactStart + 0.24));
581
+ f.toggleState = f.toggleMix >= 0.5 ? 'night' : 'day';
582
+
583
+ // ---------- 眼睛 mode ----------
584
+ // v7: 接触加深时进入 ><, 落地后给清楚的满意弧线.
585
+ let eyeMode, eyeOpenness = 1, eyeOffX = 0, eyeOffY = 0;
586
+ if (t < T.tNoticeBegin) {
587
+ eyeMode = 'capsule';
588
+ } else if (t < T.tNoticeEnd) {
589
+ // notice: capsule 朝灯偏移
590
+ const p = easeOut(progress(t, T.tNoticeBegin, T.tNoticeEnd));
591
+ eyeMode = 'capsule';
592
+ eyeOffX = lerp(0, params.noticeEyeOffsetX, p);
593
+ eyeOffY = lerp(0, params.noticeEyeOffsetY, p);
594
+ } else if (t < T.tImpactStart && f.contactAmount < 0.42) {
595
+ // crouch + jump up: 保持朝灯偏移, 微微眯眼像在瞄准
596
+ eyeMode = 'capsule';
597
+ eyeOpenness = 0.82;
598
+ eyeOffX = params.noticeEyeOffsetX;
599
+ eyeOffY = params.noticeEyeOffsetY;
600
+ } else if (t < T.tImpactEnd + 0.08) {
601
+ // 接触加深后进入用力表情
602
+ eyeMode = 'arrows';
603
+ } else if (t < T.tFallEnd) {
604
+ // fall_back: 解除用力表情, 避免像被打疼
605
+ eyeMode = 'capsule';
606
+ eyeOpenness = 0.72;
607
+ } else if (t < T.tSatisfiedEnd) {
608
+ // 落地满意 beat
609
+ eyeMode = 'smile';
610
+ } else if (t < T.tMaskSlideEnd) {
611
+ // 戴帽时还保持一点满意, 眼罩靠近时渐困
612
+ const p = progress(t, T.tSatisfiedEnd, T.tMaskSlideEnd);
613
+ if (p < 0.45) {
614
+ eyeMode = 'smile';
615
+ } else {
616
+ eyeMode = 'capsule';
617
+ eyeOpenness = lerp(0.72, 0.18, progress(p, 0.45, 1));
618
+ }
619
+ } else {
620
+ // settle + sleeping loop: 几乎闭眼 (被 mask 遮住)
621
+ eyeMode = 'capsule';
622
+ eyeOpenness = 0.05;
623
+ }
624
+ f.eyeMode = eyeMode;
625
+ f.eyeOpenness = eyeOpenness;
626
+ f.eyeOffX = eyeOffX;
627
+ f.eyeOffY = eyeOffY;
628
+
629
+ // ---------- 帽子 ----------
630
+ if (t < T.tSatisfiedEnd) {
631
+ f.hatVisible = false;
632
+ } else if (t < T.tHatDropEnd) {
633
+ f.hatVisible = true;
634
+ const u = progress(t, T.tSatisfiedEnd, T.tHatDropEnd);
635
+ const p = easeOutBack(u);
636
+ f.hatTx = lerp(params.hatStartTx, params.sleepingHatTx, p);
637
+ f.hatTy = lerp(params.hatStartTy, params.sleepingHatTy, p) - Math.sin(u * Math.PI) * 0.35;
638
+ f.hatRot = lerp(params.hatStartRot, params.sleepingHatRot, p) + Math.sin(u * Math.PI * 2) * 3.5 * (1 - u);
639
+ f.hatScale = lerp(params.hatStartScale, params.sleepingHatScale, p);
640
+ } else {
641
+ f.hatVisible = true;
642
+ f.hatTx = params.sleepingHatTx;
643
+ f.hatTy = params.sleepingHatTy;
644
+ f.hatRot = params.sleepingHatRot;
645
+ f.hatScale = params.sleepingHatScale;
646
+ }
647
+
648
+ // ---------- 眼罩 ----------
649
+ if (t < T.tHatDropEnd) {
650
+ f.maskVisible = false;
651
+ } else if (t < T.tMaskSlideEnd) {
652
+ f.maskVisible = true;
653
+ const u = progress(t, T.tHatDropEnd, T.tMaskSlideEnd);
654
+ const p = easeOutBack(u);
655
+ f.maskTx = lerp(params.maskStartTx, params.sleepingMaskTx, p);
656
+ f.maskTy = lerp(params.maskStartTy, params.sleepingMaskTy, p) - Math.sin(u * Math.PI) * 0.25;
657
+ f.maskRot = lerp(params.maskStartRot, params.sleepingMaskRot, p);
658
+ f.maskScale = params.sleepingMaskScale;
659
+ } else {
660
+ f.maskVisible = true;
661
+ f.maskTx = params.sleepingMaskTx;
662
+ f.maskTy = params.sleepingMaskTy;
663
+ f.maskRot = params.sleepingMaskRot;
664
+ f.maskScale = params.sleepingMaskScale;
665
+ }
666
+
667
+ // ---------- sleeping loop ----------
668
+ f.inSleepingLoop = t >= T.tSettleEnd;
669
+ if (f.inSleepingLoop) {
670
+ const lt = t - T.tSettleEnd;
671
+ const breathPhase = (Math.cos(2 * Math.PI * lt / 4) + 1) / 2;
672
+ f.breathScale = lerp(0.96, 1.02, breathPhase);
673
+ f.hatSwayDeg = Math.sin(2 * Math.PI * lt / 6) * 2.0;
674
+ } else {
675
+ f.breathScale = 1.0;
676
+ f.hatSwayDeg = 0;
677
+ }
678
+
679
+ return f;
680
+ }
681
+
682
+ /* =========================================================================
683
+ * 渲染
684
+ * ========================================================================= */
685
+ const stage = document.getElementById('stage');
686
+
687
+ function renderFrame(t) {
688
+ const f = computeFrame(t);
689
+ const p = 'idl';
690
+
691
+ const lamp = makeLamp(
692
+ p,
693
+ params.lampCx,
694
+ params.lampCy,
695
+ f.lampDipY,
696
+ f.toggleMix,
697
+ params.toggleDayKnobOffsetX,
698
+ params.toggleNightKnobOffsetX,
699
+ f.lampOpacity,
700
+ f.lampScale * f.lampScalePulse,
701
+ f.lampRot
702
+ );
703
+
704
+ const cloudD = makeSoftCloudPath(f.pressAmount);
705
+ const cloudling = makeCloudling(p, f.eyeMode, f.eyeOpenness, f.eyeOffX, f.eyeOffY);
706
+
707
+ const hat = f.hatVisible ? `<g transform="rotate(${f.hatSwayDeg.toFixed(3)} 12 5.4)">${makeHat(p, f.hatTx, f.hatTy, f.hatRot, f.hatScale)}</g>` : '';
708
+ const mask = f.maskVisible ? makeSleepMask(p, f.maskTx, f.maskTy, f.maskRot, f.maskScale) : '';
709
+
710
+ // Z 字 (sleeping loop)
711
+ let zHTML = '';
712
+ if (f.inSleepingLoop) {
713
+ const T = getTimeline();
714
+ const lt = t - T.tSettleEnd;
715
+ const lastSpawnTime = Math.floor(lt / 1.8) * 1.8;
716
+ for (let bornAt = lastSpawnTime; bornAt > lt - 2.9 && bornAt >= 0; bornAt -= 1.8) {
717
+ const age = lt - bornAt;
718
+ if (age < 0 || age >= 2.9) continue;
719
+ const u = age / 2.9;
720
+ const x = 19.4 + 4 * u;
721
+ const y = 3.6 - 8 * u;
722
+ const size = lerp(1.6, 1.3, u);
723
+ let opacity;
724
+ if (u < 0.18) opacity = (u/0.18) * 0.45;
725
+ else if (u < 0.7) opacity = 0.45;
726
+ else opacity = 0.45 * (1 - (u-0.7)/0.3);
727
+ const sway = Math.sin(age * 2.0) * 0.3;
728
+ zHTML += `<g transform="translate(${(x+sway).toFixed(2)} ${y.toFixed(2)}) scale(${size.toFixed(2)})" opacity="${opacity.toFixed(3)}">
729
+ <g filter="url(#${p}-z-glow)">
730
+ <path d="${makeZPath()}" fill="none" stroke="url(#${p}-z-grad)" stroke-width="0.55" stroke-linecap="round" stroke-linejoin="round"/>
731
+ </g>
732
+ </g>`;
733
+ }
734
+ }
735
+
736
+ // squash origin: impact + fall_back 段用云宝头顶 (12, 4.45) 模拟"被灯顶部压扁"
737
+ // 其他段用中心 (12, 12) 正常 squash & stretch
738
+ const T2 = getTimeline();
739
+ const squashOY = (t >= T2.tImpactStart && t < T2.tFallEnd) ? 4.45 : 12;
740
+
741
+ stage.innerHTML = `
742
+ <style>
743
+ .edge-rim-tight { stroke-opacity: .65; }
744
+ .edge-rim-soft { stroke-opacity: .50; }
745
+ .edge-contact { stroke-opacity: .65; }
746
+ .toggle-capsule-stroke { stroke-opacity: 0; }
747
+ @media (prefers-color-scheme: light) {
748
+ .edge-rim-tight { stroke-opacity: .75; }
749
+ .edge-rim-soft { stroke-opacity: .60; }
750
+ .edge-contact { stroke-opacity: .75; }
751
+ .cloud-drop-shadow { flood-opacity: .13; }
752
+ .toggle-capsule-stroke { stroke: #5C7BD9; stroke-width: 0.20; stroke-opacity: 0.55; }
753
+ .food-cloud-shadow { flood-opacity: .28; flood-color: #5060FF; }
754
+ .food-cloud > path {
755
+ stroke: #5870E8 !important;
756
+ stroke-width: 1.2 !important;
757
+ stroke-opacity: .9 !important;
758
+ vector-effect: non-scaling-stroke !important;
759
+ }
760
+ }
761
+ </style>
762
+ <defs>
763
+ ${commonDefs(p, cloudD)}
764
+ <path id="${p}-cloud-shape" d="${cloudD}"/>
765
+ </defs>
766
+ <!-- cloudling 在灯下面 (先渲染, 灯后渲染压在上面) -->
767
+ <g class="scene" transform="translate(${f.cloudTx.toFixed(3)} ${f.cloudTy.toFixed(3)}) rotate(${f.cloudRot.toFixed(3)} 12 12)">
768
+ <g class="squash" transform="translate(12 ${squashOY}) scale(${f.cloudScaleX.toFixed(4)} ${f.cloudScaleY.toFixed(4)}) translate(-12 ${(-squashOY).toFixed(3)})">
769
+ <g class="breath" transform="translate(12 12) scale(${f.breathScale.toFixed(4)}) translate(-12 -12)">
770
+ ${cloudling}
771
+ ${hat}
772
+ ${mask}
773
+ </g>
774
+ </g>
775
+ </g>
776
+ <!-- 灯在云宝上面 (云宝顶到灯底, 灯压云宝). v6 去掉拉绳三角坠. -->
777
+ ${lamp}
778
+ ${zHTML}
779
+ `;
780
+ }
781
+
782
+ /* =========================================================================
783
+ * 主循环 + 控件
784
+ * ========================================================================= */
785
+ let startT = performance.now();
786
+ let elapsedOffset = 0;
787
+ let pausedAt = 0;
788
+
789
+ const scrubberEl = document.getElementById('scrubber');
790
+ const tDisplayEl = document.getElementById('t-display');
791
+
792
+ function getCurrentT() {
793
+ if (scrubbing) return Number(scrubberEl.value);
794
+ if (paused) return elapsedOffset + (pausedAt - startT) / 1000;
795
+ return elapsedOffset + (performance.now() - startT) / 1000;
796
+ }
797
+
798
+ function loop() {
799
+ const t = getCurrentT();
800
+ const T = getTimeline();
801
+ const totalLen = T.tSettleEnd + 5;
802
+ scrubberEl.max = totalLen.toFixed(2);
803
+ if (!scrubbing) scrubberEl.value = Math.min(t, totalLen).toFixed(3);
804
+ tDisplayEl.textContent = `${t.toFixed(2)} s`;
805
+ renderFrame(t);
806
+ requestAnimationFrame(loop);
807
+ }
808
+ renderFrame(0);
809
+ requestAnimationFrame(loop);
810
+ ]]></script>
811
+ </svg>