# Robot Serenade

> A robot sings so that you do not go and slay the dragon: three scenes, three songs in English, sung by voices made sample by sample.

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

The dragon is waiting on the mountain, and ROBO-GF does not want you to go. She sings you *Let the Dragon Be* over a chiptune band: her part is a vocal part, sung over the band's song with `accompany` from `nacatamalon/speech` and played by `useSong`, both made as they play, so nothing waits to load. The speech bubble follows her syllable by syllable with `lyricsAt`. Then you choose, and she answers with `renderSpeech`.

Go, and the scene fades to your grave in the rain. In black, she sings *Why Did You Have to Go*, until grief turns to a vow: her eyes go red and she takes your sword to the mountain.

There she finds you alive, camping with the dragon, your old friend. The dragon finds out first. Then the three of you sing *Just Say Goodbye*: one part, three singers, each line sung by some of them, one tune made three voices with `transpose` and `throat`, a man's, a dragon's and hers.

Each song in `audio/serenade.ts`, `audio/lament.ts` and `audio/finale.ts` is a band and a vocal part, written apart. The songs are in English, so every syllable is written as phonemes, with its words for the bubble. Everyone is pixel art painted in code in `art/paint.ts`.

Z to listen, to decide and to fight, X to skip a song, the arrows to choose, R to start again.

## Code

`index.ts`:

```ts
import { createGame } from 'nacatamalon';
import { WIDTH, HEIGHT } from './settings';
import { Serenade } from './scenes/serenade';
import { Mourning } from './scenes/mourning';
import { Finale } from './scenes/finale';

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

// Declare Scenes
game({
    Serenade,
    Mourning,
    Finale
});
```

`art/paint.ts`:

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

// Pixel art as rows of letters, one colour each; a dot is see-through.
const paint = (rows: string[], colors: Record<string, TColor>) => {
    const pixels = createPixels(Math.max(...rows.map((row) => row.length)), rows.length);

    rows.forEach((row, y) => [...row].forEach((key, x) => colors[key] && setPixel(pixels, x, y, colors[key])));

    return pixels;
};

const COLORS: Record<string, TColor> = {
    k: getColor('#1b1b2f'),
    l: getColor('#eef3fa'),
    m: getColor('#aab6c8'),
    d: getColor('#5d6b82'),
    c: getColor('#5ff2ff'),
    p: getColor('#ff6fa8'),
    v: getColor('#8a6cff'),
    r: getColor('#ff2e4d'),
    o: getColor('#ff8a1e'),
    y: getColor('#f5c518'),
    b: getColor('#8a5a2b'),
    g: getColor('#8d93a8'),
    h: getColor('#5f6478'),
    e: getColor('#3b2f2a'),
    w: getColor('#e8e4c9'),
    s: getColor('#f1c27d'),
    t: getColor('#3f8f5f'),
    n: getColor('#2b3a67'),
};

// In mourning, her ribbons and her dress are black.
const BLACK_DRESS = {
    ...COLORS,
    p: getColor('#3b3347'),
    v: getColor('#3b3347')
};

// ROBO-GF: an antenna, pigtails, a visor with two eyes, a dress. 24 x 32. Rows 8 and 9 are her
// eyes, which change with her mood.
const robot = (eyes: [string, string], colors: Record<string, TColor>) => paint(['...........pp...........', '...........kk...........', '...........kk...........', '......kkkkkkkkkkkk......', '.....kllllllllllllk.....', '....kllmmmmmmmmmmllk....', '.ppkkmmmmmmmmmmmmmmkkpp.', 'pppkmmkkkkkkkkkkkkmmkppp', 'pppkmm' + eyes[0] + 'mmkppp', 'pppkmm' + eyes[1] + 'mmkppp', '.ppkmmkkkkkkkkkkkkmmkpp.', '..pkmmppmmmmmmmmppmmkp..', '...kmmmmmmkkkkmmmmmmk...', '....kdmmmmmmmmmmmmdk....', '.....kkkkkkkkkkkkkk.....', '..........kddk..........', '......kkkkkkkkkkkk......', '.....kvvvvvvvvvvvvk.....', '....kmkvvvvvvvvvvkmk....', '...kmmkvvvvvvvvvvkmmk...', '...kmmkvvvvvvvvvvkmmk...', '...kmmkvvvvvvvvvvkmmk...', '...kllkvvvvvvvvvvkllk...', '....kk.kvvvvvvvvk.kk....', '......kvvvvvvvvvvk......', '.....kvvvvvvvvvvvvk.....', '....kvvvvvvvvvvvvvvk....', '....kkkkkkkkkkkkkkkk....', '.......kdk....kdk.......', '.......kmk....kmk.......', '......kkmk....kmkk......', '......kkkk....kkkk......',], colors);

// Glad: both eyes lit. Grieving: only their lower half, lids down. Vengeful: red, slanted in.
const GLAD: [string, string] = ['kkcckkkkcckk', 'kkcckkkkcckk'];

export const ROBOT = robot(GLAD, COLORS);
export const MOURNING = robot(['kkkkkkkkkkkk', 'kkcckkkkcckk'], BLACK_DRESS);
export const VENGEFUL = robot(['kkrkkkkkkrkk', 'kkrrkkkkrrkk'], BLACK_DRESS);
// Glad again, still in black.
export const FORGIVING = robot(GLAD, BLACK_DRESS);

// Her mouth open, laid over the closed one while she sings.
export const MOUTH = paint(['kkkk', 'kppk'], COLORS);

// The heart on her chest, and the same heart broken.
export const CHEST = paint(['rr.rr', 'rrrrr', '.rrr.', '..r..'], COLORS);
export const HEARTBREAK = paint(['rr.rr', 'rr.rr', '.r.r.', '..r..'], COLORS);

// Your soul: the cursor of the choices.
export const SOUL = paint(['.rr.rr.', 'rrrrrrr', 'rrrrrrr', '.rrrrr.', '..rrr..', '...r...'], COLORS);

// You, the adventurer, out camping: no helmet, a green tunic. 16 x 20.
export const HUSBAND = paint(['.....kkkkkk.....', '....kbbbbbbk....', '...kbbbbbbbbk...', '...kbssssssbk...', '...kssksskssk...', '...kssssssssk...', '...kssskksssk...', '....kssssssk....', '.....kkkkkk.....', '...kkttttttkk...', '..kttttttttttk..', '..kttttttttttk..', '..ksktyyyytksk..', '....kttttttk....', '....kttttttk....', '....knnkknnk....', '....knnkknnk....', '....knnkknnk....', '...kbbbkkbbbk...', '...kkkkkkkkkk...',], COLORS);

// The dragon, sitting, facing left. Q is the top of his head and O around his eye: green as the rest
// of him, until she is done with him, when the head is bandaged and the eye black.
const dragon = (head: string, eye: string) => paint(['....k...k', '...kak.kak..........kkkk', '..kQQQQQQQk........kaaaak', '.kQQQQQQQQQk......kaaaaaak', 'kgggOOOggggk.....kaaaaaaak', 'kggOwkOgggggk...kaaaaaaaak', 'kggOOOgggggggk.kaaaaaaaak', 'kgggggggggggggkkaaaaaaak', '.kllgggggggggggkaaaaakk', '..kkkkggggggggggkkkkk', '......kgggggggggggggk', '.......kgggzzzggggggggk', '.......kggzzzzzggggggggk', '......kggzzzzzzzgggggggk', '......kggzzzzzzzggggggggk', '......kggzzzzzzzggggggggk', '......kggzzzzzzzggggggggk...kk', '......kggzzzzzzzggggggggk..kgk', '......kggzzzzzzzgggggggggkkggk', '.......kggzzzzzgggggggggggggk', '.......kgggzzzggggggggggggk', '......kggggggggggggggggkkk', '.....kgggk...kgggk', '.....kkkkk...kkkkk',], {
    ...COLORS,
    g: getColor('#4caf50'),
    a: getColor('#2e7d32'),
    z: getColor('#e8d9a0'),
    Q: getColor(head),
    O: getColor(eye)
});

export const DRAGON = dragon('#4caf50', '#4caf50');
export const BEATEN = dragon('#eef3fa', '#6a3d8a');

// A campfire on two logs.
export const FIRE = paint(['....r.....', '...rr..r..', '..rryr.rr.', '..royyorr.', '.rooyyyor.', '.royylyyr.', '..oylllyo.', 'kbbbbbbbbk', '.kbbkkbbk.',], COLORS);

// A tear, and a drop of rain.
export const TEAR = paint(['c', 'c'], COLORS);
export const RAIN = paint(['h', 'h', 'h', 'h', 'h', 'h'], COLORS);

// A crescent moon.
export const MOON = paint(['....wwww....', '..wwwww.....', '.wwwww......', '.wwww.......', 'wwww........', 'wwww........', 'wwww........', 'wwww........', '.wwww.......', '.wwwww......', '..wwwww.....', '....wwww....',], COLORS);

// Your grave: a stone with a cross, on a mound of earth. 20 x 23.
export const GRAVE = paint(['......kkkkkkkk......', '....kkggggggggkk....', '...kggggggggggggk...', '..kggggggggggggggk..', '..kggggggkkggggggk..', '..kggggggkkggggggk..', '..kggggkkkkkkggggk..', '..kggggggkkggggggk..', '..kggggggkkggggggk..', '..kggggggkkggggggk..', ...Array<string>(10).fill('..kgggggggggggghhk..'), '.kkkkkkkkkkkkkkkkkk.', 'keeeeeeeeeeeeeeeeeek', 'eeeeeeeeeeeeeeeeeeee',], COLORS);

// Your sword, point down. 7 x 22.
export const SWORD = paint(['...y...', '..yyy..', '...b...', '...b...', 'yyyyyyy', ...Array<string>(16).fill('..lml..'), '...l...',], COLORS);

// The mountains around the lair, against the dusk.
export const MOUNTAINS = (() => {
    const pixels = createPixels(480, 120);
    const peaks = [[0, 120], [0, 70], [90, 20], [170, 80], [260, 10], [360, 75], [420, 40], [480, 70], [480, 120]];

    fillPolygon(pixels, peaks.map(([x, y]) => ({
        x: x!,
        y: y!
    })), '#1c1228');

    return pixels;
})();

// The ground, with a line of grass on top.
export const ground = (earth: string, grass: string) => {
    const pixels = createPixels(480, 28, getColor(earth));

    fillRect(pixels, 0, 0, 480, 2, getColor(grass));

    return pixels;
};

// The speech bubble, its tail on the left pointing at whoever speaks.
export const bubble = (width: number, height: number) => {
    const pixels = createPixels(width, height);
    const white = getColor('#ffffff');

    fillRect(pixels, 10, 0, width - 10, height, white);
    fillPolygon(pixels, [
        {
            x: 0,
            y: height / 2
        },
        {
            x: 10,
            y: height / 2 - 6
        },
        {
            x: 10,
            y: height / 2 + 6
        }
    ], white);

    return pixels;
};

// The box at the bottom: a white frame on black.
export const frame = (width: number, height: number) => {
    const pixels = createPixels(width, height, '#ffffff');

    fillRect(pixels, 4, 4, width - 8, height - 8, '#000000');

    return pixels;
};
```

`audio/finale.ts`:

```ts
import { DOCUMENTS } from 'nacatamalon/files';
import { VOCAL_FORMAT } from 'nacatamalon/speech';
import { bars, HER_VOICE, n, type TSongSheet } from './song';

// "Just Say Goodbye", in C major: you and the dragon explain, she answers, and all three sing the end.
// The tune is written once, where she sings it; you and the dragon sing it lower with `transpose`.
const CHORDS = 'C G C Am F G C Am Dm G F Dm Dm G C F F C C'.split(' ');

const BASS: Record<string, string> = {
    C: 'c,4 g,, c, g,,',
    G: 'g,,4 d, g,, d,',
    Am: 'a,,4 e, a,, e,',
    F: 'f,,4 c, f,, c,',
    Dm: 'd,4 a,, d, a,,'
};

// The chord on the off-beats: one note, played as a chord by the instrument.
const STAB: Record<string, string> = {
    C: "@major r4 c' r c'",
    G: '@major r4 g r g',
    Am: '@minor r4 a r a',
    F: '@major r4 f r f',
    Dm: "@minor r4 d' r d'"
};

const HELD: Record<string, string> = {
    C: "e'1",
    G: "d'1",
    Am: "c'1",
    F: 'a1',
    Dm: "f'1"
};

const inChorus = (bar: number) => bar >= 14 && bar < 18;

export const FINALE: TSongSheet = {
    // An oom-pah: the bass on the beats, the chord on the off-beats, a kick and a snare; and at the end
    // a square holding the chord.
    band: {
        format: DOCUMENTS.SONG.FORMAT,
        tempo: 120,
        meter: [4, 4],
        voices: {
            triangle: {
                notes: bars(CHORDS, (chord) => BASS[chord]!),
                volume: 0.3
            },
            pulse1: {
                notes: bars(CHORDS, (chord) => STAB[chord]!),
                duty: 0.25,
                volume: 0.14
            },
            pulse2: {
                notes: bars(CHORDS, (chord, bar) => (inChorus(bar) ? '@soft ' + HELD[chord]! : 'r1')),
                duty: 0.5,
                volume: 0.1
            },
            noise: {
                notes: bars(CHORDS, (_, bar) => (bar < 2 ? 'r1' : '@kick x4 @hat x @snare x @hat x')),
                volume: 0.1
            },
        },
    },
    // Three voices: you an octave under her, the dragon lower still, with a longer throat and a growl
    // of breath. You, then the dragon, then her; the last two lines, everyone, so each a little softer
    // than alone, for the three together.
    vocal: {
        format: VOCAL_FORMAT,
        start: 8,
        singers: {
            you: {
                throat: 1,
                breath: 0.1,
                vibrato: 0.15,
                transpose: -12,
                volume: 0.6
            },
            dragon: {
                throat: 0.8,
                breath: 0.35,
                vibrato: 0.3,
                transpose: -17,
                seed: 2,
                volume: 0.6
            },
            robot: {
                ...HER_VOICE,
                vibrato: 0.1,
                volume: 0.6
            },
        },
        lines: [
            {
                notes: [n('E4', 1, 'HH AH1', 'Hon-'), n('G4', 1, 'N IY0', 'ey,'), n('C5', 1, 'AY1', 'I'), n('B4', 1, 'W AA0 Z', 'was'), n('A4', 1, 'N EH1', 'nev-'), n('G4', 1, 'V ER0', 'er'), n('E4', 2, 'D EH1 D', 'dead,')],
                who: 'you'
            },
            {
                notes: [n('F4', 1, 'AY1', 'I'), n('F4', 0.5, 'JH AH1 S T', 'just'), n('A4', 0.5, 'W EH1 N T', 'went'), n('G4', 1, 'AW1 T', 'out'), n('F4', 1, 'W IH0 DH', 'with'), n('E4', 1, 'HH IH0 M', 'him'), n('E4', 1, 'IH0 N', 'in-'), n('D4', 2, 'S T EH1 D', 'stead.')],
                who: 'you'
            },
            {
                notes: [n('C4', 0.5, 'W IY1', 'We'), n('C4', 0.5, 'AA0 R', 'are'), n('E4', 1, 'OW1 L D', 'old'), n('G4', 2, 'F R EH1 N D Z', 'friends,'), n('A4', 0.5, 'DH AH0', 'the'), n('G4', 1, 'N AY1 T', 'knight'), n('E4', 0.5, 'AE0 N D', 'and'), n('C4', 2, 'M IY1', 'me,')],
                who: 'dragon'
            },
            {
                notes: [n('D4', 1, 'P L IY1 Z', 'please'), n('D4', 1, 'D OW1 N T', "don't"), n('F4', 1, 'HH IH1 T', 'hit'), n('E4', 1, 'M IY0', 'me,'), n('D4', 1, 'L EH1 T', 'let'), n('B3', 1, 'M IY0', 'me'), n('C4', 2, 'B IY1', 'be.')],
                who: 'dragon'
            },
            {
                notes: [n('A4', 1, 'TH R IY1', 'Three'), n('A4', 1, 'HH OW1 L', 'whole'), n('A4', 1, 'D EY1 Z', 'days'), n('G4', 1, 'AY1', 'I'), n('F4', 1.5, 'K R AY1 D', 'cried'), n('E4', 0.5, 'F AO0 R', 'for'), n('D4', 2, 'Y UW1', 'you,')],
                who: 'robot'
            },
            {
                notes: [n('D4', 1, 'D AH1 G', 'dug'), n('E4', 0.5, 'AH0', 'a'), n('F4', 1.5, 'G R EY1 V', 'grave'), n('G4', 1, 'AE0 N D', 'and'), n('A4', 1, 'S AE1 NG', 'sang'), n('B4', 1, 'DH EH1 R', 'there'), n('G4', 2, 'T UW1', 'too!')],
                who: 'robot'
            },
            {
                notes: [n('C5', 2, 'F R EH1 N D Z', 'Friends,'), n('G4', 2, 'F R EH1 N D Z', 'friends'), n('A4', 0.5, 'AA1 N', 'on'), n('A4', 0.5, 'DH AH0', 'the'), n('C5', 1, 'M AW1 N', 'moun-'), n('B4', 1, 'T AH0 N', 'tain'), n('G4', 1, 'HH AY1', 'high,')]
            },
            {
                notes: [n('F4', 1, 'N EH1 K S T', 'next'), n('A4', 1, 'T AY1 M', 'time,'), n('G4', 1, 'JH AH1 S T', 'just'), n('E4', 1, 'S EY1', 'say'), n('D4', 1, 'G UH0 D', 'good-'), n('C4', 3, 'B AY1', 'bye.')]
            },
        ],
    },
    chorus: 6,
    beats: CHORDS.length * 4,
};
```

`audio/lament.ts`:

```ts
import { DOCUMENTS } from 'nacatamalon/files';
import { VOCAL_FORMAT } from 'nacatamalon/speech';
import { bars, HER_VOICE, n, type TSongSheet } from './song';

// "Why Did You Have to Go", in D minor: a lament at your grave, and then a vow.
const CHORDS = 'Dm Bb Dm Bb Dm Am Dm Gm A Dm Dm Gm F A Dm F Gm Dm Dm'.split(' ');
const inChorus = (bar: number) => bar >= 10 && bar < 18;

// The chord a quarter at a time, slow, for the grief.
const TOLLED: Record<string, string> = {
    Dm: "d'4 f' a' f'",
    Bb: "bb4 d' f' d'",
    Am: "a4 c' e' c'",
    Gm: "g4 bb d' bb",
    A: "a4 c#' e' c#'",
    F: "f4 a c' a",
};

const ROOT: Record<string, string> = {
    Dm: "d'",
    Bb: 'bb',
    Am: 'a',
    Gm: 'g',
    A: 'a',
    F: 'f'
};

const LOW: Record<string, string> = {
    Dm: 'd,',
    Bb: 'bb,,',
    Am: 'a,,',
    Gm: 'g,,',
    A: 'a,,',
    F: 'f,,'
};

const THIRD: Record<string, string> = {
    Dm: "f'1",
    Bb: "d'1",
    Am: "c'1",
    Gm: 'bb1',
    A: "c#'1",
    F: 'a1'
};

export const LAMENT: TSongSheet = {
    // The verse: a bass held a bar, the chord picked slowly, a bell tolling. The chorus: the bass
    // driving in eighths, power chords, a drum, and a square holding the chord over it all.
    band: {
        format: DOCUMENTS.SONG.FORMAT,
        tempo: 88,
        meter: [4, 4],
        voices: {
            triangle: {
                notes: bars(CHORDS, (chord, bar) => (inChorus(bar) ? LOW[chord] + '8 ' + Array(7).fill(LOW[chord]).join(' ') : LOW[chord] + '1')),
                volume: 0.4
            },
            pulse1: {
                notes: bars(CHORDS, (chord, bar) => (inChorus(bar) ? '@power ' + ROOT[chord] + '4. ' + ROOT[chord] + '8 ' + ROOT[chord] + '2' : '@soft ' + TOLLED[chord])),
                duty: 0.25,
                volume: 0.2,
            },
            pulse2: {
                notes: bars(CHORDS, (chord, bar) => (inChorus(bar) ? '@lead ' + THIRD[chord] : "@bell d''2 r2")),
                duty: 0.5,
                volume: 0.12
            },
            noise: {
                notes: bars(CHORDS, (_, bar) => (inChorus(bar) ? '@kick x4 @snare x @kick x @snare x' : 'r1')),
                volume: 0.16
            },
        },
    },
    // Crying: more vibrato, more breath.
    vocal: {
        format: VOCAL_FORMAT,
        start: 8,
        singers: {
            robot: {
                ...HER_VOICE,
                vibrato: 0.3,
                breath: 0.2
            }
        },
        lines: [
            {
                notes: [n('A4', 2, 'W AY1', 'Why'), n('G4', 1, 'D IH0 D', 'did'), n('F4', 1, 'Y UW0', 'you'), n('E4', 1.5, 'HH AE1 V', 'have'), n('F4', 0.5, 'T UW0', 'to'), n('D4', 2, 'G OW1', 'go?')]
            },
            {
                notes: [n('D4', 0.5, 'AY1', 'I'), n('F4', 1, 'T OW1 L D', 'told'), n('E4', 0.5, 'Y UW0', 'you'), n('D4', 2, 'S OW1', 'so,'), n('C4', 0.5, 'AY1', 'I'), n('E4', 1, 'T OW1 L D', 'told'), n('D4', 0.5, 'Y UW0', 'you'), n('A3', 2, 'S OW1', 'so.')]
            },
            {
                notes: [n('D4', 0.5, 'Y AO0 R', 'Your'), n('F4', 1, 'T IY1', 'tea'), n('G4', 0.5, 'IH0 Z', 'is'), n('A4', 2, 'K OW1 L D', 'cold,'), n('A4', 0.5, 'Y AO0 R', 'your'), n('Bb4', 1, 'CH EH1 R', 'chair'), n('A4', 0.5, 'IH0 Z', 'is'), n('G4', 2, 'B EH1 R', 'bare,')]
            },
            {
                notes: [n('F4', 0.5, 'DH AH0', 'the'), n('E4', 1, 'T OW1 S T', 'toast'), n('D4', 0.5, 'IH0 Z', 'is'), n('E4', 1, 'B ER1 N', 'burn-'), n('C#4', 1, 'IH0 NG', 'ing,'), n('D4', 1, 'N OW1', 'no'), n('E4', 1, 'W AH1 N Z', "one's"), n('D4', 2, 'DH EH1 R', 'there.')]
            },
            {
                notes: [n('D5', 1, 'D R AE1', 'Dra-'), n('A4', 1, 'G AH0 N', 'gon,'), n('D5', 1, 'D R AE1', 'dra-'), n('A4', 1, 'G AH0 N', 'gon,'), n('Bb4', 1, 'HH IH1 R', 'hear'), n('A4', 1, 'M AY1', 'my'), n('G4', 2, 'V AW1', 'vow:')]
            },
            {
                notes: [n('F4', 0.5, 'AY1', 'I'), n('G4', 0.5, 'AE1 M', 'am'), n('A4', 1, 'K AH1', 'com-'), n('A4', 1, 'IH0 NG', 'ing'), n('G4', 0.5, 'F AO0 R', 'for'), n('F4', 0.5, 'Y UW0', 'you'), n('E4', 4, 'N AW1', 'now.')]
            },
            {
                notes: [n('D5', 1.5, 'B OW1 L T', 'Bolt'), n('C5', 0.5, 'F AO0 R', 'for'), n('A4', 2, 'F AY1 R', 'fire,'), n('D5', 1.5, 'S T IY1 L', 'steel'), n('C5', 0.5, 'F AO0 R', 'for'), n('A4', 2, 'B OW1 N', 'bone,')]
            },
            {
                notes: [n('G4', 0.5, 'AY1', 'I'), n('A4', 0.5, 'W IH0 L', 'will'), n('Bb4', 1, 'B R IH1 NG', 'bring'), n('A4', 0.5, 'M AY0', 'my'), n('G4', 1, 'HH AH1 Z', 'hus-'), n('E4', 0.5, 'B AH0 N D', 'band'), n('D4', 4, 'HH OW1 M', 'home.')]
            },
        ],
    },
    chorus: 4,
    beats: CHORDS.length * 4,
};
```

`audio/play_once.ts`:

```ts
import { useSong, type TSongHandle } from 'nacatamalon';

import { accompany, lyricsAt } from 'nacatamalon/speech';
import type { TSongSheet } from './song';

export const playOnce = (sheet: TSongSheet) => {
    const song: TSongHandle = useSong(accompany(sheet.vocal, sheet.band));
    let last = 0;

    return {
        song,
        // The words being sung now, from the beat heard.
        lyrics: () => lyricsAt(sheet.vocal, song.beat),
        over: () => {
            const beat = song.beat ?? 0;
            const ended = beat >= sheet.beats - 0.25 || beat < last - 1;

            last = beat;

            return ended;
        },
    };
};
```

`audio/serenade.ts`:

```ts
import { DOCUMENTS } from 'nacatamalon/files';
import { VOCAL_FORMAT } from 'nacatamalon/speech';
import { bars, HER_VOICE, n, type TSongSheet } from './song';

// "Let the Dragon Be", in A minor: she sings it so that you stay home.
const CHORDS = 'Am F Am F C E Am Dm E Am F C G Am F C G Am Am'.split(' ');

const PICKED: Record<string, string> = {
    Am: "a8 c' e' a' e' c' e' c'",
    F: "f8 a c' f' c' a c' a",
    C: "c'8 e' g' c'' g' e' g' e'",
    G: "g8 b d' g' d' b d' b",
    E: "e8 g# b e' b g# b g#",
    Dm: "d'8 f' a' d'' a' f' a' f'",
};

const BASS: Record<string, string> = {
    Am: 'a,,2 e,',
    F: 'f,,2 c,',
    C: 'c,2 g,',
    G: 'g,,2 d,',
    E: 'e,,2 b,,',
    Dm: 'd,2 a,'
};

const HELD: Record<string, string> = {
    Am: "c'1",
    F: 'a1',
    C: "e'1",
    G: "d'1",
    E: 'g#1',
    Dm: "f'1"
};

const inChorus = (bar: number) => bar >= 10 && bar < 18;

export const SERENADE: TSongSheet = {
    // The triangle on the bass, a thin square picking the chord in eighths, and in the chorus a round
    // square holding it and a hi-hat.
    band: {
        format: DOCUMENTS.SONG.FORMAT,
        tempo: 104,
        meter: [4, 4],
        voices: {
            triangle: {
                notes: bars(CHORDS, (chord) => BASS[chord]!),
                volume: 0.4
            },
            pulse1: {
                notes: bars(CHORDS, (chord) => '@pluck ' + PICKED[chord]!),
                duty: 0.125,
                volume: 0.25
            },
            pulse2: {
                notes: bars(CHORDS, (chord, bar) => (inChorus(bar) ? '@soft ' + HELD[chord]! : 'r1')),
                duty: 0.5,
                volume: 0.15
            },
            noise: {
                notes: bars(CHORDS, (_, bar) => (inChorus(bar) ? '@hat x8 x x x x x x x' : 'r1')),
                volume: 0.12
            },
        },
    },
    // Her part, from the third bar, after two of the band alone. One line a two-bar phrase.
    vocal: {
        format: VOCAL_FORMAT,
        start: 8,
        singers: {
            robot: {
                ...HER_VOICE,
                vibrato: 0.1
            }
        },
        lines: [
            {
                notes: [n('E4', 1, 'D OW1 N T', "Don't"), n('E4', 1, 'G OW1', 'go'), n('D4', 0.5, 'T UW0', 'to'), n('C4', 0.5, 'DH AH0', 'the'), n('D4', 1, 'M AW1 N', 'moun-'), n('E4', 1, 'T AH0 N', 'tain,'), n('A3', 3, 'L AH1 V', 'love,')]
            },
            {
                notes: [n('A3', 1, 'DH AH0', 'the'), n('C4', 1, 'D R AE1', 'dra-'), n('B3', 1, 'G AH0 N', 'gon'), n('C4', 1, 'D AH1', 'does-'), n('D4', 1, 'Z AH0 N T', "n't"), n('E4', 3, 'K EH1 R', 'care.')]
            },
            {
                notes: [n('E4', 1, 'AY1', 'I'), n('E4', 1, 'M EY1 D', 'made'), n('F4', 0.5, 'Y UW0', 'you'), n('E4', 1.5, 'T IY1', 'tea'), n('D4', 0.5, 'AH0 N D', 'and'), n('C4', 1, 'T OW1 S T', 'toast'), n('D4', 0.5, 'AE0 T', 'at'), n('E4', 2, 'N AY1 N', 'nine')]
            },
            {
                notes: [n('C4', 1, 'AH0 N D', 'and'), n('B3', 1, 'F IH1 K S T', 'fixed'), n('A3', 1, 'Y AO0 R', 'your'), n('G#3', 1, 'F EY1', 'fa-'), n('B3', 1, 'V R IH0 T', 'vorite'), n('A3', 3, 'CH EH1 R', 'chair.')]
            },
            {
                notes: [n('A4', 2, 'S T EY1', 'Stay,'), n('G4', 2, 'S T EY1', 'stay'), n('E4', 1, 'HH IH1 R', 'here'), n('D4', 1, 'W IH0 DH', 'with'), n('E4', 2, 'M IY1', 'me,')]
            },
            {
                notes: [n('C4', 1, 'L EH1 T', 'let'), n('D4', 1, 'DH AH0', 'the'), n('E4', 1, 'D R AE1', 'dra-'), n('G4', 1, 'G AH0 N', 'gon'), n('E4', 4, 'B IY1', 'be.')]
            },
            {
                notes: [n('A4', 1, 'M AY1', 'My'), n('A4', 1, 'HH AA1 R T S', "heart's"), n('G4', 0.5, 'AH0', 'a'), n('E4', 1.5, 'B AE1', 'bat-'), n('D4', 1, 'T ER0', 'te-'), n('E4', 3, 'R IY0', 'ry')]
            },
            {
                notes: [n('C4', 1, 'AH0 N D', 'and'), n('D4', 1, 'IH0 T', 'it'), n('E4', 1, 'R AH1 N Z', 'runs'), n('D4', 1, 'AA1 N', 'on'), n('A3', 4, 'Y UW1', 'you.')]
            },
        ],
    },
    chorus: 4,
    beats: CHORDS.length * 4,
};
```

`audio/song.ts`:

```ts
import type { TSongDoc } from 'nacatamalon';
import type { TSungNote, TVocalDoc } from 'nacatamalon/speech';

// One sung note: its pitch, its beats, its syllable as phonemes (the songs are in English) and as
// words, for the speech bubble. A syllable ending in '-' goes on into the next one: 'moun-', 'tain'.
export const n = (pitch: string, beats: number, phonemes: string, lyric: string): TSungNote => ({
    pitch,
    beats,
    phonemes,
    lyric
});

// A song of hers: the band, its vocal part, the line where the chorus starts, and how many beats
// the band plays in all, to know when it is over.
export type TSongSheet = {
    band: TSongDoc;
    vocal: TVocalDoc;
    chorus: number;
    beats: number;
};

// Her voice: a woman's throat, little breath. A robot, but a warm one.
export const HER_VOICE = {
    throat: 1.15,
    breath: 0.05
};

// A band's notes, one chord a bar: `play` writes the bar for a chord.
export const bars = (chords: string[], play: (chord: string, bar: number) => string) => chords.map(play).join(' | ') + ' |';
```

`components/stage.ts`:

```ts
import { createSprite, createText, getColor, type TTexture } from 'nacatamalon';
import { createPixelTexture } from 'nacatamalon/pixels';
import { bubble, frame, MOUTH } from '../art/paint';

const WHITE = getColor('#ffffff');
const BLACK = getColor('#000000');
const GRAY = getColor('#7a7a8c');

// ROBO-GF, her art four times its size (96 x 128), with a mouth that opens and a heart that beats.
export const createRobot = (look: TTexture, chest: TTexture) => {
    const body = createSprite({
        texture: look,
        width: 96,
        height: 128,
        transform: {
            x: 130,
            y: 104
        }
    });

    const mouth = createSprite({
        texture: createPixelTexture(MOUTH),
        width: 16,
        height: 8,
        transform: {
            x: 130,
            y: 92
        },
        visible: false
    });

    const heart = createSprite({
        texture: chest,
        width: 20,
        height: 16,
        transform: {
            x: 130,
            y: 124
        }
    });

    return {
        body,
        mouth,
        heart,
        // Puts her middle at x, y; her mouth and her heart go with her.
        place: (x: number, y: number) => {
            body.transform.x = mouth.transform.x = heart.transform.x = x;
            body.transform.y = y;
            mouth.transform.y = y - 12;
            heart.transform.y = y + 20;
        },
        // Her heart jumps on every whole beat and settles before the next.
        beat: (beat: number) => {
            const pulse = 1 + 0.35 * Math.max(0, 1 - (((beat % 1) + 1) % 1) * 4);

            heart.transform.scaleX = pulse;
            heart.transform.scaleY = pulse;
        },
    };
};

// A speech bubble. What it says goes on two lines when the whole line does not fit on one, cut where
// the whole line is, so that a word does not jump down while it is sung.
export const createBubble = (width = 270) => {
    const speech = createSprite({
        texture: createPixelTexture(bubble(width, 44)),
        width,
        height: 44,
        transform: {
            x: 0,
            y: 0
        },
        visible: false
    });

    const lines = [0, 1].map(() => createText({
        text: '',
        style: {
            fontSize: 8
        },
        tint: BLACK,
        transform: {
            x: 0,
            y: 0
        }
    }));

    // Letters that fit across it.
    const wide = Math.floor((width - 40) / 10);

    // Puts the tip of its tail at x, y: pointing left, with the bubble to its right, or pointing right.
    const place = (x: number, y: number, tail: 'left' | 'right' = 'left') => {
        speech.flipX = tail === 'right';
        speech.transform.x = tail === 'left' ? x + width / 2 : x - width / 2;
        speech.transform.y = y;
        lines.forEach((line, i) => {
            line.transform.x = tail === 'left' ? x + 12 : x - width + 6;
            line.transform.y = y - 12 + i * 16;
        });
    };

    // Beside ROBO-GF where she stands.
    place(190, 70);

    const say = (words: string, whole = words) => {
        const cut = whole.length > wide ? whole.lastIndexOf(' ', wide) : whole.length;

        lines[0]!.text = words.slice(0, cut);
        lines[1]!.text = words.slice(cut).trim();
        speech.visible = words !== '';
    };

    return {
        say,
        place
    };
};

// The box at the bottom: two lines of what happens, and under it the keys.
export const createBox = () => {
    createSprite({
        texture: createPixelTexture(frame(432, 104)),
        width: 432,
        height: 104,
        transform: {
            x: 240,
            y: 248
        }
    });

    const lines = [0, 1].map((i) => createText({
        text: '',
        style: {
            fontSize: 16
        },
        tint: WHITE,
        transform: {
            x: 44,
            y: 216 + i * 28
        }
    }));

    const hint = createText({
        text: '',
        style: {
            fontSize: 8
        },
        tint: GRAY,
        anchor: {
            x: 0.5,
            y: 0
        },
        transform: {
            x: 240,
            y: 306
        }
    });

    return (first: string, second = '', keys = '') => {
        lines[0]!.text = first;
        lines[1]!.text = second;
        hint.text = keys;
    };
};
```

`scenes/finale.ts`:

```ts
import { createScene, createSprite, createText, renderSfx, useGame, useKeyboard, useScene, useSound, useUpdate } from 'nacatamalon';
import { createPixelTexture } from 'nacatamalon/pixels';
import { fade } from 'nacatamalon/transitions';
import { renderSpeech } from 'nacatamalon/speech';
import { HER_VOICE } from '../audio/song';

import { FINALE } from '../audio/finale';
import { createBox, createBubble, createRobot } from '../components/stage';
import { BEATEN, CHEST, DRAGON, FIRE, FORGIVING, ground, HEARTBREAK, HUSBAND, MOUNTAINS, SWORD, VENGEFUL } from '../art/paint';
import { RED } from '../settings';
import { playOnce } from '../audio/play_once';

export const Finale = () => {
    useGame().setBackground('#2b1b3d');

    const keys = useKeyboard();
    const scene = useScene();

    const { song, lyrics, over } = playOnce(FINALE);

    const hit = useSound({
        samples: renderSfx({
            preset: 'hit'
        }),
        sampleRate: 44100
    });

    const wait = useSound(renderSpeech({
        phonemes: 'HH AH1 N IY0 , W EY1 T ! HH IY1 IH1 Z M AY1 F R EH1 N D !',
        pitch: 47,
        melody: 4
    }));

    const toast = useSound(renderSpeech({
        ...HER_VOICE,
        phonemes: 'F AY1 N . HH UW1 W AA1 N T S T OW1 S T ?',
        pitch: 57,
        melody: 4
    }));

    createSprite({
        texture: createPixelTexture(MOUNTAINS),
        width: 480,
        height: 120,
        transform: {
            x: 240,
            y: 108
        }
    });
    createSprite({
        texture: createPixelTexture(ground('#2a2030', '#3d2f45')),
        width: 480,
        height: 28,
        transform: {
            x: 240,
            y: 182
        }
    });

    const fire = createSprite({
        texture: createPixelTexture(FIRE),
        width: 30,
        height: 27,
        transform: {
            x: 300,
            y: 155
        }
    });

    const you = createSprite({
        texture: createPixelTexture(HUSBAND),
        width: 48,
        height: 60,
        transform: {
            x: 230,
            y: 138
        }
    });

    const dragon = createSprite({
        texture: createPixelTexture(DRAGON),
        width: 90,
        height: 72,
        transform: {
            x: 400,
            y: 132
        }
    });

    const beaten = createPixelTexture(BEATEN);
    const robot = createRobot(createPixelTexture(VENGEFUL), createPixelTexture(HEARTBREAK));

    const mended = {
        look: createPixelTexture(FORGIVING),
        heart: createPixelTexture(CHEST)
    };

    const sword = createSprite({
        texture: createPixelTexture(SWORD),
        width: 21,
        height: 66,
        transform: {
            x: 0,
            y: 0
        }
    });

    sword.flipY = true;

    const damage = createText({
        text: '',
        style: {
            fontSize: 16
        },
        tint: RED,
        anchor: {
            x: 0.5,
            y: 0
        },
        transform: {
            x: 400,
            y: 70
        }
    });

    const { say, place } = createBubble(220);
    const tell = createBox();

    // Where each singer's bubble goes, and what the box says while they sing.
    const SINGERS: Record<string, { tail: [number, number, 'left' | 'right']; box: [string, string]; }> = {
        you: {
            tail: [250, 60, 'left'],
            box: ['* You explain.', '* In song.']
        },
        dragon: {
            tail: [350, 60, 'right'],
            box: ['* The dragon sobs.', '* Bruised. Sincere.']
        },
        robot: {
            tail: [126, 40, 'left'],
            box: ['* ROBO-GF is not', '  amused.']
        },
        all: {
            tail: [126, 40, 'left'],
            box: ['* Everyone sings.', '* Even the fire.']
        },
    };

    type TState = 'arrive' | 'fight' | 'wait' | 'singing' | 'end';

    let state: TState = 'arrive';
    let clock = 0;
    let x = -60;
    let struck = -1;

    const enter = (next: TState) => {
        state = next;
        clock = 0;
        say('');
        robot.mouth.visible = false;

        if (next === 'arrive') {
            tell("* The dragon's lair.", '* You are... alive?');
        }

        if (next === 'fight') {
            tell('* ROBO-GF attacks!');
        }

        if (next === 'wait') {
            dragon.texture = beaten;
            damage.text = '';
            wait.play();
            place(250, 60);
            say('Honey, wait! He is my friend!');
            tell('* The dragon is', '  very sorry.', 'Z  LISTEN');
        }

        if (next === 'singing') {
            song.play();
        }

        if (next === 'end') {
            // Her eyes come back, her heart mends, and the sword goes down.
            robot.body.texture = mended.look;
            robot.heart.texture = mended.heart;
            sword.visible = false;
            toast.play();
            place(126, 40);
            say('...Fine. Who wants toast?');
            tell('* She forgives you.', '* Mostly.', 'R  AGAIN');
        }
    };

    enter('arrive');

    const pressed = (...names: string[]) => names.some((name) => keys.justPressed(name));

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

        let lunge = 0;
        let singers: string[] = [];

        if (state === 'arrive') {
            // She walks in with your sword, while you two laugh by the fire.
            x = Math.min(80, x + 60 * delta);

            if (x === 80) {
                tell("* The dragon's lair.", '* You are... alive?', 'Z  FIGHT');
            }

            if (x === 80 && pressed('z', 'Enter', ' ')) {
                enter('fight');
            }
        } else if (state === 'fight') {
            // Three blows: she lunges, the dragon flashes and shakes, the damage rises over him.
            const blow = Math.floor((clock - 0.15) / 0.6);

            if (blow >= 0 && blow < 3 && blow !== struck) {
                struck = blow;
                hit.play();
                damage.text = ['12', '34', '9999'][blow]!;
                damage.transform.y = 70;
            }

            const since = clock - 0.15 - struck * 0.6;
            const reeling = struck >= 0 && since < 0.2;

            lunge = struck >= 0 && since < 0.25 ? 1 - since * 4 : 0;
            damage.transform.y -= 20 * delta;
            dragon.visible = !(reeling && Math.floor(clock * 30) % 2 === 0);
            dragon.transform.x = 400 + (reeling ? Math.sin(clock * 90) * 4 : 0);

            if (clock > 2.2) {
                dragon.visible = true;
                enter('wait');
            }
        } else if (state === 'wait' && pressed('z', 'Enter', ' ')) {
            wait.stop();
            enter('singing');
        } else if (state === 'singing') {
            // The bubble goes to whoever sings the line; all three, beside her.
            const now = lyrics();
            const who = now?.who ?? [];

            singers = now?.singing ? who : [];

            const singer = SINGERS[who.length > 1 ? 'all' : who[0] ?? 'you']!;

            place(...singer.tail);
            say(now?.words ?? '', now?.whole);
            tell(...singer.box, 'X  SKIP');
            robot.mouth.visible = singers.includes('robot');

            if (over() || pressed('x')) {
                song.stop();
                enter('end');
            }
        } else if (state === 'end' && pressed('r')) {
            toast.stop();
            scene.change('Serenade', {
                transition: fade(1)
            });
        }

        // While you laugh, you and the dragon bob in turn; while you sing, a whole art pixel up on
        // every syllable; and you jump up when she stops.
        const laughing = state === 'arrive' && Math.sin(time * 6) > 0;

        you.transform.y = 138 - (laughing || singers.includes('you') || state === 'wait' ? 3 : 0);
        dragon.transform.y = 132 - ((state === 'arrive' && !laughing) || singers.includes('dragon') ? 3 : 0);

        const bob = state === 'arrive' && x < 80 ? (Math.sin(time * 12) > 0 ? 0 : 4) : Math.sin(time * 2) > 0 ? 0 : 4;

        robot.place(x + lunge * 60, 104 + bob);
        sword.transform.x = x + lunge * 60 + 34;
        sword.transform.y = 106 + bob;
        robot.beat(state === 'singing' ? song.beat ?? 0 : time * (state === 'end' ? 1.2 : 3));
        fire.transform.scaleY = 1 + 0.12 * Math.sin(time * 13) * Math.sin(time * 7.3);
    });

    return createScene();
};
```

`scenes/mourning.ts`:

```ts
import { createScene, createSprite, useGame, useKeyboard, useScene, useUpdate } from 'nacatamalon';
import { createPixelTexture } from 'nacatamalon/pixels';
import { fade } from 'nacatamalon/transitions';

import { LAMENT } from '../audio/lament';

import { createBox, createBubble, createRobot } from '../components/stage';
import { GRAVE, ground, HEARTBREAK, MOON, MOURNING, RAIN, SWORD, TEAR, VENGEFUL } from '../art/paint';

import { playOnce } from '../audio/play_once';

export const Mourning = () => {
    useGame().setBackground('#0b0d1a');

    const keys = useKeyboard();
    const scene = useScene();

    const { song, lyrics, over } = playOnce(LAMENT);

    createSprite({
        texture: createPixelTexture(MOON),
        width: 36,
        height: 36,
        transform: {
            x: 440,
            y: 30
        }
    });

    const rain = createPixelTexture(RAIN);

    const drops = Array.from({
        length: 36
    }, (_, i) =>
        createSprite({
            texture: rain,
            width: 2,
            height: 12,
            transform: {
                x: (i * 53) % 480,
                y: (i * 97) % 180 - 10
            }
        }));

    createSprite({
        texture: createPixelTexture(ground('#141a22', '#24302a')),
        width: 480,
        height: 28,
        transform: {
            x: 240,
            y: 182
        }
    });
    createSprite({
        texture: createPixelTexture(GRAVE),
        width: 60,
        height: 69,
        transform: {
            x: 405,
            y: 142
        }
    });

    // Your sword, stuck in the ground beside you.
    const sword = createSprite({
        texture: createPixelTexture(SWORD),
        width: 21,
        height: 66,
        transform: {
            x: 345,
            y: 145
        }
    });

    const robot = createRobot(createPixelTexture(MOURNING), createPixelTexture(HEARTBREAK));
    const angry = createPixelTexture(VENGEFUL);
    const tear = createPixelTexture(TEAR);

    const tears = Array.from({
        length: 6
    }, () => ({
        sprite: createSprite({
            texture: tear,
            width: 4,
            height: 8,
            transform: {
                x: 0,
                y: 0
            },
            visible: false
        }),
        life: 0,
    }));

    const { say } = createBubble();
    const tell = createBox();

    type TState = 'ready' | 'singing' | 'leaving' | 'gone';

    let state: TState = 'ready';
    let clock = 0;
    let x = 130;
    let weep = 0;
    let eye = 0;
    // Once the chorus comes, grief has turned to anger, and it stays so.
    let vengeful = false;

    const enter = (next: TState) => {
        state = next;
        clock = 0;
        say('');
        robot.mouth.visible = false;

        if (next === 'ready') {
            tell('* Three days later.', '* The dragon won.', 'Z  LISTEN');
        }

        if (next === 'singing') {
            song.play();
            tell('* ROBO-GF weeps.', '* It rains.', 'X  SKIP');
        }

        if (next === 'leaving') {
            vengeful = true;
            robot.body.texture = angry;
            // In her hand, point up, in front of her.
            sword.flipY = true;
            sword.zIndex = 1;
            tell('* She takes your', '  sword.');
        }

        if (next === 'gone') {
            tell('* The dragon should', '  have run.');
        }
    };

    enter('ready');

    const pressed = (...names: string[]) => names.some((name) => keys.justPressed(name));

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

        if (state === 'ready' && pressed('z', 'Enter', ' ')) {
            enter('singing');
        } else if (state === 'singing') {
            const now = lyrics();

            say(now?.words ?? '', now?.whole);
            robot.mouth.visible = now?.singing ?? false;

            // The chorus: grief turns to anger, and her eyes go red.
            if (!vengeful && (now?.line ?? 0) >= LAMENT.chorus) {
                vengeful = true;
                robot.body.texture = angry;
                tell('* Her eyes go red.', '* Run, dragon.', 'X  SKIP');
            }

            if (over() || pressed('x')) {
                song.stop();
                enter('leaving');
            }
        } else if (state === 'leaving') {
            // Off to the mountain, your sword held high.
            x += 50 * delta;

            if (x > 560) {
                enter('gone');
            }
        } else if (state === 'gone' && clock > 3) {
            scene.change('Finale', {
                transition: fade(1.5)
            });
        }

        const bob = state === 'leaving' ? (Math.sin(time * 12) > 0 ? 0 : 4) : Math.sin(time * 1.5) > 0 ? 0 : 4;

        robot.place(x, 104 + bob);

        if (sword.flipY) {
            sword.transform.x = x + 34;
            sword.transform.y = 106 + bob;
        }

        // A broken heart still beats: with the song, and faster once she is angry.
        robot.beat(state === 'singing' ? song.beat ?? 0 : time * (vengeful ? 3 : 0.8));

        // A tear from one eye and then the other, while she grieves.
        weep -= delta;

        const free = tears.find((one) => one.life <= 0);

        if (!vengeful && weep <= 0 && free) {
            weep = 0.45;
            eye = 1 - eye;
            free.life = 0.7;
            free.sprite.visible = true;
            free.sprite.transform.x = x - 12 + eye * 24;
            free.sprite.transform.y = robot.body.transform.y - 22;
        }

        for (const one of tears) {
            if (one.life <= 0) {
                continue;
            }

            one.life -= delta;
            one.sprite.transform.y += 70 * delta;

            if (one.life <= 0) {
                one.sprite.visible = false;
            }
        }

        for (const drop of drops) {
            drop.transform.y += 260 * delta;

            if (drop.transform.y > 170) {
                drop.transform.y -= 180;
            }
        }
    });

    return createScene();
};

// The dragon's lair: you, alive, by a fire with the dragon, your old friend, off on an adventure. She
// did not know. The dragon finds out first.
```

`scenes/serenade.ts`:

```ts
import { createScene, createSprite, createText, useGame, useKeyboard, useScene, useSound, useUpdate } from 'nacatamalon';
import { createPixelTexture } from 'nacatamalon/pixels';
import { fade } from 'nacatamalon/transitions';
import { renderSpeech } from 'nacatamalon/speech';
import { HER_VOICE } from '../audio/song';
import { SERENADE } from '../audio/serenade';

import { createBox, createBubble, createRobot } from '../components/stage';
import { CHEST, ROBOT, SOUL } from '../art/paint';
import { WHITE, YELLOW } from '../settings';
import { playOnce } from '../audio/play_once';

export const Serenade = () => {
    useGame().setBackground('#000000');

    const keys = useKeyboard();
    const scene = useScene();

    const { song, lyrics, over } = playOnce(SERENADE);

    const replies = {
        stay: {
            words: 'Yay! More toast, then.',
            sound: useSound(renderSpeech({
                ...HER_VOICE,
                phonemes: 'Y EY1 ! M AO1 R T OW1 S T , DH EH1 N .',
                pitch: 57,
                melody: 4
            }))
        },
        go: {
            words: 'Okay. Bring me a scale?',
            sound: useSound(renderSpeech({
                ...HER_VOICE,
                phonemes: 'OW0 K EY1 . B R IH1 NG M IY1 AH0 S K EY1 L ?',
                pitch: 55,
                melody: 4
            }))
        },
    };

    const robot = createRobot(createPixelTexture(ROBOT), createPixelTexture(CHEST));
    const { say } = createBubble();
    const tell = createBox();

    const choices = ['STAY', 'GO'].map((label, i) =>
        createText({
            text: label,
            style: {
                fontSize: 16
            },
            tint: WHITE,
            transform: {
                x: 124 + i * 160,
                y: 244
            },
            visible: false
        }));

    const soul = createSprite({
        texture: createPixelTexture(SOUL),
        width: 14,
        height: 12,
        transform: {
            x: 104,
            y: 251
        },
        visible: false
    });

    type TState = 'ready' | 'singing' | 'choose' | 'stay' | 'go';

    let state: TState = 'ready';
    let clock = 0;
    let choice = 0;

    const enter = (next: TState) => {
        state = next;
        clock = 0;
        say('');
        robot.mouth.visible = false;
        choices.forEach((text) => (text.visible = next === 'choose'));
        soul.visible = next === 'choose';

        if (next === 'ready') {
            tell('* ROBO-GF blocks', '  the door.', 'Z  LISTEN');
        }

        if (next === 'singing') {
            song.play();
            tell('* ROBO-GF sings.', '* Hear her out.', 'X  SKIP');
        }

        if (next === 'choose') {
            tell('* The dragon waits.', '', '← →  CHOOSE     Z  DECIDE');
        }

        if (next === 'stay' || next === 'go') {
            const reply = replies[next];

            reply.sound.play();
            say(reply.words);

            if (next === 'stay') {
                tell('* You drop the', '  sword. She glows.', 'R  AGAIN');
            } else {
                tell('* You go. She', '  waves goodbye.');
            }
        }
    };

    enter('ready');

    const pressed = (...names: string[]) => names.some((name) => keys.justPressed(name));

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

        if (state === 'ready' && pressed('z', 'Enter', ' ')) {
            enter('singing');
        } else if (state === 'singing') {
            const now = lyrics();

            say(now?.words ?? '', now?.whole);
            robot.mouth.visible = now?.singing ?? false;

            if ((now?.line ?? 0) >= SERENADE.chorus) {
                tell('* Her heart hums', '  at 110 volts.', 'X  SKIP');
            }

            if (over() || pressed('x')) {
                song.stop();
                enter('choose');
            }
        } else if (state === 'choose') {
            if (pressed('ArrowLeft')) {
                choice = 0;
            }

            if (pressed('ArrowRight')) {
                choice = 1;
            }

            soul.transform.x = 104 + choice * 160;
            choices.forEach((text, i) => (text.tint = i === choice ? YELLOW : WHITE));

            if (pressed('z', 'Enter', ' ')) {
                enter(choice === 0 ? 'stay' : 'go');
            }
        } else if (state === 'stay' && pressed('r')) {
            replies.stay.sound.stop();
            enter('ready');
        } else if (state === 'go' && clock > 4) {
            // You did not come back.
            scene.change('Mourning', {
                transition: fade(1.5)
            });
        }

        // She bobs a whole art pixel at a time, and her heart beats: with the song while she sings,
        // fast when you stay.
        robot.place(130, 104 + (Math.sin(time * 2.5) > 0 ? 0 : 4));
        robot.beat(state === 'singing' ? song.beat ?? 0 : state === 'stay' ? time * 4 : time * 1.2);
    });

    return createScene();
};

// Some days later, at your grave, in the rain: she sings why you had to go, and then that the dragon
// will pay for it.
```

`settings.ts`:

```ts
import { getColor } from 'nacatamalon';

export const WIDTH = 480;
export const HEIGHT = 320;
export const WHITE = getColor('#ffffff');
export const YELLOW = getColor('#f5c518');
export const RED = getColor('#ff2e4d');
```
