ES
games/satisfying-bounce / src / Render / Gfx.php · commit 8bdd6e4
<?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));        }    }}