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 installSin 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 -- typewriterDe 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:
- Comprueba el motor.
engineFits(), desrc/plugin.ts, pregunta si el motor que corre el juego es uno para el que se hizo este plugin, según el rango depeerDependenciesdelpackage.json(>=0.7.0 <1). Si no lo es, el motor lo dice una vez, en nombre del plugin, y el plugin decide qué hacer: este sigue enseñando el texto, todo de golpe. - Sus mensajes empiezan por su nombre.
PLUGIN_NAMEesnacatamalon-typewriter, así que quien lee la consola sabe quién habla. - Cada opción tiene un valor por defecto y un comentario de documentación: es lo que lee quien hace el juego cuando su editor de código le enseña la opción.
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 devImporta 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 typecheckgame.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 demoMientras 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 packConstruye dist/, comprueba el plugin y deja dos archivos en release/:
nacatamalon-typewriter-1.0.0.zip, para mandarlo a mano o subirlo a itch.io, a una página de versiones o a tu web.nacatamalon-typewriter-1.0.0.tgz, lo mismo para npm, si quieres publicarlo allí.
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.