<?phpnamespace App\Render;use JumPHP\Renderer\Renderer;use JumPHP\Shorts\Quality;use JumPHP\Shorts\Sprites;use JumPHP\Util\Color;/** * The shapes of these videos, drawn for the HD renderer (project.yaml hd: the 2D is drawn at the window's resolution, coordinates stay * in view pixels and may be fractions): rings and arcs as parallel lines one real pixel apart, ellipses and polygons as scanlines one real * pixel high. Nothing is rounded to the view grid, so edges are round and motion is smooth. Without HD (hd = 1) they still work, coarser. * Colours are [r, g, b] arrays; mix() blends towards the background where a glow should be (no alpha on lines). */final class Gfx{ public const BG_TOP = [9, 10, 26]; public const BG_BOTTOM = [22, 13, 44]; /** [r, g, b] of a hue (0..1, wraps), saturation and value */ public static function hsv(float $h, float $s = 0.55, float $v = 1.0): array { $h -= floor($h); $i = (int)floor($h * 6); $f = $h * 6 - $i; $p = $v * (1 - $s); $q = $v * (1 - $f * $s); $t = $v * (1 - (1 - $f) * $s); [$r, $g, $b] = match ($i % 6) { 0 => [$v, $t, $p], 1 => [$q, $v, $p], 2 => [$p, $v, $t], 3 => [$p, $q, $v], 4 => [$t, $p, $v], default => [$v, $p, $q], }; return [(int)round($r * 255), (int)round($g * 255), (int)round($b * 255)]; } /** @return array{0: int, 1: int, 2: int} */ public static function rgb(array|string $c): array { if (is_array($c)) { return [(int)$c[0], (int)$c[1], (int)$c[2]]; } $hex = ltrim($c, '#'); return [(int)hexdec(substr($hex, 0, 2)), (int)hexdec(substr($hex, 2, 2)), (int)hexdec(substr($hex, 4, 2))]; } public static function hex(array $c): string { return sprintf('#%02x%02x%02x', $c[0], $c[1], $c[2]); } /** $a moved $k (0..1) of the way to $b */ public static function mix(array $a, array $b, float $k): array { $k = max(0.0, min(1.0, $k)); return [(int)round($a[0] + ($b[0] - $a[0]) * $k), (int)round($a[1] + ($b[1] - $a[1]) * $k), (int)round($a[2] + ($b[2] - $a[2]) * $k)]; } public static function color(array $rgb, int $alpha = 255): Color { return new Color($rgb[0], $rgb[1], $rgb[2], $alpha); } /** The background: a vertical gradient (bands), a little lighter for a moment after a beat ($pulse 0..1, tinted by $tint). */ public static function background(Renderer $r, float $pulse = 0.0, array $tint = [120, 120, 255]): void { $w = $r->getWidth(); $h = $r->getHeight(); $bands = 24; for ($i = 0; $i < $bands; $i++) { $c = self::mix(self::BG_TOP, self::BG_BOTTOM, $i / ($bands - 1)); if ($pulse > 0.01) { $c = self::mix($c, $tint, $pulse * 0.12); } $r->fillRect(0, $i * $h / $bands, $w, $h / $bands + 1, self::color($c)); } } public static function disc(Renderer $r, float $x, float $y, float $radius, array $rgb, int $alpha = 255): void { if (Quality::$live) { Sprites::disc($r, $x, $y, $radius, $radius, 0.0, $rgb, $alpha); return; } if ($radius > 0.2) { $r->fillCircle($x, $y, $radius, self::color($rgb, $alpha)); } } /** A soft glow: a few translucent discs, bigger and fainter outwards. */ public static function glow(Renderer $r, float $x, float $y, float $radius, array $rgb, float $strength = 1.0): void { if (Quality::$live) { if (Quality::halos()) { Sprites::glow($r, $x, $y, $radius, $rgb, $strength); } return; } foreach ([[1.0, 20], [0.72, 28], [0.46, 38]] as [$k, $a]) { self::disc($r, $x, $y, $radius * (1 + $k * 1.2), $rgb, (int)($a * $strength)); } } /** * An arc of a circle from $from to $to radians (clockwise on screen), $thickness view pixels, as parallel polylines one real pixel apart. * Parts outside the view are skipped. */ public static function arc(Renderer $r, float $cx, float $cy, float $radius, float $from, float $to, float $thickness, array $rgb, float $ky = 1.0): void { $span = $to - $from; if ($span <= 0 || $radius <= 0.3 || $thickness <= 0) { return; } $w = $r->getWidth(); $h = $r->getHeight(); // the circle is entirely outside the view: nothing to draw $far = max(hypot($cx, $cy), hypot($w - $cx, $cy), hypot($cx, $h - $cy), hypot($w - $cx, $h - $cy)); if ($radius - $thickness > $far + 4) { return; } if (Quality::$live) { self::arcFast($r, $cx, $cy, $radius, $from, $span, $thickness, $rgb, $ky, $w, $h); return; } $hd = max(1, $r->hd()); $segments = max(8, min(720, (int)ceil(abs($span) * $radius / 2.5))); $lines = max(1, (int)ceil($thickness * $hd * 0.8)); $color = self::color($rgb); $cos = $sin = []; for ($i = 0; $i <= $segments; $i++) { $a = $from + $span * $i / $segments; $cos[$i] = cos($a); $sin[$i] = sin($a); } for ($k = 0; $k < $lines; $k++) { $rr = $radius - $thickness / 2 + ($k + 0.5) * $thickness / $lines; $px = $cx + $cos[0] * $rr; $py = $cy + $sin[0] * $rr * $ky; for ($i = 1; $i <= $segments; $i++) { $x = $cx + $cos[$i] * $rr; $y = $cy + $sin[$i] * $rr * $ky; if (($x > -6 && $x < $w + 6 && $y > -6 && $y < $h + 6) || ($px > -6 && $px < $w + 6 && $py > -6 && $py < $h + 6)) { $r->drawLine($px, $py, $x, $y, $color); } $px = $x; $py = $y; } } } /** * The live arc: a chain of short rectangles along the curve (one copy each) instead of a polyline per real pixel of thickness. * Chunks are a little longer than their chord, so the outside of a thick curve has no gap. */ private static function arcFast(Renderer $r, float $cx, float $cy, float $radius, float $from, float $span, float $thickness, array $rgb, float $ky, float $w, float $h): void { // a chord of length l leaves the curve by l^2 / (8 R) in the middle: 0.15 view pixel at most (0.3 when running light) $chunk = max(2.5, min(18.0, sqrt(($radius < 400 ? 1.2 : 2.4) * (Quality::$level >= 2 ? 1.0 : 2.0) * $radius))); $segments = max(1, min(240, (int)ceil(max(abs($span) / 0.35, abs($span) * $radius / $chunk)))); $th = max($thickness, 0.5); $px = $cx + cos($from) * $radius; $py = $cy + sin($from) * $radius * $ky; for ($i = 1; $i <= $segments; $i++) { $a = $from + $span * $i / $segments; $x = $cx + cos($a) * $radius; $y = $cy + sin($a) * $radius * $ky; if (($x > -8 && $x < $w + 8 && $y > -8 && $y < $h + 8) || ($px > -8 && $px < $w + 8 && $py > -8 && $py < $h + 8)) { $dx = $x - $px; $dy = $y - $py; $len = sqrt($dx * $dx + $dy * $dy); if ($len > 0.01) { Sprites::rect($r, ($px + $x) / 2, ($py + $y) / 2, $len * 1.1 + 0.25, $th, atan2($dy, $dx), $rgb); } } $px = $x; $py = $y; } } /** A ring with a gap of $gap radians centred on $angle; round caps at the ends of the gap. */ public static function ring(Renderer $r, float $cx, float $cy, float $radius, float $angle, float $gap, float $thickness, array $rgb, bool $caps = true): void { if ($gap >= 2 * M_PI - 0.01) { return; } if ($gap <= 0.001) { self::arc($r, $cx, $cy, $radius, 0, 2 * M_PI, $thickness, $rgb); return; } $from = $angle + $gap / 2; self::arc($r, $cx, $cy, $radius, $from, $from + 2 * M_PI - $gap, $thickness, $rgb); if ($caps) { foreach ([$from, $angle - $gap / 2] as $a) { self::disc($r, $cx + cos($a) * $radius, $cy + sin($a) * $radius, $thickness * 0.62, $rgb); } } } /** A ring whose colour runs round it (a wave of colour): $hueAt(angle) gives the [r, g, b]. */ public static function waveRing(Renderer $r, float $cx, float $cy, float $radius, float $angle, float $gap, float $thickness, callable $colorAt, int $pieces = 36): void { if (Quality::$live) { $pieces = min($pieces, 18); } $from = $angle + $gap / 2; $span = 2 * M_PI - $gap; for ($i = 0; $i < $pieces; $i++) { $a0 = $from + $span * $i / $pieces; $a1 = $from + $span * ($i + 1) / $pieces + 0.01; self::arc($r, $cx, $cy, $radius, $a0, $a1, $thickness, $colorAt($a0 - $angle)); } if ($gap > 0.001) { foreach ([$from, $angle - $gap / 2] as $a) { self::disc($r, $cx + cos($a) * $radius, $cy + sin($a) * $radius, $thickness * 0.62, $colorAt($a - $angle)); } } } /** A rotated ellipse (radii $rx along $angle, $ry across), scanlines one real pixel high. */ public static function blob(Renderer $r, float $cx, float $cy, float $rx, float $ry, float $angle, array $rgb, int $alpha = 255): void { if ($rx < 0.2 || $ry < 0.2) { return; } if (Quality::$live) { Sprites::disc($r, $cx, $cy, $rx, $ry, $angle, $rgb, $alpha); return; } $hd = max(1, $r->hd()); $step = 1.0 / $hd; $c = cos($angle); $s = sin($angle); $ext = sqrt(($rx * $c) ** 2 + ($ry * $s) ** 2) ; $ext = max($ext, sqrt(($rx * $s) ** 2 + ($ry * $c) ** 2)); $a = ($c / $rx) ** 2 + ($s / $ry) ** 2; $k = $c * $s * (1 / ($rx * $rx) - 1 / ($ry * $ry)); $d = ($s / $rx) ** 2 + ($c / $ry) ** 2; $color = self::color($rgb, $alpha); for ($y = -$ext + $step / 2; $y < $ext; $y += $step) { $b = 2 * $y * $k; $cc = $y * $y * $d - 1; $disc = $b * $b - 4 * $a * $cc; if ($disc <= 0) { continue; } $q = sqrt($disc); $x0 = (-$b - $q) / (2 * $a); $x1 = (-$b + $q) / (2 * $a); $r->fillRect($cx + $x0, $cy + $y, $x1 - $x0, $step, $color); } } /** * A filled polygon (even-odd: stars work), scanlines one real pixel high. * * @param list<array{0: float, 1: float}> $pts */ public static function poly(Renderer $r, array $pts, array $rgb, int $alpha = 255): void { $n = count($pts); if ($n < 3) { return; } $hd = max(1, $r->hd()); $step = Quality::$live ? max(1.0 / $hd, 0.5) : 1.0 / $hd; $minY = min(array_column($pts, 1)); $maxY = max(array_column($pts, 1)); $color = self::color($rgb, $alpha); for ($y = $minY + $step / 2; $y < $maxY; $y += $step) { $xs = []; for ($i = 0; $i < $n; $i++) { [$x1, $y1] = $pts[$i]; [$x2, $y2] = $pts[($i + 1) % $n]; if (($y1 <= $y && $y2 > $y) || ($y2 <= $y && $y1 > $y)) { $xs[] = $x1 + ($y - $y1) / ($y2 - $y1) * ($x2 - $x1); } } sort($xs); for ($i = 0; $i + 1 < count($xs); $i += 2) { $r->fillRect($xs[$i], $y, $xs[$i + 1] - $xs[$i], $step, $color); } } } /** A rectangle $w x $h centred on ($cx, $cy), turned by $angle. */ public static function box(Renderer $r, float $cx, float $cy, float $w, float $h, float $angle, array $rgb, int $alpha = 255): void { if (Quality::$live) { Sprites::rect($r, $cx, $cy, $w, $h, $angle, $rgb, $alpha); return; } $c = cos($angle); $s = sin($angle); $pts = []; foreach ([[-1, -1], [1, -1], [1, 1], [-1, 1]] as [$ux, $uy]) { $px = $ux * $w / 2; $py = $uy * $h / 2; $pts[] = [$cx + $px * $c - $py * $s, $cy + $px * $s + $py * $c]; } self::poly($r, $pts, $rgb, $alpha); } /** A star with $points tips, outer radius $ro, inner radius $ri. */ public static function star(Renderer $r, float $cx, float $cy, float $ro, float $ri, float $angle, array $rgb, int $points = 5): void { $pts = []; for ($i = 0; $i < $points * 2; $i++) { $a = $angle + M_PI * $i / $points - M_PI / 2; $rad = $i % 2 ? $ri : $ro; $pts[] = [$cx + cos($a) * $rad, $cy + sin($a) * $rad]; } self::poly($r, $pts, $rgb); } /** A line of $thickness view pixels with square ends. */ public static function bar(Renderer $r, float $x1, float $y1, float $x2, float $y2, float $thickness, array $rgb): void { $len = hypot($x2 - $x1, $y2 - $y1); if ($len < 0.01) { return; } self::box($r, ($x1 + $x2) / 2, ($y1 + $y2) / 2, $len, $thickness, atan2($y2 - $y1, $x2 - $x1), $rgb); } /** A fading trail of positions (oldest first): discs getting smaller and fainter, from the colour of the head to $tail. */ public static function trail(Renderer $r, array $points, float $radius, array $head, array $tail, float $maxAlpha = 110): void { $n = count($points); if (Quality::$live && !Quality::halos()) { return; } foreach ($points as $i => [$x, $y]) { $k = ($i + 1) / max(1, $n); self::disc($r, $x, $y, max(0.4, $radius * (0.25 + 0.75 * $k)), self::mix($tail, $head, $k), (int)($maxAlpha * $k ** 1.4)); } }}