dsh-intertidal 0.0.0-stage → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +103 -0
- package/README.md +102 -2
- package/assets/icon-180.png +0 -0
- package/assets/screenshots/media-modes.png +0 -0
- package/assets/screenshots/overlay-countdown.png +0 -0
- package/assets/screenshots/procedural-overview.png +0 -0
- package/assets/screenshots/settings-narrow.png +0 -0
- package/cordis.patch.yml +4 -0
- package/dist/client.js +3730 -0
- package/package.json +70 -4
- package/screenshots.json +6 -0
- package/src/cities.js +165 -0
- package/src/client/api.js +140 -0
- package/src/client/format.js +28 -0
- package/src/client/i18n.js +250 -0
- package/src/client/index.jsx +163 -0
- package/src/client/overlay.jsx +260 -0
- package/src/client/palette.js +376 -0
- package/src/client/rhythm.js +538 -0
- package/src/client/sea-canvas.jsx +1280 -0
- package/src/client/settings-panel.jsx +430 -0
- package/src/client/sound.js +163 -0
- package/src/client/styles.js +217 -0
- package/src/client/water.js +314 -0
- package/src/host.js +458 -0
- package/src/media.js +423 -0
- package/src/settings.js +322 -0
- package/src/sky.js +428 -0
- package/src/store.js +169 -0
|
@@ -0,0 +1,1280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 潮间 · 海 —— Canvas 2D 五层视差水面
|
|
3
|
+
*
|
|
4
|
+
* 只用 Canvas 2D,全部程序化绘制:没有图片 / 字体 / 音频 / 网络。
|
|
5
|
+
*
|
|
6
|
+
* 铁律(docs/CONTRACTS.md §6 + spec §4.1/§4.3/§4.5):
|
|
7
|
+
* 1. 所有层共用一个 t = performance.now()/1000,**绝不依赖帧计数** ——
|
|
8
|
+
* 掉帧只会少画几帧,不会让海变慢;
|
|
9
|
+
* 2. 不可见(paused / document.hidden)→ cancelAnimationFrame 完全停帧;
|
|
10
|
+
* 恢复时把暂停时长累进偏移量,相位连续、不跳变;
|
|
11
|
+
* 3. 背衬缓冲(离屏缓冲)宽度上限 1280,由 CSS 撑满父容器;devicePixelRatio
|
|
12
|
+
* 参与,resize 防抖 200ms 重建;
|
|
13
|
+
* 4. 目标 30fps(form === 'pip' 时 15fps),连续 3 秒平均 < 20fps → 降到 15fps
|
|
14
|
+
* (只降不升),通过 onStats({ fps, downgraded }) 反馈;
|
|
15
|
+
* 5. reducedMotion → 只画一帧静态海,之后每 60 秒换一帧(仅色温微变);
|
|
16
|
+
* 6. 任何分支都不抛:拿不到 2D context(或绘制抛错)就退回 CSS 渐变静态海,
|
|
17
|
+
* 仍随时间变色。
|
|
18
|
+
*/
|
|
19
|
+
import { useEffect, useRef } from 'react';
|
|
20
|
+
import { paletteFor, mix } from './palette.js';
|
|
21
|
+
import { createWaterField, drawWater } from './water.js';
|
|
22
|
+
|
|
23
|
+
const TAU = Math.PI * 2;
|
|
24
|
+
|
|
25
|
+
/* ==========================================================================
|
|
26
|
+
* 可调参数表:改画风只动这里
|
|
27
|
+
* ========================================================================== */
|
|
28
|
+
|
|
29
|
+
/** 生命周期 / 性能预算(spec §4.3)。 */
|
|
30
|
+
const TUNING = Object.freeze({
|
|
31
|
+
maxBufferWidth: 1280, // 内部渲染缓冲宽度上限(程序化海)
|
|
32
|
+
mediaBufferWidth: 2560, // 媒体模式的上限(照片要有清晰度,契约 A3)
|
|
33
|
+
targetFps: 30, // 目标帧率(不是 60,省电)
|
|
34
|
+
pipFps: 15, // form === 'pip' 的小窗帧率
|
|
35
|
+
degradedFps: 15, // 降级帧率
|
|
36
|
+
slowWindowMs: 3000, // 连续 3 秒
|
|
37
|
+
slowFps: 20, // 平均帧率低于它 → 降级
|
|
38
|
+
resizeDebounceMs: 200, // resize 防抖
|
|
39
|
+
reducedRefreshMs: 60000, // reducedMotion:每 60 秒换一帧
|
|
40
|
+
maxStepMs: 120, // 时间步长上限:单帧最多推进 120ms(防节流回来跳帧)
|
|
41
|
+
stopOnBlur: false, // spec §4.3 提到「窗口失焦停帧」;默认关:
|
|
42
|
+
// 开 DevTools 会让页面 blur,验收时会被误判成卡死。
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** 画面几何与五层参数(spec §4.1)。 */
|
|
46
|
+
const HORIZON = 0.52; // 地平线 y(与 palette.js 的坐标约定一致)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
/** L3 泡沫带:浪脊处阈值触发的白斑 —— 覆盖率 3%–9%。 */
|
|
50
|
+
const L3 = Object.freeze({
|
|
51
|
+
coverageMin: 0.03, // spec §4.1:覆盖率 3%–9%
|
|
52
|
+
coverageMax: 0.09,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** 媒体层(契约 A3):Ken Burns + 天光罩 + 水面呼吸 + 地平线雾 + 暗角。 */
|
|
56
|
+
const MEDIA = Object.freeze({
|
|
57
|
+
kenBurnsPeriod: 40, // 秒,契约要求 ~40s 周期
|
|
58
|
+
kenBurnsZoom: 0.06, // 1.0 → 1.06
|
|
59
|
+
overscan: 1.008, // 恒定超采样,保证 1–2px 位移不漏边
|
|
60
|
+
breathFreq: 0.11, // Hz,水面呼吸
|
|
61
|
+
breathPx: 2, // 横向位移幅度(契约 1–2px)
|
|
62
|
+
sweepHeight: 0.09, // 高光扫过带的厚度(占画面高)
|
|
63
|
+
skyMaskBottom: 0.62, // 顶部天光蒙版盖到画面的 62%
|
|
64
|
+
vignette: 0.22, // 暗角强度
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** L4 前景浪尖:更大更快的近处浪头,偶尔翻白 —— 0.6Hz + 单次事件,占画面下 22%。 */
|
|
68
|
+
const L4 = Object.freeze({
|
|
69
|
+
freq: 0.6, // Hz
|
|
70
|
+
band: 0.22, // 占画面下 22%
|
|
71
|
+
eventPeriod: 9.2, // 翻白事件的周期(秒)
|
|
72
|
+
eventRise: 0.05, // 涨:周期的 5%(≈0.46s)
|
|
73
|
+
eventDecay: 0.12, // 落:周期的 12%(≈1.1s)
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/* ==========================================================================
|
|
77
|
+
* 小工具(全部容错,任何输入都不抛)
|
|
78
|
+
* ========================================================================== */
|
|
79
|
+
|
|
80
|
+
function num(value, fallback) {
|
|
81
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function clamp(value, lo, hi) {
|
|
85
|
+
return value < lo ? lo : value > hi ? hi : value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function clamp01(value) {
|
|
89
|
+
return clamp(num(value, 0), 0, 1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function lerp(a, b, k) {
|
|
93
|
+
return a + (b - a) * k;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function round(value, digits) {
|
|
97
|
+
const base = Math.pow(10, digits);
|
|
98
|
+
return Math.round(num(value, 0) * base) / base;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** '#rrggbb' → 'rgba(r,g,b,a)';坏颜色退回透明,不抛。 */
|
|
102
|
+
function rgba(color, alpha) {
|
|
103
|
+
let r = 0;
|
|
104
|
+
let g = 0;
|
|
105
|
+
let b = 0;
|
|
106
|
+
const match = typeof color === 'string' ? /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim()) : null;
|
|
107
|
+
if (match) {
|
|
108
|
+
let hex = match[1];
|
|
109
|
+
if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
|
110
|
+
r = parseInt(hex.slice(0, 2), 16) || 0;
|
|
111
|
+
g = parseInt(hex.slice(2, 4), 16) || 0;
|
|
112
|
+
b = parseInt(hex.slice(4, 6), 16) || 0;
|
|
113
|
+
}
|
|
114
|
+
return 'rgba(' + r + ',' + g + ',' + b + ',' + round(clamp01(alpha), 3) + ')';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 确定性哈希(0..1):同一格每帧同一值,泡沫才不会乱闪。 */
|
|
118
|
+
function hash1(n) {
|
|
119
|
+
const s = Math.sin(n * 12.9898) * 43758.5453;
|
|
120
|
+
return s - Math.floor(s);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
/* ==========================================================================
|
|
125
|
+
* L0 天空:垂直三分渐变 + 地平线辉光(随太阳高度角变化,颜色全来自 palette)
|
|
126
|
+
* ========================================================================== */
|
|
127
|
+
|
|
128
|
+
function drawSky(ctx, W, H, horizonY, pal) {
|
|
129
|
+
const height = Math.max(1, horizonY);
|
|
130
|
+
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
|
131
|
+
gradient.addColorStop(0, pal.skyTop);
|
|
132
|
+
gradient.addColorStop(0.54, pal.skyMid);
|
|
133
|
+
gradient.addColorStop(1, pal.skyBottom);
|
|
134
|
+
ctx.fillStyle = gradient;
|
|
135
|
+
ctx.fillRect(0, 0, W, height);
|
|
136
|
+
|
|
137
|
+
// 地平线辉光:太阳越低(exposure 越小)铺得越宽
|
|
138
|
+
const spread = H * (0.09 + 0.07 * (1 - clamp01(pal.exposure)));
|
|
139
|
+
const glow = ctx.createLinearGradient(0, horizonY - spread, 0, horizonY + H * 0.004);
|
|
140
|
+
glow.addColorStop(0, rgba(pal.horizonGlow, 0));
|
|
141
|
+
glow.addColorStop(0.72, rgba(pal.horizonGlow, 0.3));
|
|
142
|
+
glow.addColorStop(1, rgba(pal.horizonGlow, 0.16));
|
|
143
|
+
ctx.fillStyle = glow;
|
|
144
|
+
ctx.fillRect(0, Math.max(0, horizonY - spread), W, Math.min(H, spread + H * 0.004));
|
|
145
|
+
|
|
146
|
+
// 地平线上方一层薄雾:压对比,晨昏的「雾感」靠它
|
|
147
|
+
const hazeTop = Math.max(0, horizonY - spread * 0.62);
|
|
148
|
+
ctx.fillStyle = rgba(pal.skyBottom, 0.16);
|
|
149
|
+
ctx.fillRect(0, hazeTop, W, horizonY - hazeTop);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* ==========================================================================
|
|
153
|
+
* 星(数量由 palette.stars 控制)
|
|
154
|
+
* ========================================================================== */
|
|
155
|
+
|
|
156
|
+
function drawStars(ctx, W, H, horizonY, pal, t) {
|
|
157
|
+
const density = clamp01(pal.stars);
|
|
158
|
+
if (density <= 0.01) return;
|
|
159
|
+
const count = Math.round(density * 120);
|
|
160
|
+
const tint = mix(pal.foam, '#ffffff', 0.65);
|
|
161
|
+
const ceiling = Math.max(1, horizonY * 0.95);
|
|
162
|
+
for (let i = 0; i < count; i += 1) {
|
|
163
|
+
const hx = hash1(i * 1.731 + 0.37);
|
|
164
|
+
const hy = hash1(i * 2.917 + 0.11);
|
|
165
|
+
const x = hx * W;
|
|
166
|
+
const y = hy * hy * ceiling; // hy² 让星更密于天顶
|
|
167
|
+
const size = 0.7 + hash1(i * 3.77) * 1.1;
|
|
168
|
+
const twinkle = 0.55 + 0.45 * Math.sin(t * (0.6 + hx * 1.9) + i * 1.3);
|
|
169
|
+
ctx.fillStyle = rgba(tint, clamp01(density * twinkle) * 0.85);
|
|
170
|
+
ctx.fillRect(x, y, size, size);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ==========================================================================
|
|
175
|
+
* 太阳(画在海面之前,地平线以下的部分由海水盖住)
|
|
176
|
+
* ========================================================================== */
|
|
177
|
+
|
|
178
|
+
function drawSun(ctx, W, H, pal) {
|
|
179
|
+
const sun = pal.sun;
|
|
180
|
+
if (!sun) return;
|
|
181
|
+
const alpha = clamp01(sun.alpha);
|
|
182
|
+
if (alpha <= 0.02) return;
|
|
183
|
+
const cx = clamp(sun.x, -0.2, 1.2) * W;
|
|
184
|
+
const cy = clamp(sun.y, -0.2, 1.2) * H;
|
|
185
|
+
const radius = Math.max(6, H * 0.028);
|
|
186
|
+
|
|
187
|
+
const halo = ctx.createRadialGradient(cx, cy, radius * 0.4, cx, cy, radius * 7);
|
|
188
|
+
halo.addColorStop(0, rgba(pal.horizonGlow, 0.42 * alpha));
|
|
189
|
+
halo.addColorStop(0.35, rgba(pal.horizonGlow, 0.13 * alpha));
|
|
190
|
+
halo.addColorStop(1, rgba(pal.horizonGlow, 0));
|
|
191
|
+
ctx.fillStyle = halo;
|
|
192
|
+
ctx.beginPath();
|
|
193
|
+
ctx.arc(cx, cy, radius * 7, 0, TAU);
|
|
194
|
+
ctx.fill();
|
|
195
|
+
|
|
196
|
+
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
|
197
|
+
core.addColorStop(0, rgba(sun.color, alpha));
|
|
198
|
+
core.addColorStop(0.72, rgba(sun.color, alpha * 0.92));
|
|
199
|
+
core.addColorStop(1, rgba(sun.color, alpha * 0.5));
|
|
200
|
+
ctx.fillStyle = core;
|
|
201
|
+
ctx.beginPath();
|
|
202
|
+
ctx.arc(cx, cy, radius, 0, TAU);
|
|
203
|
+
ctx.fill();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* ==========================================================================
|
|
207
|
+
* 月亮:带缺口的圆盘,phase 决定亮面(0 = 朔,0.5 = 望)
|
|
208
|
+
* ========================================================================== */
|
|
209
|
+
|
|
210
|
+
function drawMoon(ctx, W, H, pal) {
|
|
211
|
+
const moon = pal.moon;
|
|
212
|
+
if (!moon) return;
|
|
213
|
+
const alpha = clamp01(moon.alpha);
|
|
214
|
+
if (alpha <= 0.02) return;
|
|
215
|
+
const cx = clamp(moon.x, -0.2, 1.2) * W;
|
|
216
|
+
const cy = clamp(moon.y, -0.2, 1.2) * H;
|
|
217
|
+
const radius = Math.max(5, H * 0.019);
|
|
218
|
+
const phase = clamp01(num(moon.phase, 0.5));
|
|
219
|
+
|
|
220
|
+
// 月晕
|
|
221
|
+
const halo = ctx.createRadialGradient(cx, cy, radius * 0.6, cx, cy, radius * 6);
|
|
222
|
+
halo.addColorStop(0, rgba(moon.color, 0.26 * alpha));
|
|
223
|
+
halo.addColorStop(0.4, rgba(moon.color, 0.08 * alpha));
|
|
224
|
+
halo.addColorStop(1, rgba(moon.color, 0));
|
|
225
|
+
ctx.fillStyle = halo;
|
|
226
|
+
ctx.beginPath();
|
|
227
|
+
ctx.arc(cx, cy, radius * 6, 0, TAU);
|
|
228
|
+
ctx.fill();
|
|
229
|
+
|
|
230
|
+
// 亮面 = 半个圆盘 + 终结线椭圆回扫:rx = r·|cos(2πp)|,
|
|
231
|
+
// 新月时椭圆与圆重合(面积 0),上下弦时退化成直线,望月时铺满整圆。
|
|
232
|
+
const waxing = phase < 0.5;
|
|
233
|
+
const p = waxing ? phase : 1 - phase;
|
|
234
|
+
const rx = Math.max(0.01, radius * Math.abs(Math.cos(TAU * p)));
|
|
235
|
+
|
|
236
|
+
ctx.save();
|
|
237
|
+
if (!waxing) {
|
|
238
|
+
// 亏月=把上弦的亮面镜像到左半边
|
|
239
|
+
ctx.translate(cx * 2, 0);
|
|
240
|
+
ctx.scale(-1, 1);
|
|
241
|
+
}
|
|
242
|
+
ctx.beginPath();
|
|
243
|
+
ctx.arc(cx, cy, radius, -Math.PI / 2, Math.PI / 2, false);
|
|
244
|
+
if (typeof ctx.ellipse === 'function') {
|
|
245
|
+
ctx.ellipse(cx, cy, rx, radius, 0, Math.PI / 2, -Math.PI / 2, p < 0.25);
|
|
246
|
+
} else {
|
|
247
|
+
ctx.lineTo(cx, cy + radius);
|
|
248
|
+
ctx.lineTo(cx, cy - radius);
|
|
249
|
+
}
|
|
250
|
+
ctx.closePath();
|
|
251
|
+
ctx.fillStyle = rgba(moon.color, alpha);
|
|
252
|
+
ctx.fill();
|
|
253
|
+
ctx.restore();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* ==========================================================================
|
|
257
|
+
* L1 远海:低频起伏的地平线(噪声 × 0.08Hz,振幅 2–4px)+ 海面底色
|
|
258
|
+
* ========================================================================== */
|
|
259
|
+
|
|
260
|
+
function farSeaNoise(u, t) {
|
|
261
|
+
const w = TAU * L1.freq;
|
|
262
|
+
return (
|
|
263
|
+
Math.sin(u * L1.spatial + t * w) * 0.5 +
|
|
264
|
+
Math.sin(u * L1.spatial * 2.6 - t * w * 1.7 + 1.1) * 0.3 +
|
|
265
|
+
Math.sin(u * L1.spatial * 5.4 + t * w * 2.9 + 2.3) * 0.2
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function drawFarSea(ctx, W, H, horizonY, pal, t, px) {
|
|
270
|
+
const amp = lerp(L1.ampMin, L1.ampMax, 0.5 + 0.5 * Math.sin(t * TAU * L1.freq)) * px;
|
|
271
|
+
const step = Math.max(8, W / 80);
|
|
272
|
+
const curve = (x) => horizonY - amp * (0.5 + 0.5 * farSeaNoise((x / Math.max(1, W)) * TAU, t));
|
|
273
|
+
|
|
274
|
+
ctx.save();
|
|
275
|
+
ctx.beginPath();
|
|
276
|
+
ctx.moveTo(0, curve(0));
|
|
277
|
+
for (let x = step; x < W + step; x += step) {
|
|
278
|
+
const xx = Math.min(x, W);
|
|
279
|
+
ctx.lineTo(xx, curve(xx));
|
|
280
|
+
}
|
|
281
|
+
ctx.lineTo(W, H);
|
|
282
|
+
ctx.lineTo(0, H);
|
|
283
|
+
ctx.closePath();
|
|
284
|
+
ctx.clip();
|
|
285
|
+
|
|
286
|
+
const gradient = ctx.createLinearGradient(0, horizonY - amp - 2, 0, H);
|
|
287
|
+
// 地平线附近是「把天空倒下来」的反射色,往下逐渐变成深水色
|
|
288
|
+
const mirror = 0.45 + 0.25 * (1 - clamp01(pal.exposure)); // 太阳越低,掠射反射越亮
|
|
289
|
+
gradient.addColorStop(0, mix(pal.seaFar, pal.skyBottom, mirror));
|
|
290
|
+
gradient.addColorStop(0.18, mix(pal.seaFar, pal.skyBottom, mirror * 0.45));
|
|
291
|
+
gradient.addColorStop(0.42, mix(pal.seaFar, pal.seaMid, 0.85));
|
|
292
|
+
gradient.addColorStop(0.7, pal.seaMid);
|
|
293
|
+
gradient.addColorStop(1, mix(pal.seaNear, pal.seaMid, 0.15));
|
|
294
|
+
ctx.fillStyle = gradient;
|
|
295
|
+
ctx.fillRect(0, horizonY - amp - 2, W, H - horizonY + amp + 4);
|
|
296
|
+
ctx.restore();
|
|
297
|
+
|
|
298
|
+
// 水平雾带:厚度与浓度随曝光变化(越暗/越低的天越厚),把海天接缝糊掉
|
|
299
|
+
const fogAmount = clamp01(0.3 + 0.5 * (1 - clamp01(pal.exposure)));
|
|
300
|
+
const fogHeight = H * (0.045 + 0.085 * (1 - clamp01(pal.exposure)));
|
|
301
|
+
const fogColor = mix(pal.skyBottom, pal.horizonGlow, 0.45);
|
|
302
|
+
// 跨在地平线上的窄雾带:峰值正好压在地平线,天光不被糊成一条灰带
|
|
303
|
+
const fog = ctx.createLinearGradient(0, horizonY - fogHeight * 0.45, 0, horizonY + fogHeight * 0.9);
|
|
304
|
+
fog.addColorStop(0, rgba(fogColor, 0));
|
|
305
|
+
fog.addColorStop(0.34, rgba(fogColor, 0.26 * fogAmount));
|
|
306
|
+
fog.addColorStop(0.55, rgba(fogColor, 0.18 * fogAmount));
|
|
307
|
+
fog.addColorStop(1, rgba(fogColor, 0));
|
|
308
|
+
ctx.fillStyle = fog;
|
|
309
|
+
ctx.fillRect(0, horizonY - fogHeight * 0.45, W, fogHeight * 1.35);
|
|
310
|
+
|
|
311
|
+
// 地平线亮边
|
|
312
|
+
ctx.beginPath();
|
|
313
|
+
ctx.moveTo(0, curve(0));
|
|
314
|
+
for (let x = step; x < W + step; x += step) {
|
|
315
|
+
const xx = Math.min(x, W);
|
|
316
|
+
ctx.lineTo(xx, curve(xx));
|
|
317
|
+
}
|
|
318
|
+
ctx.strokeStyle = rgba(mix(pal.seaFar, pal.foam, 0.3), 0.06 + 0.16 * clamp01(pal.exposure));
|
|
319
|
+
ctx.lineWidth = Math.max(0.6, 1.1 * px);
|
|
320
|
+
ctx.stroke();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* ==========================================================================
|
|
324
|
+
* 浪区(surf zone)—— 面向观察者推过来的浪
|
|
325
|
+
*
|
|
326
|
+
* 关键改变:波不再是「一行行各自横向平移的正弦线」,而是 N 条**横向贯通的波前**。
|
|
327
|
+
* 每条波前有一个 depth ∈ (0,1]:depth 越大 = 越靠近观察者 = 画面越靠下、
|
|
328
|
+
* 振幅越大、在屏幕上推进得越快。深度用取模滚动推进:
|
|
329
|
+
*
|
|
330
|
+
* raw = i / N + t / period
|
|
331
|
+
* depth = raw - floor(raw) // 0 在地平线生成 → 1 在画面底部消失
|
|
332
|
+
*
|
|
333
|
+
* 波前横向的形状(三组正弦叠加)**挂在波前自己身上**:相位只由它的「编号」
|
|
334
|
+
* (serial)决定,随波前一起往下走。所以看到的是「同一条浪朝我推过来」,
|
|
335
|
+
* 而不是「条纹在横向滑动」。唯一随时间变化的相位是一个极慢的公共呼吸项
|
|
336
|
+
* (让浪形轻微起伏),幅度小到不产生横向漂移感。
|
|
337
|
+
* ========================================================================== */
|
|
338
|
+
|
|
339
|
+
function gradeOf(media) {
|
|
340
|
+
if (!media || typeof media !== 'object') return 0.55;
|
|
341
|
+
const flat = media.grade !== undefined ? media.grade : media.settings && media.settings.grade;
|
|
342
|
+
return clamp01(num(flat, 0.55));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** 设置里的 media.motion(0..1,0=完全静止)。缺字段/非法 → 0.5。 */
|
|
346
|
+
function motionOf(media) {
|
|
347
|
+
if (!media || typeof media !== 'object') return 0.5;
|
|
348
|
+
const flat = media.motion !== undefined ? media.motion : media.settings && media.settings.motion;
|
|
349
|
+
return clamp01(num(flat, 0.5));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** 字符串 → 0..1 的稳定哈希(同一张素材每次浮现推拉方向一致)。 */
|
|
353
|
+
function hashString(text) {
|
|
354
|
+
const source = typeof text === 'string' ? text : '';
|
|
355
|
+
let h = 2166136261;
|
|
356
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
357
|
+
h ^= source.charCodeAt(i);
|
|
358
|
+
h = Math.imul(h, 16777619) >>> 0;
|
|
359
|
+
}
|
|
360
|
+
return (h >>> 0) / 4294967296;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** 设合成模式并执行;浏览器不支持该模式时自动退回 source-over(不抛)。 */
|
|
364
|
+
function withComposite(ctx, op, paint) {
|
|
365
|
+
const previous = ctx.globalCompositeOperation;
|
|
366
|
+
try {
|
|
367
|
+
ctx.globalCompositeOperation = op;
|
|
368
|
+
} catch (_error) {
|
|
369
|
+
/* ignore */
|
|
370
|
+
}
|
|
371
|
+
if (ctx.globalCompositeOperation !== op) ctx.globalCompositeOperation = 'source-over';
|
|
372
|
+
try {
|
|
373
|
+
paint();
|
|
374
|
+
} finally {
|
|
375
|
+
ctx.globalCompositeOperation = previous || 'source-over';
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** 全屏一次混色(alpha 用来减弱合成强度)。 */
|
|
380
|
+
function compositeFill(ctx, op, color, alpha, W, H) {
|
|
381
|
+
const a = clamp01(alpha);
|
|
382
|
+
if (a <= 0.003) return;
|
|
383
|
+
withComposite(ctx, op, () => {
|
|
384
|
+
ctx.fillStyle = rgba(color, a);
|
|
385
|
+
ctx.fillRect(0, 0, W, H);
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 天光罩:把素材按「此刻的天光」调色。
|
|
391
|
+
* 夜里真的压暗(multiply),正午提亮(screen),晨昏偏橘/紫(soft-light 统一色温)。
|
|
392
|
+
*/
|
|
393
|
+
function drawMediaGrade(ctx, W, H, pal, grade) {
|
|
394
|
+
const g = clamp01(grade);
|
|
395
|
+
if (g <= 0.01) return;
|
|
396
|
+
const exposure = clamp01(pal.exposure);
|
|
397
|
+
const lowSun = 1 - exposure; // 0 正午 → 1 深夜
|
|
398
|
+
const darkness = clamp01((0.95 - exposure) / 0.65); // 正午 0 → 深夜 1(按五档 exposure 标定)
|
|
399
|
+
const tint = mix(pal.seaMid, pal.horizonGlow, clamp01(0.2 + 0.75 * lowSun));
|
|
400
|
+
const shade = mix(tint, '#03060e', clamp01(0.4 + 0.55 * darkness));
|
|
401
|
+
|
|
402
|
+
// 1) multiply 压暗 + 上色(夜里真的压下去)
|
|
403
|
+
compositeFill(ctx, 'multiply', shade, clamp01(g * (0.08 + 0.9 * darkness)), W, H);
|
|
404
|
+
// 2) 深夜再压一层:一张正午照片要变成夜,一层 multiply 不够
|
|
405
|
+
if (darkness > 0.2) compositeFill(ctx, 'multiply', '#04070f', clamp01((g * 0.4 * (darkness - 0.2)) / 0.8), W, H);
|
|
406
|
+
// 3) screen 提亮(正午要更亮)
|
|
407
|
+
compositeFill(ctx, 'screen', mix(pal.horizonGlow, '#ffffff', 0.5), clamp01(g * (0.1 + 0.16 * exposure)), W, H);
|
|
408
|
+
// 4) soft-light 统一色温(晨昏偏橘/紫、夜里偏靛)
|
|
409
|
+
compositeFill(ctx, 'soft-light', tint, clamp01(g * 0.55), W, H);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 顶部天光蒙版:让照片的天变成「此刻的天」。
|
|
414
|
+
* 两道:先 multiply 用调亮过的天空色把亮度拉到位(同时把色相带过去),
|
|
415
|
+
* 再叠一层很淡的同色纱,让色相更明确(multiply 对色相改动有限)。
|
|
416
|
+
*/
|
|
417
|
+
function drawMediaSkyMask(ctx, W, H, pal, grade) {
|
|
418
|
+
const g = clamp01(grade);
|
|
419
|
+
if (g <= 0.01) return;
|
|
420
|
+
const bottom = H * MEDIA.skyMaskBottom;
|
|
421
|
+
|
|
422
|
+
const shade = ctx.createLinearGradient(0, 0, 0, bottom);
|
|
423
|
+
shade.addColorStop(0, rgba(mix('#ffffff', pal.skyTop, 0.62), 0.9 * g));
|
|
424
|
+
shade.addColorStop(0.45, rgba(mix('#ffffff', pal.skyMid, 0.5), 0.72 * g));
|
|
425
|
+
shade.addColorStop(1, rgba(mix('#ffffff', pal.skyBottom, 0.3), 0));
|
|
426
|
+
withComposite(ctx, 'multiply', () => {
|
|
427
|
+
ctx.fillStyle = shade;
|
|
428
|
+
ctx.fillRect(0, 0, W, bottom);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const veil = ctx.createLinearGradient(0, 0, 0, bottom);
|
|
432
|
+
veil.addColorStop(0, rgba(pal.skyTop, 0.34 * g));
|
|
433
|
+
veil.addColorStop(0.5, rgba(pal.skyMid, 0.2 * g));
|
|
434
|
+
veil.addColorStop(1, rgba(pal.skyBottom, 0));
|
|
435
|
+
ctx.fillStyle = veil;
|
|
436
|
+
ctx.fillRect(0, 0, W, bottom);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** 水面呼吸的高光扫过:极薄一条亮带自上而下,0.11Hz,两端淡出。 */
|
|
440
|
+
function drawMediaSweep(ctx, W, H, pal, t, motion, grade) {
|
|
441
|
+
const phase = (((t * MEDIA.breathFreq) % 1) + 1) % 1;
|
|
442
|
+
const bandHeight = H * MEDIA.sweepHeight;
|
|
443
|
+
const y = -bandHeight + phase * (H + bandHeight * 2);
|
|
444
|
+
const strength = Math.sin(Math.PI * phase);
|
|
445
|
+
const alpha = 0.05 * strength * (0.45 + 0.75 * clamp01(grade)) * (0.5 + 0.5 * clamp01(motion));
|
|
446
|
+
if (alpha <= 0.004) return;
|
|
447
|
+
const sweep = ctx.createLinearGradient(0, y, 0, y + bandHeight);
|
|
448
|
+
sweep.addColorStop(0, rgba(pal.foam, 0));
|
|
449
|
+
sweep.addColorStop(0.5, rgba(mix(pal.horizonGlow, '#ffffff', 0.4), alpha));
|
|
450
|
+
sweep.addColorStop(1, rgba(pal.foam, 0));
|
|
451
|
+
withComposite(ctx, 'screen', () => {
|
|
452
|
+
ctx.fillStyle = sweep;
|
|
453
|
+
ctx.fillRect(0, y, W, bandHeight);
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** 轻暗角。 */
|
|
458
|
+
function drawVignette(ctx, W, H, strength) {
|
|
459
|
+
const s = clamp01(strength);
|
|
460
|
+
if (s <= 0.01) return;
|
|
461
|
+
const gradient = ctx.createRadialGradient(W * 0.5, H * 0.46, Math.min(W, H) * 0.34, W * 0.5, H * 0.5, Math.max(W, H) * 0.78);
|
|
462
|
+
gradient.addColorStop(0, 'rgba(0,0,0,0)');
|
|
463
|
+
gradient.addColorStop(1, rgba('#000000', s));
|
|
464
|
+
ctx.fillStyle = gradient;
|
|
465
|
+
ctx.fillRect(0, 0, W, H);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* 画一帧媒体素材。成功画了返回 true;不可画(未就绪 / 没有帧 / 元素缺失)
|
|
470
|
+
* 返回 false,调用方据此退回程序化海 —— 绝不留黑帧。
|
|
471
|
+
*/
|
|
472
|
+
function renderMediaScene(ctx, W, H, t, pal, asset, media, warmth) {
|
|
473
|
+
const el = asset && asset.el;
|
|
474
|
+
if (!el) return false;
|
|
475
|
+
let iw = 0;
|
|
476
|
+
let ih = 0;
|
|
477
|
+
if (asset.kind === 'video') {
|
|
478
|
+
if (num(el.readyState, 0) < 2) return false; // 还没有可画帧
|
|
479
|
+
iw = num(el.videoWidth, 0);
|
|
480
|
+
ih = num(el.videoHeight, 0);
|
|
481
|
+
} else {
|
|
482
|
+
iw = num(el.naturalWidth, num(asset.w, 0));
|
|
483
|
+
ih = num(el.naturalHeight, num(asset.h, 0));
|
|
484
|
+
}
|
|
485
|
+
if (!iw || !ih) return false;
|
|
486
|
+
|
|
487
|
+
const motion = motionOf(media);
|
|
488
|
+
const grade = gradeOf(media);
|
|
489
|
+
const seed = clamp01(num(asset.seed, 0));
|
|
490
|
+
|
|
491
|
+
// Ken Burns:40s 一个来回,缩放 1.0→1.06 + 轻微平移;方向由素材哈希决定,会话内固定
|
|
492
|
+
const phase = (((t / MEDIA.kenBurnsPeriod + seed) % 1) + 1) % 1;
|
|
493
|
+
const ease = 0.5 - 0.5 * Math.cos(TAU * phase);
|
|
494
|
+
const zoom = MEDIA.overscan + MEDIA.kenBurnsZoom * motion * (0.35 + 0.65 * ease);
|
|
495
|
+
const scale = Math.max(W / iw, H / ih) * zoom;
|
|
496
|
+
const dw = Math.max(W + 2, iw * scale);
|
|
497
|
+
const dh = Math.max(H + 2, ih * scale);
|
|
498
|
+
const marginX = Math.max(0, (dw - W) / 2);
|
|
499
|
+
const marginY = Math.max(0, (dh - H) / 2);
|
|
500
|
+
const panX = marginX * 0.8 * motion * Math.sin(TAU * phase + seed * TAU);
|
|
501
|
+
const panY = marginY * 0.5 * motion * Math.cos(TAU * phase * 0.71 + seed * 5.1);
|
|
502
|
+
// 水面呼吸的横向位移(1–2px):叠在平移上,钳在超采样余量内,永不留边
|
|
503
|
+
const breath = motion > 0.02 ? Math.sin(TAU * MEDIA.breathFreq * t) * MEDIA.breathPx * (0.4 + 0.6 * motion) : 0;
|
|
504
|
+
const dx = (W - dw) / 2 + clamp(panX + breath, -marginX, marginX);
|
|
505
|
+
const dy = (H - dh) / 2 + clamp(panY, -marginY, marginY);
|
|
506
|
+
|
|
507
|
+
ctx.drawImage(el, dx, dy, dw, dh);
|
|
508
|
+
|
|
509
|
+
drawMediaGrade(ctx, W, H, pal, grade); // 1) 天光罩
|
|
510
|
+
drawMediaSweep(ctx, W, H, pal, t, motion, grade); // 2) 水面呼吸(高光扫过)
|
|
511
|
+
drawMediaSkyMask(ctx, W, H, pal, grade); // 3) 地平线雾 / 顶部天光蒙版
|
|
512
|
+
drawVignette(ctx, W, H, MEDIA.vignette * (0.5 + 0.5 * grade)); // 4) 暗角
|
|
513
|
+
|
|
514
|
+
if (warmth) {
|
|
515
|
+
ctx.fillStyle = rgba(warmth > 0 ? '#ffb066' : '#6fa8ff', Math.abs(warmth) * 0.9);
|
|
516
|
+
ctx.fillRect(0, 0, W, H);
|
|
517
|
+
}
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/* ==========================================================================
|
|
522
|
+
* 整帧
|
|
523
|
+
* ========================================================================== */
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* 仅测试用:最近一帧真正用的 t(秒)。
|
|
527
|
+
* 假 canvas 环境里没法从绘图指令反解 t,用它验证「停帧恢复不跳变 / 掉帧不改海速」。
|
|
528
|
+
*/
|
|
529
|
+
let lastRenderTime = 0;
|
|
530
|
+
export function __lastRenderTime() {
|
|
531
|
+
return lastRenderTime;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// 兜底的兜底:调用方忘了传 field 也照样能画。
|
|
535
|
+
// (v0.1.1 的事故正是"调用点漏传"— 于是每帧都走 drawFarSea,画面是平的。这里懒建一份缓冲,
|
|
536
|
+
// 让"漏接一根线"退化成"多用一份内存",而不是"没有海"。)
|
|
537
|
+
let lazyWaterField = null;
|
|
538
|
+
|
|
539
|
+
function resolveField(field) {
|
|
540
|
+
if (field) return field;
|
|
541
|
+
if (!lazyWaterField) {
|
|
542
|
+
try {
|
|
543
|
+
lazyWaterField = createWaterField(typeof document !== 'undefined' ? document : undefined);
|
|
544
|
+
} catch (_error) {
|
|
545
|
+
lazyWaterField = null;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return lazyWaterField;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function renderScene(ctx, W, H, t, pal, tideN, warmth, field) {
|
|
552
|
+
lastRenderTime = t;
|
|
553
|
+
field = resolveField(field);
|
|
554
|
+
const px = clamp(H / 720, 0.65, 1.8);
|
|
555
|
+
const nearLift = tideN * H * 0.006; // 潮位只让水位线轻微起伏,不喧宾夺主
|
|
556
|
+
const horizonY = H * HORIZON + nearLift;
|
|
557
|
+
|
|
558
|
+
ctx.save();
|
|
559
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
560
|
+
ctx.globalAlpha = 1;
|
|
561
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
562
|
+
ctx.imageSmoothingEnabled = true;
|
|
563
|
+
|
|
564
|
+
// L0 天空(+ 星)
|
|
565
|
+
drawSky(ctx, W, H, horizonY, pal);
|
|
566
|
+
drawStars(ctx, W, H, horizonY, pal, t);
|
|
567
|
+
|
|
568
|
+
// 天体在海面之前画:地平线以下的部分自然被海水盖住
|
|
569
|
+
drawSun(ctx, W, H, pal);
|
|
570
|
+
drawMoon(ctx, W, H, pal);
|
|
571
|
+
|
|
572
|
+
// 水面:**斑块场**(不是一排排浪脊)。
|
|
573
|
+
// 低分辨率高度场按透视采样噪声 → 整块放大 → 随机的碎块、近大远小、无贯通全屏的条纹。
|
|
574
|
+
// 失败(拿不到缓冲)就退回一版最简单的竖向渐变,绝不留白。
|
|
575
|
+
let drawn = false
|
|
576
|
+
if (field) {
|
|
577
|
+
try {
|
|
578
|
+
drawn = drawWater(ctx, { W, H, horizonY, t, pal, tideN }, field)
|
|
579
|
+
} catch (_error) {
|
|
580
|
+
drawn = false
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (!drawn) {
|
|
584
|
+
drawFarSea(ctx, W, H, horizonY, pal, t, px)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// reducedMotion 的「色温微变」:极薄一层冷暖罩,alpha ≈ 0.02
|
|
588
|
+
if (warmth) {
|
|
589
|
+
ctx.fillStyle = rgba(warmth > 0 ? '#ffb066' : '#6fa8ff', Math.abs(warmth) * 0.9);
|
|
590
|
+
ctx.fillRect(0, 0, W, H);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
ctx.restore();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/* ==========================================================================
|
|
597
|
+
* 组件
|
|
598
|
+
* ========================================================================== */
|
|
599
|
+
|
|
600
|
+
const HOST_STYLE = {
|
|
601
|
+
position: 'absolute',
|
|
602
|
+
top: 0,
|
|
603
|
+
right: 0,
|
|
604
|
+
bottom: 0,
|
|
605
|
+
left: 0,
|
|
606
|
+
overflow: 'hidden',
|
|
607
|
+
pointerEvents: 'none', // 装饰层:点击/拖拽都留给覆盖层自己处理
|
|
608
|
+
background: '#0b1220',
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
const CANVAS_STYLE = {
|
|
612
|
+
display: 'block',
|
|
613
|
+
width: '100%',
|
|
614
|
+
height: '100%',
|
|
615
|
+
border: 'none',
|
|
616
|
+
background: 'transparent',
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
export default function SeaCanvas({ now, media, form, paused, reducedMotion, onStats }) {
|
|
620
|
+
const hostRef = useRef(null);
|
|
621
|
+
const canvasRef = useRef(null);
|
|
622
|
+
const nowRef = useRef(now);
|
|
623
|
+
const mediaRef = useRef(media);
|
|
624
|
+
const formRef = useRef(form);
|
|
625
|
+
const pausedRef = useRef(Boolean(paused));
|
|
626
|
+
const reducedRef = useRef(Boolean(reducedMotion));
|
|
627
|
+
const statsRef = useRef(onStats);
|
|
628
|
+
const apiRef = useRef(null);
|
|
629
|
+
|
|
630
|
+
// ── props 变化只更新 ref,不重建渲染循环 ───────────────────────────────
|
|
631
|
+
useEffect(() => {
|
|
632
|
+
nowRef.current = now;
|
|
633
|
+
const api = apiRef.current;
|
|
634
|
+
if (api) api.refreshNow();
|
|
635
|
+
}, [now]);
|
|
636
|
+
|
|
637
|
+
useEffect(() => {
|
|
638
|
+
mediaRef.current = media;
|
|
639
|
+
const api = apiRef.current;
|
|
640
|
+
// 只换设置(grade/motion)不重载素材:加载器内部按 kind|url 判重
|
|
641
|
+
if (api) api.setMedia(media);
|
|
642
|
+
}, [media]);
|
|
643
|
+
|
|
644
|
+
useEffect(() => {
|
|
645
|
+
formRef.current = form;
|
|
646
|
+
}, [form]);
|
|
647
|
+
|
|
648
|
+
useEffect(() => {
|
|
649
|
+
statsRef.current = onStats;
|
|
650
|
+
}, [onStats]);
|
|
651
|
+
|
|
652
|
+
useEffect(() => {
|
|
653
|
+
pausedRef.current = Boolean(paused);
|
|
654
|
+
const api = apiRef.current;
|
|
655
|
+
if (api) api.sync();
|
|
656
|
+
}, [paused]);
|
|
657
|
+
|
|
658
|
+
useEffect(() => {
|
|
659
|
+
reducedRef.current = Boolean(reducedMotion);
|
|
660
|
+
const api = apiRef.current;
|
|
661
|
+
if (api) api.sync();
|
|
662
|
+
}, [reducedMotion]);
|
|
663
|
+
|
|
664
|
+
// ── 挂载一次:建缓冲、起循环、装监听;卸载全部收干净 ────────────────────
|
|
665
|
+
useEffect(() => {
|
|
666
|
+
const host = hostRef.current;
|
|
667
|
+
const canvas = canvasRef.current;
|
|
668
|
+
if (!host || !canvas) return undefined;
|
|
669
|
+
|
|
670
|
+
const perfNow =
|
|
671
|
+
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
672
|
+
? () => performance.now()
|
|
673
|
+
: () => Date.now();
|
|
674
|
+
|
|
675
|
+
const state = {
|
|
676
|
+
alive: true,
|
|
677
|
+
mode: 'off', // 'anim' | 'static' | 'off'
|
|
678
|
+
hidden: typeof document !== 'undefined' ? document.hidden === true : false,
|
|
679
|
+
blurred: false,
|
|
680
|
+
ctx: null,
|
|
681
|
+
w: 0,
|
|
682
|
+
h: 0,
|
|
683
|
+
dpr: 0,
|
|
684
|
+
raf: 0,
|
|
685
|
+
staticTimer: 0,
|
|
686
|
+
resizeTimer: 0,
|
|
687
|
+
clockMs: 0,
|
|
688
|
+
clockAnchor: 0,
|
|
689
|
+
lastDrawMs: 0,
|
|
690
|
+
curSecond: -1,
|
|
691
|
+
curCount: 0,
|
|
692
|
+
seconds: [],
|
|
693
|
+
fps: 0,
|
|
694
|
+
degraded: false,
|
|
695
|
+
mediaCap: false, // 媒体就绪 → 缓冲上限提到 2560
|
|
696
|
+
staticStep: 0,
|
|
697
|
+
// 水面斑块场的低分辨率高度场缓冲:挂载时建一次,整个生命周期复用。
|
|
698
|
+
// 建不出来就是 null,renderScene 会退回 drawFarSea(简单的远景渐变),不会留白。
|
|
699
|
+
field: (() => {
|
|
700
|
+
try {
|
|
701
|
+
return createWaterField(typeof document !== 'undefined' ? document : undefined);
|
|
702
|
+
} catch (_error) {
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
})(),
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
// 2D context:拿不到(或抛错)就退回 CSS 渐变静态海,绝不抛
|
|
709
|
+
try {
|
|
710
|
+
state.ctx = canvas.getContext('2d', { alpha: false });
|
|
711
|
+
} catch (_error) {
|
|
712
|
+
state.ctx = null;
|
|
713
|
+
}
|
|
714
|
+
if (!state.ctx) {
|
|
715
|
+
try {
|
|
716
|
+
state.ctx = canvas.getContext('2d');
|
|
717
|
+
} catch (_error) {
|
|
718
|
+
state.ctx = null;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
if (!state.ctx) {
|
|
722
|
+
try {
|
|
723
|
+
canvas.style.display = 'none';
|
|
724
|
+
} catch (_error) {
|
|
725
|
+
/* ignore */
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/* ---------- 媒体素材(image / video)---------- */
|
|
730
|
+
|
|
731
|
+
const asset = { key: '', kind: null, el: null, ready: false, failed: false, w: 0, h: 0, seed: 0 };
|
|
732
|
+
|
|
733
|
+
const bufferCap = () => (state.mediaCap ? TUNING.mediaBufferWidth : TUNING.maxBufferWidth);
|
|
734
|
+
|
|
735
|
+
function teardownAsset() {
|
|
736
|
+
const el = asset.el;
|
|
737
|
+
asset.el = null;
|
|
738
|
+
asset.kind = null;
|
|
739
|
+
asset.key = '';
|
|
740
|
+
asset.ready = false;
|
|
741
|
+
asset.failed = false;
|
|
742
|
+
asset.w = 0;
|
|
743
|
+
asset.h = 0;
|
|
744
|
+
asset.seed = 0;
|
|
745
|
+
if (!el) return;
|
|
746
|
+
try {
|
|
747
|
+
if (typeof el.pause === 'function') el.pause();
|
|
748
|
+
} catch (_error) {
|
|
749
|
+
/* ignore */
|
|
750
|
+
}
|
|
751
|
+
try {
|
|
752
|
+
if (typeof el.removeAttribute === 'function') el.removeAttribute('src');
|
|
753
|
+
} catch (_error) {
|
|
754
|
+
/* ignore */
|
|
755
|
+
}
|
|
756
|
+
try {
|
|
757
|
+
if (typeof el.load === 'function') el.load(); // 停掉解码、释放缓冲
|
|
758
|
+
} catch (_error) {
|
|
759
|
+
/* ignore */
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function setVideoPlaying(playing) {
|
|
764
|
+
const el = asset.el;
|
|
765
|
+
if (!el || asset.kind !== 'video') return;
|
|
766
|
+
try {
|
|
767
|
+
if (playing) {
|
|
768
|
+
const started = el.play();
|
|
769
|
+
if (started && typeof started.catch === 'function') started.catch(() => {});
|
|
770
|
+
} else if (typeof el.pause === 'function') {
|
|
771
|
+
el.pause();
|
|
772
|
+
}
|
|
773
|
+
} catch (_error) {
|
|
774
|
+
/* ignore:自动播放被拒也无所谓,帧还在 */
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function onMediaReady() {
|
|
779
|
+
state.mediaCap = true; // 照片要清晰度:上限 1280 → 2560
|
|
780
|
+
measureNow();
|
|
781
|
+
if (state.mode !== 'anim') draw(perfNow());
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function onMediaFailed() {
|
|
785
|
+
state.mediaCap = false; // 退回程序化海,缓冲也缩回去
|
|
786
|
+
measureNow();
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* 载入一轮媒体素材。失败(加载/解码/跨域/不支持)一律静默:
|
|
791
|
+
* 只剩 failed 标记,画面照旧走程序化海,不抛、不黑屏。
|
|
792
|
+
*/
|
|
793
|
+
function loadAsset(next) {
|
|
794
|
+
teardownAsset();
|
|
795
|
+
if (!next || typeof next !== 'object') return;
|
|
796
|
+
const kind = next.kind === 'video' ? 'video' : next.kind === 'image' ? 'image' : null;
|
|
797
|
+
const url = typeof next.url === 'string' ? next.url.trim() : '';
|
|
798
|
+
if (!kind || !url) return;
|
|
799
|
+
|
|
800
|
+
asset.key = kind + '|' + url;
|
|
801
|
+
asset.kind = kind;
|
|
802
|
+
asset.seed = hashString(asset.key); // 会话内固定:同一张图推拉方向一致
|
|
803
|
+
|
|
804
|
+
try {
|
|
805
|
+
if (kind === 'image') {
|
|
806
|
+
const img = new Image();
|
|
807
|
+
asset.el = img;
|
|
808
|
+
img.onload = () => {
|
|
809
|
+
if (asset.el !== img) return;
|
|
810
|
+
const w = num(img.naturalWidth, 0);
|
|
811
|
+
const h = num(img.naturalHeight, 0);
|
|
812
|
+
if (!w || !h) {
|
|
813
|
+
asset.failed = true;
|
|
814
|
+
onMediaFailed();
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
asset.w = w;
|
|
818
|
+
asset.h = h;
|
|
819
|
+
asset.failed = false;
|
|
820
|
+
asset.ready = true;
|
|
821
|
+
onMediaReady();
|
|
822
|
+
};
|
|
823
|
+
img.onerror = () => {
|
|
824
|
+
if (asset.el !== img) return;
|
|
825
|
+
asset.ready = false;
|
|
826
|
+
asset.failed = true;
|
|
827
|
+
onMediaFailed();
|
|
828
|
+
};
|
|
829
|
+
// 契约:不设 crossOrigin、不读像素(同源 /media/file 就够)
|
|
830
|
+
try {
|
|
831
|
+
img.decoding = 'async';
|
|
832
|
+
} catch (_error) {
|
|
833
|
+
/* ignore */
|
|
834
|
+
}
|
|
835
|
+
img.src = url;
|
|
836
|
+
} else {
|
|
837
|
+
const video = document.createElement('video');
|
|
838
|
+
asset.el = video;
|
|
839
|
+
video.muted = true;
|
|
840
|
+
video.loop = true;
|
|
841
|
+
video.autoplay = true;
|
|
842
|
+
video.preload = 'auto';
|
|
843
|
+
try {
|
|
844
|
+
video.setAttribute('playsinline', '');
|
|
845
|
+
video.setAttribute('webkit-playsinline', '');
|
|
846
|
+
video.setAttribute('muted', '');
|
|
847
|
+
video.setAttribute('disableremoteplayback', '');
|
|
848
|
+
} catch (_error) {
|
|
849
|
+
/* ignore */
|
|
850
|
+
}
|
|
851
|
+
const markReady = () => {
|
|
852
|
+
if (asset.el !== video) return;
|
|
853
|
+
const w = num(video.videoWidth, 0);
|
|
854
|
+
const h = num(video.videoHeight, 0);
|
|
855
|
+
if (!w || !h) return;
|
|
856
|
+
asset.w = w;
|
|
857
|
+
asset.h = h;
|
|
858
|
+
asset.failed = false;
|
|
859
|
+
asset.ready = true;
|
|
860
|
+
onMediaReady();
|
|
861
|
+
};
|
|
862
|
+
try {
|
|
863
|
+
video.addEventListener('loadeddata', markReady);
|
|
864
|
+
video.addEventListener('canplay', markReady);
|
|
865
|
+
video.addEventListener('error', () => {
|
|
866
|
+
if (asset.el !== video) return;
|
|
867
|
+
asset.ready = false;
|
|
868
|
+
asset.failed = true;
|
|
869
|
+
onMediaFailed();
|
|
870
|
+
});
|
|
871
|
+
} catch (_error) {
|
|
872
|
+
/* ignore */
|
|
873
|
+
}
|
|
874
|
+
video.src = url;
|
|
875
|
+
setVideoPlaying(true);
|
|
876
|
+
}
|
|
877
|
+
} catch (_error) {
|
|
878
|
+
asset.ready = false;
|
|
879
|
+
asset.failed = true;
|
|
880
|
+
onMediaFailed();
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const requestFrame =
|
|
885
|
+
typeof requestAnimationFrame === 'function'
|
|
886
|
+
? (fn) => requestAnimationFrame(fn)
|
|
887
|
+
: (fn) => setTimeout(() => fn(perfNow()), 16);
|
|
888
|
+
const cancelFrame =
|
|
889
|
+
typeof cancelAnimationFrame === 'function'
|
|
890
|
+
? (id) => cancelAnimationFrame(id)
|
|
891
|
+
: (id) => clearTimeout(id);
|
|
892
|
+
|
|
893
|
+
/* ---------- 颜色 / 时间 ---------- */
|
|
894
|
+
|
|
895
|
+
const currentPalette = () => {
|
|
896
|
+
try {
|
|
897
|
+
return paletteFor(nowRef.current);
|
|
898
|
+
} catch (_error) {
|
|
899
|
+
return paletteFor(null);
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
const tideNormalized = () => {
|
|
904
|
+
const source = nowRef.current;
|
|
905
|
+
const height = source && source.tide ? source.tide.height : 0;
|
|
906
|
+
return clamp(num(height, 0) / 1.2, -1, 1);
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* 动画时钟:由 performance.now() 累加而来(**不是帧计数**),
|
|
911
|
+
* 单帧最多推进 TUNING.maxStepMs,避免被浏览器节流后回来时一帧跳很远。
|
|
912
|
+
* 正常帧(16–120ms)时它就是绝对时间 —— 掉帧不会改变海的速度。
|
|
913
|
+
* 停帧期间不推进;恢复时把锚点归零,delta=0,相位连续不跳变。
|
|
914
|
+
*/
|
|
915
|
+
const advanceClock = (ms) => {
|
|
916
|
+
const nowMs = num(ms, perfNow());
|
|
917
|
+
if (!state.clockAnchor) state.clockAnchor = nowMs;
|
|
918
|
+
const delta = nowMs - state.clockAnchor;
|
|
919
|
+
state.clockAnchor = nowMs;
|
|
920
|
+
state.clockMs += clamp(delta, 0, TUNING.maxStepMs);
|
|
921
|
+
return state.clockMs;
|
|
922
|
+
};
|
|
923
|
+
const animTime = (ms) => advanceClock(ms) / 1000;
|
|
924
|
+
|
|
925
|
+
const staticWarmth = () => (state.staticStep % 2 === 0 ? 0.022 : -0.022);
|
|
926
|
+
|
|
927
|
+
/* ---------- CSS 兜底海(无 canvas 或绘制失败时) ---------- */
|
|
928
|
+
|
|
929
|
+
const paintFallback = (pal, ms) => {
|
|
930
|
+
try {
|
|
931
|
+
const breathe = 0.97 + 0.03 * Math.sin((num(ms, perfNow()) / 1000) * 0.35); // 仍随时间变色
|
|
932
|
+
const horizonTint = mix(pal.skyBottom, pal.foam, 0.5);
|
|
933
|
+
host.style.background =
|
|
934
|
+
'linear-gradient(180deg, ' +
|
|
935
|
+
pal.skyTop +
|
|
936
|
+
' 0%, ' +
|
|
937
|
+
pal.skyMid +
|
|
938
|
+
' 32%, ' +
|
|
939
|
+
pal.skyBottom +
|
|
940
|
+
' 50%, ' +
|
|
941
|
+
horizonTint +
|
|
942
|
+
' 52%, ' +
|
|
943
|
+
pal.seaFar +
|
|
944
|
+
' 56%, ' +
|
|
945
|
+
pal.seaMid +
|
|
946
|
+
' 76%, ' +
|
|
947
|
+
pal.seaNear +
|
|
948
|
+
' 100%)';
|
|
949
|
+
host.style.filter = 'brightness(' + round(breathe, 3) + ')';
|
|
950
|
+
} catch (_error) {
|
|
951
|
+
/* ignore:兜底本身也不许抛 */
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
/* ---------- 一帧 ---------- */
|
|
956
|
+
|
|
957
|
+
const draw = (ms) => {
|
|
958
|
+
const pal = currentPalette();
|
|
959
|
+
if (!state.ctx) {
|
|
960
|
+
paintFallback(pal, ms);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (!state.w || !state.h) return;
|
|
964
|
+
const t = animTime(ms);
|
|
965
|
+
const warmth = state.mode === 'static' ? staticWarmth() : 0;
|
|
966
|
+
|
|
967
|
+
// 有素材就先画素材;画不成(未就绪/没有帧/异常)立刻退回程序化海 —— 绝不留黑帧
|
|
968
|
+
if (asset.el) {
|
|
969
|
+
let drewMedia = false;
|
|
970
|
+
try {
|
|
971
|
+
drewMedia = renderMediaScene(state.ctx, state.w, state.h, t, pal, asset, mediaRef.current, warmth);
|
|
972
|
+
} catch (_error) {
|
|
973
|
+
drewMedia = false;
|
|
974
|
+
}
|
|
975
|
+
if (drewMedia) return;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
try {
|
|
979
|
+
renderScene(state.ctx, state.w, state.h, t, pal, tideNormalized(), warmth, state.field);
|
|
980
|
+
} catch (_error) {
|
|
981
|
+
// 绘制异常不外泄:永久退回 CSS 渐变,海还在(只是不再是 canvas 的)
|
|
982
|
+
state.ctx = null;
|
|
983
|
+
try {
|
|
984
|
+
canvas.style.display = 'none';
|
|
985
|
+
} catch (_ignored) {
|
|
986
|
+
/* ignore */
|
|
987
|
+
}
|
|
988
|
+
paintFallback(pal, ms);
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
/* ---------- fps 统计与降级(只降不升) ---------- */
|
|
993
|
+
|
|
994
|
+
const reportStats = () => {
|
|
995
|
+
const callback = statsRef.current;
|
|
996
|
+
if (typeof callback !== 'function') return;
|
|
997
|
+
try {
|
|
998
|
+
callback({ fps: state.fps, downgraded: state.degraded });
|
|
999
|
+
} catch (_error) {
|
|
1000
|
+
/* 回调自己抛错不许影响渲染 */
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* 帧率结算:每秒一个桶,评估**最近 3 秒**(滑动窗口)。
|
|
1006
|
+
* 连续 3 秒平均 < 20fps → 降级 15fps,只降不升。
|
|
1007
|
+
* 每秒都通过 onStats 汇报一次,UI 能拿到 fps 与是否已降级。
|
|
1008
|
+
*/
|
|
1009
|
+
const trackFps = (ms, targetFps) => {
|
|
1010
|
+
const second = Math.floor(ms / 1000);
|
|
1011
|
+
if (second !== state.curSecond) {
|
|
1012
|
+
if (state.curSecond >= 0) {
|
|
1013
|
+
state.seconds.push(state.curCount);
|
|
1014
|
+
while (state.seconds.length > 3) state.seconds.shift();
|
|
1015
|
+
}
|
|
1016
|
+
state.curSecond = second;
|
|
1017
|
+
state.curCount = 0;
|
|
1018
|
+
const n = state.seconds.length;
|
|
1019
|
+
if (n > 0) {
|
|
1020
|
+
let sum = 0;
|
|
1021
|
+
for (const count of state.seconds) sum += count;
|
|
1022
|
+
const average = sum / n;
|
|
1023
|
+
state.fps = Math.round(average * 10) / 10;
|
|
1024
|
+
// 只有本来就奔着 30fps 跑的时候才判定「太慢」,否则 pip 的 15fps 会被误降
|
|
1025
|
+
if (n >= 3 && !state.degraded && targetFps === TUNING.targetFps && average < TUNING.slowFps) {
|
|
1026
|
+
state.degraded = true; // 只降不升
|
|
1027
|
+
state.lastDrawMs = 0;
|
|
1028
|
+
}
|
|
1029
|
+
reportStats();
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
state.curCount += 1;
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
/* ---------- rAF 循环 ---------- */
|
|
1036
|
+
|
|
1037
|
+
const stopRaf = () => {
|
|
1038
|
+
if (state.raf) {
|
|
1039
|
+
try {
|
|
1040
|
+
cancelFrame(state.raf);
|
|
1041
|
+
} catch (_error) {
|
|
1042
|
+
/* ignore */
|
|
1043
|
+
}
|
|
1044
|
+
state.raf = 0;
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
const clearStatic = () => {
|
|
1049
|
+
if (state.staticTimer) {
|
|
1050
|
+
clearTimeout(state.staticTimer);
|
|
1051
|
+
state.staticTimer = 0;
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
function frame(ms) {
|
|
1056
|
+
state.raf = 0;
|
|
1057
|
+
if (!state.alive) return;
|
|
1058
|
+
const target = state.degraded ? TUNING.degradedFps : formRef.current === 'pip' ? TUNING.pipFps : TUNING.targetFps;
|
|
1059
|
+
const interval = 1000 / target;
|
|
1060
|
+
const gate = state.ctx ? interval : 900; // 无 canvas 的 CSS 海 1Hz 足够
|
|
1061
|
+
if (state.lastDrawMs && ms - state.lastDrawMs < gate - 0.5) {
|
|
1062
|
+
scheduleFrame();
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
state.lastDrawMs = ms;
|
|
1066
|
+
draw(ms);
|
|
1067
|
+
if (state.ctx) trackFps(ms, target);
|
|
1068
|
+
// devicePixelRatio 变了(换屏 / 缩放)也走一次防抖重建
|
|
1069
|
+
if (num(typeof window !== 'undefined' ? window.devicePixelRatio : 1, 1) !== state.dpr) scheduleResize();
|
|
1070
|
+
scheduleFrame();
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function scheduleFrame() {
|
|
1074
|
+
if (!state.alive || state.raf) return;
|
|
1075
|
+
state.raf = requestFrame(frame);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/* ---------- reducedMotion:只画一帧,之后每 60 秒换一帧 ---------- */
|
|
1079
|
+
|
|
1080
|
+
function scheduleStatic() {
|
|
1081
|
+
clearStatic();
|
|
1082
|
+
state.staticTimer = setTimeout(() => {
|
|
1083
|
+
state.staticTimer = 0;
|
|
1084
|
+
if (!state.alive || state.mode !== 'static') return;
|
|
1085
|
+
state.staticStep += 1;
|
|
1086
|
+
draw(perfNow());
|
|
1087
|
+
scheduleStatic();
|
|
1088
|
+
}, TUNING.reducedRefreshMs);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/* ---------- 尺寸:缓冲上限 1280,resize 防抖 200ms ---------- */
|
|
1092
|
+
|
|
1093
|
+
function measureNow() {
|
|
1094
|
+
if (!state.alive) return;
|
|
1095
|
+
let rect = { width: 0, height: 0 };
|
|
1096
|
+
try {
|
|
1097
|
+
rect = host.getBoundingClientRect();
|
|
1098
|
+
} catch (_error) {
|
|
1099
|
+
rect = { width: 0, height: 0 };
|
|
1100
|
+
}
|
|
1101
|
+
const dpr = clamp(num(typeof window !== 'undefined' ? window.devicePixelRatio : 1, 1), 1, 3);
|
|
1102
|
+
const cssWidth = Math.max(64, Math.round(num(rect.width, 0) || host.clientWidth || 640));
|
|
1103
|
+
const cssHeight = Math.max(64, Math.round(num(rect.height, 0) || host.clientHeight || 360));
|
|
1104
|
+
const rawWidth = cssWidth * dpr;
|
|
1105
|
+
const width = Math.max(160, Math.min(bufferCap(), Math.round(rawWidth)));
|
|
1106
|
+
const height = Math.max(90, Math.round(cssHeight * dpr * (width / rawWidth)));
|
|
1107
|
+
state.dpr = dpr;
|
|
1108
|
+
if (width === state.w && height === state.h) return;
|
|
1109
|
+
state.w = width;
|
|
1110
|
+
state.h = height;
|
|
1111
|
+
if (state.ctx) {
|
|
1112
|
+
try {
|
|
1113
|
+
canvas.width = width; // 改背衬尺寸会顺带清空并重置 ctx 状态
|
|
1114
|
+
canvas.height = height;
|
|
1115
|
+
} catch (_error) {
|
|
1116
|
+
state.ctx = null;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
state.lastDrawMs = 0;
|
|
1120
|
+
if (state.mode === 'static') draw(perfNow());
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function scheduleResize() {
|
|
1124
|
+
if (!state.alive) return;
|
|
1125
|
+
if (state.resizeTimer) clearTimeout(state.resizeTimer);
|
|
1126
|
+
state.resizeTimer = setTimeout(() => {
|
|
1127
|
+
state.resizeTimer = 0;
|
|
1128
|
+
measureNow();
|
|
1129
|
+
}, TUNING.resizeDebounceMs);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/* ---------- 模式机:anim / static / off ---------- */
|
|
1133
|
+
|
|
1134
|
+
function applyMode() {
|
|
1135
|
+
if (!state.alive) return;
|
|
1136
|
+
const invisible = pausedRef.current || state.hidden || (TUNING.stopOnBlur && state.blurred);
|
|
1137
|
+
const want = invisible ? 'off' : reducedRef.current ? 'static' : 'anim';
|
|
1138
|
+
if (want === state.mode) return;
|
|
1139
|
+
const previous = state.mode;
|
|
1140
|
+
state.mode = want;
|
|
1141
|
+
|
|
1142
|
+
if (want === 'off') {
|
|
1143
|
+
stopRaf(); // 完全停帧:不在后台偷偷跑 rAF
|
|
1144
|
+
clearStatic();
|
|
1145
|
+
setVideoPlaying(false); // 视频也一起停,别在后台解码
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// 从停帧里回来:把时钟锚点归零 → 下一帧 delta=0,从 performance.now()
|
|
1150
|
+
// 续上而不跳变(海不会被“补算”停掉的那段时间)
|
|
1151
|
+
if (previous === 'off') state.clockAnchor = 0;
|
|
1152
|
+
|
|
1153
|
+
if (want === 'anim') {
|
|
1154
|
+
clearStatic();
|
|
1155
|
+
// 帧率窗口不跨「停帧」结算,否则回来那一秒会被误判成掉帧
|
|
1156
|
+
state.curSecond = -1;
|
|
1157
|
+
state.curCount = 0;
|
|
1158
|
+
state.seconds = [];
|
|
1159
|
+
state.lastDrawMs = 0;
|
|
1160
|
+
setVideoPlaying(true);
|
|
1161
|
+
scheduleFrame();
|
|
1162
|
+
} else {
|
|
1163
|
+
// static:只画一帧静态海,之后每 60 秒换一帧(仅色温微变)
|
|
1164
|
+
stopRaf();
|
|
1165
|
+
setVideoPlaying(false);
|
|
1166
|
+
draw(perfNow());
|
|
1167
|
+
scheduleStatic();
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/* ---------- 监听 ---------- */
|
|
1172
|
+
|
|
1173
|
+
function onVisibilityChange() {
|
|
1174
|
+
state.hidden = typeof document !== 'undefined' && document.hidden === true;
|
|
1175
|
+
applyMode();
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const onResize = () => scheduleResize();
|
|
1179
|
+
|
|
1180
|
+
// spec §4.3 还提到「窗口失焦即停帧」。默认不接(TUNING.stopOnBlur=false):
|
|
1181
|
+
// 开 DevTools 会让页面 blur,验收会把它误判成卡死。要接就把它置 true。
|
|
1182
|
+
function onWindowBlur() {
|
|
1183
|
+
state.blurred = true;
|
|
1184
|
+
if (TUNING.stopOnBlur) applyMode();
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function onWindowFocus() {
|
|
1188
|
+
state.blurred = false;
|
|
1189
|
+
if (TUNING.stopOnBlur) applyMode();
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
let resizeObserver = null;
|
|
1193
|
+
try {
|
|
1194
|
+
if (typeof ResizeObserver === 'function') {
|
|
1195
|
+
resizeObserver = new ResizeObserver(() => scheduleResize());
|
|
1196
|
+
resizeObserver.observe(host);
|
|
1197
|
+
}
|
|
1198
|
+
} catch (_error) {
|
|
1199
|
+
resizeObserver = null;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
try {
|
|
1203
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibilityChange);
|
|
1204
|
+
if (typeof window !== 'undefined') {
|
|
1205
|
+
window.addEventListener('resize', onResize);
|
|
1206
|
+
window.addEventListener('blur', onWindowBlur);
|
|
1207
|
+
window.addEventListener('focus', onWindowFocus);
|
|
1208
|
+
}
|
|
1209
|
+
} catch (_error) {
|
|
1210
|
+
/* ignore */
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// 对外暴露给 props 副作用用的小接口
|
|
1214
|
+
apiRef.current = {
|
|
1215
|
+
sync: applyMode,
|
|
1216
|
+
refreshNow: () => {
|
|
1217
|
+
if (state.mode === 'static') draw(perfNow());
|
|
1218
|
+
},
|
|
1219
|
+
setMedia: (next) => {
|
|
1220
|
+
const kind = next && typeof next === 'object' ? (next.kind === 'video' ? 'video' : next.kind === 'image' ? 'image' : '') : '';
|
|
1221
|
+
const url = next && typeof next.url === 'string' ? next.url.trim() : '';
|
|
1222
|
+
const key = kind && url ? kind + '|' + url : '';
|
|
1223
|
+
if (key === asset.key) return; // 同一张素材:grade/motion 实时读,不必重载
|
|
1224
|
+
loadAsset(next);
|
|
1225
|
+
},
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
// 首帧:先接住初始素材 → 量尺寸 → 起模式
|
|
1229
|
+
apiRef.current.setMedia(mediaRef.current);
|
|
1230
|
+
measureNow();
|
|
1231
|
+
applyMode();
|
|
1232
|
+
|
|
1233
|
+
return () => {
|
|
1234
|
+
state.alive = false;
|
|
1235
|
+
stopRaf();
|
|
1236
|
+
clearStatic();
|
|
1237
|
+
if (state.resizeTimer) {
|
|
1238
|
+
clearTimeout(state.resizeTimer);
|
|
1239
|
+
state.resizeTimer = 0;
|
|
1240
|
+
}
|
|
1241
|
+
try {
|
|
1242
|
+
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
1243
|
+
if (typeof window !== 'undefined') {
|
|
1244
|
+
window.removeEventListener('resize', onResize);
|
|
1245
|
+
window.removeEventListener('blur', onWindowBlur);
|
|
1246
|
+
window.removeEventListener('focus', onWindowFocus);
|
|
1247
|
+
}
|
|
1248
|
+
} catch (_error) {
|
|
1249
|
+
/* ignore */
|
|
1250
|
+
}
|
|
1251
|
+
try {
|
|
1252
|
+
if (resizeObserver && typeof resizeObserver.disconnect === 'function') resizeObserver.disconnect();
|
|
1253
|
+
} catch (_error) {
|
|
1254
|
+
/* ignore */
|
|
1255
|
+
}
|
|
1256
|
+
resizeObserver = null;
|
|
1257
|
+
teardownAsset();
|
|
1258
|
+
apiRef.current = null;
|
|
1259
|
+
// 释放离屏缓冲:背衬清零,GPU/内存立刻回收
|
|
1260
|
+
try {
|
|
1261
|
+
canvas.width = 0;
|
|
1262
|
+
canvas.height = 0;
|
|
1263
|
+
} catch (_error) {
|
|
1264
|
+
/* ignore */
|
|
1265
|
+
}
|
|
1266
|
+
try {
|
|
1267
|
+
host.style.background = '';
|
|
1268
|
+
host.style.filter = '';
|
|
1269
|
+
} catch (_error) {
|
|
1270
|
+
/* ignore */
|
|
1271
|
+
}
|
|
1272
|
+
};
|
|
1273
|
+
}, []);
|
|
1274
|
+
|
|
1275
|
+
return (
|
|
1276
|
+
<div ref={hostRef} style={HOST_STYLE} aria-hidden="true">
|
|
1277
|
+
<canvas ref={canvasRef} style={CANVAS_STYLE} />
|
|
1278
|
+
</div>
|
|
1279
|
+
);
|
|
1280
|
+
}
|