StePe

FlowFields early draft

Mar 19th, 2026
70
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
C++ 32.80 KB | None | 0 0
  1. import colorsys
  2. import math
  3. import sys
  4. from dataclasses import dataclass
  5.  
  6. import pygame
  7.  
  8. GRID_SIZE = 32
  9. CELL_SIZE = 16
  10. GRID_PIXELS = GRID_SIZE * CELL_SIZE
  11. RIGHT_UI_WIDTH = 330
  12. MARGIN = 20
  13. FPS = 60
  14.  
  15. COLOR_SHIFT_DEFAULT = 0.10
  16. ENDPOINT_SPEED_DEFAULT = 0.35
  17. SPIRAL_TRANSPORT_FRACTION = 0.45
  18. SPIRAL_DIM_DEFAULT = 1.00
  19. ROUND_SPIRAL_ANGULAR_STEP = 0.28
  20. ROUND_SPIRAL_RADIAL_STEP = 0.18
  21. ORBIT_DOT_DIAMETER = 2.5
  22. ORBIT_RADIUS = 9.5
  23.  
  24. WINDOW_WIDTH = MARGIN * 3 + GRID_PIXELS + RIGHT_UI_WIDTH
  25. WINDOW_HEIGHT = MARGIN * 2 + GRID_PIXELS
  26.  
  27. BG = (15, 18, 24)
  28. TEXT = (230, 230, 240)
  29. TRACK = (50, 56, 70)
  30. FILL = (70, 130, 255)
  31. KNOB = (235, 239, 248)
  32. PANEL = (22, 26, 36)
  33. BORDER = (70, 70, 85)
  34.  
  35. MODE_NAMES = [
  36.     "No Trail",
  37.     "2. Chromatic Flow Split",
  38.     "4. Polar Warp Pulsation",
  39.     "4b. Polar Warp 2",
  40.     "6. Shockwave Displacement",
  41.     "6b. Shockwave 2",
  42.     "7. Rotating Coordinate",
  43.     "7b. Rotating Wind",
  44.     "7c. Rotating Wind Wave",
  45.     "10. Attractor Fields",
  46.     "15. Fractal Spiral Step",
  47.     "Square Spiral Stream",
  48.     "Spiral Stream",
  49.     "Spiral Outwards",
  50.     "To the center",
  51.     "From the center",
  52. ]
  53.  
  54. MODE_BUTTON_LABELS = [
  55.     "No Trail",
  56.     "Chrom Split",
  57.     "Polar Warp",
  58.     "Polar Warp 2",
  59.     "Shockwave",
  60.     "Shockwave 2",
  61.     "Rotate Coord",
  62.     "Rotating Wind",
  63.     "Rotating Wind Wave",
  64.     "Attractors",
  65.     "Fractal Spiral",
  66.     "Square Spiral",
  67.     "Spiral In",
  68.     "Spiral Out",
  69.     "To Center",
  70.     "From Center",
  71. ]
  72.  
  73.  
  74. @dataclass
  75. class Slider:
  76.     label: str
  77.     min_value: float
  78.     max_value: float
  79.     value: float
  80.     rect: pygame.Rect
  81.     decimals: int = 2
  82.     dragging: bool = False
  83.  
  84.     def _set_from_x(self, x: int) -> None:
  85.         t = (x - self.rect.left) / self.rect.width
  86.         t = max(0.0, min(1.0, t))
  87.         self.value = self.min_value + t * (self.max_value - self.min_value)
  88.  
  89.     def handle_event(self, event: pygame.event.Event) -> None:
  90.         if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1 and self.rect.collidepoint(event.pos):
  91.             self.dragging = True
  92.             self._set_from_x(event.pos[0])
  93.         elif event.type == pygame.MOUSEBUTTONUP and event.button == 1:
  94.             self.dragging = False
  95.         elif event.type == pygame.MOUSEMOTION and self.dragging:
  96.             self._set_from_x(event.pos[0])
  97.  
  98.     def draw(self, surface: pygame.Surface, font: pygame.font.Font) -> None:
  99.         pygame.draw.rect(surface, TRACK, self.rect, border_radius=5)
  100.         t = (self.value - self.min_value) / (self.max_value - self.min_value)
  101.         fill_width = int(self.rect.width * t)
  102.         if fill_width > 0:
  103.             pygame.draw.rect(surface, FILL, (self.rect.left, self.rect.top, fill_width, self.rect.height), border_radius=5)
  104.         knob_x = self.rect.left + fill_width
  105.         pygame.draw.circle(surface, KNOB, (knob_x, self.rect.centery), self.rect.height // 2 + 3)
  106.         label = f"{self.label}: {self.value:.{self.decimals}f}"
  107.         surface.blit(font.render(label, True, TEXT), (self.rect.left, self.rect.top - 20))
  108.  
  109.  
  110. @dataclass
  111. class Button:
  112.     label: str
  113.     rect: pygame.Rect
  114.     active: bool = False
  115.  
  116.     def handle_event(self, event: pygame.event.Event) -> bool:
  117.         return event.type == pygame.MOUSEBUTTONDOWN and event.button == 1 and self.rect.collidepoint(event.pos)
  118.  
  119.     def draw(self, surface: pygame.Surface, font: pygame.font.Font) -> None:
  120.         fill = (74, 124, 189) if self.active else (52, 52, 62)
  121.         pygame.draw.rect(surface, fill, self.rect, border_radius=6)
  122.         pygame.draw.rect(surface, BORDER, self.rect, 1, border_radius=6)
  123.         text = font.render(self.label, True, TEXT)
  124.         surface.blit(text, (self.rect.centerx - text.get_width() // 2, self.rect.centery - text.get_height() // 2))
  125.  
  126.  
  127. def clamp(v: float, lo: float, hi: float) -> float:
  128.     return max(lo, min(hi, v))
  129.  
  130.  
  131. def hsv_color_with_phase(t: float, speed: float, phase: float) -> tuple[int, int, int]:
  132.     hue = (t * speed + phase) % 1.0
  133.     r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0)
  134.     return int(r * 255), int(g * 255), int(b * 255)
  135.  
  136.  
  137. def get_rgb(surface: pygame.Surface, x: int, y: int) -> tuple[int, int, int]:
  138.     if 0 <= x < GRID_SIZE and 0 <= y < GRID_SIZE:
  139.         c = surface.get_at((x, y))
  140.         return c.r, c.g, c.b
  141.     return 0, 0, 0
  142.  
  143.  
  144. def set_rgb(surface: pygame.Surface, x: int, y: int, color: tuple[int, int, int]) -> None:
  145.     if 0 <= x < GRID_SIZE and 0 <= y < GRID_SIZE:
  146.         surface.set_at((x, y), color)
  147.  
  148.  
  149. def sample_rgb_bilinear(surface: pygame.Surface, x: float, y: float) -> tuple[float, float, float]:
  150.     x = clamp(x, 0.0, (GRID_SIZE - 1) - 1e-6)
  151.     y = clamp(y, 0.0, (GRID_SIZE - 1) - 1e-6)
  152.  
  153.     x0 = int(math.floor(x))
  154.     y0 = int(math.floor(y))
  155.     x1 = min(GRID_SIZE - 1, x0 + 1)
  156.     y1 = min(GRID_SIZE - 1, y0 + 1)
  157.     fx = x - x0
  158.     fy = y - y0
  159.  
  160.     c00 = get_rgb(surface, x0, y0)
  161.     c10 = get_rgb(surface, x1, y0)
  162.     c01 = get_rgb(surface, x0, y1)
  163.     c11 = get_rgb(surface, x1, y1)
  164.  
  165.     r0 = c00[0] * (1.0 - fx) + c10[0] * fx
  166.     g0 = c00[1] * (1.0 - fx) + c10[1] * fx
  167.     b0 = c00[2] * (1.0 - fx) + c10[2] * fx
  168.  
  169.     r1 = c01[0] * (1.0 - fx) + c11[0] * fx
  170.     g1 = c01[1] * (1.0 - fx) + c11[1] * fx
  171.     b1 = c01[2] * (1.0 - fx) + c11[2] * fx
  172.  
  173.     return (
  174.         r0 * (1.0 - fy) + r1 * fy,
  175.         g0 * (1.0 - fy) + g1 * fy,
  176.         b0 * (1.0 - fy) + b1 * fy,
  177.     )
  178.  
  179.  
  180. def transport_and_dim(
  181.     surface: pygame.Surface,
  182.     x: int,
  183.     y: int,
  184.     sx: int,
  185.     sy: int,
  186.     fraction: float,
  187.     dim: float,
  188. ) -> None:
  189.     c0 = get_rgb(surface, x, y)
  190.     c1 = get_rgb(surface, sx, sy)
  191.     r = c0[0] * (1.0 - fraction) + c1[0] * fraction
  192.     g = c0[1] * (1.0 - fraction) + c1[1] * fraction
  193.     b = c0[2] * (1.0 - fraction) + c1[2] * fraction
  194.     set_rgb(surface, x, y, (int(round(r * dim)), int(round(g * dim)), int(round(b * dim))))
  195.  
  196.  
  197. def apply_square_spiral_tail(
  198.     surface: pygame.Surface, cx: int, cy: int, radius: int, fraction: float, dim: float
  199. ) -> None:
  200.     for d in range(radius, -1, -1):
  201.         for i in range(cx - d, cx + d + 1):
  202.             transport_and_dim(surface, i, cy - d, i + 1, cy - d, fraction, dim)
  203.         for i in range(cy - d, cy + d + 1):
  204.             transport_and_dim(surface, cx + d, i, cx + d, i + 1, fraction, dim)
  205.         for i in range(cx + d, cx - d - 1, -1):
  206.             transport_and_dim(surface, i, cy + d, i - 1, cy + d, fraction, dim)
  207.         for i in range(cy + d, cy - d - 1, -1):
  208.             transport_and_dim(surface, cx - d, i, cx - d, i - 1, fraction, dim)
  209.  
  210.  
  211. def apply_round_spiral_tail(
  212.     surface: pygame.Surface,
  213.     cx: float,
  214.     cy: float,
  215.     radius: float,
  216.     fraction: float,
  217.     dim: float,
  218.     outward: bool = False,
  219. ) -> None:
  220.     src = surface.copy()
  221.     for y in range(GRID_SIZE):
  222.         for x in range(GRID_SIZE):
  223.             dx = x - cx
  224.             dy = y - cy
  225.             r = math.hypot(dx, dy)
  226.             if r > radius:
  227.                 c = get_rgb(src, x, y)
  228.                 set_rgb(surface, x, y, (int(round(c[0] * dim)), int(round(c[1] * dim)), int(round(c[2] * dim))))
  229.                 continue
  230.  
  231.             theta = math.atan2(dy, dx)
  232.             if outward:
  233.                 sample_r = max(0.0, r - ROUND_SPIRAL_RADIAL_STEP)
  234.             else:
  235.                 sample_r = min(radius + 1.5, r + ROUND_SPIRAL_RADIAL_STEP)
  236.             sample_theta = theta - ROUND_SPIRAL_ANGULAR_STEP
  237.             sx = cx + math.cos(sample_theta) * sample_r
  238.             sy = cy + math.sin(sample_theta) * sample_r
  239.  
  240.             c0 = get_rgb(src, x, y)
  241.             c1 = sample_rgb_bilinear(src, sx, sy)
  242.             nr = (c0[0] * (1.0 - fraction) + c1[0] * fraction) * dim
  243.             ng = (c0[1] * (1.0 - fraction) + c1[1] * fraction) * dim
  244.             nb = (c0[2] * (1.0 - fraction) + c1[2] * fraction) * dim
  245.             set_rgb(surface, x, y, (int(round(nr)), int(round(ng)), int(round(nb))))
  246.  
  247.  
  248. def apply_to_center_tail(surface: pygame.Surface, cx: float, cy: float, fraction: float, dim: float) -> None:
  249.     src = surface.copy()
  250.     for y in range(GRID_SIZE):
  251.         for x in range(GRID_SIZE):
  252.             dx = x - cx
  253.             dy = y - cy
  254.             r = math.hypot(dx, dy)
  255.             if r > 1e-6:
  256.                 ux = dx / r
  257.                 uy = dy / r
  258.                 sx = x + ux * ROUND_SPIRAL_RADIAL_STEP
  259.                 sy = y + uy * ROUND_SPIRAL_RADIAL_STEP
  260.                 c1 = sample_rgb_bilinear(src, sx, sy)
  261.             else:
  262.                 c1 = get_rgb(src, x, y)
  263.             c0 = get_rgb(src, x, y)
  264.             nr = (c0[0] * (1.0 - fraction) + c1[0] * fraction) * dim
  265.             ng = (c0[1] * (1.0 - fraction) + c1[1] * fraction) * dim
  266.             nb = (c0[2] * (1.0 - fraction) + c1[2] * fraction) * dim
  267.             set_rgb(surface, x, y, (int(round(nr)), int(round(ng)), int(round(nb))))
  268.  
  269.  
  270. def apply_from_center_tail(surface: pygame.Surface, cx: float, cy: float, fraction: float, dim: float) -> None:
  271.     src = surface.copy()
  272.     for y in range(GRID_SIZE):
  273.         for x in range(GRID_SIZE):
  274.             dx = x - cx
  275.             dy = y - cy
  276.             r = math.hypot(dx, dy)
  277.             if r > 1e-6:
  278.                 ux = dx / r
  279.                 uy = dy / r
  280.                 sx = x - ux * ROUND_SPIRAL_RADIAL_STEP
  281.                 sy = y - uy * ROUND_SPIRAL_RADIAL_STEP
  282.                 c1 = sample_rgb_bilinear(src, sx, sy)
  283.             else:
  284.                 c1 = get_rgb(src, x, y)
  285.             c0 = get_rgb(src, x, y)
  286.             nr = (c0[0] * (1.0 - fraction) + c1[0] * fraction) * dim
  287.             ng = (c0[1] * (1.0 - fraction) + c1[1] * fraction) * dim
  288.             nb = (c0[2] * (1.0 - fraction) + c1[2] * fraction) * dim
  289.             set_rgb(surface, x, y, (int(round(nr)), int(round(ng)), int(round(nb))))
  290.  
  291.  
  292. def blend_pixel_weighted(surface: pygame.Surface, px: int, py: int, color: tuple[int, int, int], w: float) -> None:
  293.     if not (0 <= px < GRID_SIZE and 0 <= py < GRID_SIZE):
  294.         return
  295.     w = clamp(w, 0.0, 1.0)
  296.     if w <= 0.0:
  297.         return
  298.     old = surface.get_at((px, py))
  299.     nr = int(old.r * (1.0 - w) + color[0] * w)
  300.     ng = int(old.g * (1.0 - w) + color[1] * w)
  301.     nb = int(old.b * (1.0 - w) + color[2] * w)
  302.     surface.set_at((px, py), (nr, ng, nb))
  303.  
  304.  
  305. def draw_aa_endpoint_disc(surface: pygame.Surface, cx: float, cy: float, color: tuple[int, int, int], radius: float = 0.85) -> None:
  306.     min_x = max(0, int(math.floor(cx - radius - 1.0)))
  307.     max_x = min(GRID_SIZE - 1, int(math.ceil(cx + radius + 1.0)))
  308.     min_y = max(0, int(math.floor(cy - radius - 1.0)))
  309.     max_y = min(GRID_SIZE - 1, int(math.ceil(cy + radius + 1.0)))
  310.     for py in range(min_y, max_y + 1):
  311.         for px in range(min_x, max_x + 1):
  312.             dx = (px + 0.5) - cx
  313.             dy = (py + 0.5) - cy
  314.             dist = math.hypot(dx, dy)
  315.             w = clamp(radius + 0.5 - dist, 0.0, 1.0)
  316.             blend_pixel_weighted(surface, px, py, color, w)
  317.  
  318.  
  319. def draw_aa_subpixel_line(
  320.     surface: pygame.Surface,
  321.     x0: float,
  322.     y0: float,
  323.     x1: float,
  324.     y1: float,
  325.     t: float,
  326.     color_shift: float,
  327.     phase_start: float = 0.0,
  328.     phase_span: float = 1.0,
  329. ) -> None:
  330.     dx = x1 - x0
  331.     dy = y1 - y0
  332.     steps = max(1, int(max(abs(dx), abs(dy)) * 3))
  333.     for i in range(steps + 1):
  334.         u = i / steps
  335.         x = x0 + dx * u
  336.         y = y0 + dy * u
  337.         xi = math.floor(x)
  338.         yi = math.floor(y)
  339.         fx = x - xi
  340.         fy = y - yi
  341.         color = hsv_color_with_phase(t, color_shift, phase_start + u * phase_span)
  342.         blend_pixel_weighted(surface, int(xi), int(yi), color, (1.0 - fx) * (1.0 - fy))
  343.         blend_pixel_weighted(surface, int(xi + 1), int(yi), color, fx * (1.0 - fy))
  344.         blend_pixel_weighted(surface, int(xi), int(yi + 1), color, (1.0 - fx) * fy)
  345.         blend_pixel_weighted(surface, int(xi + 1), int(yi + 1), color, fx * fy)
  346.  
  347.  
  348. def inject_lissajous_line(surface: pygame.Surface, t: float, color_shift: float, endpoint_speed: float) -> None:
  349.     c = (GRID_SIZE - 1) * 0.5
  350.     s = endpoint_speed
  351.     x1 = c + 11.5 * math.sin(t * s * 1.13 + 0.20)
  352.     y1 = c + 10.5 * math.sin(t * s * 1.71 + 1.30)
  353.     x2 = c + 12.0 * math.sin(t * s * 1.89 + 2.20)
  354.     y2 = c + 11.0 * math.sin(t * s * 1.37 + 0.70)
  355.     draw_aa_subpixel_line(surface, x1, y1, x2, y2, t, color_shift)
  356.  
  357.  
  358. def inject_orbiting_dots(surface: pygame.Surface, t: float, orbit_speed: float, color_shift: float) -> None:
  359.     cx = (GRID_SIZE - 1) * 0.5
  360.     cy = (GRID_SIZE - 1) * 0.5
  361.     radius = ORBIT_DOT_DIAMETER * 0.5
  362.     base_angle = t * orbit_speed
  363.     for i in range(3):
  364.         a = base_angle + i * (2.0 * math.pi / 3.0)
  365.         x = cx + math.cos(a) * ORBIT_RADIUS
  366.         y = cy + math.sin(a) * ORBIT_RADIUS
  367.         color = hsv_color_with_phase(t, color_shift, i / 3.0)
  368.         draw_aa_endpoint_disc(surface, x, y, color, radius=radius)
  369.  
  370.  
  371. def inject_lissajous_triangle(surface: pygame.Surface, t: float, color_shift: float, endpoint_speed: float) -> None:
  372.     c = (GRID_SIZE - 1) * 0.5
  373.     s = endpoint_speed
  374.     p1 = (
  375.         c + 11.8 * math.sin(t * s * 1.13 + 0.20),
  376.         c + 10.4 * math.sin(t * s * 1.71 + 1.30),
  377.     )
  378.     p2 = (
  379.         c + 11.2 * math.sin(t * s * 1.89 + 2.20),
  380.         c + 10.9 * math.sin(t * s * 1.37 + 0.70),
  381.     )
  382.     p3 = (
  383.         c + 10.9 * math.sin(t * s * 1.57 + 3.10),
  384.         c + 11.3 * math.sin(t * s * 1.21 + 2.40),
  385.     )
  386.  
  387.     l12 = math.hypot(p2[0] - p1[0], p2[1] - p1[1])
  388.     l23 = math.hypot(p3[0] - p2[0], p3[1] - p2[1])
  389.     l31 = math.hypot(p1[0] - p3[0], p1[1] - p3[1])
  390.     total = max(1e-6, l12 + l23 + l31)
  391.  
  392.     s1 = 0.0
  393.     s2 = l12 / total
  394.     s3 = (l12 + l23) / total
  395.  
  396.     draw_aa_subpixel_line(surface, p1[0], p1[1], p2[0], p2[1], t, color_shift, phase_start=s1, phase_span=(l12 / total))
  397.     draw_aa_subpixel_line(surface, p2[0], p2[1], p3[0], p3[1], t, color_shift, phase_start=s2, phase_span=(l23 / total))
  398.     draw_aa_subpixel_line(surface, p3[0], p3[1], p1[0], p1[1], t, color_shift, phase_start=s3, phase_span=(l31 / total))
  399.  
  400.  
  401. def scalar_noise(x: float, y: float, t: float) -> float:
  402.     n = (
  403.         math.sin(1.73 * x + 0.91 * t)
  404.         + math.sin(1.37 * y - 1.11 * t)
  405.         + math.sin(1.09 * (x + y) + 0.77 * t)
  406.     )
  407.     return n / 3.0
  408.  
  409.  
  410. def curl_noise(x: float, y: float, t: float) -> tuple[float, float]:
  411.     eps = 0.05
  412.     dn_dy = (scalar_noise(x, y + eps, t) - scalar_noise(x, y - eps, t)) / (2 * eps)
  413.     dn_dx = (scalar_noise(x + eps, y, t) - scalar_noise(x - eps, y, t)) / (2 * eps)
  414.     return dn_dy, -dn_dx
  415.  
  416.  
  417. def transport_surface(surface: pygame.Surface, sample_fn, fraction: float = 0.75, dim: float = 1.0, glow: float = 0.0) -> None:
  418.     src = surface.copy()
  419.     for y in range(GRID_SIZE):
  420.         for x in range(GRID_SIZE):
  421.             c0 = get_rgb(src, x, y)
  422.             sx, sy = sample_fn(x, y)
  423.             c1 = sample_rgb_bilinear(src, sx, sy)
  424.             nr = (c0[0] * (1.0 - fraction) + c1[0] * fraction) * dim
  425.             ng = (c0[1] * (1.0 - fraction) + c1[1] * fraction) * dim
  426.             nb = (c0[2] * (1.0 - fraction) + c1[2] * fraction) * dim
  427.             if glow > 0.0:
  428.                 vx = sx - x
  429.                 vy = sy - y
  430.                 g = clamp(math.hypot(vx, vy) * glow, 0.0, 1.0)
  431.                 nr = nr + g * 28.0
  432.                 ng = ng + g * 28.0
  433.                 nb = nb + g * 28.0
  434.                 peak = max(nr, ng, nb)
  435.                 if peak > 220.0:
  436.                     s = 220.0 / peak
  437.                     nr *= s
  438.                     ng *= s
  439.                     nb *= s
  440.             set_rgb(surface, x, y, (int(round(nr)), int(round(ng)), int(round(nb))))
  441.  
  442.  
  443. def apply_mode(surface: pygame.Surface, mode_idx: int, t: float, state: dict) -> None:
  444.     cx = (GRID_SIZE - 1) * 0.5
  445.     cy = (GRID_SIZE - 1) * 0.5
  446.  
  447.     if mode_idx == 0:
  448.         # No trail processing: frame is emitter-only (buffer clear happens in main loop).
  449.         return
  450.  
  451.     elif mode_idx == 1:
  452.         # Chromatic split transport: R/G/B sample from different vector fields.
  453.         src = surface.copy()
  454.         for y in range(GRID_SIZE):
  455.             for x in range(GRID_SIZE):
  456.                 dx = x - cx
  457.                 dy = y - cy
  458.                 r = math.hypot(dx, dy) + 1e-6
  459.                 th = math.atan2(dy, dx)
  460.                 # R inward spiral
  461.                 sxr = cx + math.cos(th - 0.18) * min(40.0, r + 0.35)
  462.                 syr = cy + math.sin(th - 0.18) * min(40.0, r + 0.35)
  463.                 # G outward
  464.                 sxg = cx + math.cos(th) * max(0.0, r - 0.35)
  465.                 syg = cy + math.sin(th) * max(0.0, r - 0.35)
  466.                 # B rotation
  467.                 sxb = cx + math.cos(th - 0.22) * r
  468.                 syb = cy + math.sin(th - 0.22) * r
  469.                 cr = sample_rgb_bilinear(src, sxr, syr)
  470.                 cg = sample_rgb_bilinear(src, sxg, syg)
  471.                 cb = sample_rgb_bilinear(src, sxb, syb)
  472.                 set_rgb(surface, x, y, (int(cr[0]), int(cg[1]), int(cb[2])))
  473.  
  474.     elif mode_idx == 2:
  475.         # Polar radial warp: periodic in/out scaling around grid center.
  476.         src = surface.copy()
  477.         pulse = 1.0 + 0.16 * math.sin(t * 2.3)
  478.         for y in range(GRID_SIZE):
  479.             for x in range(GRID_SIZE):
  480.                 dx = x - cx
  481.                 dy = y - cy
  482.                 r = math.hypot(dx, dy)
  483.                 th = math.atan2(dy, dx)
  484.                 sr = r / pulse
  485.                 sx = cx + math.cos(th) * sr
  486.                 sy = cy + math.sin(th) * sr
  487.                 c = sample_rgb_bilinear(src, sx, sy)
  488.                 set_rgb(surface, x, y, (int(c[0]), int(c[1]), int(c[2])))
  489.  
  490.     elif mode_idx == 3:
  491.         # Polar warp 2: outward-only projection by sampling from smaller radius.
  492.         src = surface.copy()
  493.         # Outward-only projection: sample from inner radius so content mirrors/flows outward.
  494.         outward_scale = 1.22
  495.         for y in range(GRID_SIZE):
  496.             for x in range(GRID_SIZE):
  497.                 dx = x - cx
  498.                 dy = y - cy
  499.                 r = math.hypot(dx, dy)
  500.                 th = math.atan2(dy, dx)
  501.                 sr = r / outward_scale
  502.                 sx = cx + math.cos(th) * sr
  503.                 sy = cy + math.sin(th) * sr
  504.                 c = sample_rgb_bilinear(src, sx, sy)
  505.                 set_rgb(surface, x, y, (int(c[0]), int(c[1]), int(c[2])))
  506.  
  507.     elif mode_idx == 4:
  508.         # Event-driven shockwave: radial displacement starts when center 2x2 gets newly lit.
  509.         trigger_time = state.get("shockwave_trigger_time", -1.0)
  510.         if trigger_time < 0.0:
  511.             return
  512.         elapsed = t - trigger_time
  513.         if elapsed < 0.0:
  514.             return
  515.         wave_speed = 18.0
  516.         wave_radius = elapsed * wave_speed
  517.         max_radius = math.hypot(cx, cy) + 1.5
  518.         if wave_radius > max_radius:
  519.             return
  520.         width = 1.3
  521.         def sample_fn(x, y):
  522.             dx = x - cx
  523.             dy = y - cy
  524.             r = math.hypot(dx, dy) + 1e-6
  525.             ux = dx / r
  526.             uy = dy / r
  527.             strength = 0.15
  528.             if abs(r - wave_radius) < width:
  529.                 strength = 1.2
  530.             return x - ux * strength, y - uy * strength
  531.         transport_surface(surface, sample_fn, fraction=0.82, dim=0.997)
  532.  
  533.     elif mode_idx == 5:
  534.         # Periodic shockwave: repeats every 500 ms independent of emitter-center trigger.
  535.         period = 0.5
  536.         phase = (t % period) / period
  537.         max_radius = math.hypot(cx, cy)
  538.         wave_radius = phase * max_radius
  539.         width = 1.1
  540.  
  541.         def sample_fn(x, y):
  542.             dx = x - cx
  543.             dy = y - cy
  544.             r = math.hypot(dx, dy) + 1e-6
  545.             ux = dx / r
  546.             uy = dy / r
  547.             strength = 0.0
  548.             if abs(r - wave_radius) < width:
  549.                 strength = 1.6
  550.             return x - ux * strength, y - uy * strength
  551.  
  552.         transport_surface(surface, sample_fn, fraction=0.9, dim=0.998)
  553.  
  554.     elif mode_idx == 6:
  555.         # Rotating coordinate sampling: global frame sampled in a sinusoidally rotated basis.
  556.         src = surface.copy()
  557.         angle = math.sin(t) * 0.3
  558.         ca = math.cos(angle)
  559.         sa = math.sin(angle)
  560.         for y in range(GRID_SIZE):
  561.             for x in range(GRID_SIZE):
  562.                 dx = x - cx
  563.                 dy = y - cy
  564.                 sx = cx + (dx * ca - dy * sa)
  565.                 sy = cy + (dx * sa + dy * ca)
  566.                 c = sample_rgb_bilinear(src, sx, sy)
  567.                 set_rgb(surface, x, y, (int(c[0]), int(c[1]), int(c[2])))
  568.  
  569.     elif mode_idx == 7:
  570.         # Rotating wind advection: global directional smear, wind vector rotates over time.
  571.         # Global directional smear with slowly rotating wind direction.
  572.         wind_angle = t * (2.0 * math.pi * 0.25)
  573.         wind_step = 0.95
  574.         wx = math.cos(wind_angle) * wind_step
  575.         wy = math.sin(wind_angle) * wind_step
  576.  
  577.         def sample_fn(x, y):
  578.             # Backward sampling along wind keeps advection stable.
  579.             return x - wx, y - wy
  580.  
  581.         transport_surface(surface, sample_fn, fraction=0.86, dim=0.999)
  582.  
  583.     elif mode_idx == 8:
  584.         # Rotating wind wave: linear advection + sinusoidal perpendicular wobble.
  585.         wind_angle = t * (2.0 * math.pi * 0.25)
  586.         wind_step = 0.95
  587.         wx = math.cos(wind_angle)
  588.         wy = math.sin(wind_angle)
  589.         # Unit perpendicular to current wind direction.
  590.         px = -wy
  591.         py = wx
  592.         wave_amp = 0.65
  593.         wave_freq = 0.20
  594.         wave_speed = 1.20
  595.  
  596.         def sample_fn(x, y):
  597.             # 1) Backward advection along linear wind.
  598.             sx = x - wx * wind_step
  599.             sy = y - wy * wind_step
  600.             # 2) Sinus wobble only along perpendicular axis.
  601.             proj = sx * wx + sy * wy
  602.             wobble = math.sin(proj * wave_freq + t * wave_speed) * wave_amp
  603.             sx += px * wobble
  604.             sy += py * wobble
  605.             return sx, sy
  606.  
  607.         transport_surface(surface, sample_fn, fraction=0.88, dim=0.999)
  608.  
  609.     elif mode_idx == 9:
  610.         # Attractor field: inverse-square pull plus tangential swirl around moving attractors.
  611.         def sample_fn(x, y):
  612.             dx = dy = 0.0
  613.             at = [
  614.                 (cx + 7.0 * math.sin(t * 0.7), cy + 7.0 * math.cos(t * 0.9)),
  615.                 (cx + 6.0 * math.cos(t * 1.1), cy + 6.0 * math.sin(t * 0.8)),
  616.             ]
  617.             for ax, ay in at:
  618.                 vx = ax - x
  619.                 vy = ay - y
  620.                 d2 = vx * vx + vy * vy + 2.0
  621.                 inv = 1.0 / d2
  622.                 dx += vx * inv * 9.0
  623.                 dy += vy * inv * 9.0
  624.                 dx += -vy * inv * 4.0
  625.                 dy += vx * inv * 4.0
  626.             return x - dx, y - dy
  627.         transport_surface(surface, sample_fn, fraction=0.8, dim=0.997)
  628.  
  629.     elif mode_idx == 10:
  630.         # Fractal spiral advection: spiral angle depends on radius and time.
  631.         def sample_fn(x, y):
  632.             dx = x - cx
  633.             dy = y - cy
  634.             r = math.hypot(dx, dy)
  635.             th = math.atan2(dy, dx)
  636.             ang_step = 0.18 + 0.22 * math.sin(r * 0.45 + t * 0.8)
  637.             sr = min(40.0, r + 0.28)
  638.             sx = cx + math.cos(th - ang_step) * sr
  639.             sy = cy + math.sin(th - ang_step) * sr
  640.             return sx, sy
  641.         transport_surface(surface, sample_fn, fraction=0.82, dim=0.998)
  642.     elif mode_idx == 11:
  643.         # Square spiral stream: ring-by-ring orthogonal transport on square shells.
  644.         apply_square_spiral_tail(
  645.             surface,
  646.             GRID_SIZE // 2,
  647.             GRID_SIZE // 2,
  648.             (GRID_SIZE // 2) - 1,
  649.             SPIRAL_TRANSPORT_FRACTION,
  650.             SPIRAL_DIM_DEFAULT,
  651.         )
  652.     elif mode_idx == 12:
  653.         # Round spiral inward: backward sample from larger radius to pull content inward.
  654.         apply_round_spiral_tail(
  655.             surface,
  656.             cx,
  657.             cy,
  658.             math.hypot(cx, cy),
  659.             SPIRAL_TRANSPORT_FRACTION,
  660.             SPIRAL_DIM_DEFAULT,
  661.             outward=False,
  662.         )
  663.     elif mode_idx == 13:
  664.         # Round spiral outward: backward sample from smaller radius to push outward.
  665.         apply_round_spiral_tail(
  666.             surface,
  667.             cx,
  668.             cy,
  669.             math.hypot(cx, cy),
  670.             SPIRAL_TRANSPORT_FRACTION,
  671.             SPIRAL_DIM_DEFAULT,
  672.             outward=True,
  673.         )
  674.     elif mode_idx == 14:
  675.         # To center: radial inward transport toward center for all pixels.
  676.         apply_to_center_tail(
  677.             surface,
  678.             cx,
  679.             cy,
  680.             SPIRAL_TRANSPORT_FRACTION,
  681.             SPIRAL_DIM_DEFAULT,
  682.         )
  683.     elif mode_idx == 15:
  684.         # From center: radial outward transport from center for all pixels.
  685.         apply_from_center_tail(
  686.             surface,
  687.             cx,
  688.             cy,
  689.             SPIRAL_TRANSPORT_FRACTION,
  690.             SPIRAL_DIM_DEFAULT,
  691.         )
  692.  
  693.  
  694. def apply_global_fade(surface: pygame.Surface, fade_factor: float) -> None:
  695.     for y in range(GRID_SIZE):
  696.         for x in range(GRID_SIZE):
  697.             c = surface.get_at((x, y))
  698.             surface.set_at((x, y), (int(round(c.r * fade_factor)), int(round(c.g * fade_factor)), int(round(c.b * fade_factor))))
  699.  
  700.  
  701. def apply_blur_2x2(surface: pygame.Surface) -> None:
  702.     src = surface.copy()
  703.     for y in range(GRID_SIZE):
  704.         for x in range(GRID_SIZE):
  705.             r_sum = 0.0
  706.             g_sum = 0.0
  707.             b_sum = 0.0
  708.             for oy in (-1, 0, 1):
  709.                 sy = clamp(y + oy, 0, GRID_SIZE - 1)
  710.                 for ox in (-1, 0, 1):
  711.                     sx = clamp(x + ox, 0, GRID_SIZE - 1)
  712.                     c = get_rgb(src, int(sx), int(sy))
  713.                     r_sum += c[0]
  714.                     g_sum += c[1]
  715.                     b_sum += c[2]
  716.             r = r_sum / 9.0
  717.             g = g_sum / 9.0
  718.             b = b_sum / 9.0
  719.             set_rgb(surface, x, y, (int(r), int(g), int(b)))
  720.  
  721.  
  722. def center_4_energy(surface: pygame.Surface) -> float:
  723.     c0 = (GRID_SIZE // 2) - 1
  724.     c1 = GRID_SIZE // 2
  725.     e = 0.0
  726.     for y in (c0, c1):
  727.         for x in (c0, c1):
  728.             r, g, b = get_rgb(surface, x, y)
  729.             e += r + g + b
  730.     return e
  731.  
  732.  
  733. def main() -> None:
  734.     pygame.init()
  735.     screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))
  736.     pygame.display.set_caption("Prototypes")
  737.     clock = pygame.time.Clock()
  738.  
  739.     font = pygame.font.SysFont("arial", 16)
  740.     font_small = pygame.font.SysFont("arial", 12)
  741.  
  742.     grid_rect = pygame.Rect(MARGIN, MARGIN, GRID_PIXELS, GRID_PIXELS)
  743.     grid_surface = pygame.Surface((GRID_SIZE, GRID_SIZE))
  744.     grid_surface.fill((0, 0, 0))
  745.  
  746.     ui_x = grid_rect.right + MARGIN
  747.     panel_rect = pygame.Rect(ui_x, MARGIN, RIGHT_UI_WIDTH, WINDOW_HEIGHT - 2 * MARGIN)
  748.  
  749.     def slider_y(idx: int) -> int:
  750.         return MARGIN + 128 + idx * 56
  751.  
  752.     sliders = [
  753.         Slider("Endpoint Speed", 0.00, 2.00, ENDPOINT_SPEED_DEFAULT, pygame.Rect(ui_x + 20, slider_y(0), RIGHT_UI_WIDTH - 40, 14), 2),
  754.         Slider("Color Cycling Speed", 0.00, 1.00, COLOR_SHIFT_DEFAULT, pygame.Rect(ui_x + 20, slider_y(1), RIGHT_UI_WIDTH - 40, 14), 2),
  755.         Slider("Fade %", 95.0, 100.0, 99.995, pygame.Rect(ui_x + 20, slider_y(2), RIGHT_UI_WIDTH - 40, 14), 3),
  756.     ]
  757.  
  758.     emitter_gap = 6
  759.     emitter_btn_w = (RIGHT_UI_WIDTH - 40 - emitter_gap * 2) // 3
  760.     emitter_line_button = Button("Line", pygame.Rect(ui_x + 20, MARGIN + 58, emitter_btn_w, 28), active=True)
  761.     emitter_dots_button = Button("Orbiting dots", pygame.Rect(ui_x + 20 + emitter_btn_w + emitter_gap, MARGIN + 58, emitter_btn_w, 28), active=False)
  762.     emitter_triangle_button = Button("Triangle", pygame.Rect(ui_x + 20 + (emitter_btn_w + emitter_gap) * 2, MARGIN + 58, emitter_btn_w, 28), active=False)
  763.  
  764.     mode_buttons: list[Button] = []
  765.     mode_grid_y = MARGIN + 332
  766.     mode_gap_x = 8
  767.     mode_gap_y = 4
  768.     mode_cols = 2
  769.     mode_btn_w = (RIGHT_UI_WIDTH - 40 - mode_gap_x) // mode_cols
  770.     mode_btn_h = 18
  771.     for idx, label in enumerate(MODE_BUTTON_LABELS):
  772.         col = idx % mode_cols
  773.         row = idx // mode_cols
  774.         bx = ui_x + 20 + col * (mode_btn_w + mode_gap_x)
  775.         by = mode_grid_y + row * (mode_btn_h + mode_gap_y)
  776.         mode_buttons.append(Button(label, pygame.Rect(bx, by, mode_btn_w, mode_btn_h), active=(idx == 0)))
  777.     mode_rows = (len(MODE_BUTTON_LABELS) + mode_cols - 1) // mode_cols
  778.     blur_button = Button("Blur", pygame.Rect(ui_x + 20, mode_grid_y + mode_rows * (mode_btn_h + mode_gap_y) + 6, RIGHT_UI_WIDTH - 40, 24), active=False)
  779.  
  780.     state = {
  781.         "history": [grid_surface.copy(), grid_surface.copy(), grid_surface.copy()],
  782.         "rd": [[0.0 for _ in range(GRID_SIZE)] for _ in range(GRID_SIZE)],
  783.         "frame": 0,
  784.         "fade": 0.999,
  785.         "shockwave_trigger_time": -1.0,
  786.         "shockwave_center_latched": False,
  787.     }
  788.  
  789.     emitter_mode = "line"
  790.     mode_idx = 0
  791.     blur_enabled = False
  792.  
  793.     start_time = pygame.time.get_ticks() / 1000.0
  794.     running = True
  795.     while running:
  796.         clock.tick(0)
  797.  
  798.         for event in pygame.event.get():
  799.             if event.type == pygame.QUIT:
  800.                 running = False
  801.  
  802.             if emitter_line_button.handle_event(event):
  803.                 emitter_mode = "line"
  804.                 emitter_line_button.active = True
  805.                 emitter_dots_button.active = False
  806.                 emitter_triangle_button.active = False
  807.             if emitter_dots_button.handle_event(event):
  808.                 emitter_mode = "orbiting_dots"
  809.                 emitter_line_button.active = False
  810.                 emitter_dots_button.active = True
  811.                 emitter_triangle_button.active = False
  812.             if emitter_triangle_button.handle_event(event):
  813.                 emitter_mode = "triangle"
  814.                 emitter_line_button.active = False
  815.                 emitter_dots_button.active = False
  816.                 emitter_triangle_button.active = True
  817.  
  818.             for i, btn in enumerate(mode_buttons):
  819.                 if btn.handle_event(event):
  820.                     mode_idx = i
  821.                     for j, other in enumerate(mode_buttons):
  822.                         other.active = (j == mode_idx)
  823.             if blur_button.handle_event(event):
  824.                 blur_enabled = not blur_enabled
  825.                 blur_button.active = blur_enabled
  826.  
  827.             for slider in sliders:
  828.                 slider.handle_event(event)
  829.  
  830.         t = pygame.time.get_ticks() / 1000.0 - start_time
  831.         endpoint_speed, color_shift, fade_percent = [s.value for s in sliders]
  832.         fade_factor = fade_percent / 100.0
  833.         state["fade"] = fade_factor
  834.  
  835.         center_before = center_4_energy(grid_surface)
  836.         if mode_idx == 0:
  837.             grid_surface.fill((0, 0, 0))
  838.             center_before = 0.0
  839.  
  840.         if emitter_mode == "line":
  841.             inject_lissajous_line(grid_surface, t, color_shift, endpoint_speed)
  842.         elif emitter_mode == "orbiting_dots":
  843.             inject_orbiting_dots(grid_surface, t, endpoint_speed, color_shift)
  844.         else:
  845.             inject_lissajous_triangle(grid_surface, t, color_shift, endpoint_speed)
  846.  
  847.         if mode_idx == 4:
  848.             center_after = center_4_energy(grid_surface)
  849.             center_newly_drawn = (center_after - center_before) > 24.0
  850.             if center_newly_drawn and not state["shockwave_center_latched"]:
  851.                 state["shockwave_trigger_time"] = t
  852.                 state["shockwave_center_latched"] = True
  853.             elif not center_newly_drawn:
  854.                 state["shockwave_center_latched"] = False
  855.  
  856.         if mode_idx != 0:
  857.             apply_mode(grid_surface, mode_idx, t, state)
  858.             apply_global_fade(grid_surface, fade_factor)
  859.         if blur_enabled:
  860.             apply_blur_2x2(grid_surface)
  861.  
  862.         # Update temporal history for echo-based modes.
  863.         state["history"][2] = state["history"][1]
  864.         state["history"][1] = state["history"][0]
  865.         state["history"][0] = grid_surface.copy()
  866.         state["frame"] += 1
  867.  
  868.         screen.fill(BG)
  869.         scaled_grid = pygame.transform.scale(grid_surface, (GRID_PIXELS, GRID_PIXELS))
  870.         screen.blit(scaled_grid, grid_rect.topleft)
  871.  
  872.         pygame.draw.rect(screen, PANEL, panel_rect, border_radius=10)
  873.         title = font.render("Prototypes", True, TEXT)
  874.         screen.blit(title, (panel_rect.left + 20, panel_rect.top + 20))
  875.         fps_text = font.render(f"FPS: {clock.get_fps():5.1f}", True, TEXT)
  876.         screen.blit(fps_text, (panel_rect.right - fps_text.get_width() - 20, panel_rect.top + 20))
  877.  
  878.         emitter_line_button.draw(screen, font)
  879.         emitter_dots_button.draw(screen, font)
  880.         emitter_triangle_button.draw(screen, font)
  881.  
  882.         mode_header = font_small.render("Trail Modes", True, TEXT)
  883.         screen.blit(mode_header, (panel_rect.left + 20, MARGIN + 334))
  884.         for btn in mode_buttons:
  885.             btn.draw(screen, font_small)
  886.         blur_button.draw(screen, font)
  887.  
  888.         for slider in sliders:
  889.             slider.draw(screen, font)
  890.  
  891.         pygame.display.flip()
  892.  
  893.     pygame.quit()
  894.     sys.exit(0)
  895.  
  896.  
  897. if __name__ == "__main__":
  898.     main()
  899.  
Advertisement
Add Comment
Please, Sign In to add comment