
Ficción interactiva
Aventura narrada en texto en la que escribes órdenes en lenguaje natural, como «abre la puerta» o «ve al norte», y el juego te describe lo que ocurre. El reto es explorar, entender el mundo a partir de las descripciones y resolver acertijos con los objetos que encuentras.
También se llama: aventura conversacional, aventura de texto, relato interactivo
Mecánicas que lo definen
- Un intérprete de órdenes (parser) que convierte lo que escribes en un verbo y unos objetos, y entiende sinónimos y palabras de relleno.
- Un mapa de habitaciones unidas por salidas con nombre de dirección (norte, sur, este, oeste, arriba, abajo).
- Objetos que se examinan, se recogen y se combinan para abrir el camino, guardados en un inventario.
- El texto como única salida, porque el narrador describe cada lugar y responde a cada acción, también a las que no se pueden hacer.
Bucle de juego
Leer la descripción, imaginar qué probar, escribir una orden y leer la respuesta, que da una pista, cambia el mundo o te deja donde estabas.
Demo propia
Controles. Escribe con el teclado y pulsa Enter. Órdenes útiles son mirar, ir norte (o solo «norte» o «n»), examinar nota, coger llave, abrir puerta, inventario y ayuda. En el móvil, toca la pantalla para que aparezca el teclado.
Abrir en pantalla completaUna aventura de texto de cinco habitaciones. Escribes órdenes como «ir norte» o «coger llave», el intérprete las entiende y el juego te cuenta lo que pasa. Una nota te dice dónde está la llave, la llave abre una puerta y detrás de la puerta está el cofre.
Hecha con Kaplay, en unas 194 líneas de JavaScript.
Cómo está hecha
Configuración
Se crea el juego con tamaño fijo y sin botones virtuales, porque todo se juega escribiendo. Las constantes marcan dónde va la transcripción y cuántas letras caben en una orden.
// A text adventure needs no buttons: the whole game is the keyboard. kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [16, 20, 28] }); const TEXT_SIZE = 15; const LOG_TOP = 40; // the transcript lives between LOG_TOP and LOG_BOTTOM const LOG_BOTTOM = 312; const MAX_INPUT = 40;El mundo como datos
Las habitaciones son un objeto con su nombre, su descripción, sus salidas (n, s, e, w) y las cosas que contiene. Las cosas tienen los sustantivos que las nombran y lo que ves al examinarlas. Como el código nunca nombra una habitación, puedes dibujar otro mapa cambiando solo estos datos.
// The world is DATA: rooms with exits and things. The code below never // mentions a room by name, so you can rewrite the map without touching it. const ROOMS = { hall: { name: { es: "Vestíbulo", en: "Hall" }, exits: { n: "library", e: "garden", w: "vault" }, things: ["door"], desc: { es: "Un vestíbulo silencioso, con el suelo de madera.", en: "A quiet hall with a wooden floor." } }, library: { name: { es: "Biblioteca", en: "Library" }, exits: { s: "hall" }, things: ["note"], desc: { es: "Estanterías hasta el techo. Hay una mesa con un papel encima.", en: "Shelves up to the ceiling. There is a table with a paper on it." } }, garden: { name: { es: "Jardín", en: "Garden" }, exits: { w: "hall", n: "shed" }, things: [], desc: { es: "Un jardín con la hierba alta. Al norte se ve un cobertizo.", en: "A garden with tall grass. A shed stands to the north." } }, shed: { name: { es: "Cobertizo", en: "Shed" }, exits: { s: "garden" }, things: ["key"], desc: { es: "Un cobertizo pequeño que huele a tierra.", en: "A small shed that smells of earth." } }, vault: { name: { es: "Cámara", en: "Vault" }, exits: { e: "hall" }, things: ["chest"], desc: { es: "Una sala sin ventanas. En el centro hay un cofre.", en: "A windowless room. A chest sits in the middle." } }, }; // Each thing has the nouns that name it, what you see when you examine it // and, optionally, a line that appears in the room description. const THINGS = { door: { nouns: { es: ["puerta"], en: ["door"] }, blocks: "w", look: () => (state.doorOpen ? { es: "La puerta está abierta.", en: "The door is open." } : { es: "Una puerta de roble, cerrada con llave.", en: "An oak door, locked." }), here: () => (state.doorOpen ? { es: "Al oeste, la puerta está abierta.", en: "To the west, the door stands open." } : { es: "Al oeste hay una puerta cerrada.", en: "There is a closed door to the west." }) }, note: { nouns: { es: ["nota", "papel"], en: ["note", "paper"] }, look: () => ({ es: "La nota dice: «Dejé la llave en el cobertizo».", en: "The note says: \"I left the key in the shed.\"" }) }, key: { nouns: { es: ["llave"], en: ["key"] }, portable: true, look: () => ({ es: "Una llave de hierro, pesada y fría.", en: "A heavy, cold iron key." }), here: () => ({ es: "Hay una llave en el suelo.", en: "There is a key on the floor." }) }, chest: { nouns: { es: ["cofre", "caja"], en: ["chest", "box"] }, look: () => ({ es: "Un cofre cerrado, sin cerradura.", en: "A closed chest with no lock." }) }, }; let state; // reset at the start of every game (scene "game")El intérprete de órdenes
Es la pieza que define el género. Convierte una frase escrita en una orden con verbo, dirección y cosas. Primero pasa todo a minúsculas y quita tildes y signos, así «¡Mirar!» y «mirar» son lo mismo. Luego descarta las palabras de relleno (el, la, con, the, to…) y busca cada palabra en un diccionario. Los sinónimos son solo varias palabras para el mismo verbo: «coger», «toma» y «agarra» llevan a la misma acción. Una dirección sola, como «norte», se entiende como «ir norte».
// The parser turns a sentence into { verb, dir, things }. Steps: lowercase, // strip accents and punctuation, drop filler words and look each word up in // a dictionary. Synonyms are just several words for the same verb. const WORDS = { es: { look: ["mirar", "mira", "miro", "m", "ver", "examinar", "examina", "x", "leer", "lee"], go: ["ir", "ve", "voy", "vete", "andar", "anda", "camina", "caminar", "entrar", "entra"], take: ["coger", "coge", "cojo", "tomar", "toma", "agarrar", "agarra", "recoger", "recoge"], open: ["abrir", "abre", "abro", "usar", "usa", "uso"], inventory: ["inventario", "inv", "i"], help: ["ayuda"], }, en: { look: ["look", "l", "examine", "x", "inspect", "read", "check"], go: ["go", "walk", "move", "run", "enter"], take: ["take", "get", "grab", "pick", "carry"], open: ["open", "unlock", "use"], inventory: ["inventory", "inv", "i"], help: ["help"], }, }; const DIRS = { es: { norte: "n", n: "n", sur: "s", s: "s", este: "e", e: "e", oeste: "w", o: "w" }, en: { north: "n", n: "n", south: "s", s: "s", east: "e", e: "e", west: "w", w: "w" }, }; const DIR_NAMES = { es: { n: "norte", s: "sur", e: "este", w: "oeste" }, en: { n: "north", s: "south", e: "east", w: "west" } }; const FILLER = { es: ["el", "la", "los", "las", "un", "una", "al", "a", "de", "del", "hacia", "con", "en", "por", "mi"], en: ["the", "a", "an", "to", "at", "with", "on", "up", "in", "into", "my", "around"], }; function parse(sentence) { const words = sentence.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "") .replace(/[^a-z0-9 ]/g, " ").split(" ").filter((w) => w && !FILLER[lang].includes(w)); const cmd = { verb: null, dir: null, things: [] }; for (const w of words) { const verb = Object.keys(WORDS[lang]).find((v) => WORDS[lang][v].includes(w)); const thing = Object.keys(THINGS).find((id) => THINGS[id].nouns[lang].includes(w)); if (verb && !cmd.verb) cmd.verb = verb; else if (DIRS[lang][w]) cmd.dir = DIRS[lang][w]; else if (thing) cmd.things.push(thing); else if (!cmd.verb) cmd.verb = "?" + w; // an unknown first word } if (!cmd.verb && cmd.dir) cmd.verb = "go"; // "north" alone means "go north" return cmd; }Las acciones
Cada verbo es una función pequeña que mira el estado, lo cambia si hace falta y devuelve la respuesta del juego. Solo cuentan las cosas que están en la habitación o en tu inventario. La puerta bloquea la salida oeste hasta que la abres con la llave, y abrir el cofre gana la partida. Las respuestas a lo que no se puede hacer («No ves eso aquí», «Está cerrada con llave») son tan importantes como las que sí, porque son las que orientan al jugador.
// Each verb is a small function that reads and changes the state and returns // what the game answers. Only things that are here or in your pocket count. const here = () => state.things[state.room]; const visible = (id) => here().includes(id) || state.inventory.includes(id); function describeRoom() { const room = ROOMS[state.room]; const extras = here().map((id) => THINGS[id].here?.()).filter(Boolean).map(t); const exits = Object.keys(room.exits).map((d) => DIR_NAMES[lang][d]).join(", "); return [`[title]${t(room.name)}[/title]`, t(room.desc), ...extras, t({ es: "Salidas: ", en: "Exits: " }) + exits].join("\n"); } const ACTIONS = { look: ({ things }) => (things.length ? (visible(things[0]) ? t(THINGS[things[0]].look()) : t({ es: "No ves eso aquí.", en: "You can't see that here." })) : describeRoom()), go: ({ dir }) => { const room = ROOMS[state.room]; if (!dir) return t({ es: "¿Hacia dónde? (norte, sur, este, oeste)", en: "Which way? (north, south, east, west)" }); if (!room.exits[dir]) return t({ es: "No puedes ir por ahí.", en: "You can't go that way." }); if (here().some((id) => THINGS[id].blocks === dir) && !state.doorOpen) return t({ es: "La puerta está cerrada con llave.", en: "The door is locked." }); state.room = room.exits[dir]; return describeRoom(); }, take: ({ things }) => { const id = things[0]; if (!id) return t({ es: "¿Coger qué?", en: "Take what?" }); if (state.inventory.includes(id)) return t({ es: "Ya lo llevas.", en: "You already have it." }); if (!visible(id)) return t({ es: "No ves eso aquí.", en: "You can't see that here." }); if (!THINGS[id].portable) return t({ es: "No puedes llevarte eso.", en: "You can't carry that." }); state.things[state.room] = here().filter((x) => x !== id); state.inventory.push(id); return t({ es: "Cogido.", en: "Taken." }); }, open: ({ things }) => { const id = things.find((x) => x === "door" || x === "chest"); // "use key on door" also works if (!id) return t({ es: "¿Abrir qué?", en: "Open what?" }); if (!visible(id)) return t({ es: "No ves eso aquí.", en: "You can't see that here." }); if (id === "chest") return (state.won = true), t({ es: "Levantas la tapa: ¡el cofre está lleno de monedas!", en: "You lift the lid: the chest is full of coins!" }); if (state.doorOpen) return t({ es: "Ya está abierta.", en: "It's already open." }); if (!state.inventory.includes("key")) return t({ es: "Está cerrada con llave.", en: "It's locked." }); state.doorOpen = true; return t({ es: "Giras la llave y la puerta se abre.", en: "You turn the key and the door opens." }); }, inventory: () => (state.inventory.length ? t({ es: "Llevas: ", en: "You carry: " }) + state.inventory.map((id) => THINGS[id].nouns[lang][0]).join(", ") : t({ es: "No llevas nada.", en: "You carry nothing." })), help: () => t({ es: "Prueba: mirar, ir norte, coger llave, abrir puerta, inventario.", en: "Try: look, go north, take key, open door, inventory." }), }; function run(sentence) { const cmd = parse(sentence); if (!cmd.verb) return ""; state.moves++; if (cmd.verb.startsWith("?")) return t({ es: `No sé qué es «${cmd.verb.slice(1)}». Escribe «ayuda».`, en: `I don't know the word "${cmd.verb.slice(1)}". Type "help".` }); return ACTIONS[cmd.verb](cmd); }Escribir en el móvil
Un teléfono solo muestra su teclado cuando hay un campo de texto de verdad. Por eso hay un <input> invisible que recibe el foco al tocar la pantalla y copia lo que escribes a la línea del juego. El campo atiende sus propias teclas, así que el teclado no se cierra con cada letra.
// Phones only open their keyboard for a real text field, so an invisible // <input> catches what is typed there and copies it to the game's line. const field = document.createElement("input"); Object.assign(field, { autocomplete: "off", autocapitalize: "none", spellcheck: false, enterKeyHint: "send" }); field.style.cssText = "position:fixed;left:0;bottom:0;width:1px;height:1px;opacity:0;border:0;font-size:16px"; document.body.append(field); let typed = ""; // the line being written, from the physical keyboard or from the field let onEnter = () => {}; // what Enter does in the current scene field.addEventListener("input", () => (typed = field.value.slice(0, MAX_INPUT))); field.addEventListener("keydown", (e) => { e.stopPropagation(); // the field handles its own keys if (e.key === "Enter") onEnter(); }); if (matchMedia("(pointer: coarse)").matches) document.addEventListener("pointerdown", () => field.focus());La transcripción
Todo lo que se dice queda en un solo texto, con tu orden en azul y el nombre de cada habitación en dorado (con las etiquetas de estilo de Kaplay). Cuando ya no cabe, se borran los párrafos más antiguos, como en una terminal que se desplaza. Arriba se ven la habitación actual y los turnos.
// The transcript is one text with everything said so far. When it no // longer fits, the oldest paragraphs are dropped, like a scrolling terminal. const styles = { title: { color: rgb(255, 210, 120) }, cmd: { color: rgb(130, 200, 255) } }; const log = []; const logText = add([text("", { size: TEXT_SIZE, font: FONT, width: 600, lineSpacing: 3, styles }), pos(20, LOG_TOP)]); function say(paragraph) { log.push(paragraph); const opts = { size: TEXT_SIZE, font: FONT, width: 600, lineSpacing: 3, styles }; while (log.length > 1 && formatText({ ...opts, text: log.join("\n\n") }).height > LOG_BOTTOM - LOG_TOP) log.shift(); logText.text = log.join("\n\n"); } const hud = add([text("", { size: 13, font: FONT }), pos(20, 14), color(150, 160, 180)]); add([rect(600, 1), pos(20, 34), color(60, 70, 90)]); add([rect(600, 1), pos(20, LOG_BOTTOM + 6), color(60, 70, 90)]); const prompt = add([text("", { size: TEXT_SIZE + 1, font: FONT }), pos(20, LOG_BOTTOM + 16), color(130, 200, 255)]); onUpdate(() => { hud.text = `${t(ROOMS[state.room].name)} · ${t({ es: "Turnos", en: "Moves" })}: ${state.moves}`; prompt.text = "> " + typed + (time() % 1 < 0.5 ? "_" : ""); }); say(describeRoom());La línea de órdenes
onCharInput recibe cada letra del teclado físico y Retroceso borra la última. Enter envía la frase al intérprete, la repite en la transcripción y muestra la respuesta. Cuando abres el cofre, el juego espera un momento y pasa a la escena final.
// The physical keyboard writes letter by letter; Enter sends the sentence. const submit = () => { const sentence = typed.trim(); typed = field.value = ""; if (!sentence || state.won) return; say(`[cmd]> ${sentence.replace(/[[\]\\]/g, "")}[/cmd]\n` + run(sentence)); if (state.won) wait(2.5, () => go("end", state.moves)); }; onEnter = submit; onCharInput((ch) => { if (typed.length < MAX_INPUT && (typed || ch !== " ")) typed += ch; field.value = typed; }); onKeyPressRepeat("backspace", () => (field.value = typed = typed.slice(0, -1))); onKeyPress("enter", submit);Escenas
Hay tres escenas (title, game y end). Cada partida nueva vuelve a crear el estado a partir de los datos del mundo, así que la llave vuelve a su sitio sin limpiar nada a mano.
function screen(title, subtitle) { add([text(title, { size: 32, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]); add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560, lineSpacing: 4 }), pos(center().add(0, 30)), anchor("center"), color(190, 200, 230)]); onEnter = () => go("game"); onKeyPress(["enter", "space"], onEnter); onClick(onEnter); } scene("title", () => screen( t({ es: "Ficción interactiva", en: "Interactive fiction" }), t({ es: "Escribe órdenes y pulsa Enter: «mirar», «ir norte», «coger llave», «abrir puerta», «inventario».\nEncuentra lo que guarda la casa.\n\nPulsa Enter o toca para empezar.", en: "Type commands and press Enter: \"look\", \"go north\", \"take key\", \"open door\", \"inventory\".\nFind what the house is hiding.\n\nPress Enter or tap to start.", }), ), ); scene("end", (moves) => screen( t({ es: "¡Encontraste el tesoro!", en: "You found the treasure!" }), t({ es: `Lo lograste en ${moves} turnos. Pulsa Enter para jugar otra vez.`, en: `You did it in ${moves} moves. Press Enter to play again.` }), ), ); go("title");
Qué simplificamos
- El intérprete solo entiende un verbo, una dirección y los nombres de las cosas. Ignora el resto de palabras, así que «abre la puerta con la llave, por favor» funciona, pero no distingue frases con dos objetos como «pon la llave en el cofre».
- Solo hay cinco verbos (mirar, ir, coger, abrir e inventario) y no se pueden soltar objetos ni guardar la partida.
- Cada idioma tiene su propio diccionario, así que la versión en español entiende órdenes en español y la versión en inglés, órdenes en inglés.
Para modificarla
- Añade una sexta habitación al mapa y un objeto nuevo que haga falta para entrar en ella (por ejemplo, una linterna para un sótano oscuro).
- Agrega el verbo «soltar» (drop) para dejar objetos en la habitación donde estás.
- Haz que el intérprete acepte «coger todo» o «take all» y recoja todos los objetos portátiles de la habitación.
- Juega una partida con un compañero que no conozca el mapa y anota cada orden que el juego no entendió. Usa esa lista para añadir sinónimos.
Código completo
// Interactive fiction demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { lang, t, FONT, pixelDensity } from "../common.js";
// #region setup
// A text adventure needs no buttons: the whole game is the keyboard.
kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [16, 20, 28] });
const TEXT_SIZE = 15;
const LOG_TOP = 40; // the transcript lives between LOG_TOP and LOG_BOTTOM
const LOG_BOTTOM = 312;
const MAX_INPUT = 40;
// #endregion
// #region world
// The world is DATA: rooms with exits and things. The code below never
// mentions a room by name, so you can rewrite the map without touching it.
const ROOMS = {
hall: { name: { es: "Vestíbulo", en: "Hall" }, exits: { n: "library", e: "garden", w: "vault" }, things: ["door"],
desc: { es: "Un vestíbulo silencioso, con el suelo de madera.", en: "A quiet hall with a wooden floor." } },
library: { name: { es: "Biblioteca", en: "Library" }, exits: { s: "hall" }, things: ["note"],
desc: { es: "Estanterías hasta el techo. Hay una mesa con un papel encima.", en: "Shelves up to the ceiling. There is a table with a paper on it." } },
garden: { name: { es: "Jardín", en: "Garden" }, exits: { w: "hall", n: "shed" }, things: [],
desc: { es: "Un jardín con la hierba alta. Al norte se ve un cobertizo.", en: "A garden with tall grass. A shed stands to the north." } },
shed: { name: { es: "Cobertizo", en: "Shed" }, exits: { s: "garden" }, things: ["key"],
desc: { es: "Un cobertizo pequeño que huele a tierra.", en: "A small shed that smells of earth." } },
vault: { name: { es: "Cámara", en: "Vault" }, exits: { e: "hall" }, things: ["chest"],
desc: { es: "Una sala sin ventanas. En el centro hay un cofre.", en: "A windowless room. A chest sits in the middle." } },
};
// Each thing has the nouns that name it, what you see when you examine it
// and, optionally, a line that appears in the room description.
const THINGS = {
door: { nouns: { es: ["puerta"], en: ["door"] }, blocks: "w",
look: () => (state.doorOpen ? { es: "La puerta está abierta.", en: "The door is open." } : { es: "Una puerta de roble, cerrada con llave.", en: "An oak door, locked." }),
here: () => (state.doorOpen ? { es: "Al oeste, la puerta está abierta.", en: "To the west, the door stands open." } : { es: "Al oeste hay una puerta cerrada.", en: "There is a closed door to the west." }) },
note: { nouns: { es: ["nota", "papel"], en: ["note", "paper"] },
look: () => ({ es: "La nota dice: «Dejé la llave en el cobertizo».", en: "The note says: \"I left the key in the shed.\"" }) },
key: { nouns: { es: ["llave"], en: ["key"] }, portable: true,
look: () => ({ es: "Una llave de hierro, pesada y fría.", en: "A heavy, cold iron key." }),
here: () => ({ es: "Hay una llave en el suelo.", en: "There is a key on the floor." }) },
chest: { nouns: { es: ["cofre", "caja"], en: ["chest", "box"] },
look: () => ({ es: "Un cofre cerrado, sin cerradura.", en: "A closed chest with no lock." }) },
};
let state; // reset at the start of every game (scene "game")
// #endregion
// #region parser
// The parser turns a sentence into { verb, dir, things }. Steps: lowercase,
// strip accents and punctuation, drop filler words and look each word up in
// a dictionary. Synonyms are just several words for the same verb.
const WORDS = {
es: {
look: ["mirar", "mira", "miro", "m", "ver", "examinar", "examina", "x", "leer", "lee"],
go: ["ir", "ve", "voy", "vete", "andar", "anda", "camina", "caminar", "entrar", "entra"],
take: ["coger", "coge", "cojo", "tomar", "toma", "agarrar", "agarra", "recoger", "recoge"],
open: ["abrir", "abre", "abro", "usar", "usa", "uso"],
inventory: ["inventario", "inv", "i"],
help: ["ayuda"],
},
en: {
look: ["look", "l", "examine", "x", "inspect", "read", "check"],
go: ["go", "walk", "move", "run", "enter"],
take: ["take", "get", "grab", "pick", "carry"],
open: ["open", "unlock", "use"],
inventory: ["inventory", "inv", "i"],
help: ["help"],
},
};
const DIRS = {
es: { norte: "n", n: "n", sur: "s", s: "s", este: "e", e: "e", oeste: "w", o: "w" },
en: { north: "n", n: "n", south: "s", s: "s", east: "e", e: "e", west: "w", w: "w" },
};
const DIR_NAMES = { es: { n: "norte", s: "sur", e: "este", w: "oeste" }, en: { n: "north", s: "south", e: "east", w: "west" } };
const FILLER = {
es: ["el", "la", "los", "las", "un", "una", "al", "a", "de", "del", "hacia", "con", "en", "por", "mi"],
en: ["the", "a", "an", "to", "at", "with", "on", "up", "in", "into", "my", "around"],
};
function parse(sentence) {
const words = sentence.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9 ]/g, " ").split(" ").filter((w) => w && !FILLER[lang].includes(w));
const cmd = { verb: null, dir: null, things: [] };
for (const w of words) {
const verb = Object.keys(WORDS[lang]).find((v) => WORDS[lang][v].includes(w));
const thing = Object.keys(THINGS).find((id) => THINGS[id].nouns[lang].includes(w));
if (verb && !cmd.verb) cmd.verb = verb;
else if (DIRS[lang][w]) cmd.dir = DIRS[lang][w];
else if (thing) cmd.things.push(thing);
else if (!cmd.verb) cmd.verb = "?" + w; // an unknown first word
}
if (!cmd.verb && cmd.dir) cmd.verb = "go"; // "north" alone means "go north"
return cmd;
}
// #endregion
// #region actions
// Each verb is a small function that reads and changes the state and returns
// what the game answers. Only things that are here or in your pocket count.
const here = () => state.things[state.room];
const visible = (id) => here().includes(id) || state.inventory.includes(id);
function describeRoom() {
const room = ROOMS[state.room];
const extras = here().map((id) => THINGS[id].here?.()).filter(Boolean).map(t);
const exits = Object.keys(room.exits).map((d) => DIR_NAMES[lang][d]).join(", ");
return [`[title]${t(room.name)}[/title]`, t(room.desc), ...extras, t({ es: "Salidas: ", en: "Exits: " }) + exits].join("\n");
}
const ACTIONS = {
look: ({ things }) => (things.length ? (visible(things[0]) ? t(THINGS[things[0]].look()) : t({ es: "No ves eso aquí.", en: "You can't see that here." })) : describeRoom()),
go: ({ dir }) => {
const room = ROOMS[state.room];
if (!dir) return t({ es: "¿Hacia dónde? (norte, sur, este, oeste)", en: "Which way? (north, south, east, west)" });
if (!room.exits[dir]) return t({ es: "No puedes ir por ahí.", en: "You can't go that way." });
if (here().some((id) => THINGS[id].blocks === dir) && !state.doorOpen) return t({ es: "La puerta está cerrada con llave.", en: "The door is locked." });
state.room = room.exits[dir];
return describeRoom();
},
take: ({ things }) => {
const id = things[0];
if (!id) return t({ es: "¿Coger qué?", en: "Take what?" });
if (state.inventory.includes(id)) return t({ es: "Ya lo llevas.", en: "You already have it." });
if (!visible(id)) return t({ es: "No ves eso aquí.", en: "You can't see that here." });
if (!THINGS[id].portable) return t({ es: "No puedes llevarte eso.", en: "You can't carry that." });
state.things[state.room] = here().filter((x) => x !== id);
state.inventory.push(id);
return t({ es: "Cogido.", en: "Taken." });
},
open: ({ things }) => {
const id = things.find((x) => x === "door" || x === "chest"); // "use key on door" also works
if (!id) return t({ es: "¿Abrir qué?", en: "Open what?" });
if (!visible(id)) return t({ es: "No ves eso aquí.", en: "You can't see that here." });
if (id === "chest") return (state.won = true), t({ es: "Levantas la tapa: ¡el cofre está lleno de monedas!", en: "You lift the lid: the chest is full of coins!" });
if (state.doorOpen) return t({ es: "Ya está abierta.", en: "It's already open." });
if (!state.inventory.includes("key")) return t({ es: "Está cerrada con llave.", en: "It's locked." });
state.doorOpen = true;
return t({ es: "Giras la llave y la puerta se abre.", en: "You turn the key and the door opens." });
},
inventory: () => (state.inventory.length ? t({ es: "Llevas: ", en: "You carry: " }) + state.inventory.map((id) => THINGS[id].nouns[lang][0]).join(", ") : t({ es: "No llevas nada.", en: "You carry nothing." })),
help: () => t({ es: "Prueba: mirar, ir norte, coger llave, abrir puerta, inventario.", en: "Try: look, go north, take key, open door, inventory." }),
};
function run(sentence) {
const cmd = parse(sentence);
if (!cmd.verb) return "";
state.moves++;
if (cmd.verb.startsWith("?")) return t({ es: `No sé qué es «${cmd.verb.slice(1)}». Escribe «ayuda».`, en: `I don't know the word "${cmd.verb.slice(1)}". Type "help".` });
return ACTIONS[cmd.verb](cmd);
}
// #endregion
// #region touch-typing
// Phones only open their keyboard for a real text field, so an invisible
// <input> catches what is typed there and copies it to the game's line.
const field = document.createElement("input");
Object.assign(field, { autocomplete: "off", autocapitalize: "none", spellcheck: false, enterKeyHint: "send" });
field.style.cssText = "position:fixed;left:0;bottom:0;width:1px;height:1px;opacity:0;border:0;font-size:16px";
document.body.append(field);
let typed = ""; // the line being written, from the physical keyboard or from the field
let onEnter = () => {}; // what Enter does in the current scene
field.addEventListener("input", () => (typed = field.value.slice(0, MAX_INPUT)));
field.addEventListener("keydown", (e) => {
e.stopPropagation(); // the field handles its own keys
if (e.key === "Enter") onEnter();
});
if (matchMedia("(pointer: coarse)").matches) document.addEventListener("pointerdown", () => field.focus());
// #endregion
scene("game", () => {
// The state is everything that can change; ROOMS keeps the starting layout.
const things = Object.fromEntries(Object.entries(ROOMS).map(([id, r]) => [id, [...r.things]]));
state = { room: "hall", things, inventory: [], doorOpen: false, moves: 0, won: false };
typed = field.value = "";
// #region transcript
// The transcript is one text with everything said so far. When it no
// longer fits, the oldest paragraphs are dropped, like a scrolling terminal.
const styles = { title: { color: rgb(255, 210, 120) }, cmd: { color: rgb(130, 200, 255) } };
const log = [];
const logText = add([text("", { size: TEXT_SIZE, font: FONT, width: 600, lineSpacing: 3, styles }), pos(20, LOG_TOP)]);
function say(paragraph) {
log.push(paragraph);
const opts = { size: TEXT_SIZE, font: FONT, width: 600, lineSpacing: 3, styles };
while (log.length > 1 && formatText({ ...opts, text: log.join("\n\n") }).height > LOG_BOTTOM - LOG_TOP) log.shift();
logText.text = log.join("\n\n");
}
const hud = add([text("", { size: 13, font: FONT }), pos(20, 14), color(150, 160, 180)]);
add([rect(600, 1), pos(20, 34), color(60, 70, 90)]);
add([rect(600, 1), pos(20, LOG_BOTTOM + 6), color(60, 70, 90)]);
const prompt = add([text("", { size: TEXT_SIZE + 1, font: FONT }), pos(20, LOG_BOTTOM + 16), color(130, 200, 255)]);
onUpdate(() => {
hud.text = `${t(ROOMS[state.room].name)} · ${t({ es: "Turnos", en: "Moves" })}: ${state.moves}`;
prompt.text = "> " + typed + (time() % 1 < 0.5 ? "_" : "");
});
say(describeRoom());
// #endregion
// #region input
// The physical keyboard writes letter by letter; Enter sends the sentence.
const submit = () => {
const sentence = typed.trim();
typed = field.value = "";
if (!sentence || state.won) return;
say(`[cmd]> ${sentence.replace(/[[\]\\]/g, "")}[/cmd]\n` + run(sentence));
if (state.won) wait(2.5, () => go("end", state.moves));
};
onEnter = submit;
onCharInput((ch) => {
if (typed.length < MAX_INPUT && (typed || ch !== " ")) typed += ch;
field.value = typed;
});
onKeyPressRepeat("backspace", () => (field.value = typed = typed.slice(0, -1)));
onKeyPress("enter", submit);
// #endregion
});
// #region scenes
function screen(title, subtitle) {
add([text(title, { size: 32, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]);
add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560, lineSpacing: 4 }), pos(center().add(0, 30)), anchor("center"), color(190, 200, 230)]);
onEnter = () => go("game");
onKeyPress(["enter", "space"], onEnter);
onClick(onEnter);
}
scene("title", () =>
screen(
t({ es: "Ficción interactiva", en: "Interactive fiction" }),
t({
es: "Escribe órdenes y pulsa Enter: «mirar», «ir norte», «coger llave», «abrir puerta», «inventario».\nEncuentra lo que guarda la casa.\n\nPulsa Enter o toca para empezar.",
en: "Type commands and press Enter: \"look\", \"go north\", \"take key\", \"open door\", \"inventory\".\nFind what the house is hiding.\n\nPress Enter or tap to start.",
}),
),
);
scene("end", (moves) =>
screen(
t({ es: "¡Encontraste el tesoro!", en: "You found the treasure!" }),
t({ es: `Lo lograste en ${moves} turnos. Pulsa Enter para jugar otra vez.`, en: `You did it in ${moves} moves. Press Enter to play again.` }),
),
);
go("title");
// #endregionRetos de diseño
- Anticipar lo que el jugador va a escribir. Cada orden razonable que el juego no entiende rompe la ilusión, así que hacen falta muchos sinónimos y respuestas para lo inesperado.
- Escribir descripciones que muestren lo importante sin decirlo todo, para que el jugador descubra los acertijos por sí mismo.
- Evitar el «adivina el verbo», cuando la solución está clara pero solo funciona con una palabra exacta.
- Que el jugador no se pierda, con un mapa fácil de recordar, salidas siempre anunciadas y forma de pedir ayuda.
Historia
- 1976OrigenColossal Cave Adventure
Colossal Cave Adventure, de Will Crowther (1976), ampliado por Don Woods en 1977, es el primer ejemplo conocido de ficción interactiva.
Colossal Cave Adventure (Wikipedia) - 1977Zork
En el MIT empieza Zork, cuyo intérprete entiende frases completas y no solo pares de verbo y sustantivo como los de Adventure.
Zork (Wikipedia) - 1978Adventureland
Scott Adams escribe Adventureland para el TRS-80, una aventura de órdenes de dos palabras para ordenadores domésticos, y funda Adventure International para publicar otras.
Adventureland (Wikipedia) - 1984
Yenght, de Nacho Ruiz, se publica con Dinamic para ZX Spectrum y es la primera aventura conversacional del mercado español.
Yenght (Wikipedia en español) - 1984The Hitchhiker's Guide to the Galaxy
Infocom publica The Hitchhiker's Guide to the Galaxy, escrito por Douglas Adams con Steve Meretzky, uno de los títulos más conocidos de la época comercial del género.
The Hitchhiker's Guide to the Galaxy (video game) (Wikipedia) - 1993Curses
Graham Nelson publica Inform, un lenguaje para escribir ficción interactiva, junto con Curses, el primer juego hecho con él. Abre la etapa de autores aficionados que publican gratis.
Curses (Wikipedia) - 1995
Se celebra la primera Interactive Fiction Competition (IFComp), un concurso anual de obras cortas que todavía se organiza.
Interactive Fiction Competition (Wikipedia) - 1998Photopia
Photopia, de Adam Cadre, gana la IFComp con una historia casi sin acertijos y demuestra que el género puede centrarse en narrar.
Photopia (Wikipedia) - 1998
José Luis Díaz publica InformATE, una biblioteca para escribir con Inform aventuras que entienden órdenes en español.
Aventura conversacional (Wikipedia en español) - 2006
Graham Nelson presenta Inform 7, en el que el propio programa se escribe con frases en inglés casi natural.
Inform (Wikipedia) - 2007Lost Pig
Lost Pig, de Admiral Jota, gana la IFComp con un narrador que habla como un orco torpe y un intérprete que responde a casi cualquier orden.
Lost Pig (Wikipedia)
Juegos de referencia
1976
Colossal Cave Adventure
Exploras una red de cuevas en busca de tesoros escribiendo órdenes de una o dos palabras, como «go north» o «get lamp». Es la primera aventura de texto conocida y dio nombre al género de aventura.
1977
Zork
Exploras un imperio subterráneo en busca de tesoros escribiendo frases completas como «abre el buzón». Su intérprete entendía órdenes mucho más ricas que las de Colossal Cave, y en 1980 se dividió en Zork I, II y III.
1978
Adventureland
Buscas trece tesoros en un mundo de fantasía escribiendo órdenes de dos palabras, como «climb tree». Fue el primer programa de Scott Adams y su éxito lo llevó a fundar Adventure International, que publicó una serie de aventuras parecidas.
1984
The Hitchhiker's Guide to the Galaxy
Adaptación de la novela cómica de Douglas Adams, escrita por el propio autor con Steve Meretzky, de Infocom. Escapas de la Tierra justo antes de su demolición, y el juego se burla de ti, te miente y te mata a menudo.
1993
Curses
Buscas un viejo mapa en el desván de tu casa familiar y acabas resolviendo una maldición que pesa sobre la familia. Fue el primer juego escrito en Inform, el lenguaje que su autor publicó ese mismo año, y se distribuye gratis.
1998
Photopia
Una historia corta contada en escenas que saltan entre personajes y momentos, con muy pocos acertijos. Ganó la competición de ficción interactiva de 1998 y mostró que el género podía centrarse en la narración más que en los puzles.
2007
Lost Pig
Guías a Grunk, un orco que trabaja en una granja, a recuperar un cerdo que se le escapó. El narrador habla como Grunk, con frases simples y torpes, y el intérprete entiende muchísimas órdenes distintas. Ganó la competición de ficción interactiva de 2007.
Ejercicio
Juega la demo y anota cada orden que no entendió. Luego añade al diccionario los sinónimos que faltan y una sexta habitación con un objeto nuevo que haga falta para entrar. Compara después tu intérprete con el de Lost Pig, que entiende casi cualquier cosa que se le escribe.