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 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; stars at night; polar day/night handled separately.
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
  - **形态**:整屏 / 右下角小窗(可拖拽、吸附四角),两者共享同一次浮现的剩余时长。
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
- const moon = moonOf(bodyPosition(source.moon), colors, moonPhase);
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.34 + 0.62 * d;
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 swell = Math.sin((phaseBase + wobble) * TAU);
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.46 + (detail - 0.5) * 0.26;
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
- if (foamBand > 0.01) {
470
- const mask = clamp012((h - (0.66 - 0.12 * foamBand)) / 0.26) * foamBand;
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.42;
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((40 + 150 * exposure) * clamp012(bodyAlpha + 0.2));
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.18 + 0.34 * d;
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.12 + 0.34 * exposure) * clamp012(1 - Math.abs(seedB - 0.5) * 1.9);
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.6 * d + seedC;
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 TAU2 = Math.PI * 2;
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 clamp013(value) {
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(clamp013(alpha), 3) + ")";
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 - clamp013(pal.exposure)));
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 = clamp013(pal.stars);
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, clamp013(density * twinkle) * 0.85);
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 = clamp013(sun.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, TAU2);
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, TAU2);
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 = clamp013(moon.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 = clamp013(num2(moon.phase, 0.5));
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, TAU2);
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(TAU2 * p)));
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 = TAU2 * L1.freq;
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 * TAU2 * L1.freq)) * px;
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) * TAU2, t));
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 - clamp013(pal.exposure));
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 = clamp013(0.3 + 0.5 * (1 - clamp013(pal.exposure)));
762
- const fogHeight = H * (0.045 + 0.085 * (1 - clamp013(pal.exposure)));
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 * clamp013(pal.exposure));
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 clamp013(num2(flat, 0.55));
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 clamp013(num2(flat, 0.5));
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 = clamp013(alpha);
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 = clamp013(grade);
914
+ const g = clamp014(grade);
823
915
  if (g <= 0.01) return;
824
- const exposure = clamp013(pal.exposure);
916
+ const exposure = clamp014(pal.exposure);
825
917
  const lowSun = 1 - exposure;
826
- const darkness = clamp013((0.95 - exposure) / 0.65);
827
- const tint = mix(pal.seaMid, pal.horizonGlow, clamp013(0.2 + 0.75 * lowSun));
828
- const shade = mix(tint, "#03060e", clamp013(0.4 + 0.55 * darkness));
829
- compositeFill(ctx, "multiply", shade, clamp013(g * (0.08 + 0.9 * darkness)), W, H);
830
- if (darkness > 0.2) compositeFill(ctx, "multiply", "#04070f", clamp013(g * 0.4 * (darkness - 0.2) / 0.8), W, H);
831
- compositeFill(ctx, "screen", mix(pal.horizonGlow, "#ffffff", 0.5), clamp013(g * (0.1 + 0.16 * exposure)), W, H);
832
- compositeFill(ctx, "soft-light", tint, clamp013(g * 0.55), W, H);
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 = clamp013(grade);
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 * clamp013(grade)) * (0.5 + 0.5 * clamp013(motion));
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 = clamp013(strength);
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 = clamp013(num2(asset.seed, 0));
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(TAU2 * phase);
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(TAU2 * phase + seed * TAU2);
904
- const panY = marginY * 0.5 * motion * Math.cos(TAU2 * phase * 0.71 + seed * 5.1);
905
- const breath = motion > 0.02 ? Math.sin(TAU2 * MEDIA.breathFreq * t) * MEDIA.breathPx * (0.4 + 0.6 * motion) : 0;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-intertidal",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "潮间(Intertidal):让 DSH 按设定间隔把屏幕交给一片此刻的海,到点自己退走。天光跟随本地太阳位置,可用自己的图片或视频作海面。不采集数据。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
+ }
@@ -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
- const moon = moonOf(bodyPosition(source.moon), colors, moonPhase);
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
  // 失败(拿不到缓冲)就退回一版最简单的竖向渐变,绝不留白。
@@ -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.34 + 0.62 * d
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
- const swell = Math.sin((phaseBase + wobble) * TAU)
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
- const h = 0.5 + 0.5 * swell * swellAmp + (ridge - 0.5) * 0.46 + (detail - 0.5) * 0.26
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
- if (foamBand > 0.01) {
231
- const mask = clamp01((h - (0.66 - 0.12 * foamBand)) / 0.26) * foamBand
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.42
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((40 + 150 * exposure) * clamp01(bodyAlpha + 0.2))
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
- const spread = 0.18 + 0.34 * d
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.12 + 0.34 * exposure) * clamp01(1 - Math.abs(seedB - 0.5) * 1.9)
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.6 * d + seedC
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
  }