cloudspin 0.1.0__py3-none-any.whl

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.
cloudspin/sky.py ADDED
@@ -0,0 +1,829 @@
1
+ """The living sky: a small procedural landscape that follows weather and time.
2
+
3
+ ``SkyScene`` knows nothing about Textual or the network. Feed it a ``Weather``
4
+ (how cloudy, how wet, how windy) and a ``Daylight`` (where the sun or moon is),
5
+ call ``step`` to advance the animation and ``render`` to paint a ``Canvas``.
6
+ Every weather value is eased toward its target, so scenes melt into each other
7
+ instead of cutting.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ import random
14
+ from dataclasses import dataclass
15
+
16
+ from .canvas import Canvas
17
+ from .color import RGB, approach, clamp, lerp, ramp, scale, smoothstep
18
+
19
+
20
+ @dataclass
21
+ class Weather:
22
+ """What the sky should be doing. All amounts run 0..1."""
23
+
24
+ cover: float = 0.12 # how much of the sky is cloud
25
+ rain: float = 0.0
26
+ snow: float = 0.0
27
+ fog: float = 0.0
28
+ storm: float = 0.0
29
+ wind: float = 6.0 # km/h; clouds drift left to right
30
+
31
+
32
+ @dataclass
33
+ class Daylight:
34
+ """Where the lights are. ``elevation`` is 1 at solar noon, 0 at the
35
+ horizon and -1 at the middle of the night."""
36
+
37
+ elevation: float = 0.8
38
+ sun_x: float = 0.4 # 0..1 along the sun's arc
39
+ moon_x: float = 0.0 # 0..1 along the moon's arc
40
+ moon_phase: float = 0.5 # 0 new, 0.5 full
41
+
42
+
43
+ # (elevation, zenith, mid-sky, horizon)
44
+ _CLEAR_SKY: list[tuple[float, RGB, RGB, RGB]] = [
45
+ (-0.30, (4, 6, 18), (9, 14, 36), (20, 30, 62)),
46
+ (-0.12, (12, 14, 40), (40, 34, 80), (128, 70, 104)),
47
+ (0.00, (32, 38, 90), (146, 84, 128), (255, 150, 92)),
48
+ (0.14, (52, 100, 178), (136, 168, 214), (255, 208, 154)),
49
+ (0.40, (34, 100, 198), (82, 154, 232), (166, 210, 247)),
50
+ ]
51
+ _GREY_DAY: tuple[RGB, RGB, RGB] = ((88, 98, 114), (122, 132, 148), (164, 172, 184))
52
+ _GREY_NIGHT: tuple[RGB, RGB, RGB] = ((9, 10, 15), (17, 19, 27), (29, 32, 43))
53
+
54
+ # (elevation, lit side, shadow side)
55
+ _CLOUD_TONES: list[tuple[float, RGB, RGB]] = [
56
+ (-0.30, (58, 68, 98), (20, 24, 42)),
57
+ (0.00, (255, 212, 168), (146, 94, 132)),
58
+ (0.30, (255, 255, 255), (170, 192, 224)),
59
+ ]
60
+
61
+ _SUN_TONES: list[tuple[float, RGB]] = [
62
+ (0.0, (255, 110, 58)),
63
+ (0.15, (255, 190, 104)),
64
+ (0.5, (255, 244, 216)),
65
+ ]
66
+
67
+
68
+ def _triple(a: tuple[RGB, RGB, RGB], b: tuple[RGB, RGB, RGB], t: float) -> tuple[RGB, RGB, RGB]:
69
+ return (lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t))
70
+
71
+
72
+ def clear_sky(elevation: float) -> tuple[RGB, RGB, RGB]:
73
+ keys = _CLEAR_SKY
74
+ if elevation <= keys[0][0]:
75
+ return keys[0][1:]
76
+ for lo, hi in zip(keys, keys[1:]):
77
+ if elevation <= hi[0]:
78
+ return _triple(lo[1:], hi[1:], (elevation - lo[0]) / (hi[0] - lo[0]))
79
+ return keys[-1][1:]
80
+
81
+
82
+ def _cloud_tones(elevation: float) -> tuple[RGB, RGB]:
83
+ keys = _CLOUD_TONES
84
+ if elevation <= keys[0][0]:
85
+ return keys[0][1], keys[0][2]
86
+ for lo, hi in zip(keys, keys[1:]):
87
+ if elevation <= hi[0]:
88
+ t = (elevation - lo[0]) / (hi[0] - lo[0])
89
+ return lerp(lo[1], hi[1], t), lerp(lo[2], hi[2], t)
90
+ return keys[-1][1], keys[-1][2]
91
+
92
+
93
+ class _Cloud:
94
+ __slots__ = ("w", "h", "alpha", "x", "y", "speed", "near", "opacity", "leaving")
95
+
96
+ def __init__(self, rng: random.Random, wide: int, tall: int, near: bool) -> None:
97
+ self.w = wide
98
+ self.h = tall
99
+ self.near = near
100
+ self.x = 0.0
101
+ self.y = 0
102
+ self.speed = rng.uniform(0.8, 1.2) * (1.0 if near else 0.55)
103
+ self.opacity = 0.0
104
+ self.leaving = False
105
+ puffs = []
106
+ count = max(3, round(wide / max(2.5, tall * 0.55)))
107
+ for k in range(count):
108
+ u = (k + 0.5) / count
109
+ crown = 1.0 - abs(2.0 * u - 1.0) ** 1.6
110
+ radius = max(2.0, tall * (0.34 + 0.34 * crown * rng.uniform(0.75, 1.0)))
111
+ cx = wide * u + rng.uniform(-1.0, 1.0)
112
+ cy = tall - 1 - radius * 0.42
113
+ puffs.append((cx, cy, radius))
114
+ alpha = bytearray(wide * tall)
115
+ for py in range(tall):
116
+ for px in range(wide):
117
+ best = 0.0
118
+ for cx, cy, radius in puffs:
119
+ v = (radius - math.hypot(px - cx, (py - cy) * 1.15)) / max(1.3, radius * 0.45)
120
+ if v > best:
121
+ best = v
122
+ # A low elliptical body keeps the puffs joined along the base.
123
+ bx = (px - (wide - 1) / 2.0) / max(1.0, wide / 2.0 - 1.0)
124
+ by = (py - (tall - 1)) / max(1.0, tall * 0.4)
125
+ best = max(best, (1.0 - bx * bx - by * by) * 2.5)
126
+ if best > 0.0:
127
+ alpha[py * wide + px] = int(min(1.0, best * 1.2) * 255)
128
+ self.alpha = bytes(alpha)
129
+
130
+
131
+ class SkyScene:
132
+ def __init__(self, seed: int = 11) -> None:
133
+ self.rng = random.Random(seed)
134
+ self.seed = seed
135
+ self.w = 0
136
+ self.h = 0
137
+ self.t = 0.0
138
+ self.foreground = 0 # extra flat ground, in pixels, for overlays to sit on
139
+ self.ground_override: RGB | None = None
140
+ self.scrim = 0 # columns on the left to darken behind text
141
+
142
+ self.target = Weather()
143
+ self.now = Weather()
144
+ self.light_target = Daylight()
145
+ self.light = Daylight()
146
+ self._settled = False
147
+
148
+ self.horizon = 0
149
+ self.ground: RGB = (10, 14, 24)
150
+ self.natural_ground: RGB = (10, 14, 24)
151
+ self.accents: tuple[RGB, RGB] = ((255, 202, 98), (112, 182, 255))
152
+ self._tones: tuple[RGB, RGB, RGB] = clear_sky(0.8)
153
+ self._tones_key: tuple | None = None
154
+ self._bg = Canvas()
155
+
156
+ self._far: list[float] = []
157
+ self._mid: list[float] = []
158
+ self._near: list[float] = []
159
+ self._stars: list[tuple[int, int, float, float, float, RGB]] = []
160
+ self._clouds: list[_Cloud] = []
161
+ self._cloud_clock = 0.0
162
+ self._drops: list[list[float]] = []
163
+ self._splashes: list[list[float]] = []
164
+ self._flakes: list[list[float]] = []
165
+ self._fog: list[tuple[float, float, float, float, float]] = []
166
+ self._flash = 0.0
167
+ self._bolt: list[tuple[float, float]] = []
168
+ self._bolt_ttl = 0.0
169
+ self._strike_in = 2.5
170
+ self._aftershock = 0.0
171
+ self._meteor: list[float] | None = None
172
+ self._meteor_in = 9.0
173
+ self._scrim_tables: dict[int, bytes] = {}
174
+ self._cloud_fade: list[int] = []
175
+
176
+ # -- inputs --------------------------------------------------------------
177
+
178
+ def set_weather(self, weather: Weather) -> None:
179
+ self.target = weather
180
+
181
+ def set_daylight(self, light: Daylight) -> None:
182
+ self.light_target = light
183
+
184
+ def resize(self, w: int, h: int) -> None:
185
+ if (w, h) == (self.w, self.h):
186
+ return
187
+ self.w, self.h = w, h
188
+ self._bg.resize(w, h)
189
+ self._tones_key = None
190
+ self._build_land()
191
+ self._build_stars()
192
+ self._clouds.clear()
193
+ self._drops.clear()
194
+ self._flakes.clear()
195
+ self._splashes.clear()
196
+ self._build_fog()
197
+ self._settled = False
198
+
199
+ def set_foreground(self, pixels: int) -> None:
200
+ if pixels != self.foreground:
201
+ self.foreground = pixels
202
+ if self.w:
203
+ self._build_land()
204
+ self._build_stars()
205
+
206
+ # -- construction --------------------------------------------------------
207
+
208
+ def _build_land(self) -> None:
209
+ w, h = self.w, self.h
210
+ rng = random.Random(self.seed * 7 + 1)
211
+ band = clamp(h * 0.30, 5, 18)
212
+ base = float(self.foreground)
213
+ p = [rng.uniform(0, math.tau) for _ in range(8)]
214
+ span = max(40.0, w * 0.55)
215
+ far, mid, near = [], [], []
216
+ for x in range(w):
217
+ ridge = 0.62 * (1.0 - abs(math.sin(x * math.pi / span + p[0]))) + 0.38 * (
218
+ 1.0 - abs(math.sin(x * math.pi / (span * 0.37) + p[1]))
219
+ )
220
+ far.append(base + band * (0.40 + 0.60 * ridge))
221
+ roll = 0.5 + 0.32 * math.sin(x * math.tau / (span * 0.9) + p[2]) + 0.18 * math.sin(
222
+ x * math.tau / (span * 0.31) + p[3]
223
+ )
224
+ mid.append(base + band * (0.26 + 0.26 * roll))
225
+ low = 0.5 + 0.5 * math.sin(x * math.tau / (span * 1.3) + p[4])
226
+ near.append(max(2.0, base + band * (0.10 + 0.08 * low)))
227
+ self._far, self._mid, self._near = far, mid, near
228
+ self.horizon = int(h - base - band * 0.42)
229
+
230
+ def _build_stars(self) -> None:
231
+ rng = random.Random(self.seed * 13 + 5)
232
+ limit = max(1, self.horizon)
233
+ count = int(self.w * limit / 34)
234
+ stars = []
235
+ for _ in range(count):
236
+ tint = rng.choice(((255, 255, 255), (255, 255, 255), (200, 216, 255), (255, 236, 210)))
237
+ stars.append(
238
+ (
239
+ rng.randrange(max(1, self.w)),
240
+ rng.randrange(limit),
241
+ rng.uniform(0.25, 1.0) ** 1.8,
242
+ rng.uniform(0.6, 2.4),
243
+ rng.uniform(0, math.tau),
244
+ tint,
245
+ )
246
+ )
247
+ self._stars = stars
248
+
249
+ def _build_fog(self) -> None:
250
+ rng = random.Random(self.seed * 3 + 9)
251
+ bands = []
252
+ for k in range(5):
253
+ y = self.h * (0.30 + 0.10 * k) + rng.uniform(-1, 1)
254
+ bands.append((y, rng.uniform(3.5, 6.5), rng.uniform(0.02, 0.06), rng.uniform(0, 9), rng.uniform(0.12, 0.4)))
255
+ self._fog = bands
256
+
257
+ def _spawn_cloud(self, anywhere: bool) -> _Cloud:
258
+ rng = self.rng
259
+ wet = max(self.now.rain, self.now.snow, self.now.storm)
260
+ near = rng.random() < 0.55
261
+ big = self.now.cover > 0.7
262
+ if near:
263
+ wide = rng.randint(18, 34 if not big else 52)
264
+ else:
265
+ wide = rng.randint(11, 22 if not big else 36)
266
+ wide = max(8, min(wide, max(10, self.w // 2)))
267
+ tall = max(4, min(int(wide * rng.uniform(0.24, 0.34)), max(4, self.horizon // 2)))
268
+ cloud = _Cloud(rng, wide, tall, near)
269
+ sky = max(6, self.horizon)
270
+ top = 0.02 if wet > 0.3 else 0.08
271
+ depth = 0.34 if wet > 0.3 else 0.56
272
+ cloud.y = int(sky * (top + depth * rng.random() ** (1.4 if big else 1.0))) - (1 if near else 0)
273
+ cloud.x = rng.uniform(-wide, self.w) if anywhere else float(-wide - rng.uniform(0, 12))
274
+ return cloud
275
+
276
+ # -- animation -----------------------------------------------------------
277
+
278
+ def step(self, dt: float) -> None:
279
+ if not self.w or not self.h:
280
+ return
281
+ self.t += dt
282
+ now, target = self.now, self.target
283
+ instant = not self._settled
284
+ k = 1.0 if instant else 1.0 - math.exp(-dt / 1.5)
285
+ now.cover += (target.cover - now.cover) * k
286
+ now.rain += (target.rain - now.rain) * k
287
+ now.snow += (target.snow - now.snow) * k
288
+ now.fog += (target.fog - now.fog) * k
289
+ now.storm += (target.storm - now.storm) * k
290
+ now.wind += (target.wind - now.wind) * k
291
+ light, goal = self.light, self.light_target
292
+ kl = 1.0 if instant else 1.0 - math.exp(-dt / 1.1)
293
+ light.elevation += (goal.elevation - light.elevation) * kl
294
+ light.sun_x += (goal.sun_x - light.sun_x) * kl
295
+ light.moon_x += (goal.moon_x - light.moon_x) * kl
296
+ light.moon_phase = goal.moon_phase
297
+
298
+ self._update_tones(instant, dt)
299
+ self._step_clouds(dt, instant)
300
+ self._step_rain(dt)
301
+ self._step_snow(dt)
302
+ self._step_storm(dt)
303
+ self._step_meteor(dt)
304
+ self._settled = True
305
+
306
+ def _update_tones(self, instant: bool, dt: float) -> None:
307
+ now, elev = self.now, self.light.elevation
308
+ dayness = smoothstep(-0.15, 0.25, elev)
309
+ tones = clear_sky(elev)
310
+ grey = _triple(_GREY_NIGHT, _GREY_DAY, dayness)
311
+ wet = max(now.rain, now.storm, now.snow * 0.8)
312
+ overcast = max(smoothstep(0.35, 0.95, now.cover) * 0.92, min(1.0, wet * 1.4) * 0.92)
313
+ tones = _triple(tones, grey, overcast)
314
+ if now.snow > 0.01:
315
+ pale = _triple(((30, 34, 46),) * 3, ((150, 160, 176), (176, 184, 198), (204, 210, 220)), dayness)
316
+ tones = _triple(tones, pale, now.snow * 0.6)
317
+ if now.fog > 0.01:
318
+ flat = lerp((34, 38, 48), (172, 178, 188), dayness)
319
+ tones = _triple(tones, (flat, flat, flat), now.fog * 0.72)
320
+ dark = 1.0 - 0.42 * now.storm - 0.14 * now.rain * (1.0 - now.storm)
321
+ if dark < 0.999:
322
+ tones = (scale(tones[0], dark), scale(tones[1], dark), scale(tones[2], dark))
323
+ self._tones = tones
324
+
325
+ self.natural_ground = lerp((9, 11, 17), scale(lerp(tones[0], tones[2], 0.3), 0.2), 0.75)
326
+ self.ground = self.ground_override if self.ground_override is not None else self.natural_ground
327
+
328
+ if now.storm > 0.5:
329
+ want = ((184, 164, 255), (255, 238, 150))
330
+ elif now.snow > 0.3:
331
+ want = ((206, 228, 255), (140, 190, 255))
332
+ elif now.rain > 0.25:
333
+ want = ((112, 172, 238), (92, 216, 222))
334
+ elif now.fog > 0.5:
335
+ want = ((196, 202, 214), (146, 156, 180))
336
+ else:
337
+ night = ((150, 172, 255), (196, 146, 255))
338
+ gold = ((255, 156, 92), (244, 114, 172))
339
+ day = ((255, 202, 98), (112, 182, 255))
340
+ if elev < 0:
341
+ t = smoothstep(-0.25, 0.0, elev)
342
+ want = (lerp(night[0], gold[0], t), lerp(night[1], gold[1], t))
343
+ else:
344
+ t = smoothstep(0.0, 0.3, elev)
345
+ want = (lerp(gold[0], day[0], t), lerp(gold[1], day[1], t))
346
+ if now.cover > 0.7:
347
+ t = (now.cover - 0.7) / 0.3 * 0.55
348
+ want = (lerp(want[0], (164, 184, 214), t), lerp(want[1], (122, 152, 204), t))
349
+ if instant:
350
+ self.accents = want
351
+ else:
352
+ ka = 1.0 - math.exp(-dt / 0.9)
353
+ self.accents = (approach(self.accents[0], want[0], ka), approach(self.accents[1], want[1], ka))
354
+
355
+ def _step_clouds(self, dt: float, instant: bool) -> None:
356
+ clouds = self._clouds
357
+ want = int(round(self.now.cover * self.w / 6.5))
358
+ if self.now.cover > 0.05:
359
+ want = max(1, want)
360
+ alive = [c for c in clouds if not c.leaving]
361
+ if instant:
362
+ clouds.clear()
363
+ for _ in range(want):
364
+ cloud = self._spawn_cloud(anywhere=True)
365
+ cloud.opacity = 1.0
366
+ clouds.append(cloud)
367
+ else:
368
+ self._cloud_clock -= dt
369
+ if self._cloud_clock <= 0.0:
370
+ self._cloud_clock = 0.12
371
+ if len(alive) < want:
372
+ clouds.append(self._spawn_cloud(anywhere=True))
373
+ elif len(alive) > want:
374
+ self.rng.choice(alive).leaving = True
375
+ drift = (0.35 + abs(self.now.wind) / 11.0) * (1.0 if self.now.wind >= 0 else -1.0)
376
+ drift = max(-6.0, min(6.0, drift))
377
+ fade = dt / 1.6
378
+ for cloud in clouds:
379
+ cloud.x += drift * cloud.speed * dt
380
+ if cloud.leaving:
381
+ cloud.opacity -= fade
382
+ elif cloud.opacity < 1.0:
383
+ cloud.opacity = min(1.0, cloud.opacity + fade)
384
+ if drift >= 0 and cloud.x > self.w + 2:
385
+ cloud.x = float(-cloud.w - self.rng.uniform(0, 10))
386
+ elif drift < 0 and cloud.x < -cloud.w - 2:
387
+ cloud.x = float(self.w + self.rng.uniform(0, 10))
388
+ if any(c.leaving and c.opacity <= 0.0 for c in clouds):
389
+ self._clouds = [c for c in clouds if not (c.leaving and c.opacity <= 0.0)]
390
+ # Paint far clouds first.
391
+ self._clouds.sort(key=lambda c: c.near)
392
+
393
+ def _ground_at(self, x: float) -> float:
394
+ near = self._near
395
+ if not near:
396
+ return float(self.h)
397
+ return self.h - near[min(len(near) - 1, max(0, int(x)))]
398
+
399
+ def _step_rain(self, dt: float) -> None:
400
+ rng = self.rng
401
+ amount = max(self.now.rain, 0.0)
402
+ want = int(self.w * (0.2 + 0.95 * amount)) if amount > 0.03 else 0
403
+ drops = self._drops
404
+ while len(drops) < want:
405
+ near = rng.random() < 0.6
406
+ drops.append(
407
+ [
408
+ rng.uniform(-self.w * 0.2, self.w * 1.2),
409
+ rng.uniform(-self.h, self.h),
410
+ rng.uniform(46, 68) if near else rng.uniform(26, 38),
411
+ 1.0 if near else 0.0,
412
+ ]
413
+ )
414
+ slant = max(-0.45, min(0.45, self.now.wind / 55.0))
415
+ keep = []
416
+ for drop in drops:
417
+ drop[1] += drop[2] * dt
418
+ drop[0] += drop[2] * slant * dt
419
+ if drop[1] >= self._ground_at(drop[0]):
420
+ if drop[3] and 0 <= drop[0] < self.w and len(self._splashes) < 80:
421
+ self._splashes.append([drop[0], self._ground_at(drop[0]) - 1, 0.0])
422
+ if len(keep) + 1 > want:
423
+ continue # the rain is easing off
424
+ drop[0] = rng.uniform(-self.w * 0.2, self.w * 1.2)
425
+ drop[1] = rng.uniform(-6, 0)
426
+ keep.append(drop)
427
+ self._drops = keep
428
+ if self._splashes:
429
+ for splash in self._splashes:
430
+ splash[2] += dt
431
+ self._splashes = [s for s in self._splashes if s[2] < 0.22]
432
+
433
+ def _step_snow(self, dt: float) -> None:
434
+ rng = self.rng
435
+ amount = self.now.snow
436
+ want = int(self.w * (0.25 + 0.9 * amount)) if amount > 0.03 else 0
437
+ flakes = self._flakes
438
+ while len(flakes) < want:
439
+ depth = rng.random()
440
+ flakes.append(
441
+ [
442
+ rng.uniform(0, self.w),
443
+ rng.uniform(-self.h, self.h),
444
+ 3.5 + 6.0 * depth,
445
+ rng.uniform(0.6, 2.2),
446
+ rng.uniform(0.6, 1.6),
447
+ rng.uniform(0, math.tau),
448
+ depth,
449
+ ]
450
+ )
451
+ push = self.now.wind / 9.0
452
+ keep = []
453
+ for flake in flakes:
454
+ flake[1] += flake[2] * dt
455
+ flake[0] = (flake[0] + push * (0.4 + flake[6]) * dt) % max(1, self.w)
456
+ if flake[1] >= self._ground_at(flake[0]):
457
+ if len(keep) + 1 > want:
458
+ continue
459
+ flake[1] = rng.uniform(-4, 0)
460
+ flake[0] = rng.uniform(0, self.w)
461
+ keep.append(flake)
462
+ self._flakes = keep
463
+
464
+ def _step_storm(self, dt: float) -> None:
465
+ if self._flash > 0.0:
466
+ self._flash *= math.exp(-dt / 0.16)
467
+ if self._flash < 0.02:
468
+ self._flash = 0.0
469
+ if self._bolt_ttl > 0.0:
470
+ self._bolt_ttl -= dt
471
+ if self._aftershock > 0.0:
472
+ self._aftershock -= dt
473
+ if self._aftershock <= 0.0:
474
+ self._flash = max(self._flash, 0.7)
475
+ if self.now.storm < 0.5:
476
+ return
477
+ self._strike_in -= dt
478
+ if self._strike_in <= 0.0:
479
+ rng = self.rng
480
+ self._strike_in = rng.uniform(2.8, 8.0)
481
+ self._flash = 1.0
482
+ if rng.random() < 0.65:
483
+ self._aftershock = rng.uniform(0.09, 0.16)
484
+ x = rng.uniform(self.w * 0.3, self.w * 0.95)
485
+ y = max(2.0, self.horizon * 0.22)
486
+ path = [(x, y)]
487
+ end = self.h - self._far[min(self.w - 1, max(0, int(x)))] + 1
488
+ while y < end:
489
+ y += rng.uniform(1.5, 3.2)
490
+ x += rng.uniform(-2.4, 2.4)
491
+ path.append((x, min(y, end)))
492
+ self._bolt = path
493
+ self._bolt_ttl = 0.2
494
+
495
+ def _step_meteor(self, dt: float) -> None:
496
+ if self._meteor is not None:
497
+ m = self._meteor
498
+ m[4] += dt
499
+ m[0] += m[2] * dt
500
+ m[1] += m[3] * dt
501
+ if m[4] > 0.9:
502
+ self._meteor = None
503
+ return
504
+ if self.light.elevation > -0.2 or self.now.cover > 0.45:
505
+ return
506
+ self._meteor_in -= dt
507
+ if self._meteor_in <= 0.0:
508
+ rng = self.rng
509
+ self._meteor_in = rng.uniform(14, 34)
510
+ direction = rng.choice((-1.0, 1.0))
511
+ self._meteor = [
512
+ rng.uniform(self.w * 0.35, self.w * 0.9),
513
+ rng.uniform(1, max(2, self.horizon * 0.4)),
514
+ direction * rng.uniform(34, 52),
515
+ rng.uniform(9, 16),
516
+ 0.0,
517
+ ]
518
+
519
+ # -- painting ------------------------------------------------------------
520
+
521
+ def _paint_backdrop(self) -> None:
522
+ key = (self._tones, self.horizon)
523
+ if key == self._tones_key:
524
+ return
525
+ self._tones_key = key
526
+ top, mid, low = self._tones
527
+ span = max(1, self.horizon)
528
+ stops = ((0.0, top), (0.55, mid), (1.0, low))
529
+ self._bg.fill_rows([ramp(stops, min(1.0, y / span)) for y in range(self.h)])
530
+
531
+ def render(self, cv: Canvas) -> None:
532
+ w, h = self.w, self.h
533
+ if cv.w != w or cv.h != h or not w or not h:
534
+ return
535
+ now, light = self.now, self.light
536
+ elev = light.elevation
537
+ self._paint_backdrop()
538
+ cv.blit(self._bg)
539
+ if self._flash > 0.0:
540
+ cv.lighten(int(self._flash * 150))
541
+
542
+ clearness = 1.0 - min(1.0, max(now.cover * 0.9, now.fog * 0.85, now.rain, now.snow * 0.9))
543
+ night = clamp((-elev - 0.02) / 0.22)
544
+ if night > 0.0 and clearness > 0.05:
545
+ self._paint_stars(cv, night * clearness)
546
+ if self._meteor is not None:
547
+ self._paint_meteor(cv)
548
+ self._paint_sun_and_moon(cv, clearness)
549
+ self._paint_clouds(cv)
550
+ if self._bolt_ttl > 0.0:
551
+ self._paint_bolt(cv)
552
+ if self.scrim > 0:
553
+ self._paint_scrim(cv)
554
+ self._paint_land(cv)
555
+ if self._drops:
556
+ self._paint_rain(cv)
557
+ if self._flakes:
558
+ self._paint_snow(cv)
559
+ if now.fog > 0.02:
560
+ self._paint_fog(cv)
561
+
562
+ def _paint_stars(self, cv: Canvas, amount: float) -> None:
563
+ t = self.t
564
+ add = cv.add
565
+ for x, y, base, speed, phase, tint in self._stars:
566
+ glint = base * (0.62 + 0.38 * math.sin(t * speed + phase)) * amount
567
+ if glint > 0.04:
568
+ add(x, y, tint, int(glint * 256))
569
+ if glint > 0.72:
570
+ a = int((glint - 0.6) * 150)
571
+ add(x - 1, y, tint, a)
572
+ add(x + 1, y, tint, a)
573
+
574
+ def _paint_meteor(self, cv: Canvas) -> None:
575
+ x, y, vx, vy, age = self._meteor # type: ignore[misc]
576
+ fade = 1.0 - age / 0.9
577
+ speed = math.hypot(vx, vy)
578
+ ux, uy = vx / speed, vy / speed
579
+ for k in range(9):
580
+ px, py = x - ux * k, y - uy * k
581
+ if py < self.horizon:
582
+ cv.add(int(px), int(py), (255, 250, 236), int(256 * fade * (1.0 - k / 9.0) ** 1.5))
583
+
584
+ def _paint_sun_and_moon(self, cv: Canvas, clearness: float) -> None:
585
+ w, light = self.w, self.light
586
+ elev = light.elevation
587
+ horizon = self.horizon
588
+ arc = max(4.0, horizon - 3.0)
589
+ radius = clamp(self.h * 0.085, 2.2, 5.5)
590
+ left, right = w * 0.44, w * 0.93
591
+ veil = 0.18 + 0.82 * clearness
592
+ if elev > -0.14:
593
+ sx = left + (right - left) * clamp(light.sun_x)
594
+ sy = horizon - max(elev, -0.14) * arc
595
+ tone = ramp(_SUN_TONES, max(0.0, elev))
596
+ warm = 1.0 - smoothstep(0.0, 0.35, elev)
597
+ cv.glow(sx, sy, radius * (4.2 + 3.2 * warm), tone, (0.30 + 0.25 * warm) * veil)
598
+ cv.glow(sx, sy, radius * 2.1, tone, 0.55 * veil)
599
+ cv.disc(sx, sy, radius, lerp(tone, (255, 255, 244), 0.45), int(256 * veil))
600
+ if elev < 0.1:
601
+ mx = left + (right - left) * clamp(light.moon_x)
602
+ my = horizon - max(-elev, -0.1) * arc
603
+ show = smoothstep(0.1, -0.12, elev) * veil
604
+ if show > 0.02:
605
+ self._paint_moon(cv, mx, my, radius * 0.92, light.moon_phase, show)
606
+
607
+ def _paint_moon(self, cv: Canvas, cx: float, cy: float, radius: float, phase: float, show: float) -> None:
608
+ k = math.cos(math.tau * phase)
609
+ waxing = phase < 0.5
610
+ lit_amount = (1.0 - k) / 2.0
611
+ cv.glow(cx, cy, radius * 4.5, (120, 142, 210), 0.34 * show * (0.25 + 0.75 * lit_amount))
612
+ bright = (232, 236, 246)
613
+ maria = (192, 198, 214)
614
+ dark = lerp(self._tones[1], (70, 80, 110), 0.35)
615
+ y0, y1 = int(cy - radius - 1), int(cy + radius + 2)
616
+ x0, x1 = int(cx - radius - 1), int(cx + radius + 2)
617
+ craters = ((-0.30, -0.25, 0.26), (0.28, 0.22, 0.2), (-0.05, 0.42, 0.16))
618
+ for y in range(y0, y1 + 1):
619
+ ny = (y - cy) / radius
620
+ for x in range(x0, x1 + 1):
621
+ nx = (x - cx) / radius
622
+ dist = math.hypot(nx, ny)
623
+ cover = (1.0 - dist) * radius + 0.5
624
+ if cover <= 0.0:
625
+ continue
626
+ cover = min(1.0, cover)
627
+ edge = math.sqrt(max(0.0, 1.0 - ny * ny))
628
+ side = (nx - k * edge) if waxing else (-k * edge - nx)
629
+ lit = clamp(side * radius + 0.5)
630
+ tone = bright
631
+ for ox, oy, size in craters:
632
+ if math.hypot(nx - ox, ny - oy) < size:
633
+ tone = maria
634
+ break
635
+ cv.blend(x, y, lerp(dark, tone, lit), int(256 * cover * show * (0.3 + 0.7 * lit)))
636
+
637
+ def _paint_clouds(self, cv: Canvas) -> None:
638
+ if not self._clouds:
639
+ return
640
+ now = self.now
641
+ elev = self.light.elevation
642
+ dayness = smoothstep(-0.15, 0.25, elev)
643
+ lit, shade = _cloud_tones(elev)
644
+ heavy = max(smoothstep(0.55, 1.0, now.cover), min(1.0, now.rain * 1.5), now.snow)
645
+ grey_lit = lerp((46, 50, 62), (196, 202, 212), dayness)
646
+ grey_shade = lerp((18, 20, 28), (112, 120, 134), dayness)
647
+ lit = lerp(lit, grey_lit, heavy * 0.8)
648
+ shade = lerp(shade, grey_shade, heavy * 0.85)
649
+ if now.storm > 0.01:
650
+ lit = lerp(lit, lerp((36, 38, 48), (104, 108, 122), dayness), now.storm * 0.9)
651
+ shade = lerp(shade, lerp((12, 13, 18), (42, 44, 56), dayness), now.storm * 0.9)
652
+ if self._flash > 0.0:
653
+ lit = lerp(lit, (236, 232, 255), self._flash * 0.8)
654
+ shade = lerp(shade, (150, 150, 190), self._flash * 0.6)
655
+ haze = self._tones[1]
656
+ far_lit, far_shade = lerp(lit, haze, 0.38), lerp(shade, haze, 0.38)
657
+
658
+ W, H = cv.w, cv.h
659
+ R, G, B = cv.r, cv.g, cv.b
660
+ fade = self._cloud_fade
661
+ if len(fade) != W or (fade and (fade[0] < 256) != (self.scrim > 0)):
662
+ # Clouds thin out as they drift behind the weather text.
663
+ span = min(self.scrim, W)
664
+ fade = self._cloud_fade = [
665
+ int(256 * (0.34 + 0.66 * smoothstep(0.45, 1.0, x / span))) if x < span else 256 for x in range(W)
666
+ ]
667
+ for cloud in self._clouds:
668
+ if cloud.opacity <= 0.0:
669
+ continue
670
+ top_tone, low_tone = (lit, shade) if cloud.near else (far_lit, far_shade)
671
+ strength = int(cloud.opacity * (256 if cloud.near else 205))
672
+ ox = math.floor(cloud.x)
673
+ f1 = int((cloud.x - ox) * 256)
674
+ f0 = 256 - f1
675
+ sw, sh, alpha = cloud.w, cloud.h, cloud.alpha
676
+ x_lo = max(0, ox)
677
+ x_hi = min(W - 1, ox + sw)
678
+ if x_lo > x_hi:
679
+ continue
680
+ for sy in range(sh):
681
+ y = cloud.y + sy
682
+ if y < 0 or y >= H:
683
+ continue
684
+ t = sy / (sh - 1) if sh > 1 else 0.0
685
+ cr, cg, cb = lerp(top_tone, low_tone, t * t)
686
+ base = sy * sw - ox
687
+ row = y * W
688
+ for x in range(x_lo, x_hi + 1):
689
+ sx = x - ox
690
+ a = (alpha[base + x] * f0 if sx < sw else 0) + (alpha[base + x - 1] * f1 if sx > 0 else 0)
691
+ a = (a * strength >> 16) * fade[x] >> 8
692
+ if a < 3:
693
+ continue
694
+ i = row + x
695
+ v = R[i]
696
+ R[i] = v + ((cr - v) * a >> 8)
697
+ v = G[i]
698
+ G[i] = v + ((cg - v) * a >> 8)
699
+ v = B[i]
700
+ B[i] = v + ((cb - v) * a >> 8)
701
+
702
+ def _paint_bolt(self, cv: Canvas) -> None:
703
+ strength = clamp(self._bolt_ttl / 0.2)
704
+ core = (255, 252, 240)
705
+ halo = (170, 150, 255)
706
+ for (x0, y0), (x1, y1) in zip(self._bolt, self._bolt[1:]):
707
+ steps = max(1, int(abs(y1 - y0) + abs(x1 - x0)))
708
+ for s in range(steps + 1):
709
+ t = s / steps
710
+ x, y = int(x0 + (x1 - x0) * t), int(y0 + (y1 - y0) * t)
711
+ cv.blend(x, y, core, int(256 * strength))
712
+ cv.add(x - 1, y, halo, int(120 * strength))
713
+ cv.add(x + 1, y, halo, int(120 * strength))
714
+
715
+ def _paint_scrim(self, cv: Canvas) -> None:
716
+ """Shade the left of the sky, where the weather is written, fading out to the right."""
717
+ width = min(self.scrim, self.w)
718
+ far, h = self._far, self.h
719
+ tables = self._scrim_tables
720
+ for x in range(width):
721
+ level = int(24 * (1.0 - smoothstep(0.55, 1.0, x / width)))
722
+ if level <= 0:
723
+ continue
724
+ table = tables.get(level)
725
+ if table is None:
726
+ keep = 1.0 - level / 24 * 0.4
727
+ table = tables[level] = bytes(int(v * keep) for v in range(256))
728
+ cv.remap_column(x, int(h - far[x]) + 1, table)
729
+
730
+ def _paint_land(self, cv: Canvas) -> None:
731
+ h = self.h
732
+ low = self._tones[2]
733
+ ground = self.ground
734
+ dayness = smoothstep(-0.15, 0.25, self.light.elevation)
735
+ far_tone = lerp(low, ground, 0.52)
736
+ mid_tone = lerp(low, ground, 0.80)
737
+ if self.now.snow > 0.02:
738
+ frost = scale((204, 214, 230), 0.22 + 0.78 * dayness)
739
+ far_tone = lerp(far_tone, frost, self.now.snow * 0.42)
740
+ mid_tone = lerp(mid_tone, frost, self.now.snow * 0.24)
741
+ if self._flash > 0.0:
742
+ far_tone = lerp(far_tone, (150, 150, 180), self._flash * 0.25)
743
+ for heights, tone in ((self._far, far_tone), (self._mid, mid_tone), (self._near, ground)):
744
+ for x, height in enumerate(heights):
745
+ top = h - height
746
+ y = int(math.ceil(top))
747
+ edge = y - top
748
+ if edge > 0.02:
749
+ cv.blend(x, y - 1, tone, int(256 * edge))
750
+ cv.fill_column(x, y, tone)
751
+
752
+ def _paint_rain(self, cv: Canvas) -> None:
753
+ dayness = smoothstep(-0.15, 0.25, self.light.elevation)
754
+ tone = lerp((120, 140, 190), (214, 228, 250), dayness)
755
+ slant = max(-0.45, min(0.45, self.now.wind / 55.0))
756
+ blend = cv.blend
757
+ for x, y, speed, near in self._drops:
758
+ if near:
759
+ blend(int(x), int(y), tone, 190)
760
+ blend(int(x - slant), int(y) - 1, tone, 120)
761
+ blend(int(x - slant * 2), int(y) - 2, tone, 60)
762
+ else:
763
+ blend(int(x), int(y), tone, 84)
764
+ blend(int(x - slant), int(y) - 1, tone, 40)
765
+ for x, y, age in self._splashes:
766
+ a = int(170 * (1.0 - age / 0.22))
767
+ if age < 0.09:
768
+ blend(int(x), int(y), tone, a)
769
+ else:
770
+ blend(int(x) - 1, int(y), tone, a)
771
+ blend(int(x) + 1, int(y), tone, a)
772
+
773
+ def _paint_snow(self, cv: Canvas) -> None:
774
+ t = self.t
775
+ dayness = smoothstep(-0.15, 0.25, self.light.elevation)
776
+ tone = lerp((170, 180, 210), (255, 255, 255), dayness)
777
+ blend = cv.blend
778
+ for x, y, _speed, sway, rate, phase, depth in self._flakes:
779
+ px = int(x + sway * math.sin(t * rate + phase))
780
+ blend(px, int(y), tone, int(90 + 150 * depth))
781
+ if depth > 0.82:
782
+ blend(px + 1, int(y), tone, 70)
783
+
784
+ def _paint_fog(self, cv: Canvas) -> None:
785
+ amount = self.now.fog
786
+ dayness = smoothstep(-0.15, 0.25, self.light.elevation)
787
+ tone = lerp((62, 68, 84), (206, 212, 222), dayness)
788
+ cr, cg, cb = tone
789
+ W, H = cv.w, cv.h
790
+ R, G, B = cv.r, cv.g, cv.b
791
+ t = self.t
792
+ floor = H - int(max(self._near)) - 1 if self._near else H - 1
793
+ # A veil that thickens toward the ground, so the hills sink into it.
794
+ start = int(self.horizon * 0.35)
795
+ depth = max(1, floor - start)
796
+ for y in range(max(0, start), floor + 1):
797
+ a = int(amount * 150 * ((y - start) / depth) ** 0.8)
798
+ if a < 4:
799
+ continue
800
+ keep = 256 - a
801
+ lut_r = bytes((v * keep + cr * a) >> 8 for v in range(256))
802
+ lut_g = bytes((v * keep + cg * a) >> 8 for v in range(256))
803
+ lut_b = bytes((v * keep + cb * a) >> 8 for v in range(256))
804
+ i = y * W
805
+ R[i : i + W] = R[i : i + W].translate(lut_r)
806
+ G[i : i + W] = G[i : i + W].translate(lut_g)
807
+ B[i : i + W] = B[i : i + W].translate(lut_b)
808
+ # Slow drifting banks on top of the veil.
809
+ for center, thick, freq, phase, speed in self._fog:
810
+ y0 = max(0, int(center - thick))
811
+ y1 = min(floor, int(center + thick))
812
+ wave = [0.5 + 0.5 * math.sin(x * freq + phase + t * speed) for x in range(W)]
813
+ for y in range(y0, y1 + 1):
814
+ fall = 1.0 - abs(y - center) / thick
815
+ if fall <= 0.0:
816
+ continue
817
+ peak = amount * fall * fall * 96.0
818
+ row = y * W
819
+ for x in range(W):
820
+ a = int(peak * wave[x])
821
+ if a < 4:
822
+ continue
823
+ i = row + x
824
+ v = R[i]
825
+ R[i] = v + ((cr - v) * a >> 8)
826
+ v = G[i]
827
+ G[i] = v + ((cg - v) * a >> 8)
828
+ v = B[i]
829
+ B[i] = v + ((cb - v) * a >> 8)