Documentación · tutorial

Tu primer plugin

Una máquina de escribir que escribe un texto letra a letra, desde la plantilla de plugin hasta el .zip que puede usar cualquier juego.

Arrancando

Arriba, el juego de la demo con el plugin en un archivo al lado (use_typewriter.ts). Puedes cambiar el código y verlo al momento.

Un plugin es un paquete que importan los juegos de otros: un cuadro de diálogo que escribe, una barra de vida, una forma de arrastrar cosas. Se escribe como se escribe un juego, con los mismos hooks, y un juego lo usa como usa cualquier librería: importa lo que el plugin exporta y lo llama. No se configura nada del motor para él.

Esta página hace uno de principio a fin: una máquina de escribir que escribe un texto letra a letra, con una pausa para respirar después de cada punto. Es el juego de arriba: index.ts es el juego y use_typewriter.ts es el plugin entero.

Lo que hace falta

Node.js 20.19 o superior, para construir el plugin. Quien lo usa no lo necesita, y sus jugadores tampoco.

1. Empezar desde la plantilla

Con npm, en una línea. El nombre de la carpeta es el nombre del plugin:

npm create nacatamalon@latest typewriter -- --template plugin
cd typewriter
npm install

Sin pasar por la terminal, usa el botón Descargar la plantilla de arriba. Descomprímela, cambia el nombre de la carpeta si quieres y ponle nombre al plugin, una vez:

npm install
npm run rename -- typewriter

De las dos formas, el paquete ya es nacatamalon-typewriter y su hook es useTypewriter, en src/use_typewriter.ts. Esto es lo que hay en la carpeta:

src/index.ts             lo que importa un juego, y nada más
src/plugin.ts            el nombre del plugin y los motores que acepta, leídos del package.json
src/use_typewriter.ts    el hook: lo que hace el plugin
view/                    tu mesa de trabajo, para probar el plugin mientras lo escribes
demo/                    un juego pequeño que usa el plugin, su escaparate
tests/                   el plugin en un juego sin navegador
store/                   el icono y las capturas
PLUGIN_GUIDE.md          las reglas de un plugin; es para ti y no se toca

2. El hook

Sustituye lo que hay en src/use_typewriter.ts por la máquina de escribir:

import { useUpdate } from 'nacatamalon';
import type { TText } from 'nacatamalon';
import { PLUGIN_NAME, engineFits } from './plugin';
 
export type TTypewriterOptions = {
    /** Letters per second. Default 30. */
    speed?: number;
    /** Extra seconds after these characters. Default a breath after `.`, `!`, `?` and `,`. */
    pauses?: Readonly<Record<string, number>>;
};
 
export type TTypewriter = {
    /** Starts writing this, from nothing. */
    type(text: string): void;
    /** Shows the rest at once. */
    skip(): void;
    /** Whether it is still writing. */
    readonly typing: boolean;
};
 
const PAUSES = { '.': 0.3, '!': 0.3, '?': 0.3, ',': 0.12 };
 
/** Writes a text letter by letter, as a dialogue box does. */
export const useTypewriter = (text: TText, { speed = 30, pauses = PAUSES }: TTypewriterOptions = {}): TTypewriter => {
    if (!(speed > 0)) {
        throw new Error(`${PLUGIN_NAME}: useTypewriter got a speed of ${speed}. It is letters per second, above 0.`);
    }
    const writes = engineFits();
 
    let full = '';
    let shown = 0;
    let wait = 0;
    let typing = false;
 
    useUpdate((delta) => {
        if (!typing) return;
        while (wait <= 0 && shown < full.length) {
            wait += 1 / speed + (pauses[full[shown]] ?? 0);
            shown++;
        }
        wait -= delta;
        text.text = full.slice(0, shown);
        if (shown === full.length) typing = false;
    });
 
    return {
        type(said) {
            full = said;
            shown = writes ? 0 : said.length;
            wait = 0;
            typing = writes;
            text.text = writes ? '' : said;
        },
        skip() {
            shown = full.length;
            text.text = full;
            typing = false;
        },
        get typing() {
            return typing;
        },
    };
};

No hay un init, un update ni un destroy que aprender: el ciclo de vida son los hooks, los mismos que usa un juego. Un juego llama a useTypewriter dentro de un componente. Su cuerpo se ejecuta una vez, cuando se crea ese objeto. useUpdate se ejecuta cada fotograma mientras el objeto viva, se para cuando la escena está en pausa y se va cuando se va el objeto. Lo que guarda el hook (full, shown, wait) vive dentro de él, uno para cada texto que lo usa. Nunca en una variable arriba del archivo: una página puede tener varios juegos.

Tres costumbres que tiene todo plugin, todas en esas líneas:

3. Lo que recibe un juego

src/index.ts es la puerta del plugin: lo que exporta es todo lo que un juego puede importar.

export { useTypewriter } from './use_typewriter';
export type { TTypewriter, TTypewriterOptions } from './use_typewriter';

4. Probarlo

view/ es tu mesa de trabajo: una página tuya para ver el plugin funcionando mientras lo escribes. Nunca se empaqueta. Pon ahí cada caso que quieras ver al lado de los demás, también los raros. En view/main.ts, tres velocidades:

import { createGame, createScene, createText, getColor, useSpawn } from 'nacatamalon';
import { useTypewriter } from 'nacatamalon-typewriter';
 
const Case = ({ y, label, speed }: { y: number; label: string; speed: number }) => {
    createText({ text: label, style: { fontSize: 8 }, transform: { x: 8, y } });
    const line = createText({ text: '', style: { fontSize: 8 }, transform: { x: 80, y } });
    useTypewriter(line, { speed }).type('Halt! Who goes there?');
};
 
const View = () => {
    const spawn = useSpawn(Case, 'Case');
    spawn({ y: 40, label: 'speed 10', speed: 10 });
    spawn({ y: 80, label: 'speed 30', speed: 30 });
    spawn({ y: 120, label: 'speed 90', speed: 90 });
    return createScene();
};
 
createGame('#app', { width: 320, height: 180, background: getColor('#12102b'), scaling: 'integer' })({ View });
npm run dev

Importa el plugin por su nombre, nacatamalon-typewriter, como lo haría un juego. Se recarga cada vez que guardas.

5. Probarlo con tests

Los tests ejecutan el plugin en un juego sin navegador, con nacatamalon/headless: un fotograma cada vez, igual cada vez. Sustituye el test de tests/typewriter.test.ts:

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { createScene, createText } from 'nacatamalon';
import type { TText } from 'nacatamalon';
import { createHeadlessGame } from 'nacatamalon/headless';
import { useTypewriter } from '../src';
 
test('it writes a letter at a time, then the whole line', async () => {
    let line!: TText;
    const game = createHeadlessGame({
        width: 160,
        height: 90,
        scenes: {
            Level: () => {
                line = createText({ text: '' });
                useTypewriter(line, { speed: 30 }).type('Hello there');
                return createScene();
            },
        },
    });
    await game.loaded();
 
    game.run(1);
    assert.equal(line.text, 'H');
    game.run(10);
    assert.ok(line.text.length > 1 && line.text.length < 11, line.text);
    game.run(60);
    assert.equal(line.text, 'Hello there');
    game.destroy();
});
npm test
npm run typecheck

game.loaded() no ejecuta ningún fotograma: el primer game.run(1) es el primer fotograma del juego, y la primera letra ya está. Lo que dice un texto ahora se lee del propio texto, line.text.

6. La demo

demo/ es el escaparate del plugin: un juego pequeño que lo luce. Haz que demo/main.ts sea el juego de arriba de esta página, importando el plugin por su nombre en vez de desde el archivo de al lado:

import { useTypewriter } from 'nacatamalon-typewriter';

Deja las últimas líneas que trae la plantilla, las de dev_shots. Luego:

npm run demo

Mientras corre, F9 guarda el juego tal como está en store/shot-1.png y lo apunta en el package.json. Dibuja un icono tuyo para store/icon.png, de 256 por 256.

7. Empaquetarlo

Primero rellena el package.json: description, author, license y homepage, y bajo nacatamalon el summary, la category y los tags. Luego:

npm run pack

Construye dist/, comprueba el plugin y deja dos archivos en release/:

Solo se para por lo que rompería un juego: un import que no es una de las puertas públicas del motor, una dependencia que se deja para que la instale npm (un juego que descomprime el plugin no tiene npm, así que el build las mete dentro), un archivo que el paquete no lleva, o el motor dentro de dist/ (el juego trae el suyo, y dos copias serían dos motores que no se ven). Lo que solo necesita una ficha de tienda, como una captura, lo dice y lo deja pasar.

8. Usarlo en un juego

Descomprime el .zip en la carpeta plugins/ del juego, añade una línea al package.json del juego y ejecuta npm install:

"dependencies": {
    "nacatamalon-typewriter": "file:./plugins/nacatamalon-typewriter"
}

Desde entonces se importa por su nombre, como cualquier otra cosa:

import { createScene, createText } from 'nacatamalon';
import { useTypewriter } from 'nacatamalon-typewriter';
 
const Sign = () => {
    const line = createText({ text: '', transform: { x: 16, y: 80 } });
    useTypewriter(line, { speed: 20 }).type('Welcome to the village.');
    return createScene();
};

Sin tocar el package.json también se puede importar por su ruta: '../plugins/nacatamalon-typewriter/dist/index.js'. Si lo publicaste, npm install nacatamalon-typewriter.

Lo siguiente

Mira la demo de arriba mientras escribe la segunda línea: una palabra que no cabe empieza al final de la primera línea y salta a la siguiente según crece. Una máquina de escribir terminada calcula dónde se parten sus líneas antes de empezar (measureText da las líneas en las que se coloca un texto con maxWidth), así que cada palabra empieza en la línea en la que acaba. También puede avisar al juego de cada letra que aparece, para un sonido, y pararse y seguir.

El resto de lo que puede hacer un plugin está en la guía de plugins (el enlace a la guía completa de arriba, y guides/plugins.es.md dentro del motor): una función install…() para lo que es de todo el juego, lo que da nacatamalon/extend, trabajo propio en la GPU y tipos de dibujo que el motor no tiene.