
Novela visual
Lees una historia que avanza línea a línea en una caja de texto, sobre ilustraciones de personajes y fondos, y de vez en cuando eliges qué hacer o qué decir. Tus decisiones llevan la historia por ramas distintas hasta uno de varios finales.
También se llama: VN, sound novel
Mecánicas que lo definen
- El texto avanza a tu ritmo (con un clic o una tecla) y una etiqueta muestra el nombre de quien habla.
- Decisiones en momentos clave que ramifican la historia, a veces con variables que recuerdan lo que elegiste antes.
- Varios finales que invitan a volver a jugar para descubrir las otras ramas.
- Ayudas para leer, como guardar en cualquier momento, volver atrás o pasar rápido lo ya leído.
Bucle de juego
Lees una escena, llegas a una decisión, eliges y ves sus consecuencias. Al llegar a un final, vuelves a empezar para probar otra rama.
Demo propia
Controles. Clic, toque o espacio para avanzar el texto (el primer clic termina de escribir la línea). En las decisiones, haz clic en una opción o pulsa 1 o 2.
Abrir en pantalla completaUna novela visual corta en un barrio cualquiera. Ana busca a Bigotes, su gata, antes de que llegue la tormenta. Tiene tres decisiones y cuatro finales, y todo el guion está en una sola estructura de datos que puedes reescribir sin tocar el motor.
Hecha con Kaplay, en unas 186 líneas de JavaScript.
Cómo está hecha
El guion
Toda la historia está aquí, separada del motor. Cada escena tiene un fondo, los personajes que se ven y una lista de líneas de cuatro tipos: alguien habla, el jugador elige, se salta a otra escena o se llega a un final. Cada texto lleva su versión es y en, así que traducir es completar esas claves. Para escribir tu propia historia solo cambias esta región.
// ✏️ To write your own story, change ONLY this region. // Each scene has a background, the characters on screen and a list of lines: // { who, es, en } someone speaks (who: "narrator" shows no name) // { choose: [...] } the player chooses; each option says which scene to go to // { goto: "scene" } jumps to another scene without asking // { end: {es, en} } the story ends with that ending const CHARACTERS = { ana: { name: "Ana", shape: "girl", color: [235, 120, 90] }, leo: { name: "Leo", shape: "boy", color: [120, 150, 220] }, sam: { name: "Sam", shape: "adult", color: [190, 110, 200] }, cat: { name: { es: "Bigotes", en: "Whiskers" }, shape: "cat", color: [200, 200, 210] }, }; const SCRIPT = { start: { sky: [250, 190, 140], ground: [150, 110, 90], characters: ["ana", "leo"], lines: [ { who: "narrator", es: "Amanece en el barrio. Unas nubes oscuras se acercan.", en: "Morning in the neighbourhood. Dark clouds are gathering." }, { who: "ana", es: "Leo, ¿has visto a Bigotes? Anoche no volvió a casa.", en: "Leo, have you seen Whiskers? She didn't come home last night." }, { who: "leo", es: "No... y viene tormenta. Hay que encontrarla antes del mediodía.", en: "No... and a storm is coming. We must find her before noon." }, { who: "ana", es: "¡Pero si nunca se aleja!", en: "But she never wanders off!" }, { choose: [ { es: "Seguir las huellas hacia el parque", en: "Follow the paw prints to the park", goto: "park" }, { es: "Preguntar en la tienda de la esquina", en: "Ask at the corner shop", goto: "shop" }, ] }, ] }, park: { sky: [170, 160, 190], ground: [80, 120, 90], characters: ["ana"], lines: [ { who: "narrator", es: "Las huellas cruzan el césped mojado y de pronto se separan.", en: "The paw prints cross the wet grass and suddenly split." }, { who: "ana", es: "Unas van hacia el estanque; las otras, hacia el cobertizo viejo.", en: "Some go to the pond; the others, to the old shed." }, { choose: [ { es: "Ir al estanque", en: "Go to the pond", goto: "pond" }, { es: "Ir al cobertizo", en: "Go to the shed", goto: "shed" }, ] }, ] }, shop: { sky: [140, 200, 240], ground: [190, 150, 100], characters: ["ana", "sam"], lines: [ { who: "narrator", es: "La tienda huele a pan recién hecho y suena la radio.", en: "The shop smells of fresh bread and the radio is on." }, { who: "sam", es: "¿Una gata gris con collar rojo? La vi pasar hacia el estanque.", en: "A grey cat with a red collar? I saw her heading to the pond." }, { who: "ana", es: "¡Es Bigotes! Gracias, Sam.", en: "That's Whiskers! Thank you, Sam." }, { choose: [ { es: "Correr sola al estanque", en: "Run to the pond alone", goto: "pond" }, { es: "Volver a avisar a Leo", en: "Go back and tell Leo", goto: "together" }, ] }, ] }, pond: { sky: [150, 180, 200], ground: [80, 120, 110], characters: ["ana", "cat"], lines: [ { who: "narrator", es: "Junto al estanque, Bigotes mira a los patos como si nada.", en: "By the pond, Whiskers watches the ducks as if nothing happened." }, { who: "cat", es: "Miau.", en: "Meow." }, { choose: [ { es: "Agacharte y llamarla en voz baja", en: "Kneel down and call her softly", goto: "calm" }, { es: "Correr para atraparla", en: "Run to catch her", goto: "soaked" }, ] }, ] }, calm: { sky: [250, 200, 120], ground: [150, 110, 90], characters: ["ana", "cat"], lines: [ { who: "narrator", es: "Te agachas y la llamas despacio. Bigotes se frota contra tu pierna y la llevas a casa.", en: "You kneel and call her softly. Whiskers rubs against your leg and you carry her home." }, { end: { es: "En casa antes de la lluvia", en: "Home before the rain" } }, ] }, soaked: { sky: [90, 100, 120], ground: [60, 70, 80], characters: ["ana"], lines: [ { who: "narrator", es: "Bigotes se asusta y escapa. Empieza a llover fuerte.", en: "Whiskers gets scared and runs off. It starts to pour." }, { who: "ana", es: "Vuelvo a casa empapada... ¡y Bigotes ya duerme en el sofá!", en: "I get home soaked... and Whiskers is already asleep on the sofa!" }, { end: { es: "Empapada", en: "Soaked" } }, ] }, shed: { sky: [60, 55, 80], ground: [40, 35, 50], characters: ["ana"], lines: [ { who: "narrator", es: "El cobertizo está vacío. Afuera cae la tormenta y esperas a que pase.", en: "The shed is empty. The storm breaks outside and you wait it out." }, { end: { es: "Refugio en el cobertizo", en: "Shelter in the shed" } }, ] }, together: { sky: [250, 210, 150], ground: [150, 110, 90], characters: ["leo", "cat"], lines: [ { who: "narrator", es: "Vas con Leo al estanque y entre los dos traen a Bigotes a casa.", en: "You go to the pond with Leo and together you bring Whiskers home." }, { who: "leo", es: "Bien hecho, Ana. Preguntar también es buscar.", en: "Well done, Ana. Asking is also searching." }, { end: { es: "En equipo", en: "Teamwork" } }, ] }, };Configuración
Se crea el juego y se define el botón avanzar (espacio o Enter). El total de finales se cuenta solo, leyendo el guion, y un conjunto guarda los finales que ya viste entre partida y partida.
kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [20, 18, 30], buttons: { advance: { keyboard: ["space", "enter"] } }, }); const TEXT_SPEED = 45; // letters per second const TOTAL_ENDINGS = Object.values(SCRIPT).filter((s) => s.lines.some((l) => l.end)).length; const seenEndings = new Set(); // survives between playthroughs: an invitation to replaySiluetas
Cada personaje es una silueta hecha con rectángulos, círculos y polígonos. Quien no habla se dibuja más oscuro, un recurso simple para que se sepa de un vistazo quién tiene la palabra.
// Geometric silhouettes: each shape is a list of simple pieces. // Whoever is not speaking is drawn darker, so you can tell who has the floor. function drawCharacter(id, x, speaking) { const ch = CHARACTERS[id]; const c = speaking ? rgb(...ch.color) : rgb(...ch.color).darken(80); const d = (comps) => add([...comps, color(c), "scene"]); const floor = 240; if (ch.shape === "cat") { d([rect(90, 40), pos(x - 45, floor - 70)]); // body for (const dx of [-40, -22, 22, 36]) d([rect(8, 32), pos(x + dx, floor - 32)]); // legs d([circle(22), pos(x + 50, floor - 84)]); // head for (const dx of [36, 54]) d([polygon([vec2(0, 0), vec2(6, -16), vec2(12, 0)]), pos(x + dx, floor - 100)]); // ears d([rect(8, 60), pos(x - 52, floor - 120)]); // tail return; } const halfWidth = ch.shape === "adult" ? 50 : 40; if (ch.shape === "boy") d([rect(70, 120), pos(x - 35, floor - 120)]); else d([polygon([vec2(-halfWidth, 0), vec2(halfWidth, 0), vec2(22, -120), vec2(-22, -120)]), pos(x, floor)]); d([circle(26), pos(x, floor - 148)]); // head if (ch.shape === "boy") d([rect(56, 8), pos(x - 10, floor - 166)]), d([rect(50, 18), pos(x - 25, floor - 180)]); // cap if (ch.shape === "adult") d([rect(60, 10), pos(x - 30, floor - 100)]); // apron strings if (ch.shape === "girl") d([rect(8, 70), pos(x - 36, floor - 140)]), d([rect(8, 70), pos(x + 28, floor - 140)]); // pigtails }La caja de diálogo
La caja semitransparente con el nombre de quien habla encima es la seña visual del género. Este formato, con la caja abajo y la imagen visible, se llama ADV, y el que cubre casi toda la pantalla de texto se llama NVL.
// The dialogue box with the speaker's name: the visual heart of the genre. add([rect(608, 100, { radius: 8 }), pos(16, 246), color(0, 0, 0), opacity(0.75), z(10)]); const nameBox = add([rect(170, 28, { radius: 6 }), pos(28, 232), color(80, 80, 80), opacity(1), z(11)]); const nameText = add([text("", { size: 16, font: FONT }), pos(40, 237), z(12)]); const body = add([text("", { size: 17, font: FONT, width: 580, lineSpacing: 4 }), pos(30, 268), z(12)]); const prompt = add([text("▼", { size: 14, font: FONT }), pos(604, 326), z(12), opacity(0)]);El motor
El motor solo recuerda en qué escena y en qué línea estás, y decide qué hacer con cada línea. El texto aparece letra a letra; el primer clic lo completa y el segundo avanza. Ese detalle hace que leer se sienta cómodo.
// The engine only knows how to read the script: where we are (scene + line) and what to do with each line. let current = "start"; let index = 0; let fullText = ""; let letters = 0; let options = []; let finished = false; // go() changes scene on the next frame: ignore clicks until then function showScene() { destroyAll("scene"); const s = SCRIPT[current]; add([rect(640, 360), color(...s.sky), "scene"]); // The horizon: a row of buildings of different heights ([x, top, width]). for (const [x, top, w] of [[0, 150, 120], [120, 100, 140], [260, 130, 160], [420, 80, 120], [540, 140, 100]]) add([rect(w, 360 - top), pos(x, top), color(...s.ground), "scene"]); const line = s.lines[index]; const xs = s.characters.length === 1 ? [320] : [190, 450]; s.characters.forEach((id, i) => drawCharacter(id, xs[i], !line.who || line.who === id)); } function showLine() { const line = SCRIPT[current].lines[index]; if (line.goto) return goTo(line.goto); if (line.end) return (finished = true), go("end", line.end); showScene(); if (line.choose) return showOptions(line.choose); const ch = CHARACTERS[line.who]; nameText.text = ch ? t(typeof ch.name === "string" ? { es: ch.name } : ch.name) : ""; nameBox.opacity = ch ? 1 : 0; if (ch) nameBox.color = rgb(...ch.color).darken(60); fullText = t(line); letters = 0; } function goTo(target) { [current, index] = [target, 0]; showLine(); } function advance() { if (options.length || finished) return; if (letters < fullText.length) letters = fullText.length; // first click: finish writing else index++, showLine(); } // Typewriter effect: the text appears letter by letter. onUpdate(() => { letters = Math.min(fullText.length, letters + TEXT_SPEED * dt()); body.text = fullText.slice(0, Math.floor(letters)); prompt.opacity = !options.length && letters >= fullText.length ? 0.5 + 0.5 * Math.sin(time() * 6) : 0; });Decisiones y ramas
Una decisión pinta un botón por opción, y elegir salta a la escena que indica el guion. Un solo manejador de clic elige si hay opciones y avanza si no, para que un mismo clic no haga las dos cosas.
// A choice draws one button per option; choosing jumps to another scene of the script. function showOptions(list) { [fullText, letters, nameText.text, nameBox.opacity] = ["", 0, "", 0]; options = list.map((op, i) => { const b = add([rect(440, 40, { radius: 8 }), pos(320, 110 + i * 54), anchor("center"), color(60, 50, 90), outline(2, rgb(255, 255, 255)), area(), z(20), "option", { target: op.goto }]); b.add([text(`${i + 1}. ${t(op)}`, { size: 16, font: FONT }), anchor("center")]); return b; }); } function pick(button) { if (!button) return; destroyAll("option"); options = []; goTo(button.target); } // A single click handler: if there are options, it picks; otherwise it advances. onClick(() => (options.length ? pick(options.find((b) => b.isHovering())) : advance())); onButtonPress("advance", advance); onKeyPress(["1", "2", "3"], (key) => pick(options[Number(key) - 1])); onUpdate(() => options.forEach((b) => (b.color = b.isHovering() ? rgb(100, 80, 150) : rgb(60, 50, 90)))); showLine();Escenas
Título, juego y fin (title, game y end). La pantalla de fin dice cuántos finales encontraste de los que hay, una invitación a volver a jugar, que es clave en el género. Espera medio segundo antes de aceptar un clic para que no te la saltes sin querer.
function screen(title, subtitle, next) { add([text(title, { size: 32, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]); add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 34)), anchor("center"), color(200, 210, 255)]); // Half a second of waiting so a hasty click does not skip the screen. let ready = false; wait(0.5, () => (ready = true)); const goNext = () => ready && next(); onButtonPress("advance", goNext); onClick(goNext); } scene("title", () => screen(t({ es: "¿Dónde está Bigotes?", en: "Where is Whiskers?" }), t({ es: "Una novela visual corta. Haz clic o pulsa espacio para avanzar; elige con clic o con las teclas 1 y 2.\nHaz clic o pulsa espacio para empezar.", en: "A short visual novel. Click or press space to advance; choose by clicking or with keys 1 and 2.\nClick or press space to start.", }), () => go("game"))); scene("end", (ending) => { seenEndings.add(ending.es); screen(t({ es: "Final: ", en: "Ending: " }) + t(ending), t({ es: `Encontraste ${seenEndings.size} de ${TOTAL_ENDINGS} finales. Haz clic o pulsa espacio para empezar de nuevo.`, en: `You found ${seenEndings.size} of ${TOTAL_ENDINGS} endings. Click or press space to start again.` }), () => go("game")); }); go("title");
Qué simplificamos
- Los personajes son siluetas geométricas sin expresiones y los fondos son un cielo de color y una hilera de edificios.
- No hay música, voces, historial ni guardado. Tampoco variables que recuerden decisiones anteriores; cada opción solo salta a otra escena.
Para modificarla
- Escribe tu propia historia en la región script, con dos decisiones y tres finales. Dibuja antes el árbol en papel.
- Haz la historia bilingüe en castellano y en una lengua originaria. Añade una clave (por ejemplo ay para el aimara) a cada línea y cambia la función t para que la use.
- Añade una variable que recuerde una decisión (por ejemplo, si pasaste por la tienda) y úsala más adelante para mostrar una línea distinta.
Código completo
// Visual novel demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, pixelDensity } from "../common.js";
// #region script
// ✏️ To write your own story, change ONLY this region.
// Each scene has a background, the characters on screen and a list of lines:
// { who, es, en } someone speaks (who: "narrator" shows no name)
// { choose: [...] } the player chooses; each option says which scene to go to
// { goto: "scene" } jumps to another scene without asking
// { end: {es, en} } the story ends with that ending
const CHARACTERS = {
ana: { name: "Ana", shape: "girl", color: [235, 120, 90] },
leo: { name: "Leo", shape: "boy", color: [120, 150, 220] },
sam: { name: "Sam", shape: "adult", color: [190, 110, 200] },
cat: { name: { es: "Bigotes", en: "Whiskers" }, shape: "cat", color: [200, 200, 210] },
};
const SCRIPT = {
start: { sky: [250, 190, 140], ground: [150, 110, 90], characters: ["ana", "leo"], lines: [
{ who: "narrator", es: "Amanece en el barrio. Unas nubes oscuras se acercan.", en: "Morning in the neighbourhood. Dark clouds are gathering." },
{ who: "ana", es: "Leo, ¿has visto a Bigotes? Anoche no volvió a casa.", en: "Leo, have you seen Whiskers? She didn't come home last night." },
{ who: "leo", es: "No... y viene tormenta. Hay que encontrarla antes del mediodía.", en: "No... and a storm is coming. We must find her before noon." },
{ who: "ana", es: "¡Pero si nunca se aleja!", en: "But she never wanders off!" },
{ choose: [
{ es: "Seguir las huellas hacia el parque", en: "Follow the paw prints to the park", goto: "park" },
{ es: "Preguntar en la tienda de la esquina", en: "Ask at the corner shop", goto: "shop" },
] },
] },
park: { sky: [170, 160, 190], ground: [80, 120, 90], characters: ["ana"], lines: [
{ who: "narrator", es: "Las huellas cruzan el césped mojado y de pronto se separan.", en: "The paw prints cross the wet grass and suddenly split." },
{ who: "ana", es: "Unas van hacia el estanque; las otras, hacia el cobertizo viejo.", en: "Some go to the pond; the others, to the old shed." },
{ choose: [
{ es: "Ir al estanque", en: "Go to the pond", goto: "pond" },
{ es: "Ir al cobertizo", en: "Go to the shed", goto: "shed" },
] },
] },
shop: { sky: [140, 200, 240], ground: [190, 150, 100], characters: ["ana", "sam"], lines: [
{ who: "narrator", es: "La tienda huele a pan recién hecho y suena la radio.", en: "The shop smells of fresh bread and the radio is on." },
{ who: "sam", es: "¿Una gata gris con collar rojo? La vi pasar hacia el estanque.", en: "A grey cat with a red collar? I saw her heading to the pond." },
{ who: "ana", es: "¡Es Bigotes! Gracias, Sam.", en: "That's Whiskers! Thank you, Sam." },
{ choose: [
{ es: "Correr sola al estanque", en: "Run to the pond alone", goto: "pond" },
{ es: "Volver a avisar a Leo", en: "Go back and tell Leo", goto: "together" },
] },
] },
pond: { sky: [150, 180, 200], ground: [80, 120, 110], characters: ["ana", "cat"], lines: [
{ who: "narrator", es: "Junto al estanque, Bigotes mira a los patos como si nada.", en: "By the pond, Whiskers watches the ducks as if nothing happened." },
{ who: "cat", es: "Miau.", en: "Meow." },
{ choose: [
{ es: "Agacharte y llamarla en voz baja", en: "Kneel down and call her softly", goto: "calm" },
{ es: "Correr para atraparla", en: "Run to catch her", goto: "soaked" },
] },
] },
calm: { sky: [250, 200, 120], ground: [150, 110, 90], characters: ["ana", "cat"], lines: [
{ who: "narrator", es: "Te agachas y la llamas despacio. Bigotes se frota contra tu pierna y la llevas a casa.", en: "You kneel and call her softly. Whiskers rubs against your leg and you carry her home." },
{ end: { es: "En casa antes de la lluvia", en: "Home before the rain" } },
] },
soaked: { sky: [90, 100, 120], ground: [60, 70, 80], characters: ["ana"], lines: [
{ who: "narrator", es: "Bigotes se asusta y escapa. Empieza a llover fuerte.", en: "Whiskers gets scared and runs off. It starts to pour." },
{ who: "ana", es: "Vuelvo a casa empapada... ¡y Bigotes ya duerme en el sofá!", en: "I get home soaked... and Whiskers is already asleep on the sofa!" },
{ end: { es: "Empapada", en: "Soaked" } },
] },
shed: { sky: [60, 55, 80], ground: [40, 35, 50], characters: ["ana"], lines: [
{ who: "narrator", es: "El cobertizo está vacío. Afuera cae la tormenta y esperas a que pase.", en: "The shed is empty. The storm breaks outside and you wait it out." },
{ end: { es: "Refugio en el cobertizo", en: "Shelter in the shed" } },
] },
together: { sky: [250, 210, 150], ground: [150, 110, 90], characters: ["leo", "cat"], lines: [
{ who: "narrator", es: "Vas con Leo al estanque y entre los dos traen a Bigotes a casa.", en: "You go to the pond with Leo and together you bring Whiskers home." },
{ who: "leo", es: "Bien hecho, Ana. Preguntar también es buscar.", en: "Well done, Ana. Asking is also searching." },
{ end: { es: "En equipo", en: "Teamwork" } },
] },
};
// #endregion
// #region setup
kaplay({
width: 640,
height: 360,
letterbox: true, pixelDensity,
background: [20, 18, 30],
buttons: { advance: { keyboard: ["space", "enter"] } },
});
const TEXT_SPEED = 45; // letters per second
const TOTAL_ENDINGS = Object.values(SCRIPT).filter((s) => s.lines.some((l) => l.end)).length;
const seenEndings = new Set(); // survives between playthroughs: an invitation to replay
// #endregion
// #region characters
// Geometric silhouettes: each shape is a list of simple pieces.
// Whoever is not speaking is drawn darker, so you can tell who has the floor.
function drawCharacter(id, x, speaking) {
const ch = CHARACTERS[id];
const c = speaking ? rgb(...ch.color) : rgb(...ch.color).darken(80);
const d = (comps) => add([...comps, color(c), "scene"]);
const floor = 240;
if (ch.shape === "cat") {
d([rect(90, 40), pos(x - 45, floor - 70)]); // body
for (const dx of [-40, -22, 22, 36]) d([rect(8, 32), pos(x + dx, floor - 32)]); // legs
d([circle(22), pos(x + 50, floor - 84)]); // head
for (const dx of [36, 54]) d([polygon([vec2(0, 0), vec2(6, -16), vec2(12, 0)]), pos(x + dx, floor - 100)]); // ears
d([rect(8, 60), pos(x - 52, floor - 120)]); // tail
return;
}
const halfWidth = ch.shape === "adult" ? 50 : 40;
if (ch.shape === "boy") d([rect(70, 120), pos(x - 35, floor - 120)]);
else d([polygon([vec2(-halfWidth, 0), vec2(halfWidth, 0), vec2(22, -120), vec2(-22, -120)]), pos(x, floor)]);
d([circle(26), pos(x, floor - 148)]); // head
if (ch.shape === "boy") d([rect(56, 8), pos(x - 10, floor - 166)]), d([rect(50, 18), pos(x - 25, floor - 180)]); // cap
if (ch.shape === "adult") d([rect(60, 10), pos(x - 30, floor - 100)]); // apron strings
if (ch.shape === "girl") d([rect(8, 70), pos(x - 36, floor - 140)]), d([rect(8, 70), pos(x + 28, floor - 140)]); // pigtails
}
// #endregion
scene("game", () => {
// #region dialogue-box
// The dialogue box with the speaker's name: the visual heart of the genre.
add([rect(608, 100, { radius: 8 }), pos(16, 246), color(0, 0, 0), opacity(0.75), z(10)]);
const nameBox = add([rect(170, 28, { radius: 6 }), pos(28, 232), color(80, 80, 80), opacity(1), z(11)]);
const nameText = add([text("", { size: 16, font: FONT }), pos(40, 237), z(12)]);
const body = add([text("", { size: 17, font: FONT, width: 580, lineSpacing: 4 }), pos(30, 268), z(12)]);
const prompt = add([text("▼", { size: 14, font: FONT }), pos(604, 326), z(12), opacity(0)]);
// #endregion
// #region engine
// The engine only knows how to read the script: where we are (scene + line) and what to do with each line.
let current = "start";
let index = 0;
let fullText = "";
let letters = 0;
let options = [];
let finished = false; // go() changes scene on the next frame: ignore clicks until then
function showScene() {
destroyAll("scene");
const s = SCRIPT[current];
add([rect(640, 360), color(...s.sky), "scene"]);
// The horizon: a row of buildings of different heights ([x, top, width]).
for (const [x, top, w] of [[0, 150, 120], [120, 100, 140], [260, 130, 160], [420, 80, 120], [540, 140, 100]])
add([rect(w, 360 - top), pos(x, top), color(...s.ground), "scene"]);
const line = s.lines[index];
const xs = s.characters.length === 1 ? [320] : [190, 450];
s.characters.forEach((id, i) => drawCharacter(id, xs[i], !line.who || line.who === id));
}
function showLine() {
const line = SCRIPT[current].lines[index];
if (line.goto) return goTo(line.goto);
if (line.end) return (finished = true), go("end", line.end);
showScene();
if (line.choose) return showOptions(line.choose);
const ch = CHARACTERS[line.who];
nameText.text = ch ? t(typeof ch.name === "string" ? { es: ch.name } : ch.name) : "";
nameBox.opacity = ch ? 1 : 0;
if (ch) nameBox.color = rgb(...ch.color).darken(60);
fullText = t(line);
letters = 0;
}
function goTo(target) {
[current, index] = [target, 0];
showLine();
}
function advance() {
if (options.length || finished) return;
if (letters < fullText.length) letters = fullText.length; // first click: finish writing
else index++, showLine();
}
// Typewriter effect: the text appears letter by letter.
onUpdate(() => {
letters = Math.min(fullText.length, letters + TEXT_SPEED * dt());
body.text = fullText.slice(0, Math.floor(letters));
prompt.opacity = !options.length && letters >= fullText.length ? 0.5 + 0.5 * Math.sin(time() * 6) : 0;
});
// #endregion
// #region choices
// A choice draws one button per option; choosing jumps to another scene of the script.
function showOptions(list) {
[fullText, letters, nameText.text, nameBox.opacity] = ["", 0, "", 0];
options = list.map((op, i) => {
const b = add([rect(440, 40, { radius: 8 }), pos(320, 110 + i * 54), anchor("center"), color(60, 50, 90), outline(2, rgb(255, 255, 255)), area(), z(20), "option", { target: op.goto }]);
b.add([text(`${i + 1}. ${t(op)}`, { size: 16, font: FONT }), anchor("center")]);
return b;
});
}
function pick(button) {
if (!button) return;
destroyAll("option");
options = [];
goTo(button.target);
}
// A single click handler: if there are options, it picks; otherwise it advances.
onClick(() => (options.length ? pick(options.find((b) => b.isHovering())) : advance()));
onButtonPress("advance", advance);
onKeyPress(["1", "2", "3"], (key) => pick(options[Number(key) - 1]));
onUpdate(() => options.forEach((b) => (b.color = b.isHovering() ? rgb(100, 80, 150) : rgb(60, 50, 90))));
showLine();
// #endregion
});
// #region scenes
function screen(title, subtitle, next) {
add([text(title, { size: 32, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]);
add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 34)), anchor("center"), color(200, 210, 255)]);
// Half a second of waiting so a hasty click does not skip the screen.
let ready = false;
wait(0.5, () => (ready = true));
const goNext = () => ready && next();
onButtonPress("advance", goNext);
onClick(goNext);
}
scene("title", () => screen(t({ es: "¿Dónde está Bigotes?", en: "Where is Whiskers?" }),
t({
es: "Una novela visual corta. Haz clic o pulsa espacio para avanzar; elige con clic o con las teclas 1 y 2.\nHaz clic o pulsa espacio para empezar.",
en: "A short visual novel. Click or press space to advance; choose by clicking or with keys 1 and 2.\nClick or press space to start.",
}),
() => go("game")));
scene("end", (ending) => {
seenEndings.add(ending.es);
screen(t({ es: "Final: ", en: "Ending: " }) + t(ending),
t({ es: `Encontraste ${seenEndings.size} de ${TOTAL_ENDINGS} finales. Haz clic o pulsa espacio para empezar de nuevo.`,
en: `You found ${seenEndings.size} of ${TOTAL_ENDINGS} endings. Click or press space to start again.` }),
() => go("game"));
});
go("title");
// #endregionRetos de diseño
- Controlar la ramificación, que crece muy rápido. Lo habitual es que las ramas vuelvan a juntarse en escenas comunes para que el guion no se multiplique sin fin.
- Que cada decisión importe, o al menos se sienta importante, y que el jugador vea su efecto.
- Mantener el ritmo de lectura alternando diálogo, cambios de escena y decisiones, para que los bloques de texto no cansen.
- Separar el guion del motor. Así quien escribe no necesita programar, y traducir la historia a otra lengua es cambiar solo los textos.
- Probar todas las ramas, porque un final al que no se puede llegar o una escena sin salida son errores típicos.
Historia
- 1983OrigenThe Portopia Serial Murder Case
Portopia definió la aventura japonesa de la que nace el género. El nombre «novela visual» se usa desde mediados de los noventa.
Visual novel (Wikipedia) - 1985The Portopia Serial Murder Case
La versión de Portopia para Famicom cambia el intérprete de texto por un menú de órdenes que se eligen con el mando.
The Portopia Serial Murder Case (Wikipedia) - 1992Otogirisō
Otogirisō, de Chunsoft, reduce el juego a leer y elegir en las ramas de la historia, con sonido y varios finales. Lo llaman «sound novel».
Otogirisō (Wikipedia) - 1996
Shizuku, de Leaf, es el primer juego comercial que se presenta como «visual novel».
Visual novel (Wikipedia) - 2001Phoenix Wright: Ace Attorney
Phoenix Wright, de Capcom, mezcla la lectura con investigar y con juicios en los que buscas contradicciones. Su versión en inglés de 2005 acerca el género al público occidental.
Phoenix Wright: Ace Attorney (Wikipedia) - 2002Higurashi When They Cry
Higurashi, de un grupo aficionado (dōjin), se publica por capítulos y no tiene ninguna decisión. Muestra que una novela visual puede ser del todo lineal.
Visual novel (Wikipedia) - 2004
Se publica Ren'Py, un motor libre y de código abierto para novelas visuales cuyos guiones se escriben con una sintaxis parecida a la de un guion de cine.
Ren'Py (Wikipedia) - 2017Butterfly Soup
Butterfly Soup, de Brianna Lei, se publica a precio libre en itch.io y PC Gamer la elige mejor novela visual del año. Es una muestra de la novela visual independiente fuera de Japón.
Butterfly Soup (Wikipedia)
Juegos de referencia
1983
The Portopia Serial Murder Case
Investigas un asesinato en Kobe junto a tu ayudante Yasu, interrogando a sospechosos y visitando lugares. En la versión de Famicom (1985) el intérprete de texto se cambió por un menú de órdenes, el modelo de las aventuras japonesas y de la novela visual.
1992
Otogirisō
Una pareja se refugia en una mansión embrujada. Lees el relato sobre ilustraciones con música y efectos de sonido, y en momentos clave eliges qué hacer; cada final desbloquea opciones nuevas para la siguiente partida. Chunsoft lo llamó «sound novel».
2001
Phoenix Wright: Ace Attorney
Eres un abogado defensor novato. Alternas investigaciones, en las que reúnes pruebas, con juicios en los que interrogas a testigos y señalas la contradicción entre su declaración y una prueba.
2002
Higurashi When They Cry
Un misterio de asesinatos en la aldea japonesa de Hinamizawa, publicado por un grupo aficionado (dōjin) en capítulos. No tiene decisiones; el reto es leer y deducir qué pasa antes de los capítulos de respuesta.
2015
Syrup and the Ultimate Sweet
Syrup, una alquimista de dulces, encuentra un gólem de caramelo en su sótano. Novela visual corta de una jam, hecha con Ren'Py, con diez finales según tus decisiones y traducciones a muchos idiomas, entre ellos el español.
2017
Butterfly Soup
Sigues a cuatro chicas asiático-estadounidenses de un club de béisbol escolar en California. Es una novela visual independiente de humor y romance; su autora la ofrece a precio libre, incluso gratis.
2019
Adventures with Anxiety!
Juegas como la ansiedad de una persona, representada por un lobo, y eliges qué miedo le susurras en cada momento. Una historia interactiva corta sobre salud mental, liberada al dominio público.
Ejercicio
Antes de programar, dibuja en papel el árbol de tu historia, con dos decisiones y al menos tres finales. Luego escríbela en la región «script» de la demo sin tocar el motor. Puedes hacerla bilingüe, en castellano y en una lengua originaria, porque cada línea del guion ya guarda el texto en más de un idioma.