Ethscriptions commited on
Commit
55303d0
·
1 Parent(s): 6ea47d5

Step 3: 手绘动画引擎 + 15 个场景 + 横竖屏布局

Browse files
README.md CHANGED
@@ -24,7 +24,7 @@ pinned: false
24
  |---|------|------|
25
  | 1 | 写计划 / 大纲 / 步骤(本文件) | ✅ 完成 |
26
  | 2 | 旁白文案定稿 + TTS 生成配音 + 时长表(15 场景 / 77 句 / 约 6 分钟,旁白+商人+助手三种声线) | ✅ 完成 |
27
- | 3 | 动画引擎 + 全部场景(横屏 16:9 / 竖屏 9:16 双布局) | ⏳ |
28
  | 4 | 程序化音乐 + 音效 + 旁白混音(WebAudio 离线渲染) | ⏳ |
29
  | 5 | 播放器 UI + MP4 本地导出(24/30/60fps,横/竖屏) | ⏳ |
30
  | 6 | 抖音 / B站 发布文案(可一键复制) | ⏳ |
 
24
  |---|------|------|
25
  | 1 | 写计划 / 大纲 / 步骤(本文件) | ✅ 完成 |
26
  | 2 | 旁白文案定稿 + TTS 生成配音 + 时长表(15 场景 / 77 句 / 约 6 分钟,旁白+商人+助手三种声线) | ✅ 完成 |
27
+ | 3 | 动画引擎 + 全部 15 个场景(横屏 16:9 / 竖屏 9:16 双布局,已用无头浏览器逐句截图验证) | ✅ 完成 |
28
  | 4 | 程序化音乐 + 音效 + 旁白混音(WebAudio 离线渲染) | ⏳ |
29
  | 5 | 播放器 UI + MP4 本地导出(24/30/60fps,横/竖屏) | ⏳ |
30
  | 6 | 抖音 / B站 发布文案(可一键复制) | ⏳ |
shouhouzi/fonts/kuaile.woff2 ADDED
Binary file (69.8 kB). View file
 
shouhouzi/js/draw.js ADDED
@@ -0,0 +1,629 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================
2
+ * draw.js —— 手绘涂鸦风绘图库(角色 + 道具),全部程序化绘制
3
+ * 约定:所有函数都是纯函数,给定参数和 D.boil 就得到确定的画面
4
+ * ========================================================= */
5
+ (function () {
6
+ const TAU = Math.PI * 2;
7
+ const INK = '#2b2321';
8
+ const FONT = '"KuaiLe","ZCOOL KuaiLe","PingFang SC","Microsoft YaHei","Noto Sans SC",sans-serif';
9
+ const C = {
10
+ ink: INK, paper: '#fbf3e1', sky: '#bfe7ff', sky2: '#eaf7ff', grass: '#9fd468', grass2: '#7fbf4f',
11
+ forest: '#4f9e55', forest2: '#3b8346', fur: '#a8693b', fur2: '#8a5229', face: '#f5d6a8',
12
+ gold: '#ffcd3c', gold2: '#f2a51a', red: '#ff5b4f', green: '#38b86a', blue: '#4a8fe2',
13
+ purple: '#7a5cc7', pink: '#ff8fb1', skin: '#ffd9b8', white: '#fffdf8', grey: '#9aa0a6',
14
+ };
15
+
16
+ function hash(n) { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453123; return s - Math.floor(s); }
17
+ const D = { TAU, INK, C, FONT, boil: 0, hash };
18
+ // 线条"抖动":随 boil 每秒变化 8 次,营造手绘动画感
19
+ function jit(seed, amt) { return (hash(seed * 1.37 + D.boil * 17.13) - 0.5) * 2 * amt; }
20
+ D.jit = jit;
21
+ const clamp01 = (x) => Math.max(0, Math.min(1, x));
22
+ D.clamp01 = clamp01;
23
+ D.lerp = (a, b, k) => a + (b - a) * k;
24
+
25
+ /* ---------- 缓动 ---------- */
26
+ const E = {
27
+ lin: clamp01,
28
+ out: (x) => 1 - Math.pow(1 - clamp01(x), 3),
29
+ in: (x) => Math.pow(clamp01(x), 3),
30
+ inout: (x) => { x = clamp01(x); return x < .5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; },
31
+ back: (x) => { x = clamp01(x); const c = 1.9; return 1 + (c + 1) * Math.pow(x - 1, 3) + c * Math.pow(x - 1, 2); },
32
+ elastic: (x) => { x = clamp01(x); if (x === 0 || x === 1) return x; return Math.pow(2, -10 * x) * Math.sin((x * 10 - .75) * (TAU / 3)) + 1; },
33
+ bounce: (x) => { x = clamp01(x); const n = 7.5625, d = 2.75; if (x < 1 / d) return n * x * x; if (x < 2 / d) return n * (x -= 1.5 / d) * x + .75; if (x < 2.5 / d) return n * (x -= 2.25 / d) * x + .9375; return n * (x -= 2.625 / d) * x + .984375; },
34
+ };
35
+ D.E = E;
36
+
37
+ /* ---------- 基础路径 ---------- */
38
+ D.blob = function (ctx, cx, cy, rx, ry, seed = 1, amt = 0.035, n = 16) {
39
+ const pts = [];
40
+ for (let i = 0; i < n; i++) {
41
+ const a = i / n * TAU, r = 1 + jit(seed + i * 3.1, amt);
42
+ pts.push([cx + Math.cos(a) * rx * r, cy + Math.sin(a) * ry * r]);
43
+ }
44
+ ctx.beginPath();
45
+ let p0 = pts[n - 1], p1 = pts[0];
46
+ ctx.moveTo((p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2);
47
+ for (let i = 0; i < n; i++) {
48
+ const p = pts[i], q = pts[(i + 1) % n];
49
+ ctx.quadraticCurveTo(p[0], p[1], (p[0] + q[0]) / 2, (p[1] + q[1]) / 2);
50
+ }
51
+ ctx.closePath();
52
+ };
53
+ D.rrect = function (ctx, x, y, w, h, r = 12, seed = 1, amt = 1.6) {
54
+ const j = (k) => jit(seed + k, amt);
55
+ r = Math.min(r, w / 2, h / 2);
56
+ ctx.beginPath();
57
+ ctx.moveTo(x + r + j(1), y + j(2));
58
+ ctx.quadraticCurveTo(x + w / 2, y + j(3), x + w - r + j(4), y + j(5));
59
+ ctx.quadraticCurveTo(x + w, y, x + w + j(6), y + r + j(7));
60
+ ctx.quadraticCurveTo(x + w + j(8), y + h / 2, x + w + j(9), y + h - r + j(10));
61
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r + j(11), y + h + j(12));
62
+ ctx.quadraticCurveTo(x + w / 2, y + h + j(13), x + r + j(14), y + h + j(15));
63
+ ctx.quadraticCurveTo(x, y + h, x + j(16), y + h - r + j(17));
64
+ ctx.quadraticCurveTo(x + j(18), y + h / 2, x + j(19), y + r + j(20));
65
+ ctx.quadraticCurveTo(x, y, x + r + j(1), y + j(2));
66
+ ctx.closePath();
67
+ };
68
+ D.line = function (ctx, x1, y1, x2, y2, seed = 1, amt = 2) {
69
+ ctx.beginPath();
70
+ ctx.moveTo(x1 + jit(seed, amt * .5), y1 + jit(seed + 1, amt * .5));
71
+ ctx.quadraticCurveTo((x1 + x2) / 2 + jit(seed + 2, amt), (y1 + y2) / 2 + jit(seed + 3, amt), x2 + jit(seed + 4, amt * .5), y2 + jit(seed + 5, amt * .5));
72
+ };
73
+ D.poly = function (ctx, pts, seed = 1, amt = 1.5) {
74
+ ctx.beginPath();
75
+ pts.forEach((p, i) => { const x = p[0] + jit(seed + i * 2, amt), y = p[1] + jit(seed + i * 2 + 1, amt); i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); });
76
+ ctx.closePath();
77
+ };
78
+ D.fs = function (ctx, fill, lw = 5, stroke = INK) {
79
+ if (fill) { ctx.fillStyle = fill; ctx.fill(); }
80
+ if (lw) { ctx.lineWidth = lw; ctx.strokeStyle = stroke; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.stroke(); }
81
+ };
82
+ D.stroke = function (ctx, lw = 5, color = INK) { ctx.lineWidth = lw; ctx.strokeStyle = color; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.stroke(); };
83
+ D.thick = function (ctx, x1, y1, x2, y2, color, w, seed = 1) { // 带描边的粗线(手臂/腿)
84
+ D.line(ctx, x1, y1, x2, y2, seed, 1.5); D.stroke(ctx, w + 8, INK);
85
+ D.line(ctx, x1, y1, x2, y2, seed, 1.5); D.stroke(ctx, w, color);
86
+ };
87
+
88
+ /* ---------- 文字 ---------- */
89
+ D.font = (size) => `${size}px ${FONT}`;
90
+ D.text = function (ctx, str, x, y, size, color = INK, o = {}) {
91
+ ctx.save();
92
+ ctx.translate(x, y);
93
+ if (o.rot) ctx.rotate(o.rot);
94
+ if (o.scale) ctx.scale(o.scale, o.scale);
95
+ ctx.font = D.font(size);
96
+ ctx.textAlign = o.align || 'center';
97
+ ctx.textBaseline = o.base || 'middle';
98
+ if (o.alpha != null) ctx.globalAlpha *= o.alpha;
99
+ if (o.stroke) { ctx.lineWidth = o.stroke; ctx.strokeStyle = o.strokeColor || INK; ctx.lineJoin = 'round'; ctx.strokeText(str, 0, 0); }
100
+ if (o.shadow) { ctx.fillStyle = o.shadow; ctx.fillText(str, o.sx || 4, o.sy || 6); }
101
+ ctx.fillStyle = color; ctx.fillText(str, 0, 0);
102
+ ctx.restore();
103
+ };
104
+ // 粗描边大字(海报字)
105
+ D.bigText = function (ctx, str, x, y, size, color, o = {}) {
106
+ D.text(ctx, str, x, y, size, color, Object.assign({ stroke: size * 0.16, shadow: 'rgba(43,35,33,.9)', sx: size * .05, sy: size * .08 }, o));
107
+ };
108
+
109
+ /* ---------- 通用变换 ---------- */
110
+ D.at = function (ctx, x, y, s, fn, rot = 0, alpha = 1) {
111
+ if (s <= 0.001 || alpha <= 0.001) return;
112
+ ctx.save(); ctx.translate(x, y); if (rot) ctx.rotate(rot); ctx.scale(s, s);
113
+ if (alpha < 1) ctx.globalAlpha *= alpha;
114
+ fn(); ctx.restore();
115
+ };
116
+
117
+ /* =======================================================
118
+ * 角色:猴子
119
+ * 原点在脚底中心,身高约 165
120
+ * o: {t, seed, flip, mood:'happy'|'shock'|'sad'|'smug'|'love', aL, aR, fur, blink, hold}
121
+ * ======================================================= */
122
+ D.monkey = function (ctx, x, y, s = 1, o = {}) {
123
+ const t = o.t || 0, sd = o.seed || 1;
124
+ const fur = o.fur || C.fur, face = C.face;
125
+ ctx.save(); ctx.translate(x, y); ctx.scale(o.flip ? -s : s, s); if (o.rot) ctx.rotate(o.rot);
126
+ // 尾巴
127
+ ctx.beginPath(); ctx.moveTo(22, -38);
128
+ ctx.bezierCurveTo(78, -26, 96, -98, 62 + Math.sin(t * 3 + sd) * 6, -112);
129
+ ctx.quadraticCurveTo(44, -122, 50, -100);
130
+ D.stroke(ctx, 15, INK);
131
+ ctx.beginPath(); ctx.moveTo(22, -38);
132
+ ctx.bezierCurveTo(78, -26, 96, -98, 62 + Math.sin(t * 3 + sd) * 6, -112);
133
+ ctx.quadraticCurveTo(44, -122, 50, -100);
134
+ D.stroke(ctx, 8, fur);
135
+ // 腿
136
+ D.blob(ctx, -18, -10, 17, 12, sd + 1); D.fs(ctx, fur, 4);
137
+ D.blob(ctx, 18, -10, 17, 12, sd + 2); D.fs(ctx, fur, 4);
138
+ // 身体
139
+ D.blob(ctx, 0, -52, 36, 42, sd + 3); D.fs(ctx, fur, 5);
140
+ D.blob(ctx, 0, -46, 22, 27, sd + 4); D.fs(ctx, face, 0);
141
+ // 手臂
142
+ const arm = (side, a) => {
143
+ const sx = side * 28, sy = -74, L = 46;
144
+ const ex = sx + side * Math.sin(a) * L, ey = sy + Math.cos(a) * L;
145
+ D.thick(ctx, sx, sy, ex, ey, fur, 9, sd + side * 7);
146
+ D.blob(ctx, ex, ey, 9, 9, sd + side * 9); D.fs(ctx, face, 4);
147
+ return [ex, ey];
148
+ };
149
+ const hL = arm(-1, o.aL != null ? o.aL : 0.35);
150
+ const hR = arm(1, o.aR != null ? o.aR : 0.35);
151
+ // 头
152
+ const hy = -122;
153
+ D.blob(ctx, -44, hy - 2, 17, 17, sd + 5); D.fs(ctx, fur, 5);
154
+ D.blob(ctx, 44, hy - 2, 17, 17, sd + 6); D.fs(ctx, fur, 5);
155
+ D.blob(ctx, -44, hy - 2, 9, 9, sd + 7); D.fs(ctx, C.pink, 0);
156
+ D.blob(ctx, 44, hy - 2, 9, 9, sd + 8); D.fs(ctx, C.pink, 0);
157
+ D.blob(ctx, 0, hy, 42, 39, sd + 9); D.fs(ctx, fur, 5);
158
+ D.blob(ctx, -14, hy - 2, 17, 16, sd + 10); D.fs(ctx, face, 0);
159
+ D.blob(ctx, 14, hy - 2, 17, 16, sd + 11); D.fs(ctx, face, 0);
160
+ D.blob(ctx, 0, hy + 17, 27, 17, sd + 12); D.fs(ctx, face, 3);
161
+ // 表情
162
+ const mood = o.mood || 'happy';
163
+ const blink = o.blink != null ? o.blink : ((t * 0.7 + sd * 0.37) % 3.2 < 0.12);
164
+ ctx.fillStyle = INK;
165
+ if (mood === 'shock') {
166
+ for (const ex of [-14, 14]) { D.blob(ctx, ex, hy - 3, 9, 10, sd + ex); D.fs(ctx, '#fff', 3); ctx.beginPath(); ctx.arc(ex, hy - 2, 4, 0, TAU); ctx.fillStyle = INK; ctx.fill(); }
167
+ D.blob(ctx, 0, hy + 20, 7, 9, sd + 30); D.fs(ctx, '#7a2b22', 3);
168
+ } else if (mood === 'love') {
169
+ for (const ex of [-14, 14]) D.text(ctx, '$', ex, hy - 2, 26, C.green, { stroke: 4 });
170
+ ctx.beginPath(); ctx.arc(0, hy + 14, 10, 0.1, Math.PI - 0.1); D.stroke(ctx, 4);
171
+ } else {
172
+ for (const ex of [-14, 14]) {
173
+ if (blink || mood === 'smug') { ctx.beginPath(); ctx.moveTo(ex - 6, hy - 2); ctx.lineTo(ex + 6, hy - 2); D.stroke(ctx, 4); }
174
+ else { ctx.beginPath(); ctx.arc(ex, hy - 3, 5.5, 0, TAU); ctx.fillStyle = INK; ctx.fill(); ctx.beginPath(); ctx.arc(ex + 2, hy - 5, 2, 0, TAU); ctx.fillStyle = '#fff'; ctx.fill(); }
175
+ }
176
+ ctx.beginPath(); ctx.arc(-5, hy + 10, 2, 0, TAU); ctx.arc(5, hy + 10, 2, 0, TAU); ctx.fillStyle = INK; ctx.fill();
177
+ ctx.beginPath();
178
+ if (mood === 'sad') { ctx.arc(0, hy + 26, 9, Math.PI + .3, -.3); D.stroke(ctx, 4); ctx.beginPath(); ctx.moveTo(-22, hy - 16); ctx.lineTo(-8, hy - 20); ctx.moveTo(22, hy - 16); ctx.lineTo(8, hy - 20); D.stroke(ctx, 3); }
179
+ else if (mood === 'smug') { ctx.moveTo(-10, hy + 18); ctx.quadraticCurveTo(4, hy + 26, 13, hy + 13); D.stroke(ctx, 4); }
180
+ else { ctx.arc(0, hy + 13, 11, 0.15, Math.PI - 0.15); D.stroke(ctx, 4); }
181
+ ctx.beginPath(); ctx.ellipse(-25, hy + 10, 6, 3.5, 0, 0, TAU); ctx.ellipse(25, hy + 10, 6, 3.5, 0, 0, TAU); ctx.fillStyle = 'rgba(255,120,120,.55)'; ctx.fill();
182
+ }
183
+ if (o.hold) o.hold(ctx, hR[0], hR[1], hL);
184
+ ctx.restore();
185
+ };
186
+
187
+ /* =======================================================
188
+ * 角色:人物(村民 / 商人 / 助手)
189
+ * 原点在脚底中心,身高约 280
190
+ * o: {t, walk(相位,null 为站立), shirt, pants, skin, hat:'straw'|'top'|'cap'|null, hair:'short'|'bun'|'slick'|'bald'|'spiky',
191
+ * hairColor, mustache, glasses:'sun'|'round'|null, tie, belly, thin, mood, aL, aR, holdR, holdL, flip, jump}
192
+ * ======================================================= */
193
+ D.person = function (ctx, x, y, s = 1, o = {}) {
194
+ const t = o.t || 0, sd = o.seed || 3;
195
+ const skin = o.skin || C.skin, shirt = o.shirt || '#5aa9e6', pants = o.pants || '#5b4a3a';
196
+ ctx.save(); ctx.translate(x, y - (o.jump || 0)); ctx.scale(o.flip ? -s : s, s);
197
+ const w = o.walk;
198
+ const sw = w == null ? 0 : Math.sin(w) * 22;
199
+ const bob = w == null ? Math.sin(t * 2 + sd) * 1.5 : Math.abs(Math.cos(w)) * -5;
200
+ ctx.translate(0, bob);
201
+ const bw = o.thin ? 30 : 38;
202
+ // 腿
203
+ D.thick(ctx, -13, -98, -13 + sw, -6, pants, 15, sd + 1);
204
+ D.thick(ctx, 13, -98, 13 - sw, -6, pants, 15, sd + 2);
205
+ D.blob(ctx, -16 + sw, -4, 15, 8, sd + 3); D.fs(ctx, '#3a2f2a', 4);
206
+ D.blob(ctx, 16 - sw, -4, 15, 8, sd + 4); D.fs(ctx, '#3a2f2a', 4);
207
+ // 躯干
208
+ if (o.belly) { D.blob(ctx, 0, -138, bw + 14, 56, sd + 5); D.fs(ctx, shirt, 5); }
209
+ else { D.rrect(ctx, -bw, -192, bw * 2, 102, 22, sd + 5); D.fs(ctx, shirt, 5); }
210
+ if (o.tie) {
211
+ ctx.beginPath(); ctx.moveTo(-9, -188); ctx.lineTo(9, -188); ctx.lineTo(5, -176); ctx.lineTo(10, -128); ctx.lineTo(0, -116); ctx.lineTo(-10, -128); ctx.lineTo(-5, -176); ctx.closePath(); D.fs(ctx, o.tie, 3.5);
212
+ }
213
+ if (o.suit) { // 西装翻领
214
+ ctx.beginPath(); ctx.moveTo(-bw + 6, -190); ctx.lineTo(-12, -150); ctx.lineTo(-20, -190); ctx.closePath(); D.fs(ctx, '#ffffff', 3);
215
+ ctx.beginPath(); ctx.moveTo(bw - 6, -190); ctx.lineTo(12, -150); ctx.lineTo(20, -190); ctx.closePath(); D.fs(ctx, '#ffffff', 3);
216
+ }
217
+ // 手臂
218
+ const arm = (side, a, hold) => {
219
+ const sx = side * (bw - 2), sy = -180, L = 72;
220
+ const swing = w == null ? 0 : side * Math.sin(w) * 0.45;
221
+ const aa = a + swing;
222
+ const ex = sx + side * Math.sin(aa) * L, ey = sy + Math.cos(aa) * L;
223
+ D.thick(ctx, sx, sy, ex, ey, shirt, 13, sd + side * 5);
224
+ D.blob(ctx, ex, ey, 10, 10, sd + side * 6); D.fs(ctx, skin, 4);
225
+ if (hold) { ctx.save(); ctx.translate(ex, ey); hold(ctx); ctx.restore(); }
226
+ };
227
+ // 头
228
+ const hy = -236;
229
+ const drawHead = () => {
230
+ D.blob(ctx, 0, hy, 42, 44, sd + 7); D.fs(ctx, skin, 5);
231
+ const hc = o.hairColor || '#3b2b22';
232
+ const hair = o.hair || 'short';
233
+ if (hair === 'short') { ctx.beginPath(); ctx.moveTo(-42, hy - 4); ctx.quadraticCurveTo(-40, hy - 50, 0, hy - 46); ctx.quadraticCurveTo(40, hy - 50, 42, hy - 6); ctx.quadraticCurveTo(20, hy - 26, -10, hy - 24); ctx.quadraticCurveTo(-30, hy - 20, -42, hy - 4); D.fs(ctx, hc, 4); }
234
+ else if (hair === 'spiky') { D.poly(ctx, [[-42, hy - 6], [-38, hy - 46], [-22, hy - 36], [-12, hy - 58], [2, hy - 38], [16, hy - 58], [24, hy - 36], [40, hy - 46], [42, hy - 6], [0, hy - 24]], sd); D.fs(ctx, hc, 4); }
235
+ else if (hair === 'bun') { D.blob(ctx, 0, hy - 52, 18, 16, sd + 8); D.fs(ctx, hc, 4); ctx.beginPath(); ctx.moveTo(-43, hy + 6); ctx.quadraticCurveTo(-46, hy - 48, 0, hy - 44); ctx.quadraticCurveTo(46, hy - 48, 43, hy + 6); ctx.quadraticCurveTo(30, hy - 24, 0, hy - 26); ctx.quadraticCurveTo(-30, hy - 24, -43, hy + 6); D.fs(ctx, hc, 4); }
236
+ else if (hair === 'slick') { ctx.beginPath(); ctx.moveTo(-43, hy - 2); ctx.quadraticCurveTo(-44, hy - 50, 8, hy - 47); ctx.quadraticCurveTo(46, hy - 42, 43, hy - 4); ctx.quadraticCurveTo(30, hy - 30, -20, hy - 30); ctx.quadraticCurveTo(-34, hy - 24, -43, hy - 2); D.fs(ctx, hc, 4); ctx.beginPath(); ctx.moveTo(-20, hy - 40); ctx.quadraticCurveTo(10, hy - 46, 30, hy - 34); D.stroke(ctx, 2.5, '#777'); }
237
+ // 脸
238
+ const mood = o.mood || 'happy';
239
+ const blink = ((t * 0.6 + sd * 0.29) % 3.6) < 0.12;
240
+ if (o.glasses === 'sun') {
241
+ D.rrect(ctx, -34, hy - 12, 30, 18, 6, sd + 9, .8); D.fs(ctx, '#1b1b1b', 3);
242
+ D.rrect(ctx, 4, hy - 12, 30, 18, 6, sd + 10, .8); D.fs(ctx, '#1b1b1b', 3);
243
+ ctx.beginPath(); ctx.moveTo(-4, hy - 6); ctx.lineTo(4, hy - 6); D.stroke(ctx, 3);
244
+ ctx.beginPath(); ctx.moveTo(-28, hy - 8); ctx.lineTo(-20, hy - 8); D.stroke(ctx, 2.5, 'rgba(255,255,255,.7)');
245
+ } else if (mood === 'shock') {
246
+ for (const ex of [-15, 15]) { D.blob(ctx, ex, hy - 4, 10, 12, sd + ex); D.fs(ctx, '#fff', 3); ctx.beginPath(); ctx.arc(ex, hy - 3, 4, 0, TAU); ctx.fillStyle = INK; ctx.fill(); }
247
+ } else if (mood === 'money') {
248
+ for (const ex of [-15, 15]) D.text(ctx, '¥', ex, hy - 4, 30, C.green, { stroke: 5, strokeColor: INK });
249
+ } else {
250
+ for (const ex of [-15, 15]) {
251
+ if (blink || mood === 'sly') { ctx.beginPath(); ctx.moveTo(ex - 7, hy - 4); ctx.quadraticCurveTo(ex, hy - (mood === 'sly' ? 0 : 6), ex + 7, hy - 4); D.stroke(ctx, 4); }
252
+ else { ctx.beginPath(); ctx.arc(ex, hy - 4, 5.5, 0, TAU); ctx.fillStyle = INK; ctx.fill(); ctx.beginPath(); ctx.arc(ex + 2, hy - 6, 1.8, 0, TAU); ctx.fillStyle = '#fff'; ctx.fill(); }
253
+ }
254
+ if (mood === 'angry') { ctx.beginPath(); ctx.moveTo(-26, hy - 22); ctx.lineTo(-8, hy - 14); ctx.moveTo(26, hy - 22); ctx.lineTo(8, hy - 14); D.stroke(ctx, 4); }
255
+ if (mood === 'sad') { ctx.beginPath(); ctx.moveTo(-24, hy - 16); ctx.lineTo(-8, hy - 22); ctx.moveTo(24, hy - 16); ctx.lineTo(8, hy - 22); D.stroke(ctx, 4); }
256
+ }
257
+ if (o.glasses === 'round') { ctx.beginPath(); ctx.arc(-15, hy - 4, 12, 0, TAU); ctx.moveTo(27, hy - 4); ctx.arc(15, hy - 4, 12, 0, TAU); ctx.moveTo(-3, hy - 4); ctx.lineTo(3, hy - 4); D.stroke(ctx, 3); }
258
+ // 鼻子腮红
259
+ ctx.beginPath(); ctx.arc(0, hy + 8, 4, 0.3, Math.PI - 0.3); D.stroke(ctx, 3);
260
+ ctx.beginPath(); ctx.ellipse(-27, hy + 12, 7, 4, 0, 0, TAU); ctx.ellipse(27, hy + 12, 7, 4, 0, 0, TAU); ctx.fillStyle = 'rgba(255,120,120,.45)'; ctx.fill();
261
+ // 嘴
262
+ ctx.beginPath();
263
+ if (mood === 'shock') { D.blob(ctx, 0, hy + 26, 9, 11, sd + 20); D.fs(ctx, '#7a2b22', 3.5); }
264
+ else if (mood === 'sad' || mood === 'angry') { ctx.arc(0, hy + 34, 11, Math.PI + .35, -.35); D.stroke(ctx, 4); }
265
+ else if (mood === 'sly') { ctx.moveTo(-14, hy + 22); ctx.quadraticCurveTo(4, hy + 32, 18, hy + 16); D.stroke(ctx, 4); }
266
+ else if (mood === 'talk') { const k = Math.abs(Math.sin(t * 14)); D.blob(ctx, 0, hy + 24, 11, 3 + k * 8, sd + 21); D.fs(ctx, '#7a2b22', 3.5); }
267
+ else if (mood === 'laugh' || mood === 'money') { ctx.moveTo(-14, hy + 18); ctx.quadraticCurveTo(0, hy + 40, 14, hy + 18); ctx.closePath(); D.fs(ctx, '#7a2b22', 3.5); }
268
+ else { ctx.arc(0, hy + 16, 12, 0.2, Math.PI - 0.2); D.stroke(ctx, 4); }
269
+ if (o.mustache) { ctx.beginPath(); ctx.moveTo(0, hy + 13); ctx.quadraticCurveTo(-14, hy + 6, -26, hy + 16); ctx.quadraticCurveTo(-34, hy + 8, -30, hy + 2); ctx.moveTo(0, hy + 13); ctx.quadraticCurveTo(14, hy + 6, 26, hy + 16); ctx.quadraticCurveTo(34, hy + 8, 30, hy + 2); D.stroke(ctx, 6, '#3b2b22'); }
270
+ // 帽子
271
+ if (o.hat === 'straw') {
272
+ D.blob(ctx, 0, hy - 30, 66, 13, sd + 30); D.fs(ctx, '#f0c96a', 4);
273
+ D.blob(ctx, 0, hy - 46, 32, 20, sd + 31); D.fs(ctx, '#f0c96a', 4);
274
+ ctx.beginPath(); ctx.moveTo(-30, hy - 36); ctx.quadraticCurveTo(0, hy - 30, 30, hy - 36); D.stroke(ctx, 6, C.red);
275
+ } else if (o.hat === 'top') {
276
+ D.blob(ctx, 0, hy - 36, 58, 10, sd + 32); D.fs(ctx, '#222', 4);
277
+ D.rrect(ctx, -32, hy - 108, 64, 72, 6, sd + 33); D.fs(ctx, '#222', 4);
278
+ ctx.fillStyle = C.red; ctx.fillRect(-32, hy - 54, 64, 11);
279
+ } else if (o.hat === 'cap') {
280
+ ctx.beginPath(); ctx.moveTo(-42, hy - 14); ctx.quadraticCurveTo(-40, hy - 56, 0, hy - 54); ctx.quadraticCurveTo(40, hy - 56, 42, hy - 14); ctx.closePath(); D.fs(ctx, o.capColor || C.red, 4);
281
+ D.blob(ctx, 34, hy - 16, 30, 8, sd + 34); D.fs(ctx, o.capColor || C.red, 4);
282
+ }
283
+ };
284
+ // 先画背后手,再头,再前手
285
+ arm(-1, o.aL != null ? o.aL : 0.25, o.holdL);
286
+ drawHead();
287
+ arm(1, o.aR != null ? o.aR : 0.25, o.holdR);
288
+ ctx.restore();
289
+ };
290
+
291
+ // 预设角色
292
+ D.merchant = (ctx, x, y, s, o = {}) => D.person(ctx, x, y, s, Object.assign({ shirt: '#2f4a7a', pants: '#25365a', hat: 'top', mustache: true, tie: C.red, belly: true, suit: true, hair: 'bald', seed: 11 }, o));
293
+ D.assistant = (ctx, x, y, s, o = {}) => D.person(ctx, x, y, s, Object.assign({ shirt: '#6b6f7a', pants: '#3e4048', hair: 'slick', hairColor: '#1f1f1f', glasses: 'sun', tie: C.green, thin: true, suit: true, mood: 'sly', seed: 21 }, o));
294
+ const VILLAGERS = [
295
+ { shirt: '#e8743b', pants: '#5b4a3a', hat: 'straw', hair: 'short', seed: 31 },
296
+ { shirt: '#5aa9e6', pants: '#3d5a80', hair: 'bun', hairColor: '#2a1d16', seed: 32 },
297
+ { shirt: '#7bc96f', pants: '#6b4f3a', hat: 'cap', capColor: '#ffcd3c', hair: 'short', seed: 33 },
298
+ { shirt: '#c77dff', pants: '#40334f', hair: 'spiky', hairColor: '#4a2f22', seed: 34, glasses: 'round' },
299
+ { shirt: '#ff6b81', pants: '#4b3b47', hair: 'bun', hairColor: '#5a3a2a', seed: 35 },
300
+ { shirt: '#f4d35e', pants: '#5c5346', hat: 'straw', hair: 'short', seed: 36, skin: '#f1c6a0' },
301
+ { shirt: '#4ecdc4', pants: '#2f4858', hair: 'short', hairColor: '#222', seed: 37, mustache: true },
302
+ ];
303
+ D.villager = (ctx, i, x, y, s, o = {}) => D.person(ctx, x, y, s, Object.assign({}, VILLAGERS[((i % VILLAGERS.length) + VILLAGERS.length) % VILLAGERS.length], { seed: 31 + i * 3 }, o));
304
+
305
+ /* =======================================================
306
+ * 道具
307
+ * ======================================================= */
308
+ D.coin = function (ctx, x, y, r = 22, spin = 0) {
309
+ const sx = Math.max(0.12, Math.abs(Math.cos(spin)));
310
+ ctx.save(); ctx.translate(x, y); ctx.scale(sx, 1);
311
+ D.blob(ctx, 0, 0, r, r, 77, 0.02, 12); D.fs(ctx, C.gold, 4);
312
+ ctx.beginPath(); ctx.arc(0, 0, r * 0.7, 0, TAU); D.stroke(ctx, 2.5, C.gold2);
313
+ if (sx > .4) D.text(ctx, '¥', 0, 1, r * 1.05, '#b9770e');
314
+ ctx.restore();
315
+ };
316
+ D.bill = function (ctx, x, y, s = 1, rot = 0, val = '10') {
317
+ D.at(ctx, x, y, s, () => {
318
+ D.rrect(ctx, -60, -30, 120, 60, 6, 88); D.fs(ctx, '#9fdc8c', 4);
319
+ ctx.beginPath(); ctx.arc(0, 0, 18, 0, TAU); D.stroke(ctx, 3, '#3c8c3a');
320
+ D.text(ctx, '¥' + val, 0, 1, 22, '#2f6f2c');
321
+ }, rot);
322
+ };
323
+ D.tree = function (ctx, x, y, s = 1, seed = 1, col = C.forest) {
324
+ D.at(ctx, x, y, s, () => {
325
+ D.rrect(ctx, -12, -70, 24, 72, 6, seed); D.fs(ctx, '#8a5a35', 4);
326
+ D.blob(ctx, 0, -120, 66, 58, seed + 2, .07); D.fs(ctx, col, 5);
327
+ D.blob(ctx, -30, -95, 40, 32, seed + 3, .07); D.fs(ctx, col, 5);
328
+ D.blob(ctx, 32, -98, 40, 32, seed + 4, .07); D.fs(ctx, col, 5);
329
+ ctx.beginPath(); ctx.arc(-18, -140, 12, Math.PI * 1.1, Math.PI * 1.7); D.stroke(ctx, 4, 'rgba(255,255,255,.35)');
330
+ });
331
+ };
332
+ D.pine = function (ctx, x, y, s = 1, seed = 1, col = C.forest2) {
333
+ D.at(ctx, x, y, s, () => {
334
+ ctx.fillStyle = '#7a4f2e'; ctx.fillRect(-7, -20, 14, 22);
335
+ D.poly(ctx, [[0, -170], [44, -90], [-44, -90]], seed); D.fs(ctx, col, 4);
336
+ D.poly(ctx, [[0, -130], [56, -40], [-56, -40]], seed + 9); D.fs(ctx, col, 4);
337
+ D.poly(ctx, [[0, -90], [64, -14], [-64, -14]], seed + 19); D.fs(ctx, col, 4);
338
+ });
339
+ };
340
+ D.house = function (ctx, x, y, s = 1, seed = 1, wall = '#f6e3c3', roof = '#d9534f', smokeT = null) {
341
+ D.at(ctx, x, y, s, () => {
342
+ if (smokeT != null) for (let i = 0; i < 4; i++) { const k = ((smokeT * 0.35 + i / 4) % 1); D.blob(ctx, 46 + Math.sin(k * 6 + i) * 10, -190 - k * 120, 12 + k * 18, 10 + k * 14, seed + i); ctx.globalAlpha = 1 - k; D.fs(ctx, '#ffffff', 3, 'rgba(43,35,33,.5)'); ctx.globalAlpha = 1; }
343
+ D.rrect(ctx, 30, -190, 26, 50, 3, seed + 5); D.fs(ctx, '#a0522d', 4);
344
+ D.rrect(ctx, -80, -100, 160, 100, 6, seed); D.fs(ctx, wall, 5);
345
+ D.poly(ctx, [[-100, -96], [0, -176], [100, -96]], seed + 1); D.fs(ctx, roof, 5);
346
+ D.rrect(ctx, -18, -60, 36, 60, 4, seed + 2); D.fs(ctx, '#9b6a43', 4);
347
+ D.rrect(ctx, 34, -74, 30, 28, 3, seed + 3); D.fs(ctx, '#bfe7ff', 4);
348
+ D.rrect(ctx, -64, -74, 30, 28, 3, seed + 4); D.fs(ctx, '#bfe7ff', 4);
349
+ });
350
+ };
351
+ D.cloud = function (ctx, x, y, s = 1, seed = 1) {
352
+ D.at(ctx, x, y, s, () => {
353
+ D.blob(ctx, -40, 4, 44, 28, seed); D.fs(ctx, '#fff', 4);
354
+ D.blob(ctx, 34, 6, 40, 26, seed + 1); D.fs(ctx, '#fff', 4);
355
+ D.blob(ctx, 0, -14, 50, 36, seed + 2); D.fs(ctx, '#fff', 4);
356
+ ctx.fillStyle = '#fff'; ctx.fillRect(-60, -2, 110, 22);
357
+ });
358
+ };
359
+ D.sun = function (ctx, x, y, r, t, face = true) {
360
+ ctx.save(); ctx.translate(x, y); ctx.rotate(t * 0.2);
361
+ for (let i = 0; i < 12; i++) { const a = i / 12 * TAU; D.line(ctx, Math.cos(a) * (r + 14), Math.sin(a) * (r + 14), Math.cos(a) * (r + 38), Math.sin(a) * (r + 38), i); D.stroke(ctx, 6, '#ffb52e'); }
362
+ ctx.rotate(-t * 0.2);
363
+ D.blob(ctx, 0, 0, r, r, 5); D.fs(ctx, '#ffd84a', 5);
364
+ if (face) { ctx.beginPath(); ctx.arc(-r * .3, -r * .1, 4, 0, TAU); ctx.arc(r * .3, -r * .1, 4, 0, TAU); ctx.fillStyle = INK; ctx.fill(); ctx.beginPath(); ctx.arc(0, r * .1, r * .3, .2, Math.PI - .2); D.stroke(ctx, 4); }
365
+ ctx.restore();
366
+ };
367
+ // 笼子:count 只猴子(显示上限 cap 个头像)
368
+ D.cage = function (ctx, x, y, w, h, fill, t, o = {}) {
369
+ ctx.save(); ctx.translate(x, y);
370
+ D.rrect(ctx, -w / 2, -h, w, h, 10, 51); D.fs(ctx, 'rgba(255,240,210,.55)', 0);
371
+ // 猴子按网格排布,fill 0..1
372
+ const cols = o.cols || 5, rows = o.rows || 3;
373
+ const n = Math.round(cols * rows * D.clamp01(fill));
374
+ const cw = w / cols, rh = (h - 20) / rows;
375
+ for (let i = 0; i < n; i++) {
376
+ const r = rows - 1 - Math.floor(i / cols), c = i % cols;
377
+ const mx = -w / 2 + cw * (c + .5) + (r % 2 ? 8 : -8), my = -h + 20 + rh * (r + 1) - 4;
378
+ D.monkey(ctx, mx, my + Math.sin(t * 5 + i) * 2, Math.min(cw, rh) / 150, { t, seed: i + 1, mood: o.mood || (i % 3 ? 'happy' : 'shock'), aL: 2.6 + Math.sin(t * 4 + i) * .3, aR: 0.4 });
379
+ }
380
+ // 栏杆
381
+ for (let i = 0; i <= 10; i++) { const bx = -w / 2 + w * i / 10; D.line(ctx, bx, -h, bx, 0, 60 + i, 1.5); D.stroke(ctx, 6, '#5b5b66'); }
382
+ D.rrect(ctx, -w / 2 - 10, -h - 16, w + 20, 22, 6, 52); D.fs(ctx, '#7b6f66', 5);
383
+ D.rrect(ctx, -w / 2 - 10, -8, w + 20, 18, 6, 53); D.fs(ctx, '#7b6f66', 5);
384
+ if (o.label) { D.rrect(ctx, -70, -h - 64, 140, 44, 8, 54); D.fs(ctx, '#fff4d6', 4); D.text(ctx, o.label, 0, -h - 42, 26, INK); }
385
+ ctx.restore();
386
+ };
387
+ // 价格牌(木牌) flip 0..1 用于翻牌动画
388
+ D.priceSign = function (ctx, x, y, s, price, o = {}) {
389
+ D.at(ctx, x, y, s, () => {
390
+ if (!o.noPost) { D.rrect(ctx, -10, -40, 20, 200, 4, 71); D.fs(ctx, '#8a5a35', 4); }
391
+ ctx.save(); ctx.translate(0, -110); ctx.rotate(Math.sin((o.t || 0) * 2) * 0.03);
392
+ ctx.scale(1, o.flip != null ? Math.max(0.02, Math.abs(Math.cos(o.flip * Math.PI))) : 1);
393
+ D.rrect(ctx, -150, -95, 300, 190, 16, 72); D.fs(ctx, o.bg || '#ffe9a8', 6);
394
+ D.text(ctx, o.head || '高价收猴', 0, -52, 40, '#7a4b1c');
395
+ D.bigText(ctx, '¥' + price, -14, 18, 96, o.color || C.red);
396
+ D.text(ctx, '/只', 108, 42, 30, '#7a4b1c');
397
+ ctx.restore();
398
+ });
399
+ };
400
+ D.bubble = function (ctx, x, y, w, h, tx, ty, o = {}) { // 对话气泡,tail 指向 (tx,ty)
401
+ ctx.save();
402
+ const fill = o.fill || '#fff';
403
+ ctx.beginPath();
404
+ const bx = x - w / 2, by = y - h / 2;
405
+ D.rrect(ctx, bx, by, w, h, Math.min(30, h / 2), o.seed || 91, 1.2);
406
+ D.fs(ctx, fill, 5);
407
+ ctx.beginPath(); const mx = Math.max(bx + 30, Math.min(bx + w - 30, tx));
408
+ ctx.moveTo(mx - 16, y + (ty > y ? h / 2 - 3 : -h / 2 + 3)); ctx.lineTo(tx, ty); ctx.lineTo(mx + 16, y + (ty > y ? h / 2 - 3 : -h / 2 + 3));
409
+ D.fs(ctx, fill, 0); ctx.beginPath(); ctx.moveTo(mx - 16, y + (ty > y ? h / 2 : -h / 2)); ctx.lineTo(tx, ty); ctx.lineTo(mx + 16, y + (ty > y ? h / 2 : -h / 2)); D.stroke(ctx, 5);
410
+ if (o.text) {
411
+ const lines = Array.isArray(o.text) ? o.text : [o.text];
412
+ const sz = o.size || 40; lines.forEach((ln, i) => D.text(ctx, ln, x, y + (i - (lines.length - 1) / 2) * sz * 1.25, sz, o.color || INK));
413
+ }
414
+ ctx.restore();
415
+ };
416
+ D.thought = function (ctx, x, y, w, h, tx, ty, o = {}) {
417
+ D.blob(ctx, x, y, w / 2, h / 2, 93, .06, 22); D.fs(ctx, '#fff', 5);
418
+ D.blob(ctx, D.lerp(x, tx, .62), D.lerp(y, ty, .62), 14, 12, 94); D.fs(ctx, '#fff', 4);
419
+ D.blob(ctx, D.lerp(x, tx, .85), D.lerp(y, ty, .85), 8, 7, 95); D.fs(ctx, '#fff', 4);
420
+ if (o.text) { const lines = Array.isArray(o.text) ? o.text : [o.text]; const sz = o.size || 34; lines.forEach((ln, i) => D.text(ctx, ln, x, y + (i - (lines.length - 1) / 2) * sz * 1.25, sz, o.color || INK)); }
421
+ };
422
+ D.megaphone = function (ctx) {
423
+ ctx.save(); ctx.rotate(-0.35);
424
+ D.poly(ctx, [[0, -10], [70, -34], [70, 34], [0, 10]], 101); D.fs(ctx, C.red, 4);
425
+ D.blob(ctx, 72, 0, 9, 34, 102); D.fs(ctx, '#ffe9a8', 4);
426
+ ctx.restore();
427
+ };
428
+ D.net = function (ctx) {
429
+ ctx.save(); ctx.rotate(-0.5);
430
+ D.line(ctx, 0, 30, 0, -110, 111); D.stroke(ctx, 7, '#8a5a35');
431
+ D.blob(ctx, 0, -140, 34, 30, 112); D.fs(ctx, 'rgba(255,255,255,.4)', 4);
432
+ for (let i = -2; i <= 2; i++) { ctx.beginPath(); ctx.moveTo(i * 13, -168); ctx.lineTo(i * 13, -112); ctx.moveTo(-32, -140 + i * 11); ctx.lineTo(32, -140 + i * 11); D.stroke(ctx, 1.5, 'rgba(43,35,33,.6)'); }
433
+ ctx.restore();
434
+ };
435
+ D.car = function (ctx, x, y, s, t, o = {}) {
436
+ D.at(ctx, x, y, s, () => {
437
+ D.rrect(ctx, -130, -90, 260, 64, 20, 121); D.fs(ctx, o.color || C.red, 5);
438
+ D.rrect(ctx, -70, -142, 140, 60, 20, 122); D.fs(ctx, o.color || C.red, 5);
439
+ D.rrect(ctx, -56, -130, 52, 40, 8, 123); D.fs(ctx, '#bfe7ff', 4);
440
+ D.rrect(ctx, 6, -130, 52, 40, 8, 124); D.fs(ctx, '#bfe7ff', 4);
441
+ for (const wx of [-78, 78]) { ctx.save(); ctx.translate(wx, -24); ctx.rotate(t * 12); D.blob(ctx, 0, 0, 28, 28, 125); D.fs(ctx, '#333', 5); ctx.beginPath(); ctx.moveTo(-12, 0); ctx.lineTo(12, 0); ctx.moveTo(0, -12); ctx.lineTo(0, 12); D.stroke(ctx, 4, '#bbb'); ctx.restore(); }
442
+ if (o.driver) o.driver();
443
+ });
444
+ };
445
+ D.cow = function (ctx, x, y, s, t, o = {}) {
446
+ D.at(ctx, x, y, s, () => {
447
+ for (const lx of [-50, -20, 30, 58]) { D.rrect(ctx, lx - 9, -60, 18, 60, 5, 130 + lx); D.fs(ctx, '#fff', 4); }
448
+ D.blob(ctx, 0, -86, 96, 50, 131); D.fs(ctx, '#fff', 5);
449
+ D.blob(ctx, -30, -96, 26, 20, 132); D.fs(ctx, '#333', 0); D.blob(ctx, 34, -70, 20, 16, 133); D.fs(ctx, '#333', 0);
450
+ D.blob(ctx, 100, -120, 42, 36, 134); D.fs(ctx, '#fff', 5);
451
+ D.blob(ctx, 116, -104, 26, 18, 135); D.fs(ctx, '#ffb3c1', 4);
452
+ ctx.beginPath(); ctx.arc(92, -128, 5, 0, TAU); ctx.fillStyle = INK; ctx.fill();
453
+ D.poly(ctx, [[80, -150], [70, -176], [90, -156]], 136); D.fs(ctx, '#f6e3c3', 3);
454
+ D.poly(ctx, [[112, -152], [124, -178], [124, -150]], 137); D.fs(ctx, '#f6e3c3', 3);
455
+ if (o.tag) { D.rrect(ctx, -60, -190, 120, 46, 8, 138); D.fs(ctx, '#fff4d6', 4); D.text(ctx, o.tag, 0, -167, 28, C.red); }
456
+ });
457
+ };
458
+ D.stamp = function (ctx, x, y, s, text, color = C.red, rot = -0.2) {
459
+ D.at(ctx, x, y, s, () => {
460
+ ctx.globalAlpha *= 0.92;
461
+ ctx.font = D.font(64); const w = ctx.measureText(text).width + 60;
462
+ D.rrect(ctx, -w / 2, -50, w, 100, 14, 141, 2.5); D.stroke(ctx, 9, color);
463
+ D.text(ctx, text, 0, 3, 64, color);
464
+ }, rot);
465
+ };
466
+ D.cross = function (ctx, x, y, r, color = C.red) {
467
+ D.line(ctx, x - r, y - r, x + r, y + r, 151, 3); D.stroke(ctx, 12, color);
468
+ D.line(ctx, x + r, y - r, x - r, y + r, 152, 3); D.stroke(ctx, 12, color);
469
+ };
470
+ D.check = function (ctx, x, y, r, k = 1, color = C.green) {
471
+ ctx.beginPath(); ctx.moveTo(x - r, y);
472
+ const p1 = [x - r * .3, y + r * .7], p2 = [x + r, y - r * .8];
473
+ if (k < .4) { const q = k / .4; ctx.lineTo(D.lerp(x - r, p1[0], q), D.lerp(y, p1[1], q)); }
474
+ else { ctx.lineTo(p1[0], p1[1]); const q = (k - .4) / .6; ctx.lineTo(D.lerp(p1[0], p2[0], q), D.lerp(p1[1], p2[1], q)); }
475
+ D.stroke(ctx, 12, color);
476
+ };
477
+ D.arrow = function (ctx, x1, y1, x2, y2, color = INK, w = 8, k = 1) {
478
+ const ex = D.lerp(x1, x2, k), ey = D.lerp(y1, y2, k);
479
+ D.line(ctx, x1, y1, ex, ey, 161, 2); D.stroke(ctx, w, color);
480
+ const a = Math.atan2(ey - y1, ex - x1), h = w * 3;
481
+ ctx.beginPath(); ctx.moveTo(ex + Math.cos(a) * 4, ey + Math.sin(a) * 4);
482
+ ctx.lineTo(ex - Math.cos(a - .5) * h, ey - Math.sin(a - .5) * h);
483
+ ctx.lineTo(ex - Math.cos(a + .5) * h, ey - Math.sin(a + .5) * h); ctx.closePath();
484
+ ctx.fillStyle = color; ctx.fill(); D.stroke(ctx, 3, color);
485
+ };
486
+ D.qmark = function (ctx, x, y, s, t = 0, color = C.blue) {
487
+ D.at(ctx, x, y, s, () => D.bigText(ctx, '?', 0, 0, 120, color), Math.sin(t * 3) * .12);
488
+ };
489
+ D.exclaim = function (ctx, x, y, s, t = 0, color = C.red) {
490
+ D.at(ctx, x, y, s, () => D.bigText(ctx, '!', 0, 0, 120, color), Math.sin(t * 5) * .1);
491
+ };
492
+ D.sparkle = function (ctx, x, y, r, t, color = '#fff6a8') {
493
+ const k = 0.6 + 0.4 * Math.sin(t * 8);
494
+ ctx.save(); ctx.translate(x, y); ctx.scale(k, k); ctx.rotate(t);
495
+ ctx.beginPath(); for (let i = 0; i < 8; i++) { const a = i / 8 * TAU, rr = i % 2 ? r * .35 : r; ctx.lineTo(Math.cos(a) * rr, Math.sin(a) * rr); } ctx.closePath();
496
+ D.fs(ctx, color, 3); ctx.restore();
497
+ };
498
+ D.piggy = function (ctx, x, y, s, broken = 0) {
499
+ D.at(ctx, x, y, s, () => {
500
+ if (broken > 0) {
501
+ for (let i = 0; i < 5; i++) { const a = i / 5 * TAU + .3, d = broken * 80; D.at(ctx, Math.cos(a) * d, Math.sin(a) * d * .6 - 40 + broken * broken * 60, 1, () => { D.poly(ctx, [[-30, -20], [30, -26], [20, 24], [-26, 18]], 170 + i); D.fs(ctx, C.pink, 4); }, a + broken * 3); }
502
+ return;
503
+ }
504
+ D.blob(ctx, 0, -60, 80, 60, 171); D.fs(ctx, C.pink, 5);
505
+ D.blob(ctx, 78, -66, 20, 24, 172); D.fs(ctx, '#ff6f9a', 4);
506
+ ctx.beginPath(); ctx.arc(30, -84, 5, 0, TAU); ctx.fillStyle = INK; ctx.fill();
507
+ D.poly(ctx, [[10, -116], [26, -142], [36, -112]], 173); D.fs(ctx, C.pink, 4);
508
+ ctx.fillStyle = INK; ctx.fillRect(-20, -122, 34, 7);
509
+ for (const lx of [-46, 40]) { D.rrect(ctx, lx - 10, -14, 20, 18, 4, 174 + lx); D.fs(ctx, C.pink, 4); }
510
+ });
511
+ };
512
+ D.calendar = function (ctx, x, y, s, label, sub, flip = 0) {
513
+ D.at(ctx, x, y, s, () => {
514
+ D.rrect(ctx, -110, -130, 220, 250, 14, 181); D.fs(ctx, '#fff', 6);
515
+ D.rrect(ctx, -110, -130, 220, 64, 14, 182); D.fs(ctx, C.red, 6);
516
+ for (const rx of [-60, 60]) { D.rrect(ctx, rx - 7, -150, 14, 40, 6, 183 + rx); D.fs(ctx, '#888', 4); }
517
+ D.text(ctx, sub || '', 0, -98, 30, '#fff');
518
+ ctx.save(); ctx.scale(1, Math.max(0.05, 1 - flip)); D.bigText(ctx, label, 0, 20, 84, INK, { shadow: null }); ctx.restore();
519
+ });
520
+ };
521
+ D.tulip = function (ctx, x, y, s, t = 0) {
522
+ D.at(ctx, x, y, s, () => {
523
+ D.line(ctx, 0, 0, Math.sin(t) * 6, -140, 191); D.stroke(ctx, 8, C.green);
524
+ D.blob(ctx, -26, -50, 26, 10, 192); D.fs(ctx, '#58c06a', 4);
525
+ ctx.save(); ctx.translate(Math.sin(t) * 6, -150);
526
+ D.poly(ctx, [[-40, 0], [-44, -60], [-20, -34], [0, -70], [20, -34], [44, -60], [40, 0], [0, 26]], 193); D.fs(ctx, '#ff4f6d', 5);
527
+ ctx.restore();
528
+ });
529
+ };
530
+ D.laptop = function (ctx, x, y, s, t = 0) {
531
+ D.at(ctx, x, y, s, () => {
532
+ D.rrect(ctx, -110, -150, 220, 140, 12, 201); D.fs(ctx, '#3b3f4a', 5);
533
+ D.rrect(ctx, -96, -138, 192, 116, 6, 202); D.fs(ctx, '#e8f6ff', 3);
534
+ D.text(ctx, '.com', 0, -80, 52, C.blue);
535
+ D.poly(ctx, [[-140, -10], [140, -10], [120, 14], [-120, 14]], 203); D.fs(ctx, '#9aa0aa', 5);
536
+ });
537
+ };
538
+ D.sneaker = function (ctx, x, y, s) {
539
+ D.at(ctx, x, y, s, () => {
540
+ ctx.beginPath(); ctx.moveTo(-90, 0); ctx.lineTo(-90, -60); ctx.quadraticCurveTo(-40, -86, -20, -60); ctx.quadraticCurveTo(30, -40, 90, -26); ctx.quadraticCurveTo(100, 0, 90, 0); ctx.closePath(); D.fs(ctx, '#fff', 5);
541
+ D.rrect(ctx, -96, -10, 196, 22, 8, 211); D.fs(ctx, C.red, 5);
542
+ ctx.beginPath(); ctx.moveTo(-60, -40); ctx.quadraticCurveTo(0, -10, 60, -24); D.stroke(ctx, 7, C.blue);
543
+ });
544
+ };
545
+ D.card = function (ctx, x, y, s) {
546
+ D.at(ctx, x, y, s, () => {
547
+ D.rrect(ctx, -60, -84, 120, 168, 12, 221); D.fs(ctx, '#ffe17a', 5);
548
+ D.rrect(ctx, -46, -70, 92, 92, 8, 222); D.fs(ctx, '#bfe7ff', 4);
549
+ D.text(ctx, 'SSR', 0, 50, 34, C.purple);
550
+ D.sparkle(ctx, 30, -60, 16, 1);
551
+ });
552
+ };
553
+ D.cryptoCoin = function (ctx, x, y, s, t = 0) {
554
+ D.at(ctx, x, y, s, () => {
555
+ D.blob(ctx, 0, 0, 70, 70, 231, .02); D.fs(ctx, '#ffb547', 6);
556
+ ctx.beginPath(); ctx.arc(0, 0, 50, 0, TAU); D.stroke(ctx, 4, '#e08a12');
557
+ D.text(ctx, '空气', 0, 2, 36, '#8a4b00');
558
+ }, Math.sin(t * 2) * .1);
559
+ };
560
+ D.cricket = function (ctx, x, y, s, t) {
561
+ D.at(ctx, x, y, s, () => {
562
+ D.blob(ctx, 0, -20, 34, 16, 241); D.fs(ctx, '#5a7d3a', 4);
563
+ D.blob(ctx, 34, -26, 14, 12, 242); D.fs(ctx, '#5a7d3a', 4);
564
+ ctx.beginPath(); ctx.moveTo(42, -36); ctx.quadraticCurveTo(60, -80 + Math.sin(t * 20) * 6, 80, -70); ctx.moveTo(38, -36); ctx.quadraticCurveTo(46, -86, 64, -88 + Math.sin(t * 22) * 6); D.stroke(ctx, 3);
565
+ ctx.beginPath(); ctx.moveTo(-10, -10); ctx.lineTo(-30, 10); ctx.moveTo(10, -10); ctx.lineTo(24, 10); D.stroke(ctx, 4);
566
+ });
567
+ };
568
+ D.tumbleweed = function (ctx, x, y, s, t) {
569
+ D.at(ctx, x, y, s, () => {
570
+ for (let i = 0; i < 6; i++) { ctx.beginPath(); ctx.ellipse(0, 0, 40, 40 - i * 6, i, 0, TAU); D.stroke(ctx, 3, '#a07a4a'); }
571
+ }, t * 4);
572
+ };
573
+ D.ledgerBook = function (ctx, x, y, w, h) {
574
+ D.rrect(ctx, x, y, w, h, 16, 251); D.fs(ctx, '#fffaf0', 6);
575
+ ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
576
+ for (let yy = y + 70; yy < y + h; yy += 56) { ctx.beginPath(); ctx.moveTo(x + 10, yy); ctx.lineTo(x + w - 10, yy); D.stroke(ctx, 2, 'rgba(74,143,226,.35)'); }
577
+ ctx.beginPath(); ctx.moveTo(x + 70, y); ctx.lineTo(x + 70, y + h); D.stroke(ctx, 2.5, 'rgba(255,91,79,.5)');
578
+ ctx.restore();
579
+ };
580
+ D.heart = function (ctx, x, y, r, color = C.red) {
581
+ ctx.beginPath(); ctx.moveTo(x, y + r * .9);
582
+ ctx.bezierCurveTo(x - r * 1.6, y - r * .2, x - r * .7, y - r * 1.4, x, y - r * .5);
583
+ ctx.bezierCurveTo(x + r * .7, y - r * 1.4, x + r * 1.6, y - r * .2, x, y + r * .9);
584
+ D.fs(ctx, color, 4);
585
+ };
586
+ // 确定性粒子:彩带
587
+ D.confetti = function (ctx, x, y, dt, n = 40, spread = 600, seed = 1) {
588
+ if (dt < 0 || dt > 4) return;
589
+ const cols = [C.red, C.gold, C.blue, C.green, C.pink, C.purple];
590
+ for (let i = 0; i < n; i++) {
591
+ const h1 = hash(seed + i), h2 = hash(seed + i * 7.3), h3 = hash(seed + i * 3.7);
592
+ const vx = (h1 - .5) * spread * 1.6, vy = -300 - h2 * 600;
593
+ const px = x + vx * dt, py = y + vy * dt + 700 * dt * dt;
594
+ ctx.save(); ctx.translate(px, py); ctx.rotate(dt * (4 + h3 * 8)); ctx.globalAlpha *= D.clamp01(1.4 - dt / 3);
595
+ ctx.fillStyle = cols[i % cols.length]; ctx.fillRect(-9, -5, 18, 10 * Math.abs(Math.cos(dt * 6 + i)) + 2);
596
+ ctx.restore();
597
+ }
598
+ };
599
+ D.coinBurst = function (ctx, x, y, dt, n = 14, seed = 3, power = 1) {
600
+ if (dt < 0 || dt > 2.4) return;
601
+ for (let i = 0; i < n; i++) {
602
+ const h1 = hash(seed + i), h2 = hash(seed + i * 5.1);
603
+ const vx = (h1 - .5) * 700 * power, vy = -(400 + h2 * 500) * power;
604
+ const px = x + vx * dt, py = y + vy * dt + 1100 * dt * dt;
605
+ ctx.save(); ctx.globalAlpha *= D.clamp01(2.4 - dt); D.coin(ctx, px, py, 20, dt * 10 + i); ctx.restore();
606
+ }
607
+ };
608
+ D.speedLines = function (ctx, cx, cy, t, color = 'rgba(43,35,33,.12)', n = 36, r0 = 380) {
609
+ ctx.save();
610
+ for (let i = 0; i < n; i++) {
611
+ const a = i / n * TAU + hash(i + Math.floor(t * 10)) * .1;
612
+ const r1 = r0 + hash(i * 3 + Math.floor(t * 10)) * 120;
613
+ ctx.beginPath(); ctx.moveTo(cx + Math.cos(a) * r1, cy + Math.sin(a) * r1); ctx.lineTo(cx + Math.cos(a) * 2400, cy + Math.sin(a) * 2400);
614
+ ctx.lineWidth = 6 + hash(i) * 14; ctx.strokeStyle = color; ctx.stroke();
615
+ }
616
+ ctx.restore();
617
+ };
618
+ D.dust = function (ctx, x, y, t, n = 5, seed = 1) {
619
+ for (let i = 0; i < n; i++) {
620
+ const k = ((t * 1.6 + i / n) % 1);
621
+ ctx.globalAlpha = (1 - k) * .8;
622
+ D.blob(ctx, x - k * 90 - i * 6, y - k * 40 - hash(seed + i) * 20, 10 + k * 24, 8 + k * 18, seed + i);
623
+ D.fs(ctx, '#efe2c8', 3, 'rgba(43,35,33,.5)');
624
+ }
625
+ ctx.globalAlpha = 1;
626
+ };
627
+
628
+ window.D = D;
629
+ })();
shouhouzi/js/engine.js ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================
2
+ * engine.js —— 时间轴、画幅布局、逐帧渲染(预览与导出共用)
3
+ * ========================================================= */
4
+ (function () {
5
+ const PRE = 0.5, POST = 0.4;
6
+ const STAGE_W = 1280, STAGE_H = 960;
7
+
8
+ /* ---------- 时间轴 ---------- */
9
+ const TL = { scenes: [], total: 0, lines: [] };
10
+ let acc = 0;
11
+ window.SCRIPT.forEach((sc, si) => {
12
+ const s = { id: sc.id, title: sc.title, index: si, start: acc, L: [], lines: sc.lines };
13
+ let c = PRE;
14
+ sc.lines.forEach((ln, li) => {
15
+ s.L.push(c);
16
+ TL.lines.push({ scene: si, idx: li, start: acc + c, end: acc + c + ln.dur, until: acc + c + ln.dur + ln.gap, text: ln.s, v: ln.v, file: ln.file, dur: ln.dur });
17
+ c += ln.dur + ln.gap;
18
+ });
19
+ s.dur = c + POST;
20
+ acc += s.dur;
21
+ TL.scenes.push(s);
22
+ });
23
+ TL.total = acc;
24
+ TL.byId = {}; TL.scenes.forEach((s) => (TL.byId[s.id] = s));
25
+ TL.at = function (t) {
26
+ t = Math.max(0, Math.min(TL.total - 1e-4, t));
27
+ let lo = 0, hi = TL.scenes.length - 1;
28
+ while (lo < hi) { const m = (lo + hi + 1) >> 1; if (TL.scenes[m].start <= t) lo = m; else hi = m - 1; }
29
+ return { sc: TL.scenes[lo], lt: t - TL.scenes[lo].start };
30
+ };
31
+ // 全局时间:场景 id 第 i 句开始后 off 秒;i 为 -1 表示场景开始,'end' 表示场景结束
32
+ TL.cue = function (id, i = 0, off = 0) {
33
+ const s = TL.byId[id];
34
+ if (i === -1) return s.start + off;
35
+ if (i === 'end') return s.start + s.dur + off;
36
+ return s.start + s.L[i] + off;
37
+ };
38
+ TL.lineAt = function (t) {
39
+ for (let i = TL.lines.length - 1; i >= 0; i--) { const l = TL.lines[i]; if (t >= l.start - 0.05) return t <= l.until + 0.05 ? l : null; }
40
+ return null;
41
+ };
42
+ window.TL = TL;
43
+
44
+ /* ---------- 画幅布局 ---------- */
45
+ // landscape:B站 1920×1080;portrait:抖音 1080×1920(顶部刘海/状态栏/顶栏、底部文案与进度条、右侧互动按钮均避开)
46
+ const LAYOUTS = {
47
+ landscape: { name: 'landscape', W: 1920, H: 1080, k: 0.9, sx: (1920 - STAGE_W * 0.9) / 2, sy: 22, sub: { cx: 960, y: 952, size: 46, maxW: 1600 }, safe: null },
48
+ portrait: { name: 'portrait', W: 1080, H: 1920, k: 0.72, sx: 24, sy: 476, sub: { cx: 485, y: 1225, size: 50, maxW: 880 }, safe: { top: 210, bottom: 1480, right: 940 } },
49
+ };
50
+ window.LAYOUTS = LAYOUTS;
51
+
52
+ /* ---------- HUD 关键帧(收购价 / 笼中猴子) ---------- */
53
+ function buildHud() {
54
+ const P = [
55
+ [TL.cue('arrive', 2, 0.6), '10', '收购价'],
56
+ [TL.cue('twenty', 3, 0.3), '20', '收购价'],
57
+ [TL.cue('scarce', 1, 0.6), '25', '收购价'],
58
+ [TL.cue('fifty', 2, 0.8), '50', '收购价'],
59
+ [TL.cue('deal', 2, 1.6), '35', '助手卖价'],
60
+ [TL.cue('gone', 0, 0), '???', '收购价'],
61
+ [TL.cue('gone', 'end', -0.4), null, ''],
62
+ ];
63
+ const CAGE = [
64
+ [TL.cue('ten', 3, 0), 0], [TL.cue('ten', 4, 4.5), 600],
65
+ [TL.cue('twenty', 4, 0), 600], [TL.cue('twenty', 4, 4.5), 900],
66
+ [TL.cue('scarce', 3, 0), 900], [TL.cue('scarce', 3, 3.5), 1000],
67
+ [TL.cue('frenzy', 4, 0), 1000], [TL.cue('frenzy', 4, 3.6), 0],
68
+ ];
69
+ return { P, CAGE };
70
+ }
71
+ const HUD = buildHud();
72
+ function hudAt(t) {
73
+ let price = null, label = '', since = 99;
74
+ for (const [tt, p, lab] of HUD.P) if (t >= tt) { price = p; label = lab; since = t - tt; }
75
+ let cage = null;
76
+ if (t >= HUD.CAGE[0][0] && t < TL.cue('gone', -1)) {
77
+ cage = 0;
78
+ for (let i = 0; i < HUD.CAGE.length - 1; i++) {
79
+ const [a, va] = HUD.CAGE[i], [b, vb] = HUD.CAGE[i + 1];
80
+ if (t >= a && t < b) { cage = Math.round(D.lerp(va, vb, D.E.inout((t - a) / (b - a)))); break; }
81
+ if (t >= b) cage = vb;
82
+ }
83
+ }
84
+ return { price, label, since, cage };
85
+ }
86
+ window.hudAt = hudAt;
87
+
88
+ /* ---------- 纸张颗粒 ---------- */
89
+ let grain = null;
90
+ function getGrain() {
91
+ if (grain) return grain;
92
+ const c = document.createElement('canvas'); c.width = c.height = 256;
93
+ const g = c.getContext('2d'); const id = g.createImageData(256, 256);
94
+ for (let i = 0; i < 256 * 256; i++) { const v = 128 + (D.hash(i * 0.731) - .5) * 90; id.data[i * 4] = id.data[i * 4 + 1] = id.data[i * 4 + 2] = v; id.data[i * 4 + 3] = 255; }
95
+ g.putImageData(id, 0, 0); grain = c; return c;
96
+ }
97
+
98
+ /* ---------- 字幕折行 ---------- */
99
+ function wrap(ctx, text, maxW) {
100
+ const out = []; let cur = '';
101
+ for (const ch of text) {
102
+ if (ctx.measureText(cur + ch).width > maxW && cur) {
103
+ // 避免标点落在行首
104
+ if (/[,。!?、:;”)》…,.!?]/.test(ch)) { cur += ch; continue; }
105
+ out.push(cur); cur = ch;
106
+ } else cur += ch;
107
+ }
108
+ if (cur) out.push(cur);
109
+ // 均衡两行长度
110
+ if (out.length === 2) {
111
+ const all = out.join(''); const half = Math.ceil(all.length / 2);
112
+ let cut = half;
113
+ for (let d = 0; d < 5; d++) { if (/[,。!?:;]/.test(all[half + d - 1])) { cut = half + d; break; } if (/[,。!?:;]/.test(all[half - d - 1])) { cut = half - d; break; } }
114
+ const a = all.slice(0, cut), b = all.slice(cut);
115
+ if (ctx.measureText(a).width <= maxW && ctx.measureText(b).width <= maxW) return [a, b];
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /* ---------- 叠加层 ---------- */
121
+ function drawSubtitle(ctx, t, Lo) {
122
+ const ln = TL.lineAt(t);
123
+ if (!ln) return;
124
+ const a = D.clamp01((t - ln.start + 0.05) / 0.15) * D.clamp01((ln.until + 0.05 - t) / 0.12);
125
+ if (a <= 0) return;
126
+ const S = Lo.sub;
127
+ ctx.save(); ctx.globalAlpha = a;
128
+ ctx.font = D.font(S.size);
129
+ const lines = wrap(ctx, ln.text, S.maxW);
130
+ const lh = S.size * 1.32;
131
+ const y0 = Lo.name === 'portrait' ? S.y : S.y - (lines.length - 1) * lh / 2;
132
+ const col = ln.v === 'm' ? '#ffd54a' : ln.v === 'a' ? '#8ff5b5' : '#ffffff';
133
+ if (ln.v !== 'n') {
134
+ const tag = ln.v === 'm' ? '商人' : '助手';
135
+ const ty = y0 - lh * 0.5 - 30;
136
+ ctx.font = D.font(S.size * 0.62); const tw = ctx.measureText(tag).width + 34;
137
+ D.rrect(ctx, S.cx - tw / 2, ty - 22, tw, 44, 20, 7, 0.6); D.fs(ctx, ln.v === 'm' ? '#d9a400' : '#2c9c5c', 4);
138
+ D.text(ctx, tag, S.cx, ty + 1, S.size * 0.62, '#fff');
139
+ ctx.font = D.font(S.size);
140
+ }
141
+ lines.forEach((s, i) => {
142
+ const y = y0 + i * lh;
143
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
144
+ ctx.lineJoin = 'round';
145
+ ctx.lineWidth = S.size * 0.26; ctx.strokeStyle = 'rgba(30,22,20,.95)'; ctx.strokeText(s, S.cx, y);
146
+ ctx.fillStyle = col; ctx.fillText(s, S.cx, y);
147
+ });
148
+ ctx.restore();
149
+ }
150
+
151
+ function drawHud(ctx, t, Lo, sc, lt) {
152
+ const h = hudAt(t);
153
+ const P = Lo.name === 'portrait';
154
+ // 章节标签
155
+ const chap = sc.index === 0 ? '开场' : `第${sc.index}幕`;
156
+ const ttl = sc.index === 0 ? '先问你一个问题' : sc.title;
157
+ const ink = D.clamp01(lt / 0.5) * D.clamp01((sc.dur - lt) / 0.3);
158
+ if (P) {
159
+ // 竖屏:顶部安全区下方放系列标题 + 章节
160
+ D.bigText(ctx, '收猴子的故事', 485, 282, 74, '#ffcd3c', { shadow: 'rgba(43,35,33,.9)' });
161
+ ctx.save(); ctx.globalAlpha = ink;
162
+ ctx.font = D.font(38); const txt = `${chap}|${ttl}`; const w = ctx.measureText(txt).width + 50;
163
+ D.rrect(ctx, 485 - w / 2, 340, w, 58, 28, 3, 1); D.fs(ctx, '#2b2321', 0);
164
+ D.text(ctx, txt, 485, 370, 38, '#fff');
165
+ ctx.restore();
166
+ if (h.price || h.cage != null) drawPriceChip(ctx, t, h, 485, 438, 0.92, true);
167
+ } else {
168
+ ctx.save(); ctx.globalAlpha = ink;
169
+ ctx.font = D.font(34); const txt = `${chap}|${ttl}`; const w = ctx.measureText(txt).width + 46;
170
+ D.rrect(ctx, 34, 34, w, 56, 28, 3, 1); D.fs(ctx, 'rgba(43,35,33,.88)', 0);
171
+ D.text(ctx, txt, 34 + w / 2, 63, 34, '#fff');
172
+ ctx.restore();
173
+ D.text(ctx, '经济学寓言 · 收猴子的故事', 34, 120, 26, 'rgba(43,35,33,.55)', { align: 'left' });
174
+ if (h.price || h.cage != null) drawPriceChip(ctx, t, h, 1920 - 190, 120, 1, false);
175
+ }
176
+ }
177
+ function drawPriceChip(ctx, t, h, cx, cy, s, row) {
178
+ const pulse = h.since < 0.6 ? 1 + 0.25 * Math.sin(h.since / 0.6 * Math.PI) : 1;
179
+ ctx.save(); ctx.translate(cx, cy); ctx.scale(s, s);
180
+ if (row) {
181
+ // 横排:收购价 | 笼中猴子
182
+ let x = 0;
183
+ const parts = [];
184
+ if (h.price) parts.push(['p', h.label, '¥' + h.price]);
185
+ if (h.cage != null) parts.push(['c', '笼中猴子', h.cage + '只']);
186
+ const pw = 300, gap = 20, tot = parts.length * pw + (parts.length - 1) * gap;
187
+ x = -tot / 2;
188
+ parts.forEach(([k, lab, val]) => {
189
+ D.rrect(ctx, x, -34, pw, 68, 20, k === 'p' ? 5 : 6, 1); D.fs(ctx, k === 'p' ? '#fff4d6' : '#e7f6ff', 4);
190
+ D.text(ctx, lab, x + 20, 1, 28, '#6b5a4a', { align: 'left' });
191
+ ctx.save(); ctx.translate(x + pw - 22, 0); if (k === 'p') ctx.scale(pulse, pulse);
192
+ D.text(ctx, val, 0, 2, 44, k === 'p' ? (h.price === '???' ? '#888' : D.C.red) : D.C.blue, { align: 'right', stroke: 0 });
193
+ ctx.restore();
194
+ x += pw + gap;
195
+ });
196
+ } else {
197
+ let y = 0;
198
+ if (h.price) {
199
+ D.rrect(ctx, -150, -60, 300, 140, 22, 5, 1.2); D.fs(ctx, '#fff4d6', 5);
200
+ D.text(ctx, h.label, 0, -30, 30, '#6b5a4a');
201
+ ctx.save(); ctx.translate(0, 26); ctx.scale(pulse, pulse);
202
+ D.bigText(ctx, '¥' + h.price, 0, 0, 64, h.price === '???' ? '#aaa' : D.C.red, { shadow: null });
203
+ ctx.restore();
204
+ y = 100;
205
+ }
206
+ if (h.cage != null) {
207
+ D.rrect(ctx, -150, y, 300, 90, 22, 6, 1.2); D.fs(ctx, '#e7f6ff', 5);
208
+ D.text(ctx, '笼中猴子', 0, y + 26, 26, '#4b5d6b');
209
+ D.text(ctx, h.cage + ' 只', 0, y + 62, 38, D.C.blue);
210
+ }
211
+ }
212
+ ctx.restore();
213
+ }
214
+
215
+ function drawIris(ctx, Lo, sc, lt, first, last) {
216
+ let r = null;
217
+ const cx = Lo.sx + STAGE_W * Lo.k / 2, cy = Lo.sy + STAGE_H * Lo.k / 2;
218
+ const R = Math.hypot(Lo.W, Lo.H);
219
+ if (!first && lt < 0.45) r = D.E.inout(lt / 0.45) * R;
220
+ const endD = last ? 1.2 : 0.3;
221
+ if (sc.dur - lt < endD) r = D.E.inout((sc.dur - lt) / endD) * R;
222
+ if (first && lt < 0.6) { ctx.fillStyle = `rgba(43,35,33,${1 - lt / 0.6})`; ctx.fillRect(0, 0, Lo.W, Lo.H); }
223
+ if (r == null) return;
224
+ ctx.save(); ctx.beginPath(); ctx.rect(0, 0, Lo.W, Lo.H); ctx.arc(cx, cy, Math.max(0, r), 0, Math.PI * 2, true);
225
+ ctx.fillStyle = '#2b2321'; ctx.fill('evenodd'); ctx.restore();
226
+ }
227
+
228
+ function drawSafeGuides(ctx, Lo) {
229
+ if (!Lo.safe) {
230
+ ctx.save(); ctx.setLineDash([16, 12]); ctx.strokeStyle = 'rgba(0,200,255,.8)'; ctx.lineWidth = 3;
231
+ ctx.strokeRect(Lo.W * 0.05, Lo.H * 0.05, Lo.W * 0.9, Lo.H * 0.9); ctx.restore(); return;
232
+ }
233
+ const s = Lo.safe;
234
+ ctx.save();
235
+ ctx.fillStyle = 'rgba(255,0,80,.18)';
236
+ ctx.fillRect(0, 0, Lo.W, s.top); ctx.fillRect(0, s.bottom, Lo.W, Lo.H - s.bottom); ctx.fillRect(s.right, Lo.H * 0.45, Lo.W - s.right, s.bottom - Lo.H * 0.45);
237
+ ctx.font = '30px sans-serif'; ctx.fillStyle = 'rgba(255,255,255,.95)'; ctx.textAlign = 'center';
238
+ ctx.fillText('刘海 / 状态栏 / 顶部导航 区域', Lo.W / 2, s.top / 2);
239
+ ctx.fillText('抖音 账号名 / 文案 / 音乐 / 进度条 区域', Lo.W / 2, s.bottom + 120);
240
+ ctx.save(); ctx.translate(s.right + 70, Lo.H * 0.62); ctx.rotate(Math.PI / 2); ctx.fillText('点赞 / 评论 / 分享 按钮', 0, 0); ctx.restore();
241
+ ctx.restore();
242
+ }
243
+
244
+ /* ---------- 主渲染 ---------- */
245
+ function render(ctx, t, Lo, opts = {}) {
246
+ D.boil = Math.floor(t * 8);
247
+ const { sc, lt } = TL.at(t);
248
+ const scene = window.SCENES[sc.id];
249
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
250
+ ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over';
251
+ ctx.fillStyle = '#fbf3e1'; ctx.fillRect(0, 0, Lo.W, Lo.H);
252
+ const S = {
253
+ t, lt, dur: sc.dur, L: sc.L, id: sc.id, portrait: Lo.name === 'portrait',
254
+ ls: (i) => lt - sc.L[i],
255
+ };
256
+ ctx.save();
257
+ ctx.setTransform(Lo.k, 0, 0, Lo.k, Lo.sx, Lo.sy);
258
+ try { scene && scene(ctx, lt, S); } catch (e) { console.error(sc.id, e); }
259
+ ctx.restore();
260
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
261
+ // 颗粒与暗角
262
+ ctx.save(); ctx.globalAlpha = 0.07; ctx.globalCompositeOperation = 'multiply';
263
+ ctx.fillStyle = ctx.createPattern(getGrain(), 'repeat'); ctx.fillRect(0, 0, Lo.W, Lo.H); ctx.restore();
264
+ const vg = ctx.createRadialGradient(Lo.W / 2, Lo.H / 2, Math.min(Lo.W, Lo.H) * 0.45, Lo.W / 2, Lo.H / 2, Math.hypot(Lo.W, Lo.H) * 0.62);
265
+ vg.addColorStop(0, 'rgba(43,35,33,0)'); vg.addColorStop(1, 'rgba(43,35,33,.28)');
266
+ ctx.fillStyle = vg; ctx.fillRect(0, 0, Lo.W, Lo.H);
267
+ drawIris(ctx, Lo, sc, lt, sc.index === 0, sc.index === TL.scenes.length - 1);
268
+ const irisDark = (sc.index > 0 && lt < 0.12) || (sc.dur - lt < 0.08);
269
+ if (!irisDark) drawHud(ctx, t, Lo, sc, lt);
270
+ drawSubtitle(ctx, t, Lo);
271
+ if (opts.guides) drawSafeGuides(ctx, Lo);
272
+ }
273
+
274
+ /* ---------- 封面 ---------- */
275
+ function renderCover(ctx, Lo) {
276
+ D.boil = 3;
277
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
278
+ ctx.fillStyle = '#fbf3e1'; ctx.fillRect(0, 0, Lo.W, Lo.H);
279
+ ctx.save(); ctx.setTransform(Lo.k, 0, 0, Lo.k, Lo.sx, Lo.sy);
280
+ window.SCENES.__cover(ctx, Lo.name === 'portrait');
281
+ ctx.restore();
282
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
283
+ ctx.save(); ctx.globalAlpha = 0.07; ctx.globalCompositeOperation = 'multiply';
284
+ ctx.fillStyle = ctx.createPattern(getGrain(), 'repeat'); ctx.fillRect(0, 0, Lo.W, Lo.H); ctx.restore();
285
+ const P = Lo.name === 'portrait';
286
+ if (P) {
287
+ D.bigText(ctx, '收猴子的故事', 540, 330, 132, '#ffcd3c', { rot: -0.03 });
288
+ ctx.save(); D.rrect(ctx, 150, 420, 780, 92, 40, 2, 1); D.fs(ctx, '#ff5b4f', 6); ctx.restore();
289
+ D.text(ctx, '一个寓言,看懂所有泡沫', 540, 468, 54, '#fff');
290
+ D.bigText(ctx, '你会花35块', 470, 1320, 92, '#fff');
291
+ D.bigText(ctx, '买一只猴子吗?', 470, 1440, 92, '#ffcd3c');
292
+ } else {
293
+ D.bigText(ctx, '收猴子的故事', 960, 150, 150, '#ffcd3c', { rot: -0.03 });
294
+ D.rrect(ctx, 600, 238, 720, 84, 40, 2, 1); D.fs(ctx, '#ff5b4f', 6);
295
+ D.text(ctx, '一个寓言,看懂所有泡沫', 960, 281, 52, '#fff');
296
+ D.bigText(ctx, '35块买只猴子', 330, 560, 72, '#fff', { rot: -0.08 });
297
+ D.bigText(ctx, '你干不干?', 330, 660, 72, '#ffcd3c', { rot: -0.08 });
298
+ D.bigText(ctx, '博傻理论', 1640, 600, 70, '#fff', { rot: 0.08 });
299
+ D.bigText(ctx, '泡沫真相', 1640, 700, 70, '#8ff5b5', { rot: 0.08 });
300
+ }
301
+ }
302
+
303
+ window.ENGINE = { render, renderCover, TL, LAYOUTS, STAGE_W, STAGE_H };
304
+ })();
shouhouzi/js/scenes.js ADDED
@@ -0,0 +1,897 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================================
2
+ * scenes.js —— 15 个场景动画。舞台坐标 1280×960,地平线 GY。
3
+ * 每个场景函数签名 (ctx, lt, S):lt 场景内时间,S.ls(i) = 距第 i 句开始的秒数
4
+ * 背景会画到舞台之外,以覆盖横屏两侧与竖屏上下的额外区域。
5
+ * ========================================================= */
6
+ (function () {
7
+ const { C, E } = D;
8
+ const GY = 780;
9
+ const X0 = -900, X1 = 2200, Y0 = -1000, Y1 = 2300;
10
+ const SC = {};
11
+
12
+ /* ---------- 小工具 ---------- */
13
+ // 元素可见度:a 时刻弹入,b 时刻后收起;返回缩放系数
14
+ function vis(lt, a, b = null, inD = 0.45, outD = 0.3) {
15
+ if (lt < a) return 0;
16
+ let k = E.back((lt - a) / inD);
17
+ if (b != null && lt > b) k *= 1 - E.in((lt - b) / outD);
18
+ return Math.max(0, k);
19
+ }
20
+ function fade(lt, a, b = null, inD = 0.35, outD = 0.3) {
21
+ if (lt < a) return 0;
22
+ let k = D.clamp01((lt - a) / inD);
23
+ if (b != null && lt > b) k *= 1 - D.clamp01((lt - b) / outD);
24
+ return k;
25
+ }
26
+ const at = D.at;
27
+ function fillAll(ctx, col) { ctx.fillStyle = col; ctx.fillRect(X0, Y0, X1 - X0, Y1 - Y0); }
28
+ function sky(ctx, top = '#8fd3ff', bot = '#e9f8ff') {
29
+ const g = ctx.createLinearGradient(0, -700, 0, GY); g.addColorStop(0, top); g.addColorStop(1, bot);
30
+ ctx.fillStyle = g; ctx.fillRect(X0, Y0, X1 - X0, GY - Y0 + 10);
31
+ }
32
+ function ground(ctx, col = C.grass, col2 = C.grass2, t = 0) {
33
+ ctx.beginPath(); ctx.moveTo(X0, GY + 10);
34
+ for (let x = X0; x <= X1; x += 80) ctx.lineTo(x, GY + Math.sin(x * 0.01) * 10 + D.jit(x, 1.5));
35
+ ctx.lineTo(X1, Y1); ctx.lineTo(X0, Y1); ctx.closePath(); D.fs(ctx, col, 5);
36
+ // 草丛 / 小花
37
+ for (let i = 0; i < 60; i++) {
38
+ const x = X0 + D.hash(i * 3.3) * (X1 - X0), y = GY + 40 + D.hash(i * 7.7) * 1300;
39
+ ctx.beginPath(); ctx.moveTo(x - 12, y); ctx.lineTo(x - 4, y - 18); ctx.lineTo(x, y - 4); ctx.lineTo(x + 6, y - 20); ctx.lineTo(x + 12, y);
40
+ D.stroke(ctx, 4, col2);
41
+ if (i % 5 === 0) { ctx.beginPath(); ctx.arc(x + 20, y - 8, 7, 0, Math.PI * 2); D.fs(ctx, i % 2 ? '#fff' : '#ffd84a', 3); }
42
+ }
43
+ }
44
+ function mountains(ctx, col = '#bfe0c8', col2 = '#a6d1b4') {
45
+ D.poly(ctx, [[X0, GY], [-500, 380], [-200, 560], [100, 300], [420, 520], [700, 330], [1000, 540], [1300, 280], [1650, 500], [1900, 360], [X1, 520], [X1, GY]], 401, 2); D.fs(ctx, col, 4);
46
+ D.poly(ctx, [[X0, GY], [-350, 560], [0, 640], [300, 500], [640, 620], [900, 480], [1200, 640], [1500, 520], [X1, 640], [X1, GY]], 402, 2); D.fs(ctx, col2, 4);
47
+ }
48
+ function clouds(ctx, t, y = -40) {
49
+ for (let i = 0; i < 6; i++) {
50
+ const span = X1 - X0 + 400;
51
+ const x = X0 - 200 + ((D.hash(i * 9.1) * span + t * (14 + i * 4)) % span);
52
+ D.cloud(ctx, x, y + D.hash(i * 4.2) * 260 - (i % 2) * 380, 0.8 + D.hash(i) * 0.6, 410 + i);
53
+ }
54
+ }
55
+ // 森林带:density 0..1 控制树上猴子数量
56
+ const FOREST = [];
57
+ for (let i = 0; i < 20; i++) FOREST.push({ x: X0 + 60 + i * 160 + D.hash(i * 2.1) * 50, s: 0.95 + D.hash(i * 5.3) * 0.5, pine: i % 3 === 1 });
58
+ function forest(ctx, t, y, density = 1, o = {}) {
59
+ const sc = o.scale || 1;
60
+ FOREST.forEach((f, i) => {
61
+ if (f.pine) D.pine(ctx, f.x, y, f.s * sc * 1.05, 420 + i, o.col2 || C.forest2);
62
+ else D.tree(ctx, f.x, y, f.s * sc, 420 + i, o.col || C.forest);
63
+ });
64
+ const n = Math.round(FOREST.length * 1.5 * D.clamp01(density));
65
+ for (let i = 0; i < n; i++) {
66
+ const f = FOREST[(i * 7) % FOREST.length];
67
+ const hang = i % 3 === 0;
68
+ const sw = Math.sin(t * 2.2 + i) * 0.35;
69
+ const mx = f.x + (D.hash(i * 1.7) - .5) * 70, my = y - 150 * f.s * sc + (i % 2) * 30;
70
+ if (hang) { // 吊在树枝上荡秋千
71
+ ctx.save(); ctx.translate(mx, my - 40); ctx.rotate(sw);
72
+ D.line(ctx, 0, 0, 0, 40, 430 + i); D.stroke(ctx, 6, '#6b8e23');
73
+ D.monkey(ctx, 0, 190, 0.62 * sc, { t, seed: i + 3, aL: 3.0, aR: 2.9, mood: o.mood || 'happy' });
74
+ ctx.restore();
75
+ } else {
76
+ D.monkey(ctx, mx, my + 40 + Math.abs(Math.sin(t * 3 + i)) * -14, 0.55 * sc, { t, seed: i + 3, flip: i % 2, aL: 2.4 + Math.sin(t * 4 + i) * .4, mood: o.mood || 'happy' });
77
+ }
78
+ }
79
+ }
80
+ function houses(ctx, t, smoke = true) {
81
+ D.house(ctx, -330, GY + 20, 0.9, 501, '#f6e3c3', '#d9534f', smoke ? t : null);
82
+ D.house(ctx, 170, GY + 30, 1.05, 502, '#fdebd0', '#4a8fe2', smoke ? t + 1 : null);
83
+ D.house(ctx, 1120, GY + 30, 1.0, 503, '#f6e3c3', '#e8a33d', smoke ? t + 2 : null);
84
+ D.house(ctx, 1560, GY + 20, 0.9, 504, '#fdebd0', '#7bc96f', smoke ? t + 3 : null);
85
+ }
86
+ function paper(ctx, col = '#fbf3e1', grid = 'rgba(74,143,226,.13)') {
87
+ fillAll(ctx, col);
88
+ ctx.beginPath();
89
+ for (let x = -896; x < X1; x += 64) { ctx.moveTo(x, Y0); ctx.lineTo(x, Y1); }
90
+ for (let y = -960; y < Y1; y += 64) { ctx.moveTo(X0, y); ctx.lineTo(X1, y); }
91
+ ctx.lineWidth = 2; ctx.strokeStyle = grid; ctx.stroke();
92
+ }
93
+ function rays(ctx, cx, cy, t, col1 = '#ffe08a', col2 = '#ffd04d', n = 18) {
94
+ fillAll(ctx, col1);
95
+ ctx.save(); ctx.translate(cx, cy); ctx.rotate(t * 0.15);
96
+ for (let i = 0; i < n; i++) { ctx.beginPath(); ctx.moveTo(0, 0); ctx.arc(0, 0, 3200, i / n * Math.PI * 2, (i + .5) / n * Math.PI * 2); ctx.closePath(); ctx.fillStyle = col2; ctx.fill(); }
97
+ ctx.restore();
98
+ }
99
+ function cam(ctx, cx, cy, z, fn) { ctx.save(); ctx.translate(cx, cy); ctx.scale(z, z); ctx.translate(-cx, -cy); fn(); ctx.restore(); }
100
+ function shake(ctx, amt, t) { if (amt > 0) ctx.translate(Math.sin(t * 61) * amt, Math.cos(t * 47) * amt); }
101
+ function tint(ctx, col, a) { if (a <= 0) return; ctx.save(); ctx.globalAlpha = a; fillAll(ctx, col); ctx.restore(); }
102
+ function desat(ctx, a) { if (a <= 0) return; ctx.save(); ctx.globalCompositeOperation = 'saturation'; ctx.globalAlpha = a; fillAll(ctx, '#808080'); ctx.restore(); }
103
+ function card(ctx, x, y, w, h, col = '#fffaf0', seed = 600) { D.rrect(ctx, x - w / 2 + 10, y - h / 2 + 14, w, h, 28, seed + 1); D.fs(ctx, 'rgba(43,35,33,.25)', 0); D.rrect(ctx, x - w / 2, y - h / 2, w, h, 28, seed); D.fs(ctx, col, 6); }
104
+ function label(ctx, txt, x, y, size, bg = C.red, fg = '#fff', rot = 0) {
105
+ ctx.save(); ctx.translate(x, y); ctx.rotate(rot); ctx.font = D.font(size); const w = ctx.measureText(txt).width + size;
106
+ D.rrect(ctx, -w / 2, -size * .8, w, size * 1.6, size * .6, 611); D.fs(ctx, bg, 5);
107
+ D.text(ctx, txt, 0, 2, size, fg); ctx.restore();
108
+ }
109
+ function rock(ctx, x, y, s) { at(ctx, x, y, s, () => { D.blob(ctx, 0, -50, 120, 60, 620); D.fs(ctx, '#b8b2a7', 6); ctx.beginPath(); ctx.moveTo(-60, -70); ctx.lineTo(-30, -60); D.stroke(ctx, 4, '#8f897f'); }); }
110
+ function counterText(v) { return Math.round(v).toLocaleString('en-US'); }
111
+
112
+ /* =======================================================
113
+ * 0 开场 hook
114
+ * ======================================================= */
115
+ SC.hook = function (ctx, lt, S) {
116
+ const t = S.t;
117
+ const shout = fade(lt, S.L[2], S.L[3]);
118
+ rays(ctx, 640, 480, t, shout > 0 ? '#ffd27a' : '#ffe9a6', shout > 0 ? '#ffc04a' : '#ffdf8a');
119
+ if (shout > 0) D.speedLines(ctx, 640, 420, t, `rgba(255,255,255,${0.5 * shout})`);
120
+ ctx.save(); shake(ctx, shout * (S.ls(2) < 1.2 ? 6 : 0), t);
121
+ // L0 大问号
122
+ const q = vis(lt, S.L[0], S.L[1] + 0.2);
123
+ at(ctx, 640, 420, q * 2.2, () => D.qmark(ctx, 0, 0, 1, t, C.blue));
124
+ if (q > 0) at(ctx, 640, 640, q, () => D.text(ctx, '先问你一个问题', 0, 0, 64, C.ink));
125
+ // L1~L2 商人登场
126
+ const m = vis(lt, S.L[1], S.L[3] - 0.1, 0.6);
127
+ if (m > 0) {
128
+ const my = D.lerp(1300, GY + 120, E.back(D.clamp01(S.ls(1) / 0.6)));
129
+ D.merchant(ctx, 900, my, 1.5 * Math.min(1, m + .2), { t, mood: shout > 0 ? 'talk' : 'happy', aR: 2.3, holdR: (c) => D.megaphone(c), aL: 0.5 });
130
+ if (shout > 0) {
131
+ const b = vis(lt, S.L[2], S.L[3] - 0.1);
132
+ at(ctx, 420, 330, b, () => {
133
+ D.bubble(ctx, 0, 0, 640, 300, 360, 160, { seed: 3 });
134
+ D.bigText(ctx, '收猴子!', 0, -70, 78, C.ink, { shadow: null, stroke: 0 });
135
+ D.bigText(ctx, '¥10/只', 0, 40, 120, C.red);
136
+ }, -0.05);
137
+ D.coinBurst(ctx, 640, 500, S.ls(2) - 0.4, 18, 7, 1.1);
138
+ }
139
+ }
140
+ // L3 干不干
141
+ const b3 = vis(lt, S.L[3], S.L[4] + 1.5);
142
+ if (b3 > 0) {
143
+ const click = S.ls(3) > 1.4;
144
+ at(ctx, 400, 480, b3 * (click && S.ls(3) < 1.6 ? 0.9 : 1), () => { card(ctx, 0, 0, 380, 200, click ? '#6fe08f' : '#b9f0c9', 701); D.bigText(ctx, '干!', 0, 0, 100, '#fff'); });
145
+ at(ctx, 880, 480, b3, () => { card(ctx, 0, 0, 380, 200, '#e1e1e1', 702); D.text(ctx, '不干', 0, 0, 80, '#999'); });
146
+ // 手形光标
147
+ const k = E.inout(D.clamp01((S.ls(3) - 0.4) / 0.9));
148
+ const hx = D.lerp(1100, 440, k), hy = D.lerp(820, 520, k);
149
+ at(ctx, hx, hy, b3, () => { D.blob(ctx, 0, 0, 30, 36, 703); D.fs(ctx, '#fff', 5); D.rrect(ctx, -10, -70, 20, 60, 10, 704); D.fs(ctx, '#fff', 5); });
150
+ if (click) D.confetti(ctx, 400, 440, S.ls(3) - 1.4, 30, 500, 11);
151
+ }
152
+ // L4 白来的钱
153
+ const b4 = vis(lt, S.L[4] + 0.2, S.L[5] - 0.1);
154
+ if (b4 > 0) {
155
+ at(ctx, 640, 260, b4, () => D.bigText(ctx, '白来的钱?', 0, 0, 96, C.green));
156
+ D.villager(ctx, 0, 460, GY + 60, 1.3 * b4, { t, mood: 'money', aL: 2.6, aR: 2.6 });
157
+ D.monkey(ctx, 820, GY + 40, 1.6 * b4, { t, mood: 'smug', aR: 2.5 });
158
+ D.coinBurst(ctx, 460, 500, S.ls(4) - 0.6, 14, 21, 0.9);
159
+ }
160
+ // L5 最贵的钱
161
+ const b5 = vis(lt, S.L[5], S.L[6] - 0.1);
162
+ if (b5 > 0) {
163
+ tint(ctx, '#2b2321', 0.55 * D.clamp01(S.ls(5) / 1.5));
164
+ const k = D.clamp01(S.ls(5) / 2);
165
+ D.bill(ctx, 640, 440, 3.2 * b5, Math.sin(t * 2) * 0.06, '10');
166
+ at(ctx, 900, 300, vis(lt, S.L[5] + 2.2), () => { D.stamp(ctx, 0, 0, 1.2, '最贵的钱?', C.red, 0.15); });
167
+ at(ctx, 640, 700, b5, () => D.text(ctx, '……真的是白来的吗?', 0, 0, 52, '#fff', { alpha: k }));
168
+ }
169
+ // L6 标题卡
170
+ const b6 = vis(lt, S.L[6] - 0.05, null, 0.7);
171
+ if (b6 > 0) {
172
+ rays(ctx, 640, 420, t, '#ffcd3c', '#ffb52e', 22);
173
+ D.confetti(ctx, 640, 200, S.ls(6) - 0.2, 60, 800, 31);
174
+ const title = '收猴子的故事';
175
+ [...title].forEach((ch, i) => {
176
+ const d = S.ls(6) - 0.15 - i * 0.12;
177
+ const k = E.elastic(D.clamp01(d / 0.8));
178
+ at(ctx, 640 + (i - 2.5) * 150, 360 + Math.sin(t * 3 + i) * 8, k, () => D.bigText(ctx, ch, 0, 0, 150, i % 2 ? '#fff' : C.red));
179
+ });
180
+ at(ctx, 640, 520, vis(lt, S.L[6] + 1.2), () => label(ctx, '经济学寓言', 0, 0, 56, C.ink, '#ffcd3c', -0.03));
181
+ D.monkey(ctx, 240, GY + 120, 1.5 * vis(lt, S.L[6] + 0.8), { t, aL: 2.9, aR: 2.9, mood: 'happy' });
182
+ D.monkey(ctx, 1040, GY + 120, 1.5 * vis(lt, S.L[6] + 1.0), { t, flip: 1, aL: 2.9, aR: 2.9, mood: 'smug' });
183
+ }
184
+ ctx.restore();
185
+ };
186
+
187
+ /* =======================================================
188
+ * 1 小山村 village
189
+ * ======================================================= */
190
+ SC.village = function (ctx, lt, S) {
191
+ const t = S.t;
192
+ const z = D.lerp(1.25, 1, E.inout(lt / 6));
193
+ cam(ctx, 640, 520, z, () => {
194
+ sky(ctx); D.sun(ctx, 1050, 60, 70, t); clouds(ctx, t);
195
+ mountains(ctx);
196
+ forest(ctx, t, GY - 70, vis(lt, S.L[1], null, 1.2), { scale: 0.85 });
197
+ ground(ctx);
198
+ houses(ctx, t);
199
+ // 小路
200
+ ctx.beginPath(); ctx.moveTo(560, Y1); ctx.quadraticCurveTo(600, 1000, 660, GY + 20); ctx.lineTo(720, GY + 20); ctx.quadraticCurveTo(760, 1000, 900, Y1); D.fs(ctx, '#e9d3a6', 4);
201
+ const fy = fade(lt, S.L[0], S.L[1] + 1);
202
+ if (fy > 0) at(ctx, 640, 200, 1, () => D.bigText(ctx, '从前……', 0, 0, 90, '#fff', { alpha: fy }));
203
+ if (lt > S.L[1] && lt < S.L[2] + 0.2) at(ctx, 640, 210, vis(lt, S.L[1] + 0.8, S.L[2]), () => label(ctx, '成百上千只猴子', 0, 0, 56, C.fur, '#fff'));
204
+ // L2 三个不能
205
+ const icons = [['不能吃', '🍚'], ['不能用', '🔧'], ['卖不了钱', '¥']];
206
+ icons.forEach(([txt], i) => {
207
+ const k = vis(lt, S.L[2] + 0.4 + i * 1.3, S.L[3] - 0.1);
208
+ if (k <= 0) return;
209
+ at(ctx, 300 + i * 340, 250, k, () => {
210
+ card(ctx, 0, 0, 290, 170, '#fff', 720 + i);
211
+ D.monkey(ctx, -80, 60, 0.6, { t, seed: i + 9, mood: 'smug' });
212
+ D.text(ctx, txt, 40, 0, 46, C.ink);
213
+ if (lt > S.L[2] + 0.9 + i * 1.3) D.cross(ctx, 40, 0, 50);
214
+ });
215
+ });
216
+ // L3 偷玉米
217
+ if (lt > S.L[3]) {
218
+ const d = S.ls(3);
219
+ const mx = D.lerp(-200, 1500, d / 4.4);
220
+ D.monkey(ctx, mx, GY + 110 - Math.abs(Math.sin(d * 9)) * 40, 1.1, { t, seed: 5, flip: 0, aR: 2.2, mood: 'smug', hold: (c, x, y) => { D.blob(c, x + 6, y - 20, 14, 34, 730); D.fs(c, '#ffd84a', 4); D.blob(c, x + 6, y + 14, 18, 10, 731); D.fs(c, '#7bc96f', 3); } });
221
+ const vx = D.lerp(-500, 1150, (d - 0.3) / 4.4);
222
+ D.villager(ctx, 0, vx, GY + 140, 1.0, { t, walk: d * 14, mood: 'angry', aR: 2.8 });
223
+ if (d > 0.6) at(ctx, vx + 60, GY - 210, 1, () => D.text(ctx, '站住!', 0, 0, 44, C.red, { stroke: 8, strokeColor: '#fff' }));
224
+ }
225
+ });
226
+ };
227
+
228
+ /* =======================================================
229
+ * 2 商人进村 arrive
230
+ * ======================================================= */
231
+ SC.arrive = function (ctx, lt, S) {
232
+ const t = S.t;
233
+ sky(ctx); D.sun(ctx, 1120, 40, 60, t); clouds(ctx, t);
234
+ mountains(ctx); forest(ctx, t, GY - 70, 0.8, { scale: 0.8, mood: S.ls(2) > 0 ? 'shock' : 'happy' }); ground(ctx); houses(ctx, t);
235
+ rock(ctx, 640, GY + 150, 1.6);
236
+ // 村民探头
237
+ const peek = vis(lt, S.L[0] + 1.5);
238
+ [[150, 0], [300, 1], [1000, 2], [1150, 3]].forEach(([x, i]) => D.villager(ctx, i, x, GY + 220, 0.9 * peek, { t, mood: S.ls(2) > 0.4 ? 'shock' : 'happy', flip: x > 640 }));
239
+ // 商人走入 -> 跳上石头
240
+ const d0 = S.ls(0), d1 = S.ls(1);
241
+ let mx, my, walk = null, aR = 0.3, mood = 'happy';
242
+ if (d1 < 0) { mx = D.lerp(-300, 470, E.out(d0 / 4)); my = GY + 200; walk = d0 < 4 ? d0 * 10 : null; }
243
+ else { const k = D.clamp01(d1 / 0.7); mx = D.lerp(470, 640, k); my = D.lerp(GY + 200, GY + 50, k) - Math.sin(k * Math.PI) * 120; aR = D.lerp(0.3, 2.4, k); }
244
+ if (S.ls(2) > 0) { mood = 'talk'; aR = 2.4; }
245
+ if (walk != null) D.dust(ctx, mx - 30, my, t, 5, 3);
246
+ D.merchant(ctx, mx, my, 1.2, { t, walk, mood, aR, holdR: d1 > 0.2 ? (c) => D.megaphone(c) : null, aL: 0.4 });
247
+ if (d0 > 0.3 && d1 < 0) at(ctx, mx, my - 380, vis(lt, S.L[0] + 0.5, S.L[1]), () => label(ctx, '神秘商人', 0, 0, 42, C.ink, '#ffcd3c'));
248
+ // L2 喊话
249
+ if (S.ls(2) > 0) {
250
+ const k = vis(lt, S.L[2]);
251
+ D.speedLines(ctx, 760, 300, t, 'rgba(255,255,255,.35)', 28, 300);
252
+ at(ctx, 980, 230, k, () => {
253
+ D.bubble(ctx, 0, 0, 520, 280, -220, 160, { seed: 7 });
254
+ D.text(ctx, '高价收猴子!', 0, -70, 60, C.ink);
255
+ D.bigText(ctx, '¥10/只', 0, 30, 100, C.red);
256
+ D.text(ctx, '现金结算', 0, 105, 36, C.green);
257
+ }, Math.sin(t * 8) * 0.02);
258
+ [[150, 0], [300, 1], [1000, 2], [1150, 3]].forEach(([x], i) => D.exclaim(ctx, x, GY - 120, 0.5 * vis(lt, S.L[2] + 0.4 + i * 0.15), t));
259
+ }
260
+ };
261
+
262
+ /* =======================================================
263
+ * 3 十元一只 ten
264
+ * ======================================================= */
265
+ SC.ten = function (ctx, lt, S) {
266
+ const t = S.t;
267
+ sky(ctx); clouds(ctx, t); D.sun(ctx, 1100, 0, 60, t);
268
+ const dens = 1 - 0.35 * D.clamp01((lt - S.L[2]) / 14);
269
+ forest(ctx, t, GY - 10, dens, { scale: 1.15 });
270
+ ground(ctx);
271
+ const conv = S.ls(3) > 0; // 交易阶段
272
+ // 商人 + 笼子(左侧)
273
+ const cageFill = conv ? D.clamp01(S.ls(3) / 9) * 0.6 : 0;
274
+ if (lt > S.L[1]) {
275
+ D.cage(ctx, 260, GY + 200, 360, 250, cageFill, t, { cols: 5, rows: 3, label: '收购笼' });
276
+ D.merchant(ctx, 520, GY + 210, 1.0, { t, mood: 'laugh', aR: conv ? 1.6 + Math.sin(t * 6) * .3 : 0.4 });
277
+ }
278
+ // L0 村民怀疑
279
+ if (lt < S.L[2] + 0.3) {
280
+ const k = vis(lt, S.L[0], S.L[2]);
281
+ [[700, 0], [860, 1], [1020, 2]].forEach(([x, i]) => D.villager(ctx, i, x, GY + 200, 1.0, { t, mood: S.ls(1) > 1.5 ? 'shock' : 'sly', aR: 2.6 }));
282
+ at(ctx, 860, 250, k * (S.ls(1) > 0 ? 1 - D.clamp01(S.ls(1) / .3) : 1), () => D.thought(ctx, 0, 0, 520, 200, 0, 180, { text: ['这人……', '脑子有问题吧?'], size: 46 }));
283
+ // L1 第一个人拿到钱
284
+ if (S.ls(1) > 0) {
285
+ const d = S.ls(1);
286
+ const mx = D.lerp(700, 520, E.inout(d / 1)), my = GY + 40 - Math.sin(D.clamp01(d) * Math.PI) * 80;
287
+ if (d < 1.2) D.monkey(ctx, mx, my, 0.7, { t, mood: 'shock' });
288
+ D.coinBurst(ctx, 640, GY - 100, d - 1.1, 10, 41, 0.8);
289
+ at(ctx, 700, GY - 260, vis(lt, S.L[1] + 1.3), () => D.bigText(ctx, '+¥10', 0, -Math.min(60, (d - 1.3) * 40), 80, C.green));
290
+ }
291
+ }
292
+ // L2 冲进山
293
+ if (S.ls(2) > 0 && !conv) {
294
+ const d = S.ls(2);
295
+ for (let i = 0; i < 7; i++) {
296
+ const x = D.lerp(-300 - i * 170, 1700, (d - i * 0.08) / 4.2);
297
+ D.villager(ctx, i, x, GY + 170 + (i % 2) * 70, 0.95, { t: t + i, walk: d * 12 + i, mood: 'laugh', aR: 2.7, holdR: (c) => D.net(c) });
298
+ D.dust(ctx, x - 40, GY + 170 + (i % 2) * 70, t + i * .3, 3, i);
299
+ }
300
+ at(ctx, 640, 220, vis(lt, S.L[2] + 0.3, S.L[3] - 0.2), () => D.bigText(ctx, '冲啊!!', 0, 0, 110, C.red, { rot: -0.08 }));
301
+ }
302
+ // L3~L4 一手交猴一手交钱:猴子流水线
303
+ if (conv) {
304
+ const d = S.ls(3);
305
+ for (let i = 0; i < 6; i++) {
306
+ const ph = ((d * 0.55 + i / 6) % 1);
307
+ const x = D.lerp(1250, 330, ph), y = GY + 60 - Math.abs(Math.sin(ph * Math.PI * 3)) * 140;
308
+ D.monkey(ctx, x, y, 0.55, { t, seed: i + 20, mood: 'shock', aL: 2.8, aR: 2.8 });
309
+ const cx = D.lerp(560, 1150, ph), cy = GY - 260 - Math.sin(ph * Math.PI) * 160;
310
+ D.coin(ctx, cx, cy, 22, t * 8 + i);
311
+ }
312
+ [[900, 3], [1050, 4], [1200, 5]].forEach(([x, i], j) => D.villager(ctx, i, x, GY + 210, 0.95, { t, mood: 'money', aL: 2.5 + Math.sin(t * 6 + j) * .3, aR: 2.5, flip: true, jump: Math.abs(Math.sin(t * 5 + j)) * 20 }));
313
+ at(ctx, 640, 170, vis(lt, S.L[3] + 0.2, S.L[4]), () => label(ctx, '一手交猴 · 一手交钱', 0, 0, 52, C.ink, '#ffcd3c'));
314
+ if (S.ls(4) > 0) at(ctx, 950, 200, vis(lt, S.L[4] + 0.5), () => { card(ctx, 0, 0, 420, 140, '#fff', 740); D.text(ctx, '村民口袋:鼓起来了!', 0, 0, 40, C.green); });
315
+ }
316
+ };
317
+
318
+ /* =======================================================
319
+ * 4 二十元 twenty
320
+ * ======================================================= */
321
+ SC.twenty = function (ctx, lt, S) {
322
+ const t = S.t;
323
+ // L1 太阳快速划过(一整天)
324
+ const day = D.clamp01(S.ls(1) / 4.5);
325
+ const dusk = S.ls(1) > 0 && S.ls(2) < 0 ? Math.sin(day * Math.PI) * 0.0 + day * 0.5 : 0;
326
+ sky(ctx, dusk > 0 ? '#f6a96b' : '#8fd3ff', '#fff1d6');
327
+ D.sun(ctx, D.lerp(-100, 1400, S.ls(1) > 0 && S.ls(2) < 0 ? day : 0.75), D.lerp(300, -60, Math.sin((S.ls(1) > 0 && S.ls(2) < 0 ? day : .75) * Math.PI)), 60, t);
328
+ clouds(ctx, t);
329
+ const dens = D.lerp(0.6, 0.3, D.clamp01(lt / 8));
330
+ forest(ctx, t, GY - 10, S.ls(4) > 0 ? 0.3 : dens, { scale: 1.15 });
331
+ ground(ctx);
332
+ if (S.ls(2) < 0) {
333
+ // 抓猴子越来越难
334
+ const d = S.ls(1);
335
+ const x = 640 + Math.sin(t * 1.4) * 260;
336
+ D.villager(ctx, 0, x, GY + 200, 1.1, { t, walk: t * 8, flip: Math.cos(t * 1.4) < 0, mood: d > 0 ? 'sad' : 'happy', aR: 2.6, holdR: (c) => D.net(c) });
337
+ if (d > 0) {
338
+ for (let i = 0; i < 3; i++) { const k = (t * 1.5 + i / 3) % 1; ctx.globalAlpha = 1 - k; D.blob(ctx, x + 50 + i * 10, GY - 130 + k * 60, 8, 12, 750 + i); D.fs(ctx, '#7fd0ff', 3); ctx.globalAlpha = 1; }
339
+ at(ctx, 640, 200, vis(lt, S.L[1] + 0.6, S.L[2]), () => { card(ctx, 0, 0, 560, 150, '#fff', 751); D.text(ctx, '跑一整天……才 1~2 只', 0, 0, 48, C.ink); });
340
+ } else {
341
+ at(ctx, 640, 200, vis(lt, S.L[0] + 0.8, S.L[1]), () => label(ctx, '林子里的猴子:越来越少 ↓', 0, 0, 48, C.forest2));
342
+ }
343
+ } else {
344
+ // L2~L3 商人涨价
345
+ const d3 = S.ls(3);
346
+ D.merchant(ctx, 330, GY + 210, 1.15, { t, mood: S.ls(3) > 0 && S.ls(4) < 0 ? 'talk' : 'laugh', aR: 2.4, holdR: (c) => D.megaphone(c) });
347
+ const flip = D.clamp01((d3 + 0.1) / 0.6);
348
+ const price = flip > 0.5 ? '20' : '10';
349
+ D.priceSign(ctx, 760, GY + 160, 1.1, price, { t, flip });
350
+ if (d3 > 0.5) { at(ctx, 1030, 220, vis(lt, S.L[3] + 0.5), () => D.stamp(ctx, 0, 0, 1, '涨价!翻倍!', C.red, 0.18)); }
351
+ if (d3 > 0.3 && S.ls(4) < 0) D.speedLines(ctx, 760, 400, t, 'rgba(255,255,255,.4)', 24, 320);
352
+ // L4 全村沸腾 + 隔壁村
353
+ if (S.ls(4) > 0) {
354
+ const d = S.ls(4);
355
+ for (let i = 0; i < 6; i++) D.villager(ctx, i, 640 + i * 120 - 90, GY + 330, 0.8, { t: t + i, mood: 'laugh', aL: 2.8, aR: 2.8, jump: Math.abs(Math.sin(t * 6 + i)) * 30 });
356
+ D.confetti(ctx, 900, 300, d, 40, 700, 51);
357
+ at(ctx, 1150, 470, vis(lt, S.L[4] + 1.6), () => { D.rrect(ctx, -8, 0, 16, 180, 4, 760); D.fs(ctx, '#8a5a35', 4); D.poly(ctx, [[-120, -40], [110, -40], [150, 0], [110, 40], [-120, 40]], 761); D.fs(ctx, '#fff4d6', 5); D.text(ctx, '隔壁村 →', 0, 2, 40, C.ink); });
358
+ for (let i = 0; i < 4; i++) { const x = D.lerp(1900, 1100, (d - 2 - i * 0.3) / 2.5); if (d > 2 + i * .3) D.villager(ctx, i + 2, x, GY + 420, 0.75, { t, walk: t * 12 + i, flip: true, mood: 'laugh', aR: 2.6, holdR: (c) => D.net(c) }); }
359
+ }
360
+ }
361
+ };
362
+
363
+ /* =======================================================
364
+ * 5 二十五元 scarce
365
+ * ======================================================= */
366
+ SC.scarce = function (ctx, lt, S) {
367
+ const t = S.t;
368
+ sky(ctx, '#a9cbe0', '#eef4f2'); clouds(ctx, t);
369
+ forest(ctx, t, GY - 10, D.lerp(0.25, 0.06, D.clamp01(lt / 6)), { scale: 1.15, col: '#6aa36a', col2: '#4f8a55' });
370
+ ground(ctx, '#b5d488', '#93b866');
371
+ // L0-L1 价格牌 20 -> 25
372
+ if (S.ls(2) < 0) {
373
+ const flip = D.clamp01((S.ls(1) - 0.6) / 0.6);
374
+ D.priceSign(ctx, 900, GY + 170, 1.15, flip > 0.5 ? '25' : '20', { t, flip });
375
+ D.merchant(ctx, 470, GY + 210, 1.1, { t, mood: S.ls(1) > 0 ? 'talk' : 'happy', aR: 2.2, holdR: (c) => D.megaphone(c) });
376
+ at(ctx, 640, 180, vis(lt, S.L[0] + 0.4, S.L[1]), () => label(ctx, '猴子更少了……', 0, 0, 52, C.ink, '#fff'));
377
+ if (S.ls(1) > 1.2) at(ctx, 1100, 280, vis(lt, S.L[1] + 1.2), () => D.stamp(ctx, 0, 0, 0.9, '再涨!', C.red, 0.2));
378
+ } else if (S.ls(3) < 0) {
379
+ // L2 望远镜找猴子
380
+ const d = S.ls(2);
381
+ D.villager(ctx, 2, 420, GY + 200, 1.2, { t, mood: 'sad', aR: 2.0, holdR: (c) => { c.save(); c.rotate(-1.2); D.rrect(c, -10, -80, 26, 90, 6, 770); D.fs(c, '#c08a3e', 4); D.rrect(c, -14, -100, 34, 30, 6, 771); D.fs(c, '#8a5a35', 4); c.restore(); } });
382
+ // 望远镜视野
383
+ const vx = 860 + Math.sin(d * 1.3) * 200, vy = 330 + Math.cos(d * 1.7) * 60;
384
+ ctx.save(); ctx.beginPath(); ctx.arc(vx, vy, 150, 0, Math.PI * 2); ctx.lineWidth = 14; ctx.strokeStyle = C.ink; ctx.stroke(); ctx.fillStyle = 'rgba(255,255,255,.12)'; ctx.fill(); ctx.restore();
385
+ at(ctx, vx, vy, 1, () => D.text(ctx, '空', 0, 0, 90, 'rgba(43,35,33,.35)'));
386
+ [0, 1, 2].forEach((i) => D.qmark(ctx, 300 + i * 340, 150, 0.6 * vis(lt, S.L[2] + 0.6 + i * .4), t + i, C.blue));
387
+ } else {
388
+ // L3 抓到一只的人在炫耀
389
+ const d = S.ls(3);
390
+ for (let i = 0; i < 6; i++) { const x = 640 + (i - 2.5) * 210; if (Math.abs(x - 640) > 100) D.villager(ctx, i + 1, x, GY + 240 + (i % 2) * 40, 0.9, { t: t + i, mood: 'shock', aL: 2.5 + Math.sin(t * 8 + i) * .4, aR: 2.5 - Math.sin(t * 8 + i) * .4, flip: x > 640 }); }
391
+ D.villager(ctx, 0, 640, GY + 180, 1.25, { t, mood: 'laugh', aR: 2.9, aL: 0.6, jump: Math.abs(Math.sin(t * 4)) * 16, holdR: (c) => D.monkey(c, 0, 0, 0.75, { t, mood: 'smug', aL: 2.8, aR: 2.8 }) });
392
+ for (let i = 0; i < 6; i++) D.sparkle(ctx, 640 + Math.cos(i + t) * 200, 160 + Math.sin(i * 2 + t) * 80, 26, t + i);
393
+ at(ctx, 640, 120, vis(lt, S.L[3] + 0.4), () => label(ctx, '全村炫耀三天!', 0, 0, 54, C.purple));
394
+ }
395
+ };
396
+
397
+ /* =======================================================
398
+ * 6 五十元 fifty
399
+ * ======================================================= */
400
+ SC.fifty = function (ctx, lt, S) {
401
+ const t = S.t;
402
+ const spot = S.ls(2) < 0;
403
+ if (spot) {
404
+ fillAll(ctx, '#2a2340');
405
+ ctx.save(); const g = ctx.createRadialGradient(640, GY, 50, 640, GY - 200, 600); g.addColorStop(0, 'rgba(255,240,180,.9)'); g.addColorStop(1, 'rgba(255,240,180,0)');
406
+ ctx.fillStyle = g; ctx.beginPath(); ctx.moveTo(560, -900); ctx.lineTo(720, -900); ctx.lineTo(1000, GY + 250); ctx.lineTo(280, GY + 250); ctx.closePath(); ctx.fill(); ctx.restore();
407
+ ctx.fillStyle = '#3a3157'; ctx.fillRect(X0, GY + 200, X1 - X0, Y1);
408
+ D.merchant(ctx, 640, GY + 230, 1.4, { t, mood: S.ls(1) > 0 ? 'sly' : 'happy', aR: S.ls(1) > 0 ? 2.8 : 0.4, aL: S.ls(1) > 0 ? 2.8 : 0.4 });
409
+ at(ctx, 640, 120, vis(lt, S.L[1] + 0.2), () => D.bigText(ctx, '大 招', 0, 0, 120, '#ffcd3c'));
410
+ return;
411
+ }
412
+ rays(ctx, 640, 380, t, '#ffd27a', '#ffb547', 20);
413
+ const d2 = S.ls(2);
414
+ // 巨型 ¥50
415
+ if (S.ls(4) < 0) {
416
+ const drop = E.bounce(D.clamp01(d2 / 0.9));
417
+ D.priceSign(ctx, 640, D.lerp(-600, 600, drop), 1.6, '50', { t, noPost: true, bg: '#fff2b8', head: '最新收购价' });
418
+ D.confetti(ctx, 640, 100, d2 - 0.6, 70, 900, 61);
419
+ if (S.ls(3) > 0) {
420
+ // 村民瞪大眼
421
+ for (let i = 0; i < 5; i++) {
422
+ const x = 140 + i * 250, k = vis(lt, S.L[3] + i * 0.12);
423
+ D.villager(ctx, i, x, GY + 380, 1.05 * k, { t, mood: i % 2 ? 'money' : 'shock', aL: 2.7, aR: 2.7, jump: Math.abs(Math.sin(t * 7 + i)) * 20 });
424
+ }
425
+ at(ctx, 640, 120, vis(lt, S.L[3] + 0.3), () => D.bigText(ctx, '五十块!!', 0, 0, 96, '#fff'));
426
+ }
427
+ } else {
428
+ // L4 交接给助手 / L5 坐车离开
429
+ sky(ctx, '#ffd9a0', '#fff6e0'); clouds(ctx, t); ground(ctx);
430
+ const d5 = S.ls(5);
431
+ // 道路
432
+ ctx.fillStyle = '#c9b48f'; ctx.fillRect(X0, GY + 260, X1 - X0, 120);
433
+ const carX = d5 > 0 ? D.lerp(380, 2100, E.in(D.clamp01((d5 - 0.8) / 2.2))) : 380;
434
+ D.car(ctx, carX, GY + 360, 1.1, d5 > 0.8 ? t : 0, { color: C.red, driver: () => { if (d5 > 0.5) D.merchant(ctx, -10, -60, 0.45, { t, mood: 'laugh', hat: 'top', aR: 2.8 }); } });
435
+ if (d5 > 0.8) D.dust(ctx, carX - 140, GY + 350, t, 6, 9);
436
+ if (d5 < 0.6) D.merchant(ctx, 600, GY + 300, 1.1, { t, mood: 'talk', aR: 1.6 });
437
+ const ak = vis(lt, S.L[4] + 1.6);
438
+ D.assistant(ctx, 900, GY + 300, 1.1 * ak, { t, mood: 'sly', aR: d5 > 1 ? 2.6 + Math.sin(t * 10) * .3 : 0.3 });
439
+ if (ak > 0) at(ctx, 900, GY - 120, vis(lt, S.L[4] + 2.0, S.L[5] + 0.5), () => label(ctx, '助手', 0, 0, 44, C.green));
440
+ if (d5 < 0) at(ctx, 420, 220, vis(lt, S.L[4] + 0.3), () => D.bubble(ctx, 0, 0, 480, 160, 160, 330, { text: ['我进城办点事', '这里交给助手'], size: 40 }));
441
+ if (d5 > 0.9) at(ctx, Math.min(carX, 1150), GY - 40, vis(lt, S.L[5] + 0.9), () => D.bigText(ctx, '拜拜~', 0, 0, 70, '#fff'));
442
+ }
443
+ };
444
+
445
+ /* =======================================================
446
+ * 7 助手的提议 deal
447
+ * ======================================================= */
448
+ SC.deal = function (ctx, lt, S) {
449
+ const t = S.t;
450
+ const g = ctx.createLinearGradient(0, -800, 0, GY); g.addColorStop(0, '#3a2f6b'); g.addColorStop(1, '#c47aa0');
451
+ ctx.fillStyle = g; ctx.fillRect(X0, Y0, X1 - X0, GY - Y0 + 10);
452
+ ctx.beginPath(); ctx.arc(1080, 60, 60, 0, Math.PI * 2); D.fs(ctx, '#fff4c2', 5);
453
+ for (let i = 0; i < 30; i++) { ctx.fillStyle = 'rgba(255,255,255,.7)'; ctx.fillRect(X0 + D.hash(i) * (X1 - X0), -700 + D.hash(i * 3) * 900, 4, 4); }
454
+ ground(ctx, '#6f8f5a', '#5a7748');
455
+ D.cage(ctx, 330, GY + 230, 470, 330, 1, t, { cols: 6, rows: 3, label: '1000 只' });
456
+ // 价格标签
457
+ const tagK = vis(lt, S.L[2] + 1.4);
458
+ at(ctx, 330, GY - 210, tagK, () => { D.rrect(ctx, -110, -50, 220, 100, 18, 801); D.fs(ctx, '#8ff5b5', 6); D.bigText(ctx, '¥35', 0, 2, 78, C.ink, { shadow: null, stroke: 0 }); }, Math.sin(t * 3) * 0.08);
459
+ const lean = S.ls(1) > 0 ? 0.3 : 0;
460
+ D.assistant(ctx, 700, GY + 240, 1.25, { t, mood: S.ls(2) > 0 ? 'talk' : 'sly', aL: 1.2 + lean, aR: S.ls(2) > 0 ? 2.0 + Math.sin(t * 3) * .2 : 0.4 });
461
+ // 村民
462
+ for (let i = 0; i < 4; i++) {
463
+ const mood = S.ls(4) > 1.5 ? 'money' : S.ls(2) > 0 ? 'shock' : 'happy';
464
+ D.villager(ctx, i, 920 + i * 125, GY + 250 + (i % 2) * 40, 0.95, { t: t + i, mood, flip: true, aR: 0.4, jump: mood === 'money' ? Math.abs(Math.sin(t * 6 + i)) * 18 : 0 });
465
+ }
466
+ if (S.ls(1) > 0 && S.ls(2) < 0) at(ctx, 880, 300, vis(lt, S.L[1]), () => D.bubble(ctx, 0, 0, 240, 120, -120, 200, { text: '嘘……', size: 54 }));
467
+ // L3 等老板回来卖 50
468
+ if (S.ls(3) > 0) {
469
+ const k = vis(lt, S.L[3]);
470
+ at(ctx, 1000, 150, k, () => {
471
+ D.thought(ctx, 0, 0, 470, 260, -200, 180);
472
+ D.merchant(ctx, -110, 110, 0.5, { t, mood: 'laugh' });
473
+ D.arrow(ctx, -40, 0, 50, 0, C.ink, 7);
474
+ D.bigText(ctx, '¥50', 140, 0, 74, C.red, { shadow: null });
475
+ });
476
+ }
477
+ // L4 净赚 15
478
+ if (S.ls(4) > 0) {
479
+ const k = vis(lt, S.L[4] + 0.2);
480
+ at(ctx, 640, 470, k, () => { card(ctx, 0, 0, 640, 170, '#fff8d6', 810); D.bigText(ctx, '50 − 35 = +15', 0, 0, 84, C.green, { shadow: null }); });
481
+ for (let i = 0; i < 7; i++) D.sparkle(ctx, 360 + i * 95, 360 + Math.sin(t * 3 + i) * 20, 18, t + i);
482
+ if (S.ls(4) > 3) at(ctx, 700, GY - 200, vis(lt, S.L[4] + 3), () => D.bubble(ctx, 0, 0, 380, 110, -10, 140, { text: '只告诉你们哦', size: 42, fill: '#efffef' }));
483
+ }
484
+ };
485
+
486
+ /* =======================================================
487
+ * 8 全村抢购 frenzy
488
+ * ======================================================= */
489
+ SC.frenzy = function (ctx, lt, S) {
490
+ const t = S.t;
491
+ if (S.ls(1) < 0) {
492
+ rays(ctx, 640, 420, t, '#c9f5d3', '#a7ecb9', 20);
493
+ for (let i = 0; i < 26; i++) { const k = ((t * 0.5 + D.hash(i)) % 1); D.coin(ctx, X0 + D.hash(i * 3) * (X1 - X0), -800 + k * 2700, 24, t * 5 + i); }
494
+ for (let i = 0; i < 3; i++) D.villager(ctx, i, 360 + i * 280, GY + 160, 1.25, { t: t + i, mood: 'money', aL: 2.8, aR: 2.8, jump: Math.abs(Math.sin(t * 5 + i)) * 26 });
495
+ at(ctx, 640, 230, vis(lt, S.L[0] + 1.5), () => D.bigText(ctx, '这简直是捡钱!', 0, 0, 104, C.green, { rot: -0.05 }));
496
+ return;
497
+ }
498
+ sky(ctx, '#ffd29a', '#fff3dc'); clouds(ctx, t); ground(ctx, '#c9d98a', '#a9bf6a');
499
+ if (S.ls(3) < 0) {
500
+ // L1 砸存钱罐 / L2 借钱卖牛
501
+ const pb = S.ls(1);
502
+ const broken = D.clamp01((pb - 1.2) / 0.9);
503
+ at(ctx, 330, GY + 120, 1, () => D.piggy(ctx, 0, 0, 1.3, broken));
504
+ if (pb > 0.4 && pb < 1.4) at(ctx, 330, GY - 100, 1, () => { ctx.rotate(-0.6 + Math.sin(D.clamp01((pb - 0.4) / 0.8) * Math.PI) * 0.9); D.rrect(ctx, -12, -120, 24, 140, 6, 820); D.fs(ctx, '#8a5a35', 4); D.rrect(ctx, -50, -150, 100, 50, 8, 821); D.fs(ctx, '#888', 4); });
505
+ D.coinBurst(ctx, 330, GY, pb - 1.3, 16, 71, 1);
506
+ at(ctx, 330, 180, vis(lt, S.L[1] + 0.3), () => label(ctx, '掏空积蓄', 0, 0, 52, C.pink));
507
+ if (S.ls(2) > 0) {
508
+ at(ctx, 760, 330, vis(lt, S.L[2] + 0.1), () => { card(ctx, 0, 0, 260, 300, '#fff', 830); D.text(ctx, '借 条', 0, -100, 46, C.ink); for (let i = 0; i < 4; i++) { ctx.beginPath(); ctx.moveTo(-90, -40 + i * 40); ctx.lineTo(90, -40 + i * 40); D.stroke(ctx, 3, '#bbb'); } D.text(ctx, '¥¥¥', 0, 110, 40, C.red); }, -0.08);
509
+ D.cow(ctx, 1040, GY + 200, 1.1 * vis(lt, S.L[2] + 1.7), t, { tag: '卖牛' });
510
+ at(ctx, 950, 180, vis(lt, S.L[2] + 1.9), () => label(ctx, '借钱 · 卖牛', 0, 0, 50, C.red));
511
+ }
512
+ } else {
513
+ // L3 长队 / L4 抢空
514
+ const d = S.ls(3);
515
+ const fillK = S.ls(4) > 0 ? 1 - D.clamp01(S.ls(4) / 3.6) : 1;
516
+ D.cage(ctx, 220, GY + 220, 400, 300, fillK, t, { cols: 5, rows: 3, label: S.ls(4) > 3.6 ? '空了' : '¥35 一只' });
517
+ D.assistant(ctx, 500, GY + 230, 1.0, { t, mood: 'laugh', aR: 1.4 + Math.sin(t * 8) * .4 });
518
+ const qn = 9, off = (d * 60) % 150;
519
+ for (let i = 0; i < qn; i++) {
520
+ const x = 640 + i * 150 - off;
521
+ D.villager(ctx, i, x, GY + 250, 0.85, { t: t + i, walk: t * 10 + i, flip: true, mood: i % 3 ? 'money' : 'shock', aR: 2.6, holdR: S.ls(4) > 0 && i % 2 ? (c) => D.monkey(c, 0, 0, .35, { t, mood: 'smug' }) : null });
522
+ }
523
+ at(ctx, 960, 180, vis(lt, S.L[3] + 0.3, S.L[4]), () => label(ctx, '排队抢购中……', 0, 0, 50, C.ink, '#ffcd3c'));
524
+ if (S.ls(4) > 0) at(ctx, 640, 230, vis(lt, S.L[4] + 3.4), () => D.stamp(ctx, 0, 0, 1.4, '一抢而空', C.red, -0.15));
525
+ }
526
+ };
527
+
528
+ /* =======================================================
529
+ * 9 人去楼空 gone
530
+ * ======================================================= */
531
+ SC.gone = function (ctx, lt, S) {
532
+ const t = S.t;
533
+ sky(ctx, '#7c8ea3', '#c9d2da'); clouds(ctx, t * .5); mountains(ctx, '#a5b3ad', '#93a39b'); ground(ctx, '#8fa37a', '#7a8f66');
534
+ houses(ctx, t, false);
535
+ if (S.ls(2) < 0) {
536
+ // 空荡荡的收购点
537
+ ctx.fillStyle = '#b6a585'; ctx.fillRect(X0, GY + 250, X1 - X0, 110);
538
+ at(ctx, 360, GY + 230, 1, () => { D.rrect(ctx, -110, -110, 220, 20, 4, 840); D.fs(ctx, '#8a5a35', 4); D.rrect(ctx, -100, -90, 14, 90, 4, 841); D.fs(ctx, '#8a5a35', 4); D.rrect(ctx, 86, -90, 14, 90, 4, 842); D.fs(ctx, '#8a5a35', 4); });
539
+ at(ctx, 360, GY - 30, 1, () => { D.rrect(ctx, -6, 0, 12, 160, 3, 843); D.fs(ctx, '#8a5a35', 4); D.rrect(ctx, -130, -70, 260, 80, 10, 844); D.fs(ctx, '#e8dcc0', 4); D.text(ctx, '收猴处', 0, -30, 44, '#777'); }, 0.25);
540
+ const p = S.ls(0);
541
+ if (p < 1.5) for (let i = 0; i < 6; i++) { const k = D.clamp01(p / 1.2); ctx.globalAlpha = 1 - k; D.blob(ctx, 360 + Math.cos(i) * k * 120, GY + 40 + Math.sin(i * 2) * k * 80, 40 + k * 30, 30 + k * 20, 850 + i); D.fs(ctx, '#fff', 4); ctx.globalAlpha = 1; }
542
+ if (p < 0.4) D.assistant(ctx, 360, GY + 230, 1.0, { t, mood: 'sly' });
543
+ at(ctx, 360, 230, vis(lt, S.L[0] + 0.8), () => D.bigText(ctx, '人呢?', 0, 0, 80, '#fff'));
544
+ // 村民张望
545
+ for (let i = 0; i < 3; i++) D.villager(ctx, i, 820 + i * 150, GY + 260, 0.95, { t, mood: S.ls(1) > 0 ? 'sad' : 'shock', aR: S.ls(1) > 0 ? 2.2 : 0.3, holdL: (c) => D.monkey(c, 0, 20, .35, { t, mood: 'sad' }) });
546
+ if (S.ls(1) > 0) {
547
+ D.tumbleweed(ctx, D.lerp(-200, 1500, S.ls(1) / 3.4), GY + 290 - Math.abs(Math.sin(S.ls(1) * 5)) * 40, 1, S.ls(1));
548
+ at(ctx, 1000, 230, vis(lt, S.L[1] + 0.6), () => D.thought(ctx, 0, 0, 380, 160, -40, 140, { text: '老板呢……?', size: 44 }));
549
+ }
550
+ } else {
551
+ // 日历翻页 → 满村猴子
552
+ if (S.ls(3) < 0) {
553
+ const d = S.ls(2);
554
+ const labels = ['1周', '1个月', '1年'];
555
+ const idx = Math.min(2, Math.floor(d / 1.15));
556
+ const fl = (d % 1.15) / 1.15;
557
+ D.calendar(ctx, 640, 420, 1.5, labels[idx], '过去了', idx < 2 && fl > 0.75 ? (fl - 0.75) * 4 : 0);
558
+ for (let i = 0; i < 4; i++) D.monkey(ctx, 200 + i * 300, GY + 240, 0.7, { t, seed: i, mood: 'smug', flip: i % 2 });
559
+ } else {
560
+ // 雨 + 蟋蟀 + 满山猴子
561
+ for (let i = 0; i < 18; i++) { const sp = i % 6; const x = -500 + i * 140, y = GY - 120 + (sp % 3) * 80; D.monkey(ctx, x, y + Math.sin(t * 3 + i) * 6, 0.5, { t, seed: i + 30, mood: 'smug', flip: i % 2, aL: 2.5 + Math.sin(t * 3 + i) * .4 }); }
562
+ for (let i = 0; i < 4; i++) D.villager(ctx, i, 230 + i * 270, GY + 300, 1.0, { t, mood: 'sad', aR: 0.9, holdR: (c) => D.monkey(c, 0, 30, .4, { t, mood: 'smug' }) });
563
+ D.cricket(ctx, 1120, GY + 420, 0.8, t);
564
+ at(ctx, 640, 120, vis(lt, S.L[3] + 1.5), () => label(ctx, '只剩下:满山的猴子', 0, 0, 52, '#55606b'));
565
+ }
566
+ // 雨
567
+ ctx.save(); ctx.strokeStyle = 'rgba(220,235,255,.55)'; ctx.lineWidth = 3;
568
+ for (let i = 0; i < 90; i++) { const x = X0 + D.hash(i * 1.3) * (X1 - X0), y = Y0 + ((D.hash(i * 7.1) * 3000 + t * 1300) % 3000); ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - 12, y + 42); ctx.stroke(); }
569
+ ctx.restore();
570
+ }
571
+ desat(ctx, 0.55);
572
+ };
573
+
574
+ /* =======================================================
575
+ * 10 算账 ledger
576
+ * ======================================================= */
577
+ SC.ledger = function (ctx, lt, S) {
578
+ const t = S.t;
579
+ paper(ctx, '#f7efdc');
580
+ const bookOut = S.ls(6) > 0 ? E.inout(S.ls(6) / 0.6) : 0;
581
+ if (bookOut < 1) {
582
+ ctx.save(); ctx.translate(0, bookOut * 1400);
583
+ D.ledgerBook(ctx, 90, 20, 1100, 920);
584
+ D.text(ctx, '收猴子 · 账本', 640, 72, 50, C.ink);
585
+ const rows = [
586
+ [S.L[1] + 1.4, '收购', '10元 × 600只', '6,000', C.ink],
587
+ [S.L[1] + 3.2, '收购', '20元 × 300只', '6,000', C.ink],
588
+ [S.L[1] + 5.0, '收购', '25元 × 100只', '2,500', C.ink],
589
+ ];
590
+ rows.forEach(([a, k, f, v, c], i) => {
591
+ const y = 170 + i * 72, p = fade(lt, a, null, 0.25);
592
+ if (p <= 0) return;
593
+ ctx.globalAlpha = p;
594
+ D.text(ctx, k, 180, y, 40, C.blue);
595
+ const typed = Math.floor(D.clamp01((lt - a) / 0.7) * f.length);
596
+ D.text(ctx, f.slice(0, typed), 300, y, 44, c, { align: 'left' });
597
+ D.text(ctx, v, 1110, y, 46, c, { align: 'right' });
598
+ ctx.globalAlpha = 1;
599
+ });
600
+ if (lt > S.L[2]) {
601
+ const p = D.clamp01(S.ls(2) / 0.5);
602
+ ctx.beginPath(); ctx.moveTo(160, 395); ctx.lineTo(160 + 960 * p, 395); D.stroke(ctx, 4);
603
+ D.text(ctx, '商人收购总成本', 180, 450, 44, C.ink, { align: 'left', alpha: p });
604
+ at(ctx, 1110, 450, vis(lt, S.L[2] + 0.4), () => D.text(ctx, '¥14,500', 0, 0, 56, C.blue, { align: 'right' }));
605
+ }
606
+ if (lt > S.L[3]) {
607
+ const p = fade(lt, S.L[3] + 0.6);
608
+ D.text(ctx, '卖出', 180, 540, 40, C.purple, { alpha: p });
609
+ const f = '35元 × 1000只'; D.text(ctx, f.slice(0, Math.floor(D.clamp01((S.ls(3) - 0.6) / 0.8) * f.length)), 300, 540, 44, C.ink, { align: 'left' });
610
+ at(ctx, 1110, 540, vis(lt, S.L[3] + 4.6), () => D.text(ctx, '¥35,000', 0, 0, 56, C.purple, { align: 'right' }));
611
+ }
612
+ if (lt > S.L[4]) {
613
+ const k = vis(lt, S.L[4] + 0.3);
614
+ at(ctx, 640, 660, k, () => { D.rrect(ctx, -460, -58, 920, 116, 24, 901); D.fs(ctx, '#e3f9e9', 5); D.text(ctx, '商人净赚', -280, 2, 50, C.ink); D.bigText(ctx, '+¥20,500', 140, 2, 84, C.green, { shadow: null }); });
615
+ D.coinBurst(ctx, 900, 640, S.ls(4) - 0.6, 14, 91, 0.8);
616
+ }
617
+ if (lt > S.L[5]) {
618
+ const k = vis(lt, S.L[5] + 0.4);
619
+ at(ctx, 640, 820, k, () => { D.rrect(ctx, -460, -58, 920, 116, 24, 902); D.fs(ctx, '#ffe4e1', 5); D.text(ctx, '村民净亏', -280, 2, 50, C.ink); D.bigText(ctx, '−¥20,500', 140, 2, 84, C.red, { shadow: null }); });
620
+ if (S.ls(5) > 4) at(ctx, 1000, 560, vis(lt, S.L[5] + 4), () => D.stamp(ctx, 0, 0, 0.75, '换回1000只猴子', C.fur, -0.12));
621
+ }
622
+ ctx.restore();
623
+ // L0 标题
624
+ if (S.ls(1) < 0) at(ctx, 640, 480, vis(lt, S.L[0], S.L[1] - .1), () => D.bigText(ctx, '算一笔账', 0, 0, 130, C.gold));
625
+ }
626
+ if (bookOut > 0) {
627
+ // 钱从村民口袋搬进商人口袋
628
+ const d = S.ls(6);
629
+ at(ctx, 0, (1 - bookOut) * -1200, 1, () => {
630
+ D.villager(ctx, 0, 240, GY + 120, 1.3, { t, mood: 'sad', aR: 0.4 });
631
+ D.merchant(ctx, 1040, GY + 120, 1.3, { t, mood: 'laugh', aL: 2.6 });
632
+ for (let i = 0; i < 9; i++) { const ph = (d * 0.6 + i / 9) % 1; const x = D.lerp(300, 980, ph), y = GY - 260 - Math.sin(ph * Math.PI) * 300; D.coin(ctx, x, y, 26, t * 6 + i); }
633
+ D.cage(ctx, 640, GY + 160, 330, 200, 1, t, { cols: 5, rows: 2, mood: 'smug' });
634
+ at(ctx, 640, 150, vis(lt, S.L[6] + 0.6), () => label(ctx, '猴子还是那些猴子', 0, 0, 54, C.ink, '#ffcd3c'));
635
+ D.text(ctx, '村民口袋', 240, GY + 190, 40, C.ink); D.text(ctx, '商人口袋', 1040, GY + 190, 40, C.ink);
636
+ });
637
+ }
638
+ };
639
+
640
+ /* =======================================================
641
+ * 11 为什么会上当 why
642
+ * ======================================================= */
643
+ SC.why = function (ctx, lt, S) {
644
+ const t = S.t;
645
+ paper(ctx, '#eef3fb', 'rgba(74,143,226,.12)');
646
+ if (S.ls(1) < 0) {
647
+ D.villager(ctx, 3, 640, GY + 160, 1.5, { t, mood: 'sad', aR: 2.9 });
648
+ [0, 1, 2].forEach((i) => D.qmark(ctx, 420 + i * 220, 200 + (i % 2) * 40, 0.8 * vis(lt, S.L[0] + 0.3 + i * .3), t + i, [C.red, C.blue, C.purple][i]));
649
+ at(ctx, 640, 420, vis(lt, S.L[0] + 1.6), () => D.bigText(ctx, '为什么会上当?', 0, 0, 90, C.ink, { shadow: null, stroke: 0 }));
650
+ return;
651
+ }
652
+ // 进度圆点
653
+ const step = S.ls(4) > 0 ? 3 : S.ls(3) > 0 ? 3 : S.ls(2) > 0 ? 2 : 1;
654
+ [1, 2, 3].forEach((n, i) => { D.blob(ctx, 520 + i * 120, 50, 34, 34, 910 + i); D.fs(ctx, n <= step ? C.red : '#ddd', 5); D.text(ctx, String(n), 520 + i * 120, 52, 40, '#fff'); });
655
+ if (S.ls(4) < 0) {
656
+ const cards = [
657
+ { i: 1, title: '价格是"造"出来的', sub: '只有一个买家', a: S.L[1], b: S.L[2] },
658
+ { i: 2, title: '真实的赚钱效应', sub: '前面的人真赚到了', a: S.L[2], b: S.L[3] },
659
+ { i: 3, title: '相信有人接盘', sub: '不是因为猴子有用', a: S.L[3], b: S.L[4] },
660
+ ];
661
+ cards.forEach((c) => {
662
+ if (lt < c.a - 0.1 || lt > c.b + 0.5) return;
663
+ const kin = E.out((lt - c.a) / 0.5), kout = E.in((lt - c.b) / 0.45);
664
+ const x = 640 + (1 - kin) * 1500 - kout * 1600;
665
+ ctx.save(); ctx.translate(x - 640, 0);
666
+ card(ctx, 640, 500, 1060, 760, '#fff', 920 + c.i);
667
+ D.blob(ctx, 230, 190, 50, 50, 930 + c.i); D.fs(ctx, C.red, 5); D.text(ctx, String(c.i), 230, 193, 60, '#fff');
668
+ D.text(ctx, c.title, 300, 180, 62, C.ink, { align: 'left' });
669
+ D.text(ctx, c.sub, 300, 250, 40, '#7a6a5a', { align: 'left' });
670
+ const d = lt - c.a;
671
+ if (c.i === 1) {
672
+ // 需求曲线:唯一买家离开 → 归零
673
+ ctx.save(); ctx.translate(200, 820);
674
+ ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(880, 0); ctx.moveTo(0, 0); ctx.lineTo(0, -480); D.stroke(ctx, 5);
675
+ D.text(ctx, '价格', -10, -510, 32, C.ink); D.text(ctx, '时间', 880, 36, 32, C.ink);
676
+ const pts = [[0, 10], [150, 20], [300, 25], [450, 50], [600, 35], [700, 0], [860, 0]];
677
+ const prog = D.clamp01(d / 5);
678
+ ctx.beginPath();
679
+ const maxX = 860 * prog;
680
+ pts.forEach((p, j) => { if (p[0] <= maxX) { const y = -p[1] * 8; j ? ctx.lineTo(p[0], y) : ctx.moveTo(p[0], y); } });
681
+ D.stroke(ctx, 9, C.red);
682
+ pts.slice(0, 5).forEach((p) => { if (p[0] <= maxX) D.text(ctx, '¥' + p[1], p[0], -p[1] * 8 - 34, 32, C.ink); });
683
+ if (prog > 0.82) { D.text(ctx, '商人走了 → 需求 = 0', 640, -120, 40, C.red); }
684
+ D.merchant(ctx, 520, -40, 0.45 * (prog > 0.75 ? Math.max(0, 1 - (prog - .75) * 5) : 1), { t, mood: 'laugh' });
685
+ ctx.restore();
686
+ } else if (c.i === 2) {
687
+ D.villager(ctx, 1, 420, 820, 1.2, { t, mood: 'money', aL: 2.7, aR: 2.7, jump: Math.abs(Math.sin(t * 5)) * 20 });
688
+ D.coinBurst(ctx, 420, 520, (d % 1.6) - 0.1, 10, 101 + Math.floor(d / 1.6), 0.8);
689
+ D.villager(ctx, 4, 860, 820, 1.1, { t, mood: 'shock', flip: true });
690
+ at(ctx, 880, 400, vis(lt, c.a + 1.5), () => D.thought(ctx, 0, 0, 340, 150, -40, 120, { text: ['他都赚了', '我也能!'], size: 38 }));
691
+ } else {
692
+ D.villager(ctx, 5, 420, 820, 1.1, { t, mood: 'money', aR: 2.4, holdR: (cx) => D.monkey(cx, 0, 0, .5, { t, mood: 'smug' }) });
693
+ D.arrow(ctx, 560, 560, 800, 560, C.ink, 8, D.clamp01((d - 1) / 0.8));
694
+ at(ctx, 900, 560, vis(lt, c.a + 1.8), () => { D.text(ctx, '更高价', 0, -60, 40, C.red); D.qmark(ctx, 0, 40, 0.8, t); });
695
+ at(ctx, 640, 380, vis(lt, c.a + 3), () => D.text(ctx, '猴子有没有用?不重要', 0, 0, 44, '#7a6a5a'));
696
+ }
697
+ ctx.restore();
698
+ });
699
+ } else {
700
+ // L4~L5 博傻链条
701
+ const d = S.ls(4);
702
+ D.text(ctx, '博 傻 理 论', 640, 160, 90, C.ink);
703
+ D.text(ctx, 'Greater Fool Theory', 640, 236, 36, '#7a6a5a');
704
+ const people = [[200, '¥35'], [480, '¥50'], [760, '¥80?'], [1040, '你']];
705
+ const last = S.ls(5) > 0;
706
+ people.forEach(([x, tag], i) => {
707
+ const k = vis(lt, S.L[4] + 0.4 + i * 0.7);
708
+ const isYou = i === 3;
709
+ if (last && !isYou) ctx.globalAlpha = Math.max(0.25, 1 - S.ls(5));
710
+ if (isYou && last) { ctx.save(); const g = ctx.createRadialGradient(x, 600, 20, x, 600, 360); g.addColorStop(0, 'rgba(255,230,120,.7)'); g.addColorStop(1, 'rgba(255,230,120,0)'); ctx.fillStyle = g; ctx.fillRect(x - 400, 200, 800, 800); ctx.restore(); }
711
+ D.villager(ctx, i + 1, x, GY + 40, (0.8 + i * 0.12) * k, { t: t + i, mood: isYou && last ? 'shock' : 'money', aR: 1.6 });
712
+ at(ctx, x, GY + 90, k, () => label(ctx, tag, 0, 0, 38, isYou ? C.red : C.ink, '#fff'));
713
+ if (i < 3) D.arrow(ctx, x + 80, 420, x + 200, 420, C.ink, 6, D.clamp01((lt - S.L[4] - 0.9 - i * 0.7) / 0.4));
714
+ ctx.globalAlpha = 1;
715
+ });
716
+ // 猴子沿链条传递
717
+ const pass = D.clamp01((d - 0.6) / 3.2) * 3;
718
+ const pi = Math.min(3, pass);
719
+ const mx = 200 + pi * 280, my = 400 - Math.abs(Math.sin(pass * Math.PI)) * 80;
720
+ D.monkey(ctx, mx, my, 0.6, { t, mood: 'smug' });
721
+ if (last) at(ctx, 640, 320, vis(lt, S.L[5] + 0.6), () => { D.rrect(ctx, -420, -50, 840, 100, 26, 940); D.fs(ctx, C.red, 5); D.text(ctx, '最后一个傻子 = 你自己', 0, 2, 52, '#fff'); });
722
+ }
723
+ };
724
+
725
+ /* =======================================================
726
+ * 12 现实中的猴子 real
727
+ * ======================================================= */
728
+ SC.real = function (ctx, lt, S) {
729
+ const t = S.t;
730
+ paper(ctx, '#fff6e6');
731
+ if (S.ls(2) < 0) {
732
+ const book = vis(lt, S.L[0]);
733
+ at(ctx, 640, 470, book, () => { card(ctx, 0, 0, 620, 420, '#ffe9b0', 950); D.text(ctx, '寓言', 0, -100, 90, C.fur); D.monkey(ctx, 0, 160, 1.0, { t, mood: 'smug', aL: 2.2, aR: 2.2 }); });
734
+ if (S.ls(1) > 0) {
735
+ at(ctx, 640, 150, vis(lt, S.L[1] + 0.2), () => D.bigText(ctx, '真实上演了几百年', 0, 0, 72, C.red, { rot: -0.03 }));
736
+ at(ctx, 1000, 640, vis(lt, S.L[1] + 1.4), () => D.stamp(ctx, 0, 0, 1, '真事!', C.red, 0.2));
737
+ }
738
+ return;
739
+ }
740
+ // 时间线
741
+ ctx.beginPath(); ctx.moveTo(60, 860); ctx.lineTo(1220, 860); D.stroke(ctx, 6);
742
+ [[200, '1637', S.L[2]], [640, '2000', S.L[3]], [1080, '今天', S.L[4]]].forEach(([x, y, a]) => {
743
+ const k = vis(lt, a);
744
+ at(ctx, x, 860, k, () => { D.blob(ctx, 0, 0, 20, 20, 960); D.fs(ctx, C.red, 4); D.text(ctx, y, 0, 50, 40, C.ink); });
745
+ });
746
+ if (S.ls(5) < 0) {
747
+ // 当前展示的卡片
748
+ const which = S.ls(4) > 0 ? 2 : S.ls(3) > 0 ? 1 : 0;
749
+ const a = [S.L[2], S.L[3], S.L[4]][which];
750
+ const k = vis(lt, a, null, 0.5);
751
+ at(ctx, 640, 430, k, () => {
752
+ card(ctx, 0, 0, 1080, 640, '#fff', 970 + which);
753
+ if (which === 0) {
754
+ D.text(ctx, '荷兰 · 郁金香狂热', 0, -250, 54, C.ink);
755
+ D.tulip(ctx, -280, 220, 1.6, t);
756
+ D.bigText(ctx, '=', 0, 30, 110, C.ink, { shadow: null, stroke: 0 });
757
+ D.house(ctx, 280, 200, 1.2, 971, '#f6e3c3', '#d9534f');
758
+ D.text(ctx, '一颗球茎 ≈ 一栋房子(据说)', 0, 270, 38, '#7a6a5a');
759
+ } else if (which === 1) {
760
+ D.text(ctx, '互联网泡沫', 0, -250, 54, C.ink);
761
+ D.laptop(ctx, -260, 120, 1.4, t);
762
+ // 走势:暴涨后暴跌
763
+ ctx.save(); ctx.translate(60, 170);
764
+ const pts = [[0, 0], [60, -40], [120, -60], [180, -150], [240, -290], [290, -330], [330, -120], [380, -40], [420, -20]];
765
+ const prog = D.clamp01((lt - a - 0.4) / 3.5);
766
+ ctx.beginPath(); pts.forEach((p, j) => { if (j / (pts.length - 1) <= prog) j ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); }); D.stroke(ctx, 10, prog > 0.7 ? C.red : C.green);
767
+ ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(440, 0); ctx.moveTo(0, 0); ctx.lineTo(0, -360); D.stroke(ctx, 4);
768
+ ctx.restore();
769
+ D.text(ctx, '名字加个“.com”,股价就大涨', 0, 270, 38, '#7a6a5a');
770
+ } else {
771
+ D.text(ctx, '空气币 · 炒鞋 · 炒卡', 0, -250, 54, C.ink);
772
+ const items = [(c) => D.cryptoCoin(c, 0, 0, 1.2, t), (c) => D.sneaker(c, 0, 20, 1.2), (c) => D.card(c, 0, 0, 1.1)];
773
+ items.forEach((fn, i) => {
774
+ const kk = vis(lt, a + 0.3 + i * 0.6);
775
+ at(ctx, -320 + i * 320, 40, kk, () => {
776
+ // 每样东西后面都探出一条猴子尾巴
777
+ ctx.beginPath(); ctx.moveTo(60, 40); ctx.bezierCurveTo(140, 60, 150, -60, 110, -80 + Math.sin(t * 4 + i) * 8); D.stroke(ctx, 14); ctx.beginPath(); ctx.moveTo(60, 40); ctx.bezierCurveTo(140, 60, 150, -60, 110, -80 + Math.sin(t * 4 + i) * 8); D.stroke(ctx, 7, C.fur);
778
+ fn(ctx);
779
+ });
780
+ });
781
+ D.text(ctx, '被包装成“稀缺资产”', 0, 270, 38, '#7a6a5a');
782
+ }
783
+ });
784
+ } else {
785
+ // L5 收猴子剧本循环
786
+ const steps = ['制造需求', '抬高价格', '让你接盘', '庄家离场'];
787
+ const cx = 640, cy = 440, R = 300;
788
+ ctx.save(); ctx.globalAlpha = 0.9; ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.setLineDash([18, 16]); D.stroke(ctx, 6, '#c9b9a5'); ctx.restore();
789
+ steps.forEach((s, i) => {
790
+ const a = -Math.PI / 2 + i * Math.PI / 2;
791
+ const k = vis(lt, S.L[5] + 0.6 + i * 0.9);
792
+ const x = cx + Math.cos(a) * R, y = cy + Math.sin(a) * R;
793
+ at(ctx, x, y, k, () => { D.rrect(ctx, -150, -46, 300, 92, 30, 980 + i); D.fs(ctx, [C.blue, C.gold, C.red, C.ink][i], 5); D.text(ctx, s, 0, 2, 48, '#fff'); });
794
+ });
795
+ const ang = -Math.PI / 2 + (lt - S.L[5]) * 1.2;
796
+ D.monkey(ctx, cx + Math.cos(ang) * (R - 110), cy + Math.sin(ang) * (R - 110) + 60, 0.5, { t, mood: 'smug' });
797
+ D.text(ctx, '剧本', cx, cy - 10, 60, C.ink);
798
+ D.text(ctx, '从没变过', cx, cy + 56, 40, '#7a6a5a');
799
+ }
800
+ };
801
+
802
+ /* =======================================================
803
+ * 13 识别猴子三问 check
804
+ * ======================================================= */
805
+ SC.check = function (ctx, lt, S) {
806
+ const t = S.t;
807
+ rays(ctx, 640, 480, t * .5, '#e3f4ff', '#d3ecff', 16);
808
+ if (S.ls(1) < 0) {
809
+ D.monkey(ctx, 640, GY + 80, 2.2 * vis(lt, S.L[0]), { t, mood: 'shock', aL: 2.4, aR: 2.4 });
810
+ D.at(ctx, 640, 260, vis(lt, S.L[0] + 0.6), () => { ctx.beginPath(); ctx.arc(0, 0, 130, 0, Math.PI * 2); D.stroke(ctx, 18); ctx.beginPath(); ctx.moveTo(90, 90); ctx.lineTo(190, 190); D.stroke(ctx, 26, '#8a5a35'); D.text(ctx, '?', 0, 0, 120, C.blue); }, -0.2);
811
+ return;
812
+ }
813
+ // 写字板
814
+ at(ctx, 640, 500, vis(lt, S.L[1] - 0.1), () => {
815
+ D.rrect(ctx, -500, -420, 1000, 860, 30, 1001); D.fs(ctx, '#c08a3e', 7);
816
+ D.rrect(ctx, -460, -370, 920, 790, 16, 1002); D.fs(ctx, '#fffdf6', 5);
817
+ D.rrect(ctx, -120, -450, 240, 70, 16, 1003); D.fs(ctx, '#999', 5);
818
+ D.text(ctx, '识别"猴子"的三个问题', 0, -300, 54, C.ink);
819
+ const qs = [['它本身到底有什么用?', '(抛开价格)'], ['现在是谁在买?为什么买?', ''], ['再也没人接盘,', '我还愿意拿着吗?']];
820
+ qs.forEach(([a, b], i) => {
821
+ const st = S.L[2 + i];
822
+ if (lt < st) return;
823
+ const y = -170 + i * 190;
824
+ ctx.save(); ctx.globalAlpha = D.clamp01((lt - st) / .3);
825
+ D.rrect(ctx, -400, y - 40, 80, 80, 14, 1010 + i); D.fs(ctx, '#fff', 5);
826
+ const typedA = a.slice(0, Math.floor(D.clamp01((lt - st) / 1.0) * a.length));
827
+ D.text(ctx, typedA, -290, b ? y - 20 : y, 48, C.ink, { align: 'left' });
828
+ if (b) D.text(ctx, b, -290, y + 36, b.startsWith('(') ? 34 : 48, b.startsWith('(') ? '#998' : C.ink, { align: 'left' });
829
+ D.check(ctx, -360, y, 28, D.clamp01((lt - st - 2.2) / 0.4));
830
+ ctx.restore();
831
+ });
832
+ });
833
+ if (S.ls(5) > 0) {
834
+ const k = vis(lt, S.L[5] + 0.3, null, 0.6);
835
+ tint(ctx, '#2b2321', 0.4 * D.clamp01(S.ls(5) / .5));
836
+ D.monkey(ctx, 640, GY + 40, 2.6 * k, { t, mood: 'smug', aL: 2.9, aR: 2.9 });
837
+ at(ctx, 640, 180, vis(lt, S.L[5] + 1.6), () => D.stamp(ctx, 0, 0, 1.5, '大概率是猴子', C.red, -0.1));
838
+ }
839
+ };
840
+
841
+ /* =======================================================
842
+ * 14 结尾 outro
843
+ * ======================================================= */
844
+ SC.outro = function (ctx, lt, S) {
845
+ const t = S.t;
846
+ sky(ctx, '#ff9e6b', '#ffe0b3'); D.sun(ctx, 640, 520, 110, t, false); mountains(ctx, '#e2a07c', '#c98a6a');
847
+ forest(ctx, t, GY - 60, 0.6, { scale: 0.8, col: '#5f8f4a', col2: '#4c7a3e' }); ground(ctx, '#a8b86a', '#8fa055'); houses(ctx, t);
848
+ if (S.ls(1) < 0) {
849
+ [['稳赚不赔', 360, 260, C.green], ['等老板回来就翻倍', 900, 380, C.purple]].forEach(([s, x, y, c], i) => {
850
+ const k = vis(lt, S.L[0] + 0.3 + i * 1.6);
851
+ at(ctx, x, y, k, () => { D.bubble(ctx, 0, 0, s.length * 56 + 80, 130, 0, 150, { text: s, size: 52, color: c }); if (lt > S.L[0] + 1.3 + i * 1.6) D.cross(ctx, 0, 0, 70); });
852
+ });
853
+ D.assistant(ctx, 640, GY + 240, 1.1, { t, mood: 'sly', aR: 2.2 });
854
+ } else if (S.ls(2) < 0) {
855
+ // 放大镜看笼子
856
+ D.cage(ctx, 640, GY + 220, 520, 340, 1, t, { cols: 5, rows: 2, mood: 'smug' });
857
+ const mx = 640 + Math.sin(t * 1.5) * 120, my = GY - 30;
858
+ at(ctx, mx, my, vis(lt, S.L[1] + 0.3), () => { ctx.beginPath(); ctx.arc(0, 0, 150, 0, Math.PI * 2); ctx.fillStyle = 'rgba(220,240,255,.35)'; ctx.fill(); D.stroke(ctx, 18); ctx.beginPath(); ctx.moveTo(105, 105); ctx.lineTo(230, 230); D.stroke(ctx, 30, '#8a5a35'); });
859
+ at(ctx, 640, 170, vis(lt, S.L[1] + 1.2), () => label(ctx, '先看看:是不是猴子?', 0, 0, 54, C.ink, '#ffcd3c'));
860
+ } else {
861
+ // 关注 + 再见
862
+ const d2 = S.ls(2);
863
+ const followK = vis(lt, S.L[2] + 0.2);
864
+ const pressed = d2 > 2.6;
865
+ at(ctx, 640, 380, followK * (pressed && d2 < 2.8 ? 0.9 : 1), () => { D.rrect(ctx, -230, -70, 460, 140, 70, 1101); D.fs(ctx, pressed ? '#bbbbbb' : '#ff4d6d', 6); D.text(ctx, pressed ? '已关注' : '+ 关注', 0, 4, 64, '#fff'); });
866
+ const icons = [['👍', '点赞'], ['🪙', '投币'], ['⭐', '收藏']];
867
+ icons.forEach(([, s], i) => at(ctx, 400 + i * 240, 600, vis(lt, S.L[2] + 0.8 + i * 0.3), () => {
868
+ D.blob(ctx, 0, 0, 70, 70, 1110 + i); D.fs(ctx, [C.blue, C.gold, C.pink][i], 5);
869
+ if (i === 0) D.heart(ctx, 0, 0, 30, '#fff'); else if (i === 1) D.coin(ctx, 0, 0, 30, 0); else D.sparkle(ctx, 0, 0, 40, 0.5, '#fff');
870
+ D.text(ctx, s, 0, 105, 36, C.ink);
871
+ }));
872
+ const k = E.inout(D.clamp01((d2 - 1.6) / 1));
873
+ at(ctx, D.lerp(1200, 690, k), D.lerp(820, 410, k), followK, () => { D.blob(ctx, 0, 0, 30, 36, 1120); D.fs(ctx, '#fff', 5); D.rrect(ctx, -10, -70, 20, 60, 10, 1121); D.fs(ctx, '#fff', 5); });
874
+ if (pressed) D.confetti(ctx, 640, 380, d2 - 2.6, 50, 700, 81);
875
+ if (S.ls(3) > 0) {
876
+ D.monkey(ctx, 640, GY + 200, 1.8 * vis(lt, S.L[3]), { t, mood: 'happy', aL: 0.4, aR: 2.6 + Math.sin(t * 10) * 0.4 });
877
+ at(ctx, 640, 170, vis(lt, S.L[3] + 0.2), () => D.bigText(ctx, '下期再见!', 0, 0, 110, '#fff'));
878
+ }
879
+ }
880
+ };
881
+
882
+ /* ---------- 封面 ---------- */
883
+ SC.__cover = function (ctx, portrait) {
884
+ const t = 1.3;
885
+ rays(ctx, 640, 520, 0, '#ffd27a', '#ffbf4a', 22);
886
+ D.speedLines(ctx, 640, 520, 0, 'rgba(255,255,255,.35)', 30, 420);
887
+ ctx.fillStyle = '#9fd468'; ctx.fillRect(X0, GY + 200, X1 - X0, Y1);
888
+ D.cage(ctx, 330, GY + 260, 470, 330, 1, t, { cols: 5, rows: 3, label: '1000只' });
889
+ D.merchant(ctx, 900, GY + 290, 1.35, { t, mood: 'laugh', aR: 2.4, holdR: (c) => D.megaphone(c), aL: 0.5 });
890
+ D.priceSign(ctx, 1090, GY + 10, 0.95, '50', { t: 0, noPost: true, bg: '#fff2b8' });
891
+ D.monkey(ctx, 640, GY + 330, 1.4, { t, mood: 'smug', aL: 2.8, aR: 2.4 });
892
+ for (let i = 0; i < 10; i++) D.coin(ctx, 120 + D.hash(i * 3) * 1040, 80 + D.hash(i * 7) * 380, 30, i);
893
+ D.exclaim(ctx, 640, 220, 0.9, 0.3);
894
+ };
895
+
896
+ window.SCENES = SC;
897
+ })();
shouhouzi/tools/frame-test.html ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html><meta charset="utf-8">
2
+ <style>@font-face{font-family:"KuaiLe";src:url(../fonts/kuaile.woff2) format("woff2");}</style>
3
+ <canvas id="c"></canvas>
4
+ <script src="../js/script-data.js"></script><script src="../js/draw.js"></script><script src="../js/scenes.js"></script><script src="../js/engine.js"></script>
5
+ <script>
6
+ window.snap=async(t,mode,guides)=>{await document.fonts.load('40px KuaiLe');const L=LAYOUTS[mode];const c=document.getElementById('c');c.width=L.W;c.height=L.H;const x=c.getContext('2d');ENGINE.render(x,t,L,{guides});return c.toDataURL('image/jpeg',0.8)};
7
+ window.cover=async(mode)=>{await document.fonts.load('40px KuaiLe');const L=LAYOUTS[mode];const c=document.getElementById('c');c.width=L.W;c.height=L.H;const x=c.getContext('2d');ENGINE.renderCover(x,L);return c.toDataURL('image/png')};
8
+ window.bench=async(n,mode)=>{const L=LAYOUTS[mode];const c=document.getElementById('c');c.width=L.W;c.height=L.H;const x=c.getContext('2d');const t0=performance.now();for(let i=0;i<n;i++)ENGINE.render(x,i*TL.total/n,L);return (performance.now()-t0)/n};
9
+ </script>
shouhouzi/tools/subset_font.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 收集项目中所有用到的字符,子集化站酷快乐体为 woff2
2
+ import os,glob,sys
3
+ from fontTools import subset
4
+ ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
5
+ src=sys.argv[1] if len(sys.argv)>1 else '/tmp/ZCOOLKuaiLe-Regular.ttf'
6
+ chars=set()
7
+ for f in glob.glob(ROOT+'/js/*.js')+glob.glob(ROOT+'/*.html'):
8
+ chars|=set(open(f,encoding='utf-8').read())
9
+ chars|=set('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ ¥$%+-−×=.,:;!?()[]/|·…“”‘’,。!?、:;()《》【】~~')
10
+ text=''.join(sorted(c for c in chars if ord(c)>=32))
11
+ opts=subset.Options(); opts.flavor='woff2'; opts.layout_features=['*']
12
+ f=subset.load_font(src,opts); s=subset.Subsetter(opts); s.populate(text=text); s.subset(f)
13
+ os.makedirs(ROOT+'/fonts',exist_ok=True)
14
+ subset.save_font(f,ROOT+'/fonts/kuaile.woff2',opts)
15
+ print(len(text),'chars',os.path.getsize(ROOT+'/fonts/kuaile.woff2'),'bytes')