-
-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathWaterClock.py
More file actions
2779 lines (2508 loc) · 103 KB
/
Copy pathWaterClock.py
File metadata and controls
2779 lines (2508 loc) · 103 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# =====================================================================================
# WATER CLOCK — shaded HH:MM with sloshing water
#
# Fixed upper seat: HH:MM in a soft multi-shade 3x5 digit font (scaled).
# Tide water washes around solid clock glyphs. A little sailboat tries to
# cross shore-to-shore under wind + current. Once a minute the water rises
# Starts calm at the pier with the boat ready to sail. Later: five-minute
# fishing trips, dock unload with scoreboard, cast off, weather resumes.
# Yellow sun + pale moon follow Ottawa civil times on a fixed sky path.
#
# Launch:
# LEDsim key 6 / LEDpanel / action "waterclock" / ?waterclock
# =====================================================================================
from __future__ import annotations
import math
import random
import time
from datetime import date, datetime, timedelta, timezone
import LEDarcade as LED
LED.Initialize()
try:
import pygame
HAS_PYGAME = True
except Exception:
HAS_PYGAME = False
# ---------------- Configuration ----------------
TARGET_FPS = 28
USE_24H = True
# Digit look (3×5 base from LEDarcade DigitList, scaled up)
DIGIT_W0 = 3
DIGIT_H0 = 5
DIGIT_ZOOM = 2 # → 6×10 glyphs
DIGIT_GAP = 2
COLON_W = 2
# Soft cool-white with cyan/blue shading (reads well over dark + water)
DIGIT_HI = (230, 245, 255) # top/left highlight
DIGIT_MID = (160, 200, 230) # body
DIGIT_LO = (60, 100, 140) # bottom/right shade
DIGIT_EDGE = (30, 50, 75) # soft outline
COLON_RGB = (180, 220, 255)
# ---- Tide water (rise / drain / slosh) ----
# Tall enough that high tide can cover the lower part of the HH:MM glyphs
WATER_BAND_FRAC = 0.62 # fraction of height reserved for water
WATER_MIN_FRAC = 0.10 # min mean fill of the band (calm low tide)
WATER_MAX_FRAC = 0.98 # max mean fill — reaches bottom of the clock
MAX_DROPLETS = 24 # was 48 — half the splash particles
SLOSH_PERIOD = 4.6 # seconds per left↔right slosh (slower = calmer)
LEVEL_PERIOD = 18.0 # seconds for a full rise/drain breathe
VISCOSITY = 0.32 # surface smoothing (higher = flatter / calmer)
# Base slosh is mild; sea-state multiplies this (calm ~low, rough ~high)
SLOSH_STRENGTH = 0.22 # calm-default wave tilt as fraction of band
WATER_GRAVITY = 0.18
SPLASH_CHANCE = 0.02 # was 0.04 — half the splash spawn rate
# Sea state: biased calm; rough bursts are shorter but can get wild
SEA_CALM_CHANCE = 0.72 # probability a new weather spell is calm
SEA_CALM_DUR = (9.0, 20.0) # calm spell length (seconds)
SEA_ROUGH_DUR = (2.5, 6.5) # rough spell length (shorter storms)
SEA_CALM_SLOSH = (0.35, 0.70) # multiplier on SLOSH_STRENGTH while calm
SEA_ROUGH_SLOSH = (1.8, 3.2) # multiplier while rough (pretty rough)
SEA_CALM_WAVE = 0.08 # column wave amp as fraction of band
SEA_ROUGH_WAVE = 0.36 # column wave amp when rough
# Sailboat + wind / current
BOAT_SPEED = 4.8 # px/sec sail thrust baseline
BOAT_EDGE_PAD = 3
BOAT_GOAL_MARGIN = 4 # how close to a shore counts as "arrived"
# Forces on the hull (multipliers on normalized wind/current in [-1, 1])
CURRENT_PUSH = 3.2 # water current drift (px/sec at full current)
WIND_DRIFT = 1.4 # hull drift from wind even when not sailing well
WIND_SAIL_BONUS = 3.6 # extra speed when wind fills the sail (same way)
WIND_SAIL_BEAT = 0.42 # fraction of sail power when beating into wind
# Wind field (independent of water slosh current)
WIND_PERIOD = 11.0 # slow wind shift left↔right
WIND_GUST_CHANCE = 0.012
WIND_GUST_MIN = 1.2
WIND_GUST_MAX = 2.8
# Drop anchor when seas stay calm long enough (thresholds randomized per stop)
CALM_CURRENT = 0.16 # |tide flow| below this = calm current
CALM_WIND = 0.28 # |wind| below this = calm air
CALM_NEED_MIN = 2.0 # seconds of calm before considering anchor
CALM_NEED_MAX = 5.5
ANCHOR_MIN = 3.5 # stay put at least this long once anchored
ANCHOR_MAX = 10.0
ROUGH_WEIGH = 0.9 # seconds of rough water before weighing anchor
BOAT_HULL = (170, 100, 45)
BOAT_HULL_DARK = (110, 60, 25)
BOAT_SAIL = (245, 245, 255)
BOAT_MAST = (90, 65, 40)
BOAT_ANCHOR = (140, 140, 150) # chain / anchor pixel
# Fishing line + hook (minute calm)
LINE_RGB = (160, 160, 170)
HOOK_RGB = (220, 200, 70)
HOOK_TIP_RGB = (255, 240, 120) # bright point of the J
# ---- Fishing trip (calm → fish 5 min → dock → unload → sail off) ----
# After FISHING_INTERVAL: water rises + wind dies, boat fishes for
# FISHING_DURATION. Then calms home to a wooden pier on the right, unloads,
# shows catch count on a dock display, sails the other way, weather resumes.
FISHING_INTERVAL = 60.0 # seconds between full trips
FISHING_DURATION = 300.0 # five minutes of fishing
START_READY_HOLD = 5.0 # opening: docked at pier before first cast-off
FISHING_RISE_FRAC = 0.96 # target mean water fill while fishing / dock calm
FISHING_LINE_MIN = 3.0 # shallow drop (surface school)
FISHING_LINE_MAX = 11.0 # deep drop (bottom dwellers)
FISHING_LINE_SPEED = 2.8 # line pay-out px/sec
FISHING_REEL_SPEED = 5.5 # reel-in speed when a fish is on
FISHING_DEPTH_HOLD = (2.5, 5.5) # seconds at a depth before re-dropping
FISH_COUNT_MIN = 3
FISH_COUNT_MAX = 6
FISH_SPEED = (3.5, 7.5) # px/sec (small/medium)
FISH_SPEED_BIG = (2.2, 4.5) # bigger fish are slower
FISH_INVESTIGATE_RANGE = 14.0 # how far fish notice the hook
FISH_HOOK_TOUCH = 1.35 # distance to count as touching the J
FISH_CATCH_FLASH = 1.4 # seconds the catch sits in the boat
FISH_RARE_CHANCE = 0.10 # chance of the rare big red per school
FISH_RESPAWN_EVERY = 18.0 # restock water during long fishing trips
BOAT_HOME_SPEED = 6.5 # px/sec sailing to pier / away
DOCK_HOLD = 0.6 # brief settle after tip touches dock
SAIL_LOWER_TIME = 2.2 # seconds to lower the sail before unload
UNLOAD_INTERVAL = 0.38 # seconds between fish tossed onto the pier
SCORE_HOLD = 2.8 # hold scoreboard after last fish
BOAT_BOW_OFFSET = 2 # hull-center → bow tip (sprite nose at +2)
PIER_WOOD = (150, 95, 45)
PIER_WOOD_DARK = (95, 55, 25)
PIER_POST = (110, 70, 35)
SCORE_RGB = (40, 255, 120) # dock digital display
FISH_COLORS = (
(255, 160, 40),
(255, 90, 70),
(240, 220, 60),
(100, 200, 255),
(200, 120, 255),
(90, 230, 140),
)
FISH_RARE_RED = (230, 20, 35) # rare big red
# Sun / Moon (Ottawa, Canada civil times → fixed sky path on panel)
OTTAWA_LAT = 45.4215
OTTAWA_LON = -75.6972 # west negative
SUN_CORE = (255, 230, 50)
SUN_GLOW = (255, 170, 30)
SUN_HALO = (180, 90, 15)
SUN_EDGE_PAD = 1 # inset from panel rim along the path
MOON_CORE = (230, 235, 250) # cool silver
MOON_GLOW = (150, 160, 195)
MOON_HALO = (70, 80, 110)
MOON_EDGE_PAD = 1
# Upper-limb + refraction horizon (deg below geometric) for moon rise/set
MOON_HORIZON_ALT = -0.833
# Static narrow oval reflection on the water (same color as the body)
REFLECT_NEAR = 11.0 # start when body is within this many px of waterline
REFLECT_MAX = 0.72 # peak mix of sun/moon color into water
REFLECT_OVAL_RX = 1.35 # horizontal half-width of the oval (px)
REFLECT_OVAL_RY = 3.2 # vertical half-height of the oval (px, down into water)
BG = (0, 0, 4)
# Water palette (deep → surface foam)
WATER_DEEP = (8, 35, 90)
WATER_MID = (20, 90, 170)
WATER_TOP = (70, 170, 230)
WATER_FOAM = (180, 230, 255)
WATER_DROP = (120, 200, 255)
def _stop(StopEvent):
try:
return StopEvent is not None and StopEvent.is_set()
except Exception:
return False
def _clamp(v, lo, hi):
return lo if v < lo else hi if v > hi else v
def _lerp(a, b, t):
return a + (b - a) * t
def _lerp_rgb(c0, c1, t):
t = _clamp(t, 0.0, 1.0)
return (
int(c0[0] + (c1[0] - c0[0]) * t),
int(c0[1] + (c1[1] - c0[1]) * t),
int(c0[2] + (c1[2] - c0[2]) * t),
)
# ---------------- Shaded digits ----------------
def _digit_grid(d):
d = int(d) % 10
try:
g = LED.DigitList[d]
if len(g) >= DIGIT_W0 * DIGIT_H0:
return g
except Exception:
pass
return [1] * (DIGIT_W0 * DIGIT_H0)
def _digit_pixel_size():
return DIGIT_W0 * DIGIT_ZOOM, DIGIT_H0 * DIGIT_ZOOM
def _clock_total_width():
dw, _dh = _digit_pixel_size()
# HH : MM → 4 digits + colon + 3 gaps between the 5 pieces
return 4 * dw + COLON_W + 3 * DIGIT_GAP
def _shade_for_cell(lx, ly, on_left, on_up, on_right, on_down):
"""
Multi-stop shade: highlight top-left, mid body, deeper bottom-right.
Edge cells get a slight edge tone for definition.
"""
# Normalized position in glyph
nx = lx / max(1, DIGIT_W0 - 1)
ny = ly / max(1, DIGIT_H0 - 1)
# Prefer highlight when more "upper-left"
hi = (1.0 - nx) * 0.55 + (1.0 - ny) * 0.45
if hi > 0.62:
base = _lerp_rgb(DIGIT_MID, DIGIT_HI, (hi - 0.62) / 0.38)
elif hi < 0.38:
base = _lerp_rgb(DIGIT_LO, DIGIT_MID, hi / 0.38)
else:
base = DIGIT_MID
# Soft outline where a neighbor is empty
edge = (not on_left) or (not on_up) or (not on_right) or (not on_down)
if edge:
base = _lerp_rgb(base, DIGIT_EDGE, 0.28)
return base
def draw_shaded_digit(canvas, ox, oy, digit, width, height):
grid = _digit_grid(digit)
z = DIGIT_ZOOM
def on(cx, cy):
if not (0 <= cx < DIGIT_W0 and 0 <= cy < DIGIT_H0):
return False
return bool(grid[cy * DIGIT_W0 + cx])
for ly in range(DIGIT_H0):
for lx in range(DIGIT_W0):
if not on(lx, ly):
continue
rgb = _shade_for_cell(
lx, ly,
on(lx - 1, ly), on(lx, ly - 1),
on(lx + 1, ly), on(lx, ly + 1),
)
for zv in range(z):
for zh in range(z):
# Sub-pixel shade: upper-left micro-highlight inside block
if zh == 0 and zv == 0 and z > 1:
c = _lerp_rgb(rgb, DIGIT_HI, 0.35)
elif zh == z - 1 and zv == z - 1 and z > 1:
c = _lerp_rgb(rgb, DIGIT_LO, 0.40)
else:
c = rgb
sx = ox + lx * z + zh
sy = oy + ly * z + zv
if 0 <= sx < width and 0 <= sy < height:
canvas.SetPixel(sx, sy, c[0], c[1], c[2])
def draw_colon(canvas, ox, oy, digit_h, blink_on, width, height):
if not blink_on:
return
# Two square dots vertically centered in digit height
dot = max(1, DIGIT_ZOOM)
cx = ox + max(0, (COLON_W - dot) // 2)
y1 = oy + digit_h // 3 - dot // 2
y2 = oy + (2 * digit_h) // 3 - dot // 2
for dy in range(dot):
for dx in range(dot):
for y, bright in ((y1 + dy, 1.0), (y2 + dy, 0.85)):
sx, sy = cx + dx, y
if 0 <= sx < width and 0 <= sy < height:
r = int(COLON_RGB[0] * bright)
g = int(COLON_RGB[1] * bright)
b = int(COLON_RGB[2] * bright)
canvas.SetPixel(sx, sy, r, g, b)
def _clock_origin(width, height):
"""
Fixed upper-center seat for HH:MM — never moves with the tide.
Leaves the lower band free for water; high tide can wet the glyph bottoms.
"""
dw, dh = _digit_pixel_size()
total_w = _clock_total_width()
ox = max(0, (width - total_w) // 2)
# Sit in the upper half, slightly above vertical center of the dry band
dry_h = max(dh + 2, height - int(height * WATER_BAND_FRAC))
oy = max(1, (dry_h - dh) // 2)
# Keep a little air under the glyphs so water can crest around them
oy = min(oy, max(1, height // 2 - dh - 1))
return ox, oy, dw, dh
def _now_digits():
now = time.localtime()
hour = now.tm_hour
if not USE_24H:
hour = hour % 12
if hour == 0:
hour = 12
return (
hour // 10, hour % 10,
now.tm_min // 10, now.tm_min % 10,
)
def _collect_digit_solid(ox, oy, digit, solid):
"""Add solid (on) pixels of one scaled digit into a set of (x, y)."""
grid = _digit_grid(digit)
z = DIGIT_ZOOM
for ly in range(DIGIT_H0):
for lx in range(DIGIT_W0):
if not grid[ly * DIGIT_W0 + lx]:
continue
for zv in range(z):
for zh in range(z):
solid.add((ox + lx * z + zh, oy + ly * z + zv))
def build_clock_solid_mask(width, height, blink_on=True):
"""
Pixel set occupied by the fixed clock glyphs (digits + colon when lit).
Water rendering skips these so the tide washes *around* the time.
"""
ox, oy, dw, dh = _clock_origin(width, height)
digits = _now_digits()
solid = set()
x = ox
_collect_digit_solid(x, oy, digits[0], solid)
x += dw + DIGIT_GAP
_collect_digit_solid(x, oy, digits[1], solid)
x += dw + DIGIT_GAP
if blink_on:
dot = max(1, DIGIT_ZOOM)
cx = x + max(0, (COLON_W - dot) // 2)
y1 = oy + dh // 3 - dot // 2
y2 = oy + (2 * dh) // 3 - dot // 2
for dy in range(dot):
for dx in range(dot):
solid.add((cx + dx, y1 + dy))
solid.add((cx + dx, y2 + dy))
x += COLON_W + DIGIT_GAP
_collect_digit_solid(x, oy, digits[2], solid)
x += dw + DIGIT_GAP
_collect_digit_solid(x, oy, digits[3], solid)
return solid
def draw_time(canvas, width, height):
"""HH:MM at a fixed upper-center seat (independent of water level)."""
ox, oy, dw, dh = _clock_origin(width, height)
digits = _now_digits()
blink = (int(time.time()) % 2) == 0
x = ox
draw_shaded_digit(canvas, x, oy, digits[0], width, height)
x += dw + DIGIT_GAP
draw_shaded_digit(canvas, x, oy, digits[1], width, height)
x += dw + DIGIT_GAP
draw_colon(canvas, x, oy, dh, blink, width, height)
x += COLON_W + DIGIT_GAP
draw_shaded_digit(canvas, x, oy, digits[2], width, height)
x += dw + DIGIT_GAP
draw_shaded_digit(canvas, x, oy, digits[3], width, height)
# ---------------- Water simulation ----------------
class WaterSim(object):
"""
Column surface fluid + free droplets:
- Mean level rises and drains over LEVEL_PERIOD (high tide can wet clock)
- Sea state biased calm; rough spells are shorter but can get wild
- Traveling/sloshing bias pushes water left/right
- Neighbor viscosity keeps the surface coherent
- Droplets splash when the surface is agitated (half the old rate)
"""
def __init__(self, width, height):
self.w = int(width)
self.h = int(height)
self.band = max(4, int(round(self.h * WATER_BAND_FRAC)))
self.floor_y = self.h - 1
self.band_top = self.h - self.band
self.level = [self.band * 0.35 for _ in range(self.w)]
self.flow = [0.0 for _ in range(self.w)]
self.droplets = []
self.t = 0.0
self.level_phase = random.uniform(0, math.pi * 2)
self.slosh_phase = random.uniform(0, math.pi * 2)
self.event_t = 0.0
self.event_bias = 0.0
# Sea state: start calm more often than rough
self.sea_rough = False
self.sea_t = 0.0
self.sea_slosh_mul = 0.5
self.sea_wave_frac = SEA_CALM_WAVE
self.fishing = False # minute calm: high flat water
self._roll_sea_state(force_calm=True)
def set_fishing(self, active):
"""Enter/leave the once-a-minute high, glassy-water fishing calm."""
was = self.fishing
self.fishing = bool(active)
if self.fishing and not was:
self.sea_rough = False
self.sea_slosh_mul = 0.08
self.sea_wave_frac = 0.02
self.sea_t = FISHING_DURATION + 2.0
self.event_bias = 0.18
self.event_t = FISHING_DURATION + 2.0
self.droplets = []
elif not self.fishing and was:
self._roll_sea_state(force_calm=True)
self.event_t = 0.5
def _roll_sea_state(self, force_calm=False):
"""Pick a new calm or rough spell (biased toward calm)."""
if self.fishing:
self.sea_rough = False
self.sea_slosh_mul = 0.08
self.sea_wave_frac = 0.02
self.sea_t = 4.0
return
if force_calm or random.random() < SEA_CALM_CHANCE:
self.sea_rough = False
self.sea_slosh_mul = random.uniform(*SEA_CALM_SLOSH)
self.sea_wave_frac = SEA_CALM_WAVE * random.uniform(0.7, 1.15)
self.sea_t = random.uniform(*SEA_CALM_DUR)
else:
self.sea_rough = True
self.sea_slosh_mul = random.uniform(*SEA_ROUGH_SLOSH)
self.sea_wave_frac = SEA_ROUGH_WAVE * random.uniform(0.85, 1.2)
self.sea_t = random.uniform(*SEA_ROUGH_DUR)
def update(self, dt, solid_mask=None):
self.t += dt
# Weather / sea state — calm dominates; rough is brief but strong
# (frozen glassy during fishing calm)
if not self.fishing:
self.sea_t -= dt
if self.sea_t <= 0.0:
self._roll_sea_state()
if not self.fishing:
self.event_t -= dt
if self.event_t <= 0.0:
# Tide bias: mild most of the time; high-water crests more often
# so the surface can climb over the lower clock rows
r = random.random()
if r < 0.50:
# Quiet mid/low water
self.event_bias = random.uniform(-0.18, 0.06)
elif r < 0.78:
# High tide — can cover bottom of the time
self.event_bias = random.uniform(0.10, 0.22)
else:
# Drain
self.event_bias = random.uniform(-0.28, -0.10)
self.event_t = random.uniform(3.5, 7.5)
if self.fishing:
# Rise gently toward a high, flat waterline — almost no breathe/slosh
mean_frac = FISHING_RISE_FRAC
target_mean = self.band * mean_frac
slosh_amp = 0.0
wave_amp = 0.0
track = 0.55 # slow, gentle rise
flow_push = 0.0
max_level = float(self.band) + 2.0
else:
breathe = 0.5 + 0.5 * math.sin(
self.t * (2 * math.pi / LEVEL_PERIOD) + self.level_phase
)
# Soften the breathe toward mid when calm so level isn't always slamming
if not self.sea_rough:
breathe = 0.5 + (breathe - 0.5) * 0.72
mean_frac = _clamp(
_lerp(WATER_MIN_FRAC, WATER_MAX_FRAC, breathe) + self.event_bias,
WATER_MIN_FRAC * 0.5,
1.05, # slight overshoot so high tide can crest into the glyphs
)
target_mean = self.band * mean_frac
# Slosh slower when calm, a bit snappier when rough
slosh_rate = (2 * math.pi / SLOSH_PERIOD) * (1.35 if self.sea_rough else 0.85)
self.slosh_phase += dt * slosh_rate
slosh_amp = SLOSH_STRENGTH * self.sea_slosh_mul
wave_amp = self.sea_wave_frac * self.band
track = 2.4 if self.sea_rough else 1.15
flow_push = 0.55 if self.sea_rough else 0.22
max_level = float(self.band) + (2.5 if mean_frac > 0.9 else 1.0)
tilt = math.sin(self.slosh_phase) * slosh_amp * self.band
wave_k = 2 * math.pi / max(8.0, self.w * 0.85)
desired = [0.0] * self.w
for x in range(self.w):
wave = math.sin(x * wave_k + self.slosh_phase * 1.3) * wave_amp
nx = (x / max(1, self.w - 1)) * 2.0 - 1.0
des = target_mean + tilt * (-nx) * 0.55 + wave
desired[x] = _clamp(des, 0.0, max_level)
# Surface tracks desired more gently when calm
new_level = [0.0] * self.w
for x in range(self.w):
L = self.level[x]
L += (desired[x] - L) * min(1.0, track * dt)
if 0 < x < self.w - 1:
avg = (self.level[x - 1] + self.level[x] + self.level[x + 1]) / 3.0
# Extra viscosity when calm → flatter surface
visc = VISCOSITY * (0.75 if self.sea_rough else 1.15)
if self.fishing:
visc = min(0.95, VISCOSITY * 1.6)
L = _lerp(L, avg, min(0.9, visc))
flow_dir = 0.0 if self.fishing else math.cos(self.slosh_phase)
self.flow[x] = self.flow[x] * (0.82 if self.fishing else 0.90) + flow_dir * flow_push
new_level[x] = _clamp(L, 0.0, max_level)
advected = list(new_level)
advect_scale = 0.0 if self.fishing else (9.0 if self.sea_rough else 3.5)
for x in range(self.w):
f = self.flow[x] * dt * advect_scale
if abs(f) < 0.01:
continue
dst = int(round(x + f))
if dst == x or not (0 <= dst < self.w):
continue
move = min(0.45 if self.sea_rough else 0.22, abs(f) * 0.08) * new_level[x]
if new_level[x] > move:
advected[x] -= move
advected[dst] = min(max_level, advected[dst] + move)
self.level = [_clamp(v, 0.0, max_level) for v in advected]
# Splash: half the particles/chance; mostly when rough; none while fishing
if self.fishing:
self.droplets = []
return
agitate = abs(math.cos(self.slosh_phase)) * (1.0 if self.sea_rough else 0.35)
splash_p = SPLASH_CHANCE + 0.04 * agitate * (1.0 if self.sea_rough else 0.25)
if len(self.droplets) < MAX_DROPLETS and random.random() < splash_p:
x = random.randint(0, self.w - 1)
surface_y = self.floor_y - self.level[x]
if self.level[x] > 1.5:
self.droplets.append({
"x": float(x) + random.uniform(0, 1),
"y": float(surface_y) - random.uniform(0.2, 1.2),
"vx": self.flow[x] * 0.9 + random.uniform(-0.4, 0.4),
"vy": -random.uniform(0.4, 1.6) * (0.5 + agitate),
})
alive = []
for d in self.droplets:
d["vy"] += WATER_GRAVITY
d["vx"] *= 0.99
d["x"] += d["vx"]
d["y"] += d["vy"]
if d["x"] < 0:
d["x"] = 0.0
d["vx"] = abs(d["vx"]) * 0.5
elif d["x"] >= self.w:
d["x"] = self.w - 0.01
d["vx"] = -abs(d["vx"]) * 0.5
ix = int(_clamp(d["x"], 0, self.w - 1))
surface_y = self.floor_y - self.level[ix]
if d["y"] >= surface_y:
self.level[ix] = min(max_level, self.level[ix] + 0.15)
continue
if d["y"] > self.h + 2:
continue
alive.append(d)
self.droplets = alive
def surface_y_at(self, x):
"""World y of water surface at horizontal x (float)."""
ix = int(_clamp(x, 0, self.w - 1))
return float(self.floor_y) - float(self.level[ix])
def mean_surface_y(self):
"""Average waterline y (for sun set / rise height)."""
if not self.level:
return float(self.h) * 0.7
return sum(self.floor_y - L for L in self.level) / float(len(self.level))
def mean_flow(self):
"""
Horizontal current bias: >0 water pushing right, <0 left.
Matches the slosh phase cos term used in the surface model.
"""
if self.fishing:
return 0.0
return math.cos(self.slosh_phase)
def draw(self, canvas, solid_mask=None):
set_px = canvas.SetPixel
mask = solid_mask or set()
for x in range(self.w):
h_water = self.level[x]
if h_water <= 0.05:
continue
top = int(math.floor(self.floor_y - h_water + 0.5))
top = _clamp(top, 0, self.floor_y)
depth = self.floor_y - top + 1
for y in range(top, self.floor_y + 1):
if (x, y) in mask:
continue
t = (y - top) / float(max(1, depth - 1)) if depth > 1 else 1.0
if y == top:
rgb = WATER_FOAM if h_water > 0.8 else WATER_TOP
elif t < 0.35:
rgb = _lerp_rgb(WATER_TOP, WATER_MID, t / 0.35)
else:
rgb = _lerp_rgb(WATER_MID, WATER_DEEP, (t - 0.35) / 0.65)
set_px(x, y, rgb[0], rgb[1], rgb[2])
if h_water > 1.2 and (x + int(self.t * 7)) % 11 == 0:
if 0 <= top < self.h and (x, top) not in mask:
set_px(x, top, WATER_FOAM[0], WATER_FOAM[1], WATER_FOAM[2])
for d in self.droplets:
sx = int(d["x"])
sy = int(d["y"])
if 0 <= sx < self.w and 0 <= sy < self.h and (sx, sy) not in mask:
set_px(sx, sy, WATER_DROP[0], WATER_DROP[1], WATER_DROP[2])
ty = sy + 1
if 0 <= ty < self.h and (sx, ty) not in mask:
set_px(
sx, ty,
WATER_MID[0] // 2, WATER_MID[1] // 2, WATER_MID[2] // 2,
)
# ---------------- Ottawa sun (civil sunrise → sunset) ----------------
def _nth_weekday_of_month(year, month, weekday, n):
"""weekday: Mon=0 .. Sun=6; n: 1=first, 2=second, …"""
d = date(year, month, 1)
# advance to first desired weekday
add = (weekday - d.weekday()) % 7
d = d + timedelta(days=add + 7 * (n - 1))
return d
def _eastern_offset_for_date(d: date):
"""
America/Toronto style offset: EDT (UTC−4) from 2nd Sunday of March
through the day before the 1st Sunday of November; else EST (UTC−5).
Reliable without system tzdata.
"""
# 2nd Sunday of March
dst_start = _nth_weekday_of_month(d.year, 3, 6, 2)
# 1st Sunday of November
dst_end = _nth_weekday_of_month(d.year, 11, 6, 1)
if dst_start <= d < dst_end:
return timedelta(hours=-4)
return timedelta(hours=-5)
def _ottawa_tz_for_date(d: date):
return timezone(_eastern_offset_for_date(d))
def _ottawa_now():
# Use UTC wall clock then attach Eastern offset for "now"
utc = datetime.now(timezone.utc)
off = _eastern_offset_for_date(utc.date())
# Recompute with local date after offset (DST boundary edge is fine for clock art)
local = utc.astimezone(timezone(off))
return local.replace(tzinfo=_ottawa_tz_for_date(local.date()))
def _sun_times_ottawa(on_date: date):
"""
Civil sunrise / sunset for Ottawa on the given local date.
Uses the classic USNO/Almanac algorithm (good to a couple of minutes).
Returns (sunrise, sunset) as America/Toronto-aware datetimes.
"""
lat = OTTAWA_LAT
lon = OTTAWA_LON # east-positive
zenith = 90.833 # civil sunrise/set
d2r = math.pi / 180.0
# Day of year
n1 = math.floor(275 * on_date.month / 9)
n2 = math.floor((on_date.month + 9) / 12)
n3 = 1 + math.floor((on_date.year - 4 * math.floor(on_date.year / 4) + 2) / 3)
n = int(n1 - (n2 * n3) + on_date.day - 30)
lng_hour = lon / 15.0
def _event_utc_hours(rising: bool):
t = n + ((6.0 - lng_hour) / 24.0) if rising else n + ((18.0 - lng_hour) / 24.0)
m_anom = (0.9856 * t) - 3.289
l_sun = (
m_anom
+ (1.916 * math.sin(m_anom * d2r))
+ (0.020 * math.sin(2 * m_anom * d2r))
+ 282.634
) % 360.0
ra = math.degrees(math.atan(0.91764 * math.tan(l_sun * d2r))) % 360.0
l_quad = math.floor(l_sun / 90.0) * 90.0
ra_quad = math.floor(ra / 90.0) * 90.0
ra = (ra + (l_quad - ra_quad)) / 15.0
sin_dec = 0.39782 * math.sin(l_sun * d2r)
cos_dec = math.cos(math.asin(sin_dec))
cos_h = (
math.cos(zenith * d2r) - (sin_dec * math.sin(lat * d2r))
) / (cos_dec * math.cos(lat * d2r))
if cos_h > 1.0 or cos_h < -1.0:
return None
if rising:
h = 360.0 - math.degrees(math.acos(cos_h))
else:
h = math.degrees(math.acos(cos_h))
h = h / 15.0
t_local = h + ra - (0.06571 * t) - 6.622
ut = (t_local - lng_hour) % 24.0
return ut
rise_ut = _event_utc_hours(True)
set_ut = _event_utc_hours(False)
if rise_ut is None or set_ut is None:
return None, None
tz = _ottawa_tz_for_date(on_date)
# UTC midnight of that calendar date + UT hours → Eastern local
utc_midnight = datetime(on_date.year, on_date.month, on_date.day, tzinfo=timezone.utc)
def _ut_to_local(ut_hours):
local = (utc_midnight + timedelta(hours=ut_hours)).astimezone(tz)
if local.date() < on_date:
local += timedelta(days=1)
elif local.date() > on_date:
local -= timedelta(days=1)
return local
return _ut_to_local(rise_ut), _ut_to_local(set_ut)
class Sun(object):
"""
Yellow sun with soft glow.
Path (day fraction 0=sunrise → 1=sunset, Ottawa times):
1) Rise at waterline on the RIGHT edge, climb the right side
2) Cross the TOP right → left (midday / day_t≈0.5 = top-center)
3) Descend the LEFT side down to the waterline (sunset)
Hidden at night. Drawn under lit clock segments so noon can shine
through the unlit gaps of the digits.
"""
def __init__(self, width, height):
self.w = int(width)
self.h = int(height)
self.x = 0.0
self.y = 0.0
self.visible = False
self.day_t = 0.0
self._date = None
self._sunrise = None
self._sunset = None
self._refresh_times(_ottawa_now())
def _refresh_times(self, now):
d = now.date()
if self._date == d and self._sunrise and self._sunset:
return
self._date = d
rise, sett = _sun_times_ottawa(d)
self._sunrise, self._sunset = rise, sett
if rise and sett:
print(
f"[WaterClock] Ottawa sun {d.isoformat()} "
f"rise {rise.strftime('%H:%M')} set {sett.strftime('%H:%M')}"
)
def update(self, water=None):
"""
Position depends only on real Ottawa civil time — not on water motion.
Horizon for rise/set is a fixed panel y (stable sky path).
"""
now = _ottawa_now()
self._refresh_times(now)
if not self._sunrise or not self._sunset:
self.visible = False
return
# Day fraction between civil rise and set (wall-clock only)
t0 = self._sunrise.timestamp()
t1 = self._sunset.timestamp()
tn = now.timestamp()
if tn < t0 or tn > t1 or t1 <= t0:
self.visible = False
return
self.day_t = (tn - t0) / (t1 - t0)
self.visible = True
self.x, self.y = _sky_path_xy(self.day_t, self.w, self.h, SUN_EDGE_PAD)
def draw(self, canvas, solid_mask=None):
if not self.visible:
return
set_px = canvas.SetPixel
mask = solid_mask or set()
cx = int(round(self.x))
cy = int(round(self.y))
# Warmth shifts slightly through the day (cooler noon, warmer rise/set)
edge = abs(self.day_t - 0.5) * 2.0 # 0 noon → 1 rise/set
core = (
255,
int(230 - 40 * edge),
int(50 + 30 * (1.0 - edge)),
)
glow = (
255,
int(170 - 30 * edge),
int(30 + 10 * edge),
)
halo = (
int(180 + 40 * edge),
int(90 - 20 * edge),
15,
)
# Halo (r≈2), glow (r≈1), then hard core
for dy in range(-2, 3):
for dx in range(-2, 3):
d2 = dx * dx + dy * dy
if d2 == 0 or d2 > 4:
continue
sx, sy = cx + dx, cy + dy
if mask and (sx, sy) in mask:
continue
if not (0 <= sx < self.w and 0 <= sy < self.h):
continue
if d2 >= 3:
# outer halo — dim
set_px(sx, sy, halo[0] // 2, halo[1] // 2, halo[2] // 2)
else:
set_px(sx, sy, glow[0], glow[1], glow[2])
# Core pixel on top
if 0 <= cx < self.w and 0 <= cy < self.h:
if not (mask and (cx, cy) in mask):
set_px(cx, cy, core[0], core[1], core[2])
# Extra warm touch next to core along path (subtle 4-neighbor glow)
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
sx, sy = cx + dx, cy + dy
if mask and (sx, sy) in mask:
continue
if 0 <= sx < self.w and 0 <= sy < self.h:
# Don't stomp brighter core; glow already set — reinforce
set_px(sx, sy, glow[0], glow[1], glow[2])
def _body_colors(self):
edge = abs(self.day_t - 0.5) * 2.0
core = (255, int(230 - 40 * edge), int(50 + 30 * (1.0 - edge)))
glow = (255, int(170 - 30 * edge), int(30 + 10 * edge))
return core, glow
def draw_reflection(self, canvas, water, solid_mask=None):
"""Narrow static oval of the sun's color on the water near the horizon."""
if not self.visible or water is None:
return
core, glow = self._body_colors()
_draw_water_reflection(
canvas, water, self.x, self.y, core, glow,
solid_mask=solid_mask, panel_w=self.w, panel_h=self.h,
)
# ---------------- Moon (Ottawa rise → set, same sky path) ----------------
def _julian_date_utc(dt_utc: datetime) -> float:
"""Julian Date for a timezone-aware UTC datetime."""
if dt_utc.tzinfo is None:
dt_utc = dt_utc.replace(tzinfo=timezone.utc)
else:
dt_utc = dt_utc.astimezone(timezone.utc)
y = dt_utc.year
m = dt_utc.month
d = dt_utc.day + (
dt_utc.hour
+ dt_utc.minute / 60.0
+ dt_utc.second / 3600.0
+ dt_utc.microsecond / 3.6e9
) / 24.0
if m <= 2:
y -= 1
m += 12
a = y // 100
b = 2 - a + a // 4
return int(365.25 * (y + 4716)) + int(30.6001 * (m + 1)) + d + b - 1524.5
def _moon_ra_dec(jd: float):
"""
Approximate geocentric RA (hours) and Dec (degrees) of the Moon.
Low-order series — typically within a few minutes on rise/set times.
"""
d = jd - 2451545.0
d2r = math.pi / 180.0
# Mean orbital elements (degrees)
L = (218.316 + 13.176396 * d) % 360.0 # mean longitude
M = (134.963 + 13.064993 * d) % 360.0 # mean anomaly
F = (93.272 + 13.229350 * d) % 360.0 # arg of latitude
# Ecliptic longitude / latitude (degrees) — leading periodic terms
lon = (L + 6.289 * math.sin(M * d2r)) % 360.0
lat = 5.128 * math.sin(F * d2r)
# Mean obliquity of the ecliptic
eps = 23.439 - 0.00000036 * d
lon_r = lon * d2r
lat_r = lat * d2r
eps_r = eps * d2r
ra = math.atan2(
math.sin(lon_r) * math.cos(eps_r) - math.tan(lat_r) * math.sin(eps_r),
math.cos(lon_r),
)
dec = math.asin(
math.sin(lat_r) * math.cos(eps_r)
+ math.cos(lat_r) * math.sin(eps_r) * math.sin(lon_r)
)
ra_h = (math.degrees(ra) % 360.0) / 15.0
dec_d = math.degrees(dec)
return ra_h, dec_d
def _gmst_hours(jd: float) -> float:
"""Greenwich mean sidereal time in hours (0–24)."""
t = (jd - 2451545.0) / 36525.0
gmst = (
280.46061837
+ 360.98564736629 * (jd - 2451545.0)
+ 0.000387933 * t * t
- (t * t * t) / 38710000.0
)
return (gmst % 360.0) / 15.0
def _moon_altitude_deg(lat: float, lon: float, dt_utc: datetime) -> float:
"""Apparent altitude of the Moon center (degrees) at lat/lon for UTC time."""
jd = _julian_date_utc(dt_utc)
ra_h, dec_d = _moon_ra_dec(jd)
lst_h = (_gmst_hours(jd) + lon / 15.0) % 24.0
ha_deg = (lst_h - ra_h) * 15.0
d2r = math.pi / 180.0
alt = math.asin(
math.sin(lat * d2r) * math.sin(dec_d * d2r)
+ math.cos(lat * d2r) * math.cos(dec_d * d2r) * math.cos(ha_deg * d2r)
)
return math.degrees(alt)
def _moon_illumination(jd: float) -> float:
"""
Illuminated fraction of the Moon's disc (0=new … 1=full), approximate.
"""
d = jd - 2451545.0
# Synodic phase from known new-moon epoch (2000-01-06 ≈ JD 2451550.1)
phase = ((jd - 2451550.1) / 29.530588853) % 1.0
# Geometric illumination ~ (1 - cos phase_angle) / 2
return 0.5 * (1.0 - math.cos(2.0 * math.pi * phase))
def _find_moon_crossing(t0: datetime, t1: datetime, rising: bool, step_s: float = 300.0):
"""
Find UTC time when moon altitude crosses MOON_HORIZON_ALT between t0 and t1.
rising=True → upward crossing (moonrise); False → downward (moonset).
Returns aware UTC datetime or None.
"""
lat, lon = OTTAWA_LAT, OTTAWA_LON
thr = MOON_HORIZON_ALT
prev_t = t0
prev_a = _moon_altitude_deg(lat, lon, t0)
t = t0 + timedelta(seconds=step_s)
while t <= t1 + timedelta(seconds=1):
a = _moon_altitude_deg(lat, lon, t)
crossed = False
if rising and prev_a < thr <= a:
crossed = True
elif (not rising) and prev_a > thr >= a:
crossed = True
if crossed:
# Linear interpolate in altitude
if abs(a - prev_a) < 1e-9:
return prev_t
u = (thr - prev_a) / (a - prev_a)
u = max(0.0, min(1.0, u))
return prev_t + timedelta(seconds=(t - prev_t).total_seconds() * u)
prev_t, prev_a = t, a