Saltar al contenido
GameGenresEnglish
Novela visual

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 completa

Una 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

  1. 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" } },
      ] },
    };
  2. 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 replay
  3. Siluetas

    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
    }
  4. 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)]);
  5. 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;
    });
  6. 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();
  7. 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");
// #endregion

Retos 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

  1. 1983
    OrigenThe 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)
  2. 1985
    The 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)
  3. 1992
    Otogirisō

    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)
  4. 1996

    Shizuku, de Leaf, es el primer juego comercial que se presenta como «visual novel».

    Visual novel (Wikipedia)
  5. 2001
    Phoenix 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)
  6. 2002
    Higurashi 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)
  7. 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)
  8. 2017
    Butterfly 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

Yuji Horii (Enix). NEC PC-6001, Famicom

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ō

Chunsoft. Super Famicom

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

Capcom (Shu Takumi). Game Boy Advance, Nintendo DS, Windows, iOS, Android

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

07th Expansion (Ryukishi07). Windows

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

NomnomNami. navegador, Windows, macOS, Linux, Android

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

Brianna Lei. Windows, macOS, Linux

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.

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.

Fuentes