dsh-intertidal 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,217 @@
1
+ // 潮间 —— 客户端样式。单文件注入:<style data-plugin-css="dsh-intertidal">,卸载时整段移除。
2
+ //
3
+ // z-index 是 spec §11 的 R1:覆盖层要高过内容区,但要低过审批弹窗/命令面板。这里用 900
4
+ // 这个保守值(内容区通常 < 100,模态层通常 ≥ 1000),并配合 rhythm.js 的"有对话框就推迟"
5
+ // 双重保险。真机上若发现层级打架,只改这一个变量。
6
+
7
+ export const STYLE = `
8
+ :root { --intertidal-z: 900; }
9
+
10
+ .it-root {
11
+ position: fixed;
12
+ z-index: var(--intertidal-z);
13
+ background: #0a1420;
14
+ color: #f5f7fa;
15
+ transition: opacity 800ms ease, transform 400ms cubic-bezier(.2,.7,.3,1), border-radius 400ms ease;
16
+ will-change: opacity, transform;
17
+ overflow: hidden;
18
+ font: 400 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", system-ui, sans-serif;
19
+ }
20
+ .it-root[data-form="full"] {
21
+ inset: 0;
22
+ border-radius: 0;
23
+ opacity: 1;
24
+ }
25
+ .it-root[data-form="pip"] {
26
+ right: 24px; bottom: 24px; left: auto; top: auto;
27
+ width: min(38vw, 640px);
28
+ aspect-ratio: 16 / 9;
29
+ border-radius: 14px;
30
+ box-shadow: 0 18px 48px rgba(0, 0, 0, .38), 0 1px 0 rgba(255, 255, 255, .08) inset;
31
+ transform: none;
32
+ opacity: 1;
33
+ cursor: grab;
34
+ }
35
+ .it-root[data-dragging="1"] { cursor: grabbing; }
36
+ .it-root[data-exit="1"] { opacity: 0; }
37
+ .it-root[data-exit="pip"] { transform: translateY(8px) scale(.985); }
38
+
39
+ .it-canvas-holder { position: absolute; inset: 0; }
40
+ .it-canvas-holder canvas { display: block; width: 100%; height: 100%; }
41
+ .it-fallback {
42
+ position: absolute; inset: 0;
43
+ background: linear-gradient(180deg, #7ea7c9 0%, #bcd3e2 52%, #2a5d80 52.2%, #10334d 100%);
44
+ animation: it-breathe 16s ease-in-out infinite alternate;
45
+ }
46
+ @keyframes it-breathe { from { filter: brightness(.96) } to { filter: brightness(1.04) } }
47
+
48
+ .it-caption {
49
+ position: absolute; left: 22px; bottom: 20px;
50
+ font-size: 11px; letter-spacing: .04em;
51
+ color: #fff; opacity: .45;
52
+ text-shadow: 0 1px 2px rgba(0, 0, 0, .35);
53
+ pointer-events: none;
54
+ user-select: none;
55
+ }
56
+ .it-root[data-form="pip"] .it-caption { left: 12px; bottom: 10px; font-size: 10px; }
57
+
58
+ .it-controls {
59
+ position: absolute; top: 14px; right: 14px;
60
+ display: flex; align-items: center; gap: 8px;
61
+ }
62
+ .it-root[data-form="pip"] .it-controls { top: 8px; right: 8px; }
63
+
64
+ /* 按钮组:悬停/聚焦才出现,别在沉浸时碍眼 */
65
+ .it-btnbar {
66
+ display: flex; gap: 6px;
67
+ opacity: 0; transition: opacity 220ms ease;
68
+ }
69
+ .it-root:hover .it-btnbar, .it-root:focus-within .it-btnbar { opacity: 1; }
70
+
71
+ /* 倒计时:一直可见,等宽数字,不抢画面 */
72
+ .it-countdown {
73
+ display: inline-flex; align-items: center; justify-content: center;
74
+ height: 30px; min-width: 52px; padding: 0 11px;
75
+ font-size: 12px; line-height: 1;
76
+ font-variant-numeric: tabular-nums;
77
+ letter-spacing: .02em;
78
+ border-radius: 15px;
79
+ color: rgba(255, 255, 255, .82);
80
+ background: rgba(10, 18, 28, .32);
81
+ border: 1px solid rgba(255, 255, 255, .14);
82
+ backdrop-filter: blur(8px);
83
+ text-shadow: 0 1px 2px rgba(0, 0, 0, .35);
84
+ user-select: none; pointer-events: none;
85
+ transition: opacity 400ms ease;
86
+ }
87
+ .it-countdown[data-paused="1"] { opacity: .5; }
88
+ .it-root[data-form="pip"] .it-countdown { height: 24px; min-width: 44px; padding: 0 8px; font-size: 11px; border-radius: 12px; }
89
+
90
+ .it-btn {
91
+ -webkit-appearance: none; appearance: none;
92
+ height: 30px; min-width: 30px; padding: 0 10px;
93
+ border-radius: 15px;
94
+ border: 1px solid rgba(255, 255, 255, .22);
95
+ background: rgba(12, 22, 34, .38);
96
+ color: rgba(255, 255, 255, .86);
97
+ font: inherit; font-size: 12px; line-height: 1;
98
+ cursor: pointer;
99
+ backdrop-filter: blur(8px);
100
+ transition: background 160ms ease, border-color 160ms ease;
101
+ }
102
+ .it-btn:hover { background: rgba(12, 22, 34, .62); border-color: rgba(255, 255, 255, .4); }
103
+ .it-btn:focus-visible { outline: 2px solid rgba(255, 255, 255, .8); outline-offset: 2px; }
104
+ .it-btn[data-icon="1"] { width: 30px; padding: 0; font-size: 14px; }
105
+
106
+ .it-toast {
107
+ position: absolute; left: 50%; bottom: 26px; transform: translateX(-50%);
108
+ padding: 6px 12px; border-radius: 12px;
109
+ background: rgba(10, 18, 28, .62);
110
+ font-size: 12px; color: rgba(255, 255, 255, .9);
111
+ pointer-events: none;
112
+ }
113
+
114
+ .it-error {
115
+ position: absolute; inset: 0;
116
+ display: flex; align-items: center; justify-content: center;
117
+ gap: 12px;
118
+ background: linear-gradient(180deg, #12202e, #0a1420);
119
+ font-size: 13px; color: rgba(255, 255, 255, .78);
120
+ }
121
+
122
+ /* —— 设置页 ——
123
+ 布局要点:DSH 的设置面板宽度不定(可能只有 500px),所以每一行都必须能换行:
124
+ 标签列给一个 220px 的 flex-basis(不够就整体折到下一行),控件列自己也能折行。
125
+ 早先标签列没有 flex-basis、控件是 flex:none,结果标签被挤成"一行一个词"。 */
126
+ .it-settings { display: flex; flex-direction: column; gap: 26px; max-width: 100%; }
127
+ .it-group { display: flex; flex-direction: column; gap: 4px; }
128
+ .it-group > h4 {
129
+ margin: 0 0 6px; font-size: 12px; font-weight: 600; letter-spacing: .06em;
130
+ text-transform: uppercase; opacity: .55;
131
+ }
132
+ .it-row {
133
+ display: flex; align-items: flex-start; justify-content: space-between;
134
+ gap: 8px 18px; flex-wrap: wrap;
135
+ padding: 9px 0; border-bottom: 1px solid rgba(127, 137, 150, .14);
136
+ }
137
+ .it-row:last-child { border-bottom: none; }
138
+ .it-row-label {
139
+ display: flex; flex-direction: column; gap: 3px;
140
+ flex: 1 1 220px; min-width: 0; padding-top: 4px;
141
+ }
142
+ .it-row-label > span { line-height: 1.45; }
143
+ .it-row-label small { opacity: .55; font-size: 11px; line-height: 1.5; }
144
+ .it-row-control {
145
+ display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
146
+ flex: 0 1 auto; justify-content: flex-end; max-width: 100%;
147
+ }
148
+ .it-row-note { flex: 1 1 100%; }
149
+ /* 一行里有多个控件(输入框 + 按钮 + 按钮)时,把控件块整体折到标签下面并左对齐,
150
+ 否则窄面板里会出现"输入框在左、按钮飘到下一行右端"的散架感。 */
151
+ .it-row[data-stack="1"] .it-row-control {
152
+ flex: 1 1 100%; justify-content: flex-start; align-items: stretch;
153
+ }
154
+ .it-row[data-stack="1"] .it-row-control .it-field { flex: 1 1 220px; }
155
+ .it-row[data-stack="1"] .it-row-control .it-action { flex: 0 0 auto; }
156
+ .it-settings select,
157
+ .it-settings input[type="text"],
158
+ .it-settings input[type="number"],
159
+ .it-settings input[type="time"],
160
+ .it-settings textarea {
161
+ height: 30px; padding: 0 10px;
162
+ border-radius: 7px;
163
+ border: 1px solid rgba(127, 137, 150, .34);
164
+ background: transparent; color: inherit;
165
+ font: inherit; font-size: 12px;
166
+ max-width: 100%;
167
+ }
168
+ .it-settings textarea {
169
+ height: auto; min-height: 62px; padding: 7px 10px;
170
+ line-height: 1.5; resize: vertical;
171
+ flex: 1 1 260px; min-width: 200px;
172
+ }
173
+ .it-field { flex: 1 1 200px; min-width: 160px; }
174
+ .it-field-sm { flex: 0 1 96px; min-width: 84px; }
175
+ .it-settings input[type="range"] { width: 140px; max-width: 100%; }
176
+ .it-settings select { min-width: 132px; }
177
+ .it-switch {
178
+ position: relative; width: 38px; height: 22px; flex: none;
179
+ border-radius: 11px; border: none; padding: 0;
180
+ background: rgba(127, 137, 150, .38);
181
+ cursor: pointer; transition: background 160ms ease;
182
+ }
183
+ .it-switch[aria-checked="true"] { background: #2f7d6b; }
184
+ .it-switch::after {
185
+ content: ""; position: absolute; top: 3px; left: 3px;
186
+ width: 16px; height: 16px; border-radius: 50%;
187
+ background: #fff; transition: transform 160ms ease;
188
+ }
189
+ .it-switch[aria-checked="true"]::after { transform: translateX(16px); }
190
+ .it-actions { display: flex; flex-wrap: wrap; gap: 8px; }
191
+ .it-action {
192
+ height: 30px; padding: 0 14px; border-radius: 8px;
193
+ border: 1px solid rgba(127, 137, 150, .34);
194
+ background: transparent; color: inherit; font: inherit; font-size: 12px;
195
+ cursor: pointer;
196
+ }
197
+ .it-action:hover { border-color: rgba(127, 137, 150, .6); }
198
+ .it-action[data-primary="1"] { background: #2f7d6b; border-color: #2f7d6b; color: #fff; }
199
+ .it-hint { font-size: 11px; opacity: .55; }
200
+ .it-warn { font-size: 11px; color: #c2823a; }
201
+ `
202
+
203
+ const STYLE_ID = 'dsh-intertidal'
204
+ const STYLE_ATTR = 'data-plugin-css'
205
+
206
+ /** 注入样式;返回卸载函数。重复调用是幂等的。 */
207
+ export function injectStyles(doc = globalThis.document) {
208
+ if (!doc) return () => {}
209
+ for (const node of doc.querySelectorAll(`style[${STYLE_ATTR}="${STYLE_ID}"]`)) node.remove()
210
+ const style = doc.createElement('style')
211
+ style.setAttribute(STYLE_ATTR, STYLE_ID)
212
+ style.textContent = STYLE
213
+ doc.head.appendChild(style)
214
+ return () => {
215
+ style.remove()
216
+ }
217
+ }
@@ -0,0 +1,330 @@
1
+ // 潮间 · 程序化水面的新画法:**斑块场**,不是一排排浪脊。
2
+ //
3
+ // 用户反馈(第三次):海还是一"条一条"的,真实的海是"一块一块"、随机分布的。
4
+ // 根因不是调参能解决的:只要按"横向贯通的浪前"来画,无论怎么加噪声,屏幕上都会出现
5
+ // 可数的水平带子——那是画法本身,不是细节不够。
6
+ //
7
+ // 这里的做法换了:
8
+ // 1. 在**低分辨率的高度场**上按透视采样 3 个八度的值噪声(ridged + smooth 两种),
9
+ // 每帧只算 ~4-6 万像素,然后整块放大到屏幕(双线性,天然柔化)——
10
+ // 得到的是随机分布、大小随远近变化的"水面斑块",没有贯通全屏的条纹;
11
+ // 2. 高度场的斜率决定哪里被太阳照亮(镜面碎光),所以亮点是**跟着斑块走向散落**的;
12
+ // 3. 靠近观察者(画面下方)的斑块上叠加白色浪沫,浪沫是斑块状的,不是一条线;
13
+ // 4. 地平线一条雾带把水天接缝抹掉。
14
+ //
15
+ // 这个文件不碰 React、不碰生命周期:`createWaterField()` 造缓冲,`drawWater()` 画一帧,
16
+ // 调用方(sea-canvas)决定什么时候调用、什么时候停帧。
17
+
18
+ const TAU = Math.PI * 2
19
+
20
+ /** 一帧高度场的内部缓冲宽度(像素)。高度按画面比例算,上限见 MAX_BUFFER_H。 */
21
+ const BUFFER_W = 430
22
+ const MAX_BUFFER_H = 260
23
+ const MIN_BUFFER_H = 40
24
+
25
+ /* ------------------------------ 噪声 ------------------------------ */
26
+
27
+ /** 32 位整数哈希 → [0,1)。不用 Math.random,保证同一 t 的结果可复现。 */
28
+ function hash2(x, y) {
29
+ let h = Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263)
30
+ h = Math.imul(h ^ (h >>> 13), 1274126177)
31
+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296
32
+ }
33
+
34
+ /** 值噪声(smoothstep 插值)。 */
35
+ function vnoise(x, y) {
36
+ const xi = Math.floor(x)
37
+ const yi = Math.floor(y)
38
+ const xf = x - xi
39
+ const yf = y - yi
40
+ const u = xf * xf * (3 - 2 * xf)
41
+ const v = yf * yf * (3 - 2 * yf)
42
+ const a = hash2(xi, yi)
43
+ const b = hash2(xi + 1, yi)
44
+ const c = hash2(xi, yi + 1)
45
+ const d = hash2(xi + 1, yi + 1)
46
+ return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v
47
+ }
48
+
49
+ /** 3 个八度的平滑噪声(给水色用)。 */
50
+ function fbmSmooth(x, y) {
51
+ return (
52
+ 0.55 * vnoise(x, y) +
53
+ 0.3 * vnoise(x * 2.07 + 3.1, y * 2.07 + 1.7) +
54
+ 0.15 * vnoise(x * 4.31 + 7.7, y * 4.31 + 3.3)
55
+ )
56
+ }
57
+
58
+ /** ridged 噪声(给浪脊/浪沫用):把噪声折叠成尖锐的脊,4 个八度保证近处有细碎浪。 */
59
+ function fbmRidged(x, y) {
60
+ let sum = 0
61
+ let amp = 0.6
62
+ let freq = 1
63
+ let total = 0
64
+ for (let o = 0; o < 4; o += 1) {
65
+ const n = vnoise(x * freq + o * 13.7, y * freq + o * 7.3)
66
+ sum += amp * (1 - Math.abs(2 * n - 1))
67
+ total += amp
68
+ amp *= 0.52
69
+ freq *= 2.17
70
+ }
71
+ return sum / total
72
+ }
73
+
74
+ /* ------------------------------ 颜色 ------------------------------ */
75
+
76
+ function hexToRgb(hex) {
77
+ const text = String(hex ?? '').replace('#', '')
78
+ if (text.length !== 6) return [0, 0, 0]
79
+ return [parseInt(text.slice(0, 2), 16), parseInt(text.slice(2, 4), 16), parseInt(text.slice(4, 6), 16)]
80
+ }
81
+
82
+ const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v)
83
+ const lerp = (a, b, t) => a + (b - a) * t
84
+
85
+ /* ------------------------------ 场 ------------------------------ */
86
+
87
+ /**
88
+ * 造一个高度场缓冲。`drawWater()` 内部会按需重建。
89
+ * @returns {{ width:number, height:number, canvas:any, ctx:any, image:any, h:Float32Array }}
90
+ */
91
+ export function createWaterField(doc = globalThis.document) {
92
+ const canvas = doc?.createElement?.('canvas') ?? null
93
+ return { width: 0, height: 0, canvas, ctx: canvas?.getContext?.('2d') ?? null, image: null, h: null }
94
+ }
95
+
96
+ function ensureSize(field, width, height) {
97
+ if (field.width === width && field.height === height && field.image) return true
98
+ field.width = width
99
+ field.height = height
100
+ field.h = new Float32Array(width * height)
101
+ if (!field.canvas || !field.ctx) return false
102
+ field.canvas.width = width
103
+ field.canvas.height = height
104
+ field.image = field.ctx.createImageData(width, height)
105
+ return true
106
+ }
107
+
108
+ /**
109
+ * 画一帧水面。调用方负责 `接缝`(地平线以上不动)。
110
+ *
111
+ * @param {CanvasRenderingContext2D} ctx 全分辨率上下文(已经 setTransform 成 W×H)
112
+ * @param {{W:number,H:number,horizonY:number,t:number,pal:any,tideN?:number}} view
113
+ * @param {ReturnType<typeof createWaterField>} field
114
+ * @returns {boolean} 画成功返回 true
115
+ */
116
+ export function drawWater(ctx, view, field) {
117
+ const { W, H, horizonY, t } = view
118
+ const pal = view.pal ?? {}
119
+ const tideN = Number.isFinite(view.tideN) ? view.tideN : 0
120
+ const waterH = Math.max(1, H - horizonY)
121
+
122
+ const bw = BUFFER_W
123
+ const bh = Math.max(MIN_BUFFER_H, Math.min(MAX_BUFFER_H, Math.round((bw * waterH) / Math.max(1, W))))
124
+ if (!ensureSize(field, bw, bh)) return false
125
+
126
+ const exposure = clamp01(Number.isFinite(pal.exposure) ? pal.exposure : 0.8)
127
+ const deep = hexToRgb(pal.seaNear ?? '#0d1729')
128
+ const mid = hexToRgb(pal.seaMid ?? '#27728e')
129
+ const far = hexToRgb(pal.seaFar ?? '#4793ae')
130
+ const sky = hexToRgb(pal.skyBottom ?? '#b7e1f6')
131
+ const foam = hexToRgb(pal.foam ?? '#ffffff')
132
+ const glow = hexToRgb(pal.horizonGlow ?? '#ffd5a8')
133
+
134
+ // 太阳/月亮在画面里的横向位置(0..1);没有天体就用正午的默认方向,避免碎光整片消失。
135
+ const body = pal.sun || pal.moon || null
136
+ const bodyX = body ? clamp01(body.x) : 0.5
137
+ const bodyColor = hexToRgb(body?.color ?? pal.horizonGlow ?? '#fff6e2')
138
+ const bodyAlpha = body ? clamp01(body.alpha ?? 0.8) : 0.35
139
+
140
+ const heights = field.h
141
+ // 透视:屏幕上越靠下 = 世界坐标里离观察者越近。用 zc = 1/(a+b·d) 做距离映射,
142
+ // 横向再用 zc 放大 —— 斑块在世界空间里各向同性,屏幕上自然近大远小。
143
+ //
144
+ // v0.1.3:在"随机斑块"之上加一层**涌浪层次**。纯噪声看起来是平的、没有远近层次;
145
+ // 真实的海能看出"一道一道的涌浪由远及近"。做法是把高度场乘上一个沿深度推进的相位带:
146
+ // phase = d·LAYERS - t·SPEED(t 增大 → 同一道浪的 d 增大 → 屏幕上往下走 = 朝观察者推过来)
147
+ // 再用低频噪声扭曲相位,使浪脊不是直线、而是被随机打断的弧形 —— 既有层次,又不是"一条一条"。
148
+ // 速度取 0.115 道/秒:一道涌浪从地平线推到脚下大约 8–9 秒,慢到能看清,不像快进。
149
+ const LAYERS = 5.0 // 画面里同时能看到的涌浪道数(越少:浪越大、越有起伏)
150
+ const SPEED = 0.22 // 每秒推进多少"道":一道浪从地平线到脚下约 27 秒,每 ~4.5 秒来一道新的
151
+ const scroll = t * 0.12 // 细碎纹理自身的极慢漂移
152
+ const XSPREAD = 6.2
153
+ const WFREQ = 5.4
154
+ const XFREQ = 1.45
155
+
156
+ /* ---- 第一遍:高度场 ---- */
157
+ let idx = 0
158
+ for (let j = 0; j < bh; j += 1) {
159
+ const d = (j + 0.5) / bh
160
+ const zc = 1 / (0.38 + d * 2.4)
161
+ const wz = zc - scroll
162
+ const phaseBase = d * LAYERS - t * SPEED
163
+ // 涌浪振幅随深度变大(近处高),远处收得很小,免得地平线附近糊成一片
164
+ const swellAmp = 0.30 + 0.52 * d
165
+ for (let i = 0; i < bw; i += 1) {
166
+ const wx = ((i + 0.5) / bw - 0.5) * zc * XSPREAD
167
+ // 相位扭曲:让浪脊是弧形/被绊住,而不是一条笔直的横线
168
+ const wobble = (vnoise(wx * 0.85 + 9.7, wz * 0.55 + 3.3) - 0.5) * 0.62
169
+ // 主涌浪:sin 再做一个"上尖下缓"的形变(真实海浪的波峰比波谷窄、陡)
170
+ const s0 = Math.sin((phaseBase + wobble) * TAU)
171
+ const swell = (s0 < 0 ? -1 : 1) * Math.pow(Math.abs(s0), 1.55)
172
+ // 第二道涌浪:道数、速度、相位都与主涌浪不同 —— 两者叠加才会互相"打断",
173
+ // 避免出现等间距的规则浪列(这是"一眼假"的主要来源之一)
174
+ const s1 = Math.sin((d * 3.1 - t * 0.135 + wobble * 1.7 + 2.3) * TAU)
175
+ const swell2 = (s1 < 0 ? -1 : 1) * Math.pow(Math.abs(s1), 1.4)
176
+ const ridge = fbmRidged(wx * XFREQ + 3.0, wz * WFREQ)
177
+ // 近处再叠一层更高频的碎浪(远处的会被透视压成糊,所以按深度加权)
178
+ const chop = fbmRidged(wx * XFREQ * 2.6 + 17.3, wz * WFREQ * 1.5 + 8.9)
179
+ const detail = fbmSmooth(wx * XFREQ * 1.9 + 11.3, wz * WFREQ * 0.72 + 5.1)
180
+ const h =
181
+ 0.5 +
182
+ 0.5 * swell * swellAmp +
183
+ 0.5 * swell2 * swellAmp * 0.42 +
184
+ (ridge - 0.5) * 0.54 +
185
+ (chop - 0.5) * 0.44 * d +
186
+ (detail - 0.5) * 0.22
187
+ heights[idx] = h < 0 ? 0 : h > 1 ? 1 : h
188
+ idx += 1
189
+ }
190
+ }
191
+
192
+ /* ---- 第二遍:上色(高度场邻居算斜率 → 明暗与碎光) ---- */
193
+ const data = field.image.data
194
+ let p = 0
195
+ for (let j = 0; j < bh; j += 1) {
196
+ const d = (j + 0.5) / bh
197
+ const haze = (1 - d) * (1 - d) * (0.55 + 0.35 * exposure)
198
+ // 远处偏天空色、近处偏深水
199
+ const nearness = clamp01(d * 1.6)
200
+ const rBase = lerp(lerp(far[0], mid[0], nearness), deep[0], Math.max(0, d - 0.35) / 0.65)
201
+ const gBase = lerp(lerp(far[1], mid[1], nearness), deep[1], Math.max(0, d - 0.35) / 0.65)
202
+ const bBase = lerp(lerp(far[2], mid[2], nearness), deep[2], Math.max(0, d - 0.35) / 0.65)
203
+ const foamBand = clamp01((d - 0.62) / 0.38) * clamp01(0.45 + 0.45 * tideN)
204
+ const rowLight = 0.5 + 0.5 * exposure
205
+
206
+ for (let i = 0; i < bw; i += 1) {
207
+ const h = heights[j * bw + i]
208
+ const hx = (heights[j * bw + Math.min(bw - 1, i + 1)] - heights[j * bw + Math.max(0, i - 1)]) * 0.5
209
+ const hz = (heights[Math.min(bh - 1, j + 1) * bw + i] - heights[Math.max(0, j - 1) * bw + i]) * 0.5
210
+
211
+ // 明暗:坡度朝光的一侧亮、背光暗 —— 这是"块与块之间有立体感"的来源
212
+ const tilt = -(hx * (bodyX - 0.5) * 4.4 + hz * 1.15)
213
+ // 近处不要压成黑条:明暗增益随深度递减
214
+ const shade = clamp01(0.58 + tilt * (3.1 - 1.1 * d))
215
+ const crest = clamp01((h - 0.4) / 0.6)
216
+
217
+ // 波峰提亮 / 波谷压暗(按高度,不只看斜率)——这是"起伏感"最直接的来源
218
+ const relief = 0.78 + 0.40 * (h - 0.5) * 2
219
+ let r = rBase * shade * relief
220
+ let g = gBase * shade * relief
221
+ let b = bBase * shade * relief
222
+
223
+ // 天际反射(雾感)
224
+ r = lerp(r, sky[0], haze * 0.62)
225
+ g = lerp(g, sky[1], haze * 0.62)
226
+ b = lerp(b, sky[2], haze * 0.62)
227
+
228
+ // 浪脊亮边:只有"高且坡对"的碎块才亮,不是整条线
229
+ const crestLight = crest * crest * clamp01(0.3 + tilt * 2.6) * rowLight
230
+ r += glow[0] * crestLight * 0.46
231
+ g += glow[1] * crestLight * 0.46
232
+ b += glow[2] * crestLight * 0.42
233
+
234
+ // 镜面碎光
235
+ const spec = Math.pow(clamp01(0.26 - tilt * 1.35), 5) * crest * bodyAlpha
236
+ if (spec > 0.004) {
237
+ r += bodyColor[0] * spec * 1.35
238
+ g += bodyColor[1] * spec * 1.35
239
+ b += bodyColor[2] * spec * 1.35
240
+ }
241
+
242
+ // 白沫:只在近处、只在最高的那批碎块上,斑块状
243
+ // 浪脊白沫:近岸浓、中场淡(0.55 处就有一点点,像被风吹起的白边)
244
+ const streakBand = clamp01((d - 0.42) / 0.58)
245
+ if (streakBand > 0.01) {
246
+ const mask = clamp01((h - (0.68 - 0.16 * streakBand)) / 0.24) * streakBand
247
+ if (mask > 0.01) {
248
+ const a = mask * (0.18 + 0.3 * clamp01(foamBand * 2))
249
+ r = lerp(r, foam[0], a)
250
+ g = lerp(g, foam[1], a)
251
+ b = lerp(b, foam[2], a)
252
+ }
253
+ }
254
+
255
+ data[p] = r > 255 ? 255 : r < 0 ? 0 : r
256
+ data[p + 1] = g > 255 ? 255 : g < 0 ? 0 : g
257
+ data[p + 2] = b > 255 ? 255 : b < 0 ? 0 : b
258
+ data[p + 3] = 255
259
+ p += 4
260
+ }
261
+ }
262
+
263
+ field.ctx.putImageData(field.image, 0, 0)
264
+
265
+ /* ---- 放大到屏幕 ---- */
266
+ ctx.save()
267
+ ctx.imageSmoothingEnabled = true
268
+ try {
269
+ ctx.imageSmoothingQuality = 'high'
270
+ } catch {
271
+ /* 老浏览器没有这个属性 */
272
+ }
273
+ ctx.drawImage(field.canvas, 0, horizonY, W, waterH)
274
+ ctx.restore()
275
+
276
+ /* ---- 碎光:全分辨率撒一层短线,让近处的"块"有反光点 ---- */
277
+ try {
278
+ drawGlitter(ctx, { W, H, horizonY, t, pal, exposure, bodyX, bodyAlpha, glow })
279
+ } catch {
280
+ /* 碎光失败了不影响画面 */
281
+ }
282
+
283
+ /* ---- 地平线雾带:把水天接缝抹掉 ---- */
284
+ try {
285
+ const hazeH = Math.max(2, waterH * 0.09)
286
+ const grad = ctx.createLinearGradient(0, horizonY - hazeH * 0.4, 0, horizonY + hazeH)
287
+ grad.addColorStop(0, `rgba(${sky[0] | 0},${sky[1] | 0},${sky[2] | 0},${0.55 + 0.35 * (1 - exposure)})`)
288
+ grad.addColorStop(1, `rgba(${sky[0] | 0},${sky[1] | 0},${sky[2] | 0},0)`)
289
+ ctx.fillStyle = grad
290
+ ctx.fillRect(0, horizonY - hazeH * 0.4, W, hazeH * 1.4)
291
+ } catch {
292
+ /* 渐变失败了就少一层雾,不影响画面 */
293
+ }
294
+
295
+ return true
296
+ }
297
+
298
+ /**
299
+ * 全分辨率碎光:在太阳/月亮下方的水面上撒一批 1–3px 的短线,密度与亮度跟曝光、天体高度相关。
300
+ * 位置由哈希决定(可复现),相位随 t 闪灭,所以看起来是"碎着亮"而不是一片亮。
301
+ */
302
+ function drawGlitter(ctx, { W, H, horizonY, t, pal, exposure, bodyX, bodyAlpha, glow }) {
303
+ const waterH = H - horizonY
304
+ if (waterH <= 2) return
305
+ const count = Math.round((70 + 240 * exposure) * clamp01(bodyAlpha + 0.2))
306
+ const sunU = bodyX
307
+ ctx.save()
308
+ for (let n = 0; n < count; n += 1) {
309
+ const seedA = hash2(n * 3 + 1, 7)
310
+ const seedB = hash2(n * 5 + 2, 13)
311
+ const seedC = hash2(n * 7 + 3, 19)
312
+ // 越靠下(近处)越密,横向集中在天体附近(±0.28 屏宽)
313
+ const d = Math.pow(seedA, 0.72) // 0 远 → 1 近
314
+ const y = horizonY + d * waterH
315
+ // 横向收窄:碎光集中在天体正下方,连成一条"光路"(真实海面最显眼的线索之一)
316
+ const spread = 0.10 + 0.30 * d
317
+ const x = (sunU + (seedB - 0.5) * 2 * spread) * W
318
+ if (x < 0 || x > W) continue
319
+ const twinkle = 0.35 + 0.65 * Math.abs(Math.sin(t * (0.45 + seedC * 1.1) + seedC * TAU))
320
+ const alpha = twinkle * (0.14 + 0.42 * exposure) * clamp01(1 - Math.abs(seedB - 0.5) * 1.25)
321
+ if (alpha < 0.03) continue
322
+ const len = 1 + 1.9 * d + seedC * 1.2
323
+ ctx.fillStyle = `rgba(${glow[0] | 0},${glow[1] | 0},${glow[2] | 0},${alpha.toFixed(3)})`
324
+ ctx.fillRect(x, y, len, 1)
325
+ }
326
+ ctx.restore()
327
+ }
328
+
329
+ /** 供测试:同一输入必须给出同一高度场(不含 Math.random)。 */
330
+ export const WATER_CONSTANTS = Object.freeze({ BUFFER_W, MAX_BUFFER_H, MIN_BUFFER_H, TAU })