Cómo Construir una Interfaz de Pistas
Este tutorial fue escrito para la v2 del motor.
Todo juego se beneficia de las capas visuales superpuestas. Durante el desarrollo, quieres grillas de depuración, cajas de colisión, IDs de entidad — cosas que puedes activar y desactivar sin tocar el código del juego. En un juego publicado, el mismo patrón funciona para sistemas de pistas que resaltan objetos interactuables o muestran al jugador hacia dónde ir. La técnica es idéntica en ambos casos: un flag booleano, una tecla para alternarlo y algunas llamadas de dibujo protegidas detrás de un if.
Construiremos un pequeño puzzle de grilla donde el jugador recolecta llaves para abrir puertas, y luego superpondremos tres capas independientes: resaltado de objetos, líneas de conexión y una pista de pathfinding. Al final tendrás un patrón que puedes incorporar en cualquier proyecto. Lo usaremos de nuevo en un próximo tutorial de sokoban para su sistema de pistas.
El Puzzle
Necesitamos algo sobre lo cual superponer, así que empecemos con un pequeño puzzle de grilla. Una grilla de 8×8 en el canvas de 128×128 nos da tiles de 16 píxeles — suficiente espacio para paredes, llaves, puertas y una salida. La grilla es un array 2D donde 1 significa pared y 0 significa piso:
const T = 16;
const grid = [
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 0, 0, 0, 1, 0, 0, 1],
[1, 0, 1, 0, 0, 0, 0, 1],
[1, 0, 1, 1, 1, 0, 1, 1],
[1, 0, 0, 0, 0, 0, 0, 1],
[1, 1, 1, 0, 1, 1, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
];
Las llaves y las puertas están en arrays separados para rastrear su estado independientemente. Cada llave tiene un color que coincide con una puerta — recoge la llave roja, camina hacia la puerta roja y se abre:
const keys = [
{ x: 1, y: 1, color: 8, collected: false },
{ x: 6, y: 1, color: 9, collected: false },
{ x: 1, y: 6, color: 12, collected: false },
];
const doors = [
{ x: 4, y: 2, color: 8, open: false },
{ x: 3, y: 5, color: 9, open: false },
{ x: 5, y: 3, color: 12, open: false },
];
El movimiento usa btnp() para ajuste a la grilla de un tile por pulsación. Antes de moverse, verificamos si el tile destino es una puerta cerrada para la cual el jugador tiene la llave correspondiente — si es así, la abrimos. Luego verificamos si sigue bloqueado. Si está libre, nos movemos ahí y recogemos cualquier llave en el tile:
function update() {
if (won) return;
let nx = px;
let ny = py;
if (btnp('ArrowLeft')) nx--;
if (btnp('ArrowRight')) nx++;
if (btnp('ArrowUp')) ny--;
if (btnp('ArrowDown')) ny++;
if (nx === px && ny === py) return;
if (nx < 0 || nx > 7 || ny < 0 || ny > 7) return;
for (let d of doors) {
if (!d.open && d.x === nx && d.y === ny) {
for (let k of keys) {
if (k.collected && k.color === d.color) {
d.open = true;
break;
}
}
}
}
if (isBlocked(nx, ny)) return;
px = nx;
py = ny;
for (let k of keys) {
if (!k.collected && k.x === px && k.y === py) {
k.collected = true;
}
}
if (px === exit.x && py === exit.y) won = true;
}
El dibujo es directo. Las paredes son rectfill gris oscuro, las llaves son circfill de colores, las puertas son rectfill de colores y el jugador es un círculo blanco. La salida es un tile verde. Una llamada text() en la esquina muestra las llaves recolectadas:
function draw(time, frame) {
cls(0);
for (let y = 0; y < 8; y++) {
for (let x = 0; x < 8; x++) {
if (grid[y][x] === 1) {
rectfill(x * T, y * T, x * T + T - 1, y * T + T - 1, 5);
}
}
}
rectfill(exit.x * T, exit.y * T, exit.x * T + T - 1, exit.y * T + T - 1, 11);
for (let d of doors) {
if (!d.open) {
rectfill(d.x * T + 2, d.y * T, d.x * T + T - 3, d.y * T + T - 1, d.color);
}
}
for (let k of keys) {
if (!k.collected) {
circfill(k.x * T + T / 2, k.y * T + T / 2, 3, k.color);
}
}
circfill(px * T + T / 2, py * T + T / 2, 3, 7);
let collected = keys.filter((k) => k.collected).length;
text(collected + '/' + keys.length + ' keys', 1, 1, 7);
if (won) {
text('you win!', 40, 60, 11);
}
}
Resaltando Objetos
El puzzle funciona, pero los jugadores nuevos podrían no notar con qué objetos pueden interactuar. Arreglemos eso con una capa de resaltado — presiona H y aparecen contornos pulsantes alrededor de cada llave, puerta y la salida.
Todo se reduce a un booleano y una verificación de btnp():
let showHighlights = false;
function update() {
if (btnp('h')) showHighlights = !showHighlights;
// ... rest of update
}
En draw(), después de todo el renderizado base del juego, verificamos el flag y dibujamos contornos rect() alrededor de cada objeto interactuable. Para hacerlos pulsar, alternamos entre amarillo brillante (10) y gris oscuro (5) cada 10 frames usando el contador de frames:
if (showHighlights) {
let pulse = Math.floor(frame / 10) % 2 === 0 ? 10 : 5;
for (let k of keys) {
if (!k.collected) {
rect(k.x * T, k.y * T, k.x * T + T - 1, k.y * T + T - 1, pulse);
}
}
for (let d of doors) {
if (!d.open) {
rect(d.x * T, d.y * T, d.x * T + T - 1, d.y * T + T - 1, pulse);
}
}
rect(exit.x * T, exit.y * T, exit.x * T + T - 1, exit.y * T + T - 1, pulse);
}
Algo importante: dibuja las capas superpuestas después del juego base. El motor dibuja en orden, así que las llamadas posteriores aparecen encima. Si dibujáramos los contornos antes de las llaves, quedarían ocultos detrás de ellas.
Dibujando Conexiones
Los resaltados muestran qué puedes interactuar, pero no cómo se relacionan las cosas. Una segunda capa puede resolver eso — líneas de cada llave a la puerta que abre. Tiene su propio booleano y su propia tecla, completamente independiente de la capa de resaltado:
let showConnections = false;
function update() {
if (btnp('h')) showHighlights = !showHighlights;
if (btnp('j')) showConnections = !showConnections;
// ... rest of update
}
Recorremos las llaves no recolectadas, encontramos la puerta correspondiente y dibujamos una line() entre los centros de sus tiles usando el color compartido. Una vez que una llave se recolecta o una puerta se abre, esa línea desaparece sola:
if (showConnections) {
for (let k of keys) {
if (k.collected) continue;
for (let d of doors) {
if (d.open) continue;
if (k.color === d.color) {
line(
k.x * T + T / 2,
k.y * T + T / 2,
d.x * T + T / 2,
d.y * T + T / 2,
k.color,
);
}
}
}
}
Cada capa tiene su propio flag, así que los jugadores pueden activar resaltados, conexiones, ambos o ninguno. Las capas no se conocen entre sí — son simplemente bloques independientes de llamadas de dibujo condicionales apilados al final de draw().
Ejemplo Completo
Para la tercera capa, mostremos el camino más corto desde el jugador hasta la salida. Esto usa un BFS simple (búsqueda en amplitud) — un flood fill basado en cola que encuentra la ruta caminable más corta:
function findPath() {
let queue = [{ x: px, y: py }];
let cameFrom = {};
let key = (x, y) => x + ',' + y;
cameFrom[key(px, py)] = null;
while (queue.length > 0) {
let cur = queue.shift();
if (cur.x === exit.x && cur.y === exit.y) {
let result = [];
let step = cur;
while (step) {
result.push(step);
step = cameFrom[key(step.x, step.y)];
}
return result.reverse();
}
for (let [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
let nx = cur.x + dx;
let ny = cur.y + dy;
if (nx < 0 || nx > 7 || ny < 0 || ny > 7) continue;
if (cameFrom[key(nx, ny)] !== undefined) continue;
if (isBlocked(nx, ny)) continue;
cameFrom[key(nx, ny)] = cur;
queue.push({ x: nx, y: ny });
}
}
return [];
}
No queremos ejecutar BFS cada frame — solo cuando el camino realmente cambia. Un flag pathDirty se encarga de esto. Se establece en true cuando el jugador se mueve o una puerta se abre, y se limpia después de recalcular:
let path = [];
let pathDirty = true;
// in update(), after moving:
px = nx;
py = ny;
pathDirty = true;
// and after opening a door:
d.open = true;
pathDirty = true;
// recalculate when needed:
if (pathDirty && showPath) {
path = findPath();
pathDirty = false;
}
A diferencia de las otras capas, el camino se dibuja antes de los objetos del juego — es iluminación de piso, no un contorno encima. Un rectfill azul oscuro en cada tile del camino hace que parezca que el piso está iluminado desde abajo:
if (showPath && path.length > 0) {
for (let tile of path) {
rectfill(tile.x * T, tile.y * T, tile.x * T + T - 1, tile.y * T + T - 1, 1);
}
}
Las tres capas se apilan independientemente con sus propias teclas (H, J, K). El HUD muestra cuáles están activas:
text('H:' + (showHighlights ? 'on' : 'off'), 60, 1, showHighlights ? 10 : 5);
text('J:' + (showConnections ? 'on' : 'off'), 82, 1, showConnections ? 10 : 5);
text('K:' + (showPath ? 'on' : 'off'), 105, 1, showPath ? 10 : 5);
Para Ir Más Allá
- Modo de depuración — Usa el mismo patrón de capas durante el desarrollo. Muestra cajas de colisión, coordenadas de tiles, IDs de entidades o un contador de FPS — y desactívalos antes de publicar.
- Resaltados animados — Haz que las capas aparezcan y desaparezcan gradualmente en vez de alternarse instantáneamente. Varía la velocidad del pulso según la urgencia — lento para pistas pasivas, rápido para advertencias críticas.
- Pistas contextuales — Solo resalta objetos cercanos, o revela cosas progresivamente: la primera pulsación da una pista vaga, la segunda es específica.
- Capa de minimapa — Dibuja una versión a escala reducida del nivel con las posiciones de llaves y puertas marcadas. Útil para mundos más grandes donde la cámara se ha desplazado lejos de cosas importantes.
- Opacidad de capas — Dibuja un píxel sí y otro no para un resaltado más sutil que no oculte el juego debajo.
- Cooldown de pistas — Limita la frecuencia con que se pueden activar las pistas para preservar el desafío del puzzle. Muestra un temporizador en el HUD para que el jugador sepa cuándo las pistas están disponibles de nuevo.