# Energy shield

> A plasma cannon against an energy shield: one sprite material with its honeycomb and its cracks as maps painted in code.

Example `energy-shield`, running at https://nacatamalon.com/en/examples/energy-shield. The whole catalogue: https://nacatamalon.com/llms/examples.md.

A plasma cannon against a shield. Aim with the mouse, hold the button or space to charge and let go to fire. The dome ripples where it is hit, cracks and turns pink as it runs low, shatters at nothing and grows back. Left alone, the cannon fires by itself.

The dome is one sprite with a material written for both cards. Its honeycomb and its cracks are pictures painted with `createPixels` and handed to the shader as `maps`, read at `localUv()`; the cracks grow with `updatePixelTexture` as it weakens. The last six hits are vec4 knobs (where, when, how hard) the shader turns into rings and flares, and its light is cut into four dithered steps per pixel of the dome, so it stays as blocky as the art. Around it: particles that bounce off the ground, `shockwave` and `bloom`, and sounds from `useSound`'s synthesizer.

## Code

`index.ts`:

```ts
import { createGame } from 'nacatamalon';
import { Arena } from './scenes/arena';
import { HEIGHT, WIDTH } from './settings';

// A plasma cannon against an energy shield. The dome is one sprite with a material: its honeycomb
// and its cracks are pictures painted in code and handed to the shader as maps, and every hit is a
// knob the shader reads to ripple and flare where it landed.
//
//   mouse               aim (or the up and down arrows)
//   hold click / space  charge; let go to fire
const game = createGame('#app', {
    width: WIDTH,
    height: HEIGHT,
    background: '#070a1c'
});

// Declare Scenes
game({
    Arena
});
```

`art/shield_maps.ts`:

```ts
import { type TColor } from 'nacatamalon';
import { createPixels, drawLine, setPixel, type TPixels } from 'nacatamalon/pixels';

/**
 * The honeycomb the dome is made of, for its shader: red is 1 on the lines between the cells, green
 * a number of each cell's own, so each can twinkle and drop out on its own.
 */
export const paintHexMap = (size: number, cell: number): TPixels => {
    const map = createPixels(size, size);
    const inner = (cell * Math.sqrt(3)) / 2;
    // The three ways across a hexagon with a point on top.
    const normals = [0, Math.PI / 3, (2 * Math.PI) / 3].map((a) => [Math.cos(a + Math.PI / 6), Math.sin(a + Math.PI / 6)]);

    for (let y = 0; y < size; y++) {
        for (let x = 0; x < size; x++) {
            const px = x + 0.5 - size / 2;
            const py = y + 0.5 - size / 2;
            // Which cell, by the axial coordinates rounded to the nearest whole cube.
            const q = ((Math.sqrt(3) / 3) * px - py / 3) / cell;
            const r = ((2 / 3) * py) / cell;
            let rq = Math.round(q);
            let rr = Math.round(r);
            const rs = Math.round(-q - r);
            const dq = Math.abs(rq - q);
            const dr = Math.abs(rr - r);
            const ds = Math.abs(rs + q + r);

            if (dq > dr && dq > ds) {
                rq = -rr - rs;
            } else if (dr > ds) {
                rr = -rq - rs;
            }

            const cx = cell * Math.sqrt(3) * (rq + rr / 2);
            const cy = cell * 1.5 * rr;
            const reach = Math.max(...normals.map(([nx, ny]) => Math.abs((px - cx) * nx + (py - cy) * ny)));
            const line = inner - reach < 0.9;
            const id = ((Math.imul(rq + 97, 73856093) ^ Math.imul(rr + 31, 19349663)) >>> 0) % 251;

            setPixel(map, x, y, {
                r: line ? 1 : 0,
                g: id / 250,
                b: 0,
                a: 1
            });
        }
    }

    return map;
};

const WHITE: TColor = {
    r: 1,
    g: 1,
    b: 1,
    a: 1
};

/**
 * Cracks spreading from where the dome was hit, drawn into its crack map: a few jagged lines that
 * head roughly for the middle and fork now and then. They stop at the dome's edge.
 */
export const growCracks = (map: TPixels, from: { x: number; y: number; }, rand: () => number, count: number): void => {
    const middle = map.width / 2;
    const radius = middle - 2;

    const crack = (x: number, y: number, heading: number, steps: number) => {
        for (let i = 0; i < steps; i++) {
            heading += (rand() - 0.5) * 1.1;

            const length = 3 + rand() * 4;
            const nx = x + Math.cos(heading) * length;
            const ny = y + Math.sin(heading) * length;

            if (Math.hypot(nx - middle, ny - middle) > radius) {
                return;
            }

            drawLine(map, Math.round(x), Math.round(y), Math.round(nx), Math.round(ny), WHITE);

            if (rand() < 0.18 && steps > 2) {
                crack(nx, ny, heading + (rand() < 0.5 ? -0.9 : 0.9), Math.floor(steps / 2));
            }

            x = nx;
            y = ny;
        }
    };

    const inwards = Math.atan2(middle - from.y, middle - from.x);

    for (let i = 0; i < count; i++) {
        crack(from.x, from.y, inwards + (rand() - 0.5) * 2.2, 4 + Math.floor(rand() * 5));
    }
};
```

`components/effects.ts`:

```ts
import type { TParticlesEffect2d } from 'nacatamalon';

/**
 * The particles of the fight, written in code with the fields of a `.particles` file. The trail
 * pours while a shot flies; the rest wait to be fired with `emitParticles`.
 */
export const fightEffects = () => {
    const onlyFired = {
        rate: 0,
        burst: 0
    };

    // What a flying shot leaves behind it: pink motes that cool and drift.
    const trail: TParticlesEffect2d = {
        kind: 'particles-2d',
        max: 120,
        blend: 'additive',
        emission: {
            rate: 90
        },
        shape: {
            kind: 'circle',
            radius: 2,
            edge: false
        },
        spread: Math.PI * 2,
        life: [0.15, 0.4],
        speed: [4, 18],
        size: 1,
        colorOverLife: [
            {
                t: 0,
                color: '#ffd6f6'
            },
            {
                t: 0.4,
                color: '#ff5fd6'
            },
            {
                t: 1,
                color: '#6a1a8a',
                alpha: 0
            },
        ],
    };

    // The flash out of the muzzle, along the barrel (the emitter is turned with it).
    const muzzle: TParticlesEffect2d = {
        kind: 'particles-2d',
        max: 60,
        blend: 'additive',
        emission: onlyFired,
        direction: 0,
        spread: 0.9,
        life: [0.08, 0.25],
        speed: [60, 160],
        size: [1, 2],
        damping: 0.8,
        colorOverLife: [
            {
                t: 0,
                color: '#ffffff'
            },
            {
                t: 0.5,
                color: '#ff8ae6'
            },
            {
                t: 1,
                color: '#8a1f9a',
                alpha: 0
            },
        ],
    };

    // A shot breaking on the dome: plasma thrown back out along the dome's skin.
    const splash: TParticlesEffect2d = {
        kind: 'particles-2d',
        max: 160,
        blend: 'additive',
        emission: onlyFired,
        direction: 0,
        spread: 2.2,
        life: [0.2, 0.55],
        speed: [40, 150],
        size: 1,
        gravity: {
            y: 160
        },
        damping: 0.6,
        colorOverLife: [
            {
                t: 0,
                color: '#ffffff'
            },
            {
                t: 0.3,
                color: '#ff8ae6'
            },
            {
                t: 1,
                color: '#d23cc8',
                alpha: 0
            },
        ],
        trail: {
            length: 2,
            width: 1,
            fade: true
        },
    };

    // The dome's own light knocked loose where it was struck.
    const sparks: TParticlesEffect2d = {
        kind: 'particles-2d',
        max: 120,
        blend: 'additive',
        emission: onlyFired,
        direction: 0,
        spread: 1.4,
        life: [0.2, 0.5],
        speed: [20, 80],
        size: 1,
        colorOverLife: [
            {
                t: 0,
                color: '#e6ffff'
            },
            {
                t: 0.5,
                color: '#5ff0ff'
            },
            {
                t: 1,
                color: '#1f6a9a',
                alpha: 0
            },
        ],
    };

    // The dome breaking: pieces of it all round its edge, falling and bouncing on the ground.
    const shards: TParticlesEffect2d = {
        kind: 'particles-2d',
        max: 260,
        blend: 'additive',
        emission: onlyFired,
        direction: -Math.PI / 2,
        spread: Math.PI * 2,
        life: [0.6, 1.4],
        speed: [30, 140],
        size: [1, 2],
        gravity: {
            y: 240
        },
        damping: 0.3,
        colorOverLife: [
            {
                t: 0,
                color: '#ffffff'
            },
            {
                t: 0.2,
                color: '#9ff8ff'
            },
            {
                t: 0.7,
                color: '#2fa6d9'
            },
            {
                t: 1,
                color: '#1a3a7a',
                alpha: 0
            },
        ],
        collision: {
            mode: 'bounce',
            bounce: 0.4,
            friction: 0.5
        },
    };

    // Where a shot hits the ground or the bare generator.
    const blast: TParticlesEffect2d = {
        kind: 'particles-2d',
        max: 140,
        blend: 'additive',
        emission: onlyFired,
        direction: -Math.PI / 2,
        spread: 2.6,
        life: [0.25, 0.7],
        speed: [50, 170],
        size: 1,
        gravity: {
            y: 380
        },
        colorOverLife: [
            {
                t: 0,
                color: '#ffffff'
            },
            {
                t: 0.25,
                color: '#ffb0f0'
            },
            {
                t: 0.6,
                color: '#ff6a3c'
            },
            {
                t: 1,
                color: '#7a1a1a',
                alpha: 0
            },
        ],
        trail: {
            length: 3,
            width: 1,
            fade: true
        },
        collision: {
            mode: 'bounce',
            bounce: 0.35,
            friction: 0.4
        },
    };

    return {
        trail,
        muzzle,
        splash,
        sparks,
        shards,
        blast
    };
};
```

`components/machines.ts`:

```ts
import { getColor } from 'nacatamalon';
import { createPixels, fillCircle, fillPolygon, fillRect, setPixel, type TPixels } from 'nacatamalon/pixels';

/**
 * Where the barrel turns, from the barrel picture's top-left corner.
 */
export const BARREL_PIVOT = {
    x: 4,
    y: 4
};

/**
 * The cannon's body: treads, a squat housing and the ring the barrel turns in. 30 by 20, its feet
 * at the bottom.
 */
export const paintCannonBody = (): TPixels => {
    const body = createPixels(30, 20);
    const steel = getColor('#5d6a8a');
    const shade = getColor('#3a4262');
    const dark = getColor('#1c2036');

    fillRect(body, 1, 14, 28, 6, dark);

    for (let x = 3; x < 28; x += 4) {
        fillCircle(body, x, 17, 1, '#4a5272');
    }

    fillPolygon(body, [
        {
            x: 4,
            y: 14
        },
        {
            x: 8,
            y: 6
        },
        {
            x: 22,
            y: 6
        },
        {
            x: 26,
            y: 14
        }
    ], steel);
    fillPolygon(body, [
        {
            x: 17,
            y: 6
        },
        {
            x: 22,
            y: 6
        },
        {
            x: 26,
            y: 14
        },
        {
            x: 19,
            y: 14
        }
    ], shade);
    fillRect(body, 8, 6, 14, 1, '#8d9ac0');
    fillCircle(body, 14, 5, 4, shade);
    fillCircle(body, 14, 5, 2, dark);
    // The slot the charge light shows in: the light is a sprite of its own, over it.
    fillRect(body, 9, 10, 8, 2, dark);

    return body;
};

/**
 * The barrel, pointing right: a breech, a long tube with cooling rings and a pink muzzle.
 */
export const paintBarrel = (): TPixels => {
    const barrel = createPixels(26, 9);

    fillRect(barrel, 0, 1, 8, 7, '#3a4262');
    fillRect(barrel, 6, 2, 16, 5, '#6f7ca2');
    fillRect(barrel, 6, 2, 16, 1, '#a3b0d4');
    fillRect(barrel, 6, 6, 16, 1, '#454e72');

    for (const x of [9, 12, 15]) {
        fillRect(barrel, x, 1, 1, 7, '#2a3050');
    }

    fillRect(barrel, 21, 1, 4, 7, '#3a4262');
    fillRect(barrel, 24, 2, 2, 5, '#ff5fd6');

    return barrel;
};

/**
 * The shield generator: a metal base with a crystal on top. The crystal is painted white, so the
 * game tints it by how much the shield has left.
 */
export const paintPylonBase = (): TPixels => {
    const base = createPixels(20, 14);

    fillPolygon(base, [
        {
            x: 2,
            y: 14
        },
        {
            x: 5,
            y: 5
        },
        {
            x: 15,
            y: 5
        },
        {
            x: 18,
            y: 14
        }
    ], '#4a5272');
    fillPolygon(base, [
        {
            x: 11,
            y: 5
        },
        {
            x: 15,
            y: 5
        },
        {
            x: 18,
            y: 14
        },
        {
            x: 13,
            y: 14
        }
    ], '#2f3552');
    fillRect(base, 4, 3, 12, 3, '#6f7ca2');
    fillRect(base, 6, 9, 2, 2, '#1c2036');
    fillRect(base, 12, 9, 2, 2, '#1c2036');

    return base;
};

export const paintCrystal = (): TPixels => {
    const crystal = createPixels(10, 18);

    fillPolygon(crystal, [
        {
            x: 5,
            y: 0
        },
        {
            x: 9,
            y: 6
        },
        {
            x: 5,
            y: 17
        },
        {
            x: 1,
            y: 6
        }
    ], '#b8b8b8');
    fillPolygon(crystal, [
        {
            x: 5,
            y: 0
        },
        {
            x: 9,
            y: 6
        },
        {
            x: 5,
            y: 17
        }
    ], '#8a8a8a');
    fillPolygon(crystal, [
        {
            x: 5,
            y: 2
        },
        {
            x: 6,
            y: 6
        },
        {
            x: 5,
            y: 12
        },
        {
            x: 3,
            y: 6
        }
    ], '#ffffff');

    return crystal;
};

/**
 * A ball of plasma: a white core in rings of pink, cut into flat steps. Its size is its radius.
 */
export const paintPlasma = (radius: number): TPixels => {
    const size = radius * 2 + 1;
    const ball = createPixels(size, size);
    const rings = ['#7a1a8a', '#d23cc8', '#ff8ae6', '#ffffff'];

    rings.forEach((colour, i) => {
        fillCircle(ball, radius, radius, Math.max(0, Math.round(radius * (1 - i / rings.length))), getColor(colour));
    });

    return ball;
};

/**
 * The charge gathering at the muzzle, painted again every frame on a picture of its own: specks
 * spiralling in, a core that swells with `charge`, and once it is half full, small arcs jumping off
 * it. `time` turns it.
 */
export const paintCharge = (pixels: TPixels, charge: number, time: number, rand: () => number): void => {
    pixels.data.fill(0);

    const middle = (pixels.width - 1) / 2;

    if (charge <= 0) {
        return;
    }

    const specks = 14;

    for (let i = 0; i < specks; i++) {
        const phase = (time * 1.6 + i / specks) % 1;
        const angle = i * 2.4 + time * 3;
        const reach = (1 - phase) * middle;

        setPixel(pixels, Math.round(middle + Math.cos(angle) * reach), Math.round(middle + Math.sin(angle) * reach),
            getColor(phase > 0.6 ? '#ffd6f6' : '#c03cc0'));
    }

    const core = 1 + charge * 5 + (charge >= 1 ? Math.sin(time * 30) : 0);

    fillCircle(pixels, middle, middle, Math.round(core + 2), '#8a1f9a');
    fillCircle(pixels, middle, middle, Math.round(core + 1), '#ff5fd6');
    fillCircle(pixels, middle, middle, Math.round(core), '#ffd6f6');
    fillCircle(pixels, middle, middle, Math.max(0, Math.round(core - 2)), '#ffffff');

    if (charge > 0.5) {
        for (let arc = 0; arc < 2; arc++) {
            let angle = rand() * Math.PI * 2;
            let x = middle + Math.cos(angle) * core;
            let y = middle + Math.sin(angle) * core;

            for (let step = 0; step < 4; step++) {
                angle += (rand() - 0.5) * 1.4;
                x += Math.cos(angle) * 2;
                y += Math.sin(angle) * 2;
                setPixel(pixels, Math.round(x), Math.round(y), '#ffffff');
            }
        }
    }
};
```

`components/scenery.ts`:

```ts
import { getColor } from 'nacatamalon';
import { createPixels, fillCircle, fillGradient, fillPolygon, fillRect, mapPixels, setPixel, type TPixels } from 'nacatamalon/pixels';

// A small fixed stream of chance, so the stars and the hills are the same every time.
const stream = (seed: number) => () => ((seed = (seed * 16807) % 2147483647) / 2147483647);

/**
 * A night over a plain: the sky going from deep blue to violet at the horizon, stars, a pale moon
 * and two rows of flat-topped hills.
 */
export const paintSky = (width: number, height: number, horizon: number): TPixels => {
    const rand = stream(7);
    const sky = createPixels(width, height);

    fillGradient(sky, {
        colors: ['#070a1c', '#0f1433', '#1c1a44', '#33204f'].map((hex) => getColor(hex)),
        bands: 10,
        dither: true,
    });

    for (let i = 0; i < 110; i++) {
        const bright = rand();

        setPixel(sky, Math.floor(rand() * width), Math.floor(rand() * horizon * 0.8),
            getColor(bright > 0.9 ? '#ffffff' : bright > 0.6 ? '#9aa4d8' : '#4b5490'));
    }

    fillCircle(sky, Math.round(width * 0.66), 34, 13, '#3c4378');
    fillCircle(sky, Math.round(width * 0.66), 34, 12, '#c9cdee');
    fillCircle(sky, Math.round(width * 0.66) + 4, 31, 3, '#a8acd8');
    fillCircle(sky, Math.round(width * 0.66) - 4, 38, 2, '#a8acd8');

    const hills = (top: number, rise: number, colour: string, seed: number) => {
        const r = stream(seed);

        const points = [
            {
                x: 0,
                y: horizon
            }
        ];

        for (let x = 0; x <= width; x += 14 + Math.floor(r() * 30)) {
            const y = top + Math.floor(r() * rise);

            points.push({
                x,
                y
            }, {
                x: x + 10 + Math.floor(r() * 18),
                y
            });
        }

        points.push({
            x: width,
            y: horizon
        });
        fillPolygon(sky, points, getColor(colour));
    };

    hills(horizon - 52, 26, '#1f1b42', 3);
    hills(horizon - 30, 16, '#151231', 9);

    return sky;
};

/**
 * The ground: metal plates with a lit edge and bolts at their corners.
 */
export const paintGround = (width: number, depth: number): TPixels => {
    const ground = createPixels(width, depth);

    fillGradient(ground, {
        from: '#3a3858',
        to: '#1a1830',
        bands: 4,
        dither: true
    });
    fillRect(ground, 0, 0, width, 1, '#8a86b8');
    fillRect(ground, 0, 1, width, 1, '#55527e');

    const seam = getColor('#14122a');
    const bolt = getColor('#6c6896');

    return mapPixels(ground, (colour, x, y) => {
        if (y > 2 && x % 32 === 0) {
            return seam;
        }

        if (y === 4 && (x % 32 === 3 || x % 32 === 29)) {
            return bolt;
        }

        return colour;
    });
};
```

`scenes/arena.ts`:

```ts
import { createMaterial, createParticles, createScene, createSprite, createText, emitParticles, getColor, lerpColor, useCamera2d, useKeyboard, useParticleCollider2d, usePointer, useScreenSpace, useSound, useSpawn, useTransform, useTween, useUpdate, type TParticles, type TSprite } from 'nacatamalon';
import { clamp, useRandom } from 'nacatamalon/math';
import { createPixels, createPixelTexture, fillCircle, updatePixelTexture } from 'nacatamalon/pixels';
import { bloom, shockwave, usePostProcess } from 'nacatamalon/effects';
import { BARREL_PIVOT, paintBarrel, paintCannonBody, paintCharge, paintCrystal, paintPlasma, paintPylonBase } from '../components/machines';
import { paintGround, paintSky } from '../components/scenery';
import { BLEED, CANNON, GROUND_Y, HEIGHT, LAYER, SHIELD, SHIELD_SIZE, WIDTH } from '../settings';
import { fightEffects } from '../components/effects';
import { growCracks, paintHexMap } from '../art/shield_maps';
import { HITS, SHIELD_GLSL, SHIELD_WGSL } from '../shaders/shield';
import { blocks, contact, createShield, damageShield, tickShield, type TShield } from '../simulation/shield_state';

/**
 * How far up and down the barrel turns, in radians: from nearly straight up to a little below level.
 */
const AIM = {
    min: -1.35,
    max: 0.2
};

/**
 * Seconds to charge a shot fully.
 */
const CHARGE_TIME = 1.1;

/**
 * The ground, as far as falling sparks and shards are concerned: solid below its line.
 */
const Floor = () => {
    useTransform({
        x: 0,
        y: GROUND_Y
    });
    useParticleCollider2d({
        shape: {
            kind: 'plane'
        }
    });
};

/**
 * The shield's energy bar and what to press, pinned to the screen so the shake leaves them still.
 */
const Hud = ({ shield }: { shield: TShield; }) => {
    useScreenSpace();

    const label = getColor('#8a86b8');

    createText({
        text: 'SHIELD',
        tint: label,
        anchor: {
            x: 1,
            y: 0
        },
        transform: {
            x: WIDTH - 72,
            y: 7
        },
        zIndex: LAYER.hud
    });
    createSprite({
        width: 64,
        height: 6,
        tint: '#14122a',
        anchor: {
            x: 0,
            y: 0
        },
        transform: {
            x: WIDTH - 68,
            y: 8
        },
        zIndex: LAYER.hud
    });

    const bar = createSprite({
        width: 62,
        height: 4,
        anchor: {
            x: 0,
            y: 0
        },
        transform: {
            x: WIDTH - 67,
            y: 9
        },
        zIndex: LAYER.hud
    });

    createText({
        text: 'AIM: MOUSE   HOLD CLICK: CHARGE',
        tint: label,
        transform: {
            x: 6,
            y: HEIGHT - 10
        },
        zIndex: LAYER.hud,
    });

    const full = getColor('#5ff0ff');
    const empty = getColor('#ff4fa0');

    useUpdate(() => {
        bar.width = Math.max(0, Math.round(62 * shield.energy));
        bar.tint = lerpColor(empty, full, shield.energy);
    });
};

type TShot = { sprite: TSprite; trail: TParticles; alive: boolean; x: number; y: number; vx: number; vy: number; power: number; };

/**
 * A plasma cannon and a shield. Hold to charge, let go to fire; the dome ripples where it is hit,
 * cracks as it runs low, shatters at nothing, and grows back. Left alone, the cannon fires by itself.
 */
export const Arena = () => {
    const random = useRandom();
    const keys = useKeyboard();
    const pointer = usePointer();
    const camera = useCamera2d();
    const tween = useTween();

    const wave = usePostProcess(shockwave({
        center: {
            x: 0.5,
            y: 0.5
        },
        radius: 0,
        amplitude: 0
    }));

    usePostProcess(bloom({
        threshold: 0.7,
        intensity: 0.8,
        radius: 8
    }));

    const topLeft = {
        x: 0,
        y: 0
    };

    createSprite({
        texture: createPixelTexture(paintSky(WIDTH + BLEED * 2, HEIGHT + BLEED * 2, GROUND_Y + BLEED)),
        anchor: topLeft,
        transform: {
            x: -BLEED,
            y: -BLEED
        },
        zIndex: LAYER.sky,
    });
    createSprite({
        texture: createPixelTexture(paintGround(WIDTH + BLEED * 2, HEIGHT - GROUND_Y + BLEED)),
        anchor: topLeft,
        transform: {
            x: -BLEED,
            y: GROUND_Y
        },
        zIndex: LAYER.ground,
    });
    useSpawn(Floor, 'Floor')();

    // The generator under the dome. Its crystal floats, and shows how much the shield has left.
    createSprite({
        texture: createPixelTexture(paintPylonBase()),
        anchor: {
            x: 0.5,
            y: 1
        },
        transform: {
            x: SHIELD.x,
            y: GROUND_Y
        },
        zIndex: LAYER.pylon
    });

    const crystal = createSprite({
        texture: createPixelTexture(paintCrystal()),
        anchor: {
            x: 0.5,
            y: 1
        },
        transform: {
            x: SHIELD.x,
            y: GROUND_Y - 13
        },
        zIndex: LAYER.pylon,
    });

    tween({
        target: crystal,
        to: {
            transform: {
                y: GROUND_Y - 16
            }
        },
        duration: 1.2,
        loop: true,
        yoyo: true
    }).play();

    const haloPixels = createPixels(21, 21);

    fillCircle(haloPixels, 10, 10, 10, '#1c3a5a');
    fillCircle(haloPixels, 10, 10, 6, '#3a6a9a');

    const halo = createSprite({
        texture: createPixelTexture(haloPixels),
        blend: 'additive',
        zIndex: LAYER.pylon
    });

    // The dome: one sprite with a material. Its honeycomb and its cracks are pictures painted here
    // and handed to the shader as maps; its hits are knobs the shader reads every frame.
    const shield = createShield();
    const cracks = createPixels(SHIELD_SIZE, SHIELD_SIZE);
    const cracksTexture = createPixelTexture(cracks);

    const knobs: Record<string, number | number[]> = {
        now: 0,
        energy: 1,
        form: 1
    };

    for (let i = 0; i < HITS; i++) {
        knobs['hit' + i] = [0, 0, -100, 0];
    }

    const material = createMaterial({
        name: 'shield',
        shader: 'sprite2d',
        fragment: SHIELD_WGSL,
        fragmentGlsl: SHIELD_GLSL,
        uniforms: knobs,
        maps: {
            hex: {
                texture: createPixelTexture(paintHexMap(SHIELD_SIZE, 6)),
                smooth: false
            },
            crack: {
                texture: cracksTexture,
                smooth: false
            },
        },
    });

    createSprite({
        width: SHIELD_SIZE,
        height: SHIELD_SIZE,
        material,
        blend: 'additive',
        transform: {
            x: SHIELD.x,
            y: SHIELD.y
        },
        zIndex: LAYER.shield,
    });

    const onDome = (x: number, y: number) => ({
        x: x - SHIELD.x + SHIELD_SIZE / 2,
        y: y - SHIELD.y + SHIELD_SIZE / 2
    });

    // The cannon: its body, the barrel turning in it, the light that fills as it charges, and the
    // charge itself gathering at the muzzle.
    const barrel = createSprite({
        texture: createPixelTexture(paintBarrel()),
        anchor: {
            x: (BARREL_PIVOT.x + 0.5) / 26,
            y: (BARREL_PIVOT.y + 0.5) / 9
        },
        transform: {
            x: CANNON.x,
            y: CANNON.y
        },
        zIndex: LAYER.cannon,
    });

    createSprite({
        texture: createPixelTexture(paintCannonBody()),
        anchor: topLeft,
        transform: {
            x: CANNON.x - 14,
            y: CANNON.y - 5
        },
        zIndex: LAYER.cannon + 0.5
    });

    const chargeLight = createSprite({
        width: 8,
        height: 2,
        anchor: topLeft,
        blend: 'additive',
        transform: {
            x: CANNON.x - 5,
            y: CANNON.y + 5
        },
        zIndex: LAYER.cannon + 0.6,
    });

    const chargePixels = createPixels(25, 25);
    const chargeTexture = createPixelTexture(chargePixels);

    const orb = createSprite({
        texture: chargeTexture,
        blend: 'additive',
        visible: false,
        zIndex: LAYER.shots
    });

    const effects = fightEffects();

    const muzzle = createParticles({
        effect: effects.muzzle,
        zIndex: LAYER.sparks
    });

    const splash = createParticles({
        effect: effects.splash,
        zIndex: LAYER.sparks
    });

    const sparks = createParticles({
        effect: effects.sparks,
        zIndex: LAYER.sparks
    });

    const blast = createParticles({
        effect: effects.blast,
        zIndex: LAYER.debris
    });

    // The dome breaks all along its arc: one emitter every twentieth of a turn, each throwing its
    // pieces outwards. Under the ground, so the half that is buried stays buried.
    const shards = Array.from({
        length: 11
    }, (_, i) => {
        const angle = -Math.PI + (i / 10) * Math.PI;

        return createParticles({
            effect: effects.shards,
            transform: {
                x: SHIELD.x + Math.cos(angle) * SHIELD.radius,
                y: SHIELD.y + Math.sin(angle) * SHIELD.radius,
                rotation: angle + Math.PI / 2
            },
            zIndex: LAYER.shield,
        });
    });

    const small = createPixelTexture(paintPlasma(3));
    const big = createPixelTexture(paintPlasma(5));

    const shots: TShot[] = Array.from({
        length: 8
    }, () => ({
        sprite: createSprite({
            texture: small,
            blend: 'additive',
            visible: false,
            zIndex: LAYER.shots
        }),
        trail: createParticles({
            effect: effects.trail,
            autoplay: false,
            zIndex: LAYER.shots
        }),
        alive: false,
        x: 0,
        y: 0,
        vx: 0,
        vy: 0,
        power: 0,
    }));

    const sound = {
        pew: useSound({
            sfx: {
                preset: 'jump',
                pitch: 0.55,
                length: 0.18,
                volume: 0.45
            }
        }),
        cannon: useSound({
            sfx: {
                preset: 'explosion',
                pitch: 1.8,
                length: 0.35,
                volume: 0.6
            }
        }),
        zap: useSound({
            sfx: {
                preset: 'hit',
                pitch: 1.5,
                length: 0.15,
                volume: 0.5
            }
        }),
        thud: useSound({
            sfx: {
                preset: 'hit',
                pitch: 0.7,
                length: 0.2,
                volume: 0.5
            }
        }),
        shatter: useSound({
            sfx: {
                preset: 'explosion',
                pitch: 0.7,
                length: 1.2,
                volume: 0.8
            }
        }),
        reform: useSound({
            sfx: {
                preset: 'coin',
                pitch: 0.5,
                length: 0.5,
                volume: 0.45
            }
        }),
    };

    useSpawn(Hud, 'Hud')({
        shield
    });

    let time = 0;
    let aim = -0.35;
    let charge = 0;
    let charging = false;
    let recoil = 0;
    let shake = 0;
    let nextHit = 0;
    let idle = 0;

    const auto = {
        wait: 1,
        target: -0.35,
        hold: 0
    };

    const waveState = {
        radius: 0,
        amplitude: 0
    };

    const ripple = (x: number, y: number, amplitude: number) => {
        wave.uniforms.center = [x / WIDTH, y / HEIGHT];
        waveState.radius = 0;
        waveState.amplitude = amplitude;
    };

    const kill = (shot: TShot) => {
        shot.alive = false;
        shot.sprite.visible = false;
        shot.trail.emitting = false;
    };

    const fire = (power: number) => {
        const shot = shots.find((one) => !one.alive);

        if (!shot) {
            return;
        }

        const dx = Math.cos(aim);
        const dy = Math.sin(aim);
        const speed = 200 + 90 * power;

        Object.assign(shot, {
            alive: true,
            power,
            vx: dx * speed,
            vy: dy * speed,
            x: CANNON.x + dx * CANNON.barrel,
            y: CANNON.y + dy * CANNON.barrel,
        });
        shot.sprite.texture = power > 0.55 ? big : small;
        shot.sprite.visible = true;
        shot.trail.transform.x = shot.x;
        shot.trail.transform.y = shot.y;
        shot.trail.emitting = true;

        muzzle.transform.x = shot.x;
        muzzle.transform.y = shot.y;
        muzzle.transform.rotation = aim;
        emitParticles(muzzle, Math.round(8 + 24 * power));
        recoil = 2 + 4 * power;

        if (power > 0.55) {
            sound.cannon.play();
            shake = Math.max(shake, 1.5);
        } else {
            sound.pew.play();
        }
    };

    const shatter = () => {
        for (const emitter of shards) {
            emitParticles(emitter, 16);
        }

        for (let i = 0; i < HITS; i++) {
            (knobs['hit' + i] as number[])[2] = -100;
        }

        cracks.data.fill(0);
        updatePixelTexture(cracksTexture);
        ripple(SHIELD.x, SHIELD.y - SHIELD.radius / 2, 7);
        shake = 4;
        sound.shatter.play();
    };

    const hitDome = (shot: TShot, at: { x: number; y: number; angle: number; }) => {
        // The hit goes in the oldest of the shader's slots: where on the dome, when, how hard.
        const point = onDome(at.x, at.y);
        const slot = knobs['hit' + nextHit] as number[];

        slot[0] = point.x / SHIELD_SIZE;
        slot[1] = point.y / SHIELD_SIZE;
        slot[2] = time;
        slot[3] = 0.6 + 0.8 * shot.power;
        nextHit = (nextHit + 1) % HITS;

        for (const [emitter, count] of [[splash, 10 + 30 * shot.power], [sparks, 6 + 14 * shot.power]] as const) {
            emitter.transform.x = at.x;
            emitter.transform.y = at.y;
            emitter.transform.rotation = at.angle;
            emitParticles(emitter, Math.round(count));
        }

        sound.zap.play();

        if (shot.power > 0.55) {
            ripple(at.x, at.y, 4);
            shake = Math.max(shake, 2);
        }

        if (damageShield(shield, 0.06 + 0.22 * shot.power) === 'broke') {
            shatter();
        } else if (shield.energy < 0.6) {
            growCracks(cracks, point, () => random.rand(), shot.power > 0.55 ? 3 : 1);
            updatePixelTexture(cracksTexture);
        }

        kill(shot);
    };

    const explode = (shot: TShot, x: number, y: number) => {
        blast.transform.x = x;
        blast.transform.y = y - 1;
        emitParticles(blast, Math.round(16 + 30 * shot.power));
        ripple(x, y, 2 + 3 * shot.power);
        shake = Math.max(shake, 1 + 2 * shot.power);
        sound.thud.play();
        kill(shot);
    };

    pointer.onMove((info) => {
        aim = clamp(Math.atan2(info.worldY - CANNON.y, info.worldX - CANNON.x), AIM.min, AIM.max);
        idle = 0;
    });

    useUpdate((delta) => {
        time += delta;

        // Aim and charge: by hand, or, after three quiet seconds, by itself at the dome.
        if (keys.isDown('ArrowUp')) {
            aim = Math.max(AIM.min, aim - 1.2 * delta);
        }

        if (keys.isDown('ArrowDown')) {
            aim = Math.min(AIM.max, aim + 1.2 * delta);
        }

        const held = pointer.isDown() || keys.isDown('Space');

        idle = held || keys.isDown('ArrowUp') || keys.isDown('ArrowDown') ? 0 : idle + delta;

        let wants = held;

        if (idle > 3) {
            aim += clamp(auto.target - aim, -1.5 * delta, 1.5 * delta);

            if (auto.hold > 0) {
                auto.hold -= delta;
                wants = auto.hold > 0;
            } else {
                auto.wait -= delta;

                if (auto.wait <= 0) {
                    const x = SHIELD.x + random.rand(-0.8, 0.8) * SHIELD.radius;

                    auto.target = Math.atan2(SHIELD.y - SHIELD.radius * random.rand(0.3, 0.9) - CANNON.y, x - CANNON.x);
                    auto.hold = random.chance(0.4) ? CHARGE_TIME : random.rand(0.05, 0.5);
                    auto.wait = random.rand(0.3, 1.2);
                }
            }
        }

        if (wants) {
            charge = Math.min(1, charge + delta / CHARGE_TIME);
        } else if (charging) {
            fire(charge);
            charge = 0;
        }

        charging = wants;

        recoil = Math.max(0, recoil - 30 * delta);
        barrel.transform.rotation = aim;
        barrel.transform.x = CANNON.x - Math.cos(aim) * recoil;
        barrel.transform.y = CANNON.y - Math.sin(aim) * recoil;
        chargeLight.tint = lerpColor(getColor('#2a1030'), getColor('#ff5fd6'), charge);

        orb.visible = charge > 0;

        if (orb.visible) {
            orb.transform.x = barrel.transform.x + Math.cos(aim) * (CANNON.barrel + 2);
            orb.transform.y = barrel.transform.y + Math.sin(aim) * (CANNON.barrel + 2);
            paintCharge(chargePixels, charge, time, () => random.rand());
            updatePixelTexture(chargeTexture);
        }

        for (const shot of shots) {
            if (!shot.alive) {
                continue;
            }

            shot.x += shot.vx * delta;
            shot.y += shot.vy * delta;
            shot.sprite.transform.x = shot.x;
            shot.sprite.transform.y = shot.y;
            shot.trail.transform.x = shot.x;
            shot.trail.transform.y = shot.y;

            const at = blocks(shield) ? contact(shot.x, shot.y, SHIELD.x, SHIELD.y, SHIELD.radius * shield.form) : null;

            if (at) {
                hitDome(shot, at);
            } else if (shot.y >= GROUND_Y) {
                explode(shot, shot.x, GROUND_Y);
            } else if (shield.broken && Math.abs(shot.x - SHIELD.x) < 8 && shot.y > GROUND_Y - 30) {
                explode(shot, shot.x, shot.y);
            } else if (shot.x > WIDTH + 12 || shot.y < -12) {
                kill(shot);
            }
        }

        if (tickShield(shield, delta) === 'reforming') {
            sound.reform.play();
        }

        knobs.now = time;
        knobs.energy = shield.energy;
        // It grows back fast and settles, rather than at an even pace.
        knobs.form = shield.broken ? 0 : 1 - (1 - shield.form) ** 3;

        // The crystal shows what is left: cyan when full, pink when low, dark and flickering when down.
        const flicker = shield.broken ? (Math.sin(time * 40) > 0.6 ? 0.5 : 0.15) : 1;

        crystal.tint = lerpColor(getColor('#ff4fa0'), getColor('#9ff8ff'), shield.energy);
        crystal.tint.a = 1;

        if (shield.broken) {
            crystal.tint = lerpColor(getColor('#2a2848'), getColor('#ff4fa0'), flicker * 0.4);
        }

        halo.transform.x = crystal.transform.x;
        halo.transform.y = crystal.transform.y - 9;
        halo.tint = lerpColor(getColor('#ff4fa0'), getColor('#5ff0ff'), shield.energy);
        halo.tint.a = shield.broken ? flicker * 0.3 : 0.5 + 0.2 * Math.sin(time * 3);

        waveState.radius += 230 * delta;
        wave.uniforms.radius = waveState.radius;
        wave.uniforms.amplitude = waveState.amplitude * Math.max(0, 1 - waveState.radius / 140);

        shake = Math.max(0, shake - 14 * delta);
        camera.transform.x = shake ? random.rand(-shake, shake) : 0;
        camera.transform.y = shake ? random.rand(-shake, shake) : 0;
    });

    return createScene();
};
```

`settings.ts`:

```ts
/**
 * The game's size, in its own pixels.
 */
export const WIDTH = 320;
export const HEIGHT = 200;

/**
 * Where the ground's top edge is.
 */
export const GROUND_Y = 176;

/**
 * How far the backdrop reaches past each edge, so the camera's shake never shows beyond it.
 */
export const BLEED = 6;

/**
 * The dome: its middle sits on the ground, so half of it is seen.
 */
export const SHIELD = {
    x: 236,
    y: GROUND_Y,
    radius: 54
};

/**
 * The picture the dome is drawn on: the dome and two pixels round it.
 */
export const SHIELD_SIZE = SHIELD.radius * 2 + 4;

/**
 * Where the barrel turns, and how long it is to the muzzle.
 */
export const CANNON = {
    x: 46,
    y: GROUND_Y - 15,
    barrel: 22
};

/**
 * Drawing order, back to front.
 */
export const LAYER = {
    sky: 0,
    hills: 1,
    pylon: 2,
    shield: 3,
    ground: 4,
    debris: 5,
    cannon: 6,
    shots: 7,
    sparks: 8,
    hud: 20,
} as const;
```

`shaders/shield.ts`:

```ts
import { SHIELD_SIZE } from '../settings';

/**
 * How many hits the dome remembers at once: each is one vec4 knob, (x, y, when, strength), x and y
 * across the dome's picture from 0 to 1. A new hit takes the oldest one's place.
 */
export const HITS = 6;

// The dome is worked out per pixel of its own picture, not per pixel of the screen, so it stays as
// blocky as the rest of the art however big the game is shown. Its light is gathered as one number
// (the rim, the honeycomb, the ripples, the flare round each hit, the cracks) and only then cut into
// four dithered steps of colour, cyan when it is full and going hot pink as it runs out.
//
// Both languages say the same, line for line: WGSL for WebGPU, GLSL for WebGL2.

const SIZE = `${SHIELD_SIZE}.0`;

const hitWgsl = (i: number) => `
    {
        let h = mu.hit${i};
        let age = mu.now - h.z;
        if (age >= 0.0) {
            let dist = distance(p, h.xy);
            let front = dist - age * 0.75;
            ring += h.w * exp(-front * front * 450.0) * exp(-age * 1.8);
            flare += h.w * max(0.0, 1.0 - dist / 0.32) * exp(-age * 5.0);
        }
    }`;

const hitGlsl = (i: number) => `
    {
        vec4 h = mu.hit${i};
        float age = mu.now - h.z;
        if (age >= 0.0) {
            float dist = distance(p, h.xy);
            float front = dist - age * 0.75;
            ring += h.w * exp(-front * front * 450.0) * exp(-age * 1.8);
            flare += h.w * max(0.0, 1.0 - dist / 0.32) * exp(-age * 5.0);
        }
    }`;

const all = (one: (i: number) => string) => Array.from({
    length: HITS
}, (_, i) => one(i)).join('\n');

export const SHIELD_WGSL = `
fn bayer4(ip: vec2<f32>) -> f32 {
    let x = u32(ip.x) & 3u;
    let y = u32(ip.y) & 3u;
    let lo = ((x & 1u) ^ (y & 1u)) * 2u + (y & 1u);
    let hi = (((x >> 1u) & 1u) ^ ((y >> 1u) & 1u)) * 2u + ((y >> 1u) & 1u);
    return (f32(lo * 4u + hi) + 0.5) / 16.0;
}

fn effect(color: vec4<f32>, uv: vec2<f32>) -> vec4<f32> {
    let ip = floor(localUv() * ${SIZE});
    let p = (ip + 0.5) / ${SIZE};
    let d = p * 2.0 - 1.0;
    let r = length(d) * ${SIZE} / (${SIZE} - 4.0);
    if (r > mu.form) {
        return vec4<f32>(0.0);
    }
    let e = r / max(mu.form, 0.001);
    let hex = sampleMap_hex(p);
    let crack = sampleMap_crack(p).r;
    let low = 1.0 - mu.energy;
    var ring = 0.0;
    var flare = 0.0;
${all(hitWgsl)}
    let twinkle = 0.5 + 0.5 * sin(mu.now * 2.5 + hex.g * 60.0);
    let band = d.y + 1.3 - fract(mu.now * 0.22) * 2.6;
    let sweep = exp(-band * band * 25.0);
    let growing = select(0.0, smoothstep(0.82, 1.0, e) * 1.2, mu.form < 1.0);
    var light = 0.06 + pow(e, 7.0) * 0.85 + step(0.97, e) * 0.35;
    light += hex.r * (0.10 + 0.10 * twinkle + 0.45 * sweep + 1.4 * flare + 1.2 * ring);
    light += ring * 0.6 + flare * 0.35 + crack * (0.7 + 0.6 * low) + growing;
    // Weak, it stutters: whole cells go out and come back, more of them the less it has left.
    let roll = fract(sin(hex.g * 91.7 + floor(mu.now * 12.0)) * 43758.5);
    light *= 1.0 - step(roll, max(0.0, low - 0.45) * 0.7) * 0.8;
    let level = floor(clamp(light, 0.0, 1.2) * 4.0 + bayer4(ip) - 0.5);
    if (level < 1.0) {
        return vec4<f32>(0.0);
    }
    let t = min(level - 1.0, 3.0) / 3.0;
    let cool = mix(vec3<f32>(0.05, 0.32, 0.48), vec3<f32>(0.85, 1.0, 1.0), t);
    let warm = mix(vec3<f32>(0.40, 0.05, 0.28), vec3<f32>(1.0, 0.85, 0.95), t);
    return vec4<f32>(mix(cool, warm, low * low * 0.9), 0.35 + 0.65 * t);
}
`;

export const SHIELD_GLSL = `
float bayer4(vec2 ip) {
    uint x = uint(ip.x) & 3u;
    uint y = uint(ip.y) & 3u;
    uint lo = ((x & 1u) ^ (y & 1u)) * 2u + (y & 1u);
    uint hi = (((x >> 1u) & 1u) ^ ((y >> 1u) & 1u)) * 2u + ((y >> 1u) & 1u);
    return (float(lo * 4u + hi) + 0.5) / 16.0;
}

vec4 effect(vec4 color, vec2 uv) {
    vec2 ip = floor(localUv() * ${SIZE});
    vec2 p = (ip + 0.5) / ${SIZE};
    vec2 d = p * 2.0 - 1.0;
    float r = length(d) * ${SIZE} / (${SIZE} - 4.0);
    if (r > mu.form) {
        return vec4(0.0);
    }
    float e = r / max(mu.form, 0.001);
    vec4 hex = sampleMap_hex(p);
    float crack = sampleMap_crack(p).r;
    float low = 1.0 - mu.energy;
    float ring = 0.0;
    float flare = 0.0;
${all(hitGlsl)}
    float twinkle = 0.5 + 0.5 * sin(mu.now * 2.5 + hex.g * 60.0);
    float band = d.y + 1.3 - fract(mu.now * 0.22) * 2.6;
    float sweep = exp(-band * band * 25.0);
    float growing = mu.form < 1.0 ? smoothstep(0.82, 1.0, e) * 1.2 : 0.0;
    float light = 0.06 + pow(e, 7.0) * 0.85 + step(0.97, e) * 0.35;
    light += hex.r * (0.10 + 0.10 * twinkle + 0.45 * sweep + 1.4 * flare + 1.2 * ring);
    light += ring * 0.6 + flare * 0.35 + crack * (0.7 + 0.6 * low) + growing;
    float roll = fract(sin(hex.g * 91.7 + floor(mu.now * 12.0)) * 43758.5);
    light *= 1.0 - step(roll, max(0.0, low - 0.45) * 0.7) * 0.8;
    float level = floor(clamp(light, 0.0, 1.2) * 4.0 + bayer4(ip) - 0.5);
    if (level < 1.0) {
        return vec4(0.0);
    }
    float t = min(level - 1.0, 3.0) / 3.0;
    vec3 cool = mix(vec3(0.05, 0.32, 0.48), vec3(0.85, 1.0, 1.0), t);
    vec3 warm = mix(vec3(0.40, 0.05, 0.28), vec3(1.0, 0.85, 0.95), t);
    return vec4(mix(cool, warm, low * low * 0.9), 0.35 + 0.65 * t);
}
`;
```

`simulation/shield_state.ts`:

```ts
/**
 * The dome's state, apart from how it is drawn: how much it has left, how far it has grown, and
 * whether it is down. Plain data, so a test can hit it and run its clock.
 */
export type TShield = {
    /**
     * What it has left, 1 full to 0 gone.
     */
    energy: number;
    /**
     * How far it has grown, 0 to 1: it grows from the middle when it comes back.
     */
    form: number;
    broken: boolean;
    /**
     * Seconds since it was last hit: it charges again only after a pause.
     */
    sinceHit: number;
    /**
     * Seconds it has been down.
     */
    downFor: number;
};

export const SHIELD_RULES = {
    /**
     * Energy back a second, once it has not been hit for `rechargeDelay`.
     */
    recharge: 0.08,
    rechargeDelay: 1.4,
    /**
     * How long it stays down once broken, and how long it takes to grow back.
     */
    downTime: 2.6,
    formTime: 0.9,
    /**
     * Below this much grown it does not stop anything yet.
     */
    blocksFrom: 0.25,
};

export const createShield = (): TShield => ({
    energy: 1,
    form: 1,
    broken: false,
    sinceHit: Infinity,
    downFor: 0
});

export const blocks = (shield: TShield): boolean => !shield.broken && shield.form >= SHIELD_RULES.blocksFrom;

/**
 * Takes `damage` off it. Says `'broke'` when that was the last of it.
 */
export const damageShield = (shield: TShield, damage: number): 'held' | 'broke' => {
    shield.sinceHit = 0;
    shield.energy -= damage;

    if (shield.energy > 0) {
        return 'held';
    }

    shield.energy = 0;
    shield.broken = true;
    shield.form = 0;
    shield.downFor = 0;

    return 'broke';
};

/**
 * Moves its clock on. Says `'reforming'` the moment a broken one starts growing back.
 */
export const tickShield = (shield: TShield, delta: number): 'reforming' | null => {
    if (shield.broken) {
        shield.downFor += delta;

        if (shield.downFor < SHIELD_RULES.downTime) {
            return null;
        }

        shield.broken = false;
        shield.energy = 1;
        shield.form = 0;
        shield.sinceHit = Infinity;

        return 'reforming';
    }

    if (shield.form < 1) {
        shield.form = Math.min(1, shield.form + delta / SHIELD_RULES.formTime);
    }

    shield.sinceHit += delta;

    if (shield.sinceHit > SHIELD_RULES.rechargeDelay) {
        shield.energy = Math.min(1, shield.energy + SHIELD_RULES.recharge * delta);
    }

    return null;
};

/**
 * Where a shot at (x, y) meets a dome of `radius` round (cx, cy) whose lower half is underground,
 * or `null` while it is outside. The point is on the dome's skin, and `angle` the way out of it there.
 */
export const contact = (
    x: number, y: number, cx: number, cy: number, radius: number,
): { x: number; y: number; angle: number; } | null => {
    const dx = x - cx;
    const dy = y - cy;
    const distance = Math.hypot(dx, dy);

    if (distance > radius || y > cy) {
        return null;
    }

    const angle = Math.atan2(dy, dx);

    return {
        x: cx + Math.cos(angle) * radius,
        y: cy + Math.sin(angle) * radius,
        angle
    };
};
```
