dsh-intertidal 0.1.4 → 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.
- package/README.en.md +1 -1
- package/README.md +2 -2
- package/assets/screenshots/procedural-overview.png +0 -0
- package/dist/client.js +149 -51
- package/package.json +1 -1
- package/src/client/birds.js +112 -0
- package/src/client/palette.js +16 -1
- package/src/client/sea-canvas.jsx +10 -0
- package/src/client/water.js +27 -11
package/README.en.md
CHANGED
|
@@ -13,7 +13,7 @@ Every N minutes the screen is handed to the sea **as it is right now**, for thre
|
|
|
13
13
|
## What it does
|
|
14
14
|
|
|
15
15
|
- **On a schedule** — 3 minutes every 120 minutes by default. It fades out on its own: no prompt, no residue, your draft and scroll position untouched.
|
|
16
|
-
- **True to this moment** — the phase comes from **solar altitude**, not clock time (Shanghai may be at dusk while Reykjavik is still in the afternoon); the moon is drawn from a 29.53-day synodic approximation
|
|
16
|
+
- **True to this moment** — the phase comes from **solar altitude**, not clock time (Shanghai may be at dusk while Reykjavik is still in the afternoon); the moon is drawn from a 29.53-day synodic approximation and there is always one at night; by day, small flocks of gulls cross the sky (random appearances, not a looping animation); polar day/night handled separately.
|
|
17
17
|
- **Two kinds of sea**:
|
|
18
18
|
- procedural — five palettes plus a perspective noise field: randomly distributed water patches that grow toward the viewer, with sun glitter and near-field whitecaps (the fallback);
|
|
19
19
|
- media — a random image/video from a folder you choose, or a web/image link that is fetched once and cached, then graded to match the current light.
|
package/README.md
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
## 功能
|
|
14
14
|
|
|
15
15
|
- **定时浮现**:默认每 120 分钟浮现 3 分钟,到点自己淡出——不弹确认、不留残留、不动你的输入框与滚动位置。
|
|
16
|
-
- **忠于此刻**:时段按**太阳高度角**判定而不是钟点(同一刻上海是黄昏、雷克雅未克可能还是下午);月相按 29.53
|
|
16
|
+
- **忠于此刻**:时段按**太阳高度角**判定而不是钟点(同一刻上海是黄昏、雷克雅未克可能还是下午);月相按 29.53 天朔望周期绘制,**夜里总有一轮月亮**;白天有成群的海鸥掠过天空(随机出现,不是定时动画);高纬极昼极夜走单独分支。
|
|
17
17
|
- **两种海面**:
|
|
18
|
-
- 程序化海:五档色阶 +
|
|
18
|
+
- 程序化海:五档色阶 + **斑块场**(按透视随机分布的水面碎块、朝观察者推进的涌浪、太阳光路与近岸白浪),不联网、不需要任何素材(兜底);
|
|
19
19
|
- 素材模式:本地目录随机取一张图片/视频,或给网页/图片链接自动抓取缓存,再按此刻天光着色。
|
|
20
20
|
- **右上角倒计时**:`m:ss` 常驻,显示还有多久退干净;暂停时数字冻结。
|
|
21
21
|
- **形态**:整屏 / 右下角小窗(可拖拽、吸附四角),两者共享同一次浮现的剩余时长。
|
|
Binary file
|
package/dist/client.js
CHANGED
|
@@ -297,7 +297,18 @@ function computePalette(now) {
|
|
|
297
297
|
if (sunAltitude !== null) stars *= clamp01((-sunAltitude + 1) / 7);
|
|
298
298
|
else if ((phase || FALLBACK_PHASE) !== "night") stars = 0;
|
|
299
299
|
const sun = sunOf(sunPosition, colors);
|
|
300
|
-
|
|
300
|
+
let moon = moonOf(bodyPosition(source.moon), colors, moonPhase);
|
|
301
|
+
const nightish = sunAltitude !== null ? sunAltitude < -6 : (phase || FALLBACK_PHASE) === "night";
|
|
302
|
+
if (!moon && nightish) {
|
|
303
|
+
const lit = illuminatedFraction(moonPhase);
|
|
304
|
+
moon = {
|
|
305
|
+
x: 0.62,
|
|
306
|
+
y: 0.2,
|
|
307
|
+
color: colors.moonColor,
|
|
308
|
+
alpha: round(clamp01(0.4 + 0.55 * lit), 3),
|
|
309
|
+
phase: moonPhase
|
|
310
|
+
};
|
|
311
|
+
}
|
|
301
312
|
return {
|
|
302
313
|
skyTop: colors.skyTop,
|
|
303
314
|
skyMid: colors.skyMid,
|
|
@@ -419,14 +430,18 @@ function drawWater(ctx, view, field2) {
|
|
|
419
430
|
const zc = 1 / (0.38 + d * 2.4);
|
|
420
431
|
const wz = zc - scroll;
|
|
421
432
|
const phaseBase = d * LAYERS - t * SPEED;
|
|
422
|
-
const swellAmp = 0.
|
|
433
|
+
const swellAmp = 0.3 + 0.52 * d;
|
|
423
434
|
for (let i = 0; i < bw; i += 1) {
|
|
424
435
|
const wx = ((i + 0.5) / bw - 0.5) * zc * XSPREAD;
|
|
425
436
|
const wobble = (vnoise(wx * 0.85 + 9.7, wz * 0.55 + 3.3) - 0.5) * 0.62;
|
|
426
|
-
const
|
|
437
|
+
const s0 = Math.sin((phaseBase + wobble) * TAU);
|
|
438
|
+
const swell = (s0 < 0 ? -1 : 1) * Math.pow(Math.abs(s0), 1.55);
|
|
439
|
+
const s1 = Math.sin((d * 3.1 - t * 0.135 + wobble * 1.7 + 2.3) * TAU);
|
|
440
|
+
const swell2 = (s1 < 0 ? -1 : 1) * Math.pow(Math.abs(s1), 1.4);
|
|
427
441
|
const ridge = fbmRidged(wx * XFREQ + 3, wz * WFREQ);
|
|
442
|
+
const chop = fbmRidged(wx * XFREQ * 2.6 + 17.3, wz * WFREQ * 1.5 + 8.9);
|
|
428
443
|
const detail = fbmSmooth(wx * XFREQ * 1.9 + 11.3, wz * WFREQ * 0.72 + 5.1);
|
|
429
|
-
const h = 0.5 + 0.5 * swell * swellAmp + (ridge - 0.5) * 0.
|
|
444
|
+
const h = 0.5 + 0.5 * swell * swellAmp + 0.5 * swell2 * swellAmp * 0.42 + (ridge - 0.5) * 0.54 + (chop - 0.5) * 0.44 * d + (detail - 0.5) * 0.22;
|
|
430
445
|
heights[idx] = h < 0 ? 0 : h > 1 ? 1 : h;
|
|
431
446
|
idx += 1;
|
|
432
447
|
}
|
|
@@ -466,10 +481,11 @@ function drawWater(ctx, view, field2) {
|
|
|
466
481
|
g += bodyColor[1] * spec * 1.35;
|
|
467
482
|
b += bodyColor[2] * spec * 1.35;
|
|
468
483
|
}
|
|
469
|
-
|
|
470
|
-
|
|
484
|
+
const streakBand = clamp012((d - 0.42) / 0.58);
|
|
485
|
+
if (streakBand > 0.01) {
|
|
486
|
+
const mask = clamp012((h - (0.68 - 0.16 * streakBand)) / 0.24) * streakBand;
|
|
471
487
|
if (mask > 0.01) {
|
|
472
|
-
const a = mask * 0.
|
|
488
|
+
const a = mask * (0.18 + 0.3 * clamp012(foamBand * 2));
|
|
473
489
|
r = lerp(r, foam[0], a);
|
|
474
490
|
g = lerp(g, foam[1], a);
|
|
475
491
|
b = lerp(b, foam[2], a);
|
|
@@ -509,7 +525,7 @@ function drawWater(ctx, view, field2) {
|
|
|
509
525
|
function drawGlitter(ctx, { W, H, horizonY, t, pal, exposure, bodyX, bodyAlpha, glow }) {
|
|
510
526
|
const waterH = H - horizonY;
|
|
511
527
|
if (waterH <= 2) return;
|
|
512
|
-
const count = Math.round((
|
|
528
|
+
const count = Math.round((70 + 240 * exposure) * clamp012(bodyAlpha + 0.2));
|
|
513
529
|
const sunU = bodyX;
|
|
514
530
|
ctx.save();
|
|
515
531
|
for (let n = 0; n < count; n += 1) {
|
|
@@ -518,13 +534,13 @@ function drawGlitter(ctx, { W, H, horizonY, t, pal, exposure, bodyX, bodyAlpha,
|
|
|
518
534
|
const seedC = hash2(n * 7 + 3, 19);
|
|
519
535
|
const d = Math.pow(seedA, 0.72);
|
|
520
536
|
const y = horizonY + d * waterH;
|
|
521
|
-
const spread = 0.
|
|
537
|
+
const spread = 0.1 + 0.3 * d;
|
|
522
538
|
const x = (sunU + (seedB - 0.5) * 2 * spread) * W;
|
|
523
539
|
if (x < 0 || x > W) continue;
|
|
524
540
|
const twinkle = 0.35 + 0.65 * Math.abs(Math.sin(t * (0.45 + seedC * 1.1) + seedC * TAU));
|
|
525
|
-
const alpha = twinkle * (0.
|
|
541
|
+
const alpha = twinkle * (0.14 + 0.42 * exposure) * clamp012(1 - Math.abs(seedB - 0.5) * 1.25);
|
|
526
542
|
if (alpha < 0.03) continue;
|
|
527
|
-
const len = 1 + 1.
|
|
543
|
+
const len = 1 + 1.9 * d + seedC * 1.2;
|
|
528
544
|
ctx.fillStyle = `rgba(${glow[0] | 0},${glow[1] | 0},${glow[2] | 0},${alpha.toFixed(3)})`;
|
|
529
545
|
ctx.fillRect(x, y, len, 1);
|
|
530
546
|
}
|
|
@@ -532,9 +548,85 @@ function drawGlitter(ctx, { W, H, horizonY, t, pal, exposure, bodyX, bodyAlpha,
|
|
|
532
548
|
}
|
|
533
549
|
var WATER_CONSTANTS = Object.freeze({ BUFFER_W, MAX_BUFFER_H, MIN_BUFFER_H, TAU });
|
|
534
550
|
|
|
551
|
+
// src/client/birds.js
|
|
552
|
+
var TAU2 = Math.PI * 2;
|
|
553
|
+
var clamp013 = (v) => v < 0 ? 0 : v > 1 ? 1 : v;
|
|
554
|
+
function hash(n) {
|
|
555
|
+
let x = Math.imul(n | 0, 2654435761);
|
|
556
|
+
x = Math.imul(x ^ x >>> 15, 2246822507);
|
|
557
|
+
return ((x ^ x >>> 13) >>> 0) / 4294967296;
|
|
558
|
+
}
|
|
559
|
+
var BIRD_CONSTANTS = Object.freeze({
|
|
560
|
+
slots: 9,
|
|
561
|
+
// 同时在"档期"里的鸟槽(不是同屏数量)
|
|
562
|
+
minPeriod: 19,
|
|
563
|
+
// 最短的一个轮回(秒)
|
|
564
|
+
periodSpan: 23,
|
|
565
|
+
// 周期跨度:每只鸟都不同,避免同拍
|
|
566
|
+
minFlight: 7,
|
|
567
|
+
// 飞过一整屏需要的秒数(下界)
|
|
568
|
+
flightSpan: 5
|
|
569
|
+
});
|
|
570
|
+
function daylightOf(pal) {
|
|
571
|
+
if (!pal || typeof pal !== "object") return 0;
|
|
572
|
+
if (!Number.isFinite(pal.exposure)) return 0;
|
|
573
|
+
const stars = Number.isFinite(pal.stars) ? pal.stars : 0;
|
|
574
|
+
return clamp013((pal.exposure - 0.45) / 0.4) * clamp013(1 - stars * 1.6);
|
|
575
|
+
}
|
|
576
|
+
function drawBirds(ctx, view) {
|
|
577
|
+
const { W, H, horizonY, t } = view;
|
|
578
|
+
const pal = view.pal ?? {};
|
|
579
|
+
const daylight = daylightOf(pal);
|
|
580
|
+
if (daylight <= 0.02) return 0;
|
|
581
|
+
if (!Number.isFinite(W) || !Number.isFinite(H) || !Number.isFinite(horizonY)) return 0;
|
|
582
|
+
if (horizonY <= 4 || !ctx || typeof ctx.beginPath !== "function") return 0;
|
|
583
|
+
const { slots, minPeriod, periodSpan, minFlight, flightSpan } = BIRD_CONSTANTS;
|
|
584
|
+
const skyH = horizonY;
|
|
585
|
+
const scale = Math.max(0.55, Math.min(2.2, H / 720));
|
|
586
|
+
const density = 0.55 + 0.45 * daylight;
|
|
587
|
+
let drawn = 0;
|
|
588
|
+
for (let i = 0; i < slots; i += 1) {
|
|
589
|
+
const period = minPeriod + hash(i * 3 + 1) * periodSpan;
|
|
590
|
+
const flight = minFlight + hash(i * 7 + 5) * flightSpan;
|
|
591
|
+
const offset = hash(i * 11 + 2) * period;
|
|
592
|
+
const local = (t + offset) % period;
|
|
593
|
+
if (local > flight) continue;
|
|
594
|
+
if (hash(i * 13 + 9) > density) continue;
|
|
595
|
+
const p = local / flight;
|
|
596
|
+
const r1 = hash(i * 17 + 3);
|
|
597
|
+
const r2 = hash(i * 19 + 11);
|
|
598
|
+
const r3 = hash(i * 23 + 7);
|
|
599
|
+
const leftToRight = r1 > 0.45;
|
|
600
|
+
const x0 = leftToRight ? -0.08 * W : 1.08 * W;
|
|
601
|
+
const x1 = leftToRight ? 1.08 * W : -0.08 * W;
|
|
602
|
+
const x = x0 + (x1 - x0) * p;
|
|
603
|
+
const depth = 0.28 + 0.42 * r2;
|
|
604
|
+
const bob = Math.sin((p * 2 + r3) * TAU2) * skyH * 0.012;
|
|
605
|
+
const y = horizonY * depth + bob;
|
|
606
|
+
const size = (11 + 9 * r3) * scale * (0.55 + 0.6 * depth);
|
|
607
|
+
const flaps = 6 + Math.floor(r3 * 5);
|
|
608
|
+
const wing = Math.sin(p * flaps * TAU2 + r2 * TAU2);
|
|
609
|
+
const lift = 0.55 + 0.45 * Math.abs(wing);
|
|
610
|
+
const alpha = clamp013((0.5 + 0.32 * daylight) * (0.7 + 0.3 * Math.sin(p * Math.PI)));
|
|
611
|
+
ctx.save();
|
|
612
|
+
ctx.strokeStyle = `rgba(28,34,44,${alpha.toFixed(3)})`;
|
|
613
|
+
ctx.lineWidth = Math.max(1.1, 1.7 * scale);
|
|
614
|
+
ctx.lineCap = "round";
|
|
615
|
+
if (typeof ctx.beginPath === "function") {
|
|
616
|
+
ctx.beginPath();
|
|
617
|
+
ctx.moveTo(x - size, y - wing * size * 0.55 * lift);
|
|
618
|
+
ctx.quadraticCurveTo(x - size * 0.35, y - size * 0.28 - wing * size * 0.35 * lift, x, y);
|
|
619
|
+
ctx.quadraticCurveTo(x + size * 0.35, y - size * 0.28 - wing * size * 0.35 * lift, x + size, y - wing * size * 0.55 * lift);
|
|
620
|
+
ctx.stroke();
|
|
621
|
+
}
|
|
622
|
+
drawn += 1;
|
|
623
|
+
}
|
|
624
|
+
return drawn;
|
|
625
|
+
}
|
|
626
|
+
|
|
535
627
|
// src/client/sea-canvas.jsx
|
|
536
628
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
537
|
-
var
|
|
629
|
+
var TAU3 = Math.PI * 2;
|
|
538
630
|
var TUNING = Object.freeze({
|
|
539
631
|
maxBufferWidth: 1280,
|
|
540
632
|
// 内部渲染缓冲宽度上限(程序化海)
|
|
@@ -602,7 +694,7 @@ function num2(value, fallback) {
|
|
|
602
694
|
function clamp2(value, lo, hi) {
|
|
603
695
|
return value < lo ? lo : value > hi ? hi : value;
|
|
604
696
|
}
|
|
605
|
-
function
|
|
697
|
+
function clamp014(value) {
|
|
606
698
|
return clamp2(num2(value, 0), 0, 1);
|
|
607
699
|
}
|
|
608
700
|
function lerp2(a, b, k) {
|
|
@@ -624,7 +716,7 @@ function rgba(color, alpha) {
|
|
|
624
716
|
g = parseInt(hex.slice(2, 4), 16) || 0;
|
|
625
717
|
b = parseInt(hex.slice(4, 6), 16) || 0;
|
|
626
718
|
}
|
|
627
|
-
return "rgba(" + r + "," + g + "," + b + "," + round2(
|
|
719
|
+
return "rgba(" + r + "," + g + "," + b + "," + round2(clamp014(alpha), 3) + ")";
|
|
628
720
|
}
|
|
629
721
|
function hash1(n) {
|
|
630
722
|
const s = Math.sin(n * 12.9898) * 43758.5453;
|
|
@@ -638,7 +730,7 @@ function drawSky(ctx, W, H, horizonY, pal) {
|
|
|
638
730
|
gradient.addColorStop(1, pal.skyBottom);
|
|
639
731
|
ctx.fillStyle = gradient;
|
|
640
732
|
ctx.fillRect(0, 0, W, height);
|
|
641
|
-
const spread = H * (0.09 + 0.07 * (1 -
|
|
733
|
+
const spread = H * (0.09 + 0.07 * (1 - clamp014(pal.exposure)));
|
|
642
734
|
const glow = ctx.createLinearGradient(0, horizonY - spread, 0, horizonY + H * 4e-3);
|
|
643
735
|
glow.addColorStop(0, rgba(pal.horizonGlow, 0));
|
|
644
736
|
glow.addColorStop(0.72, rgba(pal.horizonGlow, 0.3));
|
|
@@ -650,7 +742,7 @@ function drawSky(ctx, W, H, horizonY, pal) {
|
|
|
650
742
|
ctx.fillRect(0, hazeTop, W, horizonY - hazeTop);
|
|
651
743
|
}
|
|
652
744
|
function drawStars(ctx, W, H, horizonY, pal, t) {
|
|
653
|
-
const density =
|
|
745
|
+
const density = clamp014(pal.stars);
|
|
654
746
|
if (density <= 0.01) return;
|
|
655
747
|
const count = Math.round(density * 120);
|
|
656
748
|
const tint = mix(pal.foam, "#ffffff", 0.65);
|
|
@@ -662,14 +754,14 @@ function drawStars(ctx, W, H, horizonY, pal, t) {
|
|
|
662
754
|
const y = hy * hy * ceiling;
|
|
663
755
|
const size = 0.7 + hash1(i * 3.77) * 1.1;
|
|
664
756
|
const twinkle = 0.55 + 0.45 * Math.sin(t * (0.6 + hx * 1.9) + i * 1.3);
|
|
665
|
-
ctx.fillStyle = rgba(tint,
|
|
757
|
+
ctx.fillStyle = rgba(tint, clamp014(density * twinkle) * 0.85);
|
|
666
758
|
ctx.fillRect(x, y, size, size);
|
|
667
759
|
}
|
|
668
760
|
}
|
|
669
761
|
function drawSun(ctx, W, H, pal) {
|
|
670
762
|
const sun = pal.sun;
|
|
671
763
|
if (!sun) return;
|
|
672
|
-
const alpha =
|
|
764
|
+
const alpha = clamp014(sun.alpha);
|
|
673
765
|
if (alpha <= 0.02) return;
|
|
674
766
|
const cx = clamp2(sun.x, -0.2, 1.2) * W;
|
|
675
767
|
const cy = clamp2(sun.y, -0.2, 1.2) * H;
|
|
@@ -680,7 +772,7 @@ function drawSun(ctx, W, H, pal) {
|
|
|
680
772
|
halo.addColorStop(1, rgba(pal.horizonGlow, 0));
|
|
681
773
|
ctx.fillStyle = halo;
|
|
682
774
|
ctx.beginPath();
|
|
683
|
-
ctx.arc(cx, cy, radius * 7, 0,
|
|
775
|
+
ctx.arc(cx, cy, radius * 7, 0, TAU3);
|
|
684
776
|
ctx.fill();
|
|
685
777
|
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
|
686
778
|
core.addColorStop(0, rgba(sun.color, alpha));
|
|
@@ -688,29 +780,29 @@ function drawSun(ctx, W, H, pal) {
|
|
|
688
780
|
core.addColorStop(1, rgba(sun.color, alpha * 0.5));
|
|
689
781
|
ctx.fillStyle = core;
|
|
690
782
|
ctx.beginPath();
|
|
691
|
-
ctx.arc(cx, cy, radius, 0,
|
|
783
|
+
ctx.arc(cx, cy, radius, 0, TAU3);
|
|
692
784
|
ctx.fill();
|
|
693
785
|
}
|
|
694
786
|
function drawMoon(ctx, W, H, pal) {
|
|
695
787
|
const moon = pal.moon;
|
|
696
788
|
if (!moon) return;
|
|
697
|
-
const alpha =
|
|
789
|
+
const alpha = clamp014(moon.alpha);
|
|
698
790
|
if (alpha <= 0.02) return;
|
|
699
791
|
const cx = clamp2(moon.x, -0.2, 1.2) * W;
|
|
700
792
|
const cy = clamp2(moon.y, -0.2, 1.2) * H;
|
|
701
793
|
const radius = Math.max(5, H * 0.019);
|
|
702
|
-
const phase =
|
|
794
|
+
const phase = clamp014(num2(moon.phase, 0.5));
|
|
703
795
|
const halo = ctx.createRadialGradient(cx, cy, radius * 0.6, cx, cy, radius * 6);
|
|
704
796
|
halo.addColorStop(0, rgba(moon.color, 0.26 * alpha));
|
|
705
797
|
halo.addColorStop(0.4, rgba(moon.color, 0.08 * alpha));
|
|
706
798
|
halo.addColorStop(1, rgba(moon.color, 0));
|
|
707
799
|
ctx.fillStyle = halo;
|
|
708
800
|
ctx.beginPath();
|
|
709
|
-
ctx.arc(cx, cy, radius * 6, 0,
|
|
801
|
+
ctx.arc(cx, cy, radius * 6, 0, TAU3);
|
|
710
802
|
ctx.fill();
|
|
711
803
|
const waxing = phase < 0.5;
|
|
712
804
|
const p = waxing ? phase : 1 - phase;
|
|
713
|
-
const rx = Math.max(0.01, radius * Math.abs(Math.cos(
|
|
805
|
+
const rx = Math.max(0.01, radius * Math.abs(Math.cos(TAU3 * p)));
|
|
714
806
|
ctx.save();
|
|
715
807
|
if (!waxing) {
|
|
716
808
|
ctx.translate(cx * 2, 0);
|
|
@@ -730,13 +822,13 @@ function drawMoon(ctx, W, H, pal) {
|
|
|
730
822
|
ctx.restore();
|
|
731
823
|
}
|
|
732
824
|
function farSeaNoise(u, t) {
|
|
733
|
-
const w =
|
|
825
|
+
const w = TAU3 * L1.freq;
|
|
734
826
|
return Math.sin(u * L1.spatial + t * w) * 0.5 + Math.sin(u * L1.spatial * 2.6 - t * w * 1.7 + 1.1) * 0.3 + Math.sin(u * L1.spatial * 5.4 + t * w * 2.9 + 2.3) * 0.2;
|
|
735
827
|
}
|
|
736
828
|
function drawFarSea(ctx, W, H, horizonY, pal, t, px) {
|
|
737
|
-
const amp = lerp2(L1.ampMin, L1.ampMax, 0.5 + 0.5 * Math.sin(t *
|
|
829
|
+
const amp = lerp2(L1.ampMin, L1.ampMax, 0.5 + 0.5 * Math.sin(t * TAU3 * L1.freq)) * px;
|
|
738
830
|
const step = Math.max(8, W / 80);
|
|
739
|
-
const curve = (x) => horizonY - amp * (0.5 + 0.5 * farSeaNoise(x / Math.max(1, W) *
|
|
831
|
+
const curve = (x) => horizonY - amp * (0.5 + 0.5 * farSeaNoise(x / Math.max(1, W) * TAU3, t));
|
|
740
832
|
ctx.save();
|
|
741
833
|
ctx.beginPath();
|
|
742
834
|
ctx.moveTo(0, curve(0));
|
|
@@ -749,7 +841,7 @@ function drawFarSea(ctx, W, H, horizonY, pal, t, px) {
|
|
|
749
841
|
ctx.closePath();
|
|
750
842
|
ctx.clip();
|
|
751
843
|
const gradient = ctx.createLinearGradient(0, horizonY - amp - 2, 0, H);
|
|
752
|
-
const mirror = 0.45 + 0.25 * (1 -
|
|
844
|
+
const mirror = 0.45 + 0.25 * (1 - clamp014(pal.exposure));
|
|
753
845
|
gradient.addColorStop(0, mix(pal.seaFar, pal.skyBottom, mirror));
|
|
754
846
|
gradient.addColorStop(0.18, mix(pal.seaFar, pal.skyBottom, mirror * 0.45));
|
|
755
847
|
gradient.addColorStop(0.42, mix(pal.seaFar, pal.seaMid, 0.85));
|
|
@@ -758,8 +850,8 @@ function drawFarSea(ctx, W, H, horizonY, pal, t, px) {
|
|
|
758
850
|
ctx.fillStyle = gradient;
|
|
759
851
|
ctx.fillRect(0, horizonY - amp - 2, W, H - horizonY + amp + 4);
|
|
760
852
|
ctx.restore();
|
|
761
|
-
const fogAmount =
|
|
762
|
-
const fogHeight = H * (0.045 + 0.085 * (1 -
|
|
853
|
+
const fogAmount = clamp014(0.3 + 0.5 * (1 - clamp014(pal.exposure)));
|
|
854
|
+
const fogHeight = H * (0.045 + 0.085 * (1 - clamp014(pal.exposure)));
|
|
763
855
|
const fogColor = mix(pal.skyBottom, pal.horizonGlow, 0.45);
|
|
764
856
|
const fog = ctx.createLinearGradient(0, horizonY - fogHeight * 0.45, 0, horizonY + fogHeight * 0.9);
|
|
765
857
|
fog.addColorStop(0, rgba(fogColor, 0));
|
|
@@ -774,19 +866,19 @@ function drawFarSea(ctx, W, H, horizonY, pal, t, px) {
|
|
|
774
866
|
const xx = Math.min(x, W);
|
|
775
867
|
ctx.lineTo(xx, curve(xx));
|
|
776
868
|
}
|
|
777
|
-
ctx.strokeStyle = rgba(mix(pal.seaFar, pal.foam, 0.3), 0.06 + 0.16 *
|
|
869
|
+
ctx.strokeStyle = rgba(mix(pal.seaFar, pal.foam, 0.3), 0.06 + 0.16 * clamp014(pal.exposure));
|
|
778
870
|
ctx.lineWidth = Math.max(0.6, 1.1 * px);
|
|
779
871
|
ctx.stroke();
|
|
780
872
|
}
|
|
781
873
|
function gradeOf(media) {
|
|
782
874
|
if (!media || typeof media !== "object") return 0.55;
|
|
783
875
|
const flat = media.grade !== void 0 ? media.grade : media.settings && media.settings.grade;
|
|
784
|
-
return
|
|
876
|
+
return clamp014(num2(flat, 0.55));
|
|
785
877
|
}
|
|
786
878
|
function motionOf(media) {
|
|
787
879
|
if (!media || typeof media !== "object") return 0.5;
|
|
788
880
|
const flat = media.motion !== void 0 ? media.motion : media.settings && media.settings.motion;
|
|
789
|
-
return
|
|
881
|
+
return clamp014(num2(flat, 0.5));
|
|
790
882
|
}
|
|
791
883
|
function hashString(text) {
|
|
792
884
|
const source = typeof text === "string" ? text : "";
|
|
@@ -811,7 +903,7 @@ function withComposite(ctx, op, paint) {
|
|
|
811
903
|
}
|
|
812
904
|
}
|
|
813
905
|
function compositeFill(ctx, op, color, alpha, W, H) {
|
|
814
|
-
const a =
|
|
906
|
+
const a = clamp014(alpha);
|
|
815
907
|
if (a <= 3e-3) return;
|
|
816
908
|
withComposite(ctx, op, () => {
|
|
817
909
|
ctx.fillStyle = rgba(color, a);
|
|
@@ -819,20 +911,20 @@ function compositeFill(ctx, op, color, alpha, W, H) {
|
|
|
819
911
|
});
|
|
820
912
|
}
|
|
821
913
|
function drawMediaGrade(ctx, W, H, pal, grade) {
|
|
822
|
-
const g =
|
|
914
|
+
const g = clamp014(grade);
|
|
823
915
|
if (g <= 0.01) return;
|
|
824
|
-
const exposure =
|
|
916
|
+
const exposure = clamp014(pal.exposure);
|
|
825
917
|
const lowSun = 1 - exposure;
|
|
826
|
-
const darkness =
|
|
827
|
-
const tint = mix(pal.seaMid, pal.horizonGlow,
|
|
828
|
-
const shade = mix(tint, "#03060e",
|
|
829
|
-
compositeFill(ctx, "multiply", shade,
|
|
830
|
-
if (darkness > 0.2) compositeFill(ctx, "multiply", "#04070f",
|
|
831
|
-
compositeFill(ctx, "screen", mix(pal.horizonGlow, "#ffffff", 0.5),
|
|
832
|
-
compositeFill(ctx, "soft-light", tint,
|
|
918
|
+
const darkness = clamp014((0.95 - exposure) / 0.65);
|
|
919
|
+
const tint = mix(pal.seaMid, pal.horizonGlow, clamp014(0.2 + 0.75 * lowSun));
|
|
920
|
+
const shade = mix(tint, "#03060e", clamp014(0.4 + 0.55 * darkness));
|
|
921
|
+
compositeFill(ctx, "multiply", shade, clamp014(g * (0.08 + 0.9 * darkness)), W, H);
|
|
922
|
+
if (darkness > 0.2) compositeFill(ctx, "multiply", "#04070f", clamp014(g * 0.4 * (darkness - 0.2) / 0.8), W, H);
|
|
923
|
+
compositeFill(ctx, "screen", mix(pal.horizonGlow, "#ffffff", 0.5), clamp014(g * (0.1 + 0.16 * exposure)), W, H);
|
|
924
|
+
compositeFill(ctx, "soft-light", tint, clamp014(g * 0.55), W, H);
|
|
833
925
|
}
|
|
834
926
|
function drawMediaSkyMask(ctx, W, H, pal, grade) {
|
|
835
|
-
const g =
|
|
927
|
+
const g = clamp014(grade);
|
|
836
928
|
if (g <= 0.01) return;
|
|
837
929
|
const bottom = H * MEDIA.skyMaskBottom;
|
|
838
930
|
const shade = ctx.createLinearGradient(0, 0, 0, bottom);
|
|
@@ -855,7 +947,7 @@ function drawMediaSweep(ctx, W, H, pal, t, motion, grade) {
|
|
|
855
947
|
const bandHeight = H * MEDIA.sweepHeight;
|
|
856
948
|
const y = -bandHeight + phase * (H + bandHeight * 2);
|
|
857
949
|
const strength = Math.sin(Math.PI * phase);
|
|
858
|
-
const alpha = 0.05 * strength * (0.45 + 0.75 *
|
|
950
|
+
const alpha = 0.05 * strength * (0.45 + 0.75 * clamp014(grade)) * (0.5 + 0.5 * clamp014(motion));
|
|
859
951
|
if (alpha <= 4e-3) return;
|
|
860
952
|
const sweep = ctx.createLinearGradient(0, y, 0, y + bandHeight);
|
|
861
953
|
sweep.addColorStop(0, rgba(pal.foam, 0));
|
|
@@ -867,7 +959,7 @@ function drawMediaSweep(ctx, W, H, pal, t, motion, grade) {
|
|
|
867
959
|
});
|
|
868
960
|
}
|
|
869
961
|
function drawVignette(ctx, W, H, strength) {
|
|
870
|
-
const s =
|
|
962
|
+
const s = clamp014(strength);
|
|
871
963
|
if (s <= 0.01) return;
|
|
872
964
|
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);
|
|
873
965
|
gradient.addColorStop(0, "rgba(0,0,0,0)");
|
|
@@ -891,18 +983,18 @@ function renderMediaScene(ctx, W, H, t, pal, asset, media, warmth) {
|
|
|
891
983
|
if (!iw || !ih) return false;
|
|
892
984
|
const motion = motionOf(media);
|
|
893
985
|
const grade = gradeOf(media);
|
|
894
|
-
const seed =
|
|
986
|
+
const seed = clamp014(num2(asset.seed, 0));
|
|
895
987
|
const phase = ((t / MEDIA.kenBurnsPeriod + seed) % 1 + 1) % 1;
|
|
896
|
-
const ease = 0.5 - 0.5 * Math.cos(
|
|
988
|
+
const ease = 0.5 - 0.5 * Math.cos(TAU3 * phase);
|
|
897
989
|
const zoom = MEDIA.overscan + MEDIA.kenBurnsZoom * motion * (0.35 + 0.65 * ease);
|
|
898
990
|
const scale = Math.max(W / iw, H / ih) * zoom;
|
|
899
991
|
const dw = Math.max(W + 2, iw * scale);
|
|
900
992
|
const dh = Math.max(H + 2, ih * scale);
|
|
901
993
|
const marginX = Math.max(0, (dw - W) / 2);
|
|
902
994
|
const marginY = Math.max(0, (dh - H) / 2);
|
|
903
|
-
const panX = marginX * 0.8 * motion * Math.sin(
|
|
904
|
-
const panY = marginY * 0.5 * motion * Math.cos(
|
|
905
|
-
const breath = motion > 0.02 ? Math.sin(
|
|
995
|
+
const panX = marginX * 0.8 * motion * Math.sin(TAU3 * phase + seed * TAU3);
|
|
996
|
+
const panY = marginY * 0.5 * motion * Math.cos(TAU3 * phase * 0.71 + seed * 5.1);
|
|
997
|
+
const breath = motion > 0.02 ? Math.sin(TAU3 * MEDIA.breathFreq * t) * MEDIA.breathPx * (0.4 + 0.6 * motion) : 0;
|
|
906
998
|
const dx = (W - dw) / 2 + clamp2(panX + breath, -marginX, marginX);
|
|
907
999
|
const dy = (H - dh) / 2 + clamp2(panY, -marginY, marginY);
|
|
908
1000
|
ctx.drawImage(el, dx, dy, dw, dh);
|
|
@@ -944,6 +1036,12 @@ function renderScene(ctx, W, H, t, pal, tideN, warmth, field2) {
|
|
|
944
1036
|
drawStars(ctx, W, H, horizonY, pal, t);
|
|
945
1037
|
drawSun(ctx, W, H, pal);
|
|
946
1038
|
drawMoon(ctx, W, H, pal);
|
|
1039
|
+
if (daylightOf(pal) > 0.02) {
|
|
1040
|
+
try {
|
|
1041
|
+
drawBirds(ctx, { W, H, horizonY, t, pal });
|
|
1042
|
+
} catch (_error) {
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
947
1045
|
let drawn = false;
|
|
948
1046
|
if (field2) {
|
|
949
1047
|
try {
|
package/package.json
CHANGED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// 潮间 · 白天的海鸥。
|
|
2
|
+
//
|
|
3
|
+
// 设计约束(都在 spec 里):不联网、不加载素材、**不依赖帧数**、随时可停帧、任何分支都不抛。
|
|
4
|
+
// 所以这里不用"状态机 + 随机生成器",而是**时间函数**:第 i 只鸟的起飞时刻、航线、扇翅相位
|
|
5
|
+
// 全部由 t 和一个整数哈希决定。好处是:
|
|
6
|
+
// - 同一 t 画出同一帧(可单测、可截图复现);
|
|
7
|
+
// - 暂停/隐藏再恢复时不会"补飞"一段突兀的动画;
|
|
8
|
+
// - 不会因为掉帧而多出或少掉一只鸟。
|
|
9
|
+
//
|
|
10
|
+
// 视觉:远处的小剪影、两段贝塞尔翅膀 + 轻微上下起伏,一队 3 只错开飞过。做成"随机"的观感靠
|
|
11
|
+
// 每只鸟的周期/航线/相位互质,屏幕上不会出现明显规律。
|
|
12
|
+
|
|
13
|
+
const TAU = Math.PI * 2;
|
|
14
|
+
|
|
15
|
+
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
|
16
|
+
|
|
17
|
+
/** 整数哈希 → [0,1),用来给每只鸟不同的参数。 */
|
|
18
|
+
function hash(n) {
|
|
19
|
+
let x = Math.imul(n | 0, 2654435761);
|
|
20
|
+
x = Math.imul(x ^ (x >>> 15), 2246822507);
|
|
21
|
+
return ((x ^ (x >>> 13)) >>> 0) / 4294967296;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const BIRD_CONSTANTS = Object.freeze({
|
|
25
|
+
slots: 9, // 同时在"档期"里的鸟槽(不是同屏数量)
|
|
26
|
+
minPeriod: 19, // 最短的一个轮回(秒)
|
|
27
|
+
periodSpan: 23, // 周期跨度:每只鸟都不同,避免同拍
|
|
28
|
+
minFlight: 7, // 飞过一整屏需要的秒数(下界)
|
|
29
|
+
flightSpan: 5,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 白天度:0 = 完全夜里(不出鸟),1 = 大白天。深夜/晨曦微光时自动降到 0。
|
|
34
|
+
* @param {any} pal paletteFor() 的返回值
|
|
35
|
+
*/
|
|
36
|
+
export function daylightOf(pal) {
|
|
37
|
+
if (!pal || typeof pal !== 'object') return 0;
|
|
38
|
+
// 没有 exposure 就当作"不知道天光"——宁可不画鸟,也不要在大半夜画出一群海鸥。
|
|
39
|
+
if (!Number.isFinite(pal.exposure)) return 0;
|
|
40
|
+
const stars = Number.isFinite(pal.stars) ? pal.stars : 0;
|
|
41
|
+
return clamp01((pal.exposure - 0.45) / 0.4) * clamp01(1 - stars * 1.6);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 画一帧海鸥。
|
|
46
|
+
* @param {CanvasRenderingContext2D} ctx
|
|
47
|
+
* @param {{ W:number, H:number, horizonY:number, t:number, pal:any }} view
|
|
48
|
+
* @returns {number} 这一帧画出的鸟数(测试用)
|
|
49
|
+
*/
|
|
50
|
+
export function drawBirds(ctx, view) {
|
|
51
|
+
const { W, H, horizonY, t } = view;
|
|
52
|
+
const pal = view.pal ?? {};
|
|
53
|
+
const daylight = daylightOf(pal);
|
|
54
|
+
if (daylight <= 0.02) return 0;
|
|
55
|
+
if (!Number.isFinite(W) || !Number.isFinite(H) || !Number.isFinite(horizonY)) return 0;
|
|
56
|
+
if (horizonY <= 4 || !ctx || typeof ctx.beginPath !== 'function') return 0;
|
|
57
|
+
|
|
58
|
+
const { slots, minPeriod, periodSpan, minFlight, flightSpan } = BIRD_CONSTANTS;
|
|
59
|
+
const skyH = horizonY; // 鸟只在地平线以上的天空里
|
|
60
|
+
const scale = Math.max(0.55, Math.min(2.2, H / 720));
|
|
61
|
+
// 白天越亮,鸟越多;夜里 0
|
|
62
|
+
const density = 0.55 + 0.45 * daylight;
|
|
63
|
+
|
|
64
|
+
let drawn = 0;
|
|
65
|
+
for (let i = 0; i < slots; i += 1) {
|
|
66
|
+
const period = minPeriod + hash(i * 3 + 1) * periodSpan;
|
|
67
|
+
const flight = minFlight + hash(i * 7 + 5) * flightSpan;
|
|
68
|
+
const offset = hash(i * 11 + 2) * period;
|
|
69
|
+
// 每一轮里"在飞"的时间占比 = flight/period,其余时间不出现
|
|
70
|
+
const local = (t + offset) % period;
|
|
71
|
+
if (local > flight) continue;
|
|
72
|
+
// 密度筛选:不同的鸟按不同概率出现,视觉上不是固定 3 只
|
|
73
|
+
if (hash(i * 13 + 9) > density) continue;
|
|
74
|
+
|
|
75
|
+
const p = local / flight; // 0 → 1 航线进度
|
|
76
|
+
const r1 = hash(i * 17 + 3);
|
|
77
|
+
const r2 = hash(i * 19 + 11);
|
|
78
|
+
const r3 = hash(i * 23 + 7);
|
|
79
|
+
const leftToRight = r1 > 0.45;
|
|
80
|
+
// 从屏幕外飞进来、再飞出屏幕外
|
|
81
|
+
const x0 = leftToRight ? -0.08 * W : 1.08 * W;
|
|
82
|
+
const x1 = leftToRight ? 1.08 * W : -0.08 * W;
|
|
83
|
+
const x = x0 + (x1 - x0) * p;
|
|
84
|
+
// 高度:地平线以上、偏上;越靠近地平线画得越小(远)
|
|
85
|
+
const depth = 0.28 + 0.42 * r2; // 0.28(低/远) → 0.70(高/近)
|
|
86
|
+
const bob = Math.sin((p * 2.0 + r3) * TAU) * skyH * 0.012;
|
|
87
|
+
const y = horizonY * depth + bob;
|
|
88
|
+
|
|
89
|
+
const size = (11 + 9 * r3) * scale * (0.55 + 0.6 * depth);
|
|
90
|
+
// 翅膀扇动:每只鸟频率不同,period 内做 6–11 次
|
|
91
|
+
const flaps = 6 + Math.floor(r3 * 5);
|
|
92
|
+
const wing = Math.sin(p * flaps * TAU + r2 * TAU);
|
|
93
|
+
// 侧倾:扇翅到极值时翅膀更"平",中间更"折",看起来像在滑翔与扇动之间切换
|
|
94
|
+
const lift = 0.55 + 0.45 * Math.abs(wing);
|
|
95
|
+
const alpha = clamp01((0.5 + 0.32 * daylight) * (0.7 + 0.3 * Math.sin(p * Math.PI)));
|
|
96
|
+
|
|
97
|
+
ctx.save();
|
|
98
|
+
ctx.strokeStyle = `rgba(28,34,44,${alpha.toFixed(3)})`;
|
|
99
|
+
ctx.lineWidth = Math.max(1.1, 1.7 * scale);
|
|
100
|
+
ctx.lineCap = 'round';
|
|
101
|
+
if (typeof ctx.beginPath === 'function') {
|
|
102
|
+
// 两段翅膀:左翼 + 右翼,用二次曲线做出"上反角"
|
|
103
|
+
ctx.beginPath();
|
|
104
|
+
ctx.moveTo(x - size, y - wing * size * 0.55 * lift);
|
|
105
|
+
ctx.quadraticCurveTo(x - size * 0.35, y - size * 0.28 - wing * size * 0.35 * lift, x, y);
|
|
106
|
+
ctx.quadraticCurveTo(x + size * 0.35, y - size * 0.28 - wing * size * 0.35 * lift, x + size, y - wing * size * 0.55 * lift);
|
|
107
|
+
ctx.stroke();
|
|
108
|
+
}
|
|
109
|
+
drawn += 1;
|
|
110
|
+
}
|
|
111
|
+
return drawn;
|
|
112
|
+
}
|
package/src/client/palette.js
CHANGED
|
@@ -340,7 +340,22 @@ function computePalette(now) {
|
|
|
340
340
|
else if ((phase || FALLBACK_PHASE) !== 'night') stars = 0;
|
|
341
341
|
|
|
342
342
|
const sun = sunOf(sunPosition, colors);
|
|
343
|
-
|
|
343
|
+
let moon = moonOf(bodyPosition(source.moon), colors, moonPhase);
|
|
344
|
+
|
|
345
|
+
// 夜里一定要有月亮:真实天象里月亮完全可能整夜在地平线下,但"夜里抬头就该看见月亮"是
|
|
346
|
+
// 产品的取舍(用户明确要求)。所以夜间且真实月亮不可见时,按同一月相画一轮在天上,
|
|
347
|
+
// 位置和亮度都取保守值 —— 月相仍然来自宿主的朔望周期计算,不是随机数。
|
|
348
|
+
const nightish = sunAltitude !== null ? sunAltitude < -6 : (phase || FALLBACK_PHASE) === 'night';
|
|
349
|
+
if (!moon && nightish) {
|
|
350
|
+
const lit = illuminatedFraction(moonPhase);
|
|
351
|
+
moon = {
|
|
352
|
+
x: 0.62,
|
|
353
|
+
y: 0.2,
|
|
354
|
+
color: colors.moonColor,
|
|
355
|
+
alpha: round(clamp01(0.4 + 0.55 * lit), 3),
|
|
356
|
+
phase: moonPhase,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
344
359
|
|
|
345
360
|
return {
|
|
346
361
|
skyTop: colors.skyTop,
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { useEffect, useRef } from 'react';
|
|
20
20
|
import { paletteFor, mix } from './palette.js';
|
|
21
21
|
import { createWaterField, drawWater } from './water.js';
|
|
22
|
+
import { daylightOf, drawBirds } from './birds.js';
|
|
22
23
|
|
|
23
24
|
const TAU = Math.PI * 2;
|
|
24
25
|
|
|
@@ -569,6 +570,15 @@ function renderScene(ctx, W, H, t, pal, tideN, warmth, field) {
|
|
|
569
570
|
drawSun(ctx, W, H, pal);
|
|
570
571
|
drawMoon(ctx, W, H, pal);
|
|
571
572
|
|
|
573
|
+
// 白天的海鸥:只用时间函数决定航线,所以停帧/恢复不会"补飞",掉帧也不会多出或少了鸟
|
|
574
|
+
if (daylightOf(pal) > 0.02) {
|
|
575
|
+
try {
|
|
576
|
+
drawBirds(ctx, { W, H, horizonY, t, pal });
|
|
577
|
+
} catch (_error) {
|
|
578
|
+
/* 鸟画不出来不影响海 */
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
572
582
|
// 水面:**斑块场**(不是一排排浪脊)。
|
|
573
583
|
// 低分辨率高度场按透视采样噪声 → 整块放大 → 随机的碎块、近大远小、无贯通全屏的条纹。
|
|
574
584
|
// 失败(拿不到缓冲)就退回一版最简单的竖向渐变,绝不留白。
|
package/src/client/water.js
CHANGED
|
@@ -161,16 +161,29 @@ export function drawWater(ctx, view, field) {
|
|
|
161
161
|
const wz = zc - scroll
|
|
162
162
|
const phaseBase = d * LAYERS - t * SPEED
|
|
163
163
|
// 涌浪振幅随深度变大(近处高),远处收得很小,免得地平线附近糊成一片
|
|
164
|
-
const swellAmp = 0.
|
|
164
|
+
const swellAmp = 0.30 + 0.52 * d
|
|
165
165
|
for (let i = 0; i < bw; i += 1) {
|
|
166
166
|
const wx = ((i + 0.5) / bw - 0.5) * zc * XSPREAD
|
|
167
167
|
// 相位扭曲:让浪脊是弧形/被绊住,而不是一条笔直的横线
|
|
168
168
|
const wobble = (vnoise(wx * 0.85 + 9.7, wz * 0.55 + 3.3) - 0.5) * 0.62
|
|
169
|
-
|
|
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)
|
|
170
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)
|
|
171
179
|
const detail = fbmSmooth(wx * XFREQ * 1.9 + 11.3, wz * WFREQ * 0.72 + 5.1)
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
174
187
|
heights[idx] = h < 0 ? 0 : h > 1 ? 1 : h
|
|
175
188
|
idx += 1
|
|
176
189
|
}
|
|
@@ -227,10 +240,12 @@ export function drawWater(ctx, view, field) {
|
|
|
227
240
|
}
|
|
228
241
|
|
|
229
242
|
// 白沫:只在近处、只在最高的那批碎块上,斑块状
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
232
247
|
if (mask > 0.01) {
|
|
233
|
-
const a = mask * 0.
|
|
248
|
+
const a = mask * (0.18 + 0.3 * clamp01(foamBand * 2))
|
|
234
249
|
r = lerp(r, foam[0], a)
|
|
235
250
|
g = lerp(g, foam[1], a)
|
|
236
251
|
b = lerp(b, foam[2], a)
|
|
@@ -287,7 +302,7 @@ export function drawWater(ctx, view, field) {
|
|
|
287
302
|
function drawGlitter(ctx, { W, H, horizonY, t, pal, exposure, bodyX, bodyAlpha, glow }) {
|
|
288
303
|
const waterH = H - horizonY
|
|
289
304
|
if (waterH <= 2) return
|
|
290
|
-
const count = Math.round((
|
|
305
|
+
const count = Math.round((70 + 240 * exposure) * clamp01(bodyAlpha + 0.2))
|
|
291
306
|
const sunU = bodyX
|
|
292
307
|
ctx.save()
|
|
293
308
|
for (let n = 0; n < count; n += 1) {
|
|
@@ -297,13 +312,14 @@ function drawGlitter(ctx, { W, H, horizonY, t, pal, exposure, bodyX, bodyAlpha,
|
|
|
297
312
|
// 越靠下(近处)越密,横向集中在天体附近(±0.28 屏宽)
|
|
298
313
|
const d = Math.pow(seedA, 0.72) // 0 远 → 1 近
|
|
299
314
|
const y = horizonY + d * waterH
|
|
300
|
-
|
|
315
|
+
// 横向收窄:碎光集中在天体正下方,连成一条"光路"(真实海面最显眼的线索之一)
|
|
316
|
+
const spread = 0.10 + 0.30 * d
|
|
301
317
|
const x = (sunU + (seedB - 0.5) * 2 * spread) * W
|
|
302
318
|
if (x < 0 || x > W) continue
|
|
303
319
|
const twinkle = 0.35 + 0.65 * Math.abs(Math.sin(t * (0.45 + seedC * 1.1) + seedC * TAU))
|
|
304
|
-
const alpha = twinkle * (0.
|
|
320
|
+
const alpha = twinkle * (0.14 + 0.42 * exposure) * clamp01(1 - Math.abs(seedB - 0.5) * 1.25)
|
|
305
321
|
if (alpha < 0.03) continue
|
|
306
|
-
const len = 1 + 1.
|
|
322
|
+
const len = 1 + 1.9 * d + seedC * 1.2
|
|
307
323
|
ctx.fillStyle = `rgba(${glow[0] | 0},${glow[1] | 0},${glow[2] | 0},${alpha.toFixed(3)})`
|
|
308
324
|
ctx.fillRect(x, y, len, 1)
|
|
309
325
|
}
|