# Transition masks

> dissolve covers in the order a picture's greys say, darkest first: a star, a spiral, blinds, tiles and grain painted in code, and a clock from a file.

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

`dissolve(0.9, mask)` covers the screen in the order a picture's greys say, darkest first, and uncovers it the same way back: the picture is the shape. Five are painted in code with `mapPixels`, a grey for every pixel; the clock is a PNG file. Each is shown at the bottom, small, as it is: the transition stretches it over the screen and reads it smoothly.

The rooms paint their own masks with no key, so they go when the room stops, and the one a room changed with is kept by the transition until the uncover is over. `softness` turns the hard edge into a soft one, and `ease`, which any transition takes, moves it along a curve in the same time. M does the same from a `.motion` file: its scene event names the transition as data, with its mask, colour and curve.

Click the preview first so it hears the keyboard. 1 to 6 choose the shape, S softens the edge, E changes the curve (straight, sine, bounce, elastic, expo), X goes, M plays the motion.

## Code

`index.ts`:

```ts
import { createGame } from 'nacatamalon';
import { WIDTH, HEIGHT } from './settings';
import { makeRoom, type TSelection } from './scenes/room';

const state: TSelection = {
    chosen: 0,
    curve: 0,
    soft: false
};

const game = createGame('#app', {
    width: WIDTH,
    height: HEIGHT,
    background: '#05080f',
});

// Declare Scenes
game({
    Day: makeRoom(state, 'DAY', 'Night', '#3a6fb0', '#ffd34d', '/example-assets/motions/to-night.motion'),
    Night: makeRoom(state, 'NIGHT', 'Day', '#141a33', '#9b7bff', '/example-assets/motions/to-day.motion'),
}, 'Day');
```

`art/masks.ts`:

```ts
import { createPixels, fillNoise, mapPixels } from 'nacatamalon/pixels';

import type { TPixels } from 'nacatamalon/pixels';
import { MW, MH } from '../settings';

const paint = (grey: (x: number, y: number) => number): TPixels =>
    mapPixels(createPixels(MW, MH), (_, x, y) => {
        const g = Math.min(1, Math.max(0, grey(x + 0.5, y + 0.5)));

        return {
            r: g,
            g,
            b: g,
            a: 1
        };
    });

/**
 * Brings every grey of a shape into 0 to 1, so the cover spends its whole time on it.
 */
const spread = (grey: (x: number, y: number) => number): TPixels => {
    let low = Infinity;
    let high = -Infinity;

    for (let y = 0; y < MH; y++) {
        for (let x = 0; x < MW; x++) {
            const g = grey(x + 0.5, y + 0.5);

            low = Math.min(low, g);
            high = Math.max(high, g);
        }
    }

    return paint((x, y) => (grey(x, y) - low) / (high - low));
};

const fromMiddle = (x: number, y: number) => ({
    dx: x - MW / 2,
    dy: y - MH / 2
});

// Dark outside, light in the middle: it closes on the middle as a star.
export const star = () => spread((x, y) => {
    const { dx, dy } = fromMiddle(x, y);
    const point = (Math.cos((Math.atan2(dy, dx) + Math.PI / 2) * 5) + 1) / 2;

    return -Math.hypot(dx, dy) / (0.55 + 0.45 * point * point);
});

// Two turns from the edge inwards: the cover winds in. Each turn is a third of the way to the
// corner, wide enough to read as a spiral and not as a circle that wobbles.
export const spiral = () => spread((x, y) => {
    const { dx, dy } = fromMiddle(x, y);
    const turn = (Math.atan2(dy, dx) + Math.PI) / (Math.PI * 2);

    return -(Math.hypot(dx, dy) / 18 + turn);
});

// Every band dark at the top and light at the bottom: blinds closing all at once.
export const blinds = () => paint((_, y) => (y % 9) / 9);

// Tiles: diamonds that grow from their middles, the left ones first.
export const diamonds = () => spread((x, y) => {
    const cx = Math.floor(x / 12) * 12 + 6;
    const cy = Math.floor(y / 12) * 12 + 6;

    return (Math.abs(x - cx) + Math.abs(y - cy)) / 12 + x / MW;
});

// Noise: the screen eaten grain by grain.
export const grain = () => fillNoise(createPixels(MW, MH), {
    seed: 3,
    scale: 3,
    octaves: 2
});
```

`scenes/room.ts`:

```ts
import { createScene, createSprite, createText, getColor, useKeyboard, useLoadMotion, useLoadTexture, useMotion, useScene, useUpdate } from 'nacatamalon';

import { createPixelTexture } from 'nacatamalon/pixels';
import { dissolve } from 'nacatamalon/transitions';
import type { TTexture } from 'nacatamalon';

import { WIDTH, HEIGHT, CURVES, SHAPES } from '../settings';
import { star, spiral, blinds, diamonds, grain } from '../art/masks';

export type TSelection = { chosen: number; curve: number; soft: boolean; };
export const makeRoom = (state: TSelection, title: string, next: string, bg: string, block: string, motion: string) => () => {
    const scene = useScene();
    const keys = useKeyboard();

    const tour = useMotion(useLoadMotion({
        src: motion
    }));

    const masks: TTexture[] = [
        createPixelTexture(star()),
        createPixelTexture(spiral()),
        createPixelTexture(blinds()),
        createPixelTexture(diamonds()),
        createPixelTexture(grain()),
        useLoadTexture({
            src: '/example-assets/assets/masks/clock.png'
        }),
    ];

    createSprite({
        anchor: {
            x: 0,
            y: 0
        },
        width: WIDTH,
        height: HEIGHT,
        tint: getColor(bg)
    });

    for (let i = 0; i < 9; i++) {
        createSprite({
            width: 28,
            height: 28,
            tint: getColor(block),
            transform: {
                x: 40 + i * 50,
                y: 92 + Math.sin(i) * 18,
                rotation: i * 0.4
            },
        });
    }

    createText({
        text: title,
        style: {
            fontSize: 16
        },
        tint: '#f2f6fa',
        transform: {
            x: 16,
            y: 14
        }
    });

    const help = createText({
        text: '',
        style: {
            fontSize: 8
        },
        tint: '#f2f6fa',
        transform: {
            x: 16,
            y: 252
        }
    });

    const said = createText({
        text: 'M PLAYS ' + motion.split('/').slice(-2).join('/').toUpperCase(),
        style: {
            fontSize: 8
        },
        tint: '#ffd34d',
        transform: {
            x: 16,
            y: 36
        }
    });

    // A mark at the start of the motion: what is about to happen, said on screen.
    tour.on('countdown', () => {
        said.text = 'THE MOTION CHANGES ROOM IN 1.5 S';
    });

    // The masks themselves, so the greys can be set beside the shapes they make.
    const frame = createSprite({
        width: 64,
        height: 40,
        tint: '#ffd34d'
    });

    masks.forEach((texture, i) => {
        const x = 52 + i * 76;

        createSprite({
            texture,
            width: 60,
            height: 34,
            transform: {
                x,
                y: 196
            }
        });
        createText({
            text: `${i + 1} ${SHAPES[i]}`,
            style: {
                fontSize: 8
            },
            tint: '#f2f6fa',
            transform: {
                x: x - 30,
                y: 220
            }
        });
    });

    const show = () => {
        frame.transform.x = 52 + state.chosen * 76;
        frame.transform.y = 196;
        help.text = `1-6 SHAPE  S EDGE: ${state.soft ? 'SOFT' : 'HARD'}  E CURVE: ${CURVES[state.curve].label}  X GO`;
    };

    show();

    useUpdate(() => {
        for (let i = 0; i < SHAPES.length; i++) {
            if (keys.justPressed(String(i + 1))) {
                state.chosen = i;
            }
        }

        if (keys.justPressed('s')) {
            state.soft = !state.soft;
        }

        if (keys.justPressed('e')) {
            state.curve = (state.curve + 1) % CURVES.length;
        }

        show();

        if (keys.justPressed('m') && !tour.playing) {
            tour.play();
        }

        if (keys.justPressed('x')) {
            const transition = dissolve(0.9, masks[state.chosen], {
                softness: state.soft ? 0.15 : 0
            });

            const { ease } = CURVES[state.curve];

            scene.change(next, {
                transition: ease ? {
                    ...transition,
                    ease
                } : transition
            });
        }
    });

    return createScene();
};
```

`settings.ts`:

```ts
import { easeInOutExpo, easeInOutSine, easeOutBounce, easeOutElastic } from 'nacatamalon/math';

// Transition masks: dissolve() covers the screen in the order a picture's greys say,
// darkest first, and uncovers it the same way back. The picture is the shape.
//
// - Five are painted in code with mapPixels: a grey worked out for every pixel. The edge of
//   the cover at any moment is every pixel of one grey, so whatever shape one grey makes,
//   the cover has.
// - The clock is a file, public/assets/masks/clock.png: a picture made anywhere works.
// - They are small (96 x 54 for a 480 x 270 game). A mask is stretched over the screen and
//   read blended between its pixels, so a small one still has smooth edges.
// - Each room paints its own and no key keeps them, so they go when the room stops. The
//   one it changed with is kept by the transition until the uncover is over.
//
// - Any transition moves along a curve with ease, one of useTween's:
//   { ...dissolve(0.9, mask), ease: easeInOutSine } starts and ends gently, easeOutBounce lands
//   and bounces open a little before it settles. It takes as long either way; the curve only
//   spreads it differently. A curve that goes past the end (elastic, back) holds there for a
//   moment instead, so no shader is handed a number it was not written for.
//
// - M plays a .motion file instead: public/motions/to-night.motion (and to-day). Its last event
//   changes room the same way, written as data, the way an editor's motion panel saves it:
//     { "t": 1.5, "kind": "scene", "scene": "Night", "transition": { "type": "dissolve",
//       "duration": 1.2, "mask": "../assets/masks/clock.png", "softness": 0.1,
//       "color": { "r": 0.05, "g": 0.03, "b": 0.12, "a": 1 }, "ease": "easeInOutSine" } }
//   The mask is found next to the motion file, and fetched as soon as the motion arrives.
//
// 1-6 choose the shape, S softens the edge, E changes the curve, X goes to the other room,
// M plays the motion.

export const WIDTH = 480;
export const HEIGHT = 270;
export const MW = 96;
export const MH = 54;
export const SHAPES = ['STAR', 'SPIRAL', 'BLINDS', 'TILES', 'GRAIN', 'CLOCK'];
// The curves E goes through: none, which is a straight line, and four of the tween curves.
export const CURVES = [
    {
        label: 'STRAIGHT',
        ease: undefined
    },
    {
        label: 'SINE',
        ease: easeInOutSine
    },
    {
        label: 'BOUNCE',
        ease: easeOutBounce
    },
    {
        label: 'ELASTIC',
        ease: easeOutElastic
    },
    {
        label: 'EXPO',
        ease: easeInOutExpo
    },
];
```
